kxco-pq-audit 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ASSESSMENT.md +130 -0
- package/CHANGELOG.md +84 -0
- package/README.md +24 -1
- package/package.json +5 -3
- package/src/audit-log.js +98 -12
- package/src/index.d.ts +38 -1
package/ASSESSMENT.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Assessment notes
|
|
2
|
+
|
|
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.
|
|
5
|
+
|
|
6
|
+
Algorithm conformance belongs to
|
|
7
|
+
[`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum), which
|
|
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.
|
|
97
|
+
|
|
98
|
+
## Agility
|
|
99
|
+
|
|
100
|
+
**Inherited.** The signature primitive and its two interchangeable backends
|
|
101
|
+
belong to `kxco-post-quantum`.
|
|
102
|
+
|
|
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.
|
|
109
|
+
|
|
110
|
+
## Running it
|
|
111
|
+
|
|
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.
|
|
117
|
+
|
|
118
|
+
**Supported versions.** One line moving forward. Fixes land in the next release.
|
|
119
|
+
|
|
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.
|
|
123
|
+
|
|
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.
|
|
126
|
+
|
|
127
|
+
## Correcting this document
|
|
128
|
+
|
|
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,89 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0
|
|
4
|
+
|
|
5
|
+
A log that outlives its signing key can now be verified as one artefact.
|
|
6
|
+
|
|
7
|
+
**`verify()` accepts several public keys.** It took one, and applied it to every
|
|
8
|
+
record, so a log whose key rotated part-way through could not be verified at
|
|
9
|
+
all: entries before the rotation were signed by a key `verify` was no longer
|
|
10
|
+
being given. Hierarchical credentials exist so that keys can change, which made
|
|
11
|
+
this the gap most likely to be hit by the deployments least able to work around
|
|
12
|
+
it.
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
await log.verify([oldKey.publicKey, newKey.publicKey])
|
|
16
|
+
// { valid: true, count: 3, kids: ['a1b2…', 'c3d4…'] }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Entries and seals record a `kid`.** Sixteen hex characters naming the key that
|
|
20
|
+
signed them, so verification selects the right key instead of forcing one across
|
|
21
|
+
the whole file. `log.signingKid` exposes the same value for the key a log is
|
|
22
|
+
writing with. `verify()` returns `kids`, every key that actually signed, in the
|
|
23
|
+
order first seen; more than one means the log spans a rotation.
|
|
24
|
+
|
|
25
|
+
**A missing key is now named.** Supplying too few keys reports which one is
|
|
26
|
+
absent rather than a generic bad signature:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
entry 0: signed by kid a1b2c3d4e5f60718, which was not among the 1 key(s) supplied
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That message replaces `entry N: signature invalid` for this case. A wrong key
|
|
33
|
+
that shares no kid with the record is still refused; only the wording changed.
|
|
34
|
+
|
|
35
|
+
**Compatible in both directions, deliberately.** `kid` is not part of the signed
|
|
36
|
+
bytes. Including it would have meant a new signing-message version, and every
|
|
37
|
+
log written here would have stopped verifying under an older reader. So logs
|
|
38
|
+
written by 1.4.0 verify under 1.3.x, and logs written before 1.4.0 carry no kid
|
|
39
|
+
and are checked against each supplied key in turn. Passing a single key keeps
|
|
40
|
+
working exactly as before.
|
|
41
|
+
|
|
42
|
+
As a selector rather than a claim, a tampered `kid` makes verification pick the
|
|
43
|
+
wrong key and fail. It cannot make a forged record verify, because that still
|
|
44
|
+
needs a key the verifier was given.
|
|
45
|
+
|
|
46
|
+
**It also makes a log answerable to the rest of the stack.** Before this an
|
|
47
|
+
entry named no key, so there was nothing to look up. Every kid is the identifier
|
|
48
|
+
`kxco-pq-network` resolves against the registry as `active`, `revoked`,
|
|
49
|
+
`rotated` or `expired`, and that `kxco-pq-chain`'s `revokeKid()` writes on
|
|
50
|
+
chain; the on-chain credential carries `expiresAt`.
|
|
51
|
+
|
|
52
|
+
This package still does not make that call. `verify()` proves which key signed
|
|
53
|
+
each entry and that the chain is intact, and will not refuse an entry signed by
|
|
54
|
+
a since-revoked key because it does not ask. Pairing the two is the caller's,
|
|
55
|
+
and as of this release it is possible.
|
|
56
|
+
|
|
57
|
+
**ASSESSMENT.md.** Where this package's boundary falls, what cryptographic
|
|
58
|
+
agility it has beyond what the primitives provide, and what constrains its
|
|
59
|
+
lifecycle. It references the `kxco-post-quantum` evidence rather than restating
|
|
60
|
+
it, because a second copy of a conformance claim invites the reader to count it
|
|
61
|
+
twice.
|
|
62
|
+
|
|
63
|
+
**An evidence bundle.** `npm run evidence` records identity, this package's own
|
|
64
|
+
tests, its SBOM, registry signature verification, and the `kxco-post-quantum`
|
|
65
|
+
version actually installed rather than the range declared.
|
|
66
|
+
|
|
67
|
+
## 1.3.1
|
|
68
|
+
|
|
69
|
+
Documentation and a dependency refresh. No source change.
|
|
70
|
+
|
|
71
|
+
**ASSESSMENT.md.** Where this package's boundary falls, what cryptographic
|
|
72
|
+
agility it has beyond what the primitives provide, and what constrains its
|
|
73
|
+
lifecycle. It references the `kxco-post-quantum` evidence rather than restating
|
|
74
|
+
it, because a second copy of a conformance claim invites the reader to count it
|
|
75
|
+
twice.
|
|
76
|
+
|
|
77
|
+
**`npm run evidence` now exists.** The README already told you to run it and
|
|
78
|
+
there was no such script, so the command failed for anyone who followed it.
|
|
79
|
+
The bundle records identity, this package's own tests, its SBOM, registry
|
|
80
|
+
signature verification, and the `kxco-post-quantum` version actually installed
|
|
81
|
+
rather than the range declared.
|
|
82
|
+
|
|
83
|
+
**`kxco-post-quantum` refreshed to 1.7.2**, from 1.4.0 in the previous
|
|
84
|
+
lockfile. Within the existing range, so no declared dependency changed. Tests
|
|
85
|
+
pass unchanged.
|
|
86
|
+
|
|
3
87
|
## 1.3.0
|
|
4
88
|
|
|
5
89
|
### Added
|
package/README.md
CHANGED
|
@@ -134,10 +134,33 @@ Replays the entire log from entry 0. For each entry, checks:
|
|
|
134
134
|
1. `prevHash` matches the SHA-256 of the previous entry (including its signature)
|
|
135
135
|
2. The ML-DSA-65 signature is valid over the canonical signing bytes
|
|
136
136
|
|
|
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`.
|
|
137
|
+
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`.
|
|
138
138
|
|
|
139
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.
|
|
140
140
|
|
|
141
|
+
#### Logs that outlive a key rotation
|
|
142
|
+
|
|
143
|
+
A long-lived log will outlast the key it started with. Pass every key it was signed under and it verifies as one artefact:
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const result = await log.verify([oldKey.publicKey, newKey.publicKey])
|
|
147
|
+
// { valid: true, count: 3, kids: ['a1b2…', 'c3d4…'] }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
Supply too few keys and the failure names what is missing, rather than reporting a generic bad signature:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
entry 0: signed by kid a1b2c3d4e5f60718, which was not among the 1 key(s) supplied
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
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.
|
|
159
|
+
|
|
160
|
+
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.
|
|
161
|
+
|
|
162
|
+
This package does not make that call. `verify()` proves which key signed each entry and that the chain is intact; it will not refuse an entry signed by a since-revoked key, because it does not ask. Putting the two together is the caller's, and it is now possible.
|
|
163
|
+
|
|
141
164
|
### `log.seal()`
|
|
142
165
|
|
|
143
166
|
Sealed logs only. Signs everything appended since the last seal, as one run, and returns the seal. Returns `null` when nothing is unsealed, and throws on a log built without `sealed: true`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kxco-pq-audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"post-quantum",
|
|
@@ -53,7 +53,8 @@
|
|
|
53
53
|
"CHANGELOG.md",
|
|
54
54
|
"LICENSE",
|
|
55
55
|
"README.md",
|
|
56
|
-
"src"
|
|
56
|
+
"src",
|
|
57
|
+
"ASSESSMENT.md"
|
|
57
58
|
],
|
|
58
59
|
"engines": {
|
|
59
60
|
"node": ">=20.19"
|
|
@@ -63,7 +64,8 @@
|
|
|
63
64
|
"kxco-post-quantum": "^1.3.0"
|
|
64
65
|
},
|
|
65
66
|
"scripts": {
|
|
66
|
-
"test": "node --test --test-timeout=30000 test/audit.test.js"
|
|
67
|
+
"test": "node --test --test-timeout=30000 test/audit.test.js",
|
|
68
|
+
"evidence": "node scripts/build-evidence.mjs"
|
|
67
69
|
},
|
|
68
70
|
"funding": "https://kxco.ai",
|
|
69
71
|
"publishConfig": {
|
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,
|