import { fromBase64, generateX25519KeyPair, toBase64, wipe } from '../crypto/index.js'; import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js'; import type { AppSupabaseClient } from '../supabase/client.js'; import type { DevicePlatform } from '../supabase/types.js'; import type { SecretStore } from './secure-storage.js'; 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; } export async function registerDevice( client: AppSupabaseClient, params: RegisterDeviceParams, ): Promise { const { data: session } = await client.auth.getUser(); if (!session.user) throw new Error('not authenticated'); const { data, error } = await client .from('devices') .insert({ 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') .single(); if (error) throw error; return { id: data.id, name: data.name, platform: data.platform, publicKey: pgHexToBytes(data.public_key), lastSeenAt: data.last_seen_at, }; } export async function listOwnDevices(client: AppSupabaseClient): Promise { const { data: session } = await client.auth.getUser(); if (!session.user) throw new Error('not authenticated'); const { data, error } = await client .from('devices') .select('id, name, platform, public_key, last_seen_at') .eq('user_id', session.user.id) .order('last_seen_at', { ascending: false }); if (error) throw error; return data.map((row) => ({ id: row.id, name: row.name, platform: row.platform, publicKey: pgHexToBytes(row.public_key), lastSeenAt: row.last_seen_at, })); } export async function touchDeviceLastSeen( client: AppSupabaseClient, deviceId: string, ): Promise { const { error } = await client .from('devices') .update({ last_seen_at: new Date().toISOString() }) .eq('id', deviceId); if (error) throw error; } // --------------------------------------------------------------------------- // End-to-end device provisioning flow. // --------------------------------------------------------------------------- export interface ProvisionDeviceParams { client: AppSupabaseClient; secretStore: SecretStore; userId: string; name: string; platform: DevicePlatform; } export interface ProvisionResult { device: DeviceRecord; created: boolean; } function privateKeySecretName(userId: string, deviceId: string): string { return `chatapp.priv.${userId}.${deviceId}`; } // Creates a brand-new device: generates an X25519 keypair, registers the public // half with Supabase, stores the private half in the platform secret store. export async function provisionNewDevice({ client, secretStore, userId, name, platform, }: ProvisionDeviceParams): 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); } // 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.js'; export { toBase64 as base64FromBytes, fromBase64 as bytesFromBase64 };