304bf8470f7e

add chirp payload codec (64-byte PEAC frame, CRC32, X25519 ephemeral key) and v3 sync-types alongside v2.14

time
2026-05-18 02:08 UTC · 5 months ago
machine
koh2
parent
142990628562
4 files src/background/messages.ts+17 −14src/shared/chirp-payload.test.ts+106 −0src/shared/chirp-payload.ts+139 −0src/shared/sync-types.ts+156 −24
src/background/messages.ts+17 −14
@@ -189,13 +189,15 @@189189       const paired = await getPairedDevices();190190       return {191191         ...base,192-        devices: paired193-          .filter(d => d.x25519PublicKey)194-          .map(d => ({195-            publicKeyHex: d.x25519PublicKey,196-            deviceName: d.displayName,197-            pairedAt: d.pairedAt,198-          })),192+        devices: paired.flatMap(d =>193+          d.x25519PublicKey194+            ? [{195+                publicKeyHex: d.x25519PublicKey,196+                deviceName: d.displayName ?? "",197+                pairedAt: d.pairedAt,198+              }]199+            : [],200+        ),199201       };200202     },201203     applyIncomingVault: async (blob, ts, license, devices) => {@@ -1371,34 +1373,35 @@13711373         const results: Array<{ device: string } & SyncResult> = [];13721374 13731375         for (const device of paired) {1374-          if (!device.x25519PublicKey) continue;1376+          const pubkey = device.x25519PublicKey;1377+          if (!pubkey) continue;13751378 13761379           let result: SyncResult;13771380 13781381           // Fast path — use last known IP13791382           if (device.lastKnownIp) {1380-            result = await doVaultSync(device.lastKnownIp, device.x25519PublicKey);1383+            result = await doVaultSync(device.lastKnownIp, pubkey);13811384           } else {13821385             result = { status: 'errored', applied: false, error: 'no_ip' };13831386           }13841387 13851388           // Discovery path — scan if fast path failed13861389           if (result.status === 'errored' && subnet) {1387-            const newIp = await discoverDevice(subnet, device.x25519PublicKey);1390+            const newIp = await discoverDevice(subnet, pubkey);13881391             if (newIp) {1389-              await updatePairedDevice(device.x25519PublicKey, { lastKnownIp: newIp });1390-              result = await doVaultSync(newIp, device.x25519PublicKey);1392+              await updatePairedDevice(pubkey, { lastKnownIp: newIp });1393+              result = await doVaultSync(newIp, pubkey);13911394             }13921395           }13931396 13941397           if (result.status === 'completed') {1395-            await updatePairedDevice(device.x25519PublicKey, {1398+            await updatePairedDevice(pubkey, {13961399               lastSeenAt: Date.now(),13971400               lastSyncAt: Date.now(),13981401             });13991402           }14001403 1401-          results.push({ device: device.displayName, ...result });1404+          results.push({ device: device.displayName ?? "", ...result });14021405         }14031406 14041407         const syncedCount = results.filter(r => r.status === 'completed').length;

view file

src/shared/chirp-payload.test.ts+106 −0
@@ -0,0 +1,106 @@1+import { describe, expect, it } from "vitest";2+import {3+  CHIRP_MAGIC_BYTES,4+  CHIRP_PAYLOAD_LENGTH,5+  CHIRP_PROTOCOL_VERSION,6+  DEVICE_TYPE_ANDROID,7+  DEVICE_TYPE_EXTENSION,8+} from "./sync-types";9+import {10+  ChirpDecodeError,11+  crc32,12+  decodeChirpPayload,13+  encodeChirpPayload,14+} from "./chirp-payload";15+16+function fixedPayload() {17+  return {18+    version: CHIRP_PROTOCOL_VERSION,19+    deviceType: DEVICE_TYPE_EXTENSION,20+    ephemeralPublicKey: Uint8Array.from(21+      Array.from({ length: 32 }, (_, i) => (i * 7 + 11) & 0xff),22+    ),23+    sessionNonce: Uint8Array.from(24+      Array.from({ length: 16 }, (_, i) => (i * 31 + 1) & 0xff),25+    ),26+  };27+}28+29+describe("crc32", () => {30+  it("matches known IEEE 802.3 vectors", () => {31+    expect(crc32(new Uint8Array())).toBe(0x00000000);32+    expect(crc32(new TextEncoder().encode("123456789"))).toBe(0xcbf43926);33+    expect(crc32(new TextEncoder().encode("a"))).toBe(0xe8b7be43);34+  });35+});36+37+describe("chirp payload codec", () => {38+  it("round-trips identity for a fixed payload", () => {39+    const original = fixedPayload();40+    const bytes = encodeChirpPayload(original);41+    expect(bytes.length).toBe(CHIRP_PAYLOAD_LENGTH);42+    const decoded = decodeChirpPayload(bytes);43+    expect(decoded.version).toBe(original.version);44+    expect(decoded.deviceType).toBe(original.deviceType);45+    expect(decoded.ephemeralPublicKey).toEqual(original.ephemeralPublicKey);46+    expect(decoded.sessionNonce).toEqual(original.sessionNonce);47+  });48+49+  it("writes magic bytes PEAC at offset 0", () => {50+    const bytes = encodeChirpPayload(fixedPayload());51+    expect(bytes.subarray(0, 4)).toEqual(CHIRP_MAGIC_BYTES);52+  });53+54+  it("zero-fills the reserved padding region", () => {55+    const bytes = encodeChirpPayload(fixedPayload());56+    for (let i = 58; i < 64; i++) expect(bytes[i]).toBe(0);57+  });58+59+  it("rejects wrong-length input", () => {60+    expect(() => decodeChirpPayload(new Uint8Array(63))).toThrow(ChirpDecodeError);61+    expect(() => decodeChirpPayload(new Uint8Array(65))).toThrow(ChirpDecodeError);62+  });63+64+  it("rejects bad magic", () => {65+    const bytes = encodeChirpPayload(fixedPayload());66+    bytes[0] = 0;67+    expect(() => decodeChirpPayload(bytes)).toThrow(/magic/);68+  });69+70+  it("rejects unsupported version", () => {71+    const bytes = encodeChirpPayload(fixedPayload());72+    bytes[4] = 0xff;73+    // CRC will also fail, but version is checked first74+    expect(() => decodeChirpPayload(bytes)).toThrow(/version/);75+  });76+77+  it("rejects unknown device type", () => {78+    const bytes = encodeChirpPayload(fixedPayload());79+    bytes[5] = 0x09;80+    expect(() => decodeChirpPayload(bytes)).toThrow(/device type/);81+  });82+83+  it("rejects checksum tamper", () => {84+    const bytes = encodeChirpPayload(fixedPayload());85+    bytes[10] ^= 1; // flip a pubkey bit86+    expect(() => decodeChirpPayload(bytes)).toThrow(/CRC32/);87+  });88+89+  it("rejects non-zero padding", () => {90+    const bytes = encodeChirpPayload(fixedPayload());91+    // Mutate padding then re-patch CRC over the first 54 bytes so that the92+    // padding check is reached rather than failing at CRC first.93+    bytes[63] = 0xab;94+    const fresh = crc32(bytes.subarray(0, 54));95+    bytes[54] = (fresh >>> 24) & 0xff;96+    bytes[55] = (fresh >>> 16) & 0xff;97+    bytes[56] = (fresh >>> 8) & 0xff;98+    bytes[57] = fresh & 0xff;99+    expect(() => decodeChirpPayload(bytes)).toThrow(/padding/);100+  });101+102+  it("encodes android device type byte", () => {103+    const bytes = encodeChirpPayload({ ...fixedPayload(), deviceType: DEVICE_TYPE_ANDROID });104+    expect(bytes[5]).toBe(0x02);105+  });106+});

view file

src/shared/chirp-payload.ts+139 −0
@@ -0,0 +1,139 @@1+// Audio pairing payload — 64 bytes on the wire.2+//3+//   magic            4   ASCII "PEAC"4+//   version          1   currently 0x015+//   deviceType       1   0x01=ext, 0x02=android, 0x03=ios, 0x04=desktop6+//   ephemeralPubkey 32   X25519 public key for the pairing Noise NK session7+//   sessionNonce    16   random per pairing attempt; replay-detection on retry8+//   checksum         4   CRC32 (IEEE 802.3) of the preceding 54 bytes9+//   padding          6   zero; reserved10+//11+// No IP, no name, no version-string — those are all carried inside the12+// Noise NK encrypted channel once the chirp has done its job. The on-air13+// payload is therefore the smallest stable shape that two devices can14+// negotiate trust from.15+16+import {17+  CHIRP_MAGIC_BYTES,18+  CHIRP_PAYLOAD_LENGTH,19+  CHIRP_PROTOCOL_VERSION,20+  type ChirpPayload,21+  type DeviceTypeByte,22+} from "./sync-types";23+24+const OFFSET_MAGIC = 0;25+const OFFSET_VERSION = 4;26+const OFFSET_DEVICE_TYPE = 5;27+const OFFSET_PUBKEY = 6;28+const OFFSET_NONCE = 38;29+const OFFSET_CHECKSUM = 54;30+const OFFSET_PADDING = 58;31+const CHECKSUM_COVERAGE = 54;32+33+export class ChirpDecodeError extends Error {34+  readonly kind:35+    | "length"36+    | "magic"37+    | "version"38+    | "device_type"39+    | "checksum"40+    | "padding";41+  constructor(kind: ChirpDecodeError["kind"], message: string) {42+    super(message);43+    this.kind = kind;44+  }45+}46+47+export function encodeChirpPayload(payload: ChirpPayload): Uint8Array {48+  if (payload.ephemeralPublicKey.length !== 32) {49+    throw new Error("ephemeralPublicKey must be 32 bytes");50+  }51+  if (payload.sessionNonce.length !== 16) {52+    throw new Error("sessionNonce must be 16 bytes");53+  }54+  const buf = new Uint8Array(CHIRP_PAYLOAD_LENGTH);55+  buf.set(CHIRP_MAGIC_BYTES, OFFSET_MAGIC);56+  buf[OFFSET_VERSION] = payload.version & 0xff;57+  buf[OFFSET_DEVICE_TYPE] = payload.deviceType & 0xff;58+  buf.set(payload.ephemeralPublicKey, OFFSET_PUBKEY);59+  buf.set(payload.sessionNonce, OFFSET_NONCE);60+  const crc = crc32(buf.subarray(0, CHECKSUM_COVERAGE));61+  buf[OFFSET_CHECKSUM] = (crc >>> 24) & 0xff;62+  buf[OFFSET_CHECKSUM + 1] = (crc >>> 16) & 0xff;63+  buf[OFFSET_CHECKSUM + 2] = (crc >>> 8) & 0xff;64+  buf[OFFSET_CHECKSUM + 3] = crc & 0xff;65+  // padding bytes already 066+  return buf;67+}68+69+export function decodeChirpPayload(buf: Uint8Array): ChirpPayload {70+  if (buf.length !== CHIRP_PAYLOAD_LENGTH) {71+    throw new ChirpDecodeError(72+      "length",73+      `chirp payload must be ${CHIRP_PAYLOAD_LENGTH} bytes, got ${buf.length}`,74+    );75+  }76+  for (let i = 0; i < 4; i++) {77+    if (buf[OFFSET_MAGIC + i] !== CHIRP_MAGIC_BYTES[i]) {78+      throw new ChirpDecodeError("magic", "magic bytes are not PEAC");79+    }80+  }81+  const version = buf[OFFSET_VERSION];82+  if (version !== CHIRP_PROTOCOL_VERSION) {83+    throw new ChirpDecodeError(84+      "version",85+      `unsupported chirp protocol version ${version}`,86+    );87+  }88+  const deviceType = buf[OFFSET_DEVICE_TYPE];89+  if (deviceType < 0x01 || deviceType > 0x04) {90+    throw new ChirpDecodeError(91+      "device_type",92+      `unknown device type byte 0x${deviceType.toString(16)}`,93+    );94+  }95+  const declared =96+    ((buf[OFFSET_CHECKSUM] << 24) |97+      (buf[OFFSET_CHECKSUM + 1] << 16) |98+      (buf[OFFSET_CHECKSUM + 2] << 8) |99+      buf[OFFSET_CHECKSUM + 3]) >>>100+    0;101+  const computed = crc32(buf.subarray(0, CHECKSUM_COVERAGE));102+  if (declared !== computed) {103+    throw new ChirpDecodeError("checksum", "CRC32 mismatch");104+  }105+  for (let i = OFFSET_PADDING; i < CHIRP_PAYLOAD_LENGTH; i++) {106+    if (buf[i] !== 0) {107+      throw new ChirpDecodeError("padding", "reserved padding bytes must be zero");108+    }109+  }110+  return {111+    version,112+    deviceType: deviceType as DeviceTypeByte,113+    ephemeralPublicKey: buf.slice(OFFSET_PUBKEY, OFFSET_PUBKEY + 32),114+    sessionNonce: buf.slice(OFFSET_NONCE, OFFSET_NONCE + 16),115+  };116+}117+118+// CRC32 — IEEE 802.3 (poly 0xEDB88320, reflected). Table-driven; built119+// lazily on first use because the chirp codec is not on the hot path.120+let crcTable: Uint32Array | undefined;121+122+function buildCrcTable(): Uint32Array {123+  const table = new Uint32Array(256);124+  for (let i = 0; i < 256; i++) {125+    let c = i;126+    for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);127+    table[i] = c >>> 0;128+  }129+  return table;130+}131+132+export function crc32(bytes: Uint8Array): number {133+  if (!crcTable) crcTable = buildCrcTable();134+  let crc = 0xffffffff;135+  for (let i = 0; i < bytes.length; i++) {136+    crc = crcTable[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);137+  }138+  return (crc ^ 0xffffffff) >>> 0;139+}

view file

src/shared/sync-types.ts+156 −24
@@ -1,23 +1,169 @@1-export interface TrustedNetwork {2-  ssid?: string;3-  bssid?: string;4-  isTailscale: boolean;5-  addedAt: number;1+// Chirp Sync — type definitions.2+//3+// This file is in the middle of a v2.14 → v3 migration. The v3 chirp-sync4+// architecture (audio-bootstrapped pairing + manifest-diff WebSocket sync)5+// is being added alongside the v2.14 full-blob-over-native-host types.6+// The v2.14 types are scheduled for removal once every consumer in7+// messages.ts has been migrated; do not write new code against them.8+//9+// v3 — audio pairing once per device pair (Phase 1). All ongoing vault sync10+// after that runs over a direct WebSocket on the local network (Phase 2).11+// The two phases never overlap.12+13+// ---------------------------------------------------------------------------14+// v3 chirp pairing — on-air audio frame15+// ---------------------------------------------------------------------------16+17+export type DeviceType = "extension" | "android" | "ios" | "desktop";18+19+// Numeric tags used in the on-air chirp payload. The audio frame is a20+// fixed 64-byte struct (see chirp-payload.ts); these are the byte values21+// that go on the wire — not the string DeviceType.22+export const DEVICE_TYPE_EXTENSION = 0x01 as const;23+export const DEVICE_TYPE_ANDROID = 0x02 as const;24+export const DEVICE_TYPE_IOS = 0x03 as const;25+export const DEVICE_TYPE_DESKTOP = 0x04 as const;26+27+export type DeviceTypeByte =28+  | typeof DEVICE_TYPE_EXTENSION29+  | typeof DEVICE_TYPE_ANDROID30+  | typeof DEVICE_TYPE_IOS31+  | typeof DEVICE_TYPE_DESKTOP;32+33+export const CHIRP_MAGIC_BYTES = Uint8Array.of(0x50, 0x45, 0x41, 0x43); // "PEAC"34+export const CHIRP_PROTOCOL_VERSION = 0x01 as const;35+export const CHIRP_PAYLOAD_LENGTH = 64 as const;36+37+export function deviceTypeFromByte(b: DeviceTypeByte): DeviceType {38+  switch (b) {39+    case DEVICE_TYPE_EXTENSION: return "extension";40+    case DEVICE_TYPE_ANDROID: return "android";41+    case DEVICE_TYPE_IOS: return "ios";42+    case DEVICE_TYPE_DESKTOP: return "desktop";43+  }644 }745 46+export function deviceTypeToByte(t: DeviceType): DeviceTypeByte {47+  switch (t) {48+    case "extension": return DEVICE_TYPE_EXTENSION;49+    case "android": return DEVICE_TYPE_ANDROID;50+    case "ios": return DEVICE_TYPE_IOS;51+    case "desktop": return DEVICE_TYPE_DESKTOP;52+  }53+}54+55+export interface ChirpPayload {56+  version: number;57+  deviceType: DeviceTypeByte;58+  ephemeralPublicKey: Uint8Array; // 32 bytes59+  sessionNonce: Uint8Array; // 16 bytes60+}61+62+// ---------------------------------------------------------------------------63+// v3 chirp pairing — long-term paired device registry64+// ---------------------------------------------------------------------------65+66+/**67+ * v3 paired-device record. Stored in `chrome.storage.sync` so the registry68+ * roams across browser profiles signed into Chrome/Firefox sync.69+ *70+ * `longTermPublicKeyHex` is the counterpart's vault-derived sync identity71+ * static X25519 public key — see `deriveSyncIdentityKeypair`. Mutual Noise72+ * XX authentication on every WebSocket connection rejects any peer not73+ * present in this registry.74+ *75+ * `lastKnownIp` is intentionally NOT stored here — it's per-network and76+ * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See77+ * `getLastKnownIp` / `setLastKnownIp` in sync-storage.ts.78+ *79+ * Legacy v2.14 fields `displayName`, `x25519PublicKey`, `lastSeenAt`,80+ * `lastSyncAt`, `lastKnownIp` are kept for migration only; new code reads81+ * `deviceName`, `longTermPublicKeyHex`, `lastSyncedAt`.82+ */883 export interface PairedDevice {984   deviceId: string;10-  displayName: string;11-  x25519PublicKey: string;85+  deviceName?: string;86+  deviceType?: DeviceType;87+  longTermPublicKeyHex?: string;1288   pairedAt: number;89+  lastSyncedAt?: number;90+91+  // v2.14 legacy — kept readable so the migration sweep doesn't crash92+  // every test that still constructs the old shape. New writers must use93+  // the v3 fields above.94+  displayName?: string;95+  x25519PublicKey?: string;1396   lastSeenAt?: number;1497   lastSyncAt?: number;1598   lastKnownIp?: string;1699 }17100 18-// A stripped-down device record propagated in SYNC_VAULT_SEND so every peer19-// learns the full mesh without re-pairing. No IP — IPs are ephemeral and20-// discovered fresh via subnet scan.101+// ---------------------------------------------------------------------------102+// v3 chirp pairing — phase-1 UI state103+// ---------------------------------------------------------------------------104+105+/**106+ * Phase 1 pairing state, surfaced to the SyncPairing view. The view binds107+ * its bird-animation acts directly to these states: `listening` and108+ * `chirping` map to Act 1, `handshake` maps to Act 2, `paired` to Act 3.109+ */110+export type PairingPhase =111+  | "idle"112+  | "listening"113+  | "chirping"114+  | "handshake"115+  | "paired"116+  | "timeout"117+  | "mic_denied"118+  | "version_mismatch"119+  | "handshake_failed";120+121+export interface PairingProgress {122+  phase: PairingPhase;123+  peerDeviceName?: string;124+  errorMessage?: string;125+}126+127+// ---------------------------------------------------------------------------128+// v3 wire protocol — phase-2 WebSocket manifest sync129+// ---------------------------------------------------------------------------130+131+/**132+ * v3 wire protocol — after Noise XX is open.133+ *134+ * Every frame is JSON, then ChaCha20-Poly1305 encrypted by Noise XX. The135+ * WebSocket transport sees only ciphertext.136+ *137+ * The manifest carries per-entry timestamps; the receiver requests entries138+ * where the remote timestamp beats its own. Entries traverse as opaque139+ * encrypted blobs — the sync layer never decrypts vault data.140+ */141+export type ChirpSyncMessage =142+  | { type: "SYNC_HELLO"; vaultId: string; deviceId: string }143+  | { type: "SYNC_MANIFEST"; entries: Record<string, number>; tombstones: Record<string, number> }144+  | { type: "SYNC_REQUEST"; entryIds: string[] }145+  | { type: "SYNC_ENTRIES"; entries: Record<string, string> }146+  | { type: "SYNC_TOMBSTONES"; tombstones: Record<string, number> }147+  | { type: "SYNC_DONE" }148+  | { type: "SYNC_ERROR"; error: string };149+150+export type SyncStatusV3 = "up_to_date" | "syncing" | "unreachable" | "never";151+152+// ---------------------------------------------------------------------------153+// v2.14 legacy types — DO NOT USE IN NEW CODE154+//155+// These remain only because messages.ts and the v2.14 coordinator/network156+// modules import them. Once the migration sweep lands, this whole block157+// goes away.158+// ---------------------------------------------------------------------------159+160+export interface TrustedNetwork {161+  ssid?: string;162+  bssid?: string;163+  isTailscale: boolean;164+  addedAt: number;165+}166+21167 export interface SyncableDevice {22168   publicKeyHex: string;23169   deviceName: string;@@ -33,24 +179,10 @@33179 34180 export type SyncStatus = "synced" | "syncing" | "no_peers" | "not_trusted" | "offline";35181 36-/**37- * Pairing request received from a peer that needs user confirmation.38- *39- * The banner expires after 60 seconds (`expiresAt - receivedAt = 60_000`);40- * after that the popup hides the banner and the background drops the41- * request. The publicKeyHex is the peer's static X25519 identity — it42- * gets written to trusted devices on accept and discarded on reject.43- */44182 export interface IncomingPairingRequest {45183   deviceName: string;46184   publicKeyHex: string;47185   receivedAt: number;48186   expiresAt: number;49-  /**50-   * Connection id of the still-open pairing-only socket. The background's51-   * RESPOND_INCOMING_PAIRING handler sends PAIR_ACCEPT or PAIR_REJECT over52-   * this connection then disconnects. May be absent for synthetic test53-   * injections that do not have a real connection behind them.54-   */55187   connectionId?: string;56188 }

view file