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 +18 -0
- package/NOTICE +6 -0
- package/README.md +2 -2
- package/package.json +6 -2
- package/src/audit-log.js +59 -2
- package/src/backends/file.js +38 -2
- package/src/index.d.ts +30 -0
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
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
|
|
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
|
|
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
|
+
"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 (
|
|
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
|
|
package/src/backends/file.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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. */
|