0d94b684bf
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
166 lines
4.8 KiB
TypeScript
166 lines
4.8 KiB
TypeScript
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<DeviceRecord> {
|
|
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<DeviceRecord[]> {
|
|
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<void> {
|
|
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<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);
|
|
}
|
|
|
|
// 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 };
|