@blamejs/blamejs-shop 0.3.69 → 0.3.71
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/README.md +1 -1
- package/lib/admin.js +254 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/vendor/MANIFEST.json +95 -83
- package/lib/vendor/blamejs/.github/workflows/actions-lint.yml +3 -3
- package/lib/vendor/blamejs/.github/workflows/cflite_batch.yml +1 -1
- package/lib/vendor/blamejs/.github/workflows/cflite_pr.yml +1 -1
- package/lib/vendor/blamejs/.github/workflows/ci.yml +10 -10
- package/lib/vendor/blamejs/.github/workflows/codeql.yml +3 -3
- package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +2 -2
- package/lib/vendor/blamejs/.github/workflows/release-container.yml +4 -4
- package/lib/vendor/blamejs/.github/workflows/scorecard.yml +2 -2
- package/lib/vendor/blamejs/.github/workflows/sha-to-tag-verify.yml +1 -1
- package/lib/vendor/blamejs/CHANGELOG.md +4 -0
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/SECURITY.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +108 -4
- package/lib/vendor/blamejs/lib/auth/oauth.js +736 -1
- package/lib/vendor/blamejs/lib/auth/oid4vci.js +124 -5
- package/lib/vendor/blamejs/lib/auth/oid4vp.js +14 -4
- package/lib/vendor/blamejs/lib/auth/sd-jwt-vc-holder.js +46 -1
- package/lib/vendor/blamejs/lib/break-glass.js +1 -2
- package/lib/vendor/blamejs/lib/config.js +28 -31
- package/lib/vendor/blamejs/lib/crypto-field.js +274 -17
- package/lib/vendor/blamejs/lib/dora.js +8 -5
- package/lib/vendor/blamejs/lib/dsr.js +2 -2
- package/lib/vendor/blamejs/lib/flag-evaluation-context.js +7 -0
- package/lib/vendor/blamejs/lib/guard-html-wcag-aria.js +4 -2
- package/lib/vendor/blamejs/lib/guard-html-wcag-forms.js +4 -2
- package/lib/vendor/blamejs/lib/guard-html-wcag-tables.js +4 -2
- package/lib/vendor/blamejs/lib/guard-html-wcag-tagwalk.js +20 -0
- package/lib/vendor/blamejs/lib/guard-html-wcag.js +1 -1
- package/lib/vendor/blamejs/lib/honeytoken.js +27 -20
- package/lib/vendor/blamejs/lib/mail-auth.js +333 -0
- package/lib/vendor/blamejs/lib/mail-deploy.js +1 -1
- package/lib/vendor/blamejs/lib/mail-send-deliver.js +13 -4
- package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +140 -13
- package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +3 -0
- package/lib/vendor/blamejs/lib/middleware/csp-report.js +13 -9
- package/lib/vendor/blamejs/lib/middleware/fetch-metadata.js +115 -14
- package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +3 -0
- package/lib/vendor/blamejs/lib/middleware/scim-server.js +297 -19
- package/lib/vendor/blamejs/lib/middleware/security-headers.js +47 -0
- package/lib/vendor/blamejs/lib/middleware/security-txt.js +1 -2
- package/lib/vendor/blamejs/lib/middleware/trace-log-correlation.js +1 -2
- package/lib/vendor/blamejs/lib/network-smtp-policy.js +4 -4
- package/lib/vendor/blamejs/lib/object-store/sigv4-bucket-ops.js +11 -2
- package/lib/vendor/blamejs/lib/observability-tracer.js +1 -1
- package/lib/vendor/blamejs/lib/observability.js +39 -1
- package/lib/vendor/blamejs/lib/problem-details.js +56 -11
- package/lib/vendor/blamejs/lib/pubsub-cluster.js +16 -3
- package/lib/vendor/blamejs/lib/queue-sqs.js +20 -2
- package/lib/vendor/blamejs/lib/redis-client.js +32 -4
- package/lib/vendor/blamejs/lib/safe-redirect.js +16 -2
- package/lib/vendor/blamejs/memory/specs/node-26-map-getorinsert-migration.md +3 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.20.json +73 -0
- package/lib/vendor/blamejs/release-notes/v0.14.21.json +98 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +339 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/asyncapi.test.js +37 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/break-glass.test.js +22 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +315 -5
- package/lib/vendor/blamejs/test/layer-0-primitives/config.test.js +46 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-unseal-rate-cap.test.js +176 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +86 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/dora.test.js +38 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/dsr.test.js +29 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +236 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/fetch-metadata.test.js +190 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/flag.test.js +23 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-html-wcag.test.js +59 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/honeytoken.test.js +26 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +179 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-deploy-tlsrpt.test.js +16 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +108 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +269 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/observability-tracing.test.js +28 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/observability.test.js +39 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/openapi.test.js +37 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/problem-details.test.js +79 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/pubsub.test.js +49 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/queue-sqs.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +60 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-redirect.test.js +118 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +259 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sd-jwt-vc.test.js +46 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/security-headers.test.js +113 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/security-txt.test.js +111 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sigv4-bucket-ops.test.js +62 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/smtp-policy.test.js +39 -0
- package/package.json +1 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.cryptoField unseal-failure rate cap (CWE-307).
|
|
4
|
+
*
|
|
5
|
+
* A DB-write attacker who can write `vault:<crafted>` / `vault.aad:<…>`
|
|
6
|
+
* payloads to sealed columns can force KEM-decapsulation / AEAD-verify on
|
|
7
|
+
* attacker-controlled bytes on every read. unsealRow already nulls the
|
|
8
|
+
* field + emits system.crypto.unseal_failed, but absent a cap the oracle
|
|
9
|
+
* can be hammered indefinitely. configureUnsealRateCap adds an opt-in
|
|
10
|
+
* per-(actor, table, column) sliding-window failure cap: past `threshold`
|
|
11
|
+
* failures inside `windowMs`, further unseal attempts for that tuple are
|
|
12
|
+
* refused for `cooldownMs` with a typed CryptoFieldRateError + a distinct
|
|
13
|
+
* system.crypto.unseal_rate_exceeded audit.
|
|
14
|
+
*
|
|
15
|
+
* Every window / cooldown assertion drives an INJECTED clock — no real
|
|
16
|
+
* sleeps — so the test is deterministic on contended runners.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
var helpers = require("../helpers");
|
|
20
|
+
var b = helpers.b;
|
|
21
|
+
var check = helpers.check;
|
|
22
|
+
var fs = require("fs");
|
|
23
|
+
var os = require("os");
|
|
24
|
+
var path = require("path");
|
|
25
|
+
|
|
26
|
+
// A value that is shaped like an AAD-sealed envelope but is garbage —
|
|
27
|
+
// vaultAad.unseal throws on it, driving unsealRow's catch path.
|
|
28
|
+
var FORGED = "vault.aad:Zm9yZ2VkLWdhcmJhZ2U=";
|
|
29
|
+
|
|
30
|
+
async function run() {
|
|
31
|
+
var dir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-cf-ratecap-"));
|
|
32
|
+
await b.vault.init({ mode: "plaintext", dataDir: dir });
|
|
33
|
+
|
|
34
|
+
b.cryptoField.registerTable("cf_ratecap", {
|
|
35
|
+
sealedFields: ["secret", "other"], aad: true, rowIdField: "id",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ---- config-time validation (THROW tier) ----
|
|
39
|
+
function throws(re, fn) {
|
|
40
|
+
try { fn(); return false; } catch (e) { return re.test(e.message); }
|
|
41
|
+
}
|
|
42
|
+
check("bad threshold (0) throws",
|
|
43
|
+
throws(/threshold must be/, function () { b.cryptoField.configureUnsealRateCap({ threshold: 0 }); }));
|
|
44
|
+
check("bad threshold (Infinity) throws",
|
|
45
|
+
throws(/threshold must be/, function () { b.cryptoField.configureUnsealRateCap({ threshold: Infinity }); }));
|
|
46
|
+
check("bad threshold (float) throws",
|
|
47
|
+
throws(/threshold must be/, function () { b.cryptoField.configureUnsealRateCap({ threshold: 2.5 }); }));
|
|
48
|
+
check("bad windowMs throws",
|
|
49
|
+
throws(/windowMs must be/, function () { b.cryptoField.configureUnsealRateCap({ threshold: 3, windowMs: -1 }); }));
|
|
50
|
+
check("bad cooldownMs throws",
|
|
51
|
+
throws(/cooldownMs must be/, function () { b.cryptoField.configureUnsealRateCap({ threshold: 3, cooldownMs: 0 }); }));
|
|
52
|
+
check("bad now (not a function) throws",
|
|
53
|
+
throws(/now must be/, function () { b.cryptoField.configureUnsealRateCap({ threshold: 3, now: 5 }); }));
|
|
54
|
+
check("bad onAudit (not a function) throws",
|
|
55
|
+
throws(/onAudit must be/, function () { b.cryptoField.configureUnsealRateCap({ threshold: 3, onAudit: "x" }); }));
|
|
56
|
+
check("unknown opt key throws",
|
|
57
|
+
throws(/unknown|allowed|cryptoField.configureUnsealRateCap/i,
|
|
58
|
+
function () { b.cryptoField.configureUnsealRateCap({ threshold: 3, bogus: 1 }); }));
|
|
59
|
+
|
|
60
|
+
// ---- default OFF: unconfigured unsealRow is audit-only, never throws ----
|
|
61
|
+
b.cryptoField.clearRateCapForTest();
|
|
62
|
+
var offThrew = false;
|
|
63
|
+
for (var k = 0; k < 50; k++) {
|
|
64
|
+
try { b.cryptoField.unsealRow("cf_ratecap", { id: "r1", secret: FORGED }, "attacker"); }
|
|
65
|
+
catch (_e) { offThrew = true; }
|
|
66
|
+
}
|
|
67
|
+
check("cap default-off: 50 forged unseals never throw (back-compat)", offThrew === false);
|
|
68
|
+
|
|
69
|
+
// ---- injected clock + audit sink ----
|
|
70
|
+
var nowMs = 1000000;
|
|
71
|
+
function clock() { return nowMs; }
|
|
72
|
+
var audits = [];
|
|
73
|
+
b.cryptoField.configureUnsealRateCap({
|
|
74
|
+
threshold: 3, windowMs: 60000, cooldownMs: 300000,
|
|
75
|
+
now: clock,
|
|
76
|
+
onAudit: function (ev) { audits.push(ev); },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// The first `threshold-1` forged unseals null the field but do NOT trip.
|
|
80
|
+
var r1 = { id: "r1", secret: FORGED };
|
|
81
|
+
check("forged unseal #1 nulls field, no throw",
|
|
82
|
+
b.cryptoField.unsealRow("cf_ratecap", r1, "attacker").secret === null);
|
|
83
|
+
check("forged unseal #2 nulls field, no throw",
|
|
84
|
+
b.cryptoField.unsealRow("cf_ratecap", r1, "attacker").secret === null);
|
|
85
|
+
check("no rate audit before threshold reached", audits.length === 0);
|
|
86
|
+
|
|
87
|
+
// The 3rd failure (== threshold) trips the cap: it still returns (nulls
|
|
88
|
+
// the field) AND arms the cooldown + emits the distinct audit once.
|
|
89
|
+
var third = b.cryptoField.unsealRow("cf_ratecap", r1, "attacker");
|
|
90
|
+
check("threshold-th forged unseal still returns (field nulled)", third.secret === null);
|
|
91
|
+
check("rate-exceeded audit emitted once on the trip transition", audits.length === 1);
|
|
92
|
+
check("rate audit action is system.crypto.unseal_rate_exceeded",
|
|
93
|
+
audits[0].action === "system.crypto.unseal_rate_exceeded");
|
|
94
|
+
check("rate audit outcome is denied", audits[0].outcome === "denied");
|
|
95
|
+
check("rate audit carries the tuple + caps",
|
|
96
|
+
audits[0].metadata.table === "cf_ratecap" &&
|
|
97
|
+
audits[0].metadata.field === "secret" &&
|
|
98
|
+
audits[0].metadata.actor === "attacker" &&
|
|
99
|
+
audits[0].metadata.threshold === 3);
|
|
100
|
+
|
|
101
|
+
// Now in cooldown: the NEXT unseal of that tuple is REFUSED with the
|
|
102
|
+
// typed error (oracle no longer exercised), and re-emits the audit.
|
|
103
|
+
var refused = false, refusedCode = null;
|
|
104
|
+
try { b.cryptoField.unsealRow("cf_ratecap", { id: "r1", secret: FORGED }, "attacker"); }
|
|
105
|
+
catch (e) { refused = true; refusedCode = e.code; }
|
|
106
|
+
check("cooldown refuses further unseal with a throw", refused === true);
|
|
107
|
+
check("refusal is CryptoFieldRateError typed code",
|
|
108
|
+
refusedCode === "crypto-field/unseal-rate-exceeded");
|
|
109
|
+
check("refusal instanceof CryptoFieldRateError",
|
|
110
|
+
(function () {
|
|
111
|
+
try { b.cryptoField.unsealRow("cf_ratecap", { id: "r1", secret: FORGED }, "attacker"); return false; }
|
|
112
|
+
catch (e) { return e instanceof b.cryptoField.CryptoFieldRateError; }
|
|
113
|
+
})());
|
|
114
|
+
check("each cooldown refusal re-emits the rate audit", audits.length >= 3);
|
|
115
|
+
|
|
116
|
+
// ---- per-tuple isolation: a DIFFERENT column for the same actor is
|
|
117
|
+
// unaffected (its own window is independent). ----
|
|
118
|
+
var otherCol = b.cryptoField.unsealRow("cf_ratecap", { id: "r9", other: FORGED }, "attacker");
|
|
119
|
+
check("different column for same actor is not in cooldown (nulls, no throw)",
|
|
120
|
+
otherCol.other === null);
|
|
121
|
+
|
|
122
|
+
// A DIFFERENT actor on the same column is likewise independent.
|
|
123
|
+
var otherActor = b.cryptoField.unsealRow("cf_ratecap", { id: "r9", secret: FORGED }, "honest-user");
|
|
124
|
+
check("different actor on same column is not in cooldown (nulls, no throw)",
|
|
125
|
+
otherActor.secret === null);
|
|
126
|
+
|
|
127
|
+
// ---- cooldown expiry: advance the injected clock past cooldownMs;
|
|
128
|
+
// the tuple unseals normally again (a valid round-trip succeeds). ----
|
|
129
|
+
nowMs += 300000 + 1;
|
|
130
|
+
var validRow = b.cryptoField.sealRow("cf_ratecap", { id: "rok", secret: "plaintext-ok" });
|
|
131
|
+
check("after cooldown expiry a VALID unseal succeeds again",
|
|
132
|
+
b.cryptoField.unsealRow("cf_ratecap", validRow, "attacker").secret === "plaintext-ok");
|
|
133
|
+
|
|
134
|
+
// ---- sliding window: re-arm a fresh cap; two failures, then advance
|
|
135
|
+
// the clock past the window, then one more failure → still under
|
|
136
|
+
// threshold (the first two slid out), so NO trip. ----
|
|
137
|
+
audits.length = 0;
|
|
138
|
+
nowMs = 5000000;
|
|
139
|
+
b.cryptoField.configureUnsealRateCap({
|
|
140
|
+
threshold: 3, windowMs: 60000, cooldownMs: 300000,
|
|
141
|
+
now: clock,
|
|
142
|
+
onAudit: function (ev) { audits.push(ev); },
|
|
143
|
+
});
|
|
144
|
+
b.cryptoField.unsealRow("cf_ratecap", { id: "w1", secret: FORGED }, "slider");
|
|
145
|
+
b.cryptoField.unsealRow("cf_ratecap", { id: "w1", secret: FORGED }, "slider");
|
|
146
|
+
nowMs += 60000 + 1; // both prior failures slide out of the window
|
|
147
|
+
var afterSlide = b.cryptoField.unsealRow("cf_ratecap", { id: "w1", secret: FORGED }, "slider");
|
|
148
|
+
check("sliding window: failures older than windowMs do not count toward threshold",
|
|
149
|
+
afterSlide.secret === null && audits.length === 0);
|
|
150
|
+
|
|
151
|
+
// Two MORE failures inside the new window DO trip (1 surviving + 2 new
|
|
152
|
+
// = 3 == threshold).
|
|
153
|
+
b.cryptoField.unsealRow("cf_ratecap", { id: "w1", secret: FORGED }, "slider");
|
|
154
|
+
b.cryptoField.unsealRow("cf_ratecap", { id: "w1", secret: FORGED }, "slider");
|
|
155
|
+
check("three failures within one window trip the cap", audits.length === 1);
|
|
156
|
+
|
|
157
|
+
// ---- disable path: configureUnsealRateCap(null) restores audit-only ----
|
|
158
|
+
b.cryptoField.configureUnsealRateCap(null);
|
|
159
|
+
var afterDisable = false;
|
|
160
|
+
try { b.cryptoField.unsealRow("cf_ratecap", { id: "w1", secret: FORGED }, "slider"); }
|
|
161
|
+
catch (_e) { afterDisable = true; }
|
|
162
|
+
check("configureUnsealRateCap(null) turns the cap back off (no throw)", afterDisable === false);
|
|
163
|
+
|
|
164
|
+
b.cryptoField.clearRateCapForTest();
|
|
165
|
+
console.log("OK — crypto-field unseal-rate-cap tests");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { run: run };
|
|
169
|
+
if (require.main === module) {
|
|
170
|
+
// Rethrow on failure so Node surfaces the error and exits non-zero,
|
|
171
|
+
// instead of logging the caught error object (a taint analyzer would
|
|
172
|
+
// trace a logged error back to a fixture and raise a false clear-text-
|
|
173
|
+
// logging alert).
|
|
174
|
+
run().then(function () { process.exit(0); })
|
|
175
|
+
.catch(function (err) { process.exitCode = 1; throw err; });
|
|
176
|
+
}
|
|
@@ -38,6 +38,92 @@ async function run() {
|
|
|
38
38
|
testOnRejectRejectsNonFunction();
|
|
39
39
|
await testValidPostReachesOnReport();
|
|
40
40
|
await testOversizedPostRefused();
|
|
41
|
+
await testAuditDefaultEmitsViolationRow();
|
|
42
|
+
await testAuditFalseSuppressesViolationRow();
|
|
43
|
+
testMaxBytesGarbageThrowsAtCreate();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// SUCCESS path for the audit side effect: a valid POST with the default
|
|
47
|
+
// audit:true emits one `csp.violation` row per normalized report, AND
|
|
48
|
+
// returns 204 with the report still processed (onReport reached). The
|
|
49
|
+
// @opts block documents `audit: boolean, // default true`; this asserts
|
|
50
|
+
// the default actually emits.
|
|
51
|
+
async function testAuditDefaultEmitsViolationRow() {
|
|
52
|
+
var rows = [];
|
|
53
|
+
var origEmit = b.audit.safeEmit;
|
|
54
|
+
b.audit.safeEmit = function (rec) {
|
|
55
|
+
if (rec && rec.action === "csp.violation") rows.push(rec);
|
|
56
|
+
return origEmit.apply(b.audit, arguments);
|
|
57
|
+
};
|
|
58
|
+
var reports = [];
|
|
59
|
+
var res = _capRes();
|
|
60
|
+
try {
|
|
61
|
+
var mw = b.middleware.cspReport({ onReport: function (r) { reports.push(r); } });
|
|
62
|
+
var payload = JSON.stringify([{
|
|
63
|
+
type: "csp-violation",
|
|
64
|
+
url: "https://app.example.com/",
|
|
65
|
+
body: { effectiveDirective: "script-src", blockedURL: "https://evil.example/x.js" },
|
|
66
|
+
}]);
|
|
67
|
+
await mw(_bodyReq("POST", { "content-type": "application/reports+json" }, payload), res, function () {});
|
|
68
|
+
} finally {
|
|
69
|
+
b.audit.safeEmit = origEmit;
|
|
70
|
+
}
|
|
71
|
+
check("cspReport: audit default returns 204 with report processed",
|
|
72
|
+
res._sent.status === 204 && reports.length === 1);
|
|
73
|
+
check("cspReport: audit default emits one csp.violation row",
|
|
74
|
+
rows.length === 1 && rows[0].metadata.effectiveDirective === "script-src");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// audit:false suppresses the audit emission while the report is STILL
|
|
78
|
+
// processed end-to-end: no `csp.violation` row, but onReport reached and
|
|
79
|
+
// 204 returned. Operators who route violations through their own metrics
|
|
80
|
+
// sink shouldn't pay for the duplicate audit row.
|
|
81
|
+
async function testAuditFalseSuppressesViolationRow() {
|
|
82
|
+
var rows = [];
|
|
83
|
+
var origEmit = b.audit.safeEmit;
|
|
84
|
+
b.audit.safeEmit = function (rec) {
|
|
85
|
+
if (rec && rec.action === "csp.violation") rows.push(rec);
|
|
86
|
+
return origEmit.apply(b.audit, arguments);
|
|
87
|
+
};
|
|
88
|
+
var reports = [];
|
|
89
|
+
var res = _capRes();
|
|
90
|
+
try {
|
|
91
|
+
var mw = b.middleware.cspReport({
|
|
92
|
+
audit: false,
|
|
93
|
+
onReport: function (r) { reports.push(r); },
|
|
94
|
+
});
|
|
95
|
+
var payload = JSON.stringify([{
|
|
96
|
+
type: "csp-violation",
|
|
97
|
+
url: "https://app.example.com/",
|
|
98
|
+
body: { effectiveDirective: "img-src" },
|
|
99
|
+
}]);
|
|
100
|
+
await mw(_bodyReq("POST", { "content-type": "application/reports+json" }, payload), res, function () {});
|
|
101
|
+
} finally {
|
|
102
|
+
b.audit.safeEmit = origEmit;
|
|
103
|
+
}
|
|
104
|
+
check("cspReport: audit:false still returns 204 with report processed",
|
|
105
|
+
res._sent.status === 204 && reports.length === 1 &&
|
|
106
|
+
reports[0].body.effectiveDirective === "img-src");
|
|
107
|
+
check("cspReport: audit:false emits no csp.violation row",
|
|
108
|
+
rows.length === 0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// maxBytes is a byte count routed through validateOpts.optionalPositiveInt
|
|
112
|
+
// — garbage (string / negative / NaN / fractional) throws at create()
|
|
113
|
+
// rather than silently falling back to the 64 KiB default while the
|
|
114
|
+
// sibling onReject one line below would have thrown.
|
|
115
|
+
function testMaxBytesGarbageThrowsAtCreate() {
|
|
116
|
+
var bad = ["nope", -1, NaN, 1.5, Infinity, 0];
|
|
117
|
+
for (var i = 0; i < bad.length; i++) {
|
|
118
|
+
var threw = false;
|
|
119
|
+
try { b.middleware.cspReport({ maxBytes: bad[i] }); }
|
|
120
|
+
catch (_e) { threw = true; }
|
|
121
|
+
check("cspReport: maxBytes garbage throws at create (" + String(bad[i]) + ")", threw);
|
|
122
|
+
}
|
|
123
|
+
// A valid positive integer is accepted; absent stays the default.
|
|
124
|
+
var ok = false;
|
|
125
|
+
try { b.middleware.cspReport({ maxBytes: 1024 }); ok = true; } catch (_e) { ok = false; }
|
|
126
|
+
check("cspReport: valid maxBytes accepted at create", ok);
|
|
41
127
|
}
|
|
42
128
|
|
|
43
129
|
// The SUCCESS path: a valid POST with a parseable report body must reach
|
|
@@ -144,6 +144,43 @@ function testDraftFinalReport() {
|
|
|
144
144
|
typeof draft.lessonsLearned === "string");
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
function testReportObservabilityKnob() {
|
|
148
|
+
// The `observability` opt gates the best-effort report counter.
|
|
149
|
+
// Default (omitted) emits the dora.incident.reported event; setting
|
|
150
|
+
// observability:false suppresses it. We tap the observability sink to
|
|
151
|
+
// observe the emission.
|
|
152
|
+
var events = [];
|
|
153
|
+
b.observability.setTap(function (name) {
|
|
154
|
+
if (name === "dora.incident.reported") events.push(name);
|
|
155
|
+
});
|
|
156
|
+
try {
|
|
157
|
+
var doraDefault = b.dora.create({ audit: false });
|
|
158
|
+
doraDefault.report({
|
|
159
|
+
incidentId: "INC-obs-default",
|
|
160
|
+
classification: "major",
|
|
161
|
+
stage: "initial",
|
|
162
|
+
detectedAt: Date.now(),
|
|
163
|
+
description: "obs default path",
|
|
164
|
+
});
|
|
165
|
+
check("dora.observability default: counter emitted",
|
|
166
|
+
events.indexOf("dora.incident.reported") !== -1);
|
|
167
|
+
|
|
168
|
+
events.length = 0;
|
|
169
|
+
var doraSilent = b.dora.create({ audit: false, observability: false });
|
|
170
|
+
doraSilent.report({
|
|
171
|
+
incidentId: "INC-obs-silent",
|
|
172
|
+
classification: "major",
|
|
173
|
+
stage: "initial",
|
|
174
|
+
detectedAt: Date.now(),
|
|
175
|
+
description: "obs suppressed path",
|
|
176
|
+
});
|
|
177
|
+
check("dora.observability false: no counter emitted",
|
|
178
|
+
events.length === 0);
|
|
179
|
+
} finally {
|
|
180
|
+
b.observability.setTap(null);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
147
184
|
async function run() {
|
|
148
185
|
testSurface();
|
|
149
186
|
testClassifyMajor();
|
|
@@ -155,6 +192,7 @@ async function run() {
|
|
|
155
192
|
testReportFinal();
|
|
156
193
|
testReportBadInput();
|
|
157
194
|
testDraftFinalReport();
|
|
195
|
+
testReportObservabilityKnob();
|
|
158
196
|
}
|
|
159
197
|
|
|
160
198
|
module.exports = { run: run };
|
|
@@ -512,6 +512,35 @@ function testCreateValidation() {
|
|
|
512
512
|
});
|
|
513
513
|
} catch (_e) { threwNoIdentity = true; }
|
|
514
514
|
check("dsr.create: missing identityResolver throws", threwNoIdentity);
|
|
515
|
+
|
|
516
|
+
// De-advertised create-time keys: `observability` was never read at
|
|
517
|
+
// create (the counter always fires through the module sink) and
|
|
518
|
+
// `verifyContext` is a per-call process() opt, not a create() opt.
|
|
519
|
+
// Both removed from the create allowlist → unknown-option throw.
|
|
520
|
+
function _validBase() {
|
|
521
|
+
return {
|
|
522
|
+
ticketStore: b.dsr.memoryTicketStore(),
|
|
523
|
+
identityResolver: async function () { return { subjectId: "u" }; },
|
|
524
|
+
sources: [{ name: "x", query: async function () { return []; } }],
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
var threwObs = false;
|
|
528
|
+
try {
|
|
529
|
+
var oObs = _validBase(); oObs.observability = true;
|
|
530
|
+
b.dsr.create(oObs);
|
|
531
|
+
} catch (e) { threwObs = /unknown option 'observability'/.test(e.message || ""); }
|
|
532
|
+
check("dsr.create: unknown 'observability' opt rejected", threwObs);
|
|
533
|
+
|
|
534
|
+
var threwVc = false;
|
|
535
|
+
try {
|
|
536
|
+
var oVc = _validBase(); oVc.verifyContext = { mfaVerified: true };
|
|
537
|
+
b.dsr.create(oVc);
|
|
538
|
+
} catch (e) { threwVc = /unknown option 'verifyContext'/.test(e.message || ""); }
|
|
539
|
+
check("dsr.create: unknown create-time 'verifyContext' opt rejected", threwVc);
|
|
540
|
+
|
|
541
|
+
// Sanity: the valid base still constructs.
|
|
542
|
+
check("dsr.create: valid base opts construct",
|
|
543
|
+
typeof b.dsr.create(_validBase()).submit === "function");
|
|
515
544
|
}
|
|
516
545
|
|
|
517
546
|
// ---- Memory store ----
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* - b.auth.openidFederation (entity statement parse + verify, metadata-policy operators)
|
|
7
7
|
* - b.auth.oid4vp (DCQL validator + matcher)
|
|
8
8
|
* - b.auth.oid4vci (issuer config validation, metadata, offer creation shape,
|
|
9
|
-
* kid-only proof resolution via resolveKid
|
|
9
|
+
* kid-only proof resolution via resolveKid, x5c leaf-cert
|
|
10
|
+
* proof binding + validateX5c hook, expired-c_nonce typed refusal)
|
|
10
11
|
* - b.auth.ciba (client.create validation + binding-message rules)
|
|
11
12
|
* - b.auth.sdJwtVc (key_attestation header surfaces through present)
|
|
12
13
|
* - b.session.isAnonymous + b.session.create({ anonymous: true })
|
|
@@ -22,6 +23,7 @@ var check = helpers.check;
|
|
|
22
23
|
var setupTestDb = helpers.setupTestDb;
|
|
23
24
|
var teardownTestDb = helpers.teardownTestDb;
|
|
24
25
|
var nodeCrypto = require("node:crypto");
|
|
26
|
+
var asn1 = require("../../lib/asn1-der");
|
|
25
27
|
|
|
26
28
|
// ---- xmlC14n ----------------------------------------------------------
|
|
27
29
|
|
|
@@ -236,6 +238,41 @@ function testDcqlMatch() {
|
|
|
236
238
|
check("DCQL null at leaf misses empty array", !_dcqlValid(["tags", null], { tags: [] }));
|
|
237
239
|
// nested null wildcards recurse to arbitrary depth
|
|
238
240
|
check("DCQL nested null wildcards match", _dcqlValid(["a", null, "b", null], { a: [{ b: [1, 2] }] }));
|
|
241
|
+
|
|
242
|
+
// ---- numeric path segment = non-negative integer array index ----
|
|
243
|
+
// OpenID4VP 1.0 §7.1.1: a numeric claim-path segment is an array
|
|
244
|
+
// index and MUST be a non-negative integer. Config-time / entry-point
|
|
245
|
+
// tier — _validateDcql throws AuthError on the bad shape (driven here
|
|
246
|
+
// through the public matchDcql, which calls _validateDcql first).
|
|
247
|
+
function _segThrows(segment) {
|
|
248
|
+
var q = { credentials: [{ id: "c", format: "vc+sd-jwt",
|
|
249
|
+
claims: [{ path: ["arr", segment] }] }] };
|
|
250
|
+
var threw = null;
|
|
251
|
+
try { b.auth.oid4vp.matchDcql([], q); } catch (e) { threw = e; }
|
|
252
|
+
return threw;
|
|
253
|
+
}
|
|
254
|
+
function _segAccepted(segment) {
|
|
255
|
+
var q = { credentials: [{ id: "c", format: "vc+sd-jwt",
|
|
256
|
+
claims: [{ path: ["arr", segment] }] }] };
|
|
257
|
+
var threw = null;
|
|
258
|
+
try { b.auth.oid4vp.matchDcql([{ id: "c", format: "vc+sd-jwt", claims: { arr: [1, 2, 3] } }], q); }
|
|
259
|
+
catch (e) { threw = e; }
|
|
260
|
+
return threw === null;
|
|
261
|
+
}
|
|
262
|
+
var segNeg = _segThrows(-1);
|
|
263
|
+
check("DCQL: negative index segment throws AuthError",
|
|
264
|
+
!!segNeg && segNeg.code === "auth-oid4vp/bad-claim-segment");
|
|
265
|
+
var segFrac = _segThrows(1.5);
|
|
266
|
+
check("DCQL: fractional index segment throws AuthError",
|
|
267
|
+
!!segFrac && segFrac.code === "auth-oid4vp/bad-claim-segment");
|
|
268
|
+
var segNaN = _segThrows(NaN);
|
|
269
|
+
check("DCQL: NaN index segment throws AuthError",
|
|
270
|
+
!!segNaN && segNaN.code === "auth-oid4vp/bad-claim-segment");
|
|
271
|
+
var segInf = _segThrows(Infinity);
|
|
272
|
+
check("DCQL: Infinity index segment throws AuthError",
|
|
273
|
+
!!segInf && segInf.code === "auth-oid4vp/bad-claim-segment");
|
|
274
|
+
check("DCQL: zero index segment accepted", _segAccepted(0));
|
|
275
|
+
check("DCQL: positive index segment accepted", _segAccepted(2));
|
|
239
276
|
}
|
|
240
277
|
|
|
241
278
|
// ---- OID4VCI issuer config -------------------------------------------
|
|
@@ -400,6 +437,202 @@ async function testOid4vciKidResolver() {
|
|
|
400
437
|
check("OID4VCI: resolveKid failure is a typed AuthError", isAuthErr);
|
|
401
438
|
}
|
|
402
439
|
|
|
440
|
+
// ---- OID4VCI x5c proof (RFC 7515 §4.1.6 / OID4VCI §8.2.1.1) -----------
|
|
441
|
+
|
|
442
|
+
// Build a self-signed P-256 EC leaf certificate (the holder cert the
|
|
443
|
+
// wallet ships in `x5c`). The cert's private key signs the proof JWT;
|
|
444
|
+
// the leaf SPKI is the holder key the issuer binds cnf to. Returns the
|
|
445
|
+
// raw DER + the EC private key. Mirrors content-credentials.test.js's
|
|
446
|
+
// in-tree DER cert minting (no openssl dependency).
|
|
447
|
+
function _makeEcLeafCert(cn) {
|
|
448
|
+
var kp = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
449
|
+
var spki = kp.publicKey.export({ type: "spki", format: "der" }); // full EC SubjectPublicKeyInfo (alg id + curve + point)
|
|
450
|
+
var name = asn1.writeSequence([asn1.writeSet([asn1.writeSequence([
|
|
451
|
+
asn1.writeOid("2.5.4.3"), asn1.writeUtf8String(cn)])])]); // CN=<cn>
|
|
452
|
+
var sigAlgId = asn1.writeSequence([asn1.writeOid("1.2.840.10045.4.3.2")]); // ecdsa-with-SHA256
|
|
453
|
+
var version = asn1.writeContextExplicit(0, asn1.writeInteger(Buffer.from([2])));
|
|
454
|
+
var serial = asn1.writeInteger(Buffer.from([0x2c]));
|
|
455
|
+
var now = Date.now();
|
|
456
|
+
function _utc(d) { var s = d.toISOString().replace(/[-:T]/g, "").slice(2, 14) + "Z"; return asn1.writeNode(0x17, Buffer.from(s, "ascii")); }
|
|
457
|
+
var validity = asn1.writeSequence([_utc(new Date(now - 86400000)), _utc(new Date(now + 86400000 * 3650))]);
|
|
458
|
+
var tbs = asn1.writeSequence([version, serial, sigAlgId, name, validity, name, spki]);
|
|
459
|
+
var tbsSig = nodeCrypto.sign("sha256", tbs, kp.privateKey); // ECDSA sig is DER-encoded (X509 default)
|
|
460
|
+
var certDer = asn1.writeSequence([tbs, sigAlgId, asn1.writeBitString(tbsSig, 0)]);
|
|
461
|
+
return { certDer: certDer, privateKey: kp.privateKey };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Build a holder-signed openid4vci-proof+jwt carrying an `x5c` chain
|
|
465
|
+
// (standard base64, leaf first) and no inline jwk/kid. ES256 over the
|
|
466
|
+
// leaf cert's EC private key.
|
|
467
|
+
function _signX5cProof(leafPrivKey, x5cArray, aud, nonce) {
|
|
468
|
+
var header = { typ: "openid4vci-proof+jwt", alg: "ES256", x5c: x5cArray };
|
|
469
|
+
var payload = { aud: aud, nonce: nonce, iat: Math.floor(Date.now() / 1000) };
|
|
470
|
+
var signingInput = _b64url(JSON.stringify(header)) + "." + _b64url(JSON.stringify(payload));
|
|
471
|
+
var sig = nodeCrypto.sign("sha256", Buffer.from(signingInput, "ascii"),
|
|
472
|
+
{ key: leafPrivKey, dsaEncoding: "ieee-p1363" });
|
|
473
|
+
return signingInput + "." + _b64url(sig);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function testOid4vciX5cProof() {
|
|
477
|
+
var aud = "https://issuer.example";
|
|
478
|
+
var leaf = _makeEcLeafCert("Holder Wallet Leaf");
|
|
479
|
+
var leafB64 = leaf.certDer.toString("base64"); // standard base64 (RFC 7515 §4.1.6)
|
|
480
|
+
var leafPubJwk = nodeCrypto.createPublicKey(leaf.privateKey).export({ format: "jwk" });
|
|
481
|
+
|
|
482
|
+
// SUCCESS PATH — x5c-only proof, no operator validateX5c hook. The
|
|
483
|
+
// leaf SPKI binds at the same self-asserted trust as inline jwk; the
|
|
484
|
+
// proof signature proves the holder controls the key. End-to-end:
|
|
485
|
+
// offer → token → credential issued, cnf bound to the leaf SPKI.
|
|
486
|
+
var ok = await _runIssuanceWithProof(
|
|
487
|
+
{},
|
|
488
|
+
function (nonce) { return _signX5cProof(leaf.privateKey, [leafB64], aud, nonce); });
|
|
489
|
+
check("OID4VCI: x5c-only proof issues credential (success path)",
|
|
490
|
+
ok.rv && ok.rv.credential === "fake-sd-jwt");
|
|
491
|
+
check("OID4VCI: x5c leaf SPKI bound to cnf",
|
|
492
|
+
ok.captured.holderKey && ok.captured.holderKey.kty === "EC" &&
|
|
493
|
+
ok.captured.holderKey.x === leafPubJwk.x);
|
|
494
|
+
|
|
495
|
+
// SUCCESS PATH — operator validateX5c hook receives the leaf DER + the
|
|
496
|
+
// header and may pass. Confirms the chain buffers + header reach the hook.
|
|
497
|
+
var seen = { chainLen: 0, headerAlg: null, leafIsBuffer: false };
|
|
498
|
+
var okHook = await _runIssuanceWithProof(
|
|
499
|
+
{ validateX5c: function (chainDerBuffers, header) {
|
|
500
|
+
seen.chainLen = chainDerBuffers.length;
|
|
501
|
+
seen.headerAlg = header.alg;
|
|
502
|
+
seen.leafIsBuffer = Buffer.isBuffer(chainDerBuffers[0]);
|
|
503
|
+
} },
|
|
504
|
+
function (nonce) { return _signX5cProof(leaf.privateKey, [leafB64], aud, nonce); });
|
|
505
|
+
check("OID4VCI: validateX5c hook passing issues credential",
|
|
506
|
+
okHook.rv && okHook.rv.credential === "fake-sd-jwt");
|
|
507
|
+
check("OID4VCI: validateX5c hook receives leaf DER buffer + header.alg",
|
|
508
|
+
seen.chainLen === 1 && seen.headerAlg === "ES256" && seen.leafIsBuffer === true);
|
|
509
|
+
|
|
510
|
+
// REFUSAL — operator validateX5c throws → typed AuthError refusal, not
|
|
511
|
+
// an unhandled rejection.
|
|
512
|
+
var threw = false, isAuthErr = false;
|
|
513
|
+
try {
|
|
514
|
+
await _runIssuanceWithProof(
|
|
515
|
+
{ validateX5c: function () { throw new Error("untrusted-attestation-ca"); } },
|
|
516
|
+
function (nonce) { return _signX5cProof(leaf.privateKey, [leafB64], aud, nonce); });
|
|
517
|
+
} catch (e) {
|
|
518
|
+
threw = /x5c-rejected/.test(e.code) && /untrusted-attestation-ca/.test(e.message);
|
|
519
|
+
isAuthErr = e instanceof b.frameworkError.AuthError;
|
|
520
|
+
}
|
|
521
|
+
check("OID4VCI: validateX5c throwing yields typed x5c-rejected", threw);
|
|
522
|
+
check("OID4VCI: validateX5c refusal is a typed AuthError", isAuthErr);
|
|
523
|
+
|
|
524
|
+
// REFUSAL — wrong leaf key (proof signed by a DIFFERENT EC key than the
|
|
525
|
+
// cert carries) → signature verification fails cleanly.
|
|
526
|
+
var otherLeaf = _makeEcLeafCert("Imposter Leaf");
|
|
527
|
+
threw = false;
|
|
528
|
+
try {
|
|
529
|
+
await _runIssuanceWithProof(
|
|
530
|
+
{},
|
|
531
|
+
function (nonce) { return _signX5cProof(otherLeaf.privateKey, [leafB64], aud, nonce); });
|
|
532
|
+
} catch (e) { threw = /proof-bad-signature/.test(e.code) || /signature verification failed/.test(e.message); }
|
|
533
|
+
check("OID4VCI: x5c proof signed by non-leaf key fails signature", threw);
|
|
534
|
+
|
|
535
|
+
// MALFORMED x5c — each shape refused with a typed code (not a crash).
|
|
536
|
+
function _refusedX5c(x5cVal, label) {
|
|
537
|
+
var t = false, typed = false;
|
|
538
|
+
return _runIssuanceWithProof(
|
|
539
|
+
{},
|
|
540
|
+
function (nonce) {
|
|
541
|
+
// Build a proof whose header carries the malformed x5c; the proof
|
|
542
|
+
// never reaches signature verification — it's refused at parse.
|
|
543
|
+
var header = { typ: "openid4vci-proof+jwt", alg: "ES256", x5c: x5cVal };
|
|
544
|
+
var payload = { aud: aud, nonce: nonce, iat: Math.floor(Date.now() / 1000) };
|
|
545
|
+
var input = _b64url(JSON.stringify(header)) + "." + _b64url(JSON.stringify(payload));
|
|
546
|
+
var sig = nodeCrypto.sign("sha256", Buffer.from(input, "ascii"),
|
|
547
|
+
{ key: leaf.privateKey, dsaEncoding: "ieee-p1363" });
|
|
548
|
+
return input + "." + _b64url(sig);
|
|
549
|
+
}
|
|
550
|
+
).then(
|
|
551
|
+
function () { check("OID4VCI: malformed x5c (" + label + ") refused", false); },
|
|
552
|
+
function (e) {
|
|
553
|
+
t = /bad-x5c/.test(e.code);
|
|
554
|
+
typed = e instanceof b.frameworkError.AuthError;
|
|
555
|
+
check("OID4VCI: malformed x5c (" + label + ") refused typed", t && typed);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
await _refusedX5c([], "empty array");
|
|
559
|
+
await _refusedX5c([""], "empty string entry");
|
|
560
|
+
await _refusedX5c(["@@@not-base64@@@"], "non-base64 entry");
|
|
561
|
+
// base64url chars (- / _) are invalid for x5c (standard base64 only).
|
|
562
|
+
await _refusedX5c([leafB64.replace(/[+/]/, "-")], "base64url-charset entry");
|
|
563
|
+
// valid base64 but not a parseable DER certificate.
|
|
564
|
+
await _refusedX5c([Buffer.from("not a certificate").toString("base64")], "non-DER-cert entry");
|
|
565
|
+
|
|
566
|
+
// non-array x5c — header.x5c truthy-but-not-array. _parseX5cChain
|
|
567
|
+
// refuses. (header.jwk/kid absent so the x5c branch is taken.)
|
|
568
|
+
await _refusedX5c({ "0": leafB64 }, "object not array");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---- OID4VCI expired c_nonce (typed refusal, not TypeError) ----------
|
|
572
|
+
|
|
573
|
+
async function testOid4vciCNonceExpired() {
|
|
574
|
+
var aud = "https://issuer.example";
|
|
575
|
+
var holderKp = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
576
|
+
var holderJwk = holderKp.publicKey.export({ format: "jwk" });
|
|
577
|
+
|
|
578
|
+
// A cNonceStore whose get() always returns undefined simulates the
|
|
579
|
+
// c_nonce TTL (5m) elapsing before /credential is called while the
|
|
580
|
+
// access token (15m) is still live — cNonceStore.get returns undefined
|
|
581
|
+
// on miss/expiry. set/del are accepted no-ops so issuance can proceed
|
|
582
|
+
// up to the proof check.
|
|
583
|
+
var expiredCNonceStore = {
|
|
584
|
+
set: async function () {},
|
|
585
|
+
get: async function () { return undefined; },
|
|
586
|
+
del: async function () {},
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
var iss = b.auth.oid4vci.issuer.create({
|
|
590
|
+
credentialIssuerUrl: aud,
|
|
591
|
+
credentialEndpoint: aud + "/credential",
|
|
592
|
+
tokenEndpoint: aud + "/token",
|
|
593
|
+
sdJwtIssuer: { issue: async function () { return { token: "fake-sd-jwt" }; } },
|
|
594
|
+
supportedCredentials: {
|
|
595
|
+
"id-card-1": { format: "vc+sd-jwt", vct: "https://example.com/vct/identity",
|
|
596
|
+
claims: { given_name: {} } },
|
|
597
|
+
},
|
|
598
|
+
cNonceStore: expiredCNonceStore,
|
|
599
|
+
});
|
|
600
|
+
var offer = await iss.createCredentialOffer({ subject: "user-9", credentialIds: ["id-card-1"] });
|
|
601
|
+
var tokens = await iss.exchangePreAuthorizedCode({ preAuthCode: offer.preAuthCode });
|
|
602
|
+
|
|
603
|
+
// The proof carries SOME nonce (the wallet's last-seen c_nonce), but
|
|
604
|
+
// the store has expired the expected value → undefined. Pre-fix this
|
|
605
|
+
// crashed with a raw TypeError from timingSafeEqual(nonce, undefined);
|
|
606
|
+
// now it must refuse with the typed c-nonce-expired code. Use an
|
|
607
|
+
// inline-jwk proof so the key path succeeds and the ONLY failure is
|
|
608
|
+
// the expired c_nonce.
|
|
609
|
+
function _signJwkProof(priv, jwk, a, nonce) {
|
|
610
|
+
var header = { typ: "openid4vci-proof+jwt", alg: "ES256", jwk: jwk };
|
|
611
|
+
var payload = { aud: a, nonce: nonce, iat: Math.floor(Date.now() / 1000) };
|
|
612
|
+
var input = _b64url(JSON.stringify(header)) + "." + _b64url(JSON.stringify(payload));
|
|
613
|
+
var sig = nodeCrypto.sign("sha256", Buffer.from(input, "ascii"), { key: priv, dsaEncoding: "ieee-p1363" });
|
|
614
|
+
return input + "." + _b64url(sig);
|
|
615
|
+
}
|
|
616
|
+
var proof = _signJwkProof(holderKp.privateKey, holderJwk, aud, "stale-nonce");
|
|
617
|
+
|
|
618
|
+
var threw = false, isAuthErr = false, isTypeError = false;
|
|
619
|
+
try {
|
|
620
|
+
await iss.issueCredential({
|
|
621
|
+
accessToken: tokens.access_token,
|
|
622
|
+
credentialIdentifier: "id-card-1",
|
|
623
|
+
proof: proof,
|
|
624
|
+
claims: { given_name: "Alice" },
|
|
625
|
+
});
|
|
626
|
+
} catch (e) {
|
|
627
|
+
threw = /c-nonce-expired/.test(e.code || "");
|
|
628
|
+
isAuthErr = e instanceof b.frameworkError.AuthError;
|
|
629
|
+
isTypeError = e instanceof TypeError;
|
|
630
|
+
}
|
|
631
|
+
check("OID4VCI: expired c_nonce (store miss) refused with c-nonce-expired", threw);
|
|
632
|
+
check("OID4VCI: expired c_nonce refusal is a typed AuthError", isAuthErr);
|
|
633
|
+
check("OID4VCI: expired c_nonce does NOT throw a raw TypeError", !isTypeError);
|
|
634
|
+
}
|
|
635
|
+
|
|
403
636
|
// ---- CIBA client config ----------------------------------------------
|
|
404
637
|
|
|
405
638
|
function testCibaConfig() {
|
|
@@ -560,6 +793,8 @@ async function run() {
|
|
|
560
793
|
testDcqlMatch();
|
|
561
794
|
testOid4vciIssuerConfig();
|
|
562
795
|
await testOid4vciKidResolver();
|
|
796
|
+
await testOid4vciX5cProof();
|
|
797
|
+
await testOid4vciCNonceExpired();
|
|
563
798
|
testCibaConfig();
|
|
564
799
|
await testSdJwtKeyAttestationStorage();
|
|
565
800
|
await testAnonymousSessions();
|