63 lines
2.1 KiB
TypeScript
63 lines
2.1 KiB
TypeScript
|
|
import { ValidationError } from '@shade/core';
|
||
|
|
|
||
|
|
export const STREAM_NONCE_BYTES = 12;
|
||
|
|
|
||
|
|
/** Maximum chunk seq value (u64 max). Hard-spec'd hard limit. */
|
||
|
|
export const MAX_SEQ = 0xffff_ffff_ffff_ffffn;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Construct the deterministic AES-GCM nonce for a stream chunk.
|
||
|
|
*
|
||
|
|
* nonce[0..4] = u32_be(laneId)
|
||
|
|
* nonce[4..12] = u64_be(seq)
|
||
|
|
*
|
||
|
|
* Per (laneId, seq) is unique — combined with the lane-specific key, this
|
||
|
|
* guarantees AES-GCM nonce-uniqueness even across multiple parallel lanes.
|
||
|
|
*/
|
||
|
|
export function buildChunkNonce(laneId: number, seq: number | bigint): Uint8Array {
|
||
|
|
if (!Number.isInteger(laneId) || laneId < 0 || laneId > 0xffff_ffff) {
|
||
|
|
throw new ValidationError(`laneId must fit in u32: ${laneId}`, 'laneId');
|
||
|
|
}
|
||
|
|
const seqBig = typeof seq === 'bigint' ? seq : BigInt(seq);
|
||
|
|
if (seqBig < 0n || seqBig > MAX_SEQ) {
|
||
|
|
throw new ValidationError(`seq must fit in u64 (>= 0): ${seq}`, 'seq');
|
||
|
|
}
|
||
|
|
const out = new Uint8Array(STREAM_NONCE_BYTES);
|
||
|
|
const view = new DataView(out.buffer);
|
||
|
|
view.setUint32(0, laneId, false);
|
||
|
|
view.setBigUint64(4, seqBig, false);
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build the AAD bound to a stream chunk. Computed implicitly on both sides
|
||
|
|
* from the chunk header (never transmitted as-is). Tampering with any header
|
||
|
|
* field invalidates the AEAD tag.
|
||
|
|
*
|
||
|
|
* aad = streamId(16) || u32_be(laneId) || u64_be(seq) || u8(isLast)
|
||
|
|
*/
|
||
|
|
export function buildChunkAad(
|
||
|
|
streamId: Uint8Array,
|
||
|
|
laneId: number,
|
||
|
|
seq: number | bigint,
|
||
|
|
isLast: boolean,
|
||
|
|
): Uint8Array {
|
||
|
|
if (streamId.length !== 16) {
|
||
|
|
throw new ValidationError('streamId must be 16 bytes', 'streamId');
|
||
|
|
}
|
||
|
|
if (!Number.isInteger(laneId) || laneId < 0 || laneId > 0xffff_ffff) {
|
||
|
|
throw new ValidationError(`laneId must fit in u32: ${laneId}`, 'laneId');
|
||
|
|
}
|
||
|
|
const seqBig = typeof seq === 'bigint' ? seq : BigInt(seq);
|
||
|
|
if (seqBig < 0n || seqBig > MAX_SEQ) {
|
||
|
|
throw new ValidationError(`seq must fit in u64 (>= 0): ${seq}`, 'seq');
|
||
|
|
}
|
||
|
|
const out = new Uint8Array(16 + 4 + 8 + 1);
|
||
|
|
out.set(streamId, 0);
|
||
|
|
const view = new DataView(out.buffer);
|
||
|
|
view.setUint32(16, laneId, false);
|
||
|
|
view.setBigUint64(20, seqBig, false);
|
||
|
|
out[28] = isLast ? 0x01 : 0x00;
|
||
|
|
return out;
|
||
|
|
}
|