kxco-pq-audit 1.4.3 → 1.4.5

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.5
4
+
5
+ `verify(publicKey, { checkpoint })` takes the `{ count, tip }` an earlier verify
6
+ returned, and fails a log that has since lost or replaced its last entry.
7
+ Verify results carry `tip`.
8
+
9
+ A file line that is JSON but not an entry, and a torn or edited `.seals` line,
10
+ are refused with KxcoPqAuditError naming the line. The README Security section
11
+ says when each kind of removal is caught.
12
+
13
+ ## 1.4.4
14
+
15
+ Documentation. No source change.
16
+
17
+ A NOTICE file names the copyright owner, Knightsbridge Financial Ltd, trading
18
+ as KXCO, and ships in the package, so anyone who redistributes it carries the
19
+ attribution, as section 4(d) of the Apache License requires.
20
+
3
21
  ## 1.4.3
4
22
 
5
23
  Documentation. No source change.
package/NOTICE ADDED
@@ -0,0 +1,6 @@
1
+ kxco-pq-audit
2
+ Copyright 2026 Knightsbridge Financial Ltd, trading as KXCO.
3
+ Registered in England and Wales, company number 15684975.
4
+
5
+ Licensed under the Apache License, Version 2.0. See LICENSE.
6
+ https://kxco.ai
package/README.md CHANGED
@@ -315,13 +315,13 @@ Evidenced, and reproducible on your own machine:
315
315
 
316
316
  Dependency audit history is recorded in [AUDIT.md](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/AUDIT.md).
317
317
 
318
- The hash chain means a compromised or deleted entry cannot be hidden: any gap breaks verification of every subsequent entry.
318
+ 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
319
 
320
320
  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
321
 
322
322
  ## License
323
323
 
324
- Apache-2.0 © 2026 KXCO by Knightsbridge
324
+ Apache-2.0 © 2026 Knightsbridge Financial Ltd, trading as KXCO. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
325
325
 
326
326
  ## Maintainers
327
327
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kxco-pq-audit",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
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",
@@ -61,6 +61,7 @@
61
61
  "files": [
62
62
  "CHANGELOG.md",
63
63
  "LICENSE",
64
+ "NOTICE",
64
65
  "README.md",
65
66
  "src",
66
67
  "ASSESSMENT.md"
@@ -73,12 +74,15 @@
73
74
  "kxco-post-quantum": "^1.3.0"
74
75
  },
75
76
  "scripts": {
76
- "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",
77
78
  "evidence": "node scripts/build-evidence.mjs"
78
79
  },
79
80
  "funding": "https://kxco.ai",
80
81
  "publishConfig": {
81
82
  "provenance": true,
82
83
  "access": "public"
84
+ },
85
+ "devDependencies": {
86
+ "fast-check": "4.10.2"
83
87
  }
84
88
  }
package/src/audit-log.js CHANGED
@@ -62,6 +62,33 @@ function candidatesFrom(publicKey) {
62
62
  })
63
63
  }
64
64
 
65
+ /**
66
+ * Normalise a checkpoint: what a verifier kept from an earlier look at the log.
67
+ *
68
+ * `count` is how many entries the log held and `tip` the hash of the last of
69
+ * them, so any earlier verify() result is a checkpoint as it stands. Either may
70
+ * be given alone. Checked strictly, because a checkpoint that names nothing
71
+ * would check nothing and read as a pass.
72
+ */
73
+ function checkpointFrom(checkpoint) {
74
+ if (checkpoint === undefined) return null
75
+ const count = checkpoint?.count
76
+ const tip = checkpoint?.tip ?? undefined
77
+ if (count !== undefined && !(Number.isSafeInteger(count) && count >= 0)) {
78
+ throw new KxcoPqAuditError('verify: checkpoint.count must be a whole number of entries')
79
+ }
80
+ if (tip !== undefined && typeof tip !== 'string') {
81
+ throw new KxcoPqAuditError('verify: checkpoint.tip must be the hash string an earlier verify() returned')
82
+ }
83
+ if (count === undefined && tip === undefined) {
84
+ throw new KxcoPqAuditError('verify: a checkpoint needs a count, a tip or both')
85
+ }
86
+ if (count === 0 && tip !== undefined) {
87
+ throw new KxcoPqAuditError('verify: a checkpoint with a tip covers at least one entry')
88
+ }
89
+ return { count, tip }
90
+ }
91
+
65
92
  function verifySignature(candidates, recordKid, msg, signatureB64, label) {
66
93
  const sigHex = () => Buffer.from(fromB64url(signatureB64)).toString('hex')
67
94
 
@@ -298,10 +325,18 @@ export class AuditLog {
298
325
  /**
299
326
  * Replay the log from entry 0. Streams, so memory is bounded by one entry and
300
327
  * the seal list rather than by the log.
328
+ *
329
+ * The chain catches an entry removed from before the last, because the entry
330
+ * after it stops linking. Nothing follows the last entry, so its removal is
331
+ * caught against a checkpoint: the `{ count, tip }` an earlier verify()
332
+ * returned. The log may have grown since, but the entry the checkpoint was
333
+ * taken at must still be there, unchanged.
301
334
  */
302
- async verify(publicKey) {
335
+ async verify(publicKey, { checkpoint } = {}) {
303
336
  const candidates = candidatesFrom(publicKey)
337
+ const pin = checkpointFrom(checkpoint)
304
338
  const usedKids = new Set()
339
+ let tipSeen = false
305
340
 
306
341
  const seals = this.#sealed ? await this._seals() : []
307
342
  let sealIndex = 0
@@ -348,11 +383,32 @@ export class AuditLog {
348
383
  }
349
384
 
350
385
  prevHash = hashEntry(entry)
386
+ if (pin?.tip !== undefined) {
387
+ if (pin.count === undefined) {
388
+ tipSeen ||= prevHash === pin.tip
389
+ } else if (count === pin.count - 1) {
390
+ if (prevHash !== pin.tip) {
391
+ return { valid: false, error: `checkpoint: entry ${count} does not match the checkpoint tip` }
392
+ }
393
+ tipSeen = true
394
+ }
395
+ }
351
396
  expectedSeq = entry.seq + 1
352
397
  count++
353
398
  }
354
399
 
355
- if (!this.#sealed) return { valid: true, count, kids: [...usedKids] }
400
+ if (pin?.count !== undefined && count < pin.count) {
401
+ return { valid: false, error: `checkpoint: covers seq ${pin.count - 1}, log ends at ${count - 1}` }
402
+ }
403
+ if (pin?.tip !== undefined && !tipSeen) {
404
+ return { valid: false, error: 'checkpoint: no entry matches the checkpoint tip' }
405
+ }
406
+
407
+ // The hash of the last entry, which the next one will chain to. With
408
+ // `count`, it is the checkpoint a later verify() can be held to.
409
+ const tip = prevHash
410
+
411
+ if (!this.#sealed) return { valid: true, count, kids: [...usedKids], tip }
356
412
 
357
413
  if (sealIndex < seals.length) {
358
414
  const s = seals[sealIndex]
@@ -367,6 +423,7 @@ export class AuditLog {
367
423
  sealedThrough: expectedFrom - 1,
368
424
  unsealed: count - expectedFrom,
369
425
  kids: [...usedKids],
426
+ tip,
370
427
  }
371
428
  }
372
429
 
@@ -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
@@ -64,6 +64,32 @@ export interface AuditVerifySuccess {
64
64
  * order first seen. More than one means the log spans a key rotation.
65
65
  */
66
66
  kids: string[]
67
+ /**
68
+ * base64url SHA-256 of the last entry, the hash the next entry will chain
69
+ * to, or null for an empty log. With `count`, it is a checkpoint: keep this
70
+ * result and pass it to a later `verify()` as `{ checkpoint }`.
71
+ */
72
+ tip: string | null
73
+ }
74
+
75
+ /**
76
+ * What a verifier kept from an earlier look at the log. Any earlier
77
+ * `AuditVerifySuccess` is one. Either field may be given alone.
78
+ */
79
+ export interface AuditCheckpoint {
80
+ /** The log must still hold at least this many entries. */
81
+ count?: number
82
+ /** The entry at `count - 1`, or with no count some entry, must hash to this. */
83
+ tip?: string | null
84
+ }
85
+
86
+ export interface AuditVerifyOptions {
87
+ /**
88
+ * Fail unless the log still holds, unchanged, the entry this checkpoint was
89
+ * taken at. The log may have grown since. This is what catches the last
90
+ * entry being removed or replaced, which leaves no later entry to break.
91
+ */
92
+ checkpoint?: AuditCheckpoint
67
93
  }
68
94
 
69
95
  export interface AuditVerifyFailure {
@@ -138,9 +164,13 @@ export declare class AuditLog {
138
164
  *
139
165
  * A record naming a key that was not supplied is a failure that says so,
140
166
  * rather than a generic invalid signature.
167
+ *
168
+ * Pass `{ checkpoint }`, the `{ count, tip }` of an earlier result, to also
169
+ * catch the last entry being removed or replaced since then.
141
170
  */
142
171
  verify(
143
172
  publicKey: Uint8Array | Buffer | Array<Uint8Array | Buffer>,
173
+ options?: AuditVerifyOptions,
144
174
  ): Promise<AuditVerifyResult>
145
175
 
146
176
  /** The kid of the key this log signs with. */