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 +114 -106
- package/CHANGELOG.md +76 -0
- package/README.md +114 -66
- package/package.json +13 -3
- package/src/audit-log.js +98 -12
- package/src/index.d.ts +38 -1
package/ASSESSMENT.md
CHANGED
|
@@ -1,122 +1,130 @@
|
|
|
1
1
|
# Assessment notes
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
6
|
+
Algorithm conformance belongs to
|
|
8
7
|
[`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum), which
|
|
9
|
-
runs
|
|
10
|
-
and publishes
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
100
|
+
**Inherited.** The signature primitive and its two interchangeable backends
|
|
101
|
+
belong to `kxco-post-quantum`.
|
|
72
102
|
|
|
73
|
-
**
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
118
|
+
**Supported versions.** One line moving forward. Fixes land in the next release.
|
|
90
119
|
|
|
91
|
-
**
|
|
92
|
-
|
|
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
|
-
**
|
|
95
|
-
|
|
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
|
|
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
|
[](https://www.npmjs.com/package/kxco-pq-audit)
|
|
6
|
+
[](https://www.npmjs.com/package/kxco-pq-audit)
|
|
7
|
+
[](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/CONFORMANCE.md)
|
|
8
|
+
[](https://www.npmjs.com/package/kxco-pq-audit)
|
|
4
9
|
[](https://socket.dev/npm/package/kxco-pq-audit)
|
|
5
10
|
[](./LICENSE)
|
|
6
11
|
[](https://nodejs.org)
|
|
7
12
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
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
|
|
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 |
|
|
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
|
|
123
|
-
- `metadata
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
| Package | Role |
|
|
272
|
+
| You need to | Install |
|
|
247
273
|
|---|---|
|
|
248
|
-
| [`kxco-
|
|
249
|
-
| [`kxco-
|
|
250
|
-
| [`kxco-pq-
|
|
251
|
-
| [`kxco-pq-
|
|
252
|
-
| [`kxco-pq-
|
|
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
|
-
- **
|
|
263
|
-
- **225 interoperability checks passed, 0 failed
|
|
264
|
-
- **SLSA provenance** on every published release
|
|
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
|
|
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.
|
|
4
|
-
"description": "Tamper-evident post-quantum audit
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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,
|