@noy-db/hub 0.1.0-pre.7 → 0.1.0-pre.8
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-NZ4XCIKS.js → chunk-2WGMYBYS.js} +3 -3
- package/dist/{chunk-3WCRU7TI.js → chunk-7XBQS42M.js} +2 -2
- package/dist/{chunk-CL37QSND.js → chunk-HC7Z5EQZ.js} +2 -2
- package/dist/{chunk-B6HF6NTZ.js → chunk-PJK6IOBC.js} +1 -1
- package/dist/chunk-PJK6IOBC.js.map +1 -0
- package/dist/{chunk-KPF2HHPI.js → chunk-R2ZTGEVP.js} +2 -2
- package/dist/{chunk-GILMPJXB.js → chunk-RSPLI376.js} +2 -2
- 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-INSJBB5W.js → chunk-TOQK4KAN.js} +3 -3
- package/dist/{chunk-UFL4DUEV.js → chunk-VQBTTTUN.js} +1 -1
- package/dist/chunk-VQBTTTUN.js.map +1 -0
- package/dist/{chunk-FAAWLVTF.js → chunk-WN6UK7PM.js} +2 -2
- package/dist/{chunk-N2LMZKLR.js → chunk-Y4CMTMUW.js} +2 -2
- package/dist/{chunk-6IJQ27XN.js → chunk-YVFTBQHL.js} +14 -4
- package/dist/chunk-YVFTBQHL.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-Dk14V6lX.d.cts → dev-unlock-BZKx666y.d.cts} +1 -1
- package/dist/{dev-unlock-CcJ1qIi7.d.ts → dev-unlock-BygpnIWe.d.ts} +1 -1
- package/dist/{hash-1Xsqx1jl.d.ts → hash-B0eU2Qv9.d.ts} +1 -1
- package/dist/{hash-h_2U3TFb.d.cts → hash-CIyfmKsg.d.cts} +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-Dp4tKCjX.d.ts} +1 -1
- package/dist/{index-Cvb0efA_.d.cts → index-DsVbTDZI.d.cts} +1 -1
- package/dist/index.cjs +384 -57
- 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 +377 -72
- package/dist/index.js.map +1 -1
- package/dist/{ledger-5V67MAIL.js → ledger-UQIMMKO5.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-3QTQADDW.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-D-6bmD2c.d.ts → types-DD9eKKNc.d.ts} +644 -72
- package/dist/{types-D3QLmhlk.d.cts → types-arFMsCtn.d.cts} +644 -72
- 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-NZ4XCIKS.js.map → chunk-2WGMYBYS.js.map} +0 -0
- /package/dist/{chunk-3WCRU7TI.js.map → chunk-7XBQS42M.js.map} +0 -0
- /package/dist/{chunk-CL37QSND.js.map → chunk-HC7Z5EQZ.js.map} +0 -0
- /package/dist/{chunk-KPF2HHPI.js.map → chunk-R2ZTGEVP.js.map} +0 -0
- /package/dist/{chunk-GILMPJXB.js.map → chunk-RSPLI376.js.map} +0 -0
- /package/dist/{chunk-INSJBB5W.js.map → chunk-TOQK4KAN.js.map} +0 -0
- /package/dist/{chunk-FAAWLVTF.js.map → chunk-WN6UK7PM.js.map} +0 -0
- /package/dist/{chunk-N2LMZKLR.js.map → chunk-Y4CMTMUW.js.map} +0 -0
- /package/dist/{delegation-XDJCBTI2.js.map → delegation-2DBS2EOH.js.map} +0 -0
- /package/dist/{ledger-5V67MAIL.js.map → ledger-UQIMMKO5.js.map} +0 -0
- /package/dist/{public-envelope-DFJZHXVH.js.map → public-envelope-3QTQADDW.js.map} +0 -0
package/dist/index.cjs
CHANGED
|
@@ -1919,6 +1919,7 @@ async function loadActiveDelegations(store, vault, user, delegationsDek, now = /
|
|
|
1919
1919
|
}
|
|
1920
1920
|
if (token.toUser !== user.userId) continue;
|
|
1921
1921
|
if (token.until <= nowIso) continue;
|
|
1922
|
+
if (!user.kek) continue;
|
|
1922
1923
|
let dek;
|
|
1923
1924
|
try {
|
|
1924
1925
|
dek = await unwrapKey(token.wrappedDek, user.kek);
|
|
@@ -2163,6 +2164,8 @@ __export(src_exports, {
|
|
|
2163
2164
|
mergeCrdtStates: () => mergeCrdtStates,
|
|
2164
2165
|
mergePolicy: () => mergePolicy,
|
|
2165
2166
|
min: () => min,
|
|
2167
|
+
mintPaperRecoveryEntry: () => mintPaperRecoveryEntry,
|
|
2168
|
+
mintWrappedDeksBlob: () => mintWrappedDeksBlob,
|
|
2166
2169
|
paddedIndex: () => paddedIndex,
|
|
2167
2170
|
parseBytes: () => parseBytes,
|
|
2168
2171
|
parseIndex: () => parseIndex,
|
|
@@ -2173,6 +2176,7 @@ __export(src_exports, {
|
|
|
2173
2176
|
readNoydbBundlePublicEnvelope: () => readNoydbBundlePublicEnvelope,
|
|
2174
2177
|
readPath: () => readPath,
|
|
2175
2178
|
readPublicEnvelope: () => readPublicEnvelope,
|
|
2179
|
+
recoverUser: () => recoverUser,
|
|
2176
2180
|
reduceRecords: () => reduceRecords,
|
|
2177
2181
|
ref: () => ref,
|
|
2178
2182
|
removeAuthenticator: () => removeAuthenticator,
|
|
@@ -2194,6 +2198,8 @@ __export(src_exports, {
|
|
|
2194
2198
|
saveVaultPolicy: () => saveVaultPolicy,
|
|
2195
2199
|
sha256Hex: () => sha256Hex3,
|
|
2196
2200
|
sum: () => sum,
|
|
2201
|
+
unwrapDeksFromBlob: () => unwrapDeksFromBlob,
|
|
2202
|
+
unwrapDeksFromPaperEntry: () => unwrapDeksFromPaperEntry,
|
|
2197
2203
|
unwrapMagicLinkGrant: () => unwrapMagicLinkGrant,
|
|
2198
2204
|
validateI18nTextValue: () => validateI18nTextValue,
|
|
2199
2205
|
validatePassphrase: () => validatePassphrase,
|
|
@@ -4869,6 +4875,9 @@ var SUGGESTIONS = {
|
|
|
4869
4875
|
"repeated-adjacent": "Avoid repeating the same word twice in a row."
|
|
4870
4876
|
};
|
|
4871
4877
|
function validatePassphrase(s, opts) {
|
|
4878
|
+
if (opts?.customValidator) {
|
|
4879
|
+
return opts.customValidator(s);
|
|
4880
|
+
}
|
|
4872
4881
|
const minWords = opts?.minWords ?? DEFAULT_MIN_WORDS;
|
|
4873
4882
|
const minWordLength = opts?.minWordLength ?? DEFAULT_MIN_WORD_LENGTH;
|
|
4874
4883
|
const rejectRepeated = opts?.rejectRepeatedAdjacent ?? true;
|
|
@@ -4881,7 +4890,8 @@ function validatePassphrase(s, opts) {
|
|
|
4881
4890
|
if (s.includes(" ")) {
|
|
4882
4891
|
return { ok: false, reason: "double-space" };
|
|
4883
4892
|
}
|
|
4884
|
-
|
|
4893
|
+
const charPattern = opts?.pattern ?? /^[a-z]+( [a-z]+)*$/;
|
|
4894
|
+
if (!charPattern.test(s)) {
|
|
4885
4895
|
return { ok: false, reason: "invalid-chars" };
|
|
4886
4896
|
}
|
|
4887
4897
|
const words = s.split(" ");
|
|
@@ -5393,6 +5403,11 @@ function hasAccess(keyring, collectionName) {
|
|
|
5393
5403
|
return collectionName in keyring.permissions;
|
|
5394
5404
|
}
|
|
5395
5405
|
async function persistKeyring(adapter, vault, keyring) {
|
|
5406
|
+
if (!keyring.kek) {
|
|
5407
|
+
throw new ValidationError(
|
|
5408
|
+
"persistKeyring: keyring.kek is null \u2014 cannot wrap DEKs without the KEK. This typically means the keyring was opened via tier-3 PIN resume, session restore, or a wrap-DEKs tier-2 unlock. Re-authenticate at tier 1 (passphrase) before persisting."
|
|
5409
|
+
);
|
|
5410
|
+
}
|
|
5396
5411
|
const wrappedDeks = {};
|
|
5397
5412
|
for (const [collName, dek] of keyring.deks) {
|
|
5398
5413
|
wrappedDeks[collName] = await wrapKey(dek, keyring.kek);
|
|
@@ -5470,14 +5485,22 @@ async function enrollAuthenticator(store, vault, keyring, options) {
|
|
|
5470
5485
|
`enrollAuthenticator: slot id "${options.id}" already exists in vault "${vault}". Remove the slot first or pick a unique id.`
|
|
5471
5486
|
);
|
|
5472
5487
|
}
|
|
5473
|
-
const
|
|
5488
|
+
const base = {
|
|
5474
5489
|
id: options.id,
|
|
5475
5490
|
method: options.method,
|
|
5476
5491
|
enrolled_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5477
5492
|
enrolled_via_tier: options.enrolled_via_tier ?? 1,
|
|
5478
|
-
wrapped_kek: options.wrapped_kek,
|
|
5479
5493
|
meta: options.meta
|
|
5480
5494
|
};
|
|
5495
|
+
const slot = options.wrapKind === "deks" ? {
|
|
5496
|
+
...base,
|
|
5497
|
+
wrapKind: "deks",
|
|
5498
|
+
wrapped_deks: options.wrapped_deks,
|
|
5499
|
+
iv: options.iv
|
|
5500
|
+
} : {
|
|
5501
|
+
...base,
|
|
5502
|
+
wrapped_kek: options.wrapped_kek
|
|
5503
|
+
};
|
|
5481
5504
|
const next = appendSlot(keyring, slot);
|
|
5482
5505
|
await persistKeyring(store, vault, next);
|
|
5483
5506
|
return next;
|
|
@@ -5589,50 +5612,39 @@ var RecoveryProfileNotImplementedError = class extends NoydbError {
|
|
|
5589
5612
|
|
|
5590
5613
|
// src/team/recovery.ts
|
|
5591
5614
|
init_types();
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5615
|
+
|
|
5616
|
+
// src/team/wrapped-deks.ts
|
|
5617
|
+
var PBKDF2_ITERATIONS2 = 6e5;
|
|
5618
|
+
var SALT_BYTES2 = 32;
|
|
5619
|
+
var IV_BYTES2 = 12;
|
|
5620
|
+
var subtle2 = globalThis.crypto.subtle;
|
|
5621
|
+
async function mintWrappedDeksBlob(deks, credential) {
|
|
5622
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES2));
|
|
5623
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES2));
|
|
5624
|
+
const wrappingKey = await deriveWrappingKey(credential, salt);
|
|
5625
|
+
const exported = {};
|
|
5626
|
+
for (const [coll, dek] of deks) {
|
|
5627
|
+
const raw = await subtle2.exportKey("raw", dek);
|
|
5628
|
+
exported[coll] = bytesToBase64(new Uint8Array(raw));
|
|
5602
5629
|
}
|
|
5603
|
-
}
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5614
|
-
_iv: "",
|
|
5615
|
-
_data: JSON.stringify(doc)
|
|
5630
|
+
const plaintext = new TextEncoder().encode(JSON.stringify({ deks: exported }));
|
|
5631
|
+
const ciphertext = await subtle2.encrypt(
|
|
5632
|
+
{ name: "AES-GCM", iv },
|
|
5633
|
+
wrappingKey,
|
|
5634
|
+
plaintext
|
|
5635
|
+
);
|
|
5636
|
+
return {
|
|
5637
|
+
salt: bytesToBase64(salt),
|
|
5638
|
+
iv: bytesToBase64(iv),
|
|
5639
|
+
wrappedDeks: bytesToBase64(new Uint8Array(ciphertext))
|
|
5616
5640
|
};
|
|
5617
|
-
await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
|
|
5618
|
-
}
|
|
5619
|
-
async function burnPaperRecoveryEntry(store, vault, codeId) {
|
|
5620
|
-
const entries = await loadPaperRecoveryEntries(store, vault);
|
|
5621
|
-
const remaining = entries.filter((e) => e.codeId !== codeId);
|
|
5622
|
-
await savePaperRecoveryEntries(store, vault, remaining);
|
|
5623
|
-
}
|
|
5624
|
-
async function hasRecoveryEnrolled(store, vault) {
|
|
5625
|
-
const paper = await loadPaperRecoveryEntries(store, vault);
|
|
5626
|
-
return paper.length > 0;
|
|
5627
5641
|
}
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
async function unwrapDeksFromPaperEntry(entry, code) {
|
|
5631
|
-
const wrappingKey = await deriveRecoveryWrappingKey(code, base64ToBytes(entry.salt));
|
|
5642
|
+
async function unwrapDeksFromBlob(blob, credential) {
|
|
5643
|
+
const wrappingKey = await deriveWrappingKey(credential, base64ToBytes(blob.salt));
|
|
5632
5644
|
const plaintext = await subtle2.decrypt(
|
|
5633
|
-
{ name: "AES-GCM", iv: base64ToBytes(
|
|
5645
|
+
{ name: "AES-GCM", iv: base64ToBytes(blob.iv) },
|
|
5634
5646
|
wrappingKey,
|
|
5635
|
-
base64ToBytes(
|
|
5647
|
+
base64ToBytes(blob.wrappedDeks)
|
|
5636
5648
|
);
|
|
5637
5649
|
const parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
|
5638
5650
|
const deks = /* @__PURE__ */ new Map();
|
|
@@ -5649,10 +5661,10 @@ async function unwrapDeksFromPaperEntry(entry, code) {
|
|
|
5649
5661
|
}
|
|
5650
5662
|
return deks;
|
|
5651
5663
|
}
|
|
5652
|
-
async function
|
|
5664
|
+
async function deriveWrappingKey(credential, salt) {
|
|
5653
5665
|
const ikm = await subtle2.importKey(
|
|
5654
5666
|
"raw",
|
|
5655
|
-
new TextEncoder().encode(
|
|
5667
|
+
new TextEncoder().encode(credential),
|
|
5656
5668
|
"PBKDF2",
|
|
5657
5669
|
false,
|
|
5658
5670
|
["deriveKey"]
|
|
@@ -5661,7 +5673,7 @@ async function deriveRecoveryWrappingKey(code, salt) {
|
|
|
5661
5673
|
{
|
|
5662
5674
|
name: "PBKDF2",
|
|
5663
5675
|
salt,
|
|
5664
|
-
iterations:
|
|
5676
|
+
iterations: PBKDF2_ITERATIONS2,
|
|
5665
5677
|
hash: "SHA-256"
|
|
5666
5678
|
},
|
|
5667
5679
|
ikm,
|
|
@@ -5670,6 +5682,11 @@ async function deriveRecoveryWrappingKey(code, salt) {
|
|
|
5670
5682
|
["encrypt", "decrypt"]
|
|
5671
5683
|
);
|
|
5672
5684
|
}
|
|
5685
|
+
function bytesToBase64(b) {
|
|
5686
|
+
let s = "";
|
|
5687
|
+
for (const x of b) s += String.fromCharCode(x);
|
|
5688
|
+
return btoa(s);
|
|
5689
|
+
}
|
|
5673
5690
|
function base64ToBytes(b64) {
|
|
5674
5691
|
const s = atob(b64);
|
|
5675
5692
|
const out = new Uint8Array(s.length);
|
|
@@ -5677,7 +5694,57 @@ function base64ToBytes(b64) {
|
|
|
5677
5694
|
return out;
|
|
5678
5695
|
}
|
|
5679
5696
|
|
|
5697
|
+
// src/team/recovery.ts
|
|
5698
|
+
var PAPER_DOC_ID = "recovery-paper";
|
|
5699
|
+
async function loadPaperRecoveryEntries(store, vault) {
|
|
5700
|
+
const env = await store.get(vault, "_meta", PAPER_DOC_ID);
|
|
5701
|
+
if (!env) return [];
|
|
5702
|
+
try {
|
|
5703
|
+
const doc = JSON.parse(env._data);
|
|
5704
|
+
if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
|
|
5705
|
+
return doc.entries;
|
|
5706
|
+
} catch {
|
|
5707
|
+
return [];
|
|
5708
|
+
}
|
|
5709
|
+
}
|
|
5710
|
+
async function savePaperRecoveryEntries(store, vault, entries) {
|
|
5711
|
+
const doc = {
|
|
5712
|
+
_noydb_recovery: 1,
|
|
5713
|
+
profile: "paper",
|
|
5714
|
+
entries
|
|
5715
|
+
};
|
|
5716
|
+
const envelope = {
|
|
5717
|
+
_noydb: NOYDB_FORMAT_VERSION,
|
|
5718
|
+
_v: 1,
|
|
5719
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5720
|
+
_iv: "",
|
|
5721
|
+
_data: JSON.stringify(doc)
|
|
5722
|
+
};
|
|
5723
|
+
await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
|
|
5724
|
+
}
|
|
5725
|
+
async function burnPaperRecoveryEntry(store, vault, codeId) {
|
|
5726
|
+
const entries = await loadPaperRecoveryEntries(store, vault);
|
|
5727
|
+
const remaining = entries.filter((e) => e.codeId !== codeId);
|
|
5728
|
+
await savePaperRecoveryEntries(store, vault, remaining);
|
|
5729
|
+
}
|
|
5730
|
+
async function hasRecoveryEnrolled(store, vault) {
|
|
5731
|
+
const paper = await loadPaperRecoveryEntries(store, vault);
|
|
5732
|
+
return paper.length > 0;
|
|
5733
|
+
}
|
|
5734
|
+
async function mintPaperRecoveryEntry(deks, code, codeId) {
|
|
5735
|
+
const blob = await mintWrappedDeksBlob(deks, code);
|
|
5736
|
+
return {
|
|
5737
|
+
...blob,
|
|
5738
|
+
codeId,
|
|
5739
|
+
enrolledAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5740
|
+
};
|
|
5741
|
+
}
|
|
5742
|
+
async function unwrapDeksFromPaperEntry(entry, code) {
|
|
5743
|
+
return unwrapDeksFromBlob(entry, code);
|
|
5744
|
+
}
|
|
5745
|
+
|
|
5680
5746
|
// src/team/rotate-recover.ts
|
|
5747
|
+
init_errors();
|
|
5681
5748
|
async function rotatePassphrase(store, vault, userId, input) {
|
|
5682
5749
|
if (!input.allowWeakPassphrase) {
|
|
5683
5750
|
assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
|
|
@@ -5699,14 +5766,53 @@ async function rotatePassphrase(store, vault, userId, input) {
|
|
|
5699
5766
|
for (const [coll, dek] of deks) {
|
|
5700
5767
|
wrappedDeks[coll] = await wrapKey(dek, newKek);
|
|
5701
5768
|
}
|
|
5769
|
+
const oldSlots = file.authenticators ?? [];
|
|
5770
|
+
const newSlots = [];
|
|
5771
|
+
if (input.slotCeremonies && oldSlots.length > 0) {
|
|
5772
|
+
for (const oldSlot of oldSlots) {
|
|
5773
|
+
const ceremony = input.slotCeremonies[oldSlot.id];
|
|
5774
|
+
if (!ceremony) continue;
|
|
5775
|
+
const result = await ceremony({ newKek, newDeks: deks, oldSlot });
|
|
5776
|
+
if (result.id !== oldSlot.id) {
|
|
5777
|
+
throw new ValidationError(
|
|
5778
|
+
`slotCeremonies['${oldSlot.id}'] returned id="${result.id}". The id must match the rotated slot \u2014 a ceremony cannot change a slot's identity.`
|
|
5779
|
+
);
|
|
5780
|
+
}
|
|
5781
|
+
if (result.method !== oldSlot.method) {
|
|
5782
|
+
throw new ValidationError(
|
|
5783
|
+
`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.`
|
|
5784
|
+
);
|
|
5785
|
+
}
|
|
5786
|
+
const baseFields = {
|
|
5787
|
+
id: result.id,
|
|
5788
|
+
method: result.method,
|
|
5789
|
+
// Preserve original enrolled_at — rotation is rewrapping, not
|
|
5790
|
+
// re-enrollment. The slot's enrolment timestamp tracks when
|
|
5791
|
+
// the user originally added the slot, not when it was last
|
|
5792
|
+
// rewrapped. Forensics consumers reading enrolled_at are
|
|
5793
|
+
// tracking the slot's ORIGIN, not its CURRENT wrapping.
|
|
5794
|
+
enrolled_at: oldSlot.enrolled_at,
|
|
5795
|
+
enrolled_via_tier: result.enrolled_via_tier ?? oldSlot.enrolled_via_tier,
|
|
5796
|
+
meta: result.meta
|
|
5797
|
+
};
|
|
5798
|
+
const newSlot = result.wrapKind === "deks" ? {
|
|
5799
|
+
...baseFields,
|
|
5800
|
+
wrapKind: "deks",
|
|
5801
|
+
wrapped_deks: result.wrapped_deks,
|
|
5802
|
+
iv: result.iv
|
|
5803
|
+
} : {
|
|
5804
|
+
...baseFields,
|
|
5805
|
+
wrapped_kek: result.wrapped_kek
|
|
5806
|
+
};
|
|
5807
|
+
newSlots.push(newSlot);
|
|
5808
|
+
}
|
|
5809
|
+
}
|
|
5702
5810
|
const next = {
|
|
5703
5811
|
...file,
|
|
5704
5812
|
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
5705
5813
|
deks: wrappedDeks,
|
|
5706
5814
|
salt: bufferToBase64(newSalt),
|
|
5707
|
-
|
|
5708
|
-
// re-enrols afterwards via `db.enrollAuthenticator`.
|
|
5709
|
-
authenticators: []
|
|
5815
|
+
authenticators: newSlots
|
|
5710
5816
|
};
|
|
5711
5817
|
await writeKeyringFile2(store, vault, userId, next);
|
|
5712
5818
|
return {
|
|
@@ -5717,7 +5823,7 @@ async function rotatePassphrase(store, vault, userId, input) {
|
|
|
5717
5823
|
deks,
|
|
5718
5824
|
kek: newKek,
|
|
5719
5825
|
salt: newSalt,
|
|
5720
|
-
authenticators:
|
|
5826
|
+
authenticators: newSlots,
|
|
5721
5827
|
...file.export_capability !== void 0 && { exportCapability: file.export_capability },
|
|
5722
5828
|
...file.import_capability !== void 0 && { importCapability: file.import_capability }
|
|
5723
5829
|
};
|
|
@@ -6195,8 +6301,76 @@ function sanitizeId(s) {
|
|
|
6195
6301
|
return s.replace(/[^a-zA-Z0-9]/g, "_");
|
|
6196
6302
|
}
|
|
6197
6303
|
|
|
6304
|
+
// src/team/peer-recover.ts
|
|
6305
|
+
init_types();
|
|
6306
|
+
init_crypto();
|
|
6307
|
+
init_errors();
|
|
6308
|
+
var ADMIN_RECOVERABLE_TARGETS = ["operator", "viewer", "client", "admin"];
|
|
6309
|
+
function canRecover(callerRole, targetRole) {
|
|
6310
|
+
if (callerRole === "owner") return true;
|
|
6311
|
+
if (callerRole === "admin") return ADMIN_RECOVERABLE_TARGETS.includes(targetRole);
|
|
6312
|
+
return false;
|
|
6313
|
+
}
|
|
6314
|
+
async function recoverUser(store, vault, callerKeyring, options) {
|
|
6315
|
+
const env = await store.get(vault, "_keyring", options.userId);
|
|
6316
|
+
if (!env) {
|
|
6317
|
+
throw new NoAccessError(
|
|
6318
|
+
`recoverUser: user "${options.userId}" has no keyring in vault "${vault}".`
|
|
6319
|
+
);
|
|
6320
|
+
}
|
|
6321
|
+
const target = JSON.parse(env._data);
|
|
6322
|
+
const targetRole = options.role ?? target.role;
|
|
6323
|
+
if (!canRecover(callerKeyring.role, targetRole)) {
|
|
6324
|
+
throw new PermissionDeniedError(
|
|
6325
|
+
`Role "${callerKeyring.role}" cannot recover role "${targetRole}"`
|
|
6326
|
+
);
|
|
6327
|
+
}
|
|
6328
|
+
if (!canRecover(callerKeyring.role, target.role)) {
|
|
6329
|
+
throw new PermissionDeniedError(
|
|
6330
|
+
`Role "${callerKeyring.role}" cannot recover role "${target.role}"`
|
|
6331
|
+
);
|
|
6332
|
+
}
|
|
6333
|
+
for (const coll of Object.keys(target.deks)) {
|
|
6334
|
+
if (!callerKeyring.deks.has(coll)) {
|
|
6335
|
+
throw new PrivilegeEscalationError(coll);
|
|
6336
|
+
}
|
|
6337
|
+
}
|
|
6338
|
+
if (options.validatePassphrase && !options.allowWeakPassphrase) {
|
|
6339
|
+
assertStrongPassphrase(options.passphrase, options.passphrasePolicy);
|
|
6340
|
+
}
|
|
6341
|
+
const newSalt = generateSalt();
|
|
6342
|
+
const newKek = await deriveKey(options.passphrase, newSalt);
|
|
6343
|
+
const wrappedDeks = {};
|
|
6344
|
+
for (const coll of Object.keys(target.deks)) {
|
|
6345
|
+
const callerDek = callerKeyring.deks.get(coll);
|
|
6346
|
+
if (!callerDek) {
|
|
6347
|
+
throw new PrivilegeEscalationError(coll);
|
|
6348
|
+
}
|
|
6349
|
+
wrappedDeks[coll] = await wrapKey(callerDek, newKek);
|
|
6350
|
+
}
|
|
6351
|
+
const next = {
|
|
6352
|
+
...target,
|
|
6353
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
6354
|
+
role: targetRole,
|
|
6355
|
+
display_name: options.displayName ?? target.display_name,
|
|
6356
|
+
deks: wrappedDeks,
|
|
6357
|
+
salt: bufferToBase64(newSalt),
|
|
6358
|
+
granted_by: callerKeyring.userId,
|
|
6359
|
+
authenticators: []
|
|
6360
|
+
};
|
|
6361
|
+
const envelope = {
|
|
6362
|
+
_noydb: 1,
|
|
6363
|
+
_v: 1,
|
|
6364
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6365
|
+
_iv: "",
|
|
6366
|
+
_data: JSON.stringify(next)
|
|
6367
|
+
};
|
|
6368
|
+
await store.put(vault, "_keyring", options.userId, envelope);
|
|
6369
|
+
}
|
|
6370
|
+
|
|
6198
6371
|
// src/noydb.ts
|
|
6199
6372
|
init_errors();
|
|
6373
|
+
init_ulid();
|
|
6200
6374
|
init_public_envelope();
|
|
6201
6375
|
|
|
6202
6376
|
// src/vault.ts
|
|
@@ -12463,6 +12637,11 @@ var Vault = class {
|
|
|
12463
12637
|
*/
|
|
12464
12638
|
async delegate(opts) {
|
|
12465
12639
|
const { issueDelegation: issueDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await Promise.resolve().then(() => (init_delegation(), delegation_exports));
|
|
12640
|
+
if (!this.keyring.kek) {
|
|
12641
|
+
throw new ValidationError(
|
|
12642
|
+
"issueDelegation: keyring.kek is null \u2014 issuing a delegation requires a tier-1 unlock. Re-authenticate at tier 1 (passphrase) first."
|
|
12643
|
+
);
|
|
12644
|
+
}
|
|
12466
12645
|
const targetKek = this.keyring.kek;
|
|
12467
12646
|
const delegationsDek = await this.getDEK(DELEGATIONS_COLLECTION2);
|
|
12468
12647
|
return issueDelegation2(
|
|
@@ -13453,7 +13632,23 @@ var PERSONAL_POLICY = Object.freeze({
|
|
|
13453
13632
|
gates: {
|
|
13454
13633
|
"rotate-passphrase": {
|
|
13455
13634
|
minTier: 1,
|
|
13456
|
-
|
|
13635
|
+
// Any second factor satisfies the gate — off-device kinds (TOTP,
|
|
13636
|
+
// email-OTP, paper recovery, roaming WebAuthn) are the strongest;
|
|
13637
|
+
// platform-bound kinds (platform WebAuthn, password, PIN) are
|
|
13638
|
+
// accepted because requiring "something off-device" is overkill
|
|
13639
|
+
// for personal/SMB threat models. Consumers needing the off-device
|
|
13640
|
+
// guarantee should use STRICT_POLICY or override this gate.
|
|
13641
|
+
factors: [{
|
|
13642
|
+
anyOf: [
|
|
13643
|
+
"totp",
|
|
13644
|
+
"email-otp",
|
|
13645
|
+
"recovery",
|
|
13646
|
+
"webauthn-roaming",
|
|
13647
|
+
"webauthn-platform",
|
|
13648
|
+
"password",
|
|
13649
|
+
"pin"
|
|
13650
|
+
]
|
|
13651
|
+
}]
|
|
13457
13652
|
},
|
|
13458
13653
|
"recover-passphrase": {
|
|
13459
13654
|
minTier: 1,
|
|
@@ -13464,6 +13659,12 @@ var PERSONAL_POLICY = Object.freeze({
|
|
|
13464
13659
|
"rotate-unlock": { minTier: 2 },
|
|
13465
13660
|
"enroll-user": { minTier: 1 },
|
|
13466
13661
|
"revoke-user": { minTier: 1 },
|
|
13662
|
+
// Peer-recovery is a high-trust intentional op — co-owners
|
|
13663
|
+
// recovering each other should not need an off-device factor in
|
|
13664
|
+
// the personal/SMB threat model (the partner is already vetted by
|
|
13665
|
+
// virtue of being a co-owner). Tier-1 unlock is the floor; the
|
|
13666
|
+
// STRICT preset adds a recovery/email-OTP requirement.
|
|
13667
|
+
"peer-recover-user": { minTier: 1 },
|
|
13467
13668
|
"export-bundle": { minTier: 1 },
|
|
13468
13669
|
"export-plaintext": {
|
|
13469
13670
|
minTier: 1,
|
|
@@ -13517,6 +13718,19 @@ var STRICT_POLICY = Object.freeze({
|
|
|
13517
13718
|
minTier: 1,
|
|
13518
13719
|
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
13519
13720
|
},
|
|
13721
|
+
// STRICT peer-recovery: the issuer must present a recovery code
|
|
13722
|
+
// OR a fresh off-device second factor at the moment of recovery.
|
|
13723
|
+
// This binds the high-trust operation to a verifiable proof
|
|
13724
|
+
// (recovery sheet photographed by an attacker won't suffice —
|
|
13725
|
+
// they'd also need tier-1 unlock first; this gate is the freshness
|
|
13726
|
+
// binding on top). Roaming WebAuthn (YubiKey-class hardware key)
|
|
13727
|
+
// accepted; platform-bound kinds (Touch ID, password, PIN)
|
|
13728
|
+
// intentionally excluded under STRICT because they don't survive
|
|
13729
|
+
// device theft — the off-device requirement is the whole point.
|
|
13730
|
+
"peer-recover-user": {
|
|
13731
|
+
minTier: 1,
|
|
13732
|
+
factors: [{ anyOf: ["recovery", "totp", "email-otp", "webauthn-roaming"] }]
|
|
13733
|
+
},
|
|
13520
13734
|
"export-bundle": {
|
|
13521
13735
|
minTier: 1,
|
|
13522
13736
|
factors: [{ anyOf: ["totp", "email-otp"] }],
|
|
@@ -14663,22 +14877,108 @@ var Noydb = class {
|
|
|
14663
14877
|
async recoverPassphrase(vault, input, factors) {
|
|
14664
14878
|
await this.checkGate(vault, "recover-passphrase", factors);
|
|
14665
14879
|
const userId = this.options.user;
|
|
14880
|
+
const entriesBeforeRecovery = await loadPaperRecoveryEntries(this.options.store, vault);
|
|
14666
14881
|
const next = await recoverPassphrase(this.options.store, vault, userId, input);
|
|
14667
14882
|
this.keyringCache.set(vault, next);
|
|
14883
|
+
const rotateRemaining = input.rotateRemainingCodes ?? true;
|
|
14884
|
+
const remainingAfterBurn = Math.max(0, entriesBeforeRecovery.length - 1);
|
|
14885
|
+
if (!rotateRemaining || remainingAfterBurn === 0) {
|
|
14886
|
+
return { newCodes: [] };
|
|
14887
|
+
}
|
|
14888
|
+
const codeGen = input.codeGenerator ?? generateULID;
|
|
14889
|
+
const newCodeCount = input.newCodeCount ?? remainingAfterBurn;
|
|
14890
|
+
const codes = [];
|
|
14891
|
+
const newEntries = [];
|
|
14892
|
+
for (let i = 0; i < newCodeCount; i++) {
|
|
14893
|
+
const rawCode = codeGen();
|
|
14894
|
+
const entry = await mintPaperRecoveryEntry(next.deks, rawCode, generateULID());
|
|
14895
|
+
codes.push(rawCode);
|
|
14896
|
+
newEntries.push(entry);
|
|
14897
|
+
}
|
|
14898
|
+
await savePaperRecoveryEntries(this.options.store, vault, newEntries);
|
|
14899
|
+
return { newCodes: codes };
|
|
14900
|
+
}
|
|
14901
|
+
/**
|
|
14902
|
+
* Atomic peer-recovery — re-wraps an EXISTING user's keyring under
|
|
14903
|
+
* a fresh temp passphrase in a single store write. Closes #34's
|
|
14904
|
+
* partial-failure window (the previous compose-from-primitives
|
|
14905
|
+
* pattern was `db.revoke + db.grant`, two writes — if the issuer
|
|
14906
|
+
* cancelled between them the target was locked out entirely).
|
|
14907
|
+
*
|
|
14908
|
+
* Different from `db.revoke + db.grant`:
|
|
14909
|
+
*
|
|
14910
|
+
* - Same `userId`, role, permissions, capabilities preserved.
|
|
14911
|
+
* - DEKs unchanged → every other principal in the vault keeps
|
|
14912
|
+
* access. No key rotation.
|
|
14913
|
+
* - Allows owner→owner natively (#33). The existing
|
|
14914
|
+
* `db.revoke` retains its block — peer-recovery is a separate,
|
|
14915
|
+
* intentionally-named operation.
|
|
14916
|
+
* - Tier-2 slots dropped (they wrap the old KEK).
|
|
14917
|
+
*
|
|
14918
|
+
* Gated by `peer-recover-user`; `STRICT_POLICY` requires a
|
|
14919
|
+
* recovery / TOTP / email-OTP factor proof at the moment of
|
|
14920
|
+
* recovery, so the issuer affirmatively re-asserts identity.
|
|
14921
|
+
*
|
|
14922
|
+
* The recipient should call `db.rotatePassphrase` on first session
|
|
14923
|
+
* to choose their own phrase — the temp acts as a single-use
|
|
14924
|
+
* bridge.
|
|
14925
|
+
*
|
|
14926
|
+
* ```ts
|
|
14927
|
+
* await db.recoverUser('acme', {
|
|
14928
|
+
* userId: 'bob',
|
|
14929
|
+
* passphrase: 'temporary-correct-horse-battery-staple-printer',
|
|
14930
|
+
* }, { factors: [{ kind: 'recovery' }] })
|
|
14931
|
+
* // Bob opens createNoydb({ user: 'bob', secret: tempPhrase })
|
|
14932
|
+
* // and immediately calls db.rotatePassphrase to set his own.
|
|
14933
|
+
* ```
|
|
14934
|
+
*
|
|
14935
|
+
* @throws `NoAccessError` when no keyring exists for the target.
|
|
14936
|
+
* @throws `PermissionDeniedError` when the caller's role can't
|
|
14937
|
+
* recover the target's role (admin→owner is blocked even
|
|
14938
|
+
* under recovery).
|
|
14939
|
+
* @throws `PrivilegeEscalationError` when the caller lacks a DEK
|
|
14940
|
+
* the target previously had access to.
|
|
14941
|
+
*
|
|
14942
|
+
* @see #33 #34 — the issues this method closes.
|
|
14943
|
+
*/
|
|
14944
|
+
async recoverUser(vault, options, factors) {
|
|
14945
|
+
await this.checkGate(vault, "peer-recover-user", factors);
|
|
14946
|
+
const callerKeyring = await this.getKeyring(vault);
|
|
14947
|
+
await recoverUser(this.options.store, vault, callerKeyring, options);
|
|
14948
|
+
if (options.userId === this.options.user) {
|
|
14949
|
+
this.keyringCache.delete(vault);
|
|
14950
|
+
}
|
|
14668
14951
|
}
|
|
14669
14952
|
/**
|
|
14670
14953
|
* Persist a recovery enrollment. v0.1.0-pre.5 accepts the `'paper'`
|
|
14671
|
-
* profile
|
|
14672
|
-
*
|
|
14673
|
-
*
|
|
14674
|
-
*
|
|
14954
|
+
* profile.
|
|
14955
|
+
*
|
|
14956
|
+
* The hub wraps the user's DEK set (not the KEK) under a code-derived
|
|
14957
|
+
* AES-GCM key — see `team/recovery.ts` for the rationale. The mint
|
|
14958
|
+
* helper {@link mintPaperRecoveryEntry} is the canonical primitive;
|
|
14959
|
+
* pair it with `db.getKeyring(vault)` to obtain the live DEK set:
|
|
14675
14960
|
*
|
|
14676
14961
|
* ```ts
|
|
14677
|
-
* import {
|
|
14678
|
-
*
|
|
14962
|
+
* import { mintPaperRecoveryEntry } from '@noy-db/hub'
|
|
14963
|
+
*
|
|
14964
|
+
* const keyring = await db.getKeyring('acme')
|
|
14965
|
+
* const codes: string[] = ['CORRECT-HORSE-1', 'BATTERY-STAPLE-2', ...]
|
|
14966
|
+
* const entries = await Promise.all(
|
|
14967
|
+
* codes.map((code, i) => mintPaperRecoveryEntry(keyring.deks, code, `code-${i}`)),
|
|
14968
|
+
* )
|
|
14679
14969
|
* await db.enrollRecovery('acme', { profile: 'paper', entries })
|
|
14680
14970
|
* showCodesToUser(codes)
|
|
14681
14971
|
* ```
|
|
14972
|
+
*
|
|
14973
|
+
* As of pre.8, `@noy-db/on-recovery`'s `generateRecoveryCodeSet`
|
|
14974
|
+
* delegates to `mintPaperRecoveryEntry` internally — its output is
|
|
14975
|
+
* fed directly to this API. Pick whichever fits your code-gen layer:
|
|
14976
|
+
*
|
|
14977
|
+
* ```ts
|
|
14978
|
+
* import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
|
|
14979
|
+
* const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 8 })
|
|
14980
|
+
* await db.enrollRecovery('acme', { profile: 'paper', entries })
|
|
14981
|
+
* ```
|
|
14682
14982
|
*/
|
|
14683
14983
|
async enrollRecovery(vault, enrollment) {
|
|
14684
14984
|
if (enrollment.profile !== "paper") {
|
|
@@ -14732,7 +15032,29 @@ var Noydb = class {
|
|
|
14732
15032
|
clearQuickUnlock(vault) {
|
|
14733
15033
|
this.quickUnlock.delete(vault);
|
|
14734
15034
|
}
|
|
14735
|
-
/**
|
|
15035
|
+
/**
|
|
15036
|
+
* Public accessor for the unlocked keyring of a vault — issue #28.
|
|
15037
|
+
*
|
|
15038
|
+
* Returns the cached `UnlockedKeyring` (already in memory after
|
|
15039
|
+
* `createNoydb` + first vault touch); loads it on demand if absent.
|
|
15040
|
+
* Used by `@noy-db/on-*` ceremonies that need the live DEK set
|
|
15041
|
+
* (paper recovery via {@link mintPaperRecoveryEntry}, tier-3 PIN
|
|
15042
|
+
* enrolment via on-pin's `enrollPin`, custom on-* ceremonies that
|
|
15043
|
+
* don't have a hub-side wrapper).
|
|
15044
|
+
*
|
|
15045
|
+
* No new permission gate — this is an accessor over already-unlocked
|
|
15046
|
+
* state. The keyring is materialized only after the calling session
|
|
15047
|
+
* has unlocked the vault at tier 1, 2, or 3, so exposing it does not
|
|
15048
|
+
* widen access. Throws `ValidationError` when encryption is enabled
|
|
15049
|
+
* and no `secret` / `getKeyring` is configured.
|
|
15050
|
+
*
|
|
15051
|
+
* ```ts
|
|
15052
|
+
* const keyring = await db.getKeyring('acme')
|
|
15053
|
+
* // keyring.deks: Map<collection, CryptoKey>
|
|
15054
|
+
* // keyring.kek: CryptoKey (non-extractable; null for tier-3 sessions)
|
|
15055
|
+
* // keyring.role / .permissions / .authenticators
|
|
15056
|
+
* ```
|
|
15057
|
+
*/
|
|
14736
15058
|
async getKeyring(vault) {
|
|
14737
15059
|
if (this.options.encrypt === false) {
|
|
14738
15060
|
return createPlaintextKeyring(this.options.user);
|
|
@@ -16655,6 +16977,8 @@ function shortJSON(value) {
|
|
|
16655
16977
|
mergeCrdtStates,
|
|
16656
16978
|
mergePolicy,
|
|
16657
16979
|
min,
|
|
16980
|
+
mintPaperRecoveryEntry,
|
|
16981
|
+
mintWrappedDeksBlob,
|
|
16658
16982
|
paddedIndex,
|
|
16659
16983
|
parseBytes,
|
|
16660
16984
|
parseIndex,
|
|
@@ -16665,6 +16989,7 @@ function shortJSON(value) {
|
|
|
16665
16989
|
readNoydbBundlePublicEnvelope,
|
|
16666
16990
|
readPath,
|
|
16667
16991
|
readPublicEnvelope,
|
|
16992
|
+
recoverUser,
|
|
16668
16993
|
reduceRecords,
|
|
16669
16994
|
ref,
|
|
16670
16995
|
removeAuthenticator,
|
|
@@ -16686,6 +17011,8 @@ function shortJSON(value) {
|
|
|
16686
17011
|
saveVaultPolicy,
|
|
16687
17012
|
sha256Hex,
|
|
16688
17013
|
sum,
|
|
17014
|
+
unwrapDeksFromBlob,
|
|
17015
|
+
unwrapDeksFromPaperEntry,
|
|
16689
17016
|
unwrapMagicLinkGrant,
|
|
16690
17017
|
validateI18nTextValue,
|
|
16691
17018
|
validatePassphrase,
|