@aleutian/verification 0.0.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,597 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@aleutian/verification` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to a SemVer track per the
7
+ [SDK Release Charter](../../docs/sdk/release_charter_v1.md). While on the
8
+ 0.x track, minor bumps may introduce breaking changes; patch bumps remain
9
+ backwards-compatible. Cross-language version alignment activates only after
10
+ a 1.0 cut (see charter §5).
11
+
12
+ ## [Unreleased]
13
+
14
+ ## [0.7.0] — 2026-10-20
15
+
16
+ ### Added — X-Wing specification vectors
17
+
18
+ - `test/xwingSpecVectors.test.ts` holds the Tier-2 X-Wing decapsulation to the
19
+ specification's official vectors (`sdk/testdata/xwing_spec_vectors.json`, now
20
+ part of proof's vector set; `MANIFEST.json` SHA-256 `304583d41eca4e74e2fb19cd799f499e663d60d0366bb70f4f712db903264d3c`). It re-checks the
21
+ fixture against the SHAKE128 checksum the specification publishes, derives each
22
+ `pk` from its `sk`, and decapsulates each `ct` to `ss`. Until now this code was
23
+ checked only against vectors this project generated. The only source change is
24
+ an internal export of the two functions for the test, outside the package's
25
+ exports.
26
+
27
+ ### Added — proof v0.5.0 formats
28
+
29
+ Implemented from proof's format-spec (§2.0, §2.2, §2.5, §5.0, §5.5, §9),
30
+ bundle-format §16 and sink-format §9.1 alone, and held to all five new shared
31
+ vector files of the v0.5.0 set (`MANIFEST.json` SHA-256
32
+ `2eecb9f606ca3937849bea3d891eeed2fc4306d77ccf344b01ddf33ff4e0f514`). The
33
+ v2/v3/v6/bundle-v1 paths are unchanged and still pass their frozen vectors.
34
+ Dispatch is on declared versions only (`format_version`, anchor `version`,
35
+ bundle `format`).
36
+
37
+ - **Chain format v4**: `computeLeafHashV4`, `computeChainHashV4` (ids and
38
+ types hashed as stored, never normalised; length-prefixed). `verifyChainOffline`
39
+ verifies v4 entries, erased ones from their stored leaf hash (no chain hash
40
+ is taken on trust), and v2/v3 chains continued in v4 (§2.5). The walk applies
41
+ §5.5's break order: `format_field_misuse` for a v2/v3 entry after v4, a v4
42
+ member on a v2/v3 entry, or a v4 entry with run fields or a tombstone shape;
43
+ the v4 first-entry rule and the two entry shapes as `invalid_field`; leaf
44
+ hash before chain hash. New result field `erasedFound`. `parseEntriesText`
45
+ reads v4 entries (an erased one has no id, type or content hash) and records
46
+ which members were present (`presentMembers`). Held to every case of
47
+ `chain_v4_vectors.json` (vectors, `v3_prefix`, rejects) and the 39
48
+ `chain_cases` of `verify_verdict_v4_vectors.json`.
49
+ - **Anchors v7**: strict reading (`entry_count`, `start_seq`, `end_seq` are
50
+ JSON strings, returned as `bigint`, so values above 2^53 stay exact; every
51
+ member required), §9.3 invariants, `canonicalizeAnchorV7`,
52
+ `computeAnchorChainHashV7` (length-prefixed subject), and
53
+ `verifyAnchorLocally` with the ML-DSA context `aleutian.proof.anchor.v7`
54
+ (chosen from the declared version; v1–v6 keep the empty context).
55
+ `bindAnchorV7ToEntries(anchor, entries, { subject, previousAnchorHash })`
56
+ binds by sequence in §9.7's order and reports `subjectChecked`; new outcome
57
+ `tenant_mismatch`. `bindAnchorToEntries` and `verifyChainOffline` route a v7
58
+ anchor there. Held to `anchor_v7_canonical_vectors.json` (canonical bytes,
59
+ chain hash, signatures, rejects) and the 23 `bind_cases` of
60
+ `verify_verdict_v4_vectors.json`.
61
+ - **Record envelope `aleutian.proof.record.v2`**: `buildRecordEnvelopeV2`,
62
+ `verifyRecordSignatureV2` (field 0x09 leaf hash, context
63
+ `aleutian.proof.record.v2`), and `verifyMLDSA65PureWithContext`. Held to
64
+ `record_v2_vectors.json`.
65
+ - **Bundles `aleutian.proof.bundle.v2`** in `verifyProofBundle` and the
66
+ `verify-proof-bundle` CLI: entries with `salt` and `leaf_hash` (both
67
+ required; a v1-shaped or erased-shaped entry is `structure`), v7 checkpoints
68
+ (step e over entry `end_seq` by index, counts compared as integers), record.v2
69
+ signatures, and the v2 value limit of 17,000,000 (`PROOF_BUNDLE_LIMITS.valuesV2`).
70
+ Same result format and codes. Held to all 192 cases of `bundle_v2_vectors.json`,
71
+ and to each signature a case lists.
72
+
73
+ ### Changed — proof v0.5.0 vectors, revised
74
+
75
+ Held to the revised v0.5.0 vector set (`MANIFEST.json` SHA-256
76
+ `62b96a47373c1cf41b21af29ac23b9760eee205b00933d26769efcee09b5c662`):
77
+ 52 `chain_cases`, 20 `entries_text_cases` and 23 `bind_cases` in
78
+ `verify_verdict_v4_vectors.json`, 30 anchor v7 rejects, 22 chain v4 vectors
79
+ and 193 bundle v2 cases.
80
+
81
+ - The verdict vectors now pin the number of breaks a walk reports with every
82
+ break collected (§5.0, §5.5: at most one per entry from steps 2 to 8, after
83
+ a `sequence_gap`); the walk already agreed on every case.
84
+ - `parseEntriesText` refuses a member name that case-folds to a format member
85
+ without being it (`ſalt` with U+017F, `eraſed`), as it already refused
86
+ `CHAIN_HASH`: a case-folding reader takes either for the real member.
87
+ - A v7 anchor's `anchor_id` and `previous_anchor_id` must be non-empty, and
88
+ its `subject`, `anchor_id`, `previous_anchor_id` and `signing_key_id` valid
89
+ UTF-8 (no lone surrogate), in `checkAnchorWellFormed` (§9.3, §9.8). The
90
+ strict text reader already refused null members, invalid UTF-8 and lone
91
+ surrogate escapes.
92
+
93
+ ### Changed
94
+
95
+ - `canonicalizeAnchorForVersion` refuses a version with no canonical form
96
+ (5, or anything but 1–4, 6, 7) instead of canonicalizing it as v1.
97
+ - A v1–v6 anchor carrying `previous_anchor_hash` or a sequence range is
98
+ refused (format-spec §9.8), and a v2 or v3 entry carrying `salt`,
99
+ `leaf_hash` or `erased` is the break `format_field_misuse` (§2.2); both
100
+ members were ignored before.
101
+
102
+ Held to proof's SHARED vectors for chain hash v3, anchor v6 and key ids
103
+ (computed in Python from the format spec), instead of constants copied from Go.
104
+ They found two real disagreements with the format, both fixed here.
105
+
106
+ ### Fixed
107
+
108
+ - **Anchor canonical bytes now escape exactly as Go does** (format-spec §9.2):
109
+ `<` `>` `&` U+2028 U+2029 as `\u` escapes, in every anchor version. Before,
110
+ an anchor whose id or subject held one of them canonicalized differently from
111
+ the bytes that were signed, and failed verification.
112
+ - **Chain hash v3 refuses malformed inputs** (format-spec §2.1, §3), as proof
113
+ does: a `previous_hash` that is not empty or 128 lowercase hex, a
114
+ `content_hash` that is neither 128 lowercase hex nor a tombstone value, a
115
+ `global_seq` above 2^63 − 1, or a timestamp not in the `YYYY-MM-DDTHH:MM:SS.ffffffZ` form. The preimage is `|`-delimited and not
116
+ self-delimiting, so a malformed field could make two different entries hash
117
+ alike. *(Breaking only for callers that passed malformed values.)*
118
+ - **Proof bundle verifier, after review (`_72e`), to bundle-format revision 3.2:**
119
+ - **Bounded memory.** The bundle is read in one streaming pass that counts
120
+ JSON values (§5's new 15,000,000 limit, `too-large` at §9.1 position 3;
121
+ `ProofBundleLimits` gains `values`), knows each value's §4 position, stops
122
+ building at the first structure violation, duplicate name, wrong `format`
123
+ or count overrun, and decodes escaped strings without one allocation per
124
+ escape. 16 MiB of `{}` chains went from about 1.3 GB of heap to under
125
+ 1 MB; 64 MiB no longer crashes Node.
126
+ - **Piped CLI output is complete.** The bin sets `process.exitCode` instead
127
+ of calling `process.exit()`, which cut `--json | jq` at 64 KiB on macOS. An
128
+ error escaping the command prints a fixed message and exits 5, never a
129
+ stack.
130
+ - Checkpoint step e is linear (the first malformed entry is found once).
131
+ - Hashing in the bundle verifier is synchronous (`@noble/hashes`).
132
+ - Key files must be regular files of at most 16 KiB (a directory, FIFO or
133
+ larger file is a usage error); the bundle file is opened once and read up
134
+ to one byte past the 256 MiB limit.
135
+ - Unreadable human output states both trust dimensions (§10).
136
+ - A malformed `trust` argument throws `ProofBundleVerifierError`.
137
+ - The bundle verifier no longer imports `keys.ts`, so browser bundles of it
138
+ carry no embedded key fixtures: the v6 anchor canonicalization moved to
139
+ `anchorCanonical.ts` (`canonicalizeAnchorV6` is still exported unchanged).
140
+
141
+ ### Added
142
+
143
+ - Key ids (format-spec §9.6): `keyIdMLDSA65`, `keyIdXWing`, `mldsa65Spki`. A verifier uses them to check that the
144
+ key it verifies a signature with really has the key id the signature names.
145
+ - The commitment and `record.v1` vectors are vendored too, ready for the sink
146
+ bundle verifier (`_72`).
147
+ - **Proof sink export bundles** (`aleutian.proof.bundle.v1`, proof's
148
+ `bundle-format.md` revision 3.2): `verifyProofBundle(bytes, { checkpointKeys,
149
+ recordKeys })` returns the `aleutian.proof.bundle-result.v1` result, and the
150
+ CLI gains `aleutian-verify verify-proof-bundle <bundle.json>
151
+ [--checkpoint-key F]... [--record-key F]... [--json]` (exit 0 ok, 1 failed,
152
+ 2 usage, 3 unreadable, 4 unauthenticated, 5 internal error). Written from the
153
+ spec alone, independently of proof's code, and held to all 114 cases of the
154
+ shared `bundle_v1_vectors.json`. A strict JSON parser enforces the bundle
155
+ grammar (valid UTF-8, no BOM, no lone surrogates, duplicate names refused
156
+ after unescaping, depth 8, numbers never rounded); `global_seq` is a BigInt.
157
+ Signatures are pure ML-DSA-65 with an empty context: context and HashML-DSA
158
+ signatures fail. Output never prints a bundle value other than a valid chain
159
+ id. Browser-safe.
160
+ - `parseProofPublicKeyFile`: trust key files (one `PUBLIC KEY` PEM block, the
161
+ RFC 9881 SPKI of an ML-DSA-65 key); anything else is refused.
162
+ - Salted commitments (format-spec §8): `computeCommitment`, `openCommitment`.
163
+ - The record-signature envelope (sink-format §9.1): `buildRecordEnvelopeV1`,
164
+ `verifyRecordSignatureV1`, `verifyMLDSA65Pure`. Held to the commitment and
165
+ `record.v1` vectors, including the pinned regression signature.
166
+
167
+ ## [0.6.0] — 2026-09-26
168
+
169
+ _Prepared, never published._ This version was built and gated for chain format
170
+ v3 and anchor v6, but never uploaded. Its contents shipped first in 0.7.0, with
171
+ everything in that entry. This number is skipped.
172
+
173
+ First release to implement the CURRENT AleutianChain formats, and the first
174
+ held to shared cross-language conformance vectors.
175
+
176
+ ### Added
177
+
178
+ - **Chain hash v3** (`computeChainHashV3`, `CHAIN_HASH_PREFIX_V3`), which binds
179
+ the chain-wide `globalSeq` rather than v2's batch-local `sequenceNum`, making
180
+ a chain independent of how it was grouped into append calls.
181
+ - **`globalSeq` accepts `bigint`.** The format's sequence is a 64-bit integer
182
+ and JavaScript's `number` holds 53 bits exactly: the reference
183
+ implementation's own test vector `9007199254740993` becomes `…992` as a
184
+ literal AND through `JSON.parse`. Passing a `number` above
185
+ `Number.MAX_SAFE_INTEGER` now THROWS rather than hashing a value that is not
186
+ the one on the wire — a wrong digest surfaces downstream as a chain break on
187
+ intact data.
188
+ - **Format dispatch** in `verifyChainOffline`; `formatVersion` absent or 0 means
189
+ v2, which keeps every pre-v3 chain verifiable.
190
+ - **Anchor v6** (`canonicalizeAnchorV6`), where `subject` replaces `company_id`
191
+ inside the signed bytes and moves from 3rd alphabetically to 8th. v5 remains
192
+ refused.
193
+ - Break types `unknown_format` and `format_field_misuse`, neither of which is
194
+ tamper evidence.
195
+ - **Conformance vector enforcement** (`test/conformanceVectors.test.ts`).
196
+
197
+ ### Changed
198
+
199
+ - **BREAKING: `Anchor.companyId` is now `Anchor.subject`.** The wire key still
200
+ follows the anchor's version, and `parseAnchor` accepts either spelling —
201
+ refusing two different values under them.
202
+
203
+ ### Fixed
204
+
205
+ - The Security section claimed **"Ed25519 signatures via tweetnacl"**. Anchors
206
+ are ML-DSA-65; tweetnacl is used for X25519 key exchange in the tier-2 unseal
207
+ path, not for signatures. It also said HMAC-SHA256 while the feature list nine
208
+ lines above said HMAC-SHA-512 — the latter is correct.
209
+
210
+ ## [0.5.0] — 2026-08-20
211
+
212
+ Closes `consent_rendered_hash_06`. `hashRendered` computed unkeyed SHA-512,
213
+ which has never matched production server output — the server has
214
+ HMAC-keyed this hash since 2026-04-16 (`18_20_review_01`), closing a
215
+ rainbow-table oracle on PII-adjacent rendered consent bytes. This release
216
+ makes the SDK match.
217
+
218
+ ### Changed
219
+
220
+ - **BREAKING: `hashRendered(b: Uint8Array): Promise<string>` is now
221
+ `hashRendered(b: Uint8Array, tenantPepper: Uint8Array): string`** —
222
+ synchronous, and moved to a new module (`consentRenderedHash.ts`). There
223
+ is no unkeyed variant to fall back to. Callers obtain `tenantPepper` via
224
+ the new consent-pepper reveal endpoint
225
+ (`POST /v1/internal/consent/rendered-pepper/reveal`,
226
+ `consent_rendered_hash_04`) before calling `hashRendered`.
227
+ - **BREAKING: `hashRendered` is no longer exported from the browser bundle**
228
+ (`@aleutian/verification/browser`, `dist/browser/index.js`). Its input now
229
+ includes a SECRET tenant pepper, which must never ship into
230
+ browser-executable code — same reasoning that already excludes the
231
+ `webhook` module from the browser build. Node-only; import from the
232
+ default (Node) entry point.
233
+
234
+ ## [0.4.0] — 2026-06-14
235
+
236
+ Closes `sdk_anchor_binding_gap`. Previous SDK releases could not validate
237
+ real Aleutian-produced anchors because the anchor's `chain_hash` lives in
238
+ a different SHA-512 domain (`"aleutian.anchor.v2:"`) than the per-entry
239
+ `chain_hash` (`"aleutian.chain.v2:"`). `verifyChainOffline` was doing raw
240
+ byte-equality between the walked tip and `anchor.chainHash` — a comparison
241
+ that can never succeed for real producer output. This release ships the
242
+ missing binding primitive and rewrites the verification step to use it.
243
+
244
+ ### Added
245
+
246
+ - **`computeAnchorChainHash(prevAnchorHash, companyId, startEntryId, endEntryId, tipChainHash)`** — async; byte-identical to the Go SDK `ComputeAnchorChainHash` and the producer at `internal/chainlinker/anchor_generator.go`. Recomputes the SHA-512 over the `"aleutian.anchor.v2:"` domain that the producer stores in `anchors.chain_hash`.
247
+ - **`SEED_ANCHOR_HASH`** — the genesis `previousAnchorHash` (`SHA-512("aleutian.anchor.seed.v2")`), used as the prior-anchor input when verifying the first anchor in a company's chain.
248
+ - **`SEED_ANCHOR_ID`** — sentinel `previousAnchorId` for genesis anchors.
249
+ - **`ANCHOR_CHAIN_DOMAIN_V2`** — exported domain-separator constant, for callers reimplementing the hash in non-SDK code.
250
+ - **`ChainVerifyOptions.anchor`** + **`ChainVerifyOptions.previousAnchorHash`** — the new supported anchor-binding API. Pass the full anchor and the prior anchor's `chainHash`; the SDK recomputes the expected anchor digest and constant-time compares against `anchor.chainHash`. Empty `previousAnchorHash` + `anchor.previousAnchorId === SEED_ANCHOR_ID` auto-fills `SEED_ANCHOR_HASH` for convenience.
251
+ - New CLI flag on `aleutian-verify`: `--previous-anchor-hash <hex>`. Genesis anchors can omit it; mid-chain anchors require it.
252
+
253
+ ### Changed
254
+
255
+ - `Anchor.chainHash` doc-comment corrected — clarifies that this field is the anchor-of-anchors digest (output of `computeAnchorChainHash`), NOT the tip entry's `chain_hash`. Verifiers must recompute, not byte-compare.
256
+ - README + module doc-comments updated to the new pattern; the prior examples referenced `anchor.chainState.lastChainHash`, a field that never existed on the public `Anchor` type.
257
+
258
+ ### Deprecated
259
+
260
+ - **`ChainVerifyOptions.expectedLastHash`** — the raw-equality path that could never bind real producer anchors. Preserved this release for any caller still passing it; will be removed in the next minor.
261
+
262
+ ### Migration
263
+
264
+ ```ts
265
+ // Before (0.3.x — could not bind real anchors)
266
+ const result = await verifyChainOffline(entries, {
267
+ expectedLastHash: anchor.chainHash,
268
+ });
269
+
270
+ // After (0.4.0)
271
+ import { SEED_ANCHOR_HASH } from "@aleutian/verification";
272
+ const result = await verifyChainOffline(entries, {
273
+ anchor,
274
+ // For genesis anchors pass SEED_ANCHOR_HASH (or omit; SDK auto-fills).
275
+ // For mid-chain anchors pass the prior anchor's chainHash here.
276
+ previousAnchorHash: SEED_ANCHOR_HASH,
277
+ });
278
+ ```
279
+
280
+ ### Verification
281
+
282
+ Pinned by cross-language golden vector (byte-identical Go + Python + JS),
283
+ SDK↔producer byte-equality regression test, and end-to-end live-BQ smoke
284
+ against real `duncan` production data (`scripts/smoke-sdk-live.sh`).
285
+
286
+ ## [0.3.1] — 2026-05-29
287
+
288
+ Patch release shipping two deferred items from
289
+ `dsar_chain_f_post_review_followups`: an additive options-object API
290
+ shape for `verifyChainEntryV3` (M1) and elimination of the redundant
291
+ per-field NFC re-normalization on the canonical-bytes hot path (M6).
292
+ No canonical-bytes change for NFC inputs (the only contract that
293
+ matters cross-language).
294
+
295
+ ### Added
296
+
297
+ - **`verifyChainEntryV3` options-object form (M1).** The function now
298
+ accepts a second-shape call:
299
+
300
+ ```ts
301
+ verifyChainEntryV3(entry, { signature, contentHash, trustManifest })
302
+ ```
303
+
304
+ in addition to the existing positional form. The new form is
305
+ preferred going forward; it removes positional-argument-order
306
+ bugs and is the only form supported by the 0.4.0 cut. A new
307
+ `VerifyChainEntryV3Options` interface is exported. The new shape
308
+ emits no deprecation warning.
309
+
310
+ ### Deprecated
311
+
312
+ - **Positional form of `verifyChainEntryV3`.** Calling
313
+ `verifyChainEntryV3(entry, signature, contentHash, trustManifest)`
314
+ still works on the 0.3.x track but emits a **one-shot**
315
+ `console.warn` notice on first use per process. Migrate to the
316
+ options-object form. Positional form will be **removed** in 0.4.0.
317
+
318
+ ### Changed
319
+
320
+ - **Double-NFC encoding eliminated (M6).** `V3Encoder` gained a
321
+ constructor flag `{ assumeNormalized: true }` that skips the
322
+ redundant `s.normalize("NFC")` call in `appendStr` on the
323
+ assumption that the caller has already validated. The internal
324
+ `chainCanonicalV3` pipeline now constructs its encoder with this
325
+ flag, saving one `String.normalize` call per field on the hot path.
326
+ `validateV3StringField` now rejects inputs that are not already in
327
+ Unicode NFC form to make the flag sound (defense-in-depth — every
328
+ v3 string field today is restricted to ASCII via upstream regex, so
329
+ the gate is unreachable on well-formed inputs and no real caller
330
+ is affected). Canonical bytes are unchanged for all NFC inputs;
331
+ golden vectors and cross-language pins still pass.
332
+
333
+ ### References
334
+
335
+ - Ticket: `tickets/code_complete/dsar_chain_f_post_review_followups.md`
336
+ - Parent: `dsar_chain_f_review_pre_emptive` (closed 2026-05-29)
337
+
338
+ ## [0.3.0] — 2026-05-29
339
+
340
+ This release closes the six post-V1/V2-audit JS SDK fix tickets from the
341
+ `dsar_js_v1v2_audit_pass` sweep. One TypeScript-typing widening is a
342
+ breaking change at the type level (no runtime behaviour change for
343
+ in-range values), so this lands as a minor bump on the 0.x track per the
344
+ release charter.
345
+
346
+ ### Breaking (types only)
347
+
348
+ - **`Anchor` int64 fields widened to `bigint | number`.**
349
+ `Anchor.entryCount`, `Anchor.createdAtMs`, and `Anchor.verifiedThrough`
350
+ are now typed `bigint | number` so int64 values exceeding
351
+ `Number.MAX_SAFE_INTEGER` (2^53 − 1) can be represented losslessly.
352
+ Real-world millisecond timestamps and entry counts continue to fit
353
+ in `number` and require no caller change. Callers that explicitly
354
+ declared variables as `number` will see a type error and should
355
+ widen to `bigint | number` or narrow with a `typeof === "number"`
356
+ guard. `parseAnchor` accepts JSON `number` (legacy) **or** JSON
357
+ `string` (full int64 precision) for int64 fields. The
358
+ `canonicalizeAnchor` / `canonicalizeAnchorV4` encoders manually
359
+ assemble the canonical JSON so `bigint` values emit byte-identically
360
+ to Go/Python without going through the lossy `JSON.stringify(bigint)`
361
+ path (which throws). `canonicalizeAnchorForVersion` is now exported
362
+ from the package root. Ticket: `dsar_js_anchor_int64_widening`.
363
+
364
+ ### Security / Privacy
365
+
366
+ - **`InvalidProcessingMode` no longer echoes caller input** into its
367
+ error message. The message is now a fixed string
368
+ `"unknown processing mode (input redacted)"` so callers cannot
369
+ accidentally surface untrusted bytes through logs. Enforces Privacy
370
+ Invariant 4 at the SDK boundary. Ticket:
371
+ `dsar_js_processing_mode_error_echo`.
372
+ - **Defensive copy on trust-manifest and embedded public keys.**
373
+ `TrustManifest.publicKey()` and `getPublicKey()` now return a fresh
374
+ `Uint8Array` per call so caller mutation can no longer corrupt the
375
+ cached/embedded key bytes. Ticket:
376
+ `dsar_js_trust_manifest_key_mutability`.
377
+ - **Lone-surrogate gate on canonical-encoder string inputs.**
378
+ `consentCanonical` and `encodeProcessingModeCanonicalFields` now
379
+ reject strings containing unpaired UTF-16 surrogates with a
380
+ redacted-byte error, preventing `TextEncoder`'s silent
381
+ U+FFFD-substitution from diverging the JS encoder from the Go /
382
+ Python producers. Well-formed surrogate pairs (emoji etc.) continue
383
+ to pass through. Ticket: `dsar_js_consent_lone_surrogate_gate`.
384
+
385
+ ### Added
386
+
387
+ - **`redactedView` module.** Four helpers — `redactedAnchor`,
388
+ `redactedConsentChainEntry`, `redactedExportedEntry`,
389
+ `redactedOfflineBreak` — return frozen opaque markers
390
+ (`{ __type: "<TypeName>", __redacted: true }`) safe to pass into
391
+ `console.log`, `JSON.stringify`, structured loggers, or template
392
+ literals. The producer interfaces are plain object literals (cannot
393
+ own a `toJSON` override), so the WARNING comments on `Anchor`,
394
+ `ConsentChainEntry`, `ExportedEntry`, and `OfflineBreak` now point
395
+ callers at the matching helper. Ticket:
396
+ `dsar_js_consent_anchor_json_stringify_leak`.
397
+
398
+ ### Internal
399
+
400
+ - **Consolidated constant-time comparator in `verify.ts`.** The local
401
+ string-XOR `constantTimeEqual` was replaced with the shared
402
+ `internal/constantTime.ts` byte comparator (which prefers Node's
403
+ C-level `crypto.timingSafeEqual` when available and falls back to a
404
+ non-short-circuiting pure-JS loop in browsers). A thin
405
+ `chainHashEqual` wrapper handles the hex-string → UTF-8 byte
406
+ encoding so the call sites stay ergonomic. Strictly better timing
407
+ profile on Node; no behavioural change on browsers. Ticket:
408
+ `dsar_js_verify_consolidate_constant_time`.
409
+
410
+ ### Migration
411
+
412
+ If you previously declared anchor int64 fields as `number`:
413
+
414
+ ```ts
415
+ // Before
416
+ const count: number = anchor.entryCount;
417
+
418
+ // After (choose one)
419
+ const count: bigint | number = anchor.entryCount;
420
+ // or, if you know the value fits in Number:
421
+ const count = typeof anchor.entryCount === "bigint"
422
+ ? Number(anchor.entryCount)
423
+ : anchor.entryCount;
424
+ ```
425
+
426
+ For producers emitting int64 values that can exceed
427
+ `Number.MAX_SAFE_INTEGER`, ship them as JSON strings; `parseAnchor`
428
+ will promote them to `bigint`. For values that fit in `number`, the
429
+ existing JSON-number form continues to work unchanged.
430
+
431
+ ## [0.2.2] — 2026-05-29
432
+
433
+ ### Added
434
+
435
+ - **Browser bundle.** A dedicated browser-target build now ships
436
+ alongside the Node bundle:
437
+ - `dist/browser/index.js` (ESM) — for bundlers (Vite, webpack, esbuild, Rollup)
438
+ - `dist/browser/index.global.js` (IIFE) — for direct `<script>` includes,
439
+ exposes a `window.AleutianVerification` global
440
+ - `dist/browser/index.d.ts` — typings
441
+ - `package.json` `exports` map gains a `"browser"` conditional, so
442
+ bundlers automatically resolve to the browser bundle when building
443
+ for a browser target. A `"./browser"` sub-path is also exposed for
444
+ explicit opt-in.
445
+ - `src/internal/constantTime.ts` — shared cross-runtime constant-time
446
+ byte-array equality. `verifyV3.ts` and `consent.ts` both consume it;
447
+ the previous duplication and `consent.ts`'s top-level static
448
+ `import … from "crypto"` (a module-load blocker in browsers) are
449
+ gone.
450
+ - `src/anchorLoaders.ts` — Node-only `loadAnchor` /
451
+ `loadExportedEntries` split out of `anchor.ts` so the browser bundle
452
+ does not pull `await import("fs/promises")` into its reachable
453
+ graph. Re-exported from `src/index.ts` for back-compat; intentionally
454
+ not re-exported from the browser entry.
455
+ - `test/browserBundle.smoke.test.ts` — happy-dom-environment smoke
456
+ that imports the built `dist/browser/index.js`, re-encodes a
457
+ Go-baked golden V3 fixture, and asserts byte-identical content
458
+ hash. Also pins that the webhook surface is absent from the browser
459
+ bundle.
460
+
461
+ ### Changed
462
+
463
+ - `consent.ts` `verifyConsentEntry`: `Buffer.from(s, "utf8")` →
464
+ `new TextEncoder().encode(s)` for cross-runtime byte construction.
465
+ No behavioral change in Node; unblocks browser usage.
466
+
467
+ ### Notes
468
+
469
+ - The Node bundle (`dist/index.js`, `dist/index.cjs`) is **unchanged**
470
+ in API or behavior — webhook verification, file-system loaders, and
471
+ every other previously-exported symbol remain on the Node entry.
472
+ - Webhook verification is intentionally absent from the browser bundle
473
+ (server→server only; existing API is sync HMAC-SHA-512 with no
474
+ async-free browser equivalent). See README "Browser Support" for
475
+ detail.
476
+ - See `dsar_js_browser_target_audit` for the audit and design.
477
+
478
+ ## [0.2.1] — 2026-05-29
479
+
480
+ ### Changed
481
+
482
+ - **License flipped to Apache 2.0.** Resolves the prior `UNLICENSED` state
483
+ documented in the 0.2.0 notes. The SDK is now openly distributed under
484
+ Apache License 2.0 — `package.json` `license` field updated, full LICENSE
485
+ file added at the module root, per-file Apache 2.0 short-form headers
486
+ applied across all source and test files. Decision locked via
487
+ `sdk_license_unification` ticket Option D (per-module rule in
488
+ CLAUDE.md §2.2). Aligns with the Go SDK and Python SDK, which were
489
+ flipped to Apache 2.0 in the same change. Customers integrating the
490
+ prior 0.2.0 release should re-resolve from this version to pick up the
491
+ Apache 2.0 license file in their build artifacts; no source or
492
+ behavioral changes in this release.
493
+
494
+ ## [0.2.0] — 2026-05-28
495
+
496
+ Added full V3 SAR (Data Subject Access Request) chain entry verification at
497
+ byte-level parity with the Go and Python SDK implementations.
498
+
499
+ ### Added
500
+
501
+ **`canonicalV3` module** — TS port of the V3 canonical encoder
502
+
503
+ - `chainCanonicalV3(entry: ChainEntryV3): Uint8Array` — encode a typed V3
504
+ chain entry to the canonical TLV byte sequence per pqc_05c v3 §3 + §4.
505
+ - `computeV3ContentHash(canonical: Uint8Array): Promise<Uint8Array>` —
506
+ SHA-512(`aleutian.chain.entry.v3:` || canonical).
507
+ - `encodeAndHashV3(entry)` — one-call convenience pairing the above.
508
+ - `validateV3Entry(entry: ChainEntryV3): void` — typed V3 entry validator;
509
+ throws `ErrInvalidEntryV3` on any failure. Called automatically by
510
+ `chainCanonicalV3` before encoding.
511
+ - `V3Encoder` class — exported for direct unit testing of encoder mechanics
512
+ (NFC normalization, BE encoding, length cap) without routing through the
513
+ full pipeline.
514
+ - Constants: `DOMAIN_PREFIX_V3`, `DOMAIN_PREFIX_V3_BYTES`,
515
+ `MAX_V3_STRING_FIELD_BYTES = 256`, `MAX_CANONICAL_BYTES_V3 = 4096`,
516
+ `V3_SHA512_HEX_LEN = 128`, `EXTENDED_SANITY_BOUND_MS`.
517
+
518
+ **`verifyV3` module** — V3 signature verifier
519
+
520
+ - `verifyChainEntryV3(entry, signature, contentHash, trustManifest)` — the
521
+ single customer-facing verifier entry point. Resolves on success; throws
522
+ one of the typed `Err*V3` subclasses on failure.
523
+ - `TrustManifestV3.fromMap({...})` — construct an in-process trust manifest
524
+ with **eager** ML-DSA-65 byte-length validation + defensive key copies.
525
+ - `constantTimeEqual(a, b)` — pure-JS constant-time byte-array equality
526
+ (browser-safe; no `crypto.timingSafeEqual` available there).
527
+ - Constant: `CONTENT_HASH_V3_BYTES = 64`.
528
+
529
+ **Error hierarchy** — all extend `ErrInvalidEntryV3`
530
+
531
+ - `ErrInvalidEntryV3` — encoder/validator base sentinel.
532
+ - `ErrContentHashMismatchV3` — re-encoded hash does not match the claim.
533
+ - `ErrSignatureInvalidV3` — signature parses but does not verify.
534
+ - `ErrSignatureMalformedV3` — signature has wrong shape (length, type).
535
+ - `ErrUnknownSigningKeyV3` — `signingKeyId` not in the trust manifest.
536
+ - `ErrPublicKeyMalformedV3` — trust-manifest public key has wrong shape.
537
+
538
+ **5 SAR entry type interfaces + supporting enums**
539
+
540
+ - `SARIntentEntryV3`, `SARAcknowledgedEntryV3`, `SARFulfilledEntryV3`
541
+ (includes `certificateId` as the alphabetical-first body field per
542
+ `dsar_fulfill_b_pre_02e`), `SARRejectedEntryV3`, `SARExtendedEntryV3`.
543
+ - `ChainEntryV3` discriminated union over `entryType`.
544
+ - `EventTypeV3`, `SARTypeV3`, `SARFulfillmentKindV3`, `SARSourceChannelV3`
545
+ literal types.
546
+ - `TenantHMACDigestV3` branded type + `tenantHMACDigestFromHex` /
547
+ `tenantHMACDigestFromRaw` / `tenantHMACDigestZero` / `tenantHMACDigestIsZero`
548
+ constructors and predicates.
549
+ - `CONCRETE_SAR_ENTRY_TYPE_TAGS` runtime-immutable allowlist
550
+ (`Object.freeze` + `add`/`delete`/`clear` overridden to throw).
551
+ - `hasValidChainEntryTag(value)` — runtime guard against
552
+ structural-typing impostors. `isChainEntryV3` retained as a
553
+ deprecation alias.
554
+
555
+ ### Changed
556
+
557
+ - **Cross-language byte-identical canonical bytes against
558
+ `sdk/verification-go`** — proven by a new fixture-parity test
559
+ (`test/goldenV3.test.ts`) that loads the Go-baked golden vectors and
560
+ asserts byte-for-byte equality on canonical bytes AND content hashes
561
+ for all 5 SAR entry types (7 vectors total).
562
+ - **Privacy posture hardening** (Privacy Inv 4 / chain_d H2 throughout):
563
+ error messages do NOT echo caller-supplied values (offending field
564
+ contents, post-NFC byte lengths, claimed/computed hash bytes, or
565
+ `signingKeyId`). Regression-guarded by dedicated tests.
566
+ - **Hardened cross-language constant pins** — `MAX_CANONICAL_BYTES_V3 = 4096`
567
+ and SHA-512 hex length pinned identically to Go and Python SDKs.
568
+ - **Internal v3 regex pinning** — V3 regex patterns are now string-equality
569
+ pinned against the producer source-of-truth shape, so silent drift on
570
+ either side fails CI.
571
+
572
+ ### Cross-SDK status
573
+
574
+ After this release, all three Aleutian verification SDKs are at full V3 SAR
575
+ parity:
576
+
577
+ | Capability | Go | Python | JS (0.2.0) |
578
+ |---|---|---|---|
579
+ | Canonical encoder | ✓ | ✓ | ✓ |
580
+ | Validators | ✓ | ✓ | ✓ |
581
+ | Verifier | ✓ | ✓ | ✓ |
582
+ | TrustManifest | ✓ | ✓ | ✓ |
583
+ | Cross-language fixture parity | ✓ | ✓ | ✓ |
584
+
585
+ ### Notes
586
+
587
+ - License posture (`UNLICENSED` → AGPL-3.0-or-later) remains pending the
588
+ cross-SDK `sdk_license_unification` decision and is intentionally NOT
589
+ flipped in this release.
590
+ - Bundled fixtures live at `testdata/` and are exercised by the in-package
591
+ cross-language parity test.
592
+
593
+ ## [0.1.0] — Prior
594
+
595
+ V1/V2 anchor, chain, tombstone, and webhook verification primitives.
596
+ SHA-512-based chain hashing (post-`dsar_chain_f_pre_js_webhook_sha512`).
597
+ ML-DSA-65 standalone verifier for anchor signatures.