first commit
This commit is contained in:
82
docs/api-stack/api/api-audit-stdout/src/hmac/hmac.md
Normal file
82
docs/api-stack/api/api-audit-stdout/src/hmac/hmac.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# `hmac` — canonical bytes + HMAC-SHA-256 signing
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## 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 and `cause` omitted when absent.
|
||||
- `signRecord(record, key)` — lowercase hex HMAC-SHA-256, computed
|
||||
via `@suaveplan/crypto-hash`'s `hmac("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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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): Uint8Array`
|
||||
- `signRecord(record: AuditRecord, key: Uint8Array): string` — hex.
|
||||
- `verifyRecord(record: AuditRecord, key: Uint8Array, sig: string): boolean`
|
||||
- `HMAC_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.
|
||||
Reference in New Issue
Block a user