// Audio pairing payload — 64 bytes on the wire. // // magic 4 ASCII "PEAC" // version 1 currently 0x01 // deviceType 1 0x01=ext, 0x02=android, 0x03=ios, 0x04=desktop // ephemeralPubkey 32 X25519 public key for the pairing Noise NK session // sessionNonce 16 random per pairing attempt; replay-detection on retry // checksum 4 CRC32 (IEEE 802.3) of the preceding 54 bytes // padding 6 zero; reserved // // No IP, no name, no version-string — those are all carried inside the // Noise NK encrypted channel once the chirp has done its job. The on-air // payload is therefore the smallest stable shape that two devices can // negotiate trust from. import { CHIRP_MAGIC_BYTES, CHIRP_PAYLOAD_LENGTH, CHIRP_PROTOCOL_VERSION, type ChirpPayload, type DeviceTypeByte, } from "./sync-types"; const OFFSET_MAGIC = 0; const OFFSET_VERSION = 4; const OFFSET_DEVICE_TYPE = 5; const OFFSET_PUBKEY = 6; const OFFSET_NONCE = 38; const OFFSET_CHECKSUM = 54; const OFFSET_PADDING = 58; const CHECKSUM_COVERAGE = 54; export class ChirpDecodeError extends Error { readonly kind: | "length" | "magic" | "version" | "device_type" | "checksum" | "padding"; constructor(kind: ChirpDecodeError["kind"], message: string) { super(message); this.kind = kind; } } export function encodeChirpPayload(payload: ChirpPayload): Uint8Array { if (payload.ephemeralPublicKey.length !== 32) { throw new Error("ephemeralPublicKey must be 32 bytes"); } if (payload.sessionNonce.length !== 16) { throw new Error("sessionNonce must be 16 bytes"); } const buf = new Uint8Array(CHIRP_PAYLOAD_LENGTH); buf.set(CHIRP_MAGIC_BYTES, OFFSET_MAGIC); buf[OFFSET_VERSION] = payload.version & 0xff; buf[OFFSET_DEVICE_TYPE] = payload.deviceType & 0xff; buf.set(payload.ephemeralPublicKey, OFFSET_PUBKEY); buf.set(payload.sessionNonce, OFFSET_NONCE); const crc = crc32(buf.subarray(0, CHECKSUM_COVERAGE)); buf[OFFSET_CHECKSUM] = (crc >>> 24) & 0xff; buf[OFFSET_CHECKSUM + 1] = (crc >>> 16) & 0xff; buf[OFFSET_CHECKSUM + 2] = (crc >>> 8) & 0xff; buf[OFFSET_CHECKSUM + 3] = crc & 0xff; // padding bytes already 0 return buf; } export function decodeChirpPayload(buf: Uint8Array): ChirpPayload { if (buf.length !== CHIRP_PAYLOAD_LENGTH) { throw new ChirpDecodeError( "length", `chirp payload must be ${CHIRP_PAYLOAD_LENGTH} bytes, got ${buf.length}`, ); } for (let i = 0; i < 4; i++) { if (buf[OFFSET_MAGIC + i] !== CHIRP_MAGIC_BYTES[i]) { throw new ChirpDecodeError("magic", "magic bytes are not PEAC"); } } const version = buf[OFFSET_VERSION]; if (version !== CHIRP_PROTOCOL_VERSION) { throw new ChirpDecodeError( "version", `unsupported chirp protocol version ${version}`, ); } const deviceType = buf[OFFSET_DEVICE_TYPE]; if (deviceType < 0x01 || deviceType > 0x04) { throw new ChirpDecodeError( "device_type", `unknown device type byte 0x${deviceType.toString(16)}`, ); } const declared = ((buf[OFFSET_CHECKSUM] << 24) | (buf[OFFSET_CHECKSUM + 1] << 16) | (buf[OFFSET_CHECKSUM + 2] << 8) | buf[OFFSET_CHECKSUM + 3]) >>> 0; const computed = crc32(buf.subarray(0, CHECKSUM_COVERAGE)); if (declared !== computed) { throw new ChirpDecodeError("checksum", "CRC32 mismatch"); } for (let i = OFFSET_PADDING; i < CHIRP_PAYLOAD_LENGTH; i++) { if (buf[i] !== 0) { throw new ChirpDecodeError("padding", "reserved padding bytes must be zero"); } } return { version, deviceType: deviceType as DeviceTypeByte, ephemeralPublicKey: buf.slice(OFFSET_PUBKEY, OFFSET_PUBKEY + 32), sessionNonce: buf.slice(OFFSET_NONCE, OFFSET_NONCE + 16), }; } // CRC32 — IEEE 802.3 (poly 0xEDB88320, reflected). Table-driven; built // lazily on first use because the chirp codec is not on the hot path. let crcTable: Uint32Array | undefined; function buildCrcTable(): Uint32Array { const table = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); table[i] = c >>> 0; } return table; } export function crc32(bytes: Uint8Array): number { if (!crcTable) crcTable = buildCrcTable(); let crc = 0xffffffff; for (let i = 0; i < bytes.length; i++) { crc = crcTable[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); } return (crc ^ 0xffffffff) >>> 0; }