@blamejs/blamejs-shop 0.3.40 → 0.3.42
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/storefront.js +14 -3
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +8 -0
- package/lib/vendor/blamejs/README.md +8 -5
- package/lib/vendor/blamejs/SECURITY.md +7 -0
- package/lib/vendor/blamejs/api-snapshot.json +266 -2
- package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +1 -0
- package/lib/vendor/blamejs/index.js +7 -1
- package/lib/vendor/blamejs/lib/agent-idempotency.js +113 -0
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +108 -0
- package/lib/vendor/blamejs/lib/agent-snapshot.js +137 -0
- package/lib/vendor/blamejs/lib/agent-tenant.js +193 -17
- package/lib/vendor/blamejs/lib/ai-input.js +167 -3
- package/lib/vendor/blamejs/lib/ai-output.js +463 -0
- package/lib/vendor/blamejs/lib/ai-prompt.js +304 -0
- package/lib/vendor/blamejs/lib/archive-wrap.js +234 -1
- package/lib/vendor/blamejs/lib/archive.js +1 -0
- package/lib/vendor/blamejs/lib/audit.js +3 -0
- package/lib/vendor/blamejs/lib/auth/oid4vp.js +47 -28
- package/lib/vendor/blamejs/lib/cluster.js +186 -14
- package/lib/vendor/blamejs/lib/codepoint-class.js +18 -0
- package/lib/vendor/blamejs/lib/compliance-ai-act.js +446 -0
- package/lib/vendor/blamejs/lib/consent.js +104 -8
- package/lib/vendor/blamejs/lib/content-credentials.js +851 -41
- package/lib/vendor/blamejs/lib/crypto-field.js +5 -0
- package/lib/vendor/blamejs/lib/db.js +15 -0
- package/lib/vendor/blamejs/lib/framework-error.js +21 -0
- package/lib/vendor/blamejs/lib/mail-srs.js +122 -19
- package/lib/vendor/blamejs/lib/privacy.js +168 -0
- package/lib/vendor/blamejs/lib/safe-archive.js +196 -136
- package/lib/vendor/blamejs/lib/validate-opts.js +24 -0
- package/lib/vendor/blamejs/lib/vault/rotate.js +175 -15
- package/lib/vendor/blamejs/lib/vault-aad.js +84 -33
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.11.json +72 -0
- package/lib/vendor/blamejs/release-notes/v0.14.12.json +95 -0
- package/lib/vendor/blamejs/release-notes/v0.14.13.json +52 -0
- package/lib/vendor/blamejs/release-notes/v0.14.14.json +31 -0
- package/lib/vendor/blamejs/test/00-primitives.js +9 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +103 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +91 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-snapshot.test.js +186 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-tenant.test.js +140 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-input.test.js +59 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-output.test.js +125 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-prompt.test.js +133 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +6 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +94 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-wrap.test.js +176 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster-vault-rotation.test.js +243 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +250 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +130 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/consent-purposes.test.js +70 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/content-credentials.test.js +289 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +22 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-srs.test.js +61 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/privacy-vendor-review.test.js +69 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/vault-rotate-aad.test.js +158 -0
- package/package.json +1 -1
|
@@ -1,11 +1,122 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* b.contentCredentials — SB-942 / AB-853 / C2PA manifest builder
|
|
3
|
+
* b.contentCredentials — SB-942 / AB-853 / C2PA manifest builder, the
|
|
4
|
+
* COSE_Sign1 interop wrapper with its RFC 3161 sigTst2 countersignature,
|
|
5
|
+
* and the CAWG identity assertion.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
var helpers = require("../helpers");
|
|
7
9
|
var b = helpers.b;
|
|
8
10
|
var check = helpers.check;
|
|
11
|
+
var asn1 = require("../../lib/asn1-der");
|
|
12
|
+
var nodeCrypto = require("node:crypto");
|
|
13
|
+
|
|
14
|
+
// ---- RFC 3161 token-mint helpers (mock TSA, in-tree ASN.1 DER) ----
|
|
15
|
+
// Mirrors test/layer-0-primitives/tsa.test.js so the sigTst2
|
|
16
|
+
// countersignature round-trip exercises a real-shaped timestamp token
|
|
17
|
+
// (CMS SignedData over a TSTInfo) with full control over the imprint.
|
|
18
|
+
var ID_CT_TST_INFO = "1.2.840.113549.1.9.16.1.4";
|
|
19
|
+
var ID_KP_TIMESTAMPING = "1.3.6.1.5.5.7.3.8";
|
|
20
|
+
var OID_CONTENT_TYPE_ATTR = "1.2.840.113549.1.9.3";
|
|
21
|
+
var OID_MESSAGE_DIGEST_ATTR = "1.2.840.113549.1.9.4";
|
|
22
|
+
var OID_SIGNED_DATA = "1.2.840.113549.1.7.2";
|
|
23
|
+
var OID_SHA512 = "2.16.840.1.101.3.4.2.3";
|
|
24
|
+
var OID_RSA_ENCRYPTION = "1.2.840.113549.1.1.1";
|
|
25
|
+
|
|
26
|
+
function _utcTime(d) { var s = d.toISOString().replace(/[-:T]/g, "").slice(2, 14) + "Z"; return asn1.writeNode(0x17, Buffer.from(s, "ascii")); }
|
|
27
|
+
function _genTime(d) { var s = d.toISOString().replace(/[-:T]/g, "").slice(0, 14) + "Z"; return asn1.writeNode(0x18, Buffer.from(s, "ascii")); }
|
|
28
|
+
function _algId(oid, n) { return n ? asn1.writeSequence([asn1.writeOid(oid), asn1.writeNull()]) : asn1.writeSequence([asn1.writeOid(oid)]); }
|
|
29
|
+
function _certName(cn) { return asn1.writeSequence([asn1.writeSet([asn1.writeSequence([asn1.writeOid("2.5.4.3"), asn1.writeUtf8String(cn)])])]); }
|
|
30
|
+
|
|
31
|
+
function _makeTsaCert() {
|
|
32
|
+
var kp = nodeCrypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
33
|
+
var spki = kp.publicKey.export({ type: "spki", format: "der" });
|
|
34
|
+
var version = asn1.writeContextExplicit(0, asn1.writeInteger(Buffer.from([2])));
|
|
35
|
+
var serial = asn1.writeInteger(Buffer.from([0x2a]));
|
|
36
|
+
var sigAlgId = _algId("1.2.840.113549.1.1.11", true);
|
|
37
|
+
var name = _certName("Test TSA");
|
|
38
|
+
var now = Date.now();
|
|
39
|
+
var validity = asn1.writeSequence([_utcTime(new Date(now - 86400000)), _utcTime(new Date(now + 86400000 * 3650))]);
|
|
40
|
+
var ekuChildren = [asn1.writeOid("2.5.29.37"), asn1.writeBoolean(true),
|
|
41
|
+
asn1.writeOctetString(asn1.writeSequence([asn1.writeOid(ID_KP_TIMESTAMPING)]))];
|
|
42
|
+
var exts = [asn1.writeSequence(ekuChildren)];
|
|
43
|
+
var children = [version, serial, sigAlgId, name, validity, name, spki, asn1.writeContextExplicit(3, asn1.writeSequence(exts))];
|
|
44
|
+
var tbs = asn1.writeSequence(children);
|
|
45
|
+
var tbsSig = nodeCrypto.sign("sha256", tbs, kp.privateKey);
|
|
46
|
+
var certDer = asn1.writeSequence([tbs, sigAlgId, asn1.writeBitString(tbsSig, 0)]);
|
|
47
|
+
return { certDer: certDer, key: kp.privateKey, serial: Buffer.from([0x2a]), issuer: name };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Build an X.509 cert for `cn`, signed by `issuer` ({ name, key }) or
|
|
51
|
+
// self-signed (issuer null). Used to assemble a real [leaf, intermediate,
|
|
52
|
+
// root] chain so the identity-assertion chain walk is exercised through
|
|
53
|
+
// an intermediate CA, not only a direct-root / self-signed leaf.
|
|
54
|
+
function _makeCert(cn, issuer) {
|
|
55
|
+
var kp = nodeCrypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
56
|
+
var spki = kp.publicKey.export({ type: "spki", format: "der" });
|
|
57
|
+
var subjName = _certName(cn);
|
|
58
|
+
var issuerName = issuer ? issuer.name : subjName;
|
|
59
|
+
var issuerKey = issuer ? issuer.key : kp.privateKey;
|
|
60
|
+
var sigAlgId = _algId("1.2.840.113549.1.1.11", true);
|
|
61
|
+
var version = asn1.writeContextExplicit(0, asn1.writeInteger(Buffer.from([2])));
|
|
62
|
+
var serial = asn1.writeInteger(Buffer.from([0x2b]));
|
|
63
|
+
var now = Date.now();
|
|
64
|
+
var validity = asn1.writeSequence([_utcTime(new Date(now - 86400000)), _utcTime(new Date(now + 86400000 * 3650))]);
|
|
65
|
+
var tbs = asn1.writeSequence([version, serial, sigAlgId, issuerName, validity, subjName, spki]);
|
|
66
|
+
var tbsSig = nodeCrypto.sign("sha256", tbs, issuerKey);
|
|
67
|
+
var certDer = asn1.writeSequence([tbs, sigAlgId, asn1.writeBitString(tbsSig, 0)]);
|
|
68
|
+
return { name: subjName, key: kp.privateKey, pem: new nodeCrypto.X509Certificate(certDer).toString() };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _makeTsToken(o) {
|
|
72
|
+
var tstChildren = [
|
|
73
|
+
asn1.writeInteger(Buffer.from([1])),
|
|
74
|
+
asn1.writeOid("1.2.3.4.1"),
|
|
75
|
+
asn1.writeSequence([_algId(OID_SHA512, true), asn1.writeOctetString(o.imprintHash)]),
|
|
76
|
+
asn1.writeInteger(Buffer.from([0x05])),
|
|
77
|
+
_genTime(new Date()),
|
|
78
|
+
];
|
|
79
|
+
if (o.nonce) tstChildren.push(asn1.writeInteger(o.nonce));
|
|
80
|
+
var tstInfo = asn1.writeSequence(tstChildren);
|
|
81
|
+
var msgDigest = nodeCrypto.createHash("sha512").update(tstInfo).digest();
|
|
82
|
+
var ctAttr = asn1.writeSequence([asn1.writeOid(OID_CONTENT_TYPE_ATTR), asn1.writeSet([asn1.writeOid(ID_CT_TST_INFO)])]);
|
|
83
|
+
var mdAttr = asn1.writeSequence([asn1.writeOid(OID_MESSAGE_DIGEST_ATTR), asn1.writeSet([asn1.writeOctetString(msgDigest)])]);
|
|
84
|
+
var signedSet = asn1.writeSet([ctAttr, mdAttr]);
|
|
85
|
+
var sig = nodeCrypto.sign("sha512", signedSet, o.key);
|
|
86
|
+
var signedAttrsImplicit = Buffer.concat([Buffer.from([0xa0]), signedSet.slice(1)]);
|
|
87
|
+
var sid = asn1.writeSequence([o.issuer, asn1.writeInteger(o.serial)]);
|
|
88
|
+
var signerInfo = asn1.writeSequence([
|
|
89
|
+
asn1.writeInteger(Buffer.from([1])), sid, _algId(OID_SHA512, true), signedAttrsImplicit,
|
|
90
|
+
_algId(OID_RSA_ENCRYPTION, true), asn1.writeOctetString(sig),
|
|
91
|
+
]);
|
|
92
|
+
var encap = asn1.writeSequence([asn1.writeOid(ID_CT_TST_INFO), asn1.writeContextExplicit(0, asn1.writeOctetString(tstInfo))]);
|
|
93
|
+
var certsField = asn1.writeContextImplicit(0, o.certDer, { constructed: true });
|
|
94
|
+
var signedData = asn1.writeSequence([
|
|
95
|
+
asn1.writeInteger(Buffer.from([3])), asn1.writeSet([_algId(OID_SHA512, true)]),
|
|
96
|
+
encap, certsField, asn1.writeSet([signerInfo]),
|
|
97
|
+
]);
|
|
98
|
+
return asn1.writeSequence([asn1.writeOid(OID_SIGNED_DATA), asn1.writeContextExplicit(0, signedData)]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Drive a full signCose request→TSA→attach→verify round trip and return
|
|
102
|
+
// { attached, verifyResult, anchorPem, nonce }.
|
|
103
|
+
function _timestampedRoundTrip(manifest, pair) {
|
|
104
|
+
var req = b.contentCredentials.signCose(manifest, {
|
|
105
|
+
privateKeyPem: pair.privateKey, alg: "ml-dsa-87", timestamp: {}, audit: false,
|
|
106
|
+
});
|
|
107
|
+
var digest = nodeCrypto.createHash("sha512").update(req.timestampRequest.toBeSigned).digest();
|
|
108
|
+
var cert = _makeTsaCert();
|
|
109
|
+
var token = _makeTsToken({
|
|
110
|
+
certDer: cert.certDer, key: cert.key, issuer: cert.issuer, serial: cert.serial,
|
|
111
|
+
imprintHash: digest, nonce: req.timestampRequest.nonce,
|
|
112
|
+
});
|
|
113
|
+
var attached = b.contentCredentials.signCose(manifest, {
|
|
114
|
+
privateKeyPem: pair.privateKey, alg: "ml-dsa-87",
|
|
115
|
+
timestamp: { token: token, signature: req.timestampRequest.signature }, audit: false,
|
|
116
|
+
});
|
|
117
|
+
var anchorPem = new nodeCrypto.X509Certificate(cert.certDer).toString();
|
|
118
|
+
return { req: req, attached: attached, cert: cert, token: token, anchorPem: anchorPem };
|
|
119
|
+
}
|
|
9
120
|
|
|
10
121
|
async function run() {
|
|
11
122
|
check("build is fn", typeof b.contentCredentials.build === "function");
|
|
@@ -79,16 +190,192 @@ async function run() {
|
|
|
79
190
|
var cose = b.contentCredentials.signCose(manifest2, {
|
|
80
191
|
privateKeyPem: pair.privateKey,
|
|
81
192
|
alg: "ml-dsa-87",
|
|
193
|
+
timestamp: false,
|
|
194
|
+
timestampOptOutReason: "interop fixture — no TSA in this test",
|
|
82
195
|
});
|
|
83
196
|
check("signCose: returns coseSign1 Buffer", Buffer.isBuffer(cose.coseSign1));
|
|
84
197
|
check("signCose: alg echoed", cose.alg === "ml-dsa-87");
|
|
85
198
|
check("signCose: CBOR tag 18 (COSE_Sign1)", cose.coseSign1[0] === 0xD2);
|
|
86
199
|
check("signCose: array of 4 elements", cose.coseSign1[1] === 0x84);
|
|
200
|
+
check("signCose: not timestamped on opt-out", cose.timestamped === false);
|
|
87
201
|
|
|
88
202
|
threw = null;
|
|
89
|
-
try { b.contentCredentials.signCose(manifest2, { privateKeyPem: pair.privateKey, alg: "unknown" }); }
|
|
203
|
+
try { b.contentCredentials.signCose(manifest2, { privateKeyPem: pair.privateKey, alg: "unknown", timestamp: false, timestampOptOutReason: "x" }); }
|
|
90
204
|
catch (e) { threw = e; }
|
|
91
205
|
check("signCose: unknown alg refused", threw && threw.code === "content-credentials/bad-alg");
|
|
206
|
+
|
|
207
|
+
// ---- v0.14.11: sigTst2 timestamp + verifyCose fail-closed ----
|
|
208
|
+
check("verifyCose is fn", typeof b.contentCredentials.verifyCose === "function");
|
|
209
|
+
|
|
210
|
+
// Timestamping is fail-closed: a missing timestamp opt THROWS.
|
|
211
|
+
threw = null;
|
|
212
|
+
try { b.contentCredentials.signCose(manifest2, { privateKeyPem: pair.privateKey, alg: "ml-dsa-87" }); }
|
|
213
|
+
catch (e) { threw = e; }
|
|
214
|
+
check("signCose: missing timestamp opt refused", threw && threw.code === "content-credentials/timestamp-required");
|
|
215
|
+
|
|
216
|
+
// Explicit opt-out requires a recorded reason.
|
|
217
|
+
threw = null;
|
|
218
|
+
try { b.contentCredentials.signCose(manifest2, { privateKeyPem: pair.privateKey, alg: "ml-dsa-87", timestamp: false }); }
|
|
219
|
+
catch (e) { threw = e; }
|
|
220
|
+
check("signCose: timestamp:false without reason refused", threw && threw.code === "TIMESTAMP_OPT_OUT_NO_REASON");
|
|
221
|
+
|
|
222
|
+
// Request-builder mode returns a TSA query + the signature to re-pin.
|
|
223
|
+
var reqOnly = b.contentCredentials.signCose(manifest2, {
|
|
224
|
+
privateKeyPem: pair.privateKey, alg: "ml-dsa-87", timestamp: {}, audit: false,
|
|
225
|
+
});
|
|
226
|
+
check("signCose request mode: not timestamped yet", reqOnly.timestamped === false);
|
|
227
|
+
check("signCose request mode: returns DER request", Buffer.isBuffer(reqOnly.timestampRequest.der));
|
|
228
|
+
check("signCose request mode: returns signature to re-pin", typeof reqOnly.timestampRequest.signature === "string");
|
|
229
|
+
check("signCose request mode: hashAlg default SHA-512", reqOnly.timestampRequest.hashAlg === "SHA-512");
|
|
230
|
+
|
|
231
|
+
// Bad hashAlg refused.
|
|
232
|
+
threw = null;
|
|
233
|
+
try { b.contentCredentials.signCose(manifest2, { privateKeyPem: pair.privateKey, alg: "ml-dsa-87", timestamp: { hashAlg: "MD5" } }); }
|
|
234
|
+
catch (e) { threw = e; }
|
|
235
|
+
check("signCose: bad timestamp hashAlg refused", threw && threw.code === "content-credentials/bad-tst-hash");
|
|
236
|
+
|
|
237
|
+
// Full round trip: request → mint token → attach → verify (timestamp valid).
|
|
238
|
+
var rt = _timestampedRoundTrip(manifest2, pair);
|
|
239
|
+
check("signCose attach mode: timestamped", rt.attached.timestamped === true);
|
|
240
|
+
check("signCose attach mode: sigTst2 present grows the COSE bytes", rt.attached.coseSign1.length > cose.coseSign1.length);
|
|
241
|
+
|
|
242
|
+
var v3 = b.contentCredentials.verifyCose(rt.attached.coseSign1, pair.publicKey, {
|
|
243
|
+
timestampNonce: rt.req.timestampRequest.nonce, timestampTrustAnchorsPem: [rt.anchorPem], audit: false,
|
|
244
|
+
});
|
|
245
|
+
check("verifyCose: signature valid", v3.valid === true);
|
|
246
|
+
check("verifyCose: timestamp valid (routed through tsa.verifyToken)", v3.timestamp && v3.timestamp.valid === true);
|
|
247
|
+
check("verifyCose: timestamp genTime is a Date", v3.timestamp.genTime instanceof Date);
|
|
248
|
+
check("verifyCose: alg surfaced", v3.alg === "ml-dsa-87");
|
|
249
|
+
check("verifyCose: claims recovered", v3.claims && v3.claims.content && v3.claims.content.id === "img-002");
|
|
250
|
+
|
|
251
|
+
// verifyCose NEVER throws — bad inputs return { valid:false }.
|
|
252
|
+
var vNoBuf = b.contentCredentials.verifyCose("not a buffer", pair.publicKey, { audit: false });
|
|
253
|
+
check("verifyCose: non-buffer fails closed (no throw)", vNoBuf.valid === false && vNoBuf.reason === "cose-not-buffer");
|
|
254
|
+
var vNoKey = b.contentCredentials.verifyCose(rt.attached.coseSign1, "", { audit: false });
|
|
255
|
+
check("verifyCose: missing key fails closed", vNoKey.valid === false && vNoKey.reason === "public-key-required");
|
|
256
|
+
|
|
257
|
+
// requireTimestamp default true refuses a token-less COSE_Sign1.
|
|
258
|
+
var vReq = b.contentCredentials.verifyCose(cose.coseSign1, pair.publicKey, { audit: false });
|
|
259
|
+
check("verifyCose: token-less refused by default", vReq.valid === false && vReq.reason === "timestamp-required");
|
|
260
|
+
var vReqOff = b.contentCredentials.verifyCose(cose.coseSign1, pair.publicKey, { requireTimestamp: false, audit: false });
|
|
261
|
+
check("verifyCose: requireTimestamp:false accepts token-less", vReqOff.valid === true);
|
|
262
|
+
|
|
263
|
+
// Tampered payload byte → signature mismatch, fail-closed.
|
|
264
|
+
var tamperedCose = Buffer.from(rt.attached.coseSign1); tamperedCose[tamperedCose.length - 3] ^= 0xff;
|
|
265
|
+
var vTamper = b.contentCredentials.verifyCose(tamperedCose, pair.publicKey, { audit: false });
|
|
266
|
+
check("verifyCose: tampered token fails closed", vTamper.valid === false);
|
|
267
|
+
|
|
268
|
+
// Wrong nonce on an otherwise-valid token → tsa-derived timestamp failure,
|
|
269
|
+
// surfaced as a fail-closed verdict (NOT a throw, NOT a chain-only pass).
|
|
270
|
+
var vNonce = b.contentCredentials.verifyCose(rt.attached.coseSign1, pair.publicKey, {
|
|
271
|
+
timestampNonce: nodeCrypto.randomBytes(8), audit: false,
|
|
272
|
+
});
|
|
273
|
+
check("verifyCose: wrong timestamp nonce fails closed via tsa.verifyToken",
|
|
274
|
+
vNonce.valid === false && /^timestamp-invalid:/.test(vNonce.reason));
|
|
275
|
+
|
|
276
|
+
// A cryptographically valid COSE_Sign1 over a payload that is NOT a
|
|
277
|
+
// complete SB-942 manifest must NOT verify as a content credential —
|
|
278
|
+
// verifyCose re-runs the required-field gate that verify() applies.
|
|
279
|
+
var partialCose = b.contentCredentials.signCose({ provider: { name: "x" }, content: { id: "y" } }, {
|
|
280
|
+
privateKeyPem: pair.privateKey, alg: "ml-dsa-87",
|
|
281
|
+
timestamp: false, timestampOptOutReason: "test fixture", audit: false,
|
|
282
|
+
});
|
|
283
|
+
var vPartial = b.contentCredentials.verifyCose(partialCose.coseSign1, pair.publicKey, {
|
|
284
|
+
requireTimestamp: false, audit: false,
|
|
285
|
+
});
|
|
286
|
+
check("verifyCose: incomplete manifest rejected (missing-required)",
|
|
287
|
+
vPartial.valid === false && vPartial.reason.indexOf("missing-required") === 0);
|
|
288
|
+
|
|
289
|
+
// ---- v0.14.11: CAWG identity assertion ----
|
|
290
|
+
check("attachIdentityAssertion is fn", typeof b.contentCredentials.attachIdentityAssertion === "function");
|
|
291
|
+
check("verifyIdentityAssertion is fn", typeof b.contentCredentials.verifyIdentityAssertion === "function");
|
|
292
|
+
|
|
293
|
+
var orgPair = b.crypto.generateSigningKeyPair("ml-dsa-87");
|
|
294
|
+
var refs = [{ label: "c2pa.actions", data: { action: "c2pa.created" } }];
|
|
295
|
+
var ia = b.contentCredentials.attachIdentityAssertion({
|
|
296
|
+
binding: "x509",
|
|
297
|
+
subject: { name: "Acme Newsroom", org: "Acme Media", id: "acme-001" },
|
|
298
|
+
referencedAssertions: refs,
|
|
299
|
+
privateKeyPem: orgPair.privateKey,
|
|
300
|
+
audit: false,
|
|
301
|
+
});
|
|
302
|
+
check("attachIdentityAssertion: v1.2 envelope", ia.type === "cawg.identity" && ia.version === "1.2");
|
|
303
|
+
check("attachIdentityAssertion: hash-binds referenced assertions", ia.signer_payload.referenced_assertions.length === 1);
|
|
304
|
+
check("attachIdentityAssertion: assertion is hash-bound (sha3-512)", ia.signer_payload.referenced_assertions[0].alg === "sha3-512");
|
|
305
|
+
check("attachIdentityAssertion: base64 signature", typeof ia.signature === "string" && ia.signature.length > 0);
|
|
306
|
+
|
|
307
|
+
// Self-asserted (no trust anchor) → valid signature, but verified:false.
|
|
308
|
+
var iaSelf = b.contentCredentials.verifyIdentityAssertion(ia, orgPair.publicKey, { referencedAssertions: refs, audit: false });
|
|
309
|
+
check("verifyIdentityAssertion: self-asserted valid", iaSelf.valid === true);
|
|
310
|
+
check("verifyIdentityAssertion: self-asserted NOT verified (no anchor)", iaSelf.verified === false && iaSelf.reason === "no-trust-anchor");
|
|
311
|
+
|
|
312
|
+
// x509 chained to a supplied trust anchor → verified:true. Reuse the
|
|
313
|
+
// mock TSA cert as a self-signed leaf==anchor.
|
|
314
|
+
var orgCert = _makeTsaCert();
|
|
315
|
+
var orgPem = new nodeCrypto.X509Certificate(orgCert.certDer).toString();
|
|
316
|
+
var iaTrusted = b.contentCredentials.verifyIdentityAssertion(ia, orgPair.publicKey, {
|
|
317
|
+
referencedAssertions: refs, identityCertChainPem: orgPem, identityTrustAnchorsPem: orgPem, audit: false,
|
|
318
|
+
});
|
|
319
|
+
check("verifyIdentityAssertion: x509 chained → verified", iaTrusted.valid === true && iaTrusted.verified === true);
|
|
320
|
+
|
|
321
|
+
// x509 chain through an INTERMEDIATE CA: chain [leaf, intermediate]
|
|
322
|
+
// with the root as the trust anchor must verify — the chain walk uses
|
|
323
|
+
// chain[1..], not only a direct leaf-vs-anchor test.
|
|
324
|
+
var caRoot = _makeCert("CAWG Test Root", null);
|
|
325
|
+
var caInter = _makeCert("CAWG Test Intermediate", { name: caRoot.name, key: caRoot.key });
|
|
326
|
+
var caLeaf = _makeCert("Acme Newsroom Leaf", { name: caInter.name, key: caInter.key });
|
|
327
|
+
var iaChain = b.contentCredentials.verifyIdentityAssertion(ia, orgPair.publicKey, {
|
|
328
|
+
referencedAssertions: refs,
|
|
329
|
+
identityCertChainPem: [caLeaf.pem, caInter.pem],
|
|
330
|
+
identityTrustAnchorsPem: caRoot.pem, audit: false,
|
|
331
|
+
});
|
|
332
|
+
check("verifyIdentityAssertion: [leaf,intermediate]→root anchor verified (walks intermediates)",
|
|
333
|
+
iaChain.valid === true && iaChain.verified === true);
|
|
334
|
+
// The same chain against an unrelated anchor must NOT verify.
|
|
335
|
+
var caRogue = _makeCert("Rogue Root", null);
|
|
336
|
+
var iaWrongAnchor = b.contentCredentials.verifyIdentityAssertion(ia, orgPair.publicKey, {
|
|
337
|
+
referencedAssertions: refs,
|
|
338
|
+
identityCertChainPem: [caLeaf.pem, caInter.pem],
|
|
339
|
+
identityTrustAnchorsPem: caRogue.pem, audit: false,
|
|
340
|
+
});
|
|
341
|
+
check("verifyIdentityAssertion: chain to unrelated anchor → not verified",
|
|
342
|
+
iaWrongAnchor.verified === false);
|
|
343
|
+
|
|
344
|
+
// Transplanted referenced assertions → fail closed.
|
|
345
|
+
var iaTransplant = b.contentCredentials.verifyIdentityAssertion(ia, orgPair.publicKey, {
|
|
346
|
+
referencedAssertions: [{ label: "x", data: { other: 1 } }], audit: false,
|
|
347
|
+
});
|
|
348
|
+
check("verifyIdentityAssertion: transplanted assertions fail closed",
|
|
349
|
+
iaTransplant.valid === false && iaTransplant.reason === "assertion-hash-mismatch");
|
|
350
|
+
|
|
351
|
+
// Aggregator path is never verified:true even with an anchor.
|
|
352
|
+
var iaAgg = b.contentCredentials.attachIdentityAssertion({
|
|
353
|
+
binding: "identity-claims-aggregator", subject: { name: "Jane Doe" },
|
|
354
|
+
referencedAssertions: refs, privateKeyPem: orgPair.privateKey, audit: false,
|
|
355
|
+
});
|
|
356
|
+
var iaAggV = b.contentCredentials.verifyIdentityAssertion(iaAgg, orgPair.publicKey, {
|
|
357
|
+
referencedAssertions: refs, identityTrustAnchorsPem: orgPem, audit: false,
|
|
358
|
+
});
|
|
359
|
+
check("verifyIdentityAssertion: aggregator path never verified:true",
|
|
360
|
+
iaAggV.valid === true && iaAggV.verified === false && iaAggV.reason === "aggregator-self-asserted");
|
|
361
|
+
|
|
362
|
+
// Wrong key → signature mismatch, fail closed (no throw).
|
|
363
|
+
var otherPair = b.crypto.generateSigningKeyPair("ml-dsa-87");
|
|
364
|
+
var iaWrongKey = b.contentCredentials.verifyIdentityAssertion(ia, otherPair.publicKey, { referencedAssertions: refs, audit: false });
|
|
365
|
+
check("verifyIdentityAssertion: wrong key fails closed", iaWrongKey.valid === false && iaWrongKey.reason === "signature-mismatch");
|
|
366
|
+
|
|
367
|
+
// Bad binding refused at config time.
|
|
368
|
+
threw = null;
|
|
369
|
+
try { b.contentCredentials.attachIdentityAssertion({ binding: "made-up", subject: { name: "x" }, referencedAssertions: refs, privateKeyPem: orgPair.privateKey }); }
|
|
370
|
+
catch (e) { threw = e; }
|
|
371
|
+
check("attachIdentityAssertion: bad binding refused", threw && threw.code === "content-credentials/bad-identity-binding");
|
|
92
372
|
}
|
|
93
373
|
|
|
94
374
|
module.exports = { run: run };
|
|
375
|
+
|
|
376
|
+
if (require.main === module) {
|
|
377
|
+
run().then(
|
|
378
|
+
function () { console.log("[content-credentials] OK — " + helpers.getChecks() + " checks passed"); },
|
|
379
|
+
function (e) { console.error("FAIL:", (e && e.stack) || e); process.exit(1); }
|
|
380
|
+
);
|
|
381
|
+
}
|
|
@@ -213,6 +213,28 @@ function testDcqlMatch() {
|
|
|
213
213
|
try { b.auth.oid4vp.matchDcql([], { credentials: [] }); }
|
|
214
214
|
catch (e) { threw = /non-empty array/.test(e.message); }
|
|
215
215
|
check("DCQL: empty credentials array refused", threw);
|
|
216
|
+
|
|
217
|
+
// ---- null path-segment = array wildcard (OpenID4VP 1.0 §7.1.1) ----
|
|
218
|
+
function _dcqlValid(path, claimsObj, values) {
|
|
219
|
+
var q = { credentials: [{ id: "c", format: "vc+sd-jwt",
|
|
220
|
+
claims: [values ? { path: path, values: values } : { path: path }] }] };
|
|
221
|
+
return b.auth.oid4vp.matchDcql([{ id: "c", format: "vc+sd-jwt", claims: claimsObj }], q).valid;
|
|
222
|
+
}
|
|
223
|
+
var degrees = { degrees: [{ type: "BachelorDegree" }, { type: "MasterDegree" }] };
|
|
224
|
+
check("DCQL null: any-element type matches", _dcqlValid(["degrees", null, "type"], degrees));
|
|
225
|
+
check("DCQL null: value-match on any element (hit)", _dcqlValid(["degrees", null, "type"], degrees, ["MasterDegree"]));
|
|
226
|
+
check("DCQL null: value-match on any element (miss)", !_dcqlValid(["degrees", null, "type"], degrees, ["PhD"]));
|
|
227
|
+
// null on a non-array node → clean non-match, never throws (holder data, not config)
|
|
228
|
+
var threwNull = false; var nullNonArray = true;
|
|
229
|
+
try { nullNonArray = _dcqlValid(["degrees", null], { degrees: "not-an-array" }); }
|
|
230
|
+
catch (_e) { threwNull = true; }
|
|
231
|
+
check("DCQL null on non-array does not throw", !threwNull);
|
|
232
|
+
check("DCQL null on non-array is a non-match", !nullNonArray);
|
|
233
|
+
// null at the leaf → match iff the array holds any defined element
|
|
234
|
+
check("DCQL null at leaf matches non-empty array", _dcqlValid(["tags", null], { tags: ["a", "b"] }));
|
|
235
|
+
check("DCQL null at leaf misses empty array", !_dcqlValid(["tags", null], { tags: [] }));
|
|
236
|
+
// nested null wildcards recurse to arbitrary depth
|
|
237
|
+
check("DCQL nested null wildcards match", _dcqlValid(["a", null, "b", null], { a: [{ b: [1, 2] }] }));
|
|
216
238
|
}
|
|
217
239
|
|
|
218
240
|
// ---- OID4VCI issuer config -------------------------------------------
|
|
@@ -133,6 +133,66 @@ function testLocalPartWithEquals() {
|
|
|
133
133
|
srs.reverse(rw) === "foo=bar@bob.com");
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
function testSrs1DoubleForward() {
|
|
137
|
+
// Two distinct forwarders with distinct secrets.
|
|
138
|
+
var srsA = b.mail.srs.create({ secret: b.crypto.generateToken(32), forwarderDomain: "fwd1.example" });
|
|
139
|
+
var srsB = b.mail.srs.create({ secret: b.crypto.generateToken(32), forwarderDomain: "fwd2.example" });
|
|
140
|
+
|
|
141
|
+
// Hop 1: alice@bob.com → fwd1 (SRS0).
|
|
142
|
+
var rw0 = srsA.rewrite("alice@bob.com");
|
|
143
|
+
check("srs1: first hop is SRS0 at fwd1", rw0.startsWith("SRS0=") && rw0.endsWith("@fwd1.example"));
|
|
144
|
+
|
|
145
|
+
// Hop 2: fwd2 chains the already-SRS0 address as SRS1.
|
|
146
|
+
var rw1 = srsB.srs1Rewrite(rw0);
|
|
147
|
+
check("srs1: produces SRS1=", rw1.startsWith("SRS1="));
|
|
148
|
+
check("srs1: ends at this forwarder (fwd2)", rw1.endsWith("@fwd2.example"));
|
|
149
|
+
check("srs1: embeds the SRS0 originator", rw1.indexOf("fwd1.example") !== -1);
|
|
150
|
+
check("srs1: has '==' separator", rw1.indexOf("==") > 0);
|
|
151
|
+
|
|
152
|
+
// reverse() at fwd2 unwraps exactly one hop → the originator's SRS0 (== rw0).
|
|
153
|
+
var back = srsB.reverse(rw1);
|
|
154
|
+
check("srs1: reverse unwraps one hop to the originator SRS0", back === rw0);
|
|
155
|
+
// fwd1 then reverses that SRS0 → original sender (full chain).
|
|
156
|
+
check("srs1: two-hop chain recovers the original sender", srsA.reverse(back) === "alice@bob.com");
|
|
157
|
+
|
|
158
|
+
// Tampered SRS1 tag → bad-tag.
|
|
159
|
+
var tampered = "SRS1=ZZZZ=" + rw1.slice(rw1.indexOf("=", 5) + 1);
|
|
160
|
+
var threwTag = null;
|
|
161
|
+
try { srsB.reverse(tampered); } catch (e) { threwTag = e; }
|
|
162
|
+
check("srs1: tampered tag refused", threwTag && /srs\/bad-tag/.test(threwTag.code || ""));
|
|
163
|
+
|
|
164
|
+
// rw1 is addressed to fwd2; fwd1 reversing it → wrong-forwarder.
|
|
165
|
+
var threwFwd = null;
|
|
166
|
+
try { srsA.reverse(rw1); } catch (e) { threwFwd = e; }
|
|
167
|
+
check("srs1: bounce at the wrong forwarder refused", threwFwd && /srs\/wrong-forwarder/.test(threwFwd.code || ""));
|
|
168
|
+
|
|
169
|
+
// srs1Rewrite on a plain (non-SRS) address → not-srs0.
|
|
170
|
+
var threwPlain = null;
|
|
171
|
+
try { srsB.srs1Rewrite("alice@bob.com"); } catch (e) { threwPlain = e; }
|
|
172
|
+
check("srs1Rewrite: plain address refused", threwPlain && /srs\/not-srs0/.test(threwPlain.code || ""));
|
|
173
|
+
|
|
174
|
+
// srs1Rewrite empty / no-@ → bad-address.
|
|
175
|
+
var threwEmpty = null;
|
|
176
|
+
try { srsB.srs1Rewrite(""); } catch (e) { threwEmpty = e; }
|
|
177
|
+
check("srs1Rewrite: empty refused", threwEmpty && /srs\/bad-address/.test(threwEmpty.code || ""));
|
|
178
|
+
|
|
179
|
+
// reverse of a malformed SRS1 (no '==' separator) → malformed.
|
|
180
|
+
var threwMal = null;
|
|
181
|
+
try { srsB.reverse("SRS1=abc@fwd2.example"); } catch (e) { threwMal = e; }
|
|
182
|
+
check("srs1: malformed (no '==') refused", threwMal && /srs\/malformed/.test(threwMal.code || ""));
|
|
183
|
+
|
|
184
|
+
// Third hop: fwd3 re-wraps fwd2's SRS1. The bounce target stays the
|
|
185
|
+
// SRS0 originator (fwd1), so it still routes straight back, never to
|
|
186
|
+
// the intermediate fwd2.
|
|
187
|
+
var srsC = b.mail.srs.create({ secret: b.crypto.generateToken(32), forwarderDomain: "fwd3.example" });
|
|
188
|
+
var rw2 = srsC.srs1Rewrite(rw1);
|
|
189
|
+
check("srs1: third hop is SRS1 at fwd3", rw2.startsWith("SRS1=") && rw2.endsWith("@fwd3.example"));
|
|
190
|
+
check("srs1: third hop keeps the originator (fwd1)", rw2.indexOf("fwd1.example") !== -1);
|
|
191
|
+
var back2 = srsC.reverse(rw2);
|
|
192
|
+
check("srs1: third hop unwraps straight back to the originator SRS0", back2 === rw0);
|
|
193
|
+
check("srs1: full three-hop chain recovers the original sender", srsA.reverse(back2) === "alice@bob.com");
|
|
194
|
+
}
|
|
195
|
+
|
|
136
196
|
async function run() {
|
|
137
197
|
testSurface();
|
|
138
198
|
testRoundTrip();
|
|
@@ -142,6 +202,7 @@ async function run() {
|
|
|
142
202
|
testBadShape();
|
|
143
203
|
testForwarderDomainBinding();
|
|
144
204
|
testLocalPartWithEquals();
|
|
205
|
+
testSrs1DoubleForward();
|
|
145
206
|
}
|
|
146
207
|
|
|
147
208
|
module.exports = { run: run };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// b.privacy.vendorReview + b.privacy.listVendorReviewClauses — FERPA / SOPIPA
|
|
3
|
+
// EdTech vendor-review attestation builder (pure; no DB).
|
|
4
|
+
|
|
5
|
+
var helpers = require("../helpers");
|
|
6
|
+
var b = helpers.b;
|
|
7
|
+
var check = helpers.check;
|
|
8
|
+
|
|
9
|
+
var ALL_TRUE = {
|
|
10
|
+
educationalPurposeOnly: true, noTargetedAdvertising: true, noCommercialProfiling: true,
|
|
11
|
+
noSaleOfStudentData: true, securitySafeguards: true, deletionOnRequest: true,
|
|
12
|
+
subProcessorsCurrent: true, breachNotification: true, schoolOfficialDesignation: true,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function run() {
|
|
16
|
+
check("vendorReview is a function", typeof b.privacy.vendorReview === "function");
|
|
17
|
+
check("listVendorReviewClauses is a function", typeof b.privacy.listVendorReviewClauses === "function");
|
|
18
|
+
check("PrivacyError exposed on b.frameworkError", typeof b.frameworkError.PrivacyError === "function");
|
|
19
|
+
check("b.privacy.PrivacyError is the same constructor",
|
|
20
|
+
b.privacy.PrivacyError === b.frameworkError.PrivacyError);
|
|
21
|
+
|
|
22
|
+
var clauses = b.privacy.listVendorReviewClauses();
|
|
23
|
+
check("clauses: frozen array", Array.isArray(clauses) && Object.isFrozen(clauses));
|
|
24
|
+
check("clauses: >= 9 entries", clauses.length >= 9);
|
|
25
|
+
check("clauses: each has id + citation + required boolean",
|
|
26
|
+
clauses.every(function (c) { return typeof c.id === "string" && typeof c.citation === "string" && typeof c.required === "boolean"; }));
|
|
27
|
+
check("clauses: educationalPurposeOnly is required",
|
|
28
|
+
clauses.some(function (c) { return c.id === "educationalPurposeOnly" && c.required === true; }));
|
|
29
|
+
|
|
30
|
+
// All required clauses true → attested, no gaps, 365-day next review clock.
|
|
31
|
+
var reviewedAt = 1700000000000;
|
|
32
|
+
var ok = b.privacy.vendorReview({ vendorName: "Acme LMS", reviewedAt: reviewedAt, clauses: ALL_TRUE });
|
|
33
|
+
check("vendorReview: attested when all required true", ok.attested === true);
|
|
34
|
+
check("vendorReview: no gaps", ok.gaps.length === 0);
|
|
35
|
+
check("vendorReview: nextReviewDueAt = reviewedAt + 365 days",
|
|
36
|
+
ok.nextReviewDueAt === reviewedAt + b.constants.TIME.days(365));
|
|
37
|
+
check("vendorReview: report frozen", Object.isFrozen(ok));
|
|
38
|
+
check("vendorReview: vendorName carried", ok.vendorName === "Acme LMS");
|
|
39
|
+
check("vendorReview: coversPeriod is the prior year", ok.coversPeriod.to === reviewedAt && ok.coversPeriod.from === reviewedAt - b.constants.TIME.days(365));
|
|
40
|
+
|
|
41
|
+
// One required clause false → not attested + the gap is named.
|
|
42
|
+
var partial = Object.assign({}, ALL_TRUE, { noSaleOfStudentData: false });
|
|
43
|
+
var gap = b.privacy.vendorReview({ vendorName: "Acme", reviewedAt: reviewedAt, clauses: partial });
|
|
44
|
+
check("vendorReview: not attested on a required gap", gap.attested === false);
|
|
45
|
+
check("vendorReview: gap names the failed clause", gap.gaps.indexOf("noSaleOfStudentData") !== -1);
|
|
46
|
+
|
|
47
|
+
// The one optional clause omitted still attests.
|
|
48
|
+
check("vendorReview: optional clause omitted still attests",
|
|
49
|
+
b.privacy.vendorReview({ vendorName: "A", reviewedAt: reviewedAt, clauses: ALL_TRUE }).attested === true);
|
|
50
|
+
|
|
51
|
+
// Config-time throws (rule §5 — entry-point tier).
|
|
52
|
+
function expectCode(label, fn, code) {
|
|
53
|
+
var threw = null;
|
|
54
|
+
try { fn(); } catch (e) { threw = e; }
|
|
55
|
+
check(label, threw && (threw.code || "") === code);
|
|
56
|
+
}
|
|
57
|
+
expectCode("missing vendorName throws",
|
|
58
|
+
function () { b.privacy.vendorReview({ reviewedAt: reviewedAt, clauses: ALL_TRUE }); }, "privacy/bad-vendor");
|
|
59
|
+
expectCode("non-boolean clause value throws",
|
|
60
|
+
function () { b.privacy.vendorReview({ vendorName: "A", reviewedAt: reviewedAt, clauses: { noSaleOfStudentData: "yes" } }); }, "privacy/bad-clause-value");
|
|
61
|
+
expectCode("unknown clause key throws",
|
|
62
|
+
function () { b.privacy.vendorReview({ vendorName: "A", reviewedAt: reviewedAt, clauses: { bogusClause: true } }); }, "privacy/unknown-clause");
|
|
63
|
+
expectCode("missing reviewedAt throws",
|
|
64
|
+
function () { b.privacy.vendorReview({ vendorName: "A", clauses: ALL_TRUE }); }, "privacy/bad-reviewed-at");
|
|
65
|
+
expectCode("non-object opts throws",
|
|
66
|
+
function () { b.privacy.vendorReview("nope"); }, "privacy/bad-opts");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { run: run };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.vaultRotate.rotate — AAD round-trip.
|
|
4
|
+
*
|
|
5
|
+
* Regression for the vault-key rotation orphaning class: a keypair
|
|
6
|
+
* rotation must re-seal, old-root -> new-root,
|
|
7
|
+
* - vault.aad: data cells (registerTable({ aad: true })),
|
|
8
|
+
* - the AAD-bound db.key.enc (deployment-context sealed master key), and
|
|
9
|
+
* - the AAD-bound db.enc envelope (_dbEncAad(dataDir)),
|
|
10
|
+
* so the rotated data dir opens and decrypts under the NEW keypair and no
|
|
11
|
+
* longer under the old one. Before this fix rotate re-sealed only plain
|
|
12
|
+
* `vault:` cells, threw `bad-dbkey` on the AAD-sealed master key, and read
|
|
13
|
+
* db.enc with no AAD — silently orphaning AAD cells while reporting ok.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
var helpers = require("../helpers");
|
|
17
|
+
var b = helpers.b;
|
|
18
|
+
var fs = helpers.fs;
|
|
19
|
+
var os = helpers.os;
|
|
20
|
+
var path = helpers.path;
|
|
21
|
+
var check = helpers.check;
|
|
22
|
+
|
|
23
|
+
var SECRETS_SCHEMA = [{
|
|
24
|
+
name: "secrets",
|
|
25
|
+
columns: { _id: "TEXT PRIMARY KEY", tenantId: "TEXT", secret: "TEXT" },
|
|
26
|
+
sealedFields: ["secret"],
|
|
27
|
+
aad: true,
|
|
28
|
+
rowIdField: "_id",
|
|
29
|
+
schemaVersion: "1",
|
|
30
|
+
}];
|
|
31
|
+
|
|
32
|
+
// Discover every lib module that declares an external AAD_ROTATION descriptor
|
|
33
|
+
// by reading its actual export, so the gate-coverage assertion can't drift as
|
|
34
|
+
// modules are added. backend:"external" means the store lives outside db.enc
|
|
35
|
+
// (an operator-supplied backend) and the rotation pipeline can only refuse and
|
|
36
|
+
// point at the module's reseal hook — so every such module MUST be reachable
|
|
37
|
+
// from rotate's EXTERNAL_AAD_MODULE_LOADERS or a rotation silently orphans it.
|
|
38
|
+
function _discoverExternalAadTables() {
|
|
39
|
+
var libDir = path.join(__dirname, "..", "..", "lib");
|
|
40
|
+
var files = [];
|
|
41
|
+
fs.readdirSync(libDir).forEach(function (name) {
|
|
42
|
+
var full = path.join(libDir, name);
|
|
43
|
+
if (fs.statSync(full).isDirectory()) {
|
|
44
|
+
fs.readdirSync(full).forEach(function (n2) { if (n2.slice(-3) === ".js") files.push(path.join(full, n2)); });
|
|
45
|
+
} else if (name.slice(-3) === ".js") { files.push(full); }
|
|
46
|
+
});
|
|
47
|
+
var tables = [];
|
|
48
|
+
files.forEach(function (f) {
|
|
49
|
+
if (f.replace(/\\/g, "/").indexOf("/vault/rotate.js") !== -1) return; // the gate consumes; it doesn't declare
|
|
50
|
+
var src = fs.readFileSync(f, "utf8");
|
|
51
|
+
if (src.indexOf("AAD_ROTATION") === -1 || src.indexOf("\"external\"") === -1) return;
|
|
52
|
+
var mod;
|
|
53
|
+
try { mod = require(f); } catch (_e) { return; }
|
|
54
|
+
var desc = mod && mod.AAD_ROTATION;
|
|
55
|
+
if (!desc) return;
|
|
56
|
+
(Array.isArray(desc) ? desc : [desc]).forEach(function (d) {
|
|
57
|
+
if (d && d.backend === "external" && d.table) tables.push(d.table);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
return tables;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function _reset() {
|
|
64
|
+
process.env.BLAMEJS_SKIP_NTP_CHECK = "1";
|
|
65
|
+
b.cluster._resetForTest();
|
|
66
|
+
b.audit._resetForTest();
|
|
67
|
+
b.vault._resetForTest();
|
|
68
|
+
b.db._resetForTest();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function run() {
|
|
72
|
+
// Gate-coverage invariant: rotate's external-AAD gate must name every
|
|
73
|
+
// external AAD_ROTATION table any lib module declares. A module that ships
|
|
74
|
+
// the descriptor but isn't wired into EXTERNAL_AAD_MODULE_LOADERS would let
|
|
75
|
+
// rotate() succeed with no acknowledgement, orphaning that store under the
|
|
76
|
+
// retired root (the archive-wrap:tenant-blobs gap caught on PR #293).
|
|
77
|
+
var declaredExternal = _discoverExternalAadTables();
|
|
78
|
+
var gatedExternal = b.vaultRotate._externalAadTables();
|
|
79
|
+
check("found the external AAD_ROTATION modules (idempotency/orchestrator/tenant/snapshot/archive-wrap)",
|
|
80
|
+
declaredExternal.length >= 6);
|
|
81
|
+
var ungated = declaredExternal.filter(function (t) { return gatedExternal.indexOf(t) === -1; });
|
|
82
|
+
check("rotate's external-AAD gate covers every declared external AAD_ROTATION table (ungated: " + ungated.join(", ") + ")",
|
|
83
|
+
ungated.length === 0);
|
|
84
|
+
|
|
85
|
+
var dirNew = fs.mkdtempSync(path.join(os.tmpdir(), "vr-new-"));
|
|
86
|
+
var dirA = fs.mkdtempSync(path.join(os.tmpdir(), "vr-a-"));
|
|
87
|
+
var staging = path.join(os.tmpdir(), "vr-stg-" + process.pid + "-" + Date.now());
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// 1. A fresh, distinct keypair to rotate INTO.
|
|
91
|
+
await _reset();
|
|
92
|
+
await b.vault.init({ dataDir: dirNew, mode: "plaintext" });
|
|
93
|
+
var newKeys = JSON.parse(b.vault.getKeysJson());
|
|
94
|
+
b.vault._resetForTest();
|
|
95
|
+
|
|
96
|
+
// 2. The live deployment: vault + encrypted db + an AAD-bound table
|
|
97
|
+
// carrying one sealed row.
|
|
98
|
+
await _reset();
|
|
99
|
+
await b.vault.init({ dataDir: dirA, mode: "plaintext" });
|
|
100
|
+
var oldKeys = JSON.parse(b.vault.getKeysJson());
|
|
101
|
+
check("old and new keypairs differ", JSON.stringify(oldKeys) !== JSON.stringify(newKeys));
|
|
102
|
+
await b.db.init({ dataDir: dirA, tmpDir: path.join(dirA, "tmpfs"), atRest: "encrypted", auditSigning: false, frameworkTables: false, schema: SECRETS_SCHEMA });
|
|
103
|
+
|
|
104
|
+
var sealed = b.cryptoField.sealRow("secrets", { _id: "sec-1", tenantId: "t1", secret: "top-secret-value" });
|
|
105
|
+
check("AAD seal produced a vault.aad: cell (db.init honored aad:true)",
|
|
106
|
+
typeof sealed.secret === "string" && sealed.secret.indexOf("vault.aad:") === 0);
|
|
107
|
+
b.db.from("secrets").insertOne(sealed);
|
|
108
|
+
await b.db.flushToDisk();
|
|
109
|
+
await b.db.close();
|
|
110
|
+
|
|
111
|
+
// Detect-and-refuse: without acknowledging the external-AAD agent
|
|
112
|
+
// stores, rotate must refuse rather than silently orphan them.
|
|
113
|
+
var refused = null;
|
|
114
|
+
try {
|
|
115
|
+
await b.vaultRotate.rotate({ dataDir: dirA, stagingDir: staging + "-x", oldKeys: oldKeys, newKeys: newKeys, mode: "plaintext" });
|
|
116
|
+
} catch (e) { refused = e; }
|
|
117
|
+
check("rotate refuses unacknowledged external-AAD stores",
|
|
118
|
+
!!refused && refused.code === "vault-rotate/external-aad-unresealed");
|
|
119
|
+
|
|
120
|
+
// 3. Rotate the keypair old -> new.
|
|
121
|
+
// externalAadResealed: true acknowledges the operator-supplied agent
|
|
122
|
+
// stores (idempotency/orchestrator/tenant/snapshot) are handled via
|
|
123
|
+
// their own reseal hooks — this deployment uses none of them.
|
|
124
|
+
var result = await b.vaultRotate.rotate({
|
|
125
|
+
dataDir: dirA, stagingDir: staging, oldKeys: oldKeys, newKeys: newKeys, mode: "plaintext",
|
|
126
|
+
externalAadResealed: true,
|
|
127
|
+
});
|
|
128
|
+
// rotate's own round-trip verify decrypts the staged db.enc with the
|
|
129
|
+
// new keypair and (with oldKeys supplied) regression-checks the old —
|
|
130
|
+
// so ok===true means the AAD cell + db.key.enc + db.enc all rotated.
|
|
131
|
+
check("rotate internal verify ok (AAD cells decrypt under new root, old rejected)",
|
|
132
|
+
!!result.verifyResult && result.verifyResult.ok === true);
|
|
133
|
+
check("rotate processed the sealed row", result.totalRowsProcessed >= 1);
|
|
134
|
+
|
|
135
|
+
// 4. Swap staging -> dataDir and re-open under the NEW keypair: proves
|
|
136
|
+
// db.init's AAD-first open of the rotated db.enc + db.key.enc works
|
|
137
|
+
// and the data cell decrypts end-to-end.
|
|
138
|
+
["db.enc", "db.key.enc", "vault.key"].forEach(function (f) {
|
|
139
|
+
var s = path.join(staging, f);
|
|
140
|
+
if (fs.existsSync(s)) fs.copyFileSync(s, path.join(dirA, f));
|
|
141
|
+
});
|
|
142
|
+
try { fs.rmSync(path.join(dirA, "tmpfs"), { recursive: true, force: true }); } catch (_e) { /* fresh decrypt */ }
|
|
143
|
+
|
|
144
|
+
await _reset();
|
|
145
|
+
await b.vault.init({ dataDir: dirA, mode: "plaintext" });
|
|
146
|
+
check("vault now live under the NEW keypair", JSON.stringify(JSON.parse(b.vault.getKeysJson())) === JSON.stringify(newKeys));
|
|
147
|
+
await b.db.init({ dataDir: dirA, tmpDir: path.join(dirA, "tmpfs"), atRest: "encrypted", auditSigning: false, frameworkTables: false, schema: SECRETS_SCHEMA });
|
|
148
|
+
|
|
149
|
+
var got = b.cryptoField.unsealRow("secrets", b.db.from("secrets").where({ _id: "sec-1" }).first());
|
|
150
|
+
check("AAD cell decrypts after rotation under the new keypair", !!got && got.secret === "top-secret-value");
|
|
151
|
+
await b.db.close();
|
|
152
|
+
} finally {
|
|
153
|
+
await _reset();
|
|
154
|
+
[dirNew, dirA, staging].forEach(function (d) { try { fs.rmSync(d, { recursive: true, force: true }); } catch (_e) { /* cleanup */ } });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { run: run };
|