sync-identity.ts

3.4 KB · raw

1// Sync identity — long-term X25519 keypair tied to the vault, not the2// device installation.3//4//   sync_identity_keypair = X25519-keygen(HKDF(master_key,5//                              salt="peach-sync-identity-v1",6//                              info=device_id))7//8//   vault_id = first 8 bytes of BLAKE3(master_key || "peach-vault-id"), hex9//10// Determinism is the point: losing and reinstalling Peach against the same11// vault recovers the same sync identity, so paired peers do not need to12// re-pair after a reinstall. The master_key must be the same byte buffer13// that the vault layer holds in memory after unlock — not the user-typed14// password, not the password-derived key.15//16// device_id is a per-installation UUID v4. Two devices that share the17// same vault always derive distinct identities; the device_id is the18// salt that breaks the symmetry.1920import { HKDF } from "@stablelib/hkdf";21import { SHA256 } from "@stablelib/sha256";22import { generateKeyPairFromSeed } from "@stablelib/x25519";23import { blake3 } from "hash-wasm";2425const SYNC_IDENTITY_SALT = new TextEncoder().encode("peach-sync-identity-v1");26const VAULT_ID_INFO = new TextEncoder().encode("peach-vault-id");27const VAULT_ID_HEX_LENGTH = 16; // 8 bytes → 16 hex chars2829export interface SyncIdentityKeypair {30  publicKey: Uint8Array; // 32 bytes31  secretKey: Uint8Array; // 32 bytes — sensitive; treat as key material32}3334/**35 * Derive the deterministic sync identity keypair for a vault on a given36 * installation. Always returns the same keypair for the same37 * (masterKey, deviceId) pair across runs and reinstalls.38 */39export function deriveSyncIdentityKeypair(40  masterKey: Uint8Array,41  deviceId: string,42): SyncIdentityKeypair {43  if (masterKey.length === 0) throw new Error("masterKey must not be empty");44  if (!deviceId) throw new Error("deviceId must not be empty");45  const info = new TextEncoder().encode(deviceId);46  // HKDF-SHA256(salt="peach-sync-identity-v1", ikm=master_key, info=device_id)47  // → 32 bytes used directly as the X25519 secret key seed.48  const seed = new HKDF(SHA256, masterKey, SYNC_IDENTITY_SALT, info).expand(32);49  const kp = generateKeyPairFromSeed(seed);50  return { publicKey: kp.publicKey, secretKey: kp.secretKey };51}5253/**54 * Stable per-vault identifier carried in the mDNS TXT record and the55 * first WebSocket handshake. Receiver rejects the connection if its56 * own vault_id differs — prevents a paired-but-different-vault peer57 * from corrupting the registry.58 */59export async function computeVaultId(masterKey: Uint8Array): Promise<string> {60  if (masterKey.length === 0) throw new Error("masterKey must not be empty");61  const buf = new Uint8Array(masterKey.length + VAULT_ID_INFO.length);62  buf.set(masterKey, 0);63  buf.set(VAULT_ID_INFO, masterKey.length);64  const fullHex = await blake3(buf);65  return fullHex.slice(0, VAULT_ID_HEX_LENGTH);66}6768export function pubkeyToHex(pk: Uint8Array): string {69  let s = "";70  for (let i = 0; i < pk.length; i++) {71    s += pk[i].toString(16).padStart(2, "0");72  }73  return s;74}7576export function hexToPubkey(hex: string): Uint8Array {77  if (hex.length % 2 !== 0) throw new Error("hex length must be even");78  const out = new Uint8Array(hex.length / 2);79  for (let i = 0; i < out.length; i++) {80    const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);81    if (Number.isNaN(byte)) throw new Error(`invalid hex at offset ${i * 2}`);82    out[i] = byte;83  }84  return out;85}