Compare commits

...

9 Commits

Author SHA1 Message Date
byGalax 7efbcf7e39 fix(vault): per-user filename so multi-account on same machine works
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 21:29:03 +02:00
byGalax 49c64cc5d9 feat(crypto): self-rolled encrypted file vault, replaces flaky stronghold
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 21:21:40 +02:00
byGalax d20c7e210b fix(senderkey): backfill missing key bundles on mount + refresh on incoming bundle
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 21:07:47 +02:00
byGalax 58fa9487e3 fix(auth): replace navigator.locks with in-process serial lock to avoid 'lock stolen' aborts
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:38:42 +02:00
byGalax d9b08592da fix(messages): refetch row via REST on realtime insert + optimistic sender update
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:35:00 +02:00
byGalax 4a80bf1c0e fix(realtime): handle base64 bytea payloads from postgres_changes
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:19:40 +02:00
byGalax 05c962d46f fix(auth): scope signOut to local session only
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:11:08 +02:00
byGalax cf3fef6936 fix(secretStore): fall back to localStorage when stronghold init fails
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:05:37 +02:00
byGalax 8c878b3718 fix(capabilities): allow stronghold operations
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:02:44 +02:00
15 changed files with 638 additions and 104 deletions
+1
View File
@@ -23,6 +23,7 @@
"@livekit/components-react": "^2.9.0", "@livekit/components-react": "^2.9.0",
"@supabase/supabase-js": "^2.46.0", "@supabase/supabase-js": "^2.46.0",
"@tauri-apps/api": "^2.1.1", "@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-notification": "^2.0.1", "@tauri-apps/plugin-notification": "^2.0.1",
"@tauri-apps/plugin-sql": "^2.0.1", "@tauri-apps/plugin-sql": "^2.0.1",
+25
View File
@@ -684,6 +684,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-fs",
"tauri-plugin-global-shortcut", "tauri-plugin-global-shortcut",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-sql", "tauri-plugin-sql",
@@ -5483,6 +5484,30 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "tauri-plugin-global-shortcut" name = "tauri-plugin-global-shortcut"
version = "2.3.1" version = "2.3.1"
+1
View File
@@ -18,6 +18,7 @@ tauri = { version = "2", features = ["devtools"] }
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] } tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-stronghold = "2" tauri-plugin-stronghold = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -15,6 +15,27 @@
"updater:allow-check", "updater:allow-check",
"updater:allow-download", "updater:allow-download",
"updater:allow-install", "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/**" }
]
}
] ]
} }
+1
View File
@@ -3,6 +3,7 @@ pub fn run() {
let mut builder = tauri::Builder::default() let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_sql::Builder::default().build()) .plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_fs::init())
.plugin( .plugin(
tauri_plugin_stronghold::Builder::new(|password| { tauri_plugin_stronghold::Builder::new(|password| {
// TODO: derive stronghold key from password using argon2 / blake2b. // TODO: derive stronghold key from password using argon2 / blake2b.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp", "productName": "ChatApp",
"version": "0.3.1", "version": "0.4.1",
"identifier": "com.meinname.chatapp", "identifier": "com.meinname.chatapp",
"build": { "build": {
"beforeDevCommand": "pnpm vite:dev", "beforeDevCommand": "pnpm vite:dev",
+1 -1
View File
@@ -61,7 +61,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const status = (error as { status?: number }).status; const status = (error as { status?: number }).status;
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
// Token genuinely invalid — wipe. // Token genuinely invalid — wipe.
await supabase.auth.signOut().catch(() => { await supabase.auth.signOut({ scope: 'local' }).catch(() => {
/* ignore */ /* ignore */
}); });
setSession(null); setSession(null);
+174 -70
View File
@@ -5,16 +5,24 @@ import { pgHexToBytes } from '@chat-app/shared/supabase';
import { devLocalSecretStore } from './secretStore'; import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase'; import { supabase } from './supabase';
// Watches the `devices` table for INSERTs and, whenever a peer registers a // Watches the `devices` table for new entries AND, on mount, scans every
// new device that's in any of our conversations, wraps the active // conversation we participate in for missing key bundles. Fills gaps by
// conversation key for the freshly-arrived device. This makes Sender-Key // re-wrapping our active conv-key for the missing recipient devices.
// onboarding "just work" — the new device picks up the bundle from
// `conversation_keys` and can decrypt the entire history once at least one
// of our existing devices was online to do the wrapping.
// //
// At-least-once delivery: if no existing device of any participant is online // This fixes the "cannot decrypt" cliff for devices that registered while
// at the moment the new device joins, the new device stays unable to decrypt // no other participant device was online to share the key with them.
// until SOMEONE comes online and runs this loop. Standard Signal trade-off.
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( export function startConversationKeySync(
ownUserId: string, ownUserId: string,
@@ -23,8 +31,13 @@ export function startConversationKeySync(
let cancelled = false; let cancelled = false;
let priv: Uint8Array | null = null; let priv: Uint8Array | null = null;
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then((pk) => { void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
if (cancelled) return;
priv = pk; 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 const channel = supabase
@@ -36,73 +49,164 @@ export function startConversationKeySync(
if (cancelled) return; if (cancelled) return;
const row = payload.new; const row = payload.new;
if (!row?.id || !row.user_id || !row.public_key) return; if (!row?.id || !row.user_id || !row.public_key) return;
// Skip our own devices — we don't need to send keys to ourselves
// (each install bootstraps its own keys via getOrCreateConvKey).
if (row.user_id === ownUserId && row.id === ownDeviceId) return; if (row.user_id === ownUserId && row.id === ownDeviceId) return;
void wrapKeysForNewDevice(ownUserId, ownDeviceId, row.id, row.user_id, row.public_key); 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(); .subscribe();
async function wrapKeysForNewDevice(
myUserId: string,
myDeviceId: string,
newDeviceId: string,
newDeviceUserId: string,
newDevicePubKeyHex: string,
) {
if (!priv) {
priv = await loadDevicePrivateKey(devLocalSecretStore, myUserId, myDeviceId);
if (!priv) return;
}
const newPub = pgHexToBytes(newDevicePubKeyHex);
const ownCtx: OwnDeviceCtx = {
userId: myUserId,
deviceId: myDeviceId,
privateKey: priv,
};
// Find conversations I'm in that the new device's user is also in.
const { data: shared, error: sErr } = await supabase
.from('conversation_members')
.select('conversation_id')
.eq('user_id', newDeviceUserId);
if (sErr) {
console.warn('keySync member lookup failed', sErr);
return;
}
const peerConvs = new Set((shared ?? []).map((r) => r.conversation_id as string));
if (peerConvs.size === 0) return;
const { data: mine, error: mErr } = await supabase
.from('conversation_members')
.select('conversation_id')
.eq('user_id', myUserId)
.eq('accepted', true);
if (mErr) {
console.warn('keySync own-member lookup failed', mErr);
return;
}
const targets: string[] = [];
for (const row of mine ?? []) {
const id = row.conversation_id as string;
if (peerConvs.has(id)) targets.push(id);
}
for (const convId of targets) {
try {
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
} catch (err: unknown) {
// Common: this device has no key for that conv yet (was offline at
// bootstrap). Other online devices will handle it.
console.warn('shareConvKeyToDevice failed', { convId, err });
}
}
}
return () => { return () => {
cancelled = true; cancelled = true;
void supabase.removeChannel(channel); 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 });
}
}
}
+23 -17
View File
@@ -1,17 +1,17 @@
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth'; import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
import { isTauriRuntime } from './globalShortcut'; import { isTauriRuntime } from './globalShortcut';
import { makeStrongholdStore, migrateLocalStorageToStronghold } from './strongholdStore'; import { makeSecureFileStore, migrateLocalStorageToVault } from './secureFileStore';
// Secret store with two backends: // Two-tier SecretStore:
// - Tauri: Stronghold-encrypted vault file in appLocalDataDir. Survives app // - Tauri runtime: encrypted single-file vault in `appLocalDataDir`
// reinstalls and is encrypted at rest with a password derived from the // (`secureFileStore` — XSalsa20-Poly1305 + Argon2id KDF). Survives app
// authenticated user-id. // reinstalls when the OS preserves the data dir.
// - Web / pre-auth: localStorage (legacy dev fallback). // - Web / pre-auth: plain localStorage (legacy fallback).
// //
// Callers don't need to care which one is active — they import a singleton // Callers import `devLocalSecretStore` and call `setSecretStoreUser(userId)`
// and call setSecretStoreUser(userId) once the session is known. Until that // once the session is known. The singleton object's identity is stable so
// happens, calls fall through to localStorage. // existing imports keep working.
const PREFIX = 'chatapp.secret:'; const PREFIX = 'chatapp.secret:';
@@ -38,21 +38,27 @@ export async function setSecretStoreUser(userId: string | null): Promise<void> {
activeUserId = userId; activeUserId = userId;
if (userId && isTauriRuntime()) { if (userId && isTauriRuntime()) {
const stronghold = makeStrongholdStore(userId); const fileStore = makeSecureFileStore(userId);
activeBackend = stronghold;
try { try {
await migrateLocalStorageToStronghold(userId, PREFIX); // 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) { } catch (err: unknown) {
console.warn('stronghold migration failed', err); console.warn('secure file vault init failed — falling back to localStorage', err);
activeBackend = localStore;
} }
} else { } else {
activeBackend = localStore; activeBackend = localStore;
} }
} }
// Singleton with stable identity — internals delegate to whichever backend is
// currently active. Existing call-sites that imported `devLocalSecretStore`
// keep working without changes.
export const devLocalSecretStore: SecretStore = { export const devLocalSecretStore: SecretStore = {
async getSecret(key) { async getSecret(key) {
return activeBackend.getSecret(key); return activeBackend.getSecret(key);
@@ -65,6 +71,6 @@ export const devLocalSecretStore: SecretStore = {
}, },
}; };
export function isStrongholdActive(): boolean { export function isEncryptedVaultActive(): boolean {
return activeBackend !== localStore; return activeBackend !== localStore;
} }
+237
View File
@@ -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);
}
+105 -13
View File
@@ -10,7 +10,7 @@ import {
type MessageWithCipher, type MessageWithCipher,
sendEncryptedMessage, sendEncryptedMessage,
} from '@chat-app/shared/chat'; } from '@chat-app/shared/chat';
import { bytesToPgHex, pgHexToBytes } from '@chat-app/shared/supabase'; import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { devLocalSecretStore } from './secretStore'; import { devLocalSecretStore } from './secretStore';
@@ -44,8 +44,8 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
editedAt: row.edited_at ? String(row.edited_at) : null, editedAt: row.edited_at ? String(row.edited_at) : null,
deletedAt: row.deleted_at ? String(row.deleted_at) : null, deletedAt: row.deleted_at ? String(row.deleted_at) : null,
createdAt: String(row.created_at), createdAt: String(row.created_at),
ciphertext: pgHexToBytes(String(row.ciphertext ?? '\\x')), ciphertext: pgBytesToBytes(String(row.ciphertext ?? '\\x')),
nonce: pgHexToBytes(String(row.nonce ?? '\\x')), nonce: pgBytesToBytes(String(row.nonce ?? '\\x')),
keyVersion: typeof row.key_version === 'number' ? row.key_version : 1, keyVersion: typeof row.key_version === 'number' ? row.key_version : 1,
}; };
} }
@@ -98,26 +98,81 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
} }
}, [conversationId, decryptBatch]); }, [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( const handleInsert = useCallback(
async (row: Record<string, unknown>) => { async (row: Record<string, unknown>) => {
if (!deviceId) return; if (!conversationId || !deviceId) return;
const msg = rowToMessage(row); const id = String(row.id);
let decrypted: DecryptedMessage = { ...msg, plaintext: null };
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++) { 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]); const [d] = await decryptBatch([msg]);
if (d) { if (d) {
decrypted = d; decrypted = d;
if (d.plaintext !== null) break; 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) => { setState((prev) => {
if (prev.messages.some((m) => m.id === decrypted.id)) return prev; if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
return { ...prev, messages: [...prev.messages, decrypted] }; return { ...prev, messages: [...prev.messages, decrypted!] };
}); });
}, },
[deviceId, decryptBatch], [conversationId, deviceId, decryptBatch],
); );
const handleUpdate = useCallback( const handleUpdate = useCallback(
@@ -183,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(); .subscribe();
return () => { return () => {
@@ -219,7 +290,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce)); 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({ const msg = await sendEncryptedMessage({
client: supabase, client: supabase,
conversationId, conversationId,
@@ -230,7 +301,28 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
...(handles.length > 0 ? { attachmentHandles: handles } : {}), ...(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) { for (const h of handles) {
const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x'; const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x';
await insertAttachmentRow(supabase, msg.id, h, blobNonce); await insertAttachmentRow(supabase, msg.id, h, blobNonce);
+5 -1
View File
@@ -86,7 +86,11 @@ export async function completeSessionFromUrl(
} }
export async function signOut(client: AppSupabaseClient): Promise<void> { 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; if (error) throw error;
} }
+17
View File
@@ -23,3 +23,20 @@ export function pgHexToBytes(hex: string): Uint8Array {
} }
return out; 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;
}
+15
View File
@@ -5,6 +5,20 @@ import type { Database, SupabaseConfig } from './types.js';
// Typed client alias used throughout the app. // Typed client alias used throughout the app.
export type AppSupabaseClient = SupabaseClient<Database>; 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 { export function createClient(config: SupabaseConfig): AppSupabaseClient {
return createSupabaseClient<Database>(config.url, config.anonKey, { return createSupabaseClient<Database>(config.url, config.anonKey, {
auth: { auth: {
@@ -12,6 +26,7 @@ export function createClient(config: SupabaseConfig): AppSupabaseClient {
autoRefreshToken: true, autoRefreshToken: true,
persistSession: true, persistSession: true,
detectSessionInUrl: config.detectSessionInUrl ?? false, detectSessionInUrl: config.detectSessionInUrl ?? false,
lock: acquireLock,
}, },
}); });
} }
+10
View File
@@ -65,6 +65,9 @@ importers:
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.10.1 version: 2.10.1
'@tauri-apps/plugin-fs':
specifier: ^2.5.0
version: 2.5.0
'@tauri-apps/plugin-global-shortcut': '@tauri-apps/plugin-global-shortcut':
specifier: ^2.3.1 specifier: ^2.3.1
version: 2.3.1 version: 2.3.1
@@ -1766,6 +1769,9 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
'@tauri-apps/plugin-fs@2.5.0':
resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==}
'@tauri-apps/plugin-global-shortcut@2.3.1': '@tauri-apps/plugin-global-shortcut@2.3.1':
resolution: {integrity: sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==} resolution: {integrity: sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==}
@@ -7401,6 +7407,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-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': '@tauri-apps/plugin-global-shortcut@2.3.1':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1