sync-identity.ts

7.6 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 = BLAKE3(master_key || "peach-vault-id"), hex (64 chars / 32 bytes)9//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 SYNC_SIGNING_SALT = new TextEncoder().encode("peach-sync-signing-v1");27const VAULT_ID_INFO = new TextEncoder().encode("peach-vault-id");28const VAULT_ID_HEX_LENGTH = 64; // full 32-byte BLAKE3 digest → 64 hex chars2930// PKCS#8 prefix for a raw 32-byte Ed25519 seed (RFC 8410 §7).31//   SEQUENCE(46) { INTEGER 0, AlgorithmIdentifier(id-Ed25519),32//                  OCTET STRING(34) { OCTET STRING(32) <seed> } }33const ED25519_PKCS8_PREFIX = Uint8Array.of(34  0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,35  0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,36);3738export interface SyncIdentityKeypair {39  publicKey: Uint8Array; // 32 bytes40  secretKey: Uint8Array; // 32 bytes — sensitive; treat as key material41}4243/**44 * Derive the deterministic sync identity keypair for a vault on a given45 * installation. Always returns the same keypair for the same46 * (masterKey, deviceId) pair across runs and reinstalls.47 */48export function deriveSyncIdentityKeypair(49  masterKey: Uint8Array,50  deviceId: string,51): SyncIdentityKeypair {52  if (masterKey.length === 0) throw new Error("masterKey must not be empty");53  if (!deviceId) throw new Error("deviceId must not be empty");54  const info = new TextEncoder().encode(deviceId);55  // HKDF-SHA256(salt="peach-sync-identity-v1", ikm=master_key, info=device_id)56  // → 32 bytes used directly as the X25519 secret key seed.57  const seed = new HKDF(SHA256, masterKey, SYNC_IDENTITY_SALT, info).expand(32);58  const kp = generateKeyPairFromSeed(seed);59  return { publicKey: kp.publicKey, secretKey: kp.secretKey };60}6162/**63 * Stable per-vault identifier carried in the mDNS TXT record and the64 * first WebSocket handshake. Receiver rejects the connection if its65 * own vault_id differs — prevents a paired-but-different-vault peer66 * from corrupting the registry.67 *68 * PRIVACY NOTE (PCH-02-006): vault_id is stable across sessions and broadcast69 * over mDNS during LAN sync discovery. A passive observer on the same network70 * can link every device sharing this vault and observe sync activity. This is71 * inherent to discovery and one-way — the master key is not recoverable from72 * the BLAKE3 output. Users who need LAN unlinkability should leave sync73 * disabled; "rotate sync identity" re-keys the root key and this identifier.74 */75export async function computeVaultId(masterKey: Uint8Array): Promise<string> {76  if (masterKey.length === 0) throw new Error("masterKey must not be empty");77  const buf = new Uint8Array(masterKey.length + VAULT_ID_INFO.length);78  buf.set(masterKey, 0);79  buf.set(VAULT_ID_INFO, masterKey.length);80  const fullHex = await blake3(buf);81  return fullHex.slice(0, VAULT_ID_HEX_LENGTH);82}8384/**85 * Derive the deterministic Ed25519 signing keypair used to sign device86 * manifests (v2 brief §4.3). Distinct from the X25519 sync identity so87 * each key class stays single-purpose; both derive from the same master88 * key under a different HKDF salt.89 *90 * Returns Web Crypto `CryptoKey` handles. The private key is imported as91 * EXTRACTABLE (intentionally — see the inline note at the importKey call: the92 * seed is already in memory, and a JWK export is the portable way to recover the93 * matching public key on engines that don't expose it from a pkcs8 handle). The94 * 32-byte raw public key is also returned for inclusion in pairing payloads.95 */96export async function deriveSyncSigningKeypair(97  masterKey: Uint8Array,98  deviceId: string,99): Promise<{ privateKey: CryptoKey; publicKeyRaw: Uint8Array }> {100  if (masterKey.length === 0) throw new Error("masterKey must not be empty");101  if (!deviceId) throw new Error("deviceId must not be empty");102  const info = new TextEncoder().encode(deviceId);103  const seed = new HKDF(SHA256, masterKey, SYNC_SIGNING_SALT, info).expand(32);104105  const pkcs8 = new Uint8Array(ED25519_PKCS8_PREFIX.length + 32);106  pkcs8.set(ED25519_PKCS8_PREFIX, 0);107  pkcs8.set(seed, ED25519_PKCS8_PREFIX.length);108109  // The seed is already in memory so making the private key extractable110  // costs nothing in terms of attack surface, and the JWK export is the111  // portable way to recover the matching public key in Web Crypto engines112  // that don't expose `publicKey` directly from a `pkcs8`-imported handle.113  const privateKey = await crypto.subtle.importKey(114    "pkcs8",115    pkcs8 as Uint8Array<ArrayBuffer>,116    { name: "Ed25519" },117    true,118    ["sign"],119  );120  const jwk = (await crypto.subtle.exportKey("jwk", privateKey)) as JsonWebKey & { x?: string };121  if (!jwk.x) throw new Error("Ed25519 JWK missing 'x' component");122  const publicKeyRaw = base64UrlToBytes(jwk.x);123  return { privateKey, publicKeyRaw };124}125126/**127 * Verify an Ed25519 signature against a raw 32-byte public key. Returns128 * true if the signature is valid. Used to validate incoming device129 * manifests (v2 brief §4.3).130 */131export async function verifyEd25519(132  publicKeyRaw: Uint8Array,133  message: Uint8Array,134  signature: Uint8Array,135): Promise<boolean> {136  if (publicKeyRaw.length !== 32) return false;137  if (signature.length !== 64) return false;138  try {139    const key = await crypto.subtle.importKey(140      "raw",141      publicKeyRaw as Uint8Array<ArrayBuffer>,142      { name: "Ed25519" },143      false,144      ["verify"],145    );146    return await crypto.subtle.verify(147      "Ed25519",148      key,149      signature as Uint8Array<ArrayBuffer>,150      message as Uint8Array<ArrayBuffer>,151    );152  } catch {153    return false;154  }155}156157/** Sign with a private Ed25519 CryptoKey from `deriveSyncSigningKeypair`. */158export async function signEd25519(159  privateKey: CryptoKey,160  message: Uint8Array,161): Promise<Uint8Array> {162  const sig = await crypto.subtle.sign(163    "Ed25519",164    privateKey,165    message as Uint8Array<ArrayBuffer>,166  );167  return new Uint8Array(sig);168}169170function base64UrlToBytes(b64url: string): Uint8Array {171  const pad = (4 - (b64url.length % 4)) % 4;172  const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad);173  const bin = atob(b64);174  const out = new Uint8Array(bin.length);175  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);176  return out;177}178179export function pubkeyToHex(pk: Uint8Array): string {180  let s = "";181  for (let i = 0; i < pk.length; i++) {182    s += pk[i].toString(16).padStart(2, "0");183  }184  return s;185}186187export function hexToPubkey(hex: string): Uint8Array {188  if (hex.length !== 64) throw new Error("public key must be exactly 32 bytes");189  const out = new Uint8Array(32);190  for (let i = 0; i < out.length; i++) {191    const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);192    if (Number.isNaN(byte)) throw new Error(`invalid hex at offset ${i * 2}`);193    out[i] = byte;194  }195  return out;196}