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:
@@ -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<DeviceRecord | null> {
|
|
||||||
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<DeviceRecord> {
|
|
||||||
const device = await provisionNewDevice({
|
|
||||||
client: supabase,
|
|
||||||
secretStore: devLocalSecretStore,
|
|
||||||
userId: params.userId,
|
|
||||||
name: params.name,
|
|
||||||
platform: detectDesktopPlatform(),
|
|
||||||
});
|
|
||||||
writeLocalDeviceId(params.userId, device.id);
|
|
||||||
return device;
|
|
||||||
}
|
|
||||||
@@ -118,7 +118,7 @@ export type Database = {
|
|||||||
last_seen_at: string
|
last_seen_at: string
|
||||||
name: string
|
name: string
|
||||||
platform: Database["public"]["Enums"]["device_platform"]
|
platform: Database["public"]["Enums"]["device_platform"]
|
||||||
public_key: string
|
public_key: string | null
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
@@ -127,7 +127,7 @@ export type Database = {
|
|||||||
last_seen_at?: string
|
last_seen_at?: string
|
||||||
name: string
|
name: string
|
||||||
platform: Database["public"]["Enums"]["device_platform"]
|
platform: Database["public"]["Enums"]["device_platform"]
|
||||||
public_key: string
|
public_key?: string | null
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
@@ -136,7 +136,7 @@ export type Database = {
|
|||||||
last_seen_at?: string
|
last_seen_at?: string
|
||||||
name?: string
|
name?: string
|
||||||
platform?: Database["public"]["Enums"]["device_platform"]
|
platform?: Database["public"]["Enums"]["device_platform"]
|
||||||
public_key?: string
|
public_key?: string | null
|
||||||
user_id?: string
|
user_id?: string
|
||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { fromBase64, generateX25519KeyPair, toBase64, wipe } from '../crypto/index';
|
import { fromBase64, toBase64 } from '../crypto/index';
|
||||||
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea';
|
|
||||||
import type { AppSupabaseClient } from '../supabase/client';
|
import type { AppSupabaseClient } from '../supabase/client';
|
||||||
import type { DevicePlatform } from '../supabase/types';
|
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 {
|
export interface RegisterDeviceParams {
|
||||||
name: string; // user-facing, e.g. "Dennis Laptop"
|
name: string; // user-facing, e.g. "Dennis Laptop"
|
||||||
platform: DevicePlatform;
|
platform: DevicePlatform;
|
||||||
publicKey: Uint8Array; // X25519 public key, 32 bytes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceRecord {
|
export interface DeviceRecord {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
platform: DevicePlatform;
|
platform: DevicePlatform;
|
||||||
publicKey: Uint8Array;
|
|
||||||
lastSeenAt: string;
|
lastSeenAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,9 +31,8 @@ export async function registerDevice(
|
|||||||
user_id: session.user.id,
|
user_id: session.user.id,
|
||||||
name: params.name,
|
name: params.name,
|
||||||
platform: params.platform,
|
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();
|
.single();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
@@ -41,7 +40,6 @@ export async function registerDevice(
|
|||||||
id: data.id,
|
id: data.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
platform: data.platform,
|
platform: data.platform,
|
||||||
publicKey: pgHexToBytes(data.public_key),
|
|
||||||
lastSeenAt: data.last_seen_at,
|
lastSeenAt: data.last_seen_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -52,7 +50,7 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceR
|
|||||||
|
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from('devices')
|
.from('devices')
|
||||||
.select('id, name, platform, public_key, last_seen_at')
|
.select('id, name, platform, last_seen_at')
|
||||||
.eq('user_id', session.user.id)
|
.eq('user_id', session.user.id)
|
||||||
.order('last_seen_at', { ascending: false });
|
.order('last_seen_at', { ascending: false });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -61,7 +59,6 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceR
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
platform: row.platform,
|
platform: row.platform,
|
||||||
publicKey: pgHexToBytes(row.public_key),
|
|
||||||
lastSeenAt: row.last_seen_at,
|
lastSeenAt: row.last_seen_at,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -77,139 +74,6 @@ export async function touchDeviceLastSeen(
|
|||||||
if (error) throw error;
|
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.
|
// Intentional re-exports so app layers only need @chat-app/shared/auth.
|
||||||
export type { SecretStore } from './secure-storage';
|
export type { SecretStore } from './secure-storage';
|
||||||
export { toBase64 as base64FromBytes, fromBase64 as bytesFromBase64 };
|
export { toBase64 as base64FromBytes, fromBase64 as bytesFromBase64 };
|
||||||
|
|||||||
@@ -77,11 +77,13 @@ export async function listConversationDeviceKeys(
|
|||||||
.in('user_id', memberIds);
|
.in('user_id', memberIds);
|
||||||
if (dErr) throw dErr;
|
if (dErr) throw dErr;
|
||||||
|
|
||||||
return (devices ?? []).map((d) => ({
|
return (devices ?? [])
|
||||||
deviceId: d.id,
|
.filter((d): d is typeof d & { public_key: string } => d.public_key !== null)
|
||||||
userId: d.user_id,
|
.map((d) => ({
|
||||||
publicKey: pgHexToBytes(d.public_key),
|
deviceId: d.id,
|
||||||
}));
|
userId: d.user_id,
|
||||||
|
publicKey: pgHexToBytes(d.public_key),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageParams {
|
export interface SendMessageParams {
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user