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 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.0",
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
- 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,