diff --git a/apps/desktop/src/lib/device.ts b/apps/desktop/src/lib/device.ts deleted file mode 100644 index 8f46f1b..0000000 --- a/apps/desktop/src/lib/device.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - deviceIdStorageKey, - listOwnDevices, - loadDevicePrivateKey, - provisionNewDevice, - touchDeviceLastSeen, - type DeviceRecord, -} from '@chat-app/shared/auth'; -import type { DevicePlatform } from '@chat-app/shared/supabase'; - -import { devLocalSecretStore } from './secretStore'; -import { supabase } from './supabase'; - -export function detectDesktopPlatform(): DevicePlatform { - const ua = - typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string' - ? navigator.userAgent.toLowerCase() - : ''; - if (ua.includes('mac')) return 'macos'; - if (ua.includes('win')) return 'windows'; - return 'linux'; -} - -export function readLocalDeviceId(userId: string): string | null { - return window.localStorage.getItem(deviceIdStorageKey(userId)); -} - -export function writeLocalDeviceId(userId: string, deviceId: string): void { - window.localStorage.setItem(deviceIdStorageKey(userId), deviceId); -} - -export function clearLocalDeviceId(userId: string): void { - window.localStorage.removeItem(deviceIdStorageKey(userId)); -} - -// Look up the current install's device record. Returns null when either: -// - no device id is cached locally, or -// - the cached id was deleted server-side (e.g. wiped from Studio). -// In both cases the UI should prompt the user to register a fresh device. -export async function findExistingDevice(userId: string): Promise { - const cachedId = readLocalDeviceId(userId); - if (!cachedId) return null; - - const all = await listOwnDevices(supabase); - const hit = all.find((d) => d.id === cachedId) ?? null; - if (!hit) return null; - - const priv = await loadDevicePrivateKey(devLocalSecretStore, userId, hit.id); - if (!priv) { - // Server row exists but we lost the private key locally — treat as fresh install. - return null; - } - - void touchDeviceLastSeen(supabase, hit.id).catch(() => { - /* non-fatal */ - }); - return hit; -} - -export async function registerCurrentDevice(params: { - userId: string; - name: string; -}): Promise { - const device = await provisionNewDevice({ - client: supabase, - secretStore: devLocalSecretStore, - userId: params.userId, - name: params.name, - platform: detectDesktopPlatform(), - }); - writeLocalDeviceId(params.userId, device.id); - return device; -} diff --git a/packages/db-types/src/index.ts b/packages/db-types/src/index.ts index 778295a..b6b8922 100644 --- a/packages/db-types/src/index.ts +++ b/packages/db-types/src/index.ts @@ -118,7 +118,7 @@ export type Database = { last_seen_at: string name: string platform: Database["public"]["Enums"]["device_platform"] - public_key: string + public_key: string | null user_id: string } Insert: { @@ -127,7 +127,7 @@ export type Database = { last_seen_at?: string name: string platform: Database["public"]["Enums"]["device_platform"] - public_key: string + public_key?: string | null user_id: string } Update: { @@ -136,7 +136,7 @@ export type Database = { last_seen_at?: string name?: string platform?: Database["public"]["Enums"]["device_platform"] - public_key?: string + public_key?: string | null user_id?: string } Relationships: [] diff --git a/packages/shared/src/auth/device.ts b/packages/shared/src/auth/device.ts index dc6ca3e..6e9f389 100644 --- a/packages/shared/src/auth/device.ts +++ b/packages/shared/src/auth/device.ts @@ -1,20 +1,20 @@ -import { fromBase64, generateX25519KeyPair, toBase64, wipe } from '../crypto/index'; -import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea'; +import { fromBase64, toBase64 } from '../crypto/index'; import type { AppSupabaseClient } from '../supabase/client'; import type { DevicePlatform } from '../supabase/types'; -import type { SecretStore } from './secure-storage'; + +// After the per-user encryption refactor, devices rows are pure telemetry: +// they record which physical/browser installs are signed into the account so +// the user can audit them, but they no longer carry cryptographic identity. export interface RegisterDeviceParams { name: string; // user-facing, e.g. "Dennis Laptop" platform: DevicePlatform; - publicKey: Uint8Array; // X25519 public key, 32 bytes } export interface DeviceRecord { id: string; name: string; platform: DevicePlatform; - publicKey: Uint8Array; lastSeenAt: string; } @@ -31,9 +31,8 @@ export async function registerDevice( user_id: session.user.id, name: params.name, platform: params.platform, - public_key: bytesToPgHex(params.publicKey), }) - .select('id, name, platform, public_key, last_seen_at') + .select('id, name, platform, last_seen_at') .single(); if (error) throw error; @@ -41,7 +40,6 @@ export async function registerDevice( id: data.id, name: data.name, platform: data.platform, - publicKey: pgHexToBytes(data.public_key), lastSeenAt: data.last_seen_at, }; } @@ -52,7 +50,7 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise { - const kp = await generateX25519KeyPair(); - - const device = await registerDevice(client, { - name, - platform, - publicKey: kp.publicKey, - }); - - try { - await secretStore.setSecret(privateKeySecretName(userId, device.id), kp.privateKey); - } finally { - wipe(kp.privateKey); - } - return device; -} - -// Loads the private key for `deviceId` from the secret store. Returns null if -// this install has never stored one. -export async function loadDevicePrivateKey( - secretStore: SecretStore, - userId: string, - deviceId: string, -): Promise { - return secretStore.getSecret(privateKeySecretName(userId, deviceId)); -} - -export async function forgetDevicePrivateKey( - secretStore: SecretStore, - userId: string, - deviceId: string, -): Promise { - 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 { - await secretStore.setSecret(privateKeySecretName(userId, deviceId), privateKey); -} - -// Restores a device record from a backup by re-seeding the local private key -// store for an EXISTING server-side device row. Does NOT insert a new row — -// the original row is kept intact so conversation-key bundles stay valid. -// Throws when the server-side device was removed (the backup is then unusable; -// user must provision a fresh device and get conv-keys shared from another -// live device). -export async function restoreDeviceFromServerRecord(params: { - client: AppSupabaseClient; - secretStore: SecretStore; - userId: string; - deviceId: string; - privateKey: Uint8Array; -}): Promise { - const { data: session } = await params.client.auth.getUser(); - if (!session.user) throw new Error('not authenticated'); - if (session.user.id !== params.userId) { - throw new Error( - 'Backup is for a different account — sign in as the owner before restoring.', - ); - } - - const { data: row, error } = await params.client - .from('devices') - .select('id, name, platform, public_key, last_seen_at') - .eq('id', params.deviceId) - .eq('user_id', params.userId) - .maybeSingle(); - if (error) throw error; - if (!row) { - throw new Error( - 'Device record not found on server — it was removed. Backup is no longer valid; register a new device instead.', - ); - } - - await saveDevicePrivateKey( - params.secretStore, - params.userId, - params.deviceId, - params.privateKey, - ); - - return { - id: row.id, - name: row.name, - platform: row.platform, - publicKey: pgHexToBytes(row.public_key), - lastSeenAt: row.last_seen_at, - }; -} - -// 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'; - -export function deviceIdStorageKey(userId: string): string { - return `${DEVICE_ID_STORAGE_KEY_PREFIX}.${userId}`; -} - // Intentional re-exports so app layers only need @chat-app/shared/auth. export type { SecretStore } from './secure-storage'; export { toBase64 as base64FromBytes, fromBase64 as bytesFromBase64 }; diff --git a/packages/shared/src/chat/messages.ts b/packages/shared/src/chat/messages.ts index 850d6cc..df2e295 100644 --- a/packages/shared/src/chat/messages.ts +++ b/packages/shared/src/chat/messages.ts @@ -77,11 +77,13 @@ export async function listConversationDeviceKeys( .in('user_id', memberIds); if (dErr) throw dErr; - return (devices ?? []).map((d) => ({ - deviceId: d.id, - userId: d.user_id, - publicKey: pgHexToBytes(d.public_key), - })); + return (devices ?? []) + .filter((d): d is typeof d & { public_key: string } => d.public_key !== null) + .map((d) => ({ + deviceId: d.id, + userId: d.user_id, + publicKey: pgHexToBytes(d.public_key), + })); } export interface SendMessageParams { diff --git a/supabase/migrations/20260515000004_devices_public_key_optional.sql b/supabase/migrations/20260515000004_devices_public_key_optional.sql new file mode 100644 index 0000000..c38cce0 --- /dev/null +++ b/supabase/migrations/20260515000004_devices_public_key_optional.sql @@ -0,0 +1,5 @@ +-- After the per-user encryption refactor, devices rows no longer carry +-- cryptographic identity. Existing rows stay untouched; new telemetry rows +-- can omit the column. + +alter table public.devices alter column public_key drop not null;