@noy-db/hub 0.1.0-pre.4 → 0.1.0-pre.7
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/dist/blobs/index.cjs.map +1 -1
- package/dist/blobs/index.d.cts +3 -3
- package/dist/blobs/index.d.ts +3 -3
- package/dist/blobs/index.js +2 -2
- package/dist/bundle/index.cjs +26 -3
- package/dist/bundle/index.cjs.map +1 -1
- package/dist/bundle/index.d.cts +3 -3
- package/dist/bundle/index.d.ts +3 -3
- package/dist/bundle/index.js +3 -1
- package/dist/{chunk-LSZHBNDG.js → chunk-3WCRU7TI.js} +2 -2
- package/dist/{chunk-PSHTHSIX.js → chunk-6IJQ27XN.js} +213 -10
- package/dist/chunk-6IJQ27XN.js.map +1 -0
- package/dist/{chunk-O5GK62FJ.js → chunk-B6HF6NTZ.js} +1 -1
- package/dist/chunk-B6HF6NTZ.js.map +1 -0
- package/dist/{chunk-AVWFLPNR.js → chunk-CL37QSND.js} +2 -2
- package/dist/chunk-EMIGCR7X.js +39 -0
- package/dist/chunk-EMIGCR7X.js.map +1 -0
- package/dist/{chunk-GJILMRPO.js → chunk-FAAWLVTF.js} +42 -4
- package/dist/chunk-FAAWLVTF.js.map +1 -0
- package/dist/chunk-GILMPJXB.js +155 -0
- package/dist/chunk-GILMPJXB.js.map +1 -0
- package/dist/{chunk-L77MEFCH.js → chunk-INSJBB5W.js} +3 -3
- package/dist/{chunk-QZIACZZU.js → chunk-KPF2HHPI.js} +2 -2
- package/dist/{chunk-NK2NSXXK.js → chunk-N2LMZKLR.js} +2 -2
- package/dist/{chunk-EARQCIL7.js → chunk-NZ4XCIKS.js} +3 -3
- package/dist/{chunk-E445ICYI.js → chunk-UFL4DUEV.js} +5 -3
- package/dist/chunk-UFL4DUEV.js.map +1 -0
- package/dist/consent/index.d.cts +3 -3
- package/dist/consent/index.d.ts +3 -3
- package/dist/{dev-unlock-XOUecfQ9.d.ts → dev-unlock-CcJ1qIi7.d.ts} +1 -1
- package/dist/{dev-unlock-5SmCVGyx.d.cts → dev-unlock-Dk14V6lX.d.cts} +1 -1
- package/dist/{hash-Bxud16vM.d.ts → hash-1Xsqx1jl.d.ts} +1 -1
- package/dist/{hash-CvuKN2gH.d.cts → hash-h_2U3TFb.d.cts} +1 -1
- package/dist/history/index.cjs.map +1 -1
- package/dist/history/index.d.cts +4 -4
- package/dist/history/index.d.ts +4 -4
- package/dist/history/index.js +2 -2
- package/dist/i18n/index.cjs +3 -1
- package/dist/i18n/index.cjs.map +1 -1
- package/dist/i18n/index.d.cts +3 -3
- package/dist/i18n/index.d.ts +3 -3
- package/dist/i18n/index.js +3 -3
- package/dist/{index-DN-J-5wT.d.cts → index-6xNpPsxR.d.cts} +1 -1
- package/dist/{index-Cy-MKrdK.d.ts → index-Cvb0efA_.d.cts} +39 -5
- package/dist/{index-BRHBCmLt.d.ts → index-DJTf9yxn.d.ts} +1 -1
- package/dist/{index-BvUiM47h.d.cts → index-DZn6Yick.d.ts} +39 -5
- package/dist/index.cjs +2001 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +315 -19
- package/dist/index.d.ts +315 -19
- package/dist/index.js +1503 -41
- package/dist/index.js.map +1 -1
- package/dist/{ledger-HWXYGUIQ.js → ledger-5V67MAIL.js} +3 -3
- package/dist/periods/index.cjs.map +1 -1
- package/dist/periods/index.d.cts +3 -3
- package/dist/periods/index.d.ts +3 -3
- package/dist/periods/index.js +3 -3
- package/dist/public-envelope-DFJZHXVH.js +31 -0
- package/dist/public-envelope-DFJZHXVH.js.map +1 -0
- package/dist/query/index.d.cts +1 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/session/index.cjs +4 -2
- package/dist/session/index.cjs.map +1 -1
- package/dist/session/index.d.cts +4 -4
- package/dist/session/index.d.ts +4 -4
- package/dist/session/index.js +1 -1
- package/dist/shadow/index.d.cts +3 -3
- package/dist/shadow/index.d.ts +3 -3
- package/dist/store/index.d.cts +3 -3
- package/dist/store/index.d.ts +3 -3
- package/dist/sync/index.cjs.map +1 -1
- package/dist/sync/index.d.cts +2 -2
- package/dist/sync/index.d.ts +2 -2
- package/dist/sync/index.js +2 -2
- package/dist/team/index.cjs +3 -1
- package/dist/team/index.cjs.map +1 -1
- package/dist/team/index.d.cts +3 -3
- package/dist/team/index.d.ts +3 -3
- package/dist/team/index.js +4 -4
- package/dist/tx/index.d.cts +3 -3
- package/dist/tx/index.d.ts +3 -3
- package/dist/{types-Dmi7nrC9.d.ts → types-D-6bmD2c.d.ts} +1271 -3
- package/dist/{types-BVSfkYg6.d.cts → types-D3QLmhlk.d.cts} +1271 -3
- package/package.json +1 -1
- package/dist/chunk-E445ICYI.js.map +0 -1
- package/dist/chunk-GJILMRPO.js.map +0 -1
- package/dist/chunk-O5GK62FJ.js.map +0 -1
- package/dist/chunk-PSHTHSIX.js.map +0 -1
- /package/dist/{chunk-LSZHBNDG.js.map → chunk-3WCRU7TI.js.map} +0 -0
- /package/dist/{chunk-AVWFLPNR.js.map → chunk-CL37QSND.js.map} +0 -0
- /package/dist/{chunk-L77MEFCH.js.map → chunk-INSJBB5W.js.map} +0 -0
- /package/dist/{chunk-QZIACZZU.js.map → chunk-KPF2HHPI.js.map} +0 -0
- /package/dist/{chunk-NK2NSXXK.js.map → chunk-N2LMZKLR.js.map} +0 -0
- /package/dist/{chunk-EARQCIL7.js.map → chunk-NZ4XCIKS.js.map} +0 -0
- /package/dist/{ledger-HWXYGUIQ.js.map → ledger-5V67MAIL.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_PUBLIC_ENVELOPE_SCHEMA,
|
|
3
|
+
PUBLIC_ENVELOPE_FIELDS,
|
|
4
|
+
resolveSchema
|
|
5
|
+
} from "./chunk-EMIGCR7X.js";
|
|
1
6
|
import {
|
|
2
7
|
DELEGATIONS_COLLECTION,
|
|
3
8
|
assertTierAccess,
|
|
@@ -23,15 +28,24 @@ import {
|
|
|
23
28
|
hasNoydbBundleMagic,
|
|
24
29
|
readNoydbBundle,
|
|
25
30
|
readNoydbBundleHeader,
|
|
31
|
+
readNoydbBundlePublicEnvelope,
|
|
26
32
|
resetBrotliSupportCache,
|
|
27
33
|
writeNoydbBundle
|
|
28
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-FAAWLVTF.js";
|
|
35
|
+
import {
|
|
36
|
+
PUBLIC_ENVELOPE_RECORD_ID,
|
|
37
|
+
isPublicEnvelope,
|
|
38
|
+
loadPublicEnvelope,
|
|
39
|
+
readPublicEnvelope,
|
|
40
|
+
savePublicEnvelope,
|
|
41
|
+
validatePublicEnvelopeInput
|
|
42
|
+
} from "./chunk-GILMPJXB.js";
|
|
29
43
|
import {
|
|
30
44
|
CONSENT_AUDIT_COLLECTION
|
|
31
45
|
} from "./chunk-M62XNWRA.js";
|
|
32
46
|
import {
|
|
33
47
|
PERIODS_COLLECTION
|
|
34
|
-
} from "./chunk-
|
|
48
|
+
} from "./chunk-3WCRU7TI.js";
|
|
35
49
|
import "./chunk-UF3BUNQZ.js";
|
|
36
50
|
import {
|
|
37
51
|
CollectionFrame,
|
|
@@ -55,7 +69,7 @@ import {
|
|
|
55
69
|
isI18nTextDescriptor,
|
|
56
70
|
resolveI18nText,
|
|
57
71
|
validateI18nTextValue
|
|
58
|
-
} from "./chunk-
|
|
72
|
+
} from "./chunk-NZ4XCIKS.js";
|
|
59
73
|
import {
|
|
60
74
|
createBundleStore,
|
|
61
75
|
routeStore,
|
|
@@ -75,17 +89,24 @@ import {
|
|
|
75
89
|
getCredential,
|
|
76
90
|
listCredentials,
|
|
77
91
|
putCredential
|
|
78
|
-
} from "./chunk-
|
|
92
|
+
} from "./chunk-INSJBB5W.js";
|
|
79
93
|
import {
|
|
80
94
|
PresenceHandle,
|
|
81
95
|
SyncEngine,
|
|
82
96
|
SyncTransaction
|
|
83
|
-
} from "./chunk-
|
|
97
|
+
} from "./chunk-CL37QSND.js";
|
|
84
98
|
import {
|
|
99
|
+
USER_ENVELOPE_COLLECTION,
|
|
100
|
+
USER_ENVELOPE_MAX_BYTES,
|
|
101
|
+
UserEnvelopeOversizedError,
|
|
102
|
+
WeakPassphraseError,
|
|
103
|
+
assertStrongPassphrase,
|
|
85
104
|
buildRecipientKeyringFile,
|
|
86
105
|
changeSecret,
|
|
87
106
|
createOwnerKeyring,
|
|
107
|
+
deleteUserEnvelope,
|
|
88
108
|
ensureCollectionDEK,
|
|
109
|
+
estimateEntropy,
|
|
89
110
|
evaluateExportCapability,
|
|
90
111
|
evaluateImportCapability,
|
|
91
112
|
grant,
|
|
@@ -93,11 +114,17 @@ import {
|
|
|
93
114
|
hasExportCapability,
|
|
94
115
|
hasImportCapability,
|
|
95
116
|
hasWritePermission,
|
|
117
|
+
listUserEnvelopeIds,
|
|
96
118
|
listUsers,
|
|
119
|
+
listUsersWithEnvelopes,
|
|
97
120
|
loadKeyring,
|
|
121
|
+
loadUserEnvelope,
|
|
122
|
+
persistKeyring,
|
|
98
123
|
revoke,
|
|
99
|
-
rotateKeys
|
|
100
|
-
|
|
124
|
+
rotateKeys,
|
|
125
|
+
saveUserEnvelope,
|
|
126
|
+
validatePassphrase
|
|
127
|
+
} from "./chunk-6IJQ27XN.js";
|
|
101
128
|
import {
|
|
102
129
|
BUNDLE_STORE_POLICY,
|
|
103
130
|
INDEXED_STORE_POLICY,
|
|
@@ -117,7 +144,7 @@ import {
|
|
|
117
144
|
revokeAllSessions,
|
|
118
145
|
revokeSession,
|
|
119
146
|
validateSessionPolicy
|
|
120
|
-
} from "./chunk-
|
|
147
|
+
} from "./chunk-UFL4DUEV.js";
|
|
121
148
|
import {
|
|
122
149
|
generateULID,
|
|
123
150
|
isULID
|
|
@@ -134,7 +161,7 @@ import {
|
|
|
134
161
|
LedgerStore,
|
|
135
162
|
applyPatch,
|
|
136
163
|
computePatch
|
|
137
|
-
} from "./chunk-
|
|
164
|
+
} from "./chunk-N2LMZKLR.js";
|
|
138
165
|
import {
|
|
139
166
|
canonicalJson,
|
|
140
167
|
envelopePayloadHash,
|
|
@@ -189,24 +216,26 @@ import {
|
|
|
189
216
|
detectMimeType,
|
|
190
217
|
isPreCompressed,
|
|
191
218
|
runCompaction
|
|
192
|
-
} from "./chunk-
|
|
219
|
+
} from "./chunk-KPF2HHPI.js";
|
|
193
220
|
import {
|
|
194
221
|
NOYDB_BACKUP_VERSION,
|
|
195
222
|
NOYDB_FORMAT_VERSION,
|
|
196
223
|
NOYDB_KEYRING_VERSION,
|
|
197
224
|
NOYDB_SYNC_VERSION,
|
|
198
225
|
createStore
|
|
199
|
-
} from "./chunk-
|
|
226
|
+
} from "./chunk-B6HF6NTZ.js";
|
|
200
227
|
import {
|
|
201
228
|
base64ToBuffer,
|
|
202
229
|
bufferToBase64,
|
|
203
230
|
decrypt,
|
|
204
231
|
decryptBytes,
|
|
205
232
|
decryptDeterministic,
|
|
233
|
+
deriveKey,
|
|
206
234
|
derivePresenceKey,
|
|
207
235
|
encrypt,
|
|
208
236
|
encryptBytes,
|
|
209
237
|
encryptDeterministic,
|
|
238
|
+
generateSalt,
|
|
210
239
|
unwrapKey,
|
|
211
240
|
wrapKey
|
|
212
241
|
} from "./chunk-MR4424N3.js";
|
|
@@ -397,6 +426,728 @@ var RefRegistry = class {
|
|
|
397
426
|
}
|
|
398
427
|
};
|
|
399
428
|
|
|
429
|
+
// src/team/authenticators.ts
|
|
430
|
+
async function enrollAuthenticator(store, vault, keyring, options) {
|
|
431
|
+
const existing = keyring.authenticators.find((a) => a.id === options.id);
|
|
432
|
+
if (existing) {
|
|
433
|
+
throw new ValidationError(
|
|
434
|
+
`enrollAuthenticator: slot id "${options.id}" already exists in vault "${vault}". Remove the slot first or pick a unique id.`
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
const slot = {
|
|
438
|
+
id: options.id,
|
|
439
|
+
method: options.method,
|
|
440
|
+
enrolled_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
441
|
+
enrolled_via_tier: options.enrolled_via_tier ?? 1,
|
|
442
|
+
wrapped_kek: options.wrapped_kek,
|
|
443
|
+
meta: options.meta
|
|
444
|
+
};
|
|
445
|
+
const next = appendSlot(keyring, slot);
|
|
446
|
+
await persistKeyring(store, vault, next);
|
|
447
|
+
return next;
|
|
448
|
+
}
|
|
449
|
+
async function removeAuthenticator(store, vault, keyring, slotId) {
|
|
450
|
+
const filtered = keyring.authenticators.filter((a) => a.id !== slotId);
|
|
451
|
+
if (filtered.length === keyring.authenticators.length) {
|
|
452
|
+
return keyring;
|
|
453
|
+
}
|
|
454
|
+
const next = {
|
|
455
|
+
...keyring,
|
|
456
|
+
authenticators: filtered
|
|
457
|
+
};
|
|
458
|
+
await persistKeyring(store, vault, next);
|
|
459
|
+
return next;
|
|
460
|
+
}
|
|
461
|
+
function findAuthenticator(keyring, slotId) {
|
|
462
|
+
return keyring.authenticators.find((a) => a.id === slotId);
|
|
463
|
+
}
|
|
464
|
+
function appendSlot(keyring, slot) {
|
|
465
|
+
return {
|
|
466
|
+
...keyring,
|
|
467
|
+
authenticators: [...keyring.authenticators, slot]
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/session/unlock-state.ts
|
|
472
|
+
var QuickUnlockStore = class {
|
|
473
|
+
states = /* @__PURE__ */ new Map();
|
|
474
|
+
timers = /* @__PURE__ */ new Map();
|
|
475
|
+
/**
|
|
476
|
+
* Register a quick-unlock state for a vault. Replaces any existing
|
|
477
|
+
* state. Schedules an automatic clear when the state's `expiresAt`
|
|
478
|
+
* elapses.
|
|
479
|
+
*/
|
|
480
|
+
set(vault, state) {
|
|
481
|
+
this.clearTimer(vault);
|
|
482
|
+
this.states.set(vault, state);
|
|
483
|
+
const ttl = new Date(state.expiresAt).getTime() - Date.now();
|
|
484
|
+
if (ttl > 0) {
|
|
485
|
+
const timer = setTimeout(() => this.delete(vault), ttl);
|
|
486
|
+
this.timers.set(vault, timer);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** Read the state for a vault. Returns undefined when none is registered. */
|
|
490
|
+
get(vault) {
|
|
491
|
+
return this.states.get(vault);
|
|
492
|
+
}
|
|
493
|
+
/** Drop the state for a vault. Cancels the auto-clear timer. */
|
|
494
|
+
delete(vault) {
|
|
495
|
+
this.clearTimer(vault);
|
|
496
|
+
this.states.delete(vault);
|
|
497
|
+
}
|
|
498
|
+
/** Drop every cached state. Called on `db.close()`. */
|
|
499
|
+
clear() {
|
|
500
|
+
for (const vault of this.states.keys()) {
|
|
501
|
+
this.clearTimer(vault);
|
|
502
|
+
}
|
|
503
|
+
this.states.clear();
|
|
504
|
+
}
|
|
505
|
+
clearTimer(vault) {
|
|
506
|
+
const t = this.timers.get(vault);
|
|
507
|
+
if (t) clearTimeout(t);
|
|
508
|
+
this.timers.delete(vault);
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
// src/policy/errors.ts
|
|
513
|
+
var PolicyDeniedError = class extends NoydbError {
|
|
514
|
+
gate;
|
|
515
|
+
reason;
|
|
516
|
+
required;
|
|
517
|
+
constructor(gate, reason, required, message) {
|
|
518
|
+
super(
|
|
519
|
+
"POLICY_DENIED",
|
|
520
|
+
message ?? `Gate "${gate}" denied: ${reason}.`
|
|
521
|
+
);
|
|
522
|
+
this.name = "PolicyDeniedError";
|
|
523
|
+
this.gate = gate;
|
|
524
|
+
this.reason = reason;
|
|
525
|
+
this.required = required;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
var RecoveryNotEnrolledError = class extends NoydbError {
|
|
529
|
+
constructor(message = 'Recovery profile not enrolled. Pass `recovery: [{ profile: "paper", codes: 10 }]` to `createNoydb()`, or set `policy.gates["recover-passphrase"].enabled = false` to opt out of recovery (passphrase loss = data loss). See docs/subsystems/session-tiers.md.') {
|
|
530
|
+
super("RECOVERY_NOT_ENROLLED", message);
|
|
531
|
+
this.name = "RecoveryNotEnrolledError";
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
var RecoveryProfileNotImplementedError = class extends NoydbError {
|
|
535
|
+
profile;
|
|
536
|
+
tracking;
|
|
537
|
+
constructor(profile, tracking) {
|
|
538
|
+
super(
|
|
539
|
+
"RECOVERY_PROFILE_NOT_IMPLEMENTED",
|
|
540
|
+
`Recovery profile "${profile}" is not yet implemented in this hub release. Tracking: ${tracking}. Use the "paper" profile via @noy-db/on-recovery in the meantime.`
|
|
541
|
+
);
|
|
542
|
+
this.name = "RecoveryProfileNotImplementedError";
|
|
543
|
+
this.profile = profile;
|
|
544
|
+
this.tracking = tracking;
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// src/team/recovery.ts
|
|
549
|
+
var PAPER_DOC_ID = "recovery-paper";
|
|
550
|
+
async function loadPaperRecoveryEntries(store, vault) {
|
|
551
|
+
const env = await store.get(vault, "_meta", PAPER_DOC_ID);
|
|
552
|
+
if (!env) return [];
|
|
553
|
+
try {
|
|
554
|
+
const doc = JSON.parse(env._data);
|
|
555
|
+
if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
|
|
556
|
+
return doc.entries;
|
|
557
|
+
} catch {
|
|
558
|
+
return [];
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function savePaperRecoveryEntries(store, vault, entries) {
|
|
562
|
+
const doc = {
|
|
563
|
+
_noydb_recovery: 1,
|
|
564
|
+
profile: "paper",
|
|
565
|
+
entries
|
|
566
|
+
};
|
|
567
|
+
const envelope = {
|
|
568
|
+
_noydb: NOYDB_FORMAT_VERSION,
|
|
569
|
+
_v: 1,
|
|
570
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
571
|
+
_iv: "",
|
|
572
|
+
_data: JSON.stringify(doc)
|
|
573
|
+
};
|
|
574
|
+
await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
|
|
575
|
+
}
|
|
576
|
+
async function burnPaperRecoveryEntry(store, vault, codeId) {
|
|
577
|
+
const entries = await loadPaperRecoveryEntries(store, vault);
|
|
578
|
+
const remaining = entries.filter((e) => e.codeId !== codeId);
|
|
579
|
+
await savePaperRecoveryEntries(store, vault, remaining);
|
|
580
|
+
}
|
|
581
|
+
async function hasRecoveryEnrolled(store, vault) {
|
|
582
|
+
const paper = await loadPaperRecoveryEntries(store, vault);
|
|
583
|
+
return paper.length > 0;
|
|
584
|
+
}
|
|
585
|
+
var subtle = globalThis.crypto.subtle;
|
|
586
|
+
var RECOVERY_PBKDF2_ITERATIONS = 6e5;
|
|
587
|
+
async function unwrapDeksFromPaperEntry(entry, code) {
|
|
588
|
+
const wrappingKey = await deriveRecoveryWrappingKey(code, base64ToBytes(entry.salt));
|
|
589
|
+
const plaintext = await subtle.decrypt(
|
|
590
|
+
{ name: "AES-GCM", iv: base64ToBytes(entry.iv) },
|
|
591
|
+
wrappingKey,
|
|
592
|
+
base64ToBytes(entry.wrappedDeks)
|
|
593
|
+
);
|
|
594
|
+
const parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
|
595
|
+
const deks = /* @__PURE__ */ new Map();
|
|
596
|
+
for (const [coll, b64] of Object.entries(parsed.deks)) {
|
|
597
|
+
const raw = base64ToBytes(b64);
|
|
598
|
+
const key = await subtle.importKey(
|
|
599
|
+
"raw",
|
|
600
|
+
raw,
|
|
601
|
+
{ name: "AES-GCM", length: 256 },
|
|
602
|
+
true,
|
|
603
|
+
["encrypt", "decrypt"]
|
|
604
|
+
);
|
|
605
|
+
deks.set(coll, key);
|
|
606
|
+
}
|
|
607
|
+
return deks;
|
|
608
|
+
}
|
|
609
|
+
async function deriveRecoveryWrappingKey(code, salt) {
|
|
610
|
+
const ikm = await subtle.importKey(
|
|
611
|
+
"raw",
|
|
612
|
+
new TextEncoder().encode(code),
|
|
613
|
+
"PBKDF2",
|
|
614
|
+
false,
|
|
615
|
+
["deriveKey"]
|
|
616
|
+
);
|
|
617
|
+
return subtle.deriveKey(
|
|
618
|
+
{
|
|
619
|
+
name: "PBKDF2",
|
|
620
|
+
salt,
|
|
621
|
+
iterations: RECOVERY_PBKDF2_ITERATIONS,
|
|
622
|
+
hash: "SHA-256"
|
|
623
|
+
},
|
|
624
|
+
ikm,
|
|
625
|
+
{ name: "AES-GCM", length: 256 },
|
|
626
|
+
false,
|
|
627
|
+
["encrypt", "decrypt"]
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
function base64ToBytes(b64) {
|
|
631
|
+
const s = atob(b64);
|
|
632
|
+
const out = new Uint8Array(s.length);
|
|
633
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// src/team/rotate-recover.ts
|
|
638
|
+
async function rotatePassphrase(store, vault, userId, input) {
|
|
639
|
+
if (!input.allowWeakPassphrase) {
|
|
640
|
+
assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
|
|
641
|
+
}
|
|
642
|
+
const env = await store.get(vault, "_keyring", userId);
|
|
643
|
+
if (!env) {
|
|
644
|
+
throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}".`);
|
|
645
|
+
}
|
|
646
|
+
const file = JSON.parse(env._data);
|
|
647
|
+
const oldSalt = base64ToBuffer(file.salt);
|
|
648
|
+
const oldKek = await deriveKey(input.oldPassphrase, oldSalt);
|
|
649
|
+
const deks = /* @__PURE__ */ new Map();
|
|
650
|
+
for (const [coll, wrapped] of Object.entries(file.deks)) {
|
|
651
|
+
deks.set(coll, await unwrapKey(wrapped, oldKek));
|
|
652
|
+
}
|
|
653
|
+
const newSalt = generateSalt();
|
|
654
|
+
const newKek = await deriveKey(input.newPassphrase, newSalt);
|
|
655
|
+
const wrappedDeks = {};
|
|
656
|
+
for (const [coll, dek] of deks) {
|
|
657
|
+
wrappedDeks[coll] = await wrapKey(dek, newKek);
|
|
658
|
+
}
|
|
659
|
+
const next = {
|
|
660
|
+
...file,
|
|
661
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
662
|
+
deks: wrappedDeks,
|
|
663
|
+
salt: bufferToBase64(newSalt),
|
|
664
|
+
// Tier-2 slots reference the old KEK — drop them. User
|
|
665
|
+
// re-enrols afterwards via `db.enrollAuthenticator`.
|
|
666
|
+
authenticators: []
|
|
667
|
+
};
|
|
668
|
+
await writeKeyringFile(store, vault, userId, next);
|
|
669
|
+
return {
|
|
670
|
+
userId: file.user_id,
|
|
671
|
+
displayName: file.display_name,
|
|
672
|
+
role: file.role,
|
|
673
|
+
permissions: file.permissions,
|
|
674
|
+
deks,
|
|
675
|
+
kek: newKek,
|
|
676
|
+
salt: newSalt,
|
|
677
|
+
authenticators: [],
|
|
678
|
+
...file.export_capability !== void 0 && { exportCapability: file.export_capability },
|
|
679
|
+
...file.import_capability !== void 0 && { importCapability: file.import_capability }
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
async function recoverPassphrase(store, vault, userId, input) {
|
|
683
|
+
if (!input.allowWeakPassphrase) {
|
|
684
|
+
assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
|
|
685
|
+
}
|
|
686
|
+
switch (input.recoveryProof.profile) {
|
|
687
|
+
case "paper":
|
|
688
|
+
return recoverViaPaperCode(store, vault, userId, input);
|
|
689
|
+
case "shamir":
|
|
690
|
+
throw new RecoveryProfileNotImplementedError(
|
|
691
|
+
"shamir",
|
|
692
|
+
"https://github.com/vLannaAi/noy-db/issues/10"
|
|
693
|
+
);
|
|
694
|
+
case "multi-channel":
|
|
695
|
+
throw new RecoveryProfileNotImplementedError(
|
|
696
|
+
"multi-channel",
|
|
697
|
+
"https://github.com/vLannaAi/noy-db/issues/10"
|
|
698
|
+
);
|
|
699
|
+
case "admin-mediated":
|
|
700
|
+
throw new RecoveryProfileNotImplementedError(
|
|
701
|
+
"admin-mediated",
|
|
702
|
+
"https://github.com/vLannaAi/noy-db/issues/10"
|
|
703
|
+
);
|
|
704
|
+
default: {
|
|
705
|
+
const _exhaustive = input.recoveryProof;
|
|
706
|
+
throw new Error(`Unknown recovery profile: ${String(_exhaustive)}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
async function recoverViaPaperCode(store, vault, userId, input) {
|
|
711
|
+
if (input.recoveryProof.profile !== "paper") throw new Error("unreachable");
|
|
712
|
+
const { code } = input.recoveryProof.payload;
|
|
713
|
+
const env = await store.get(vault, "_keyring", userId);
|
|
714
|
+
if (!env) {
|
|
715
|
+
throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}".`);
|
|
716
|
+
}
|
|
717
|
+
const file = JSON.parse(env._data);
|
|
718
|
+
const entries = await loadPaperRecoveryEntries(store, vault);
|
|
719
|
+
if (entries.length === 0) {
|
|
720
|
+
throw new NoAccessError(
|
|
721
|
+
`No paper-recovery entries enrolled for vault "${vault}". Enroll via \`db.enrollRecovery({ profile: "paper", entries })\` before relying on recovery.`
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
const normalized = normalizePaperCode(code);
|
|
725
|
+
let recovered;
|
|
726
|
+
for (const entry of entries) {
|
|
727
|
+
try {
|
|
728
|
+
const deks2 = await unwrapDeksFromPaperEntry(entry, normalized);
|
|
729
|
+
recovered = { deks: deks2, entry };
|
|
730
|
+
break;
|
|
731
|
+
} catch {
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (!recovered) {
|
|
735
|
+
throw new InvalidKeyError(
|
|
736
|
+
"Recovery code does not match any enrolled paper entry. The code may have been previously used (single-use) or typed incorrectly."
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
const deks = recovered.deks;
|
|
740
|
+
const newSalt = generateSalt();
|
|
741
|
+
const newKek = await deriveKey(input.newPassphrase, newSalt);
|
|
742
|
+
const wrappedDeks = {};
|
|
743
|
+
for (const [coll, dek] of deks) {
|
|
744
|
+
wrappedDeks[coll] = await wrapKey(dek, newKek);
|
|
745
|
+
}
|
|
746
|
+
const next = {
|
|
747
|
+
...file,
|
|
748
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
749
|
+
deks: wrappedDeks,
|
|
750
|
+
salt: bufferToBase64(newSalt),
|
|
751
|
+
authenticators: []
|
|
752
|
+
// tier-2 slots wrap old KEK, drop them
|
|
753
|
+
};
|
|
754
|
+
await writeKeyringFile(store, vault, userId, next);
|
|
755
|
+
await burnPaperRecoveryEntry(store, vault, recovered.entry.codeId);
|
|
756
|
+
return {
|
|
757
|
+
userId: file.user_id,
|
|
758
|
+
displayName: file.display_name,
|
|
759
|
+
role: file.role,
|
|
760
|
+
permissions: file.permissions,
|
|
761
|
+
deks,
|
|
762
|
+
kek: newKek,
|
|
763
|
+
salt: newSalt,
|
|
764
|
+
authenticators: [],
|
|
765
|
+
...file.export_capability !== void 0 && { exportCapability: file.export_capability },
|
|
766
|
+
...file.import_capability !== void 0 && { importCapability: file.import_capability }
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
function normalizePaperCode(input) {
|
|
770
|
+
return input.toUpperCase().replace(/[\s\-_]/g, "");
|
|
771
|
+
}
|
|
772
|
+
async function writeKeyringFile(store, vault, userId, file) {
|
|
773
|
+
const envelope = {
|
|
774
|
+
_noydb: 1,
|
|
775
|
+
_v: 1,
|
|
776
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
777
|
+
_iv: "",
|
|
778
|
+
_data: JSON.stringify(file)
|
|
779
|
+
};
|
|
780
|
+
await store.put(vault, "_keyring", userId, envelope);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// src/meta/user-envelope/api.ts
|
|
784
|
+
var UserApi = class {
|
|
785
|
+
constructor(adapter, vaultName, writerKeyringId, getDek, checkGate2) {
|
|
786
|
+
this.adapter = adapter;
|
|
787
|
+
this.vaultName = vaultName;
|
|
788
|
+
this.writerKeyringId = writerKeyringId;
|
|
789
|
+
this.getDek = getDek;
|
|
790
|
+
this.checkGate = checkGate2;
|
|
791
|
+
}
|
|
792
|
+
adapter;
|
|
793
|
+
vaultName;
|
|
794
|
+
writerKeyringId;
|
|
795
|
+
getDek;
|
|
796
|
+
checkGate;
|
|
797
|
+
/** keyringId → set of listeners. Wildcard '*' fires on every change. */
|
|
798
|
+
listeners = /* @__PURE__ */ new Map();
|
|
799
|
+
// ─── Write-self ──────────────────────────────────────────────────────
|
|
800
|
+
/** Read the writer's own envelope. Returns null if never written. */
|
|
801
|
+
async me() {
|
|
802
|
+
const dek = await this.getDek();
|
|
803
|
+
return loadUserEnvelope(this.adapter, this.vaultName, this.writerKeyringId, dek);
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Deep-merge a partial patch into the writer's own envelope. Creates
|
|
807
|
+
* the envelope on first call. Optimistic-concurrency safe — a stale
|
|
808
|
+
* `_v` (parallel writer on another device) throws `ConflictError`.
|
|
809
|
+
*
|
|
810
|
+
* Gated by the `edit-own-profile` policy gate (default `minTier: 3`).
|
|
811
|
+
* Pass `presented` to satisfy tightened policies that require a
|
|
812
|
+
* factor proof (e.g. STRICT_POLICY's TOTP requirement).
|
|
813
|
+
*/
|
|
814
|
+
async updateMe(patch, presented) {
|
|
815
|
+
if (this.checkGate) await this.checkGate("edit-own-profile", presented);
|
|
816
|
+
const dek = await this.getDek();
|
|
817
|
+
const current = await loadUserEnvelope(
|
|
818
|
+
this.adapter,
|
|
819
|
+
this.vaultName,
|
|
820
|
+
this.writerKeyringId,
|
|
821
|
+
dek
|
|
822
|
+
);
|
|
823
|
+
const merged = current ? deepMerge(current.data, patch) : patch;
|
|
824
|
+
const written = await saveUserEnvelope(
|
|
825
|
+
this.adapter,
|
|
826
|
+
this.vaultName,
|
|
827
|
+
this.writerKeyringId,
|
|
828
|
+
merged,
|
|
829
|
+
dek,
|
|
830
|
+
current?._v ?? 0
|
|
831
|
+
);
|
|
832
|
+
this.fireChange(this.writerKeyringId, written);
|
|
833
|
+
return written;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Replace the writer's own envelope with `payload`. Use sparingly —
|
|
837
|
+
* `updateMe` is the canonical mutation. No `expectedVersion` check;
|
|
838
|
+
* callers explicitly take last-write-wins semantics.
|
|
839
|
+
*
|
|
840
|
+
* Gated by `edit-own-profile`. See `updateMe` for `presented` usage.
|
|
841
|
+
*/
|
|
842
|
+
async setMe(payload, presented) {
|
|
843
|
+
if (this.checkGate) await this.checkGate("edit-own-profile", presented);
|
|
844
|
+
const dek = await this.getDek();
|
|
845
|
+
const written = await saveUserEnvelope(
|
|
846
|
+
this.adapter,
|
|
847
|
+
this.vaultName,
|
|
848
|
+
this.writerKeyringId,
|
|
849
|
+
payload,
|
|
850
|
+
dek
|
|
851
|
+
);
|
|
852
|
+
this.fireChange(this.writerKeyringId, written);
|
|
853
|
+
return written;
|
|
854
|
+
}
|
|
855
|
+
// ─── Read-anyone ─────────────────────────────────────────────────────
|
|
856
|
+
/**
|
|
857
|
+
* Read another principal's envelope by their keyringId. Returns null
|
|
858
|
+
* if the principal exists but has no envelope yet, or if the
|
|
859
|
+
* keyringId does not exist at all.
|
|
860
|
+
*
|
|
861
|
+
* Gated by `view-team-profiles` (default `minTier: 2`) — but ONLY for
|
|
862
|
+
* cross-principal reads. Reading your own envelope (`keyringId ===
|
|
863
|
+
* self`) is never gated; that's just `me()` written long-form.
|
|
864
|
+
*/
|
|
865
|
+
async get(keyringId, presented) {
|
|
866
|
+
if (this.checkGate && keyringId !== this.writerKeyringId) {
|
|
867
|
+
await this.checkGate("view-team-profiles", presented);
|
|
868
|
+
}
|
|
869
|
+
const dek = await this.getDek();
|
|
870
|
+
return loadUserEnvelope(this.adapter, this.vaultName, keyringId, dek);
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Read every persisted envelope in the vault. Order is store-defined.
|
|
874
|
+
*
|
|
875
|
+
* Gated by `view-team-profiles`. Default policy (`minTier: 2`) lets
|
|
876
|
+
* any authenticated session read all envelopes. Two privacy-strict
|
|
877
|
+
* opt-outs:
|
|
878
|
+
*
|
|
879
|
+
* - `view-team-profiles.enabled: false` → list() returns only the
|
|
880
|
+
* caller's own envelope (silent self-fallback, no thrown error).
|
|
881
|
+
* - `view-team-profiles.minTier: 1` + insufficient tier → throws
|
|
882
|
+
* `PolicyDeniedError` with `reason: 'insufficient-tier'`. The
|
|
883
|
+
* caller is expected to elevate, not silently degrade.
|
|
884
|
+
*
|
|
885
|
+
* The asymmetry is deliberate: `enabled: false` is a deliberate
|
|
886
|
+
* design choice ("nobody sees teammate profiles in this app");
|
|
887
|
+
* `insufficient-tier` is "you need to authenticate further". Different
|
|
888
|
+
* UX prompts for different intents.
|
|
889
|
+
*/
|
|
890
|
+
async list(presented) {
|
|
891
|
+
if (this.checkGate) {
|
|
892
|
+
try {
|
|
893
|
+
await this.checkGate("view-team-profiles", presented);
|
|
894
|
+
} catch (err) {
|
|
895
|
+
if (err instanceof PolicyDeniedError && err.reason === "disabled") {
|
|
896
|
+
const me = await this.me();
|
|
897
|
+
return me ? [me] : [];
|
|
898
|
+
}
|
|
899
|
+
throw err;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const dek = await this.getDek();
|
|
903
|
+
const ids = await listUserEnvelopeIds(this.adapter, this.vaultName);
|
|
904
|
+
const envelopes = await Promise.all(
|
|
905
|
+
ids.map((id) => loadUserEnvelope(this.adapter, this.vaultName, id, dek))
|
|
906
|
+
);
|
|
907
|
+
return envelopes.filter((e) => e !== null);
|
|
908
|
+
}
|
|
909
|
+
// ─── Reactive ────────────────────────────────────────────────────────
|
|
910
|
+
/**
|
|
911
|
+
* Listen for changes to a specific keyringId's envelope. The callback
|
|
912
|
+
* fires synchronously after every successful local `updateMe` /
|
|
913
|
+
* `setMe` for that principal.
|
|
914
|
+
*
|
|
915
|
+
* Cross-instance changes (a teammate edits their profile on their
|
|
916
|
+
* device, the sync engine pulls the diff onto this device) will fire
|
|
917
|
+
* subscribers when the sync layer replays the write through this API.
|
|
918
|
+
* In v1, subscribers do NOT fire on raw store changes — wire your sync
|
|
919
|
+
* layer to call back through `vault.user.setMe` / `updateMe` if you
|
|
920
|
+
* need that.
|
|
921
|
+
*
|
|
922
|
+
* Pass keyringId `'*'` to fire on every change in the vault.
|
|
923
|
+
*/
|
|
924
|
+
subscribe(keyringId, cb) {
|
|
925
|
+
let listeners = this.listeners.get(keyringId);
|
|
926
|
+
if (!listeners) {
|
|
927
|
+
listeners = /* @__PURE__ */ new Set();
|
|
928
|
+
this.listeners.set(keyringId, listeners);
|
|
929
|
+
}
|
|
930
|
+
const wrapped = cb;
|
|
931
|
+
listeners.add(wrapped);
|
|
932
|
+
return () => {
|
|
933
|
+
listeners?.delete(wrapped);
|
|
934
|
+
if (listeners && listeners.size === 0) {
|
|
935
|
+
this.listeners.delete(keyringId);
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Reactive handle that caches the current value and re-reads on every
|
|
941
|
+
* change for the given keyringId. Convenient for framework bindings:
|
|
942
|
+
*
|
|
943
|
+
* const live = vault.user.live<UserShape>(vault.userId)
|
|
944
|
+
* live.subscribe(env => render(env?.data))
|
|
945
|
+
*
|
|
946
|
+
* Initial value is `null` until the first `current()` call materializes
|
|
947
|
+
* it via `vault.user.get()`. Call `stop()` when done to release the
|
|
948
|
+
* subscription.
|
|
949
|
+
*/
|
|
950
|
+
live(keyringId) {
|
|
951
|
+
let value = null;
|
|
952
|
+
let primed = false;
|
|
953
|
+
const unsubscribe = this.subscribe(keyringId, (env) => {
|
|
954
|
+
value = env;
|
|
955
|
+
});
|
|
956
|
+
return {
|
|
957
|
+
current() {
|
|
958
|
+
if (!primed) {
|
|
959
|
+
primed = true;
|
|
960
|
+
}
|
|
961
|
+
return value;
|
|
962
|
+
},
|
|
963
|
+
subscribe: (cb) => this.subscribe(keyringId, cb),
|
|
964
|
+
stop: unsubscribe
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
// ─── Internal: change emission ───────────────────────────────────────
|
|
968
|
+
fireChange(keyringId, env) {
|
|
969
|
+
const targeted = this.listeners.get(keyringId);
|
|
970
|
+
if (targeted) for (const l of targeted) l(env);
|
|
971
|
+
const wildcard = this.listeners.get("*");
|
|
972
|
+
if (wildcard) for (const l of wildcard) l(env);
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
function deepMerge(source, patch) {
|
|
976
|
+
if (!isPlainObject(source) || !isPlainObject(patch)) {
|
|
977
|
+
return patch;
|
|
978
|
+
}
|
|
979
|
+
const out = { ...source };
|
|
980
|
+
for (const [key, patchVal] of Object.entries(patch)) {
|
|
981
|
+
const sourceVal = source[key];
|
|
982
|
+
if (isPlainObject(sourceVal) && isPlainObject(patchVal)) {
|
|
983
|
+
out[key] = deepMerge(sourceVal, patchVal);
|
|
984
|
+
} else {
|
|
985
|
+
out[key] = patchVal;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return out;
|
|
989
|
+
}
|
|
990
|
+
function isPlainObject(x) {
|
|
991
|
+
if (x === null || typeof x !== "object") return false;
|
|
992
|
+
if (Array.isArray(x)) return false;
|
|
993
|
+
const proto = Object.getPrototypeOf(x);
|
|
994
|
+
return proto === Object.prototype || proto === null;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// src/policy/storage.ts
|
|
998
|
+
var META_COLLECTION = "_meta";
|
|
999
|
+
var POLICY_RECORD_ID = "policy";
|
|
1000
|
+
async function loadVaultPolicy(store, vault) {
|
|
1001
|
+
const envelope = await store.get(vault, META_COLLECTION, POLICY_RECORD_ID);
|
|
1002
|
+
if (!envelope) return void 0;
|
|
1003
|
+
try {
|
|
1004
|
+
const parsed = JSON.parse(envelope._data);
|
|
1005
|
+
if (!isVaultPolicy(parsed)) return void 0;
|
|
1006
|
+
return parsed;
|
|
1007
|
+
} catch {
|
|
1008
|
+
return void 0;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
async function saveVaultPolicy(store, vault, policy) {
|
|
1012
|
+
const envelope = {
|
|
1013
|
+
_noydb: NOYDB_FORMAT_VERSION,
|
|
1014
|
+
_v: 1,
|
|
1015
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1016
|
+
_iv: "",
|
|
1017
|
+
_data: JSON.stringify(policy)
|
|
1018
|
+
};
|
|
1019
|
+
await store.put(vault, META_COLLECTION, POLICY_RECORD_ID, envelope);
|
|
1020
|
+
}
|
|
1021
|
+
function isVaultPolicy(x) {
|
|
1022
|
+
if (x === null || typeof x !== "object") return false;
|
|
1023
|
+
if (!("gates" in x)) return false;
|
|
1024
|
+
const gates = x.gates;
|
|
1025
|
+
return gates !== null && typeof gates === "object";
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// src/auth-introspection/index.ts
|
|
1029
|
+
async function describeAuthConfig(store, vault) {
|
|
1030
|
+
const policy = await loadVaultPolicy(store, vault) ?? defaultPolicySnapshot();
|
|
1031
|
+
const recoveryProfiles = await listRecoveryProfilesEnrolled(store, vault);
|
|
1032
|
+
const lines = [];
|
|
1033
|
+
lines.push(`Vault "${vault}" \u2014 three-tier authentication`);
|
|
1034
|
+
lines.push("");
|
|
1035
|
+
lines.push("Tier 1 \u2014 Passphrase (master)");
|
|
1036
|
+
lines.push(` Phrase format: ${policy.passphrase?.minWords ?? 6}+ words, lowercase letters, \u2265${policy.passphrase?.minWordLength ?? 3} chars/word`);
|
|
1037
|
+
lines.push(" Strength validator: enforced (override available for tests only)");
|
|
1038
|
+
lines.push("");
|
|
1039
|
+
lines.push("Tier 2 \u2014 Authenticate (daily login)");
|
|
1040
|
+
lines.push(" Allowed methods: WebAuthn (passkey), OIDC, Password");
|
|
1041
|
+
lines.push(" Slots per user: unlimited");
|
|
1042
|
+
lines.push("");
|
|
1043
|
+
lines.push("Tier 3 \u2014 Unlock (quick resume)");
|
|
1044
|
+
lines.push(" Method: PIN (per-app configurable)");
|
|
1045
|
+
lines.push("");
|
|
1046
|
+
lines.push(`Recovery profiles enrolled: ${recoveryProfiles.length === 0 ? "none" : recoveryProfiles.join(", ")}`);
|
|
1047
|
+
lines.push("Managed-passphrase mode: off (post-1.0)");
|
|
1048
|
+
lines.push("");
|
|
1049
|
+
lines.push("Sensitive-action gates:");
|
|
1050
|
+
for (const [gate, gp] of Object.entries(policy.gates)) {
|
|
1051
|
+
lines.push(` ${gate} \u2014 ${describeGatePolicy(gp)}`);
|
|
1052
|
+
}
|
|
1053
|
+
return lines.join("\n");
|
|
1054
|
+
}
|
|
1055
|
+
async function diagramAuthConfig(store, vault) {
|
|
1056
|
+
const policy = await loadVaultPolicy(store, vault) ?? defaultPolicySnapshot();
|
|
1057
|
+
const lines = [];
|
|
1058
|
+
lines.push("flowchart TB");
|
|
1059
|
+
lines.push(` vault["Vault: ${escapeMermaid(vault)}"]`);
|
|
1060
|
+
lines.push(' tier1["Tier 1<br/>Passphrase"]');
|
|
1061
|
+
lines.push(' tier2["Tier 2<br/>Multi-slot Authenticate"]');
|
|
1062
|
+
lines.push(' tier3["Tier 3<br/>PIN / Quick-resume"]');
|
|
1063
|
+
lines.push(" vault --> tier1");
|
|
1064
|
+
lines.push(" tier1 --> tier2");
|
|
1065
|
+
lines.push(" tier2 --> tier3");
|
|
1066
|
+
for (const [gateName, gp] of Object.entries(policy.gates)) {
|
|
1067
|
+
if (gp.enabled === false) continue;
|
|
1068
|
+
const id = sanitizeId(gateName);
|
|
1069
|
+
const label = `${gateName}<br/>tier \u2265 ${gp.minTier}`;
|
|
1070
|
+
lines.push(` ${id}["${escapeMermaid(label)}"]`);
|
|
1071
|
+
const tierNode = gp.minTier === 1 ? "tier1" : gp.minTier === 2 ? "tier2" : "tier3";
|
|
1072
|
+
lines.push(` ${tierNode} --> ${id}`);
|
|
1073
|
+
}
|
|
1074
|
+
return lines.join("\n");
|
|
1075
|
+
}
|
|
1076
|
+
async function describeUserAuth(store, vault, userId) {
|
|
1077
|
+
const env = await store.get(vault, "_keyring", userId);
|
|
1078
|
+
if (!env) return "";
|
|
1079
|
+
const file = JSON.parse(env._data);
|
|
1080
|
+
const lines = [];
|
|
1081
|
+
lines.push(
|
|
1082
|
+
`User: ${file.user_id} (joined ${file.created_at.slice(0, 10)}, role: ${file.role})`
|
|
1083
|
+
);
|
|
1084
|
+
lines.push("");
|
|
1085
|
+
lines.push("Tier 2 enrollments:");
|
|
1086
|
+
if (!file.authenticators || file.authenticators.length === 0) {
|
|
1087
|
+
lines.push(" (none enrolled)");
|
|
1088
|
+
} else {
|
|
1089
|
+
for (const slot of file.authenticators) {
|
|
1090
|
+
lines.push(` - ${describeSlot(slot)}`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
return lines.join("\n");
|
|
1094
|
+
}
|
|
1095
|
+
async function describeAllUsersAuth(store, vault) {
|
|
1096
|
+
const ids = await store.list(vault, "_keyring");
|
|
1097
|
+
const results = [];
|
|
1098
|
+
for (const userId of ids) {
|
|
1099
|
+
const description = await describeUserAuth(store, vault, userId);
|
|
1100
|
+
if (description !== "") results.push({ userId, description });
|
|
1101
|
+
}
|
|
1102
|
+
return results;
|
|
1103
|
+
}
|
|
1104
|
+
var SLOT_FIELD_ALLOWLIST = [
|
|
1105
|
+
"id",
|
|
1106
|
+
"method",
|
|
1107
|
+
"enrolled_at",
|
|
1108
|
+
"enrolled_via_tier"
|
|
1109
|
+
];
|
|
1110
|
+
function describeSlot(slot) {
|
|
1111
|
+
const sanitized = {};
|
|
1112
|
+
for (const key of SLOT_FIELD_ALLOWLIST) {
|
|
1113
|
+
if (key in slot) {
|
|
1114
|
+
sanitized[key] = slot[key];
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const date = (sanitized.enrolled_at ?? "").slice(0, 10);
|
|
1118
|
+
return `${sanitized.method ?? "?"} (id=${sanitized.id ?? "?"}, enrolled ${date}, via tier ${sanitized.enrolled_via_tier ?? "?"})`;
|
|
1119
|
+
}
|
|
1120
|
+
function describeGatePolicy(gp) {
|
|
1121
|
+
if (gp.enabled === false) return "disabled";
|
|
1122
|
+
const parts = [];
|
|
1123
|
+
parts.push(`tier ${gp.minTier}`);
|
|
1124
|
+
if (gp.factors && gp.factors.length > 0) {
|
|
1125
|
+
for (const f of gp.factors) {
|
|
1126
|
+
parts.push(`+ ${f.count ?? 1}\xD7 ${f.anyOf.join("|")}`);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
if (gp.warn?.sharedDevice === "block") parts.push("block-on-shared-device");
|
|
1130
|
+
return parts.join(" ");
|
|
1131
|
+
}
|
|
1132
|
+
function defaultPolicySnapshot() {
|
|
1133
|
+
return {
|
|
1134
|
+
passphrase: { minWords: 6, minWordLength: 3, rejectRepeatedAdjacent: true },
|
|
1135
|
+
gates: {}
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
async function listRecoveryProfilesEnrolled(store, vault) {
|
|
1139
|
+
const enrolled = [];
|
|
1140
|
+
const paper = await loadPaperRecoveryEntries(store, vault);
|
|
1141
|
+
if (paper.length > 0) enrolled.push(`paper (${paper.length} codes)`);
|
|
1142
|
+
return enrolled;
|
|
1143
|
+
}
|
|
1144
|
+
function escapeMermaid(s) {
|
|
1145
|
+
return s.replace(/"/g, '\\"').replace(/\n/g, " ");
|
|
1146
|
+
}
|
|
1147
|
+
function sanitizeId(s) {
|
|
1148
|
+
return s.replace(/[^a-zA-Z0-9]/g, "_");
|
|
1149
|
+
}
|
|
1150
|
+
|
|
400
1151
|
// src/crdt/strategy.ts
|
|
401
1152
|
var NOT_ENABLED = new Error(
|
|
402
1153
|
'CRDT mode requires the CRDT strategy. Import `{ withCrdt }` from "@noy-db/hub/crdt" and pass it to `createNoydb({ crdtStrategy: withCrdt() })`.'
|
|
@@ -2901,13 +3652,13 @@ var MAGIC_LINK_GRANTS_COLLECTION = "_magic_link_grants";
|
|
|
2901
3652
|
var MAGIC_LINK_CONTENT_INFO_PREFIX = "noydb-magic-link-content-v1:";
|
|
2902
3653
|
var MAGIC_LINK_KEK_INFO_PREFIX = "noydb-magic-link-v1:";
|
|
2903
3654
|
async function deriveMagicLinkContentKey(serverSecret, token, vault) {
|
|
2904
|
-
const
|
|
3655
|
+
const subtle2 = globalThis.crypto.subtle;
|
|
2905
3656
|
const ikmBytes = serverSecret instanceof Uint8Array ? serverSecret : new TextEncoder().encode(serverSecret);
|
|
2906
3657
|
const tokenBytes = new TextEncoder().encode(token);
|
|
2907
|
-
const saltBuffer = await
|
|
3658
|
+
const saltBuffer = await subtle2.digest("SHA-256", tokenBytes);
|
|
2908
3659
|
const info = new TextEncoder().encode(MAGIC_LINK_CONTENT_INFO_PREFIX + vault);
|
|
2909
|
-
const ikm = await
|
|
2910
|
-
return
|
|
3660
|
+
const ikm = await subtle2.importKey("raw", ikmBytes, "HKDF", false, ["deriveKey"]);
|
|
3661
|
+
return subtle2.deriveKey(
|
|
2911
3662
|
{ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info },
|
|
2912
3663
|
ikm,
|
|
2913
3664
|
{ name: "AES-GCM", length: 256 },
|
|
@@ -3032,6 +3783,19 @@ var Vault = class {
|
|
|
3032
3783
|
i18nStrategy;
|
|
3033
3784
|
syncStrategy;
|
|
3034
3785
|
getDEK;
|
|
3786
|
+
/**
|
|
3787
|
+
* Per-principal user envelope API.
|
|
3788
|
+
*
|
|
3789
|
+
* - Write-self: `me()`, `updateMe(patch)`, `setMe(payload)` — always
|
|
3790
|
+
* target this vault session's keyringId. There is no method to write
|
|
3791
|
+
* another principal's envelope (own-only write rule, structural).
|
|
3792
|
+
* - Read-anyone: `get(keyringId)`, `list()` — read other principals'
|
|
3793
|
+
* envelopes, subject to the `view-team-profiles` policy gate (#22).
|
|
3794
|
+
* - Reactive: `subscribe(id, cb)`, `live(id)` — fire on local writes.
|
|
3795
|
+
*
|
|
3796
|
+
* @see docs/superpowers/specs/2026-05-05-user-envelope-design.md
|
|
3797
|
+
*/
|
|
3798
|
+
user;
|
|
3035
3799
|
/**
|
|
3036
3800
|
* Optional callback that re-derives an UnlockedKeyring from the
|
|
3037
3801
|
* adapter using the active user's passphrase. Called by `load()`
|
|
@@ -3164,6 +3928,13 @@ var Vault = class {
|
|
|
3164
3928
|
this.locale = opts.locale;
|
|
3165
3929
|
this.translateText = opts.plaintextTranslator;
|
|
3166
3930
|
this.getDEK = this.makeGetDEK();
|
|
3931
|
+
this.user = new UserApi(
|
|
3932
|
+
this.adapter,
|
|
3933
|
+
this.name,
|
|
3934
|
+
this.keyring.userId,
|
|
3935
|
+
() => this.getDEK(USER_ENVELOPE_COLLECTION),
|
|
3936
|
+
(gate, presented) => this.noydb.checkGate(this.name, gate, presented)
|
|
3937
|
+
);
|
|
3167
3938
|
}
|
|
3168
3939
|
/**
|
|
3169
3940
|
* Construct (or reconstruct) the lazy DEK resolver. Captures the
|
|
@@ -4436,6 +5207,23 @@ var Vault = class {
|
|
|
4436
5207
|
await this.adapter.put(this.name, "_meta", "handle", envelope);
|
|
4437
5208
|
return handle;
|
|
4438
5209
|
}
|
|
5210
|
+
/**
|
|
5211
|
+
* Read the owner-curated public envelope for this vault (or
|
|
5212
|
+
* `undefined` if none is persisted). The envelope lives in
|
|
5213
|
+
* `_meta/public-envelope` as plaintext — readable without any KEK
|
|
5214
|
+
* — so `getBundleHandle`-style callers can label a vault before
|
|
5215
|
+
* unlock.
|
|
5216
|
+
*
|
|
5217
|
+
* Mirrors `Noydb.getPublicEnvelope(vault, opts)` but scoped to a
|
|
5218
|
+
* single, already-opened `Vault` instance so the
|
|
5219
|
+
* bundle writer can snapshot it without holding a `Noydb` reference.
|
|
5220
|
+
*
|
|
5221
|
+
* @see docs/subsystems/public-envelope.md
|
|
5222
|
+
*/
|
|
5223
|
+
async getPublicEnvelope(opts = {}) {
|
|
5224
|
+
const { readPublicEnvelope: readPublicEnvelope2 } = await import("./public-envelope-DFJZHXVH.js");
|
|
5225
|
+
return readPublicEnvelope2(this.adapter, this.name, opts);
|
|
5226
|
+
}
|
|
4439
5227
|
/**
|
|
4440
5228
|
* Dump vault as a verifiable encrypted JSON backup string.
|
|
4441
5229
|
*
|
|
@@ -4997,6 +5785,180 @@ var NO_SESSION = {
|
|
|
4997
5785
|
}
|
|
4998
5786
|
};
|
|
4999
5787
|
|
|
5788
|
+
// src/policy/presets.ts
|
|
5789
|
+
var PERSONAL_POLICY = Object.freeze({
|
|
5790
|
+
passphrase: {
|
|
5791
|
+
minWords: 6,
|
|
5792
|
+
minWordLength: 3,
|
|
5793
|
+
rejectRepeatedAdjacent: true
|
|
5794
|
+
},
|
|
5795
|
+
gates: {
|
|
5796
|
+
"rotate-passphrase": {
|
|
5797
|
+
minTier: 1,
|
|
5798
|
+
factors: [{ anyOf: ["totp", "email-otp", "recovery"] }]
|
|
5799
|
+
},
|
|
5800
|
+
"recover-passphrase": {
|
|
5801
|
+
minTier: 1,
|
|
5802
|
+
enabled: true
|
|
5803
|
+
},
|
|
5804
|
+
"enroll-authenticator": { minTier: 1 },
|
|
5805
|
+
"remove-authenticator": { minTier: 1 },
|
|
5806
|
+
"rotate-unlock": { minTier: 2 },
|
|
5807
|
+
"enroll-user": { minTier: 1 },
|
|
5808
|
+
"revoke-user": { minTier: 1 },
|
|
5809
|
+
"export-bundle": { minTier: 1 },
|
|
5810
|
+
"export-plaintext": {
|
|
5811
|
+
minTier: 1,
|
|
5812
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5813
|
+
},
|
|
5814
|
+
"view-user-auth": {
|
|
5815
|
+
minTier: 1,
|
|
5816
|
+
enabled: false
|
|
5817
|
+
},
|
|
5818
|
+
// ─── User envelope gates (#22) ────────────────────────────────────
|
|
5819
|
+
// edit-own-profile: tier 3 floor — any active session can edit their
|
|
5820
|
+
// own profile/preferences. Tightening to require a TOTP for
|
|
5821
|
+
// profile changes is a one-line override.
|
|
5822
|
+
// view-team-profiles: tier 2 floor — an authenticated session can
|
|
5823
|
+
// read teammates' profiles (display names, avatars, locales).
|
|
5824
|
+
// Setting `enabled: false` makes vault.user.list() return only
|
|
5825
|
+
// self (privacy-strict opt-out).
|
|
5826
|
+
"edit-own-profile": { minTier: 3 },
|
|
5827
|
+
"view-team-profiles": { minTier: 2 }
|
|
5828
|
+
}
|
|
5829
|
+
});
|
|
5830
|
+
var STRICT_POLICY = Object.freeze({
|
|
5831
|
+
passphrase: {
|
|
5832
|
+
minWords: 8,
|
|
5833
|
+
minWordLength: 3,
|
|
5834
|
+
rejectRepeatedAdjacent: true
|
|
5835
|
+
},
|
|
5836
|
+
gates: {
|
|
5837
|
+
"rotate-passphrase": {
|
|
5838
|
+
minTier: 1,
|
|
5839
|
+
factors: [{ anyOf: ["totp", "email-otp", "recovery"], count: 2 }]
|
|
5840
|
+
},
|
|
5841
|
+
"recover-passphrase": {
|
|
5842
|
+
minTier: 1,
|
|
5843
|
+
enabled: true
|
|
5844
|
+
},
|
|
5845
|
+
"enroll-authenticator": {
|
|
5846
|
+
minTier: 1,
|
|
5847
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5848
|
+
},
|
|
5849
|
+
"remove-authenticator": {
|
|
5850
|
+
minTier: 1,
|
|
5851
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5852
|
+
},
|
|
5853
|
+
"rotate-unlock": { minTier: 1 },
|
|
5854
|
+
"enroll-user": {
|
|
5855
|
+
minTier: 1,
|
|
5856
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5857
|
+
},
|
|
5858
|
+
"revoke-user": {
|
|
5859
|
+
minTier: 1,
|
|
5860
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5861
|
+
},
|
|
5862
|
+
"export-bundle": {
|
|
5863
|
+
minTier: 1,
|
|
5864
|
+
factors: [{ anyOf: ["totp", "email-otp"] }],
|
|
5865
|
+
warn: { sharedDevice: "block" }
|
|
5866
|
+
},
|
|
5867
|
+
"export-plaintext": {
|
|
5868
|
+
minTier: 1,
|
|
5869
|
+
factors: [{ anyOf: ["totp", "email-otp"], count: 2 }],
|
|
5870
|
+
warn: { sharedDevice: "block" }
|
|
5871
|
+
},
|
|
5872
|
+
"view-user-auth": {
|
|
5873
|
+
minTier: 1,
|
|
5874
|
+
enabled: false
|
|
5875
|
+
},
|
|
5876
|
+
// ─── User envelope gates (#22) ────────────────────────────────────
|
|
5877
|
+
// STRICT: profile edits require a TOTP/email-OTP factor (typical
|
|
5878
|
+
// shared-workstation hardening — your name/avatar shouldn't change
|
|
5879
|
+
// without a fresh second-factor proof).
|
|
5880
|
+
"edit-own-profile": {
|
|
5881
|
+
minTier: 2,
|
|
5882
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5883
|
+
},
|
|
5884
|
+
"view-team-profiles": { minTier: 2 }
|
|
5885
|
+
}
|
|
5886
|
+
});
|
|
5887
|
+
function mergePolicy(base, override) {
|
|
5888
|
+
if (!override) return base;
|
|
5889
|
+
const passphrase = override.passphrase ?? base.passphrase;
|
|
5890
|
+
return {
|
|
5891
|
+
...passphrase !== void 0 ? { passphrase } : {},
|
|
5892
|
+
gates: {
|
|
5893
|
+
...base.gates,
|
|
5894
|
+
...override.gates ?? {}
|
|
5895
|
+
}
|
|
5896
|
+
};
|
|
5897
|
+
}
|
|
5898
|
+
|
|
5899
|
+
// src/policy/engine.ts
|
|
5900
|
+
var DEFAULT_FRESHNESS_MS = 5 * 60 * 1e3;
|
|
5901
|
+
async function checkGate(policy, gate, context) {
|
|
5902
|
+
const configured = policy.gates[gate];
|
|
5903
|
+
if (!configured) {
|
|
5904
|
+
if (gate.startsWith("app:")) {
|
|
5905
|
+
return;
|
|
5906
|
+
}
|
|
5907
|
+
throw deny(gate, "disabled", { minTier: 1, enabled: false });
|
|
5908
|
+
}
|
|
5909
|
+
if (configured.enabled === false) {
|
|
5910
|
+
throw deny(gate, "disabled", configured);
|
|
5911
|
+
}
|
|
5912
|
+
if (context.activeTier > configured.minTier) {
|
|
5913
|
+
throw deny(gate, "insufficient-tier", configured);
|
|
5914
|
+
}
|
|
5915
|
+
if (configured.factors && configured.factors.length > 0) {
|
|
5916
|
+
const presented = context.factors ?? [];
|
|
5917
|
+
const now = context.now ?? Date.now();
|
|
5918
|
+
for (const requirement of configured.factors) {
|
|
5919
|
+
const matches = countMatchingFactors(presented, requirement, now);
|
|
5920
|
+
const need = requirement.count ?? 1;
|
|
5921
|
+
if (matches.fresh < need) {
|
|
5922
|
+
if (matches.totalKindMatches < need) {
|
|
5923
|
+
throw deny(gate, "missing-factor", configured);
|
|
5924
|
+
}
|
|
5925
|
+
throw deny(gate, "stale-proof", configured);
|
|
5926
|
+
}
|
|
5927
|
+
}
|
|
5928
|
+
}
|
|
5929
|
+
if (configured.warn?.sharedDevice === "block" && context.sharedDevice === true) {
|
|
5930
|
+
throw deny(gate, "shared-device-blocked", configured);
|
|
5931
|
+
}
|
|
5932
|
+
}
|
|
5933
|
+
async function describeGate(policy, gate, context) {
|
|
5934
|
+
try {
|
|
5935
|
+
await checkGate(policy, gate, context);
|
|
5936
|
+
return { ok: true };
|
|
5937
|
+
} catch (err) {
|
|
5938
|
+
if (err instanceof PolicyDeniedError) {
|
|
5939
|
+
return { ok: false, reason: err.reason, required: err.required };
|
|
5940
|
+
}
|
|
5941
|
+
throw err;
|
|
5942
|
+
}
|
|
5943
|
+
}
|
|
5944
|
+
function countMatchingFactors(presented, requirement, now) {
|
|
5945
|
+
const freshnessMs = requirement.freshnessMs ?? DEFAULT_FRESHNESS_MS;
|
|
5946
|
+
let totalKindMatches = 0;
|
|
5947
|
+
let fresh = 0;
|
|
5948
|
+
for (const proof of presented) {
|
|
5949
|
+
if (!requirement.anyOf.includes(proof.kind)) continue;
|
|
5950
|
+
totalKindMatches += 1;
|
|
5951
|
+
const minted = proof.mintedAt ? Date.parse(proof.mintedAt) : now;
|
|
5952
|
+
if (Number.isFinite(minted) && now - minted <= freshnessMs) {
|
|
5953
|
+
fresh += 1;
|
|
5954
|
+
}
|
|
5955
|
+
}
|
|
5956
|
+
return { totalKindMatches, fresh };
|
|
5957
|
+
}
|
|
5958
|
+
function deny(gate, reason, required) {
|
|
5959
|
+
return new PolicyDeniedError(gate, reason, required);
|
|
5960
|
+
}
|
|
5961
|
+
|
|
5000
5962
|
// src/noydb.ts
|
|
5001
5963
|
var ROLE_RANK = {
|
|
5002
5964
|
client: 1,
|
|
@@ -5013,7 +5975,8 @@ function createPlaintextKeyring(userId) {
|
|
|
5013
5975
|
permissions: {},
|
|
5014
5976
|
deks: /* @__PURE__ */ new Map(),
|
|
5015
5977
|
kek: null,
|
|
5016
|
-
salt: new Uint8Array(0)
|
|
5978
|
+
salt: new Uint8Array(0),
|
|
5979
|
+
authenticators: []
|
|
5017
5980
|
};
|
|
5018
5981
|
}
|
|
5019
5982
|
var Noydb = class {
|
|
@@ -5022,6 +5985,25 @@ var Noydb = class {
|
|
|
5022
5985
|
vaultCache = /* @__PURE__ */ new Map();
|
|
5023
5986
|
keyringCache = /* @__PURE__ */ new Map();
|
|
5024
5987
|
syncEngines = /* @__PURE__ */ new Map();
|
|
5988
|
+
/**
|
|
5989
|
+
* Per-vault active session tier — defaults to `1` after a passphrase
|
|
5990
|
+
* unlock; tier-2 / tier-3 unlocks (issue #11) downgrade it. Used by
|
|
5991
|
+
* {@link checkGate} to evaluate `gate.minTier`.
|
|
5992
|
+
*/
|
|
5993
|
+
activeTier = /* @__PURE__ */ new Map();
|
|
5994
|
+
/**
|
|
5995
|
+
* Per-vault loaded policy. Cached after the first
|
|
5996
|
+
* `_meta/policy` load; replaced by `db.updatePolicy()`.
|
|
5997
|
+
*/
|
|
5998
|
+
policyCache = /* @__PURE__ */ new Map();
|
|
5999
|
+
/** Per-vault tier-3 (PIN / quick-resume) state — issue #11. */
|
|
6000
|
+
quickUnlock = new QuickUnlockStore();
|
|
6001
|
+
/**
|
|
6002
|
+
* Resolved public-envelope schema. Lazily computed once from
|
|
6003
|
+
* `NoydbOptions.publicEnvelope`; `undefined` when the developer
|
|
6004
|
+
* didn't opt in.
|
|
6005
|
+
*/
|
|
6006
|
+
publicEnvelopeSchema;
|
|
5025
6007
|
closed = false;
|
|
5026
6008
|
sessionTimer = null;
|
|
5027
6009
|
/** Per-vault policy enforcers. */
|
|
@@ -5042,6 +6024,7 @@ var Noydb = class {
|
|
|
5042
6024
|
this.txStrategy = options.txStrategy ?? NO_TX;
|
|
5043
6025
|
this.sessionStrategy = options.sessionStrategy ?? NO_SESSION;
|
|
5044
6026
|
this.syncStrategy = options.syncStrategy ?? NO_SYNC;
|
|
6027
|
+
this.publicEnvelopeSchema = resolveSchema(options.publicEnvelope);
|
|
5045
6028
|
if (options.sessionPolicy) {
|
|
5046
6029
|
this.sessionStrategy.validateSessionPolicy(options.sessionPolicy);
|
|
5047
6030
|
}
|
|
@@ -5113,6 +6096,12 @@ var Noydb = class {
|
|
|
5113
6096
|
return comp;
|
|
5114
6097
|
}
|
|
5115
6098
|
const keyring = await this.getKeyring(name);
|
|
6099
|
+
if (!this.activeTier.has(name)) {
|
|
6100
|
+
this.activeTier.set(name, 1);
|
|
6101
|
+
}
|
|
6102
|
+
if (this.options.encrypt !== false && !this.policyCache.has(name)) {
|
|
6103
|
+
await this.bootstrapPolicy(name);
|
|
6104
|
+
}
|
|
5116
6105
|
let syncEngine;
|
|
5117
6106
|
const targets = normalizeSyncTargets(this.options.sync);
|
|
5118
6107
|
if (targets.length > 0) {
|
|
@@ -5583,6 +6572,36 @@ var Noydb = class {
|
|
|
5583
6572
|
off(event, handler) {
|
|
5584
6573
|
this.emitter.off(event, handler);
|
|
5585
6574
|
}
|
|
6575
|
+
/**
|
|
6576
|
+
* Soft-lock a single vault: clear its in-memory keyring, DEKs, vault
|
|
6577
|
+
* instance, sync engine, policy enforcer, and active-tier entry —
|
|
6578
|
+
* WITHOUT destroying the `Noydb` instance.
|
|
6579
|
+
*
|
|
6580
|
+
* Designed for "lock screen" UX: the user taps **Lock** and DEKs are
|
|
6581
|
+
* scrubbed from memory immediately, but the same `Noydb` instance can
|
|
6582
|
+
* be re-unlocked via {@link unlockViaAuthenticator} (tier 2) or
|
|
6583
|
+
* {@link unlockViaPin} (tier 3) without re-running `createNoydb`.
|
|
6584
|
+
*
|
|
6585
|
+
* **QuickUnlock state is preserved.** That's the whole point — the
|
|
6586
|
+
* user can still resume via PIN without a full credential re-prompt.
|
|
6587
|
+
* The on-disk `_meta/policy` document is also kept in cache (it
|
|
6588
|
+
* survives lock; nothing about it changes when DEKs are scrubbed).
|
|
6589
|
+
*
|
|
6590
|
+
* No-op when `vault` is not currently in cache (idempotent).
|
|
6591
|
+
*
|
|
6592
|
+
* Unblocks vLannaAi/niwat#33.
|
|
6593
|
+
*
|
|
6594
|
+
* @see #17
|
|
6595
|
+
*/
|
|
6596
|
+
lockVault(vault) {
|
|
6597
|
+
this.syncEngines.get(vault)?.stopAutoSync();
|
|
6598
|
+
this.syncEngines.delete(vault);
|
|
6599
|
+
this.policyEnforcers.get(vault)?.destroy();
|
|
6600
|
+
this.policyEnforcers.delete(vault);
|
|
6601
|
+
this.keyringCache.delete(vault);
|
|
6602
|
+
this.vaultCache.delete(vault);
|
|
6603
|
+
this.activeTier.delete(vault);
|
|
6604
|
+
}
|
|
5586
6605
|
close() {
|
|
5587
6606
|
this.closed = true;
|
|
5588
6607
|
if (this.sessionTimer) {
|
|
@@ -5600,6 +6619,9 @@ var Noydb = class {
|
|
|
5600
6619
|
this.syncEngines.clear();
|
|
5601
6620
|
this.keyringCache.clear();
|
|
5602
6621
|
this.vaultCache.clear();
|
|
6622
|
+
this.activeTier.clear();
|
|
6623
|
+
this.policyCache.clear();
|
|
6624
|
+
this.quickUnlock.clear();
|
|
5603
6625
|
this.emitter.removeAllListeners();
|
|
5604
6626
|
this.translatorCache.clear();
|
|
5605
6627
|
this._translatorAuditLog.length = 0;
|
|
@@ -5652,6 +6674,406 @@ var Noydb = class {
|
|
|
5652
6674
|
});
|
|
5653
6675
|
return result;
|
|
5654
6676
|
}
|
|
6677
|
+
// ─── Policy gates (issue #9) ──────────────────────────────────
|
|
6678
|
+
/**
|
|
6679
|
+
* Read the active policy for a vault. Loads from `_meta/policy` on
|
|
6680
|
+
* first call; subsequent calls hit the in-memory cache. Throws
|
|
6681
|
+
* `ValidationError` if the vault has not been opened.
|
|
6682
|
+
*/
|
|
6683
|
+
async getPolicy(vault) {
|
|
6684
|
+
if (this.closed) throw new ValidationError("Instance is closed");
|
|
6685
|
+
const cached = this.policyCache.get(vault);
|
|
6686
|
+
if (cached) return cached;
|
|
6687
|
+
await this.bootstrapPolicy(vault);
|
|
6688
|
+
return this.policyCache.get(vault) ?? PERSONAL_POLICY;
|
|
6689
|
+
}
|
|
6690
|
+
/**
|
|
6691
|
+
* Replace the policy document at `_meta/policy` and update the
|
|
6692
|
+
* in-memory cache. Gated by the `enroll-user` policy (a policy
|
|
6693
|
+
* change is fundamentally a privilege-management action).
|
|
6694
|
+
*/
|
|
6695
|
+
async updatePolicy(vault, override) {
|
|
6696
|
+
if (this.closed) throw new ValidationError("Instance is closed");
|
|
6697
|
+
const current = await this.getPolicy(vault);
|
|
6698
|
+
const merged = mergePolicy(current, override);
|
|
6699
|
+
if (this.options.encrypt !== false) {
|
|
6700
|
+
await saveVaultPolicy(this.options.store, vault, merged);
|
|
6701
|
+
}
|
|
6702
|
+
this.policyCache.set(vault, merged);
|
|
6703
|
+
return merged;
|
|
6704
|
+
}
|
|
6705
|
+
/**
|
|
6706
|
+
* Evaluate a policy gate against the active session tier and the
|
|
6707
|
+
* presented factor proofs. Throws {@link PolicyDeniedError} on
|
|
6708
|
+
* denial; resolves with `void` on success.
|
|
6709
|
+
*
|
|
6710
|
+
* @param vault The vault whose policy applies.
|
|
6711
|
+
* @param gate Gate name — built-in (e.g. `'rotate-passphrase'`)
|
|
6712
|
+
* or app-defined (`app:*`).
|
|
6713
|
+
* @param presented Caller-supplied factor proofs.
|
|
6714
|
+
*/
|
|
6715
|
+
async checkGate(vault, gate, presented) {
|
|
6716
|
+
const policy = await this.getPolicy(vault);
|
|
6717
|
+
const tier = this.activeTier.get(vault) ?? 1;
|
|
6718
|
+
await checkGate(policy, gate, {
|
|
6719
|
+
activeTier: tier,
|
|
6720
|
+
...presented?.factors !== void 0 ? { factors: presented.factors } : {},
|
|
6721
|
+
...presented?.sharedDevice !== void 0 ? { sharedDevice: presented.sharedDevice } : {}
|
|
6722
|
+
});
|
|
6723
|
+
}
|
|
6724
|
+
/** Read or persist the vault policy at `_meta/policy` on first open. */
|
|
6725
|
+
async bootstrapPolicy(vault) {
|
|
6726
|
+
const onDisk = await loadVaultPolicy(this.options.store, vault);
|
|
6727
|
+
if (onDisk) {
|
|
6728
|
+
this.policyCache.set(vault, onDisk);
|
|
6729
|
+
await this.assertRecoveryEnrolled(vault, onDisk);
|
|
6730
|
+
return;
|
|
6731
|
+
}
|
|
6732
|
+
const initial = this.options.policy ? mergePolicy(PERSONAL_POLICY, this.options.policy) : PERSONAL_POLICY;
|
|
6733
|
+
await saveVaultPolicy(this.options.store, vault, initial);
|
|
6734
|
+
this.policyCache.set(vault, initial);
|
|
6735
|
+
await this.assertRecoveryEnrolled(vault, initial);
|
|
6736
|
+
}
|
|
6737
|
+
/**
|
|
6738
|
+
* Throw {@link RecoveryNotEnrolledError} when the developer
|
|
6739
|
+
* explicitly opts into strict mandatory-recovery enforcement
|
|
6740
|
+
* (`createNoydb({ requireRecovery: true })`) and no recovery
|
|
6741
|
+
* entries are persisted.
|
|
6742
|
+
*
|
|
6743
|
+
* The default behavior is lenient — `recover-passphrase` is enabled
|
|
6744
|
+
* in `PERSONAL_POLICY` but the hub does not block vault open on
|
|
6745
|
+
* missing enrollment. v1.0 will flip the default to strict; for now,
|
|
6746
|
+
* apps that want the spec-mandated check turn it on per-vault.
|
|
6747
|
+
*/
|
|
6748
|
+
async assertRecoveryEnrolled(vault, policy) {
|
|
6749
|
+
if (this.options.requireRecovery !== true) return;
|
|
6750
|
+
const gate = policy.gates["recover-passphrase"];
|
|
6751
|
+
if (gate?.enabled === false) return;
|
|
6752
|
+
const enrolled = await hasRecoveryEnrolled(this.options.store, vault);
|
|
6753
|
+
if (enrolled) return;
|
|
6754
|
+
throw new RecoveryNotEnrolledError();
|
|
6755
|
+
}
|
|
6756
|
+
/**
|
|
6757
|
+
* Internal accessor used by tier-2/tier-3 unlock paths (issue #11)
|
|
6758
|
+
* to mark the active session tier.
|
|
6759
|
+
* @internal
|
|
6760
|
+
*/
|
|
6761
|
+
_setActiveTier(vault, tier) {
|
|
6762
|
+
this.activeTier.set(vault, tier);
|
|
6763
|
+
}
|
|
6764
|
+
// ─── Tier-2 enroll / remove (issue #11) ────────────────────────
|
|
6765
|
+
/**
|
|
6766
|
+
* Add a tier-2 authenticator slot to the calling user's keyring.
|
|
6767
|
+
* Each slot independently wraps the SAME KEK under a method-specific
|
|
6768
|
+
* key — adding a slot is a constant-time keyring write.
|
|
6769
|
+
*
|
|
6770
|
+
* The wrapping ciphertext is produced by the corresponding
|
|
6771
|
+
* `@noy-db/on-*` package (e.g. `enrollPasswordAuthenticator` from
|
|
6772
|
+
* `@noy-db/on-password`); the hub persists the result.
|
|
6773
|
+
*
|
|
6774
|
+
* Gated by `enroll-authenticator`; `presented` carries any factor
|
|
6775
|
+
* proofs the active policy demands.
|
|
6776
|
+
*/
|
|
6777
|
+
async enrollAuthenticator(vault, options, presented) {
|
|
6778
|
+
await this.checkGate(vault, "enroll-authenticator", presented);
|
|
6779
|
+
const keyring = await this.getKeyring(vault);
|
|
6780
|
+
const next = await enrollAuthenticator(this.options.store, vault, keyring, options);
|
|
6781
|
+
this.keyringCache.set(vault, next);
|
|
6782
|
+
}
|
|
6783
|
+
/**
|
|
6784
|
+
* Remove a tier-2 authenticator slot. Idempotent — removing a
|
|
6785
|
+
* non-existent slot is a successful no-op. Gated by
|
|
6786
|
+
* `remove-authenticator`.
|
|
6787
|
+
*/
|
|
6788
|
+
async removeAuthenticator(vault, slotId, presented) {
|
|
6789
|
+
await this.checkGate(vault, "remove-authenticator", presented);
|
|
6790
|
+
const keyring = await this.getKeyring(vault);
|
|
6791
|
+
const next = await removeAuthenticator(this.options.store, vault, keyring, slotId);
|
|
6792
|
+
this.keyringCache.set(vault, next);
|
|
6793
|
+
}
|
|
6794
|
+
/** Read the slot list for a vault. Internal — `describeAuthConfig` (#13) consumes this. */
|
|
6795
|
+
async listAuthenticators(vault) {
|
|
6796
|
+
const keyring = await this.getKeyring(vault);
|
|
6797
|
+
return keyring.authenticators;
|
|
6798
|
+
}
|
|
6799
|
+
/**
|
|
6800
|
+
* Native WebAuthn enrollment using the **real** internal keyring (#16).
|
|
6801
|
+
*
|
|
6802
|
+
* Why this exists: when a consumer is using `createNoydb({ secret })`,
|
|
6803
|
+
* they cannot reach the live `UnlockedKeyring` to feed it to
|
|
6804
|
+
* `enrollWebAuthn(keyring, vault, opts)` from `@noy-db/on-webauthn`.
|
|
6805
|
+
* Constructing a synthetic keyring (the previous workaround) produces
|
|
6806
|
+
* a slot whose `wrapped_kek` references the synthetic payload, not
|
|
6807
|
+
* the live session — so `unlockViaAuthenticator()` later replaces the
|
|
6808
|
+
* live DEK map with stale wrapped DEKs and every decrypt fails.
|
|
6809
|
+
*
|
|
6810
|
+
* This method runs `ceremony` with the REAL keyring (still in
|
|
6811
|
+
* `keyringCache`). The ceremony performs the WebAuthn enrollment and
|
|
6812
|
+
* returns the slot options that hub then persists via the standard
|
|
6813
|
+
* tier-2 enrollAuthenticator path.
|
|
6814
|
+
*
|
|
6815
|
+
* Layering note: hub does not import `@noy-db/on-webauthn` (that
|
|
6816
|
+
* would invert the dep graph). The consumer wires it in:
|
|
6817
|
+
*
|
|
6818
|
+
* ```ts
|
|
6819
|
+
* import { enrollWebAuthn } from '@noy-db/on-webauthn'
|
|
6820
|
+
*
|
|
6821
|
+
* await db.enrollWebAuthn('demo', async (keyring) => {
|
|
6822
|
+
* const e = await enrollWebAuthn(keyring, 'demo', { rp: {...} })
|
|
6823
|
+
* return {
|
|
6824
|
+
* id: `webauthn-${e.credentialId.slice(0, 8)}`,
|
|
6825
|
+
* method: 'webauthn',
|
|
6826
|
+
* wrapped_kek: e.wrappedPayload,
|
|
6827
|
+
* meta: {
|
|
6828
|
+
* credentialId: e.credentialId,
|
|
6829
|
+
* wrapIv: e.wrapIv,
|
|
6830
|
+
* prfUsed: e.prfUsed,
|
|
6831
|
+
* beFlag: e.beFlag,
|
|
6832
|
+
* requireSingleDevice: e.requireSingleDevice,
|
|
6833
|
+
* },
|
|
6834
|
+
* }
|
|
6835
|
+
* })
|
|
6836
|
+
* ```
|
|
6837
|
+
*
|
|
6838
|
+
* Returns the WebAuthn `credentialId` (extracted from `meta.credentialId`)
|
|
6839
|
+
* for the caller's lookup index (a bootstrap vault, a PublicEnvelope,
|
|
6840
|
+
* a server-side allowlist).
|
|
6841
|
+
*
|
|
6842
|
+
* Gated by `enroll-authenticator` like `enrollAuthenticator()` itself.
|
|
6843
|
+
*
|
|
6844
|
+
* @see #16
|
|
6845
|
+
*/
|
|
6846
|
+
async enrollWebAuthn(vault, ceremony, presented) {
|
|
6847
|
+
await this.checkGate(vault, "enroll-authenticator", presented);
|
|
6848
|
+
const keyring = await this.getKeyring(vault);
|
|
6849
|
+
const slotOptions = await ceremony(keyring);
|
|
6850
|
+
if (slotOptions.method !== "webauthn") {
|
|
6851
|
+
throw new ValidationError(
|
|
6852
|
+
`enrollWebAuthn: ceremony returned method "${slotOptions.method}"; expected "webauthn". Use db.enrollAuthenticator() for non-webauthn methods.`
|
|
6853
|
+
);
|
|
6854
|
+
}
|
|
6855
|
+
const credentialId = slotOptions.meta.credentialId;
|
|
6856
|
+
if (typeof credentialId !== "string" || credentialId.length === 0) {
|
|
6857
|
+
throw new ValidationError(
|
|
6858
|
+
"enrollWebAuthn: ceremony result must include `meta.credentialId` (base64 string). See @noy-db/on-webauthn enrollWebAuthn() return shape."
|
|
6859
|
+
);
|
|
6860
|
+
}
|
|
6861
|
+
const next = await enrollAuthenticator(this.options.store, vault, keyring, slotOptions);
|
|
6862
|
+
this.keyringCache.set(vault, next);
|
|
6863
|
+
return { credentialId };
|
|
6864
|
+
}
|
|
6865
|
+
/**
|
|
6866
|
+
* Filter the slot list to webauthn-method slots only. Useful for
|
|
6867
|
+
* "you have N WebAuthn credentials enrolled" UI surfaces and for
|
|
6868
|
+
* deciding when a new device prompt should appear. Identity is
|
|
6869
|
+
* `id` + `enrolled_at`; the `meta.credentialId` (base64) is used by
|
|
6870
|
+
* `allowCredentials` at unlock time.
|
|
6871
|
+
*
|
|
6872
|
+
* @see #16
|
|
6873
|
+
*/
|
|
6874
|
+
async listWebAuthnSlots(vault) {
|
|
6875
|
+
const keyring = await this.getKeyring(vault);
|
|
6876
|
+
return keyring.authenticators.filter((a) => a.method === "webauthn").map((a) => {
|
|
6877
|
+
const credentialId = a.meta.credentialId;
|
|
6878
|
+
return {
|
|
6879
|
+
id: a.id,
|
|
6880
|
+
enrolledAt: a.enrolled_at,
|
|
6881
|
+
credentialId: typeof credentialId === "string" ? credentialId : ""
|
|
6882
|
+
};
|
|
6883
|
+
});
|
|
6884
|
+
}
|
|
6885
|
+
/**
|
|
6886
|
+
* Resolve a slot by id, then hand the wrapped-KEK ciphertext + meta
|
|
6887
|
+
* to the caller-supplied verifier. The verifier is the
|
|
6888
|
+
* `unlockWith*` function from the corresponding `@noy-db/on-*`
|
|
6889
|
+
* package, e.g. `unlockWithPassword(slot, password)`.
|
|
6890
|
+
*
|
|
6891
|
+
* On success, mark the active session tier as 2 — subsequent
|
|
6892
|
+
* `checkGate` calls see a tier-2 unlock.
|
|
6893
|
+
*/
|
|
6894
|
+
async unlockViaAuthenticator(vault, slotId, verify) {
|
|
6895
|
+
const keyring = await this.getKeyring(vault);
|
|
6896
|
+
const slot = findAuthenticator(keyring, slotId);
|
|
6897
|
+
if (!slot) {
|
|
6898
|
+
throw new ValidationError(
|
|
6899
|
+
`unlockViaAuthenticator: no slot with id "${slotId}" in vault "${vault}".`
|
|
6900
|
+
);
|
|
6901
|
+
}
|
|
6902
|
+
const unlocked = await verify(slot);
|
|
6903
|
+
this.keyringCache.set(vault, unlocked);
|
|
6904
|
+
this.activeTier.set(vault, 2);
|
|
6905
|
+
return unlocked;
|
|
6906
|
+
}
|
|
6907
|
+
// ─── Public envelope (docs/subsystems/public-envelope.md) ──────
|
|
6908
|
+
/**
|
|
6909
|
+
* Set the owner-curated public envelope for a vault. Throws
|
|
6910
|
+
* `ValidationError` if the developer did not opt the hub into
|
|
6911
|
+
* `publicEnvelope` via `NoydbOptions`, or if the input violates
|
|
6912
|
+
* the resolved schema (oversized icon, disallowed MIME, oversized
|
|
6913
|
+
* string, unknown field).
|
|
6914
|
+
*
|
|
6915
|
+
* `createdAt` is set on the first write and preserved on every
|
|
6916
|
+
* subsequent write. `updatedAt` is refreshed on every write.
|
|
6917
|
+
* `version` is monotonic — increments on every successful write.
|
|
6918
|
+
*/
|
|
6919
|
+
async setPublicEnvelope(vault, input) {
|
|
6920
|
+
if (!this.publicEnvelopeSchema) {
|
|
6921
|
+
throw new ValidationError(
|
|
6922
|
+
"setPublicEnvelope: the public-envelope feature is not enabled. Pass `publicEnvelope: true` (or a schema object) to `createNoydb`."
|
|
6923
|
+
);
|
|
6924
|
+
}
|
|
6925
|
+
validatePublicEnvelopeInput(input, this.publicEnvelopeSchema);
|
|
6926
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6927
|
+
const existing = await loadPublicEnvelope(this.options.store, vault);
|
|
6928
|
+
const next = {
|
|
6929
|
+
_noydb_public: 1,
|
|
6930
|
+
version: (existing?.version ?? 0) + 1,
|
|
6931
|
+
...existing?.createdAt !== void 0 ? { createdAt: existing.createdAt } : { createdAt: now },
|
|
6932
|
+
updatedAt: now,
|
|
6933
|
+
...input.name !== void 0 ? { name: input.name } : existing?.name !== void 0 ? { name: existing.name } : {},
|
|
6934
|
+
...input.description !== void 0 ? { description: input.description } : existing?.description !== void 0 ? { description: existing.description } : {},
|
|
6935
|
+
...input.icon !== void 0 ? { icon: input.icon } : existing?.icon !== void 0 ? { icon: existing.icon } : {},
|
|
6936
|
+
...input.defaultLocale !== void 0 ? { defaultLocale: input.defaultLocale } : existing?.defaultLocale !== void 0 ? { defaultLocale: existing.defaultLocale } : {}
|
|
6937
|
+
};
|
|
6938
|
+
await savePublicEnvelope(this.options.store, vault, next);
|
|
6939
|
+
return next;
|
|
6940
|
+
}
|
|
6941
|
+
/**
|
|
6942
|
+
* Read the public envelope for a vault. Returns `undefined` when
|
|
6943
|
+
* none has been written. Pass `locale` to resolve any locale-map
|
|
6944
|
+
* fields to plain strings; omitting `locale` returns the raw map.
|
|
6945
|
+
*
|
|
6946
|
+
* Works even when the developer didn't enable
|
|
6947
|
+
* `publicEnvelope` — reads are passive and never throw on a
|
|
6948
|
+
* missing schema (the envelope is plaintext and exists on disk
|
|
6949
|
+
* regardless).
|
|
6950
|
+
*/
|
|
6951
|
+
async getPublicEnvelope(vault, opts = {}) {
|
|
6952
|
+
return readPublicEnvelope(this.options.store, vault, opts);
|
|
6953
|
+
}
|
|
6954
|
+
// ─── Auth introspection (issue #13) ────────────────────────────
|
|
6955
|
+
/** English summary of the configured auth model. */
|
|
6956
|
+
async describeAuthConfig(vault) {
|
|
6957
|
+
return describeAuthConfig(this.options.store, vault);
|
|
6958
|
+
}
|
|
6959
|
+
/** Mermaid `flowchart TB` source for the auth graph. */
|
|
6960
|
+
async diagramAuthConfig(vault) {
|
|
6961
|
+
return diagramAuthConfig(this.options.store, vault);
|
|
6962
|
+
}
|
|
6963
|
+
/**
|
|
6964
|
+
* Per-user enrollment summary. Gated by `view-user-auth` (default:
|
|
6965
|
+
* disabled). Sanitization is allowlist-based — never renders cred
|
|
6966
|
+
* ids, password hashes, secrets, or any field outside the allowlist.
|
|
6967
|
+
*/
|
|
6968
|
+
async describeUserAuth(vault, userId, factors) {
|
|
6969
|
+
await this.checkGate(vault, "view-user-auth", factors);
|
|
6970
|
+
return describeUserAuth(this.options.store, vault, userId);
|
|
6971
|
+
}
|
|
6972
|
+
/** Bulk variant for owner dashboards. Gated by `view-user-auth`. */
|
|
6973
|
+
async describeAllUsersAuth(vault, factors) {
|
|
6974
|
+
await this.checkGate(vault, "view-user-auth", factors);
|
|
6975
|
+
return describeAllUsersAuth(this.options.store, vault);
|
|
6976
|
+
}
|
|
6977
|
+
// ─── Tier-1 change flows (issue #10) ───────────────────────────
|
|
6978
|
+
/**
|
|
6979
|
+
* Rotate the user's passphrase (user remembers old). Validates the
|
|
6980
|
+
* new phrase against the configured `passphrase` policy, runs the
|
|
6981
|
+
* `rotate-passphrase` gate, then re-derives + re-wraps every DEK.
|
|
6982
|
+
*
|
|
6983
|
+
* Tier-2 authenticator slots are dropped — each slot wraps the old
|
|
6984
|
+
* KEK and would need its derivation key to be re-presented. Re-enrol
|
|
6985
|
+
* via `db.enrollAuthenticator` after rotation. Tracked as a
|
|
6986
|
+
* v0.1.0-pre.5 limitation.
|
|
6987
|
+
*
|
|
6988
|
+
* @throws `WeakPassphraseError` on a weak new phrase.
|
|
6989
|
+
* @throws `PolicyDeniedError` when the gate denies (missing factor, …).
|
|
6990
|
+
* @throws `InvalidKeyError` when `oldPassphrase` is wrong.
|
|
6991
|
+
*/
|
|
6992
|
+
async rotatePassphrase(vault, input, factors) {
|
|
6993
|
+
await this.checkGate(vault, "rotate-passphrase", factors);
|
|
6994
|
+
const userId = this.options.user;
|
|
6995
|
+
const next = await rotatePassphrase(this.options.store, vault, userId, input);
|
|
6996
|
+
this.keyringCache.set(vault, next);
|
|
6997
|
+
}
|
|
6998
|
+
/**
|
|
6999
|
+
* Reset the passphrase using a recovery proof (user forgot the old).
|
|
7000
|
+
* v0.1.0-pre.5 supports the `'paper'` profile end-to-end; the
|
|
7001
|
+
* other three profiles throw {@link RecoveryProfileNotImplementedError}.
|
|
7002
|
+
*
|
|
7003
|
+
* Burns the used recovery entry on success.
|
|
7004
|
+
*/
|
|
7005
|
+
async recoverPassphrase(vault, input, factors) {
|
|
7006
|
+
await this.checkGate(vault, "recover-passphrase", factors);
|
|
7007
|
+
const userId = this.options.user;
|
|
7008
|
+
const next = await recoverPassphrase(this.options.store, vault, userId, input);
|
|
7009
|
+
this.keyringCache.set(vault, next);
|
|
7010
|
+
}
|
|
7011
|
+
/**
|
|
7012
|
+
* Persist a recovery enrollment. v0.1.0-pre.5 accepts the `'paper'`
|
|
7013
|
+
* profile — the developer first calls
|
|
7014
|
+
* `@noy-db/on-recovery/generateRecoveryCodeSet` to mint codes +
|
|
7015
|
+
* entries, shows the codes to the user once, then hands the entries
|
|
7016
|
+
* here.
|
|
7017
|
+
*
|
|
7018
|
+
* ```ts
|
|
7019
|
+
* import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
|
|
7020
|
+
* const { codes, entries } = await generateRecoveryCodeSet({ kek, count: 10 })
|
|
7021
|
+
* await db.enrollRecovery('acme', { profile: 'paper', entries })
|
|
7022
|
+
* showCodesToUser(codes)
|
|
7023
|
+
* ```
|
|
7024
|
+
*/
|
|
7025
|
+
async enrollRecovery(vault, enrollment) {
|
|
7026
|
+
if (enrollment.profile !== "paper") {
|
|
7027
|
+
throw new ValidationError(
|
|
7028
|
+
`enrollRecovery: only 'paper' is implemented in v0.1.0-pre.5. Profile '${enrollment.profile}' is tracked under issue #10.`
|
|
7029
|
+
);
|
|
7030
|
+
}
|
|
7031
|
+
const existing = await loadPaperRecoveryEntries(this.options.store, vault);
|
|
7032
|
+
await savePaperRecoveryEntries(this.options.store, vault, [
|
|
7033
|
+
...existing,
|
|
7034
|
+
...enrollment.entries
|
|
7035
|
+
]);
|
|
7036
|
+
}
|
|
7037
|
+
/** Read the persisted paper-recovery entries. Used by `describeAuthConfig` (#13). */
|
|
7038
|
+
async listRecoveryEntries(vault) {
|
|
7039
|
+
const paper = await loadPaperRecoveryEntries(this.options.store, vault);
|
|
7040
|
+
return { paper };
|
|
7041
|
+
}
|
|
7042
|
+
// ─── Tier-3 enroll / unlock (issue #11) ────────────────────────
|
|
7043
|
+
/**
|
|
7044
|
+
* Register a tier-3 quick-unlock state for the vault. The state is
|
|
7045
|
+
* an opaque blob produced by `@noy-db/on-pin/enrollPin` (or any
|
|
7046
|
+
* compatible primitive). It is held in memory only — never persisted
|
|
7047
|
+
* — and auto-clears when its `expiresAt` elapses.
|
|
7048
|
+
*
|
|
7049
|
+
* Gated by `rotate-unlock` (the same gate covers "set" and "rotate"
|
|
7050
|
+
* because tier-3 is a single-slot rolling secret).
|
|
7051
|
+
*/
|
|
7052
|
+
async enrollUnlock(vault, state, presented) {
|
|
7053
|
+
await this.checkGate(vault, "rotate-unlock", presented);
|
|
7054
|
+
this.quickUnlock.set(vault, state);
|
|
7055
|
+
}
|
|
7056
|
+
/**
|
|
7057
|
+
* Resume a session via the registered tier-3 state. The verifier is
|
|
7058
|
+
* `@noy-db/on-pin/resumePin` (or compatible). On success, mark the
|
|
7059
|
+
* active session tier as 3 — every operation must re-authenticate at
|
|
7060
|
+
* tier 2 to elevate.
|
|
7061
|
+
*
|
|
7062
|
+
* Returns `undefined` (caller should fall back to tier 2) when no
|
|
7063
|
+
* tier-3 state is registered.
|
|
7064
|
+
*/
|
|
7065
|
+
async unlockViaPin(vault, resume) {
|
|
7066
|
+
const state = this.quickUnlock.get(vault);
|
|
7067
|
+
if (!state) return void 0;
|
|
7068
|
+
const keyring = await resume(state);
|
|
7069
|
+
this.keyringCache.set(vault, keyring);
|
|
7070
|
+
this.activeTier.set(vault, 3);
|
|
7071
|
+
return keyring;
|
|
7072
|
+
}
|
|
7073
|
+
/** Drop the tier-3 state for a vault — explicit logout. */
|
|
7074
|
+
clearQuickUnlock(vault) {
|
|
7075
|
+
this.quickUnlock.delete(vault);
|
|
7076
|
+
}
|
|
5655
7077
|
/** Get or load the keyring for a vault. */
|
|
5656
7078
|
async getKeyring(vault) {
|
|
5657
7079
|
if (this.options.encrypt === false) {
|
|
@@ -5672,7 +7094,22 @@ var Noydb = class {
|
|
|
5672
7094
|
keyring = await loadKeyring(this.options.store, vault, this.options.user, this.options.secret);
|
|
5673
7095
|
} catch (err) {
|
|
5674
7096
|
if (err instanceof NoAccessError) {
|
|
5675
|
-
keyring = await createOwnerKeyring(
|
|
7097
|
+
keyring = await createOwnerKeyring(
|
|
7098
|
+
this.options.store,
|
|
7099
|
+
vault,
|
|
7100
|
+
this.options.user,
|
|
7101
|
+
this.options.secret,
|
|
7102
|
+
{ validate: this.options.validatePassphrase === true }
|
|
7103
|
+
);
|
|
7104
|
+
} else if (err instanceof InvalidKeyError && this.options.onInvalidKey === "reset") {
|
|
7105
|
+
await this.options.store.delete(vault, "_keyring", this.options.user);
|
|
7106
|
+
keyring = await createOwnerKeyring(
|
|
7107
|
+
this.options.store,
|
|
7108
|
+
vault,
|
|
7109
|
+
this.options.user,
|
|
7110
|
+
this.options.secret,
|
|
7111
|
+
{ validate: this.options.validatePassphrase === true }
|
|
7112
|
+
);
|
|
5676
7113
|
} else {
|
|
5677
7114
|
throw err;
|
|
5678
7115
|
}
|
|
@@ -5851,30 +7288,6 @@ function shortJSON(value) {
|
|
|
5851
7288
|
if (typeof s !== "string") return "<unrepresentable>";
|
|
5852
7289
|
return s.length > 60 ? s.slice(0, 57) + "..." : s;
|
|
5853
7290
|
}
|
|
5854
|
-
|
|
5855
|
-
// src/validation.ts
|
|
5856
|
-
function validatePassphrase(passphrase) {
|
|
5857
|
-
if (passphrase.length < 8) {
|
|
5858
|
-
throw new ValidationError(
|
|
5859
|
-
"Passphrase too short \u2014 minimum 8 characters. Recommended: 12+ characters or a 4+ word passphrase."
|
|
5860
|
-
);
|
|
5861
|
-
}
|
|
5862
|
-
const entropy = estimateEntropy(passphrase);
|
|
5863
|
-
if (entropy < 28) {
|
|
5864
|
-
throw new ValidationError(
|
|
5865
|
-
"Passphrase too weak \u2014 too little entropy. Use a mix of uppercase, lowercase, numbers, and symbols, or use a 4+ word passphrase."
|
|
5866
|
-
);
|
|
5867
|
-
}
|
|
5868
|
-
}
|
|
5869
|
-
function estimateEntropy(passphrase) {
|
|
5870
|
-
let charsetSize = 0;
|
|
5871
|
-
if (/[a-z]/.test(passphrase)) charsetSize += 26;
|
|
5872
|
-
if (/[A-Z]/.test(passphrase)) charsetSize += 26;
|
|
5873
|
-
if (/[0-9]/.test(passphrase)) charsetSize += 10;
|
|
5874
|
-
if (/[^a-zA-Z0-9]/.test(passphrase)) charsetSize += 32;
|
|
5875
|
-
if (charsetSize === 0) charsetSize = 26;
|
|
5876
|
-
return Math.floor(passphrase.length * Math.log2(charsetSize));
|
|
5877
|
-
}
|
|
5878
7291
|
export {
|
|
5879
7292
|
Aggregation,
|
|
5880
7293
|
AlreadyElevatedError,
|
|
@@ -5896,7 +7309,9 @@ export {
|
|
|
5896
7309
|
CollectionInstant,
|
|
5897
7310
|
ConflictError,
|
|
5898
7311
|
DEFAULT_CHUNK_SIZE,
|
|
7312
|
+
DEFAULT_FRESHNESS_MS,
|
|
5899
7313
|
DEFAULT_JOIN_MAX_ROWS,
|
|
7314
|
+
DEFAULT_PUBLIC_ENVELOPE_SCHEMA,
|
|
5900
7315
|
DELEGATIONS_COLLECTION,
|
|
5901
7316
|
DICT_COLLECTION_PREFIX,
|
|
5902
7317
|
DanglingReferenceError,
|
|
@@ -5931,6 +7346,7 @@ export {
|
|
|
5931
7346
|
MAGIC_LINK_CONTENT_INFO_PREFIX,
|
|
5932
7347
|
MAGIC_LINK_GRANTS_COLLECTION,
|
|
5933
7348
|
MAGIC_LINK_KEK_INFO_PREFIX,
|
|
7349
|
+
META_COLLECTION,
|
|
5934
7350
|
MissingTranslationError,
|
|
5935
7351
|
NOYDB_BACKUP_VERSION,
|
|
5936
7352
|
NOYDB_BUNDLE_FORMAT_VERSION,
|
|
@@ -5945,20 +7361,29 @@ export {
|
|
|
5945
7361
|
Noydb,
|
|
5946
7362
|
NoydbError,
|
|
5947
7363
|
PERIODS_COLLECTION,
|
|
7364
|
+
PERSONAL_POLICY,
|
|
7365
|
+
POLICY_RECORD_ID,
|
|
7366
|
+
PUBLIC_ENVELOPE_FIELDS,
|
|
7367
|
+
PUBLIC_ENVELOPE_RECORD_ID,
|
|
5948
7368
|
PathEscapeError,
|
|
5949
7369
|
PeriodClosedError,
|
|
5950
7370
|
PermissionDeniedError,
|
|
7371
|
+
PolicyDeniedError,
|
|
5951
7372
|
PolicyEnforcer,
|
|
5952
7373
|
PresenceHandle,
|
|
5953
7374
|
PrivilegeEscalationError,
|
|
5954
7375
|
Query,
|
|
7376
|
+
QuickUnlockStore,
|
|
5955
7377
|
ReadOnlyAtInstantError,
|
|
5956
7378
|
ReadOnlyError,
|
|
5957
7379
|
ReadOnlyFrameError,
|
|
7380
|
+
RecoveryNotEnrolledError,
|
|
7381
|
+
RecoveryProfileNotImplementedError,
|
|
5958
7382
|
RefIntegrityError,
|
|
5959
7383
|
RefRegistry,
|
|
5960
7384
|
RefScopeError,
|
|
5961
7385
|
ReservedCollectionNameError,
|
|
7386
|
+
STRICT_POLICY,
|
|
5962
7387
|
SYNC_CREDENTIALS_COLLECTION,
|
|
5963
7388
|
ScanBuilder,
|
|
5964
7389
|
SchemaValidationError,
|
|
@@ -5976,21 +7401,29 @@ export {
|
|
|
5976
7401
|
TxCollection,
|
|
5977
7402
|
TxContext,
|
|
5978
7403
|
TxVault,
|
|
7404
|
+
USER_ENVELOPE_COLLECTION,
|
|
7405
|
+
USER_ENVELOPE_MAX_BYTES,
|
|
7406
|
+
UserApi,
|
|
7407
|
+
UserEnvelopeOversizedError,
|
|
5979
7408
|
ValidationError,
|
|
5980
7409
|
Vault,
|
|
5981
7410
|
VaultFrame,
|
|
5982
7411
|
VaultInstant,
|
|
7412
|
+
WeakPassphraseError,
|
|
5983
7413
|
activeSessionCount,
|
|
5984
7414
|
applyI18nLocale,
|
|
5985
7415
|
applyJoins,
|
|
5986
7416
|
applyPatch,
|
|
7417
|
+
assertStrongPassphrase,
|
|
5987
7418
|
assertTierAccess,
|
|
5988
7419
|
avg,
|
|
5989
7420
|
base64ToBuffer,
|
|
5990
7421
|
bufferToBase64,
|
|
5991
7422
|
buildLiveQuery,
|
|
5992
7423
|
buildRecipientKeyringFile,
|
|
7424
|
+
burnPaperRecoveryEntry,
|
|
5993
7425
|
canonicalJson,
|
|
7426
|
+
checkGate,
|
|
5994
7427
|
clearDevUnlock,
|
|
5995
7428
|
computePatch,
|
|
5996
7429
|
count,
|
|
@@ -6004,10 +7437,16 @@ export {
|
|
|
6004
7437
|
decryptDeterministic,
|
|
6005
7438
|
dekKey,
|
|
6006
7439
|
deleteCredential,
|
|
7440
|
+
deleteUserEnvelope,
|
|
6007
7441
|
deriveMagicLinkContentKey,
|
|
6008
7442
|
derivePresenceKey,
|
|
7443
|
+
describeAllUsersAuth,
|
|
7444
|
+
describeAuthConfig,
|
|
7445
|
+
describeGate,
|
|
7446
|
+
describeUserAuth,
|
|
6009
7447
|
detectMagic,
|
|
6010
7448
|
detectMimeType,
|
|
7449
|
+
diagramAuthConfig,
|
|
6011
7450
|
dictCollectionName,
|
|
6012
7451
|
dictKey,
|
|
6013
7452
|
diff,
|
|
@@ -6016,6 +7455,7 @@ export {
|
|
|
6016
7455
|
enableDevUnlock,
|
|
6017
7456
|
encryptBytes,
|
|
6018
7457
|
encryptDeterministic,
|
|
7458
|
+
enrollAuthenticator,
|
|
6019
7459
|
envelopePayloadHash,
|
|
6020
7460
|
estimateEntropy,
|
|
6021
7461
|
estimateRecordBytes,
|
|
@@ -6024,6 +7464,7 @@ export {
|
|
|
6024
7464
|
evaluateFieldClause,
|
|
6025
7465
|
evaluateImportCapability,
|
|
6026
7466
|
executePlan,
|
|
7467
|
+
findAuthenticator,
|
|
6027
7468
|
formatDiff,
|
|
6028
7469
|
generateULID,
|
|
6029
7470
|
getCredential,
|
|
@@ -6031,6 +7472,7 @@ export {
|
|
|
6031
7472
|
hasExportCapability,
|
|
6032
7473
|
hasImportCapability,
|
|
6033
7474
|
hasNoydbBundleMagic,
|
|
7475
|
+
hasRecoveryEnrolled,
|
|
6034
7476
|
hashEntry,
|
|
6035
7477
|
i18nText,
|
|
6036
7478
|
isDevUnlockActive,
|
|
@@ -6039,16 +7481,27 @@ export {
|
|
|
6039
7481
|
isI18nTextDescriptor,
|
|
6040
7482
|
isMagicLinkGrantExpired,
|
|
6041
7483
|
isPreCompressed,
|
|
7484
|
+
isPublicEnvelope,
|
|
6042
7485
|
isSessionAlive,
|
|
6043
7486
|
isULID,
|
|
6044
7487
|
issueDelegation,
|
|
7488
|
+
recoverPassphrase as keyringRecoverPassphrase,
|
|
7489
|
+
rotatePassphrase as keyringRotatePassphrase,
|
|
6045
7490
|
listCredentials,
|
|
6046
7491
|
listMagicLinkGrants,
|
|
7492
|
+
listUserEnvelopeIds,
|
|
7493
|
+
listUsers,
|
|
7494
|
+
listUsersWithEnvelopes,
|
|
6047
7495
|
loadActiveDelegations,
|
|
6048
7496
|
loadDevUnlock,
|
|
7497
|
+
loadPaperRecoveryEntries,
|
|
7498
|
+
loadPublicEnvelope,
|
|
7499
|
+
loadUserEnvelope,
|
|
7500
|
+
loadVaultPolicy,
|
|
6049
7501
|
magicLinkGrantRecordId,
|
|
6050
7502
|
max,
|
|
6051
7503
|
mergeCrdtStates,
|
|
7504
|
+
mergePolicy,
|
|
6052
7505
|
min,
|
|
6053
7506
|
paddedIndex,
|
|
6054
7507
|
parseBytes,
|
|
@@ -6057,13 +7510,17 @@ export {
|
|
|
6057
7510
|
readMagicLinkGrantRecord,
|
|
6058
7511
|
readNoydbBundle,
|
|
6059
7512
|
readNoydbBundleHeader,
|
|
7513
|
+
readNoydbBundlePublicEnvelope,
|
|
6060
7514
|
readPath,
|
|
7515
|
+
readPublicEnvelope,
|
|
6061
7516
|
reduceRecords,
|
|
6062
7517
|
ref,
|
|
7518
|
+
removeAuthenticator,
|
|
6063
7519
|
resetBrotliSupportCache,
|
|
6064
7520
|
resetJoinWarnings,
|
|
6065
7521
|
resolveCrdtSnapshot,
|
|
6066
7522
|
resolveI18nText,
|
|
7523
|
+
resolveSchema as resolvePublicEnvelopeSchema,
|
|
6067
7524
|
resolveSession,
|
|
6068
7525
|
revokeAllSessions,
|
|
6069
7526
|
revokeDelegation,
|
|
@@ -6071,11 +7528,16 @@ export {
|
|
|
6071
7528
|
revokeSession,
|
|
6072
7529
|
routeStore,
|
|
6073
7530
|
runTransaction,
|
|
7531
|
+
savePaperRecoveryEntries,
|
|
7532
|
+
savePublicEnvelope,
|
|
7533
|
+
saveUserEnvelope,
|
|
7534
|
+
saveVaultPolicy,
|
|
6074
7535
|
sha256Hex,
|
|
6075
7536
|
sum,
|
|
6076
7537
|
unwrapMagicLinkGrant,
|
|
6077
7538
|
validateI18nTextValue,
|
|
6078
7539
|
validatePassphrase,
|
|
7540
|
+
validatePublicEnvelopeInput,
|
|
6079
7541
|
validateSchemaInput,
|
|
6080
7542
|
validateSchemaOutput,
|
|
6081
7543
|
validateSessionPolicy,
|