chirp-payload.ts

4.4 KB · raw

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.1516import {17  CHIRP_MAGIC_BYTES,18  CHIRP_PAYLOAD_LENGTH,19  CHIRP_PROTOCOL_VERSION,20  type ChirpPayload,21  type DeviceTypeByte,22} from "./sync-types";2324const OFFSET_MAGIC = 0;25const OFFSET_VERSION = 4;26const OFFSET_DEVICE_TYPE = 5;27const OFFSET_PUBKEY = 6;28const OFFSET_NONCE = 38;29const OFFSET_CHECKSUM = 54;30const OFFSET_PADDING = 58;31const CHECKSUM_COVERAGE = 54;3233export 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}4647export 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}6869export 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}117118// CRC32 — IEEE 802.3 (poly 0xEDB88320, reflected). Table-driven; built119// lazily on first use because the chirp codec is not on the hot path.120let crcTable: Uint32Array | undefined;121122function 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}131132export 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}