2.9 KiB
hmac — canonical bytes + HMAC-SHA-256 signing
(no submodules)
Purpose
This module produces the HMAC-SHA-256 tag attached to every audit
line. Signing operates on a canonical byte representation of the
record (keys serialised in a fixed order, cause omitted when
absent) so the same logical record always produces the same MAC under
the same key, regardless of how the source object was constructed.
Verification recomputes the MAC and constant-time-compares to the
hex-decoded signature.
The point isn't to encrypt the record — the line is plaintext JSON written to stdout. The HMAC catches tampering downstream: a log collector or storage tier that re-writes a field to mask an audited action invalidates the MAC, and the auditor's verifier flags the discrepancy. The key never leaves the host; rotation is the caller's concern.
Features
canonicalBytes(record)— UTF-8 bytes of JSON with top-level keys in design-doc §12.5 order andcauseomitted when absent.signRecord(record, key)— lowercase hex HMAC-SHA-256, computed via@suaveplan/crypto-hash'shmac("SHA-256", …)primitive.verifyRecord(record, key, signatureHex)— constant-time match; rejects bad-length, non-hex, and tampered inputs.HMAC_FIELD— the"_hmac"field name the sink appends to the JSON line.
Usage: Basic
import { signRecord, verifyRecord } from "@suaveplan/api-audit-stdout/hmac";
const key = new Uint8Array(32);
const tag = signRecord(record, key);
const ok = verifyRecord(record, key, tag);
Usage: Advanced
import {
canonicalBytes,
HMAC_FIELD,
signRecord,
} from "@suaveplan/api-audit-stdout/hmac";
function emit(record: AuditRecord, key: Uint8Array): string {
// Equivalent to what the sink does internally, but caller-managed.
const line = JSON.parse(
new TextDecoder().decode(canonicalBytes(record))
) as Record<string, unknown>;
line[HMAC_FIELD] = signRecord(record, key);
return JSON.stringify(line);
}
API Reference
canonicalBytes(record: AuditRecord): Uint8ArraysignRecord(record: AuditRecord, key: Uint8Array): string— hex.verifyRecord(record: AuditRecord, key: Uint8Array, sig: string): booleanHMAC_FIELD: "_hmac"
Implementation Notes
The canonical encoder lists top-level keys explicitly rather than
sorting alphabetically — the design doc fixes the order so callers
reading raw lines see fields in the documented sequence. Nested
objects use natural insertion order; their shape is locked by the
Zod schema so the JSON.stringify output is deterministic. cause is
omitted (not serialised as null) when absent so a success record
without cause and a success record with cause: undefined
produce the same MAC. Hex was chosen over base64 because hex is
case-insensitive trivially and stays ASCII-safe through every common
log pipeline.