@blamejs/blamejs-shop 0.4.85 → 0.4.87
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/cart.js +75 -0
- package/lib/order.js +4 -2
- package/lib/security-middleware.js +1 -0
- package/lib/storefront.js +8 -1
- package/lib/vendor/MANIFEST.json +71 -39
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +11 -3
- package/lib/vendor/blamejs/lib/atomic-file.js +34 -0
- package/lib/vendor/blamejs/lib/auth/fido-mds3.js +10 -0
- package/lib/vendor/blamejs/lib/auth/password.js +1 -0
- package/lib/vendor/blamejs/lib/auth/saml.js +11 -3
- package/lib/vendor/blamejs/lib/daemon.js +4 -1
- package/lib/vendor/blamejs/lib/external-db.js +131 -0
- package/lib/vendor/blamejs/lib/graphql-federation.js +25 -15
- package/lib/vendor/blamejs/lib/log-stream-cloudwatch.js +1 -0
- package/lib/vendor/blamejs/lib/log-stream-local.js +14 -1
- package/lib/vendor/blamejs/lib/log-stream-otlp.js +1 -0
- package/lib/vendor/blamejs/lib/log-stream-webhook.js +1 -0
- package/lib/vendor/blamejs/lib/mail-auth.js +69 -14
- package/lib/vendor/blamejs/lib/mail-bimi.js +6 -0
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +10 -0
- package/lib/vendor/blamejs/lib/mail-dkim.js +68 -15
- package/lib/vendor/blamejs/lib/mail.js +39 -0
- package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +6 -2
- package/lib/vendor/blamejs/lib/network-dns-resolver.js +61 -11
- package/lib/vendor/blamejs/lib/network-dns.js +47 -2
- package/lib/vendor/blamejs/lib/network-nts.js +16 -0
- package/lib/vendor/blamejs/lib/network-proxy.js +55 -2
- package/lib/vendor/blamejs/lib/object-store/azure-blob-bucket-ops.js +1 -0
- package/lib/vendor/blamejs/lib/object-store/gcs-bucket-ops.js +1 -0
- package/lib/vendor/blamejs/lib/object-store/http-request.js +4 -0
- package/lib/vendor/blamejs/lib/outbox.js +29 -0
- package/lib/vendor/blamejs/lib/queue-sqs.js +1 -0
- package/lib/vendor/blamejs/lib/request-helpers.js +25 -6
- package/lib/vendor/blamejs/lib/session-device-binding.js +46 -24
- package/lib/vendor/blamejs/lib/session.js +85 -28
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.15.16.json +94 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +51 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-open-append-nofollow.test.js +87 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/external-db-non-atomic-backend.test.js +200 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/fido-mds3-cert-bad-validity.test.js +159 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/graphql-federation.test.js +49 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth-dmarc-policy-failclosed.test.js +139 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-bimi-cert-validity-unparseable.test.js +137 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime-bad-validity.test.js +134 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-dkim-numericdate-failclosed.test.js +155 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-proxy-framing-bounds.test.js +263 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-dns-lookup-timeout-default.test.js +116 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-dns-resolver-timeout.test.js +126 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-nts-handshake-byte-cap.test.js +127 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/saml-subjectconfirmation-notbefore.test.js +238 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/session-destroy-all-store-backed.test.js +128 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/session-device-binding-ipv6-canonical-and-no-store.test.js +202 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/session-device-binding.test.js +18 -9
- package/lib/vendor/blamejs/test/layer-0-primitives/webhook-verify-nonce-atomic.test.js +169 -0
- package/lib/webhooks.js +38 -9
- package/package.json +1 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.auth.saml.sp.verifyResponse — Bearer SubjectConfirmationData
|
|
4
|
+
* NotBefore must fail closed when present-but-unparseable.
|
|
5
|
+
*
|
|
6
|
+
* SAML 2.0 Web Browser SSO Profile §4.1.4.2: a Bearer
|
|
7
|
+
* SubjectConfirmationData MAY carry a `NotBefore` bounding the start of
|
|
8
|
+
* its validity window. When NotBefore is PRESENT, the verifier must
|
|
9
|
+
* honor it. A NotBefore that cannot be parsed (Date.parse → NaN) is an
|
|
10
|
+
* IdP error or a tampered attribute — it cannot establish that the
|
|
11
|
+
* confirmation has begun, so it must NOT be silently dropped, leaving
|
|
12
|
+
* the SCD accepted. It must fail closed (the confirmation is skipped,
|
|
13
|
+
* the verifier tries the next one, and an assertion with no other valid
|
|
14
|
+
* confirmation is refused) — mirroring the NotOnOrAfter line just above
|
|
15
|
+
* and the already-hardened Conditions block.
|
|
16
|
+
*
|
|
17
|
+
* These tests drive the shipped consumer path:
|
|
18
|
+
* sp = b.auth.saml.sp.create({ ... }); sp.verifyResponse(b64, vopts).
|
|
19
|
+
* They mint a self-signed RSA IdP cert, build a genuinely XMLDSig-
|
|
20
|
+
* signed SAML Response (digest + SignatureValue computed through the
|
|
21
|
+
* framework's own b.xmlC14n so the verifier's recomputation matches),
|
|
22
|
+
* and vary ONLY the SubjectConfirmationData's NotBefore between the
|
|
23
|
+
* accepted and refused cases.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
var helpers = require("../helpers");
|
|
27
|
+
var check = helpers.check;
|
|
28
|
+
var b = helpers.b;
|
|
29
|
+
var nodeCrypto = require("node:crypto");
|
|
30
|
+
var c14n = require("../../lib/xml-c14n");
|
|
31
|
+
|
|
32
|
+
var DS = "http://www.w3.org/2000/09/xmldsig#";
|
|
33
|
+
var EXC = "http://www.w3.org/2001/10/xml-exc-c14n#";
|
|
34
|
+
|
|
35
|
+
var IDP_ENTITY_ID = "https://idp.example";
|
|
36
|
+
var SP_ENTITY_ID = "https://sp.example";
|
|
37
|
+
var ACS_URL = "https://sp.example/saml/acs";
|
|
38
|
+
|
|
39
|
+
// Mint a self-signed RSA cert via the vendored @peculiar/x509 bundle.
|
|
40
|
+
// verifyResponse parses idpCertPem with nodeCrypto.createPublicKey and
|
|
41
|
+
// verifies an rsa-sha256 PKCS1 signature, so a real RSA cert + matching
|
|
42
|
+
// private key is required — there is no test bypass of the signature
|
|
43
|
+
// check.
|
|
44
|
+
async function _mintRsaCert(cn) {
|
|
45
|
+
var pki = require("../../lib/vendor/pki.cjs");
|
|
46
|
+
var x509 = pki.x509;
|
|
47
|
+
var keys = await nodeCrypto.webcrypto.subtle.generateKey(
|
|
48
|
+
{ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, // allow:raw-byte-literal — RFC 8301 §3.1 RSA bit floor
|
|
49
|
+
publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
|
|
50
|
+
true, ["sign", "verify"]);
|
|
51
|
+
var now = new Date();
|
|
52
|
+
var cert = await x509.X509CertificateGenerator.createSelfSigned({
|
|
53
|
+
serialNumber: "01",
|
|
54
|
+
name: "CN=" + cn,
|
|
55
|
+
notBefore: now,
|
|
56
|
+
notAfter: new Date(now.getTime() + 365 * 24 * 3600 * 1000), // allow:raw-time-literal — 1y fixture validity
|
|
57
|
+
signingAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
58
|
+
keys: keys,
|
|
59
|
+
});
|
|
60
|
+
var pkcs8 = await nodeCrypto.webcrypto.subtle.exportKey("pkcs8", keys.privateKey);
|
|
61
|
+
var keyPem = "-----BEGIN PRIVATE KEY-----\n" +
|
|
62
|
+
Buffer.from(pkcs8).toString("base64").match(/.{1,64}/g).join("\n") +
|
|
63
|
+
"\n-----END PRIVATE KEY-----\n";
|
|
64
|
+
return { certPem: cert.toString("pem"), keyPem: keyPem };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _isoFromNow(ms) { return new Date(Date.now() + ms).toISOString(); }
|
|
68
|
+
|
|
69
|
+
// Build a SAML Response with an Assertion-level enveloped XMLDSig
|
|
70
|
+
// signature. The Assertion's Signature child is stripped before the
|
|
71
|
+
// digest (the enveloped-signature transform), and both the digest and
|
|
72
|
+
// the SignedInfo are canonicalized through b.xmlC14n so the values
|
|
73
|
+
// match exactly what verifyResponse recomputes.
|
|
74
|
+
function _buildSignedResponse(idp, parts) {
|
|
75
|
+
var assertionId = "_assertion-" + parts.tag;
|
|
76
|
+
var responseId = "_response-" + parts.tag;
|
|
77
|
+
var issueInstant = _isoFromNow(0);
|
|
78
|
+
var subjectConfirmation =
|
|
79
|
+
"<saml:SubjectConfirmation Method=\"" + parts.method + "\">" +
|
|
80
|
+
parts.scd +
|
|
81
|
+
"</saml:SubjectConfirmation>";
|
|
82
|
+
|
|
83
|
+
var assertionInner =
|
|
84
|
+
"<saml:Issuer>" + IDP_ENTITY_ID + "</saml:Issuer>" +
|
|
85
|
+
"SIGNATURE_PLACEHOLDER" +
|
|
86
|
+
"<saml:Subject>" +
|
|
87
|
+
"<saml:NameID Format=\"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress\">" +
|
|
88
|
+
parts.nameId + "</saml:NameID>" +
|
|
89
|
+
subjectConfirmation +
|
|
90
|
+
"</saml:Subject>" +
|
|
91
|
+
(parts.conditions !== undefined ? parts.conditions :
|
|
92
|
+
"<saml:Conditions NotBefore=\"" + _isoFromNow(-5 * 60 * 1000) + // allow:raw-time-literal — 5m skew window
|
|
93
|
+
"\" NotOnOrAfter=\"" + _isoFromNow(5 * 60 * 1000) + "\">" + // allow:raw-time-literal — 5m skew window
|
|
94
|
+
"<saml:AudienceRestriction><saml:Audience>" + SP_ENTITY_ID +
|
|
95
|
+
"</saml:Audience></saml:AudienceRestriction>" +
|
|
96
|
+
"</saml:Conditions>") +
|
|
97
|
+
"<saml:AuthnStatement SessionIndex=\"_sess-1\" AuthnInstant=\"" + issueInstant + "\">" +
|
|
98
|
+
"<saml:AuthnContext><saml:AuthnContextClassRef>" +
|
|
99
|
+
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" +
|
|
100
|
+
"</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement>";
|
|
101
|
+
|
|
102
|
+
var assertionOpen =
|
|
103
|
+
"<saml:Assertion xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" " +
|
|
104
|
+
"ID=\"" + assertionId + "\" Version=\"2.0\" IssueInstant=\"" + issueInstant + "\">";
|
|
105
|
+
var assertionClose = "</saml:Assertion>";
|
|
106
|
+
|
|
107
|
+
// Digest the assertion with no Signature child (enveloped-signature
|
|
108
|
+
// transform output), canonicalized through b.xmlC14n.
|
|
109
|
+
var assertionNoSig = assertionOpen +
|
|
110
|
+
assertionInner.replace("SIGNATURE_PLACEHOLDER", "") + assertionClose;
|
|
111
|
+
var digest = nodeCrypto.createHash("sha256")
|
|
112
|
+
.update(c14n.canonicalize(assertionNoSig)).digest("base64");
|
|
113
|
+
|
|
114
|
+
var signedInfo =
|
|
115
|
+
"<ds:SignedInfo xmlns:ds=\"" + DS + "\">" +
|
|
116
|
+
"<ds:CanonicalizationMethod Algorithm=\"" + EXC + "\"></ds:CanonicalizationMethod>" +
|
|
117
|
+
"<ds:SignatureMethod Algorithm=\"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256\"></ds:SignatureMethod>" +
|
|
118
|
+
"<ds:Reference URI=\"#" + assertionId + "\">" +
|
|
119
|
+
"<ds:Transforms>" +
|
|
120
|
+
"<ds:Transform Algorithm=\"http://www.w3.org/2000/09/xmldsig#enveloped-signature\"></ds:Transform>" +
|
|
121
|
+
"<ds:Transform Algorithm=\"" + EXC + "\"></ds:Transform>" +
|
|
122
|
+
"</ds:Transforms>" +
|
|
123
|
+
"<ds:DigestMethod Algorithm=\"http://www.w3.org/2001/04/xmlenc#sha256\"></ds:DigestMethod>" +
|
|
124
|
+
"<ds:DigestValue>" + digest + "</ds:DigestValue>" +
|
|
125
|
+
"</ds:Reference>" +
|
|
126
|
+
"</ds:SignedInfo>";
|
|
127
|
+
var priv = nodeCrypto.createPrivateKey({ key: idp.keyPem, format: "pem" });
|
|
128
|
+
var sigValue = nodeCrypto.sign("sha256", c14n.canonicalize(signedInfo),
|
|
129
|
+
{ key: priv, padding: nodeCrypto.constants.RSA_PKCS1_PADDING }).toString("base64");
|
|
130
|
+
|
|
131
|
+
var signatureXml =
|
|
132
|
+
"<ds:Signature xmlns:ds=\"" + DS + "\">" + signedInfo +
|
|
133
|
+
"<ds:SignatureValue>" + sigValue + "</ds:SignatureValue></ds:Signature>";
|
|
134
|
+
var assertionFull = assertionOpen +
|
|
135
|
+
assertionInner.replace("SIGNATURE_PLACEHOLDER", signatureXml) + assertionClose;
|
|
136
|
+
|
|
137
|
+
var response =
|
|
138
|
+
"<samlp:Response xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" " +
|
|
139
|
+
"xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" " +
|
|
140
|
+
"ID=\"" + responseId + "\" Version=\"2.0\" IssueInstant=\"" + issueInstant + "\" " +
|
|
141
|
+
"Destination=\"" + ACS_URL + "\">" +
|
|
142
|
+
"<saml:Issuer>" + IDP_ENTITY_ID + "</saml:Issuer>" +
|
|
143
|
+
"<samlp:Status><samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/></samlp:Status>" +
|
|
144
|
+
assertionFull + "</samlp:Response>";
|
|
145
|
+
|
|
146
|
+
return Buffer.from(response, "utf8").toString("base64");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _bearerScd(notBeforeAttr, inResponseTo) {
|
|
150
|
+
return "<saml:SubjectConfirmationData" +
|
|
151
|
+
" NotOnOrAfter=\"" + _isoFromNow(5 * 60 * 1000) + "\"" + // allow:raw-time-literal — 5m future, otherwise-valid window
|
|
152
|
+
notBeforeAttr +
|
|
153
|
+
" Recipient=\"" + ACS_URL + "\"" +
|
|
154
|
+
" InResponseTo=\"" + inResponseTo + "\"/>";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _newSp(idp) {
|
|
158
|
+
return b.auth.saml.sp.create({
|
|
159
|
+
entityId: SP_ENTITY_ID,
|
|
160
|
+
assertionConsumerServiceUrl: ACS_URL,
|
|
161
|
+
idpEntityId: IDP_ENTITY_ID,
|
|
162
|
+
idpSsoUrl: "https://idp.example/sso",
|
|
163
|
+
idpCertPem: idp.certPem,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _verifyThrows(sp, b64, vopts) {
|
|
168
|
+
try { sp.verifyResponse(b64, vopts); return null; }
|
|
169
|
+
catch (e) { return e.code || e.message; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Case 1 — a past (already-valid) NotBefore succeeds. Proves the signer
|
|
173
|
+
// + harness are correct and the fail-closed fix does not break the happy
|
|
174
|
+
// path (an otherwise-acceptable confirmation that has begun).
|
|
175
|
+
async function testBearerPastNotBeforeAccepted() {
|
|
176
|
+
var idp = await _mintRsaCert("idp.example");
|
|
177
|
+
var sp = _newSp(idp);
|
|
178
|
+
var inResponseTo = "_req-past-nb";
|
|
179
|
+
var b64 = _buildSignedResponse(idp, {
|
|
180
|
+
tag: "bearer-past-nb",
|
|
181
|
+
method: "urn:oasis:names:tc:SAML:2.0:cm:bearer",
|
|
182
|
+
nameId: "alice@example.com",
|
|
183
|
+
scd: _bearerScd(" NotBefore=\"" + _isoFromNow(-5 * 60 * 1000) + "\"", inResponseTo), // allow:raw-time-literal — 5m past, already valid
|
|
184
|
+
});
|
|
185
|
+
var info = sp.verifyResponse(b64, { expectedInResponseTo: inResponseTo });
|
|
186
|
+
check("Bearer with past NotBefore verifies", info && typeof info === "object");
|
|
187
|
+
check("Bearer past NotBefore: nameId returned", info.nameId === "alice@example.com");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Case 2 — a NotBefore in the FUTURE (not yet valid) must be refused.
|
|
191
|
+
// This path already fails closed on the unfixed tree (isFinite(nb) is
|
|
192
|
+
// true and nb > now), so it is the harness-correctness control proving
|
|
193
|
+
// the not-yet-valid axis is enforced at all.
|
|
194
|
+
async function testBearerFutureNotBeforeRefused() {
|
|
195
|
+
var idp = await _mintRsaCert("idp.example");
|
|
196
|
+
var sp = _newSp(idp);
|
|
197
|
+
var inResponseTo = "_req-future-nb";
|
|
198
|
+
var b64 = _buildSignedResponse(idp, {
|
|
199
|
+
tag: "bearer-future-nb",
|
|
200
|
+
method: "urn:oasis:names:tc:SAML:2.0:cm:bearer",
|
|
201
|
+
nameId: "alice@example.com",
|
|
202
|
+
scd: _bearerScd(" NotBefore=\"" + _isoFromNow(60 * 60 * 1000) + "\"", inResponseTo), // allow:raw-time-literal — 1h future, not yet valid
|
|
203
|
+
});
|
|
204
|
+
check("Bearer with future NotBefore is refused (not yet valid)",
|
|
205
|
+
_verifyThrows(sp, b64, { expectedInResponseTo: inResponseTo }) === "auth-saml/no-valid-confirmation");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Case 3 — the RED case: a Bearer SubjectConfirmationData with a
|
|
209
|
+
// present-but-UNPARSEABLE NotBefore. On the unfixed tree `isFinite(nb)`
|
|
210
|
+
// is false, so the `&&`-guarded not-yet-valid check is skipped and the
|
|
211
|
+
// SCD is wrongly ACCEPTED. It must fail closed: an unparseable NotBefore
|
|
212
|
+
// cannot establish that the confirmation has begun, so the SCD is
|
|
213
|
+
// skipped and the assertion (with no other valid confirmation) refused.
|
|
214
|
+
async function testBearerUnparseableNotBeforeRefused() {
|
|
215
|
+
var idp = await _mintRsaCert("idp.example");
|
|
216
|
+
var sp = _newSp(idp);
|
|
217
|
+
var inResponseTo = "_req-bad-nb";
|
|
218
|
+
var b64 = _buildSignedResponse(idp, {
|
|
219
|
+
tag: "bearer-bad-nb",
|
|
220
|
+
method: "urn:oasis:names:tc:SAML:2.0:cm:bearer",
|
|
221
|
+
nameId: "alice@example.com",
|
|
222
|
+
scd: _bearerScd(" NotBefore=\"not-a-date\"", inResponseTo),
|
|
223
|
+
});
|
|
224
|
+
check("Bearer unparseable NotBefore is refused (fail-closed)",
|
|
225
|
+
_verifyThrows(sp, b64, { expectedInResponseTo: inResponseTo }) === "auth-saml/no-valid-confirmation");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function run() {
|
|
229
|
+
await testBearerPastNotBeforeAccepted();
|
|
230
|
+
await testBearerFutureNotBeforeRefused();
|
|
231
|
+
await testBearerUnparseableNotBeforeRefused();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (require.main === module) {
|
|
235
|
+
run().then(function () { process.exit(0); })
|
|
236
|
+
.catch(function (e) { console.error(e); process.exit(1); });
|
|
237
|
+
}
|
|
238
|
+
module.exports = { run: run };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.session.destroyAllForUser must succeed for a store-backed-only consumer
|
|
4
|
+
* (b.session.useStore) that never called b.db.init() (#340).
|
|
5
|
+
*
|
|
6
|
+
* A store-backed-only deployment points session data at an isolated localDbThin
|
|
7
|
+
* file and registers the _blamejs_sessions sealed/derived-hash schema directly
|
|
8
|
+
* (so sealRow / lookupHash work) WITHOUT initializing the framework db. Every
|
|
9
|
+
* revoke-all path — logout-everywhere, suspend, delete, role change — calls
|
|
10
|
+
* destroyAllForUser, which deletes the store rows and then raises the stateless
|
|
11
|
+
* valid-from boundary via bump(). The boundary table lives in the framework db,
|
|
12
|
+
* so an uninitialized db threw db/not-initialized (a regression: worked in
|
|
13
|
+
* 0.15.11, broke when bump() was added) and 500'd the revoke even though the
|
|
14
|
+
* store rows were already deleted.
|
|
15
|
+
*
|
|
16
|
+
* RED on the pre-fix tree: destroyAllForUser throws (db/not-initialized rewrapped
|
|
17
|
+
* to MISCONFIGURED). GREEN: it resolves AND the stateless valid-from boundary is
|
|
18
|
+
* honored (routed through the configured store), so b.session.check revokes a
|
|
19
|
+
* token issued before the boundary.
|
|
20
|
+
*
|
|
21
|
+
* Drives the real consumer path through the public b.session surface — no db.init.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
var helpers = require("../helpers");
|
|
25
|
+
var b = helpers.b;
|
|
26
|
+
var check = helpers.check;
|
|
27
|
+
var setupVaultOnly = helpers.setupVaultOnly;
|
|
28
|
+
var teardownVaultOnly = helpers.teardownVaultOnly;
|
|
29
|
+
var fs = require("fs");
|
|
30
|
+
var os = require("os");
|
|
31
|
+
var path = require("path");
|
|
32
|
+
|
|
33
|
+
// Mirror the framework's _blamejs_sessions sealed/derived schema (db.js
|
|
34
|
+
// FRAMEWORK_SCHEMA) so sealRow / lookupHash work without b.db.init().
|
|
35
|
+
function _registerSessionSchema() {
|
|
36
|
+
b.cryptoField.registerTable("_blamejs_sessions", {
|
|
37
|
+
sealedFields: ["userId", "data"],
|
|
38
|
+
derivedHashes: { userIdHash: { from: "userId" } },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function run() {
|
|
43
|
+
var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-das-"));
|
|
44
|
+
var storeFile = path.join(tmpDir, "sessions.db");
|
|
45
|
+
var store = null;
|
|
46
|
+
try {
|
|
47
|
+
// Vault only — NO b.db.init(). This is the store-backed-only deployment.
|
|
48
|
+
await setupVaultOnly(tmpDir);
|
|
49
|
+
b.cluster._resetForTest();
|
|
50
|
+
b.session._resetForTest();
|
|
51
|
+
b.cryptoField.clearForTest();
|
|
52
|
+
_registerSessionSchema();
|
|
53
|
+
|
|
54
|
+
store = b.session.stores.localDbThin({ file: storeFile, audit: false });
|
|
55
|
+
b.session.useStore(store);
|
|
56
|
+
|
|
57
|
+
var s = b.session;
|
|
58
|
+
var uid = "user-340";
|
|
59
|
+
|
|
60
|
+
// Create a real store-backed session so destroyAllForUser has a row to
|
|
61
|
+
// delete (drives sealRow + the store INSERT — the shipped consumer path).
|
|
62
|
+
var created = await s.create({ userId: uid });
|
|
63
|
+
check("create returns a sealed cookie token", typeof created.token === "string" && created.token.length > 0);
|
|
64
|
+
|
|
65
|
+
// A token issued NOW (before any revoke) is valid against the boundary.
|
|
66
|
+
var iatBefore = Date.now();
|
|
67
|
+
check("a token issued before any revoke passes session.check",
|
|
68
|
+
(await s.check(uid, iatBefore)) === true);
|
|
69
|
+
check("a fresh subject's valid-from boundary is 0", (await s.validFrom(uid)) === 0);
|
|
70
|
+
|
|
71
|
+
// The bug: destroyAllForUser deletes the store rows, then bump() routes the
|
|
72
|
+
// valid-from upsert to the framework db (uninitialized) and throws. With the
|
|
73
|
+
// fix it falls back to the configured store and resolves.
|
|
74
|
+
var revoked = null;
|
|
75
|
+
var threw = null;
|
|
76
|
+
try {
|
|
77
|
+
revoked = await s.destroyAllForUser(uid);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
threw = e;
|
|
80
|
+
}
|
|
81
|
+
check("destroyAllForUser resolves for a store-backed-only consumer (no db.init)",
|
|
82
|
+
threw === null);
|
|
83
|
+
if (threw) {
|
|
84
|
+
check(" (pre-fix would throw db/not-initialized → MISCONFIGURED): " +
|
|
85
|
+
(threw.code || threw.message), false);
|
|
86
|
+
}
|
|
87
|
+
check("destroyAllForUser reports the deleted store-row count",
|
|
88
|
+
typeof revoked === "number" && revoked >= 1);
|
|
89
|
+
|
|
90
|
+
// The stateless valid-from boundary was actually raised (routed through the
|
|
91
|
+
// store), not silently dropped — a token issued before it is now revoked.
|
|
92
|
+
var boundary = await s.validFrom(uid);
|
|
93
|
+
check("the stateless valid-from boundary was raised (honored, not dropped)",
|
|
94
|
+
typeof boundary === "number" && boundary >= iatBefore);
|
|
95
|
+
check("a token issued BEFORE the revoke now fails session.check",
|
|
96
|
+
(await s.check(uid, iatBefore - 1000)) === false);
|
|
97
|
+
check("a token issued AFTER the boundary still passes session.check",
|
|
98
|
+
(await s.check(uid, boundary + 5000)) === true);
|
|
99
|
+
|
|
100
|
+
// Monotonicity still holds through the store backend.
|
|
101
|
+
var lower = await s.bump(uid, { epochMs: boundary - 5000 });
|
|
102
|
+
check("a lower-epoch bump is a monotonic no-op through the store",
|
|
103
|
+
lower === boundary);
|
|
104
|
+
|
|
105
|
+
// With NEITHER a framework db NOR a store, bump still fails closed (the
|
|
106
|
+
// boundary is never silently dropped — a real misconfiguration propagates).
|
|
107
|
+
b.session.useStore(null);
|
|
108
|
+
var threwNoStorage = null;
|
|
109
|
+
try {
|
|
110
|
+
await s.bump("user-no-storage");
|
|
111
|
+
} catch (e) { threwNoStorage = e; }
|
|
112
|
+
check("bump fails closed when neither a framework db nor a store exists",
|
|
113
|
+
threwNoStorage !== null);
|
|
114
|
+
} finally {
|
|
115
|
+
try { if (store && store.close) store.close(); } catch (_e) { /* best-effort */ }
|
|
116
|
+
b.session.useStore(null);
|
|
117
|
+
b.session._resetForTest();
|
|
118
|
+
b.cryptoField.clearForTest();
|
|
119
|
+
try { teardownVaultOnly(tmpDir); } catch (_e) { /* best-effort */ }
|
|
120
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_e) { /* best-effort */ }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { run: run };
|
|
125
|
+
if (require.main === module) {
|
|
126
|
+
run().then(function () { console.log("OK"); })
|
|
127
|
+
.catch(function (e) { console.error(e && e.stack ? e.stack : e); process.exit(1); });
|
|
128
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.sessionDeviceBinding — two regressions:
|
|
4
|
+
*
|
|
5
|
+
* (A) IPv6 prefix masking masked the client IP by textual ':'-group slicing
|
|
6
|
+
* with no canonical normalization, so a `::`-shorthand address and its
|
|
7
|
+
* fully-expanded equivalent (2001:db8::1 vs 2001:db8:0:0:0:0:0:1), or a
|
|
8
|
+
* leading-zero-folded group, hashed to DIFFERENT device fingerprints.
|
|
9
|
+
* A roaming user whose proxy reports the address in a different textual
|
|
10
|
+
* form on the next request was logged out on a FALSE drift.
|
|
11
|
+
*
|
|
12
|
+
* (B) #330 — create() refused to construct without a store, so the stateless,
|
|
13
|
+
* store-free fingerprint() (the soft device-binding building block for
|
|
14
|
+
* self-validating tokens) was unreachable through create(). The static
|
|
15
|
+
* b.sessionDeviceBinding.fingerprint(req, opts) and a no-store instance
|
|
16
|
+
* whose fingerprint() works (while bind/verify throw a clear "no store")
|
|
17
|
+
* both make it reachable.
|
|
18
|
+
*
|
|
19
|
+
* Run standalone:
|
|
20
|
+
* node test/layer-0-primitives/session-device-binding-ipv6-canonical-and-no-store.test.js
|
|
21
|
+
* Or via smoke:
|
|
22
|
+
* node test/smoke.js
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
var helpers = require("../helpers");
|
|
26
|
+
var b = helpers.b;
|
|
27
|
+
var check = helpers.check;
|
|
28
|
+
|
|
29
|
+
function _memoryStore() {
|
|
30
|
+
var data = new Map();
|
|
31
|
+
return {
|
|
32
|
+
data: data,
|
|
33
|
+
get: function (k) { return Promise.resolve(data.get(k)); },
|
|
34
|
+
set: function (k, v) { data.set(k, v); return Promise.resolve(); },
|
|
35
|
+
del: function (k) { data.delete(k); return Promise.resolve(); },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _req(remoteAddress) {
|
|
40
|
+
return {
|
|
41
|
+
url: "/x",
|
|
42
|
+
method: "GET",
|
|
43
|
+
headers: {
|
|
44
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel)",
|
|
45
|
+
"accept-language": "en-US,en;q=0.9",
|
|
46
|
+
"accept-encoding": "gzip, br",
|
|
47
|
+
},
|
|
48
|
+
socket: { remoteAddress: remoteAddress },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Two textually-different but semantically-IDENTICAL IPv6 forms of the same
|
|
53
|
+
// address. A canonical masker must collapse them to one fingerprint bucket.
|
|
54
|
+
var IPV6_SHORTHAND = "2001:db8::1";
|
|
55
|
+
var IPV6_EXPANDED = "2001:db8:0:0:0:0:0:1";
|
|
56
|
+
// Leading-zero fold: 2001:0db8 vs 2001:db8 are the same group value.
|
|
57
|
+
var IPV6_LEADING_ZEROS = "2001:0db8::1";
|
|
58
|
+
|
|
59
|
+
// (A) Static fingerprint — equivalent IPv6 forms must hash identically.
|
|
60
|
+
function testStaticFingerprintCanonicalIpv6() {
|
|
61
|
+
var fpShort = b.sessionDeviceBinding.fingerprint(_req(IPV6_SHORTHAND));
|
|
62
|
+
var fpExpanded = b.sessionDeviceBinding.fingerprint(_req(IPV6_EXPANDED));
|
|
63
|
+
var fpLeading = b.sessionDeviceBinding.fingerprint(_req(IPV6_LEADING_ZEROS));
|
|
64
|
+
check("(A) static fp: :: shorthand == fully expanded",
|
|
65
|
+
fpShort.equals(fpExpanded));
|
|
66
|
+
check("(A) static fp: :: shorthand == leading-zero form",
|
|
67
|
+
fpShort.equals(fpLeading));
|
|
68
|
+
// Sanity: a genuinely different /64 still diverges (the mask still binds).
|
|
69
|
+
var fpOther = b.sessionDeviceBinding.fingerprint(_req("2001:db9::1"));
|
|
70
|
+
check("(A) static fp: different /64 still diverges", !fpShort.equals(fpOther));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// (A) Real consumer path — bind() with one textual form, verify() with the
|
|
74
|
+
// equivalent form, on the shipped instance API. Must NOT drift.
|
|
75
|
+
async function testVerifyCanonicalIpv6AcrossForms() {
|
|
76
|
+
var binding = b.sessionDeviceBinding.create({ bindingStore: _memoryStore() });
|
|
77
|
+
var token = "tok_v6";
|
|
78
|
+
await binding.bind(token, _req(IPV6_SHORTHAND));
|
|
79
|
+
|
|
80
|
+
var verdict = await binding.verify(token, _req(IPV6_EXPANDED));
|
|
81
|
+
check("(A) verify ok: bind(::) then verify(expanded)", verdict.ok === true);
|
|
82
|
+
|
|
83
|
+
var verdict2 = await binding.verify(token, _req(IPV6_LEADING_ZEROS));
|
|
84
|
+
check("(A) verify ok: bind(::) then verify(leading-zeros)", verdict2.ok === true);
|
|
85
|
+
|
|
86
|
+
// A different /64 IPv6 still drifts (the prefix check is alive, not disabled).
|
|
87
|
+
var drift = await binding.verify(token, _req("2001:db9::1"));
|
|
88
|
+
check("(A) verify drift: bind(2001:db8::) then verify(2001:db9::)",
|
|
89
|
+
drift.ok === false && drift.reason === "drift");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// (A) The documented `ipPrefixBits: { v4: 0, v6: 0 }` skip-the-IP escape hatch
|
|
93
|
+
// must still skip masking entirely (so a mobile client that switches networks
|
|
94
|
+
// is not logged out).
|
|
95
|
+
async function testIpPrefixBitsZeroSkipsIp() {
|
|
96
|
+
var binding = b.sessionDeviceBinding.create({
|
|
97
|
+
bindingStore: _memoryStore(),
|
|
98
|
+
ipPrefixBits: { v4: 0, v6: 0 },
|
|
99
|
+
});
|
|
100
|
+
var token = "tok_skip";
|
|
101
|
+
await binding.bind(token, _req("2001:db8::1"));
|
|
102
|
+
var across = await binding.verify(token, _req("2001:dead:beef::99"));
|
|
103
|
+
check("(A) ipPrefixBits 0/0: unrelated IPv6 still ok (IP skipped)",
|
|
104
|
+
across.ok === true);
|
|
105
|
+
var binding4 = b.sessionDeviceBinding.create({
|
|
106
|
+
bindingStore: _memoryStore(),
|
|
107
|
+
ipPrefixBits: { v4: 0, v6: 0 },
|
|
108
|
+
});
|
|
109
|
+
await binding4.bind("t4", _req("198.51.100.1"));
|
|
110
|
+
var across4 = await binding4.verify("t4", _req("203.0.113.200"));
|
|
111
|
+
check("(A) ipPrefixBits 0/0: unrelated IPv4 still ok (IP skipped)",
|
|
112
|
+
across4.ok === true);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// (B) #330 — a no-store create() returns an instance whose fingerprint() is
|
|
116
|
+
// usable (pure, no store), while bind/verify/unbind throw a clear "no store
|
|
117
|
+
// configured" error. No fabricated no-op store.
|
|
118
|
+
async function testNoStoreInstanceFingerprintReachable() {
|
|
119
|
+
var binding = b.sessionDeviceBinding.create({}); // no store, no storeInSession
|
|
120
|
+
check("(B) no-store create() returns an object", binding && typeof binding === "object");
|
|
121
|
+
|
|
122
|
+
var fp = binding.fingerprint(_req("2001:db8::1"));
|
|
123
|
+
check("(B) no-store instance fingerprint() returns a 32-byte Buffer",
|
|
124
|
+
Buffer.isBuffer(fp) && fp.length === 32);
|
|
125
|
+
|
|
126
|
+
// It must match the static stateless digest for the same shape (one algorithm).
|
|
127
|
+
var staticFp = b.sessionDeviceBinding.fingerprint(_req("2001:db8::1"));
|
|
128
|
+
check("(B) no-store instance fingerprint() == static fingerprint()",
|
|
129
|
+
fp.equals(staticFp));
|
|
130
|
+
|
|
131
|
+
var bindThrew = false;
|
|
132
|
+
try { await binding.bind("t", _req("2001:db8::1")); }
|
|
133
|
+
catch (e) { bindThrew = e && e.code === "session-device-binding/no-store"; }
|
|
134
|
+
check("(B) no-store bind() throws no-store error", bindThrew);
|
|
135
|
+
|
|
136
|
+
var verifyThrew = false;
|
|
137
|
+
try { await binding.verify("t", _req("2001:db8::1")); }
|
|
138
|
+
catch (e) { verifyThrew = e && e.code === "session-device-binding/no-store"; }
|
|
139
|
+
check("(B) no-store verify() throws no-store error", verifyThrew);
|
|
140
|
+
|
|
141
|
+
var unbindThrew = false;
|
|
142
|
+
try { await binding.unbind("t"); }
|
|
143
|
+
catch (e) { unbindThrew = e && e.code === "session-device-binding/no-store"; }
|
|
144
|
+
check("(B) no-store unbind() throws no-store error", unbindThrew);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// (B) #330 — the static stateless entry point works with no create() at all.
|
|
148
|
+
function testStaticFingerprintNoStore() {
|
|
149
|
+
check("(B) b.sessionDeviceBinding.fingerprint is a function",
|
|
150
|
+
typeof b.sessionDeviceBinding.fingerprint === "function");
|
|
151
|
+
var fp = b.sessionDeviceBinding.fingerprint(_req("203.0.113.7"));
|
|
152
|
+
check("(B) static fingerprint() returns a 32-byte Buffer with no store",
|
|
153
|
+
Buffer.isBuffer(fp) && fp.length === 32);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Codex P2 (#362): routing the IP mask through requestHelpers.ipPrefix must
|
|
157
|
+
// PRESERVE the configured prefix width, not silently force the helper's bare
|
|
158
|
+
// /24 + /64 default. The device-binding IPv6 default is /48; a client roaming
|
|
159
|
+
// within its /48 allocation but across a different /64 must NOT drift. (RED
|
|
160
|
+
// when ipPrefix ignores the width and buckets at /64.)
|
|
161
|
+
async function testConfiguredV6PrefixWidthPreserved() {
|
|
162
|
+
// Default instance (v6 = /48): same /48, DIFFERENT /64 -> same fingerprint.
|
|
163
|
+
var inst = b.sessionDeviceBinding.create({ bindingStore: _memoryStore() });
|
|
164
|
+
var fpA = inst.fingerprint(_req("2001:db8:0:1::5"));
|
|
165
|
+
var fpB = inst.fingerprint(_req("2001:db8:0:2::9")); // same /48, different /64
|
|
166
|
+
check("(P2) default v6=/48: same /48 different /64 -> same fp (not /64)", fpA.equals(fpB));
|
|
167
|
+
var fpC = inst.fingerprint(_req("2001:db8:1::5")); // different /48
|
|
168
|
+
check("(P2) default v6=/48: different /48 still diverges", !fpA.equals(fpC));
|
|
169
|
+
|
|
170
|
+
// Configured wider bucket (v6 = /32): same /32, different /48 -> same fp.
|
|
171
|
+
var wide = b.sessionDeviceBinding.create({ bindingStore: _memoryStore(), ipPrefixBits: { v6: 32 } });
|
|
172
|
+
var wA = wide.fingerprint(_req("2001:db8:1::1"));
|
|
173
|
+
var wB = wide.fingerprint(_req("2001:db8:99::1")); // same /32, different /48
|
|
174
|
+
check("(P2) configured v6=/32: same /32 different /48 -> same fp (width honored)", wA.equals(wB));
|
|
175
|
+
|
|
176
|
+
// Configured tighter bucket (v6 = /64): same /48 different /64 -> DIVERGES.
|
|
177
|
+
var tight = b.sessionDeviceBinding.create({ bindingStore: _memoryStore(), ipPrefixBits: { v6: 64 } });
|
|
178
|
+
var tA = tight.fingerprint(_req("2001:db8:0:1::5"));
|
|
179
|
+
var tB = tight.fingerprint(_req("2001:db8:0:2::9")); // same /48, different /64
|
|
180
|
+
check("(P2) configured v6=/64: same /48 different /64 -> diverges (width honored)", !tA.equals(tB));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function run() {
|
|
184
|
+
testStaticFingerprintCanonicalIpv6();
|
|
185
|
+
await testVerifyCanonicalIpv6AcrossForms();
|
|
186
|
+
await testIpPrefixBitsZeroSkipsIp();
|
|
187
|
+
await testConfiguredV6PrefixWidthPreserved();
|
|
188
|
+
await testNoStoreInstanceFingerprintReachable();
|
|
189
|
+
testStaticFingerprintNoStore();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (require.main === module) {
|
|
193
|
+
run().then(function () {
|
|
194
|
+
console.log("OK session-device-binding-ipv6-canonical-and-no-store — "
|
|
195
|
+
+ helpers.getChecks() + " checks");
|
|
196
|
+
}).catch(function (e) {
|
|
197
|
+
console.error("FAIL:", e && e.stack || e);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = { run: run };
|
|
@@ -59,16 +59,25 @@ function testSurface() {
|
|
|
59
59
|
typeof b.sessionDeviceBinding.SessionDeviceBindingError === "function");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
function testCreateRejectsBadOpts() {
|
|
62
|
+
async function testCreateRejectsBadOpts() {
|
|
63
63
|
var threw;
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
check("create()
|
|
65
|
+
// #330 — a no-store create() (no opts, or {} with neither bindingStore nor
|
|
66
|
+
// storeInSession) now returns an INSTANCE whose stateless fingerprint() works
|
|
67
|
+
// (the soft device-binding building block for self-validating tokens), while
|
|
68
|
+
// the persisted bind()/verify()/unbind() lifecycle throws a clear
|
|
69
|
+
// "no store configured" — instead of refusing to construct at all.
|
|
70
|
+
var noStore = b.sessionDeviceBinding.create();
|
|
71
|
+
check("create() with no opts returns a no-store instance", noStore && typeof noStore.fingerprint === "function");
|
|
72
|
+
var noStoreFp = noStore.fingerprint(_mockReq());
|
|
73
|
+
check("no-store instance fingerprint(req) returns a digest", Buffer.isBuffer(noStoreFp) && noStoreFp.length > 0);
|
|
74
|
+
var noStore2 = b.sessionDeviceBinding.create({});
|
|
75
|
+
check("create({}) returns a no-store instance", noStore2 && typeof noStore2.fingerprint === "function");
|
|
76
|
+
// bind() is async, so the no-store guard surfaces as a rejection.
|
|
77
|
+
var bindErr = null;
|
|
78
|
+
try { await noStore2.bind("tok_x", _mockReq()); } catch (e) { bindErr = e; }
|
|
79
|
+
check("no-store bind() fails closed with session-device-binding/no-store",
|
|
80
|
+
bindErr && bindErr.code === "session-device-binding/no-store");
|
|
72
81
|
|
|
73
82
|
threw = false;
|
|
74
83
|
try {
|
|
@@ -242,7 +251,7 @@ function testNamespaceFingerprint() {
|
|
|
242
251
|
async function run() {
|
|
243
252
|
testSurface();
|
|
244
253
|
testNamespaceFingerprint();
|
|
245
|
-
testCreateRejectsBadOpts();
|
|
254
|
+
await testCreateRejectsBadOpts();
|
|
246
255
|
await testBindAndVerifyHappyPath();
|
|
247
256
|
await testVerifyDriftRefuses();
|
|
248
257
|
await testVerifyMissingBindRefuses();
|