/** * Shade Wire Format — compact binary encoding for protocol messages. * * Format: [version:1][type:1][payload...] * * Types: * 0x01 = PreKeyMessage * 0x02 = RatchetMessage * 0x11 = StreamChunk * 0x21 = BroadcastMessage (V4.6 — sender-key encrypted group payload) * * All multi-byte integers are big-endian. * * Length prefixes are 4-byte (u32) since wire VERSION 0x02. (VERSION 0x01 * used 2-byte/u16 length prefixes which silently truncated payloads larger * than 64 KiB — a hard correctness ceiling that blocked inline file ops * up to 256 KiB. The bump is incompatible with 0.2.x peers.) */ import type { PreKeyMessage, RatchetMessage, ShadeEnvelope } from '@shade/core'; const VERSION = 0x02; const TYPE_PREKEY = 0x01; const TYPE_RATCHET = 0x02; export const TYPE_STREAM_CHUNK = 0x11; export const TYPE_BROADCAST = 0x21; // ─── Stream chunk types ────────────────────────────────────── /** * Wire-decoded stream-chunk envelope (type 0x11). * * See spec §2.2. The nonce is deterministic — derived from * (laneId, seq) on both sides — but is also serialized over the wire for * self-description and validated by the receiver. */ export interface StreamChunkWire { streamId: Uint8Array; // 16 bytes laneId: number; // u32 seq: number | bigint; // u64 isLast: boolean; nonce: Uint8Array; // 12 bytes aad: Uint8Array; // additional bound data (length=0 in v0.2.0, reserved) ciphertext: Uint8Array; // AES-GCM ciphertext including 16-byte tag } /** * Wire-decoded broadcast envelope (type 0x21). * * Carries a Signal-style sender-key message. The sender (channel owner) * encrypted once with their per-channel chain key; this same byte sequence * is delivered verbatim to every member. Authenticity rides on the embedded * Ed25519 signature — no bilateral ratchet wraps the broadcast itself. * * `generation` is a per-channel rotation counter: bumped each time a * member is revoked. Receivers silently drop broadcasts at older * generations than their currently-installed sender-key. */ export interface BroadcastWire { channelId: string; // utf-8, length-prefixed senderAddress: string; // utf-8, length-prefixed generation: number; // u32 iteration: number; // u32 — sender chain counter nonce: Uint8Array; // AES-GCM nonce (12 bytes) signature: Uint8Array; // Ed25519 signature (64 bytes) ciphertext: Uint8Array; // AES-GCM ciphertext including 16-byte tag } const STREAM_ID_BYTES = 16; const STREAM_NONCE_BYTES = 12; // ─── Encode ────────────────────────────────────────────────── export function encodeEnvelope(envelope: ShadeEnvelope): Uint8Array { if (envelope.type === 'prekey') { return encodePreKeyMessage(envelope.content as PreKeyMessage); } return encodeRatchetMessage(envelope.content as RatchetMessage); } export function encodePreKeyMessage(msg: PreKeyMessage): Uint8Array { const ratchetBytes = encodeRatchetMessageInner(msg.message); const parts: Uint8Array[] = []; // Header parts.push(new Uint8Array([VERSION, TYPE_PREKEY])); // registrationId (4 bytes) parts.push(uint32(msg.registrationId)); // preKeyId (4 bytes, 0xFFFFFFFF = none) parts.push(uint32(msg.preKeyId ?? 0xFFFFFFFF)); // signedPreKeyId (4 bytes) parts.push(uint32(msg.signedPreKeyId)); // ephemeralKey (length-prefixed) parts.push(lpBytes(msg.ephemeralKey)); // identityDHKey (length-prefixed) parts.push(lpBytes(msg.identityDHKey)); // embedded ratchet message (length-prefixed) parts.push(lpBytes(ratchetBytes)); return concat(parts); } export function encodeRatchetMessage(msg: RatchetMessage): Uint8Array { const parts: Uint8Array[] = []; parts.push(new Uint8Array([VERSION, TYPE_RATCHET])); parts.push(encodeRatchetMessageInner(msg)); return concat(parts); } function encodeRatchetMessageInner(msg: RatchetMessage): Uint8Array { const parts: Uint8Array[] = []; parts.push(lpBytes(msg.dhPublicKey)); parts.push(uint32(msg.previousCounter)); parts.push(uint32(msg.counter)); parts.push(lpBytes(msg.ciphertext)); parts.push(lpBytes(msg.nonce)); return concat(parts); } /** * Encode a stream-chunk envelope to wire bytes (type 0x11). * * Layout: see `shade-streams-spec.md` §2.2. */ export function encodeStreamChunk(c: StreamChunkWire): Uint8Array { if (c.streamId.length !== STREAM_ID_BYTES) { throw new Error(`streamId must be ${STREAM_ID_BYTES} bytes`); } if (c.nonce.length !== STREAM_NONCE_BYTES) { throw new Error(`nonce must be ${STREAM_NONCE_BYTES} bytes`); } if (!Number.isInteger(c.laneId) || c.laneId < 0 || c.laneId > 0xffff_ffff) { throw new Error(`laneId out of u32 range: ${c.laneId}`); } const seqBig = typeof c.seq === 'bigint' ? c.seq : BigInt(c.seq); if (seqBig < 0n || seqBig > 0xffff_ffff_ffff_ffffn) { throw new Error(`seq out of u64 range: ${c.seq}`); } const headerSize = 1 + 1 + STREAM_ID_BYTES + 4 + 8 + 1 + STREAM_NONCE_BYTES + 4 + c.aad.length + 4; const out = new Uint8Array(headerSize + c.ciphertext.length); const view = new DataView(out.buffer); let offset = 0; out[offset++] = VERSION; out[offset++] = TYPE_STREAM_CHUNK; out.set(c.streamId, offset); offset += STREAM_ID_BYTES; view.setUint32(offset, c.laneId, false); offset += 4; view.setBigUint64(offset, seqBig, false); offset += 8; out[offset++] = c.isLast ? 0x01 : 0x00; out.set(c.nonce, offset); offset += STREAM_NONCE_BYTES; view.setUint32(offset, c.aad.length, false); offset += 4; out.set(c.aad, offset); offset += c.aad.length; view.setUint32(offset, c.ciphertext.length, false); offset += 4; out.set(c.ciphertext, offset); return out; } // ─── Decode ────────────────────────────────────────────────── export function decodeEnvelope(data: Uint8Array): ShadeEnvelope { if (data.length < 2) throw new Error('Too short'); const version = data[0]; if (version !== VERSION) throw new Error(`Unknown version: ${version}`); const type = data[1]; const payload = data.slice(2); if (type === TYPE_PREKEY) { const msg = decodePreKeyMessageInner(payload); return { type: 'prekey', content: msg, timestamp: 0, senderAddress: '' }; } if (type === TYPE_RATCHET) { const msg = decodeRatchetMessageInner(payload, 0).value; return { type: 'ratchet', content: msg, timestamp: 0, senderAddress: '' }; } throw new Error(`Unknown type: ${type}`); } /** * Decode a stream-chunk envelope from wire bytes (type 0x11). * Throws if the data is malformed or the type tag is wrong. */ export function decodeStreamChunk(data: Uint8Array): StreamChunkWire { const minHeaderSize = 2 + STREAM_ID_BYTES + 4 + 8 + 1 + STREAM_NONCE_BYTES + 4 + 4; if (data.length < minHeaderSize) { throw new Error(`stream-chunk too short: ${data.length} < ${minHeaderSize}`); } if (data[0] !== VERSION) { throw new Error(`Unknown version: ${data[0]}`); } if (data[1] !== TYPE_STREAM_CHUNK) { throw new Error(`Not a stream-chunk: type=${data[1]}`); } const view = new DataView(data.buffer, data.byteOffset); let offset = 2; const streamId = data.slice(offset, offset + STREAM_ID_BYTES); offset += STREAM_ID_BYTES; const laneId = view.getUint32(offset, false); offset += 4; const seq = view.getBigUint64(offset, false); offset += 8; const isLast = data[offset] === 0x01; offset += 1; const nonce = data.slice(offset, offset + STREAM_NONCE_BYTES); offset += STREAM_NONCE_BYTES; const aadLen = view.getUint32(offset, false); offset += 4; if (offset + aadLen + 4 > data.length) { throw new Error('stream-chunk truncated in aad/ctLen'); } const aad = data.slice(offset, offset + aadLen); offset += aadLen; const ctLen = view.getUint32(offset, false); offset += 4; if (offset + ctLen !== data.length) { throw new Error( `stream-chunk length mismatch: declared ${offset + ctLen}, actual ${data.length}`, ); } const ciphertext = data.slice(offset, offset + ctLen); return { streamId, laneId, seq, isLast, nonce, aad, ciphertext }; } /** * Inspect the type tag of an arbitrary envelope without full parsing. * Returns one of `'prekey' | 'ratchet' | 'stream-chunk' | 'broadcast' | 'unknown'`. */ export function inspectEnvelopeType( data: Uint8Array, ): 'prekey' | 'ratchet' | 'stream-chunk' | 'broadcast' | 'unknown' { if (data.length < 2 || data[0] !== VERSION) return 'unknown'; switch (data[1]) { case TYPE_PREKEY: return 'prekey'; case TYPE_RATCHET: return 'ratchet'; case TYPE_STREAM_CHUNK: return 'stream-chunk'; case TYPE_BROADCAST: return 'broadcast'; default: return 'unknown'; } } // ─── Broadcast wire (V4.6) ─────────────────────────────────── const BROADCAST_NONCE_BYTES = 12; const BROADCAST_SIGNATURE_BYTES = 64; /** * Encode a broadcast envelope to wire bytes (type 0x21). * * Layout: * [version:1][type=0x21:1] * [channelIdLen:u16][channelId utf-8] * [senderAddrLen:u16][senderAddr utf-8] * [generation:u32] * [iteration:u32] * [nonce:12] * [signature:64] * [ctLen:u32][ciphertext] */ export function encodeBroadcast(b: BroadcastWire): Uint8Array { if (b.nonce.length !== BROADCAST_NONCE_BYTES) { throw new Error(`broadcast nonce must be ${BROADCAST_NONCE_BYTES} bytes`); } if (b.signature.length !== BROADCAST_SIGNATURE_BYTES) { throw new Error(`broadcast signature must be ${BROADCAST_SIGNATURE_BYTES} bytes`); } const enc = new TextEncoder(); const channelIdBytes = enc.encode(b.channelId); const senderBytes = enc.encode(b.senderAddress); if (channelIdBytes.length > 0xffff) throw new Error('channelId too long'); if (senderBytes.length > 0xffff) throw new Error('senderAddress too long'); const headerSize = 1 + 1 + 2 + channelIdBytes.length + 2 + senderBytes.length + 4 + 4 + BROADCAST_NONCE_BYTES + BROADCAST_SIGNATURE_BYTES + 4; const out = new Uint8Array(headerSize + b.ciphertext.length); const view = new DataView(out.buffer); let offset = 0; out[offset++] = VERSION; out[offset++] = TYPE_BROADCAST; view.setUint16(offset, channelIdBytes.length, false); offset += 2; out.set(channelIdBytes, offset); offset += channelIdBytes.length; view.setUint16(offset, senderBytes.length, false); offset += 2; out.set(senderBytes, offset); offset += senderBytes.length; view.setUint32(offset, b.generation, false); offset += 4; view.setUint32(offset, b.iteration, false); offset += 4; out.set(b.nonce, offset); offset += BROADCAST_NONCE_BYTES; out.set(b.signature, offset); offset += BROADCAST_SIGNATURE_BYTES; view.setUint32(offset, b.ciphertext.length, false); offset += 4; out.set(b.ciphertext, offset); return out; } export function decodeBroadcast(data: Uint8Array): BroadcastWire { const minSize = 1 + 1 + 2 + 0 + 2 + 0 + 4 + 4 + BROADCAST_NONCE_BYTES + BROADCAST_SIGNATURE_BYTES + 4; if (data.length < minSize) { throw new Error(`broadcast envelope too short: ${data.length} < ${minSize}`); } if (data[0] !== VERSION) throw new Error(`Unknown version: ${data[0]}`); if (data[1] !== TYPE_BROADCAST) throw new Error(`Not a broadcast: type=${data[1]}`); const view = new DataView(data.buffer, data.byteOffset); const dec = new TextDecoder(); let offset = 2; const channelIdLen = view.getUint16(offset, false); offset += 2; if (offset + channelIdLen > data.length) throw new Error('broadcast truncated in channelId'); const channelId = dec.decode(data.slice(offset, offset + channelIdLen)); offset += channelIdLen; const senderLen = view.getUint16(offset, false); offset += 2; if (offset + senderLen > data.length) throw new Error('broadcast truncated in senderAddress'); const senderAddress = dec.decode(data.slice(offset, offset + senderLen)); offset += senderLen; const generation = view.getUint32(offset, false); offset += 4; const iteration = view.getUint32(offset, false); offset += 4; const nonce = data.slice(offset, offset + BROADCAST_NONCE_BYTES); offset += BROADCAST_NONCE_BYTES; const signature = data.slice(offset, offset + BROADCAST_SIGNATURE_BYTES); offset += BROADCAST_SIGNATURE_BYTES; const ctLen = view.getUint32(offset, false); offset += 4; if (offset + ctLen !== data.length) { throw new Error(`broadcast length mismatch: declared ${offset + ctLen}, actual ${data.length}`); } const ciphertext = data.slice(offset, offset + ctLen); return { channelId, senderAddress, generation, iteration, nonce, signature, ciphertext }; } function decodePreKeyMessageInner(data: Uint8Array): PreKeyMessage { let offset = 0; const registrationId = readUint32(data, offset); offset += 4; const preKeyIdRaw = readUint32(data, offset); offset += 4; const preKeyId = preKeyIdRaw === 0xFFFFFFFF ? undefined : preKeyIdRaw; const signedPreKeyId = readUint32(data, offset); offset += 4; const ephemeral = readLP(data, offset); offset = ephemeral.end; const identityDH = readLP(data, offset); offset = identityDH.end; const ratchetData = readLP(data, offset); offset = ratchetData.end; const ratchet = decodeRatchetMessageInner(ratchetData.value, 0); const msg: PreKeyMessage = { registrationId, signedPreKeyId, ephemeralKey: ephemeral.value, identityDHKey: identityDH.value, message: ratchet.value, }; if (preKeyId !== undefined) msg.preKeyId = preKeyId; return msg; } function decodeRatchetMessageInner(data: Uint8Array, offset: number): { value: RatchetMessage; end: number } { const dhPub = readLP(data, offset); offset = dhPub.end; const prevCounter = readUint32(data, offset); offset += 4; const counter = readUint32(data, offset); offset += 4; const ciphertext = readLP(data, offset); offset = ciphertext.end; const nonce = readLP(data, offset); offset = nonce.end; return { value: { dhPublicKey: dhPub.value, previousCounter: prevCounter, counter, ciphertext: ciphertext.value, nonce: nonce.value, }, end: offset, }; } // ─── Helpers ───────────────────────────────────────────────── function uint32(n: number): Uint8Array { const buf = new Uint8Array(4); new DataView(buf.buffer).setUint32(0, n, false); return buf; } function lpBytes(data: Uint8Array): Uint8Array { const len = new Uint8Array(4); new DataView(len.buffer).setUint32(0, data.length, false); return concat([len, data]); } function readUint32(data: Uint8Array, offset: number): number { return new DataView(data.buffer, data.byteOffset + offset).getUint32(0, false); } function readLP(data: Uint8Array, offset: number): { value: Uint8Array; end: number } { const len = new DataView(data.buffer, data.byteOffset + offset).getUint32(0, false); const value = data.slice(offset + 4, offset + 4 + len); return { value, end: offset + 4 + len }; } function concat(parts: Uint8Array[]): Uint8Array { const total = parts.reduce((sum, p) => sum + p.length, 0); const result = new Uint8Array(total); let offset = 0; for (const p of parts) { result.set(p, offset); offset += p.length; } return result; }