@noy-db/hub 0.1.0-pre.3 → 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-M2F2JAWB.js → chunk-6NPQTBZN.js} +103 -8
- package/dist/chunk-6NPQTBZN.js.map +1 -0
- package/dist/{chunk-UQFSPSWG.js → chunk-E4OOAPBZ.js} +2 -2
- package/dist/chunk-EMIGCR7X.js +39 -0
- package/dist/chunk-EMIGCR7X.js.map +1 -0
- package/dist/{chunk-EXQRC2L4.js → chunk-H3DV46AQ.js} +2 -2
- package/dist/{chunk-XHFOENR2.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-4OWFYIDQ.js → chunk-MIRZMUSQ.js} +3 -3
- package/dist/{chunk-ZRG4V3F5.js → chunk-NXUVITPB.js} +1 -1
- package/dist/chunk-NXUVITPB.js.map +1 -0
- package/dist/{chunk-5AATM2M2.js → chunk-QUDXYI4W.js} +2 -2
- package/dist/{chunk-ZLMV3TUA.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-KrKkcqD3.d.ts → dev-unlock-BgFqShBi.d.ts} +1 -1
- package/dist/{dev-unlock-CeXic1xC.d.cts → dev-unlock-qVMxG2Je.d.cts} +1 -1
- package/dist/{hash-ChfJjRjQ.d.ts → hash-BhoL7iUE.d.ts} +1 -1
- package/dist/{hash-9KO1BGxh.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-DhjMjz7L.d.cts → index-DhK_zqOO.d.ts} +39 -5
- package/dist/{index-C8kQtmOk.d.ts → index-DyRt_5vM.d.cts} +39 -5
- package/dist/index.cjs +1501 -51
- 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 +1118 -44
- package/dist/index.js.map +1 -1
- package/dist/{ledger-2NX4L7PN.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-Bfs0qr5F.d.cts → types-BpyE4o_n.d.cts} +935 -4
- package/dist/{types-BZpCZB8N.d.ts → types-Df72wWCC.d.ts} +935 -4
- package/package.json +1 -1
- package/dist/chunk-E445ICYI.js.map +0 -1
- package/dist/chunk-GJILMRPO.js.map +0 -1
- package/dist/chunk-M2F2JAWB.js.map +0 -1
- package/dist/chunk-ZRG4V3F5.js.map +0 -1
- /package/dist/{chunk-UQFSPSWG.js.map → chunk-E4OOAPBZ.js.map} +0 -0
- /package/dist/{chunk-EXQRC2L4.js.map → chunk-H3DV46AQ.js.map} +0 -0
- /package/dist/{chunk-XHFOENR2.js.map → chunk-LMKOSLJY.js.map} +0 -0
- /package/dist/{chunk-4OWFYIDQ.js.map → chunk-MIRZMUSQ.js.map} +0 -0
- /package/dist/{chunk-5AATM2M2.js.map → chunk-QUDXYI4W.js.map} +0 -0
- /package/dist/{chunk-ZLMV3TUA.js.map → chunk-QV4WLLKB.js.map} +0 -0
- /package/dist/{ledger-2NX4L7PN.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) {
|
|
@@ -5659,15 +6704,26 @@ var Noydb = class {
|
|
|
5659
6704
|
}
|
|
5660
6705
|
const cached = this.keyringCache.get(vault);
|
|
5661
6706
|
if (cached) return cached;
|
|
6707
|
+
if (this.options.getKeyring) {
|
|
6708
|
+
const keyring2 = await this.options.getKeyring(vault);
|
|
6709
|
+
this.keyringCache.set(vault, keyring2);
|
|
6710
|
+
return keyring2;
|
|
6711
|
+
}
|
|
5662
6712
|
if (!this.options.secret) {
|
|
5663
|
-
throw new ValidationError("A secret (passphrase) is required when encryption is enabled");
|
|
6713
|
+
throw new ValidationError("A secret (passphrase) or getKeyring callback is required when encryption is enabled");
|
|
5664
6714
|
}
|
|
5665
6715
|
let keyring;
|
|
5666
6716
|
try {
|
|
5667
6717
|
keyring = await loadKeyring(this.options.store, vault, this.options.user, this.options.secret);
|
|
5668
6718
|
} catch (err) {
|
|
5669
6719
|
if (err instanceof NoAccessError) {
|
|
5670
|
-
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
|
+
);
|
|
5671
6727
|
} else {
|
|
5672
6728
|
throw err;
|
|
5673
6729
|
}
|
|
@@ -5678,8 +6734,11 @@ var Noydb = class {
|
|
|
5678
6734
|
};
|
|
5679
6735
|
async function createNoydb(options) {
|
|
5680
6736
|
const encrypted = options.encrypt !== false;
|
|
5681
|
-
if (
|
|
5682
|
-
throw new ValidationError("
|
|
6737
|
+
if (options.secret && options.getKeyring) {
|
|
6738
|
+
throw new ValidationError("Provide either `secret` or `getKeyring`, not both");
|
|
6739
|
+
}
|
|
6740
|
+
if (encrypted && !options.secret && !options.getKeyring) {
|
|
6741
|
+
throw new ValidationError("A secret (passphrase) or getKeyring callback is required when encryption is enabled");
|
|
5683
6742
|
}
|
|
5684
6743
|
return new Noydb(options);
|
|
5685
6744
|
}
|
|
@@ -5843,30 +6902,6 @@ function shortJSON(value) {
|
|
|
5843
6902
|
if (typeof s !== "string") return "<unrepresentable>";
|
|
5844
6903
|
return s.length > 60 ? s.slice(0, 57) + "..." : s;
|
|
5845
6904
|
}
|
|
5846
|
-
|
|
5847
|
-
// src/validation.ts
|
|
5848
|
-
function validatePassphrase(passphrase) {
|
|
5849
|
-
if (passphrase.length < 8) {
|
|
5850
|
-
throw new ValidationError(
|
|
5851
|
-
"Passphrase too short \u2014 minimum 8 characters. Recommended: 12+ characters or a 4+ word passphrase."
|
|
5852
|
-
);
|
|
5853
|
-
}
|
|
5854
|
-
const entropy = estimateEntropy(passphrase);
|
|
5855
|
-
if (entropy < 28) {
|
|
5856
|
-
throw new ValidationError(
|
|
5857
|
-
"Passphrase too weak \u2014 too little entropy. Use a mix of uppercase, lowercase, numbers, and symbols, or use a 4+ word passphrase."
|
|
5858
|
-
);
|
|
5859
|
-
}
|
|
5860
|
-
}
|
|
5861
|
-
function estimateEntropy(passphrase) {
|
|
5862
|
-
let charsetSize = 0;
|
|
5863
|
-
if (/[a-z]/.test(passphrase)) charsetSize += 26;
|
|
5864
|
-
if (/[A-Z]/.test(passphrase)) charsetSize += 26;
|
|
5865
|
-
if (/[0-9]/.test(passphrase)) charsetSize += 10;
|
|
5866
|
-
if (/[^a-zA-Z0-9]/.test(passphrase)) charsetSize += 32;
|
|
5867
|
-
if (charsetSize === 0) charsetSize = 26;
|
|
5868
|
-
return Math.floor(passphrase.length * Math.log2(charsetSize));
|
|
5869
|
-
}
|
|
5870
6905
|
export {
|
|
5871
6906
|
Aggregation,
|
|
5872
6907
|
AlreadyElevatedError,
|
|
@@ -5888,7 +6923,9 @@ export {
|
|
|
5888
6923
|
CollectionInstant,
|
|
5889
6924
|
ConflictError,
|
|
5890
6925
|
DEFAULT_CHUNK_SIZE,
|
|
6926
|
+
DEFAULT_FRESHNESS_MS,
|
|
5891
6927
|
DEFAULT_JOIN_MAX_ROWS,
|
|
6928
|
+
DEFAULT_PUBLIC_ENVELOPE_SCHEMA,
|
|
5892
6929
|
DELEGATIONS_COLLECTION,
|
|
5893
6930
|
DICT_COLLECTION_PREFIX,
|
|
5894
6931
|
DanglingReferenceError,
|
|
@@ -5923,6 +6960,7 @@ export {
|
|
|
5923
6960
|
MAGIC_LINK_CONTENT_INFO_PREFIX,
|
|
5924
6961
|
MAGIC_LINK_GRANTS_COLLECTION,
|
|
5925
6962
|
MAGIC_LINK_KEK_INFO_PREFIX,
|
|
6963
|
+
META_COLLECTION,
|
|
5926
6964
|
MissingTranslationError,
|
|
5927
6965
|
NOYDB_BACKUP_VERSION,
|
|
5928
6966
|
NOYDB_BUNDLE_FORMAT_VERSION,
|
|
@@ -5937,20 +6975,29 @@ export {
|
|
|
5937
6975
|
Noydb,
|
|
5938
6976
|
NoydbError,
|
|
5939
6977
|
PERIODS_COLLECTION,
|
|
6978
|
+
PERSONAL_POLICY,
|
|
6979
|
+
POLICY_RECORD_ID,
|
|
6980
|
+
PUBLIC_ENVELOPE_FIELDS,
|
|
6981
|
+
PUBLIC_ENVELOPE_RECORD_ID,
|
|
5940
6982
|
PathEscapeError,
|
|
5941
6983
|
PeriodClosedError,
|
|
5942
6984
|
PermissionDeniedError,
|
|
6985
|
+
PolicyDeniedError,
|
|
5943
6986
|
PolicyEnforcer,
|
|
5944
6987
|
PresenceHandle,
|
|
5945
6988
|
PrivilegeEscalationError,
|
|
5946
6989
|
Query,
|
|
6990
|
+
QuickUnlockStore,
|
|
5947
6991
|
ReadOnlyAtInstantError,
|
|
5948
6992
|
ReadOnlyError,
|
|
5949
6993
|
ReadOnlyFrameError,
|
|
6994
|
+
RecoveryNotEnrolledError,
|
|
6995
|
+
RecoveryProfileNotImplementedError,
|
|
5950
6996
|
RefIntegrityError,
|
|
5951
6997
|
RefRegistry,
|
|
5952
6998
|
RefScopeError,
|
|
5953
6999
|
ReservedCollectionNameError,
|
|
7000
|
+
STRICT_POLICY,
|
|
5954
7001
|
SYNC_CREDENTIALS_COLLECTION,
|
|
5955
7002
|
ScanBuilder,
|
|
5956
7003
|
SchemaValidationError,
|
|
@@ -5972,17 +7019,21 @@ export {
|
|
|
5972
7019
|
Vault,
|
|
5973
7020
|
VaultFrame,
|
|
5974
7021
|
VaultInstant,
|
|
7022
|
+
WeakPassphraseError,
|
|
5975
7023
|
activeSessionCount,
|
|
5976
7024
|
applyI18nLocale,
|
|
5977
7025
|
applyJoins,
|
|
5978
7026
|
applyPatch,
|
|
7027
|
+
assertStrongPassphrase,
|
|
5979
7028
|
assertTierAccess,
|
|
5980
7029
|
avg,
|
|
5981
7030
|
base64ToBuffer,
|
|
5982
7031
|
bufferToBase64,
|
|
5983
7032
|
buildLiveQuery,
|
|
5984
7033
|
buildRecipientKeyringFile,
|
|
7034
|
+
burnPaperRecoveryEntry,
|
|
5985
7035
|
canonicalJson,
|
|
7036
|
+
checkGate,
|
|
5986
7037
|
clearDevUnlock,
|
|
5987
7038
|
computePatch,
|
|
5988
7039
|
count,
|
|
@@ -5998,8 +7049,13 @@ export {
|
|
|
5998
7049
|
deleteCredential,
|
|
5999
7050
|
deriveMagicLinkContentKey,
|
|
6000
7051
|
derivePresenceKey,
|
|
7052
|
+
describeAllUsersAuth,
|
|
7053
|
+
describeAuthConfig,
|
|
7054
|
+
describeGate,
|
|
7055
|
+
describeUserAuth,
|
|
6001
7056
|
detectMagic,
|
|
6002
7057
|
detectMimeType,
|
|
7058
|
+
diagramAuthConfig,
|
|
6003
7059
|
dictCollectionName,
|
|
6004
7060
|
dictKey,
|
|
6005
7061
|
diff,
|
|
@@ -6008,6 +7064,7 @@ export {
|
|
|
6008
7064
|
enableDevUnlock,
|
|
6009
7065
|
encryptBytes,
|
|
6010
7066
|
encryptDeterministic,
|
|
7067
|
+
enrollAuthenticator,
|
|
6011
7068
|
envelopePayloadHash,
|
|
6012
7069
|
estimateEntropy,
|
|
6013
7070
|
estimateRecordBytes,
|
|
@@ -6016,6 +7073,7 @@ export {
|
|
|
6016
7073
|
evaluateFieldClause,
|
|
6017
7074
|
evaluateImportCapability,
|
|
6018
7075
|
executePlan,
|
|
7076
|
+
findAuthenticator,
|
|
6019
7077
|
formatDiff,
|
|
6020
7078
|
generateULID,
|
|
6021
7079
|
getCredential,
|
|
@@ -6023,6 +7081,7 @@ export {
|
|
|
6023
7081
|
hasExportCapability,
|
|
6024
7082
|
hasImportCapability,
|
|
6025
7083
|
hasNoydbBundleMagic,
|
|
7084
|
+
hasRecoveryEnrolled,
|
|
6026
7085
|
hashEntry,
|
|
6027
7086
|
i18nText,
|
|
6028
7087
|
isDevUnlockActive,
|
|
@@ -6031,16 +7090,23 @@ export {
|
|
|
6031
7090
|
isI18nTextDescriptor,
|
|
6032
7091
|
isMagicLinkGrantExpired,
|
|
6033
7092
|
isPreCompressed,
|
|
7093
|
+
isPublicEnvelope,
|
|
6034
7094
|
isSessionAlive,
|
|
6035
7095
|
isULID,
|
|
6036
7096
|
issueDelegation,
|
|
7097
|
+
recoverPassphrase as keyringRecoverPassphrase,
|
|
7098
|
+
rotatePassphrase as keyringRotatePassphrase,
|
|
6037
7099
|
listCredentials,
|
|
6038
7100
|
listMagicLinkGrants,
|
|
6039
7101
|
loadActiveDelegations,
|
|
6040
7102
|
loadDevUnlock,
|
|
7103
|
+
loadPaperRecoveryEntries,
|
|
7104
|
+
loadPublicEnvelope,
|
|
7105
|
+
loadVaultPolicy,
|
|
6041
7106
|
magicLinkGrantRecordId,
|
|
6042
7107
|
max,
|
|
6043
7108
|
mergeCrdtStates,
|
|
7109
|
+
mergePolicy,
|
|
6044
7110
|
min,
|
|
6045
7111
|
paddedIndex,
|
|
6046
7112
|
parseBytes,
|
|
@@ -6049,13 +7115,17 @@ export {
|
|
|
6049
7115
|
readMagicLinkGrantRecord,
|
|
6050
7116
|
readNoydbBundle,
|
|
6051
7117
|
readNoydbBundleHeader,
|
|
7118
|
+
readNoydbBundlePublicEnvelope,
|
|
6052
7119
|
readPath,
|
|
7120
|
+
readPublicEnvelope,
|
|
6053
7121
|
reduceRecords,
|
|
6054
7122
|
ref,
|
|
7123
|
+
removeAuthenticator,
|
|
6055
7124
|
resetBrotliSupportCache,
|
|
6056
7125
|
resetJoinWarnings,
|
|
6057
7126
|
resolveCrdtSnapshot,
|
|
6058
7127
|
resolveI18nText,
|
|
7128
|
+
resolveSchema as resolvePublicEnvelopeSchema,
|
|
6059
7129
|
resolveSession,
|
|
6060
7130
|
revokeAllSessions,
|
|
6061
7131
|
revokeDelegation,
|
|
@@ -6063,11 +7133,15 @@ export {
|
|
|
6063
7133
|
revokeSession,
|
|
6064
7134
|
routeStore,
|
|
6065
7135
|
runTransaction,
|
|
7136
|
+
savePaperRecoveryEntries,
|
|
7137
|
+
savePublicEnvelope,
|
|
7138
|
+
saveVaultPolicy,
|
|
6066
7139
|
sha256Hex,
|
|
6067
7140
|
sum,
|
|
6068
7141
|
unwrapMagicLinkGrant,
|
|
6069
7142
|
validateI18nTextValue,
|
|
6070
7143
|
validatePassphrase,
|
|
7144
|
+
validatePublicEnvelopeInput,
|
|
6071
7145
|
validateSchemaInput,
|
|
6072
7146
|
validateSchemaOutput,
|
|
6073
7147
|
validateSessionPolicy,
|