kxco-pq-audit 1.4.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0
4
+ **ML-DSA-87 logs.** A log given an ML-DSA-87 keypair signs its entries, or its
5
+ seals, with ML-DSA-87; `log.signingAlg` reports the set. Each such record
6
+ carries `alg: 'ML-DSA-87'` and is signed over v1.1 bytes (`kxco-audit-v1.1`,
7
+ `kxco-audit-seal-v1.1`) whose second line is the algorithm, so it is inside the
8
+ signed bytes. An ML-DSA-65 log writes exactly the v1 records it always has, so
9
+ logs from this version still verify under 1.4.x and earlier.
10
+
11
+ **The verifier takes the algorithm from the key.** A record whose `alg` names
12
+ the other set from the key its `kid` selects is refused and the error says so; a
13
+ record with no `kid` is tried only against keys of the set it states. A record
14
+ with no `alg` is read as ML-DSA-65, and a test verifies a classic and a sealed
15
+ log written by 1.4.5. A log that rotated from ML-DSA-65 to ML-DSA-87 verifies
16
+ with both keys.
17
+
18
+ The `kxco-post-quantum` floor is now ^1.6.0.
19
+
20
+ ## 1.4.5
21
+
22
+ `verify(publicKey, { checkpoint })` takes the `{ count, tip }` an earlier verify
23
+ returned, and fails a log that has since lost or replaced its last entry.
24
+ Verify results carry `tip`.
25
+
26
+ A file line that is JSON but not an entry, and a torn or edited `.seals` line,
27
+ are refused with KxcoPqAuditError naming the line. The README Security section
28
+ says when each kind of removal is caught.
29
+
3
30
  ## 1.4.4
4
31
 
5
32
  Documentation. No source change.
package/README.md CHANGED
@@ -109,7 +109,7 @@ In-memory log, for tests and short-lived processes. Use `FileAuditLog` to persis
109
109
 
110
110
  | Option | Type | Required | Default | Description |
111
111
  |---|---|---|---|---|
112
- | `keypair` | `{ secretKey, publicKey }` | Yes | none | ML-DSA-65 keypair from `kxco-post-quantum` |
112
+ | `keypair` | `{ secretKey, publicKey }` | Yes | none | ML-DSA-65 or ML-DSA-87 keypair from `kxco-post-quantum`. The key decides which set signs; `log.signingAlg` reports it |
113
113
  | `chain` | `KxcoChain` | No | `null` | Chain instance for checkpoint anchoring |
114
114
  | `checkpointEvery` | `number` | No | `100` | Anchor a checkpoint every N entries |
115
115
  | `institutionKid` | `string` | No | `null` | Identifier included in checkpoint metadata |
@@ -149,7 +149,7 @@ Appending needs the previous entry's hash, not the log, so it costs the same on
149
149
  Replays the entire log from entry 0. For each entry, checks:
150
150
 
151
151
  1. `prevHash` matches the SHA-256 of the previous entry (including its signature)
152
- 2. The ML-DSA-65 signature is valid over the canonical signing bytes
152
+ 2. The ML-DSA signature is valid over the canonical signing bytes, under the set the verifying key belongs to
153
153
 
154
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
155
 
@@ -179,6 +179,26 @@ Recording the kid is also what makes a log answerable to the rest of the stack.
179
179
  Pair `verify()` with that lookup and a log proves both that it is intact and
180
180
  that every signing key was trusted: live revocation, from the KXCO network.
181
181
 
182
+ #### ML-DSA-87 logs
183
+
184
+ A log given an ML-DSA-87 keypair signs every entry, or every seal, with
185
+ ML-DSA-87. Each such record carries `alg: 'ML-DSA-87'` and is signed over v1.1
186
+ bytes: `kxco-audit-v1.1` (or `kxco-audit-seal-v1.1`) on the first line, the
187
+ algorithm on the second, then the v1 fields unchanged, so the algorithm is
188
+ inside the signed bytes. An ML-DSA-65 log writes exactly the v1 records it
189
+ always has, with no `alg`, so it still verifies under 1.4.x and earlier.
190
+
191
+ The verifier takes the algorithm from the key. A record whose `alg` names the
192
+ other set from the key its `kid` selects is refused:
193
+
194
+ ```
195
+ entry 0: signed as ML-DSA-65, but kid a1b2c3d4e5f60718 is an ML-DSA-87 key
196
+ ```
197
+
198
+ A record with no `alg` is read as ML-DSA-65, which is how every record made
199
+ before the field reads. A log that rotated from an ML-DSA-65 key to an ML-DSA-87
200
+ key verifies as one artefact when both keys are passed.
201
+
182
202
  ### `log.seal()`
183
203
 
184
204
  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`.
@@ -220,7 +240,7 @@ Sealed logs only. Entries appended since the last seal, without replaying the lo
220
240
  }
221
241
  ```
222
242
 
223
- `prevHash` is the SHA-256 of the complete previous entry (signature included). The first entry always has `prevHash: null`. The signing message covers every field except `signature` itself.
243
+ `prevHash` is the SHA-256 of the complete previous entry (signature included). The first entry always has `prevHash: null`. The signing message covers every field except `signature` itself. An entry signed with ML-DSA-87 also carries `"alg": "ML-DSA-87"`, which the signing message covers.
224
244
 
225
245
  ## Sealed logs
226
246
 
@@ -315,7 +335,7 @@ Evidenced, and reproducible on your own machine:
315
335
 
316
336
  Dependency audit history is recorded in [AUDIT.md](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/AUDIT.md).
317
337
 
318
- The hash chain means a compromised or deleted entry cannot be hidden: any gap breaks verification of every subsequent entry.
338
+ The hash chain means a compromised or deleted entry cannot be hidden. Removing any entry before the last breaks the chain at the entry after it, and `verify()` names that entry. Removing the last entry is caught by verifying against a checkpoint: pass the `{ count, tip }` an earlier `verify()` returned, as `log.verify(publicKey, { checkpoint })`, and a log that has since lost or replaced its last entry fails. On a sealed log, the seal that covers the last entry catches its removal as well.
319
339
 
320
340
  To report a vulnerability, open a [private security advisory](https://github.com/KnightsbridgeAIQ/kxco-pq-audit/security/advisories/new) or email **security@kxco.ai**.
321
341
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kxco-pq-audit",
3
- "version": "1.4.4",
3
+ "version": "1.5.0",
4
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",
@@ -71,15 +71,18 @@
71
71
  },
72
72
  "dependencies": {
73
73
  "@noble/hashes": "2.4.0",
74
- "kxco-post-quantum": "^1.3.0"
74
+ "kxco-post-quantum": "^1.6.0"
75
75
  },
76
76
  "scripts": {
77
- "test": "node --test --test-timeout=30000 test/audit.test.js",
77
+ "test": "node --test --test-timeout=30000 test/audit.test.js test/property.test.js test/ml-dsa-87.test.js",
78
78
  "evidence": "node scripts/build-evidence.mjs"
79
79
  },
80
80
  "funding": "https://kxco.ai",
81
81
  "publishConfig": {
82
82
  "provenance": true,
83
83
  "access": "public"
84
+ },
85
+ "devDependencies": {
86
+ "fast-check": "4.10.2"
84
87
  }
85
88
  }
package/src/audit-log.js CHANGED
@@ -1,7 +1,36 @@
1
- import { mlDsa, fingerprint } from 'kxco-post-quantum'
1
+ import { mlDsa, mlDsa87, fingerprint } from 'kxco-post-quantum'
2
2
  import { sha256 } from '@noble/hashes/sha2.js'
3
3
  import { KxcoPqAuditError } from './errors.js'
4
4
 
5
+ /**
6
+ * The ML-DSA parameter sets a log can be signed with. The KEY decides which:
7
+ * a key's length names its set, on the writing side and the verifying side.
8
+ *
9
+ * An ML-DSA-65 record is signed over the v1 bytes, exactly as before, and
10
+ * carries no `alg`, so a log written by this version still verifies under
11
+ * 1.4.x and earlier. An ML-DSA-87 record carries `alg: 'ML-DSA-87'` and is
12
+ * signed over v1.1 bytes, whose first line differs from v1 and whose second
13
+ * line is the algorithm, so the algorithm is inside the signed bytes. A record
14
+ * whose `alg` names neither set is read as v1, which means ML-DSA-65: that is
15
+ * how every record made before the field existed reads.
16
+ */
17
+ const SETS = Object.freeze({
18
+ 'ML-DSA-65': Object.freeze({ module: mlDsa, publicKeyBytes: 1952, secretKeyBytes: 4032 }),
19
+ 'ML-DSA-87': Object.freeze({ module: mlDsa87, publicKeyBytes: 2592, secretKeyBytes: 4896 }),
20
+ })
21
+ const DEFAULT_ALG = 'ML-DSA-65'
22
+
23
+ function algForKey(key, field) {
24
+ for (const [name, set] of Object.entries(SETS)) if (key?.length === set[field]) return name
25
+ return null
26
+ }
27
+
28
+ const statedAlg = (value) => (Object.hasOwn(SETS, value) ? value : null)
29
+
30
+ function versionLine(tag, alg) {
31
+ return alg === null ? `${tag}-v1` : `${tag}-v1.1\n${alg}`
32
+ }
33
+
5
34
  const enc = new TextEncoder()
6
35
 
7
36
  function b64url(bytes) { return Buffer.from(bytes).toString('base64url') }
@@ -11,15 +40,15 @@ function hashEntry(entry) {
11
40
  return b64url(sha256(enc.encode(JSON.stringify(entry))))
12
41
  }
13
42
 
14
- function signingBytes(seq, timestamp, operation, metadata, prevHash) {
43
+ function signingBytes(seq, timestamp, operation, metadata, prevHash, alg = null) {
15
44
  return enc.encode(
16
- `kxco-audit-v1\n${seq}\n${timestamp}\n${operation}\n${prevHash ?? 'null'}\n${JSON.stringify(metadata)}`
45
+ `${versionLine('kxco-audit', alg)}\n${seq}\n${timestamp}\n${operation}\n${prevHash ?? 'null'}\n${JSON.stringify(metadata)}`
17
46
  )
18
47
  }
19
48
 
20
- function sealBytes(fromSeq, toSeq, prevRoot, rootHash, timestamp) {
49
+ function sealBytes(fromSeq, toSeq, prevRoot, rootHash, timestamp, alg = null) {
21
50
  return enc.encode(
22
- `kxco-audit-seal-v1\n${fromSeq}\n${toSeq}\n${prevRoot}\n${rootHash}\n${timestamp}`
51
+ `${versionLine('kxco-audit-seal', alg)}\n${fromSeq}\n${toSeq}\n${prevRoot}\n${rootHash}\n${timestamp}`
23
52
  )
24
53
  }
25
54
 
@@ -58,12 +87,54 @@ function candidatesFrom(publicKey) {
58
87
  return list.map((pk) => {
59
88
  if (!pk) throw new KxcoPqAuditError('verify: a public key was null or undefined')
60
89
  const bytes = new Uint8Array(pk)
61
- return { kid: kidOf(bytes), publicKey: bytes }
90
+ return { kid: kidOf(bytes), publicKey: bytes, alg: algForKey(bytes, 'publicKeyBytes') }
62
91
  })
63
92
  }
64
93
 
65
- function verifySignature(candidates, recordKid, msg, signatureB64, label) {
94
+ /**
95
+ * Normalise a checkpoint: what a verifier kept from an earlier look at the log.
96
+ *
97
+ * `count` is how many entries the log held and `tip` the hash of the last of
98
+ * them, so any earlier verify() result is a checkpoint as it stands. Either may
99
+ * be given alone. Checked strictly, because a checkpoint that names nothing
100
+ * would check nothing and read as a pass.
101
+ */
102
+ function checkpointFrom(checkpoint) {
103
+ if (checkpoint === undefined) return null
104
+ const count = checkpoint?.count
105
+ const tip = checkpoint?.tip ?? undefined
106
+ if (count !== undefined && !(Number.isSafeInteger(count) && count >= 0)) {
107
+ throw new KxcoPqAuditError('verify: checkpoint.count must be a whole number of entries')
108
+ }
109
+ if (tip !== undefined && typeof tip !== 'string') {
110
+ throw new KxcoPqAuditError('verify: checkpoint.tip must be the hash string an earlier verify() returned')
111
+ }
112
+ if (count === undefined && tip === undefined) {
113
+ throw new KxcoPqAuditError('verify: a checkpoint needs a count, a tip or both')
114
+ }
115
+ if (count === 0 && tip !== undefined) {
116
+ throw new KxcoPqAuditError('verify: a checkpoint with a tip covers at least one entry')
117
+ }
118
+ return { count, tip }
119
+ }
120
+
121
+ /**
122
+ * Check one record's signature. `messageFor(alg)` builds the signed bytes for
123
+ * the algorithm the record states (null for a v1 record).
124
+ *
125
+ * The key decides the algorithm. A record stating the other set from the key
126
+ * its kid names is refused rather than tried, and a record with no kid is
127
+ * tried only against keys of the set it states.
128
+ */
129
+ function verifySignature(candidates, recordKid, recordAlg, messageFor, signatureB64, label) {
66
130
  const sigHex = () => Buffer.from(fromB64url(signatureB64)).toString('hex')
131
+ const stated = statedAlg(recordAlg)
132
+ const alg = stated ?? DEFAULT_ALG
133
+ const msg = messageFor(stated)
134
+ const verifyWith = (c) => {
135
+ if (c.alg !== alg) return false
136
+ try { return SETS[alg].module.verify(c.publicKey, msg, sigHex()) } catch { return false }
137
+ }
67
138
 
68
139
  // A record that names its key is checked against that key and no other.
69
140
  // Falling back to the rest would let a swapped kid pass under a different
@@ -76,16 +147,15 @@ function verifySignature(candidates, recordKid, msg, signatureB64, label) {
76
147
  `${candidates.length} key(s) supplied`,
77
148
  }
78
149
  }
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` }
150
+ if (match.alg !== null && match.alg !== alg) {
151
+ return { error: `${label}: signed as ${alg}, but kid ${recordKid} is an ${match.alg} key` }
152
+ }
153
+ return verifyWith(match) ? { kid: match.kid } : { error: `${label}: signature invalid` }
82
154
  }
83
155
 
84
- // No kid: a log written before this version. Try each key.
156
+ // No kid: a log written before 1.4.0. Try each key of the stated set.
85
157
  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 }
158
+ if (verifyWith(c)) return { kid: c.kid }
89
159
  }
90
160
  return { error: `${label}: signature invalid` }
91
161
  }
@@ -148,6 +218,24 @@ export class AuditLog {
148
218
  return this.#kidCache
149
219
  }
150
220
 
221
+ /** The set this log signs with, decided by its secret key. */
222
+ #signingAlg() {
223
+ const alg = algForKey(this.#keypair.secretKey, 'secretKeyBytes')
224
+ if (alg === null) throw new KxcoPqAuditError('the signing key is neither ML-DSA-65 nor ML-DSA-87')
225
+ return alg
226
+ }
227
+
228
+ /** Sign `bytesFor(stated)`; returns the base64url signature and the alg to record, if any. */
229
+ #sign(bytesFor) {
230
+ const alg = this.#signingAlg()
231
+ const stated = alg === DEFAULT_ALG ? null : alg
232
+ const sig = SETS[alg].module.sign(new Uint8Array(this.#keypair.secretKey), bytesFor(stated))
233
+ return { signature: b64url(Buffer.from(sig, 'hex')), stated }
234
+ }
235
+
236
+ /** The parameter set this log signs with: 'ML-DSA-65' or 'ML-DSA-87'. */
237
+ get signingAlg() { return this.#signingAlg() }
238
+
151
239
  /**
152
240
  * The kid a verifier needs for the records this log is writing. Publishing it
153
241
  * alongside the log means a reader does not have to derive it from a key they
@@ -204,11 +292,13 @@ export class AuditLog {
204
292
  // entry costs ~2ms and ~4.4KB and does not survive agent-scale volume.
205
293
  const entry = { seq, timestamp: ts, operation, metadata, prevHash: prev }
206
294
  if (!this.#sealed) {
207
- const msg = signingBytes(seq, ts, operation, metadata, prev)
208
- entry.signature = b64url(Buffer.from(mlDsa.sign(new Uint8Array(this.#keypair.secretKey), msg), 'hex'))
295
+ const { signature, stated } = this.#sign((alg) => signingBytes(seq, ts, operation, metadata, prev, alg))
296
+ entry.signature = signature
209
297
  // Which key signed this entry, so a log that outlives a rotation can say
210
298
  // so per entry rather than forcing one key across the whole file.
211
299
  entry.kid = this.#signingKid()
300
+ // ML-DSA-87 only; inside the signed bytes. An ML-DSA-65 entry is v1.
301
+ if (stated) entry.alg = stated
212
302
  }
213
303
 
214
304
  await this._store(entry)
@@ -255,8 +345,7 @@ export class AuditLog {
255
345
  const fromSeq = run[0].seq
256
346
  const toSeq = run[run.length - 1].seq
257
347
  const timestamp = new Date().toISOString()
258
- const msg = sealBytes(fromSeq, toSeq, prevRoot, rootHash, timestamp)
259
- const signature = b64url(Buffer.from(mlDsa.sign(new Uint8Array(this.#keypair.secretKey), msg), 'hex'))
348
+ const { signature, stated } = this.#sign((alg) => sealBytes(fromSeq, toSeq, prevRoot, rootHash, timestamp, alg))
260
349
 
261
350
  const seal = {
262
351
  fromSeq,
@@ -270,6 +359,8 @@ export class AuditLog {
270
359
  // The signing key, distinct from institutionKid, which names the
271
360
  // institution rather than the key that produced this signature.
272
361
  kid: this.#signingKid(),
362
+ // ML-DSA-87 only; inside the signed bytes. An ML-DSA-65 seal is v1.
363
+ ...(stated && { alg: stated }),
273
364
  }
274
365
  await this._storeSeal(seal)
275
366
  this.#pending = []
@@ -298,10 +389,18 @@ export class AuditLog {
298
389
  /**
299
390
  * Replay the log from entry 0. Streams, so memory is bounded by one entry and
300
391
  * the seal list rather than by the log.
392
+ *
393
+ * The chain catches an entry removed from before the last, because the entry
394
+ * after it stops linking. Nothing follows the last entry, so its removal is
395
+ * caught against a checkpoint: the `{ count, tip }` an earlier verify()
396
+ * returned. The log may have grown since, but the entry the checkpoint was
397
+ * taken at must still be there, unchanged.
301
398
  */
302
- async verify(publicKey) {
399
+ async verify(publicKey, { checkpoint } = {}) {
303
400
  const candidates = candidatesFrom(publicKey)
401
+ const pin = checkpointFrom(checkpoint)
304
402
  const usedKids = new Set()
403
+ let tipSeen = false
305
404
 
306
405
  const seals = this.#sealed ? await this._seals() : []
307
406
  let sealIndex = 0
@@ -324,8 +423,8 @@ export class AuditLog {
324
423
  }
325
424
 
326
425
  if (!this.#sealed) {
327
- const msg = signingBytes(entry.seq, entry.timestamp, entry.operation, entry.metadata, entry.prevHash)
328
- const res = verifySignature(candidates, entry.kid, msg, entry.signature, `entry ${count}`)
426
+ const msgFor = (alg) => signingBytes(entry.seq, entry.timestamp, entry.operation, entry.metadata, entry.prevHash, alg)
427
+ const res = verifySignature(candidates, entry.kid, entry.alg, msgFor, entry.signature, `entry ${count}`)
329
428
  if (res.error) return { valid: false, error: res.error }
330
429
  usedKids.add(res.kid)
331
430
  } else if (sealIndex < seals.length) {
@@ -348,11 +447,32 @@ export class AuditLog {
348
447
  }
349
448
 
350
449
  prevHash = hashEntry(entry)
450
+ if (pin?.tip !== undefined) {
451
+ if (pin.count === undefined) {
452
+ tipSeen ||= prevHash === pin.tip
453
+ } else if (count === pin.count - 1) {
454
+ if (prevHash !== pin.tip) {
455
+ return { valid: false, error: `checkpoint: entry ${count} does not match the checkpoint tip` }
456
+ }
457
+ tipSeen = true
458
+ }
459
+ }
351
460
  expectedSeq = entry.seq + 1
352
461
  count++
353
462
  }
354
463
 
355
- if (!this.#sealed) return { valid: true, count, kids: [...usedKids] }
464
+ if (pin?.count !== undefined && count < pin.count) {
465
+ return { valid: false, error: `checkpoint: covers seq ${pin.count - 1}, log ends at ${count - 1}` }
466
+ }
467
+ if (pin?.tip !== undefined && !tipSeen) {
468
+ return { valid: false, error: 'checkpoint: no entry matches the checkpoint tip' }
469
+ }
470
+
471
+ // The hash of the last entry, which the next one will chain to. With
472
+ // `count`, it is the checkpoint a later verify() can be held to.
473
+ const tip = prevHash
474
+
475
+ if (!this.#sealed) return { valid: true, count, kids: [...usedKids], tip }
356
476
 
357
477
  if (sealIndex < seals.length) {
358
478
  const s = seals[sealIndex]
@@ -367,6 +487,7 @@ export class AuditLog {
367
487
  sealedThrough: expectedFrom - 1,
368
488
  unsealed: count - expectedFrom,
369
489
  kids: [...usedKids],
490
+ tip,
370
491
  }
371
492
  }
372
493
 
@@ -374,8 +495,8 @@ export class AuditLog {
374
495
  if (rootOf(prevRoot, runHashes) !== s.rootHash) {
375
496
  return { valid: false, error: `seal ${index}: entries do not reproduce rootHash` }
376
497
  }
377
- const msg = sealBytes(s.fromSeq, s.toSeq, s.prevRoot, s.rootHash, s.timestamp)
378
- const res = verifySignature(candidates, s.kid, msg, s.signature, `seal ${index}`)
498
+ const msgFor = (alg) => sealBytes(s.fromSeq, s.toSeq, s.prevRoot, s.rootHash, s.timestamp, alg)
499
+ const res = verifySignature(candidates, s.kid, s.alg, msgFor, s.signature, `seal ${index}`)
379
500
  if (res.error) return { valid: false, error: res.error }
380
501
  usedKids.add(res.kid)
381
502
  return null
@@ -4,6 +4,9 @@ import { createInterface } from 'node:readline'
4
4
  import { AuditLog } from '../audit-log.js'
5
5
  import { KxcoPqAuditError } from '../errors.js'
6
6
 
7
+ /** An entry or a seal is a JSON object: not null, not an array, not a scalar. */
8
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
9
+
7
10
  /**
8
11
  * Append-only NDJSON backend.
9
12
  *
@@ -58,8 +61,9 @@ export class FileAuditLog extends AuditLog {
58
61
  for await (const line of lines) {
59
62
  lineNo++
60
63
  if (!line) continue
64
+ let entry
61
65
  try {
62
- yield JSON.parse(line)
66
+ entry = JSON.parse(line)
63
67
  } catch {
64
68
  // A line that will not parse is either a torn write from a crash or
65
69
  // an edit. Either way it is not something to skip past in silence.
@@ -69,6 +73,14 @@ export class FileAuditLog extends AuditLog {
69
73
  'anywhere else means the file was edited.'
70
74
  )
71
75
  }
76
+ // JSON that is not an object cannot be an entry, and a torn write never
77
+ // produces one, since every entry line starts with `{`.
78
+ if (!isRecord(entry)) {
79
+ throw new KxcoPqAuditError(
80
+ `${this.#path}: line ${lineNo} is JSON but not an audit entry, which means the file was edited.`
81
+ )
82
+ }
83
+ yield entry
72
84
  }
73
85
  } finally {
74
86
  lines.close()
@@ -108,7 +120,31 @@ export class FileAuditLog extends AuditLog {
108
120
  async _seals() {
109
121
  let text
110
122
  try { text = await readFile(this.#sealPath, 'utf8') } catch { return [] }
111
- return text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
123
+ // Read line by line, so a torn or edited seal is named the same way a bad
124
+ // entry line is. A line may end in \r if the file was edited with CRLF.
125
+ const seals = []
126
+ const lines = text.split('\n')
127
+ for (let i = 0; i < lines.length; i++) {
128
+ const line = lines[i].endsWith('\r') ? lines[i].slice(0, -1) : lines[i]
129
+ if (!line) continue
130
+ let seal
131
+ try {
132
+ seal = JSON.parse(line)
133
+ } catch {
134
+ throw new KxcoPqAuditError(
135
+ `${this.#sealPath}: line ${i + 1} is not valid JSON. ` +
136
+ 'A partial last line is an interrupted seal and can be truncated; ' +
137
+ 'anywhere else means the file was edited.'
138
+ )
139
+ }
140
+ if (!isRecord(seal)) {
141
+ throw new KxcoPqAuditError(
142
+ `${this.#sealPath}: line ${i + 1} is JSON but not a seal, which means the file was edited.`
143
+ )
144
+ }
145
+ seals.push(seal)
146
+ }
147
+ return seals
112
148
  }
113
149
 
114
150
  async _storeSeal(seal) {
package/src/index.d.ts CHANGED
@@ -8,10 +8,16 @@ export interface AuditEntry {
8
8
  /** SHA-256 of the previous entry (entire JSON including signature). Null for the first entry. */
9
9
  prevHash: string | null
10
10
  /**
11
- * base64url ML-DSA-65 signature over a canonical representation of all other
11
+ * base64url ML-DSA signature over a canonical representation of all other
12
12
  * fields. Absent on a sealed log, where the run is signed once by `seal()`.
13
13
  */
14
14
  signature?: string
15
+ /**
16
+ * The signing algorithm, inside the signed bytes. Present on entries an
17
+ * ML-DSA-87 key signed; absent means ML-DSA-65, which is how every entry
18
+ * written before this field reads.
19
+ */
20
+ alg?: 'ML-DSA-87' | 'ML-DSA-65'
15
21
  /**
16
22
  * 16 hex characters identifying the key that signed this entry, present from
17
23
  * 1.4.0. Absent on entries written by earlier versions and on sealed logs,
@@ -34,8 +40,10 @@ export interface AuditSeal {
34
40
  /** base64url SHA-256 over prevRoot followed by every entry hash in the run. */
35
41
  rootHash: string
36
42
  timestamp: string
37
- /** base64url ML-DSA-65 signature over the seal's canonical form. */
43
+ /** base64url ML-DSA signature over the seal's canonical form. */
38
44
  signature: string
45
+ /** As on an entry: present when an ML-DSA-87 key sealed the run. */
46
+ alg?: 'ML-DSA-87' | 'ML-DSA-65'
39
47
  institutionKid: string | null
40
48
  /**
41
49
  * 16 hex characters identifying the key that signed this seal, present from
@@ -64,6 +72,32 @@ export interface AuditVerifySuccess {
64
72
  * order first seen. More than one means the log spans a key rotation.
65
73
  */
66
74
  kids: string[]
75
+ /**
76
+ * base64url SHA-256 of the last entry, the hash the next entry will chain
77
+ * to, or null for an empty log. With `count`, it is a checkpoint: keep this
78
+ * result and pass it to a later `verify()` as `{ checkpoint }`.
79
+ */
80
+ tip: string | null
81
+ }
82
+
83
+ /**
84
+ * What a verifier kept from an earlier look at the log. Any earlier
85
+ * `AuditVerifySuccess` is one. Either field may be given alone.
86
+ */
87
+ export interface AuditCheckpoint {
88
+ /** The log must still hold at least this many entries. */
89
+ count?: number
90
+ /** The entry at `count - 1`, or with no count some entry, must hash to this. */
91
+ tip?: string | null
92
+ }
93
+
94
+ export interface AuditVerifyOptions {
95
+ /**
96
+ * Fail unless the log still holds, unchanged, the entry this checkpoint was
97
+ * taken at. The log may have grown since. This is what catches the last
98
+ * entry being removed or replaced, which leaves no later entry to break.
99
+ */
100
+ checkpoint?: AuditCheckpoint
67
101
  }
68
102
 
69
103
  export interface AuditVerifyFailure {
@@ -138,14 +172,21 @@ export declare class AuditLog {
138
172
  *
139
173
  * A record naming a key that was not supplied is a failure that says so,
140
174
  * rather than a generic invalid signature.
175
+ *
176
+ * Pass `{ checkpoint }`, the `{ count, tip }` of an earlier result, to also
177
+ * catch the last entry being removed or replaced since then.
141
178
  */
142
179
  verify(
143
180
  publicKey: Uint8Array | Buffer | Array<Uint8Array | Buffer>,
181
+ options?: AuditVerifyOptions,
144
182
  ): Promise<AuditVerifyResult>
145
183
 
146
184
  /** The kid of the key this log signs with. */
147
185
  readonly signingKid: string
148
186
 
187
+ /** The parameter set this log signs with, decided by its secret key. */
188
+ readonly signingAlg: 'ML-DSA-65' | 'ML-DSA-87'
189
+
149
190
  /**
150
191
  * Sign everything appended since the last seal, as one run. Returns the seal,
151
192
  * or null when nothing is unsealed. Throws unless the log was constructed