Files
ChatApp/apps/desktop/src/lib/deviceApproval.ts
T
byGalax f1c7501807 fix(crypto): suppress approval banner for devices with existing wraps
After 0.16.2 some users saw the approval banner stack up to 6+ entries
on first launch — every old device they ever registered (Tauri-era,
test installs, dev builds) showed up because the only "already legit"
filter was `created_at <= ownDevice.created_at`. That fails when own
device is restored from Backup (older than every other entry) or when
the user accumulated installs around the migration window.

Add a semantic check: if a device already has at least one row in
`conversation_keys` (recipient_device_id), it has been wrapped before
and is by definition not awaiting approval. Treat as approved silently.
Bulk query against the candidate IDs, no N+1.

Plus UX: when more than one request is pending, render a sticky header
with a count and "Alle ablehnen" button so users with stale piles can
clear them in one click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:24:17 +02:00

323 lines
10 KiB
TypeScript

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[];
// Pre-filter: anything already in approved/dismissed sets, plus own
// device, never surfaces.
const candidates = rows.filter((r) => shouldSurface(r.id, ctx.ownDeviceId));
if (candidates.length === 0) return;
// Semantic skip: any candidate that already has a `conversation_keys`
// wrap somewhere is by definition legit — it was either auto-shared
// back when that path was enabled (pre-0.16.2) or explicitly approved
// earlier. Surfacing it now would just nag the user about something
// already taken care of. Bulk query against `conversation_keys` for
// all candidate device IDs at once; cheap, avoids N+1.
const candidateIds = candidates.map((c) => c.id);
const wrappedIds = new Set<string>();
try {
const { data: keyRows, error: kErr } = await (
supabase as unknown as {
from: (t: string) => {
select: (cols: string) => {
in: (
col: string,
vals: string[],
) => Promise<{ data: { recipient_device_id: string }[] | null; error: unknown }>;
};
};
}
)
.from('conversation_keys')
.select('recipient_device_id')
.in('recipient_device_id', candidateIds);
if (!kErr && keyRows) {
for (const r of keyRows) wrappedIds.add(r.recipient_device_id);
}
} catch (err) {
console.warn('deviceApproval: conv-key existence probe failed', err);
}
// Defense in depth: also keep the created_at guard. Devices older than
// own can't reasonably need approval — when own came online a fanout
// pass already covered them. Helps when the conv_keys probe returns
// partial data due to RLS.
const own = rows.find((r) => r.id === ctx.ownDeviceId);
const ownCreatedAt = own ? Date.parse(own.created_at) : Number.NEGATIVE_INFINITY;
for (const row of candidates) {
if (wrappedIds.has(row.id)) {
persistApproved(row.id);
continue;
}
const rowCreatedAt = Date.parse(row.created_at);
if (Number.isFinite(rowCreatedAt) && rowCreatedAt <= ownCreatedAt) {
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);
}
// Bulk-deny: persists every currently-pending deviceId into the dismissed
// set and clears the in-memory list in one notify. Useful when a user has
// accumulated stale entries from old test devices / migrations.
export function denyAllPending(): void {
if (pending.length === 0) return;
const dismissed = readIdSet(DISMISSED_KEY);
for (const p of pending) dismissed.add(p.deviceId);
writeIdSet(DISMISSED_KEY, dismissed);
pending = [];
notify();
}