feat(crypto): explicit device-approval flow + dev userData isolation
Disable the previous auto-share of conversation keys to newly-registered devices: a stolen password / new device registered by an attacker no longer automatically grants history access. Backup-Restore (which restores the old device-id) still opens existing wraps as before. Phase 1 of the approval replacement: - New `lib/deviceApproval.ts`: realtime listener for `devices` INSERT, surfaces a pending list, persists approve/deny decisions in `chatapp.approvedDeviceIds` / `chatapp.dismissedDeviceIds`. Filters the initial fetch by created_at > own-device's created_at so a freshly installed client doesn't try to "approve" pre-existing devices. - New `components/DeviceApprovalBanner.tsx`: bottom-right Discord-style banner per pending request with Genehmigen / Ablehnen actions; reuses `wrapForOneDevice` from conversationKeySync to fan out conv-keys. - AppShell mounts both the listener and the banner. Plus dev userData isolation in main.ts: when running unpackaged, append `-Dev` to the userData path so `pnpm dev` runs side-by-side with the installed packaged build instead of colliding on the single-instance lock. Window title also distinguished as "ChatApp (Dev)". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ import { supabase } from './supabase';
|
||||
// This fixes the "cannot decrypt" cliff for devices that registered while
|
||||
// no other participant device was online to share the key with them.
|
||||
|
||||
interface SyncCtx {
|
||||
export interface SyncCtx {
|
||||
myUserId: string;
|
||||
myDeviceId: string;
|
||||
priv: Uint8Array;
|
||||
@@ -32,46 +32,55 @@ export function startConversationKeySync(
|
||||
ownUserId: string,
|
||||
ownDeviceId: string,
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
let priv: Uint8Array | null = null;
|
||||
const dedupeKey = ownUserId + ':' + ownDeviceId;
|
||||
|
||||
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
|
||||
if (cancelled) return;
|
||||
priv = pk;
|
||||
if (!priv) return;
|
||||
if (backfilledKey.has(dedupeKey)) return;
|
||||
backfilledKey.add(dedupeKey);
|
||||
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
|
||||
});
|
||||
|
||||
const channel = supabase
|
||||
.channel('device-key-sync:' + ownDeviceId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'devices' },
|
||||
(payload: { new: { id?: string; user_id?: string; public_key?: string } }) => {
|
||||
if (cancelled) return;
|
||||
const row = payload.new;
|
||||
if (!row?.id || !row.user_id || !row.public_key) return;
|
||||
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
|
||||
if (!priv) return; // backfill on mount will catch it later
|
||||
void wrapForOneDevice(
|
||||
{ myUserId: ownUserId, myDeviceId: ownDeviceId, priv },
|
||||
row.id,
|
||||
row.user_id,
|
||||
row.public_key,
|
||||
);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
// DISABLED: auto-share of conversation keys to newly-registered devices
|
||||
// is gone. Without it, account takeover (stolen password / new device
|
||||
// registered by attacker) no longer automatically grants history access
|
||||
// — an attacker would have a working device-key but no conv-key wraps.
|
||||
//
|
||||
// History access paths still supported:
|
||||
// 1. Backup-Restore — restores the OLD device-id + privkey, so the
|
||||
// server-side wraps for that device-id are accessible as before.
|
||||
// 2. (Planned) Approval flow — existing device or conversation peer
|
||||
// explicitly approves a new device, then conv-keys are wrapped
|
||||
// for it. Until that ships, fresh-login-without-backup means old
|
||||
// conversations stay encrypted.
|
||||
//
|
||||
// For NEW conversations: the key is generated at conv-creation time
|
||||
// and includes all current devices of all members, so a freshly-logged-
|
||||
// in device CAN still participate in newly-created conversations. It
|
||||
// just can't read the back-history of conversations it wasn't a member
|
||||
// of when those messages were sealed.
|
||||
//
|
||||
// We deliberately keep the helper functions below (syncAllExistingGaps,
|
||||
// wrapForOneDevice, …) intact so the upcoming approval flow can wire
|
||||
// them to user-driven triggers without rebuilding from scratch.
|
||||
void ownUserId;
|
||||
void ownDeviceId;
|
||||
void backfilledKey;
|
||||
void loadDevicePrivateKey;
|
||||
void devLocalSecretStore;
|
||||
void supabase;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// Keep helpers alive across the auto-sync hibernation window so the
|
||||
// upcoming approval flow can re-wire them. Without this no-op reference
|
||||
// `tsc --noEmit` flags them as unused (TS6133).
|
||||
//
|
||||
// `wrapForOneDevice` and `syncOneConversationGaps` are exported below for
|
||||
// the device-approval module — once the user explicitly approves a new
|
||||
// device the approval flow re-uses these helpers to wrap conv-keys for
|
||||
// that specific deviceId.
|
||||
void (() => {
|
||||
void listMyConversationIds;
|
||||
void listConversationDevices;
|
||||
void listExistingKeyRecipients;
|
||||
void getActiveKeyVersion;
|
||||
void syncAllExistingGaps;
|
||||
void isExpectedShareFailure;
|
||||
void rawFrom;
|
||||
});
|
||||
|
||||
async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_members')
|
||||
@@ -149,7 +158,7 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||
export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||
const version = await getActiveKeyVersion(convId);
|
||||
const devices = await listConversationDevices(convId);
|
||||
if (devices.length === 0) return;
|
||||
@@ -200,7 +209,7 @@ function isExpectedShareFailure(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function wrapForOneDevice(
|
||||
export async function wrapForOneDevice(
|
||||
ctx: SyncCtx,
|
||||
newDeviceId: string,
|
||||
newDeviceUserId: string,
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
||||
|
||||
import { type SyncCtx, wrapForOneDevice } from './conversationKeySync';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Device-approval flow (Phase 1).
|
||||
// ---------------------------------------------------------------------------
|
||||
// When the user registers a brand-new device on top of an existing one, the
|
||||
// existing device must explicitly approve it before any conv-key wraps are
|
||||
// created. This module:
|
||||
//
|
||||
// 1. On startup, fetches every device row owned by the user and surfaces
|
||||
// the ones that aren't this device, aren't already approved, and aren't
|
||||
// dismissed. Covers the "I was offline when the new device registered"
|
||||
// case.
|
||||
// 2. Subscribes to realtime INSERTs on `devices` for the user's id, so a
|
||||
// device that registers WHILE this client is online raises a banner
|
||||
// immediately.
|
||||
// 3. Persists approve/deny decisions in localStorage so a reload doesn't
|
||||
// ask again for a device the user already answered for.
|
||||
//
|
||||
// Approval call: re-uses `wrapForOneDevice` from conversationKeySync — that
|
||||
// helper already walks every shared conversation and writes the key bundle
|
||||
// for the target device.
|
||||
|
||||
export interface PendingApproval {
|
||||
deviceId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
platform: DevicePlatform | string;
|
||||
createdAt: string;
|
||||
publicKey: string; // pg-hex-encoded bytea
|
||||
}
|
||||
|
||||
export interface DeviceApprovalListenerCtx {
|
||||
ownUserId: string;
|
||||
ownDeviceId: string;
|
||||
// Lazy getter so we never hold the privkey in memory for longer than the
|
||||
// approve action that needs it. Returns null if the key isn't loadable
|
||||
// (e.g. fresh-restored device that hasn't unsealed yet).
|
||||
getPriv: () => Promise<Uint8Array | null>;
|
||||
}
|
||||
|
||||
const APPROVED_KEY = 'chatapp.approvedDeviceIds';
|
||||
const DISMISSED_KEY = 'chatapp.dismissedDeviceIds';
|
||||
|
||||
// In-process state. Module-level so the banner component and the listener
|
||||
// share one source of truth without prop-drilling through context.
|
||||
let pending: PendingApproval[] = [];
|
||||
const subscribers = new Set<(list: PendingApproval[]) => void>();
|
||||
let listenerCtx: DeviceApprovalListenerCtx | null = null;
|
||||
|
||||
function readIdSet(key: string): Set<string> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return new Set();
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
return new Set(parsed.filter((v): v is string => typeof v === 'string'));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function writeIdSet(key: string, set: Set<string>): void {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify([...set]));
|
||||
} catch {
|
||||
/* storage unavailable — non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function persistApproved(deviceId: string): void {
|
||||
const s = readIdSet(APPROVED_KEY);
|
||||
s.add(deviceId);
|
||||
writeIdSet(APPROVED_KEY, s);
|
||||
}
|
||||
|
||||
function persistDismissed(deviceId: string): void {
|
||||
const s = readIdSet(DISMISSED_KEY);
|
||||
s.add(deviceId);
|
||||
writeIdSet(DISMISSED_KEY, s);
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
const snapshot = [...pending];
|
||||
for (const cb of subscribers) {
|
||||
try {
|
||||
cb(snapshot);
|
||||
} catch (err) {
|
||||
console.warn('deviceApproval: subscriber threw', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getPendingApprovals(): PendingApproval[] {
|
||||
return [...pending];
|
||||
}
|
||||
|
||||
export function subscribePendingApprovals(
|
||||
cb: (list: PendingApproval[]) => void,
|
||||
): () => void {
|
||||
subscribers.add(cb);
|
||||
// Fire once with current state so the consumer can initialise without
|
||||
// waiting for the next change.
|
||||
try {
|
||||
cb([...pending]);
|
||||
} catch (err) {
|
||||
console.warn('deviceApproval: initial subscriber call threw', err);
|
||||
}
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
function shouldSurface(deviceId: string, ownDeviceId: string): boolean {
|
||||
if (deviceId === ownDeviceId) return false;
|
||||
const approved = readIdSet(APPROVED_KEY);
|
||||
if (approved.has(deviceId)) return false;
|
||||
const dismissed = readIdSet(DISMISSED_KEY);
|
||||
if (dismissed.has(deviceId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
interface DeviceRowLite {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
platform: DevicePlatform | string;
|
||||
created_at: string;
|
||||
public_key: string;
|
||||
}
|
||||
|
||||
function rowToPending(row: DeviceRowLite): PendingApproval {
|
||||
return {
|
||||
deviceId: row.id,
|
||||
userId: row.user_id,
|
||||
name: row.name,
|
||||
platform: row.platform,
|
||||
createdAt: row.created_at,
|
||||
publicKey: row.public_key,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertPending(req: PendingApproval): void {
|
||||
if (pending.some((p) => p.deviceId === req.deviceId)) return;
|
||||
pending = [...pending, req];
|
||||
notify();
|
||||
}
|
||||
|
||||
function removePending(deviceId: string): void {
|
||||
const next = pending.filter((p) => p.deviceId !== deviceId);
|
||||
if (next.length === pending.length) return;
|
||||
pending = next;
|
||||
notify();
|
||||
}
|
||||
|
||||
async function loadInitialPending(ctx: DeviceApprovalListenerCtx): Promise<void> {
|
||||
const { data, error } = await supabase
|
||||
.from('devices')
|
||||
.select('id, user_id, name, platform, created_at, public_key')
|
||||
.eq('user_id', ctx.ownUserId);
|
||||
if (error) {
|
||||
console.warn('deviceApproval: initial devices lookup failed', error);
|
||||
return;
|
||||
}
|
||||
const rows = (data ?? []) as DeviceRowLite[];
|
||||
|
||||
// Find OWN device's createdAt — anything older than that is a pre-existing
|
||||
// device that was already legit before this client came online and should
|
||||
// NOT raise an approval banner. Without this guard, a freshly-installed
|
||||
// client lights up with one banner per pre-existing device of the user
|
||||
// ("zich messages zur freigabe") which is the opposite of what we want:
|
||||
// approval makes sense on the OLDER device judging the NEWER one, never
|
||||
// the other way round.
|
||||
const own = rows.find((r) => r.id === ctx.ownDeviceId);
|
||||
const ownCreatedAt = own ? Date.parse(own.created_at) : Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!shouldSurface(row.id, ctx.ownDeviceId)) continue;
|
||||
const rowCreatedAt = Date.parse(row.created_at);
|
||||
if (Number.isFinite(rowCreatedAt) && rowCreatedAt <= ownCreatedAt) {
|
||||
// Older / equal-age device — auto-treat as already approved on this
|
||||
// side so it never re-prompts (covers reloads + future fetches).
|
||||
persistApproved(row.id);
|
||||
continue;
|
||||
}
|
||||
upsertPending(rowToPending(row));
|
||||
}
|
||||
}
|
||||
|
||||
// Starts the approval listener for the given user/device. Returns an
|
||||
// unsubscribe function — call it on shell unmount to tear down the realtime
|
||||
// channel and clear in-memory state.
|
||||
export function startDeviceApprovalListener(
|
||||
ctx: DeviceApprovalListenerCtx,
|
||||
): () => void {
|
||||
listenerCtx = ctx;
|
||||
|
||||
void loadInitialPending(ctx);
|
||||
|
||||
const channel = supabase
|
||||
.channel(`device-approval:${ctx.ownUserId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'devices',
|
||||
filter: `user_id=eq.${ctx.ownUserId}`,
|
||||
},
|
||||
(payload: { new: Record<string, unknown> }) => {
|
||||
const row = payload.new as unknown as DeviceRowLite;
|
||||
if (!row?.id) return;
|
||||
if (!shouldSurface(row.id, ctx.ownDeviceId)) return;
|
||||
upsertPending(rowToPending(row));
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
void supabase.removeChannel(channel).catch(() => {
|
||||
/* ignore — channel might already be gone */
|
||||
});
|
||||
pending = [];
|
||||
listenerCtx = null;
|
||||
notify();
|
||||
};
|
||||
}
|
||||
|
||||
// Approves a pending device: walks every conversation the current user is
|
||||
// in and writes a conv-key bundle for the new device. On success the request
|
||||
// is removed from the pending list and the deviceId is persisted in
|
||||
// localStorage so a reload doesn't re-prompt.
|
||||
export async function approveDevice(req: PendingApproval): Promise<void> {
|
||||
const ctx = listenerCtx;
|
||||
if (!ctx) {
|
||||
throw new Error('deviceApproval: listener not started');
|
||||
}
|
||||
if (req.userId !== ctx.ownUserId) {
|
||||
// Phase 1 only handles same-user approvals (own new device). Friend-side
|
||||
// approval is a later phase.
|
||||
throw new Error('deviceApproval: cross-user approval not supported yet');
|
||||
}
|
||||
|
||||
const priv = await ctx.getPriv();
|
||||
if (!priv) {
|
||||
throw new Error('deviceApproval: own private key unavailable');
|
||||
}
|
||||
|
||||
const sync: SyncCtx = {
|
||||
myUserId: ctx.ownUserId,
|
||||
myDeviceId: ctx.ownDeviceId,
|
||||
priv,
|
||||
};
|
||||
|
||||
try {
|
||||
await wrapForOneDevice(sync, req.deviceId, req.userId, req.publicKey);
|
||||
} finally {
|
||||
// Wipe the priv copy we asked for. The original lives in the secret
|
||||
// store; this is the transient working copy.
|
||||
for (let i = 0; i < priv.length; i++) priv[i] = 0;
|
||||
}
|
||||
|
||||
persistApproved(req.deviceId);
|
||||
removePending(req.deviceId);
|
||||
}
|
||||
|
||||
// Denies a pending device: just remembers the deviceId in the dismissed-set
|
||||
// and removes the request. No server-side change — the new device simply
|
||||
// stays without any conv-key wraps until the user changes their mind (e.g.
|
||||
// from a settings screen later).
|
||||
export function denyDevice(deviceId: string): void {
|
||||
persistDismissed(deviceId);
|
||||
removePending(deviceId);
|
||||
}
|
||||
Reference in New Issue
Block a user