@docstack/client 0.2.0 → 0.3.0
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/LICENSE +0 -0
- package/README.md +50 -37
- package/lib/core/attribute.d.ts +0 -0
- package/lib/core/class.d.ts +0 -0
- package/lib/core/content-transfer.d.ts +0 -0
- package/lib/core/crypto-engine/index.d.ts +63 -5
- package/lib/core/crypto-engine/utils.d.ts +10 -2
- package/lib/core/datamodel/index.d.ts +0 -0
- package/lib/core/domain.d.ts +0 -0
- package/lib/core/guarded-db.d.ts +0 -0
- package/lib/core/index.d.ts +1 -1
- package/lib/core/job-engine/index.d.ts +0 -0
- package/lib/core/job-engine/schedule.d.ts +0 -0
- package/lib/core/job-engine/scheduler.d.ts +0 -0
- package/lib/core/query-engine/accumulators.d.ts +0 -0
- package/lib/core/query-engine/classes.d.ts +0 -0
- package/lib/core/query-engine/evaluator.d.ts +0 -0
- package/lib/core/query-engine/executor.d.ts +0 -0
- package/lib/core/query-engine/index.d.ts +0 -0
- package/lib/core/query-engine/parser.d.ts +0 -0
- package/lib/core/query-engine/planner.d.ts +0 -0
- package/lib/core/stack.d.ts +76 -8
- package/lib/core/sync/class-filter.d.ts +0 -0
- package/lib/core/sync/filter-identity.d.ts +0 -0
- package/lib/core/sync/index.d.ts +0 -0
- package/lib/core/sync/internal-docs.d.ts +0 -0
- package/lib/core/sync/tenants.d.ts +0 -0
- package/lib/core/test-utils/docstack.d.ts +0 -0
- package/lib/core/transaction-engine/errors.d.ts +0 -0
- package/lib/core/transaction-engine/handle.d.ts +0 -0
- package/lib/core/transaction-engine/index.d.ts +0 -0
- package/lib/core/transaction-engine/overlay.d.ts +0 -0
- package/lib/core/transaction-engine/stage.d.ts +0 -0
- package/lib/core/transaction-engine/sweep.d.ts +0 -1
- package/lib/core/trigger/index.d.ts +0 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +583 -422
- package/lib/index.umd.js +1232 -421
- package/lib/index2.js +641 -0
- package/lib/plugins/pouchdb.d.ts +16 -1
- package/lib/utils/crypto/index.d.ts +0 -0
- package/lib/utils/index.d.ts +0 -0
- package/lib/utils/logger/index.d.ts +0 -0
- package/lib/utils/logger/transport.d.ts +0 -0
- package/lib/workers/dataModel.d.ts +0 -0
- package/package.json +2 -1
- package/lib/core/policy-engine/index.d.ts +0 -132
package/lib/index.js
CHANGED
|
@@ -3703,7 +3703,43 @@ const sys_017 = {
|
|
|
3703
3703
|
}
|
|
3704
3704
|
]
|
|
3705
3705
|
};
|
|
3706
|
-
|
|
3706
|
+
/**
|
|
3707
|
+
* Cryptographic access scopes arrive; the JS-rule policy engine retires
|
|
3708
|
+
* (ADR-0045, spec 02). `~AccessScope` is the carrier of the ONE access-control
|
|
3709
|
+
* language: a scope's CEK travels ABE-sealed under its attribute formula, and
|
|
3710
|
+
* denial is decryption failure - nothing evaluates rules anymore. The three
|
|
3711
|
+
* seeded `~Policy` documents are deactivated in place: nothing reads them, and
|
|
3712
|
+
* an inert-but-active policy would misstate what governs access. Their class
|
|
3713
|
+
* stays for legacy data.
|
|
3714
|
+
*/
|
|
3715
|
+
const sys_018 = {
|
|
3716
|
+
"_id": "~sys-0.0.18",
|
|
3717
|
+
"~class": "patch",
|
|
3718
|
+
"version": "0.0.18",
|
|
3719
|
+
"target": "system",
|
|
3720
|
+
"changelog": "### Schema Patch: v0.0.18\\n#### New Class: ~AccessScope (cryptographic access, ADR-0045)\\n#### Deactivated: seeded ~Policy documents (policy engine retired)",
|
|
3721
|
+
"docs": [
|
|
3722
|
+
{
|
|
3723
|
+
"_id": "~AccessScope",
|
|
3724
|
+
"~class": "class",
|
|
3725
|
+
"active": true,
|
|
3726
|
+
"name": "AccessScope",
|
|
3727
|
+
"description": "A named set of content sealed under one CEK, itself ABE-encrypted under the scope's attribute policy (ADR-0045)",
|
|
3728
|
+
"schema": {
|
|
3729
|
+
"scopeId": { "name": "scopeId", "type": "string", "config": { "mandatory": true } },
|
|
3730
|
+
"policyString": { "name": "policyString", "type": "string", "config": { "mandatory": true } },
|
|
3731
|
+
"abeWrappedCek": { "name": "abeWrappedCek", "type": "string", "config": { "mandatory": true } },
|
|
3732
|
+
"kid": { "name": "kid", "type": "string", "config": { "mandatory": true, "maxLength": 16 } },
|
|
3733
|
+
"version": { "name": "version", "type": "integer", "config": { "mandatory": true } },
|
|
3734
|
+
"encryptedMarker": { "name": "encryptedMarker", "type": "object", "config": { "mandatory": true } }
|
|
3735
|
+
}
|
|
3736
|
+
},
|
|
3737
|
+
{ "_id": "Policy-System-Classes", "_rev": "auto", "~class": "~Policy", "active": false },
|
|
3738
|
+
{ "_id": "Policy-Admin", "_rev": "auto", "~class": "~Policy", "active": false },
|
|
3739
|
+
{ "_id": "Policy-User-SelfAccess", "_rev": "auto", "~class": "~Policy", "active": false }
|
|
3740
|
+
]
|
|
3741
|
+
};
|
|
3742
|
+
syspatches.push(sys_011, sys_012, sys_013, sys_014, sys_015, sys_016, sys_017, sys_018);
|
|
3707
3743
|
/**
|
|
3708
3744
|
* Every document id the system patches seed.
|
|
3709
3745
|
*
|
|
@@ -3760,11 +3796,33 @@ const logger$4 = createLogger().child({ module: "pouchdb" });
|
|
|
3760
3796
|
* ```
|
|
3761
3797
|
*/
|
|
3762
3798
|
class StackLockedError extends Error {
|
|
3763
|
-
constructor(className) {
|
|
3764
|
-
super(
|
|
3765
|
-
`
|
|
3799
|
+
constructor(className, scopeId) {
|
|
3800
|
+
super(scopeId
|
|
3801
|
+
? `Scope '${scopeId}' is sealed: '${className}' has encrypted attributes and the keyring holds no ` +
|
|
3802
|
+
`read-write key for the scope, so writing would seal under the wrong key or none. ` +
|
|
3803
|
+
`Call 'stack.unlockScopes(attributeKey)' first.`
|
|
3804
|
+
: `Stack is locked: '${className}' has encrypted attributes and no document key has been supplied, ` +
|
|
3805
|
+
`so writing it would store those fields in the clear. Call 'stack.unlock(documentKey)' first.`);
|
|
3766
3806
|
this.name = "StackLockedError";
|
|
3767
3807
|
this.className = className;
|
|
3808
|
+
this.scopeId = scopeId;
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
/**
|
|
3812
|
+
* A write whose `~scope` label disagrees with its sealed payloads' key ids
|
|
3813
|
+
* (spec 02 §2.3 rule 2). Refused, never repaired: re-sealing content the
|
|
3814
|
+
* writer could not open under the labeled scope's key is exactly the
|
|
3815
|
+
* induced-downgrade attack a tampered label is fishing for. A legitimate
|
|
3816
|
+
* relabel opens the original scope first, so its payloads arrive as plaintext.
|
|
3817
|
+
*/
|
|
3818
|
+
class StackScopeMismatchError extends Error {
|
|
3819
|
+
constructor(docId, scopeId, kid) {
|
|
3820
|
+
super(`Document '${docId}' is labeled scope '${scopeId}' but carries a sealed payload under key ` +
|
|
3821
|
+
`'${kid !== null && kid !== void 0 ? kid : "(unstamped)"}' that does not belong to the scope. Refusing to write: relabeling ` +
|
|
3822
|
+
`requires the original scope open, and a mismatch is quarantined, never re-sealed (ADR-0045).`);
|
|
3823
|
+
this.name = "StackScopeMismatchError";
|
|
3824
|
+
this.docId = docId;
|
|
3825
|
+
this.scopeId = scopeId;
|
|
3768
3826
|
}
|
|
3769
3827
|
}
|
|
3770
3828
|
/**
|
|
@@ -4128,14 +4186,44 @@ const StackPlugin = (pouch, stack, pristine) => {
|
|
|
4128
4186
|
}
|
|
4129
4187
|
classCache.set(className, classObj);
|
|
4130
4188
|
const encryptableAttributes = classObj.getEncryptedAttributes();
|
|
4131
|
-
// A
|
|
4132
|
-
//
|
|
4133
|
-
//
|
|
4134
|
-
//
|
|
4189
|
+
// A sealed key cannot encrypt. Refuse instead of degrading:
|
|
4190
|
+
// silent plaintext is the failure mode ADR-0018 exists to remove,
|
|
4191
|
+
// and sealing under the WRONG key is its ADR-0045 sibling. A
|
|
4192
|
+
// scope-labeled document answers to its scope's CEK; an
|
|
4193
|
+
// unlabeled one to the legacy document key. Bootstrap patches
|
|
4194
|
+
// are the documented legacy-path exception - the seed system
|
|
4135
4195
|
// user has to exist before any key can be recovered, and
|
|
4136
4196
|
// `rekeyBootstrapDocuments` encrypts it once one arrives.
|
|
4137
|
-
if (encryptableAttributes.length && stack.
|
|
4138
|
-
|
|
4197
|
+
if (encryptableAttributes.length && stack.cryptoEngine.isEnabled()) {
|
|
4198
|
+
const scopeLabel = stack.resolveScopeLabel(doc, classObj.model);
|
|
4199
|
+
if (scopeLabel) {
|
|
4200
|
+
if (!stack.cryptoEngine.isScopeWritable(scopeLabel)) {
|
|
4201
|
+
throw new StackLockedError(className, scopeLabel);
|
|
4202
|
+
}
|
|
4203
|
+
// The label↔kid mismatch guard (spec 02 §2.3 rule 2),
|
|
4204
|
+
// checked here beside the lock refusal so both are the
|
|
4205
|
+
// same pre-write rejection: a payload already sealed
|
|
4206
|
+
// under a key OUTSIDE the labeled scope is a relabel the
|
|
4207
|
+
// writer could not have performed legitimately (it never
|
|
4208
|
+
// opened the source), so it is refused - never re-sealed
|
|
4209
|
+
// under the labeled key, which is the induced downgrade.
|
|
4210
|
+
const scopeKids = await stack.getAccessScopeKids(scopeLabel);
|
|
4211
|
+
if (!scopeKids) {
|
|
4212
|
+
throw new StackScopeMismatchError(doc._id, scopeLabel, undefined);
|
|
4213
|
+
}
|
|
4214
|
+
for (const attribute of encryptableAttributes) {
|
|
4215
|
+
const value = doc[attribute.getName()];
|
|
4216
|
+
if (value && typeof value === "object" && value.__enc === true) {
|
|
4217
|
+
const kid = value.kid;
|
|
4218
|
+
if (!kid || !scopeKids.has(kid)) {
|
|
4219
|
+
throw new StackScopeMismatchError(doc._id, scopeLabel, kid);
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
else if (stack.isLocked() && !(options === null || options === void 0 ? void 0 : options.isPatch)) {
|
|
4225
|
+
throw new StackLockedError(className);
|
|
4226
|
+
}
|
|
4139
4227
|
}
|
|
4140
4228
|
if (stack.cryptoEngine.isEnabled() && encryptableAttributes.length) {
|
|
4141
4229
|
await stack.cryptoEngine.decryptDocument(doc, classObj);
|
|
@@ -4230,7 +4318,11 @@ const StackPlugin = (pouch, stack, pristine) => {
|
|
|
4230
4318
|
}
|
|
4231
4319
|
classCache.set(className, classObj);
|
|
4232
4320
|
const clone = Object.assign({}, doc);
|
|
4233
|
-
|
|
4321
|
+
// The label was validated in the pre-write guard above
|
|
4322
|
+
// (scope known, writable, no foreign-kid payloads); here it
|
|
4323
|
+
// only selects the sealing key.
|
|
4324
|
+
const scopeLabel = stack.resolveScopeLabel(clone, classObj.model);
|
|
4325
|
+
await stack.cryptoEngine.encryptDocument(clone, classObj, scopeLabel);
|
|
4234
4326
|
return clone;
|
|
4235
4327
|
}
|
|
4236
4328
|
}
|
|
@@ -8676,300 +8768,6 @@ class JobScheduler {
|
|
|
8676
8768
|
}
|
|
8677
8769
|
}
|
|
8678
8770
|
|
|
8679
|
-
/**
|
|
8680
|
-
* Set of system classes that bypass policy evaluation.
|
|
8681
|
-
* These classes are internal to DocStack and always accessible.
|
|
8682
|
-
*/
|
|
8683
|
-
const SYSTEM_CLASSES = new Set([
|
|
8684
|
-
"~Policy",
|
|
8685
|
-
"~Job",
|
|
8686
|
-
"~JobRun",
|
|
8687
|
-
"~AuthModule",
|
|
8688
|
-
"~UserSession",
|
|
8689
|
-
"class",
|
|
8690
|
-
"domain"
|
|
8691
|
-
]);
|
|
8692
|
-
/**
|
|
8693
|
-
* Engine for evaluating access control policies on documents.
|
|
8694
|
-
*
|
|
8695
|
-
* PolicyEngine implements role-based access control (RBAC) by evaluating
|
|
8696
|
-
* policy rules against documents and user sessions. Policies can be:
|
|
8697
|
-
* - Class-level (apply to all documents of a class)
|
|
8698
|
-
* - User-specific (apply only to a specific user)
|
|
8699
|
-
* - Group-specific (apply only to users in a specific group)
|
|
8700
|
-
*
|
|
8701
|
-
* Policy rules are JavaScript expressions that receive the document,
|
|
8702
|
-
* session, and groupId as context and return a boolean.
|
|
8703
|
-
*
|
|
8704
|
-
* @example
|
|
8705
|
-
* ```typescript
|
|
8706
|
-
* // Policy engine is used automatically during document operations
|
|
8707
|
-
* // Policies are defined as documents:
|
|
8708
|
-
* await stack.createDoc(null, '~Policy', null, {
|
|
8709
|
-
* name: 'user-read-own',
|
|
8710
|
-
* targetClass: ['User'],
|
|
8711
|
-
* rule: 'return document.userId === session.userId;'
|
|
8712
|
-
* });
|
|
8713
|
-
* ```
|
|
8714
|
-
*/
|
|
8715
|
-
class PolicyEngine {
|
|
8716
|
-
/**
|
|
8717
|
-
* Creates a new PolicyEngine instance.
|
|
8718
|
-
* @param stack - The parent ClientStack instance
|
|
8719
|
-
*/
|
|
8720
|
-
constructor(stack) {
|
|
8721
|
-
/**
|
|
8722
|
-
* Every `~Policy` document, loaded once and reused across evaluations.
|
|
8723
|
-
*
|
|
8724
|
-
* `isReadableDocument` runs once per document a read returns, and it used to re-fetch
|
|
8725
|
-
* the policy list from the database each time - the dominant cost of every read path.
|
|
8726
|
-
* The list is invalidated on any write that touches a `~Policy` document: the write
|
|
8727
|
-
* path calls {@link invalidatePolicyCache} synchronously (see StackPlugin), and the
|
|
8728
|
-
* stack's shared changes feed calls it again for out-of-band writes such as another
|
|
8729
|
-
* tab's. `null` means not loaded.
|
|
8730
|
-
*/
|
|
8731
|
-
this.allPoliciesCache = null;
|
|
8732
|
-
/**
|
|
8733
|
-
* Compiled policy rules, keyed by their source text.
|
|
8734
|
-
*
|
|
8735
|
-
* A rule is evaluated once per policy per document, and `new Function` is a full
|
|
8736
|
-
* compile each time. The source text is the key - not the policy id - so two policies
|
|
8737
|
-
* sharing a rule share the compilation, and an edited rule is simply a new key.
|
|
8738
|
-
*/
|
|
8739
|
-
this.compiledRules = new Map();
|
|
8740
|
-
this.stack = stack;
|
|
8741
|
-
}
|
|
8742
|
-
/**
|
|
8743
|
-
* Drops the cached policy list so the next evaluation re-reads it.
|
|
8744
|
-
* Called by the write path and the changes feed whenever a `~Policy` document lands.
|
|
8745
|
-
*/
|
|
8746
|
-
invalidatePolicyCache() {
|
|
8747
|
-
this.allPoliciesCache = null;
|
|
8748
|
-
}
|
|
8749
|
-
/**
|
|
8750
|
-
* Gets the current authentication session proof.
|
|
8751
|
-
* @returns The session proof, or undefined if not authenticated
|
|
8752
|
-
*/
|
|
8753
|
-
getSessionProof() {
|
|
8754
|
-
return this.stack.authSession;
|
|
8755
|
-
}
|
|
8756
|
-
/**
|
|
8757
|
-
* Checks if a class should bypass policy evaluation.
|
|
8758
|
-
* System classes (prefixed with ~) are always allowed.
|
|
8759
|
-
*
|
|
8760
|
-
* @param targetClass - The class name to check
|
|
8761
|
-
* @returns `true` if the class should bypass policies
|
|
8762
|
-
*/
|
|
8763
|
-
shouldBypass(targetClass) {
|
|
8764
|
-
if (SYSTEM_CLASSES.has(targetClass))
|
|
8765
|
-
return true;
|
|
8766
|
-
const normalized = targetClass.startsWith("~") ? targetClass.slice(1) : `~${targetClass}`;
|
|
8767
|
-
return SYSTEM_CLASSES.has(normalized);
|
|
8768
|
-
}
|
|
8769
|
-
async loadPolicies(targetClass, aliases = []) {
|
|
8770
|
-
const identifiers = new Set([targetClass, ...aliases]);
|
|
8771
|
-
if (this.allPoliciesCache === null) {
|
|
8772
|
-
// Read raw rather than through `findDocuments`: policies are a system class
|
|
8773
|
-
// that bypasses policy evaluation anyway, and going through the read path
|
|
8774
|
-
// here recursed into a policy check per policy document. The selector is the
|
|
8775
|
-
// same one `findDocuments` produced (it injects `active: true`), and that is
|
|
8776
|
-
// the contract, not an accident: a policy enforces only while `active: true`,
|
|
8777
|
-
// exactly as a document is visible only while active - an unflagged or
|
|
8778
|
-
// explicitly inactive policy does not apply (ADR-0032). The explicit limit is
|
|
8779
|
-
// because pouchdb-find otherwise silently caps results at 25, which for
|
|
8780
|
-
// policies means silently not enforcing the 26th.
|
|
8781
|
-
const result = await this.stack.db.find({
|
|
8782
|
-
selector: { "~class": "~Policy", active: true },
|
|
8783
|
-
limit: 2 ** 31 - 1,
|
|
8784
|
-
});
|
|
8785
|
-
this.allPoliciesCache = result.docs;
|
|
8786
|
-
}
|
|
8787
|
-
return this.allPoliciesCache.filter((doc) => {
|
|
8788
|
-
if (!Array.isArray(doc.targetClass))
|
|
8789
|
-
return false;
|
|
8790
|
-
return doc.targetClass.some((entry) => identifiers.has(entry));
|
|
8791
|
-
});
|
|
8792
|
-
}
|
|
8793
|
-
getTargetIdentifiers(targetId, targetName) {
|
|
8794
|
-
const identifiers = new Set();
|
|
8795
|
-
const variants = [targetId, targetName];
|
|
8796
|
-
for (const value of variants) {
|
|
8797
|
-
identifiers.add(value);
|
|
8798
|
-
if (value.startsWith("~")) {
|
|
8799
|
-
identifiers.add(value.slice(1));
|
|
8800
|
-
}
|
|
8801
|
-
else {
|
|
8802
|
-
identifiers.add(`~${value}`);
|
|
8803
|
-
}
|
|
8804
|
-
}
|
|
8805
|
-
return Array.from(identifiers);
|
|
8806
|
-
}
|
|
8807
|
-
async resolveClassTarget(targetClass) {
|
|
8808
|
-
var _a, _b;
|
|
8809
|
-
const classModel = await this.stack.getClassModel(targetClass).catch(() => null);
|
|
8810
|
-
return {
|
|
8811
|
-
id: (_a = classModel === null || classModel === void 0 ? void 0 : classModel._id) !== null && _a !== void 0 ? _a : targetClass,
|
|
8812
|
-
name: (_b = classModel === null || classModel === void 0 ? void 0 : classModel.name) !== null && _b !== void 0 ? _b : targetClass,
|
|
8813
|
-
};
|
|
8814
|
-
}
|
|
8815
|
-
/**
|
|
8816
|
-
* Evaluates a policy rule against a document and session.
|
|
8817
|
-
* The rule is a JavaScript expression that returns a boolean.
|
|
8818
|
-
*
|
|
8819
|
-
* @param policy - The policy containing the rule
|
|
8820
|
-
* @param document - The document being accessed
|
|
8821
|
-
* @param session - The current auth session
|
|
8822
|
-
* @returns Whether the rule permits access
|
|
8823
|
-
*/
|
|
8824
|
-
async evaluateRule(policy, document, session) {
|
|
8825
|
-
let executor = this.compiledRules.get(policy.rule);
|
|
8826
|
-
if (!executor) {
|
|
8827
|
-
executor = new Function("document", "session", "groupId", `"use strict"; ${policy.rule}`);
|
|
8828
|
-
this.compiledRules.set(policy.rule, executor);
|
|
8829
|
-
}
|
|
8830
|
-
const result = executor(document || {}, session.session, session.session.groupId);
|
|
8831
|
-
if (result instanceof Promise) {
|
|
8832
|
-
return Boolean(await result);
|
|
8833
|
-
}
|
|
8834
|
-
return Boolean(result);
|
|
8835
|
-
}
|
|
8836
|
-
filterPoliciesForSession(policies, session) {
|
|
8837
|
-
const sessionUserId = session.session.userId || session.session.username;
|
|
8838
|
-
const sessionGroups = Array.isArray(session.session.groupId)
|
|
8839
|
-
? session.session.groupId
|
|
8840
|
-
: session.session.groupId
|
|
8841
|
-
? [session.session.groupId]
|
|
8842
|
-
: [];
|
|
8843
|
-
return policies.filter((policy) => {
|
|
8844
|
-
const matchesUser = !policy.userId || policy.userId === sessionUserId || policy.userId === session.session.username;
|
|
8845
|
-
const matchesGroup = !policy.groupId || sessionGroups.includes(policy.groupId);
|
|
8846
|
-
return matchesUser && matchesGroup;
|
|
8847
|
-
});
|
|
8848
|
-
}
|
|
8849
|
-
async authorize(targetClass, operation, document) {
|
|
8850
|
-
const { id: targetId, name: targetName } = await this.resolveClassTarget(targetClass);
|
|
8851
|
-
const identifiers = this.getTargetIdentifiers(targetId, targetName);
|
|
8852
|
-
if (this.shouldBypass(targetName)) {
|
|
8853
|
-
return true;
|
|
8854
|
-
}
|
|
8855
|
-
const policies = await this.loadPolicies(targetId, identifiers);
|
|
8856
|
-
if (policies.length === 0) {
|
|
8857
|
-
return true;
|
|
8858
|
-
}
|
|
8859
|
-
const session = this.getSessionProof();
|
|
8860
|
-
if (!session) {
|
|
8861
|
-
throw new Error("Stack is not authenticated for policy evaluation");
|
|
8862
|
-
}
|
|
8863
|
-
const targetedPolicies = policies.filter((policy) => policy.userId || policy.groupId);
|
|
8864
|
-
const basePolicies = policies.filter((policy) => !policy.userId && !policy.groupId);
|
|
8865
|
-
const matchingPolicies = targetedPolicies.length > 0
|
|
8866
|
-
? this.filterPoliciesForSession(targetedPolicies, session)
|
|
8867
|
-
: this.filterPoliciesForSession(basePolicies, session);
|
|
8868
|
-
if (targetedPolicies.length > 0 && matchingPolicies.length === 0) {
|
|
8869
|
-
throw new Error(`No matching policy allowed ${operation} on class '${targetClass}'`);
|
|
8870
|
-
}
|
|
8871
|
-
let allowed = false;
|
|
8872
|
-
for (const policy of matchingPolicies) {
|
|
8873
|
-
const result = await this.evaluateRule(policy, document, session);
|
|
8874
|
-
if (result === false) {
|
|
8875
|
-
throw new Error(`Policy '${policy._id}' denied ${operation} on class '${targetClass}'`);
|
|
8876
|
-
}
|
|
8877
|
-
if (result === true) {
|
|
8878
|
-
allowed = true;
|
|
8879
|
-
}
|
|
8880
|
-
}
|
|
8881
|
-
return allowed;
|
|
8882
|
-
}
|
|
8883
|
-
/**
|
|
8884
|
-
* Whether any policy applies to a class - i.e. whether reads of it are filtered.
|
|
8885
|
-
*
|
|
8886
|
-
* Lets the query engine know when a database-level LIMIT is safe: with no
|
|
8887
|
-
* applicable policies, no row fetched within the limit can be dropped afterwards.
|
|
8888
|
-
* Cheap once the policy list is cached.
|
|
8889
|
-
*
|
|
8890
|
-
* @param targetClass - The class name or id.
|
|
8891
|
-
* @returns `true` if at least one policy targets the class.
|
|
8892
|
-
*/
|
|
8893
|
-
async hasPoliciesFor(targetClass) {
|
|
8894
|
-
const { id: targetId, name: targetName } = await this.resolveClassTarget(targetClass);
|
|
8895
|
-
if (this.shouldBypass(targetName))
|
|
8896
|
-
return false;
|
|
8897
|
-
const policies = await this.loadPolicies(targetId, this.getTargetIdentifiers(targetId, targetName));
|
|
8898
|
-
return policies.length > 0;
|
|
8899
|
-
}
|
|
8900
|
-
/**
|
|
8901
|
-
* Ensures write access is allowed for a document of the given class.
|
|
8902
|
-
* Throws an error if no policy permits the write operation.
|
|
8903
|
-
*
|
|
8904
|
-
* @param targetClass - The class of the document being written
|
|
8905
|
-
* @param document - The document to write
|
|
8906
|
-
* @throws Error if write is not permitted
|
|
8907
|
-
*
|
|
8908
|
-
* @example
|
|
8909
|
-
* ```typescript
|
|
8910
|
-
* // Called automatically during createDoc/updateCard operations
|
|
8911
|
-
* await policyEngine.ensureWriteAllowed('Task', taskDocument);
|
|
8912
|
-
* ```
|
|
8913
|
-
*/
|
|
8914
|
-
async ensureWriteAllowed(targetClass, document) {
|
|
8915
|
-
const allowed = await this.authorize(targetClass, "write", document);
|
|
8916
|
-
if (!allowed) {
|
|
8917
|
-
throw new Error(`No matching policy allowed write on class '${targetClass}'`);
|
|
8918
|
-
}
|
|
8919
|
-
}
|
|
8920
|
-
/**
|
|
8921
|
-
* Checks if a document is readable by the current user.
|
|
8922
|
-
* Used to filter query results based on read policies.
|
|
8923
|
-
*
|
|
8924
|
-
* @param document - The document to check
|
|
8925
|
-
* @returns `true` if the document can be read
|
|
8926
|
-
* @throws Error if the stack is not authenticated
|
|
8927
|
-
*
|
|
8928
|
-
* @example
|
|
8929
|
-
* ```typescript
|
|
8930
|
-
* // Used internally during findDocuments
|
|
8931
|
-
* const readable = await policyEngine.isReadableDocument(doc);
|
|
8932
|
-
* if (readable) {
|
|
8933
|
-
* results.push(doc);
|
|
8934
|
-
* }
|
|
8935
|
-
* ```
|
|
8936
|
-
*/
|
|
8937
|
-
async isReadableDocument(document) {
|
|
8938
|
-
const targetClass = document === null || document === void 0 ? void 0 : document["~class"];
|
|
8939
|
-
const { id: targetId, name: targetName } = await this.resolveClassTarget(targetClass);
|
|
8940
|
-
if (!targetClass || this.shouldBypass(targetName)) {
|
|
8941
|
-
return true;
|
|
8942
|
-
}
|
|
8943
|
-
const policies = await this.loadPolicies(targetId, this.getTargetIdentifiers(targetId, targetName));
|
|
8944
|
-
if (policies.length === 0) {
|
|
8945
|
-
return true;
|
|
8946
|
-
}
|
|
8947
|
-
const session = this.getSessionProof();
|
|
8948
|
-
if (!session) {
|
|
8949
|
-
throw new Error("Stack is not authenticated for policy evaluation");
|
|
8950
|
-
}
|
|
8951
|
-
const targetedPolicies = policies.filter((policy) => policy.userId || policy.groupId);
|
|
8952
|
-
const basePolicies = policies.filter((policy) => !policy.userId && !policy.groupId);
|
|
8953
|
-
const matchingPolicies = targetedPolicies.length > 0
|
|
8954
|
-
? this.filterPoliciesForSession(targetedPolicies, session)
|
|
8955
|
-
: this.filterPoliciesForSession(basePolicies, session);
|
|
8956
|
-
if (targetedPolicies.length > 0 && matchingPolicies.length === 0) {
|
|
8957
|
-
return false;
|
|
8958
|
-
}
|
|
8959
|
-
let permitted = false;
|
|
8960
|
-
for (const policy of matchingPolicies) {
|
|
8961
|
-
const result = await this.evaluateRule(policy, document, session);
|
|
8962
|
-
if (result === false) {
|
|
8963
|
-
return false;
|
|
8964
|
-
}
|
|
8965
|
-
if (result === true) {
|
|
8966
|
-
permitted = true;
|
|
8967
|
-
}
|
|
8968
|
-
}
|
|
8969
|
-
return permitted;
|
|
8970
|
-
}
|
|
8971
|
-
}
|
|
8972
|
-
|
|
8973
8771
|
const encoder = new TextEncoder();
|
|
8974
8772
|
const decoder = new TextDecoder();
|
|
8975
8773
|
const getCrypto = () => globalThis.crypto;
|
|
@@ -9037,12 +8835,23 @@ const deriveKeyId = async (hexKey) => {
|
|
|
9037
8835
|
.map(b => b.toString(16).padStart(2, "0"))
|
|
9038
8836
|
.join("");
|
|
9039
8837
|
};
|
|
9040
|
-
|
|
8838
|
+
/**
|
|
8839
|
+
* The additional-authenticated-data string binding a scope-sealed payload to
|
|
8840
|
+
* its label (spec 02 §2.3 rule 3): decryption derives it from the DOCUMENT's
|
|
8841
|
+
* `~scope` at read time, so a tampered or stripped label does not merely look
|
|
8842
|
+
* inconsistent - the GCM authentication fails and the payload stays sealed.
|
|
8843
|
+
* Legacy-key payloads pass no AAD, keeping pre-scope ciphertext readable.
|
|
8844
|
+
*/
|
|
8845
|
+
const scopeAad = (scopeId, kid) => `${scopeId}|${kid}`;
|
|
8846
|
+
const encryptWithAesGcm = async (plaintext, key, kid, aad) => {
|
|
9041
8847
|
const cryptoObj = getCrypto();
|
|
9042
8848
|
const iv = new Uint8Array(12);
|
|
9043
8849
|
cryptoObj.getRandomValues(iv);
|
|
9044
8850
|
const ivBuffer = iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength);
|
|
9045
|
-
const
|
|
8851
|
+
const params = { name: "AES-GCM", iv: ivBuffer };
|
|
8852
|
+
if (aad)
|
|
8853
|
+
params.additionalData = encoder.encode(aad);
|
|
8854
|
+
const ciphertext = await cryptoObj.subtle.encrypt(params, key, encoder.encode(plaintext));
|
|
9046
8855
|
const payload = {
|
|
9047
8856
|
__enc: true,
|
|
9048
8857
|
iv: toBase64(iv),
|
|
@@ -9053,18 +8862,23 @@ const encryptWithAesGcm = async (plaintext, key, kid) => {
|
|
|
9053
8862
|
payload.kid = kid;
|
|
9054
8863
|
return payload;
|
|
9055
8864
|
};
|
|
9056
|
-
const decryptWithAesGcm = async (payload, key) => {
|
|
8865
|
+
const decryptWithAesGcm = async (payload, key, aad) => {
|
|
9057
8866
|
const cryptoObj = getCrypto();
|
|
9058
8867
|
const ivBytes = fromBase64(payload.iv);
|
|
9059
8868
|
const dataBytes = fromBase64(payload.data);
|
|
9060
8869
|
const ivBuffer = ivBytes.buffer.slice(ivBytes.byteOffset, ivBytes.byteOffset + ivBytes.byteLength);
|
|
9061
8870
|
const dataBuffer = dataBytes.buffer.slice(dataBytes.byteOffset, dataBytes.byteOffset + dataBytes.byteLength);
|
|
9062
|
-
const
|
|
8871
|
+
const params = { name: "AES-GCM", iv: ivBuffer };
|
|
8872
|
+
if (aad)
|
|
8873
|
+
params.additionalData = encoder.encode(aad);
|
|
8874
|
+
const decrypted = await cryptoObj.subtle.decrypt(params, key, dataBuffer);
|
|
9063
8875
|
return decoder.decode(decrypted);
|
|
9064
8876
|
};
|
|
9065
8877
|
const wrapDocumentKey = async (documentKey, derivedKeyHex) => {
|
|
9066
8878
|
const key = await importAesKeyFromHex(derivedKeyHex);
|
|
9067
|
-
|
|
8879
|
+
// Stamped with the WRAPPED key's id, so wrapped key material is
|
|
8880
|
+
// self-identifying (spec 02 §3): a user record can say which key it holds.
|
|
8881
|
+
const payload = await encryptWithAesGcm(documentKey, key, await deriveKeyId(documentKey));
|
|
9068
8882
|
return JSON.stringify(payload);
|
|
9069
8883
|
};
|
|
9070
8884
|
const unwrapDocumentKey = async (wrappedDocumentKey, cryptoKey, derivedKeyHex) => {
|
|
@@ -9127,6 +8941,18 @@ class CryptoEngine {
|
|
|
9127
8941
|
* interrupted, and resumed.
|
|
9128
8942
|
*/
|
|
9129
8943
|
this.retiredKeys = new Map();
|
|
8944
|
+
/**
|
|
8945
|
+
* Scope CEKs admitted by {@link admitScopeKey}, by key id (ADR-0045).
|
|
8946
|
+
*
|
|
8947
|
+
* The keyring the single document key generalizes into: reads dispatch by a
|
|
8948
|
+
* payload's `kid` across the legacy key, retired keys, and these; writes
|
|
8949
|
+
* into a scope-labeled document select through {@link scopeWriteKeys}. A
|
|
8950
|
+
* scope whose CEK is absent here is simply LOCKED - its payloads stay
|
|
8951
|
+
* sealed, which is the access decision.
|
|
8952
|
+
*/
|
|
8953
|
+
this.scopeKeys = new Map();
|
|
8954
|
+
/** The read-write entry per scope id - the CEK that seals new writes. */
|
|
8955
|
+
this.scopeWriteKeys = new Map();
|
|
9130
8956
|
this.logger = createLogger().child({ module: "crypto-engine" });
|
|
9131
8957
|
this.stack = stack;
|
|
9132
8958
|
this.enabled = !stack.isCryptoEngineDisabled();
|
|
@@ -9206,8 +9032,94 @@ class CryptoEngine {
|
|
|
9206
9032
|
return [
|
|
9207
9033
|
...(this.documentKeyId ? [this.documentKeyId] : []),
|
|
9208
9034
|
...this.retiredKeys.keys(),
|
|
9035
|
+
...this.scopeKeys.keys(),
|
|
9209
9036
|
];
|
|
9210
9037
|
}
|
|
9038
|
+
/**
|
|
9039
|
+
* Admits a scope's CEK into the keyring after its canary verified
|
|
9040
|
+
* (spec 02 §2.1 - admission is the caller's `verifyScopeCek` first, this
|
|
9041
|
+
* second). The winning version of a scope enters read-write and becomes
|
|
9042
|
+
* the key new writes into the scope seal under; older versions enter
|
|
9043
|
+
* read-only, the retired-keys discipline applied per scope.
|
|
9044
|
+
*
|
|
9045
|
+
* @returns The admitted key's id.
|
|
9046
|
+
*/
|
|
9047
|
+
async admitScopeKey(scopeId, cekHex, version, mode) {
|
|
9048
|
+
if (!this.enabled)
|
|
9049
|
+
throw new Error("Crypto engine is disabled");
|
|
9050
|
+
const kid = await deriveKeyId(cekHex);
|
|
9051
|
+
const entry = {
|
|
9052
|
+
kid,
|
|
9053
|
+
cryptoKey: await importAesKeyFromHex(cekHex, mode === "read-write" ? ["encrypt", "decrypt"] : ["decrypt"]),
|
|
9054
|
+
scopeId,
|
|
9055
|
+
version,
|
|
9056
|
+
mode,
|
|
9057
|
+
};
|
|
9058
|
+
this.scopeKeys.set(kid, entry);
|
|
9059
|
+
if (mode === "read-write") {
|
|
9060
|
+
const current = this.scopeWriteKeys.get(scopeId);
|
|
9061
|
+
if (!current || current.version <= version) {
|
|
9062
|
+
if (current && current.kid !== kid) {
|
|
9063
|
+
// Superseded by a higher rotation version: keep it readable.
|
|
9064
|
+
this.scopeKeys.set(current.kid, Object.assign(Object.assign({}, current), { mode: "read-only" }));
|
|
9065
|
+
}
|
|
9066
|
+
this.scopeWriteKeys.set(scopeId, entry);
|
|
9067
|
+
}
|
|
9068
|
+
else {
|
|
9069
|
+
// A lower version arriving late reads, never writes.
|
|
9070
|
+
this.scopeKeys.set(kid, Object.assign(Object.assign({}, entry), { mode: "read-only" }));
|
|
9071
|
+
}
|
|
9072
|
+
}
|
|
9073
|
+
return kid;
|
|
9074
|
+
}
|
|
9075
|
+
/**
|
|
9076
|
+
* Tests a candidate CEK against a scope's canary WITHOUT admitting it -
|
|
9077
|
+
* the ADR-0018 admission discipline per scope: a corrupted or
|
|
9078
|
+
* rotated-away ciphertext is an error at unlock, not garbage later. The
|
|
9079
|
+
* marker's AAD binds it to the scope and key it was minted for.
|
|
9080
|
+
*/
|
|
9081
|
+
async verifyScopeCek(scopeId, cekHex, marker) {
|
|
9082
|
+
if (!this.enabled)
|
|
9083
|
+
return false;
|
|
9084
|
+
if (!isEncryptedPayload(marker))
|
|
9085
|
+
return false;
|
|
9086
|
+
try {
|
|
9087
|
+
const kid = await deriveKeyId(cekHex);
|
|
9088
|
+
const key = await importAesKeyFromHex(cekHex, ["decrypt"]);
|
|
9089
|
+
await decryptWithAesGcm(marker, key, scopeAad(scopeId, kid));
|
|
9090
|
+
return true;
|
|
9091
|
+
}
|
|
9092
|
+
catch (_a) {
|
|
9093
|
+
return false;
|
|
9094
|
+
}
|
|
9095
|
+
}
|
|
9096
|
+
/** Whether new writes into this scope can seal - a read-write CEK is held. */
|
|
9097
|
+
isScopeWritable(scopeId) {
|
|
9098
|
+
return this.enabled && this.scopeWriteKeys.has(scopeId);
|
|
9099
|
+
}
|
|
9100
|
+
/** Scope ids holding a read-write CEK. */
|
|
9101
|
+
unlockedScopeIds() {
|
|
9102
|
+
return this.enabled ? [...this.scopeWriteKeys.keys()] : [];
|
|
9103
|
+
}
|
|
9104
|
+
/** The key id new writes into a scope seal under, when the scope is open. */
|
|
9105
|
+
getScopeWriteKeyId(scopeId) {
|
|
9106
|
+
var _a;
|
|
9107
|
+
return (_a = this.scopeWriteKeys.get(scopeId)) === null || _a === void 0 ? void 0 : _a.kid;
|
|
9108
|
+
}
|
|
9109
|
+
/** The scope a held key id belongs to, if it is a scope key. */
|
|
9110
|
+
scopeOfKid(kid) {
|
|
9111
|
+
var _a;
|
|
9112
|
+
return (_a = this.scopeKeys.get(kid)) === null || _a === void 0 ? void 0 : _a.scopeId;
|
|
9113
|
+
}
|
|
9114
|
+
/**
|
|
9115
|
+
* Drops every admitted scope CEK - the session's material is gone, the
|
|
9116
|
+
* scopes are locked again. Mirrors what clearing the document key does for
|
|
9117
|
+
* the legacy path.
|
|
9118
|
+
*/
|
|
9119
|
+
dropScopeKeys() {
|
|
9120
|
+
this.scopeKeys.clear();
|
|
9121
|
+
this.scopeWriteKeys.clear();
|
|
9122
|
+
}
|
|
9211
9123
|
/**
|
|
9212
9124
|
* Generates a cryptographically secure random string.
|
|
9213
9125
|
* Useful for generating salts or nonces.
|
|
@@ -9309,22 +9221,29 @@ class CryptoEngine {
|
|
|
9309
9221
|
return this.cryptoKey;
|
|
9310
9222
|
}
|
|
9311
9223
|
/**
|
|
9312
|
-
* Chooses the
|
|
9224
|
+
* Chooses the keyring entry that can open a payload.
|
|
9313
9225
|
*
|
|
9314
9226
|
* A payload names its key, so an old field found mid-re-key is decrypted with the key
|
|
9315
9227
|
* it was actually written under instead of failing against the current one. Payloads
|
|
9316
|
-
* from before identifiers existed name nothing, and are tried against the
|
|
9317
|
-
*
|
|
9228
|
+
* from before identifiers existed name nothing, and are tried against the LEGACY
|
|
9229
|
+
* document key only (spec 02 §3): under multiple keys, falling back to "whatever is
|
|
9230
|
+
* current" would silently mis-route - a scope key never answers for an unnamed
|
|
9231
|
+
* payload.
|
|
9318
9232
|
*/
|
|
9319
|
-
async
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
return
|
|
9323
|
-
|
|
9324
|
-
|
|
9325
|
-
|
|
9233
|
+
async resolveEntryFor(payload) {
|
|
9234
|
+
if (!payload.kid || payload.kid === this.documentKeyId) {
|
|
9235
|
+
const legacy = await this.getCryptoKey();
|
|
9236
|
+
return legacy ? { key: legacy } : null;
|
|
9237
|
+
}
|
|
9238
|
+
const retired = this.retiredKeys.get(payload.kid);
|
|
9239
|
+
if (retired)
|
|
9240
|
+
return { key: retired };
|
|
9241
|
+
const scope = this.scopeKeys.get(payload.kid);
|
|
9242
|
+
if (scope)
|
|
9243
|
+
return { key: scope.cryptoKey, scope };
|
|
9244
|
+
return null;
|
|
9326
9245
|
}
|
|
9327
|
-
async encryptValue(value, key) {
|
|
9246
|
+
async encryptValue(value, key, kid, aad) {
|
|
9328
9247
|
if (!this.enabled)
|
|
9329
9248
|
return value;
|
|
9330
9249
|
if (!key)
|
|
@@ -9334,26 +9253,38 @@ class CryptoEngine {
|
|
|
9334
9253
|
if (isEncryptedPayload(value))
|
|
9335
9254
|
return value;
|
|
9336
9255
|
const serialized = JSON.stringify(value);
|
|
9337
|
-
return encryptWithAesGcm(serialized, key, this.documentKeyId);
|
|
9256
|
+
return encryptWithAesGcm(serialized, key, kid !== null && kid !== void 0 ? kid : this.documentKeyId, aad);
|
|
9338
9257
|
}
|
|
9339
|
-
|
|
9258
|
+
/**
|
|
9259
|
+
* @param label - The document's `~scope` at read time. A scope-sealed
|
|
9260
|
+
* payload authenticates against it (AAD, spec 02 §2.3 rule 3): a tampered
|
|
9261
|
+
* or stripped label fails the GCM authentication and the payload stays
|
|
9262
|
+
* sealed - the mismatch is detected, never silently honored.
|
|
9263
|
+
*/
|
|
9264
|
+
async decryptValue(value, label) {
|
|
9340
9265
|
if (!this.enabled)
|
|
9341
9266
|
return value;
|
|
9342
|
-
if (!key)
|
|
9343
|
-
return value;
|
|
9344
9267
|
if (!isEncryptedPayload(value))
|
|
9345
9268
|
return value;
|
|
9346
|
-
const resolved = await this.
|
|
9269
|
+
const resolved = await this.resolveEntryFor(value);
|
|
9347
9270
|
if (!resolved) {
|
|
9348
9271
|
this.logger.warn("No held key matches this payload; leaving it encrypted", { kid: value.kid });
|
|
9349
9272
|
return value;
|
|
9350
9273
|
}
|
|
9274
|
+
const aad = resolved.scope ? scopeAad(label !== null && label !== void 0 ? label : "", value.kid) : undefined;
|
|
9351
9275
|
try {
|
|
9352
|
-
const decrypted = await decryptWithAesGcm(value, resolved);
|
|
9276
|
+
const decrypted = await decryptWithAesGcm(value, resolved.key, aad);
|
|
9353
9277
|
return JSON.parse(decrypted);
|
|
9354
9278
|
}
|
|
9355
9279
|
catch (error) {
|
|
9356
|
-
|
|
9280
|
+
if (resolved.scope) {
|
|
9281
|
+
this.logger.warn("Scope payload does not authenticate against the document's label; leaving it sealed", {
|
|
9282
|
+
kid: value.kid, scopeOfKey: resolved.scope.scopeId, label: label !== null && label !== void 0 ? label : null,
|
|
9283
|
+
});
|
|
9284
|
+
}
|
|
9285
|
+
else {
|
|
9286
|
+
this.logger.error("Failed to decrypt value", { error: (error === null || error === void 0 ? void 0 : error.message) || error });
|
|
9287
|
+
}
|
|
9357
9288
|
return value;
|
|
9358
9289
|
}
|
|
9359
9290
|
}
|
|
@@ -9388,23 +9319,46 @@ class CryptoEngine {
|
|
|
9388
9319
|
* @param document - The document to encrypt
|
|
9389
9320
|
* @param classObj - The class defining which fields to encrypt
|
|
9390
9321
|
*/
|
|
9391
|
-
|
|
9322
|
+
/**
|
|
9323
|
+
* @param scopeId - The document's resolved scope label. When present, the
|
|
9324
|
+
* scope's read-write CEK seals every attribute (stamped with its `kid`,
|
|
9325
|
+
* bound to the label via AAD); the caller has already refused the write if
|
|
9326
|
+
* the scope is not open. Absent, the legacy document key path applies
|
|
9327
|
+
* unchanged.
|
|
9328
|
+
*/
|
|
9329
|
+
async encryptDocument(document, classObj, scopeId) {
|
|
9392
9330
|
var _a, _b, _c;
|
|
9393
9331
|
if (!this.enabled)
|
|
9394
9332
|
return;
|
|
9395
9333
|
const encryptableAttributes = classObj.getEncryptedAttributes();
|
|
9396
9334
|
if (!encryptableAttributes.length)
|
|
9397
9335
|
return;
|
|
9398
|
-
|
|
9399
|
-
|
|
9400
|
-
|
|
9401
|
-
|
|
9336
|
+
let key;
|
|
9337
|
+
let kid;
|
|
9338
|
+
let aad;
|
|
9339
|
+
if (scopeId) {
|
|
9340
|
+
const entry = this.scopeWriteKeys.get(scopeId);
|
|
9341
|
+
if (!entry) {
|
|
9342
|
+
// The plugin refuses scope writes before reaching here; this is the
|
|
9343
|
+
// engine's own last line - sealing under the wrong key is never a fallback.
|
|
9344
|
+
throw new Error(`Scope '${scopeId}' holds no read-write key; cannot encrypt.`);
|
|
9345
|
+
}
|
|
9346
|
+
key = entry.cryptoKey;
|
|
9347
|
+
kid = entry.kid;
|
|
9348
|
+
aad = scopeAad(scopeId, entry.kid);
|
|
9349
|
+
}
|
|
9350
|
+
else {
|
|
9351
|
+
key = await this.getCryptoKey();
|
|
9352
|
+
if (!key) {
|
|
9353
|
+
this.logger.warn("Document key is not available; skipping encryption", { className: (_b = (_a = classObj.getName) === null || _a === void 0 ? void 0 : _a.call(classObj)) !== null && _b !== void 0 ? _b : (_c = classObj.model) === null || _c === void 0 ? void 0 : _c.name });
|
|
9354
|
+
return;
|
|
9355
|
+
}
|
|
9402
9356
|
}
|
|
9403
9357
|
for (const attribute of encryptableAttributes) {
|
|
9404
9358
|
const name = attribute.getName();
|
|
9405
9359
|
if (!(name in document))
|
|
9406
9360
|
continue;
|
|
9407
|
-
const encrypted = await this.encryptValue(document[name], key);
|
|
9361
|
+
const encrypted = await this.encryptValue(document[name], key, kid, aad);
|
|
9408
9362
|
document[name] = encrypted;
|
|
9409
9363
|
}
|
|
9410
9364
|
}
|
|
@@ -9417,19 +9371,18 @@ class CryptoEngine {
|
|
|
9417
9371
|
* @param encryptedKeys - Optional pre-computed list of encrypted field names
|
|
9418
9372
|
*/
|
|
9419
9373
|
async decryptDocument(document, classObj, encryptedKeys) {
|
|
9420
|
-
var _a, _b, _c;
|
|
9421
9374
|
if (!this.enabled)
|
|
9422
9375
|
return;
|
|
9423
9376
|
const encryptedAttributes = encryptedKeys !== null && encryptedKeys !== void 0 ? encryptedKeys : this.identifyEncryptedKeys(document, classObj);
|
|
9424
9377
|
if (!encryptedAttributes.length)
|
|
9425
9378
|
return;
|
|
9426
|
-
|
|
9427
|
-
|
|
9428
|
-
|
|
9429
|
-
|
|
9430
|
-
|
|
9379
|
+
// No early bail on a missing legacy key: the keyring dispatches per
|
|
9380
|
+
// payload, and a scope key can open what the document key cannot. A
|
|
9381
|
+
// payload nothing opens stays sealed - which IS the (per-scope) locked
|
|
9382
|
+
// read, handled by the read paths' null convention.
|
|
9383
|
+
const label = document["~scope"];
|
|
9431
9384
|
for (const name of encryptedAttributes) {
|
|
9432
|
-
const decrypted = await this.decryptValue(document[name],
|
|
9385
|
+
const decrypted = await this.decryptValue(document[name], typeof label === "string" ? label : undefined);
|
|
9433
9386
|
document[name] = decrypted;
|
|
9434
9387
|
}
|
|
9435
9388
|
}
|
|
@@ -9665,15 +9618,8 @@ const sweepEntry = async (stack, stage, entry, options) => {
|
|
|
9665
9618
|
if (isPatch(doc)) {
|
|
9666
9619
|
throw new TransactionUnsupportedDocError(docId, "patches carry class models and apply through 'applyPatch'.");
|
|
9667
9620
|
}
|
|
9668
|
-
// A hard delete carries no content to validate
|
|
9669
|
-
// author's to prove - unless this is DocStack's own machinery (an internal
|
|
9670
|
-
// handle: patch application runs before any session exists, and the patch
|
|
9671
|
-
// path's direct writes never pass through policy either - ADR-0044).
|
|
9621
|
+
// A hard delete carries no content to validate.
|
|
9672
9622
|
if (entry.op === "delete") {
|
|
9673
|
-
const type = doc["~class"];
|
|
9674
|
-
if (typeof type === "string" && !(options === null || options === void 0 ? void 0 : options.skipPolicy)) {
|
|
9675
|
-
await stack.policyEngine.ensureWriteAllowed(type, doc);
|
|
9676
|
-
}
|
|
9677
9623
|
return;
|
|
9678
9624
|
}
|
|
9679
9625
|
if (isRelation(doc)) {
|
|
@@ -9704,18 +9650,24 @@ const sweepEntry = async (stack, stage, entry, options) => {
|
|
|
9704
9650
|
if (!classObj) {
|
|
9705
9651
|
throw new TransactionValidationError(`Class '${type}' not found for document '${docId}'.`, docId);
|
|
9706
9652
|
}
|
|
9707
|
-
// Same refusal the plugin makes (ADR-0018
|
|
9708
|
-
// committing later while still
|
|
9709
|
-
|
|
9710
|
-
|
|
9653
|
+
// Same refusal the plugin makes (ADR-0018, per scope since ADR-0045): a
|
|
9654
|
+
// sealed key cannot encrypt, and committing later while still sealed would
|
|
9655
|
+
// land the fields in the clear - or under the wrong key, which is worse.
|
|
9656
|
+
if (classObj.getEncryptedAttributes().length) {
|
|
9657
|
+
const label = stack.resolveScopeLabel(doc, classObj.model);
|
|
9658
|
+
if (label) {
|
|
9659
|
+
if (!stack.cryptoEngine.isScopeWritable(label)) {
|
|
9660
|
+
throw new StackLockedError(type, label);
|
|
9661
|
+
}
|
|
9662
|
+
}
|
|
9663
|
+
else if (stack.isLocked()) {
|
|
9664
|
+
throw new StackLockedError(type);
|
|
9665
|
+
}
|
|
9711
9666
|
}
|
|
9712
9667
|
const valid = await classObj.validate(doc);
|
|
9713
9668
|
if (!valid) {
|
|
9714
9669
|
throw new TransactionValidationError(`Document '${docId}' does not validate against class '${type}'.`, docId);
|
|
9715
9670
|
}
|
|
9716
|
-
if (!(options === null || options === void 0 ? void 0 : options.skipPolicy)) {
|
|
9717
|
-
await stack.policyEngine.ensureWriteAllowed(type, doc);
|
|
9718
|
-
}
|
|
9719
9671
|
};
|
|
9720
9672
|
|
|
9721
9673
|
class PouchError extends Error {
|
|
@@ -12088,7 +12040,7 @@ class TransactionHandle {
|
|
|
12088
12040
|
stagedAt: Date.now(),
|
|
12089
12041
|
};
|
|
12090
12042
|
delete entry.doc._rev;
|
|
12091
|
-
await sweepEntry(this.stack, this.stage, entry, { allowClassModels: this.internal
|
|
12043
|
+
await sweepEntry(this.stack, this.stage, entry, { allowClassModels: this.internal });
|
|
12092
12044
|
this.stage.set(docId, entry);
|
|
12093
12045
|
return this.stage.get(docId);
|
|
12094
12046
|
}
|
|
@@ -12415,7 +12367,7 @@ class TransactionEngine {
|
|
|
12415
12367
|
// can be stale (a policy changed, a class tightened). Zero consequences on
|
|
12416
12368
|
// refusal.
|
|
12417
12369
|
for (const entry of entries) {
|
|
12418
|
-
await sweepEntry(this.stack, stage, entry, { allowClassModels: handle.internal
|
|
12370
|
+
await sweepEntry(this.stack, stage, entry, { allowClassModels: handle.internal });
|
|
12419
12371
|
}
|
|
12420
12372
|
// 2. Rev pre-flight: every staged id's stored winner must still be the
|
|
12421
12373
|
// revision it was staged against. `allDocs` is below the plugin - revs
|
|
@@ -12643,6 +12595,7 @@ const DOCSTACK_OPTION_KEYS = [
|
|
|
12643
12595
|
"disableCryptoEngine",
|
|
12644
12596
|
"documentKey",
|
|
12645
12597
|
"transactions",
|
|
12598
|
+
"accessKeys",
|
|
12646
12599
|
"logLevel",
|
|
12647
12600
|
];
|
|
12648
12601
|
/**
|
|
@@ -12792,6 +12745,19 @@ class ClientStack extends Stack {
|
|
|
12792
12745
|
*/
|
|
12793
12746
|
this.classDocSubscribers = new Map();
|
|
12794
12747
|
this.modelWorker = null;
|
|
12748
|
+
/**
|
|
12749
|
+
* Engine for enforcing read/write access control policies.
|
|
12750
|
+
* Policies are evaluated based on user session and document content.
|
|
12751
|
+
*/
|
|
12752
|
+
/**
|
|
12753
|
+
* The access-scope registry: every `~AccessScope` document, grouped by
|
|
12754
|
+
* `scopeId` (ADR-0045). Built from the database, independent of which CEKs
|
|
12755
|
+
* the keyring actually holds - the registry knows a scope's key ids even
|
|
12756
|
+
* when the session cannot open them, which is what the label↔kid mismatch
|
|
12757
|
+
* guard needs (spec 02 §2.3 rule 2).
|
|
12758
|
+
*/
|
|
12759
|
+
this.accessScopeRegistry = null;
|
|
12760
|
+
this.accessScopeRegistryDirty = true;
|
|
12795
12761
|
/**
|
|
12796
12762
|
* Application patches held back because they write encrypted attributes and the stack
|
|
12797
12763
|
* has no key yet. Replayed by {@link unlock}. Kept in memory on purpose - reopening
|
|
@@ -12956,10 +12922,12 @@ class ClientStack extends Stack {
|
|
|
12956
12922
|
throw new Error(`exportContent - no such content class: ${unknown.join(", ")}`);
|
|
12957
12923
|
}
|
|
12958
12924
|
}
|
|
12959
|
-
// A
|
|
12960
|
-
//
|
|
12961
|
-
//
|
|
12962
|
-
|
|
12925
|
+
// A sealed payload reads back as `null` - a locked legacy key, or a
|
|
12926
|
+
// scope the keyring cannot open. Writing that into an export would lose
|
|
12927
|
+
// data in a way nothing downstream could detect, so it is refused
|
|
12928
|
+
// unless the caller has said they want the rest anyway, naming what is
|
|
12929
|
+
// sealed (spec 02 §5: lossy per locked scope, and the report says which).
|
|
12930
|
+
if (!options.allowLossyWhenLocked) {
|
|
12963
12931
|
const encrypted = [];
|
|
12964
12932
|
for (const className of classes) {
|
|
12965
12933
|
const classObj = await this.getClassSnapshot(className);
|
|
@@ -12967,9 +12935,17 @@ class ClientStack extends Stack {
|
|
|
12967
12935
|
encrypted.push(className);
|
|
12968
12936
|
}
|
|
12969
12937
|
if (encrypted.length) {
|
|
12970
|
-
|
|
12971
|
-
|
|
12972
|
-
|
|
12938
|
+
const sealedScopes = this.cryptoEngine.isEnabled() ? await this.lockedScopeIds() : [];
|
|
12939
|
+
if (this.isLocked()) {
|
|
12940
|
+
throw new Error(`exportContent - the stack is locked, so encrypted attributes on ${encrypted.join(", ")} ` +
|
|
12941
|
+
"would be exported as null. Unlock it with 'stack.unlock(documentKey)', or pass " +
|
|
12942
|
+
"'allowLossyWhenLocked: true' to accept the loss.");
|
|
12943
|
+
}
|
|
12944
|
+
if (sealedScopes.length) {
|
|
12945
|
+
throw new Error(`exportContent - scopes ${sealedScopes.join(", ")} are sealed, so their encrypted attributes ` +
|
|
12946
|
+
"would be exported as null. Unlock them with 'stack.unlockScopes(attributeKey)', or pass " +
|
|
12947
|
+
"'allowLossyWhenLocked: true' to accept the loss.");
|
|
12948
|
+
}
|
|
12973
12949
|
}
|
|
12974
12950
|
}
|
|
12975
12951
|
const keep = (doc) => options.includeInactive || doc.active !== false;
|
|
@@ -13563,41 +13539,48 @@ class ClientStack extends Stack {
|
|
|
13563
13539
|
* @param docs - The documents just written; omit to invalidate everything.
|
|
13564
13540
|
*/
|
|
13565
13541
|
this.invalidateWriteCaches = (docs) => {
|
|
13566
|
-
var _a;
|
|
13567
13542
|
let classTouched = !docs;
|
|
13568
|
-
let
|
|
13543
|
+
let scopeTouched = !docs;
|
|
13569
13544
|
for (const doc of docs !== null && docs !== void 0 ? docs : []) {
|
|
13570
13545
|
if (!doc || typeof doc !== "object")
|
|
13571
13546
|
continue;
|
|
13572
13547
|
if (isClassModel(doc))
|
|
13573
13548
|
classTouched = true;
|
|
13574
|
-
else if (doc["~class"] === "~
|
|
13575
|
-
|
|
13576
|
-
if (classTouched &&
|
|
13549
|
+
else if (doc["~class"] === "~AccessScope")
|
|
13550
|
+
scopeTouched = true;
|
|
13551
|
+
if (classTouched && scopeTouched)
|
|
13577
13552
|
break;
|
|
13578
13553
|
}
|
|
13579
13554
|
if (classTouched) {
|
|
13580
13555
|
this.classModelCache.clear();
|
|
13581
13556
|
this.classSnapshotCache.clear();
|
|
13582
13557
|
}
|
|
13583
|
-
if (
|
|
13584
|
-
|
|
13558
|
+
if (scopeTouched) {
|
|
13559
|
+
this.accessScopeRegistryDirty = true;
|
|
13585
13560
|
}
|
|
13586
13561
|
};
|
|
13587
13562
|
/**
|
|
13588
13563
|
* Whether a database-level `limit` returns the same rows as limiting in memory.
|
|
13589
13564
|
*
|
|
13590
|
-
* `findDocuments`
|
|
13591
|
-
*
|
|
13592
|
-
*
|
|
13593
|
-
*
|
|
13565
|
+
* `findDocuments` drops a document whose visible fields are all sealed - a
|
|
13566
|
+
* locked legacy key, or a scope the keyring cannot open. A limit applied
|
|
13567
|
+
* before that filter would under-fill. So pushdown is allowed exactly when
|
|
13568
|
+
* no row of this class can drop: the class has no encrypted attributes, or
|
|
13569
|
+
* every key that might seal one is held (the legacy key, and every declared
|
|
13570
|
+
* scope - a document may carry any label). The query engine asks this
|
|
13571
|
+
* before pushing a SQL LIMIT into the fetch.
|
|
13594
13572
|
*
|
|
13595
13573
|
* @param className - The class being queried.
|
|
13596
13574
|
*/
|
|
13597
13575
|
this.canApplyQueryLimitEarly = async (className) => {
|
|
13598
|
-
if (this.cryptoEngine.isEnabled()
|
|
13576
|
+
if (!this.cryptoEngine.isEnabled())
|
|
13577
|
+
return true;
|
|
13578
|
+
const classObj = await this.getClassSnapshot(className).catch(() => null);
|
|
13579
|
+
if (!classObj || !classObj.getEncryptedAttributes().length)
|
|
13580
|
+
return true;
|
|
13581
|
+
if (!this.cryptoEngine.getDocumentKey())
|
|
13599
13582
|
return false;
|
|
13600
|
-
return
|
|
13583
|
+
return (await this.lockedScopeIds()).length === 0;
|
|
13601
13584
|
};
|
|
13602
13585
|
/** Fields whose sort index exists this session; avoids re-running createIndex. */
|
|
13603
13586
|
this.sortIndexSession = new Map();
|
|
@@ -14268,7 +14251,6 @@ class ClientStack extends Stack {
|
|
|
14268
14251
|
// A name-derived id converges: the two writes are the same document.
|
|
14269
14252
|
const result = await this.createDoc(classModel.name, classOrigin.getName(), classOrigin, classModel);
|
|
14270
14253
|
fnLogger.info("Added class card", { result });
|
|
14271
|
-
await this.ensureDefaultPolicyForClass(result);
|
|
14272
14254
|
return result;
|
|
14273
14255
|
}
|
|
14274
14256
|
catch (e) {
|
|
@@ -14448,7 +14430,6 @@ class ClientStack extends Stack {
|
|
|
14448
14430
|
// console.log("Doc after merge", { doc_ })
|
|
14449
14431
|
}
|
|
14450
14432
|
fnLogger.info("Doc AFTER elaboration (i.e. merge)", { doc_ });
|
|
14451
|
-
await this.policyEngine.ensureWriteAllowed(type, doc_);
|
|
14452
14433
|
let response = await db.put(doc_);
|
|
14453
14434
|
// Stamped from the response, not left as the pre-put draft. A caller cannot
|
|
14454
14435
|
// otherwise tell a document that landed from one that did not - which is
|
|
@@ -14552,7 +14533,6 @@ class ClientStack extends Stack {
|
|
|
14552
14533
|
fnLogger.info("Doc BEFORE elaboration (i.e. merge)", { doc, params });
|
|
14553
14534
|
const doc_ = withoutEmptyRev(Object.assign(Object.assign(Object.assign({}, doc), params), { _id: docId, _rev: doc._rev, "~updateTimestamp": new Date().getTime() }));
|
|
14554
14535
|
fnLogger.info("Doc AFTER elaboration (i.e. merge)", { doc_ });
|
|
14555
|
-
await this.policyEngine.ensureWriteAllowed(type, doc_);
|
|
14556
14536
|
documents.push(doc_);
|
|
14557
14537
|
if (isNewDoc)
|
|
14558
14538
|
newDocsIds.push(docId);
|
|
@@ -14777,8 +14757,6 @@ class ClientStack extends Stack {
|
|
|
14777
14757
|
const doc = await this.db.get(_id);
|
|
14778
14758
|
if (doc) {
|
|
14779
14759
|
try {
|
|
14780
|
-
const targetClass = doc["~class"];
|
|
14781
|
-
await this.policyEngine.ensureWriteAllowed(targetClass, doc);
|
|
14782
14760
|
await this.db.put(Object.assign(Object.assign({}, doc), { active: false }));
|
|
14783
14761
|
return true;
|
|
14784
14762
|
}
|
|
@@ -14921,7 +14899,6 @@ class ClientStack extends Stack {
|
|
|
14921
14899
|
};
|
|
14922
14900
|
this.jobEngine = new JobEngine(this);
|
|
14923
14901
|
this.jobScheduler = new JobScheduler(this);
|
|
14924
|
-
this.policyEngine = new PolicyEngine(this);
|
|
14925
14902
|
this.cryptoEngine = new CryptoEngine(this);
|
|
14926
14903
|
// Re-created, never carried over: `reset()` re-runs initialize, and a stage
|
|
14927
14904
|
// surviving a reset would resurrect uncommitted writes (ADR-0039).
|
|
@@ -15076,6 +15053,9 @@ class ClientStack extends Stack {
|
|
|
15076
15053
|
clearAuthSession() {
|
|
15077
15054
|
this.authSession = undefined;
|
|
15078
15055
|
this.cryptoEngine.setDocumentKey(null);
|
|
15056
|
+
// Scope CEKs are session material too: what the attribute key opened
|
|
15057
|
+
// closes with it. Re-adoption goes through `unlockScopes`.
|
|
15058
|
+
this.cryptoEngine.dropScopeKeys();
|
|
15079
15059
|
}
|
|
15080
15060
|
/**
|
|
15081
15061
|
* Whether the stack is operating without its document encryption key.
|
|
@@ -15141,6 +15121,153 @@ class ClientStack extends Stack {
|
|
|
15141
15121
|
this.dispatchEvent(new CustomEvent("unlocked", { detail: { stackName: this.name } }));
|
|
15142
15122
|
return this;
|
|
15143
15123
|
}
|
|
15124
|
+
/**
|
|
15125
|
+
* The access-scope registry, loaded from `~AccessScope` documents and
|
|
15126
|
+
* refreshed whenever one is written (ADR-0045). Raw read: scope docs are
|
|
15127
|
+
* the machinery that DECIDES readability - they cannot sit behind it.
|
|
15128
|
+
*/
|
|
15129
|
+
async getAccessScopeRegistry() {
|
|
15130
|
+
if (!this.accessScopeRegistry || this.accessScopeRegistryDirty) {
|
|
15131
|
+
const registry = new Map();
|
|
15132
|
+
const found = await this.db.find({
|
|
15133
|
+
selector: { "~class": "~AccessScope", active: true },
|
|
15134
|
+
limit: 2 ** 31 - 1,
|
|
15135
|
+
}).catch(() => ({ docs: [] }));
|
|
15136
|
+
for (const raw of found.docs) {
|
|
15137
|
+
if (typeof raw.scopeId !== "string" || typeof raw.kid !== "string")
|
|
15138
|
+
continue;
|
|
15139
|
+
let entry = registry.get(raw.scopeId);
|
|
15140
|
+
if (!entry) {
|
|
15141
|
+
entry = { scopeId: raw.scopeId, kids: new Set(), winningVersion: -Infinity, docs: [] };
|
|
15142
|
+
registry.set(raw.scopeId, entry);
|
|
15143
|
+
}
|
|
15144
|
+
entry.kids.add(raw.kid);
|
|
15145
|
+
entry.docs.push(raw);
|
|
15146
|
+
if (typeof raw.version === "number" && raw.version > entry.winningVersion)
|
|
15147
|
+
entry.winningVersion = raw.version;
|
|
15148
|
+
}
|
|
15149
|
+
this.accessScopeRegistry = registry;
|
|
15150
|
+
this.accessScopeRegistryDirty = false;
|
|
15151
|
+
}
|
|
15152
|
+
return this.accessScopeRegistry;
|
|
15153
|
+
}
|
|
15154
|
+
/**
|
|
15155
|
+
* The scope a document's write seals under: its own `~scope` label, else
|
|
15156
|
+
* its class's `defaultScope` (spec 02 §2.2 - the document's value wins).
|
|
15157
|
+
*/
|
|
15158
|
+
resolveScopeLabel(doc, classModel) {
|
|
15159
|
+
const own = doc === null || doc === void 0 ? void 0 : doc["~scope"];
|
|
15160
|
+
if (typeof own === "string" && own)
|
|
15161
|
+
return own;
|
|
15162
|
+
const fallback = classModel === null || classModel === void 0 ? void 0 : classModel.defaultScope;
|
|
15163
|
+
return typeof fallback === "string" && fallback ? fallback : undefined;
|
|
15164
|
+
}
|
|
15165
|
+
/** Every key id belonging to a scope, across rotation versions - or null for an unknown scope. */
|
|
15166
|
+
async getAccessScopeKids(scopeId) {
|
|
15167
|
+
var _a, _b;
|
|
15168
|
+
const registry = await this.getAccessScopeRegistry();
|
|
15169
|
+
return (_b = (_a = registry.get(scopeId)) === null || _a === void 0 ? void 0 : _a.kids) !== null && _b !== void 0 ? _b : null;
|
|
15170
|
+
}
|
|
15171
|
+
/** Whether a declared scope's CEK is absent from the keyring - its content is sealed. */
|
|
15172
|
+
isScopeLocked(scopeId) {
|
|
15173
|
+
return !this.cryptoEngine.isScopeWritable(scopeId);
|
|
15174
|
+
}
|
|
15175
|
+
/** Declared scopes whose CEK the keyring lacks. Loaded scopes only - call after open. */
|
|
15176
|
+
async lockedScopeIds() {
|
|
15177
|
+
const registry = await this.getAccessScopeRegistry();
|
|
15178
|
+
return [...registry.keys()].filter(scopeId => !this.cryptoEngine.isScopeWritable(scopeId));
|
|
15179
|
+
}
|
|
15180
|
+
/**
|
|
15181
|
+
* Attempts every declared access scope with the session's attribute key
|
|
15182
|
+
* (ADR-0045): the ABE decryption either yields a scope's CEK - verified
|
|
15183
|
+
* against the scope's canary, then admitted to the keyring - or fails,
|
|
15184
|
+
* and the scope stays locked. There is no gate to ask; this IS the access
|
|
15185
|
+
* decision. Idempotent: a later call with better material unlocks more.
|
|
15186
|
+
* Deferred patches that were waiting on a scope replay after.
|
|
15187
|
+
*/
|
|
15188
|
+
async unlockScopes(attributeKey) {
|
|
15189
|
+
if (!this.cryptoEngine.isEnabled()) {
|
|
15190
|
+
throw new Error("Stack was opened with the crypto engine disabled; there are no scopes to unlock.");
|
|
15191
|
+
}
|
|
15192
|
+
if (!attributeKey)
|
|
15193
|
+
throw new Error("unlockScopes requires the session's attribute key.");
|
|
15194
|
+
const fnLogger = logger.child({ method: "unlockScopes" });
|
|
15195
|
+
this.accessScopeRegistryDirty = true;
|
|
15196
|
+
const registry = await this.getAccessScopeRegistry();
|
|
15197
|
+
if (!registry.size)
|
|
15198
|
+
return { unlocked: [], locked: [] };
|
|
15199
|
+
const { decryptCek } = await import('./index2.js');
|
|
15200
|
+
const unlocked = [];
|
|
15201
|
+
const locked = [];
|
|
15202
|
+
for (const entry of registry.values()) {
|
|
15203
|
+
let opened = false;
|
|
15204
|
+
for (const scopeDoc of entry.docs) {
|
|
15205
|
+
if (this.cryptoEngine.getReadableKeyIds().includes(scopeDoc.kid)) {
|
|
15206
|
+
opened = true;
|
|
15207
|
+
continue;
|
|
15208
|
+
}
|
|
15209
|
+
const cekBytes = await decryptCek(attributeKey, scopeDoc.abeWrappedCek).catch(() => null);
|
|
15210
|
+
if (!cekBytes)
|
|
15211
|
+
continue;
|
|
15212
|
+
const cekHex = Array.from(cekBytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
15213
|
+
// The scope doc's stated kid and canary are both admission tests: a
|
|
15214
|
+
// corrupted or tampered scope doc is an error here, not garbage later.
|
|
15215
|
+
if (await deriveKeyId(cekHex) !== scopeDoc.kid) {
|
|
15216
|
+
fnLogger.warn("Scope CEK does not match the scope document's kid; refusing admission", { scopeId: entry.scopeId, version: scopeDoc.version });
|
|
15217
|
+
continue;
|
|
15218
|
+
}
|
|
15219
|
+
if (!(await this.cryptoEngine.verifyScopeCek(entry.scopeId, cekHex, scopeDoc.encryptedMarker))) {
|
|
15220
|
+
fnLogger.warn("Scope CEK does not verify against the scope's canary; refusing admission", { scopeId: entry.scopeId, version: scopeDoc.version });
|
|
15221
|
+
continue;
|
|
15222
|
+
}
|
|
15223
|
+
const mode = scopeDoc.version === entry.winningVersion ? "read-write" : "read-only";
|
|
15224
|
+
await this.cryptoEngine.admitScopeKey(entry.scopeId, cekHex, scopeDoc.version, mode);
|
|
15225
|
+
opened = true;
|
|
15226
|
+
this.dispatchEvent(new CustomEvent("scopeUnlocked", { detail: { stackName: this.name, scopeId: entry.scopeId, version: scopeDoc.version, mode } }));
|
|
15227
|
+
}
|
|
15228
|
+
(opened ? unlocked : locked).push(entry.scopeId);
|
|
15229
|
+
}
|
|
15230
|
+
if (unlocked.length && this.deferredPatches.length) {
|
|
15231
|
+
// A patch held back by a sealed scope replays now - the per-scope
|
|
15232
|
+
// half of the ADR-0018/0040 deferral discipline.
|
|
15233
|
+
const deferred = this.deferredPatches;
|
|
15234
|
+
this.deferredPatches = [];
|
|
15235
|
+
await this.applyConsumerPatches(deferred);
|
|
15236
|
+
}
|
|
15237
|
+
fnLogger.info("Scope unlock attempted", { unlocked, locked });
|
|
15238
|
+
return { unlocked, locked };
|
|
15239
|
+
}
|
|
15240
|
+
/**
|
|
15241
|
+
* AUTHORITY-side helper: assembles a complete `~AccessScope` document from
|
|
15242
|
+
* a fresh (or supplied) CEK - ABE-sealing it under the policy, stamping the
|
|
15243
|
+
* kid, minting the per-scope canary. Runs wherever the application controls
|
|
15244
|
+
* (its server, an admin ceremony, tests); it needs the authority PUBLIC key
|
|
15245
|
+
* only, never the master secret. The document is returned, not written -
|
|
15246
|
+
* publishing it (and distributing attribute keys) is the consumer's act.
|
|
15247
|
+
*/
|
|
15248
|
+
static async buildAccessScope(input) {
|
|
15249
|
+
var _a, _b;
|
|
15250
|
+
const { wrapCek, normalizePolicy } = await import('./index2.js');
|
|
15251
|
+
const cekHex = (_a = input.cekHex) !== null && _a !== void 0 ? _a : Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
15252
|
+
if (!/^[0-9a-f]{64}$/.test(cekHex))
|
|
15253
|
+
throw new Error("buildAccessScope needs a 32-byte hex CEK.");
|
|
15254
|
+
const cekBytes = new Uint8Array(cekHex.match(/.{2}/g).map(h => parseInt(h, 16)));
|
|
15255
|
+
const kid = await deriveKeyId(cekHex);
|
|
15256
|
+
const key = await importAesKeyFromHex(cekHex);
|
|
15257
|
+
const marker = await encryptWithAesGcm(JSON.stringify({ nonce: Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) => b.toString(16).padStart(2, "0")).join("") }), key, kid, scopeAad(input.scopeId, kid));
|
|
15258
|
+
const version = (_b = input.version) !== null && _b !== void 0 ? _b : 1;
|
|
15259
|
+
return {
|
|
15260
|
+
_id: `~scope-${input.scopeId}-v${version}`,
|
|
15261
|
+
"~class": "~AccessScope",
|
|
15262
|
+
active: true,
|
|
15263
|
+
scopeId: input.scopeId,
|
|
15264
|
+
policyString: normalizePolicy(input.policyString),
|
|
15265
|
+
abeWrappedCek: await wrapCek(input.pk, input.policyString, cekBytes),
|
|
15266
|
+
kid,
|
|
15267
|
+
version,
|
|
15268
|
+
encryptedMarker: marker,
|
|
15269
|
+
};
|
|
15270
|
+
}
|
|
15144
15271
|
/**
|
|
15145
15272
|
* Encrypts bootstrap documents that were seeded before this stack had a key.
|
|
15146
15273
|
*
|
|
@@ -15209,31 +15336,6 @@ class ClientStack extends Stack {
|
|
|
15209
15336
|
return 0;
|
|
15210
15337
|
}
|
|
15211
15338
|
}
|
|
15212
|
-
async ensureDefaultPolicyForClass(targetClass) {
|
|
15213
|
-
const fnLogger = logger.child({ method: "ensureDefaultPolicyForClass", targetClass: targetClass._id });
|
|
15214
|
-
const existingPolicy = await this.findDocument({
|
|
15215
|
-
"~class": { $eq: "~Policy" },
|
|
15216
|
-
targetClass: { $elemMatch: { $eq: targetClass._id } }
|
|
15217
|
-
});
|
|
15218
|
-
if (existingPolicy) {
|
|
15219
|
-
return;
|
|
15220
|
-
}
|
|
15221
|
-
const policyDoc = {
|
|
15222
|
-
_id: `Policy-${targetClass._id}`,
|
|
15223
|
-
"~class": "~Policy",
|
|
15224
|
-
active: true,
|
|
15225
|
-
rule: "return session && session.sessionStatus === 'active';",
|
|
15226
|
-
description: `Default policy for ${targetClass.name || targetClass._id}`,
|
|
15227
|
-
targetClass: [targetClass._id],
|
|
15228
|
-
};
|
|
15229
|
-
fnLogger.info("Creating default policy", { policyDoc });
|
|
15230
|
-
try {
|
|
15231
|
-
await this.db.bulkDocs([policyDoc]);
|
|
15232
|
-
}
|
|
15233
|
-
catch (error) {
|
|
15234
|
-
throw new Error(`Failed to create default policy for ${targetClass._id}: ${(error === null || error === void 0 ? void 0 : error.message) || error}`);
|
|
15235
|
-
}
|
|
15236
|
-
}
|
|
15237
15339
|
/**
|
|
15238
15340
|
* Creates and initializes a new ClientStack instance.
|
|
15239
15341
|
* This is the primary way to instantiate a stack - the constructor is private.
|
|
@@ -15276,6 +15378,23 @@ class ClientStack extends Stack {
|
|
|
15276
15378
|
&& existing.target === p.target
|
|
15277
15379
|
&& existing.active !== false)));
|
|
15278
15380
|
}
|
|
15381
|
+
// Scope material is attempted AFTER patches: a consumer patch may carry
|
|
15382
|
+
// the very `~AccessScope` docs the key opens, and a patch deferred on a
|
|
15383
|
+
// sealed scope replays inside `unlockScopes`. Adoption discipline is the
|
|
15384
|
+
// consumer's (spec 02 §4): the stack stores nothing of the key.
|
|
15385
|
+
if ((options === null || options === void 0 ? void 0 : options.accessKeys) && stack.cryptoEngine.isEnabled()) {
|
|
15386
|
+
let { attributeKey } = options.accessKeys;
|
|
15387
|
+
if (attributeKey) {
|
|
15388
|
+
await stack.unlockScopes(attributeKey);
|
|
15389
|
+
}
|
|
15390
|
+
const stillLocked = await stack.lockedScopeIds();
|
|
15391
|
+
if (stillLocked.length && options.accessKeys.requestAttributeKey) {
|
|
15392
|
+
const fetched = await options.accessKeys.requestAttributeKey(stillLocked).catch(() => null);
|
|
15393
|
+
if (fetched) {
|
|
15394
|
+
await stack.unlockScopes(fetched);
|
|
15395
|
+
}
|
|
15396
|
+
}
|
|
15397
|
+
}
|
|
15279
15398
|
if (options === null || options === void 0 ? void 0 : options.credentials) {
|
|
15280
15399
|
await stack.authenticate(options.credentials);
|
|
15281
15400
|
}
|
|
@@ -15542,7 +15661,7 @@ class ClientStack extends Stack {
|
|
|
15542
15661
|
try {
|
|
15543
15662
|
for (let index = 0; index < patches.length; index++) {
|
|
15544
15663
|
const patch = patches[index];
|
|
15545
|
-
if (
|
|
15664
|
+
if (await this.patchBlockedByLock(patch, stagedClassSchema)) {
|
|
15546
15665
|
await deferFrom(index);
|
|
15547
15666
|
break;
|
|
15548
15667
|
}
|
|
@@ -15724,6 +15843,47 @@ class ClientStack extends Stack {
|
|
|
15724
15843
|
* have when patch N had committed.
|
|
15725
15844
|
* @returns `true` if any document in it belongs to a class with encrypted attributes.
|
|
15726
15845
|
*/
|
|
15846
|
+
/**
|
|
15847
|
+
* The deferral barrier, generalized per scope (spec 02 §5): a patch is
|
|
15848
|
+
* blocked when the legacy half applies (stack locked and the patch needs
|
|
15849
|
+
* the document key) OR any document it carries writes into a declared
|
|
15850
|
+
* scope whose CEK the keyring lacks - sealing under the wrong key is never
|
|
15851
|
+
* a fallback, so the patch waits for `unlockScopes` exactly as key-needing
|
|
15852
|
+
* patches wait for `unlock`.
|
|
15853
|
+
*/
|
|
15854
|
+
async patchBlockedByLock(patch, stagedSchema) {
|
|
15855
|
+
var _a, _b, _c, _d, _e;
|
|
15856
|
+
if (this.isLocked() && await this.patchNeedsDocumentKey(patch, stagedSchema))
|
|
15857
|
+
return true;
|
|
15858
|
+
if (!this.cryptoEngine.isEnabled())
|
|
15859
|
+
return false;
|
|
15860
|
+
const hasEncrypted = (schema) => !!schema && Object.values(schema).some((attribute) => { var _a; return ((_a = attribute === null || attribute === void 0 ? void 0 : attribute.config) === null || _a === void 0 ? void 0 : _a.encrypted) === true; });
|
|
15861
|
+
const classModelsInPatch = new Map();
|
|
15862
|
+
for (const doc of (_a = patch.docs) !== null && _a !== void 0 ? _a : []) {
|
|
15863
|
+
if (isClassModel(doc))
|
|
15864
|
+
classModelsInPatch.set((_b = doc.name) !== null && _b !== void 0 ? _b : doc._id, doc);
|
|
15865
|
+
}
|
|
15866
|
+
for (const doc of (_c = patch.docs) !== null && _c !== void 0 ? _c : []) {
|
|
15867
|
+
if (isClassModel(doc) || isRelation(doc))
|
|
15868
|
+
continue;
|
|
15869
|
+
const className = doc["~class"];
|
|
15870
|
+
if (typeof className !== "string" || !className)
|
|
15871
|
+
continue;
|
|
15872
|
+
const inPatch = classModelsInPatch.get(className);
|
|
15873
|
+
const stored = inPatch ? null : await this.getClassModel(className).catch(() => null);
|
|
15874
|
+
const label = this.resolveScopeLabel(doc, (inPatch !== null && inPatch !== void 0 ? inPatch : stored));
|
|
15875
|
+
if (!label)
|
|
15876
|
+
continue;
|
|
15877
|
+
const schema = (_e = (_d = inPatch === null || inPatch === void 0 ? void 0 : inPatch.schema) !== null && _d !== void 0 ? _d : stagedSchema === null || stagedSchema === void 0 ? void 0 : stagedSchema(className)) !== null && _e !== void 0 ? _e : stored === null || stored === void 0 ? void 0 : stored.schema;
|
|
15878
|
+
if (hasEncrypted(schema) && !this.cryptoEngine.isScopeWritable(label)) {
|
|
15879
|
+
// Only a DECLARED scope defers - an unknown label is a patch
|
|
15880
|
+
// fault the chain should refuse loudly, not wait on forever.
|
|
15881
|
+
if ((await this.getAccessScopeRegistry()).has(label))
|
|
15882
|
+
return true;
|
|
15883
|
+
}
|
|
15884
|
+
}
|
|
15885
|
+
return false;
|
|
15886
|
+
}
|
|
15727
15887
|
async patchNeedsDocumentKey(patch, stagedSchema) {
|
|
15728
15888
|
var _a;
|
|
15729
15889
|
// A one-shot job's touch-set cannot be inspected, so the author answers the
|
|
@@ -16347,11 +16507,6 @@ class ClientStack extends Stack {
|
|
|
16347
16507
|
return classesInResult.get(className);
|
|
16348
16508
|
};
|
|
16349
16509
|
for (const doc of docs) {
|
|
16350
|
-
const canRead = await this.policyEngine.isReadableDocument(doc);
|
|
16351
|
-
if (!canRead) {
|
|
16352
|
-
logger.info("processFoundDocuments - document is not readable by policy", { docId: doc._id, docClass: doc["~class"] });
|
|
16353
|
-
continue;
|
|
16354
|
-
}
|
|
16355
16510
|
const encryptedKeys = this.cryptoEngine.identifyEncryptedKeys(doc);
|
|
16356
16511
|
const classObj = encryptedKeys.length || (fields && fields.length)
|
|
16357
16512
|
? await classFor(doc["~class"])
|
|
@@ -16372,14 +16527,18 @@ class ClientStack extends Stack {
|
|
|
16372
16527
|
return doc;
|
|
16373
16528
|
}
|
|
16374
16529
|
const clone = Object.assign({}, doc);
|
|
16375
|
-
|
|
16376
|
-
|
|
16530
|
+
// Per payload, not per stack (spec 02 §5): the keyring opens what it
|
|
16531
|
+
// can - legacy key, retired keys, unlocked scope CEKs - and whatever
|
|
16532
|
+
// stays sealed (a locked scope, a missing legacy key, a payload whose
|
|
16533
|
+
// label fails its AAD) reads as `null`, the locked-read convention
|
|
16534
|
+
// applied at the granularity the keyring actually has.
|
|
16535
|
+
let sealedCount = 0;
|
|
16536
|
+
if (encryptedKeys.length) {
|
|
16377
16537
|
await this.cryptoEngine.decryptDocument(clone, classObj, encryptedKeys);
|
|
16378
|
-
}
|
|
16379
|
-
else if (encryptedKeys.length) {
|
|
16380
16538
|
for (const key of encryptedKeys) {
|
|
16381
|
-
if (clone[key]
|
|
16539
|
+
if (isEncryptedPayload(clone[key])) {
|
|
16382
16540
|
clone[key] = null;
|
|
16541
|
+
sealedCount++;
|
|
16383
16542
|
}
|
|
16384
16543
|
}
|
|
16385
16544
|
}
|
|
@@ -16393,7 +16552,9 @@ class ClientStack extends Stack {
|
|
|
16393
16552
|
}
|
|
16394
16553
|
return clone[key] !== undefined;
|
|
16395
16554
|
});
|
|
16396
|
-
if (
|
|
16555
|
+
if (sealedCount > 0) {
|
|
16556
|
+
// A document whose every visible field stayed sealed is hidden -
|
|
16557
|
+
// there is nothing of it this keyring can show.
|
|
16397
16558
|
const nonEncryptedVisible = visibleKeys.filter((key) => !encryptedKeySet.has(key));
|
|
16398
16559
|
if (!nonEncryptedVisible.length) {
|
|
16399
16560
|
return null;
|
|
@@ -17331,4 +17492,4 @@ class DocStack extends EventTarget {
|
|
|
17331
17492
|
}
|
|
17332
17493
|
}
|
|
17333
17494
|
|
|
17334
|
-
export { Attribute, CONTENT_EXPORT_FORMAT, Class, ClientStack, DATA_MODEL_CLASSES, DocStack, DocStackSyncHandle, Domain, INTERNAL_DOC_CLASSES, INTERNAL_DOC_IDS, INTERNAL_DOC_ID_PREFIXES, JOB_SCHEDULE_DOC_ID, JobEngine, JobScheduler, META_CLASSES, OPTIONAL_INTERNAL_DOC_CLASSES, SYNC_META_DOC_ID, SYSTEM_SEEDED_DOC_IDS, StackLockedError, StackSyncHandle, StackWriteGuardError, SyncSchemaMismatchError, TransactionConflictError, TransactionDb, TransactionEngine, TransactionHandle, TransactionStateError, TransactionUnsupportedDocError, TransactionValidationError, TransactionsDisabledError, Trigger, classTenants, collectQueryClasses, createClassFilter, createReplicationFilter, DocStack as default, deriveKeyId, deriveTenantScope, describeFilter, hasClassRules, isContentClassName, isContentDocument, isContentRelation, isEncryptedPayload, isInternalDoc, nextOccurrence, parseSchedule, publishSchemaVersion, readRemoteConsumerSchemaVersion, readRemoteSchemaVersion, resolveInternalClasses, withFilterIdentity };
|
|
17495
|
+
export { Attribute, CONTENT_EXPORT_FORMAT, Class, ClientStack, DATA_MODEL_CLASSES, DocStack, DocStackSyncHandle, Domain, INTERNAL_DOC_CLASSES, INTERNAL_DOC_IDS, INTERNAL_DOC_ID_PREFIXES, JOB_SCHEDULE_DOC_ID, JobEngine, JobScheduler, META_CLASSES, OPTIONAL_INTERNAL_DOC_CLASSES, SYNC_META_DOC_ID, SYSTEM_SEEDED_DOC_IDS, StackLockedError, StackScopeMismatchError, StackSyncHandle, StackWriteGuardError, SyncSchemaMismatchError, TransactionConflictError, TransactionDb, TransactionEngine, TransactionHandle, TransactionStateError, TransactionUnsupportedDocError, TransactionValidationError, TransactionsDisabledError, Trigger, classTenants, collectQueryClasses, createClassFilter, createReplicationFilter, DocStack as default, deriveKeyId, deriveTenantScope, describeFilter, hasClassRules, isContentClassName, isContentDocument, isContentRelation, isEncryptedPayload, isInternalDoc, nextOccurrence, parseSchedule, publishSchemaVersion, readRemoteConsumerSchemaVersion, readRemoteSchemaVersion, resolveInternalClasses, withFilterIdentity };
|