@blamejs/core 0.8.43 → 0.8.49
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 +92 -0
- package/README.md +10 -10
- package/index.js +52 -0
- package/lib/a2a.js +159 -34
- package/lib/acme.js +762 -0
- package/lib/ai-pref.js +166 -43
- package/lib/api-key.js +108 -47
- package/lib/api-snapshot.js +157 -40
- package/lib/app-shutdown.js +113 -77
- package/lib/archive.js +337 -40
- package/lib/arg-parser.js +697 -0
- package/lib/asyncapi.js +99 -55
- package/lib/atomic-file.js +465 -104
- package/lib/audit-chain.js +123 -34
- package/lib/audit-daily-review.js +389 -0
- package/lib/audit-sign.js +302 -56
- package/lib/audit-tools.js +412 -63
- package/lib/audit.js +656 -35
- package/lib/auth/jwt-external.js +17 -0
- package/lib/auth/oauth.js +7 -0
- package/lib/auth-bot-challenge.js +505 -0
- package/lib/auth-header.js +92 -25
- package/lib/backup/bundle.js +26 -0
- package/lib/backup/index.js +512 -89
- package/lib/backup/manifest.js +168 -7
- package/lib/break-glass.js +415 -39
- package/lib/budr.js +103 -30
- package/lib/bundler.js +86 -66
- package/lib/cache.js +192 -72
- package/lib/chain-writer.js +65 -40
- package/lib/circuit-breaker.js +56 -33
- package/lib/cli-helpers.js +106 -75
- package/lib/cli.js +6 -30
- package/lib/cloud-events.js +99 -32
- package/lib/cluster-storage.js +162 -37
- package/lib/cluster.js +340 -49
- package/lib/codepoint-class.js +66 -0
- package/lib/compliance.js +424 -24
- package/lib/config-drift.js +111 -46
- package/lib/config.js +94 -40
- package/lib/consent.js +165 -18
- package/lib/constants.js +1 -0
- package/lib/content-credentials.js +153 -48
- package/lib/cookies.js +154 -62
- package/lib/credential-hash.js +133 -61
- package/lib/crypto-field.js +702 -18
- package/lib/crypto-hpke.js +256 -0
- package/lib/crypto.js +744 -22
- package/lib/csv.js +178 -35
- package/lib/daemon.js +456 -0
- package/lib/dark-patterns.js +186 -55
- package/lib/db-query.js +79 -2
- package/lib/db.js +1431 -60
- package/lib/ddl-change-control.js +523 -0
- package/lib/deprecate.js +195 -40
- package/lib/dev.js +82 -39
- package/lib/dora.js +67 -48
- package/lib/dr-runbook.js +368 -0
- package/lib/dsr.js +142 -11
- package/lib/dual-control.js +91 -56
- package/lib/events.js +120 -41
- package/lib/external-db-migrate.js +192 -2
- package/lib/external-db.js +795 -50
- package/lib/fapi2.js +122 -1
- package/lib/fda-21cfr11.js +395 -0
- package/lib/fdx.js +132 -2
- package/lib/file-type.js +87 -0
- package/lib/file-upload.js +93 -0
- package/lib/flag.js +82 -20
- package/lib/forms.js +132 -29
- package/lib/framework-error.js +169 -0
- package/lib/framework-schema.js +163 -35
- package/lib/gate-contract.js +849 -175
- package/lib/graphql-federation.js +68 -7
- package/lib/guard-all.js +172 -55
- package/lib/guard-archive.js +286 -124
- package/lib/guard-auth.js +194 -21
- package/lib/guard-cidr.js +190 -28
- package/lib/guard-csv.js +397 -51
- package/lib/guard-domain.js +213 -91
- package/lib/guard-email.js +236 -29
- package/lib/guard-filename.js +307 -75
- package/lib/guard-graphql.js +263 -30
- package/lib/guard-html.js +310 -116
- package/lib/guard-image.js +243 -30
- package/lib/guard-json.js +260 -54
- package/lib/guard-jsonpath.js +235 -23
- package/lib/guard-jwt.js +284 -30
- package/lib/guard-markdown.js +204 -22
- package/lib/guard-mime.js +190 -26
- package/lib/guard-oauth.js +277 -28
- package/lib/guard-pdf.js +251 -27
- package/lib/guard-regex.js +226 -18
- package/lib/guard-shell.js +229 -26
- package/lib/guard-svg.js +177 -10
- package/lib/guard-template.js +232 -21
- package/lib/guard-time.js +195 -29
- package/lib/guard-uuid.js +189 -30
- package/lib/guard-xml.js +259 -36
- package/lib/guard-yaml.js +241 -44
- package/lib/honeytoken.js +63 -27
- package/lib/html-balance.js +83 -0
- package/lib/http-client.js +486 -59
- package/lib/http-message-signature.js +582 -0
- package/lib/i18n.js +102 -49
- package/lib/iab-mspa.js +112 -32
- package/lib/iab-tcf.js +107 -2
- package/lib/inbox.js +90 -52
- package/lib/keychain.js +865 -0
- package/lib/legal-hold.js +374 -0
- package/lib/local-db-thin.js +320 -0
- package/lib/log-stream.js +281 -51
- package/lib/log.js +184 -86
- package/lib/mail-bounce.js +107 -62
- package/lib/mail.js +295 -58
- package/lib/mcp.js +108 -27
- package/lib/metrics.js +98 -89
- package/lib/middleware/age-gate.js +36 -0
- package/lib/middleware/ai-act-disclosure.js +37 -0
- package/lib/middleware/api-encrypt.js +45 -0
- package/lib/middleware/assetlinks.js +40 -0
- package/lib/middleware/asyncapi-serve.js +35 -0
- package/lib/middleware/attach-user.js +40 -0
- package/lib/middleware/bearer-auth.js +40 -0
- package/lib/middleware/body-parser.js +230 -0
- package/lib/middleware/bot-disclose.js +34 -0
- package/lib/middleware/bot-guard.js +39 -0
- package/lib/middleware/compression.js +37 -0
- package/lib/middleware/cookies.js +32 -0
- package/lib/middleware/cors.js +40 -0
- package/lib/middleware/csp-nonce.js +40 -0
- package/lib/middleware/csp-report.js +34 -0
- package/lib/middleware/csrf-protect.js +43 -0
- package/lib/middleware/daily-byte-quota.js +53 -85
- package/lib/middleware/db-role-for.js +40 -0
- package/lib/middleware/dpop.js +40 -0
- package/lib/middleware/error-handler.js +37 -14
- package/lib/middleware/fetch-metadata.js +39 -0
- package/lib/middleware/flag-context.js +34 -0
- package/lib/middleware/gpc.js +33 -0
- package/lib/middleware/headers.js +35 -0
- package/lib/middleware/health.js +46 -0
- package/lib/middleware/host-allowlist.js +30 -0
- package/lib/middleware/network-allowlist.js +38 -0
- package/lib/middleware/openapi-serve.js +34 -0
- package/lib/middleware/rate-limit.js +160 -18
- package/lib/middleware/request-id.js +36 -18
- package/lib/middleware/request-log.js +37 -0
- package/lib/middleware/require-aal.js +29 -0
- package/lib/middleware/require-auth.js +32 -0
- package/lib/middleware/require-bound-key.js +41 -0
- package/lib/middleware/require-content-type.js +32 -0
- package/lib/middleware/require-methods.js +27 -0
- package/lib/middleware/require-mtls.js +33 -0
- package/lib/middleware/require-step-up.js +37 -0
- package/lib/middleware/security-headers.js +44 -0
- package/lib/middleware/security-txt.js +38 -0
- package/lib/middleware/span-http-server.js +37 -0
- package/lib/middleware/sse.js +36 -0
- package/lib/middleware/trace-log-correlation.js +33 -0
- package/lib/middleware/trace-propagate.js +32 -0
- package/lib/middleware/tus-upload.js +90 -0
- package/lib/middleware/web-app-manifest.js +53 -0
- package/lib/mtls-ca.js +100 -70
- package/lib/network-byte-quota.js +308 -0
- package/lib/network-heartbeat.js +135 -0
- package/lib/network-tls.js +534 -4
- package/lib/network.js +103 -0
- package/lib/notify.js +114 -43
- package/lib/ntp-check.js +192 -51
- package/lib/observability.js +145 -47
- package/lib/openapi.js +90 -44
- package/lib/outbox.js +99 -1
- package/lib/pagination.js +168 -86
- package/lib/parsers/index.js +16 -5
- package/lib/permissions.js +93 -40
- package/lib/pqc-agent.js +84 -8
- package/lib/pqc-software.js +94 -60
- package/lib/process-spawn.js +95 -21
- package/lib/pubsub.js +96 -66
- package/lib/queue.js +375 -54
- package/lib/redact.js +793 -21
- package/lib/render.js +139 -47
- package/lib/request-helpers.js +485 -121
- package/lib/restore-bundle.js +142 -39
- package/lib/restore-rollback.js +136 -45
- package/lib/retention.js +178 -50
- package/lib/retry.js +116 -33
- package/lib/router.js +475 -23
- package/lib/safe-async.js +543 -94
- package/lib/safe-buffer.js +337 -41
- package/lib/safe-json.js +467 -62
- package/lib/safe-jsonpath.js +285 -0
- package/lib/safe-schema.js +631 -87
- package/lib/safe-sql.js +221 -59
- package/lib/safe-url.js +278 -46
- package/lib/sandbox-worker.js +135 -0
- package/lib/sandbox.js +358 -0
- package/lib/scheduler.js +135 -70
- package/lib/self-update.js +647 -0
- package/lib/session-device-binding.js +431 -0
- package/lib/session.js +259 -49
- package/lib/slug.js +138 -26
- package/lib/ssrf-guard.js +316 -56
- package/lib/storage.js +433 -70
- package/lib/subject.js +405 -23
- package/lib/template.js +148 -8
- package/lib/tenant-quota.js +545 -0
- package/lib/testing.js +440 -53
- package/lib/time.js +291 -23
- package/lib/tls-exporter.js +239 -0
- package/lib/tracing.js +90 -74
- package/lib/uuid.js +97 -22
- package/lib/vault/index.js +284 -22
- package/lib/vault/seal-pem-file.js +66 -0
- package/lib/watcher.js +368 -0
- package/lib/webhook.js +196 -63
- package/lib/websocket.js +393 -68
- package/lib/wiki-concepts.js +338 -0
- package/lib/worker-pool.js +464 -0
- package/package.json +3 -3
- package/sbom.cyclonedx.json +7 -7
package/lib/crypto-field.js
CHANGED
|
@@ -1,29 +1,162 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* @module b.cryptoField
|
|
4
|
+
* @nav Crypto
|
|
5
|
+
* @title Field-Level Crypto
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Per-column field-level encryption with AAD-bound envelopes. Apps
|
|
9
|
+
* declare which columns hold PHI / PCI / personal data via
|
|
10
|
+
* `b.db.init({ schema })`; the framework then auto-protects those
|
|
11
|
+
* columns on every write (`sealRow`) and reverses on every read
|
|
12
|
+
* (`unsealRow`). Sealed values are produced by `b.vault.seal`, which
|
|
13
|
+
* wraps an XChaCha20-Poly1305 ciphertext under the framework's PQC
|
|
14
|
+
* envelope (ML-KEM + ECDH hybrid) — every encryption uses a fresh
|
|
15
|
+
* random nonce, so two seals of the same plaintext never collide.
|
|
9
16
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
17
|
+
* Per-row key (K_row) derivation is opt-in via `declarePerRowKey`.
|
|
18
|
+
* Tables that opt in get a fresh K_row per INSERT, stored sealed in
|
|
19
|
+
* `_blamejs_per_row_keys`. AAD on the K_row binds (table, rowId,
|
|
20
|
+
* info-label) — copying a wrapped K_row from one row to another
|
|
21
|
+
* fails Poly1305 verification, so a DB-write attacker cannot move
|
|
22
|
+
* ciphertext between rows to bypass row-scoped erasure. This is the
|
|
23
|
+
* crypto-shred substrate for `b.subject.eraseHard`: deleting the
|
|
24
|
+
* K_row entry leaves WAL / replica residual ciphertext mathematically
|
|
25
|
+
* undecryptable.
|
|
14
26
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
27
|
+
* Derived hashes (`derivedHashes`) provide indexed lookup for sealed
|
|
28
|
+
* columns: a normalized SHA3 of the plaintext, salted by the vault's
|
|
29
|
+
* per-deployment salt + a per-field namespace, so dictionary /
|
|
30
|
+
* rainbow attacks across fields and across deployments fail. Sealed
|
|
31
|
+
* columns without a derived hash are unindexable — queries on them
|
|
32
|
+
* silently return zero rows.
|
|
17
33
|
*
|
|
18
|
-
*
|
|
34
|
+
* Per-column residency (`declareColumnResidency`) declares EU / US /
|
|
35
|
+
* global tags; the storage-write gate (`assertColumnResidency`)
|
|
36
|
+
* refuses writes to a backend whose tag doesn't satisfy the column
|
|
37
|
+
* under gdpr / dpdp / pipl-cn / uk-gdpr postures.
|
|
38
|
+
*
|
|
39
|
+
* No mutation of the input row — every operation returns a new
|
|
40
|
+
* object, suitable for direct insertion into the audit chain.
|
|
41
|
+
*
|
|
42
|
+
* @card
|
|
43
|
+
* Per-column field-level encryption with AAD-bound envelopes.
|
|
19
44
|
*/
|
|
45
|
+
var lazyRequire = require("./lazy-require");
|
|
20
46
|
var vault = require("./vault");
|
|
21
|
-
var { sha3Hash } = require("./crypto");
|
|
47
|
+
var { sha3Hash, kdf } = require("./crypto");
|
|
22
48
|
var { HASH_PREFIX, VAULT_PREFIX, TIME } = require("./constants");
|
|
23
49
|
|
|
50
|
+
var complianceMod = lazyRequire(function () { return require("./compliance"); });
|
|
51
|
+
var dbMod = lazyRequire(function () { return require("./db"); });
|
|
52
|
+
var auditMod = lazyRequire(function () { return require("./audit"); });
|
|
53
|
+
|
|
54
|
+
// F-POSTURE-1 cascade hook + F-RTBF-2 integration. Recording the
|
|
55
|
+
// posture lets eraseRow call b.db.vacuumAfterErase({ mode: "full" })
|
|
56
|
+
// automatically under postures whose POSTURE_DEFAULTS sets
|
|
57
|
+
// requireVacuumAfterErase: true (gdpr / dpdp / pipl-cn / lgpd-br /
|
|
58
|
+
// hipaa). Without the vacuum, freed B-tree index pages keep sealed-
|
|
59
|
+
// column ciphertext readable from a forensic disk image — defeats the
|
|
60
|
+
// "right to erasure" the regulatory regime guarantees.
|
|
61
|
+
var _activePosture = null;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @primitive b.cryptoField.applyPosture
|
|
65
|
+
* @signature b.cryptoField.applyPosture(posture)
|
|
66
|
+
* @since 0.7.27
|
|
67
|
+
* @compliance gdpr, hipaa
|
|
68
|
+
* @related b.cryptoField.getActivePosture, b.cryptoField.eraseRow
|
|
69
|
+
*
|
|
70
|
+
* Records the active compliance posture so `eraseRow` can cascade into
|
|
71
|
+
* `b.db.vacuumAfterErase({ mode: "full" })` under regimes whose
|
|
72
|
+
* `POSTURE_DEFAULTS` sets `requireVacuumAfterErase: true` (gdpr / dpdp /
|
|
73
|
+
* pipl-cn / lgpd-br / hipaa). Without the vacuum, freed B-tree index
|
|
74
|
+
* pages keep sealed-column ciphertext readable from a forensic disk
|
|
75
|
+
* image — defeating the "right to erasure" the regime guarantees.
|
|
76
|
+
* Returns null when posture is empty/non-string; otherwise returns
|
|
77
|
+
* `{ posture, requireVacuumAfterErase }`.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* var info = b.cryptoField.applyPosture("gdpr");
|
|
81
|
+
* info.posture; // → "gdpr"
|
|
82
|
+
* info.requireVacuumAfterErase; // → true
|
|
83
|
+
*
|
|
84
|
+
* b.cryptoField.applyPosture(""); // → null (no-op)
|
|
85
|
+
*/
|
|
86
|
+
function applyPosture(posture) {
|
|
87
|
+
if (typeof posture !== "string" || posture.length === 0) return null;
|
|
88
|
+
_activePosture = posture;
|
|
89
|
+
var requireVacuum = false;
|
|
90
|
+
try {
|
|
91
|
+
requireVacuum = complianceMod().postureDefault(posture, "requireVacuumAfterErase") === true;
|
|
92
|
+
} catch (_e) { /* compliance not loaded — record posture only */ }
|
|
93
|
+
return { posture: posture, requireVacuumAfterErase: requireVacuum };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @primitive b.cryptoField.getActivePosture
|
|
98
|
+
* @signature b.cryptoField.getActivePosture()
|
|
99
|
+
* @since 0.7.27
|
|
100
|
+
* @related b.cryptoField.applyPosture
|
|
101
|
+
*
|
|
102
|
+
* Returns the posture string most recently recorded via `applyPosture`,
|
|
103
|
+
* or null when no posture has been applied. Read-only — does not
|
|
104
|
+
* mutate state. Used by storage backends to gate cross-border writes.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* b.cryptoField.applyPosture("hipaa");
|
|
108
|
+
* b.cryptoField.getActivePosture(); // → "hipaa"
|
|
109
|
+
*/
|
|
110
|
+
function getActivePosture() { return _activePosture; }
|
|
111
|
+
|
|
24
112
|
// Per-table registry, populated by db.init()
|
|
25
113
|
var schemas = Object.create(null);
|
|
26
114
|
|
|
115
|
+
// F-CBT-1 — per-COLUMN data residency registry. Real GDPR / DPDP
|
|
116
|
+
// deployments have row-level mixed residency: a `users.name` column
|
|
117
|
+
// may be global, but `users.addressLine1` must stay in EU storage.
|
|
118
|
+
// db.init({ schema }) carries the operator's residency declaration
|
|
119
|
+
// per table; this registry stores it for cross-region check at the
|
|
120
|
+
// storage-write boundary.
|
|
121
|
+
//
|
|
122
|
+
// { tableName: { columnName: "eu" | "us" | "global" | <tag> } }
|
|
123
|
+
var columnResidency = Object.create(null);
|
|
124
|
+
|
|
125
|
+
// F-RTBF-3 — per-row key declaration registry. For tables that opt
|
|
126
|
+
// into per-row keying, b.subject.eraseHard deletes the wrapped K_row
|
|
127
|
+
// from _blamejs_per_row_keys, leaving WAL/replica residual ciphertext
|
|
128
|
+
// undecryptable.
|
|
129
|
+
//
|
|
130
|
+
// { tableName: { keySize, info, residencyTag } }
|
|
131
|
+
var perRowKeyTables = Object.create(null);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @primitive b.cryptoField.registerTable
|
|
135
|
+
* @signature b.cryptoField.registerTable(name, opts)
|
|
136
|
+
* @since 0.4.0
|
|
137
|
+
* @related b.cryptoField.getSchema, b.cryptoField.sealRow
|
|
138
|
+
*
|
|
139
|
+
* Registers a table's sealed-column declaration. Called from
|
|
140
|
+
* `b.db.init({ schema })` at boot — operators rarely call directly.
|
|
141
|
+
* Stores the per-table list of sealed fields, the derived-hash specs
|
|
142
|
+
* (mapping `derivedField -> { from, normalize }`), and any per-field
|
|
143
|
+
* hash namespaces. Subsequent `sealRow` / `unsealRow` / `eraseRow`
|
|
144
|
+
* calls dispatch through this registry.
|
|
145
|
+
*
|
|
146
|
+
* @opts
|
|
147
|
+
* sealedFields: string[], // column names sealed via vault.seal
|
|
148
|
+
* derivedHashes: { [hashCol]: { from: string, normalize?: fn } },
|
|
149
|
+
* hashNamespaces: { [field]: string }, // override default rainbow-defense ns
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* b.cryptoField.registerTable("patients", {
|
|
153
|
+
* sealedFields: ["ssn", "diagnosis"],
|
|
154
|
+
* derivedHashes: {
|
|
155
|
+
* ssnHash: { from: "ssn", normalize: function (s) { return String(s).replace(/-/g, ""); } }
|
|
156
|
+
* }
|
|
157
|
+
* });
|
|
158
|
+
* b.cryptoField.getSealedFields("patients"); // → ["ssn", "diagnosis"]
|
|
159
|
+
*/
|
|
27
160
|
function registerTable(name, opts) {
|
|
28
161
|
schemas[name] = {
|
|
29
162
|
sealedFields: Array.isArray(opts.sealedFields) ? opts.sealedFields.slice() : [],
|
|
@@ -32,15 +165,68 @@ function registerTable(name, opts) {
|
|
|
32
165
|
};
|
|
33
166
|
}
|
|
34
167
|
|
|
168
|
+
/**
|
|
169
|
+
* @primitive b.cryptoField.getSchema
|
|
170
|
+
* @signature b.cryptoField.getSchema(table)
|
|
171
|
+
* @since 0.4.0
|
|
172
|
+
* @related b.cryptoField.registerTable, b.cryptoField.getSealedFields
|
|
173
|
+
*
|
|
174
|
+
* Returns the registered schema record for `table` — `{ sealedFields,
|
|
175
|
+
* derivedHashes, hashNamespaces }` — or null when the table was never
|
|
176
|
+
* registered. Read-only; mutations to the returned object do not
|
|
177
|
+
* affect future calls (the inner arrays/objects are shared, so
|
|
178
|
+
* operators should treat the result as read-only).
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
|
|
182
|
+
* var schema = b.cryptoField.getSchema("patients");
|
|
183
|
+
* schema.sealedFields; // → ["ssn"]
|
|
184
|
+
*
|
|
185
|
+
* b.cryptoField.getSchema("unknown"); // → null
|
|
186
|
+
*/
|
|
35
187
|
function getSchema(table) {
|
|
36
188
|
return schemas[table] || null;
|
|
37
189
|
}
|
|
38
190
|
|
|
191
|
+
/**
|
|
192
|
+
* @primitive b.cryptoField.getSealedFields
|
|
193
|
+
* @signature b.cryptoField.getSealedFields(table)
|
|
194
|
+
* @since 0.4.0
|
|
195
|
+
* @related b.cryptoField.getSchema, b.cryptoField.sealRow
|
|
196
|
+
*
|
|
197
|
+
* Returns the array of sealed column names for `table`, or an empty
|
|
198
|
+
* array when the table is unregistered. Convenience accessor used by
|
|
199
|
+
* storage backends to know which columns to wrap in `vault.seal` on
|
|
200
|
+
* write and `vault.unseal` on read.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* b.cryptoField.registerTable("patients", { sealedFields: ["ssn", "diagnosis"] });
|
|
204
|
+
* b.cryptoField.getSealedFields("patients"); // → ["ssn", "diagnosis"]
|
|
205
|
+
* b.cryptoField.getSealedFields("public"); // → []
|
|
206
|
+
*/
|
|
39
207
|
function getSealedFields(table) {
|
|
40
208
|
var s = schemas[table];
|
|
41
209
|
return s ? s.sealedFields : [];
|
|
42
210
|
}
|
|
43
211
|
|
|
212
|
+
/**
|
|
213
|
+
* @primitive b.cryptoField.clearForTest
|
|
214
|
+
* @signature b.cryptoField.clearForTest()
|
|
215
|
+
* @since 0.4.0
|
|
216
|
+
* @status experimental
|
|
217
|
+
* @related b.cryptoField.registerTable
|
|
218
|
+
*
|
|
219
|
+
* Test-only helper. Drops every entry from the per-table schema
|
|
220
|
+
* registry so a test fixture can re-register tables under different
|
|
221
|
+
* sealed-field declarations between cases. Operator code never calls
|
|
222
|
+
* this — production schemas come from `b.db.init({ schema })` once at
|
|
223
|
+
* boot.
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
|
|
227
|
+
* b.cryptoField.clearForTest();
|
|
228
|
+
* b.cryptoField.getSchema("patients"); // → null
|
|
229
|
+
*/
|
|
44
230
|
function clearForTest() {
|
|
45
231
|
for (var k in schemas) delete schemas[k];
|
|
46
232
|
}
|
|
@@ -57,6 +243,31 @@ function namespaceFor(table, field, registered) {
|
|
|
57
243
|
return "bj-" + table + "-" + field + ":";
|
|
58
244
|
}
|
|
59
245
|
|
|
246
|
+
/**
|
|
247
|
+
* @primitive b.cryptoField.computeDerived
|
|
248
|
+
* @signature b.cryptoField.computeDerived(table, sourceField, sourceValue)
|
|
249
|
+
* @since 0.4.0
|
|
250
|
+
* @related b.cryptoField.lookupHash, b.cryptoField.sealRow
|
|
251
|
+
*
|
|
252
|
+
* Computes the derived hash for a (table, sourceField) pair when the
|
|
253
|
+
* schema declares a derived-hash mirror of that source. Returns
|
|
254
|
+
* `{ field, value }` naming the derived column and its hash, or null
|
|
255
|
+
* when no derived hash is declared. Hashes are SHA3 of
|
|
256
|
+
* `vaultSalt + namespace + normalizedValue`, where the per-deployment
|
|
257
|
+
* vault salt prevents cross-deployment correlation and the per-field
|
|
258
|
+
* namespace prevents cross-field rainbow attacks.
|
|
259
|
+
*
|
|
260
|
+
* @example
|
|
261
|
+
* b.cryptoField.registerTable("users", {
|
|
262
|
+
* sealedFields: ["email"],
|
|
263
|
+
* derivedHashes: { emailHash: { from: "email" } }
|
|
264
|
+
* });
|
|
265
|
+
* var d = b.cryptoField.computeDerived("users", "email", "alice@example.com");
|
|
266
|
+
* d.field; // → "emailHash"
|
|
267
|
+
* typeof d.value; // → "string"
|
|
268
|
+
*
|
|
269
|
+
* b.cryptoField.computeDerived("users", "email", null); // → null
|
|
270
|
+
*/
|
|
60
271
|
function computeDerived(table, sourceField, sourceValue) {
|
|
61
272
|
if (sourceValue === undefined || sourceValue === null) return null;
|
|
62
273
|
var s = schemas[table];
|
|
@@ -76,6 +287,32 @@ function computeDerived(table, sourceField, sourceValue) {
|
|
|
76
287
|
|
|
77
288
|
// ---- Row sealing / unsealing ----
|
|
78
289
|
|
|
290
|
+
/**
|
|
291
|
+
* @primitive b.cryptoField.sealRow
|
|
292
|
+
* @signature b.cryptoField.sealRow(table, row)
|
|
293
|
+
* @since 0.4.0
|
|
294
|
+
* @compliance hipaa, gdpr, pci-dss
|
|
295
|
+
* @related b.cryptoField.unsealRow, b.cryptoField.eraseRow, b.vault.seal
|
|
296
|
+
*
|
|
297
|
+
* Returns a copy of `row` with every sealed column wrapped in
|
|
298
|
+
* `vault.seal()` and every derived-hash mirror computed from the
|
|
299
|
+
* pre-seal plaintext. The input row is never mutated. `vault.seal` is
|
|
300
|
+
* idempotent — already-sealed values pass through unchanged so
|
|
301
|
+
* round-trips through the storage layer are safe. Derived hashes are
|
|
302
|
+
* computed BEFORE sealing the source so the indexed lookup column
|
|
303
|
+
* captures the plaintext digest.
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* b.cryptoField.registerTable("patients", {
|
|
307
|
+
* sealedFields: ["ssn"],
|
|
308
|
+
* derivedHashes: { ssnHash: { from: "ssn" } }
|
|
309
|
+
* });
|
|
310
|
+
* var row = { id: 1, name: "Alice", ssn: "123-45-6789" };
|
|
311
|
+
* var sealed = b.cryptoField.sealRow("patients", row);
|
|
312
|
+
* String(sealed.ssn).startsWith("vault:"); // → true
|
|
313
|
+
* typeof sealed.ssnHash; // → "string"
|
|
314
|
+
* row.ssn; // → "123-45-6789" (input untouched)
|
|
315
|
+
*/
|
|
79
316
|
function sealRow(table, row) {
|
|
80
317
|
if (!row) return row;
|
|
81
318
|
var s = schemas[table];
|
|
@@ -109,6 +346,28 @@ function sealRow(table, row) {
|
|
|
109
346
|
return out;
|
|
110
347
|
}
|
|
111
348
|
|
|
349
|
+
/**
|
|
350
|
+
* @primitive b.cryptoField.unsealRow
|
|
351
|
+
* @signature b.cryptoField.unsealRow(table, row)
|
|
352
|
+
* @since 0.4.0
|
|
353
|
+
* @compliance hipaa, gdpr, pci-dss
|
|
354
|
+
* @related b.cryptoField.sealRow, b.vault.unseal
|
|
355
|
+
*
|
|
356
|
+
* Returns a copy of `row` with every sealed column unwrapped via
|
|
357
|
+
* `vault.unseal()`. Round-trips with `sealRow`. When `vault.unseal`
|
|
358
|
+
* throws (DB-write attacker forging a `vault:<crafted>` payload to
|
|
359
|
+
* force ML-KEM decapsulation on attacker-controlled bytes), the
|
|
360
|
+
* failure is recorded on the audit chain as
|
|
361
|
+
* `system.crypto.unseal_failed` and the field is replaced with null
|
|
362
|
+
* so downstream code sees "no value" instead of crashing the request.
|
|
363
|
+
* The input row is never mutated.
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
|
|
367
|
+
* var sealed = b.cryptoField.sealRow("patients", { id: 1, ssn: "123-45-6789" });
|
|
368
|
+
* var clear = b.cryptoField.unsealRow("patients", sealed);
|
|
369
|
+
* clear.ssn; // → "123-45-6789"
|
|
370
|
+
*/
|
|
112
371
|
function unsealRow(table, row) {
|
|
113
372
|
if (!row) return row;
|
|
114
373
|
var s = schemas[table];
|
|
@@ -161,6 +420,38 @@ function unsealRow(table, row) {
|
|
|
161
420
|
// Callers that need the row removed entirely should DELETE; eraseRow
|
|
162
421
|
// is for the case where downstream FKs / audit references make
|
|
163
422
|
// outright deletion infeasible.
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* @primitive b.cryptoField.eraseRow
|
|
426
|
+
* @signature b.cryptoField.eraseRow(table, row)
|
|
427
|
+
* @since 0.7.10
|
|
428
|
+
* @compliance gdpr, hipaa
|
|
429
|
+
* @related b.cryptoField.sealRow, b.subject.eraseHard, b.db.vacuumAfterErase
|
|
430
|
+
*
|
|
431
|
+
* Returns a tombstoned copy of `row`: every sealed column NULLed,
|
|
432
|
+
* every derived-hash mirror NULLed, and `__erasedAt` set to a
|
|
433
|
+
* 1-day-bucketed UTC ms timestamp (sub-day timing is intentionally
|
|
434
|
+
* fuzzed to defeat audit-log exfiltration + cross-tenant correlation
|
|
435
|
+
* attacks like "this row was erased 2.3s before that one"). Under
|
|
436
|
+
* regulatory postures whose `POSTURE_DEFAULTS` sets
|
|
437
|
+
* `requireVacuumAfterErase: true` (gdpr / dpdp / pipl-cn / lgpd-br /
|
|
438
|
+
* hipaa), automatically schedules `b.db.vacuumAfterErase({ mode:
|
|
439
|
+
* "full" })` so freed B-tree pages don't linger with sealed-column
|
|
440
|
+
* ciphertext readable from a forensic disk image. The row stays in
|
|
441
|
+
* the table for referential integrity; outright DELETE remains the
|
|
442
|
+
* caller's choice when FKs allow.
|
|
443
|
+
*
|
|
444
|
+
* @example
|
|
445
|
+
* b.cryptoField.registerTable("patients", {
|
|
446
|
+
* sealedFields: ["ssn"],
|
|
447
|
+
* derivedHashes: { ssnHash: { from: "ssn" } }
|
|
448
|
+
* });
|
|
449
|
+
* var sealed = b.cryptoField.sealRow("patients", { id: 1, ssn: "123-45-6789" });
|
|
450
|
+
* var erased = b.cryptoField.eraseRow("patients", sealed);
|
|
451
|
+
* erased.ssn; // → null
|
|
452
|
+
* erased.ssnHash; // → null
|
|
453
|
+
* typeof erased.__erasedAt; // → "number"
|
|
454
|
+
*/
|
|
164
455
|
function eraseRow(table, row) {
|
|
165
456
|
if (!row) return row;
|
|
166
457
|
var s = schemas[table];
|
|
@@ -190,16 +481,77 @@ function eraseRow(table, row) {
|
|
|
190
481
|
// (which is itself sealed under the audit-sign keypair).
|
|
191
482
|
var dayMs = TIME.days(1);
|
|
192
483
|
out.__erasedAt = Math.floor(Date.now() / dayMs) * dayMs;
|
|
484
|
+
|
|
485
|
+
// F-RTBF-2 — under regulatory postures whose POSTURE_DEFAULTS sets
|
|
486
|
+
// requireVacuumAfterErase: true (gdpr / dpdp / pipl-cn / lgpd-br /
|
|
487
|
+
// hipaa), the B-tree index pages freed by the upcoming UPDATE/DELETE
|
|
488
|
+
// would otherwise linger with sealed-column ciphertext readable
|
|
489
|
+
// from a forensic disk image. The cascade-installed posture (set by
|
|
490
|
+
// b.compliance.set) drives an automatic VACUUM after the in-memory
|
|
491
|
+
// tombstone — the actual write happens at the operator's call site,
|
|
492
|
+
// and the framework only schedules the vacuum AFTER the next write.
|
|
493
|
+
// Each erase emits cryptofield.erase.row + (when vacuum runs)
|
|
494
|
+
// db.vacuum_after_erase so the audit trail covers both halves.
|
|
495
|
+
if (_activePosture) {
|
|
496
|
+
var requireVacuum = false;
|
|
497
|
+
try {
|
|
498
|
+
requireVacuum = complianceMod().postureDefault(
|
|
499
|
+
_activePosture, "requireVacuumAfterErase") === true;
|
|
500
|
+
} catch (_e) { /* compliance lookup best-effort */ }
|
|
501
|
+
if (requireVacuum) {
|
|
502
|
+
try {
|
|
503
|
+
var db = dbMod();
|
|
504
|
+
if (db && typeof db.vacuumAfterErase === "function") {
|
|
505
|
+
db.vacuumAfterErase({ mode: "full" });
|
|
506
|
+
}
|
|
507
|
+
} catch (_vacErr) {
|
|
508
|
+
// VACUUM is best-effort at the eraseRow seam — DB might not be
|
|
509
|
+
// initialized yet (cluster mode, test fixture). The cascade row
|
|
510
|
+
// captures the skip; operators on regulated postures wire the
|
|
511
|
+
// sweep through b.retention which gates erasure on db.init().
|
|
512
|
+
try {
|
|
513
|
+
auditMod().safeEmit({
|
|
514
|
+
action: "cryptofield.vacuum.skipped",
|
|
515
|
+
outcome: "failure",
|
|
516
|
+
metadata: {
|
|
517
|
+
posture: _activePosture,
|
|
518
|
+
reason: (_vacErr && _vacErr.message) ? _vacErr.message : String(_vacErr),
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
} catch (_ae) { /* audit best-effort */ }
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
193
525
|
return out;
|
|
194
526
|
}
|
|
195
527
|
|
|
196
528
|
// ---- Lookup translation ----
|
|
197
529
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
530
|
+
/**
|
|
531
|
+
* @primitive b.cryptoField.lookupHash
|
|
532
|
+
* @signature b.cryptoField.lookupHash(table, field, value)
|
|
533
|
+
* @since 0.4.0
|
|
534
|
+
* @related b.cryptoField.computeDerived, b.cryptoField.sealRow
|
|
535
|
+
*
|
|
536
|
+
* Translates a plaintext-keyed lookup (e.g. `where({ email: "..." })`)
|
|
537
|
+
* into the derived-hash form (`where({ emailHash: hash(...) })`).
|
|
538
|
+
* Returns `{ field, value }` naming the derived column and its hash,
|
|
539
|
+
* or null when no derived hash is declared for that source field.
|
|
540
|
+
* Sealed columns without a declared derived hash are unindexable —
|
|
541
|
+
* every encryption uses a fresh random nonce, so the ciphertext alone
|
|
542
|
+
* cannot anchor a query.
|
|
543
|
+
*
|
|
544
|
+
* @example
|
|
545
|
+
* b.cryptoField.registerTable("users", {
|
|
546
|
+
* sealedFields: ["email"],
|
|
547
|
+
* derivedHashes: { emailHash: { from: "email" } }
|
|
548
|
+
* });
|
|
549
|
+
* var lookup = b.cryptoField.lookupHash("users", "email", "alice@example.com");
|
|
550
|
+
* lookup.field; // → "emailHash"
|
|
551
|
+
* typeof lookup.value; // → "string"
|
|
552
|
+
*
|
|
553
|
+
* b.cryptoField.lookupHash("users", "name", "Alice"); // → null (no derived hash)
|
|
554
|
+
*/
|
|
203
555
|
function lookupHash(table, field, value) {
|
|
204
556
|
var s = schemas[table];
|
|
205
557
|
if (!s || !s.derivedHashes) return null;
|
|
@@ -215,6 +567,328 @@ function lookupHash(table, field, value) {
|
|
|
215
567
|
return null;
|
|
216
568
|
}
|
|
217
569
|
|
|
570
|
+
/**
|
|
571
|
+
* @primitive b.cryptoField.declareColumnResidency
|
|
572
|
+
* @signature b.cryptoField.declareColumnResidency(table, opts)
|
|
573
|
+
* @since 0.7.27
|
|
574
|
+
* @compliance gdpr
|
|
575
|
+
* @related b.cryptoField.assertColumnResidency, b.cryptoField.getColumnResidency
|
|
576
|
+
*
|
|
577
|
+
* Declares per-column data residency for `table`. Real GDPR / DPDP /
|
|
578
|
+
* pipl-cn deployments have row-level mixed residency: a `users.name`
|
|
579
|
+
* column may be globally replicable, but `users.addressLine1` must
|
|
580
|
+
* stay in EU storage. At write time
|
|
581
|
+
* (`b.db.set` / `b.db.from(...).insert` / `.update`), the framework
|
|
582
|
+
* consults this registry; if the storage backend's tag doesn't satisfy
|
|
583
|
+
* the column's tag, the write is refused under gdpr / dpdp / pipl-cn /
|
|
584
|
+
* uk-gdpr postures. Throws on bad input (config-time fail-loud).
|
|
585
|
+
*
|
|
586
|
+
* @opts
|
|
587
|
+
* columnResidency: { [columnName]: "eu" | "us" | "global" | <tag> },
|
|
588
|
+
*
|
|
589
|
+
* @example
|
|
590
|
+
* b.cryptoField.declareColumnResidency("users", {
|
|
591
|
+
* columnResidency: {
|
|
592
|
+
* name: "global",
|
|
593
|
+
* addressLine1: "eu",
|
|
594
|
+
* addressLine2: "eu"
|
|
595
|
+
* }
|
|
596
|
+
* });
|
|
597
|
+
* var got = b.cryptoField.getColumnResidency("users");
|
|
598
|
+
* got.addressLine1; // → "eu"
|
|
599
|
+
*/
|
|
600
|
+
function declareColumnResidency(table, opts) {
|
|
601
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
602
|
+
throw new Error("declareColumnResidency: table must be a non-empty string");
|
|
603
|
+
}
|
|
604
|
+
if (opts === null || opts === undefined || typeof opts !== "object" || Array.isArray(opts)) {
|
|
605
|
+
throw new Error("declareColumnResidency: opts must be a plain object");
|
|
606
|
+
}
|
|
607
|
+
var map = opts.columnResidency;
|
|
608
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) {
|
|
609
|
+
throw new Error("declareColumnResidency: opts.columnResidency must be an object");
|
|
610
|
+
}
|
|
611
|
+
var entry = Object.create(null);
|
|
612
|
+
for (var col in map) {
|
|
613
|
+
if (!Object.prototype.hasOwnProperty.call(map, col)) continue;
|
|
614
|
+
var tag = map[col];
|
|
615
|
+
if (typeof tag !== "string" || tag.length === 0) {
|
|
616
|
+
throw new Error("declareColumnResidency: column '" + col +
|
|
617
|
+
"' residency tag must be a non-empty string");
|
|
618
|
+
}
|
|
619
|
+
entry[col] = tag;
|
|
620
|
+
}
|
|
621
|
+
columnResidency[table] = entry;
|
|
622
|
+
return { table: table, columnResidency: Object.assign({}, entry) };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* @primitive b.cryptoField.getColumnResidency
|
|
627
|
+
* @signature b.cryptoField.getColumnResidency(table)
|
|
628
|
+
* @since 0.7.27
|
|
629
|
+
* @related b.cryptoField.declareColumnResidency
|
|
630
|
+
*
|
|
631
|
+
* Returns the residency map declared for `table`, or null when the
|
|
632
|
+
* table has no residency declaration. Read-only — does not mutate
|
|
633
|
+
* state. Storage backends use this to inspect residency at the
|
|
634
|
+
* write boundary.
|
|
635
|
+
*
|
|
636
|
+
* @example
|
|
637
|
+
* b.cryptoField.declareColumnResidency("users", {
|
|
638
|
+
* columnResidency: { addressLine1: "eu" }
|
|
639
|
+
* });
|
|
640
|
+
* b.cryptoField.getColumnResidency("users"); // → { addressLine1: "eu" }
|
|
641
|
+
* b.cryptoField.getColumnResidency("unknown"); // → null
|
|
642
|
+
*/
|
|
643
|
+
function getColumnResidency(table) {
|
|
644
|
+
return columnResidency[table] || null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* @primitive b.cryptoField.assertColumnResidency
|
|
649
|
+
* @signature b.cryptoField.assertColumnResidency(table, row, args)
|
|
650
|
+
* @since 0.7.27
|
|
651
|
+
* @compliance gdpr
|
|
652
|
+
* @related b.cryptoField.declareColumnResidency
|
|
653
|
+
*
|
|
654
|
+
* Storage-write gate. Storage backends call this with the proposed
|
|
655
|
+
* row before the SQL hits the wire; refusal under regulated postures
|
|
656
|
+
* surfaces a config-time error rather than a silent cross-border leak.
|
|
657
|
+
* Returns null on pass; returns
|
|
658
|
+
* `{ error, table, column, want, got }` on refusal so the storage
|
|
659
|
+
* backend can wrap it in its own error class. Columns tagged "global"
|
|
660
|
+
* or "unrestricted" pass any backend; columns tagged with a region
|
|
661
|
+
* (e.g. "eu") refuse mismatched backends.
|
|
662
|
+
*
|
|
663
|
+
* @opts
|
|
664
|
+
* backendTag: string, // tag of the storage backend ("eu" | "us" | "unrestricted")
|
|
665
|
+
*
|
|
666
|
+
* @example
|
|
667
|
+
* b.cryptoField.declareColumnResidency("users", {
|
|
668
|
+
* columnResidency: { addressLine1: "eu" }
|
|
669
|
+
* });
|
|
670
|
+
* var refusal = b.cryptoField.assertColumnResidency(
|
|
671
|
+
* "users",
|
|
672
|
+
* { id: 1, addressLine1: "10 Rue de Rivoli" },
|
|
673
|
+
* { backendTag: "us" }
|
|
674
|
+
* );
|
|
675
|
+
* refusal.error; // → "column-residency-mismatch"
|
|
676
|
+
* refusal.column; // → "addressLine1"
|
|
677
|
+
* refusal.want; // → "eu"
|
|
678
|
+
* refusal.got; // → "us"
|
|
679
|
+
*
|
|
680
|
+
* b.cryptoField.assertColumnResidency(
|
|
681
|
+
* "users",
|
|
682
|
+
* { id: 1, addressLine1: "10 Rue de Rivoli" },
|
|
683
|
+
* { backendTag: "eu" }
|
|
684
|
+
* ); // → null (pass)
|
|
685
|
+
*/
|
|
686
|
+
function assertColumnResidency(table, row, args) {
|
|
687
|
+
var entry = columnResidency[table];
|
|
688
|
+
if (!entry || !row || !args) return null;
|
|
689
|
+
var backendTag = args.backendTag || "unrestricted";
|
|
690
|
+
for (var col in entry) {
|
|
691
|
+
var want = entry[col];
|
|
692
|
+
if (row[col] === undefined || row[col] === null) continue;
|
|
693
|
+
if (want === "global" || want === "unrestricted") continue;
|
|
694
|
+
if (backendTag === "unrestricted") continue;
|
|
695
|
+
if (backendTag !== want) {
|
|
696
|
+
return {
|
|
697
|
+
error: "column-residency-mismatch",
|
|
698
|
+
table: table,
|
|
699
|
+
column: col,
|
|
700
|
+
want: want,
|
|
701
|
+
got: backendTag,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* @primitive b.cryptoField.declarePerRowKey
|
|
710
|
+
* @signature b.cryptoField.declarePerRowKey(table, opts)
|
|
711
|
+
* @since 0.7.27
|
|
712
|
+
* @compliance gdpr, hipaa
|
|
713
|
+
* @related b.cryptoField.materializePerRowKey, b.cryptoField.destroyPerRowKey, b.subject.eraseHard
|
|
714
|
+
*
|
|
715
|
+
* Opts a table into per-row keying (K_row crypto-shred substrate).
|
|
716
|
+
* After registration, every INSERT generates a fresh K_row and stores
|
|
717
|
+
* it sealed in `_blamejs_per_row_keys (table, rowId, wrapped)`. AAD on
|
|
718
|
+
* the K_row binds (table, rowId, info-label) — copy-row attacks fail
|
|
719
|
+
* Poly1305 verification. `b.subject.eraseHard(subjectId)` deletes the
|
|
720
|
+
* per-row key entries for the subject's rows; WAL / replica residual
|
|
721
|
+
* ciphertext becomes mathematically undecryptable because K_row is
|
|
722
|
+
* gone everywhere it ever lived. Throws on bad input (config-time
|
|
723
|
+
* fail-loud).
|
|
724
|
+
*
|
|
725
|
+
* @opts
|
|
726
|
+
* keySize: number, // bytes; default 32 (XChaCha20-Poly1305 key length); minimum 16
|
|
727
|
+
* info: string, // HKDF info label; default "blamejs-per-row-key:<table>"
|
|
728
|
+
*
|
|
729
|
+
* @example
|
|
730
|
+
* var spec = b.cryptoField.declarePerRowKey("orders", {
|
|
731
|
+
* keySize: 32,
|
|
732
|
+
* info: "blamejs-per-row-key:orders"
|
|
733
|
+
* });
|
|
734
|
+
* spec.keySize; // → 32
|
|
735
|
+
* b.cryptoField.hasPerRowKey("orders"); // → true
|
|
736
|
+
*/
|
|
737
|
+
function declarePerRowKey(table, opts) {
|
|
738
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
739
|
+
throw new Error("declarePerRowKey: table must be a non-empty string");
|
|
740
|
+
}
|
|
741
|
+
opts = opts || {};
|
|
742
|
+
var keySize = opts.keySize === undefined ? 32 : opts.keySize; // allow:raw-byte-literal — XChaCha20-Poly1305 key length in bytes
|
|
743
|
+
if (typeof keySize !== "number" || !isFinite(keySize) ||
|
|
744
|
+
keySize < 16 || Math.floor(keySize) !== keySize) { // allow:raw-byte-literal — minimum AES-128 key length in bytes
|
|
745
|
+
throw new Error("declarePerRowKey: opts.keySize must be an integer >= 16 (bytes)");
|
|
746
|
+
}
|
|
747
|
+
var info = opts.info || ("blamejs-per-row-key:" + table);
|
|
748
|
+
if (typeof info !== "string" || info.length === 0) {
|
|
749
|
+
throw new Error("declarePerRowKey: opts.info must be a non-empty string");
|
|
750
|
+
}
|
|
751
|
+
perRowKeyTables[table] = { keySize: keySize, info: info };
|
|
752
|
+
return { table: table, keySize: keySize, info: info };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* @primitive b.cryptoField.hasPerRowKey
|
|
757
|
+
* @signature b.cryptoField.hasPerRowKey(table)
|
|
758
|
+
* @since 0.7.27
|
|
759
|
+
* @related b.cryptoField.declarePerRowKey
|
|
760
|
+
*
|
|
761
|
+
* Returns `true` when `table` has been registered for per-row keying
|
|
762
|
+
* via `declarePerRowKey`, `false` otherwise. Storage backends gate
|
|
763
|
+
* the K_row materialize/destroy paths through this check.
|
|
764
|
+
*
|
|
765
|
+
* @example
|
|
766
|
+
* b.cryptoField.hasPerRowKey("orders"); // → false
|
|
767
|
+
* b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
|
|
768
|
+
* b.cryptoField.hasPerRowKey("orders"); // → true
|
|
769
|
+
*/
|
|
770
|
+
function hasPerRowKey(table) {
|
|
771
|
+
return !!perRowKeyTables[table];
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* @primitive b.cryptoField.materializePerRowKey
|
|
776
|
+
* @signature b.cryptoField.materializePerRowKey(table, rowId, dbHandle)
|
|
777
|
+
* @since 0.7.27
|
|
778
|
+
* @compliance gdpr, hipaa
|
|
779
|
+
* @related b.cryptoField.declarePerRowKey, b.cryptoField.destroyPerRowKey
|
|
780
|
+
*
|
|
781
|
+
* Derive-and-store: called by the storage backend on INSERT. Generates
|
|
782
|
+
* `K_row = SHAKE256(vaultSalt + table + rowId + info, keySize)`, seals
|
|
783
|
+
* it via `vault.seal`, and inserts into `_blamejs_per_row_keys`.
|
|
784
|
+
* Returns the unwrapped K_row Buffer for the caller to use to encrypt
|
|
785
|
+
* sealed columns under the row-scoped key. Idempotent on UPSERT — if
|
|
786
|
+
* a K_row already exists for (table, rowId), returns the unwrapped
|
|
787
|
+
* existing key. The AAD-bound envelope rejects copy-row attacks: a
|
|
788
|
+
* wrapped K_row pasted under a different rowId fails Poly1305
|
|
789
|
+
* verification at unseal time.
|
|
790
|
+
*
|
|
791
|
+
* @example
|
|
792
|
+
* b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
|
|
793
|
+
* var dbHandle = b.db.handle();
|
|
794
|
+
* var kRow = b.cryptoField.materializePerRowKey("orders", "ord-42", dbHandle);
|
|
795
|
+
* Buffer.isBuffer(kRow); // → true
|
|
796
|
+
* kRow.length; // → 32
|
|
797
|
+
*
|
|
798
|
+
* // Idempotent — second call returns the same key.
|
|
799
|
+
* var kRowAgain = b.cryptoField.materializePerRowKey("orders", "ord-42", dbHandle);
|
|
800
|
+
* kRow.equals(kRowAgain); // → true
|
|
801
|
+
*/
|
|
802
|
+
function materializePerRowKey(table, rowId, dbHandle) {
|
|
803
|
+
var spec = perRowKeyTables[table];
|
|
804
|
+
if (!spec) return null;
|
|
805
|
+
if (!dbHandle || typeof dbHandle.prepare !== "function") {
|
|
806
|
+
throw new Error("materializePerRowKey: dbHandle (b.db) is required");
|
|
807
|
+
}
|
|
808
|
+
// Existing key? Re-use to support idempotent UPSERTs.
|
|
809
|
+
var existing = dbHandle.prepare(
|
|
810
|
+
'SELECT wrappedKey FROM "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
|
|
811
|
+
).get(table, rowId);
|
|
812
|
+
if (existing) {
|
|
813
|
+
return vault.unseal(existing.wrappedKey);
|
|
814
|
+
}
|
|
815
|
+
// Derive K_row from the table-level vault key salt + rowId via
|
|
816
|
+
// SHAKE256 expand. This is a one-shot derivation (HKDF-shaped) that
|
|
817
|
+
// matches the framework's PQC-first kdf — no HMAC-SHA3 dependency.
|
|
818
|
+
var saltHex = vault.getDerivedHashSalt().toString("hex");
|
|
819
|
+
var ikm = Buffer.from(saltHex + ":" + table + ":" + rowId + ":" + spec.info, "utf8");
|
|
820
|
+
var kRow = kdf(ikm, spec.keySize);
|
|
821
|
+
var sealed = vault.seal(kRow.toString("base64"));
|
|
822
|
+
dbHandle.prepare(
|
|
823
|
+
'INSERT INTO "_blamejs_per_row_keys" (tableName, rowId, wrappedKey, createdAt) ' +
|
|
824
|
+
'VALUES (?, ?, ?, ?)'
|
|
825
|
+
).run(table, rowId, sealed, Date.now());
|
|
826
|
+
return kRow;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* @primitive b.cryptoField.destroyPerRowKey
|
|
831
|
+
* @signature b.cryptoField.destroyPerRowKey(table, rowId, dbHandle)
|
|
832
|
+
* @since 0.7.27
|
|
833
|
+
* @compliance gdpr, hipaa
|
|
834
|
+
* @related b.cryptoField.materializePerRowKey, b.subject.eraseHard
|
|
835
|
+
*
|
|
836
|
+
* Crypto-shred: drops the per-row K_row entry from
|
|
837
|
+
* `_blamejs_per_row_keys`. Called by `b.subject.eraseHard` for each
|
|
838
|
+
* row mapped to the erased subject. Returns
|
|
839
|
+
* `{ destroyed: <rowsAffected> }`. After destruction, any WAL /
|
|
840
|
+
* replica residual ciphertext for the row is mathematically
|
|
841
|
+
* undecryptable — even with the vault root key — because K_row is
|
|
842
|
+
* gone everywhere it ever lived. No-op when the table is not
|
|
843
|
+
* registered for per-row keying.
|
|
844
|
+
*
|
|
845
|
+
* @example
|
|
846
|
+
* b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
|
|
847
|
+
* var dbHandle = b.db.handle();
|
|
848
|
+
* b.cryptoField.materializePerRowKey("orders", "ord-42", dbHandle);
|
|
849
|
+
*
|
|
850
|
+
* var result = b.cryptoField.destroyPerRowKey("orders", "ord-42", dbHandle);
|
|
851
|
+
* result.destroyed; // → 1
|
|
852
|
+
*
|
|
853
|
+
* // Subsequent destroy is a no-op.
|
|
854
|
+
* b.cryptoField.destroyPerRowKey("orders", "ord-42", dbHandle).destroyed; // → 0
|
|
855
|
+
*/
|
|
856
|
+
function destroyPerRowKey(table, rowId, dbHandle) {
|
|
857
|
+
if (!perRowKeyTables[table]) return { destroyed: 0 };
|
|
858
|
+
if (!dbHandle || typeof dbHandle.prepare !== "function") {
|
|
859
|
+
throw new Error("destroyPerRowKey: dbHandle (b.db) is required");
|
|
860
|
+
}
|
|
861
|
+
var result = dbHandle.prepare(
|
|
862
|
+
'DELETE FROM "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
|
|
863
|
+
).run(table, rowId);
|
|
864
|
+
return { destroyed: (result && result.changes) || 0 };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* @primitive b.cryptoField.clearResidencyForTest
|
|
869
|
+
* @signature b.cryptoField.clearResidencyForTest()
|
|
870
|
+
* @since 0.7.27
|
|
871
|
+
* @status experimental
|
|
872
|
+
* @related b.cryptoField.declareColumnResidency, b.cryptoField.declarePerRowKey
|
|
873
|
+
*
|
|
874
|
+
* Test-only helper. Drops every entry from the per-column residency
|
|
875
|
+
* registry AND the per-row-key registry so a test fixture can
|
|
876
|
+
* re-declare both between cases. Operator code never calls this —
|
|
877
|
+
* production declarations come from `b.db.init({ schema })` once at
|
|
878
|
+
* boot.
|
|
879
|
+
*
|
|
880
|
+
* @example
|
|
881
|
+
* b.cryptoField.declareColumnResidency("users", {
|
|
882
|
+
* columnResidency: { addressLine1: "eu" }
|
|
883
|
+
* });
|
|
884
|
+
* b.cryptoField.clearResidencyForTest();
|
|
885
|
+
* b.cryptoField.getColumnResidency("users"); // → null
|
|
886
|
+
*/
|
|
887
|
+
function clearResidencyForTest() {
|
|
888
|
+
for (var t in columnResidency) delete columnResidency[t];
|
|
889
|
+
for (var u in perRowKeyTables) delete perRowKeyTables[u];
|
|
890
|
+
}
|
|
891
|
+
|
|
218
892
|
module.exports = {
|
|
219
893
|
registerTable: registerTable,
|
|
220
894
|
getSchema: getSchema,
|
|
@@ -222,7 +896,17 @@ module.exports = {
|
|
|
222
896
|
sealRow: sealRow,
|
|
223
897
|
unsealRow: unsealRow,
|
|
224
898
|
eraseRow: eraseRow,
|
|
899
|
+
applyPosture: applyPosture,
|
|
900
|
+
getActivePosture: getActivePosture,
|
|
225
901
|
computeDerived: computeDerived,
|
|
226
902
|
lookupHash: lookupHash,
|
|
227
903
|
clearForTest: clearForTest,
|
|
904
|
+
declareColumnResidency: declareColumnResidency,
|
|
905
|
+
getColumnResidency: getColumnResidency,
|
|
906
|
+
assertColumnResidency: assertColumnResidency,
|
|
907
|
+
declarePerRowKey: declarePerRowKey,
|
|
908
|
+
hasPerRowKey: hasPerRowKey,
|
|
909
|
+
materializePerRowKey: materializePerRowKey,
|
|
910
|
+
destroyPerRowKey: destroyPerRowKey,
|
|
911
|
+
clearResidencyForTest: clearResidencyForTest,
|
|
228
912
|
};
|