@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.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(" ");
|
|
@@ -5002,6 +5012,11 @@ function canRevoke(callerRole, targetRole) {
|
|
|
5002
5012
|
if (callerRole === "admin") return ADMIN_GRANTABLE_TARGETS.includes(targetRole);
|
|
5003
5013
|
return false;
|
|
5004
5014
|
}
|
|
5015
|
+
function canUpdateRole(callerRole, targetRole) {
|
|
5016
|
+
if (callerRole === "owner") return true;
|
|
5017
|
+
if (callerRole === "admin") return ADMIN_GRANTABLE_TARGETS.includes(targetRole);
|
|
5018
|
+
return false;
|
|
5019
|
+
}
|
|
5005
5020
|
async function loadKeyring(adapter, vault, userId, passphrase) {
|
|
5006
5021
|
const envelope = await adapter.get(vault, "_keyring", userId);
|
|
5007
5022
|
if (!envelope) {
|
|
@@ -5195,6 +5210,37 @@ async function revoke(adapter, vault, callerKeyring, options) {
|
|
|
5195
5210
|
await rotateKeys(adapter, vault, callerKeyring, [...affectedCollections]);
|
|
5196
5211
|
}
|
|
5197
5212
|
}
|
|
5213
|
+
async function updateKeyringIdentity(adapter, vault, callerKeyring, options) {
|
|
5214
|
+
if (options.role === void 0 && options.displayName === void 0 && options.permissions === void 0) {
|
|
5215
|
+
throw new ValidationError(
|
|
5216
|
+
`updateUser: at least one of role / displayName / permissions must be provided (userId: "${options.userId}").`
|
|
5217
|
+
);
|
|
5218
|
+
}
|
|
5219
|
+
const env = await adapter.get(vault, "_keyring", options.userId);
|
|
5220
|
+
if (!env) {
|
|
5221
|
+
throw new NoAccessError(
|
|
5222
|
+
`updateUser: user "${options.userId}" has no keyring in vault "${vault}".`
|
|
5223
|
+
);
|
|
5224
|
+
}
|
|
5225
|
+
const target = JSON.parse(env._data);
|
|
5226
|
+
if (!canUpdateRole(callerKeyring.role, target.role)) {
|
|
5227
|
+
throw new PermissionDeniedError(
|
|
5228
|
+
`Role "${callerKeyring.role}" cannot update a keyring with role "${target.role}"`
|
|
5229
|
+
);
|
|
5230
|
+
}
|
|
5231
|
+
if (options.role !== void 0 && options.role !== target.role && !canUpdateRole(callerKeyring.role, options.role)) {
|
|
5232
|
+
throw new PermissionDeniedError(
|
|
5233
|
+
`Role "${callerKeyring.role}" cannot promote target to role "${options.role}"`
|
|
5234
|
+
);
|
|
5235
|
+
}
|
|
5236
|
+
const next = {
|
|
5237
|
+
...target,
|
|
5238
|
+
...options.role !== void 0 && { role: options.role },
|
|
5239
|
+
...options.displayName !== void 0 && { display_name: options.displayName },
|
|
5240
|
+
...options.permissions !== void 0 && { permissions: options.permissions }
|
|
5241
|
+
};
|
|
5242
|
+
await writeKeyringFile(adapter, vault, options.userId, next);
|
|
5243
|
+
}
|
|
5198
5244
|
async function rotateKeys(adapter, vault, callerKeyring, collections) {
|
|
5199
5245
|
const newDeks = /* @__PURE__ */ new Map();
|
|
5200
5246
|
for (const collName of collections) {
|
|
@@ -5393,6 +5439,11 @@ function hasAccess(keyring, collectionName) {
|
|
|
5393
5439
|
return collectionName in keyring.permissions;
|
|
5394
5440
|
}
|
|
5395
5441
|
async function persistKeyring(adapter, vault, keyring) {
|
|
5442
|
+
if (!keyring.kek) {
|
|
5443
|
+
throw new ValidationError(
|
|
5444
|
+
"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."
|
|
5445
|
+
);
|
|
5446
|
+
}
|
|
5396
5447
|
const wrappedDeks = {};
|
|
5397
5448
|
for (const [collName, dek] of keyring.deks) {
|
|
5398
5449
|
wrappedDeks[collName] = await wrapKey(dek, keyring.kek);
|
|
@@ -5470,18 +5521,58 @@ async function enrollAuthenticator(store, vault, keyring, options) {
|
|
|
5470
5521
|
`enrollAuthenticator: slot id "${options.id}" already exists in vault "${vault}". Remove the slot first or pick a unique id.`
|
|
5471
5522
|
);
|
|
5472
5523
|
}
|
|
5473
|
-
const
|
|
5524
|
+
const base = {
|
|
5474
5525
|
id: options.id,
|
|
5475
5526
|
method: options.method,
|
|
5476
5527
|
enrolled_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5477
5528
|
enrolled_via_tier: options.enrolled_via_tier ?? 1,
|
|
5478
|
-
wrapped_kek: options.wrapped_kek,
|
|
5479
5529
|
meta: options.meta
|
|
5480
5530
|
};
|
|
5531
|
+
const slot = options.wrapKind === "deks" ? {
|
|
5532
|
+
...base,
|
|
5533
|
+
wrapKind: "deks",
|
|
5534
|
+
wrapped_deks: options.wrapped_deks,
|
|
5535
|
+
iv: options.iv
|
|
5536
|
+
} : {
|
|
5537
|
+
...base,
|
|
5538
|
+
wrapped_kek: options.wrapped_kek
|
|
5539
|
+
};
|
|
5481
5540
|
const next = appendSlot(keyring, slot);
|
|
5482
5541
|
await persistKeyring(store, vault, next);
|
|
5483
5542
|
return next;
|
|
5484
5543
|
}
|
|
5544
|
+
async function updateAuthenticator(store, vault, keyring, slotId, options) {
|
|
5545
|
+
if (options.meta === void 0) {
|
|
5546
|
+
throw new ValidationError(
|
|
5547
|
+
`updateAuthenticator: at least one of meta must be provided (slotId: "${slotId}").`
|
|
5548
|
+
);
|
|
5549
|
+
}
|
|
5550
|
+
const idx = keyring.authenticators.findIndex((a) => a.id === slotId);
|
|
5551
|
+
if (idx === -1) {
|
|
5552
|
+
throw new NoAccessError(
|
|
5553
|
+
`updateAuthenticator: slot "${slotId}" not found in vault "${vault}".`
|
|
5554
|
+
);
|
|
5555
|
+
}
|
|
5556
|
+
const existing = keyring.authenticators[idx];
|
|
5557
|
+
const mergedMeta = { ...existing.meta };
|
|
5558
|
+
for (const [k, v] of Object.entries(options.meta)) {
|
|
5559
|
+
if (v === void 0) continue;
|
|
5560
|
+
if (v === null) {
|
|
5561
|
+
delete mergedMeta[k];
|
|
5562
|
+
continue;
|
|
5563
|
+
}
|
|
5564
|
+
mergedMeta[k] = v;
|
|
5565
|
+
}
|
|
5566
|
+
const next = { ...existing, meta: mergedMeta };
|
|
5567
|
+
const nextSlots = [...keyring.authenticators];
|
|
5568
|
+
nextSlots[idx] = next;
|
|
5569
|
+
const nextKeyring = {
|
|
5570
|
+
...keyring,
|
|
5571
|
+
authenticators: nextSlots
|
|
5572
|
+
};
|
|
5573
|
+
await persistKeyring(store, vault, nextKeyring);
|
|
5574
|
+
return nextKeyring;
|
|
5575
|
+
}
|
|
5485
5576
|
async function removeAuthenticator(store, vault, keyring, slotId) {
|
|
5486
5577
|
const filtered = keyring.authenticators.filter((a) => a.id !== slotId);
|
|
5487
5578
|
if (filtered.length === keyring.authenticators.length) {
|
|
@@ -5589,50 +5680,39 @@ var RecoveryProfileNotImplementedError = class extends NoydbError {
|
|
|
5589
5680
|
|
|
5590
5681
|
// src/team/recovery.ts
|
|
5591
5682
|
init_types();
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5683
|
+
|
|
5684
|
+
// src/team/wrapped-deks.ts
|
|
5685
|
+
var PBKDF2_ITERATIONS2 = 6e5;
|
|
5686
|
+
var SALT_BYTES2 = 32;
|
|
5687
|
+
var IV_BYTES2 = 12;
|
|
5688
|
+
var subtle2 = globalThis.crypto.subtle;
|
|
5689
|
+
async function mintWrappedDeksBlob(deks, credential) {
|
|
5690
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES2));
|
|
5691
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES2));
|
|
5692
|
+
const wrappingKey = await deriveWrappingKey(credential, salt);
|
|
5693
|
+
const exported = {};
|
|
5694
|
+
for (const [coll, dek] of deks) {
|
|
5695
|
+
const raw = await subtle2.exportKey("raw", dek);
|
|
5696
|
+
exported[coll] = bytesToBase64(new Uint8Array(raw));
|
|
5602
5697
|
}
|
|
5603
|
-
}
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5614
|
-
_iv: "",
|
|
5615
|
-
_data: JSON.stringify(doc)
|
|
5698
|
+
const plaintext = new TextEncoder().encode(JSON.stringify({ deks: exported }));
|
|
5699
|
+
const ciphertext = await subtle2.encrypt(
|
|
5700
|
+
{ name: "AES-GCM", iv },
|
|
5701
|
+
wrappingKey,
|
|
5702
|
+
plaintext
|
|
5703
|
+
);
|
|
5704
|
+
return {
|
|
5705
|
+
salt: bytesToBase64(salt),
|
|
5706
|
+
iv: bytesToBase64(iv),
|
|
5707
|
+
wrappedDeks: bytesToBase64(new Uint8Array(ciphertext))
|
|
5616
5708
|
};
|
|
5617
|
-
await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
|
|
5618
5709
|
}
|
|
5619
|
-
async function
|
|
5620
|
-
const
|
|
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
|
-
}
|
|
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));
|
|
5710
|
+
async function unwrapDeksFromBlob(blob, credential) {
|
|
5711
|
+
const wrappingKey = await deriveWrappingKey(credential, base64ToBytes(blob.salt));
|
|
5632
5712
|
const plaintext = await subtle2.decrypt(
|
|
5633
|
-
{ name: "AES-GCM", iv: base64ToBytes(
|
|
5713
|
+
{ name: "AES-GCM", iv: base64ToBytes(blob.iv) },
|
|
5634
5714
|
wrappingKey,
|
|
5635
|
-
base64ToBytes(
|
|
5715
|
+
base64ToBytes(blob.wrappedDeks)
|
|
5636
5716
|
);
|
|
5637
5717
|
const parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
|
5638
5718
|
const deks = /* @__PURE__ */ new Map();
|
|
@@ -5649,10 +5729,10 @@ async function unwrapDeksFromPaperEntry(entry, code) {
|
|
|
5649
5729
|
}
|
|
5650
5730
|
return deks;
|
|
5651
5731
|
}
|
|
5652
|
-
async function
|
|
5732
|
+
async function deriveWrappingKey(credential, salt) {
|
|
5653
5733
|
const ikm = await subtle2.importKey(
|
|
5654
5734
|
"raw",
|
|
5655
|
-
new TextEncoder().encode(
|
|
5735
|
+
new TextEncoder().encode(credential),
|
|
5656
5736
|
"PBKDF2",
|
|
5657
5737
|
false,
|
|
5658
5738
|
["deriveKey"]
|
|
@@ -5661,7 +5741,7 @@ async function deriveRecoveryWrappingKey(code, salt) {
|
|
|
5661
5741
|
{
|
|
5662
5742
|
name: "PBKDF2",
|
|
5663
5743
|
salt,
|
|
5664
|
-
iterations:
|
|
5744
|
+
iterations: PBKDF2_ITERATIONS2,
|
|
5665
5745
|
hash: "SHA-256"
|
|
5666
5746
|
},
|
|
5667
5747
|
ikm,
|
|
@@ -5670,6 +5750,11 @@ async function deriveRecoveryWrappingKey(code, salt) {
|
|
|
5670
5750
|
["encrypt", "decrypt"]
|
|
5671
5751
|
);
|
|
5672
5752
|
}
|
|
5753
|
+
function bytesToBase64(b) {
|
|
5754
|
+
let s = "";
|
|
5755
|
+
for (const x of b) s += String.fromCharCode(x);
|
|
5756
|
+
return btoa(s);
|
|
5757
|
+
}
|
|
5673
5758
|
function base64ToBytes(b64) {
|
|
5674
5759
|
const s = atob(b64);
|
|
5675
5760
|
const out = new Uint8Array(s.length);
|
|
@@ -5677,7 +5762,57 @@ function base64ToBytes(b64) {
|
|
|
5677
5762
|
return out;
|
|
5678
5763
|
}
|
|
5679
5764
|
|
|
5765
|
+
// src/team/recovery.ts
|
|
5766
|
+
var PAPER_DOC_ID = "recovery-paper";
|
|
5767
|
+
async function loadPaperRecoveryEntries(store, vault) {
|
|
5768
|
+
const env = await store.get(vault, "_meta", PAPER_DOC_ID);
|
|
5769
|
+
if (!env) return [];
|
|
5770
|
+
try {
|
|
5771
|
+
const doc = JSON.parse(env._data);
|
|
5772
|
+
if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
|
|
5773
|
+
return doc.entries;
|
|
5774
|
+
} catch {
|
|
5775
|
+
return [];
|
|
5776
|
+
}
|
|
5777
|
+
}
|
|
5778
|
+
async function savePaperRecoveryEntries(store, vault, entries) {
|
|
5779
|
+
const doc = {
|
|
5780
|
+
_noydb_recovery: 1,
|
|
5781
|
+
profile: "paper",
|
|
5782
|
+
entries
|
|
5783
|
+
};
|
|
5784
|
+
const envelope = {
|
|
5785
|
+
_noydb: NOYDB_FORMAT_VERSION,
|
|
5786
|
+
_v: 1,
|
|
5787
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5788
|
+
_iv: "",
|
|
5789
|
+
_data: JSON.stringify(doc)
|
|
5790
|
+
};
|
|
5791
|
+
await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
|
|
5792
|
+
}
|
|
5793
|
+
async function burnPaperRecoveryEntry(store, vault, codeId) {
|
|
5794
|
+
const entries = await loadPaperRecoveryEntries(store, vault);
|
|
5795
|
+
const remaining = entries.filter((e) => e.codeId !== codeId);
|
|
5796
|
+
await savePaperRecoveryEntries(store, vault, remaining);
|
|
5797
|
+
}
|
|
5798
|
+
async function hasRecoveryEnrolled(store, vault) {
|
|
5799
|
+
const paper = await loadPaperRecoveryEntries(store, vault);
|
|
5800
|
+
return paper.length > 0;
|
|
5801
|
+
}
|
|
5802
|
+
async function mintPaperRecoveryEntry(deks, code, codeId) {
|
|
5803
|
+
const blob = await mintWrappedDeksBlob(deks, code);
|
|
5804
|
+
return {
|
|
5805
|
+
...blob,
|
|
5806
|
+
codeId,
|
|
5807
|
+
enrolledAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5808
|
+
};
|
|
5809
|
+
}
|
|
5810
|
+
async function unwrapDeksFromPaperEntry(entry, code) {
|
|
5811
|
+
return unwrapDeksFromBlob(entry, code);
|
|
5812
|
+
}
|
|
5813
|
+
|
|
5680
5814
|
// src/team/rotate-recover.ts
|
|
5815
|
+
init_errors();
|
|
5681
5816
|
async function rotatePassphrase(store, vault, userId, input) {
|
|
5682
5817
|
if (!input.allowWeakPassphrase) {
|
|
5683
5818
|
assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
|
|
@@ -5699,14 +5834,53 @@ async function rotatePassphrase(store, vault, userId, input) {
|
|
|
5699
5834
|
for (const [coll, dek] of deks) {
|
|
5700
5835
|
wrappedDeks[coll] = await wrapKey(dek, newKek);
|
|
5701
5836
|
}
|
|
5837
|
+
const oldSlots = file.authenticators ?? [];
|
|
5838
|
+
const newSlots = [];
|
|
5839
|
+
if (input.slotCeremonies && oldSlots.length > 0) {
|
|
5840
|
+
for (const oldSlot of oldSlots) {
|
|
5841
|
+
const ceremony = input.slotCeremonies[oldSlot.id];
|
|
5842
|
+
if (!ceremony) continue;
|
|
5843
|
+
const result = await ceremony({ newKek, newDeks: deks, oldSlot });
|
|
5844
|
+
if (result.id !== oldSlot.id) {
|
|
5845
|
+
throw new ValidationError(
|
|
5846
|
+
`slotCeremonies['${oldSlot.id}'] returned id="${result.id}". The id must match the rotated slot \u2014 a ceremony cannot change a slot's identity.`
|
|
5847
|
+
);
|
|
5848
|
+
}
|
|
5849
|
+
if (result.method !== oldSlot.method) {
|
|
5850
|
+
throw new ValidationError(
|
|
5851
|
+
`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.`
|
|
5852
|
+
);
|
|
5853
|
+
}
|
|
5854
|
+
const baseFields = {
|
|
5855
|
+
id: result.id,
|
|
5856
|
+
method: result.method,
|
|
5857
|
+
// Preserve original enrolled_at — rotation is rewrapping, not
|
|
5858
|
+
// re-enrollment. The slot's enrolment timestamp tracks when
|
|
5859
|
+
// the user originally added the slot, not when it was last
|
|
5860
|
+
// rewrapped. Forensics consumers reading enrolled_at are
|
|
5861
|
+
// tracking the slot's ORIGIN, not its CURRENT wrapping.
|
|
5862
|
+
enrolled_at: oldSlot.enrolled_at,
|
|
5863
|
+
enrolled_via_tier: result.enrolled_via_tier ?? oldSlot.enrolled_via_tier,
|
|
5864
|
+
meta: result.meta
|
|
5865
|
+
};
|
|
5866
|
+
const newSlot = result.wrapKind === "deks" ? {
|
|
5867
|
+
...baseFields,
|
|
5868
|
+
wrapKind: "deks",
|
|
5869
|
+
wrapped_deks: result.wrapped_deks,
|
|
5870
|
+
iv: result.iv
|
|
5871
|
+
} : {
|
|
5872
|
+
...baseFields,
|
|
5873
|
+
wrapped_kek: result.wrapped_kek
|
|
5874
|
+
};
|
|
5875
|
+
newSlots.push(newSlot);
|
|
5876
|
+
}
|
|
5877
|
+
}
|
|
5702
5878
|
const next = {
|
|
5703
5879
|
...file,
|
|
5704
5880
|
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
5705
5881
|
deks: wrappedDeks,
|
|
5706
5882
|
salt: bufferToBase64(newSalt),
|
|
5707
|
-
|
|
5708
|
-
// re-enrols afterwards via `db.enrollAuthenticator`.
|
|
5709
|
-
authenticators: []
|
|
5883
|
+
authenticators: newSlots
|
|
5710
5884
|
};
|
|
5711
5885
|
await writeKeyringFile2(store, vault, userId, next);
|
|
5712
5886
|
return {
|
|
@@ -5717,7 +5891,7 @@ async function rotatePassphrase(store, vault, userId, input) {
|
|
|
5717
5891
|
deks,
|
|
5718
5892
|
kek: newKek,
|
|
5719
5893
|
salt: newSalt,
|
|
5720
|
-
authenticators:
|
|
5894
|
+
authenticators: newSlots,
|
|
5721
5895
|
...file.export_capability !== void 0 && { exportCapability: file.export_capability },
|
|
5722
5896
|
...file.import_capability !== void 0 && { importCapability: file.import_capability }
|
|
5723
5897
|
};
|
|
@@ -5853,6 +6027,17 @@ var UserApi = class {
|
|
|
5853
6027
|
* the envelope on first call. Optimistic-concurrency safe — a stale
|
|
5854
6028
|
* `_v` (parallel writer on another device) throws `ConflictError`.
|
|
5855
6029
|
*
|
|
6030
|
+
* Patch semantics (#57):
|
|
6031
|
+
* - `undefined` (or omitted key) — skip; existing value preserved
|
|
6032
|
+
* - `null` — delete the field from the merged result
|
|
6033
|
+
* - any other value — overwrite (deep-merge for plain objects,
|
|
6034
|
+
* replace for primitives / arrays)
|
|
6035
|
+
*
|
|
6036
|
+
* To clear a field, pass `null` rather than `undefined`. Callers
|
|
6037
|
+
* with shape `T = string | null` where `null` is a meaningful value
|
|
6038
|
+
* should use `setMe` for that specific field instead — `null` here
|
|
6039
|
+
* always means delete.
|
|
6040
|
+
*
|
|
5856
6041
|
* Gated by the `edit-own-profile` policy gate (default `minTier: 3`).
|
|
5857
6042
|
* Pass `presented` to satisfy tightened policies that require a
|
|
5858
6043
|
* factor proof (e.g. STRICT_POLICY's TOTP requirement).
|
|
@@ -6024,9 +6209,17 @@ function deepMerge(source, patch) {
|
|
|
6024
6209
|
}
|
|
6025
6210
|
const out = { ...source };
|
|
6026
6211
|
for (const [key, patchVal] of Object.entries(patch)) {
|
|
6212
|
+
if (patchVal === void 0) {
|
|
6213
|
+
continue;
|
|
6214
|
+
}
|
|
6215
|
+
if (patchVal === null) {
|
|
6216
|
+
delete out[key];
|
|
6217
|
+
continue;
|
|
6218
|
+
}
|
|
6027
6219
|
const sourceVal = source[key];
|
|
6028
|
-
if (isPlainObject(
|
|
6029
|
-
|
|
6220
|
+
if (isPlainObject(patchVal)) {
|
|
6221
|
+
const recurseSource = isPlainObject(sourceVal) ? sourceVal : {};
|
|
6222
|
+
out[key] = deepMerge(recurseSource, patchVal);
|
|
6030
6223
|
} else {
|
|
6031
6224
|
out[key] = patchVal;
|
|
6032
6225
|
}
|
|
@@ -6195,8 +6388,76 @@ function sanitizeId(s) {
|
|
|
6195
6388
|
return s.replace(/[^a-zA-Z0-9]/g, "_");
|
|
6196
6389
|
}
|
|
6197
6390
|
|
|
6391
|
+
// src/team/peer-recover.ts
|
|
6392
|
+
init_types();
|
|
6393
|
+
init_crypto();
|
|
6394
|
+
init_errors();
|
|
6395
|
+
var ADMIN_RECOVERABLE_TARGETS = ["operator", "viewer", "client", "admin"];
|
|
6396
|
+
function canRecover(callerRole, targetRole) {
|
|
6397
|
+
if (callerRole === "owner") return true;
|
|
6398
|
+
if (callerRole === "admin") return ADMIN_RECOVERABLE_TARGETS.includes(targetRole);
|
|
6399
|
+
return false;
|
|
6400
|
+
}
|
|
6401
|
+
async function recoverUser(store, vault, callerKeyring, options) {
|
|
6402
|
+
const env = await store.get(vault, "_keyring", options.userId);
|
|
6403
|
+
if (!env) {
|
|
6404
|
+
throw new NoAccessError(
|
|
6405
|
+
`recoverUser: user "${options.userId}" has no keyring in vault "${vault}".`
|
|
6406
|
+
);
|
|
6407
|
+
}
|
|
6408
|
+
const target = JSON.parse(env._data);
|
|
6409
|
+
const targetRole = options.role ?? target.role;
|
|
6410
|
+
if (!canRecover(callerKeyring.role, targetRole)) {
|
|
6411
|
+
throw new PermissionDeniedError(
|
|
6412
|
+
`Role "${callerKeyring.role}" cannot recover role "${targetRole}"`
|
|
6413
|
+
);
|
|
6414
|
+
}
|
|
6415
|
+
if (!canRecover(callerKeyring.role, target.role)) {
|
|
6416
|
+
throw new PermissionDeniedError(
|
|
6417
|
+
`Role "${callerKeyring.role}" cannot recover role "${target.role}"`
|
|
6418
|
+
);
|
|
6419
|
+
}
|
|
6420
|
+
for (const coll of Object.keys(target.deks)) {
|
|
6421
|
+
if (!callerKeyring.deks.has(coll)) {
|
|
6422
|
+
throw new PrivilegeEscalationError(coll);
|
|
6423
|
+
}
|
|
6424
|
+
}
|
|
6425
|
+
if (options.validatePassphrase && !options.allowWeakPassphrase) {
|
|
6426
|
+
assertStrongPassphrase(options.passphrase, options.passphrasePolicy);
|
|
6427
|
+
}
|
|
6428
|
+
const newSalt = generateSalt();
|
|
6429
|
+
const newKek = await deriveKey(options.passphrase, newSalt);
|
|
6430
|
+
const wrappedDeks = {};
|
|
6431
|
+
for (const coll of Object.keys(target.deks)) {
|
|
6432
|
+
const callerDek = callerKeyring.deks.get(coll);
|
|
6433
|
+
if (!callerDek) {
|
|
6434
|
+
throw new PrivilegeEscalationError(coll);
|
|
6435
|
+
}
|
|
6436
|
+
wrappedDeks[coll] = await wrapKey(callerDek, newKek);
|
|
6437
|
+
}
|
|
6438
|
+
const next = {
|
|
6439
|
+
...target,
|
|
6440
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
6441
|
+
role: targetRole,
|
|
6442
|
+
display_name: options.displayName ?? target.display_name,
|
|
6443
|
+
deks: wrappedDeks,
|
|
6444
|
+
salt: bufferToBase64(newSalt),
|
|
6445
|
+
granted_by: callerKeyring.userId,
|
|
6446
|
+
authenticators: []
|
|
6447
|
+
};
|
|
6448
|
+
const envelope = {
|
|
6449
|
+
_noydb: 1,
|
|
6450
|
+
_v: 1,
|
|
6451
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6452
|
+
_iv: "",
|
|
6453
|
+
_data: JSON.stringify(next)
|
|
6454
|
+
};
|
|
6455
|
+
await store.put(vault, "_keyring", options.userId, envelope);
|
|
6456
|
+
}
|
|
6457
|
+
|
|
6198
6458
|
// src/noydb.ts
|
|
6199
6459
|
init_errors();
|
|
6460
|
+
init_ulid();
|
|
6200
6461
|
init_public_envelope();
|
|
6201
6462
|
|
|
6202
6463
|
// src/vault.ts
|
|
@@ -12463,6 +12724,11 @@ var Vault = class {
|
|
|
12463
12724
|
*/
|
|
12464
12725
|
async delegate(opts) {
|
|
12465
12726
|
const { issueDelegation: issueDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await Promise.resolve().then(() => (init_delegation(), delegation_exports));
|
|
12727
|
+
if (!this.keyring.kek) {
|
|
12728
|
+
throw new ValidationError(
|
|
12729
|
+
"issueDelegation: keyring.kek is null \u2014 issuing a delegation requires a tier-1 unlock. Re-authenticate at tier 1 (passphrase) first."
|
|
12730
|
+
);
|
|
12731
|
+
}
|
|
12466
12732
|
const targetKek = this.keyring.kek;
|
|
12467
12733
|
const delegationsDek = await this.getDEK(DELEGATIONS_COLLECTION2);
|
|
12468
12734
|
return issueDelegation2(
|
|
@@ -13453,7 +13719,23 @@ var PERSONAL_POLICY = Object.freeze({
|
|
|
13453
13719
|
gates: {
|
|
13454
13720
|
"rotate-passphrase": {
|
|
13455
13721
|
minTier: 1,
|
|
13456
|
-
|
|
13722
|
+
// Any second factor satisfies the gate — off-device kinds (TOTP,
|
|
13723
|
+
// email-OTP, paper recovery, roaming WebAuthn) are the strongest;
|
|
13724
|
+
// platform-bound kinds (platform WebAuthn, password, PIN) are
|
|
13725
|
+
// accepted because requiring "something off-device" is overkill
|
|
13726
|
+
// for personal/SMB threat models. Consumers needing the off-device
|
|
13727
|
+
// guarantee should use STRICT_POLICY or override this gate.
|
|
13728
|
+
factors: [{
|
|
13729
|
+
anyOf: [
|
|
13730
|
+
"totp",
|
|
13731
|
+
"email-otp",
|
|
13732
|
+
"recovery",
|
|
13733
|
+
"webauthn-roaming",
|
|
13734
|
+
"webauthn-platform",
|
|
13735
|
+
"password",
|
|
13736
|
+
"pin"
|
|
13737
|
+
]
|
|
13738
|
+
}]
|
|
13457
13739
|
},
|
|
13458
13740
|
"recover-passphrase": {
|
|
13459
13741
|
minTier: 1,
|
|
@@ -13461,9 +13743,27 @@ var PERSONAL_POLICY = Object.freeze({
|
|
|
13461
13743
|
},
|
|
13462
13744
|
"enroll-authenticator": { minTier: 1 },
|
|
13463
13745
|
"remove-authenticator": { minTier: 1 },
|
|
13746
|
+
// update-authenticator: meta-only mutation (slot rename, label
|
|
13747
|
+
// changes). Symmetric with enroll/remove under PERSONAL — tier-1
|
|
13748
|
+
// unlock alone. The structural anti-slot-swap guard inside the
|
|
13749
|
+
// implementation enforces wrap-material/id/method immutability
|
|
13750
|
+
// regardless of this gate's settings.
|
|
13751
|
+
"update-authenticator": { minTier: 1 },
|
|
13464
13752
|
"rotate-unlock": { minTier: 2 },
|
|
13465
13753
|
"enroll-user": { minTier: 1 },
|
|
13466
13754
|
"revoke-user": { minTier: 1 },
|
|
13755
|
+
// Peer-recovery is a high-trust intentional op — co-owners
|
|
13756
|
+
// recovering each other should not need an off-device factor in
|
|
13757
|
+
// the personal/SMB threat model (the partner is already vetted by
|
|
13758
|
+
// virtue of being a co-owner). Tier-1 unlock is the floor; the
|
|
13759
|
+
// STRICT preset adds a recovery/email-OTP requirement.
|
|
13760
|
+
"peer-recover-user": { minTier: 1 },
|
|
13761
|
+
// update-user: post-grant identity mutation (role/displayName/
|
|
13762
|
+
// permissions). PERSONAL_POLICY treats this on par with enroll-user
|
|
13763
|
+
// / revoke-user — tier-1 unlock alone. The role-elevation guard
|
|
13764
|
+
// inside the implementation is the structural backstop that this
|
|
13765
|
+
// gate's settings cannot weaken.
|
|
13766
|
+
"update-user": { minTier: 1 },
|
|
13467
13767
|
"export-bundle": { minTier: 1 },
|
|
13468
13768
|
"export-plaintext": {
|
|
13469
13769
|
minTier: 1,
|
|
@@ -13508,6 +13808,15 @@ var STRICT_POLICY = Object.freeze({
|
|
|
13508
13808
|
minTier: 1,
|
|
13509
13809
|
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
13510
13810
|
},
|
|
13811
|
+
// STRICT update-authenticator: same factor floor as enroll/remove.
|
|
13812
|
+
// Even though meta changes don't touch wrap material, a malicious
|
|
13813
|
+
// rename could mislead the user about which device a slot
|
|
13814
|
+
// corresponds to ("MacBook Touch ID" → "iPhone Touch ID" on a
|
|
13815
|
+
// shared workstation). STRICT requires a fresh factor proof.
|
|
13816
|
+
"update-authenticator": {
|
|
13817
|
+
minTier: 1,
|
|
13818
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
13819
|
+
},
|
|
13511
13820
|
"rotate-unlock": { minTier: 1 },
|
|
13512
13821
|
"enroll-user": {
|
|
13513
13822
|
minTier: 1,
|
|
@@ -13517,6 +13826,31 @@ var STRICT_POLICY = Object.freeze({
|
|
|
13517
13826
|
minTier: 1,
|
|
13518
13827
|
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
13519
13828
|
},
|
|
13829
|
+
// STRICT peer-recovery: the issuer must present a recovery code
|
|
13830
|
+
// OR a fresh off-device second factor at the moment of recovery.
|
|
13831
|
+
// This binds the high-trust operation to a verifiable proof
|
|
13832
|
+
// (recovery sheet photographed by an attacker won't suffice —
|
|
13833
|
+
// they'd also need tier-1 unlock first; this gate is the freshness
|
|
13834
|
+
// binding on top). Roaming WebAuthn (YubiKey-class hardware key)
|
|
13835
|
+
// accepted; platform-bound kinds (Touch ID, password, PIN)
|
|
13836
|
+
// intentionally excluded under STRICT because they don't survive
|
|
13837
|
+
// device theft — the off-device requirement is the whole point.
|
|
13838
|
+
"peer-recover-user": {
|
|
13839
|
+
minTier: 1,
|
|
13840
|
+
factors: [{ anyOf: ["recovery", "totp", "email-otp", "webauthn-roaming"] }]
|
|
13841
|
+
},
|
|
13842
|
+
// STRICT update-user: matches the enroll-user / revoke-user shape
|
|
13843
|
+
// (off-device factor required). Update-user is admin-shaped — it
|
|
13844
|
+
// mutates someone else's role/permissions; STRICT requires a fresh
|
|
13845
|
+
// off-device factor proof so the operator affirmatively re-asserts
|
|
13846
|
+
// identity at the moment of mutation. Platform-bound factors
|
|
13847
|
+
// (Touch ID / password / PIN) intentionally excluded: same logic as
|
|
13848
|
+
// peer-recover-user — the off-device requirement is the whole
|
|
13849
|
+
// point under STRICT.
|
|
13850
|
+
"update-user": {
|
|
13851
|
+
minTier: 1,
|
|
13852
|
+
factors: [{ anyOf: ["totp", "email-otp"] }]
|
|
13853
|
+
},
|
|
13520
13854
|
"export-bundle": {
|
|
13521
13855
|
minTier: 1,
|
|
13522
13856
|
factors: [{ anyOf: ["totp", "email-otp"] }],
|
|
@@ -13911,6 +14245,56 @@ var Noydb = class {
|
|
|
13911
14245
|
const keyring = await this.getKeyring(vault);
|
|
13912
14246
|
await revoke(this.options.store, vault, keyring, options);
|
|
13913
14247
|
}
|
|
14248
|
+
/**
|
|
14249
|
+
* Mutate post-grant identity fields on an existing keyring — `role`,
|
|
14250
|
+
* `displayName`, and/or `permissions`. Pure plaintext-header rewrite:
|
|
14251
|
+
* no DEK rewrap, no KEK required, no authenticator slots touched.
|
|
14252
|
+
* Tier-2 enrollments and recovery codes survive.
|
|
14253
|
+
*
|
|
14254
|
+
* Different from `db.revoke + db.grant`:
|
|
14255
|
+
*
|
|
14256
|
+
* - Same `userId`, same DEK wrappings, same `granted_by`, same
|
|
14257
|
+
* `_users/<keyringId>` envelope. Only the specified header
|
|
14258
|
+
* fields move. Last-write-wins via the standard keyring put.
|
|
14259
|
+
* - No cascade on role demotion (admins demoted to operator keep
|
|
14260
|
+
* the keyrings they previously granted; the cascade rules are
|
|
14261
|
+
* a `db.revoke` concern, not `db.updateUser`).
|
|
14262
|
+
* - Tier-2 slots NOT dropped — the wrapping is unaffected.
|
|
14263
|
+
*
|
|
14264
|
+
* Role-elevation guard: BOTH the old and new role must satisfy
|
|
14265
|
+
* `db.grant`'s hierarchy. Owner can do anything; admin manages
|
|
14266
|
+
* admin/operator/viewer/client laterally; admin cannot promote to
|
|
14267
|
+
* owner OR demote from owner. The guard runs regardless of the
|
|
14268
|
+
* `update-user` policy gate's settings — gates can only be more
|
|
14269
|
+
* permissive than the structural floor, never less.
|
|
14270
|
+
*
|
|
14271
|
+
* Gated by `update-user`. `STRICT_POLICY` requires a TOTP/email-OTP
|
|
14272
|
+
* factor proof so the operator affirmatively re-asserts identity at
|
|
14273
|
+
* the moment of mutation; `PERSONAL_POLICY` accepts a tier-1 unlock
|
|
14274
|
+
* alone.
|
|
14275
|
+
*
|
|
14276
|
+
* ```ts
|
|
14277
|
+
* await db.updateUser('acme', {
|
|
14278
|
+
* userId: 'bob',
|
|
14279
|
+
* role: 'operator', // promote
|
|
14280
|
+
* permissions: { invoices: 'rw' },
|
|
14281
|
+
* }, { factors: [{ kind: 'totp' }] })
|
|
14282
|
+
* ```
|
|
14283
|
+
*
|
|
14284
|
+
* @throws `NoAccessError` when no keyring exists for the target.
|
|
14285
|
+
* @throws `PermissionDeniedError` when the role hierarchy rejects.
|
|
14286
|
+
* @throws `ValidationError` when no field is provided.
|
|
14287
|
+
*
|
|
14288
|
+
* @see #54
|
|
14289
|
+
*/
|
|
14290
|
+
async updateUser(vault, options, factors) {
|
|
14291
|
+
await this.checkGate(vault, "update-user", factors);
|
|
14292
|
+
const keyring = await this.getKeyring(vault);
|
|
14293
|
+
await updateKeyringIdentity(this.options.store, vault, keyring, options);
|
|
14294
|
+
if (options.userId === this.options.user) {
|
|
14295
|
+
this.keyringCache.delete(vault);
|
|
14296
|
+
}
|
|
14297
|
+
}
|
|
13914
14298
|
/**
|
|
13915
14299
|
* Rotate the DEKs for the given collections in a vault.
|
|
13916
14300
|
*
|
|
@@ -14454,6 +14838,40 @@ var Noydb = class {
|
|
|
14454
14838
|
const keyring = await this.getKeyring(vault);
|
|
14455
14839
|
return keyring.authenticators;
|
|
14456
14840
|
}
|
|
14841
|
+
/**
|
|
14842
|
+
* Mutate the `meta` blob on an existing authenticator slot — slot
|
|
14843
|
+
* rename, label change, attachment of UI hints. The slot's `id`,
|
|
14844
|
+
* `method`, and wrap material (`wrapped_kek` / `wrapped_deks` + `iv`)
|
|
14845
|
+
* are immutable through this method. Anti-slot-swap is structural,
|
|
14846
|
+
* not gate-driven.
|
|
14847
|
+
*
|
|
14848
|
+
* `meta` patch semantics (#57-aligned):
|
|
14849
|
+
* - Top-level merge — absent keys preserved
|
|
14850
|
+
* - `null` value — delete that meta key
|
|
14851
|
+
* - Other values — replace verbatim
|
|
14852
|
+
*
|
|
14853
|
+
* Use case: per-slot nickname for "iPhone Touch ID" vs "MacBook
|
|
14854
|
+
* Touch ID" disambiguation in admin UIs. The slot id (auto-derived
|
|
14855
|
+
* from credentialId prefix) is not human-friendly; `meta.nickname`
|
|
14856
|
+
* is.
|
|
14857
|
+
*
|
|
14858
|
+
* Gated by `update-authenticator`. PERSONAL_POLICY: tier-1 unlock
|
|
14859
|
+
* alone (matches enroll/remove). STRICT_POLICY: tier-1 +
|
|
14860
|
+
* TOTP/email-OTP factor proof — a malicious rename on a shared
|
|
14861
|
+
* workstation could mislead the user about which device a slot
|
|
14862
|
+
* corresponds to, so STRICT requires fresh factor binding.
|
|
14863
|
+
*
|
|
14864
|
+
* @throws `NoAccessError` when no slot with the given id exists.
|
|
14865
|
+
* @throws `ValidationError` when no patch field is provided.
|
|
14866
|
+
*
|
|
14867
|
+
* @see #55
|
|
14868
|
+
*/
|
|
14869
|
+
async updateAuthenticator(vault, slotId, options, presented) {
|
|
14870
|
+
await this.checkGate(vault, "update-authenticator", presented);
|
|
14871
|
+
const keyring = await this.getKeyring(vault);
|
|
14872
|
+
const next = await updateAuthenticator(this.options.store, vault, keyring, slotId, options);
|
|
14873
|
+
this.keyringCache.set(vault, next);
|
|
14874
|
+
}
|
|
14457
14875
|
/**
|
|
14458
14876
|
* Native WebAuthn enrollment using the **real** internal keyring (#16).
|
|
14459
14877
|
*
|
|
@@ -14663,22 +15081,108 @@ var Noydb = class {
|
|
|
14663
15081
|
async recoverPassphrase(vault, input, factors) {
|
|
14664
15082
|
await this.checkGate(vault, "recover-passphrase", factors);
|
|
14665
15083
|
const userId = this.options.user;
|
|
15084
|
+
const entriesBeforeRecovery = await loadPaperRecoveryEntries(this.options.store, vault);
|
|
14666
15085
|
const next = await recoverPassphrase(this.options.store, vault, userId, input);
|
|
14667
15086
|
this.keyringCache.set(vault, next);
|
|
15087
|
+
const rotateRemaining = input.rotateRemainingCodes ?? true;
|
|
15088
|
+
const remainingAfterBurn = Math.max(0, entriesBeforeRecovery.length - 1);
|
|
15089
|
+
if (!rotateRemaining || remainingAfterBurn === 0) {
|
|
15090
|
+
return { newCodes: [] };
|
|
15091
|
+
}
|
|
15092
|
+
const codeGen = input.codeGenerator ?? generateULID;
|
|
15093
|
+
const newCodeCount = input.newCodeCount ?? remainingAfterBurn;
|
|
15094
|
+
const codes = [];
|
|
15095
|
+
const newEntries = [];
|
|
15096
|
+
for (let i = 0; i < newCodeCount; i++) {
|
|
15097
|
+
const rawCode = codeGen();
|
|
15098
|
+
const entry = await mintPaperRecoveryEntry(next.deks, rawCode, generateULID());
|
|
15099
|
+
codes.push(rawCode);
|
|
15100
|
+
newEntries.push(entry);
|
|
15101
|
+
}
|
|
15102
|
+
await savePaperRecoveryEntries(this.options.store, vault, newEntries);
|
|
15103
|
+
return { newCodes: codes };
|
|
15104
|
+
}
|
|
15105
|
+
/**
|
|
15106
|
+
* Atomic peer-recovery — re-wraps an EXISTING user's keyring under
|
|
15107
|
+
* a fresh temp passphrase in a single store write. Closes #34's
|
|
15108
|
+
* partial-failure window (the previous compose-from-primitives
|
|
15109
|
+
* pattern was `db.revoke + db.grant`, two writes — if the issuer
|
|
15110
|
+
* cancelled between them the target was locked out entirely).
|
|
15111
|
+
*
|
|
15112
|
+
* Different from `db.revoke + db.grant`:
|
|
15113
|
+
*
|
|
15114
|
+
* - Same `userId`, role, permissions, capabilities preserved.
|
|
15115
|
+
* - DEKs unchanged → every other principal in the vault keeps
|
|
15116
|
+
* access. No key rotation.
|
|
15117
|
+
* - Allows owner→owner natively (#33). The existing
|
|
15118
|
+
* `db.revoke` retains its block — peer-recovery is a separate,
|
|
15119
|
+
* intentionally-named operation.
|
|
15120
|
+
* - Tier-2 slots dropped (they wrap the old KEK).
|
|
15121
|
+
*
|
|
15122
|
+
* Gated by `peer-recover-user`; `STRICT_POLICY` requires a
|
|
15123
|
+
* recovery / TOTP / email-OTP factor proof at the moment of
|
|
15124
|
+
* recovery, so the issuer affirmatively re-asserts identity.
|
|
15125
|
+
*
|
|
15126
|
+
* The recipient should call `db.rotatePassphrase` on first session
|
|
15127
|
+
* to choose their own phrase — the temp acts as a single-use
|
|
15128
|
+
* bridge.
|
|
15129
|
+
*
|
|
15130
|
+
* ```ts
|
|
15131
|
+
* await db.recoverUser('acme', {
|
|
15132
|
+
* userId: 'bob',
|
|
15133
|
+
* passphrase: 'temporary-correct-horse-battery-staple-printer',
|
|
15134
|
+
* }, { factors: [{ kind: 'recovery' }] })
|
|
15135
|
+
* // Bob opens createNoydb({ user: 'bob', secret: tempPhrase })
|
|
15136
|
+
* // and immediately calls db.rotatePassphrase to set his own.
|
|
15137
|
+
* ```
|
|
15138
|
+
*
|
|
15139
|
+
* @throws `NoAccessError` when no keyring exists for the target.
|
|
15140
|
+
* @throws `PermissionDeniedError` when the caller's role can't
|
|
15141
|
+
* recover the target's role (admin→owner is blocked even
|
|
15142
|
+
* under recovery).
|
|
15143
|
+
* @throws `PrivilegeEscalationError` when the caller lacks a DEK
|
|
15144
|
+
* the target previously had access to.
|
|
15145
|
+
*
|
|
15146
|
+
* @see #33 #34 — the issues this method closes.
|
|
15147
|
+
*/
|
|
15148
|
+
async recoverUser(vault, options, factors) {
|
|
15149
|
+
await this.checkGate(vault, "peer-recover-user", factors);
|
|
15150
|
+
const callerKeyring = await this.getKeyring(vault);
|
|
15151
|
+
await recoverUser(this.options.store, vault, callerKeyring, options);
|
|
15152
|
+
if (options.userId === this.options.user) {
|
|
15153
|
+
this.keyringCache.delete(vault);
|
|
15154
|
+
}
|
|
14668
15155
|
}
|
|
14669
15156
|
/**
|
|
14670
15157
|
* Persist a recovery enrollment. v0.1.0-pre.5 accepts the `'paper'`
|
|
14671
|
-
* profile
|
|
14672
|
-
*
|
|
14673
|
-
*
|
|
14674
|
-
*
|
|
15158
|
+
* profile.
|
|
15159
|
+
*
|
|
15160
|
+
* The hub wraps the user's DEK set (not the KEK) under a code-derived
|
|
15161
|
+
* AES-GCM key — see `team/recovery.ts` for the rationale. The mint
|
|
15162
|
+
* helper {@link mintPaperRecoveryEntry} is the canonical primitive;
|
|
15163
|
+
* pair it with `db.getKeyring(vault)` to obtain the live DEK set:
|
|
14675
15164
|
*
|
|
14676
15165
|
* ```ts
|
|
14677
|
-
* import {
|
|
14678
|
-
*
|
|
15166
|
+
* import { mintPaperRecoveryEntry } from '@noy-db/hub'
|
|
15167
|
+
*
|
|
15168
|
+
* const keyring = await db.getKeyring('acme')
|
|
15169
|
+
* const codes: string[] = ['CORRECT-HORSE-1', 'BATTERY-STAPLE-2', ...]
|
|
15170
|
+
* const entries = await Promise.all(
|
|
15171
|
+
* codes.map((code, i) => mintPaperRecoveryEntry(keyring.deks, code, `code-${i}`)),
|
|
15172
|
+
* )
|
|
14679
15173
|
* await db.enrollRecovery('acme', { profile: 'paper', entries })
|
|
14680
15174
|
* showCodesToUser(codes)
|
|
14681
15175
|
* ```
|
|
15176
|
+
*
|
|
15177
|
+
* As of pre.8, `@noy-db/on-recovery`'s `generateRecoveryCodeSet`
|
|
15178
|
+
* delegates to `mintPaperRecoveryEntry` internally — its output is
|
|
15179
|
+
* fed directly to this API. Pick whichever fits your code-gen layer:
|
|
15180
|
+
*
|
|
15181
|
+
* ```ts
|
|
15182
|
+
* import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
|
|
15183
|
+
* const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 8 })
|
|
15184
|
+
* await db.enrollRecovery('acme', { profile: 'paper', entries })
|
|
15185
|
+
* ```
|
|
14682
15186
|
*/
|
|
14683
15187
|
async enrollRecovery(vault, enrollment) {
|
|
14684
15188
|
if (enrollment.profile !== "paper") {
|
|
@@ -14732,7 +15236,29 @@ var Noydb = class {
|
|
|
14732
15236
|
clearQuickUnlock(vault) {
|
|
14733
15237
|
this.quickUnlock.delete(vault);
|
|
14734
15238
|
}
|
|
14735
|
-
/**
|
|
15239
|
+
/**
|
|
15240
|
+
* Public accessor for the unlocked keyring of a vault — issue #28.
|
|
15241
|
+
*
|
|
15242
|
+
* Returns the cached `UnlockedKeyring` (already in memory after
|
|
15243
|
+
* `createNoydb` + first vault touch); loads it on demand if absent.
|
|
15244
|
+
* Used by `@noy-db/on-*` ceremonies that need the live DEK set
|
|
15245
|
+
* (paper recovery via {@link mintPaperRecoveryEntry}, tier-3 PIN
|
|
15246
|
+
* enrolment via on-pin's `enrollPin`, custom on-* ceremonies that
|
|
15247
|
+
* don't have a hub-side wrapper).
|
|
15248
|
+
*
|
|
15249
|
+
* No new permission gate — this is an accessor over already-unlocked
|
|
15250
|
+
* state. The keyring is materialized only after the calling session
|
|
15251
|
+
* has unlocked the vault at tier 1, 2, or 3, so exposing it does not
|
|
15252
|
+
* widen access. Throws `ValidationError` when encryption is enabled
|
|
15253
|
+
* and no `secret` / `getKeyring` is configured.
|
|
15254
|
+
*
|
|
15255
|
+
* ```ts
|
|
15256
|
+
* const keyring = await db.getKeyring('acme')
|
|
15257
|
+
* // keyring.deks: Map<collection, CryptoKey>
|
|
15258
|
+
* // keyring.kek: CryptoKey (non-extractable; null for tier-3 sessions)
|
|
15259
|
+
* // keyring.role / .permissions / .authenticators
|
|
15260
|
+
* ```
|
|
15261
|
+
*/
|
|
14736
15262
|
async getKeyring(vault) {
|
|
14737
15263
|
if (this.options.encrypt === false) {
|
|
14738
15264
|
return createPlaintextKeyring(this.options.user);
|
|
@@ -16655,6 +17181,8 @@ function shortJSON(value) {
|
|
|
16655
17181
|
mergeCrdtStates,
|
|
16656
17182
|
mergePolicy,
|
|
16657
17183
|
min,
|
|
17184
|
+
mintPaperRecoveryEntry,
|
|
17185
|
+
mintWrappedDeksBlob,
|
|
16658
17186
|
paddedIndex,
|
|
16659
17187
|
parseBytes,
|
|
16660
17188
|
parseIndex,
|
|
@@ -16665,6 +17193,7 @@ function shortJSON(value) {
|
|
|
16665
17193
|
readNoydbBundlePublicEnvelope,
|
|
16666
17194
|
readPath,
|
|
16667
17195
|
readPublicEnvelope,
|
|
17196
|
+
recoverUser,
|
|
16668
17197
|
reduceRecords,
|
|
16669
17198
|
ref,
|
|
16670
17199
|
removeAuthenticator,
|
|
@@ -16686,6 +17215,8 @@ function shortJSON(value) {
|
|
|
16686
17215
|
saveVaultPolicy,
|
|
16687
17216
|
sha256Hex,
|
|
16688
17217
|
sum,
|
|
17218
|
+
unwrapDeksFromBlob,
|
|
17219
|
+
unwrapDeksFromPaperEntry,
|
|
16689
17220
|
unwrapMagicLinkGrant,
|
|
16690
17221
|
validateI18nTextValue,
|
|
16691
17222
|
validatePassphrase,
|