@noy-db/hub 0.1.0-pre.7 → 0.1.0-pre.9
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 +2 -2
- package/dist/blobs/index.d.ts +2 -2
- package/dist/blobs/index.js +2 -2
- package/dist/bundle/index.d.cts +2 -2
- package/dist/bundle/index.d.ts +2 -2
- package/dist/bundle/index.js +3 -3
- package/dist/{chunk-KPF2HHPI.js → chunk-2CSJGFCB.js} +2 -2
- package/dist/{chunk-INSJBB5W.js → chunk-4PWAI7Q4.js} +3 -3
- package/dist/{chunk-CL37QSND.js → chunk-AVVPZ4BC.js} +2 -2
- package/dist/{chunk-FAAWLVTF.js → chunk-EXHNQEV4.js} +2 -2
- package/dist/{chunk-NZ4XCIKS.js → chunk-MDDTIZUO.js} +3 -3
- package/dist/{chunk-GILMPJXB.js → chunk-PTVMYYON.js} +2 -2
- package/dist/{chunk-N2LMZKLR.js → chunk-QAVUREFT.js} +2 -2
- package/dist/{chunk-3WCRU7TI.js → chunk-QGZRWRSL.js} +2 -2
- package/dist/{chunk-B6HF6NTZ.js → chunk-RKJ6OL7K.js} +1 -1
- package/dist/chunk-RKJ6OL7K.js.map +1 -0
- package/dist/{chunk-XCL3WP6J.js → chunk-SCZXXXU4.js} +2 -1
- package/dist/{chunk-XCL3WP6J.js.map → chunk-SCZXXXU4.js.map} +1 -1
- package/dist/{chunk-UFL4DUEV.js → chunk-VQBTTTUN.js} +1 -1
- package/dist/chunk-VQBTTTUN.js.map +1 -0
- package/dist/{chunk-6IJQ27XN.js → chunk-WDM5XGGS.js} +51 -4
- package/dist/chunk-WDM5XGGS.js.map +1 -0
- package/dist/consent/index.d.cts +2 -2
- package/dist/consent/index.d.ts +2 -2
- package/dist/{delegation-XDJCBTI2.js → delegation-2DBS2EOH.js} +2 -2
- package/dist/{dev-unlock-CcJ1qIi7.d.ts → dev-unlock-BdPp68qn.d.ts} +1 -1
- package/dist/{dev-unlock-Dk14V6lX.d.cts → dev-unlock-Da1B0TIK.d.cts} +1 -1
- package/dist/{hash-h_2U3TFb.d.cts → hash-BEfzPKwo.d.cts} +1 -1
- package/dist/{hash-1Xsqx1jl.d.ts → hash-lsoL3eEW.d.ts} +1 -1
- package/dist/history/index.cjs.map +1 -1
- package/dist/history/index.d.cts +3 -3
- package/dist/history/index.d.ts +3 -3
- package/dist/history/index.js +2 -2
- package/dist/i18n/index.cjs +11 -0
- package/dist/i18n/index.cjs.map +1 -1
- package/dist/i18n/index.d.cts +2 -2
- package/dist/i18n/index.d.ts +2 -2
- package/dist/i18n/index.js +3 -3
- package/dist/{index-DZn6Yick.d.ts → index-8QDuznDr.d.ts} +1 -1
- package/dist/{index-Cvb0efA_.d.cts → index-CywCC1qZ.d.cts} +1 -1
- package/dist/index.cjs +590 -59
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +548 -74
- package/dist/index.js.map +1 -1
- package/dist/{ledger-5V67MAIL.js → ledger-QZTTHQAQ.js} +3 -3
- package/dist/periods/index.cjs.map +1 -1
- package/dist/periods/index.d.cts +2 -2
- package/dist/periods/index.d.ts +2 -2
- package/dist/periods/index.js +3 -3
- package/dist/{public-envelope-DFJZHXVH.js → public-envelope-6JTACYJV.js} +3 -3
- package/dist/session/index.cjs.map +1 -1
- package/dist/session/index.d.cts +3 -3
- package/dist/session/index.d.ts +3 -3
- package/dist/session/index.js +1 -1
- package/dist/shadow/index.d.cts +2 -2
- package/dist/shadow/index.d.ts +2 -2
- package/dist/store/index.d.cts +2 -2
- package/dist/store/index.d.ts +2 -2
- package/dist/sync/index.cjs.map +1 -1
- package/dist/sync/index.d.cts +1 -1
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.js +2 -2
- package/dist/team/index.cjs +11 -0
- package/dist/team/index.cjs.map +1 -1
- package/dist/team/index.d.cts +2 -2
- package/dist/team/index.d.ts +2 -2
- package/dist/team/index.js +4 -4
- package/dist/tx/index.d.cts +2 -2
- package/dist/tx/index.d.ts +2 -2
- package/dist/{types-D3QLmhlk.d.cts → types-Bnb82f5R.d.cts} +818 -74
- package/dist/{types-D-6bmD2c.d.ts → types-Bo7NSXJr.d.ts} +818 -74
- package/package.json +1 -1
- package/dist/chunk-6IJQ27XN.js.map +0 -1
- package/dist/chunk-B6HF6NTZ.js.map +0 -1
- package/dist/chunk-UFL4DUEV.js.map +0 -1
- /package/dist/{chunk-KPF2HHPI.js.map → chunk-2CSJGFCB.js.map} +0 -0
- /package/dist/{chunk-INSJBB5W.js.map → chunk-4PWAI7Q4.js.map} +0 -0
- /package/dist/{chunk-CL37QSND.js.map → chunk-AVVPZ4BC.js.map} +0 -0
- /package/dist/{chunk-FAAWLVTF.js.map → chunk-EXHNQEV4.js.map} +0 -0
- /package/dist/{chunk-NZ4XCIKS.js.map → chunk-MDDTIZUO.js.map} +0 -0
- /package/dist/{chunk-GILMPJXB.js.map → chunk-PTVMYYON.js.map} +0 -0
- /package/dist/{chunk-N2LMZKLR.js.map → chunk-QAVUREFT.js.map} +0 -0
- /package/dist/{chunk-3WCRU7TI.js.map → chunk-QGZRWRSL.js.map} +0 -0
- /package/dist/{delegation-XDJCBTI2.js.map → delegation-2DBS2EOH.js.map} +0 -0
- /package/dist/{ledger-5V67MAIL.js.map → ledger-QZTTHQAQ.js.map} +0 -0
- /package/dist/{public-envelope-DFJZHXVH.js.map → public-envelope-6JTACYJV.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
issueDelegation,
|
|
12
12
|
loadActiveDelegations,
|
|
13
13
|
revokeDelegation
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-SCZXXXU4.js";
|
|
15
15
|
import {
|
|
16
16
|
LazyQuery,
|
|
17
17
|
decodeIdxId,
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
readNoydbBundlePublicEnvelope,
|
|
32
32
|
resetBrotliSupportCache,
|
|
33
33
|
writeNoydbBundle
|
|
34
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-EXHNQEV4.js";
|
|
35
35
|
import {
|
|
36
36
|
PUBLIC_ENVELOPE_RECORD_ID,
|
|
37
37
|
isPublicEnvelope,
|
|
@@ -39,13 +39,13 @@ import {
|
|
|
39
39
|
readPublicEnvelope,
|
|
40
40
|
savePublicEnvelope,
|
|
41
41
|
validatePublicEnvelopeInput
|
|
42
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-PTVMYYON.js";
|
|
43
43
|
import {
|
|
44
44
|
CONSENT_AUDIT_COLLECTION
|
|
45
45
|
} from "./chunk-M62XNWRA.js";
|
|
46
46
|
import {
|
|
47
47
|
PERIODS_COLLECTION
|
|
48
|
-
} from "./chunk-
|
|
48
|
+
} from "./chunk-QGZRWRSL.js";
|
|
49
49
|
import "./chunk-UF3BUNQZ.js";
|
|
50
50
|
import {
|
|
51
51
|
CollectionFrame,
|
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
isI18nTextDescriptor,
|
|
70
70
|
resolveI18nText,
|
|
71
71
|
validateI18nTextValue
|
|
72
|
-
} from "./chunk-
|
|
72
|
+
} from "./chunk-MDDTIZUO.js";
|
|
73
73
|
import {
|
|
74
74
|
createBundleStore,
|
|
75
75
|
routeStore,
|
|
@@ -89,12 +89,12 @@ import {
|
|
|
89
89
|
getCredential,
|
|
90
90
|
listCredentials,
|
|
91
91
|
putCredential
|
|
92
|
-
} from "./chunk-
|
|
92
|
+
} from "./chunk-4PWAI7Q4.js";
|
|
93
93
|
import {
|
|
94
94
|
PresenceHandle,
|
|
95
95
|
SyncEngine,
|
|
96
96
|
SyncTransaction
|
|
97
|
-
} from "./chunk-
|
|
97
|
+
} from "./chunk-AVVPZ4BC.js";
|
|
98
98
|
import {
|
|
99
99
|
USER_ENVELOPE_COLLECTION,
|
|
100
100
|
USER_ENVELOPE_MAX_BYTES,
|
|
@@ -123,8 +123,9 @@ import {
|
|
|
123
123
|
revoke,
|
|
124
124
|
rotateKeys,
|
|
125
125
|
saveUserEnvelope,
|
|
126
|
+
updateKeyringIdentity,
|
|
126
127
|
validatePassphrase
|
|
127
|
-
} from "./chunk-
|
|
128
|
+
} from "./chunk-WDM5XGGS.js";
|
|
128
129
|
import {
|
|
129
130
|
BUNDLE_STORE_POLICY,
|
|
130
131
|
INDEXED_STORE_POLICY,
|
|
@@ -144,7 +145,7 @@ import {
|
|
|
144
145
|
revokeAllSessions,
|
|
145
146
|
revokeSession,
|
|
146
147
|
validateSessionPolicy
|
|
147
|
-
} from "./chunk-
|
|
148
|
+
} from "./chunk-VQBTTTUN.js";
|
|
148
149
|
import {
|
|
149
150
|
generateULID,
|
|
150
151
|
isULID
|
|
@@ -161,7 +162,7 @@ import {
|
|
|
161
162
|
LedgerStore,
|
|
162
163
|
applyPatch,
|
|
163
164
|
computePatch
|
|
164
|
-
} from "./chunk-
|
|
165
|
+
} from "./chunk-QAVUREFT.js";
|
|
165
166
|
import {
|
|
166
167
|
canonicalJson,
|
|
167
168
|
envelopePayloadHash,
|
|
@@ -216,14 +217,14 @@ import {
|
|
|
216
217
|
detectMimeType,
|
|
217
218
|
isPreCompressed,
|
|
218
219
|
runCompaction
|
|
219
|
-
} from "./chunk-
|
|
220
|
+
} from "./chunk-2CSJGFCB.js";
|
|
220
221
|
import {
|
|
221
222
|
NOYDB_BACKUP_VERSION,
|
|
222
223
|
NOYDB_FORMAT_VERSION,
|
|
223
224
|
NOYDB_KEYRING_VERSION,
|
|
224
225
|
NOYDB_SYNC_VERSION,
|
|
225
226
|
createStore
|
|
226
|
-
} from "./chunk-
|
|
227
|
+
} from "./chunk-RKJ6OL7K.js";
|
|
227
228
|
import {
|
|
228
229
|
base64ToBuffer,
|
|
229
230
|
bufferToBase64,
|
|
@@ -434,18 +435,58 @@ async function enrollAuthenticator(store, vault, keyring, options) {
|
|
|
434
435
|
`enrollAuthenticator: slot id "${options.id}" already exists in vault "${vault}". Remove the slot first or pick a unique id.`
|
|
435
436
|
);
|
|
436
437
|
}
|
|
437
|
-
const
|
|
438
|
+
const base = {
|
|
438
439
|
id: options.id,
|
|
439
440
|
method: options.method,
|
|
440
441
|
enrolled_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
441
442
|
enrolled_via_tier: options.enrolled_via_tier ?? 1,
|
|
442
|
-
wrapped_kek: options.wrapped_kek,
|
|
443
443
|
meta: options.meta
|
|
444
444
|
};
|
|
445
|
+
const slot = options.wrapKind === "deks" ? {
|
|
446
|
+
...base,
|
|
447
|
+
wrapKind: "deks",
|
|
448
|
+
wrapped_deks: options.wrapped_deks,
|
|
449
|
+
iv: options.iv
|
|
450
|
+
} : {
|
|
451
|
+
...base,
|
|
452
|
+
wrapped_kek: options.wrapped_kek
|
|
453
|
+
};
|
|
445
454
|
const next = appendSlot(keyring, slot);
|
|
446
455
|
await persistKeyring(store, vault, next);
|
|
447
456
|
return next;
|
|
448
457
|
}
|
|
458
|
+
async function updateAuthenticator(store, vault, keyring, slotId, options) {
|
|
459
|
+
if (options.meta === void 0) {
|
|
460
|
+
throw new ValidationError(
|
|
461
|
+
`updateAuthenticator: at least one of meta must be provided (slotId: "${slotId}").`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
const idx = keyring.authenticators.findIndex((a) => a.id === slotId);
|
|
465
|
+
if (idx === -1) {
|
|
466
|
+
throw new NoAccessError(
|
|
467
|
+
`updateAuthenticator: slot "${slotId}" not found in vault "${vault}".`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const existing = keyring.authenticators[idx];
|
|
471
|
+
const mergedMeta = { ...existing.meta };
|
|
472
|
+
for (const [k, v] of Object.entries(options.meta)) {
|
|
473
|
+
if (v === void 0) continue;
|
|
474
|
+
if (v === null) {
|
|
475
|
+
delete mergedMeta[k];
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
mergedMeta[k] = v;
|
|
479
|
+
}
|
|
480
|
+
const next = { ...existing, meta: mergedMeta };
|
|
481
|
+
const nextSlots = [...keyring.authenticators];
|
|
482
|
+
nextSlots[idx] = next;
|
|
483
|
+
const nextKeyring = {
|
|
484
|
+
...keyring,
|
|
485
|
+
authenticators: nextSlots
|
|
486
|
+
};
|
|
487
|
+
await persistKeyring(store, vault, nextKeyring);
|
|
488
|
+
return nextKeyring;
|
|
489
|
+
}
|
|
449
490
|
async function removeAuthenticator(store, vault, keyring, slotId) {
|
|
450
491
|
const filtered = keyring.authenticators.filter((a) => a.id !== slotId);
|
|
451
492
|
if (filtered.length === keyring.authenticators.length) {
|
|
@@ -545,51 +586,38 @@ var RecoveryProfileNotImplementedError = class extends NoydbError {
|
|
|
545
586
|
}
|
|
546
587
|
};
|
|
547
588
|
|
|
548
|
-
// src/team/
|
|
549
|
-
var
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
589
|
+
// src/team/wrapped-deks.ts
|
|
590
|
+
var PBKDF2_ITERATIONS = 6e5;
|
|
591
|
+
var SALT_BYTES = 32;
|
|
592
|
+
var IV_BYTES = 12;
|
|
593
|
+
var subtle = globalThis.crypto.subtle;
|
|
594
|
+
async function mintWrappedDeksBlob(deks, credential) {
|
|
595
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
|
596
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
597
|
+
const wrappingKey = await deriveWrappingKey(credential, salt);
|
|
598
|
+
const exported = {};
|
|
599
|
+
for (const [coll, dek] of deks) {
|
|
600
|
+
const raw = await subtle.exportKey("raw", dek);
|
|
601
|
+
exported[coll] = bytesToBase64(new Uint8Array(raw));
|
|
559
602
|
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
571
|
-
_iv: "",
|
|
572
|
-
_data: JSON.stringify(doc)
|
|
603
|
+
const plaintext = new TextEncoder().encode(JSON.stringify({ deks: exported }));
|
|
604
|
+
const ciphertext = await subtle.encrypt(
|
|
605
|
+
{ name: "AES-GCM", iv },
|
|
606
|
+
wrappingKey,
|
|
607
|
+
plaintext
|
|
608
|
+
);
|
|
609
|
+
return {
|
|
610
|
+
salt: bytesToBase64(salt),
|
|
611
|
+
iv: bytesToBase64(iv),
|
|
612
|
+
wrappedDeks: bytesToBase64(new Uint8Array(ciphertext))
|
|
573
613
|
};
|
|
574
|
-
await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
|
|
575
|
-
}
|
|
576
|
-
async function burnPaperRecoveryEntry(store, vault, codeId) {
|
|
577
|
-
const entries = await loadPaperRecoveryEntries(store, vault);
|
|
578
|
-
const remaining = entries.filter((e) => e.codeId !== codeId);
|
|
579
|
-
await savePaperRecoveryEntries(store, vault, remaining);
|
|
580
|
-
}
|
|
581
|
-
async function hasRecoveryEnrolled(store, vault) {
|
|
582
|
-
const paper = await loadPaperRecoveryEntries(store, vault);
|
|
583
|
-
return paper.length > 0;
|
|
584
614
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
async function unwrapDeksFromPaperEntry(entry, code) {
|
|
588
|
-
const wrappingKey = await deriveRecoveryWrappingKey(code, base64ToBytes(entry.salt));
|
|
615
|
+
async function unwrapDeksFromBlob(blob, credential) {
|
|
616
|
+
const wrappingKey = await deriveWrappingKey(credential, base64ToBytes(blob.salt));
|
|
589
617
|
const plaintext = await subtle.decrypt(
|
|
590
|
-
{ name: "AES-GCM", iv: base64ToBytes(
|
|
618
|
+
{ name: "AES-GCM", iv: base64ToBytes(blob.iv) },
|
|
591
619
|
wrappingKey,
|
|
592
|
-
base64ToBytes(
|
|
620
|
+
base64ToBytes(blob.wrappedDeks)
|
|
593
621
|
);
|
|
594
622
|
const parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
|
595
623
|
const deks = /* @__PURE__ */ new Map();
|
|
@@ -606,10 +634,10 @@ async function unwrapDeksFromPaperEntry(entry, code) {
|
|
|
606
634
|
}
|
|
607
635
|
return deks;
|
|
608
636
|
}
|
|
609
|
-
async function
|
|
637
|
+
async function deriveWrappingKey(credential, salt) {
|
|
610
638
|
const ikm = await subtle.importKey(
|
|
611
639
|
"raw",
|
|
612
|
-
new TextEncoder().encode(
|
|
640
|
+
new TextEncoder().encode(credential),
|
|
613
641
|
"PBKDF2",
|
|
614
642
|
false,
|
|
615
643
|
["deriveKey"]
|
|
@@ -618,7 +646,7 @@ async function deriveRecoveryWrappingKey(code, salt) {
|
|
|
618
646
|
{
|
|
619
647
|
name: "PBKDF2",
|
|
620
648
|
salt,
|
|
621
|
-
iterations:
|
|
649
|
+
iterations: PBKDF2_ITERATIONS,
|
|
622
650
|
hash: "SHA-256"
|
|
623
651
|
},
|
|
624
652
|
ikm,
|
|
@@ -627,6 +655,11 @@ async function deriveRecoveryWrappingKey(code, salt) {
|
|
|
627
655
|
["encrypt", "decrypt"]
|
|
628
656
|
);
|
|
629
657
|
}
|
|
658
|
+
function bytesToBase64(b) {
|
|
659
|
+
let s = "";
|
|
660
|
+
for (const x of b) s += String.fromCharCode(x);
|
|
661
|
+
return btoa(s);
|
|
662
|
+
}
|
|
630
663
|
function base64ToBytes(b64) {
|
|
631
664
|
const s = atob(b64);
|
|
632
665
|
const out = new Uint8Array(s.length);
|
|
@@ -634,6 +667,55 @@ function base64ToBytes(b64) {
|
|
|
634
667
|
return out;
|
|
635
668
|
}
|
|
636
669
|
|
|
670
|
+
// src/team/recovery.ts
|
|
671
|
+
var PAPER_DOC_ID = "recovery-paper";
|
|
672
|
+
async function loadPaperRecoveryEntries(store, vault) {
|
|
673
|
+
const env = await store.get(vault, "_meta", PAPER_DOC_ID);
|
|
674
|
+
if (!env) return [];
|
|
675
|
+
try {
|
|
676
|
+
const doc = JSON.parse(env._data);
|
|
677
|
+
if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
|
|
678
|
+
return doc.entries;
|
|
679
|
+
} catch {
|
|
680
|
+
return [];
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
async function savePaperRecoveryEntries(store, vault, entries) {
|
|
684
|
+
const doc = {
|
|
685
|
+
_noydb_recovery: 1,
|
|
686
|
+
profile: "paper",
|
|
687
|
+
entries
|
|
688
|
+
};
|
|
689
|
+
const envelope = {
|
|
690
|
+
_noydb: NOYDB_FORMAT_VERSION,
|
|
691
|
+
_v: 1,
|
|
692
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
693
|
+
_iv: "",
|
|
694
|
+
_data: JSON.stringify(doc)
|
|
695
|
+
};
|
|
696
|
+
await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
|
|
697
|
+
}
|
|
698
|
+
async function burnPaperRecoveryEntry(store, vault, codeId) {
|
|
699
|
+
const entries = await loadPaperRecoveryEntries(store, vault);
|
|
700
|
+
const remaining = entries.filter((e) => e.codeId !== codeId);
|
|
701
|
+
await savePaperRecoveryEntries(store, vault, remaining);
|
|
702
|
+
}
|
|
703
|
+
async function hasRecoveryEnrolled(store, vault) {
|
|
704
|
+
const paper = await loadPaperRecoveryEntries(store, vault);
|
|
705
|
+
return paper.length > 0;
|
|
706
|
+
}
|
|
707
|
+
async function mintPaperRecoveryEntry(deks, code, codeId) {
|
|
708
|
+
const blob = await mintWrappedDeksBlob(deks, code);
|
|
709
|
+
return {
|
|
710
|
+
...blob,
|
|
711
|
+
codeId,
|
|
712
|
+
enrolledAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
async function unwrapDeksFromPaperEntry(entry, code) {
|
|
716
|
+
return unwrapDeksFromBlob(entry, code);
|
|
717
|
+
}
|
|
718
|
+
|
|
637
719
|
// src/team/rotate-recover.ts
|
|
638
720
|
async function rotatePassphrase(store, vault, userId, input) {
|
|
639
721
|
if (!input.allowWeakPassphrase) {
|
|
@@ -656,14 +738,53 @@ async function rotatePassphrase(store, vault, userId, input) {
|
|
|
656
738
|
for (const [coll, dek] of deks) {
|
|
657
739
|
wrappedDeks[coll] = await wrapKey(dek, newKek);
|
|
658
740
|
}
|
|
741
|
+
const oldSlots = file.authenticators ?? [];
|
|
742
|
+
const newSlots = [];
|
|
743
|
+
if (input.slotCeremonies && oldSlots.length > 0) {
|
|
744
|
+
for (const oldSlot of oldSlots) {
|
|
745
|
+
const ceremony = input.slotCeremonies[oldSlot.id];
|
|
746
|
+
if (!ceremony) continue;
|
|
747
|
+
const result = await ceremony({ newKek, newDeks: deks, oldSlot });
|
|
748
|
+
if (result.id !== oldSlot.id) {
|
|
749
|
+
throw new ValidationError(
|
|
750
|
+
`slotCeremonies['${oldSlot.id}'] returned id="${result.id}". The id must match the rotated slot \u2014 a ceremony cannot change a slot's identity.`
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
if (result.method !== oldSlot.method) {
|
|
754
|
+
throw new ValidationError(
|
|
755
|
+
`slotCeremonies['${oldSlot.id}'] returned method="${result.method}", expected "${oldSlot.method}". The method must match the rotated slot \u2014 a ceremony cannot change the auth method (e.g. webauthn \u2192 password) under cover of rotation.`
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
const baseFields = {
|
|
759
|
+
id: result.id,
|
|
760
|
+
method: result.method,
|
|
761
|
+
// Preserve original enrolled_at — rotation is rewrapping, not
|
|
762
|
+
// re-enrollment. The slot's enrolment timestamp tracks when
|
|
763
|
+
// the user originally added the slot, not when it was last
|
|
764
|
+
// rewrapped. Forensics consumers reading enrolled_at are
|
|
765
|
+
// tracking the slot's ORIGIN, not its CURRENT wrapping.
|
|
766
|
+
enrolled_at: oldSlot.enrolled_at,
|
|
767
|
+
enrolled_via_tier: result.enrolled_via_tier ?? oldSlot.enrolled_via_tier,
|
|
768
|
+
meta: result.meta
|
|
769
|
+
};
|
|
770
|
+
const newSlot = result.wrapKind === "deks" ? {
|
|
771
|
+
...baseFields,
|
|
772
|
+
wrapKind: "deks",
|
|
773
|
+
wrapped_deks: result.wrapped_deks,
|
|
774
|
+
iv: result.iv
|
|
775
|
+
} : {
|
|
776
|
+
...baseFields,
|
|
777
|
+
wrapped_kek: result.wrapped_kek
|
|
778
|
+
};
|
|
779
|
+
newSlots.push(newSlot);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
659
782
|
const next = {
|
|
660
783
|
...file,
|
|
661
784
|
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
662
785
|
deks: wrappedDeks,
|
|
663
786
|
salt: bufferToBase64(newSalt),
|
|
664
|
-
|
|
665
|
-
// re-enrols afterwards via `db.enrollAuthenticator`.
|
|
666
|
-
authenticators: []
|
|
787
|
+
authenticators: newSlots
|
|
667
788
|
};
|
|
668
789
|
await writeKeyringFile(store, vault, userId, next);
|
|
669
790
|
return {
|
|
@@ -674,7 +795,7 @@ async function rotatePassphrase(store, vault, userId, input) {
|
|
|
674
795
|
deks,
|
|
675
796
|
kek: newKek,
|
|
676
797
|
salt: newSalt,
|
|
677
|
-
authenticators:
|
|
798
|
+
authenticators: newSlots,
|
|
678
799
|
...file.export_capability !== void 0 && { exportCapability: file.export_capability },
|
|
679
800
|
...file.import_capability !== void 0 && { importCapability: file.import_capability }
|
|
680
801
|
};
|
|
@@ -807,6 +928,17 @@ var UserApi = class {
|
|
|
807
928
|
* the envelope on first call. Optimistic-concurrency safe — a stale
|
|
808
929
|
* `_v` (parallel writer on another device) throws `ConflictError`.
|
|
809
930
|
*
|
|
931
|
+
* Patch semantics (#57):
|
|
932
|
+
* - `undefined` (or omitted key) — skip; existing value preserved
|
|
933
|
+
* - `null` — delete the field from the merged result
|
|
934
|
+
* - any other value — overwrite (deep-merge for plain objects,
|
|
935
|
+
* replace for primitives / arrays)
|
|
936
|
+
*
|
|
937
|
+
* To clear a field, pass `null` rather than `undefined`. Callers
|
|
938
|
+
* with shape `T = string | null` where `null` is a meaningful value
|
|
939
|
+
* should use `setMe` for that specific field instead — `null` here
|
|
940
|
+
* always means delete.
|
|
941
|
+
*
|
|
810
942
|
* Gated by the `edit-own-profile` policy gate (default `minTier: 3`).
|
|
811
943
|
* Pass `presented` to satisfy tightened policies that require a
|
|
812
944
|
* factor proof (e.g. STRICT_POLICY's TOTP requirement).
|
|
@@ -978,9 +1110,17 @@ function deepMerge(source, patch) {
|
|
|
978
1110
|
}
|
|
979
1111
|
const out = { ...source };
|
|
980
1112
|
for (const [key, patchVal] of Object.entries(patch)) {
|
|
1113
|
+
if (patchVal === void 0) {
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
if (patchVal === null) {
|
|
1117
|
+
delete out[key];
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
981
1120
|
const sourceVal = source[key];
|
|
982
|
-
if (isPlainObject(
|
|
983
|
-
|
|
1121
|
+
if (isPlainObject(patchVal)) {
|
|
1122
|
+
const recurseSource = isPlainObject(sourceVal) ? sourceVal : {};
|
|
1123
|
+
out[key] = deepMerge(recurseSource, patchVal);
|
|
984
1124
|
} else {
|
|
985
1125
|
out[key] = patchVal;
|
|
986
1126
|
}
|
|
@@ -1148,6 +1288,70 @@ function sanitizeId(s) {
|
|
|
1148
1288
|
return s.replace(/[^a-zA-Z0-9]/g, "_");
|
|
1149
1289
|
}
|
|
1150
1290
|
|
|
1291
|
+
// src/team/peer-recover.ts
|
|
1292
|
+
var ADMIN_RECOVERABLE_TARGETS = ["operator", "viewer", "client", "admin"];
|
|
1293
|
+
function canRecover(callerRole, targetRole) {
|
|
1294
|
+
if (callerRole === "owner") return true;
|
|
1295
|
+
if (callerRole === "admin") return ADMIN_RECOVERABLE_TARGETS.includes(targetRole);
|
|
1296
|
+
return false;
|
|
1297
|
+
}
|
|
1298
|
+
async function recoverUser(store, vault, callerKeyring, options) {
|
|
1299
|
+
const env = await store.get(vault, "_keyring", options.userId);
|
|
1300
|
+
if (!env) {
|
|
1301
|
+
throw new NoAccessError(
|
|
1302
|
+
`recoverUser: user "${options.userId}" has no keyring in vault "${vault}".`
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
const target = JSON.parse(env._data);
|
|
1306
|
+
const targetRole = options.role ?? target.role;
|
|
1307
|
+
if (!canRecover(callerKeyring.role, targetRole)) {
|
|
1308
|
+
throw new PermissionDeniedError(
|
|
1309
|
+
`Role "${callerKeyring.role}" cannot recover role "${targetRole}"`
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
if (!canRecover(callerKeyring.role, target.role)) {
|
|
1313
|
+
throw new PermissionDeniedError(
|
|
1314
|
+
`Role "${callerKeyring.role}" cannot recover role "${target.role}"`
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
for (const coll of Object.keys(target.deks)) {
|
|
1318
|
+
if (!callerKeyring.deks.has(coll)) {
|
|
1319
|
+
throw new PrivilegeEscalationError(coll);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
if (options.validatePassphrase && !options.allowWeakPassphrase) {
|
|
1323
|
+
assertStrongPassphrase(options.passphrase, options.passphrasePolicy);
|
|
1324
|
+
}
|
|
1325
|
+
const newSalt = generateSalt();
|
|
1326
|
+
const newKek = await deriveKey(options.passphrase, newSalt);
|
|
1327
|
+
const wrappedDeks = {};
|
|
1328
|
+
for (const coll of Object.keys(target.deks)) {
|
|
1329
|
+
const callerDek = callerKeyring.deks.get(coll);
|
|
1330
|
+
if (!callerDek) {
|
|
1331
|
+
throw new PrivilegeEscalationError(coll);
|
|
1332
|
+
}
|
|
1333
|
+
wrappedDeks[coll] = await wrapKey(callerDek, newKek);
|
|
1334
|
+
}
|
|
1335
|
+
const next = {
|
|
1336
|
+
...target,
|
|
1337
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
1338
|
+
role: targetRole,
|
|
1339
|
+
display_name: options.displayName ?? target.display_name,
|
|
1340
|
+
deks: wrappedDeks,
|
|
1341
|
+
salt: bufferToBase64(newSalt),
|
|
1342
|
+
granted_by: callerKeyring.userId,
|
|
1343
|
+
authenticators: []
|
|
1344
|
+
};
|
|
1345
|
+
const envelope = {
|
|
1346
|
+
_noydb: 1,
|
|
1347
|
+
_v: 1,
|
|
1348
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1349
|
+
_iv: "",
|
|
1350
|
+
_data: JSON.stringify(next)
|
|
1351
|
+
};
|
|
1352
|
+
await store.put(vault, "_keyring", options.userId, envelope);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1151
1355
|
// src/crdt/strategy.ts
|
|
1152
1356
|
var NOT_ENABLED = new Error(
|
|
1153
1357
|
'CRDT mode requires the CRDT strategy. Import `{ withCrdt }` from "@noy-db/hub/crdt" and pass it to `createNoydb({ crdtStrategy: withCrdt() })`.'
|
|
@@ -4804,7 +5008,12 @@ var Vault = class {
|
|
|
4804
5008
|
* collection.
|
|
4805
5009
|
*/
|
|
4806
5010
|
async delegate(opts) {
|
|
4807
|
-
const { issueDelegation: issueDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-
|
|
5011
|
+
const { issueDelegation: issueDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-2DBS2EOH.js");
|
|
5012
|
+
if (!this.keyring.kek) {
|
|
5013
|
+
throw new ValidationError(
|
|
5014
|
+
"issueDelegation: keyring.kek is null \u2014 issuing a delegation requires a tier-1 unlock. Re-authenticate at tier 1 (passphrase) first."
|
|
5015
|
+
);
|
|
5016
|
+
}
|
|
4808
5017
|
const targetKek = this.keyring.kek;
|
|
4809
5018
|
const delegationsDek = await this.getDEK(DELEGATIONS_COLLECTION2);
|
|
4810
5019
|
return issueDelegation2(
|
|
@@ -4821,7 +5030,7 @@ var Vault = class {
|
|
|
4821
5030
|
* if the id does not exist.
|
|
4822
5031
|
*/
|
|
4823
5032
|
async revokeDelegation(id) {
|
|
4824
|
-
const { revokeDelegation: revokeDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-
|
|
5033
|
+
const { revokeDelegation: revokeDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-2DBS2EOH.js");
|
|
4825
5034
|
await revokeDelegation2(this.adapter, this.name, id);
|
|
4826
5035
|
void DELEGATIONS_COLLECTION2;
|
|
4827
5036
|
}
|
|
@@ -5221,7 +5430,7 @@ var Vault = class {
|
|
|
5221
5430
|
* @see docs/subsystems/public-envelope.md
|
|
5222
5431
|
*/
|
|
5223
5432
|
async getPublicEnvelope(opts = {}) {
|
|
5224
|
-
const { readPublicEnvelope: readPublicEnvelope2 } = await import("./public-envelope-
|
|
5433
|
+
const { readPublicEnvelope: readPublicEnvelope2 } = await import("./public-envelope-6JTACYJV.js");
|
|
5225
5434
|
return readPublicEnvelope2(this.adapter, this.name, opts);
|
|
5226
5435
|
}
|
|
5227
5436
|
/**
|
|
@@ -5795,7 +6004,23 @@ var PERSONAL_POLICY = Object.freeze({
|
|
|
5795
6004
|
gates: {
|
|
5796
6005
|
"rotate-passphrase": {
|
|
5797
6006
|
minTier: 1,
|
|
5798
|
-
|
|
6007
|
+
// Any second factor satisfies the gate — off-device kinds (TOTP,
|
|
6008
|
+
// email-OTP, paper recovery, roaming WebAuthn) are the strongest;
|
|
6009
|
+
// platform-bound kinds (platform WebAuthn, password, PIN) are
|
|
6010
|
+
// accepted because requiring "something off-device" is overkill
|
|
6011
|
+
// for personal/SMB threat models. Consumers needing the off-device
|
|
6012
|
+
// guarantee should use STRICT_POLICY or override this gate.
|
|
6013
|
+
factors: [{
|
|
6014
|
+
anyOf: [
|
|
6015
|
+
"totp",
|
|
6016
|
+
"email-otp",
|
|
6017
|
+
"recovery",
|
|
6018
|
+
"webauthn-roaming",
|
|
6019
|
+
"webauthn-platform",
|
|
6020
|
+
"password",
|
|
6021
|
+
"pin"
|
|
6022
|
+
]
|
|
6023
|
+
}]
|
|
5799
6024
|
},
|
|
5800
6025
|
"recover-passphrase": {
|
|
5801
6026
|
minTier: 1,
|
|
@@ -5803,9 +6028,27 @@ var PERSONAL_POLICY = Object.freeze({
|
|
|
5803
6028
|
},
|
|
5804
6029
|
"enroll-authenticator": { minTier: 1 },
|
|
5805
6030
|
"remove-authenticator": { minTier: 1 },
|
|
6031
|
+
// update-authenticator: meta-only mutation (slot rename, label
|
|
6032
|
+
// changes). Symmetric with enroll/remove under PERSONAL — tier-1
|
|
6033
|
+
// unlock alone. The structural anti-slot-swap guard inside the
|
|
6034
|
+
// implementation enforces wrap-material/id/method immutability
|
|
6035
|
+
// regardless of this gate's settings.
|
|
6036
|
+
"update-authenticator": { minTier: 1 },
|
|
5806
6037
|
"rotate-unlock": { minTier: 2 },
|
|
5807
6038
|
"enroll-user": { minTier: 1 },
|
|
5808
6039
|
"revoke-user": { minTier: 1 },
|
|
6040
|
+
// Peer-recovery is a high-trust intentional op — co-owners
|
|
6041
|
+
// recovering each other should not need an off-device factor in
|
|
6042
|
+
// the personal/SMB threat model (the partner is already vetted by
|
|
6043
|
+
// virtue of being a co-owner). Tier-1 unlock is the floor; the
|
|
6044
|
+
// STRICT preset adds a recovery/email-OTP requirement.
|
|
6045
|
+
"peer-recover-user": { minTier: 1 },
|
|
6046
|
+
// update-user: post-grant identity mutation (role/displayName/
|
|
6047
|
+
// permissions). PERSONAL_POLICY treats this on par with enroll-user
|
|
6048
|
+
// / revoke-user — tier-1 unlock alone. The role-elevation guard
|
|
6049
|
+
// inside the implementation is the structural backstop that this
|
|
6050
|
+
// gate's settings cannot weaken.
|
|
6051
|
+
"update-user": { minTier: 1 },
|
|
5809
6052
|
"export-bundle": { minTier: 1 },
|
|
5810
6053
|
"export-plaintext": {
|
|
5811
6054
|
minTier: 1,
|
|
@@ -5850,6 +6093,15 @@ var STRICT_POLICY = Object.freeze({
|
|
|
5850
6093
|
minTier: 1,
|
|
5851
6094
|
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5852
6095
|
},
|
|
6096
|
+
// STRICT update-authenticator: same factor floor as enroll/remove.
|
|
6097
|
+
// Even though meta changes don't touch wrap material, a malicious
|
|
6098
|
+
// rename could mislead the user about which device a slot
|
|
6099
|
+
// corresponds to ("MacBook Touch ID" → "iPhone Touch ID" on a
|
|
6100
|
+
// shared workstation). STRICT requires a fresh factor proof.
|
|
6101
|
+
"update-authenticator": {
|
|
6102
|
+
minTier: 1,
|
|
6103
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
6104
|
+
},
|
|
5853
6105
|
"rotate-unlock": { minTier: 1 },
|
|
5854
6106
|
"enroll-user": {
|
|
5855
6107
|
minTier: 1,
|
|
@@ -5859,6 +6111,31 @@ var STRICT_POLICY = Object.freeze({
|
|
|
5859
6111
|
minTier: 1,
|
|
5860
6112
|
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
5861
6113
|
},
|
|
6114
|
+
// STRICT peer-recovery: the issuer must present a recovery code
|
|
6115
|
+
// OR a fresh off-device second factor at the moment of recovery.
|
|
6116
|
+
// This binds the high-trust operation to a verifiable proof
|
|
6117
|
+
// (recovery sheet photographed by an attacker won't suffice —
|
|
6118
|
+
// they'd also need tier-1 unlock first; this gate is the freshness
|
|
6119
|
+
// binding on top). Roaming WebAuthn (YubiKey-class hardware key)
|
|
6120
|
+
// accepted; platform-bound kinds (Touch ID, password, PIN)
|
|
6121
|
+
// intentionally excluded under STRICT because they don't survive
|
|
6122
|
+
// device theft — the off-device requirement is the whole point.
|
|
6123
|
+
"peer-recover-user": {
|
|
6124
|
+
minTier: 1,
|
|
6125
|
+
factors: [{ anyOf: ["recovery", "totp", "email-otp", "webauthn-roaming"] }]
|
|
6126
|
+
},
|
|
6127
|
+
// STRICT update-user: matches the enroll-user / revoke-user shape
|
|
6128
|
+
// (off-device factor required). Update-user is admin-shaped — it
|
|
6129
|
+
// mutates someone else's role/permissions; STRICT requires a fresh
|
|
6130
|
+
// off-device factor proof so the operator affirmatively re-asserts
|
|
6131
|
+
// identity at the moment of mutation. Platform-bound factors
|
|
6132
|
+
// (Touch ID / password / PIN) intentionally excluded: same logic as
|
|
6133
|
+
// peer-recover-user — the off-device requirement is the whole
|
|
6134
|
+
// point under STRICT.
|
|
6135
|
+
"update-user": {
|
|
6136
|
+
minTier: 1,
|
|
6137
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
6138
|
+
},
|
|
5862
6139
|
"export-bundle": {
|
|
5863
6140
|
minTier: 1,
|
|
5864
6141
|
factors: [{ anyOf: ["totp", "email-otp"] }],
|
|
@@ -6253,6 +6530,56 @@ var Noydb = class {
|
|
|
6253
6530
|
const keyring = await this.getKeyring(vault);
|
|
6254
6531
|
await revoke(this.options.store, vault, keyring, options);
|
|
6255
6532
|
}
|
|
6533
|
+
/**
|
|
6534
|
+
* Mutate post-grant identity fields on an existing keyring — `role`,
|
|
6535
|
+
* `displayName`, and/or `permissions`. Pure plaintext-header rewrite:
|
|
6536
|
+
* no DEK rewrap, no KEK required, no authenticator slots touched.
|
|
6537
|
+
* Tier-2 enrollments and recovery codes survive.
|
|
6538
|
+
*
|
|
6539
|
+
* Different from `db.revoke + db.grant`:
|
|
6540
|
+
*
|
|
6541
|
+
* - Same `userId`, same DEK wrappings, same `granted_by`, same
|
|
6542
|
+
* `_users/<keyringId>` envelope. Only the specified header
|
|
6543
|
+
* fields move. Last-write-wins via the standard keyring put.
|
|
6544
|
+
* - No cascade on role demotion (admins demoted to operator keep
|
|
6545
|
+
* the keyrings they previously granted; the cascade rules are
|
|
6546
|
+
* a `db.revoke` concern, not `db.updateUser`).
|
|
6547
|
+
* - Tier-2 slots NOT dropped — the wrapping is unaffected.
|
|
6548
|
+
*
|
|
6549
|
+
* Role-elevation guard: BOTH the old and new role must satisfy
|
|
6550
|
+
* `db.grant`'s hierarchy. Owner can do anything; admin manages
|
|
6551
|
+
* admin/operator/viewer/client laterally; admin cannot promote to
|
|
6552
|
+
* owner OR demote from owner. The guard runs regardless of the
|
|
6553
|
+
* `update-user` policy gate's settings — gates can only be more
|
|
6554
|
+
* permissive than the structural floor, never less.
|
|
6555
|
+
*
|
|
6556
|
+
* Gated by `update-user`. `STRICT_POLICY` requires a TOTP/email-OTP
|
|
6557
|
+
* factor proof so the operator affirmatively re-asserts identity at
|
|
6558
|
+
* the moment of mutation; `PERSONAL_POLICY` accepts a tier-1 unlock
|
|
6559
|
+
* alone.
|
|
6560
|
+
*
|
|
6561
|
+
* ```ts
|
|
6562
|
+
* await db.updateUser('acme', {
|
|
6563
|
+
* userId: 'bob',
|
|
6564
|
+
* role: 'operator', // promote
|
|
6565
|
+
* permissions: { invoices: 'rw' },
|
|
6566
|
+
* }, { factors: [{ kind: 'totp' }] })
|
|
6567
|
+
* ```
|
|
6568
|
+
*
|
|
6569
|
+
* @throws `NoAccessError` when no keyring exists for the target.
|
|
6570
|
+
* @throws `PermissionDeniedError` when the role hierarchy rejects.
|
|
6571
|
+
* @throws `ValidationError` when no field is provided.
|
|
6572
|
+
*
|
|
6573
|
+
* @see #54
|
|
6574
|
+
*/
|
|
6575
|
+
async updateUser(vault, options, factors) {
|
|
6576
|
+
await this.checkGate(vault, "update-user", factors);
|
|
6577
|
+
const keyring = await this.getKeyring(vault);
|
|
6578
|
+
await updateKeyringIdentity(this.options.store, vault, keyring, options);
|
|
6579
|
+
if (options.userId === this.options.user) {
|
|
6580
|
+
this.keyringCache.delete(vault);
|
|
6581
|
+
}
|
|
6582
|
+
}
|
|
6256
6583
|
/**
|
|
6257
6584
|
* Rotate the DEKs for the given collections in a vault.
|
|
6258
6585
|
*
|
|
@@ -6796,6 +7123,40 @@ var Noydb = class {
|
|
|
6796
7123
|
const keyring = await this.getKeyring(vault);
|
|
6797
7124
|
return keyring.authenticators;
|
|
6798
7125
|
}
|
|
7126
|
+
/**
|
|
7127
|
+
* Mutate the `meta` blob on an existing authenticator slot — slot
|
|
7128
|
+
* rename, label change, attachment of UI hints. The slot's `id`,
|
|
7129
|
+
* `method`, and wrap material (`wrapped_kek` / `wrapped_deks` + `iv`)
|
|
7130
|
+
* are immutable through this method. Anti-slot-swap is structural,
|
|
7131
|
+
* not gate-driven.
|
|
7132
|
+
*
|
|
7133
|
+
* `meta` patch semantics (#57-aligned):
|
|
7134
|
+
* - Top-level merge — absent keys preserved
|
|
7135
|
+
* - `null` value — delete that meta key
|
|
7136
|
+
* - Other values — replace verbatim
|
|
7137
|
+
*
|
|
7138
|
+
* Use case: per-slot nickname for "iPhone Touch ID" vs "MacBook
|
|
7139
|
+
* Touch ID" disambiguation in admin UIs. The slot id (auto-derived
|
|
7140
|
+
* from credentialId prefix) is not human-friendly; `meta.nickname`
|
|
7141
|
+
* is.
|
|
7142
|
+
*
|
|
7143
|
+
* Gated by `update-authenticator`. PERSONAL_POLICY: tier-1 unlock
|
|
7144
|
+
* alone (matches enroll/remove). STRICT_POLICY: tier-1 +
|
|
7145
|
+
* TOTP/email-OTP factor proof — a malicious rename on a shared
|
|
7146
|
+
* workstation could mislead the user about which device a slot
|
|
7147
|
+
* corresponds to, so STRICT requires fresh factor binding.
|
|
7148
|
+
*
|
|
7149
|
+
* @throws `NoAccessError` when no slot with the given id exists.
|
|
7150
|
+
* @throws `ValidationError` when no patch field is provided.
|
|
7151
|
+
*
|
|
7152
|
+
* @see #55
|
|
7153
|
+
*/
|
|
7154
|
+
async updateAuthenticator(vault, slotId, options, presented) {
|
|
7155
|
+
await this.checkGate(vault, "update-authenticator", presented);
|
|
7156
|
+
const keyring = await this.getKeyring(vault);
|
|
7157
|
+
const next = await updateAuthenticator(this.options.store, vault, keyring, slotId, options);
|
|
7158
|
+
this.keyringCache.set(vault, next);
|
|
7159
|
+
}
|
|
6799
7160
|
/**
|
|
6800
7161
|
* Native WebAuthn enrollment using the **real** internal keyring (#16).
|
|
6801
7162
|
*
|
|
@@ -7005,22 +7366,108 @@ var Noydb = class {
|
|
|
7005
7366
|
async recoverPassphrase(vault, input, factors) {
|
|
7006
7367
|
await this.checkGate(vault, "recover-passphrase", factors);
|
|
7007
7368
|
const userId = this.options.user;
|
|
7369
|
+
const entriesBeforeRecovery = await loadPaperRecoveryEntries(this.options.store, vault);
|
|
7008
7370
|
const next = await recoverPassphrase(this.options.store, vault, userId, input);
|
|
7009
7371
|
this.keyringCache.set(vault, next);
|
|
7372
|
+
const rotateRemaining = input.rotateRemainingCodes ?? true;
|
|
7373
|
+
const remainingAfterBurn = Math.max(0, entriesBeforeRecovery.length - 1);
|
|
7374
|
+
if (!rotateRemaining || remainingAfterBurn === 0) {
|
|
7375
|
+
return { newCodes: [] };
|
|
7376
|
+
}
|
|
7377
|
+
const codeGen = input.codeGenerator ?? generateULID;
|
|
7378
|
+
const newCodeCount = input.newCodeCount ?? remainingAfterBurn;
|
|
7379
|
+
const codes = [];
|
|
7380
|
+
const newEntries = [];
|
|
7381
|
+
for (let i = 0; i < newCodeCount; i++) {
|
|
7382
|
+
const rawCode = codeGen();
|
|
7383
|
+
const entry = await mintPaperRecoveryEntry(next.deks, rawCode, generateULID());
|
|
7384
|
+
codes.push(rawCode);
|
|
7385
|
+
newEntries.push(entry);
|
|
7386
|
+
}
|
|
7387
|
+
await savePaperRecoveryEntries(this.options.store, vault, newEntries);
|
|
7388
|
+
return { newCodes: codes };
|
|
7389
|
+
}
|
|
7390
|
+
/**
|
|
7391
|
+
* Atomic peer-recovery — re-wraps an EXISTING user's keyring under
|
|
7392
|
+
* a fresh temp passphrase in a single store write. Closes #34's
|
|
7393
|
+
* partial-failure window (the previous compose-from-primitives
|
|
7394
|
+
* pattern was `db.revoke + db.grant`, two writes — if the issuer
|
|
7395
|
+
* cancelled between them the target was locked out entirely).
|
|
7396
|
+
*
|
|
7397
|
+
* Different from `db.revoke + db.grant`:
|
|
7398
|
+
*
|
|
7399
|
+
* - Same `userId`, role, permissions, capabilities preserved.
|
|
7400
|
+
* - DEKs unchanged → every other principal in the vault keeps
|
|
7401
|
+
* access. No key rotation.
|
|
7402
|
+
* - Allows owner→owner natively (#33). The existing
|
|
7403
|
+
* `db.revoke` retains its block — peer-recovery is a separate,
|
|
7404
|
+
* intentionally-named operation.
|
|
7405
|
+
* - Tier-2 slots dropped (they wrap the old KEK).
|
|
7406
|
+
*
|
|
7407
|
+
* Gated by `peer-recover-user`; `STRICT_POLICY` requires a
|
|
7408
|
+
* recovery / TOTP / email-OTP factor proof at the moment of
|
|
7409
|
+
* recovery, so the issuer affirmatively re-asserts identity.
|
|
7410
|
+
*
|
|
7411
|
+
* The recipient should call `db.rotatePassphrase` on first session
|
|
7412
|
+
* to choose their own phrase — the temp acts as a single-use
|
|
7413
|
+
* bridge.
|
|
7414
|
+
*
|
|
7415
|
+
* ```ts
|
|
7416
|
+
* await db.recoverUser('acme', {
|
|
7417
|
+
* userId: 'bob',
|
|
7418
|
+
* passphrase: 'temporary-correct-horse-battery-staple-printer',
|
|
7419
|
+
* }, { factors: [{ kind: 'recovery' }] })
|
|
7420
|
+
* // Bob opens createNoydb({ user: 'bob', secret: tempPhrase })
|
|
7421
|
+
* // and immediately calls db.rotatePassphrase to set his own.
|
|
7422
|
+
* ```
|
|
7423
|
+
*
|
|
7424
|
+
* @throws `NoAccessError` when no keyring exists for the target.
|
|
7425
|
+
* @throws `PermissionDeniedError` when the caller's role can't
|
|
7426
|
+
* recover the target's role (admin→owner is blocked even
|
|
7427
|
+
* under recovery).
|
|
7428
|
+
* @throws `PrivilegeEscalationError` when the caller lacks a DEK
|
|
7429
|
+
* the target previously had access to.
|
|
7430
|
+
*
|
|
7431
|
+
* @see #33 #34 — the issues this method closes.
|
|
7432
|
+
*/
|
|
7433
|
+
async recoverUser(vault, options, factors) {
|
|
7434
|
+
await this.checkGate(vault, "peer-recover-user", factors);
|
|
7435
|
+
const callerKeyring = await this.getKeyring(vault);
|
|
7436
|
+
await recoverUser(this.options.store, vault, callerKeyring, options);
|
|
7437
|
+
if (options.userId === this.options.user) {
|
|
7438
|
+
this.keyringCache.delete(vault);
|
|
7439
|
+
}
|
|
7010
7440
|
}
|
|
7011
7441
|
/**
|
|
7012
7442
|
* Persist a recovery enrollment. v0.1.0-pre.5 accepts the `'paper'`
|
|
7013
|
-
* profile
|
|
7014
|
-
*
|
|
7015
|
-
*
|
|
7016
|
-
*
|
|
7443
|
+
* profile.
|
|
7444
|
+
*
|
|
7445
|
+
* The hub wraps the user's DEK set (not the KEK) under a code-derived
|
|
7446
|
+
* AES-GCM key — see `team/recovery.ts` for the rationale. The mint
|
|
7447
|
+
* helper {@link mintPaperRecoveryEntry} is the canonical primitive;
|
|
7448
|
+
* pair it with `db.getKeyring(vault)` to obtain the live DEK set:
|
|
7017
7449
|
*
|
|
7018
7450
|
* ```ts
|
|
7019
|
-
* import {
|
|
7020
|
-
*
|
|
7451
|
+
* import { mintPaperRecoveryEntry } from '@noy-db/hub'
|
|
7452
|
+
*
|
|
7453
|
+
* const keyring = await db.getKeyring('acme')
|
|
7454
|
+
* const codes: string[] = ['CORRECT-HORSE-1', 'BATTERY-STAPLE-2', ...]
|
|
7455
|
+
* const entries = await Promise.all(
|
|
7456
|
+
* codes.map((code, i) => mintPaperRecoveryEntry(keyring.deks, code, `code-${i}`)),
|
|
7457
|
+
* )
|
|
7021
7458
|
* await db.enrollRecovery('acme', { profile: 'paper', entries })
|
|
7022
7459
|
* showCodesToUser(codes)
|
|
7023
7460
|
* ```
|
|
7461
|
+
*
|
|
7462
|
+
* As of pre.8, `@noy-db/on-recovery`'s `generateRecoveryCodeSet`
|
|
7463
|
+
* delegates to `mintPaperRecoveryEntry` internally — its output is
|
|
7464
|
+
* fed directly to this API. Pick whichever fits your code-gen layer:
|
|
7465
|
+
*
|
|
7466
|
+
* ```ts
|
|
7467
|
+
* import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
|
|
7468
|
+
* const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 8 })
|
|
7469
|
+
* await db.enrollRecovery('acme', { profile: 'paper', entries })
|
|
7470
|
+
* ```
|
|
7024
7471
|
*/
|
|
7025
7472
|
async enrollRecovery(vault, enrollment) {
|
|
7026
7473
|
if (enrollment.profile !== "paper") {
|
|
@@ -7074,7 +7521,29 @@ var Noydb = class {
|
|
|
7074
7521
|
clearQuickUnlock(vault) {
|
|
7075
7522
|
this.quickUnlock.delete(vault);
|
|
7076
7523
|
}
|
|
7077
|
-
/**
|
|
7524
|
+
/**
|
|
7525
|
+
* Public accessor for the unlocked keyring of a vault — issue #28.
|
|
7526
|
+
*
|
|
7527
|
+
* Returns the cached `UnlockedKeyring` (already in memory after
|
|
7528
|
+
* `createNoydb` + first vault touch); loads it on demand if absent.
|
|
7529
|
+
* Used by `@noy-db/on-*` ceremonies that need the live DEK set
|
|
7530
|
+
* (paper recovery via {@link mintPaperRecoveryEntry}, tier-3 PIN
|
|
7531
|
+
* enrolment via on-pin's `enrollPin`, custom on-* ceremonies that
|
|
7532
|
+
* don't have a hub-side wrapper).
|
|
7533
|
+
*
|
|
7534
|
+
* No new permission gate — this is an accessor over already-unlocked
|
|
7535
|
+
* state. The keyring is materialized only after the calling session
|
|
7536
|
+
* has unlocked the vault at tier 1, 2, or 3, so exposing it does not
|
|
7537
|
+
* widen access. Throws `ValidationError` when encryption is enabled
|
|
7538
|
+
* and no `secret` / `getKeyring` is configured.
|
|
7539
|
+
*
|
|
7540
|
+
* ```ts
|
|
7541
|
+
* const keyring = await db.getKeyring('acme')
|
|
7542
|
+
* // keyring.deks: Map<collection, CryptoKey>
|
|
7543
|
+
* // keyring.kek: CryptoKey (non-extractable; null for tier-3 sessions)
|
|
7544
|
+
* // keyring.role / .permissions / .authenticators
|
|
7545
|
+
* ```
|
|
7546
|
+
*/
|
|
7078
7547
|
async getKeyring(vault) {
|
|
7079
7548
|
if (this.options.encrypt === false) {
|
|
7080
7549
|
return createPlaintextKeyring(this.options.user);
|
|
@@ -7503,6 +7972,8 @@ export {
|
|
|
7503
7972
|
mergeCrdtStates,
|
|
7504
7973
|
mergePolicy,
|
|
7505
7974
|
min,
|
|
7975
|
+
mintPaperRecoveryEntry,
|
|
7976
|
+
mintWrappedDeksBlob,
|
|
7506
7977
|
paddedIndex,
|
|
7507
7978
|
parseBytes,
|
|
7508
7979
|
parseIndex,
|
|
@@ -7513,6 +7984,7 @@ export {
|
|
|
7513
7984
|
readNoydbBundlePublicEnvelope,
|
|
7514
7985
|
readPath,
|
|
7515
7986
|
readPublicEnvelope,
|
|
7987
|
+
recoverUser,
|
|
7516
7988
|
reduceRecords,
|
|
7517
7989
|
ref,
|
|
7518
7990
|
removeAuthenticator,
|
|
@@ -7534,6 +8006,8 @@ export {
|
|
|
7534
8006
|
saveVaultPolicy,
|
|
7535
8007
|
sha256Hex,
|
|
7536
8008
|
sum,
|
|
8009
|
+
unwrapDeksFromBlob,
|
|
8010
|
+
unwrapDeksFromPaperEntry,
|
|
7537
8011
|
unwrapMagicLinkGrant,
|
|
7538
8012
|
validateI18nTextValue,
|
|
7539
8013
|
validatePassphrase,
|