@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.
Files changed (89) hide show
  1. package/dist/blobs/index.cjs.map +1 -1
  2. package/dist/blobs/index.d.cts +2 -2
  3. package/dist/blobs/index.d.ts +2 -2
  4. package/dist/blobs/index.js +2 -2
  5. package/dist/bundle/index.d.cts +2 -2
  6. package/dist/bundle/index.d.ts +2 -2
  7. package/dist/bundle/index.js +3 -3
  8. package/dist/{chunk-NZ4XCIKS.js → chunk-2WGMYBYS.js} +3 -3
  9. package/dist/{chunk-3WCRU7TI.js → chunk-7XBQS42M.js} +2 -2
  10. package/dist/{chunk-CL37QSND.js → chunk-HC7Z5EQZ.js} +2 -2
  11. package/dist/{chunk-B6HF6NTZ.js → chunk-PJK6IOBC.js} +1 -1
  12. package/dist/chunk-PJK6IOBC.js.map +1 -0
  13. package/dist/{chunk-KPF2HHPI.js → chunk-R2ZTGEVP.js} +2 -2
  14. package/dist/{chunk-GILMPJXB.js → chunk-RSPLI376.js} +2 -2
  15. package/dist/{chunk-XCL3WP6J.js → chunk-SCZXXXU4.js} +2 -1
  16. package/dist/{chunk-XCL3WP6J.js.map → chunk-SCZXXXU4.js.map} +1 -1
  17. package/dist/{chunk-INSJBB5W.js → chunk-TOQK4KAN.js} +3 -3
  18. package/dist/{chunk-UFL4DUEV.js → chunk-VQBTTTUN.js} +1 -1
  19. package/dist/chunk-VQBTTTUN.js.map +1 -0
  20. package/dist/{chunk-FAAWLVTF.js → chunk-WN6UK7PM.js} +2 -2
  21. package/dist/{chunk-N2LMZKLR.js → chunk-Y4CMTMUW.js} +2 -2
  22. package/dist/{chunk-6IJQ27XN.js → chunk-YVFTBQHL.js} +14 -4
  23. package/dist/chunk-YVFTBQHL.js.map +1 -0
  24. package/dist/consent/index.d.cts +2 -2
  25. package/dist/consent/index.d.ts +2 -2
  26. package/dist/{delegation-XDJCBTI2.js → delegation-2DBS2EOH.js} +2 -2
  27. package/dist/{dev-unlock-Dk14V6lX.d.cts → dev-unlock-BZKx666y.d.cts} +1 -1
  28. package/dist/{dev-unlock-CcJ1qIi7.d.ts → dev-unlock-BygpnIWe.d.ts} +1 -1
  29. package/dist/{hash-1Xsqx1jl.d.ts → hash-B0eU2Qv9.d.ts} +1 -1
  30. package/dist/{hash-h_2U3TFb.d.cts → hash-CIyfmKsg.d.cts} +1 -1
  31. package/dist/history/index.cjs.map +1 -1
  32. package/dist/history/index.d.cts +3 -3
  33. package/dist/history/index.d.ts +3 -3
  34. package/dist/history/index.js +2 -2
  35. package/dist/i18n/index.cjs +11 -0
  36. package/dist/i18n/index.cjs.map +1 -1
  37. package/dist/i18n/index.d.cts +2 -2
  38. package/dist/i18n/index.d.ts +2 -2
  39. package/dist/i18n/index.js +3 -3
  40. package/dist/{index-DZn6Yick.d.ts → index-Dp4tKCjX.d.ts} +1 -1
  41. package/dist/{index-Cvb0efA_.d.cts → index-DsVbTDZI.d.cts} +1 -1
  42. package/dist/index.cjs +384 -57
  43. package/dist/index.cjs.map +1 -1
  44. package/dist/index.d.cts +5 -5
  45. package/dist/index.d.ts +5 -5
  46. package/dist/index.js +377 -72
  47. package/dist/index.js.map +1 -1
  48. package/dist/{ledger-5V67MAIL.js → ledger-UQIMMKO5.js} +3 -3
  49. package/dist/periods/index.cjs.map +1 -1
  50. package/dist/periods/index.d.cts +2 -2
  51. package/dist/periods/index.d.ts +2 -2
  52. package/dist/periods/index.js +3 -3
  53. package/dist/{public-envelope-DFJZHXVH.js → public-envelope-3QTQADDW.js} +3 -3
  54. package/dist/session/index.cjs.map +1 -1
  55. package/dist/session/index.d.cts +3 -3
  56. package/dist/session/index.d.ts +3 -3
  57. package/dist/session/index.js +1 -1
  58. package/dist/shadow/index.d.cts +2 -2
  59. package/dist/shadow/index.d.ts +2 -2
  60. package/dist/store/index.d.cts +2 -2
  61. package/dist/store/index.d.ts +2 -2
  62. package/dist/sync/index.cjs.map +1 -1
  63. package/dist/sync/index.d.cts +1 -1
  64. package/dist/sync/index.d.ts +1 -1
  65. package/dist/sync/index.js +2 -2
  66. package/dist/team/index.cjs +11 -0
  67. package/dist/team/index.cjs.map +1 -1
  68. package/dist/team/index.d.cts +2 -2
  69. package/dist/team/index.d.ts +2 -2
  70. package/dist/team/index.js +4 -4
  71. package/dist/tx/index.d.cts +2 -2
  72. package/dist/tx/index.d.ts +2 -2
  73. package/dist/{types-D-6bmD2c.d.ts → types-DD9eKKNc.d.ts} +644 -72
  74. package/dist/{types-D3QLmhlk.d.cts → types-arFMsCtn.d.cts} +644 -72
  75. package/package.json +1 -1
  76. package/dist/chunk-6IJQ27XN.js.map +0 -1
  77. package/dist/chunk-B6HF6NTZ.js.map +0 -1
  78. package/dist/chunk-UFL4DUEV.js.map +0 -1
  79. /package/dist/{chunk-NZ4XCIKS.js.map → chunk-2WGMYBYS.js.map} +0 -0
  80. /package/dist/{chunk-3WCRU7TI.js.map → chunk-7XBQS42M.js.map} +0 -0
  81. /package/dist/{chunk-CL37QSND.js.map → chunk-HC7Z5EQZ.js.map} +0 -0
  82. /package/dist/{chunk-KPF2HHPI.js.map → chunk-R2ZTGEVP.js.map} +0 -0
  83. /package/dist/{chunk-GILMPJXB.js.map → chunk-RSPLI376.js.map} +0 -0
  84. /package/dist/{chunk-INSJBB5W.js.map → chunk-TOQK4KAN.js.map} +0 -0
  85. /package/dist/{chunk-FAAWLVTF.js.map → chunk-WN6UK7PM.js.map} +0 -0
  86. /package/dist/{chunk-N2LMZKLR.js.map → chunk-Y4CMTMUW.js.map} +0 -0
  87. /package/dist/{delegation-XDJCBTI2.js.map → delegation-2DBS2EOH.js.map} +0 -0
  88. /package/dist/{ledger-5V67MAIL.js.map → ledger-UQIMMKO5.js.map} +0 -0
  89. /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
- if (!/^[a-z]+( [a-z]+)*$/.test(s)) {
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 slot = {
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
- var PAPER_DOC_ID = "recovery-paper";
5593
- async function loadPaperRecoveryEntries(store, vault) {
5594
- const env = await store.get(vault, "_meta", PAPER_DOC_ID);
5595
- if (!env) return [];
5596
- try {
5597
- const doc = JSON.parse(env._data);
5598
- if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
5599
- return doc.entries;
5600
- } catch {
5601
- return [];
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
- async function savePaperRecoveryEntries(store, vault, entries) {
5605
- const doc = {
5606
- _noydb_recovery: 1,
5607
- profile: "paper",
5608
- entries
5609
- };
5610
- const envelope = {
5611
- _noydb: NOYDB_FORMAT_VERSION,
5612
- _v: 1,
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
- var subtle2 = globalThis.crypto.subtle;
5629
- var RECOVERY_PBKDF2_ITERATIONS = 6e5;
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(entry.iv) },
5645
+ { name: "AES-GCM", iv: base64ToBytes(blob.iv) },
5634
5646
  wrappingKey,
5635
- base64ToBytes(entry.wrappedDeks)
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 deriveRecoveryWrappingKey(code, salt) {
5664
+ async function deriveWrappingKey(credential, salt) {
5653
5665
  const ikm = await subtle2.importKey(
5654
5666
  "raw",
5655
- new TextEncoder().encode(code),
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: RECOVERY_PBKDF2_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
- // Tier-2 slots reference the old KEK — drop them. User
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
- factors: [{ anyOf: ["totp", "email-otp", "recovery"] }]
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 — the developer first calls
14672
- * `@noy-db/on-recovery/generateRecoveryCodeSet` to mint codes +
14673
- * entries, shows the codes to the user once, then hands the entries
14674
- * here.
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 { generateRecoveryCodeSet } from '@noy-db/on-recovery'
14678
- * const { codes, entries } = await generateRecoveryCodeSet({ kek, count: 10 })
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
- /** Get or load the keyring for a vault. */
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,