refactor(shared): strip cryptographic device provisioning (now telemetry-only)

devices rows no longer carry public_key for crypto purposes. The whole
per-device key API surface (provisionNewDevice, loadDevicePrivateKey,
saveDevicePrivateKey, forgetDevicePrivateKey, restoreDeviceFromServerRecord)
is removed; registerDevice now records {name, platform} only. SQL drops the
NOT NULL on devices.public_key so future telemetry rows can omit it.

Note: SQL not applied locally - push via pnpm prod:migrate when ready.
This commit is contained in:
byGalax
2026-05-15 23:19:26 +02:00
parent 15ef9ece66
commit f7c60945d0
5 changed files with 22 additions and 224 deletions
+3 -3
View File
@@ -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: []
+7 -143
View File
@@ -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<DeviceR
const { data, error } = await client
.from('devices')
.select('id, name, platform, public_key, last_seen_at')
.select('id, name, platform, last_seen_at')
.eq('user_id', session.user.id)
.order('last_seen_at', { ascending: false });
if (error) throw error;
@@ -61,7 +59,6 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceR
id: row.id,
name: row.name,
platform: row.platform,
publicKey: pgHexToBytes(row.public_key),
lastSeenAt: row.last_seen_at,
}));
}
@@ -77,139 +74,6 @@ export async function touchDeviceLastSeen(
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<DeviceRecord> {
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<Uint8Array | null> {
return secretStore.getSecret(privateKeySecretName(userId, deviceId));
}
export async function forgetDevicePrivateKey(
secretStore: SecretStore,
userId: string,
deviceId: string,
): Promise<void> {
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);
}
// 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<DeviceRecord> {
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 };
+7 -5
View File
@@ -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 {