Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 961ac2dde5 | |||
| 7efbcf7e39 | |||
| 49c64cc5d9 | |||
| d20c7e210b | |||
| 58fa9487e3 | |||
| d9b08592da | |||
| 4a80bf1c0e | |||
| 05c962d46f | |||
| cf3fef6936 | |||
| 8c878b3718 | |||
| 389f00e85c | |||
| 75618637e2 | |||
| e57f81c9c3 | |||
| 0a5811cc68 | |||
| 0d94b684bf | |||
| 3c2579b3ed |
@@ -21,12 +21,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: macos-14 # apple silicon
|
||||
args: "--target aarch64-apple-darwin --bundles app,updater"
|
||||
- platform: macos-13 # intel
|
||||
args: "--target x86_64-apple-darwin --bundles app,updater"
|
||||
- platform: ubuntu-22.04
|
||||
args: ""
|
||||
- platform: macos-14 # universal binary covers both Intel + Apple Silicon
|
||||
args: "--target universal-apple-darwin --bundles app,updater"
|
||||
- platform: windows-latest
|
||||
args: ""
|
||||
|
||||
@@ -47,13 +43,7 @@ jobs:
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin' || matrix.platform == 'macos-13' && 'x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Install Linux build deps
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libgtk-3-dev
|
||||
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Install JS deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@livekit/components-react": "^2.9.0",
|
||||
"@supabase/supabase-js": "^2.46.0",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2.0.1",
|
||||
"@tauri-apps/plugin-sql": "^2.0.1",
|
||||
|
||||
Generated
+25
@@ -684,6 +684,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-sql",
|
||||
@@ -5483,6 +5484,30 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-global-shortcut"
|
||||
version = "2.3.1"
|
||||
|
||||
@@ -14,10 +14,11 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||
tauri-plugin-stronghold = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
|
||||
@@ -15,6 +15,27 @@
|
||||
"updater:allow-check",
|
||||
"updater:allow-download",
|
||||
"updater:allow-install",
|
||||
"updater:allow-download-and-install"
|
||||
"updater:allow-download-and-install",
|
||||
"stronghold:default",
|
||||
"stronghold:allow-initialize",
|
||||
"stronghold:allow-load-client",
|
||||
"stronghold:allow-create-client",
|
||||
"stronghold:allow-save",
|
||||
"stronghold:allow-get-store-record",
|
||||
"stronghold:allow-save-store-record",
|
||||
"stronghold:allow-remove-store-record",
|
||||
"fs:default",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-exists",
|
||||
"fs:allow-rename",
|
||||
"fs:allow-remove",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
{ "path": "$APPLOCALDATA/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pub fn run() {
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(
|
||||
tauri_plugin_stronghold::Builder::new(|password| {
|
||||
// TODO: derive stronghold key from password using argon2 / blake2b.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ChatApp",
|
||||
"version": "0.1.1",
|
||||
"version": "0.4.2",
|
||||
"identifier": "com.meinname.chatapp",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm vite:dev",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { UpdateToast } from './components/UpdateToast';
|
||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { CallProvider } from './context/CallContext';
|
||||
@@ -44,6 +45,7 @@ export function App() {
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/chats" replace />} />
|
||||
</Routes>
|
||||
<UpdateToast />
|
||||
</BrowserRouter>
|
||||
</CallProvider>
|
||||
</ConversationsProvider>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||
import { CallUI } from './CallUI';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { UpdateToast } from './UpdateToast';
|
||||
|
||||
export function AppShell() {
|
||||
const { session, device } = useAuth();
|
||||
useEffect(() => {
|
||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||
// re-asking if the user already responded this session.
|
||||
void ensureNotificationPermission();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user.id || !device?.id) return;
|
||||
return startConversationKeySync(session.user.id, device.id);
|
||||
}, [session?.user.id, device?.id]);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
|
||||
<ShellBackground />
|
||||
@@ -25,7 +32,6 @@ export function AppShell() {
|
||||
</main>
|
||||
</div>
|
||||
<CallUI />
|
||||
<UpdateToast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@ export function MessageBubble({
|
||||
messageId: message.id,
|
||||
conversationId,
|
||||
newPlaintext: trimmed,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderPrivateKey: priv,
|
||||
});
|
||||
setEditing(false);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CallBar } from './CallUI';
|
||||
import {
|
||||
ChatBubbleIcon,
|
||||
GearIcon,
|
||||
LogoMark,
|
||||
LogoLockup,
|
||||
ShieldIcon,
|
||||
SignOutIcon,
|
||||
UsersIcon,
|
||||
@@ -33,7 +33,7 @@ const ADMIN_NAV_ITEM: NavItem = {
|
||||
};
|
||||
|
||||
export function Sidebar() {
|
||||
const { t } = useTranslation(['app', 'common']);
|
||||
const { t } = useTranslation(['app']);
|
||||
const { signOut, profile } = useAuth();
|
||||
const { incomingCount } = useFriendshipsContext();
|
||||
const { totalUnread } = useConversationsContext();
|
||||
@@ -45,11 +45,8 @@ export function Sidebar() {
|
||||
aria-label="Primary navigation"
|
||||
className="flex h-screen w-72 shrink-0 flex-col border-r border-white/5 bg-ink-900/70 backdrop-blur-xl"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 px-5 pb-3 pt-5">
|
||||
<LogoMark className="h-7 w-7" />
|
||||
<span className="font-display text-base font-semibold tracking-tight text-white">
|
||||
{t('common:app_name')}
|
||||
</span>
|
||||
<div className="flex items-center px-5 pb-3 pt-5">
|
||||
<LogoLockup tone="dark" className="h-8 w-auto" aria-label="Netralax" />
|
||||
</div>
|
||||
|
||||
<nav className="mt-2 flex flex-col gap-0.5 px-3">
|
||||
|
||||
@@ -376,3 +376,58 @@ export function LogoMark(props: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Full lockup: hex icon + "Netralax" wordmark. `tone` decides text colour:
|
||||
// "dark" = white text (use on dark background), "light" = black text.
|
||||
export function LogoLockup({
|
||||
tone = 'dark',
|
||||
...props
|
||||
}: IconProps & { tone?: 'dark' | 'light' }) {
|
||||
const textFill = tone === 'dark' ? '#ffffff' : '#0F172A';
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 260 64"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="logo-lockup-hex-clip">
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065" />
|
||||
<g clipPath="url(#logo-lockup-hex-clip)">
|
||||
<path
|
||||
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
|
||||
fill="#7c4dff"
|
||||
/>
|
||||
<path
|
||||
d="M-4 32 Q 16 18 32 32 T 68 32"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
<polygon
|
||||
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
fill="none"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<text
|
||||
x="78"
|
||||
y="42"
|
||||
fontFamily="'Space Grotesk', system-ui, sans-serif"
|
||||
fontSize="30"
|
||||
fontWeight="600"
|
||||
letterSpacing="-0.6"
|
||||
fill={textFill}
|
||||
>
|
||||
Netralax
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { findExistingDevice } from '../lib/device';
|
||||
import { setSecretStoreUser } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface AuthContextValue {
|
||||
@@ -60,7 +61,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status === 401 || status === 403) {
|
||||
// Token genuinely invalid — wipe.
|
||||
await supabase.auth.signOut().catch(() => {
|
||||
await supabase.auth.signOut({ scope: 'local' }).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
setSession(null);
|
||||
@@ -81,6 +82,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
|
||||
setSession(s);
|
||||
setReady(true);
|
||||
void setSecretStoreUser(s?.user.id ?? null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
|
||||
import { pgHexToBytes } from '@chat-app/shared/supabase';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Watches the `devices` table for new entries AND, on mount, scans every
|
||||
// conversation we participate in for missing key bundles. Fills gaps by
|
||||
// re-wrapping our active conv-key for the missing recipient devices.
|
||||
//
|
||||
// This fixes the "cannot decrypt" cliff for devices that registered while
|
||||
// no other participant device was online to share the key with them.
|
||||
|
||||
interface SyncCtx {
|
||||
myUserId: string;
|
||||
myDeviceId: string;
|
||||
priv: Uint8Array;
|
||||
}
|
||||
|
||||
// db-types is stale for `conversation_keys`/`active_key_version`; bypass.
|
||||
function rawFrom(table: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (supabase as unknown as { from: (t: string) => any }).from(table);
|
||||
}
|
||||
|
||||
export function startConversationKeySync(
|
||||
ownUserId: string,
|
||||
ownDeviceId: string,
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
let priv: Uint8Array | null = null;
|
||||
|
||||
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
|
||||
if (cancelled) return;
|
||||
priv = pk;
|
||||
if (!priv) return;
|
||||
// Run a full backfill once we have the private key — covers devices that
|
||||
// registered while we were offline.
|
||||
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
|
||||
});
|
||||
|
||||
const channel = supabase
|
||||
.channel('device-key-sync:' + ownDeviceId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'devices' },
|
||||
(payload: { new: { id?: string; user_id?: string; public_key?: string } }) => {
|
||||
if (cancelled) return;
|
||||
const row = payload.new;
|
||||
if (!row?.id || !row.user_id || !row.public_key) return;
|
||||
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
|
||||
if (!priv) return; // backfill on mount will catch it later
|
||||
void wrapForOneDevice(
|
||||
{ myUserId: ownUserId, myDeviceId: ownDeviceId, priv },
|
||||
row.id,
|
||||
row.user_id,
|
||||
row.public_key,
|
||||
);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}
|
||||
|
||||
async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('conversation_id')
|
||||
.eq('user_id', myUserId)
|
||||
.eq('accepted', true);
|
||||
if (error) {
|
||||
console.warn('keySync: own-member lookup failed', error);
|
||||
return [];
|
||||
}
|
||||
return (data ?? []).map((r) => r.conversation_id as string);
|
||||
}
|
||||
|
||||
async function listConversationDevices(
|
||||
conversationId: string,
|
||||
): Promise<{ id: string; user_id: string; public_key: string }[]> {
|
||||
const { data: members, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('user_id')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('accepted', true);
|
||||
if (mErr) {
|
||||
console.warn('keySync: members lookup failed', mErr);
|
||||
return [];
|
||||
}
|
||||
const userIds = (members ?? []).map((m) => m.user_id as string);
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const { data: devices, error: dErr } = await supabase
|
||||
.from('devices')
|
||||
.select('id, user_id, public_key')
|
||||
.in('user_id', userIds);
|
||||
if (dErr) {
|
||||
console.warn('keySync: devices lookup failed', dErr);
|
||||
return [];
|
||||
}
|
||||
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
|
||||
}
|
||||
|
||||
async function listExistingKeyRecipients(
|
||||
conversationId: string,
|
||||
keyVersion: number,
|
||||
): Promise<Set<string>> {
|
||||
const { data, error } = await rawFrom('conversation_keys')
|
||||
.select('recipient_device_id')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('key_version', keyVersion);
|
||||
if (error) {
|
||||
console.warn('keySync: existing keys lookup failed', error);
|
||||
return new Set();
|
||||
}
|
||||
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id));
|
||||
}
|
||||
|
||||
async function getActiveKeyVersion(conversationId: string): Promise<number> {
|
||||
const { data, error } = await rawFrom('conversations')
|
||||
.select('active_key_version')
|
||||
.eq('id', conversationId)
|
||||
.single();
|
||||
if (error) {
|
||||
console.warn('keySync: active key version lookup failed', error);
|
||||
return 1;
|
||||
}
|
||||
return (data as { active_key_version: number }).active_key_version;
|
||||
}
|
||||
|
||||
async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
|
||||
const convs = await listMyConversationIds(ctx.myUserId);
|
||||
for (const convId of convs) {
|
||||
try {
|
||||
await syncOneConversationGaps(ctx, convId);
|
||||
} catch (err: unknown) {
|
||||
console.warn('keySync: conv gap sync failed', { convId, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||
const version = await getActiveKeyVersion(convId);
|
||||
const devices = await listConversationDevices(convId);
|
||||
if (devices.length === 0) return;
|
||||
|
||||
const recipients = await listExistingKeyRecipients(convId, version);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
userId: ctx.myUserId,
|
||||
deviceId: ctx.myDeviceId,
|
||||
privateKey: ctx.priv,
|
||||
};
|
||||
|
||||
for (const dev of devices) {
|
||||
if (recipients.has(dev.id)) continue;
|
||||
// Skip our own device — we already have the bundle if we're capable of
|
||||
// sharing (or don't need it if we ourselves haven't been wrapped yet).
|
||||
if (dev.id === ctx.myDeviceId) continue;
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
|
||||
} catch (err: unknown) {
|
||||
// Most common: this device hasn't been wrapped for us yet either, so
|
||||
// tryGetConvKey couldn't unwrap. Another peer with the key will fill
|
||||
// the gap when they hit syncAllExistingGaps.
|
||||
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
|
||||
convId,
|
||||
recipient: dev.id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function wrapForOneDevice(
|
||||
ctx: SyncCtx,
|
||||
newDeviceId: string,
|
||||
newDeviceUserId: string,
|
||||
newDevicePubHex: string,
|
||||
): Promise<void> {
|
||||
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
|
||||
const { data: peerMember, error: pErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('conversation_id')
|
||||
.eq('user_id', newDeviceUserId);
|
||||
if (pErr) {
|
||||
console.warn('keySync: peer-member lookup failed', pErr);
|
||||
return;
|
||||
}
|
||||
const sharedConvs = (peerMember ?? [])
|
||||
.map((r) => r.conversation_id as string)
|
||||
.filter((id) => myConvs.has(id));
|
||||
if (sharedConvs.length === 0) return;
|
||||
|
||||
const newPub = pgHexToBytes(newDevicePubHex);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
userId: ctx.myUserId,
|
||||
deviceId: ctx.myDeviceId,
|
||||
privateKey: ctx.priv,
|
||||
};
|
||||
for (const convId of sharedConvs) {
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
|
||||
} catch (err: unknown) {
|
||||
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import sodium from 'libsodium-wrappers';
|
||||
|
||||
// Encrypts/decrypts the device private key with a user-provided passphrase
|
||||
// so the backup string can be safely written down or stored in a password
|
||||
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
|
||||
// XSalsa20-Poly1305 (crypto_secretbox) for the AEAD.
|
||||
//
|
||||
// Backup format (base64url-encoded blob, prefixed with a magic string so we
|
||||
// can version it):
|
||||
//
|
||||
// chatapp-backup-v1.<base64url(salt(16) | nonce(24) | ciphertext)>
|
||||
|
||||
const MAGIC = 'chatapp-backup-v1.';
|
||||
const SALT_LEN = 16; // crypto_pwhash_SALTBYTES
|
||||
const NONCE_LEN = 24; // crypto_secretbox_NONCEBYTES
|
||||
const KEY_LEN = 32; // crypto_secretbox_KEYBYTES
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function unb64url(s: string): Uint8Array {
|
||||
let str = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) str += '=';
|
||||
const bin = atob(str);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(passphrase: string, salt: Uint8Array, sodiumLib: typeof sodium): Promise<Uint8Array> {
|
||||
return sodiumLib.crypto_pwhash(
|
||||
KEY_LEN,
|
||||
passphrase,
|
||||
salt,
|
||||
sodiumLib.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
sodiumLib.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
sodiumLib.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportDeviceKey(
|
||||
privateKey: Uint8Array,
|
||||
passphrase: string,
|
||||
): Promise<string> {
|
||||
if (passphrase.length < 8) throw new Error('Passphrase must be at least 8 characters.');
|
||||
const s = await ensureSodium();
|
||||
const salt = s.randombytes_buf(SALT_LEN);
|
||||
const nonce = s.randombytes_buf(NONCE_LEN);
|
||||
const key = await deriveKey(passphrase, salt, s);
|
||||
const backend = getCryptoBackend();
|
||||
const ciphertext = backend.secretbox(privateKey, nonce, key);
|
||||
s.memzero(key);
|
||||
const blob = new Uint8Array(SALT_LEN + NONCE_LEN + ciphertext.length);
|
||||
blob.set(salt, 0);
|
||||
blob.set(nonce, SALT_LEN);
|
||||
blob.set(ciphertext, SALT_LEN + NONCE_LEN);
|
||||
return MAGIC + b64url(blob);
|
||||
}
|
||||
|
||||
export async function importDeviceKey(
|
||||
backup: string,
|
||||
passphrase: string,
|
||||
): Promise<Uint8Array> {
|
||||
if (!backup.startsWith(MAGIC)) {
|
||||
throw new Error('Invalid backup format');
|
||||
}
|
||||
const blob = unb64url(backup.slice(MAGIC.length));
|
||||
if (blob.length < SALT_LEN + NONCE_LEN + 1) {
|
||||
throw new Error('Backup too short');
|
||||
}
|
||||
const salt = blob.slice(0, SALT_LEN);
|
||||
const nonce = blob.slice(SALT_LEN, SALT_LEN + NONCE_LEN);
|
||||
const ciphertext = blob.slice(SALT_LEN + NONCE_LEN);
|
||||
const s = await ensureSodium();
|
||||
const key = await deriveKey(passphrase, salt, s);
|
||||
const backend = getCryptoBackend();
|
||||
try {
|
||||
return backend.secretboxOpen(ciphertext, nonce, key);
|
||||
} catch {
|
||||
throw new Error('Wrong passphrase or corrupt backup');
|
||||
} finally {
|
||||
s.memzero(key);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
sendNotification,
|
||||
} from '@tauri-apps/plugin-notification';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
// Tracks whether permission has already been requested this session so we
|
||||
// don't spam the OS prompt. Actual permission state lives in the OS.
|
||||
let permissionChecked = false;
|
||||
@@ -12,6 +14,11 @@ let permissionGranted = false;
|
||||
export async function ensureNotificationPermission(): Promise<boolean> {
|
||||
if (permissionChecked) return permissionGranted;
|
||||
permissionChecked = true;
|
||||
if (!isTauriRuntime()) {
|
||||
// Web preview / Chrome — Tauri notification plugin not available.
|
||||
permissionGranted = false;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let granted = await isPermissionGranted();
|
||||
if (!granted) {
|
||||
@@ -20,7 +27,6 @@ export async function ensureNotificationPermission(): Promise<boolean> {
|
||||
}
|
||||
permissionGranted = granted;
|
||||
} catch (err: unknown) {
|
||||
// Not running under Tauri (e.g. web preview) — fall back silently.
|
||||
permissionGranted = false;
|
||||
console.warn('notification permission check failed', err);
|
||||
}
|
||||
@@ -40,6 +46,7 @@ interface NotifyOpts {
|
||||
|
||||
export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> {
|
||||
if (!force && isAppFocused()) return;
|
||||
if (!isTauriRuntime()) return;
|
||||
const granted = await ensureNotificationPermission();
|
||||
if (!granted) return;
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
|
||||
|
||||
// M1 dev-only impl: persists secrets as base64 in localStorage.
|
||||
// Swap this out for a tauri-plugin-stronghold implementation before release.
|
||||
// The SecretStore interface stays identical so callers won't notice.
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
import { makeSecureFileStore, migrateLocalStorageToVault } from './secureFileStore';
|
||||
|
||||
// Two-tier SecretStore:
|
||||
// - Tauri runtime: encrypted single-file vault in `appLocalDataDir`
|
||||
// (`secureFileStore` — XSalsa20-Poly1305 + Argon2id KDF). Survives app
|
||||
// reinstalls when the OS preserves the data dir.
|
||||
// - Web / pre-auth: plain localStorage (legacy fallback).
|
||||
//
|
||||
// Callers import `devLocalSecretStore` and call `setSecretStoreUser(userId)`
|
||||
// once the session is known. The singleton object's identity is stable so
|
||||
// existing imports keep working.
|
||||
|
||||
const PREFIX = 'chatapp.secret:';
|
||||
|
||||
export const devLocalSecretStore: SecretStore = {
|
||||
const localStore: SecretStore = {
|
||||
async getSecret(key: string): Promise<Uint8Array | null> {
|
||||
const raw = window.localStorage.getItem(PREFIX + key);
|
||||
if (!raw) return null;
|
||||
@@ -20,3 +29,48 @@ export const devLocalSecretStore: SecretStore = {
|
||||
window.localStorage.removeItem(PREFIX + key);
|
||||
},
|
||||
};
|
||||
|
||||
let activeBackend: SecretStore = localStore;
|
||||
let activeUserId: string | null = null;
|
||||
|
||||
export async function setSecretStoreUser(userId: string | null): Promise<void> {
|
||||
if (userId === activeUserId) return;
|
||||
activeUserId = userId;
|
||||
|
||||
if (userId && isTauriRuntime()) {
|
||||
const fileStore = makeSecureFileStore(userId);
|
||||
try {
|
||||
// Probe write/read to confirm the vault is usable on this machine.
|
||||
// If anything throws (perm denied, disk full, KDF error), fall back to
|
||||
// localStorage so the rest of the app keeps working.
|
||||
await fileStore.getSecret('__probe');
|
||||
activeBackend = fileStore;
|
||||
try {
|
||||
await migrateLocalStorageToVault(userId, PREFIX);
|
||||
} catch (err: unknown) {
|
||||
console.warn('vault migration failed', err);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('secure file vault init failed — falling back to localStorage', err);
|
||||
activeBackend = localStore;
|
||||
}
|
||||
} else {
|
||||
activeBackend = localStore;
|
||||
}
|
||||
}
|
||||
|
||||
export const devLocalSecretStore: SecretStore = {
|
||||
async getSecret(key) {
|
||||
return activeBackend.getSecret(key);
|
||||
},
|
||||
async setSecret(key, value) {
|
||||
return activeBackend.setSecret(key, value);
|
||||
},
|
||||
async removeSecret(key) {
|
||||
return activeBackend.removeSecret(key);
|
||||
},
|
||||
};
|
||||
|
||||
export function isEncryptedVaultActive(): boolean {
|
||||
return activeBackend !== localStore;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { SecretStore } from '@chat-app/shared/auth';
|
||||
import { exists, mkdir, readFile, rename, writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { appLocalDataDir } from '@tauri-apps/api/path';
|
||||
import sodium from 'libsodium-wrappers';
|
||||
|
||||
// Encrypted single-file SecretStore for Tauri. Replaces the flaky
|
||||
// tauri-plugin-stronghold implementation.
|
||||
//
|
||||
// File layout (binary, little-endian):
|
||||
// bytes 0..7 magic: ASCII "CHATVLT1"
|
||||
// bytes 8..23 salt for KDF (16 bytes)
|
||||
// bytes 24..47 XSalsa20-Poly1305 nonce (24 bytes)
|
||||
// bytes 48.. secretbox(plaintext_json, key, nonce)
|
||||
//
|
||||
// `plaintext_json` is a UTF-8 JSON object { [key: string]: base64url(value) }.
|
||||
//
|
||||
// Key derivation: Argon2id (libsodium MODERATE ops/mem) over a passphrase
|
||||
// derived from the authenticated user-id + a constant. Same userId on the
|
||||
// same machine after re-install ⇒ same key ⇒ vault recovers automatically.
|
||||
//
|
||||
// Atomic writes: serialised vault is first written to `<file>.tmp` then
|
||||
// renamed onto `<file>` so an interrupted write never corrupts the existing
|
||||
// vault.
|
||||
|
||||
// Per-user vault filename so multiple accounts on the same machine each get
|
||||
// their own file (and Argon2 derives a different key per user, so cross-user
|
||||
// decrypt is also blocked even if filenames collided).
|
||||
async function vaultFileName(userId: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const buf = await crypto.subtle.digest('SHA-256', enc.encode('chatapp-vault-name:' + userId));
|
||||
const hex = Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return 'chatapp-vault-' + hex.slice(0, 16) + '.bin';
|
||||
}
|
||||
const MAGIC = new TextEncoder().encode('CHATVLT1'); // 8 bytes
|
||||
const SALT_LEN = 16;
|
||||
const NONCE_LEN = 24;
|
||||
const KEY_LEN = 32;
|
||||
|
||||
interface VaultState {
|
||||
path: string;
|
||||
tmpPath: string;
|
||||
key: Uint8Array; // derived encryption key
|
||||
data: Map<string, Uint8Array>;
|
||||
}
|
||||
|
||||
let initPromise: Promise<VaultState> | null = null;
|
||||
let vault: VaultState | null = null;
|
||||
let initializedFor: string | null = null;
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/';
|
||||
return dir + sep + name;
|
||||
}
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function unb64url(s: string): Uint8Array {
|
||||
let str = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) str += '=';
|
||||
const bin = atob(str);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(userId: string, salt: Uint8Array, s: typeof sodium): Promise<Uint8Array> {
|
||||
const passphrase = 'chatapp-vault-v1:' + userId;
|
||||
return s.crypto_pwhash(
|
||||
KEY_LEN,
|
||||
passphrase,
|
||||
salt,
|
||||
s.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
s.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadOrCreateVault(userId: string): Promise<VaultState> {
|
||||
const s = await ensureSodium();
|
||||
const dir = await appLocalDataDir();
|
||||
const fileName = await vaultFileName(userId);
|
||||
const path = joinPath(dir, fileName);
|
||||
const tmpPath = path + '.tmp';
|
||||
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
} catch {
|
||||
/* parent likely already exists */
|
||||
}
|
||||
|
||||
const fileExists = await exists(path).catch(() => false);
|
||||
if (!fileExists) {
|
||||
const salt = s.randombytes_buf(SALT_LEN);
|
||||
const key = await deriveKey(userId, salt, s);
|
||||
const state: VaultState = { path, tmpPath, key, data: new Map() };
|
||||
await persist(state, salt, s);
|
||||
return state;
|
||||
}
|
||||
|
||||
const raw = await readFile(path);
|
||||
if (raw.length < MAGIC.length + SALT_LEN + NONCE_LEN + 1) {
|
||||
throw new Error('vault file too short');
|
||||
}
|
||||
for (let i = 0; i < MAGIC.length; i++) {
|
||||
if (raw[i] !== MAGIC[i]) throw new Error('vault magic mismatch');
|
||||
}
|
||||
const salt = raw.slice(MAGIC.length, MAGIC.length + SALT_LEN);
|
||||
const nonce = raw.slice(MAGIC.length + SALT_LEN, MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
const ciphertext = raw.slice(MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
|
||||
const key = await deriveKey(userId, salt, s);
|
||||
let plain: Uint8Array;
|
||||
try {
|
||||
plain = s.crypto_secretbox_open_easy(ciphertext, nonce, key);
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
'vault decrypt failed (wrong user / corrupted file): ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
const json = new TextDecoder().decode(plain) || '{}';
|
||||
const obj = JSON.parse(json) as Record<string, string>;
|
||||
const data = new Map<string, Uint8Array>();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
try {
|
||||
data.set(k, unb64url(v));
|
||||
} catch {
|
||||
/* skip malformed entries */
|
||||
}
|
||||
}
|
||||
return { path, tmpPath, key, data };
|
||||
}
|
||||
|
||||
async function persist(state: VaultState, salt: Uint8Array, s: typeof sodium): Promise<void> {
|
||||
const obj: Record<string, string> = {};
|
||||
for (const [k, v] of state.data) obj[k] = b64url(v);
|
||||
const plain = new TextEncoder().encode(JSON.stringify(obj));
|
||||
const nonce = s.randombytes_buf(NONCE_LEN);
|
||||
const ciphertext = s.crypto_secretbox_easy(plain, nonce, state.key);
|
||||
|
||||
const out = new Uint8Array(MAGIC.length + SALT_LEN + NONCE_LEN + ciphertext.length);
|
||||
out.set(MAGIC, 0);
|
||||
out.set(salt, MAGIC.length);
|
||||
out.set(nonce, MAGIC.length + SALT_LEN);
|
||||
out.set(ciphertext, MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
|
||||
// Atomic write: tmp → rename. `rename` on the same filesystem is atomic
|
||||
// on macOS, Linux, and Windows (NTFS).
|
||||
await writeFile(state.tmpPath, out);
|
||||
await rename(state.tmpPath, state.path);
|
||||
}
|
||||
|
||||
// Re-derives the salt by reading the existing file header so persist() can
|
||||
// keep using the same KDF salt across writes (we don't rotate KDF on every
|
||||
// save — only on initial vault creation).
|
||||
async function readSalt(state: VaultState): Promise<Uint8Array> {
|
||||
const raw = await readFile(state.path);
|
||||
return raw.slice(MAGIC.length, MAGIC.length + SALT_LEN);
|
||||
}
|
||||
|
||||
async function ensureInit(userId: string): Promise<VaultState> {
|
||||
if (initializedFor === userId && vault) return vault;
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = loadOrCreateVault(userId)
|
||||
.then((v) => {
|
||||
vault = v;
|
||||
initializedFor = userId;
|
||||
return v;
|
||||
})
|
||||
.finally(() => {
|
||||
initPromise = null;
|
||||
});
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function makeSecureFileStore(userId: string): SecretStore {
|
||||
return {
|
||||
async getSecret(key: string): Promise<Uint8Array | null> {
|
||||
const v = await ensureInit(userId);
|
||||
const found = v.data.get(key);
|
||||
return found ? new Uint8Array(found) : null;
|
||||
},
|
||||
async setSecret(key: string, value: Uint8Array): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
v.data.set(key, new Uint8Array(value));
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
},
|
||||
async removeSecret(key: string): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
v.data.delete(key);
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Migrates legacy localStorage entries (chatapp.secret:*) into the encrypted
|
||||
// vault on first init. Idempotent — checks for marker key.
|
||||
export async function migrateLocalStorageToVault(
|
||||
userId: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
if (v.data.has('__migrated_from_localstorage')) return;
|
||||
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const fullKey = window.localStorage.key(i);
|
||||
if (!fullKey || !fullKey.startsWith(prefix)) continue;
|
||||
const raw = window.localStorage.getItem(fullKey);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const decoded = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
|
||||
const shortKey = fullKey.slice(prefix.length);
|
||||
v.data.set(shortKey, decoded);
|
||||
} catch {
|
||||
/* skip malformed */
|
||||
}
|
||||
}
|
||||
v.data.set('__migrated_from_localstorage', new Uint8Array([1]));
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { SecretStore } from '@chat-app/shared/auth';
|
||||
import { appLocalDataDir } from '@tauri-apps/api/path';
|
||||
import { type Client, type Store, Stronghold } from '@tauri-apps/plugin-stronghold';
|
||||
|
||||
// Stronghold-backed SecretStore. Vault file lives in Tauri's
|
||||
// `appLocalDataDir/chatapp.stronghold` and survives app re-installs (the
|
||||
// directory is preserved by the OS on macOS/Windows/Linux unless the user
|
||||
// manually removes it). Vault password is derived from the Supabase user-id
|
||||
// so the same user re-installing the app on the same machine recovers their
|
||||
// device key automatically.
|
||||
|
||||
const VAULT_NAME = 'chatapp.stronghold';
|
||||
const CLIENT_NAME = 'chatapp';
|
||||
|
||||
let strongholdRef: Stronghold | null = null;
|
||||
let storeRef: Store | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let initializedFor: string | null = null;
|
||||
|
||||
async function derivePassword(userId: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const buf = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
enc.encode('chatapp-stronghold-v1:' + userId),
|
||||
);
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function ensureInit(userId: string): Promise<void> {
|
||||
if (initializedFor === userId && storeRef) return;
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
const dir = await appLocalDataDir();
|
||||
const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/';
|
||||
const vaultPath = dir + sep + VAULT_NAME;
|
||||
const password = await derivePassword(userId);
|
||||
|
||||
strongholdRef = await Stronghold.load(vaultPath, password);
|
||||
let client: Client;
|
||||
try {
|
||||
client = await strongholdRef.loadClient(CLIENT_NAME);
|
||||
} catch {
|
||||
client = await strongholdRef.createClient(CLIENT_NAME);
|
||||
}
|
||||
storeRef = client.getStore();
|
||||
initializedFor = userId;
|
||||
})().finally(() => {
|
||||
initPromise = null;
|
||||
});
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function makeStrongholdStore(userId: string): SecretStore {
|
||||
return {
|
||||
async getSecret(key: string): Promise<Uint8Array | null> {
|
||||
await ensureInit(userId);
|
||||
const val = await storeRef!.get(key);
|
||||
if (!val) return null;
|
||||
return val instanceof Uint8Array ? val : new Uint8Array(val);
|
||||
},
|
||||
async setSecret(key: string, value: Uint8Array): Promise<void> {
|
||||
await ensureInit(userId);
|
||||
await storeRef!.insert(key, Array.from(value));
|
||||
await strongholdRef!.save();
|
||||
},
|
||||
async removeSecret(key: string): Promise<void> {
|
||||
await ensureInit(userId);
|
||||
await storeRef!.remove(key);
|
||||
await strongholdRef!.save();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// One-time migration: copies any keys we find in localStorage (the legacy
|
||||
// dev store) into Stronghold so a user who upgrades from a localStorage-only
|
||||
// build doesn't lose their device key. Safe to call multiple times — no-op
|
||||
// once the marker key is present.
|
||||
export async function migrateLocalStorageToStronghold(
|
||||
userId: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
await ensureInit(userId);
|
||||
if (!storeRef) return;
|
||||
const markerKey = '__migrated_from_localstorage';
|
||||
const already = await storeRef.get(markerKey);
|
||||
if (already) return;
|
||||
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const fullKey = window.localStorage.key(i);
|
||||
if (!fullKey || !fullKey.startsWith(prefix)) continue;
|
||||
const raw = window.localStorage.getItem(fullKey);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const decoded = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
|
||||
const shortKey = fullKey.slice(prefix.length);
|
||||
await storeRef.insert(shortKey, Array.from(decoded));
|
||||
} catch {
|
||||
// Skip malformed entries.
|
||||
}
|
||||
}
|
||||
await storeRef.insert(markerKey, [1]);
|
||||
if (strongholdRef) await strongholdRef.save();
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
type ChatMessage,
|
||||
type DecryptedMessage,
|
||||
decryptMessages,
|
||||
encryptAndUploadAttachment,
|
||||
fetchConversationMessages,
|
||||
fetchOwnEnvelopes,
|
||||
fetchSenderDeviceKeys,
|
||||
insertAttachmentRow,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
type MessageWithCipher,
|
||||
sendEncryptedMessage,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { bytesToPgHex } from '@chat-app/shared/supabase';
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
@@ -36,7 +34,7 @@ type MessageChangePayload = {
|
||||
old: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function rowToMessage(row: Record<string, unknown>): ChatMessage {
|
||||
function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
||||
return {
|
||||
id: String(row.id),
|
||||
conversationId: String(row.conversation_id),
|
||||
@@ -46,6 +44,9 @@ function rowToMessage(row: Record<string, unknown>): ChatMessage {
|
||||
editedAt: row.edited_at ? String(row.edited_at) : null,
|
||||
deletedAt: row.deleted_at ? String(row.deleted_at) : null,
|
||||
createdAt: String(row.created_at),
|
||||
ciphertext: pgBytesToBytes(String(row.ciphertext ?? '\\x')),
|
||||
nonce: pgBytesToBytes(String(row.nonce ?? '\\x')),
|
||||
keyVersion: typeof row.key_version === 'number' ? row.key_version : 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,23 +67,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}, [userId, deviceId]);
|
||||
|
||||
const decryptBatch = useCallback(
|
||||
async (messages: ChatMessage[]): Promise<DecryptedMessage[]> => {
|
||||
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv || !deviceId || messages.length === 0) {
|
||||
return messages.map((m) => ({ ...m, plaintext: null }));
|
||||
}
|
||||
const ids = messages.map((m) => m.id);
|
||||
const senderDeviceIds = messages
|
||||
.map((m) => m.senderDeviceId)
|
||||
.filter((v): v is string => v != null);
|
||||
const [envelopes, senderKeys] = await Promise.all([
|
||||
fetchOwnEnvelopes(supabase, ids, deviceId),
|
||||
fetchSenderDeviceKeys(supabase, senderDeviceIds),
|
||||
]);
|
||||
return decryptMessages({
|
||||
client: supabase,
|
||||
messages,
|
||||
envelopes,
|
||||
senderKeys,
|
||||
ownDeviceId: deviceId,
|
||||
ownPrivateKey: priv,
|
||||
});
|
||||
},
|
||||
@@ -105,26 +98,81 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
}, [conversationId, decryptBatch]);
|
||||
|
||||
// Realtime INSERT handler — decrypt + append (with retry for envelope race).
|
||||
// Realtime INSERT handler — refetches the row via REST so we get the
|
||||
// canonical bytea encoding (postgres_changes payloads serialize bytea
|
||||
// differently and decoding them inline is brittle). Then decrypt + append.
|
||||
// Skips if the message is already in state (e.g. optimistic insert from our
|
||||
// own send), so the sender's cached copy isn't overwritten with a flicker.
|
||||
const handleInsert = useCallback(
|
||||
async (row: Record<string, unknown>) => {
|
||||
if (!deviceId) return;
|
||||
const msg = rowToMessage(row);
|
||||
let decrypted: DecryptedMessage = { ...msg, plaintext: null };
|
||||
if (!conversationId || !deviceId) return;
|
||||
const id = String(row.id);
|
||||
|
||||
let alreadyHave = false;
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === id)) alreadyHave = true;
|
||||
return prev;
|
||||
});
|
||||
if (alreadyHave) return;
|
||||
|
||||
let decrypted: DecryptedMessage | null = null;
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.select(
|
||||
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version',
|
||||
)
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
console.warn('handleInsert refetch failed', error);
|
||||
return;
|
||||
}
|
||||
if (!data) {
|
||||
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
// db-types snapshot predates the sender-key columns; cast to bypass.
|
||||
const r = data as unknown as {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
sender_id: string;
|
||||
sender_device_id: string | null;
|
||||
reply_to_id: string | null;
|
||||
edited_at: string | null;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
key_version: number;
|
||||
};
|
||||
const msg: MessageWithCipher = {
|
||||
id: r.id,
|
||||
conversationId: r.conversation_id,
|
||||
senderId: r.sender_id,
|
||||
senderDeviceId: r.sender_device_id,
|
||||
replyToId: r.reply_to_id,
|
||||
editedAt: r.edited_at,
|
||||
deletedAt: r.deleted_at,
|
||||
createdAt: r.created_at,
|
||||
ciphertext: pgBytesToBytes(String(r.ciphertext)),
|
||||
nonce: pgBytesToBytes(String(r.nonce)),
|
||||
keyVersion: r.key_version,
|
||||
};
|
||||
const [d] = await decryptBatch([msg]);
|
||||
if (d) {
|
||||
decrypted = d;
|
||||
if (d.plaintext !== null) break;
|
||||
}
|
||||
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
|
||||
await new Promise((r) => window.setTimeout(r, 200 * (attempt + 1)));
|
||||
}
|
||||
if (!decrypted) return;
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === decrypted.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted] };
|
||||
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted!] };
|
||||
});
|
||||
},
|
||||
[deviceId, decryptBatch],
|
||||
[conversationId, deviceId, decryptBatch],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
@@ -190,6 +238,22 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
},
|
||||
)
|
||||
// When a peer device wraps the conversation-key for us (e.g. we just
|
||||
// registered a fresh device), re-decrypt the visible messages.
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'conversation_keys',
|
||||
filter: 'conversation_id=eq.' + conversationId,
|
||||
},
|
||||
(payload: { new: { recipient_device_id?: string } }) => {
|
||||
if (payload.new?.recipient_device_id === deviceId) {
|
||||
void refresh();
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
@@ -226,7 +290,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||
}
|
||||
|
||||
// 2. Send message (inserts messages + envelopes in one helper).
|
||||
// 2. Send message (inserts messages + per-conversation key bundles).
|
||||
const msg = await sendEncryptedMessage({
|
||||
client: supabase,
|
||||
conversationId,
|
||||
@@ -237,7 +301,28 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
...(handles.length > 0 ? { attachmentHandles: handles } : {}),
|
||||
});
|
||||
|
||||
// 3. Insert public attachment metadata rows pointing at the new message.
|
||||
// 3. Optimistic insert — we already have the plaintext in hand and the
|
||||
// server returned the row id, so add the message to local state
|
||||
// immediately. Realtime will then no-op (handleInsert dedupes by id).
|
||||
const attachmentsPayload =
|
||||
handles.length === 0
|
||||
? trimmed
|
||||
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{
|
||||
...msg,
|
||||
plaintext: attachmentsPayload,
|
||||
} as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// 4. Insert public attachment metadata rows pointing at the new message.
|
||||
for (const h of handles) {
|
||||
const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x';
|
||||
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
|
||||
|
||||
@@ -9,6 +9,9 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { LockIcon } from '../components/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { exportDeviceKey, importDeviceKey } from '../lib/deviceBackup';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import {
|
||||
getPttSettings,
|
||||
keyCodeToLabel,
|
||||
@@ -161,6 +164,7 @@ export function SettingsPage() {
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<DeviceKeyBackupControls />
|
||||
</Section>
|
||||
|
||||
{/* Danger zone */}
|
||||
@@ -323,6 +327,160 @@ function AudioQualityControls() {
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceKeyBackupControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile, device } = useAuth();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [backupOut, setBackupOut] = useState<string | null>(null);
|
||||
const [exportPass, setExportPass] = useState('');
|
||||
const [importPass, setImportPass] = useState('');
|
||||
const [importBlob, setImportBlob] = useState('');
|
||||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
const canRun = !!profile?.userId && !!device?.id;
|
||||
|
||||
async function handleExport() {
|
||||
if (!canRun) return;
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||
if (!priv) throw new Error('No device key on this install');
|
||||
const out = await exportDeviceKey(priv, exportPass);
|
||||
setBackupOut(out);
|
||||
setExportPass('');
|
||||
setMsg({
|
||||
kind: 'ok',
|
||||
text: t('app:settings.backup_export_ok', {
|
||||
defaultValue: 'Backup erstellt — kopiere und bewahre es sicher auf.',
|
||||
}),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
kind: 'err',
|
||||
text: err instanceof Error ? err.message : 'export failed',
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!canRun) return;
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const priv = await importDeviceKey(importBlob.trim(), importPass);
|
||||
await saveDevicePrivateKey(devLocalSecretStore, profile.userId, device.id, priv);
|
||||
setImportBlob('');
|
||||
setImportPass('');
|
||||
setMsg({
|
||||
kind: 'ok',
|
||||
text: t('app:settings.backup_import_ok', {
|
||||
defaultValue:
|
||||
'Schlüssel importiert. Beim nächsten Reload sollten alte Nachrichten lesbar sein.',
|
||||
}),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
kind: 'err',
|
||||
text: err instanceof Error ? err.message : 'import failed',
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-white/10 bg-ink-900/40 p-4">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-400">
|
||||
{t('app:settings.device_key_backup_hint', {
|
||||
defaultValue:
|
||||
'Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{t('app:settings.backup_export', { defaultValue: 'Export' })}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={exportPass}
|
||||
onChange={(e) => setExportPass(e.target.value)}
|
||||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase (min 8)' })}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || exportPass.length < 8 || !canRun}
|
||||
onClick={() => void handleExport()}
|
||||
className="cursor-pointer rounded-lg bg-brand-500/80 px-4 text-sm font-semibold text-white transition hover:bg-brand-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:settings.backup_create', { defaultValue: 'Erstellen' })}
|
||||
</button>
|
||||
</div>
|
||||
{backupOut && (
|
||||
<textarea
|
||||
readOnly
|
||||
value={backupOut}
|
||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-emerald-500/30 bg-ink-950/60 p-2 font-mono text-[10px] text-emerald-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 border-t border-white/5 pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{t('app:settings.backup_import', { defaultValue: 'Import' })}
|
||||
</div>
|
||||
<textarea
|
||||
value={importBlob}
|
||||
onChange={(e) => setImportBlob(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={t('app:settings.backup_blob_placeholder', {
|
||||
defaultValue: 'chatapp-backup-v1.…',
|
||||
})}
|
||||
className="w-full rounded-lg border border-white/10 bg-ink-800 p-2 font-mono text-[11px] text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={importPass}
|
||||
onChange={(e) => setImportPass(e.target.value)}
|
||||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase' })}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !importBlob || !importPass || !canRun}
|
||||
onClick={() => void handleImport()}
|
||||
className="cursor-pointer rounded-lg bg-emerald-500/80 px-4 text-sm font-semibold text-white transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:settings.backup_restore', { defaultValue: 'Wiederherstellen' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
className={
|
||||
'mt-3 text-xs ' +
|
||||
(msg.kind === 'ok' ? 'text-emerald-300' : 'text-rose-300')
|
||||
}
|
||||
>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenShareControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [cfg, setCfg] = useState<ScreenShareSettings>(() => getScreenShareSettings());
|
||||
|
||||
@@ -141,6 +141,17 @@ export async function forgetDevicePrivateKey(
|
||||
await secretStore.removeSecret(privateKeySecretName(userId, deviceId));
|
||||
}
|
||||
|
||||
// Writes a device private key into the secret store. Used by the
|
||||
// backup-restore flow to re-import a key generated on another machine.
|
||||
export async function saveDevicePrivateKey(
|
||||
secretStore: SecretStore,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
privateKey: Uint8Array,
|
||||
): Promise<void> {
|
||||
await secretStore.setSecret(privateKeySecretName(userId, deviceId), privateKey);
|
||||
}
|
||||
|
||||
// Lightweight helpers for platforms that want to cache their current device id
|
||||
// in JSON storage (separate from the secret store, which only holds raw bytes).
|
||||
export const DEVICE_ID_STORAGE_KEY_PREFIX = 'chatapp.device_id';
|
||||
|
||||
@@ -86,7 +86,11 @@ export async function completeSessionFromUrl(
|
||||
}
|
||||
|
||||
export async function signOut(client: AppSupabaseClient): Promise<void> {
|
||||
const { error } = await client.auth.signOut();
|
||||
// `scope: 'local'` only ends the session in THIS client. Without it Supabase
|
||||
// defaults to 'global', which invalidates the user's refresh tokens
|
||||
// everywhere — meaning a logout in the browser would also kick the desktop
|
||||
// app (and vice versa) the next time it tries to refresh its token.
|
||||
const { error } = await client.auth.signOut({ scope: 'local' });
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
decryptWithConvKey,
|
||||
encryptWithConvKey,
|
||||
generateConvKey,
|
||||
unwrapConvKey,
|
||||
wrapConvKeyForRecipient,
|
||||
} from '../crypto/sessionKeys.js';
|
||||
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
|
||||
import type { AppSupabaseClient } from '../supabase/client.js';
|
||||
|
||||
// db-types in this monorepo is a static snapshot generated against the older
|
||||
// schema. The new `conversation_keys` table + `active_key_version` column on
|
||||
// `conversations` aren't in there yet. Until the codegen catches up we bypass
|
||||
// the typed builder for those calls.
|
||||
function rawFrom(client: AppSupabaseClient, table: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (client as unknown as { from: (t: string) => any }).from(table);
|
||||
}
|
||||
|
||||
// Per-conversation symmetric key management. Replaces per-device envelopes
|
||||
// with a single conv-key (32-byte XSalsa20-Poly1305) wrapped to each device's
|
||||
// X25519 pubkey via crypto_box.
|
||||
|
||||
interface DeviceKey {
|
||||
deviceId: string;
|
||||
userId: string;
|
||||
publicKey: Uint8Array;
|
||||
}
|
||||
|
||||
export interface OwnDeviceCtx {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
export interface ConvKeyHandle {
|
||||
conversationId: string;
|
||||
keyVersion: number;
|
||||
key: Uint8Array;
|
||||
}
|
||||
|
||||
// In-process cache to avoid re-fetching + re-unwrapping every send/decrypt.
|
||||
const cache = new Map<string, ConvKeyHandle>();
|
||||
const cacheKey = (convId: string, version: number) => convId + '@' + version;
|
||||
|
||||
export function clearConvKeyCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
async function listDeviceKeys(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
): Promise<DeviceKey[]> {
|
||||
const { data: members, error: mErr } = await client
|
||||
.from('conversation_members')
|
||||
.select('user_id, accepted')
|
||||
.eq('conversation_id', conversationId);
|
||||
if (mErr) throw mErr;
|
||||
const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
|
||||
if (memberIds.length === 0) return [];
|
||||
|
||||
const { data: devices, error: dErr } = await client
|
||||
.from('devices')
|
||||
.select('id, user_id, public_key')
|
||||
.in('user_id', memberIds);
|
||||
if (dErr) throw dErr;
|
||||
|
||||
return (devices ?? []).map((d) => ({
|
||||
deviceId: d.id,
|
||||
userId: d.user_id,
|
||||
publicKey: pgHexToBytes(d.public_key),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchActiveKeyVersion(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
): Promise<number> {
|
||||
const { data, error } = await rawFrom(client, 'conversations')
|
||||
.select('active_key_version')
|
||||
.eq('id', conversationId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return (data as { active_key_version: number }).active_key_version;
|
||||
}
|
||||
|
||||
interface SenderInfo {
|
||||
senderDeviceId: string;
|
||||
senderPublicKey: Uint8Array;
|
||||
}
|
||||
|
||||
async function fetchKeyBundle(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
ownDeviceId: string,
|
||||
keyVersion: number,
|
||||
): Promise<{ encryptedKey: Uint8Array; nonce: Uint8Array; sender: SenderInfo } | null> {
|
||||
const { data, error } = await rawFrom(client, 'conversation_keys')
|
||||
.select('encrypted_key, nonce, sender_device_id')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('recipient_device_id', ownDeviceId)
|
||||
.eq('key_version', keyVersion)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) return null;
|
||||
|
||||
const row = data as {
|
||||
encrypted_key: string;
|
||||
nonce: string;
|
||||
sender_device_id: string;
|
||||
};
|
||||
const { data: dev, error: dErr } = await client
|
||||
.from('devices')
|
||||
.select('id, public_key')
|
||||
.eq('id', row.sender_device_id)
|
||||
.single();
|
||||
if (dErr) throw dErr;
|
||||
|
||||
return {
|
||||
encryptedKey: pgHexToBytes(row.encrypted_key),
|
||||
nonce: pgHexToBytes(row.nonce),
|
||||
sender: {
|
||||
senderDeviceId: dev.id,
|
||||
senderPublicKey: pgHexToBytes(dev.public_key),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Bootstraps a brand-new conv-key, wrapping it for every member device that
|
||||
// currently exists (including the caller's own devices). Used the first time
|
||||
// a conversation needs a key, or when rotation is requested.
|
||||
export async function bootstrapConvKey(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
own: OwnDeviceCtx,
|
||||
keyVersion: number,
|
||||
): Promise<ConvKeyHandle> {
|
||||
const convKey = generateConvKey();
|
||||
const recipients = await listDeviceKeys(client, conversationId);
|
||||
if (recipients.length === 0) {
|
||||
throw new Error('cannot bootstrap conv key — no recipient devices');
|
||||
}
|
||||
|
||||
const rows: Array<{
|
||||
conversation_id: string;
|
||||
recipient_device_id: string;
|
||||
key_version: number;
|
||||
sender_device_id: string;
|
||||
encrypted_key: string;
|
||||
nonce: string;
|
||||
}> = [];
|
||||
for (const r of recipients) {
|
||||
const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey);
|
||||
rows.push({
|
||||
conversation_id: conversationId,
|
||||
recipient_device_id: r.deviceId,
|
||||
key_version: keyVersion,
|
||||
sender_device_id: own.deviceId,
|
||||
encrypted_key: bytesToPgHex(wrapped.ciphertext),
|
||||
nonce: bytesToPgHex(wrapped.nonce),
|
||||
});
|
||||
}
|
||||
|
||||
const { error } = await rawFrom(client, 'conversation_keys').upsert(rows, {
|
||||
onConflict: 'conversation_id,recipient_device_id,key_version',
|
||||
ignoreDuplicates: true,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
const handle = { conversationId, keyVersion, key: convKey };
|
||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Resolves the current conv-key for `conversationId`. Order:
|
||||
// 1) cache hit
|
||||
// 2) DB row for own device → unwrap
|
||||
// 3) bootstrap a brand-new key (only valid path if NO existing keys exist
|
||||
// for any device — i.e. this is the conversation's very first message)
|
||||
export async function getOrCreateConvKey(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
own: OwnDeviceCtx,
|
||||
): Promise<ConvKeyHandle> {
|
||||
const version = await fetchActiveKeyVersion(client, conversationId);
|
||||
const cached = cache.get(cacheKey(conversationId, version));
|
||||
if (cached) return cached;
|
||||
|
||||
const bundle = await fetchKeyBundle(client, conversationId, own.deviceId, version);
|
||||
if (bundle) {
|
||||
const key = await unwrapConvKey(
|
||||
bundle.encryptedKey,
|
||||
bundle.nonce,
|
||||
bundle.sender.senderPublicKey,
|
||||
own.privateKey,
|
||||
);
|
||||
const handle = { conversationId, keyVersion: version, key };
|
||||
cache.set(cacheKey(conversationId, version), handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
// No bundle yet for THIS device. Two cases:
|
||||
// - I'm the first ever sender → bootstrap.
|
||||
// - Conversation already has keys but my device wasn't included yet → I
|
||||
// have to wait until an existing device wraps the key for me.
|
||||
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
|
||||
.select('recipient_device_id', { count: 'exact', head: true })
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('key_version', version);
|
||||
if (cntErr) throw cntErr;
|
||||
|
||||
if ((count ?? 0) > 0) {
|
||||
throw new Error(
|
||||
'Awaiting conversation key — another device must share it with this device.',
|
||||
);
|
||||
}
|
||||
return bootstrapConvKey(client, conversationId, own, version);
|
||||
}
|
||||
|
||||
// Read-only variant: never bootstraps. Returns null if no key bundle exists
|
||||
// for this device yet.
|
||||
export async function tryGetConvKey(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
ownDeviceId: string,
|
||||
ownPrivateKey: Uint8Array,
|
||||
keyVersion: number,
|
||||
): Promise<ConvKeyHandle | null> {
|
||||
const cached = cache.get(cacheKey(conversationId, keyVersion));
|
||||
if (cached) return cached;
|
||||
|
||||
const bundle = await fetchKeyBundle(client, conversationId, ownDeviceId, keyVersion);
|
||||
if (!bundle) return null;
|
||||
const key = await unwrapConvKey(
|
||||
bundle.encryptedKey,
|
||||
bundle.nonce,
|
||||
bundle.sender.senderPublicKey,
|
||||
ownPrivateKey,
|
||||
);
|
||||
const handle = { conversationId, keyVersion, key };
|
||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Wraps the active conv-key for a single new device (e.g. when a peer
|
||||
// registers a new device). The caller's device must have an unwrapped copy
|
||||
// of the conv-key in cache (or be able to fetch it).
|
||||
export async function shareConvKeyToDevice(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
recipientDeviceId: string,
|
||||
recipientPublicKey: Uint8Array,
|
||||
own: OwnDeviceCtx,
|
||||
): Promise<void> {
|
||||
const version = await fetchActiveKeyVersion(client, conversationId);
|
||||
const handle =
|
||||
cache.get(cacheKey(conversationId, version)) ??
|
||||
(await tryGetConvKey(client, conversationId, own.deviceId, own.privateKey, version));
|
||||
if (!handle) {
|
||||
throw new Error('cannot share conv key — own device does not have it yet');
|
||||
}
|
||||
|
||||
const wrapped = await wrapConvKeyForRecipient(
|
||||
handle.key,
|
||||
recipientPublicKey,
|
||||
own.privateKey,
|
||||
);
|
||||
const { error } = await rawFrom(client, 'conversation_keys').upsert(
|
||||
{
|
||||
conversation_id: conversationId,
|
||||
recipient_device_id: recipientDeviceId,
|
||||
key_version: version,
|
||||
sender_device_id: own.deviceId,
|
||||
encrypted_key: bytesToPgHex(wrapped.ciphertext),
|
||||
nonce: bytesToPgHex(wrapped.nonce),
|
||||
},
|
||||
{
|
||||
onConflict: 'conversation_id,recipient_device_id,key_version',
|
||||
ignoreDuplicates: true,
|
||||
},
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Re-exports for convenience.
|
||||
export { decryptWithConvKey, encryptWithConvKey };
|
||||
@@ -2,6 +2,7 @@ import type { AppSupabaseClient } from '../supabase/client.js';
|
||||
|
||||
export * from './attachments.js';
|
||||
export * from './conversations.js';
|
||||
export * from './convKeys.js';
|
||||
export * from './groups.js';
|
||||
export * from './messages.js';
|
||||
export * from './types.js';
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import {
|
||||
bytesToUtf8,
|
||||
decryptFrom,
|
||||
encryptFor,
|
||||
utf8ToBytes,
|
||||
} from '../crypto/index.js';
|
||||
import { bytesToUtf8, utf8ToBytes } from '../crypto/index.js';
|
||||
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
|
||||
import type { AppSupabaseClient } from '../supabase/client.js';
|
||||
import {
|
||||
decryptWithConvKey,
|
||||
encryptWithConvKey,
|
||||
getOrCreateConvKey,
|
||||
type OwnDeviceCtx,
|
||||
tryGetConvKey,
|
||||
} from './convKeys.js';
|
||||
import type { ChatMessage, DecryptedMessage } from './types.js';
|
||||
|
||||
const MESSAGE_COLS =
|
||||
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at';
|
||||
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version';
|
||||
|
||||
interface MessageRow {
|
||||
id: string;
|
||||
@@ -20,9 +22,18 @@ interface MessageRow {
|
||||
edited_at: string | null;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
key_version: number;
|
||||
}
|
||||
|
||||
function mapMessage(row: MessageRow): ChatMessage {
|
||||
interface MessageWithCipher extends ChatMessage {
|
||||
ciphertext: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
keyVersion: number;
|
||||
}
|
||||
|
||||
function mapMessage(row: MessageRow): MessageWithCipher {
|
||||
return {
|
||||
id: row.id,
|
||||
conversationId: row.conversation_id,
|
||||
@@ -32,6 +43,9 @@ function mapMessage(row: MessageRow): ChatMessage {
|
||||
editedAt: row.edited_at,
|
||||
deletedAt: row.deleted_at,
|
||||
createdAt: row.created_at,
|
||||
ciphertext: pgHexToBytes(row.ciphertext),
|
||||
nonce: pgHexToBytes(row.nonce),
|
||||
keyVersion: row.key_version,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,14 +99,17 @@ export interface SendMessageParams {
|
||||
attachmentHandles?: import('./attachments.js').AttachmentHandle[];
|
||||
}
|
||||
|
||||
// Encrypts and inserts a message + per-device envelopes (one per recipient
|
||||
// device, including the sender's own devices so multi-device sender devices
|
||||
// can decrypt their own outbox).
|
||||
// Encrypts and inserts a message using the shared per-conversation key
|
||||
// (Sender-Key / Signal-style). The conv-key is generated lazily on first
|
||||
// send and shared with every existing recipient device. New devices that
|
||||
// register later receive their key bundle through `shareConvKeyToDevice`.
|
||||
export async function sendEncryptedMessage(params: SendMessageParams): Promise<ChatMessage> {
|
||||
const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId);
|
||||
if (deviceKeys.length === 0) {
|
||||
throw new Error('no recipient devices found');
|
||||
}
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
userId: params.senderUserId,
|
||||
deviceId: params.senderDeviceId,
|
||||
privateKey: params.senderPrivateKey,
|
||||
};
|
||||
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
|
||||
|
||||
const attachments = params.attachmentHandles ?? [];
|
||||
const payloadString =
|
||||
@@ -101,11 +118,15 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
|
||||
: JSON.stringify({ v: 1, text: params.plaintext, attachments });
|
||||
const plainBytes = utf8ToBytes(payloadString);
|
||||
|
||||
// Insert the message metadata first.
|
||||
const cipher = encryptWithConvKey(plainBytes, handle.key);
|
||||
|
||||
const insertPayload: Record<string, unknown> = {
|
||||
conversation_id: params.conversationId,
|
||||
sender_id: params.senderUserId,
|
||||
sender_device_id: params.senderDeviceId,
|
||||
ciphertext: bytesToPgHex(cipher.ciphertext),
|
||||
nonce: bytesToPgHex(cipher.nonce),
|
||||
key_version: handle.keyVersion,
|
||||
};
|
||||
if (params.replyToId) insertPayload.reply_to_id = params.replyToId;
|
||||
|
||||
@@ -115,30 +136,7 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
|
||||
.select(MESSAGE_COLS)
|
||||
.single();
|
||||
if (insertErr) throw insertErr;
|
||||
const msg = mapMessage(messageRow as unknown as MessageRow);
|
||||
|
||||
// Encrypt one envelope per recipient device (including own devices).
|
||||
const envelopes: { message_id: string; recipient_device_id: string; ciphertext: string; nonce: string }[] = [];
|
||||
for (const dk of deviceKeys) {
|
||||
const { ciphertext, nonce } = await encryptFor(plainBytes, dk.publicKey, params.senderPrivateKey);
|
||||
envelopes.push({
|
||||
message_id: msg.id,
|
||||
recipient_device_id: dk.deviceId,
|
||||
ciphertext: bytesToPgHex(ciphertext),
|
||||
nonce: bytesToPgHex(nonce),
|
||||
});
|
||||
}
|
||||
|
||||
const { error: envErr } = await params.client
|
||||
.from('message_envelopes')
|
||||
.insert(envelopes as never);
|
||||
if (envErr) {
|
||||
// Best-effort cleanup if envelope insert failed.
|
||||
await params.client.from('messages').delete().eq('id', msg.id);
|
||||
throw envErr;
|
||||
}
|
||||
|
||||
return msg;
|
||||
return mapMessage(messageRow as unknown as MessageRow);
|
||||
}
|
||||
|
||||
// Fetch the last `limit` messages of a conversation in ascending order.
|
||||
@@ -146,7 +144,7 @@ export async function fetchConversationMessages(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
limit = 100,
|
||||
): Promise<ChatMessage[]> {
|
||||
): Promise<MessageWithCipher[]> {
|
||||
const { data, error } = await client
|
||||
.from('messages')
|
||||
.select(MESSAGE_COLS)
|
||||
@@ -158,47 +156,7 @@ export async function fetchConversationMessages(
|
||||
return rows.map(mapMessage).reverse();
|
||||
}
|
||||
|
||||
// Pull envelopes targeted at our own device for a batch of message ids.
|
||||
export async function fetchOwnEnvelopes(
|
||||
client: AppSupabaseClient,
|
||||
messageIds: string[],
|
||||
ownDeviceId: string,
|
||||
): Promise<Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>> {
|
||||
if (messageIds.length === 0) return new Map();
|
||||
const { data, error } = await client
|
||||
.from('message_envelopes')
|
||||
.select('message_id, ciphertext, nonce')
|
||||
.in('message_id', messageIds)
|
||||
.eq('recipient_device_id', ownDeviceId);
|
||||
if (error) throw error;
|
||||
const out = new Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>();
|
||||
for (const row of data ?? []) {
|
||||
out.set(row.message_id, {
|
||||
ciphertext: pgHexToBytes(row.ciphertext),
|
||||
nonce: pgHexToBytes(row.nonce),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Map sender device id -> public key (for verifying envelope authenticity).
|
||||
export async function fetchSenderDeviceKeys(
|
||||
client: AppSupabaseClient,
|
||||
deviceIds: string[],
|
||||
): Promise<Map<string, Uint8Array>> {
|
||||
if (deviceIds.length === 0) return new Map();
|
||||
const unique = Array.from(new Set(deviceIds));
|
||||
const { data, error } = await client
|
||||
.from('devices')
|
||||
.select('id, public_key')
|
||||
.in('id', unique);
|
||||
if (error) throw error;
|
||||
const out = new Map<string, Uint8Array>();
|
||||
for (const row of data ?? []) {
|
||||
out.set(row.id, pgHexToBytes(row.public_key));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export type { MessageWithCipher };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit + delete
|
||||
@@ -212,42 +170,30 @@ export interface EditMessageParams {
|
||||
senderPrivateKey: Uint8Array;
|
||||
}
|
||||
|
||||
// Re-encrypts the message for every currently-registered device in the
|
||||
// conversation and rewrites the envelope rows. The server-side trigger
|
||||
// enforces the 24h window + sender-only rule.
|
||||
export async function editEncryptedMessage(params: EditMessageParams): Promise<void> {
|
||||
const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId);
|
||||
if (deviceKeys.length === 0) throw new Error('no recipient devices found');
|
||||
// Re-encrypts the message body with the conv-key and updates the row.
|
||||
// Server-side trigger enforces 24h window + sender-only rule.
|
||||
export async function editEncryptedMessage(
|
||||
params: EditMessageParams & { senderUserId: string; senderDeviceId: string },
|
||||
): Promise<void> {
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
userId: params.senderUserId,
|
||||
deviceId: params.senderDeviceId,
|
||||
privateKey: params.senderPrivateKey,
|
||||
};
|
||||
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
|
||||
|
||||
const plainBytes = utf8ToBytes(params.newPlaintext);
|
||||
const rows: {
|
||||
message_id: string;
|
||||
recipient_device_id: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
}[] = [];
|
||||
for (const dk of deviceKeys) {
|
||||
const { ciphertext, nonce } = await encryptFor(plainBytes, dk.publicKey, params.senderPrivateKey);
|
||||
rows.push({
|
||||
message_id: params.messageId,
|
||||
recipient_device_id: dk.deviceId,
|
||||
ciphertext: bytesToPgHex(ciphertext),
|
||||
nonce: bytesToPgHex(nonce),
|
||||
});
|
||||
}
|
||||
const cipher = encryptWithConvKey(utf8ToBytes(params.newPlaintext), handle.key);
|
||||
|
||||
// UPDATE the message row — trigger rechecks 24h window + sets edited_at.
|
||||
const { error: mErr } = await params.client
|
||||
const { error } = await params.client
|
||||
.from('messages')
|
||||
.update({ edited_at: new Date().toISOString() } as never)
|
||||
.update({
|
||||
ciphertext: bytesToPgHex(cipher.ciphertext),
|
||||
nonce: bytesToPgHex(cipher.nonce),
|
||||
key_version: handle.keyVersion,
|
||||
edited_at: new Date().toISOString(),
|
||||
} as never)
|
||||
.eq('id', params.messageId);
|
||||
if (mErr) throw mErr;
|
||||
|
||||
// Upsert envelopes (INSERT on conflict UPDATE).
|
||||
const { error: eErr } = await params.client
|
||||
.from('message_envelopes')
|
||||
.upsert(rows as never, { onConflict: 'message_id,recipient_device_id' });
|
||||
if (eErr) throw eErr;
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function softDeleteMessage(
|
||||
@@ -365,24 +311,46 @@ export async function removeReaction(
|
||||
// Decrypt helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DecryptOptions {
|
||||
export interface DecryptParams {
|
||||
client: AppSupabaseClient;
|
||||
messages: MessageWithCipher[];
|
||||
ownDeviceId: string;
|
||||
ownPrivateKey: Uint8Array;
|
||||
}
|
||||
|
||||
export async function decryptMessages(opts: {
|
||||
messages: ChatMessage[];
|
||||
envelopes: Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>;
|
||||
senderKeys: Map<string, Uint8Array>;
|
||||
ownPrivateKey: Uint8Array;
|
||||
}): Promise<DecryptedMessage[]> {
|
||||
// Decrypts messages using their conv-key (looked up + cached per
|
||||
// keyVersion). Returns null `plaintext` when this device has no key bundle
|
||||
// for that version yet (e.g. brand-new device waiting for share).
|
||||
export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMessage[]> {
|
||||
const out: DecryptedMessage[] = [];
|
||||
// Group versions to avoid redundant lookups.
|
||||
const versions = new Map<string, Map<number, Uint8Array | null>>(); // convId -> version -> key | null
|
||||
|
||||
for (const m of opts.messages) {
|
||||
const env = opts.envelopes.get(m.id);
|
||||
const senderKey = m.senderDeviceId ? opts.senderKeys.get(m.senderDeviceId) : undefined;
|
||||
let convCache = versions.get(m.conversationId);
|
||||
if (!convCache) {
|
||||
convCache = new Map();
|
||||
versions.set(m.conversationId, convCache);
|
||||
}
|
||||
let key: Uint8Array | null;
|
||||
if (convCache.has(m.keyVersion)) {
|
||||
key = convCache.get(m.keyVersion) ?? null;
|
||||
} else {
|
||||
const handle = await tryGetConvKey(
|
||||
opts.client,
|
||||
m.conversationId,
|
||||
opts.ownDeviceId,
|
||||
opts.ownPrivateKey,
|
||||
m.keyVersion,
|
||||
);
|
||||
key = handle?.key ?? null;
|
||||
convCache.set(m.keyVersion, key);
|
||||
}
|
||||
|
||||
let plaintext: string | null = null;
|
||||
if (env && senderKey) {
|
||||
if (key) {
|
||||
try {
|
||||
const decoded = await decryptFrom(env.ciphertext, env.nonce, senderKey, opts.ownPrivateKey);
|
||||
const decoded = decryptWithConvKey(m.ciphertext, m.nonce, key);
|
||||
plaintext = bytesToUtf8(decoded);
|
||||
} catch {
|
||||
plaintext = null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './backend.js';
|
||||
export * from './box.js';
|
||||
export * from './keys.js';
|
||||
export * from './sessionKeys.js';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getCryptoBackend } from './backend.js';
|
||||
import { decryptFrom, encryptFor, type EncryptedEnvelope } from './box.js';
|
||||
|
||||
// Sender-Key (Signal-style) helpers — one symmetric XSalsa20-Poly1305 key per
|
||||
// conversation, wrapped with `crypto_box` for each recipient device's pubkey.
|
||||
//
|
||||
// Flow:
|
||||
// - generateConvKey() produces 32 random bytes
|
||||
// - wrapConvKeyForRecipient() encrypts the conv-key with sender's private key
|
||||
// and recipient's pubkey -> stored in `conversation_keys` table
|
||||
// - unwrapConvKey() reverses it on the receiving side
|
||||
// - encryptWithConvKey()/decryptWithConvKey() do the message-payload work
|
||||
|
||||
export function generateConvKey(): Uint8Array {
|
||||
const backend = getCryptoBackend();
|
||||
return backend.randomBytes(backend.secretboxKeyLength);
|
||||
}
|
||||
|
||||
export async function wrapConvKeyForRecipient(
|
||||
convKey: Uint8Array,
|
||||
recipientPublicKey: Uint8Array,
|
||||
senderPrivateKey: Uint8Array,
|
||||
): Promise<EncryptedEnvelope> {
|
||||
return encryptFor(convKey, recipientPublicKey, senderPrivateKey);
|
||||
}
|
||||
|
||||
export async function unwrapConvKey(
|
||||
encryptedKey: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
senderPublicKey: Uint8Array,
|
||||
recipientPrivateKey: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
return decryptFrom(encryptedKey, nonce, senderPublicKey, recipientPrivateKey);
|
||||
}
|
||||
|
||||
export interface ConvCipher {
|
||||
ciphertext: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
}
|
||||
|
||||
export function encryptWithConvKey(
|
||||
plaintext: Uint8Array,
|
||||
convKey: Uint8Array,
|
||||
): ConvCipher {
|
||||
const backend = getCryptoBackend();
|
||||
const nonce = backend.randomBytes(backend.secretboxNonceLength);
|
||||
const ciphertext = backend.secretbox(plaintext, nonce, convKey);
|
||||
return { ciphertext, nonce };
|
||||
}
|
||||
|
||||
export function decryptWithConvKey(
|
||||
ciphertext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
convKey: Uint8Array,
|
||||
): Uint8Array {
|
||||
const backend = getCryptoBackend();
|
||||
return backend.secretboxOpen(ciphertext, nonce, convKey);
|
||||
}
|
||||
@@ -23,3 +23,20 @@ export function pgHexToBytes(hex: string): Uint8Array {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Accepts either:
|
||||
// - PostgREST/REST `\x<hex>` strings (what the .from('table').select() path
|
||||
// returns for bytea), or
|
||||
// - Realtime `postgres_changes` payloads, which encode bytea as plain
|
||||
// base64 (no `\x` prefix).
|
||||
// Useful when the same row can arrive through both paths in the same UI.
|
||||
export function pgBytesToBytes(value: string): Uint8Array {
|
||||
if (value.startsWith('\\x')) {
|
||||
return pgHexToBytes(value);
|
||||
}
|
||||
// Assume base64 (the realtime serializer's default for bytea).
|
||||
const bin = atob(value);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,20 @@ import type { Database, SupabaseConfig } from './types.js';
|
||||
// Typed client alias used throughout the app.
|
||||
export type AppSupabaseClient = SupabaseClient<Database>;
|
||||
|
||||
// Inline serial lock — replaces Supabase's default `navigator.locks` based
|
||||
// lock that occasionally throws "Lock was stolen by another request" when
|
||||
// the same origin opens multiple tabs / Tauri windows / HMR-reloaded
|
||||
// modules. We only have one client instance per process so a simple promise
|
||||
// chain serialises token-refresh fine without cross-tab coordination.
|
||||
const acquireLock = (() => {
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
return async <R>(_name: string, _acquireTimeout: number, fn: () => Promise<R>): Promise<R> => {
|
||||
const next = chain.then(() => fn(), () => fn());
|
||||
chain = next.catch(() => undefined);
|
||||
return next;
|
||||
};
|
||||
})();
|
||||
|
||||
export function createClient(config: SupabaseConfig): AppSupabaseClient {
|
||||
return createSupabaseClient<Database>(config.url, config.anonKey, {
|
||||
auth: {
|
||||
@@ -12,6 +26,7 @@ export function createClient(config: SupabaseConfig): AppSupabaseClient {
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: config.detectSessionInUrl ?? false,
|
||||
lock: acquireLock,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Generated
+10
@@ -65,6 +65,9 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.1.1
|
||||
version: 2.10.1
|
||||
'@tauri-apps/plugin-fs':
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
'@tauri-apps/plugin-global-shortcut':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
@@ -1766,6 +1769,9 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-fs@2.5.0':
|
||||
resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==}
|
||||
|
||||
'@tauri-apps/plugin-global-shortcut@2.3.1':
|
||||
resolution: {integrity: sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==}
|
||||
|
||||
@@ -7401,6 +7407,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
||||
|
||||
'@tauri-apps/plugin-fs@2.5.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
'@tauri-apps/plugin-global-shortcut@2.3.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
-- Sender-Key (Signal-style) multi-device crypto.
|
||||
--
|
||||
-- Replaces per-device envelopes with a per-conversation symmetric key.
|
||||
-- Each device that should be able to read a conversation gets one row in
|
||||
-- `conversation_keys` containing the conv-key wrapped to its X25519 pubkey.
|
||||
--
|
||||
-- Senders encrypt the message body once with the conv-key (XSalsa20-Poly1305
|
||||
-- secretbox) instead of N times to N device pubkeys. This makes new-device
|
||||
-- onboarding for existing conversations possible: any existing device can
|
||||
-- wrap the conv-key for a freshly-registered device, and that device can then
|
||||
-- decrypt the entire history without back-population.
|
||||
--
|
||||
-- Pre-launch wipe: existing messages + envelopes are dropped (the old
|
||||
-- per-device envelopes can't be re-encoded into the new format without the
|
||||
-- sender's private key, which lives only on each user's device).
|
||||
|
||||
-- 1) Wipe legacy message data --------------------------------------------------
|
||||
|
||||
drop table if exists public.message_envelopes cascade;
|
||||
truncate table
|
||||
public.message_reactions,
|
||||
public.message_reads,
|
||||
public.message_attachments,
|
||||
public.messages
|
||||
restart identity cascade;
|
||||
|
||||
-- 2) Add ciphertext + nonce + key_version columns to messages -----------------
|
||||
|
||||
alter table public.messages
|
||||
add column if not exists ciphertext bytea,
|
||||
add column if not exists nonce bytea,
|
||||
add column if not exists key_version int;
|
||||
|
||||
-- Backfill not needed because we just truncated. Make NOT NULL going forward.
|
||||
alter table public.messages
|
||||
alter column ciphertext set not null,
|
||||
alter column nonce set not null,
|
||||
alter column key_version set not null,
|
||||
alter column key_version set default 1;
|
||||
|
||||
-- 3) Track currently-active key version per conversation ----------------------
|
||||
|
||||
alter table public.conversations
|
||||
add column if not exists active_key_version int not null default 1;
|
||||
|
||||
-- 4) conversation_keys table ---------------------------------------------------
|
||||
--
|
||||
-- One row per (conversation, recipient_device, key_version). The wrapped key
|
||||
-- is encrypted with `crypto_box`/`box`-style asymmetric crypto (X25519 +
|
||||
-- XSalsa20-Poly1305) — sender_device's private key + recipient_device's
|
||||
-- public key derive a shared secret to decrypt the embedded conv-key.
|
||||
|
||||
create table if not exists public.conversation_keys (
|
||||
conversation_id uuid not null references public.conversations(id) on delete cascade,
|
||||
recipient_device_id uuid not null references public.devices(id) on delete cascade,
|
||||
key_version int not null,
|
||||
sender_device_id uuid not null references public.devices(id) on delete restrict,
|
||||
encrypted_key bytea not null,
|
||||
nonce bytea not null,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (conversation_id, recipient_device_id, key_version)
|
||||
);
|
||||
|
||||
create index if not exists conversation_keys_recipient_idx
|
||||
on public.conversation_keys(recipient_device_id);
|
||||
|
||||
create index if not exists conversation_keys_conv_version_idx
|
||||
on public.conversation_keys(conversation_id, key_version);
|
||||
|
||||
-- 5) RLS policies for conversation_keys ---------------------------------------
|
||||
|
||||
alter table public.conversation_keys enable row level security;
|
||||
|
||||
-- A user can SELECT a row only if the recipient device belongs to them.
|
||||
drop policy if exists conversation_keys_select_owner on public.conversation_keys;
|
||||
create policy conversation_keys_select_owner
|
||||
on public.conversation_keys
|
||||
for select
|
||||
to authenticated
|
||||
using (
|
||||
exists (
|
||||
select 1
|
||||
from public.devices d
|
||||
where d.id = recipient_device_id
|
||||
and d.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT allowed only by an authenticated user who:
|
||||
-- (a) is a member of the conversation,
|
||||
-- (b) owns the sender_device_id (so a 3rd party can't impersonate),
|
||||
-- (c) the recipient_device belongs to a member of the same conversation.
|
||||
drop policy if exists conversation_keys_insert_member on public.conversation_keys;
|
||||
create policy conversation_keys_insert_member
|
||||
on public.conversation_keys
|
||||
for insert
|
||||
to authenticated
|
||||
with check (
|
||||
exists (
|
||||
select 1
|
||||
from public.conversation_members m
|
||||
where m.conversation_id = conversation_keys.conversation_id
|
||||
and m.user_id = auth.uid()
|
||||
and m.accepted = true
|
||||
)
|
||||
and exists (
|
||||
select 1
|
||||
from public.devices d
|
||||
where d.id = sender_device_id
|
||||
and d.user_id = auth.uid()
|
||||
)
|
||||
and exists (
|
||||
select 1
|
||||
from public.devices d
|
||||
join public.conversation_members m
|
||||
on m.user_id = d.user_id
|
||||
and m.conversation_id = conversation_keys.conversation_id
|
||||
where d.id = recipient_device_id
|
||||
and m.accepted = true
|
||||
)
|
||||
);
|
||||
|
||||
-- DELETE: only sender (cleanup, e.g. when a device is removed). Rare.
|
||||
drop policy if exists conversation_keys_delete_sender on public.conversation_keys;
|
||||
create policy conversation_keys_delete_sender
|
||||
on public.conversation_keys
|
||||
for delete
|
||||
to authenticated
|
||||
using (
|
||||
exists (
|
||||
select 1
|
||||
from public.devices d
|
||||
where d.id = sender_device_id
|
||||
and d.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- 6) Realtime publication ------------------------------------------------------
|
||||
--
|
||||
-- Devices subscribe to INSERTs on `conversation_keys` so a freshly-registered
|
||||
-- device sees its key bundles arrive. They also already subscribe to `devices`
|
||||
-- INSERTs to know when peers join a conversation; that's set up in earlier
|
||||
-- migrations.
|
||||
|
||||
alter publication supabase_realtime add table public.conversation_keys;
|
||||
Reference in New Issue
Block a user