@noy-db/hub 0.1.0-pre.4 → 0.1.0-pre.5
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-PSHTHSIX.js → chunk-6NPQTBZN.js} +103 -8
- package/dist/chunk-6NPQTBZN.js.map +1 -0
- package/dist/{chunk-QZIACZZU.js → chunk-E4OOAPBZ.js} +2 -2
- package/dist/chunk-EMIGCR7X.js +39 -0
- package/dist/chunk-EMIGCR7X.js.map +1 -0
- package/dist/{chunk-AVWFLPNR.js → chunk-H3DV46AQ.js} +2 -2
- package/dist/{chunk-NK2NSXXK.js → chunk-LMKOSLJY.js} +2 -2
- package/dist/{chunk-GJILMRPO.js → chunk-LRN3PNI6.js} +42 -4
- package/dist/chunk-LRN3PNI6.js.map +1 -0
- package/dist/{chunk-L77MEFCH.js → chunk-MIRZMUSQ.js} +3 -3
- package/dist/{chunk-O5GK62FJ.js → chunk-NXUVITPB.js} +1 -1
- package/dist/chunk-NXUVITPB.js.map +1 -0
- package/dist/{chunk-LSZHBNDG.js → chunk-QUDXYI4W.js} +2 -2
- package/dist/{chunk-EARQCIL7.js → chunk-QV4WLLKB.js} +3 -3
- package/dist/{chunk-E445ICYI.js → chunk-UFL4DUEV.js} +5 -3
- package/dist/chunk-UFL4DUEV.js.map +1 -0
- package/dist/chunk-UQQ2XFXI.js +155 -0
- package/dist/chunk-UQQ2XFXI.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-BgFqShBi.d.ts} +1 -1
- package/dist/{dev-unlock-5SmCVGyx.d.cts → dev-unlock-qVMxG2Je.d.cts} +1 -1
- package/dist/{hash-Bxud16vM.d.ts → hash-BhoL7iUE.d.ts} +1 -1
- package/dist/{hash-CvuKN2gH.d.cts → hash-Bpvl2eSe.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-BRHBCmLt.d.ts → index-DJTf9yxn.d.ts} +1 -1
- package/dist/{index-BvUiM47h.d.cts → index-DhK_zqOO.d.ts} +39 -5
- package/dist/{index-Cy-MKrdK.d.ts → index-DyRt_5vM.d.cts} +39 -5
- package/dist/index.cjs +1490 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +261 -19
- package/dist/index.d.ts +261 -19
- package/dist/index.js +1107 -41
- package/dist/index.js.map +1 -1
- package/dist/{ledger-HWXYGUIQ.js → ledger-GA4DMJS6.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-R4EIEQE6.js +31 -0
- package/dist/public-envelope-R4EIEQE6.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-BVSfkYg6.d.cts → types-BpyE4o_n.d.cts} +895 -3
- package/dist/{types-Dmi7nrC9.d.ts → types-Df72wWCC.d.ts} +895 -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-QZIACZZU.js.map → chunk-E4OOAPBZ.js.map} +0 -0
- /package/dist/{chunk-AVWFLPNR.js.map → chunk-H3DV46AQ.js.map} +0 -0
- /package/dist/{chunk-NK2NSXXK.js.map → chunk-LMKOSLJY.js.map} +0 -0
- /package/dist/{chunk-L77MEFCH.js.map → chunk-MIRZMUSQ.js.map} +0 -0
- /package/dist/{chunk-LSZHBNDG.js.map → chunk-QUDXYI4W.js.map} +0 -0
- /package/dist/{chunk-EARQCIL7.js.map → chunk-QV4WLLKB.js.map} +0 -0
- /package/dist/{ledger-HWXYGUIQ.js.map → ledger-GA4DMJS6.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-LRN3PNI6.js";
|
|
35
|
+
import {
|
|
36
|
+
PUBLIC_ENVELOPE_RECORD_ID,
|
|
37
|
+
isPublicEnvelope,
|
|
38
|
+
loadPublicEnvelope,
|
|
39
|
+
readPublicEnvelope,
|
|
40
|
+
savePublicEnvelope,
|
|
41
|
+
validatePublicEnvelopeInput
|
|
42
|
+
} from "./chunk-UQQ2XFXI.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-QUDXYI4W.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-QV4WLLKB.js";
|
|
59
73
|
import {
|
|
60
74
|
createBundleStore,
|
|
61
75
|
routeStore,
|
|
@@ -75,17 +89,20 @@ import {
|
|
|
75
89
|
getCredential,
|
|
76
90
|
listCredentials,
|
|
77
91
|
putCredential
|
|
78
|
-
} from "./chunk-
|
|
92
|
+
} from "./chunk-MIRZMUSQ.js";
|
|
79
93
|
import {
|
|
80
94
|
PresenceHandle,
|
|
81
95
|
SyncEngine,
|
|
82
96
|
SyncTransaction
|
|
83
|
-
} from "./chunk-
|
|
97
|
+
} from "./chunk-H3DV46AQ.js";
|
|
84
98
|
import {
|
|
99
|
+
WeakPassphraseError,
|
|
100
|
+
assertStrongPassphrase,
|
|
85
101
|
buildRecipientKeyringFile,
|
|
86
102
|
changeSecret,
|
|
87
103
|
createOwnerKeyring,
|
|
88
104
|
ensureCollectionDEK,
|
|
105
|
+
estimateEntropy,
|
|
89
106
|
evaluateExportCapability,
|
|
90
107
|
evaluateImportCapability,
|
|
91
108
|
grant,
|
|
@@ -95,9 +112,11 @@ import {
|
|
|
95
112
|
hasWritePermission,
|
|
96
113
|
listUsers,
|
|
97
114
|
loadKeyring,
|
|
115
|
+
persistKeyring,
|
|
98
116
|
revoke,
|
|
99
|
-
rotateKeys
|
|
100
|
-
|
|
117
|
+
rotateKeys,
|
|
118
|
+
validatePassphrase
|
|
119
|
+
} from "./chunk-6NPQTBZN.js";
|
|
101
120
|
import {
|
|
102
121
|
BUNDLE_STORE_POLICY,
|
|
103
122
|
INDEXED_STORE_POLICY,
|
|
@@ -117,7 +136,7 @@ import {
|
|
|
117
136
|
revokeAllSessions,
|
|
118
137
|
revokeSession,
|
|
119
138
|
validateSessionPolicy
|
|
120
|
-
} from "./chunk-
|
|
139
|
+
} from "./chunk-UFL4DUEV.js";
|
|
121
140
|
import {
|
|
122
141
|
generateULID,
|
|
123
142
|
isULID
|
|
@@ -134,7 +153,7 @@ import {
|
|
|
134
153
|
LedgerStore,
|
|
135
154
|
applyPatch,
|
|
136
155
|
computePatch
|
|
137
|
-
} from "./chunk-
|
|
156
|
+
} from "./chunk-LMKOSLJY.js";
|
|
138
157
|
import {
|
|
139
158
|
canonicalJson,
|
|
140
159
|
envelopePayloadHash,
|
|
@@ -189,24 +208,26 @@ import {
|
|
|
189
208
|
detectMimeType,
|
|
190
209
|
isPreCompressed,
|
|
191
210
|
runCompaction
|
|
192
|
-
} from "./chunk-
|
|
211
|
+
} from "./chunk-E4OOAPBZ.js";
|
|
193
212
|
import {
|
|
194
213
|
NOYDB_BACKUP_VERSION,
|
|
195
214
|
NOYDB_FORMAT_VERSION,
|
|
196
215
|
NOYDB_KEYRING_VERSION,
|
|
197
216
|
NOYDB_SYNC_VERSION,
|
|
198
217
|
createStore
|
|
199
|
-
} from "./chunk-
|
|
218
|
+
} from "./chunk-NXUVITPB.js";
|
|
200
219
|
import {
|
|
201
220
|
base64ToBuffer,
|
|
202
221
|
bufferToBase64,
|
|
203
222
|
decrypt,
|
|
204
223
|
decryptBytes,
|
|
205
224
|
decryptDeterministic,
|
|
225
|
+
deriveKey,
|
|
206
226
|
derivePresenceKey,
|
|
207
227
|
encrypt,
|
|
208
228
|
encryptBytes,
|
|
209
229
|
encryptDeterministic,
|
|
230
|
+
generateSalt,
|
|
210
231
|
unwrapKey,
|
|
211
232
|
wrapKey
|
|
212
233
|
} from "./chunk-MR4424N3.js";
|
|
@@ -397,6 +418,514 @@ var RefRegistry = class {
|
|
|
397
418
|
}
|
|
398
419
|
};
|
|
399
420
|
|
|
421
|
+
// src/team/authenticators.ts
|
|
422
|
+
async function enrollAuthenticator(store, vault, keyring, options) {
|
|
423
|
+
const existing = keyring.authenticators.find((a) => a.id === options.id);
|
|
424
|
+
if (existing) {
|
|
425
|
+
throw new ValidationError(
|
|
426
|
+
`enrollAuthenticator: slot id "${options.id}" already exists in vault "${vault}". Remove the slot first or pick a unique id.`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
const slot = {
|
|
430
|
+
id: options.id,
|
|
431
|
+
method: options.method,
|
|
432
|
+
enrolled_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
433
|
+
enrolled_via_tier: options.enrolled_via_tier ?? 1,
|
|
434
|
+
wrapped_kek: options.wrapped_kek,
|
|
435
|
+
meta: options.meta
|
|
436
|
+
};
|
|
437
|
+
const next = appendSlot(keyring, slot);
|
|
438
|
+
await persistKeyring(store, vault, next);
|
|
439
|
+
return next;
|
|
440
|
+
}
|
|
441
|
+
async function removeAuthenticator(store, vault, keyring, slotId) {
|
|
442
|
+
const filtered = keyring.authenticators.filter((a) => a.id !== slotId);
|
|
443
|
+
if (filtered.length === keyring.authenticators.length) {
|
|
444
|
+
return keyring;
|
|
445
|
+
}
|
|
446
|
+
const next = {
|
|
447
|
+
...keyring,
|
|
448
|
+
authenticators: filtered
|
|
449
|
+
};
|
|
450
|
+
await persistKeyring(store, vault, next);
|
|
451
|
+
return next;
|
|
452
|
+
}
|
|
453
|
+
function findAuthenticator(keyring, slotId) {
|
|
454
|
+
return keyring.authenticators.find((a) => a.id === slotId);
|
|
455
|
+
}
|
|
456
|
+
function appendSlot(keyring, slot) {
|
|
457
|
+
return {
|
|
458
|
+
...keyring,
|
|
459
|
+
authenticators: [...keyring.authenticators, slot]
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// src/session/unlock-state.ts
|
|
464
|
+
var QuickUnlockStore = class {
|
|
465
|
+
states = /* @__PURE__ */ new Map();
|
|
466
|
+
timers = /* @__PURE__ */ new Map();
|
|
467
|
+
/**
|
|
468
|
+
* Register a quick-unlock state for a vault. Replaces any existing
|
|
469
|
+
* state. Schedules an automatic clear when the state's `expiresAt`
|
|
470
|
+
* elapses.
|
|
471
|
+
*/
|
|
472
|
+
set(vault, state) {
|
|
473
|
+
this.clearTimer(vault);
|
|
474
|
+
this.states.set(vault, state);
|
|
475
|
+
const ttl = new Date(state.expiresAt).getTime() - Date.now();
|
|
476
|
+
if (ttl > 0) {
|
|
477
|
+
const timer = setTimeout(() => this.delete(vault), ttl);
|
|
478
|
+
this.timers.set(vault, timer);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/** Read the state for a vault. Returns undefined when none is registered. */
|
|
482
|
+
get(vault) {
|
|
483
|
+
return this.states.get(vault);
|
|
484
|
+
}
|
|
485
|
+
/** Drop the state for a vault. Cancels the auto-clear timer. */
|
|
486
|
+
delete(vault) {
|
|
487
|
+
this.clearTimer(vault);
|
|
488
|
+
this.states.delete(vault);
|
|
489
|
+
}
|
|
490
|
+
/** Drop every cached state. Called on `db.close()`. */
|
|
491
|
+
clear() {
|
|
492
|
+
for (const vault of this.states.keys()) {
|
|
493
|
+
this.clearTimer(vault);
|
|
494
|
+
}
|
|
495
|
+
this.states.clear();
|
|
496
|
+
}
|
|
497
|
+
clearTimer(vault) {
|
|
498
|
+
const t = this.timers.get(vault);
|
|
499
|
+
if (t) clearTimeout(t);
|
|
500
|
+
this.timers.delete(vault);
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// src/policy/errors.ts
|
|
505
|
+
var PolicyDeniedError = class extends NoydbError {
|
|
506
|
+
gate;
|
|
507
|
+
reason;
|
|
508
|
+
required;
|
|
509
|
+
constructor(gate, reason, required, message) {
|
|
510
|
+
super(
|
|
511
|
+
"POLICY_DENIED",
|
|
512
|
+
message ?? `Gate "${gate}" denied: ${reason}.`
|
|
513
|
+
);
|
|
514
|
+
this.name = "PolicyDeniedError";
|
|
515
|
+
this.gate = gate;
|
|
516
|
+
this.reason = reason;
|
|
517
|
+
this.required = required;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
var RecoveryNotEnrolledError = class extends NoydbError {
|
|
521
|
+
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.') {
|
|
522
|
+
super("RECOVERY_NOT_ENROLLED", message);
|
|
523
|
+
this.name = "RecoveryNotEnrolledError";
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
var RecoveryProfileNotImplementedError = class extends NoydbError {
|
|
527
|
+
profile;
|
|
528
|
+
tracking;
|
|
529
|
+
constructor(profile, tracking) {
|
|
530
|
+
super(
|
|
531
|
+
"RECOVERY_PROFILE_NOT_IMPLEMENTED",
|
|
532
|
+
`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.`
|
|
533
|
+
);
|
|
534
|
+
this.name = "RecoveryProfileNotImplementedError";
|
|
535
|
+
this.profile = profile;
|
|
536
|
+
this.tracking = tracking;
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// src/team/recovery.ts
|
|
541
|
+
var PAPER_DOC_ID = "recovery-paper";
|
|
542
|
+
async function loadPaperRecoveryEntries(store, vault) {
|
|
543
|
+
const env = await store.get(vault, "_meta", PAPER_DOC_ID);
|
|
544
|
+
if (!env) return [];
|
|
545
|
+
try {
|
|
546
|
+
const doc = JSON.parse(env._data);
|
|
547
|
+
if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
|
|
548
|
+
return doc.entries;
|
|
549
|
+
} catch {
|
|
550
|
+
return [];
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
async function savePaperRecoveryEntries(store, vault, entries) {
|
|
554
|
+
const doc = {
|
|
555
|
+
_noydb_recovery: 1,
|
|
556
|
+
profile: "paper",
|
|
557
|
+
entries
|
|
558
|
+
};
|
|
559
|
+
const envelope = {
|
|
560
|
+
_noydb: NOYDB_FORMAT_VERSION,
|
|
561
|
+
_v: 1,
|
|
562
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
563
|
+
_iv: "",
|
|
564
|
+
_data: JSON.stringify(doc)
|
|
565
|
+
};
|
|
566
|
+
await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
|
|
567
|
+
}
|
|
568
|
+
async function burnPaperRecoveryEntry(store, vault, codeId) {
|
|
569
|
+
const entries = await loadPaperRecoveryEntries(store, vault);
|
|
570
|
+
const remaining = entries.filter((e) => e.codeId !== codeId);
|
|
571
|
+
await savePaperRecoveryEntries(store, vault, remaining);
|
|
572
|
+
}
|
|
573
|
+
async function hasRecoveryEnrolled(store, vault) {
|
|
574
|
+
const paper = await loadPaperRecoveryEntries(store, vault);
|
|
575
|
+
return paper.length > 0;
|
|
576
|
+
}
|
|
577
|
+
var subtle = globalThis.crypto.subtle;
|
|
578
|
+
var RECOVERY_PBKDF2_ITERATIONS = 6e5;
|
|
579
|
+
async function unwrapDeksFromPaperEntry(entry, code) {
|
|
580
|
+
const wrappingKey = await deriveRecoveryWrappingKey(code, base64ToBytes(entry.salt));
|
|
581
|
+
const plaintext = await subtle.decrypt(
|
|
582
|
+
{ name: "AES-GCM", iv: base64ToBytes(entry.iv) },
|
|
583
|
+
wrappingKey,
|
|
584
|
+
base64ToBytes(entry.wrappedDeks)
|
|
585
|
+
);
|
|
586
|
+
const parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
|
587
|
+
const deks = /* @__PURE__ */ new Map();
|
|
588
|
+
for (const [coll, b64] of Object.entries(parsed.deks)) {
|
|
589
|
+
const raw = base64ToBytes(b64);
|
|
590
|
+
const key = await subtle.importKey(
|
|
591
|
+
"raw",
|
|
592
|
+
raw,
|
|
593
|
+
{ name: "AES-GCM", length: 256 },
|
|
594
|
+
true,
|
|
595
|
+
["encrypt", "decrypt"]
|
|
596
|
+
);
|
|
597
|
+
deks.set(coll, key);
|
|
598
|
+
}
|
|
599
|
+
return deks;
|
|
600
|
+
}
|
|
601
|
+
async function deriveRecoveryWrappingKey(code, salt) {
|
|
602
|
+
const ikm = await subtle.importKey(
|
|
603
|
+
"raw",
|
|
604
|
+
new TextEncoder().encode(code),
|
|
605
|
+
"PBKDF2",
|
|
606
|
+
false,
|
|
607
|
+
["deriveKey"]
|
|
608
|
+
);
|
|
609
|
+
return subtle.deriveKey(
|
|
610
|
+
{
|
|
611
|
+
name: "PBKDF2",
|
|
612
|
+
salt,
|
|
613
|
+
iterations: RECOVERY_PBKDF2_ITERATIONS,
|
|
614
|
+
hash: "SHA-256"
|
|
615
|
+
},
|
|
616
|
+
ikm,
|
|
617
|
+
{ name: "AES-GCM", length: 256 },
|
|
618
|
+
false,
|
|
619
|
+
["encrypt", "decrypt"]
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
function base64ToBytes(b64) {
|
|
623
|
+
const s = atob(b64);
|
|
624
|
+
const out = new Uint8Array(s.length);
|
|
625
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
626
|
+
return out;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// src/team/rotate-recover.ts
|
|
630
|
+
async function rotatePassphrase(store, vault, userId, input) {
|
|
631
|
+
if (!input.allowWeakPassphrase) {
|
|
632
|
+
assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
|
|
633
|
+
}
|
|
634
|
+
const env = await store.get(vault, "_keyring", userId);
|
|
635
|
+
if (!env) {
|
|
636
|
+
throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}".`);
|
|
637
|
+
}
|
|
638
|
+
const file = JSON.parse(env._data);
|
|
639
|
+
const oldSalt = base64ToBuffer(file.salt);
|
|
640
|
+
const oldKek = await deriveKey(input.oldPassphrase, oldSalt);
|
|
641
|
+
const deks = /* @__PURE__ */ new Map();
|
|
642
|
+
for (const [coll, wrapped] of Object.entries(file.deks)) {
|
|
643
|
+
deks.set(coll, await unwrapKey(wrapped, oldKek));
|
|
644
|
+
}
|
|
645
|
+
const newSalt = generateSalt();
|
|
646
|
+
const newKek = await deriveKey(input.newPassphrase, newSalt);
|
|
647
|
+
const wrappedDeks = {};
|
|
648
|
+
for (const [coll, dek] of deks) {
|
|
649
|
+
wrappedDeks[coll] = await wrapKey(dek, newKek);
|
|
650
|
+
}
|
|
651
|
+
const next = {
|
|
652
|
+
...file,
|
|
653
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
654
|
+
deks: wrappedDeks,
|
|
655
|
+
salt: bufferToBase64(newSalt),
|
|
656
|
+
// Tier-2 slots reference the old KEK — drop them. User
|
|
657
|
+
// re-enrols afterwards via `db.enrollAuthenticator`.
|
|
658
|
+
authenticators: []
|
|
659
|
+
};
|
|
660
|
+
await writeKeyringFile(store, vault, userId, next);
|
|
661
|
+
return {
|
|
662
|
+
userId: file.user_id,
|
|
663
|
+
displayName: file.display_name,
|
|
664
|
+
role: file.role,
|
|
665
|
+
permissions: file.permissions,
|
|
666
|
+
deks,
|
|
667
|
+
kek: newKek,
|
|
668
|
+
salt: newSalt,
|
|
669
|
+
authenticators: [],
|
|
670
|
+
...file.export_capability !== void 0 && { exportCapability: file.export_capability },
|
|
671
|
+
...file.import_capability !== void 0 && { importCapability: file.import_capability }
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
async function recoverPassphrase(store, vault, userId, input) {
|
|
675
|
+
if (!input.allowWeakPassphrase) {
|
|
676
|
+
assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
|
|
677
|
+
}
|
|
678
|
+
switch (input.recoveryProof.profile) {
|
|
679
|
+
case "paper":
|
|
680
|
+
return recoverViaPaperCode(store, vault, userId, input);
|
|
681
|
+
case "shamir":
|
|
682
|
+
throw new RecoveryProfileNotImplementedError(
|
|
683
|
+
"shamir",
|
|
684
|
+
"https://github.com/vLannaAi/noy-db/issues/10"
|
|
685
|
+
);
|
|
686
|
+
case "multi-channel":
|
|
687
|
+
throw new RecoveryProfileNotImplementedError(
|
|
688
|
+
"multi-channel",
|
|
689
|
+
"https://github.com/vLannaAi/noy-db/issues/10"
|
|
690
|
+
);
|
|
691
|
+
case "admin-mediated":
|
|
692
|
+
throw new RecoveryProfileNotImplementedError(
|
|
693
|
+
"admin-mediated",
|
|
694
|
+
"https://github.com/vLannaAi/noy-db/issues/10"
|
|
695
|
+
);
|
|
696
|
+
default: {
|
|
697
|
+
const _exhaustive = input.recoveryProof;
|
|
698
|
+
throw new Error(`Unknown recovery profile: ${String(_exhaustive)}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
async function recoverViaPaperCode(store, vault, userId, input) {
|
|
703
|
+
if (input.recoveryProof.profile !== "paper") throw new Error("unreachable");
|
|
704
|
+
const { code } = input.recoveryProof.payload;
|
|
705
|
+
const env = await store.get(vault, "_keyring", userId);
|
|
706
|
+
if (!env) {
|
|
707
|
+
throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}".`);
|
|
708
|
+
}
|
|
709
|
+
const file = JSON.parse(env._data);
|
|
710
|
+
const entries = await loadPaperRecoveryEntries(store, vault);
|
|
711
|
+
if (entries.length === 0) {
|
|
712
|
+
throw new NoAccessError(
|
|
713
|
+
`No paper-recovery entries enrolled for vault "${vault}". Enroll via \`db.enrollRecovery({ profile: "paper", entries })\` before relying on recovery.`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
const normalized = normalizePaperCode(code);
|
|
717
|
+
let recovered;
|
|
718
|
+
for (const entry of entries) {
|
|
719
|
+
try {
|
|
720
|
+
const deks2 = await unwrapDeksFromPaperEntry(entry, normalized);
|
|
721
|
+
recovered = { deks: deks2, entry };
|
|
722
|
+
break;
|
|
723
|
+
} catch {
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (!recovered) {
|
|
727
|
+
throw new InvalidKeyError(
|
|
728
|
+
"Recovery code does not match any enrolled paper entry. The code may have been previously used (single-use) or typed incorrectly."
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
const deks = recovered.deks;
|
|
732
|
+
const newSalt = generateSalt();
|
|
733
|
+
const newKek = await deriveKey(input.newPassphrase, newSalt);
|
|
734
|
+
const wrappedDeks = {};
|
|
735
|
+
for (const [coll, dek] of deks) {
|
|
736
|
+
wrappedDeks[coll] = await wrapKey(dek, newKek);
|
|
737
|
+
}
|
|
738
|
+
const next = {
|
|
739
|
+
...file,
|
|
740
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
741
|
+
deks: wrappedDeks,
|
|
742
|
+
salt: bufferToBase64(newSalt),
|
|
743
|
+
authenticators: []
|
|
744
|
+
// tier-2 slots wrap old KEK, drop them
|
|
745
|
+
};
|
|
746
|
+
await writeKeyringFile(store, vault, userId, next);
|
|
747
|
+
await burnPaperRecoveryEntry(store, vault, recovered.entry.codeId);
|
|
748
|
+
return {
|
|
749
|
+
userId: file.user_id,
|
|
750
|
+
displayName: file.display_name,
|
|
751
|
+
role: file.role,
|
|
752
|
+
permissions: file.permissions,
|
|
753
|
+
deks,
|
|
754
|
+
kek: newKek,
|
|
755
|
+
salt: newSalt,
|
|
756
|
+
authenticators: [],
|
|
757
|
+
...file.export_capability !== void 0 && { exportCapability: file.export_capability },
|
|
758
|
+
...file.import_capability !== void 0 && { importCapability: file.import_capability }
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
function normalizePaperCode(input) {
|
|
762
|
+
return input.toUpperCase().replace(/[\s\-_]/g, "");
|
|
763
|
+
}
|
|
764
|
+
async function writeKeyringFile(store, vault, userId, file) {
|
|
765
|
+
const envelope = {
|
|
766
|
+
_noydb: 1,
|
|
767
|
+
_v: 1,
|
|
768
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
769
|
+
_iv: "",
|
|
770
|
+
_data: JSON.stringify(file)
|
|
771
|
+
};
|
|
772
|
+
await store.put(vault, "_keyring", userId, envelope);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// src/policy/storage.ts
|
|
776
|
+
var META_COLLECTION = "_meta";
|
|
777
|
+
var POLICY_RECORD_ID = "policy";
|
|
778
|
+
async function loadVaultPolicy(store, vault) {
|
|
779
|
+
const envelope = await store.get(vault, META_COLLECTION, POLICY_RECORD_ID);
|
|
780
|
+
if (!envelope) return void 0;
|
|
781
|
+
try {
|
|
782
|
+
const parsed = JSON.parse(envelope._data);
|
|
783
|
+
if (!isVaultPolicy(parsed)) return void 0;
|
|
784
|
+
return parsed;
|
|
785
|
+
} catch {
|
|
786
|
+
return void 0;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async function saveVaultPolicy(store, vault, policy) {
|
|
790
|
+
const envelope = {
|
|
791
|
+
_noydb: NOYDB_FORMAT_VERSION,
|
|
792
|
+
_v: 1,
|
|
793
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
794
|
+
_iv: "",
|
|
795
|
+
_data: JSON.stringify(policy)
|
|
796
|
+
};
|
|
797
|
+
await store.put(vault, META_COLLECTION, POLICY_RECORD_ID, envelope);
|
|
798
|
+
}
|
|
799
|
+
function isVaultPolicy(x) {
|
|
800
|
+
if (x === null || typeof x !== "object") return false;
|
|
801
|
+
if (!("gates" in x)) return false;
|
|
802
|
+
const gates = x.gates;
|
|
803
|
+
return gates !== null && typeof gates === "object";
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// src/auth-introspection/index.ts
|
|
807
|
+
async function describeAuthConfig(store, vault) {
|
|
808
|
+
const policy = await loadVaultPolicy(store, vault) ?? defaultPolicySnapshot();
|
|
809
|
+
const recoveryProfiles = await listRecoveryProfilesEnrolled(store, vault);
|
|
810
|
+
const lines = [];
|
|
811
|
+
lines.push(`Vault "${vault}" \u2014 three-tier authentication`);
|
|
812
|
+
lines.push("");
|
|
813
|
+
lines.push("Tier 1 \u2014 Passphrase (master)");
|
|
814
|
+
lines.push(` Phrase format: ${policy.passphrase?.minWords ?? 6}+ words, lowercase letters, \u2265${policy.passphrase?.minWordLength ?? 3} chars/word`);
|
|
815
|
+
lines.push(" Strength validator: enforced (override available for tests only)");
|
|
816
|
+
lines.push("");
|
|
817
|
+
lines.push("Tier 2 \u2014 Authenticate (daily login)");
|
|
818
|
+
lines.push(" Allowed methods: WebAuthn (passkey), OIDC, Password");
|
|
819
|
+
lines.push(" Slots per user: unlimited");
|
|
820
|
+
lines.push("");
|
|
821
|
+
lines.push("Tier 3 \u2014 Unlock (quick resume)");
|
|
822
|
+
lines.push(" Method: PIN (per-app configurable)");
|
|
823
|
+
lines.push("");
|
|
824
|
+
lines.push(`Recovery profiles enrolled: ${recoveryProfiles.length === 0 ? "none" : recoveryProfiles.join(", ")}`);
|
|
825
|
+
lines.push("Managed-passphrase mode: off (post-1.0)");
|
|
826
|
+
lines.push("");
|
|
827
|
+
lines.push("Sensitive-action gates:");
|
|
828
|
+
for (const [gate, gp] of Object.entries(policy.gates)) {
|
|
829
|
+
lines.push(` ${gate} \u2014 ${describeGatePolicy(gp)}`);
|
|
830
|
+
}
|
|
831
|
+
return lines.join("\n");
|
|
832
|
+
}
|
|
833
|
+
async function diagramAuthConfig(store, vault) {
|
|
834
|
+
const policy = await loadVaultPolicy(store, vault) ?? defaultPolicySnapshot();
|
|
835
|
+
const lines = [];
|
|
836
|
+
lines.push("flowchart TB");
|
|
837
|
+
lines.push(` vault["Vault: ${escapeMermaid(vault)}"]`);
|
|
838
|
+
lines.push(' tier1["Tier 1<br/>Passphrase"]');
|
|
839
|
+
lines.push(' tier2["Tier 2<br/>Multi-slot Authenticate"]');
|
|
840
|
+
lines.push(' tier3["Tier 3<br/>PIN / Quick-resume"]');
|
|
841
|
+
lines.push(" vault --> tier1");
|
|
842
|
+
lines.push(" tier1 --> tier2");
|
|
843
|
+
lines.push(" tier2 --> tier3");
|
|
844
|
+
for (const [gateName, gp] of Object.entries(policy.gates)) {
|
|
845
|
+
if (gp.enabled === false) continue;
|
|
846
|
+
const id = sanitizeId(gateName);
|
|
847
|
+
const label = `${gateName}<br/>tier \u2265 ${gp.minTier}`;
|
|
848
|
+
lines.push(` ${id}["${escapeMermaid(label)}"]`);
|
|
849
|
+
const tierNode = gp.minTier === 1 ? "tier1" : gp.minTier === 2 ? "tier2" : "tier3";
|
|
850
|
+
lines.push(` ${tierNode} --> ${id}`);
|
|
851
|
+
}
|
|
852
|
+
return lines.join("\n");
|
|
853
|
+
}
|
|
854
|
+
async function describeUserAuth(store, vault, userId) {
|
|
855
|
+
const env = await store.get(vault, "_keyring", userId);
|
|
856
|
+
if (!env) return "";
|
|
857
|
+
const file = JSON.parse(env._data);
|
|
858
|
+
const lines = [];
|
|
859
|
+
lines.push(
|
|
860
|
+
`User: ${file.user_id} (joined ${file.created_at.slice(0, 10)}, role: ${file.role})`
|
|
861
|
+
);
|
|
862
|
+
lines.push("");
|
|
863
|
+
lines.push("Tier 2 enrollments:");
|
|
864
|
+
if (!file.authenticators || file.authenticators.length === 0) {
|
|
865
|
+
lines.push(" (none enrolled)");
|
|
866
|
+
} else {
|
|
867
|
+
for (const slot of file.authenticators) {
|
|
868
|
+
lines.push(` - ${describeSlot(slot)}`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return lines.join("\n");
|
|
872
|
+
}
|
|
873
|
+
async function describeAllUsersAuth(store, vault) {
|
|
874
|
+
const ids = await store.list(vault, "_keyring");
|
|
875
|
+
const results = [];
|
|
876
|
+
for (const userId of ids) {
|
|
877
|
+
const description = await describeUserAuth(store, vault, userId);
|
|
878
|
+
if (description !== "") results.push({ userId, description });
|
|
879
|
+
}
|
|
880
|
+
return results;
|
|
881
|
+
}
|
|
882
|
+
var SLOT_FIELD_ALLOWLIST = [
|
|
883
|
+
"id",
|
|
884
|
+
"method",
|
|
885
|
+
"enrolled_at",
|
|
886
|
+
"enrolled_via_tier"
|
|
887
|
+
];
|
|
888
|
+
function describeSlot(slot) {
|
|
889
|
+
const sanitized = {};
|
|
890
|
+
for (const key of SLOT_FIELD_ALLOWLIST) {
|
|
891
|
+
if (key in slot) {
|
|
892
|
+
sanitized[key] = slot[key];
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const date = (sanitized.enrolled_at ?? "").slice(0, 10);
|
|
896
|
+
return `${sanitized.method ?? "?"} (id=${sanitized.id ?? "?"}, enrolled ${date}, via tier ${sanitized.enrolled_via_tier ?? "?"})`;
|
|
897
|
+
}
|
|
898
|
+
function describeGatePolicy(gp) {
|
|
899
|
+
if (gp.enabled === false) return "disabled";
|
|
900
|
+
const parts = [];
|
|
901
|
+
parts.push(`tier ${gp.minTier}`);
|
|
902
|
+
if (gp.factors && gp.factors.length > 0) {
|
|
903
|
+
for (const f of gp.factors) {
|
|
904
|
+
parts.push(`+ ${f.count ?? 1}\xD7 ${f.anyOf.join("|")}`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (gp.warn?.sharedDevice === "block") parts.push("block-on-shared-device");
|
|
908
|
+
return parts.join(" ");
|
|
909
|
+
}
|
|
910
|
+
function defaultPolicySnapshot() {
|
|
911
|
+
return {
|
|
912
|
+
passphrase: { minWords: 6, minWordLength: 3, rejectRepeatedAdjacent: true },
|
|
913
|
+
gates: {}
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
async function listRecoveryProfilesEnrolled(store, vault) {
|
|
917
|
+
const enrolled = [];
|
|
918
|
+
const paper = await loadPaperRecoveryEntries(store, vault);
|
|
919
|
+
if (paper.length > 0) enrolled.push(`paper (${paper.length} codes)`);
|
|
920
|
+
return enrolled;
|
|
921
|
+
}
|
|
922
|
+
function escapeMermaid(s) {
|
|
923
|
+
return s.replace(/"/g, '\\"').replace(/\n/g, " ");
|
|
924
|
+
}
|
|
925
|
+
function sanitizeId(s) {
|
|
926
|
+
return s.replace(/[^a-zA-Z0-9]/g, "_");
|
|
927
|
+
}
|
|
928
|
+
|
|
400
929
|
// src/crdt/strategy.ts
|
|
401
930
|
var NOT_ENABLED = new Error(
|
|
402
931
|
'CRDT mode requires the CRDT strategy. Import `{ withCrdt }` from "@noy-db/hub/crdt" and pass it to `createNoydb({ crdtStrategy: withCrdt() })`.'
|
|
@@ -2901,13 +3430,13 @@ var MAGIC_LINK_GRANTS_COLLECTION = "_magic_link_grants";
|
|
|
2901
3430
|
var MAGIC_LINK_CONTENT_INFO_PREFIX = "noydb-magic-link-content-v1:";
|
|
2902
3431
|
var MAGIC_LINK_KEK_INFO_PREFIX = "noydb-magic-link-v1:";
|
|
2903
3432
|
async function deriveMagicLinkContentKey(serverSecret, token, vault) {
|
|
2904
|
-
const
|
|
3433
|
+
const subtle2 = globalThis.crypto.subtle;
|
|
2905
3434
|
const ikmBytes = serverSecret instanceof Uint8Array ? serverSecret : new TextEncoder().encode(serverSecret);
|
|
2906
3435
|
const tokenBytes = new TextEncoder().encode(token);
|
|
2907
|
-
const saltBuffer = await
|
|
3436
|
+
const saltBuffer = await subtle2.digest("SHA-256", tokenBytes);
|
|
2908
3437
|
const info = new TextEncoder().encode(MAGIC_LINK_CONTENT_INFO_PREFIX + vault);
|
|
2909
|
-
const ikm = await
|
|
2910
|
-
return
|
|
3438
|
+
const ikm = await subtle2.importKey("raw", ikmBytes, "HKDF", false, ["deriveKey"]);
|
|
3439
|
+
return subtle2.deriveKey(
|
|
2911
3440
|
{ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info },
|
|
2912
3441
|
ikm,
|
|
2913
3442
|
{ name: "AES-GCM", length: 256 },
|
|
@@ -4436,6 +4965,23 @@ var Vault = class {
|
|
|
4436
4965
|
await this.adapter.put(this.name, "_meta", "handle", envelope);
|
|
4437
4966
|
return handle;
|
|
4438
4967
|
}
|
|
4968
|
+
/**
|
|
4969
|
+
* Read the owner-curated public envelope for this vault (or
|
|
4970
|
+
* `undefined` if none is persisted). The envelope lives in
|
|
4971
|
+
* `_meta/public-envelope` as plaintext — readable without any KEK
|
|
4972
|
+
* — so `getBundleHandle`-style callers can label a vault before
|
|
4973
|
+
* unlock.
|
|
4974
|
+
*
|
|
4975
|
+
* Mirrors `Noydb.getPublicEnvelope(vault, opts)` but scoped to a
|
|
4976
|
+
* single, already-opened `Vault` instance so the
|
|
4977
|
+
* bundle writer can snapshot it without holding a `Noydb` reference.
|
|
4978
|
+
*
|
|
4979
|
+
* @see docs/subsystems/public-envelope.md
|
|
4980
|
+
*/
|
|
4981
|
+
async getPublicEnvelope(opts = {}) {
|
|
4982
|
+
const { readPublicEnvelope: readPublicEnvelope2 } = await import("./public-envelope-R4EIEQE6.js");
|
|
4983
|
+
return readPublicEnvelope2(this.adapter, this.name, opts);
|
|
4984
|
+
}
|
|
4439
4985
|
/**
|
|
4440
4986
|
* Dump vault as a verifiable encrypted JSON backup string.
|
|
4441
4987
|
*
|
|
@@ -4997,6 +5543,161 @@ var NO_SESSION = {
|
|
|
4997
5543
|
}
|
|
4998
5544
|
};
|
|
4999
5545
|
|
|
5546
|
+
// src/policy/presets.ts
|
|
5547
|
+
var PERSONAL_POLICY = Object.freeze({
|
|
5548
|
+
passphrase: {
|
|
5549
|
+
minWords: 6,
|
|
5550
|
+
minWordLength: 3,
|
|
5551
|
+
rejectRepeatedAdjacent: true
|
|
5552
|
+
},
|
|
5553
|
+
gates: {
|
|
5554
|
+
"rotate-passphrase": {
|
|
5555
|
+
minTier: 1,
|
|
5556
|
+
factors: [{ anyOf: ["totp", "email-otp", "recovery"] }]
|
|
5557
|
+
},
|
|
5558
|
+
"recover-passphrase": {
|
|
5559
|
+
minTier: 1,
|
|
5560
|
+
enabled: true
|
|
5561
|
+
},
|
|
5562
|
+
"enroll-authenticator": { minTier: 1 },
|
|
5563
|
+
"remove-authenticator": { minTier: 1 },
|
|
5564
|
+
"rotate-unlock": { minTier: 2 },
|
|
5565
|
+
"enroll-user": { minTier: 1 },
|
|
5566
|
+
"revoke-user": { minTier: 1 },
|
|
5567
|
+
"export-bundle": { minTier: 1 },
|
|
5568
|
+
"export-plaintext": {
|
|
5569
|
+
minTier: 1,
|
|
5570
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5571
|
+
},
|
|
5572
|
+
"view-user-auth": {
|
|
5573
|
+
minTier: 1,
|
|
5574
|
+
enabled: false
|
|
5575
|
+
}
|
|
5576
|
+
}
|
|
5577
|
+
});
|
|
5578
|
+
var STRICT_POLICY = Object.freeze({
|
|
5579
|
+
passphrase: {
|
|
5580
|
+
minWords: 8,
|
|
5581
|
+
minWordLength: 3,
|
|
5582
|
+
rejectRepeatedAdjacent: true
|
|
5583
|
+
},
|
|
5584
|
+
gates: {
|
|
5585
|
+
"rotate-passphrase": {
|
|
5586
|
+
minTier: 1,
|
|
5587
|
+
factors: [{ anyOf: ["totp", "email-otp", "recovery"], count: 2 }]
|
|
5588
|
+
},
|
|
5589
|
+
"recover-passphrase": {
|
|
5590
|
+
minTier: 1,
|
|
5591
|
+
enabled: true
|
|
5592
|
+
},
|
|
5593
|
+
"enroll-authenticator": {
|
|
5594
|
+
minTier: 1,
|
|
5595
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5596
|
+
},
|
|
5597
|
+
"remove-authenticator": {
|
|
5598
|
+
minTier: 1,
|
|
5599
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5600
|
+
},
|
|
5601
|
+
"rotate-unlock": { minTier: 1 },
|
|
5602
|
+
"enroll-user": {
|
|
5603
|
+
minTier: 1,
|
|
5604
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5605
|
+
},
|
|
5606
|
+
"revoke-user": {
|
|
5607
|
+
minTier: 1,
|
|
5608
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5609
|
+
},
|
|
5610
|
+
"export-bundle": {
|
|
5611
|
+
minTier: 1,
|
|
5612
|
+
factors: [{ anyOf: ["totp", "email-otp"] }],
|
|
5613
|
+
warn: { sharedDevice: "block" }
|
|
5614
|
+
},
|
|
5615
|
+
"export-plaintext": {
|
|
5616
|
+
minTier: 1,
|
|
5617
|
+
factors: [{ anyOf: ["totp", "email-otp"], count: 2 }],
|
|
5618
|
+
warn: { sharedDevice: "block" }
|
|
5619
|
+
},
|
|
5620
|
+
"view-user-auth": {
|
|
5621
|
+
minTier: 1,
|
|
5622
|
+
enabled: false
|
|
5623
|
+
}
|
|
5624
|
+
}
|
|
5625
|
+
});
|
|
5626
|
+
function mergePolicy(base, override) {
|
|
5627
|
+
if (!override) return base;
|
|
5628
|
+
const passphrase = override.passphrase ?? base.passphrase;
|
|
5629
|
+
return {
|
|
5630
|
+
...passphrase !== void 0 ? { passphrase } : {},
|
|
5631
|
+
gates: {
|
|
5632
|
+
...base.gates,
|
|
5633
|
+
...override.gates ?? {}
|
|
5634
|
+
}
|
|
5635
|
+
};
|
|
5636
|
+
}
|
|
5637
|
+
|
|
5638
|
+
// src/policy/engine.ts
|
|
5639
|
+
var DEFAULT_FRESHNESS_MS = 5 * 60 * 1e3;
|
|
5640
|
+
async function checkGate(policy, gate, context) {
|
|
5641
|
+
const configured = policy.gates[gate];
|
|
5642
|
+
if (!configured) {
|
|
5643
|
+
if (gate.startsWith("app:")) {
|
|
5644
|
+
return;
|
|
5645
|
+
}
|
|
5646
|
+
throw deny(gate, "disabled", { minTier: 1, enabled: false });
|
|
5647
|
+
}
|
|
5648
|
+
if (configured.enabled === false) {
|
|
5649
|
+
throw deny(gate, "disabled", configured);
|
|
5650
|
+
}
|
|
5651
|
+
if (context.activeTier > configured.minTier) {
|
|
5652
|
+
throw deny(gate, "insufficient-tier", configured);
|
|
5653
|
+
}
|
|
5654
|
+
if (configured.factors && configured.factors.length > 0) {
|
|
5655
|
+
const presented = context.factors ?? [];
|
|
5656
|
+
const now = context.now ?? Date.now();
|
|
5657
|
+
for (const requirement of configured.factors) {
|
|
5658
|
+
const matches = countMatchingFactors(presented, requirement, now);
|
|
5659
|
+
const need = requirement.count ?? 1;
|
|
5660
|
+
if (matches.fresh < need) {
|
|
5661
|
+
if (matches.totalKindMatches < need) {
|
|
5662
|
+
throw deny(gate, "missing-factor", configured);
|
|
5663
|
+
}
|
|
5664
|
+
throw deny(gate, "stale-proof", configured);
|
|
5665
|
+
}
|
|
5666
|
+
}
|
|
5667
|
+
}
|
|
5668
|
+
if (configured.warn?.sharedDevice === "block" && context.sharedDevice === true) {
|
|
5669
|
+
throw deny(gate, "shared-device-blocked", configured);
|
|
5670
|
+
}
|
|
5671
|
+
}
|
|
5672
|
+
async function describeGate(policy, gate, context) {
|
|
5673
|
+
try {
|
|
5674
|
+
await checkGate(policy, gate, context);
|
|
5675
|
+
return { ok: true };
|
|
5676
|
+
} catch (err) {
|
|
5677
|
+
if (err instanceof PolicyDeniedError) {
|
|
5678
|
+
return { ok: false, reason: err.reason, required: err.required };
|
|
5679
|
+
}
|
|
5680
|
+
throw err;
|
|
5681
|
+
}
|
|
5682
|
+
}
|
|
5683
|
+
function countMatchingFactors(presented, requirement, now) {
|
|
5684
|
+
const freshnessMs = requirement.freshnessMs ?? DEFAULT_FRESHNESS_MS;
|
|
5685
|
+
let totalKindMatches = 0;
|
|
5686
|
+
let fresh = 0;
|
|
5687
|
+
for (const proof of presented) {
|
|
5688
|
+
if (!requirement.anyOf.includes(proof.kind)) continue;
|
|
5689
|
+
totalKindMatches += 1;
|
|
5690
|
+
const minted = proof.mintedAt ? Date.parse(proof.mintedAt) : now;
|
|
5691
|
+
if (Number.isFinite(minted) && now - minted <= freshnessMs) {
|
|
5692
|
+
fresh += 1;
|
|
5693
|
+
}
|
|
5694
|
+
}
|
|
5695
|
+
return { totalKindMatches, fresh };
|
|
5696
|
+
}
|
|
5697
|
+
function deny(gate, reason, required) {
|
|
5698
|
+
return new PolicyDeniedError(gate, reason, required);
|
|
5699
|
+
}
|
|
5700
|
+
|
|
5000
5701
|
// src/noydb.ts
|
|
5001
5702
|
var ROLE_RANK = {
|
|
5002
5703
|
client: 1,
|
|
@@ -5013,7 +5714,8 @@ function createPlaintextKeyring(userId) {
|
|
|
5013
5714
|
permissions: {},
|
|
5014
5715
|
deks: /* @__PURE__ */ new Map(),
|
|
5015
5716
|
kek: null,
|
|
5016
|
-
salt: new Uint8Array(0)
|
|
5717
|
+
salt: new Uint8Array(0),
|
|
5718
|
+
authenticators: []
|
|
5017
5719
|
};
|
|
5018
5720
|
}
|
|
5019
5721
|
var Noydb = class {
|
|
@@ -5022,6 +5724,25 @@ var Noydb = class {
|
|
|
5022
5724
|
vaultCache = /* @__PURE__ */ new Map();
|
|
5023
5725
|
keyringCache = /* @__PURE__ */ new Map();
|
|
5024
5726
|
syncEngines = /* @__PURE__ */ new Map();
|
|
5727
|
+
/**
|
|
5728
|
+
* Per-vault active session tier — defaults to `1` after a passphrase
|
|
5729
|
+
* unlock; tier-2 / tier-3 unlocks (issue #11) downgrade it. Used by
|
|
5730
|
+
* {@link checkGate} to evaluate `gate.minTier`.
|
|
5731
|
+
*/
|
|
5732
|
+
activeTier = /* @__PURE__ */ new Map();
|
|
5733
|
+
/**
|
|
5734
|
+
* Per-vault loaded policy. Cached after the first
|
|
5735
|
+
* `_meta/policy` load; replaced by `db.updatePolicy()`.
|
|
5736
|
+
*/
|
|
5737
|
+
policyCache = /* @__PURE__ */ new Map();
|
|
5738
|
+
/** Per-vault tier-3 (PIN / quick-resume) state — issue #11. */
|
|
5739
|
+
quickUnlock = new QuickUnlockStore();
|
|
5740
|
+
/**
|
|
5741
|
+
* Resolved public-envelope schema. Lazily computed once from
|
|
5742
|
+
* `NoydbOptions.publicEnvelope`; `undefined` when the developer
|
|
5743
|
+
* didn't opt in.
|
|
5744
|
+
*/
|
|
5745
|
+
publicEnvelopeSchema;
|
|
5025
5746
|
closed = false;
|
|
5026
5747
|
sessionTimer = null;
|
|
5027
5748
|
/** Per-vault policy enforcers. */
|
|
@@ -5042,6 +5763,7 @@ var Noydb = class {
|
|
|
5042
5763
|
this.txStrategy = options.txStrategy ?? NO_TX;
|
|
5043
5764
|
this.sessionStrategy = options.sessionStrategy ?? NO_SESSION;
|
|
5044
5765
|
this.syncStrategy = options.syncStrategy ?? NO_SYNC;
|
|
5766
|
+
this.publicEnvelopeSchema = resolveSchema(options.publicEnvelope);
|
|
5045
5767
|
if (options.sessionPolicy) {
|
|
5046
5768
|
this.sessionStrategy.validateSessionPolicy(options.sessionPolicy);
|
|
5047
5769
|
}
|
|
@@ -5113,6 +5835,12 @@ var Noydb = class {
|
|
|
5113
5835
|
return comp;
|
|
5114
5836
|
}
|
|
5115
5837
|
const keyring = await this.getKeyring(name);
|
|
5838
|
+
if (!this.activeTier.has(name)) {
|
|
5839
|
+
this.activeTier.set(name, 1);
|
|
5840
|
+
}
|
|
5841
|
+
if (this.options.encrypt !== false && !this.policyCache.has(name)) {
|
|
5842
|
+
await this.bootstrapPolicy(name);
|
|
5843
|
+
}
|
|
5116
5844
|
let syncEngine;
|
|
5117
5845
|
const targets = normalizeSyncTargets(this.options.sync);
|
|
5118
5846
|
if (targets.length > 0) {
|
|
@@ -5600,6 +6328,9 @@ var Noydb = class {
|
|
|
5600
6328
|
this.syncEngines.clear();
|
|
5601
6329
|
this.keyringCache.clear();
|
|
5602
6330
|
this.vaultCache.clear();
|
|
6331
|
+
this.activeTier.clear();
|
|
6332
|
+
this.policyCache.clear();
|
|
6333
|
+
this.quickUnlock.clear();
|
|
5603
6334
|
this.emitter.removeAllListeners();
|
|
5604
6335
|
this.translatorCache.clear();
|
|
5605
6336
|
this._translatorAuditLog.length = 0;
|
|
@@ -5652,6 +6383,320 @@ var Noydb = class {
|
|
|
5652
6383
|
});
|
|
5653
6384
|
return result;
|
|
5654
6385
|
}
|
|
6386
|
+
// ─── Policy gates (issue #9) ──────────────────────────────────
|
|
6387
|
+
/**
|
|
6388
|
+
* Read the active policy for a vault. Loads from `_meta/policy` on
|
|
6389
|
+
* first call; subsequent calls hit the in-memory cache. Throws
|
|
6390
|
+
* `ValidationError` if the vault has not been opened.
|
|
6391
|
+
*/
|
|
6392
|
+
async getPolicy(vault) {
|
|
6393
|
+
if (this.closed) throw new ValidationError("Instance is closed");
|
|
6394
|
+
const cached = this.policyCache.get(vault);
|
|
6395
|
+
if (cached) return cached;
|
|
6396
|
+
await this.bootstrapPolicy(vault);
|
|
6397
|
+
return this.policyCache.get(vault) ?? PERSONAL_POLICY;
|
|
6398
|
+
}
|
|
6399
|
+
/**
|
|
6400
|
+
* Replace the policy document at `_meta/policy` and update the
|
|
6401
|
+
* in-memory cache. Gated by the `enroll-user` policy (a policy
|
|
6402
|
+
* change is fundamentally a privilege-management action).
|
|
6403
|
+
*/
|
|
6404
|
+
async updatePolicy(vault, override) {
|
|
6405
|
+
if (this.closed) throw new ValidationError("Instance is closed");
|
|
6406
|
+
const current = await this.getPolicy(vault);
|
|
6407
|
+
const merged = mergePolicy(current, override);
|
|
6408
|
+
if (this.options.encrypt !== false) {
|
|
6409
|
+
await saveVaultPolicy(this.options.store, vault, merged);
|
|
6410
|
+
}
|
|
6411
|
+
this.policyCache.set(vault, merged);
|
|
6412
|
+
return merged;
|
|
6413
|
+
}
|
|
6414
|
+
/**
|
|
6415
|
+
* Evaluate a policy gate against the active session tier and the
|
|
6416
|
+
* presented factor proofs. Throws {@link PolicyDeniedError} on
|
|
6417
|
+
* denial; resolves with `void` on success.
|
|
6418
|
+
*
|
|
6419
|
+
* @param vault The vault whose policy applies.
|
|
6420
|
+
* @param gate Gate name — built-in (e.g. `'rotate-passphrase'`)
|
|
6421
|
+
* or app-defined (`app:*`).
|
|
6422
|
+
* @param presented Caller-supplied factor proofs.
|
|
6423
|
+
*/
|
|
6424
|
+
async checkGate(vault, gate, presented) {
|
|
6425
|
+
const policy = await this.getPolicy(vault);
|
|
6426
|
+
const tier = this.activeTier.get(vault) ?? 1;
|
|
6427
|
+
await checkGate(policy, gate, {
|
|
6428
|
+
activeTier: tier,
|
|
6429
|
+
...presented?.factors !== void 0 ? { factors: presented.factors } : {},
|
|
6430
|
+
...presented?.sharedDevice !== void 0 ? { sharedDevice: presented.sharedDevice } : {}
|
|
6431
|
+
});
|
|
6432
|
+
}
|
|
6433
|
+
/** Read or persist the vault policy at `_meta/policy` on first open. */
|
|
6434
|
+
async bootstrapPolicy(vault) {
|
|
6435
|
+
const onDisk = await loadVaultPolicy(this.options.store, vault);
|
|
6436
|
+
if (onDisk) {
|
|
6437
|
+
this.policyCache.set(vault, onDisk);
|
|
6438
|
+
await this.assertRecoveryEnrolled(vault, onDisk);
|
|
6439
|
+
return;
|
|
6440
|
+
}
|
|
6441
|
+
const initial = this.options.policy ? mergePolicy(PERSONAL_POLICY, this.options.policy) : PERSONAL_POLICY;
|
|
6442
|
+
await saveVaultPolicy(this.options.store, vault, initial);
|
|
6443
|
+
this.policyCache.set(vault, initial);
|
|
6444
|
+
await this.assertRecoveryEnrolled(vault, initial);
|
|
6445
|
+
}
|
|
6446
|
+
/**
|
|
6447
|
+
* Throw {@link RecoveryNotEnrolledError} when the developer
|
|
6448
|
+
* explicitly opts into strict mandatory-recovery enforcement
|
|
6449
|
+
* (`createNoydb({ requireRecovery: true })`) and no recovery
|
|
6450
|
+
* entries are persisted.
|
|
6451
|
+
*
|
|
6452
|
+
* The default behavior is lenient — `recover-passphrase` is enabled
|
|
6453
|
+
* in `PERSONAL_POLICY` but the hub does not block vault open on
|
|
6454
|
+
* missing enrollment. v1.0 will flip the default to strict; for now,
|
|
6455
|
+
* apps that want the spec-mandated check turn it on per-vault.
|
|
6456
|
+
*/
|
|
6457
|
+
async assertRecoveryEnrolled(vault, policy) {
|
|
6458
|
+
if (this.options.requireRecovery !== true) return;
|
|
6459
|
+
const gate = policy.gates["recover-passphrase"];
|
|
6460
|
+
if (gate?.enabled === false) return;
|
|
6461
|
+
const enrolled = await hasRecoveryEnrolled(this.options.store, vault);
|
|
6462
|
+
if (enrolled) return;
|
|
6463
|
+
throw new RecoveryNotEnrolledError();
|
|
6464
|
+
}
|
|
6465
|
+
/**
|
|
6466
|
+
* Internal accessor used by tier-2/tier-3 unlock paths (issue #11)
|
|
6467
|
+
* to mark the active session tier.
|
|
6468
|
+
* @internal
|
|
6469
|
+
*/
|
|
6470
|
+
_setActiveTier(vault, tier) {
|
|
6471
|
+
this.activeTier.set(vault, tier);
|
|
6472
|
+
}
|
|
6473
|
+
// ─── Tier-2 enroll / remove (issue #11) ────────────────────────
|
|
6474
|
+
/**
|
|
6475
|
+
* Add a tier-2 authenticator slot to the calling user's keyring.
|
|
6476
|
+
* Each slot independently wraps the SAME KEK under a method-specific
|
|
6477
|
+
* key — adding a slot is a constant-time keyring write.
|
|
6478
|
+
*
|
|
6479
|
+
* The wrapping ciphertext is produced by the corresponding
|
|
6480
|
+
* `@noy-db/on-*` package (e.g. `enrollPasswordAuthenticator` from
|
|
6481
|
+
* `@noy-db/on-password`); the hub persists the result.
|
|
6482
|
+
*
|
|
6483
|
+
* Gated by `enroll-authenticator`; `presented` carries any factor
|
|
6484
|
+
* proofs the active policy demands.
|
|
6485
|
+
*/
|
|
6486
|
+
async enrollAuthenticator(vault, options, presented) {
|
|
6487
|
+
await this.checkGate(vault, "enroll-authenticator", presented);
|
|
6488
|
+
const keyring = await this.getKeyring(vault);
|
|
6489
|
+
const next = await enrollAuthenticator(this.options.store, vault, keyring, options);
|
|
6490
|
+
this.keyringCache.set(vault, next);
|
|
6491
|
+
}
|
|
6492
|
+
/**
|
|
6493
|
+
* Remove a tier-2 authenticator slot. Idempotent — removing a
|
|
6494
|
+
* non-existent slot is a successful no-op. Gated by
|
|
6495
|
+
* `remove-authenticator`.
|
|
6496
|
+
*/
|
|
6497
|
+
async removeAuthenticator(vault, slotId, presented) {
|
|
6498
|
+
await this.checkGate(vault, "remove-authenticator", presented);
|
|
6499
|
+
const keyring = await this.getKeyring(vault);
|
|
6500
|
+
const next = await removeAuthenticator(this.options.store, vault, keyring, slotId);
|
|
6501
|
+
this.keyringCache.set(vault, next);
|
|
6502
|
+
}
|
|
6503
|
+
/** Read the slot list for a vault. Internal — `describeAuthConfig` (#13) consumes this. */
|
|
6504
|
+
async listAuthenticators(vault) {
|
|
6505
|
+
const keyring = await this.getKeyring(vault);
|
|
6506
|
+
return keyring.authenticators;
|
|
6507
|
+
}
|
|
6508
|
+
/**
|
|
6509
|
+
* Resolve a slot by id, then hand the wrapped-KEK ciphertext + meta
|
|
6510
|
+
* to the caller-supplied verifier. The verifier is the
|
|
6511
|
+
* `unlockWith*` function from the corresponding `@noy-db/on-*`
|
|
6512
|
+
* package, e.g. `unlockWithPassword(slot, password)`.
|
|
6513
|
+
*
|
|
6514
|
+
* On success, mark the active session tier as 2 — subsequent
|
|
6515
|
+
* `checkGate` calls see a tier-2 unlock.
|
|
6516
|
+
*/
|
|
6517
|
+
async unlockViaAuthenticator(vault, slotId, verify) {
|
|
6518
|
+
const keyring = await this.getKeyring(vault);
|
|
6519
|
+
const slot = findAuthenticator(keyring, slotId);
|
|
6520
|
+
if (!slot) {
|
|
6521
|
+
throw new ValidationError(
|
|
6522
|
+
`unlockViaAuthenticator: no slot with id "${slotId}" in vault "${vault}".`
|
|
6523
|
+
);
|
|
6524
|
+
}
|
|
6525
|
+
const unlocked = await verify(slot);
|
|
6526
|
+
this.keyringCache.set(vault, unlocked);
|
|
6527
|
+
this.activeTier.set(vault, 2);
|
|
6528
|
+
return unlocked;
|
|
6529
|
+
}
|
|
6530
|
+
// ─── Public envelope (docs/subsystems/public-envelope.md) ──────
|
|
6531
|
+
/**
|
|
6532
|
+
* Set the owner-curated public envelope for a vault. Throws
|
|
6533
|
+
* `ValidationError` if the developer did not opt the hub into
|
|
6534
|
+
* `publicEnvelope` via `NoydbOptions`, or if the input violates
|
|
6535
|
+
* the resolved schema (oversized icon, disallowed MIME, oversized
|
|
6536
|
+
* string, unknown field).
|
|
6537
|
+
*
|
|
6538
|
+
* `createdAt` is set on the first write and preserved on every
|
|
6539
|
+
* subsequent write. `updatedAt` is refreshed on every write.
|
|
6540
|
+
* `version` is monotonic — increments on every successful write.
|
|
6541
|
+
*/
|
|
6542
|
+
async setPublicEnvelope(vault, input) {
|
|
6543
|
+
if (!this.publicEnvelopeSchema) {
|
|
6544
|
+
throw new ValidationError(
|
|
6545
|
+
"setPublicEnvelope: the public-envelope feature is not enabled. Pass `publicEnvelope: true` (or a schema object) to `createNoydb`."
|
|
6546
|
+
);
|
|
6547
|
+
}
|
|
6548
|
+
validatePublicEnvelopeInput(input, this.publicEnvelopeSchema);
|
|
6549
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6550
|
+
const existing = await loadPublicEnvelope(this.options.store, vault);
|
|
6551
|
+
const next = {
|
|
6552
|
+
_noydb_public: 1,
|
|
6553
|
+
version: (existing?.version ?? 0) + 1,
|
|
6554
|
+
...existing?.createdAt !== void 0 ? { createdAt: existing.createdAt } : { createdAt: now },
|
|
6555
|
+
updatedAt: now,
|
|
6556
|
+
...input.name !== void 0 ? { name: input.name } : existing?.name !== void 0 ? { name: existing.name } : {},
|
|
6557
|
+
...input.description !== void 0 ? { description: input.description } : existing?.description !== void 0 ? { description: existing.description } : {},
|
|
6558
|
+
...input.icon !== void 0 ? { icon: input.icon } : existing?.icon !== void 0 ? { icon: existing.icon } : {},
|
|
6559
|
+
...input.defaultLocale !== void 0 ? { defaultLocale: input.defaultLocale } : existing?.defaultLocale !== void 0 ? { defaultLocale: existing.defaultLocale } : {}
|
|
6560
|
+
};
|
|
6561
|
+
await savePublicEnvelope(this.options.store, vault, next);
|
|
6562
|
+
return next;
|
|
6563
|
+
}
|
|
6564
|
+
/**
|
|
6565
|
+
* Read the public envelope for a vault. Returns `undefined` when
|
|
6566
|
+
* none has been written. Pass `locale` to resolve any locale-map
|
|
6567
|
+
* fields to plain strings; omitting `locale` returns the raw map.
|
|
6568
|
+
*
|
|
6569
|
+
* Works even when the developer didn't enable
|
|
6570
|
+
* `publicEnvelope` — reads are passive and never throw on a
|
|
6571
|
+
* missing schema (the envelope is plaintext and exists on disk
|
|
6572
|
+
* regardless).
|
|
6573
|
+
*/
|
|
6574
|
+
async getPublicEnvelope(vault, opts = {}) {
|
|
6575
|
+
return readPublicEnvelope(this.options.store, vault, opts);
|
|
6576
|
+
}
|
|
6577
|
+
// ─── Auth introspection (issue #13) ────────────────────────────
|
|
6578
|
+
/** English summary of the configured auth model. */
|
|
6579
|
+
async describeAuthConfig(vault) {
|
|
6580
|
+
return describeAuthConfig(this.options.store, vault);
|
|
6581
|
+
}
|
|
6582
|
+
/** Mermaid `flowchart TB` source for the auth graph. */
|
|
6583
|
+
async diagramAuthConfig(vault) {
|
|
6584
|
+
return diagramAuthConfig(this.options.store, vault);
|
|
6585
|
+
}
|
|
6586
|
+
/**
|
|
6587
|
+
* Per-user enrollment summary. Gated by `view-user-auth` (default:
|
|
6588
|
+
* disabled). Sanitization is allowlist-based — never renders cred
|
|
6589
|
+
* ids, password hashes, secrets, or any field outside the allowlist.
|
|
6590
|
+
*/
|
|
6591
|
+
async describeUserAuth(vault, userId, factors) {
|
|
6592
|
+
await this.checkGate(vault, "view-user-auth", factors);
|
|
6593
|
+
return describeUserAuth(this.options.store, vault, userId);
|
|
6594
|
+
}
|
|
6595
|
+
/** Bulk variant for owner dashboards. Gated by `view-user-auth`. */
|
|
6596
|
+
async describeAllUsersAuth(vault, factors) {
|
|
6597
|
+
await this.checkGate(vault, "view-user-auth", factors);
|
|
6598
|
+
return describeAllUsersAuth(this.options.store, vault);
|
|
6599
|
+
}
|
|
6600
|
+
// ─── Tier-1 change flows (issue #10) ───────────────────────────
|
|
6601
|
+
/**
|
|
6602
|
+
* Rotate the user's passphrase (user remembers old). Validates the
|
|
6603
|
+
* new phrase against the configured `passphrase` policy, runs the
|
|
6604
|
+
* `rotate-passphrase` gate, then re-derives + re-wraps every DEK.
|
|
6605
|
+
*
|
|
6606
|
+
* Tier-2 authenticator slots are dropped — each slot wraps the old
|
|
6607
|
+
* KEK and would need its derivation key to be re-presented. Re-enrol
|
|
6608
|
+
* via `db.enrollAuthenticator` after rotation. Tracked as a
|
|
6609
|
+
* v0.1.0-pre.5 limitation.
|
|
6610
|
+
*
|
|
6611
|
+
* @throws `WeakPassphraseError` on a weak new phrase.
|
|
6612
|
+
* @throws `PolicyDeniedError` when the gate denies (missing factor, …).
|
|
6613
|
+
* @throws `InvalidKeyError` when `oldPassphrase` is wrong.
|
|
6614
|
+
*/
|
|
6615
|
+
async rotatePassphrase(vault, input, factors) {
|
|
6616
|
+
await this.checkGate(vault, "rotate-passphrase", factors);
|
|
6617
|
+
const userId = this.options.user;
|
|
6618
|
+
const next = await rotatePassphrase(this.options.store, vault, userId, input);
|
|
6619
|
+
this.keyringCache.set(vault, next);
|
|
6620
|
+
}
|
|
6621
|
+
/**
|
|
6622
|
+
* Reset the passphrase using a recovery proof (user forgot the old).
|
|
6623
|
+
* v0.1.0-pre.5 supports the `'paper'` profile end-to-end; the
|
|
6624
|
+
* other three profiles throw {@link RecoveryProfileNotImplementedError}.
|
|
6625
|
+
*
|
|
6626
|
+
* Burns the used recovery entry on success.
|
|
6627
|
+
*/
|
|
6628
|
+
async recoverPassphrase(vault, input, factors) {
|
|
6629
|
+
await this.checkGate(vault, "recover-passphrase", factors);
|
|
6630
|
+
const userId = this.options.user;
|
|
6631
|
+
const next = await recoverPassphrase(this.options.store, vault, userId, input);
|
|
6632
|
+
this.keyringCache.set(vault, next);
|
|
6633
|
+
}
|
|
6634
|
+
/**
|
|
6635
|
+
* Persist a recovery enrollment. v0.1.0-pre.5 accepts the `'paper'`
|
|
6636
|
+
* profile — the developer first calls
|
|
6637
|
+
* `@noy-db/on-recovery/generateRecoveryCodeSet` to mint codes +
|
|
6638
|
+
* entries, shows the codes to the user once, then hands the entries
|
|
6639
|
+
* here.
|
|
6640
|
+
*
|
|
6641
|
+
* ```ts
|
|
6642
|
+
* import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
|
|
6643
|
+
* const { codes, entries } = await generateRecoveryCodeSet({ kek, count: 10 })
|
|
6644
|
+
* await db.enrollRecovery('acme', { profile: 'paper', entries })
|
|
6645
|
+
* showCodesToUser(codes)
|
|
6646
|
+
* ```
|
|
6647
|
+
*/
|
|
6648
|
+
async enrollRecovery(vault, enrollment) {
|
|
6649
|
+
if (enrollment.profile !== "paper") {
|
|
6650
|
+
throw new ValidationError(
|
|
6651
|
+
`enrollRecovery: only 'paper' is implemented in v0.1.0-pre.5. Profile '${enrollment.profile}' is tracked under issue #10.`
|
|
6652
|
+
);
|
|
6653
|
+
}
|
|
6654
|
+
const existing = await loadPaperRecoveryEntries(this.options.store, vault);
|
|
6655
|
+
await savePaperRecoveryEntries(this.options.store, vault, [
|
|
6656
|
+
...existing,
|
|
6657
|
+
...enrollment.entries
|
|
6658
|
+
]);
|
|
6659
|
+
}
|
|
6660
|
+
/** Read the persisted paper-recovery entries. Used by `describeAuthConfig` (#13). */
|
|
6661
|
+
async listRecoveryEntries(vault) {
|
|
6662
|
+
const paper = await loadPaperRecoveryEntries(this.options.store, vault);
|
|
6663
|
+
return { paper };
|
|
6664
|
+
}
|
|
6665
|
+
// ─── Tier-3 enroll / unlock (issue #11) ────────────────────────
|
|
6666
|
+
/**
|
|
6667
|
+
* Register a tier-3 quick-unlock state for the vault. The state is
|
|
6668
|
+
* an opaque blob produced by `@noy-db/on-pin/enrollPin` (or any
|
|
6669
|
+
* compatible primitive). It is held in memory only — never persisted
|
|
6670
|
+
* — and auto-clears when its `expiresAt` elapses.
|
|
6671
|
+
*
|
|
6672
|
+
* Gated by `rotate-unlock` (the same gate covers "set" and "rotate"
|
|
6673
|
+
* because tier-3 is a single-slot rolling secret).
|
|
6674
|
+
*/
|
|
6675
|
+
async enrollUnlock(vault, state, presented) {
|
|
6676
|
+
await this.checkGate(vault, "rotate-unlock", presented);
|
|
6677
|
+
this.quickUnlock.set(vault, state);
|
|
6678
|
+
}
|
|
6679
|
+
/**
|
|
6680
|
+
* Resume a session via the registered tier-3 state. The verifier is
|
|
6681
|
+
* `@noy-db/on-pin/resumePin` (or compatible). On success, mark the
|
|
6682
|
+
* active session tier as 3 — every operation must re-authenticate at
|
|
6683
|
+
* tier 2 to elevate.
|
|
6684
|
+
*
|
|
6685
|
+
* Returns `undefined` (caller should fall back to tier 2) when no
|
|
6686
|
+
* tier-3 state is registered.
|
|
6687
|
+
*/
|
|
6688
|
+
async unlockViaPin(vault, resume) {
|
|
6689
|
+
const state = this.quickUnlock.get(vault);
|
|
6690
|
+
if (!state) return void 0;
|
|
6691
|
+
const keyring = await resume(state);
|
|
6692
|
+
this.keyringCache.set(vault, keyring);
|
|
6693
|
+
this.activeTier.set(vault, 3);
|
|
6694
|
+
return keyring;
|
|
6695
|
+
}
|
|
6696
|
+
/** Drop the tier-3 state for a vault — explicit logout. */
|
|
6697
|
+
clearQuickUnlock(vault) {
|
|
6698
|
+
this.quickUnlock.delete(vault);
|
|
6699
|
+
}
|
|
5655
6700
|
/** Get or load the keyring for a vault. */
|
|
5656
6701
|
async getKeyring(vault) {
|
|
5657
6702
|
if (this.options.encrypt === false) {
|
|
@@ -5672,7 +6717,13 @@ var Noydb = class {
|
|
|
5672
6717
|
keyring = await loadKeyring(this.options.store, vault, this.options.user, this.options.secret);
|
|
5673
6718
|
} catch (err) {
|
|
5674
6719
|
if (err instanceof NoAccessError) {
|
|
5675
|
-
keyring = await createOwnerKeyring(
|
|
6720
|
+
keyring = await createOwnerKeyring(
|
|
6721
|
+
this.options.store,
|
|
6722
|
+
vault,
|
|
6723
|
+
this.options.user,
|
|
6724
|
+
this.options.secret,
|
|
6725
|
+
{ validate: this.options.validatePassphrase === true }
|
|
6726
|
+
);
|
|
5676
6727
|
} else {
|
|
5677
6728
|
throw err;
|
|
5678
6729
|
}
|
|
@@ -5851,30 +6902,6 @@ function shortJSON(value) {
|
|
|
5851
6902
|
if (typeof s !== "string") return "<unrepresentable>";
|
|
5852
6903
|
return s.length > 60 ? s.slice(0, 57) + "..." : s;
|
|
5853
6904
|
}
|
|
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
6905
|
export {
|
|
5879
6906
|
Aggregation,
|
|
5880
6907
|
AlreadyElevatedError,
|
|
@@ -5896,7 +6923,9 @@ export {
|
|
|
5896
6923
|
CollectionInstant,
|
|
5897
6924
|
ConflictError,
|
|
5898
6925
|
DEFAULT_CHUNK_SIZE,
|
|
6926
|
+
DEFAULT_FRESHNESS_MS,
|
|
5899
6927
|
DEFAULT_JOIN_MAX_ROWS,
|
|
6928
|
+
DEFAULT_PUBLIC_ENVELOPE_SCHEMA,
|
|
5900
6929
|
DELEGATIONS_COLLECTION,
|
|
5901
6930
|
DICT_COLLECTION_PREFIX,
|
|
5902
6931
|
DanglingReferenceError,
|
|
@@ -5931,6 +6960,7 @@ export {
|
|
|
5931
6960
|
MAGIC_LINK_CONTENT_INFO_PREFIX,
|
|
5932
6961
|
MAGIC_LINK_GRANTS_COLLECTION,
|
|
5933
6962
|
MAGIC_LINK_KEK_INFO_PREFIX,
|
|
6963
|
+
META_COLLECTION,
|
|
5934
6964
|
MissingTranslationError,
|
|
5935
6965
|
NOYDB_BACKUP_VERSION,
|
|
5936
6966
|
NOYDB_BUNDLE_FORMAT_VERSION,
|
|
@@ -5945,20 +6975,29 @@ export {
|
|
|
5945
6975
|
Noydb,
|
|
5946
6976
|
NoydbError,
|
|
5947
6977
|
PERIODS_COLLECTION,
|
|
6978
|
+
PERSONAL_POLICY,
|
|
6979
|
+
POLICY_RECORD_ID,
|
|
6980
|
+
PUBLIC_ENVELOPE_FIELDS,
|
|
6981
|
+
PUBLIC_ENVELOPE_RECORD_ID,
|
|
5948
6982
|
PathEscapeError,
|
|
5949
6983
|
PeriodClosedError,
|
|
5950
6984
|
PermissionDeniedError,
|
|
6985
|
+
PolicyDeniedError,
|
|
5951
6986
|
PolicyEnforcer,
|
|
5952
6987
|
PresenceHandle,
|
|
5953
6988
|
PrivilegeEscalationError,
|
|
5954
6989
|
Query,
|
|
6990
|
+
QuickUnlockStore,
|
|
5955
6991
|
ReadOnlyAtInstantError,
|
|
5956
6992
|
ReadOnlyError,
|
|
5957
6993
|
ReadOnlyFrameError,
|
|
6994
|
+
RecoveryNotEnrolledError,
|
|
6995
|
+
RecoveryProfileNotImplementedError,
|
|
5958
6996
|
RefIntegrityError,
|
|
5959
6997
|
RefRegistry,
|
|
5960
6998
|
RefScopeError,
|
|
5961
6999
|
ReservedCollectionNameError,
|
|
7000
|
+
STRICT_POLICY,
|
|
5962
7001
|
SYNC_CREDENTIALS_COLLECTION,
|
|
5963
7002
|
ScanBuilder,
|
|
5964
7003
|
SchemaValidationError,
|
|
@@ -5980,17 +7019,21 @@ export {
|
|
|
5980
7019
|
Vault,
|
|
5981
7020
|
VaultFrame,
|
|
5982
7021
|
VaultInstant,
|
|
7022
|
+
WeakPassphraseError,
|
|
5983
7023
|
activeSessionCount,
|
|
5984
7024
|
applyI18nLocale,
|
|
5985
7025
|
applyJoins,
|
|
5986
7026
|
applyPatch,
|
|
7027
|
+
assertStrongPassphrase,
|
|
5987
7028
|
assertTierAccess,
|
|
5988
7029
|
avg,
|
|
5989
7030
|
base64ToBuffer,
|
|
5990
7031
|
bufferToBase64,
|
|
5991
7032
|
buildLiveQuery,
|
|
5992
7033
|
buildRecipientKeyringFile,
|
|
7034
|
+
burnPaperRecoveryEntry,
|
|
5993
7035
|
canonicalJson,
|
|
7036
|
+
checkGate,
|
|
5994
7037
|
clearDevUnlock,
|
|
5995
7038
|
computePatch,
|
|
5996
7039
|
count,
|
|
@@ -6006,8 +7049,13 @@ export {
|
|
|
6006
7049
|
deleteCredential,
|
|
6007
7050
|
deriveMagicLinkContentKey,
|
|
6008
7051
|
derivePresenceKey,
|
|
7052
|
+
describeAllUsersAuth,
|
|
7053
|
+
describeAuthConfig,
|
|
7054
|
+
describeGate,
|
|
7055
|
+
describeUserAuth,
|
|
6009
7056
|
detectMagic,
|
|
6010
7057
|
detectMimeType,
|
|
7058
|
+
diagramAuthConfig,
|
|
6011
7059
|
dictCollectionName,
|
|
6012
7060
|
dictKey,
|
|
6013
7061
|
diff,
|
|
@@ -6016,6 +7064,7 @@ export {
|
|
|
6016
7064
|
enableDevUnlock,
|
|
6017
7065
|
encryptBytes,
|
|
6018
7066
|
encryptDeterministic,
|
|
7067
|
+
enrollAuthenticator,
|
|
6019
7068
|
envelopePayloadHash,
|
|
6020
7069
|
estimateEntropy,
|
|
6021
7070
|
estimateRecordBytes,
|
|
@@ -6024,6 +7073,7 @@ export {
|
|
|
6024
7073
|
evaluateFieldClause,
|
|
6025
7074
|
evaluateImportCapability,
|
|
6026
7075
|
executePlan,
|
|
7076
|
+
findAuthenticator,
|
|
6027
7077
|
formatDiff,
|
|
6028
7078
|
generateULID,
|
|
6029
7079
|
getCredential,
|
|
@@ -6031,6 +7081,7 @@ export {
|
|
|
6031
7081
|
hasExportCapability,
|
|
6032
7082
|
hasImportCapability,
|
|
6033
7083
|
hasNoydbBundleMagic,
|
|
7084
|
+
hasRecoveryEnrolled,
|
|
6034
7085
|
hashEntry,
|
|
6035
7086
|
i18nText,
|
|
6036
7087
|
isDevUnlockActive,
|
|
@@ -6039,16 +7090,23 @@ export {
|
|
|
6039
7090
|
isI18nTextDescriptor,
|
|
6040
7091
|
isMagicLinkGrantExpired,
|
|
6041
7092
|
isPreCompressed,
|
|
7093
|
+
isPublicEnvelope,
|
|
6042
7094
|
isSessionAlive,
|
|
6043
7095
|
isULID,
|
|
6044
7096
|
issueDelegation,
|
|
7097
|
+
recoverPassphrase as keyringRecoverPassphrase,
|
|
7098
|
+
rotatePassphrase as keyringRotatePassphrase,
|
|
6045
7099
|
listCredentials,
|
|
6046
7100
|
listMagicLinkGrants,
|
|
6047
7101
|
loadActiveDelegations,
|
|
6048
7102
|
loadDevUnlock,
|
|
7103
|
+
loadPaperRecoveryEntries,
|
|
7104
|
+
loadPublicEnvelope,
|
|
7105
|
+
loadVaultPolicy,
|
|
6049
7106
|
magicLinkGrantRecordId,
|
|
6050
7107
|
max,
|
|
6051
7108
|
mergeCrdtStates,
|
|
7109
|
+
mergePolicy,
|
|
6052
7110
|
min,
|
|
6053
7111
|
paddedIndex,
|
|
6054
7112
|
parseBytes,
|
|
@@ -6057,13 +7115,17 @@ export {
|
|
|
6057
7115
|
readMagicLinkGrantRecord,
|
|
6058
7116
|
readNoydbBundle,
|
|
6059
7117
|
readNoydbBundleHeader,
|
|
7118
|
+
readNoydbBundlePublicEnvelope,
|
|
6060
7119
|
readPath,
|
|
7120
|
+
readPublicEnvelope,
|
|
6061
7121
|
reduceRecords,
|
|
6062
7122
|
ref,
|
|
7123
|
+
removeAuthenticator,
|
|
6063
7124
|
resetBrotliSupportCache,
|
|
6064
7125
|
resetJoinWarnings,
|
|
6065
7126
|
resolveCrdtSnapshot,
|
|
6066
7127
|
resolveI18nText,
|
|
7128
|
+
resolveSchema as resolvePublicEnvelopeSchema,
|
|
6067
7129
|
resolveSession,
|
|
6068
7130
|
revokeAllSessions,
|
|
6069
7131
|
revokeDelegation,
|
|
@@ -6071,11 +7133,15 @@ export {
|
|
|
6071
7133
|
revokeSession,
|
|
6072
7134
|
routeStore,
|
|
6073
7135
|
runTransaction,
|
|
7136
|
+
savePaperRecoveryEntries,
|
|
7137
|
+
savePublicEnvelope,
|
|
7138
|
+
saveVaultPolicy,
|
|
6074
7139
|
sha256Hex,
|
|
6075
7140
|
sum,
|
|
6076
7141
|
unwrapMagicLinkGrant,
|
|
6077
7142
|
validateI18nTextValue,
|
|
6078
7143
|
validatePassphrase,
|
|
7144
|
+
validatePublicEnvelopeInput,
|
|
6079
7145
|
validateSchemaInput,
|
|
6080
7146
|
validateSchemaOutput,
|
|
6081
7147
|
validateSessionPolicy,
|