5cb96ec333eb

add deterministic sync identity derivation via HKDF + X25519 and BLAKE3 vault_id

time
2026-05-18 02:10 UTC · 5 months ago
machine
koh2
parent
998d37410c79
2 files src/shared/sync-identity.test.ts+90 −0src/shared/sync-identity.ts+85 −0
src/shared/sync-identity.test.ts+90 −0
@@ -0,0 +1,90 @@1+import { describe, expect, it } from "vitest";2+import { sharedKey } from "@stablelib/x25519";3+import {4+  computeVaultId,5+  deriveSyncIdentityKeypair,6+  hexToPubkey,7+  pubkeyToHex,8+} from "./sync-identity";9+10+const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i + 1) & 0xff));11+const OTHER_MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 3 + 5) & 0xff));12+13+describe("deriveSyncIdentityKeypair", () => {14+  it("is deterministic across calls", () => {15+    const a = deriveSyncIdentityKeypair(MASTER, "abc-123");16+    const b = deriveSyncIdentityKeypair(MASTER, "abc-123");17+    expect(a.publicKey).toEqual(b.publicKey);18+    expect(a.secretKey).toEqual(b.secretKey);19+  });20+21+  it("differs across device_id (same master_key)", () => {22+    const a = deriveSyncIdentityKeypair(MASTER, "device-A");23+    const b = deriveSyncIdentityKeypair(MASTER, "device-B");24+    expect(a.publicKey).not.toEqual(b.publicKey);25+  });26+27+  it("differs across master_key (same device_id)", () => {28+    const a = deriveSyncIdentityKeypair(MASTER, "device-A");29+    const b = deriveSyncIdentityKeypair(OTHER_MASTER, "device-A");30+    expect(a.publicKey).not.toEqual(b.publicKey);31+  });32+33+  it("produces 32-byte keys", () => {34+    const kp = deriveSyncIdentityKeypair(MASTER, "x");35+    expect(kp.publicKey.length).toBe(32);36+    expect(kp.secretKey.length).toBe(32);37+  });38+39+  it("yields a working X25519 keypair (DH commutes)", () => {40+    const alice = deriveSyncIdentityKeypair(MASTER, "alice");41+    const bob = deriveSyncIdentityKeypair(MASTER, "bob");42+    const ab = sharedKey(alice.secretKey, bob.publicKey);43+    const ba = sharedKey(bob.secretKey, alice.publicKey);44+    expect(ab).toEqual(ba);45+  });46+47+  it("rejects empty master key", () => {48+    expect(() => deriveSyncIdentityKeypair(new Uint8Array(), "x")).toThrow();49+  });50+51+  it("rejects empty device id", () => {52+    expect(() => deriveSyncIdentityKeypair(MASTER, "")).toThrow();53+  });54+});55+56+describe("computeVaultId", () => {57+  it("is deterministic for the same master key", async () => {58+    const a = await computeVaultId(MASTER);59+    const b = await computeVaultId(MASTER);60+    expect(a).toBe(b);61+  });62+63+  it("differs across master keys", async () => {64+    const a = await computeVaultId(MASTER);65+    const b = await computeVaultId(OTHER_MASTER);66+    expect(a).not.toBe(b);67+  });68+69+  it("returns 16 hex chars (first 8 bytes of BLAKE3)", async () => {70+    const id = await computeVaultId(MASTER);71+    expect(id).toMatch(/^[0-9a-f]{16}$/);72+  });73+});74+75+describe("pubkeyToHex / hexToPubkey", () => {76+  it("round-trips", () => {77+    const pk = Uint8Array.from([0x00, 0xff, 0xa5, 0x10, 0x42]);78+    const hex = pubkeyToHex(pk);79+    expect(hex).toBe("00ffa51042");80+    expect(hexToPubkey(hex)).toEqual(pk);81+  });82+83+  it("rejects odd-length hex", () => {84+    expect(() => hexToPubkey("abc")).toThrow();85+  });86+87+  it("rejects invalid hex chars", () => {88+    expect(() => hexToPubkey("zz")).toThrow();89+  });90+});

view file

src/shared/sync-identity.ts+85 −0
@@ -0,0 +1,85 @@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.19+20+import { HKDF } from "@stablelib/hkdf";21+import { SHA256 } from "@stablelib/sha256";22+import { generateKeyPairFromSeed } from "@stablelib/x25519";23+import { blake3 } from "hash-wasm";24+25+const SYNC_IDENTITY_SALT = new TextEncoder().encode("peach-sync-identity-v1");26+const VAULT_ID_INFO = new TextEncoder().encode("peach-vault-id");27+const VAULT_ID_HEX_LENGTH = 16; // 8 bytes → 16 hex chars28+29+export interface SyncIdentityKeypair {30+  publicKey: Uint8Array; // 32 bytes31+  secretKey: Uint8Array; // 32 bytes — sensitive; treat as key material32+}33+34+/**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+ */39+export 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+}52+53+/**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+ */59+export 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+}67+68+export 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+}75+76+export 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+}

view file