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 +27 -0
- package/README.md +24 -4
- package/package.json +6 -3
- package/src/audit-log.js +145 -24
- package/src/backends/file.js +38 -2
- package/src/index.d.ts +43 -2
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
208
|
-
entry.signature =
|
|
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
|
|
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
|
|
328
|
-
const res = verifySignature(candidates, entry.kid,
|
|
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 (
|
|
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
|
|
378
|
-
const res = verifySignature(candidates, s.kid,
|
|
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
|
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
|
@@ -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
|
|
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
|
|
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
|