sync-identity.ts

3.7 KB · raw

1// Sync identity — vault-scoped primitives.2//3//   vault_id = BLAKE3(master_key || "peach-vault-id"), hex (64 chars / 32 bytes)4//5// Device key material is deliberately NOT derived here. Each installation6// generates random X25519/Ed25519 credentials once and keeps them in a local7// record (src/background/device-identity.ts); the vault root must never be an8// input to device-private-key derivation. The legacy root→device derivation9// survives only in the test-only owner10// `legacy-identity-derivation.test-support.ts` (campaign task 2.4).11//12// `vault_id` is a compatibility check, never an authenticator: every mesh13// member can compute it from the root, so it proves nothing on its own.14//15// The signing helpers below take explicit public keys and never touch the root.1617import { blake3 } from "hash-wasm";1819const VAULT_ID_INFO = new TextEncoder().encode("peach-vault-id");20const VAULT_ID_HEX_LENGTH = 64; // full 32-byte BLAKE3 digest → 64 hex chars2122export interface SyncIdentityKeypair {23  publicKey: Uint8Array; // 32 bytes24  secretKey: Uint8Array; // 32 bytes — sensitive; treat as key material25}2627/**28 * Stable per-vault identifier carried inside encrypted pairing and sync29 * frames. Receiver rejects the connection if its30 * own vault_id differs — prevents a paired-but-different-vault peer31 * from corrupting the registry.32 *33 * PRIVACY NOTE (PCH-02-006): vault_id is stable across sessions but is no34 * longer broadcast by discovery; it travels only after Noise is established.35 * It remains a one-way identifier — the master key is not recoverable from36 * the BLAKE3 output. "Rotate sync identity" re-keys the root key and this37 * identifier.38 */39export async function computeVaultId(masterKey: Uint8Array): Promise<string> {40  if (masterKey.length === 0) throw new Error("masterKey must not be empty");41  const buf = new Uint8Array(masterKey.length + VAULT_ID_INFO.length);42  buf.set(masterKey, 0);43  buf.set(VAULT_ID_INFO, masterKey.length);44  const fullHex = await blake3(buf);45  return fullHex.slice(0, VAULT_ID_HEX_LENGTH);46}4748/**49 * Verify an Ed25519 signature against a raw 32-byte public key. Returns50 * true if the signature is valid. Used to validate incoming device51 * manifests (v2 brief §4.3).52 */53export async function verifyEd25519(54  publicKeyRaw: Uint8Array,55  message: Uint8Array,56  signature: Uint8Array,57): Promise<boolean> {58  if (publicKeyRaw.length !== 32) return false;59  if (signature.length !== 64) return false;60  try {61    const key = await crypto.subtle.importKey(62      "raw",63      publicKeyRaw as Uint8Array<ArrayBuffer>,64      { name: "Ed25519" },65      false,66      ["verify"],67    );68    return await crypto.subtle.verify(69      "Ed25519",70      key,71      signature as Uint8Array<ArrayBuffer>,72      message as Uint8Array<ArrayBuffer>,73    );74  } catch {75    return false;76  }77}7879/** Sign with a local Ed25519 private CryptoKey imported from the device identity record. */80export async function signEd25519(81  privateKey: CryptoKey,82  message: Uint8Array,83): Promise<Uint8Array> {84  const sig = await crypto.subtle.sign(85    "Ed25519",86    privateKey,87    message as Uint8Array<ArrayBuffer>,88  );89  return new Uint8Array(sig);90}9192export function pubkeyToHex(pk: Uint8Array): string {93  let s = "";94  for (let i = 0; i < pk.length; i++) {95    s += pk[i].toString(16).padStart(2, "0");96  }97  return s;98}99100export function hexToPubkey(hex: string): Uint8Array {101  if (hex.length !== 64) throw new Error("public key must be exactly 32 bytes");102  const out = new Uint8Array(32);103  for (let i = 0; i < out.length; i++) {104    const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);105    if (Number.isNaN(byte)) throw new Error(`invalid hex at offset ${i * 2}`);106    out[i] = byte;107  }108  return out;109}