Compare commits

...

9 Commits

Author SHA1 Message Date
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
byGalax 389f00e85c build: enable devtools in release for easier debugging
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:00:33 +02:00
byGalax 75618637e2 feat(crypto): sender-key per-conversation multi-device E2EE
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 19:27:24 +02:00
byGalax e57f81c9c3 ci: trim matrix to macos-14 universal + windows
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 18:55:06 +02:00
byGalax 0a5811cc68 feat(ui): netralax wordmark lockup in sidebar 2026-04-19 18:46:14 +02:00
byGalax 0d94b684bf feat(crypto): stronghold persistence + passphrase device-key backup/restore
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
2026-04-19 18:41:35 +02:00
byGalax 3c2579b3ed fix(updater): mount UpdateToast outside auth gates
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
2026-04-19 14:55:06 +02:00
byGalax c413ee1cf6 feat: netralax split-hex logo + app icons
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
2026-04-19 14:40:14 +02:00
77 changed files with 1258 additions and 185 deletions
+3 -13
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDg0Q0Y0N0I1Q0U2MjBEMzcKUldRM0RXTE90VWZQaERVWnBGNTVKUVZ2MWZyRktaaDFJaXVWc3NGNUZVb08yVHNxaVp2c2dOL2oK
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ5N0U0RDcxOTU2OEQ0QUUKUldTdTFHaVZjVTErU1ZuMk1lWXBUbEcyS1RHYzJQN3k4VDdiUGRvRnVJYVJKR3BxWG1xcENpdlYK
+1 -1
View File
@@ -14,7 +14,7 @@ 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"
@@ -15,6 +15,14 @@
"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"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="cp02">
<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 clip-path="url(#cp02)">
<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" stroke-width="2" fill="none"/>
</g>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp",
"version": "0.1.0",
"version": "0.3.3",
"identifier": "com.meinname.chatapp",
"build": {
"beforeDevCommand": "pnpm vite:dev",
+2
View File
@@ -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>
+8 -2
View File
@@ -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);
+4 -7
View File
@@ -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">
+80 -14
View File
@@ -340,28 +340,94 @@ export function LogoMark(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
viewBox="0 0 64 64"
fill="none"
aria-hidden="true"
{...props}
>
<defs>
<linearGradient id="logo-grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#818CF8" />
<stop offset="1" stopColor="#4F46E5" />
</linearGradient>
<clipPath id="logo-hex-clip">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
</clipPath>
</defs>
<path
d="M6 9a5 5 0 0 1 5-5h10a5 5 0 0 1 5 5v8a5 5 0 0 1-5 5h-5.5L9 27v-5H11a5 5 0 0 1-5-5V9Z"
fill="url(#logo-grad)"
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="#2e1065"
/>
<path
d="M12 14h8M12 10h8"
stroke="#0A0A0F"
strokeWidth="1.75"
strokeLinecap="round"
strokeOpacity="0.5"
<g clipPath="url(#logo-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"
/>
</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>
);
}
+2
View File
@@ -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 {
@@ -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;
+108
View File
@@ -0,0 +1,108 @@
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 INSERTs and, whenever a peer registers a
// new device that's in any of our conversations, wraps the active
// conversation key for the freshly-arrived device. This makes Sender-Key
// 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
// at the moment the new device joins, the new device stays unable to decrypt
// until SOMEONE comes online and runs this loop. Standard Signal trade-off.
export function startConversationKeySync(
ownUserId: string,
ownDeviceId: string,
): () => void {
let cancelled = false;
let priv: Uint8Array | null = null;
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then((pk) => {
priv = pk;
});
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;
// 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;
void wrapKeysForNewDevice(ownUserId, ownDeviceId, row.id, row.user_id, row.public_key);
},
)
.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 () => {
cancelled = true;
void supabase.removeChannel(channel);
};
}
+93
View File
@@ -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);
}
}
+62 -4
View File
@@ -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 { makeStrongholdStore, migrateLocalStorageToStronghold } from './strongholdStore';
// Secret store with two backends:
// - Tauri: Stronghold-encrypted vault file in appLocalDataDir. Survives app
// reinstalls and is encrypted at rest with a password derived from the
// authenticated user-id.
// - Web / pre-auth: localStorage (legacy dev fallback).
//
// Callers don't need to care which one is active — they import a singleton
// and call setSecretStoreUser(userId) once the session is known. Until that
// happens, calls fall through to localStorage.
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,52 @@ 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 stronghold = makeStrongholdStore(userId);
try {
// Force a tiny round-trip to verify Stronghold can actually open the
// vault on this machine. If not (broken vault file, bundled rust crate
// mismatch, etc.) we fall back to localStorage so the rest of the app
// remains usable instead of bricking device registration.
await stronghold.getSecret('__probe');
activeBackend = stronghold;
try {
await migrateLocalStorageToStronghold(userId, PREFIX);
} catch (err: unknown) {
console.warn('stronghold migration failed', err);
}
} catch (err: unknown) {
console.warn('stronghold init failed — falling back to localStorage', err);
activeBackend = localStore;
}
} else {
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 = {
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 isStrongholdActive(): boolean {
return activeBackend !== localStore;
}
+106
View File
@@ -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, pgHexToBytes } 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: pgHexToBytes(String(row.ciphertext ?? '\\x')),
nonce: pgHexToBytes(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,
});
},
+158
View File
@@ -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());
+11
View File
@@ -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';
+277
View File
@@ -0,0 +1,277 @@
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').insert(rows);
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').insert({
conversation_id: conversationId,
recipient_device_id: recipientDeviceId,
key_version: version,
sender_device_id: own.deviceId,
encrypted_key: bytesToPgHex(wrapped.ciphertext),
nonce: bytesToPgHex(wrapped.nonce),
});
if (error && !String(error.message ?? '').includes('duplicate')) throw error;
}
// Re-exports for convenience.
export { decryptWithConvKey, encryptWithConvKey };
+1
View File
@@ -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';
+93 -125
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export * from './backend.js';
export * from './box.js';
export * from './keys.js';
export * from './sessionKeys.js';
+58
View File
@@ -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);
}
@@ -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;