@blamejs/core 0.7.4 → 0.7.18
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 +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
|
@@ -1,936 +1,936 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* api-encrypt — end-to-end PQC payload encryption for operator-
|
|
4
|
-
* controlled clients.
|
|
5
|
-
*
|
|
6
|
-
* TLS protects browser ↔ load-balancer; api-encrypt protects request
|
|
7
|
-
* and response bodies *end-to-end* through every intermediate hop
|
|
8
|
-
* (LB → app cleartext segment, sidecar proxy, queue, log aggregator,
|
|
9
|
-
* APM tooling). A tampered byte anywhere downstream of the encrypted
|
|
10
|
-
* boundary fails the AEAD tag at this middleware before the route
|
|
11
|
-
* handler runs.
|
|
12
|
-
*
|
|
13
|
-
* Threat model targets:
|
|
14
|
-
* - Stripped-or-MITM TLS at any internal hop
|
|
15
|
-
* - Body capture at log aggregators / APM tooling
|
|
16
|
-
* - Replay (timestamp + nonce window catches it)
|
|
17
|
-
* - Forged client requests (no key holder = no valid ciphertext)
|
|
18
|
-
*
|
|
19
|
-
* What it does NOT defend against:
|
|
20
|
-
* - Semantic attacks from authorized clients (a key-holder can
|
|
21
|
-
* encrypt a malicious payload validly — safe-schema is the next
|
|
22
|
-
* layer)
|
|
23
|
-
* - Server-side key compromise
|
|
24
|
-
* - Application logic bugs in handlers
|
|
25
|
-
*
|
|
26
|
-
* The encryption layer is for operator-controlled clients (your
|
|
27
|
-
* mobile app, your service-to-service traffic). Public APIs that
|
|
28
|
-
* accept third-party callers should use TLS + webhook signatures
|
|
29
|
-
* instead — the encryption requires a key bootstrap step.
|
|
30
|
-
*
|
|
31
|
-
* Wire format (request body, JSON):
|
|
32
|
-
*
|
|
33
|
-
* {
|
|
34
|
-
* _ek: "<base64 envelope>", // session key wrapped to server pubkey
|
|
35
|
-
* _ct: "<base64 packed>", // payload encrypted with session key
|
|
36
|
-
* _ts: 1738000000000, // unix ms
|
|
37
|
-
* _nonce: "<32 hex>" // 16 random bytes, replay-checked
|
|
38
|
-
* }
|
|
39
|
-
*
|
|
40
|
-
* Wire format (response body, JSON):
|
|
41
|
-
*
|
|
42
|
-
* { _ct: "<base64 packed>" } // same session key, fresh nonce
|
|
43
|
-
*
|
|
44
|
-
* Crypto:
|
|
45
|
-
* - _ek is the framework's standard envelope encrypt:
|
|
46
|
-
* ML-KEM-1024 + P-384 ECDH hybrid → SHAKE256 KDF → XChaCha20-Poly1305
|
|
47
|
-
* The plaintext inside the envelope is the base64-encoded session key.
|
|
48
|
-
* - _ct is the framework's encryptPacked symmetric format:
|
|
49
|
-
* 1-byte version + 24-byte XChaCha20-Poly1305 nonce + ciphertext + tag
|
|
50
|
-
* Keyed by the session key recovered from _ek.
|
|
51
|
-
*
|
|
52
|
-
* Operator API:
|
|
53
|
-
*
|
|
54
|
-
* var apiEncrypt = b.middleware.apiEncrypt({
|
|
55
|
-
* keypair: { publicKey, privateKey, ecPublicKey, ecPrivateKey },
|
|
56
|
-
* replayWindowMs: C.TIME.minutes(5),
|
|
57
|
-
* nonceStore: b.nonceStore.create({ backend: 'cluster' }),
|
|
58
|
-
* exemptPaths: ["/healthz", "/.well-known/blamejs-pubkey"],
|
|
59
|
-
* contentTypes: ["application/json"], // default; pass null to disable
|
|
60
|
-
* });
|
|
61
|
-
* router.use(apiEncrypt);
|
|
62
|
-
* router.get("/.well-known/blamejs-pubkey", apiEncrypt.publishPublicKey());
|
|
63
|
-
*
|
|
64
|
-
* // Outbound (server-to-server, browser/mobile, etc.):
|
|
65
|
-
* var client = b.middleware.apiEncrypt.client({ pubkey });
|
|
66
|
-
* var { body, decryptResponse } = client.encryptRequest({ msg: "hi" });
|
|
67
|
-
*
|
|
68
|
-
* // Server-to-server with framework HTTP client:
|
|
69
|
-
* var enc = b.httpClient.encrypted({ pubkey, baseUrl: "https://service" });
|
|
70
|
-
* var resp = await enc.request({ method: "POST", path: "/api/widget", body: { ... } });
|
|
71
|
-
*
|
|
72
|
-
* Key rotation:
|
|
73
|
-
* To rotate the server keypair, generate a new keypair and pass BOTH
|
|
74
|
-
* the new and the previous keypair to the middleware as `keypairs`:
|
|
75
|
-
*
|
|
76
|
-
* b.middleware.apiEncrypt({
|
|
77
|
-
* keypairs: [newKeypair, prevKeypair],
|
|
78
|
-
* ...
|
|
79
|
-
* });
|
|
80
|
-
*
|
|
81
|
-
* keypairs[0] is the "active" keypair — published by publishPublicKey()
|
|
82
|
-
* so new client-side bootstraps pin to it. Both keypairs are tried
|
|
83
|
-
* when decrypting `_ek`, so in-flight requests still encrypted to the
|
|
84
|
-
* previous keypair continue to decrypt for as long as the previous
|
|
85
|
-
* keypair stays in the array. Operators drop the previous keypair
|
|
86
|
-
* from the array once the rotation overlap window has elapsed.
|
|
87
|
-
*
|
|
88
|
-
* Failure surfacing:
|
|
89
|
-
* AEAD tag failure / stale timestamp / replay / malformed envelope
|
|
90
|
-
* all return 400 with the same body { error: "encrypted-payload-rejected" }.
|
|
91
|
-
* The category that actually matched lands in the audit event +
|
|
92
|
-
* b.events.API_ENCRYPT_FAILURE so operators get metrics / alerting
|
|
93
|
-
* without leaking which check the attacker tripped. Missing _ek /
|
|
94
|
-
* _ct / _ts / _nonce on a non-exempt path is distinguishable in the
|
|
95
|
-
* response ("encrypted-payload-required") so operators with hybrid
|
|
96
|
-
* public/private routes can debug their wiring.
|
|
97
|
-
*/
|
|
98
|
-
|
|
99
|
-
var crypto = require("../crypto");
|
|
100
|
-
var C = require("../constants");
|
|
101
|
-
var lazyRequire = require("../lazy-require");
|
|
102
|
-
var nonceStoreLib = require("../nonce-store");
|
|
103
|
-
var requestHelpers = require("../request-helpers");
|
|
104
|
-
var safeJson = require("../safe-json");
|
|
105
|
-
var validateOpts = require("../validate-opts");
|
|
106
|
-
var { defineClass } = require("../framework-error");
|
|
107
|
-
|
|
108
|
-
var audit = lazyRequire(function () { return require("../audit"); });
|
|
109
|
-
var events = lazyRequire(function () { return require("../events"); });
|
|
110
|
-
var httpClient = lazyRequire(function () { return require("../http-client"); });
|
|
111
|
-
var logger = lazyRequire(function () { return require("../log").boot("api-encrypt"); });
|
|
112
|
-
|
|
113
|
-
var ApiEncryptError = defineClass("ApiEncryptError", { withStatusCode: true });
|
|
114
|
-
|
|
115
|
-
var DEFAULT_REPLAY_WINDOW_MS = C.TIME.minutes(5);
|
|
116
|
-
var DEFAULT_CONTENT_TYPES = ["application/json"];
|
|
117
|
-
var SESSION_KEY_BYTES = C.BYTES.bytes(32);
|
|
118
|
-
var REQUEST_NONCE_BYTES = C.BYTES.bytes(16);
|
|
119
|
-
var DEFAULT_SESSION_TTL_MS = C.TIME.minutes(15);
|
|
120
|
-
// 1024 ≈ "a session with a thousand response rotations" — round-number
|
|
121
|
-
// kibi-aligned default; operators raise this for chat / streaming sessions
|
|
122
|
-
// or lower it for strict per-key forward-secrecy postures.
|
|
123
|
-
var DEFAULT_SESSION_MAX_RESPONSES = 0x400;
|
|
124
|
-
// SID format: UUID-shaped string. Operators with their own session-id
|
|
125
|
-
// vocabulary subscribe to the same shape (cluster-storage / cache backends
|
|
126
|
-
// already index on string keys).
|
|
127
|
-
var SID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
|
|
128
|
-
var SID_MAX_LENGTH = C.BYTES.bytes(64);
|
|
129
|
-
|
|
130
|
-
function _err(code, message, statusCode) {
|
|
131
|
-
return new ApiEncryptError(code, message, true, statusCode || 400);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function _validateKeypair(kp, label) {
|
|
135
|
-
if (!kp || typeof kp !== "object") {
|
|
136
|
-
throw _err("INVALID_KEYPAIR", "apiEncrypt: " + label + " is required", 500);
|
|
137
|
-
}
|
|
138
|
-
if (typeof kp.publicKey !== "string" || typeof kp.privateKey !== "string") {
|
|
139
|
-
throw _err("INVALID_KEYPAIR",
|
|
140
|
-
"apiEncrypt: " + label + ".publicKey + .privateKey are required (ML-KEM-1024 PEM)", 500);
|
|
141
|
-
}
|
|
142
|
-
if (typeof kp.ecPublicKey !== "string" || typeof kp.ecPrivateKey !== "string") {
|
|
143
|
-
throw _err("INVALID_KEYPAIR",
|
|
144
|
-
"apiEncrypt: " + label + ".ecPublicKey + .ecPrivateKey are required (P-384 PEM hybrid)", 500);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Resolve the operator's keypair input into an ordered array. The
|
|
149
|
-
// first keypair is "active" — used by publishPublicKey() and as the
|
|
150
|
-
// hint for response encryption (responses use the per-request session
|
|
151
|
-
// key, so the active keypair only matters for what the bootstrap
|
|
152
|
-
// endpoint advertises). Every keypair in the array is tried in order
|
|
153
|
-
// when decrypting `_ek` so that during a rotation overlap window,
|
|
154
|
-
// in-flight requests encrypted to a previous keypair still decrypt
|
|
155
|
-
// successfully.
|
|
156
|
-
function _resolveKeypairs(opts) {
|
|
157
|
-
if (Array.isArray(opts.keypairs)) {
|
|
158
|
-
if (opts.keypairs.length === 0) {
|
|
159
|
-
throw _err("INVALID_KEYPAIR", "apiEncrypt: keypairs must be a non-empty array", 500);
|
|
160
|
-
}
|
|
161
|
-
opts.keypairs.forEach(function (kp, i) { _validateKeypair(kp, "keypairs[" + i + "]"); });
|
|
162
|
-
return opts.keypairs.slice();
|
|
163
|
-
}
|
|
164
|
-
if (opts.keypair) {
|
|
165
|
-
_validateKeypair(opts.keypair, "keypair");
|
|
166
|
-
return [opts.keypair];
|
|
167
|
-
}
|
|
168
|
-
throw _err("INVALID_KEYPAIR",
|
|
169
|
-
"apiEncrypt: { keypair } or { keypairs: [...] } is required", 500);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
var HTTP_STATUS = requestHelpers.HTTP_STATUS;
|
|
173
|
-
|
|
174
|
-
// _defaultSessionStore — in-memory session table for single-process
|
|
175
|
-
// deployments. Operators with multi-replica deploys pass an
|
|
176
|
-
// operator-supplied store (b.cache.create({ backend: "cluster" }) or any
|
|
177
|
-
// `{ get, set, delete }`-shaped handle). Per-replica isolation in default
|
|
178
|
-
// mode means sticky sessions; the limit is documented in the wiki.
|
|
179
|
-
//
|
|
180
|
-
// .get(sid) → row | null
|
|
181
|
-
// .set(sid, row, { ttlMs }) → void
|
|
182
|
-
// .delete(sid) → void
|
|
183
|
-
//
|
|
184
|
-
// Each row stores:
|
|
185
|
-
// { sessionKey: Buffer, lastReqCtr: int, responsesEmitted: int,
|
|
186
|
-
// createdAt: ms, expiresAt: ms, lastUsedAt: ms }
|
|
187
|
-
function _defaultSessionStore() {
|
|
188
|
-
var rows = new Map();
|
|
189
|
-
return {
|
|
190
|
-
get: function (sid) {
|
|
191
|
-
var row = rows.get(sid);
|
|
192
|
-
if (!row) return null;
|
|
193
|
-
if (Date.now() > row.expiresAt) {
|
|
194
|
-
rows.delete(sid);
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
return row;
|
|
198
|
-
},
|
|
199
|
-
set: function (sid, row /* opts */) {
|
|
200
|
-
rows.set(sid, row);
|
|
201
|
-
},
|
|
202
|
-
delete: function (sid) {
|
|
203
|
-
rows.delete(sid);
|
|
204
|
-
},
|
|
205
|
-
purgeExpired: function () {
|
|
206
|
-
var now = Date.now();
|
|
207
|
-
var purged = 0;
|
|
208
|
-
rows.forEach(function (row, sid) {
|
|
209
|
-
if (now > row.expiresAt) { rows.delete(sid); purged += 1; }
|
|
210
|
-
});
|
|
211
|
-
return purged;
|
|
212
|
-
},
|
|
213
|
-
size: function () { return rows.size; },
|
|
214
|
-
close: function () { rows.clear(); },
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function _validSid(sid) {
|
|
219
|
-
return typeof sid === "string" &&
|
|
220
|
-
sid.length > 0 &&
|
|
221
|
-
sid.length <= SID_MAX_LENGTH &&
|
|
222
|
-
SID_RE.test(sid);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function _writeRejection(res, code, body) {
|
|
226
|
-
if (res.headersSent || res.writableEnded) return;
|
|
227
|
-
if (typeof res.writeHead === "function") {
|
|
228
|
-
res.writeHead(code, { "Content-Type": "application/json" });
|
|
229
|
-
res.end(JSON.stringify(body));
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// ---- Server-side middleware ----
|
|
234
|
-
|
|
235
|
-
function create(opts) {
|
|
236
|
-
opts = opts || {};
|
|
237
|
-
validateOpts(opts, [
|
|
238
|
-
"keypair", "keypairs", "replayWindowMs", "pruneIntervalMs",
|
|
239
|
-
"nonceStore", "exemptPaths", "contentTypes", "audit",
|
|
240
|
-
"maxDecryptedBytes", "trustProxy",
|
|
241
|
-
// Per-session keying mode (opt-in; per-request stays default).
|
|
242
|
-
"keying", "sessionStore", "sessionTtlMs", "sessionMaxResponses",
|
|
243
|
-
"observability",
|
|
244
|
-
], "middleware.apiEncrypt");
|
|
245
|
-
var keypairs = _resolveKeypairs(opts);
|
|
246
|
-
var activeKeypair = keypairs[0];
|
|
247
|
-
var replayWindowMs = opts.replayWindowMs || DEFAULT_REPLAY_WINDOW_MS;
|
|
248
|
-
// Cap on decrypted-payload size handed to safeJson.parse. Defaults
|
|
249
|
-
// to 4 MiB (bodyParser's default 1 MiB plus headroom for crypto +
|
|
250
|
-
// base64 round-trip). Operators with chunkier inbound payloads
|
|
251
|
-
// raise this; the framework refuses to parse anything larger as a
|
|
252
|
-
// parse-bomb defense.
|
|
253
|
-
var maxDecryptedBytes = opts.maxDecryptedBytes != null
|
|
254
|
-
? opts.maxDecryptedBytes
|
|
255
|
-
: C.BYTES.mib(4);
|
|
256
|
-
// The spec calls for a sweep cadence of replayWindowMs/2 — short
|
|
257
|
-
// enough that expired nonces don't pile up but not so frequent the
|
|
258
|
-
// sweep query becomes a hot path. Operators can override.
|
|
259
|
-
var pruneIntervalMs = opts.pruneIntervalMs != null
|
|
260
|
-
? opts.pruneIntervalMs : Math.max(C.TIME.seconds(30), Math.floor(replayWindowMs / 2));
|
|
261
|
-
var nonceStore = opts.nonceStore || nonceStoreLib.create({ backend: "memory" });
|
|
262
|
-
var exemptPaths = Array.isArray(opts.exemptPaths) ? opts.exemptPaths.slice() : [];
|
|
263
|
-
// contentTypes scoping — middleware only operates on requests whose
|
|
264
|
-
// Content-Type is in this list. Default JSON; operators with more
|
|
265
|
-
// exotic clients (form-encoded, gRPC-web, etc.) widen the list.
|
|
266
|
-
// Set to null/false/empty array to disable content-type filtering
|
|
267
|
-
// (treat every non-exempt request as encrypted).
|
|
268
|
-
var contentTypes = opts.contentTypes === null || opts.contentTypes === false
|
|
269
|
-
? null
|
|
270
|
-
: (Array.isArray(opts.contentTypes) && opts.contentTypes.length > 0
|
|
271
|
-
? opts.contentTypes.slice()
|
|
272
|
-
: DEFAULT_CONTENT_TYPES.slice());
|
|
273
|
-
var auditOn = opts.audit !== false;
|
|
274
|
-
var trustProxy = opts.trustProxy === true;
|
|
275
|
-
var lastPruneAt = 0;
|
|
276
|
-
|
|
277
|
-
// ---- per-session keying opts ----
|
|
278
|
-
var keying = opts.keying != null ? opts.keying : "per-request";
|
|
279
|
-
if (keying !== "per-request" && keying !== "per-session") {
|
|
280
|
-
throw _err("BAD_OPT",
|
|
281
|
-
"apiEncrypt: keying must be 'per-request' (default) or 'per-session', got " +
|
|
282
|
-
JSON.stringify(opts.keying), 500);
|
|
283
|
-
}
|
|
284
|
-
var sessionTtlMs = opts.sessionTtlMs != null ? opts.sessionTtlMs : DEFAULT_SESSION_TTL_MS;
|
|
285
|
-
var sessionMaxResponses = opts.sessionMaxResponses != null
|
|
286
|
-
? opts.sessionMaxResponses : DEFAULT_SESSION_MAX_RESPONSES;
|
|
287
|
-
if (typeof sessionTtlMs !== "number" || !isFinite(sessionTtlMs) || sessionTtlMs <= 0) {
|
|
288
|
-
throw _err("BAD_OPT",
|
|
289
|
-
"apiEncrypt: sessionTtlMs must be a positive finite number (ms), got " +
|
|
290
|
-
JSON.stringify(opts.sessionTtlMs), 500);
|
|
291
|
-
}
|
|
292
|
-
if (typeof sessionMaxResponses !== "number" || !isFinite(sessionMaxResponses) ||
|
|
293
|
-
sessionMaxResponses <= 0 || Math.floor(sessionMaxResponses) !== sessionMaxResponses) {
|
|
294
|
-
throw _err("BAD_OPT",
|
|
295
|
-
"apiEncrypt: sessionMaxResponses must be a positive finite integer, got " +
|
|
296
|
-
JSON.stringify(opts.sessionMaxResponses), 500);
|
|
297
|
-
}
|
|
298
|
-
// sessionStore — duck-typed handle exposing { get, set, delete }. The
|
|
299
|
-
// helper optionalObjectWithMethod only checks one method; here we need
|
|
300
|
-
// three. Inline shape kept; not a generic enough pattern to warrant a
|
|
301
|
-
// separate helper.
|
|
302
|
-
if (opts.sessionStore !== undefined && opts.sessionStore !== null) {
|
|
303
|
-
var ss = opts.sessionStore;
|
|
304
|
-
var ssOk = typeof ss === "object" &&
|
|
305
|
-
typeof ss.get === "function" &&
|
|
306
|
-
typeof ss.set === "function" &&
|
|
307
|
-
typeof ss.delete === "function";
|
|
308
|
-
if (!ssOk) {
|
|
309
|
-
throw _err("BAD_OPT",
|
|
310
|
-
"apiEncrypt: sessionStore must expose { get(sid), set(sid, row, opts?), delete(sid) } " +
|
|
311
|
-
"(b.cache.create() is shape-compatible)", 500);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
var sessionStore = (keying === "per-session" && opts.sessionStore)
|
|
315
|
-
? opts.sessionStore
|
|
316
|
-
: (keying === "per-session" ? _defaultSessionStore() : null);
|
|
317
|
-
// Observability tap — per-session emits counters for sessions
|
|
318
|
-
// established / replay-rejected / expired / rotated. Per-request mode
|
|
319
|
-
// ignores this opt; the existing events.API_ENCRYPT_FAILURE channel
|
|
320
|
-
// already carries failure shape there.
|
|
321
|
-
validateOpts.observabilityShape(opts.observability,
|
|
322
|
-
"apiEncrypt", ApiEncryptError, "BAD_OPT");
|
|
323
|
-
var observabilityHandle = opts.observability || null;
|
|
324
|
-
function _emitObs(name, value, labels) {
|
|
325
|
-
if (observabilityHandle) {
|
|
326
|
-
observabilityHandle.safeEvent(name, value, labels || {});
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
function _emitSessionAudit(action, info) {
|
|
330
|
-
if (!auditOn) return;
|
|
331
|
-
try {
|
|
332
|
-
audit().safeEmit({
|
|
333
|
-
action: action, outcome: info.outcome || "success",
|
|
334
|
-
metadata: info.metadata || {},
|
|
335
|
-
actor: info.actor || null,
|
|
336
|
-
requestId: info.requestId || null,
|
|
337
|
-
});
|
|
338
|
-
} catch (_e) { /* audit best-effort */ }
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function _isExempt(req) {
|
|
342
|
-
var p = req.pathname || (req.url || "/").split("?")[0];
|
|
343
|
-
for (var i = 0; i < exemptPaths.length; i++) {
|
|
344
|
-
var rule = exemptPaths[i];
|
|
345
|
-
if (typeof rule === "string" ? p === rule || p.indexOf(rule + "/") === 0 : rule.test(p)) {
|
|
346
|
-
return true;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
return false;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function _matchesContentType(req) {
|
|
353
|
-
if (!contentTypes) return true; // filtering disabled
|
|
354
|
-
var ct = req.headers && (req.headers["content-type"] || req.headers["Content-Type"]);
|
|
355
|
-
if (typeof ct !== "string") return false;
|
|
356
|
-
// Strip parameters like "; charset=utf-8"
|
|
357
|
-
var bare = ct.split(";")[0].trim().toLowerCase();
|
|
358
|
-
for (var i = 0; i < contentTypes.length; i++) {
|
|
359
|
-
if (contentTypes[i].toLowerCase() === bare) return true;
|
|
360
|
-
}
|
|
361
|
-
return false;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function _emitFailure(req, reason) {
|
|
365
|
-
var info = {
|
|
366
|
-
reason: reason,
|
|
367
|
-
ip: requestHelpers.clientIp(req, { trustProxy: trustProxy }),
|
|
368
|
-
path: req.pathname || (req.url || "/").split("?")[0],
|
|
369
|
-
method: req.method,
|
|
370
|
-
ts: new Date().toISOString(),
|
|
371
|
-
requestId: req.requestId || null,
|
|
372
|
-
};
|
|
373
|
-
if (auditOn) {
|
|
374
|
-
audit().safeEmit({
|
|
375
|
-
actor: requestHelpers.extractActorContext(req),
|
|
376
|
-
action: "system.api_encrypt.failure",
|
|
377
|
-
outcome: "denied",
|
|
378
|
-
reason: reason,
|
|
379
|
-
metadata: { reason: reason, path: info.path, method: info.method },
|
|
380
|
-
requestId: info.requestId,
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
try { events().emit(events().EVENTS.API_ENCRYPT_FAILURE, info); }
|
|
384
|
-
catch (_e) { /* events best-effort */ }
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function _maybePrune() {
|
|
388
|
-
var now = Date.now();
|
|
389
|
-
if (now - lastPruneAt < pruneIntervalMs) return;
|
|
390
|
-
lastPruneAt = now;
|
|
391
|
-
nonceStore.purgeExpired().catch(function (e) {
|
|
392
|
-
try {
|
|
393
|
-
logger().warn("nonce-store prune failed: " + ((e && e.message) || String(e)));
|
|
394
|
-
} catch (_e) { /* logger best-effort */ }
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// _wrapResJson — install res.json that encrypts the response with the
|
|
399
|
-
// session key. In per-request mode the response is `{ _ct }`; in
|
|
400
|
-
// per-session mode it carries `{ _ct, _sid, _ctr }` so the client can
|
|
401
|
-
// detect tampered / replayed responses with a monotonic counter check.
|
|
402
|
-
function _wrapResJson(res, sessionKey, sessionCtx) {
|
|
403
|
-
var origJson = res.json;
|
|
404
|
-
res.json = function (data) {
|
|
405
|
-
try {
|
|
406
|
-
var ptBuf = Buffer.from(JSON.stringify(data), "utf8");
|
|
407
|
-
var ctBuf = crypto.encryptPacked(ptBuf, sessionKey);
|
|
408
|
-
var encrypted = { _ct: ctBuf.toString("base64") };
|
|
409
|
-
if (sessionCtx) {
|
|
410
|
-
encrypted._sid = sessionCtx.sid;
|
|
411
|
-
encrypted._ctr = sessionCtx.responseCtr;
|
|
412
|
-
}
|
|
413
|
-
if (typeof origJson === "function") {
|
|
414
|
-
return origJson.call(res, encrypted);
|
|
415
|
-
}
|
|
416
|
-
// Fallback if router didn't install res.json yet.
|
|
417
|
-
if (!res.headersSent) {
|
|
418
|
-
res.writeHead(res.statusCode || HTTP_STATUS.OK, { "Content-Type": "application/json" });
|
|
419
|
-
}
|
|
420
|
-
res.end(JSON.stringify(encrypted));
|
|
421
|
-
} catch (e) {
|
|
422
|
-
try {
|
|
423
|
-
logger().error("response encryption failed: " + ((e && e.message) || String(e)));
|
|
424
|
-
} catch (_e) { /* logger best-effort */ }
|
|
425
|
-
if (!res.headersSent) {
|
|
426
|
-
res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, { "Content-Type": "application/json" });
|
|
427
|
-
}
|
|
428
|
-
res.end(JSON.stringify({ error: "response-encryption-failed" }));
|
|
429
|
-
}
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// _decryptEkToSessionKey — try every keypair in order; returns the
|
|
434
|
-
// 32-byte sessionKey buffer or null on AEAD failure across all keypairs.
|
|
435
|
-
function _decryptEkToSessionKey(ek) {
|
|
436
|
-
for (var ki = 0; ki < keypairs.length; ki++) {
|
|
437
|
-
try {
|
|
438
|
-
var sessionKeyB64 = crypto.decrypt(ek, keypairs[ki]);
|
|
439
|
-
var candidate = Buffer.from(sessionKeyB64, "base64");
|
|
440
|
-
if (candidate.length === SESSION_KEY_BYTES) return candidate;
|
|
441
|
-
} catch (_e) { /* try next keypair */ }
|
|
442
|
-
}
|
|
443
|
-
return null;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
async function middleware(req, res, next) {
|
|
447
|
-
if (_isExempt(req)) return next();
|
|
448
|
-
if (!_matchesContentType(req)) return next();
|
|
449
|
-
|
|
450
|
-
var body = req.body;
|
|
451
|
-
if (!body || typeof body !== "object") {
|
|
452
|
-
_emitFailure(req, "shape");
|
|
453
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
var now = Date.now();
|
|
457
|
-
var ct = body._ct, ts = body._ts;
|
|
458
|
-
if (typeof ct !== "string" || typeof ts !== "number") {
|
|
459
|
-
_emitFailure(req, "shape");
|
|
460
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
461
|
-
}
|
|
462
|
-
if (Math.abs(now - ts) > replayWindowMs) {
|
|
463
|
-
_emitFailure(req, "stale");
|
|
464
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Per-request OR per-session bootstrap path: shape includes _ek + _nonce.
|
|
468
|
-
// Per-session subsequent path: shape includes _sid + _ctr (no _ek).
|
|
469
|
-
var ek = body._ek, nonce = body._nonce, sid = body._sid, ctr = body._ctr;
|
|
470
|
-
var sessionKey = null;
|
|
471
|
-
var sessionCtx = null; // null = per-request mode response shape
|
|
472
|
-
var session = null;
|
|
473
|
-
|
|
474
|
-
if (typeof ek === "string" && typeof nonce === "string") {
|
|
475
|
-
// ---- Bootstrap path (per-request mode OR first request of session) ----
|
|
476
|
-
var nonceHash = crypto.sha3Hash(nonce, "hex");
|
|
477
|
-
var expireAt = now + replayWindowMs;
|
|
478
|
-
var freshNonce;
|
|
479
|
-
try { freshNonce = await nonceStore.checkAndInsert(nonceHash, expireAt); }
|
|
480
|
-
catch (_e) {
|
|
481
|
-
_emitFailure(req, "nonce-store-error");
|
|
482
|
-
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
|
|
483
|
-
}
|
|
484
|
-
if (!freshNonce) {
|
|
485
|
-
_emitFailure(req, "replay");
|
|
486
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
487
|
-
}
|
|
488
|
-
sessionKey = _decryptEkToSessionKey(ek);
|
|
489
|
-
if (!sessionKey) {
|
|
490
|
-
_emitFailure(req, "tag");
|
|
491
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
492
|
-
}
|
|
493
|
-
if (keying === "per-session") {
|
|
494
|
-
if (!_validSid(sid)) {
|
|
495
|
-
_emitFailure(req, "shape");
|
|
496
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
497
|
-
}
|
|
498
|
-
if (typeof ctr !== "number" || !isFinite(ctr) || ctr < 0 || Math.floor(ctr) !== ctr) {
|
|
499
|
-
_emitFailure(req, "shape");
|
|
500
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
501
|
-
}
|
|
502
|
-
// Bootstrap a new session row keyed by sid.
|
|
503
|
-
session = {
|
|
504
|
-
sessionKey: sessionKey,
|
|
505
|
-
lastReqCtr: ctr,
|
|
506
|
-
responsesEmitted: 0,
|
|
507
|
-
createdAt: now,
|
|
508
|
-
lastUsedAt: now,
|
|
509
|
-
expiresAt: now + sessionTtlMs,
|
|
510
|
-
};
|
|
511
|
-
try { await sessionStore.set(sid, session, { ttlMs: sessionTtlMs }); }
|
|
512
|
-
catch (_e) {
|
|
513
|
-
_emitFailure(req, "session-store-error");
|
|
514
|
-
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
|
|
515
|
-
}
|
|
516
|
-
_emitObs("apiEncrypt.session.created", 1, { mode: "per-session" });
|
|
517
|
-
_emitSessionAudit("apiEncrypt.session.created", {
|
|
518
|
-
actor: requestHelpers.extractActorContext(req),
|
|
519
|
-
metadata: { sid: sid, expiresAt: session.expiresAt },
|
|
520
|
-
requestId: req.requestId || null,
|
|
521
|
-
});
|
|
522
|
-
sessionCtx = { sid: sid, responseCtr: 1 };
|
|
523
|
-
session.responsesEmitted = 1;
|
|
524
|
-
}
|
|
525
|
-
} else if (keying === "per-session" &&
|
|
526
|
-
typeof sid === "string" && typeof ctr === "number") {
|
|
527
|
-
// ---- Per-session subsequent-request path ----
|
|
528
|
-
if (!_validSid(sid)) {
|
|
529
|
-
_emitFailure(req, "shape");
|
|
530
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
531
|
-
}
|
|
532
|
-
if (!isFinite(ctr) || ctr < 0 || Math.floor(ctr) !== ctr) {
|
|
533
|
-
_emitFailure(req, "shape");
|
|
534
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
535
|
-
}
|
|
536
|
-
try { session = await sessionStore.get(sid); }
|
|
537
|
-
catch (_e) {
|
|
538
|
-
_emitFailure(req, "session-store-error");
|
|
539
|
-
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
|
|
540
|
-
}
|
|
541
|
-
if (!session) {
|
|
542
|
-
_emitObs("apiEncrypt.session.unknown", 1, {});
|
|
543
|
-
_emitFailure(req, "session-unknown");
|
|
544
|
-
return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-unknown" });
|
|
545
|
-
}
|
|
546
|
-
if (now > session.expiresAt) {
|
|
547
|
-
try { await sessionStore.delete(sid); } catch (_e) { /* best-effort */ }
|
|
548
|
-
_emitObs("apiEncrypt.session.expired", 1, {});
|
|
549
|
-
_emitSessionAudit("apiEncrypt.session.expired", {
|
|
550
|
-
outcome: "denied",
|
|
551
|
-
actor: requestHelpers.extractActorContext(req),
|
|
552
|
-
metadata: { sid: sid, reason: "ttl_exceeded" },
|
|
553
|
-
requestId: req.requestId || null,
|
|
554
|
-
});
|
|
555
|
-
_emitFailure(req, "session-expired");
|
|
556
|
-
return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-expired" });
|
|
557
|
-
}
|
|
558
|
-
if (session.responsesEmitted >= sessionMaxResponses) {
|
|
559
|
-
try { await sessionStore.delete(sid); } catch (_e) { /* best-effort */ }
|
|
560
|
-
_emitObs("apiEncrypt.session.rotated", 1, { reason: "max_responses" });
|
|
561
|
-
_emitSessionAudit("apiEncrypt.session.rotated", {
|
|
562
|
-
actor: requestHelpers.extractActorContext(req),
|
|
563
|
-
metadata: { sid: sid, reason: "max_responses_exceeded",
|
|
564
|
-
responsesEmitted: session.responsesEmitted },
|
|
565
|
-
requestId: req.requestId || null,
|
|
566
|
-
});
|
|
567
|
-
_emitFailure(req, "session-rotation-required");
|
|
568
|
-
return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-rotation-required" });
|
|
569
|
-
}
|
|
570
|
-
// Replay defense: counter MUST strictly increase.
|
|
571
|
-
if (ctr <= session.lastReqCtr) {
|
|
572
|
-
_emitObs("apiEncrypt.session.replay_rejected", 1, {});
|
|
573
|
-
_emitSessionAudit("apiEncrypt.session.replay_rejected", {
|
|
574
|
-
outcome: "denied",
|
|
575
|
-
actor: requestHelpers.extractActorContext(req),
|
|
576
|
-
metadata: { sid: sid, receivedCtr: ctr, lastSeen: session.lastReqCtr },
|
|
577
|
-
requestId: req.requestId || null,
|
|
578
|
-
});
|
|
579
|
-
_emitFailure(req, "counter-replay");
|
|
580
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
581
|
-
}
|
|
582
|
-
sessionKey = session.sessionKey;
|
|
583
|
-
if (Buffer.isBuffer(sessionKey) === false) {
|
|
584
|
-
// Operator-supplied store may have JSON-serialised the buffer.
|
|
585
|
-
// Accept hex / base64 / Uint8Array and coerce.
|
|
586
|
-
if (typeof sessionKey === "string") {
|
|
587
|
-
sessionKey = Buffer.from(sessionKey, "base64");
|
|
588
|
-
} else if (sessionKey && sessionKey.type === "Buffer" && Array.isArray(sessionKey.data)) {
|
|
589
|
-
sessionKey = Buffer.from(sessionKey.data);
|
|
590
|
-
} else if (sessionKey instanceof Uint8Array) {
|
|
591
|
-
sessionKey = Buffer.from(sessionKey);
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
if (!Buffer.isBuffer(sessionKey) || sessionKey.length !== SESSION_KEY_BYTES) {
|
|
595
|
-
_emitFailure(req, "session-store-error");
|
|
596
|
-
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
|
|
597
|
-
}
|
|
598
|
-
session.lastReqCtr = ctr;
|
|
599
|
-
session.lastUsedAt = now;
|
|
600
|
-
session.responsesEmitted += 1;
|
|
601
|
-
try { await sessionStore.set(sid, session, { ttlMs: session.expiresAt - now }); }
|
|
602
|
-
catch (_e) { /* best-effort — request still proceeds */ }
|
|
603
|
-
sessionCtx = { sid: sid, responseCtr: session.responsesEmitted };
|
|
604
|
-
} else {
|
|
605
|
-
_emitFailure(req, "shape");
|
|
606
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Decrypt _ct → cleartext payload bytes → JSON object.
|
|
610
|
-
var clearObj;
|
|
611
|
-
try {
|
|
612
|
-
var ctBuf = Buffer.from(ct, "base64");
|
|
613
|
-
var ptBuf = crypto.decryptPacked(ctBuf, sessionKey);
|
|
614
|
-
clearObj = safeJson.parse(ptBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
|
|
615
|
-
} catch (_e) {
|
|
616
|
-
_emitFailure(req, "tag");
|
|
617
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// Replace req.body with cleartext, stash session key for any
|
|
621
|
-
// operator code that wants to attach extra encrypted side-channel
|
|
622
|
-
// data (e.g. send a follow-up encrypted SSE event).
|
|
623
|
-
req.body = clearObj;
|
|
624
|
-
req.apiEncryptSessionKey = sessionKey;
|
|
625
|
-
if (sessionCtx) req.apiEncryptSession = { sid: sessionCtx.sid };
|
|
626
|
-
|
|
627
|
-
_wrapResJson(res, sessionKey, sessionCtx);
|
|
628
|
-
_maybePrune();
|
|
629
|
-
|
|
630
|
-
return next();
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
// Route handler that publishes the server's public keys for client
|
|
634
|
-
// bootstrap. Returns the PEM strings + KEM ID + a stable cache hint
|
|
635
|
-
// so clients can pin / rotate based on the published keys.
|
|
636
|
-
function publishPublicKey() {
|
|
637
|
-
return function publishHandler(_req, res) {
|
|
638
|
-
var body = {
|
|
639
|
-
publicKey: activeKeypair.publicKey,
|
|
640
|
-
ecPublicKey: activeKeypair.ecPublicKey,
|
|
641
|
-
kemId: C.ACTIVE.KEM,
|
|
642
|
-
cipherId: C.ACTIVE.CIPHER,
|
|
643
|
-
kdfId: C.ACTIVE.KDF,
|
|
644
|
-
};
|
|
645
|
-
if (typeof res.json === "function") return res.json(body);
|
|
646
|
-
if (!res.headersSent) {
|
|
647
|
-
res.writeHead(HTTP_STATUS.OK, { "Content-Type": "application/json" });
|
|
648
|
-
}
|
|
649
|
-
res.end(JSON.stringify(body));
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
middleware.publishPublicKey = publishPublicKey;
|
|
654
|
-
middleware.close = function () {
|
|
655
|
-
if (typeof nonceStore.close === "function") nonceStore.close();
|
|
656
|
-
if (sessionStore && typeof sessionStore.close === "function") sessionStore.close();
|
|
657
|
-
};
|
|
658
|
-
// Expose for tests / operator dashboards. Counts are 0 in per-request mode.
|
|
659
|
-
middleware.sessionStore = sessionStore;
|
|
660
|
-
middleware.keying = keying;
|
|
661
|
-
|
|
662
|
-
return middleware;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// ---- Client-side helper ----
|
|
666
|
-
//
|
|
667
|
-
// Operators import this in their browser/mobile/native code or in
|
|
668
|
-
// service-to-service callers. The pubkey shape MUST match what
|
|
669
|
-
// publishPublicKey() returns: { publicKey, ecPublicKey, kemId,
|
|
670
|
-
// cipherId, kdfId }.
|
|
671
|
-
|
|
672
|
-
function client(opts) {
|
|
673
|
-
opts = opts || {};
|
|
674
|
-
validateOpts(opts, ["pubkey", "maxDecryptedBytes", "keying"], "middleware.apiEncrypt.client");
|
|
675
|
-
if (!opts.pubkey || typeof opts.pubkey !== "object") {
|
|
676
|
-
throw _err("CLIENT_INVALID_PUBKEY",
|
|
677
|
-
"apiEncrypt.client: opts.pubkey is required ({ publicKey, ecPublicKey })", 500);
|
|
678
|
-
}
|
|
679
|
-
if (typeof opts.pubkey.publicKey !== "string" ||
|
|
680
|
-
typeof opts.pubkey.ecPublicKey !== "string") {
|
|
681
|
-
throw _err("CLIENT_INVALID_PUBKEY",
|
|
682
|
-
"apiEncrypt.client: pubkey.publicKey + ecPublicKey must be PEM strings", 500);
|
|
683
|
-
}
|
|
684
|
-
var pubkey = opts.pubkey;
|
|
685
|
-
var maxDecryptedBytes = opts.maxDecryptedBytes != null
|
|
686
|
-
? opts.maxDecryptedBytes
|
|
687
|
-
: C.BYTES.mib(4);
|
|
688
|
-
var keying = opts.keying != null ? opts.keying : "per-request";
|
|
689
|
-
if (keying !== "per-request" && keying !== "per-session") {
|
|
690
|
-
throw _err("CLIENT_BAD_OPT",
|
|
691
|
-
"apiEncrypt.client: keying must be 'per-request' (default) or 'per-session', got " +
|
|
692
|
-
JSON.stringify(opts.keying), 500);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
if (keying === "per-request") {
|
|
696
|
-
return { encryptRequest: _encryptPerRequest, keying: keying };
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
// Per-session: stateful client. encryptRequest mutates internal counter.
|
|
700
|
-
// First call sends the bootstrap envelope; subsequent calls omit _ek/_nonce
|
|
701
|
-
// and increment the counter. Operator can call resetSession() to force a
|
|
702
|
-
// new bootstrap (e.g. after server returns "session-expired").
|
|
703
|
-
var perSessionKey = null;
|
|
704
|
-
var perSessionSid = null;
|
|
705
|
-
var perSessionReqCtr = 0;
|
|
706
|
-
var perSessionLastResCtr = 0;
|
|
707
|
-
|
|
708
|
-
function _resetSession() {
|
|
709
|
-
perSessionKey = crypto.generateBytes(SESSION_KEY_BYTES);
|
|
710
|
-
perSessionSid = _generateUuidV4();
|
|
711
|
-
perSessionReqCtr = 0;
|
|
712
|
-
perSessionLastResCtr = 0;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
function _decryptPerSessionResponse(responseBody) {
|
|
716
|
-
if (!responseBody || typeof responseBody !== "object" ||
|
|
717
|
-
typeof responseBody._ct !== "string") {
|
|
718
|
-
throw _err("CLIENT_RESPONSE_SHAPE",
|
|
719
|
-
"apiEncrypt.client: response missing _ct field");
|
|
720
|
-
}
|
|
721
|
-
if (typeof responseBody._sid !== "string" || responseBody._sid !== perSessionSid) {
|
|
722
|
-
throw _err("CLIENT_RESPONSE_SID",
|
|
723
|
-
"apiEncrypt.client: response sid does not match opened session");
|
|
724
|
-
}
|
|
725
|
-
if (typeof responseBody._ctr !== "number" || responseBody._ctr <= perSessionLastResCtr) {
|
|
726
|
-
throw _err("CLIENT_RESPONSE_REPLAY",
|
|
727
|
-
"apiEncrypt.client: response counter is not strictly increasing " +
|
|
728
|
-
"(got " + responseBody._ctr + ", lastSeen " + perSessionLastResCtr + ")");
|
|
729
|
-
}
|
|
730
|
-
perSessionLastResCtr = responseBody._ctr;
|
|
731
|
-
var resCtBuf = Buffer.from(responseBody._ct, "base64");
|
|
732
|
-
var resPtBuf = crypto.decryptPacked(resCtBuf, perSessionKey);
|
|
733
|
-
return safeJson.parse(resPtBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
function _encryptPerSession(payload) {
|
|
737
|
-
if (payload === undefined) payload = null;
|
|
738
|
-
if (!perSessionKey) _resetSession();
|
|
739
|
-
var ts = Date.now();
|
|
740
|
-
var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
|
|
741
|
-
var ctBuf = crypto.encryptPacked(ptBuf, perSessionKey);
|
|
742
|
-
perSessionReqCtr += 1;
|
|
743
|
-
var body;
|
|
744
|
-
if (perSessionReqCtr === 1) {
|
|
745
|
-
// Bootstrap envelope — full _ek + _nonce; server stores sid → sessionKey.
|
|
746
|
-
var ek = crypto.encrypt(perSessionKey.toString("base64"), pubkey);
|
|
747
|
-
var nonce = crypto.generateBytes(REQUEST_NONCE_BYTES).toString("hex");
|
|
748
|
-
body = {
|
|
749
|
-
_ek: ek,
|
|
750
|
-
_ct: ctBuf.toString("base64"),
|
|
751
|
-
_ts: ts,
|
|
752
|
-
_nonce: nonce,
|
|
753
|
-
_sid: perSessionSid,
|
|
754
|
-
_ctr: perSessionReqCtr,
|
|
755
|
-
};
|
|
756
|
-
} else {
|
|
757
|
-
// Subsequent — sid + ctr only. KEM material amortized across the session.
|
|
758
|
-
body = {
|
|
759
|
-
_ct: ctBuf.toString("base64"),
|
|
760
|
-
_ts: ts,
|
|
761
|
-
_sid: perSessionSid,
|
|
762
|
-
_ctr: perSessionReqCtr,
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
return { body: body, decryptResponse: _decryptPerSessionResponse };
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
function _encryptPerRequest(payload) {
|
|
769
|
-
if (payload === undefined) payload = null;
|
|
770
|
-
var sessionKey = crypto.generateBytes(SESSION_KEY_BYTES);
|
|
771
|
-
var ek = crypto.encrypt(sessionKey.toString("base64"), pubkey);
|
|
772
|
-
var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
|
|
773
|
-
var ctBuf = crypto.encryptPacked(ptBuf, sessionKey);
|
|
774
|
-
var requestNonce = crypto.generateBytes(REQUEST_NONCE_BYTES).toString("hex");
|
|
775
|
-
var ts = Date.now();
|
|
776
|
-
return {
|
|
777
|
-
body: {
|
|
778
|
-
_ek: ek,
|
|
779
|
-
_ct: ctBuf.toString("base64"),
|
|
780
|
-
_ts: ts,
|
|
781
|
-
_nonce: requestNonce,
|
|
782
|
-
},
|
|
783
|
-
decryptResponse: function (responseBody) {
|
|
784
|
-
if (!responseBody || typeof responseBody !== "object" ||
|
|
785
|
-
typeof responseBody._ct !== "string") {
|
|
786
|
-
throw _err("CLIENT_RESPONSE_SHAPE",
|
|
787
|
-
"apiEncrypt.client: response missing _ct field");
|
|
788
|
-
}
|
|
789
|
-
var resCtBuf = Buffer.from(responseBody._ct, "base64");
|
|
790
|
-
var resPtBuf = crypto.decryptPacked(resCtBuf, sessionKey);
|
|
791
|
-
return safeJson.parse(resPtBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
|
|
792
|
-
},
|
|
793
|
-
};
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
return {
|
|
797
|
-
encryptRequest: _encryptPerSession,
|
|
798
|
-
resetSession: _resetSession,
|
|
799
|
-
sessionInfo: function () {
|
|
800
|
-
return {
|
|
801
|
-
sid: perSessionSid,
|
|
802
|
-
reqCtr: perSessionReqCtr,
|
|
803
|
-
lastResCtr: perSessionLastResCtr,
|
|
804
|
-
};
|
|
805
|
-
},
|
|
806
|
-
keying: keying,
|
|
807
|
-
};
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
// _generateUuidV4 — UUID v4 from 16 random bytes, formatted dash-separated.
|
|
811
|
-
// Used for client-side session-id generation in per-session keying.
|
|
812
|
-
// Slice offsets are RFC 4122 UUID hex-byte boundaries (`xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx`)
|
|
813
|
-
// — protocol-fixed values, not byte sizes. allow:raw-byte-literal
|
|
814
|
-
function _generateUuidV4() {
|
|
815
|
-
var b = crypto.generateBytes(16); // allow:raw-byte-literal — UUID is exactly 16 bytes
|
|
816
|
-
// Set version (4) and variant (10x) bits per RFC 4122.
|
|
817
|
-
b[6] = (b[6] & 0x0f) | 0x40;
|
|
818
|
-
b[8] = (b[8] & 0x3f) | 0x80;
|
|
819
|
-
var hex = b.toString("hex");
|
|
820
|
-
return hex.slice(0, 8) + "-" + // allow:raw-byte-literal — RFC 4122 hex offsets
|
|
821
|
-
hex.slice(8, 12) + "-" + // allow:raw-byte-literal
|
|
822
|
-
hex.slice(12, 16) + "-" + // allow:raw-byte-literal
|
|
823
|
-
hex.slice(16, 20) + "-" + // allow:raw-byte-literal
|
|
824
|
-
hex.slice(20, 32); // allow:raw-byte-literal
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
// ---- Server-to-server convenience ----
|
|
828
|
-
//
|
|
829
|
-
// Wraps the framework's HTTP client so service-to-service callers
|
|
830
|
-
// don't have to juggle encryptRequest + httpClient.request +
|
|
831
|
-
// JSON parsing + decryptResponse on every call. The pubkey is the
|
|
832
|
-
// callee's public bootstrap document (the JSON `publishPublicKey()`
|
|
833
|
-
// returns) so this helper works between any two blamejs instances.
|
|
834
|
-
//
|
|
835
|
-
// var enc = b.httpClient.encrypted({
|
|
836
|
-
// pubkey: callee.pubkey, // { publicKey, ecPublicKey }
|
|
837
|
-
// baseUrl: "https://callee.example",
|
|
838
|
-
// headers: { Authorization: "Bearer ..." },
|
|
839
|
-
// });
|
|
840
|
-
// var resp = await enc.request({
|
|
841
|
-
// method: "POST",
|
|
842
|
-
// path: "/api/widget",
|
|
843
|
-
// body: { user: "alice" },
|
|
844
|
-
// });
|
|
845
|
-
// resp.body // → decrypted plaintext object
|
|
846
|
-
//
|
|
847
|
-
// The helper handles only JSON-shaped request/response payloads,
|
|
848
|
-
// matching the middleware's contentTypes default.
|
|
849
|
-
function httpClientEncrypted(opts) {
|
|
850
|
-
opts = opts || {};
|
|
851
|
-
validateOpts(opts, [
|
|
852
|
-
"pubkey", "baseUrl", "headers", "method", "maxDecryptedBytes", "keying",
|
|
853
|
-
], "middleware.apiEncrypt.httpClient");
|
|
854
|
-
if (!opts.pubkey) {
|
|
855
|
-
throw _err("CLIENT_INVALID_PUBKEY",
|
|
856
|
-
"httpClient.encrypted: opts.pubkey is required (the callee's bootstrap doc)", 500);
|
|
857
|
-
}
|
|
858
|
-
var maxDecryptedBytes = opts.maxDecryptedBytes != null
|
|
859
|
-
? opts.maxDecryptedBytes
|
|
860
|
-
: C.BYTES.mib(4);
|
|
861
|
-
var keying = opts.keying != null ? opts.keying : "per-request";
|
|
862
|
-
var clientCtx = client({
|
|
863
|
-
pubkey: opts.pubkey,
|
|
864
|
-
maxDecryptedBytes: maxDecryptedBytes,
|
|
865
|
-
keying: keying,
|
|
866
|
-
});
|
|
867
|
-
var baseUrl = opts.baseUrl ? String(opts.baseUrl).replace(/\/$/, "") : "";
|
|
868
|
-
var defaultHdrs = opts.headers || {};
|
|
869
|
-
var defaultMethod = opts.method || "POST";
|
|
870
|
-
|
|
871
|
-
function _resolveUrl(reqOpts) {
|
|
872
|
-
if (typeof reqOpts.url === "string" && reqOpts.url.length > 0) return reqOpts.url;
|
|
873
|
-
if (typeof reqOpts.path === "string" && reqOpts.path.length > 0) {
|
|
874
|
-
if (!baseUrl) {
|
|
875
|
-
throw _err("CLIENT_INVALID_URL",
|
|
876
|
-
"httpClient.encrypted.request: { path } requires opts.baseUrl at create time", 500);
|
|
877
|
-
}
|
|
878
|
-
return baseUrl + (reqOpts.path[0] === "/" ? reqOpts.path : "/" + reqOpts.path);
|
|
879
|
-
}
|
|
880
|
-
throw _err("CLIENT_INVALID_URL",
|
|
881
|
-
"httpClient.encrypted.request: requires { url } or { path } (with opts.baseUrl)", 500);
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
async function request(reqOpts) {
|
|
885
|
-
reqOpts = reqOpts || {};
|
|
886
|
-
var url = _resolveUrl(reqOpts);
|
|
887
|
-
var encrypted = clientCtx.encryptRequest(
|
|
888
|
-
reqOpts.body !== undefined ? reqOpts.body : null
|
|
889
|
-
);
|
|
890
|
-
|
|
891
|
-
// Merge headers — operator's per-request headers win over default
|
|
892
|
-
// headers, but Content-Type is forced because the encrypted body
|
|
893
|
-
// is always JSON.
|
|
894
|
-
var headers = Object.assign({}, defaultHdrs, reqOpts.headers || {});
|
|
895
|
-
headers["Content-Type"] = "application/json";
|
|
896
|
-
|
|
897
|
-
var passThrough = {};
|
|
898
|
-
var passable = ["allowedProtocols", "allowInternal", "idleTimeoutMs", "maxResponseBytes",
|
|
899
|
-
"agent", "errorClass"];
|
|
900
|
-
for (var i = 0; i < passable.length; i++) {
|
|
901
|
-
if (reqOpts[passable[i]] !== undefined) passThrough[passable[i]] = reqOpts[passable[i]];
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
var rawBody = Buffer.from(JSON.stringify(encrypted.body), "utf8");
|
|
905
|
-
var resp = await httpClient().request(Object.assign({
|
|
906
|
-
url: url,
|
|
907
|
-
method: reqOpts.method || defaultMethod,
|
|
908
|
-
headers: headers,
|
|
909
|
-
body: rawBody,
|
|
910
|
-
}, passThrough));
|
|
911
|
-
|
|
912
|
-
// Empty body → no decryption (e.g. 204 No Content).
|
|
913
|
-
if (!resp.body || resp.body.length === 0) {
|
|
914
|
-
return { statusCode: resp.statusCode, headers: resp.headers, body: null };
|
|
915
|
-
}
|
|
916
|
-
var parsed;
|
|
917
|
-
try { parsed = safeJson.parse(resp.body.toString("utf8"), { maxBytes: maxDecryptedBytes }); }
|
|
918
|
-
catch (e) {
|
|
919
|
-
throw _err("CLIENT_RESPONSE_NOT_JSON",
|
|
920
|
-
"httpClient.encrypted: response body is not valid JSON: " + e.message);
|
|
921
|
-
}
|
|
922
|
-
return {
|
|
923
|
-
statusCode: resp.statusCode,
|
|
924
|
-
headers: resp.headers,
|
|
925
|
-
body: encrypted.decryptResponse(parsed),
|
|
926
|
-
};
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
return { request: request };
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
module.exports = Object.assign(create, {
|
|
933
|
-
client: client,
|
|
934
|
-
httpClient: httpClientEncrypted,
|
|
935
|
-
ApiEncryptError: ApiEncryptError,
|
|
936
|
-
});
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* api-encrypt — end-to-end PQC payload encryption for operator-
|
|
4
|
+
* controlled clients.
|
|
5
|
+
*
|
|
6
|
+
* TLS protects browser ↔ load-balancer; api-encrypt protects request
|
|
7
|
+
* and response bodies *end-to-end* through every intermediate hop
|
|
8
|
+
* (LB → app cleartext segment, sidecar proxy, queue, log aggregator,
|
|
9
|
+
* APM tooling). A tampered byte anywhere downstream of the encrypted
|
|
10
|
+
* boundary fails the AEAD tag at this middleware before the route
|
|
11
|
+
* handler runs.
|
|
12
|
+
*
|
|
13
|
+
* Threat model targets:
|
|
14
|
+
* - Stripped-or-MITM TLS at any internal hop
|
|
15
|
+
* - Body capture at log aggregators / APM tooling
|
|
16
|
+
* - Replay (timestamp + nonce window catches it)
|
|
17
|
+
* - Forged client requests (no key holder = no valid ciphertext)
|
|
18
|
+
*
|
|
19
|
+
* What it does NOT defend against:
|
|
20
|
+
* - Semantic attacks from authorized clients (a key-holder can
|
|
21
|
+
* encrypt a malicious payload validly — safe-schema is the next
|
|
22
|
+
* layer)
|
|
23
|
+
* - Server-side key compromise
|
|
24
|
+
* - Application logic bugs in handlers
|
|
25
|
+
*
|
|
26
|
+
* The encryption layer is for operator-controlled clients (your
|
|
27
|
+
* mobile app, your service-to-service traffic). Public APIs that
|
|
28
|
+
* accept third-party callers should use TLS + webhook signatures
|
|
29
|
+
* instead — the encryption requires a key bootstrap step.
|
|
30
|
+
*
|
|
31
|
+
* Wire format (request body, JSON):
|
|
32
|
+
*
|
|
33
|
+
* {
|
|
34
|
+
* _ek: "<base64 envelope>", // session key wrapped to server pubkey
|
|
35
|
+
* _ct: "<base64 packed>", // payload encrypted with session key
|
|
36
|
+
* _ts: 1738000000000, // unix ms
|
|
37
|
+
* _nonce: "<32 hex>" // 16 random bytes, replay-checked
|
|
38
|
+
* }
|
|
39
|
+
*
|
|
40
|
+
* Wire format (response body, JSON):
|
|
41
|
+
*
|
|
42
|
+
* { _ct: "<base64 packed>" } // same session key, fresh nonce
|
|
43
|
+
*
|
|
44
|
+
* Crypto:
|
|
45
|
+
* - _ek is the framework's standard envelope encrypt:
|
|
46
|
+
* ML-KEM-1024 + P-384 ECDH hybrid → SHAKE256 KDF → XChaCha20-Poly1305
|
|
47
|
+
* The plaintext inside the envelope is the base64-encoded session key.
|
|
48
|
+
* - _ct is the framework's encryptPacked symmetric format:
|
|
49
|
+
* 1-byte version + 24-byte XChaCha20-Poly1305 nonce + ciphertext + tag
|
|
50
|
+
* Keyed by the session key recovered from _ek.
|
|
51
|
+
*
|
|
52
|
+
* Operator API:
|
|
53
|
+
*
|
|
54
|
+
* var apiEncrypt = b.middleware.apiEncrypt({
|
|
55
|
+
* keypair: { publicKey, privateKey, ecPublicKey, ecPrivateKey },
|
|
56
|
+
* replayWindowMs: C.TIME.minutes(5),
|
|
57
|
+
* nonceStore: b.nonceStore.create({ backend: 'cluster' }),
|
|
58
|
+
* exemptPaths: ["/healthz", "/.well-known/blamejs-pubkey"],
|
|
59
|
+
* contentTypes: ["application/json"], // default; pass null to disable
|
|
60
|
+
* });
|
|
61
|
+
* router.use(apiEncrypt);
|
|
62
|
+
* router.get("/.well-known/blamejs-pubkey", apiEncrypt.publishPublicKey());
|
|
63
|
+
*
|
|
64
|
+
* // Outbound (server-to-server, browser/mobile, etc.):
|
|
65
|
+
* var client = b.middleware.apiEncrypt.client({ pubkey });
|
|
66
|
+
* var { body, decryptResponse } = client.encryptRequest({ msg: "hi" });
|
|
67
|
+
*
|
|
68
|
+
* // Server-to-server with framework HTTP client:
|
|
69
|
+
* var enc = b.httpClient.encrypted({ pubkey, baseUrl: "https://service" });
|
|
70
|
+
* var resp = await enc.request({ method: "POST", path: "/api/widget", body: { ... } });
|
|
71
|
+
*
|
|
72
|
+
* Key rotation:
|
|
73
|
+
* To rotate the server keypair, generate a new keypair and pass BOTH
|
|
74
|
+
* the new and the previous keypair to the middleware as `keypairs`:
|
|
75
|
+
*
|
|
76
|
+
* b.middleware.apiEncrypt({
|
|
77
|
+
* keypairs: [newKeypair, prevKeypair],
|
|
78
|
+
* ...
|
|
79
|
+
* });
|
|
80
|
+
*
|
|
81
|
+
* keypairs[0] is the "active" keypair — published by publishPublicKey()
|
|
82
|
+
* so new client-side bootstraps pin to it. Both keypairs are tried
|
|
83
|
+
* when decrypting `_ek`, so in-flight requests still encrypted to the
|
|
84
|
+
* previous keypair continue to decrypt for as long as the previous
|
|
85
|
+
* keypair stays in the array. Operators drop the previous keypair
|
|
86
|
+
* from the array once the rotation overlap window has elapsed.
|
|
87
|
+
*
|
|
88
|
+
* Failure surfacing:
|
|
89
|
+
* AEAD tag failure / stale timestamp / replay / malformed envelope
|
|
90
|
+
* all return 400 with the same body { error: "encrypted-payload-rejected" }.
|
|
91
|
+
* The category that actually matched lands in the audit event +
|
|
92
|
+
* b.events.API_ENCRYPT_FAILURE so operators get metrics / alerting
|
|
93
|
+
* without leaking which check the attacker tripped. Missing _ek /
|
|
94
|
+
* _ct / _ts / _nonce on a non-exempt path is distinguishable in the
|
|
95
|
+
* response ("encrypted-payload-required") so operators with hybrid
|
|
96
|
+
* public/private routes can debug their wiring.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
var crypto = require("../crypto");
|
|
100
|
+
var C = require("../constants");
|
|
101
|
+
var lazyRequire = require("../lazy-require");
|
|
102
|
+
var nonceStoreLib = require("../nonce-store");
|
|
103
|
+
var requestHelpers = require("../request-helpers");
|
|
104
|
+
var safeJson = require("../safe-json");
|
|
105
|
+
var validateOpts = require("../validate-opts");
|
|
106
|
+
var { defineClass } = require("../framework-error");
|
|
107
|
+
|
|
108
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
109
|
+
var events = lazyRequire(function () { return require("../events"); });
|
|
110
|
+
var httpClient = lazyRequire(function () { return require("../http-client"); });
|
|
111
|
+
var logger = lazyRequire(function () { return require("../log").boot("api-encrypt"); });
|
|
112
|
+
|
|
113
|
+
var ApiEncryptError = defineClass("ApiEncryptError", { withStatusCode: true });
|
|
114
|
+
|
|
115
|
+
var DEFAULT_REPLAY_WINDOW_MS = C.TIME.minutes(5);
|
|
116
|
+
var DEFAULT_CONTENT_TYPES = ["application/json"];
|
|
117
|
+
var SESSION_KEY_BYTES = C.BYTES.bytes(32);
|
|
118
|
+
var REQUEST_NONCE_BYTES = C.BYTES.bytes(16);
|
|
119
|
+
var DEFAULT_SESSION_TTL_MS = C.TIME.minutes(15);
|
|
120
|
+
// 1024 ≈ "a session with a thousand response rotations" — round-number
|
|
121
|
+
// kibi-aligned default; operators raise this for chat / streaming sessions
|
|
122
|
+
// or lower it for strict per-key forward-secrecy postures.
|
|
123
|
+
var DEFAULT_SESSION_MAX_RESPONSES = 0x400;
|
|
124
|
+
// SID format: UUID-shaped string. Operators with their own session-id
|
|
125
|
+
// vocabulary subscribe to the same shape (cluster-storage / cache backends
|
|
126
|
+
// already index on string keys).
|
|
127
|
+
var SID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
|
|
128
|
+
var SID_MAX_LENGTH = C.BYTES.bytes(64);
|
|
129
|
+
|
|
130
|
+
function _err(code, message, statusCode) {
|
|
131
|
+
return new ApiEncryptError(code, message, true, statusCode || 400);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _validateKeypair(kp, label) {
|
|
135
|
+
if (!kp || typeof kp !== "object") {
|
|
136
|
+
throw _err("INVALID_KEYPAIR", "apiEncrypt: " + label + " is required", 500);
|
|
137
|
+
}
|
|
138
|
+
if (typeof kp.publicKey !== "string" || typeof kp.privateKey !== "string") {
|
|
139
|
+
throw _err("INVALID_KEYPAIR",
|
|
140
|
+
"apiEncrypt: " + label + ".publicKey + .privateKey are required (ML-KEM-1024 PEM)", 500);
|
|
141
|
+
}
|
|
142
|
+
if (typeof kp.ecPublicKey !== "string" || typeof kp.ecPrivateKey !== "string") {
|
|
143
|
+
throw _err("INVALID_KEYPAIR",
|
|
144
|
+
"apiEncrypt: " + label + ".ecPublicKey + .ecPrivateKey are required (P-384 PEM hybrid)", 500);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Resolve the operator's keypair input into an ordered array. The
|
|
149
|
+
// first keypair is "active" — used by publishPublicKey() and as the
|
|
150
|
+
// hint for response encryption (responses use the per-request session
|
|
151
|
+
// key, so the active keypair only matters for what the bootstrap
|
|
152
|
+
// endpoint advertises). Every keypair in the array is tried in order
|
|
153
|
+
// when decrypting `_ek` so that during a rotation overlap window,
|
|
154
|
+
// in-flight requests encrypted to a previous keypair still decrypt
|
|
155
|
+
// successfully.
|
|
156
|
+
function _resolveKeypairs(opts) {
|
|
157
|
+
if (Array.isArray(opts.keypairs)) {
|
|
158
|
+
if (opts.keypairs.length === 0) {
|
|
159
|
+
throw _err("INVALID_KEYPAIR", "apiEncrypt: keypairs must be a non-empty array", 500);
|
|
160
|
+
}
|
|
161
|
+
opts.keypairs.forEach(function (kp, i) { _validateKeypair(kp, "keypairs[" + i + "]"); });
|
|
162
|
+
return opts.keypairs.slice();
|
|
163
|
+
}
|
|
164
|
+
if (opts.keypair) {
|
|
165
|
+
_validateKeypair(opts.keypair, "keypair");
|
|
166
|
+
return [opts.keypair];
|
|
167
|
+
}
|
|
168
|
+
throw _err("INVALID_KEYPAIR",
|
|
169
|
+
"apiEncrypt: { keypair } or { keypairs: [...] } is required", 500);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
var HTTP_STATUS = requestHelpers.HTTP_STATUS;
|
|
173
|
+
|
|
174
|
+
// _defaultSessionStore — in-memory session table for single-process
|
|
175
|
+
// deployments. Operators with multi-replica deploys pass an
|
|
176
|
+
// operator-supplied store (b.cache.create({ backend: "cluster" }) or any
|
|
177
|
+
// `{ get, set, delete }`-shaped handle). Per-replica isolation in default
|
|
178
|
+
// mode means sticky sessions; the limit is documented in the wiki.
|
|
179
|
+
//
|
|
180
|
+
// .get(sid) → row | null
|
|
181
|
+
// .set(sid, row, { ttlMs }) → void
|
|
182
|
+
// .delete(sid) → void
|
|
183
|
+
//
|
|
184
|
+
// Each row stores:
|
|
185
|
+
// { sessionKey: Buffer, lastReqCtr: int, responsesEmitted: int,
|
|
186
|
+
// createdAt: ms, expiresAt: ms, lastUsedAt: ms }
|
|
187
|
+
function _defaultSessionStore() {
|
|
188
|
+
var rows = new Map();
|
|
189
|
+
return {
|
|
190
|
+
get: function (sid) {
|
|
191
|
+
var row = rows.get(sid);
|
|
192
|
+
if (!row) return null;
|
|
193
|
+
if (Date.now() > row.expiresAt) {
|
|
194
|
+
rows.delete(sid);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return row;
|
|
198
|
+
},
|
|
199
|
+
set: function (sid, row /* opts */) {
|
|
200
|
+
rows.set(sid, row);
|
|
201
|
+
},
|
|
202
|
+
delete: function (sid) {
|
|
203
|
+
rows.delete(sid);
|
|
204
|
+
},
|
|
205
|
+
purgeExpired: function () {
|
|
206
|
+
var now = Date.now();
|
|
207
|
+
var purged = 0;
|
|
208
|
+
rows.forEach(function (row, sid) {
|
|
209
|
+
if (now > row.expiresAt) { rows.delete(sid); purged += 1; }
|
|
210
|
+
});
|
|
211
|
+
return purged;
|
|
212
|
+
},
|
|
213
|
+
size: function () { return rows.size; },
|
|
214
|
+
close: function () { rows.clear(); },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function _validSid(sid) {
|
|
219
|
+
return typeof sid === "string" &&
|
|
220
|
+
sid.length > 0 &&
|
|
221
|
+
sid.length <= SID_MAX_LENGTH &&
|
|
222
|
+
SID_RE.test(sid);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function _writeRejection(res, code, body) {
|
|
226
|
+
if (res.headersSent || res.writableEnded) return;
|
|
227
|
+
if (typeof res.writeHead === "function") {
|
|
228
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
229
|
+
res.end(JSON.stringify(body));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- Server-side middleware ----
|
|
234
|
+
|
|
235
|
+
function create(opts) {
|
|
236
|
+
opts = opts || {};
|
|
237
|
+
validateOpts(opts, [
|
|
238
|
+
"keypair", "keypairs", "replayWindowMs", "pruneIntervalMs",
|
|
239
|
+
"nonceStore", "exemptPaths", "contentTypes", "audit",
|
|
240
|
+
"maxDecryptedBytes", "trustProxy",
|
|
241
|
+
// Per-session keying mode (opt-in; per-request stays default).
|
|
242
|
+
"keying", "sessionStore", "sessionTtlMs", "sessionMaxResponses",
|
|
243
|
+
"observability",
|
|
244
|
+
], "middleware.apiEncrypt");
|
|
245
|
+
var keypairs = _resolveKeypairs(opts);
|
|
246
|
+
var activeKeypair = keypairs[0];
|
|
247
|
+
var replayWindowMs = opts.replayWindowMs || DEFAULT_REPLAY_WINDOW_MS;
|
|
248
|
+
// Cap on decrypted-payload size handed to safeJson.parse. Defaults
|
|
249
|
+
// to 4 MiB (bodyParser's default 1 MiB plus headroom for crypto +
|
|
250
|
+
// base64 round-trip). Operators with chunkier inbound payloads
|
|
251
|
+
// raise this; the framework refuses to parse anything larger as a
|
|
252
|
+
// parse-bomb defense.
|
|
253
|
+
var maxDecryptedBytes = opts.maxDecryptedBytes != null
|
|
254
|
+
? opts.maxDecryptedBytes
|
|
255
|
+
: C.BYTES.mib(4);
|
|
256
|
+
// The spec calls for a sweep cadence of replayWindowMs/2 — short
|
|
257
|
+
// enough that expired nonces don't pile up but not so frequent the
|
|
258
|
+
// sweep query becomes a hot path. Operators can override.
|
|
259
|
+
var pruneIntervalMs = opts.pruneIntervalMs != null
|
|
260
|
+
? opts.pruneIntervalMs : Math.max(C.TIME.seconds(30), Math.floor(replayWindowMs / 2));
|
|
261
|
+
var nonceStore = opts.nonceStore || nonceStoreLib.create({ backend: "memory" });
|
|
262
|
+
var exemptPaths = Array.isArray(opts.exemptPaths) ? opts.exemptPaths.slice() : [];
|
|
263
|
+
// contentTypes scoping — middleware only operates on requests whose
|
|
264
|
+
// Content-Type is in this list. Default JSON; operators with more
|
|
265
|
+
// exotic clients (form-encoded, gRPC-web, etc.) widen the list.
|
|
266
|
+
// Set to null/false/empty array to disable content-type filtering
|
|
267
|
+
// (treat every non-exempt request as encrypted).
|
|
268
|
+
var contentTypes = opts.contentTypes === null || opts.contentTypes === false
|
|
269
|
+
? null
|
|
270
|
+
: (Array.isArray(opts.contentTypes) && opts.contentTypes.length > 0
|
|
271
|
+
? opts.contentTypes.slice()
|
|
272
|
+
: DEFAULT_CONTENT_TYPES.slice());
|
|
273
|
+
var auditOn = opts.audit !== false;
|
|
274
|
+
var trustProxy = opts.trustProxy === true;
|
|
275
|
+
var lastPruneAt = 0;
|
|
276
|
+
|
|
277
|
+
// ---- per-session keying opts ----
|
|
278
|
+
var keying = opts.keying != null ? opts.keying : "per-request";
|
|
279
|
+
if (keying !== "per-request" && keying !== "per-session") {
|
|
280
|
+
throw _err("BAD_OPT",
|
|
281
|
+
"apiEncrypt: keying must be 'per-request' (default) or 'per-session', got " +
|
|
282
|
+
JSON.stringify(opts.keying), 500);
|
|
283
|
+
}
|
|
284
|
+
var sessionTtlMs = opts.sessionTtlMs != null ? opts.sessionTtlMs : DEFAULT_SESSION_TTL_MS;
|
|
285
|
+
var sessionMaxResponses = opts.sessionMaxResponses != null
|
|
286
|
+
? opts.sessionMaxResponses : DEFAULT_SESSION_MAX_RESPONSES;
|
|
287
|
+
if (typeof sessionTtlMs !== "number" || !isFinite(sessionTtlMs) || sessionTtlMs <= 0) {
|
|
288
|
+
throw _err("BAD_OPT",
|
|
289
|
+
"apiEncrypt: sessionTtlMs must be a positive finite number (ms), got " +
|
|
290
|
+
JSON.stringify(opts.sessionTtlMs), 500);
|
|
291
|
+
}
|
|
292
|
+
if (typeof sessionMaxResponses !== "number" || !isFinite(sessionMaxResponses) ||
|
|
293
|
+
sessionMaxResponses <= 0 || Math.floor(sessionMaxResponses) !== sessionMaxResponses) {
|
|
294
|
+
throw _err("BAD_OPT",
|
|
295
|
+
"apiEncrypt: sessionMaxResponses must be a positive finite integer, got " +
|
|
296
|
+
JSON.stringify(opts.sessionMaxResponses), 500);
|
|
297
|
+
}
|
|
298
|
+
// sessionStore — duck-typed handle exposing { get, set, delete }. The
|
|
299
|
+
// helper optionalObjectWithMethod only checks one method; here we need
|
|
300
|
+
// three. Inline shape kept; not a generic enough pattern to warrant a
|
|
301
|
+
// separate helper.
|
|
302
|
+
if (opts.sessionStore !== undefined && opts.sessionStore !== null) {
|
|
303
|
+
var ss = opts.sessionStore;
|
|
304
|
+
var ssOk = typeof ss === "object" &&
|
|
305
|
+
typeof ss.get === "function" &&
|
|
306
|
+
typeof ss.set === "function" &&
|
|
307
|
+
typeof ss.delete === "function";
|
|
308
|
+
if (!ssOk) {
|
|
309
|
+
throw _err("BAD_OPT",
|
|
310
|
+
"apiEncrypt: sessionStore must expose { get(sid), set(sid, row, opts?), delete(sid) } " +
|
|
311
|
+
"(b.cache.create() is shape-compatible)", 500);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
var sessionStore = (keying === "per-session" && opts.sessionStore)
|
|
315
|
+
? opts.sessionStore
|
|
316
|
+
: (keying === "per-session" ? _defaultSessionStore() : null);
|
|
317
|
+
// Observability tap — per-session emits counters for sessions
|
|
318
|
+
// established / replay-rejected / expired / rotated. Per-request mode
|
|
319
|
+
// ignores this opt; the existing events.API_ENCRYPT_FAILURE channel
|
|
320
|
+
// already carries failure shape there.
|
|
321
|
+
validateOpts.observabilityShape(opts.observability,
|
|
322
|
+
"apiEncrypt", ApiEncryptError, "BAD_OPT");
|
|
323
|
+
var observabilityHandle = opts.observability || null;
|
|
324
|
+
function _emitObs(name, value, labels) {
|
|
325
|
+
if (observabilityHandle) {
|
|
326
|
+
observabilityHandle.safeEvent(name, value, labels || {});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function _emitSessionAudit(action, info) {
|
|
330
|
+
if (!auditOn) return;
|
|
331
|
+
try {
|
|
332
|
+
audit().safeEmit({
|
|
333
|
+
action: action, outcome: info.outcome || "success",
|
|
334
|
+
metadata: info.metadata || {},
|
|
335
|
+
actor: info.actor || null,
|
|
336
|
+
requestId: info.requestId || null,
|
|
337
|
+
});
|
|
338
|
+
} catch (_e) { /* audit best-effort */ }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function _isExempt(req) {
|
|
342
|
+
var p = req.pathname || (req.url || "/").split("?")[0];
|
|
343
|
+
for (var i = 0; i < exemptPaths.length; i++) {
|
|
344
|
+
var rule = exemptPaths[i];
|
|
345
|
+
if (typeof rule === "string" ? p === rule || p.indexOf(rule + "/") === 0 : rule.test(p)) {
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function _matchesContentType(req) {
|
|
353
|
+
if (!contentTypes) return true; // filtering disabled
|
|
354
|
+
var ct = req.headers && (req.headers["content-type"] || req.headers["Content-Type"]);
|
|
355
|
+
if (typeof ct !== "string") return false;
|
|
356
|
+
// Strip parameters like "; charset=utf-8"
|
|
357
|
+
var bare = ct.split(";")[0].trim().toLowerCase();
|
|
358
|
+
for (var i = 0; i < contentTypes.length; i++) {
|
|
359
|
+
if (contentTypes[i].toLowerCase() === bare) return true;
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function _emitFailure(req, reason) {
|
|
365
|
+
var info = {
|
|
366
|
+
reason: reason,
|
|
367
|
+
ip: requestHelpers.clientIp(req, { trustProxy: trustProxy }),
|
|
368
|
+
path: req.pathname || (req.url || "/").split("?")[0],
|
|
369
|
+
method: req.method,
|
|
370
|
+
ts: new Date().toISOString(),
|
|
371
|
+
requestId: req.requestId || null,
|
|
372
|
+
};
|
|
373
|
+
if (auditOn) {
|
|
374
|
+
audit().safeEmit({
|
|
375
|
+
actor: requestHelpers.extractActorContext(req),
|
|
376
|
+
action: "system.api_encrypt.failure",
|
|
377
|
+
outcome: "denied",
|
|
378
|
+
reason: reason,
|
|
379
|
+
metadata: { reason: reason, path: info.path, method: info.method },
|
|
380
|
+
requestId: info.requestId,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
try { events().emit(events().EVENTS.API_ENCRYPT_FAILURE, info); }
|
|
384
|
+
catch (_e) { /* events best-effort */ }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function _maybePrune() {
|
|
388
|
+
var now = Date.now();
|
|
389
|
+
if (now - lastPruneAt < pruneIntervalMs) return;
|
|
390
|
+
lastPruneAt = now;
|
|
391
|
+
nonceStore.purgeExpired().catch(function (e) {
|
|
392
|
+
try {
|
|
393
|
+
logger().warn("nonce-store prune failed: " + ((e && e.message) || String(e)));
|
|
394
|
+
} catch (_e) { /* logger best-effort */ }
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// _wrapResJson — install res.json that encrypts the response with the
|
|
399
|
+
// session key. In per-request mode the response is `{ _ct }`; in
|
|
400
|
+
// per-session mode it carries `{ _ct, _sid, _ctr }` so the client can
|
|
401
|
+
// detect tampered / replayed responses with a monotonic counter check.
|
|
402
|
+
function _wrapResJson(res, sessionKey, sessionCtx) {
|
|
403
|
+
var origJson = res.json;
|
|
404
|
+
res.json = function (data) {
|
|
405
|
+
try {
|
|
406
|
+
var ptBuf = Buffer.from(JSON.stringify(data), "utf8");
|
|
407
|
+
var ctBuf = crypto.encryptPacked(ptBuf, sessionKey);
|
|
408
|
+
var encrypted = { _ct: ctBuf.toString("base64") };
|
|
409
|
+
if (sessionCtx) {
|
|
410
|
+
encrypted._sid = sessionCtx.sid;
|
|
411
|
+
encrypted._ctr = sessionCtx.responseCtr;
|
|
412
|
+
}
|
|
413
|
+
if (typeof origJson === "function") {
|
|
414
|
+
return origJson.call(res, encrypted);
|
|
415
|
+
}
|
|
416
|
+
// Fallback if router didn't install res.json yet.
|
|
417
|
+
if (!res.headersSent) {
|
|
418
|
+
res.writeHead(res.statusCode || HTTP_STATUS.OK, { "Content-Type": "application/json" });
|
|
419
|
+
}
|
|
420
|
+
res.end(JSON.stringify(encrypted));
|
|
421
|
+
} catch (e) {
|
|
422
|
+
try {
|
|
423
|
+
logger().error("response encryption failed: " + ((e && e.message) || String(e)));
|
|
424
|
+
} catch (_e) { /* logger best-effort */ }
|
|
425
|
+
if (!res.headersSent) {
|
|
426
|
+
res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, { "Content-Type": "application/json" });
|
|
427
|
+
}
|
|
428
|
+
res.end(JSON.stringify({ error: "response-encryption-failed" }));
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// _decryptEkToSessionKey — try every keypair in order; returns the
|
|
434
|
+
// 32-byte sessionKey buffer or null on AEAD failure across all keypairs.
|
|
435
|
+
function _decryptEkToSessionKey(ek) {
|
|
436
|
+
for (var ki = 0; ki < keypairs.length; ki++) {
|
|
437
|
+
try {
|
|
438
|
+
var sessionKeyB64 = crypto.decrypt(ek, keypairs[ki]);
|
|
439
|
+
var candidate = Buffer.from(sessionKeyB64, "base64");
|
|
440
|
+
if (candidate.length === SESSION_KEY_BYTES) return candidate;
|
|
441
|
+
} catch (_e) { /* try next keypair */ }
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function middleware(req, res, next) {
|
|
447
|
+
if (_isExempt(req)) return next();
|
|
448
|
+
if (!_matchesContentType(req)) return next();
|
|
449
|
+
|
|
450
|
+
var body = req.body;
|
|
451
|
+
if (!body || typeof body !== "object") {
|
|
452
|
+
_emitFailure(req, "shape");
|
|
453
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
var now = Date.now();
|
|
457
|
+
var ct = body._ct, ts = body._ts;
|
|
458
|
+
if (typeof ct !== "string" || typeof ts !== "number") {
|
|
459
|
+
_emitFailure(req, "shape");
|
|
460
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
461
|
+
}
|
|
462
|
+
if (Math.abs(now - ts) > replayWindowMs) {
|
|
463
|
+
_emitFailure(req, "stale");
|
|
464
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Per-request OR per-session bootstrap path: shape includes _ek + _nonce.
|
|
468
|
+
// Per-session subsequent path: shape includes _sid + _ctr (no _ek).
|
|
469
|
+
var ek = body._ek, nonce = body._nonce, sid = body._sid, ctr = body._ctr;
|
|
470
|
+
var sessionKey = null;
|
|
471
|
+
var sessionCtx = null; // null = per-request mode response shape
|
|
472
|
+
var session = null;
|
|
473
|
+
|
|
474
|
+
if (typeof ek === "string" && typeof nonce === "string") {
|
|
475
|
+
// ---- Bootstrap path (per-request mode OR first request of session) ----
|
|
476
|
+
var nonceHash = crypto.sha3Hash(nonce, "hex");
|
|
477
|
+
var expireAt = now + replayWindowMs;
|
|
478
|
+
var freshNonce;
|
|
479
|
+
try { freshNonce = await nonceStore.checkAndInsert(nonceHash, expireAt); }
|
|
480
|
+
catch (_e) {
|
|
481
|
+
_emitFailure(req, "nonce-store-error");
|
|
482
|
+
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
|
|
483
|
+
}
|
|
484
|
+
if (!freshNonce) {
|
|
485
|
+
_emitFailure(req, "replay");
|
|
486
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
487
|
+
}
|
|
488
|
+
sessionKey = _decryptEkToSessionKey(ek);
|
|
489
|
+
if (!sessionKey) {
|
|
490
|
+
_emitFailure(req, "tag");
|
|
491
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
492
|
+
}
|
|
493
|
+
if (keying === "per-session") {
|
|
494
|
+
if (!_validSid(sid)) {
|
|
495
|
+
_emitFailure(req, "shape");
|
|
496
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
497
|
+
}
|
|
498
|
+
if (typeof ctr !== "number" || !isFinite(ctr) || ctr < 0 || Math.floor(ctr) !== ctr) {
|
|
499
|
+
_emitFailure(req, "shape");
|
|
500
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
501
|
+
}
|
|
502
|
+
// Bootstrap a new session row keyed by sid.
|
|
503
|
+
session = {
|
|
504
|
+
sessionKey: sessionKey,
|
|
505
|
+
lastReqCtr: ctr,
|
|
506
|
+
responsesEmitted: 0,
|
|
507
|
+
createdAt: now,
|
|
508
|
+
lastUsedAt: now,
|
|
509
|
+
expiresAt: now + sessionTtlMs,
|
|
510
|
+
};
|
|
511
|
+
try { await sessionStore.set(sid, session, { ttlMs: sessionTtlMs }); }
|
|
512
|
+
catch (_e) {
|
|
513
|
+
_emitFailure(req, "session-store-error");
|
|
514
|
+
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
|
|
515
|
+
}
|
|
516
|
+
_emitObs("apiEncrypt.session.created", 1, { mode: "per-session" });
|
|
517
|
+
_emitSessionAudit("apiEncrypt.session.created", {
|
|
518
|
+
actor: requestHelpers.extractActorContext(req),
|
|
519
|
+
metadata: { sid: sid, expiresAt: session.expiresAt },
|
|
520
|
+
requestId: req.requestId || null,
|
|
521
|
+
});
|
|
522
|
+
sessionCtx = { sid: sid, responseCtr: 1 };
|
|
523
|
+
session.responsesEmitted = 1;
|
|
524
|
+
}
|
|
525
|
+
} else if (keying === "per-session" &&
|
|
526
|
+
typeof sid === "string" && typeof ctr === "number") {
|
|
527
|
+
// ---- Per-session subsequent-request path ----
|
|
528
|
+
if (!_validSid(sid)) {
|
|
529
|
+
_emitFailure(req, "shape");
|
|
530
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
531
|
+
}
|
|
532
|
+
if (!isFinite(ctr) || ctr < 0 || Math.floor(ctr) !== ctr) {
|
|
533
|
+
_emitFailure(req, "shape");
|
|
534
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
535
|
+
}
|
|
536
|
+
try { session = await sessionStore.get(sid); }
|
|
537
|
+
catch (_e) {
|
|
538
|
+
_emitFailure(req, "session-store-error");
|
|
539
|
+
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
|
|
540
|
+
}
|
|
541
|
+
if (!session) {
|
|
542
|
+
_emitObs("apiEncrypt.session.unknown", 1, {});
|
|
543
|
+
_emitFailure(req, "session-unknown");
|
|
544
|
+
return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-unknown" });
|
|
545
|
+
}
|
|
546
|
+
if (now > session.expiresAt) {
|
|
547
|
+
try { await sessionStore.delete(sid); } catch (_e) { /* best-effort */ }
|
|
548
|
+
_emitObs("apiEncrypt.session.expired", 1, {});
|
|
549
|
+
_emitSessionAudit("apiEncrypt.session.expired", {
|
|
550
|
+
outcome: "denied",
|
|
551
|
+
actor: requestHelpers.extractActorContext(req),
|
|
552
|
+
metadata: { sid: sid, reason: "ttl_exceeded" },
|
|
553
|
+
requestId: req.requestId || null,
|
|
554
|
+
});
|
|
555
|
+
_emitFailure(req, "session-expired");
|
|
556
|
+
return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-expired" });
|
|
557
|
+
}
|
|
558
|
+
if (session.responsesEmitted >= sessionMaxResponses) {
|
|
559
|
+
try { await sessionStore.delete(sid); } catch (_e) { /* best-effort */ }
|
|
560
|
+
_emitObs("apiEncrypt.session.rotated", 1, { reason: "max_responses" });
|
|
561
|
+
_emitSessionAudit("apiEncrypt.session.rotated", {
|
|
562
|
+
actor: requestHelpers.extractActorContext(req),
|
|
563
|
+
metadata: { sid: sid, reason: "max_responses_exceeded",
|
|
564
|
+
responsesEmitted: session.responsesEmitted },
|
|
565
|
+
requestId: req.requestId || null,
|
|
566
|
+
});
|
|
567
|
+
_emitFailure(req, "session-rotation-required");
|
|
568
|
+
return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-rotation-required" });
|
|
569
|
+
}
|
|
570
|
+
// Replay defense: counter MUST strictly increase.
|
|
571
|
+
if (ctr <= session.lastReqCtr) {
|
|
572
|
+
_emitObs("apiEncrypt.session.replay_rejected", 1, {});
|
|
573
|
+
_emitSessionAudit("apiEncrypt.session.replay_rejected", {
|
|
574
|
+
outcome: "denied",
|
|
575
|
+
actor: requestHelpers.extractActorContext(req),
|
|
576
|
+
metadata: { sid: sid, receivedCtr: ctr, lastSeen: session.lastReqCtr },
|
|
577
|
+
requestId: req.requestId || null,
|
|
578
|
+
});
|
|
579
|
+
_emitFailure(req, "counter-replay");
|
|
580
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
581
|
+
}
|
|
582
|
+
sessionKey = session.sessionKey;
|
|
583
|
+
if (Buffer.isBuffer(sessionKey) === false) {
|
|
584
|
+
// Operator-supplied store may have JSON-serialised the buffer.
|
|
585
|
+
// Accept hex / base64 / Uint8Array and coerce.
|
|
586
|
+
if (typeof sessionKey === "string") {
|
|
587
|
+
sessionKey = Buffer.from(sessionKey, "base64");
|
|
588
|
+
} else if (sessionKey && sessionKey.type === "Buffer" && Array.isArray(sessionKey.data)) {
|
|
589
|
+
sessionKey = Buffer.from(sessionKey.data);
|
|
590
|
+
} else if (sessionKey instanceof Uint8Array) {
|
|
591
|
+
sessionKey = Buffer.from(sessionKey);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (!Buffer.isBuffer(sessionKey) || sessionKey.length !== SESSION_KEY_BYTES) {
|
|
595
|
+
_emitFailure(req, "session-store-error");
|
|
596
|
+
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
|
|
597
|
+
}
|
|
598
|
+
session.lastReqCtr = ctr;
|
|
599
|
+
session.lastUsedAt = now;
|
|
600
|
+
session.responsesEmitted += 1;
|
|
601
|
+
try { await sessionStore.set(sid, session, { ttlMs: session.expiresAt - now }); }
|
|
602
|
+
catch (_e) { /* best-effort — request still proceeds */ }
|
|
603
|
+
sessionCtx = { sid: sid, responseCtr: session.responsesEmitted };
|
|
604
|
+
} else {
|
|
605
|
+
_emitFailure(req, "shape");
|
|
606
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Decrypt _ct → cleartext payload bytes → JSON object.
|
|
610
|
+
var clearObj;
|
|
611
|
+
try {
|
|
612
|
+
var ctBuf = Buffer.from(ct, "base64");
|
|
613
|
+
var ptBuf = crypto.decryptPacked(ctBuf, sessionKey);
|
|
614
|
+
clearObj = safeJson.parse(ptBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
|
|
615
|
+
} catch (_e) {
|
|
616
|
+
_emitFailure(req, "tag");
|
|
617
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Replace req.body with cleartext, stash session key for any
|
|
621
|
+
// operator code that wants to attach extra encrypted side-channel
|
|
622
|
+
// data (e.g. send a follow-up encrypted SSE event).
|
|
623
|
+
req.body = clearObj;
|
|
624
|
+
req.apiEncryptSessionKey = sessionKey;
|
|
625
|
+
if (sessionCtx) req.apiEncryptSession = { sid: sessionCtx.sid };
|
|
626
|
+
|
|
627
|
+
_wrapResJson(res, sessionKey, sessionCtx);
|
|
628
|
+
_maybePrune();
|
|
629
|
+
|
|
630
|
+
return next();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Route handler that publishes the server's public keys for client
|
|
634
|
+
// bootstrap. Returns the PEM strings + KEM ID + a stable cache hint
|
|
635
|
+
// so clients can pin / rotate based on the published keys.
|
|
636
|
+
function publishPublicKey() {
|
|
637
|
+
return function publishHandler(_req, res) {
|
|
638
|
+
var body = {
|
|
639
|
+
publicKey: activeKeypair.publicKey,
|
|
640
|
+
ecPublicKey: activeKeypair.ecPublicKey,
|
|
641
|
+
kemId: C.ACTIVE.KEM,
|
|
642
|
+
cipherId: C.ACTIVE.CIPHER,
|
|
643
|
+
kdfId: C.ACTIVE.KDF,
|
|
644
|
+
};
|
|
645
|
+
if (typeof res.json === "function") return res.json(body);
|
|
646
|
+
if (!res.headersSent) {
|
|
647
|
+
res.writeHead(HTTP_STATUS.OK, { "Content-Type": "application/json" });
|
|
648
|
+
}
|
|
649
|
+
res.end(JSON.stringify(body));
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
middleware.publishPublicKey = publishPublicKey;
|
|
654
|
+
middleware.close = function () {
|
|
655
|
+
if (typeof nonceStore.close === "function") nonceStore.close();
|
|
656
|
+
if (sessionStore && typeof sessionStore.close === "function") sessionStore.close();
|
|
657
|
+
};
|
|
658
|
+
// Expose for tests / operator dashboards. Counts are 0 in per-request mode.
|
|
659
|
+
middleware.sessionStore = sessionStore;
|
|
660
|
+
middleware.keying = keying;
|
|
661
|
+
|
|
662
|
+
return middleware;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ---- Client-side helper ----
|
|
666
|
+
//
|
|
667
|
+
// Operators import this in their browser/mobile/native code or in
|
|
668
|
+
// service-to-service callers. The pubkey shape MUST match what
|
|
669
|
+
// publishPublicKey() returns: { publicKey, ecPublicKey, kemId,
|
|
670
|
+
// cipherId, kdfId }.
|
|
671
|
+
|
|
672
|
+
function client(opts) {
|
|
673
|
+
opts = opts || {};
|
|
674
|
+
validateOpts(opts, ["pubkey", "maxDecryptedBytes", "keying"], "middleware.apiEncrypt.client");
|
|
675
|
+
if (!opts.pubkey || typeof opts.pubkey !== "object") {
|
|
676
|
+
throw _err("CLIENT_INVALID_PUBKEY",
|
|
677
|
+
"apiEncrypt.client: opts.pubkey is required ({ publicKey, ecPublicKey })", 500);
|
|
678
|
+
}
|
|
679
|
+
if (typeof opts.pubkey.publicKey !== "string" ||
|
|
680
|
+
typeof opts.pubkey.ecPublicKey !== "string") {
|
|
681
|
+
throw _err("CLIENT_INVALID_PUBKEY",
|
|
682
|
+
"apiEncrypt.client: pubkey.publicKey + ecPublicKey must be PEM strings", 500);
|
|
683
|
+
}
|
|
684
|
+
var pubkey = opts.pubkey;
|
|
685
|
+
var maxDecryptedBytes = opts.maxDecryptedBytes != null
|
|
686
|
+
? opts.maxDecryptedBytes
|
|
687
|
+
: C.BYTES.mib(4);
|
|
688
|
+
var keying = opts.keying != null ? opts.keying : "per-request";
|
|
689
|
+
if (keying !== "per-request" && keying !== "per-session") {
|
|
690
|
+
throw _err("CLIENT_BAD_OPT",
|
|
691
|
+
"apiEncrypt.client: keying must be 'per-request' (default) or 'per-session', got " +
|
|
692
|
+
JSON.stringify(opts.keying), 500);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (keying === "per-request") {
|
|
696
|
+
return { encryptRequest: _encryptPerRequest, keying: keying };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Per-session: stateful client. encryptRequest mutates internal counter.
|
|
700
|
+
// First call sends the bootstrap envelope; subsequent calls omit _ek/_nonce
|
|
701
|
+
// and increment the counter. Operator can call resetSession() to force a
|
|
702
|
+
// new bootstrap (e.g. after server returns "session-expired").
|
|
703
|
+
var perSessionKey = null;
|
|
704
|
+
var perSessionSid = null;
|
|
705
|
+
var perSessionReqCtr = 0;
|
|
706
|
+
var perSessionLastResCtr = 0;
|
|
707
|
+
|
|
708
|
+
function _resetSession() {
|
|
709
|
+
perSessionKey = crypto.generateBytes(SESSION_KEY_BYTES);
|
|
710
|
+
perSessionSid = _generateUuidV4();
|
|
711
|
+
perSessionReqCtr = 0;
|
|
712
|
+
perSessionLastResCtr = 0;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function _decryptPerSessionResponse(responseBody) {
|
|
716
|
+
if (!responseBody || typeof responseBody !== "object" ||
|
|
717
|
+
typeof responseBody._ct !== "string") {
|
|
718
|
+
throw _err("CLIENT_RESPONSE_SHAPE",
|
|
719
|
+
"apiEncrypt.client: response missing _ct field");
|
|
720
|
+
}
|
|
721
|
+
if (typeof responseBody._sid !== "string" || responseBody._sid !== perSessionSid) {
|
|
722
|
+
throw _err("CLIENT_RESPONSE_SID",
|
|
723
|
+
"apiEncrypt.client: response sid does not match opened session");
|
|
724
|
+
}
|
|
725
|
+
if (typeof responseBody._ctr !== "number" || responseBody._ctr <= perSessionLastResCtr) {
|
|
726
|
+
throw _err("CLIENT_RESPONSE_REPLAY",
|
|
727
|
+
"apiEncrypt.client: response counter is not strictly increasing " +
|
|
728
|
+
"(got " + responseBody._ctr + ", lastSeen " + perSessionLastResCtr + ")");
|
|
729
|
+
}
|
|
730
|
+
perSessionLastResCtr = responseBody._ctr;
|
|
731
|
+
var resCtBuf = Buffer.from(responseBody._ct, "base64");
|
|
732
|
+
var resPtBuf = crypto.decryptPacked(resCtBuf, perSessionKey);
|
|
733
|
+
return safeJson.parse(resPtBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function _encryptPerSession(payload) {
|
|
737
|
+
if (payload === undefined) payload = null;
|
|
738
|
+
if (!perSessionKey) _resetSession();
|
|
739
|
+
var ts = Date.now();
|
|
740
|
+
var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
|
|
741
|
+
var ctBuf = crypto.encryptPacked(ptBuf, perSessionKey);
|
|
742
|
+
perSessionReqCtr += 1;
|
|
743
|
+
var body;
|
|
744
|
+
if (perSessionReqCtr === 1) {
|
|
745
|
+
// Bootstrap envelope — full _ek + _nonce; server stores sid → sessionKey.
|
|
746
|
+
var ek = crypto.encrypt(perSessionKey.toString("base64"), pubkey);
|
|
747
|
+
var nonce = crypto.generateBytes(REQUEST_NONCE_BYTES).toString("hex");
|
|
748
|
+
body = {
|
|
749
|
+
_ek: ek,
|
|
750
|
+
_ct: ctBuf.toString("base64"),
|
|
751
|
+
_ts: ts,
|
|
752
|
+
_nonce: nonce,
|
|
753
|
+
_sid: perSessionSid,
|
|
754
|
+
_ctr: perSessionReqCtr,
|
|
755
|
+
};
|
|
756
|
+
} else {
|
|
757
|
+
// Subsequent — sid + ctr only. KEM material amortized across the session.
|
|
758
|
+
body = {
|
|
759
|
+
_ct: ctBuf.toString("base64"),
|
|
760
|
+
_ts: ts,
|
|
761
|
+
_sid: perSessionSid,
|
|
762
|
+
_ctr: perSessionReqCtr,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
return { body: body, decryptResponse: _decryptPerSessionResponse };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function _encryptPerRequest(payload) {
|
|
769
|
+
if (payload === undefined) payload = null;
|
|
770
|
+
var sessionKey = crypto.generateBytes(SESSION_KEY_BYTES);
|
|
771
|
+
var ek = crypto.encrypt(sessionKey.toString("base64"), pubkey);
|
|
772
|
+
var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
|
|
773
|
+
var ctBuf = crypto.encryptPacked(ptBuf, sessionKey);
|
|
774
|
+
var requestNonce = crypto.generateBytes(REQUEST_NONCE_BYTES).toString("hex");
|
|
775
|
+
var ts = Date.now();
|
|
776
|
+
return {
|
|
777
|
+
body: {
|
|
778
|
+
_ek: ek,
|
|
779
|
+
_ct: ctBuf.toString("base64"),
|
|
780
|
+
_ts: ts,
|
|
781
|
+
_nonce: requestNonce,
|
|
782
|
+
},
|
|
783
|
+
decryptResponse: function (responseBody) {
|
|
784
|
+
if (!responseBody || typeof responseBody !== "object" ||
|
|
785
|
+
typeof responseBody._ct !== "string") {
|
|
786
|
+
throw _err("CLIENT_RESPONSE_SHAPE",
|
|
787
|
+
"apiEncrypt.client: response missing _ct field");
|
|
788
|
+
}
|
|
789
|
+
var resCtBuf = Buffer.from(responseBody._ct, "base64");
|
|
790
|
+
var resPtBuf = crypto.decryptPacked(resCtBuf, sessionKey);
|
|
791
|
+
return safeJson.parse(resPtBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return {
|
|
797
|
+
encryptRequest: _encryptPerSession,
|
|
798
|
+
resetSession: _resetSession,
|
|
799
|
+
sessionInfo: function () {
|
|
800
|
+
return {
|
|
801
|
+
sid: perSessionSid,
|
|
802
|
+
reqCtr: perSessionReqCtr,
|
|
803
|
+
lastResCtr: perSessionLastResCtr,
|
|
804
|
+
};
|
|
805
|
+
},
|
|
806
|
+
keying: keying,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// _generateUuidV4 — UUID v4 from 16 random bytes, formatted dash-separated.
|
|
811
|
+
// Used for client-side session-id generation in per-session keying.
|
|
812
|
+
// Slice offsets are RFC 4122 UUID hex-byte boundaries (`xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx`)
|
|
813
|
+
// — protocol-fixed values, not byte sizes. allow:raw-byte-literal
|
|
814
|
+
function _generateUuidV4() {
|
|
815
|
+
var b = crypto.generateBytes(16); // allow:raw-byte-literal — UUID is exactly 16 bytes
|
|
816
|
+
// Set version (4) and variant (10x) bits per RFC 4122.
|
|
817
|
+
b[6] = (b[6] & 0x0f) | 0x40;
|
|
818
|
+
b[8] = (b[8] & 0x3f) | 0x80;
|
|
819
|
+
var hex = b.toString("hex");
|
|
820
|
+
return hex.slice(0, 8) + "-" + // allow:raw-byte-literal — RFC 4122 hex offsets
|
|
821
|
+
hex.slice(8, 12) + "-" + // allow:raw-byte-literal
|
|
822
|
+
hex.slice(12, 16) + "-" + // allow:raw-byte-literal
|
|
823
|
+
hex.slice(16, 20) + "-" + // allow:raw-byte-literal
|
|
824
|
+
hex.slice(20, 32); // allow:raw-byte-literal
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ---- Server-to-server convenience ----
|
|
828
|
+
//
|
|
829
|
+
// Wraps the framework's HTTP client so service-to-service callers
|
|
830
|
+
// don't have to juggle encryptRequest + httpClient.request +
|
|
831
|
+
// JSON parsing + decryptResponse on every call. The pubkey is the
|
|
832
|
+
// callee's public bootstrap document (the JSON `publishPublicKey()`
|
|
833
|
+
// returns) so this helper works between any two blamejs instances.
|
|
834
|
+
//
|
|
835
|
+
// var enc = b.httpClient.encrypted({
|
|
836
|
+
// pubkey: callee.pubkey, // { publicKey, ecPublicKey }
|
|
837
|
+
// baseUrl: "https://callee.example",
|
|
838
|
+
// headers: { Authorization: "Bearer ..." },
|
|
839
|
+
// });
|
|
840
|
+
// var resp = await enc.request({
|
|
841
|
+
// method: "POST",
|
|
842
|
+
// path: "/api/widget",
|
|
843
|
+
// body: { user: "alice" },
|
|
844
|
+
// });
|
|
845
|
+
// resp.body // → decrypted plaintext object
|
|
846
|
+
//
|
|
847
|
+
// The helper handles only JSON-shaped request/response payloads,
|
|
848
|
+
// matching the middleware's contentTypes default.
|
|
849
|
+
function httpClientEncrypted(opts) {
|
|
850
|
+
opts = opts || {};
|
|
851
|
+
validateOpts(opts, [
|
|
852
|
+
"pubkey", "baseUrl", "headers", "method", "maxDecryptedBytes", "keying",
|
|
853
|
+
], "middleware.apiEncrypt.httpClient");
|
|
854
|
+
if (!opts.pubkey) {
|
|
855
|
+
throw _err("CLIENT_INVALID_PUBKEY",
|
|
856
|
+
"httpClient.encrypted: opts.pubkey is required (the callee's bootstrap doc)", 500);
|
|
857
|
+
}
|
|
858
|
+
var maxDecryptedBytes = opts.maxDecryptedBytes != null
|
|
859
|
+
? opts.maxDecryptedBytes
|
|
860
|
+
: C.BYTES.mib(4);
|
|
861
|
+
var keying = opts.keying != null ? opts.keying : "per-request";
|
|
862
|
+
var clientCtx = client({
|
|
863
|
+
pubkey: opts.pubkey,
|
|
864
|
+
maxDecryptedBytes: maxDecryptedBytes,
|
|
865
|
+
keying: keying,
|
|
866
|
+
});
|
|
867
|
+
var baseUrl = opts.baseUrl ? String(opts.baseUrl).replace(/\/$/, "") : "";
|
|
868
|
+
var defaultHdrs = opts.headers || {};
|
|
869
|
+
var defaultMethod = opts.method || "POST";
|
|
870
|
+
|
|
871
|
+
function _resolveUrl(reqOpts) {
|
|
872
|
+
if (typeof reqOpts.url === "string" && reqOpts.url.length > 0) return reqOpts.url;
|
|
873
|
+
if (typeof reqOpts.path === "string" && reqOpts.path.length > 0) {
|
|
874
|
+
if (!baseUrl) {
|
|
875
|
+
throw _err("CLIENT_INVALID_URL",
|
|
876
|
+
"httpClient.encrypted.request: { path } requires opts.baseUrl at create time", 500);
|
|
877
|
+
}
|
|
878
|
+
return baseUrl + (reqOpts.path[0] === "/" ? reqOpts.path : "/" + reqOpts.path);
|
|
879
|
+
}
|
|
880
|
+
throw _err("CLIENT_INVALID_URL",
|
|
881
|
+
"httpClient.encrypted.request: requires { url } or { path } (with opts.baseUrl)", 500);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function request(reqOpts) {
|
|
885
|
+
reqOpts = reqOpts || {};
|
|
886
|
+
var url = _resolveUrl(reqOpts);
|
|
887
|
+
var encrypted = clientCtx.encryptRequest(
|
|
888
|
+
reqOpts.body !== undefined ? reqOpts.body : null
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
// Merge headers — operator's per-request headers win over default
|
|
892
|
+
// headers, but Content-Type is forced because the encrypted body
|
|
893
|
+
// is always JSON.
|
|
894
|
+
var headers = Object.assign({}, defaultHdrs, reqOpts.headers || {});
|
|
895
|
+
headers["Content-Type"] = "application/json";
|
|
896
|
+
|
|
897
|
+
var passThrough = {};
|
|
898
|
+
var passable = ["allowedProtocols", "allowInternal", "idleTimeoutMs", "maxResponseBytes",
|
|
899
|
+
"agent", "errorClass"];
|
|
900
|
+
for (var i = 0; i < passable.length; i++) {
|
|
901
|
+
if (reqOpts[passable[i]] !== undefined) passThrough[passable[i]] = reqOpts[passable[i]];
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
var rawBody = Buffer.from(JSON.stringify(encrypted.body), "utf8");
|
|
905
|
+
var resp = await httpClient().request(Object.assign({
|
|
906
|
+
url: url,
|
|
907
|
+
method: reqOpts.method || defaultMethod,
|
|
908
|
+
headers: headers,
|
|
909
|
+
body: rawBody,
|
|
910
|
+
}, passThrough));
|
|
911
|
+
|
|
912
|
+
// Empty body → no decryption (e.g. 204 No Content).
|
|
913
|
+
if (!resp.body || resp.body.length === 0) {
|
|
914
|
+
return { statusCode: resp.statusCode, headers: resp.headers, body: null };
|
|
915
|
+
}
|
|
916
|
+
var parsed;
|
|
917
|
+
try { parsed = safeJson.parse(resp.body.toString("utf8"), { maxBytes: maxDecryptedBytes }); }
|
|
918
|
+
catch (e) {
|
|
919
|
+
throw _err("CLIENT_RESPONSE_NOT_JSON",
|
|
920
|
+
"httpClient.encrypted: response body is not valid JSON: " + e.message);
|
|
921
|
+
}
|
|
922
|
+
return {
|
|
923
|
+
statusCode: resp.statusCode,
|
|
924
|
+
headers: resp.headers,
|
|
925
|
+
body: encrypted.decryptResponse(parsed),
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return { request: request };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
module.exports = Object.assign(create, {
|
|
933
|
+
client: client,
|
|
934
|
+
httpClient: httpClientEncrypted,
|
|
935
|
+
ApiEncryptError: ApiEncryptError,
|
|
936
|
+
});
|