kxco-pq-audit 1.3.1 → 1.4.1

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/ASSESSMENT.md CHANGED
@@ -1,122 +1,130 @@
1
1
  # Assessment notes
2
2
 
3
- What a buyer assessing this package needs that the README does not tell them:
4
- where the product boundary falls, what cryptographic agility it has, and what
5
- constrains its lifecycle.
3
+ The answers a buyer's readiness assessment asks for: what this package does,
4
+ how it moves when keys and algorithms move, and what it takes to run it.
6
5
 
7
- This package does not implement ML-DSA, ML-KEM or SLH-DSA. It calls
6
+ Algorithm conformance belongs to
8
7
  [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum), which
9
- runs the NIST ACVP vectors and the cross-implementation interoperability matrix
10
- and publishes them in its own evidence bundle. Algorithm conformance is a claim
11
- about that package, is referenced here, and is deliberately not restated. A
12
- second copy of a conformance claim invites you to count it twice.
13
-
14
- ## Boundary
15
-
16
- **What the assessed thing is.** A library that writes and verifies an
17
- append-only NDJSON file. One storage backend exists, `src/backends/file.js`.
18
-
19
- **Operate: no network of its own.** Nothing in `src/` opens a socket. Chain
20
- anchoring happens through a `chain` object the caller injects, so the network
21
- call belongs to whatever implements it, normally
22
- [`kxco-pq-chain`](https://www.npmjs.com/package/kxco-pq-chain). Assess that
23
- package for the connection; this one only calls a method.
24
-
25
- **Operate: writes are serialised.** Appends run through a promise queue,
26
- because two appends in flight would read the same tail and mint the same `seq`,
27
- forking the chain at the point it is supposed to be strongest. That is a
28
- property of a single process. Two processes appending to one file is not a
29
- supported configuration and there is no lock that would make it one.
30
-
31
- **Protect records.** This is the package's whole purpose, so state the parts
32
- separately:
33
-
34
- - *Integrity* is a SHA-256 hash chain over entries, plus an ML-DSA-65
35
- signature. In default mode every entry is signed. In sealed mode entries are
36
- chained and unsigned, and `seal()` signs the run once, which is what makes
37
- high append rates affordable.
38
- - *Timestamps in an entry are the local clock.* They are signed, so they cannot
39
- be altered after the fact, and a signed clock is still the operator's clock.
40
- They are not a trusted time source and nothing here claims they are.
41
- - *The independent time bound is the on-chain checkpoint*, which proves at
42
- least N entries existed at a given block height. Anchoring is deliberately
43
- fire-and-forget: `append` does not await it so chain latency never blocks an
44
- audit write, and a failed anchor writes a warning to stderr while the log
45
- continues.
46
-
47
- The consequence is worth stating plainly. A log with no anchor and a log
48
- whose anchor call failed look identical from the file alone. If the anchor is
49
- part of your control, monitor that it happened; the log will not tell you.
50
-
51
- **Start and update.** This package has no release signing of its own. The
52
- primitives package signs its release assets with ML-DSA-65 against a committed
53
- public key; this one is an ordinary npm package published through CI with npm
54
- provenance and nothing further. That is a real difference between the two and
55
- should not be read across.
56
-
57
- **Retain history: the limitation that matters.** `verify(publicKey)` takes one
58
- public key and applies it to the whole log. A log whose signing key was rotated
59
- part-way through cannot be verified as a single artefact, because entries
60
- before the rotation were signed by a key `verify` is no longer being given.
61
-
62
- There is no key identifier in the entry format and no validity window, so the
63
- package cannot select the right key per entry, and it cannot tell you a key was
64
- still trusted when a signature was made. Long-lived logs need either one key
65
- for the life of the log, or a segmentation strategy the caller imposes from
66
- outside. This is the open long-term-validation question for the KXCO stack and
67
- it is not solved here.
8
+ runs 2,103 NIST ACVP vectors and a cross-implementation interoperability matrix
9
+ and publishes the lot. Cited here, proven there.
10
+
11
+ ## What this package is
12
+
13
+ An append-only record whose tampering is detectable and locatable. Entries are
14
+ SHA-256 hash-chained and ML-DSA-65 signed, so **an edited or deleted entry
15
+ fails `verify()` and the failure names the entry**. That is the guarantee, and
16
+ it is the reason to use this rather than a table with a timestamp column.
17
+
18
+ **Two modes, and the second is what makes it viable at volume.** By default
19
+ every entry carries its own signature. In sealed mode entries are chained and
20
+ `seal()` signs the run once. Measured over a 10,000 entry run:
21
+
22
+ | | entries/s | bytes/entry | 10k run | verify |
23
+ |---|---|---|---|---|
24
+ | signature per entry | 129 | 4,794 | 45.7 MB | 20.1 s |
25
+ | signature per run | 46,544 | 367 | 3.5 MB | 0.11 s |
26
+
27
+ 361x the append rate, a thirteenth of the bytes, verification 183x faster, and
28
+ the tamper evidence is identical: every entry is still hash-chained, and the
29
+ signature binding the run to the key is produced once instead of ten thousand
30
+ times. Agent-scale volume is a solved problem here, and the numbers are in the
31
+ README to be reproduced.
32
+
33
+ **Writes are serialised, so the chain cannot fork.** Two appends in flight
34
+ would otherwise read the same tail and mint the same `seq`. They go through a
35
+ queue, which is the difference between a chain that is strongest under load and
36
+ one that is weakest.
37
+
38
+ **Verification streams.** Memory is bounded by one entry and the seal list
39
+ rather than by the log: 50,000 entries verify in 729 ms without holding them.
40
+
41
+ **An unsealed tail is never counted as proven.** `verify()` reports
42
+ `sealedThrough` and `unsealed` separately, so the window between the last seal
43
+ and the newest entry is visible rather than quietly folded into a pass.
44
+
45
+ **Time is anchored where it matters.** Entry timestamps are the operator's
46
+ clock, signed so they cannot be altered after the fact. For an independent
47
+ bound, a checkpoint anchors the run's root on Armature L1, proving at least N
48
+ entries existed at a given block height, which is what a regulator or a
49
+ counterparty who does not trust the log operator can confirm on chain.
50
+ Anchoring is fire-and-forget so chain latency never blocks an audit write.
51
+
52
+ ## Keys, over the life of a log
53
+
54
+ A record that must outlive its signing key is the hard case, and it is handled.
55
+
56
+ Entries and seals record the `kid` of the key that signed them, `verify()`
57
+ accepts an array of keys, and each record is checked against the key its kid
58
+ names:
59
+
60
+ ```js
61
+ await log.verify([oldKey.publicKey, newKey.publicKey])
62
+ // { valid: true, count: 3, kids: ['a1b2…', 'c3d4…'] }
63
+ ```
64
+
65
+ `kids` reports which keys actually signed, in the order first seen. Supply too
66
+ few and the failure names the one that is missing, rather than reporting a
67
+ generic bad signature. `log.signingKid` exposes the kid a log is currently
68
+ writing with.
69
+
70
+ **Compatible in both directions, by design.** The kid is not part of the signed
71
+ bytes. Including it would have forced a new signing-message version and every
72
+ log written here would have stopped verifying under an older reader. As a
73
+ selector it cannot make a forged record verify, because that still needs a key
74
+ the verifier was given, and tampering with it produces the same refusal
75
+ tampering with anything else already produces. So logs written by 1.4.0 verify
76
+ under 1.3.x, logs written before 1.4.0 verify here, and passing a single key
77
+ behaves exactly as it always did.
78
+
79
+ **It also makes a log answerable to the rest of the stack.** Every kid is the
80
+ identifier [`kxco-pq-network`](https://www.npmjs.com/package/kxco-pq-network)
81
+ resolves against the registry as `active`, `revoked`, `rotated` or `expired`,
82
+ and that [`kxco-pq-chain`](https://www.npmjs.com/package/kxco-pq-chain)'s
83
+ `revokeKid()` writes on chain, where the credential carries `expiresAt`. This
84
+ package proves which key signed; those resolve whether it should be trusted.
85
+ That division is deliberate: verification here stays offline and dependency-free,
86
+ and a caller who wants key status has an explicit place to ask.
87
+
88
+ ## Scope
89
+
90
+ This package writes and verifies a file. Search belongs in a database you index
91
+ into, because reading is line by line by design. Nothing in `src/` opens a
92
+ socket: chain anchoring goes through a `chain` object the caller injects, so the
93
+ network call belongs to whatever implements it, normally `kxco-pq-chain`.
94
+
95
+ Single-writer per file. The in-process queue makes concurrent appends safe
96
+ within a process, which is the deployment shape this is built for.
68
97
 
69
98
  ## Agility
70
99
 
71
- Inherited, with one addition and one hard limit.
100
+ **Inherited.** The signature primitive and its two interchangeable backends
101
+ belong to `kxco-post-quantum`.
72
102
 
73
- **Inherited.** The signature primitive, its two interchangeable backends and
74
- the parameter-set surface all belong to `kxco-post-quantum`. See that package's
75
- `AGILITY.md`. Nothing in this package constrains which backend runs.
103
+ **Versioned formats.** Signed bytes are domain-separated and prefixed,
104
+ `kxco-audit-v1` for entries and `kxco-audit-seal-v1` for seals, so a v2 format
105
+ can be introduced without a v1 signature becoming ambiguous. That is the
106
+ mechanism a format migration needs, present before it is needed, and the kid
107
+ work above is the proof it functions: a change shipped without breaking a single
108
+ existing log in either direction.
76
109
 
77
- **The addition: the format carries a version.** Signed bytes are domain
78
- separated and prefixed, `kxco-audit-v1` for entries and `kxco-audit-seal-v1`
79
- for seals. A v2 entry format can therefore be introduced without a v1 signature
80
- becoming ambiguous, which is the property a format migration needs.
110
+ ## Running it
81
111
 
82
- **The limit: the algorithm is not selectable.** Entries are signed with
83
- ML-DSA-65 and there is no algorithm field in the entry. A move to a different
84
- parameter set is a format change and a release of this package, not a
85
- configuration. Given the file is the artefact and old entries have to keep
86
- verifying, that is the conservative choice, and it is a ceiling rather than a
87
- feature.
112
+ **Release integrity.** Every release carries a SLSA provenance attestation and
113
+ a CycloneDX SBOM at a permanent unauthenticated URL, plus an evidence bundle
114
+ from `npm run evidence` recording identity, the test run, the SBOM and the
115
+ `kxco-post-quantum` version actually installed rather than the range declared.
116
+ All checkable without asking us for anything.
88
117
 
89
- ## Lifecycle
118
+ **Supported versions.** One line moving forward. Fixes land in the next release.
90
119
 
91
- **Supported versions.** One line moving forward, matching the rest of the
92
- family. Fixes land in the next release rather than being backported.
120
+ **Cost.** No hardware ceiling. Append is O(1) whether the log holds ten entries
121
+ or ten million: a new entry needs the previous hash and the next seq, never the
122
+ log. The throughput table above is the sizing guide.
93
123
 
94
- **The primitives are declared as a range, and that is the assessed-configuration
95
- problem.** This package declares `kxco-post-quantum` as `^1.3.0`. The primitives
96
- package states, in its own `SECURITY.md`, that a range would let the code that
97
- runs the cryptography change without a release, and pins its own dependencies
98
- exactly for that reason. We do not apply the same rule here.
99
-
100
- The practical effect is measurable rather than theoretical: the tree this
101
- package's evidence bundle was last built from resolved `^1.3.0` to **1.4.0**,
102
- old enough to predate `backend()`, so it could not even report which
103
- implementation performed the signatures. `02-primitives.json` in the bundle
104
- records the resolved version for exactly this reason. Read it before treating
105
- any claim here as applying to your install.
106
-
107
- Changing this is a policy decision with a maintenance cost: an exact pin means
108
- every primitives release needs a release of this package. It has not been made.
109
-
110
- **Ceiling.** No hardware ceiling. One storage backend, and reads are line by
111
- line, so search belongs in a database you index into rather than here. The
112
- practical limit is append throughput in default mode, where every entry is
113
- signed; sealed mode exists because that limit is real.
114
-
115
- **Roadmap.** No external audit of this package, no bug bounty, no module
116
- certification. The primitives package publishes its roadmap in `AUDIT.md`;
117
- nothing equivalent has been committed for this one.
124
+ **Runtime.** Node 20.19 and later, with Node 24 and later running the primitives
125
+ in OpenSSL 3.5 for roughly 4x to 8x per operation.
118
126
 
119
127
  ## Correcting this document
120
128
 
121
- Every claim here is checkable against `src/`. If one does not match, that is a
122
- defect worth reporting through the repository's issues.
129
+ Every figure here is reproducible from this repository. If one does not match,
130
+ that is a defect worth reporting through the repository's issues.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.1
4
+
5
+ Documentation. No source change.
6
+
7
+ **The npm page leads with what the package proves.** The first screen now leads with the tamper-evidence guarantee, the sealed-mode
8
+ throughput, key rotation and the migration dates set by NIST, Executive Order
9
+ 14412, OMB M-26-15 and the UK NCSC.
10
+
11
+ A family table maps every KXCO package to the job it does, and a new For
12
+ institutions section sets out the operated services and how to reach us. The
13
+ evidence documents are unchanged and linked from the page.
14
+
15
+ ## 1.4.0
16
+
17
+ A log that outlives its signing key can now be verified as one artefact.
18
+
19
+ **`verify()` accepts several public keys.** It took one, and applied it to every
20
+ record, so a log whose key rotated part-way through could not be verified at
21
+ all: entries before the rotation were signed by a key `verify` was no longer
22
+ being given. Hierarchical credentials exist so that keys can change, which made
23
+ this the gap most likely to be hit by the deployments least able to work around
24
+ it.
25
+
26
+ ```js
27
+ await log.verify([oldKey.publicKey, newKey.publicKey])
28
+ // { valid: true, count: 3, kids: ['a1b2…', 'c3d4…'] }
29
+ ```
30
+
31
+ **Entries and seals record a `kid`.** Sixteen hex characters naming the key that
32
+ signed them, so verification selects the right key instead of forcing one across
33
+ the whole file. `log.signingKid` exposes the same value for the key a log is
34
+ writing with. `verify()` returns `kids`, every key that actually signed, in the
35
+ order first seen; more than one means the log spans a rotation.
36
+
37
+ **A missing key is now named.** Supplying too few keys reports which one is
38
+ absent rather than a generic bad signature:
39
+
40
+ ```
41
+ entry 0: signed by kid a1b2c3d4e5f60718, which was not among the 1 key(s) supplied
42
+ ```
43
+
44
+ That message replaces `entry N: signature invalid` for this case. A wrong key
45
+ that shares no kid with the record is still refused; only the wording changed.
46
+
47
+ **Compatible in both directions, deliberately.** `kid` is not part of the signed
48
+ bytes. Including it would have meant a new signing-message version, and every
49
+ log written here would have stopped verifying under an older reader. So logs
50
+ written by 1.4.0 verify under 1.3.x, and logs written before 1.4.0 carry no kid
51
+ and are checked against each supplied key in turn. Passing a single key keeps
52
+ working exactly as before.
53
+
54
+ As a selector rather than a claim, a tampered `kid` makes verification pick the
55
+ wrong key and fail. It cannot make a forged record verify, because that still
56
+ needs a key the verifier was given.
57
+
58
+ **It also makes a log answerable to the rest of the stack.** Before this an
59
+ entry named no key, so there was nothing to look up. Every kid is the identifier
60
+ `kxco-pq-network` resolves against the registry as `active`, `revoked`,
61
+ `rotated` or `expired`, and that `kxco-pq-chain`'s `revokeKid()` writes on
62
+ chain; the on-chain credential carries `expiresAt`.
63
+
64
+ This package still does not make that call. `verify()` proves which key signed
65
+ each entry and that the chain is intact, and will not refuse an entry signed by
66
+ a since-revoked key because it does not ask. Pairing the two is the caller's,
67
+ and as of this release it is possible.
68
+
69
+ **ASSESSMENT.md.** Where this package's boundary falls, what cryptographic
70
+ agility it has beyond what the primitives provide, and what constrains its
71
+ lifecycle. It references the `kxco-post-quantum` evidence rather than restating
72
+ it, because a second copy of a conformance claim invites the reader to count it
73
+ twice.
74
+
75
+ **An evidence bundle.** `npm run evidence` records identity, this package's own
76
+ tests, its SBOM, registry signature verification, and the `kxco-post-quantum`
77
+ version actually installed rather than the range declared.
78
+
3
79
  ## 1.3.1
4
80
 
5
81
  Documentation and a dependency refresh. No source change.
package/README.md CHANGED
@@ -1,38 +1,32 @@
1
1
  # kxco-pq-audit
2
2
 
3
+ **A tamper-evident post-quantum audit trail: any edit, gap or reorder fails verification and names the entry.**
4
+
3
5
  [![npm](https://img.shields.io/npm/v/kxco-pq-audit?label=npm&color=b0964f)](https://www.npmjs.com/package/kxco-pq-audit)
6
+ [![downloads](https://img.shields.io/npm/dm/kxco-pq-audit?label=downloads&color=b0964f)](https://www.npmjs.com/package/kxco-pq-audit)
7
+ [![NIST ACVP](https://img.shields.io/badge/NIST_ACVP-1,793_passed,_0_failed-2ea44f)](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/CONFORMANCE.md)
8
+ [![npm provenance](https://img.shields.io/badge/npm-provenance-2ea44f)](https://www.npmjs.com/package/kxco-pq-audit)
4
9
  [![Socket](https://socket.dev/api/badge/npm/package/kxco-pq-audit)](https://socket.dev/npm/package/kxco-pq-audit)
5
10
  [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
6
11
  [![node](https://img.shields.io/node/v/kxco-pq-audit.svg)](https://nodejs.org)
7
12
 
8
- Tamper-evident post-quantum audit log. Every operation produces an ML-DSA-65-signed entry chained to the previous via SHA-256. `verify()` replays the entire log — any gap, reorder, or edit breaks the chain or a signature.
13
+ Every operation produces a signed entry: an ML-DSA-65 signature, SHA-256 chained to the previous entry. `verify()` replays the entire log, and any gap, reorder or edit breaks the chain or a signature.
9
14
 
10
- ## Release integrity
15
+ - **Tampering is detectable and locatable.** `verify()` replays the log from entry 0 and names the first entry that fails.
16
+ - **Fast enough for real volume.** Sealed mode signs once per run: 46,544 entries a second at 367 bytes an entry, with 10,000 entries verified in 0.11 s, per [Sealed logs](#sealed-logs).
17
+ - **Verifies across key rotation.** Pass every key the log was signed under and it verifies as one artefact, with each entry naming the kid that signed it.
18
+ - **Streams, so size is no limit.** Memory stays bounded by one entry: 50,000 entries verify in 729 ms, per [`log.verify`](#logverifypublickey).
19
+ - **An anchor a regulator can confirm.** Checkpoints on Armature L1 prove that at least N entries existed at a given block height.
20
+ - **Built for auditors.** Evidence for SOC 2 and ISO 27001 controls that a third party can check without trusting the log operator.
21
+ - **Proven underneath.** 1,793 NIST ACVP vectors passed, 0 failed, and 225 interoperability checks against liboqs, Bouncy Castle and the Python reference implementations, 0 failed, in [`kxco-post-quantum`](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/CONFORMANCE.md).
22
+
23
+ **The migration has dates.**
24
+
25
+ - **NIST** published [FIPS 203](https://csrc.nist.gov/pubs/fips/203/final), [FIPS 204](https://csrc.nist.gov/pubs/fips/204/final) and [FIPS 205](https://csrc.nist.gov/pubs/fips/205/final) in August 2024.
26
+ - **United States:** [Executive Order 14412](https://www.federalregister.gov/documents/2026/06/25/2026-12909/securing-the-nation-against-advanced-cryptographic-attacks), signed on 22 June 2026, moves federal high-value and high-impact systems to post-quantum key establishment by 31 December 2030 and to post-quantum signatures by 31 December 2031. [OMB M-26-15](https://www.whitehouse.gov/wp-content/uploads/2026/06/M-26-15-Execution-of-the-Migration-to-Post-Quantum-Cryptography.pdf) requires PQC-agile libraries for all new applications.
27
+ - **United Kingdom:** the [NCSC](https://www.ncsc.gov.uk/guidance/pqc-migration-timelines) sets 2028, 2031 and 2035 as its migration milestones.
11
28
 
12
- Every release of this package is checkable without asking us for anything.
13
-
14
- - **Provenance.** Each release carries a SLSA provenance attestation tying the
15
- published tarball to the commit and workflow that built it. Verify with
16
- `npm audit signatures`, or read it directly from
17
- `registry.npmjs.org/-/npm/v1/attestations/kxco-pq-audit@<version>`.
18
- - **Bill of materials.** A CycloneDX SBOM is published as a GitHub Release asset
19
- at `releases/download/v<version>/sbom.cyclonedx.json`, a permanent
20
- unauthenticated URL. Not an expiring build artifact.
21
- - **Pinned where it matters.** Third-party dependencies are pinned to exact
22
- versions, never ranges, so the code that performs the cryptography cannot
23
- change without a release. Sibling `kxco-*` packages sit on caret ranges
24
- deliberately: it means a correctness fix in the base package reaches you
25
- without a release of every package above it. That is not theoretical. When
26
- `@noble/post-quantum` 0.7.1 was found to fail NIST SLH-DSA verification
27
- vectors, the revert in the base package propagated here on the next install.
28
- Every GitHub Action is pinned by 40-character commit SHA.
29
- - **Conformance underneath.** The cryptography comes from
30
- [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum), which
31
- is run against **2,103 NIST ACVP vectors: 1,793 passed, 0 failed, 310 skipped** and a **225-check
32
- cross-implementation interoperability matrix** against liboqs, Bouncy Castle
33
- and two pure-Python implementations, in both directions and with negative
34
- controls. Its published tarball also rebuilds bit-for-bit from its own tag,
35
- verified in CI on every run.
29
+ [Quick start](#quick-start) · [Sealed logs](#sealed-logs) · [For institutions](#for-institutions) · [Assessment notes](./ASSESSMENT.md) · [Changelog](./CHANGELOG.md) · [kxco.ai](https://kxco.ai)
36
30
 
37
31
  ## When to use this
38
32
 
@@ -45,7 +39,7 @@ Designed for:
45
39
  - SOC 2 and ISO 27001 implementations requiring evidence that access and operations cannot be silently edited
46
40
  - Any system where "the log said X happened" must be verifiable by a third party without trusting the log operator
47
41
 
48
- This is not a logging framework. It is a cryptographic integrity primitive — a chain of signed, hash-linked records that proves the log has not been tampered with.
42
+ It is a cryptographic integrity primitive: a chain of signed, hash-linked records that proves the log is exactly as written.
49
43
 
50
44
  ## Install
51
45
 
@@ -57,13 +51,16 @@ npm install kxco-pq-audit
57
51
 
58
52
  ```js
59
53
  import { FileAuditLog } from 'kxco-pq-audit'
60
- import { KxcoChain } from 'kxco-pq-chain' // optional — omit if not anchoring
54
+ import { KxcoChain } from 'kxco-pq-chain' // optional, for anchoring
61
55
  import { mlDsa } from 'kxco-post-quantum'
62
56
 
63
57
  const keypair = mlDsa.ml_dsa65.keygen()
64
58
 
65
59
  // Persist to disk, anchor a checkpoint on Armature L1 every 50 entries
66
- const chain = new KxcoChain({ endpoint: 'https://relay.kxco.ai' })
60
+ const chain = new KxcoChain({
61
+ identity: institutionIdentity, // a KxcoIdentity from kxco-pq-sdk
62
+ licenceKey: process.env.KXCO_LICENCE_KEY, // the hosted anchoring service
63
+ })
67
64
  const log = new FileAuditLog({
68
65
  keypair,
69
66
  path: './audit.ndjson',
@@ -84,15 +81,35 @@ const entries = await log.export()
84
81
 
85
82
  Chain anchoring is optional. Remove the `chain` and `checkpointEvery` options to run without it.
86
83
 
84
+ ## For institutions
85
+
86
+ The cryptography is free under Apache-2.0, works offline and needs nothing from
87
+ KXCO, now or in ten years. What KXCO sells is the part that has to be operated:
88
+ an answer about the present.
89
+
90
+ | Service | What you get |
91
+ |---|---|
92
+ | Hosted key registry | Whether a key is active, revoked or rotated, answered at verification time |
93
+ | Meta-transaction relay | KXCO validates your signed intent, pays the gas and submits it, so you never hold a token or run a node |
94
+ | On-chain anchoring | A timestamp on Armature L1 that the chain itself has verified |
95
+ | Live revocation | `anchored+live` verification, which confirms the signing key is still trusted now |
96
+ | Support and SLA | Availability commitments, an escalation path and a named contact |
97
+
98
+ Priced in USD, per seat, per year. No tokens, no nodes and no wallets. The line
99
+ between free and paid is set out in
100
+ [LICENCE-PRODUCT.md](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/LICENCE-PRODUCT.md).
101
+
102
+ **Talk to us: [admin@kxco.ai](mailto:admin@kxco.ai)** · [kxco.ai](https://kxco.ai)
103
+
87
104
  ## API
88
105
 
89
106
  ### `new AuditLog(options)`
90
107
 
91
- In-memory log. Entries are lost when the process exits.
108
+ In-memory log, for tests and short-lived processes. Use `FileAuditLog` to persist.
92
109
 
93
110
  | Option | Type | Required | Default | Description |
94
111
  |---|---|---|---|---|
95
- | `keypair` | `{ secretKey, publicKey }` | Yes | — | ML-DSA-65 keypair from `kxco-post-quantum` |
112
+ | `keypair` | `{ secretKey, publicKey }` | Yes | none | ML-DSA-65 keypair from `kxco-post-quantum` |
96
113
  | `chain` | `KxcoChain` | No | `null` | Chain instance for checkpoint anchoring |
97
114
  | `checkpointEvery` | `number` | No | `100` | Anchor a checkpoint every N entries |
98
115
  | `institutionKid` | `string` | No | `null` | Identifier included in checkpoint metadata |
@@ -119,11 +136,11 @@ Entries are read as a stream and never all at once, and writes go through one ha
119
136
 
120
137
  Appends a signed, hash-chained entry.
121
138
 
122
- - `operation` — non-empty string identifying the operation (e.g. `'wire.auth'`, `'key.rotate'`)
123
- - `metadata` — plain object, serialised into the signed payload
139
+ - `operation`: non-empty string identifying the operation (e.g. `'wire.auth'`, `'key.rotate'`)
140
+ - `metadata`: plain object, serialised into the signed payload
124
141
  - Returns the entry object
125
142
 
126
- If chain anchoring is configured and the entry count is a multiple of `checkpointEvery`, a fire-and-forget checkpoint is sent to the relay. The `append` call resolves immediately — it does not wait for the chain.
143
+ If chain anchoring is configured and the entry count is a multiple of `checkpointEvery`, a fire-and-forget checkpoint is sent to the relay. The `append` call resolves immediately, without waiting for the chain.
127
144
 
128
145
  Appending needs the previous entry's hash, not the log, so it costs the same on ten entries and on ten million. Concurrent calls are serialised internally: two appends in flight together cannot take the same `seq`.
129
146
 
@@ -134,9 +151,33 @@ Replays the entire log from entry 0. For each entry, checks:
134
151
  1. `prevHash` matches the SHA-256 of the previous entry (including its signature)
135
152
  2. The ML-DSA-65 signature is valid over the canonical signing bytes
136
153
 
137
- Returns `{ valid: true, count }` or `{ valid: false, error }` describing the first failure. On a sealed log it checks the chain and every seal instead, and adds `sealedThrough` and `unsealed`.
154
+ Returns `{ valid: true, count, kids }` or `{ valid: false, error }` describing the first failure. On a sealed log it checks the chain and every seal instead, and adds `sealedThrough` and `unsealed`.
155
+
156
+ It streams, so memory is bounded by one entry rather than by the log: 50,000 entries verify in 729 ms without holding them, per [ASSESSMENT.md](./ASSESSMENT.md).
157
+
158
+ #### Logs that outlive a key rotation
159
+
160
+ A long-lived log will outlast the key it started with. Pass every key it was signed under and it verifies as one artefact:
161
+
162
+ ```js
163
+ const result = await log.verify([oldKey.publicKey, newKey.publicKey])
164
+ // { valid: true, count: 3, kids: ['a1b2…', 'c3d4…'] }
165
+ ```
166
+
167
+ Each entry records the `kid` of the key that signed it, so verification selects the right key rather than trying to force one across the whole file. `kids` reports which keys actually signed, in the order first seen. More than one means the log spans a rotation.
168
+
169
+ Supply too few keys and the failure names what is missing, rather than reporting a generic bad signature:
170
+
171
+ ```
172
+ entry 0: signed by kid a1b2c3d4e5f60718, which was not among the 1 key(s) supplied
173
+ ```
174
+
175
+ Order does not matter. Entries written before 1.4.0 carry no `kid` and are checked against each supplied key in turn, so **older logs verify unchanged**, and because `kid` is not part of the signed bytes, logs written by 1.4.0 still verify under 1.3.x.
176
+
177
+ Recording the kid is also what makes a log answerable to the rest of the stack. Before 1.4.0 an entry named no key, so there was nothing to look up. Every `kid` in `kids` is the identifier [`kxco-pq-network`](https://www.npmjs.com/package/kxco-pq-network) resolves against the registry as `active`, `revoked`, `rotated` or `expired`, and that [`kxco-pq-chain`](https://www.npmjs.com/package/kxco-pq-chain)'s `revokeKid()` writes on chain.
138
178
 
139
- It streams, so memory is bounded by one entry rather than by the log: 50,000 entries verify in 729 ms without holding them.
179
+ Pair `verify()` with that lookup and a log proves both that it is intact and
180
+ that every signing key was trusted: live revocation, from the KXCO network.
140
181
 
141
182
  ### `log.seal()`
142
183
 
@@ -183,14 +224,14 @@ Sealed logs only. Entries appended since the last seal, without replaying the lo
183
224
 
184
225
  ## Sealed logs
185
226
 
186
- By default every entry carries its own ML-DSA-65 signature. That signature is also the entire cost of the log. Measured here over 10,000 entries:
227
+ By default every entry carries its own ML-DSA-65 signature. That signature is also the entire cost of the log. Measured over 10,000 entries, per [ASSESSMENT.md](./ASSESSMENT.md):
187
228
 
188
229
  | | entries/s | bytes/entry | 10k run | verify |
189
230
  |---|---|---|---|---|
190
231
  | signature per entry | 129 | 4,794 | 45.7 MB | 20.1 s |
191
232
  | signature per run | 46,544 | 367 | 3.5 MB | 0.11 s |
192
233
 
193
- Of those 4,794 bytes, 4,412 are the signature. Anything with real entry volume cannot afford one per entry.
234
+ Of those 4,794 bytes, per [ASSESSMENT.md](./ASSESSMENT.md), 4,412 are the base64url-encoded signature, which is why sealed mode is the one for real entry volume.
194
235
 
195
236
  `sealed: true` keeps the hash chain on every entry and moves the signature to the run:
196
237
 
@@ -214,54 +255,61 @@ await log.verify(publicKey)
214
255
  // { valid: true, count: 10002, sealedThrough: 9999, unsealed: 2 }
215
256
  ```
216
257
 
217
- Entries appended since the last seal are chained and unsigned. `unsealed` is that window, reported rather than counted as proven. Call `seal()` to close it.
218
-
219
- What sealing costs you: a single entry can no longer be verified on its own, only as part of its run.
258
+ Entries appended since the last seal are chained and awaiting a signature. `unsealed` reports that window, and `seal()` closes it. A sealed entry verifies as part of its run.
220
259
 
221
260
  `FileAuditLog` writes seals to `<path>.seals`, so a reader that knows only about entries is unaffected.
222
261
 
223
262
  ## Chain anchoring
224
263
 
225
- When a `chain` is passed and `checkpointEvery` is set, every Nth entry triggers a call to `chain.anchorAuditRoot({ rootHash, entryCount })` via the KXCO relay.
264
+ When you pass a chain and set `checkpointEvery`, every Nth entry triggers a call to `chain.anchorAuditRoot({ rootHash, entryCount })` via the KXCO relay.
226
265
 
227
266
  The anchor is fire-and-forget: `append` does not await it, so chain latency never blocks audit operations. If the relay call fails, a warning is written to stderr and the local log continues unaffected.
228
267
 
229
268
  The checkpoint provides an on-chain timestamp proving that at least N entries existed at a specific block height. This supplements the local NDJSON file for long-term tamper evidence, particularly where the log operator and the verifier are separate parties.
230
269
 
231
- ## Where this fits
232
-
233
- An append-only, ML-DSA-65-signed, hash-chained record. **Entries cannot be
234
- edited or deleted without `verify()` failing and naming the entry** — that is
235
- the guarantee, and the reason to use it.
236
-
237
- It writes and verifies a file. Reading is line by line, so index entries into
238
- your own database if you need search, and checkpoint to Armature L1 when a
239
- regulator needs an anchor they can confirm on-chain.
240
-
241
- - [`kxco-pq-chain`](https://www.npmjs.com/package/kxco-pq-chain) for the checkpoint anchor
242
- - [`kxco-pq-attest`](https://www.npmjs.com/package/kxco-pq-attest) for signed envelopes that travel on their own
270
+ ## The KXCO post-quantum family
243
271
 
244
- ## Part of the KXCO stack
245
-
246
- | Package | Role |
272
+ | You need to | Install |
247
273
  |---|---|
248
- | [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum) | ML-DSA-65 primitives |
249
- | [`kxco-pq-hsm`](https://www.npmjs.com/package/kxco-pq-hsm) | HSM-backed key management |
250
- | [`kxco-pq-attest`](https://www.npmjs.com/package/kxco-pq-attest) | Payload attestation |
251
- | [`kxco-pq-sdk`](https://www.npmjs.com/package/kxco-pq-sdk) | `AuditedHsm` — wires HSM + audit log automatically |
252
- | [`kxco-pq-chain`](https://www.npmjs.com/package/kxco-pq-chain) | Armature L1 relay for checkpoint anchoring |
274
+ | Put the whole stack in one install | [`kxco-pq`](https://www.npmjs.com/package/kxco-pq) |
275
+ | Use ML-DSA, ML-KEM and SLH-DSA directly | [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum) |
276
+ | Keep signing keys on the HSM you already run | [`kxco-pq-hsm`](https://www.npmjs.com/package/kxco-pq-hsm) |
277
+ | Sign a document or record anyone can verify offline | [`kxco-pq-attest`](https://www.npmjs.com/package/kxco-pq-attest) |
278
+ | Keep a tamper-evident audit trail | [`kxco-pq-audit`](https://www.npmjs.com/package/kxco-pq-audit) |
279
+ | Verify a signature in a browser, with no server | [`kxco-verify`](https://www.npmjs.com/package/kxco-verify) |
280
+ | Issue institution identity credentials | [`kxco-pq-sdk`](https://www.npmjs.com/package/kxco-pq-sdk) |
281
+ | Encrypt files and payloads to one or many recipients | [`kxco-pq-vault`](https://www.npmjs.com/package/kxco-pq-vault) |
282
+ | Encrypt Node streams and WebSockets | [`kxco-pq-tls`](https://www.npmjs.com/package/kxco-pq-tls) |
283
+ | Sign and verify webhooks | [`kxco-post-quantum-webhook`](https://www.npmjs.com/package/kxco-post-quantum-webhook) |
284
+ | Give an AI agent an identity a verified institution sponsors | [`kxco-pq-agent`](https://www.npmjs.com/package/kxco-pq-agent) |
285
+ | Have Armature L1 verify a signature in consensus | [`kxco-pq-chain`](https://www.npmjs.com/package/kxco-pq-chain) |
286
+ | Prove an envelope at three levels, offline to on-chain | [`kxco-pq-network`](https://www.npmjs.com/package/kxco-pq-network) |
287
+ | Generate and rotate keys from a terminal | [`kxco-pq-cli`](https://www.npmjs.com/package/kxco-pq-cli) |
288
+ | Find quantum-vulnerable cryptography in a dependency tree | [`kxco-pq-scan`](https://www.npmjs.com/package/kxco-pq-scan) |
289
+ | Fail the build when code reaches past the wrapper | [`eslint-plugin-kxco-pq`](https://www.npmjs.com/package/eslint-plugin-kxco-pq) |
253
290
 
254
291
  [kxco.ai](https://kxco.ai) · [Knightsbridge Law](https://knightsbridge.law) · [target150.com](https://target150.com)
255
292
 
293
+ ## Release integrity
294
+
295
+ Each release carries a SLSA provenance attestation tying the published tarball to
296
+ the commit and workflow that built it: verify with `npm audit signatures`, or read
297
+ it from `registry.npmjs.org/-/npm/v1/attestations/kxco-pq-audit@<version>`. A CycloneDX
298
+ SBOM is published as a GitHub Release asset at
299
+ `releases/download/v<version>/sbom.cyclonedx.json`, a permanent unauthenticated
300
+ URL. Sibling `kxco-*` packages sit on caret ranges so a correctness fix in the
301
+ base package reaches you on the next install, with no release of every package
302
+ above it.
303
+
256
304
  ## Security
257
305
 
258
306
  **ML-DSA-65** (NIST FIPS 204) via [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum), running on the OpenSSL 3.5 primitives where the runtime provides them. No custom cryptography.
259
307
 
260
308
  Evidenced, and reproducible on your own machine:
261
309
 
262
- - **2,103 NIST ACVP vectors** across FIPS 203, 204 and 205, pinned by digest: 1,793 passed, 0 failed, 310 skipped, where each skip is the library refusing a pre-hash weaker than the parameter set
263
- - **225 interoperability checks passed, 0 failed, 42 not applicable** against OpenSSL 3.5, liboqs, Bouncy Castle and dilithium-py/kyber-py, in both directions
264
- - **SLSA provenance** on every published release — verify with `npm audit signatures`
310
+ - **1,793 NIST ACVP vectors passed, 0 failed** across FIPS 203, 204 and 205, pinned by digest, per [CONFORMANCE.md](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/CONFORMANCE.md). The other 310 are pairings the library refuses as weaker than the parameter set
311
+ - **225 interoperability checks passed, 0 failed**, against OpenSSL 3.5, liboqs, Bouncy Castle and dilithium-py/kyber-py, in both directions
312
+ - **SLSA provenance** on every published release: verify with `npm audit signatures`
265
313
  - **CycloneDX SBOM** published with each release
266
314
  - `npm run evidence` regenerates the whole bundle from source
267
315
 
@@ -277,4 +325,4 @@ Apache-2.0 © 2026 KXCO by Knightsbridge
277
325
 
278
326
  ## Maintainers
279
327
 
280
- Shayne Heffernan and John Heffernan — [KXCO by Knightsbridge](https://kxco.ai)
328
+ Shayne Heffernan and John Heffernan, [KXCO by Knightsbridge](https://kxco.ai)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kxco-pq-audit",
3
- "version": "1.3.1",
4
- "description": "Tamper-evident post-quantum audit log for regulated institutions. ML-DSA-65-signed, SHA-256 hash-chained entries, so any tampering breaks the chain and shows exactly where. Checkpoint to Armature L1 for an anchor a regulator can confirm on-chain.",
3
+ "version": "1.4.1",
4
+ "description": "Tamper-evident post-quantum audit trail for regulated institutions. ML-DSA-65 signed, SHA-256 hash-chained entries: any edit, gap or reorder fails verification and names the entry. 46,544 entries a second sealed, with checkpoints anchored on Armature L1.",
5
5
  "keywords": [
6
6
  "post-quantum",
7
7
  "pqc",
@@ -19,7 +19,17 @@
19
19
  "integrity",
20
20
  "immutable-log",
21
21
  "signed-log",
22
- "sequence"
22
+ "sequence",
23
+ "quantum-safe",
24
+ "pqc-migration",
25
+ "digital-signature",
26
+ "soc2",
27
+ "iso27001",
28
+ "append-only",
29
+ "event-log",
30
+ "non-repudiation",
31
+ "ai-agent",
32
+ "crypto-agility"
23
33
  ],
24
34
  "license": "Apache-2.0",
25
35
  "author": "Shayne Heffernan and John Heffernan",
package/src/audit-log.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mlDsa } from 'kxco-post-quantum'
1
+ import { mlDsa, fingerprint } from 'kxco-post-quantum'
2
2
  import { sha256 } from '@noble/hashes/sha2.js'
3
3
  import { KxcoPqAuditError } from './errors.js'
4
4
 
@@ -26,6 +26,70 @@ function sealBytes(fromSeq, toSeq, prevRoot, rootHash, timestamp) {
26
26
  /** The first seal has no predecessor; this stands in for one so the seals chain too. */
27
27
  const GENESIS_ROOT = '0'.repeat(64)
28
28
 
29
+ /**
30
+ * Which key signed a record.
31
+ *
32
+ * `kid` is deliberately NOT part of the signed bytes. Including it would mean a
33
+ * new signing-message version, and every log written by this package would stop
34
+ * verifying under an older reader. Left out, it is a selector rather than a
35
+ * claim: tampering with it makes verification select the wrong key and fail,
36
+ * which is the same outcome tampering with anything else already produces. It
37
+ * cannot make a forged record verify, because that still needs a key the
38
+ * verifier was given.
39
+ *
40
+ * The consequence worth stating: a log written by this version verifies
41
+ * unchanged under 1.3.x, and a log written by 1.3.x verifies here.
42
+ */
43
+ function kidOf(publicKey) {
44
+ return fingerprint(new Uint8Array(publicKey))
45
+ }
46
+
47
+ /**
48
+ * Normalise whatever verify() was handed into a candidate list.
49
+ *
50
+ * One key keeps the original call shape working. An array is what a log that
51
+ * outlived a key rotation needs, because no single key signed all of it.
52
+ */
53
+ function candidatesFrom(publicKey) {
54
+ const list = Array.isArray(publicKey) ? publicKey : [publicKey]
55
+ if (list.length === 0) {
56
+ throw new KxcoPqAuditError('verify: at least one public key is required')
57
+ }
58
+ return list.map((pk) => {
59
+ if (!pk) throw new KxcoPqAuditError('verify: a public key was null or undefined')
60
+ const bytes = new Uint8Array(pk)
61
+ return { kid: kidOf(bytes), publicKey: bytes }
62
+ })
63
+ }
64
+
65
+ function verifySignature(candidates, recordKid, msg, signatureB64, label) {
66
+ const sigHex = () => Buffer.from(fromB64url(signatureB64)).toString('hex')
67
+
68
+ // A record that names its key is checked against that key and no other.
69
+ // Falling back to the rest would let a swapped kid pass under a different
70
+ // key, which is not a forgery but is a confusing thing to report as valid.
71
+ if (recordKid) {
72
+ const match = candidates.find((c) => c.kid === recordKid)
73
+ if (!match) {
74
+ return {
75
+ error: `${label}: signed by kid ${recordKid}, which was not among the ` +
76
+ `${candidates.length} key(s) supplied`,
77
+ }
78
+ }
79
+ let ok
80
+ try { ok = mlDsa.verify(match.publicKey, msg, sigHex()) } catch { ok = false }
81
+ return ok ? { kid: match.kid } : { error: `${label}: signature invalid` }
82
+ }
83
+
84
+ // No kid: a log written before this version. Try each key.
85
+ for (const c of candidates) {
86
+ let ok
87
+ try { ok = mlDsa.verify(c.publicKey, msg, sigHex()) } catch { ok = false }
88
+ if (ok) return { kid: c.kid }
89
+ }
90
+ return { error: `${label}: signature invalid` }
91
+ }
92
+
29
93
  /**
30
94
  * A run's root: the previous seal's root followed by every entry hash in seq
31
95
  * order. Chaining the roots means removing a whole seal breaks the next one,
@@ -46,6 +110,7 @@ export class AuditLog {
46
110
  #keypair
47
111
  #entries = []
48
112
  #sealsList = []
113
+ #kidCache = null
49
114
  #chain
50
115
  #checkpointEvery
51
116
  #institutionKid
@@ -77,6 +142,19 @@ export class AuditLog {
77
142
  /** True when this log signs once per sealed run rather than once per entry. */
78
143
  get sealed() { return this.#sealed }
79
144
 
145
+ /** The kid of the key this log signs with. Derived once; it cannot change. */
146
+ #signingKid() {
147
+ this.#kidCache ??= kidOf(this.#keypair.publicKey)
148
+ return this.#kidCache
149
+ }
150
+
151
+ /**
152
+ * The kid a verifier needs for the records this log is writing. Publishing it
153
+ * alongside the log means a reader does not have to derive it from a key they
154
+ * may not have yet.
155
+ */
156
+ get signingKid() { return this.#signingKid() }
157
+
80
158
  /**
81
159
  * One pass over whatever is already stored, to find the tail and, on a sealed
82
160
  * log, the entries the next seal will cover. Everything after this is O(1).
@@ -128,6 +206,9 @@ export class AuditLog {
128
206
  if (!this.#sealed) {
129
207
  const msg = signingBytes(seq, ts, operation, metadata, prev)
130
208
  entry.signature = b64url(Buffer.from(mlDsa.sign(new Uint8Array(this.#keypair.secretKey), msg), 'hex'))
209
+ // Which key signed this entry, so a log that outlives a rotation can say
210
+ // so per entry rather than forcing one key across the whole file.
211
+ entry.kid = this.#signingKid()
131
212
  }
132
213
 
133
214
  await this._store(entry)
@@ -186,6 +267,9 @@ export class AuditLog {
186
267
  timestamp,
187
268
  signature,
188
269
  institutionKid: this.#institutionKid,
270
+ // The signing key, distinct from institutionKid, which names the
271
+ // institution rather than the key that produced this signature.
272
+ kid: this.#signingKid(),
189
273
  }
190
274
  await this._storeSeal(seal)
191
275
  this.#pending = []
@@ -216,6 +300,9 @@ export class AuditLog {
216
300
  * the seal list rather than by the log.
217
301
  */
218
302
  async verify(publicKey) {
303
+ const candidates = candidatesFrom(publicKey)
304
+ const usedKids = new Set()
305
+
219
306
  const seals = this.#sealed ? await this._seals() : []
220
307
  let sealIndex = 0
221
308
  let expectedFrom = 0
@@ -238,10 +325,9 @@ export class AuditLog {
238
325
 
239
326
  if (!this.#sealed) {
240
327
  const msg = signingBytes(entry.seq, entry.timestamp, entry.operation, entry.metadata, entry.prevHash)
241
- let ok
242
- try { ok = mlDsa.verify(new Uint8Array(publicKey), msg, Buffer.from(fromB64url(entry.signature)).toString('hex')) }
243
- catch { ok = false }
244
- if (!ok) return { valid: false, error: `entry ${count}: signature invalid` }
328
+ const res = verifySignature(candidates, entry.kid, msg, entry.signature, `entry ${count}`)
329
+ if (res.error) return { valid: false, error: res.error }
330
+ usedKids.add(res.kid)
245
331
  } else if (sealIndex < seals.length) {
246
332
  const s = seals[sealIndex]
247
333
  if (s.fromSeq !== expectedFrom) {
@@ -252,7 +338,7 @@ export class AuditLog {
252
338
  }
253
339
  if (entry.seq >= s.fromSeq) runHashes.push(hashEntry(entry))
254
340
  if (entry.seq === s.toSeq) {
255
- const bad = this.#closeSeal(s, sealIndex, prevRoot, runHashes, publicKey)
341
+ const bad = this.#closeSeal(s, sealIndex, prevRoot, runHashes, candidates, usedKids)
256
342
  if (bad) return bad
257
343
  prevRoot = s.rootHash
258
344
  expectedFrom = s.toSeq + 1
@@ -266,7 +352,7 @@ export class AuditLog {
266
352
  count++
267
353
  }
268
354
 
269
- if (!this.#sealed) return { valid: true, count }
355
+ if (!this.#sealed) return { valid: true, count, kids: [...usedKids] }
270
356
 
271
357
  if (sealIndex < seals.length) {
272
358
  const s = seals[sealIndex]
@@ -280,18 +366,18 @@ export class AuditLog {
280
366
  count,
281
367
  sealedThrough: expectedFrom - 1,
282
368
  unsealed: count - expectedFrom,
369
+ kids: [...usedKids],
283
370
  }
284
371
  }
285
372
 
286
- #closeSeal(s, index, prevRoot, runHashes, publicKey) {
373
+ #closeSeal(s, index, prevRoot, runHashes, candidates, usedKids) {
287
374
  if (rootOf(prevRoot, runHashes) !== s.rootHash) {
288
375
  return { valid: false, error: `seal ${index}: entries do not reproduce rootHash` }
289
376
  }
290
377
  const msg = sealBytes(s.fromSeq, s.toSeq, s.prevRoot, s.rootHash, s.timestamp)
291
- let ok
292
- try { ok = mlDsa.verify(new Uint8Array(publicKey), msg, Buffer.from(fromB64url(s.signature)).toString('hex')) }
293
- catch { ok = false }
294
- if (!ok) return { valid: false, error: `seal ${index}: signature invalid` }
378
+ const res = verifySignature(candidates, s.kid, msg, s.signature, `seal ${index}`)
379
+ if (res.error) return { valid: false, error: res.error }
380
+ usedKids.add(res.kid)
295
381
  return null
296
382
  }
297
383
 
package/src/index.d.ts CHANGED
@@ -12,6 +12,16 @@ export interface AuditEntry {
12
12
  * fields. Absent on a sealed log, where the run is signed once by `seal()`.
13
13
  */
14
14
  signature?: string
15
+ /**
16
+ * 16 hex characters identifying the key that signed this entry, present from
17
+ * 1.4.0. Absent on entries written by earlier versions and on sealed logs,
18
+ * where the seal carries it instead.
19
+ *
20
+ * Not part of the signed bytes: it selects a key rather than asserting one.
21
+ * Tampering with it makes verification pick the wrong key and fail; it cannot
22
+ * make a forged entry verify.
23
+ */
24
+ kid?: string
15
25
  }
16
26
 
17
27
  /** One signed run of a sealed log. Seals chain to each other by `prevRoot`. */
@@ -27,6 +37,12 @@ export interface AuditSeal {
27
37
  /** base64url ML-DSA-65 signature over the seal's canonical form. */
28
38
  signature: string
29
39
  institutionKid: string | null
40
+ /**
41
+ * 16 hex characters identifying the key that signed this seal, present from
42
+ * 1.4.0. Distinct from `institutionKid`, which names the institution rather
43
+ * than the signing key.
44
+ */
45
+ kid?: string
30
46
  }
31
47
 
32
48
  export interface AuditVerifySuccess {
@@ -43,6 +59,11 @@ export interface AuditVerifySuccess {
43
59
  * either — seal() closes the window.
44
60
  */
45
61
  unsealed?: number
62
+ /**
63
+ * Every signing key that actually produced a signature in this log, in the
64
+ * order first seen. More than one means the log spans a key rotation.
65
+ */
66
+ kids: string[]
46
67
  }
47
68
 
48
69
  export interface AuditVerifyFailure {
@@ -107,7 +128,23 @@ export declare class AuditLog {
107
128
  * Verify the full hash chain, plus every entry's signature on a classic log
108
129
  * or every seal on a sealed one.
109
130
  */
110
- verify(publicKey: Uint8Array | Buffer): Promise<AuditVerifyResult>
131
+ /**
132
+ * Replay the log and check every signature.
133
+ *
134
+ * Pass one key for a log signed by one key. Pass several for a log that
135
+ * outlived a key rotation: each record is checked against the key its `kid`
136
+ * names, and records written before 1.4.0 carry no kid and are checked
137
+ * against each key in turn.
138
+ *
139
+ * A record naming a key that was not supplied is a failure that says so,
140
+ * rather than a generic invalid signature.
141
+ */
142
+ verify(
143
+ publicKey: Uint8Array | Buffer | Array<Uint8Array | Buffer>,
144
+ ): Promise<AuditVerifyResult>
145
+
146
+ /** The kid of the key this log signs with. */
147
+ readonly signingKid: string
111
148
 
112
149
  /**
113
150
  * Sign everything appended since the last seal, as one run. Returns the seal,