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
↓ peach-browser-304bf847.tar.gzatlas at this save
files changed4 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 −24src/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;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+});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+}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 }