@blamejs/blamejs-shop 0.5.14 → 0.5.16
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 +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/gift-card-ledger.js +7 -3
- package/lib/loyalty-redemption.js +9 -4
- package/lib/vendor/MANIFEST.json +19 -13
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/NOTICE +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/examples/wiki/package-lock.json +1 -1
- package/lib/vendor/blamejs/lib/audit-sign.js +10 -1
- package/lib/vendor/blamejs/lib/session.js +65 -18
- package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +11 -11
- package/lib/vendor/blamejs/lib/vendor/public-suffix-list.dat +2 -6
- package/lib/vendor/blamejs/lib/vendor/public-suffix-list.data.js +2215 -2217
- package/lib/vendor/blamejs/package-lock.json +2 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.17.23.json +40 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/audit-sign.test.js +480 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/session.test.js +593 -0
- package/package.json +1 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blamejs/core",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.23",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@blamejs/core",
|
|
9
|
-
"version": "0.17.
|
|
9
|
+
"version": "0.17.23",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"bin": {
|
|
12
12
|
"blamejs": "bin/blamejs.js"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.17.23",
|
|
4
|
+
"date": "2026-07-25",
|
|
5
|
+
"headline": "`b.session` refresh paths enforce the idle/absolute timeout floor, and a strict fingerprint policy without a request fails closed",
|
|
6
|
+
"summary": "Two session-security fixes and one audit-signing robustness fix. b.session.rotate and b.session.updateData reset a session's activity clock but did not re-check the idle/absolute timeout floor that verify() and touch() enforce, so a session already past its idle timeout (default 30 min) -- which verify() would reject -- could be resurrected with a fresh idle window by rotating it or writing session data, defeating the idle timeout (OWASP ASVS 5.0 §3.3 / NIST SP 800-63B-4). Both now enforce the same floor and fail closed. Separately, verify() gated its strict fingerprint controls (requireFingerprintMatch / maxAnomalyScore) on a request being supplied, so passing the strict flag WITHOUT a req silently skipped the check and admitted the session from any device; a strict policy with no way to compute the device fingerprint now fails closed. Finally, b.auditSign.verify honored its documented never-throw contract only for well-formed keys -- a malformed publicKeyPem made it throw; it now returns false.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Changed",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "Vendored Public Suffix List refreshed to the current snapshot",
|
|
13
|
+
"body": "The bundled Public Suffix List that backs domain classification (registrable-domain and public-suffix checks across URL, cookie, and SSRF handling) is updated to the latest upstream publicsuffix.org snapshot, so newly delegated public suffixes and updated private-domain entries are recognized."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"heading": "Security",
|
|
19
|
+
"items": [
|
|
20
|
+
{
|
|
21
|
+
"title": "b.session.rotate and b.session.updateData enforce the idle/absolute timeout floor",
|
|
22
|
+
"body": "A session read path enforces two floors independent of the operator's ttl: an idle timeout (default 30 min) and an absolute timeout (default 12 h). verify() and touch() enforced them, but rotate() and updateData() only checked that the operator ttl had not elapsed while resetting lastActivity -- so a session past its idle floor (which verify() would expire and delete) could be revived with a fresh idle window by rotating it or writing to it. Both now route through the same floor check and fail closed (return null / false and best-effort delete) on breach, so no refresh path can resurrect a session verify() would reject. touch() also now emits the idle/absolute-expiry audit signal on breach, matching verify()."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"title": "A strict fingerprint policy without a request now fails closed",
|
|
26
|
+
"body": "verify()'s strict device-binding controls -- requireFingerprintMatch: true and maxAnomalyScore -- were only applied when the caller also passed a req to compute the current device fingerprint. Setting a strict flag WITHOUT a req therefore skipped the binding check entirely and admitted the session unchecked from any device -- a fail-open of a control the operator asked to be strict. A strict policy with no req to compare against now fails closed (returns null), the same discipline already applied when the stored binding is unreadable or absent."
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"heading": "Fixed",
|
|
32
|
+
"items": [
|
|
33
|
+
{
|
|
34
|
+
"title": "b.auditSign.verify returns false on a malformed public key instead of throwing",
|
|
35
|
+
"body": "verify(payload, signature, publicKeyPem) documents that it never throws -- callers branch on the boolean, and a verifier-only consumer (b.backupManifest.verifyBytes) may pass an untrusted publicKeyPem taken from a signature block. A malformed / non-PEM key made node:crypto throw (ERR_OSSL_UNSUPPORTED) while building the key object, crashing such a caller instead of cleanly rejecting. verify() now catches that and returns false, honoring the contract. As a result, b.auditSign.reSignAll now treats an entry whose old key is unverifiable as skipped (unverifiable) rather than as an error."
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* b.auditSign — the audit-chain signing primitive's error / edge surface.
|
|
6
|
+
*
|
|
7
|
+
* The anchor protocol (anchor / verifyAnchor / verifyAnchorChain) is covered by
|
|
8
|
+
* audit-sign-anchor.test.js; the ml-dsa-65 opt-in by audit-sign-ml-dsa-65.test.js;
|
|
9
|
+
* the full wrapped-DB rotation round-trip by audit-signing-key-rotation.test.js.
|
|
10
|
+
* This file drives the reachable error / edge branches those happy-path files
|
|
11
|
+
* leave uncovered:
|
|
12
|
+
* - init() argument + on-disk-conflict + mode-mismatch validation,
|
|
13
|
+
* - plaintext load-existing / corrupt / missing-algorithm key files,
|
|
14
|
+
* - wrapped-mode first-run, unseal, and the passphrase / schema / missing-alg
|
|
15
|
+
* failure branches (crafted sealed blobs through the real vault wrap),
|
|
16
|
+
* - the _requireInit guard on every consumer entry point,
|
|
17
|
+
* - verify() with a non-Buffer signature and verifier-only (no-init) use,
|
|
18
|
+
* - reSignAll's default-key / bad-key-error / throwing-onProgress paths,
|
|
19
|
+
* - _normalizeTip's typed config-time throws,
|
|
20
|
+
* - anchor()/verifyAnchor() createdAt + empty-format defaulting,
|
|
21
|
+
* - a mid-chain signature break in verifyAnchorChain,
|
|
22
|
+
* - the unsealed public-key history: dedup, corrupt-rebuild, unreadable-store.
|
|
23
|
+
*
|
|
24
|
+
* Everything uses ml-dsa-65 (the cheapest PQC keypair) so the per-run keygen +
|
|
25
|
+
* Argon2id wrap stay fast. Every assertion PASSES on the current tree.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
var helpers = require("../helpers");
|
|
29
|
+
var b = helpers.b;
|
|
30
|
+
var check = helpers.check;
|
|
31
|
+
var fs = require("fs");
|
|
32
|
+
var os = require("os");
|
|
33
|
+
var path = require("path");
|
|
34
|
+
var nodeCrypto = require("crypto");
|
|
35
|
+
var vaultWrap = require("../../lib/vault/wrap");
|
|
36
|
+
|
|
37
|
+
var ALG = "ml-dsa-65";
|
|
38
|
+
var PASS = "blamejs-audit-sign-test-passphrase-not-secret";
|
|
39
|
+
|
|
40
|
+
function _mkdir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); }
|
|
41
|
+
function _rm(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) { /* best-effort */ } }
|
|
42
|
+
function _plaintextPath(dir) { return path.join(dir, "audit-sign.key"); }
|
|
43
|
+
function _sealedPath(dir) { return path.join(dir, "audit-sign.key.sealed"); }
|
|
44
|
+
function _historyPath(dir) { return path.join(dir, "audit-sign.pubkeys.json"); }
|
|
45
|
+
function _genPair(alg) {
|
|
46
|
+
return nodeCrypto.generateKeyPairSync(alg, {
|
|
47
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
48
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// Capture the typed error code a thrower emits (or null when it does not throw).
|
|
52
|
+
async function _codeOf(fn) {
|
|
53
|
+
try { await fn(); } catch (e) { return e && e.code; }
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- init() argument validation + no-op re-init + dataDir creation ----
|
|
58
|
+
async function testInitValidation() {
|
|
59
|
+
var as = b.auditSign;
|
|
60
|
+
as._resetForTest();
|
|
61
|
+
check("init() with no opts throws bad-init",
|
|
62
|
+
(await _codeOf(function () { return as.init(); })) === "audit-sign/bad-init");
|
|
63
|
+
check("init({}) with no dataDir throws bad-init",
|
|
64
|
+
(await _codeOf(function () { return as.init({}); })) === "audit-sign/bad-init");
|
|
65
|
+
|
|
66
|
+
var dir = _mkdir("blamejs-as-initval-");
|
|
67
|
+
try {
|
|
68
|
+
check("init() rejects an unknown mode",
|
|
69
|
+
(await _codeOf(function () { return as.init({ dataDir: dir, mode: "bogus" }); })) === "audit-sign/bad-mode");
|
|
70
|
+
|
|
71
|
+
as._resetForTest();
|
|
72
|
+
// dataDir points at a not-yet-existing nested path — init() mkdir -p's it.
|
|
73
|
+
var nested = path.join(dir, "does", "not", "exist", "yet");
|
|
74
|
+
await as.init({ dataDir: nested, mode: "plaintext", algorithm: ALG });
|
|
75
|
+
check("init() creates a missing dataDir", fs.existsSync(nested));
|
|
76
|
+
|
|
77
|
+
// A second init() with the singleton already booted is a no-op (does not
|
|
78
|
+
// re-read disk, does not throw), and leaves the live key untouched.
|
|
79
|
+
var fp = as.getPublicKeyFingerprint();
|
|
80
|
+
await as.init({ dataDir: _mkdir("blamejs-as-noop-"), mode: "wrapped" });
|
|
81
|
+
check("second init() is a no-op (key unchanged)", as.getPublicKeyFingerprint() === fp);
|
|
82
|
+
} finally {
|
|
83
|
+
as._resetForTest();
|
|
84
|
+
_rm(dir);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- on-disk key-file conflict + mode mismatch (checked before any load) ----
|
|
89
|
+
async function testConflictAndModeMismatch() {
|
|
90
|
+
var as = b.auditSign;
|
|
91
|
+
|
|
92
|
+
var d1 = _mkdir("blamejs-as-conflict-");
|
|
93
|
+
var d2 = _mkdir("blamejs-as-mm1-");
|
|
94
|
+
var d3 = _mkdir("blamejs-as-mm2-");
|
|
95
|
+
try {
|
|
96
|
+
// Both a plaintext and a sealed key file on disk — refuse, don't guess.
|
|
97
|
+
fs.writeFileSync(_plaintextPath(d1), "placeholder");
|
|
98
|
+
fs.writeFileSync(_sealedPath(d1), "placeholder");
|
|
99
|
+
as._resetForTest();
|
|
100
|
+
check("both key files present -> KEY_FILE_CONFLICT",
|
|
101
|
+
(await _codeOf(function () { return as.init({ dataDir: d1, mode: "plaintext" }); })) === "KEY_FILE_CONFLICT");
|
|
102
|
+
|
|
103
|
+
// A sealed file exists but the operator asked for plaintext.
|
|
104
|
+
fs.writeFileSync(_sealedPath(d2), "placeholder");
|
|
105
|
+
as._resetForTest();
|
|
106
|
+
check("sealed on disk + mode:plaintext -> MODE_MISMATCH",
|
|
107
|
+
(await _codeOf(function () { return as.init({ dataDir: d2, mode: "plaintext" }); })) === "MODE_MISMATCH");
|
|
108
|
+
|
|
109
|
+
// A plaintext file exists but the operator asked for wrapped.
|
|
110
|
+
fs.writeFileSync(_plaintextPath(d3), "placeholder");
|
|
111
|
+
as._resetForTest();
|
|
112
|
+
check("plaintext on disk + mode:wrapped -> MODE_MISMATCH",
|
|
113
|
+
(await _codeOf(function () { return as.init({ dataDir: d3, mode: "wrapped" }); })) === "MODE_MISMATCH");
|
|
114
|
+
} finally {
|
|
115
|
+
as._resetForTest();
|
|
116
|
+
_rm(d1); _rm(d2); _rm(d3);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- plaintext: load-existing round-trip + corrupt + missing-algorithm ----
|
|
121
|
+
async function testPlaintextLoadAndCorrupt() {
|
|
122
|
+
var as = b.auditSign;
|
|
123
|
+
|
|
124
|
+
var dGood = _mkdir("blamejs-as-ptload-");
|
|
125
|
+
var dCorrupt = _mkdir("blamejs-as-ptcorrupt-");
|
|
126
|
+
var dNoAlg = _mkdir("blamejs-as-ptnoalg-");
|
|
127
|
+
try {
|
|
128
|
+
// First run generates + persists; a fresh boot loads the SAME key off disk.
|
|
129
|
+
as._resetForTest();
|
|
130
|
+
await as.init({ dataDir: dGood, mode: "plaintext", algorithm: ALG });
|
|
131
|
+
var fp = as.getPublicKeyFingerprint();
|
|
132
|
+
check("plaintext getMode is 'plaintext'", as.getMode() === "plaintext");
|
|
133
|
+
as._resetForTest();
|
|
134
|
+
await as.init({ dataDir: dGood, mode: "plaintext" });
|
|
135
|
+
check("plaintext load-existing keeps the same fingerprint", as.getPublicKeyFingerprint() === fp);
|
|
136
|
+
check("plaintext load-existing keeps the on-disk algorithm", as.getAlgorithm() === ALG);
|
|
137
|
+
|
|
138
|
+
// A key file that is not valid JSON -> KEY_FILE_CORRUPT.
|
|
139
|
+
fs.writeFileSync(_plaintextPath(dCorrupt), "{ this is not valid json");
|
|
140
|
+
as._resetForTest();
|
|
141
|
+
check("corrupt plaintext key file -> KEY_FILE_CORRUPT",
|
|
142
|
+
(await _codeOf(function () { return as.init({ dataDir: dCorrupt, mode: "plaintext" }); })) === "KEY_FILE_CORRUPT");
|
|
143
|
+
|
|
144
|
+
// A schema-valid key file that omits the required `algorithm` field.
|
|
145
|
+
var pair = _genPair(ALG);
|
|
146
|
+
fs.writeFileSync(_plaintextPath(dNoAlg),
|
|
147
|
+
JSON.stringify({ publicKey: pair.publicKey, privateKey: pair.privateKey }, null, 2));
|
|
148
|
+
as._resetForTest();
|
|
149
|
+
check("plaintext key missing algorithm -> KEY_FILE_MISSING_ALG",
|
|
150
|
+
(await _codeOf(function () { return as.init({ dataDir: dNoAlg, mode: "plaintext" }); })) === "KEY_FILE_MISSING_ALG");
|
|
151
|
+
} finally {
|
|
152
|
+
as._resetForTest();
|
|
153
|
+
_rm(dGood); _rm(dCorrupt); _rm(dNoAlg);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- wrapped mode: first-run seal, unseal, and the three unwrap failures ----
|
|
158
|
+
async function testWrappedModeAndErrors() {
|
|
159
|
+
var as = b.auditSign;
|
|
160
|
+
|
|
161
|
+
var dSeal = _mkdir("blamejs-as-seal-");
|
|
162
|
+
var dInvalid = _mkdir("blamejs-as-unwrapinvalid-");
|
|
163
|
+
var dNoAlg = _mkdir("blamejs-as-unwrapnoalg-");
|
|
164
|
+
try {
|
|
165
|
+
// First-run wrapped: generate + seal under the operator passphrase. Omit
|
|
166
|
+
// `mode` so the "wrapped" default is exercised (it is the documented default).
|
|
167
|
+
as._resetForTest();
|
|
168
|
+
process.env.BLAMEJS_AUDIT_SIGNING_PASSPHRASE = PASS;
|
|
169
|
+
await as.init({ dataDir: dSeal, algorithm: ALG });
|
|
170
|
+
check("wrapped is the default mode when none is given", as.getMode() === "wrapped");
|
|
171
|
+
check("wrapped first-run wrote the sealed key file", fs.existsSync(_sealedPath(dSeal)));
|
|
172
|
+
var sealFp = as.getPublicKeyFingerprint();
|
|
173
|
+
|
|
174
|
+
// A fresh boot unseals the same key (happy path through _initWrapped).
|
|
175
|
+
as._resetForTest();
|
|
176
|
+
process.env.BLAMEJS_AUDIT_SIGNING_PASSPHRASE = PASS;
|
|
177
|
+
await as.init({ dataDir: dSeal, mode: "wrapped" });
|
|
178
|
+
check("wrapped unseal recovers the same fingerprint", as.getPublicKeyFingerprint() === sealFp);
|
|
179
|
+
check("wrapped unseal recovers the on-disk algorithm", as.getAlgorithm() === ALG);
|
|
180
|
+
|
|
181
|
+
// Wrong passphrase -> unwrap rejects -> PASSPHRASE_REJECTED.
|
|
182
|
+
as._resetForTest();
|
|
183
|
+
process.env.BLAMEJS_AUDIT_SIGNING_PASSPHRASE = "the-wrong-passphrase";
|
|
184
|
+
check("wrapped unseal with a wrong passphrase -> PASSPHRASE_REJECTED",
|
|
185
|
+
(await _codeOf(function () { return as.init({ dataDir: dSeal, mode: "wrapped" }); })) === "PASSPHRASE_REJECTED");
|
|
186
|
+
|
|
187
|
+
// A sealed blob whose plaintext is not a valid key object -> UNWRAPPED_INVALID.
|
|
188
|
+
var badBlob = await vaultWrap.wrap("this-plaintext-is-not-json", Buffer.from(PASS));
|
|
189
|
+
fs.writeFileSync(_sealedPath(dInvalid), badBlob);
|
|
190
|
+
as._resetForTest();
|
|
191
|
+
process.env.BLAMEJS_AUDIT_SIGNING_PASSPHRASE = PASS;
|
|
192
|
+
check("wrapped unseal of a non-key plaintext -> UNWRAPPED_INVALID",
|
|
193
|
+
(await _codeOf(function () { return as.init({ dataDir: dInvalid, mode: "wrapped" }); })) === "UNWRAPPED_INVALID");
|
|
194
|
+
|
|
195
|
+
// A sealed blob that IS a schema-valid key object but omits `algorithm`.
|
|
196
|
+
var pair = _genPair(ALG);
|
|
197
|
+
var noAlgBlob = await vaultWrap.wrap(
|
|
198
|
+
JSON.stringify({ publicKey: pair.publicKey, privateKey: pair.privateKey }),
|
|
199
|
+
Buffer.from(PASS)
|
|
200
|
+
);
|
|
201
|
+
fs.writeFileSync(_sealedPath(dNoAlg), noAlgBlob);
|
|
202
|
+
as._resetForTest();
|
|
203
|
+
process.env.BLAMEJS_AUDIT_SIGNING_PASSPHRASE = PASS;
|
|
204
|
+
check("wrapped unseal of a key missing algorithm -> UNWRAPPED_MISSING_ALG",
|
|
205
|
+
(await _codeOf(function () { return as.init({ dataDir: dNoAlg, mode: "wrapped" }); })) === "UNWRAPPED_MISSING_ALG");
|
|
206
|
+
|
|
207
|
+
// Wrapped rotation archives the OLD sealed file to the advertised history
|
|
208
|
+
// path and records the rotated-out public key so it still resolves.
|
|
209
|
+
as._resetForTest();
|
|
210
|
+
process.env.BLAMEJS_AUDIT_SIGNING_PASSPHRASE = PASS;
|
|
211
|
+
await as.init({ dataDir: dSeal, mode: "wrapped" });
|
|
212
|
+
var beforeFp = as.getPublicKeyFingerprint();
|
|
213
|
+
var beforePub = as.getPublicKey();
|
|
214
|
+
process.env.BLAMEJS_AUDIT_SIGNING_PASSPHRASE = PASS;
|
|
215
|
+
var rot = await as.rotateSigningKey();
|
|
216
|
+
check("wrapped rotation changed the live key", rot.newFingerprint !== beforeFp);
|
|
217
|
+
check("wrapped rotation returned a sealed history path",
|
|
218
|
+
typeof rot.historyPath === "string" && /\.sealed\.history-/.test(rot.historyPath));
|
|
219
|
+
check("wrapped rotation wrote the history file to disk", fs.existsSync(rot.historyPath));
|
|
220
|
+
check("wrapped rotation archived the rotated-out public key",
|
|
221
|
+
as.getPublicKeyByFingerprint(beforeFp) === beforePub);
|
|
222
|
+
} finally {
|
|
223
|
+
as._resetForTest();
|
|
224
|
+
delete process.env.BLAMEJS_AUDIT_SIGNING_PASSPHRASE;
|
|
225
|
+
_rm(dSeal); _rm(dInvalid); _rm(dNoAlg);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- _requireInit guards every consumer entry point ----
|
|
230
|
+
async function testRequireInitGuards() {
|
|
231
|
+
var as = b.auditSign;
|
|
232
|
+
as._resetForTest();
|
|
233
|
+
check("getMode() before init returns null", as.getMode() === null);
|
|
234
|
+
var G = "audit-sign/not-initialized";
|
|
235
|
+
check("sign() before init throws not-initialized",
|
|
236
|
+
(await _codeOf(function () { return as.sign("x"); })) === G);
|
|
237
|
+
check("verify() (default key) before init throws not-initialized",
|
|
238
|
+
(await _codeOf(function () { return as.verify("x", Buffer.from("00", "hex")); })) === G);
|
|
239
|
+
check("getPublicKey() before init throws not-initialized",
|
|
240
|
+
(await _codeOf(function () { return as.getPublicKey(); })) === G);
|
|
241
|
+
check("getPublicKeyFingerprint() before init throws not-initialized",
|
|
242
|
+
(await _codeOf(function () { return as.getPublicKeyFingerprint(); })) === G);
|
|
243
|
+
check("getAlgorithm() before init throws not-initialized",
|
|
244
|
+
(await _codeOf(function () { return as.getAlgorithm(); })) === G);
|
|
245
|
+
check("anchor() before init throws not-initialized",
|
|
246
|
+
(await _codeOf(function () { return as.anchor({ counter: 1, tipHash: "h" }); })) === G);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- verify() edge inputs: non-Buffer signature + verifier-only (no init) ----
|
|
250
|
+
async function testVerifyEdges() {
|
|
251
|
+
var as = b.auditSign;
|
|
252
|
+
var dir = _mkdir("blamejs-as-verify-");
|
|
253
|
+
try {
|
|
254
|
+
as._resetForTest();
|
|
255
|
+
await as.init({ dataDir: dir, mode: "plaintext", algorithm: ALG });
|
|
256
|
+
var payload = Buffer.from("verify-edge-payload", "utf8");
|
|
257
|
+
var sig = as.sign(payload);
|
|
258
|
+
var pub = as.getPublicKey();
|
|
259
|
+
|
|
260
|
+
// A non-Buffer signature (byte array) is coerced via Buffer.from().
|
|
261
|
+
check("verify() coerces a non-Buffer signature (byte array)",
|
|
262
|
+
as.verify(payload, Array.from(sig), pub) === true);
|
|
263
|
+
// A non-Buffer payload (string) is coerced too.
|
|
264
|
+
check("verify() coerces a string payload",
|
|
265
|
+
as.verify("verify-edge-payload", sig, pub) === true);
|
|
266
|
+
|
|
267
|
+
// A verifier-only process holds a trusted public key but never ran init():
|
|
268
|
+
// supplying an explicit publicKeyPem must NOT require init.
|
|
269
|
+
as._resetForTest();
|
|
270
|
+
check("verify() with an explicit public key works without init()",
|
|
271
|
+
as.verify(payload, sig, pub) === true);
|
|
272
|
+
check("verify() with an explicit public key rejects a tampered payload",
|
|
273
|
+
as.verify(Buffer.from("tampered", "utf8"), sig, pub) === false);
|
|
274
|
+
|
|
275
|
+
// Contract: verify() NEVER throws — a malformed / non-PEM publicKeyPem is a
|
|
276
|
+
// verification FAILURE (false), not a crash, so a verifier-only consumer
|
|
277
|
+
// (b.backupManifest.verifyBytes) fed an untrusted key from a signature block
|
|
278
|
+
// can branch on the boolean instead of crashing.
|
|
279
|
+
check("verify() returns false (never throws) on a structurally-PEM but invalid key",
|
|
280
|
+
as.verify(payload, sig, "-----BEGIN PUBLIC KEY-----\nbm90LWEta2V5\n-----END PUBLIC KEY-----") === false);
|
|
281
|
+
check("verify() returns false (never throws) on a non-PEM garbage key string",
|
|
282
|
+
as.verify(payload, sig, "this is not a pem at all") === false);
|
|
283
|
+
} finally {
|
|
284
|
+
as._resetForTest();
|
|
285
|
+
_rm(dir);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---- reSignAll: default key, error accounting, and a throwing onProgress ----
|
|
290
|
+
async function testReSignAllEdges() {
|
|
291
|
+
var as = b.auditSign;
|
|
292
|
+
var dir = _mkdir("blamejs-as-resign-");
|
|
293
|
+
try {
|
|
294
|
+
as._resetForTest();
|
|
295
|
+
await as.init({ dataDir: dir, mode: "plaintext", algorithm: ALG });
|
|
296
|
+
var payload = Buffer.from("resign-edge", "utf8");
|
|
297
|
+
var goodSig = as.sign(payload);
|
|
298
|
+
|
|
299
|
+
// No opts, and an entry that omits oldPublicKeyPem -> verify falls back to
|
|
300
|
+
// the current in-memory key, which validates -> the entry is re-signed.
|
|
301
|
+
async function* defaultKeyEntries() {
|
|
302
|
+
yield { id: 1, payload: payload, signature: goodSig };
|
|
303
|
+
}
|
|
304
|
+
var s1 = await as.reSignAll(defaultKeyEntries());
|
|
305
|
+
check("reSignAll with no opts + default-key entry re-signs",
|
|
306
|
+
s1.reSigned === 1 && s1.skipped === 0 && s1.errors === 0);
|
|
307
|
+
|
|
308
|
+
// A malformed oldPublicKeyPem no longer throws inside verify() (it honors
|
|
309
|
+
// the never-throw contract and returns false) -> the entry is UNVERIFIABLE
|
|
310
|
+
// and SKIPPED, and the walk continues to the next valid entry.
|
|
311
|
+
async function* badKeyEntries() {
|
|
312
|
+
yield { id: 2, payload: payload, signature: goodSig,
|
|
313
|
+
oldPublicKeyPem: "-----BEGIN PUBLIC KEY-----\nnot-a-real-key\n-----END PUBLIC KEY-----" };
|
|
314
|
+
yield { id: 3, payload: payload, signature: goodSig }; // valid -> re-signed after the skip
|
|
315
|
+
}
|
|
316
|
+
var s2 = await as.reSignAll(badKeyEntries());
|
|
317
|
+
check("reSignAll skips a bad-key (unverifiable) entry and keeps walking",
|
|
318
|
+
s2.skipped === 1 && s2.reSigned === 1 && s2.errors === 0);
|
|
319
|
+
|
|
320
|
+
// A throwing onProgress hook is drop-silent — the re-sign still counts.
|
|
321
|
+
var fired = 0;
|
|
322
|
+
async function* okEntries() {
|
|
323
|
+
yield { id: 4, payload: payload, signature: goodSig, oldPublicKeyPem: as.getPublicKey() };
|
|
324
|
+
}
|
|
325
|
+
var s3 = await as.reSignAll(okEntries(), {
|
|
326
|
+
onProgress: function () { fired += 1; throw new Error("hook blew up"); },
|
|
327
|
+
});
|
|
328
|
+
check("reSignAll swallows a throwing onProgress (still re-signed)",
|
|
329
|
+
s3.reSigned === 1 && s3.errors === 0 && fired === 1);
|
|
330
|
+
} finally {
|
|
331
|
+
as._resetForTest();
|
|
332
|
+
_rm(dir);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---- _normalizeTip typed config-time throws + anchor/verify defaulting ----
|
|
337
|
+
async function testAnchorInputEdges() {
|
|
338
|
+
var as = b.auditSign;
|
|
339
|
+
var dir = _mkdir("blamejs-as-anchoredge-");
|
|
340
|
+
try {
|
|
341
|
+
as._resetForTest();
|
|
342
|
+
await as.init({ dataDir: dir, mode: "plaintext", algorithm: ALG });
|
|
343
|
+
|
|
344
|
+
check("anchor(null) -> ANCHOR_BAD_TIP",
|
|
345
|
+
(await _codeOf(function () { return as.anchor(null); })) === "ANCHOR_BAD_TIP");
|
|
346
|
+
check("anchor(non-object) -> ANCHOR_BAD_TIP",
|
|
347
|
+
(await _codeOf(function () { return as.anchor("not-a-tip"); })) === "ANCHOR_BAD_TIP");
|
|
348
|
+
check("anchor negative counter -> ANCHOR_BAD_COUNTER",
|
|
349
|
+
(await _codeOf(function () { return as.anchor({ counter: -1, tipHash: "h" }); })) === "ANCHOR_BAD_COUNTER");
|
|
350
|
+
check("anchor non-integer counter -> ANCHOR_BAD_COUNTER",
|
|
351
|
+
(await _codeOf(function () { return as.anchor({ counter: 1.5, tipHash: "h" }); })) === "ANCHOR_BAD_COUNTER");
|
|
352
|
+
check("anchor non-string prevTipHash -> ANCHOR_BAD_PREV",
|
|
353
|
+
(await _codeOf(function () { return as.anchor({ counter: 1, tipHash: "h", prevTipHash: 123 }); })) === "ANCHOR_BAD_PREV");
|
|
354
|
+
|
|
355
|
+
// An explicit finite createdAt is recorded verbatim; an empty format string
|
|
356
|
+
// falls back to the default anchor magic.
|
|
357
|
+
var aFixed = as.anchor({ counter: 5, tipHash: "e1" }, { createdAt: 123456, format: "" });
|
|
358
|
+
check("anchor records an explicit finite createdAt", aFixed.createdAt === 123456);
|
|
359
|
+
check("anchor with an empty format falls back to the default magic",
|
|
360
|
+
aFixed.format === "blamejs-chain-anchor-v1" && as.verifyAnchor(aFixed).ok === true);
|
|
361
|
+
|
|
362
|
+
// A non-empty custom format is domain-separated verbatim into the anchor
|
|
363
|
+
// and round-trips through verification.
|
|
364
|
+
var aFmt = as.anchor({ counter: 8, tipHash: "e4" }, { format: "my-app-ledger-v1" });
|
|
365
|
+
check("anchor honors a non-empty custom format",
|
|
366
|
+
aFmt.format === "my-app-ledger-v1" && as.verifyAnchor(aFmt).ok === true);
|
|
367
|
+
|
|
368
|
+
// A non-finite createdAt (Infinity) falls back to Date.now().
|
|
369
|
+
var aInf = as.anchor({ counter: 6, tipHash: "e2" }, { createdAt: Infinity });
|
|
370
|
+
check("anchor with a non-finite createdAt falls back to a real timestamp",
|
|
371
|
+
typeof aInf.createdAt === "number" && isFinite(aInf.createdAt) && as.verifyAnchor(aInf).ok === true);
|
|
372
|
+
|
|
373
|
+
// verifyAnchor rebuilds an empty stored format as the default magic, so a
|
|
374
|
+
// clean anchor whose format field was blanked still verifies.
|
|
375
|
+
var aClean = as.anchor({ counter: 7, tipHash: "e3" });
|
|
376
|
+
check("verifyAnchor treats an empty stored format as the default magic",
|
|
377
|
+
as.verifyAnchor(Object.assign({}, aClean, { format: "" })).ok === true);
|
|
378
|
+
|
|
379
|
+
// A mid-chain signature break (a tampered interior anchor) is reported at
|
|
380
|
+
// its index with the signature-failure reason — the linkage checks never
|
|
381
|
+
// get a chance to run because the per-anchor signature check fails first.
|
|
382
|
+
var c1 = as.anchor({ counter: 1, tipHash: "c1" });
|
|
383
|
+
var c2 = as.anchor({ counter: 2, tipHash: "c2", prevTipHash: "c1" });
|
|
384
|
+
var c2tampered = Object.assign({}, c2, { tipHash: "c2-tampered" });
|
|
385
|
+
var chain = as.verifyAnchorChain([c1, c2tampered]);
|
|
386
|
+
check("verifyAnchorChain reports a mid-chain signature break at its index",
|
|
387
|
+
chain.ok === false && chain.breakAt === 1 && chain.anchorsVerified === 1 &&
|
|
388
|
+
/signature/.test(chain.reason));
|
|
389
|
+
} finally {
|
|
390
|
+
as._resetForTest();
|
|
391
|
+
_rm(dir);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---- unsealed public-key history: dedup, corrupt-rebuild, unreadable store ----
|
|
396
|
+
async function testPublicHistoryEdges() {
|
|
397
|
+
var as = b.auditSign;
|
|
398
|
+
|
|
399
|
+
var dDedup = _mkdir("blamejs-as-histdedup-");
|
|
400
|
+
var dCorrupt = _mkdir("blamejs-as-histcorrupt-");
|
|
401
|
+
var dDir = _mkdir("blamejs-as-histdir-");
|
|
402
|
+
var dNonArray = _mkdir("blamejs-as-histnonarray-");
|
|
403
|
+
try {
|
|
404
|
+
// Dedup: a rotation whose to-be-archived fingerprint is ALREADY recorded in
|
|
405
|
+
// the history does not append a duplicate entry.
|
|
406
|
+
as._resetForTest();
|
|
407
|
+
await as.init({ dataDir: dDedup, mode: "plaintext", algorithm: ALG });
|
|
408
|
+
var curFp = as.getPublicKeyFingerprint();
|
|
409
|
+
var curPub = as.getPublicKey();
|
|
410
|
+
fs.writeFileSync(_historyPath(dDedup), JSON.stringify([
|
|
411
|
+
{ fingerprint: curFp, publicKey: curPub, algorithm: ALG, rotatedAt: new Date().toISOString() },
|
|
412
|
+
], null, 2));
|
|
413
|
+
await as.rotateSigningKey();
|
|
414
|
+
var afterDedup = JSON.parse(fs.readFileSync(_historyPath(dDedup), "utf8"));
|
|
415
|
+
check("rotation does not re-append an already-recorded public key",
|
|
416
|
+
Array.isArray(afterDedup) && afterDedup.length === 1);
|
|
417
|
+
|
|
418
|
+
// Corrupt history: a non-JSON registry is rebuilt from the rotated-out key
|
|
419
|
+
// rather than aborting the rotation.
|
|
420
|
+
as._resetForTest();
|
|
421
|
+
await as.init({ dataDir: dCorrupt, mode: "plaintext", algorithm: ALG });
|
|
422
|
+
fs.writeFileSync(_historyPath(dCorrupt), "{{ corrupt not json");
|
|
423
|
+
var rc = await as.rotateSigningKey();
|
|
424
|
+
var afterCorrupt = JSON.parse(fs.readFileSync(_historyPath(dCorrupt), "utf8"));
|
|
425
|
+
check("rotation rebuilds a corrupt public-key history",
|
|
426
|
+
!!rc.newFingerprint && Array.isArray(afterCorrupt) && afterCorrupt.length === 1);
|
|
427
|
+
|
|
428
|
+
// Unreadable/unwritable store: the history PATH is a directory, so the
|
|
429
|
+
// best-effort read AND write both fail — the rotation still succeeds and
|
|
430
|
+
// the fingerprint resolver returns null rather than throwing.
|
|
431
|
+
as._resetForTest();
|
|
432
|
+
await as.init({ dataDir: dDir, mode: "plaintext", algorithm: ALG });
|
|
433
|
+
var dirFp = as.getPublicKeyFingerprint();
|
|
434
|
+
fs.mkdirSync(_historyPath(dDir));
|
|
435
|
+
var rd = await as.rotateSigningKey();
|
|
436
|
+
check("rotation survives an unwritable public-key history",
|
|
437
|
+
!!rd.newFingerprint && rd.newFingerprint !== dirFp);
|
|
438
|
+
check("getPublicKeyByFingerprint returns null when the history store is unreadable",
|
|
439
|
+
as.getPublicKeyByFingerprint(dirFp) === null);
|
|
440
|
+
|
|
441
|
+
// A history file holding valid JSON that is NOT an array resolves to null.
|
|
442
|
+
as._resetForTest();
|
|
443
|
+
await as.init({ dataDir: dNonArray, mode: "plaintext", algorithm: ALG });
|
|
444
|
+
fs.writeFileSync(_historyPath(dNonArray), JSON.stringify({ notAnArray: true }));
|
|
445
|
+
check("getPublicKeyByFingerprint returns null for a non-array history",
|
|
446
|
+
as.getPublicKeyByFingerprint("deadbeefdeadbeef") === null);
|
|
447
|
+
} finally {
|
|
448
|
+
as._resetForTest();
|
|
449
|
+
_rm(dDedup); _rm(dCorrupt); _rm(dDir); _rm(dNonArray);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function run() {
|
|
454
|
+
b.auditSign._resetForTest();
|
|
455
|
+
try {
|
|
456
|
+
await testInitValidation();
|
|
457
|
+
await testConflictAndModeMismatch();
|
|
458
|
+
await testPlaintextLoadAndCorrupt();
|
|
459
|
+
await testWrappedModeAndErrors();
|
|
460
|
+
await testRequireInitGuards();
|
|
461
|
+
await testVerifyEdges();
|
|
462
|
+
await testReSignAllEdges();
|
|
463
|
+
await testAnchorInputEdges();
|
|
464
|
+
await testPublicHistoryEdges();
|
|
465
|
+
} finally {
|
|
466
|
+
try { b.auditSign._resetForTest(); } catch (_e) { /* best-effort */ }
|
|
467
|
+
delete process.env.BLAMEJS_AUDIT_SIGNING_PASSPHRASE;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
module.exports = { run: run };
|
|
472
|
+
|
|
473
|
+
if (require.main === module) {
|
|
474
|
+
run().then(function () {
|
|
475
|
+
console.log("OK - audit-sign tests (" + helpers.getChecks() + " checks)");
|
|
476
|
+
}).catch(function (e) {
|
|
477
|
+
console.error(helpers.formatErr(e));
|
|
478
|
+
process.exitCode = 1;
|
|
479
|
+
});
|
|
480
|
+
}
|