@jwilleke/ngdpbase-types 5.39.0 → 5.41.0

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 (99) hide show
  1. package/lib/packages/ngdpbase-types/index.d.ts +1 -1
  2. package/lib/src/context/ApiContext.d.ts +3 -2
  3. package/lib/src/context/ApiContext.js +4 -2
  4. package/lib/src/ice/fileHandling.d.ts +6 -1
  5. package/lib/src/ice/givenName.d.ts +19 -0
  6. package/lib/src/ice/runExport.d.ts +41 -0
  7. package/lib/src/ice/runImport.d.ts +37 -8
  8. package/lib/src/ice/shims/csv.d.ts +24 -0
  9. package/lib/src/ice/shims/export.d.ts +23 -0
  10. package/lib/src/ice/shims/index.d.ts +3 -1
  11. package/lib/src/ice/shims/svg.d.ts +27 -0
  12. package/lib/src/ice/shims/takeout.d.ts +83 -0
  13. package/lib/src/ice/types.d.ts +301 -5
  14. package/lib/src/ice/vet.d.ts +19 -0
  15. package/lib/src/managers/AttachmentManager.d.ts +100 -36
  16. package/lib/src/managers/AuthManager.d.ts +152 -12
  17. package/lib/src/managers/AuthManager.js +3830 -0
  18. package/lib/src/managers/BackgroundJobManager.d.ts +25 -0
  19. package/lib/src/managers/BackupManager.d.ts +24 -0
  20. package/lib/src/managers/BackupManager.js +48 -1
  21. package/lib/src/managers/ExportManager.d.ts +16 -0
  22. package/lib/src/managers/ExportManager.js +25 -0
  23. package/lib/src/managers/FileManager.d.ts +182 -0
  24. package/lib/src/managers/ImportManager.d.ts +279 -16
  25. package/lib/src/managers/MediaManager.d.ts +2 -0
  26. package/lib/src/managers/PageManager.d.ts +53 -0
  27. package/lib/src/managers/SearchManager.d.ts +4 -0
  28. package/lib/src/managers/SharedFolderManager.d.ts +320 -0
  29. package/lib/src/managers/ValidationManager.d.ts +5 -2
  30. package/lib/src/middleware/csrf.js +99 -0
  31. package/lib/src/providers/AgentTokenAuthProvider.js +46 -0
  32. package/lib/src/providers/AuthentikBearerAuthProvider.js +176 -0
  33. package/lib/src/providers/BaseCredentialsProvider.js +21 -0
  34. package/lib/src/providers/BaseMediaProvider.d.ts +4 -0
  35. package/lib/src/providers/BasePageProvider.d.ts +8 -0
  36. package/lib/src/providers/CloudflareAccessAuthProvider.js +160 -0
  37. package/lib/src/providers/FileCredentialsProvider.js +188 -0
  38. package/lib/src/providers/FileSystemMediaProvider.d.ts +6 -0
  39. package/lib/src/providers/MagicLinkAuthProvider.js +333 -0
  40. package/lib/src/providers/OidcAuthProvider.js +350 -0
  41. package/lib/src/providers/OidcBearerAuthProvider.js +27 -0
  42. package/lib/src/providers/PasskeyAuthProvider.js +147 -0
  43. package/lib/src/providers/PasswordAuthProvider.js +44 -0
  44. package/lib/src/security/PolicyDecisionPoint.d.ts +3 -2
  45. package/lib/src/security/pageActions.js +43 -0
  46. package/lib/src/security/permissions.generated.d.ts +7 -3
  47. package/lib/src/types/File.d.ts +90 -0
  48. package/lib/src/types/Provider.d.ts +15 -0
  49. package/lib/src/types/express.d.ts +7 -1
  50. package/lib/src/types/guards.js +366 -0
  51. package/lib/src/types/linkedom.d.ts +2 -0
  52. package/lib/src/utils/atomicWrite.js +129 -0
  53. package/lib/src/utils/auditEventNames.generated.d.ts +12 -0
  54. package/lib/src/utils/auditEventNames.generated.js +12 -0
  55. package/lib/src/utils/auditEvents.d.ts +15 -0
  56. package/lib/src/utils/auditEvents.js +17 -1
  57. package/lib/src/utils/bip39English.js +2 -0
  58. package/lib/src/utils/browserDescription.js +28 -0
  59. package/lib/src/utils/delegation.js +79 -0
  60. package/lib/src/utils/deriveUsername.js +38 -0
  61. package/lib/src/utils/emailForLog.js +28 -0
  62. package/lib/src/utils/fileMigrationPlan.d.ts +81 -0
  63. package/lib/src/utils/instanceEnvBackup.d.ts +6 -0
  64. package/lib/src/utils/instanceEnvBackup.js +13 -0
  65. package/lib/src/utils/ipLocation.js +106 -0
  66. package/lib/src/utils/magicLinkDeviceState.js +97 -0
  67. package/lib/src/utils/pageUrl.d.ts +1 -0
  68. package/lib/src/utils/passwordHash.js +150 -0
  69. package/lib/src/utils/permissionCatalog.js +102 -0
  70. package/lib/src/utils/privateStoreAccess.d.ts +25 -0
  71. package/lib/src/utils/privateStoreAccess.js +22 -1
  72. package/lib/src/utils/privateStoreCatalogs.js +57 -0
  73. package/lib/src/utils/privateStoreCrypto.js +266 -0
  74. package/lib/src/utils/privateStoreDoor.d.ts +10 -0
  75. package/lib/src/utils/privateStoreDoor.js +298 -0
  76. package/lib/src/utils/privateStoreImport.d.ts +0 -10
  77. package/lib/src/utils/privateStoreMeta.js +123 -0
  78. package/lib/src/utils/privateStorePath.d.ts +37 -1
  79. package/lib/src/utils/privateStorePath.js +60 -3
  80. package/lib/src/utils/privateStoreTakeout.js +149 -0
  81. package/lib/src/utils/privateStoreUnlock.d.ts +3 -0
  82. package/lib/src/utils/privateStoreUnlock.js +499 -0
  83. package/lib/src/utils/recoveryWordsFile.d.ts +54 -0
  84. package/lib/src/utils/safeRedirect.js +55 -0
  85. package/lib/src/utils/sessionAccount.js +55 -0
  86. package/lib/src/utils/sessionEnd.d.ts +1 -0
  87. package/lib/src/utils/sessionEnd.js +42 -0
  88. package/lib/src/utils/sessionGeneration.js +29 -0
  89. package/lib/src/utils/sessionIdle.d.ts +4 -0
  90. package/lib/src/utils/sessionIdle.js +72 -0
  91. package/lib/src/utils/sessionLimits.js +72 -0
  92. package/lib/src/utils/sessionSecurity.js +122 -0
  93. package/lib/src/utils/shareLockbox.js +101 -0
  94. package/lib/src/utils/sharedFolderAccess.d.ts +57 -0
  95. package/lib/src/utils/sharedFolderAccess.js +80 -0
  96. package/lib/src/utils/userCreateError.js +82 -0
  97. package/lib/src/utils/username.d.ts +2 -0
  98. package/lib/src/utils/username.js +105 -0
  99. package/package.json +2 -1
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Reading a private store back out, byte for byte (#1387, epic #1382).
3
+ *
4
+ * Two features need the same thing and must not grow two answers to it:
5
+ *
6
+ * - the instance backup, which copies every store so a disk failure is
7
+ * survivable, and
8
+ * - a user's own download, which is how someone leaves with their data.
9
+ *
10
+ * What they share is this module: walk a store directory and hand back every
11
+ * file in it as bytes. Packaging and permission are the callers' business.
12
+ *
13
+ * ## Bytes, never text
14
+ *
15
+ * `FileSystemProvider.backup()` reads pages with an encoding, which is right
16
+ * for a public page and destroys a private one: a sealed store's `.md` files
17
+ * are ciphertext, and decoding them as UTF-8 substitutes U+FFFD for every byte
18
+ * that is not valid UTF-8. The file would still be in the backup, and would
19
+ * still look like a file, and would never decrypt again.
20
+ *
21
+ * So everything here is `Buffer`. A caller that wants text can decode what it
22
+ * knows to be text; nothing in this module ever does.
23
+ *
24
+ * ## The whole store, including its keys
25
+ *
26
+ * A store is self-contained by design (see `privateStorePath`), so a takeout is
27
+ * "copy this directory" — pages, their versions, the trash, the attachments and
28
+ * every index. The user's `user-keys.json` is copied too, deliberately: it is a
29
+ * WRAPPED key, useless without the password or the twelve words, and without it
30
+ * a restored sealed store could never be opened again by anyone, including its
31
+ * owner. Copying the wrapped key does not make the copier a keyholder.
32
+ */
33
+ import path from 'path';
34
+ import fs from 'fs-extra';
35
+ import { vanishedDuringBackup } from './treeArchive.js';
36
+ import { privateUserDir, privateStoreRoot, resolvePrivateStoreLayout } from './privateStorePath.js';
37
+ /** Recursively list every file under `dir`, relative to it, `/`-separated. */
38
+ async function walkFiles(dir, prefix = '') {
39
+ let entries;
40
+ try {
41
+ entries = await fs.readdir(dir, { withFileTypes: true });
42
+ }
43
+ catch (err) {
44
+ // Not there (any more): nothing to list. Any other error is a real one (#1563).
45
+ if (vanishedDuringBackup(err))
46
+ return [];
47
+ throw err;
48
+ }
49
+ const out = [];
50
+ for (const entry of entries) {
51
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
52
+ if (entry.isDirectory()) {
53
+ out.push(...await walkFiles(path.join(dir, entry.name), rel));
54
+ }
55
+ else if (entry.isFile()) {
56
+ out.push(rel);
57
+ }
58
+ // Anything else — a symlink, a socket — is not a store file. Following a
59
+ // symlink here would copy whatever it points at into a takeout.
60
+ }
61
+ return out;
62
+ }
63
+ /** Read the named files under `root` as bytes, skipping any that vanish mid-walk. */
64
+ async function readAll(root, relPaths) {
65
+ const files = [];
66
+ for (const rel of relPaths) {
67
+ const abs = path.join(root, ...rel.split('/'));
68
+ try {
69
+ const [bytes, stat] = await Promise.all([fs.readFile(abs), fs.stat(abs)]);
70
+ files.push({ path: rel, bytes, mtime: stat.mtime });
71
+ }
72
+ catch (err) {
73
+ // A page purged while the walk was running is not an error: the takeout
74
+ // is a point-in-time copy, and the file is simply no longer part of it.
75
+ // Any other error (EACCES, EIO) is, and is not hidden (#1563).
76
+ if (!vanishedDuringBackup(err))
77
+ throw err;
78
+ }
79
+ }
80
+ return files;
81
+ }
82
+ /**
83
+ * Every file of one store, as bytes.
84
+ *
85
+ * Returns an empty file list when the store does not exist, so a caller can ask
86
+ * about a store without first proving it is there.
87
+ */
88
+ export async function collectStore(pagesDirectory, owner, store, layout, skip) {
89
+ const root = privateStoreRoot(pagesDirectory, owner, store, layout);
90
+ // `skip` leaves a file out unread (#1876: the instance backup leaves the
91
+ // vault's attachments to AttachmentManager, so they are never read here).
92
+ const files = await readAll(root, (await walkFiles(root)).filter((rel) => !skip?.(rel)));
93
+ return {
94
+ owner,
95
+ store,
96
+ files,
97
+ totalBytes: files.reduce((sum, f) => sum + f.bytes.length, 0)
98
+ };
99
+ }
100
+ /** The store ids a user has on disk, in directory order. */
101
+ export async function listStoreIds(pagesDirectory, owner, layout) {
102
+ const userDir = privateUserDir(pagesDirectory, owner, layout);
103
+ try {
104
+ const entries = await fs.readdir(userDir, { withFileTypes: true });
105
+ return entries.filter(e => e.isDirectory()).map(e => e.name);
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ }
111
+ /** The users who have a private directory on disk, in directory order. */
112
+ export async function listPrivateOwners(pagesDirectory, layout) {
113
+ const L = resolvePrivateStoreLayout(layout);
114
+ try {
115
+ const entries = await fs.readdir(path.join(pagesDirectory, L.privateRoot), { withFileTypes: true });
116
+ return entries.filter(e => e.isDirectory()).map(e => e.name);
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ }
122
+ /**
123
+ * Everything one user keeps privately: each of their stores, plus the files
124
+ * that sit at the user level beside them (`user-keys.json` and any legacy
125
+ * catalogue still there).
126
+ *
127
+ * This is the unit both callers work in — an instance backup asks for it once
128
+ * per owner, and a user's own download asks for it once, for themselves.
129
+ */
130
+ export async function collectUser(pagesDirectory, owner, layout, skip) {
131
+ const userDir = privateUserDir(pagesDirectory, owner, layout);
132
+ // Files directly in the user directory — not the store directories.
133
+ let topLevel;
134
+ try {
135
+ const entries = await fs.readdir(userDir, { withFileTypes: true });
136
+ topLevel = entries.filter(e => e.isFile()).map(e => e.name);
137
+ }
138
+ catch {
139
+ topLevel = [];
140
+ }
141
+ const userFiles = await readAll(userDir, topLevel);
142
+ const stores = [];
143
+ for (const store of await listStoreIds(pagesDirectory, owner, layout)) {
144
+ stores.push(await collectStore(pagesDirectory, owner, store, layout, skip));
145
+ }
146
+ const totalBytes = userFiles.reduce((sum, f) => sum + f.bytes.length, 0) +
147
+ stores.reduce((sum, s) => sum + s.totalBytes, 0);
148
+ return { owner, userFiles, stores, totalBytes };
149
+ }
@@ -123,6 +123,9 @@ export declare function newPrivateStoreHandle(): string;
123
123
  * owner's own bag — DEKs are keyed by store id, and every user has a
124
124
  * `default` — or, for someone acting for the owner, from the key copy their
125
125
  * grant carries (#1762 slice 5).
126
+ *
127
+ * A shared folder's key is the server's, never in a bag (#2125): handed only
128
+ * to a context the PDP has permitted on that folder (`sharedFolderKeyFor`).
126
129
  */
127
130
  export declare function dekFor(ctx: ActorContext | undefined, owner: string, store: string): Buffer | undefined;
128
131
  /**
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Process-level unlock bag for private-store keys (#1384, #1391).
3
+ *
4
+ * Keyed by an opaque random handle — never the session id — created at
5
+ * password login ({@link newPrivateStoreHandle}), stored on the session as
6
+ * `privateStoreHandle`, and carried on the request subject so a caller reaches
7
+ * its keys through the context it was given ({@link dekFor}; security-posture
8
+ * P1). Key bytes never go into express-session JSON, a context, or a record.
9
+ * Logout calls {@link lockPrivateStores}.
10
+ *
11
+ * There is no ambient slot: `AsyncLocalStorage` is refused by P1 because the
12
+ * call site does not show what identity it runs under. Every caller reaches
13
+ * its keys through the context it was handed.
14
+ */
15
+ import { randomUUID } from 'node:crypto';
16
+ import fs from 'fs-extra';
17
+ import logger from './logger.js';
18
+ import { assertEncryptedStoreWritable, newPersonKey, openKekCopy, personPublicKey, recoveryWordsId, rewrapPassword, sealKekCopy, rewrapPasswordWithKek, unwrapDek, unwrapKekWithMnemonic, unwrapKekWithPassword } from './privateStoreCrypto.js';
19
+ import { readStoreMeta, readVaultSealState, refuseUnsealedVault } from './privateStoreMeta.js';
20
+ import { dropPendingWords } from './privateStoreDoor.js';
21
+ import { PRIVATE_USER_CATALOG_FILES, isSharedFolderOwner, isValidStoreId, privateUserDir, privateUserKeysPath } from './privateStorePath.js';
22
+ import { emptyUserCatalog, readUserCatalog } from './privateStoreCatalogs.js';
23
+ import { isJobContext } from '../context/ActorContext.js';
24
+ import { writeFileAtomic } from './atomicWrite.js';
25
+ import { SECRET_FILE_MODE } from './secretFileMode.js';
26
+ import { mixesProxyWithDelegation, proxyCarries } from './proxyClaim.js';
27
+ import { sharedFolderKeyFor } from './sharedFolderAccess.js';
28
+ const bags = new Map();
29
+ export function unlockPrivateStores(sessionId, username, kek) {
30
+ bags.set(sessionId, {
31
+ username,
32
+ kek: Buffer.from(kek),
33
+ deks: new Map(),
34
+ userIndex: emptyUserCatalog()
35
+ });
36
+ }
37
+ export function getSessionUserIndex(sessionId) {
38
+ return bags.get(sessionId)?.userIndex;
39
+ }
40
+ /**
41
+ * Replace a user-keys.json (#1625): the only copy of both wraps of the KEK, so
42
+ * atomic (a crash leaves the old file or the new one, never a truncated one),
43
+ * owner-only and flushed to the device.
44
+ */
45
+ async function writeUserKeyEnvelope(file, envelope) {
46
+ await writeFileAtomic(file, JSON.stringify(envelope), 'utf8', { mode: SECRET_FILE_MODE, fsync: true });
47
+ }
48
+ function wipeCopy(bag) {
49
+ if (!bag.copy)
50
+ return;
51
+ bag.copy.kek.fill(0);
52
+ for (const dek of bag.copy.deks.values())
53
+ dek.fill(0);
54
+ bag.copy.deks.clear();
55
+ delete bag.copy;
56
+ }
57
+ export function lockPrivateStores(sessionId) {
58
+ const bag = bags.get(sessionId);
59
+ if (bag) {
60
+ wipeCopy(bag);
61
+ bag.kek.fill(0);
62
+ for (const dek of bag.deks.values())
63
+ dek.fill(0);
64
+ bag.deks.clear();
65
+ bags.delete(sessionId);
66
+ }
67
+ }
68
+ export function getUnlockedKek(sessionId) {
69
+ const kek = bags.get(sessionId)?.kek;
70
+ return kek ? Buffer.from(kek) : undefined;
71
+ }
72
+ export function setUnlockedDek(sessionId, storeId, dek) {
73
+ const bag = bags.get(sessionId);
74
+ if (!bag)
75
+ throw new Error('private stores are locked for this session');
76
+ bag.deks.set(storeId, Buffer.from(dek));
77
+ }
78
+ export function getUnlockedDek(sessionId, storeId) {
79
+ const dek = bags.get(sessionId)?.deks.get(storeId);
80
+ return dek ? Buffer.from(dek) : undefined;
81
+ }
82
+ /** DEK from the process bag for this request's session (or an explicit id). */
83
+ export function sessionDekForStore(storeId, handle) {
84
+ return getUnlockedDek(handle, storeId);
85
+ }
86
+ function isUserKeyEnvelope(value) {
87
+ if (!value || typeof value !== 'object')
88
+ return false;
89
+ const rec = value;
90
+ return rec.version === 1 && rec.passwordWrap !== undefined && rec.kdf !== undefined;
91
+ }
92
+ /**
93
+ * After password login: unwrap the user KEK into the bag and load store DEKs.
94
+ * No-ops when the user has no envelope yet. Never writes key bytes to session JSON.
95
+ */
96
+ export async function unlockPrivateStoresWithPassword(args) {
97
+ const envelope = await readUserKeyEnvelope(args.pagesDirectory, args.username);
98
+ if (!envelope)
99
+ return;
100
+ await unlockWithKek(args, envelope, await unwrapKekWithPassword(envelope, args.password));
101
+ }
102
+ /**
103
+ * Unlock with the 12 recovery words instead of the password (#1453).
104
+ *
105
+ * The words unwrap the SAME key the password does — the envelope holds it
106
+ * twice, once per wrap — so everything after the unwrap is the password
107
+ * path's, shared. Wrong words fail the authenticated unwrap and return
108
+ * `false`; nothing is unlocked and nothing is written. The words are never
109
+ * logged, stored or returned.
110
+ */
111
+ export async function unlockPrivateStoresWithMnemonic(args) {
112
+ const envelope = await readUserKeyEnvelope(args.pagesDirectory, args.username);
113
+ if (!envelope)
114
+ return false;
115
+ let kek;
116
+ try {
117
+ kek = unwrapKekWithMnemonic(envelope, args.words);
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ await unlockWithKek(args, envelope, kek);
123
+ return true;
124
+ }
125
+ /** The user's key envelope, or null when they have none (or it is not one). */
126
+ async function readUserKeyEnvelope(pagesDirectory, username) {
127
+ const keysPath = privateUserKeysPath(pagesDirectory, username);
128
+ if (!await fs.pathExists(keysPath))
129
+ return null;
130
+ const raw = await fs.readJson(keysPath);
131
+ return isUserKeyEnvelope(raw) ? raw : null;
132
+ }
133
+ /**
134
+ * Everything an unlock does once it holds the user's key: the key into the
135
+ * session's bag, each encrypted store's data key, and the user's catalogue.
136
+ * One implementation, whichever wrap the key came out of.
137
+ */
138
+ async function unlockWithKek(args, envelope, kek) {
139
+ unlockPrivateStores(args.handle, args.username, kek);
140
+ for (const [store, dek] of await storeDeks(args.pagesDirectory, args.username, kek)) {
141
+ setUnlockedDek(args.handle, store, dek);
142
+ dek.fill(0);
143
+ }
144
+ try {
145
+ await ensurePersonKey(args.pagesDirectory, args.username, envelope, kek);
146
+ }
147
+ catch {
148
+ logger.warn('[private-store] the person key pair could not be made at unlock');
149
+ }
150
+ const bag = bags.get(args.handle);
151
+ if (bag) {
152
+ try {
153
+ bag.userIndex = await readUserCatalog(args.pagesDirectory, args.username, kek);
154
+ }
155
+ catch {
156
+ logger.warn('[private-store] the user page catalog stayed sealed after login');
157
+ }
158
+ }
159
+ }
160
+ /**
161
+ * The data key of each of `username`'s encrypted stores that `kek` opens. One
162
+ * walk of the container, for an unlock and for a proxy's key copy alike; a
163
+ * store that does not open stays locked and is left out.
164
+ */
165
+ async function storeDeks(pagesDirectory, username, kek) {
166
+ const deks = new Map();
167
+ const userDir = privateUserDir(pagesDirectory, username);
168
+ if (!await fs.pathExists(userDir))
169
+ return deks;
170
+ const entries = await fs.readdir(userDir, { withFileTypes: true });
171
+ for (const ent of entries) {
172
+ if (!ent.isDirectory())
173
+ continue;
174
+ if (PRIVATE_USER_CATALOG_FILES.has(ent.name))
175
+ continue;
176
+ // Only store folders: a folder whose name is not a store id is not a store,
177
+ // and must not abort the unlock of the stores that are.
178
+ if (!isValidStoreId(ent.name))
179
+ continue;
180
+ const meta = await readStoreMeta(pagesDirectory, username, ent.name);
181
+ if (meta.encrypt !== true)
182
+ continue;
183
+ try {
184
+ deks.set(ent.name, unwrapDek(kek, meta));
185
+ }
186
+ catch {
187
+ logger.warn('[private-store] an encrypted store stayed locked');
188
+ }
189
+ }
190
+ return deks;
191
+ }
192
+ /**
193
+ * Give the person a key pair if their envelope has none (#1762 slice 5):
194
+ * enrolment makes one (`createUserKeys`); a person enrolled before it existed
195
+ * gets theirs the first time their store is unlocked. Only the new field is
196
+ * added; every wrap of the KEK is written back as it was read.
197
+ */
198
+ async function ensurePersonKey(pagesDirectory, username, envelope, kek) {
199
+ if (envelope.personKey)
200
+ return;
201
+ await writeUserKeyEnvelope(privateUserKeysPath(pagesDirectory, username), { ...envelope, personKey: await newPersonKey(kek) });
202
+ }
203
+ /** Whether `username` has keys at all, i.e. a store that can be encrypted. */
204
+ export async function hasUserKeys(pagesDirectory, username) {
205
+ return (await readUserKeyEnvelope(pagesDirectory, username)) !== null;
206
+ }
207
+ /** Which set of recovery words `username` has (#1928, `recoveryWordsId`), or null when they have no keys. */
208
+ export async function recoveryWordsIdOf(pagesDirectory, username) {
209
+ const envelope = await readUserKeyEnvelope(pagesDirectory, username);
210
+ return envelope ? recoveryWordsId(envelope) : null;
211
+ }
212
+ /**
213
+ * The public half of `username`'s key pair (#1762 slice 5), or null when they
214
+ * have no keys or no pair yet. Public: anyone may seal to it.
215
+ */
216
+ export async function personKeyOf(pagesDirectory, username) {
217
+ const envelope = await readUserKeyEnvelope(pagesDirectory, username);
218
+ return envelope ? personPublicKey(envelope) : null;
219
+ }
220
+ /**
221
+ * Seal a copy of this context's OWN unlocked KEK to `recipient` (#1762 slice
222
+ * 5): what a proxy grant made while the owner's store is open carries. Null
223
+ * when the context holds no key — a locked store, a token, a proxy (it holds
224
+ * no KEK of its own through `kekFor`). The KEK copy taken here is zeroed.
225
+ */
226
+ export async function sealKekCopyFor(ctx, recipient) {
227
+ const kek = kekFor(ctx);
228
+ if (!kek)
229
+ return null;
230
+ try {
231
+ return await sealKekCopy(recipient, kek);
232
+ }
233
+ finally {
234
+ kek.fill(0);
235
+ }
236
+ }
237
+ /**
238
+ * Open the key copy a proxy grant carries (#1762 slice 5) into the actor's
239
+ * own bag (`handle`), when the actor's store is unlocked: their KEK unwraps
240
+ * their private key, which opens the copy of the owner's KEK, which opens the
241
+ * owner's encrypted stores. Replaces any copy the bag held. False — nothing
242
+ * held — when the actor's store is locked, the bag is not the actor's, or the
243
+ * copy does not open (it is then not retried for this bag). No key byte is
244
+ * returned or logged.
245
+ */
246
+ export async function openKeyCopy(args) {
247
+ const bag = bags.get(args.handle);
248
+ if (!bag || bag.username !== args.actor)
249
+ return false;
250
+ if (bag.copy?.grantId === args.grantId && bag.copy.owner === args.owner)
251
+ return true;
252
+ if (bag.copyRefused === args.grantId)
253
+ return false;
254
+ wipeCopy(bag);
255
+ const envelope = await readUserKeyEnvelope(args.pagesDirectory, args.actor);
256
+ let kek;
257
+ try {
258
+ if (!envelope)
259
+ throw new Error('no keys');
260
+ kek = await openKekCopy(envelope, bag.kek, args.copy);
261
+ }
262
+ catch {
263
+ bag.copyRefused = args.grantId;
264
+ return false;
265
+ }
266
+ const deks = await storeDeks(args.pagesDirectory, args.owner, kek);
267
+ // The bag may have been locked while the stores were read.
268
+ if (bags.get(args.handle) !== bag) {
269
+ kek.fill(0);
270
+ for (const dek of deks.values())
271
+ dek.fill(0);
272
+ return false;
273
+ }
274
+ delete bag.copyRefused;
275
+ bag.copy = { grantId: args.grantId, owner: args.owner, kek, deks };
276
+ return true;
277
+ }
278
+ /** The grant and person whose key copy this handle's bag holds, if any — ids only, no key. */
279
+ export function heldKeyCopy(handle) {
280
+ const copy = typeof handle === 'string' ? bags.get(handle)?.copy : undefined;
281
+ return copy ? { grantId: copy.grantId, owner: copy.owner } : undefined;
282
+ }
283
+ /** Wipe the key copy this handle's bag holds (#1762 slice 5): switching back, or to someone else. The actor's own keys stay. */
284
+ export function dropKeyCopy(handle) {
285
+ if (typeof handle !== 'string')
286
+ return;
287
+ const bag = bags.get(handle);
288
+ if (!bag)
289
+ return;
290
+ wipeCopy(bag);
291
+ delete bag.copyRefused;
292
+ }
293
+ /** Wipe every copy opened under this grant, in every session (#1762 slice 5): the grant was revoked or its account closed. */
294
+ export function dropKeyCopiesOfGrant(grantId) {
295
+ let dropped = 0;
296
+ for (const bag of bags.values()) {
297
+ if (bag.copy?.grantId !== grantId)
298
+ continue;
299
+ wipeCopy(bag);
300
+ dropped++;
301
+ }
302
+ return dropped;
303
+ }
304
+ /**
305
+ * Password change: re-wrap the user envelope. Recovery wrap is unchanged.
306
+ * No-ops when there is no envelope yet. Not a manager — any caller can use this.
307
+ */
308
+ export async function rewrapUserKeysOnPasswordChange(args) {
309
+ const keysPath = privateUserKeysPath(args.pagesDirectory, args.username);
310
+ if (!await fs.pathExists(keysPath))
311
+ return;
312
+ const raw = await fs.readJson(keysPath);
313
+ if (!isUserKeyEnvelope(raw))
314
+ return;
315
+ const next = await rewrapPassword(raw, args.oldPassword, args.newPassword);
316
+ await fs.ensureDir(privateUserDir(args.pagesDirectory, args.username));
317
+ await writeUserKeyEnvelope(keysPath, next);
318
+ }
319
+ /**
320
+ * A forgotten password, reset with the recovery words (#1452): the words
321
+ * unwrap the KEK, and the password wrap is replaced with one made from the
322
+ * new password. The recovery wrap and the KEK are untouched.
323
+ *
324
+ * Returns `false` — writing nothing — when the user has no keys or the words
325
+ * do not open them. The words are never logged or stored.
326
+ */
327
+ export async function resetPasswordWrapWithMnemonic(args) {
328
+ const envelope = await readUserKeyEnvelope(args.pagesDirectory, args.username);
329
+ if (!envelope)
330
+ return false;
331
+ let kek;
332
+ try {
333
+ kek = unwrapKekWithMnemonic(envelope, args.words);
334
+ }
335
+ catch {
336
+ return false;
337
+ }
338
+ try {
339
+ await writeUserKeyEnvelope(privateUserKeysPath(args.pagesDirectory, args.username), await rewrapPasswordWithKek(envelope, kek, args.newPassword));
340
+ }
341
+ finally {
342
+ kek.fill(0);
343
+ }
344
+ return true;
345
+ }
346
+ /** A fresh handle for a session's key bag: random, never the session id. */
347
+ export function newPrivateStoreHandle() {
348
+ return randomUUID();
349
+ }
350
+ /**
351
+ * The handle a context carries to its session's key bag. Only a password
352
+ * session's request subject has one; a `JobContext`, a bearer-token request
353
+ * and a share visitor carry none, so they reach no keys.
354
+ *
355
+ * A subject acting for someone (`viaProxy`, #1762) reaches none of the bag's
356
+ * own keys, whatever handle it carries: the session's bag is the actor's own.
357
+ * The represented person's encrypted stores open to it only through the key
358
+ * copy its grant carries ({@link copyFor}).
359
+ */
360
+ function handleOf(ctx) {
361
+ // A caller that passed no context reaches no keys — a refusal, not a crash.
362
+ const subject = ctx;
363
+ return subject?.viaProxy ? undefined : subject?.privateStoreHandle;
364
+ }
365
+ /**
366
+ * The key copy a proxy subject reaches (#1762 slice 5), or none. All of it
367
+ * must hold: a `proxy` claim (never `impersonation`) carrying `vault-proxy`
368
+ * and no token or share; the bag at its `keyHandle` belongs to the claim's
369
+ * actor; and the copy there was opened under this very grant for the person
370
+ * the subject represents. Whether the grant is still in force is the
371
+ * request's (`AuthManager.subjectActingFor` rebuilds the claim from the live
372
+ * grant and wipes the copy when it is not).
373
+ */
374
+ function copyFor(ctx) {
375
+ // A job carries no key (#2047): `jobContextFromRequest` drops the handle, and
376
+ // a job context that still names one is refused here as well.
377
+ if (!ctx || isJobContext(ctx))
378
+ return undefined;
379
+ const subject = ctx;
380
+ const claim = subject?.viaProxy;
381
+ if (!claim?.keyHandle || !proxyCarries(claim, 'vault-proxy') || mixesProxyWithDelegation(subject))
382
+ return undefined;
383
+ const bag = bags.get(claim.keyHandle);
384
+ if (!bag || bag.username !== claim.actor)
385
+ return undefined;
386
+ const copy = bag.copy;
387
+ return copy && copy.grantId === claim.grantId && copy.owner === subject?.username ? copy : undefined;
388
+ }
389
+ /**
390
+ * The DEK for `owner`'s `store` that this context holds, if any. Only from the
391
+ * owner's own bag — DEKs are keyed by store id, and every user has a
392
+ * `default` — or, for someone acting for the owner, from the key copy their
393
+ * grant carries (#1762 slice 5).
394
+ *
395
+ * A shared folder's key is the server's, never in a bag (#2125): handed only
396
+ * to a context the PDP has permitted on that folder (`sharedFolderKeyFor`).
397
+ */
398
+ export function dekFor(ctx, owner, store) {
399
+ if (isSharedFolderOwner(owner))
400
+ return sharedFolderKeyFor(ctx, store);
401
+ const copy = copyFor(ctx);
402
+ if (copy) {
403
+ const dek = copy.owner === owner ? copy.deks.get(store) : undefined;
404
+ return dek ? Buffer.from(dek) : undefined;
405
+ }
406
+ const handle = handleOf(ctx);
407
+ if (!handle || bags.get(handle)?.username !== owner)
408
+ return undefined;
409
+ return getUnlockedDek(handle, store);
410
+ }
411
+ /**
412
+ * The ids of the requester's OWN stores whose DEK its session holds (#1400) —
413
+ * the encrypted stores it can open right now. Another user's, or a locked
414
+ * store, is never listed; acting for someone, the represented person's stores
415
+ * the grant's key copy opened (#1762 slice 5). Ids only: no key bytes leave
416
+ * the bag.
417
+ */
418
+ export function unlockedStoreIdsFor(ctx) {
419
+ const copy = copyFor(ctx);
420
+ if (copy)
421
+ return [...copy.deks.keys()];
422
+ const handle = handleOf(ctx);
423
+ const bag = handle ? bags.get(handle) : undefined;
424
+ if (!bag || bag.username !== ctx.username)
425
+ return [];
426
+ return [...bag.deks.keys()];
427
+ }
428
+ /** The unlocked sealed-store page catalog this context's session holds, if any (#1385). */
429
+ export function userIndexFor(ctx) {
430
+ const handle = handleOf(ctx);
431
+ return handle ? getSessionUserIndex(handle) : undefined;
432
+ }
433
+ /**
434
+ * Refuse a write into an encrypted store unless this context holds its DEK
435
+ * (#1394). The context-carrying form of {@link assertCurrentSessionCanWriteStore}.
436
+ */
437
+ export async function assertContextCanWriteStore(ctx, args) {
438
+ // #1834: a sealed kind's vault that is not sealed itself is refused, never
439
+ // written in the clear; the kind is the layout's `sealedVaults`.
440
+ const { state, meta } = await readVaultSealState(args.pagesDirectory, args.owner, args.store, args.layout);
441
+ refuseUnsealedVault(args.owner, args.store, state);
442
+ assertEncryptedStoreWritable({
443
+ encrypt: meta.encrypt,
444
+ dek: meta.encrypt ? dekFor(ctx, args.owner, args.store) : undefined
445
+ });
446
+ }
447
+ /**
448
+ * Whether this context's session holds an unlocked user key (#1448).
449
+ *
450
+ * A yes/no, deliberately. {@link kekFor} hands back a COPY of the key bytes,
451
+ * which is right for a caller that is about to use them and wrong for one that
452
+ * only needs to know they exist — every page render asks this, and none of
453
+ * them should be copying key material into a buffer it then has to remember
454
+ * to zero.
455
+ */
456
+ export function hasUnlockedKey(ctx) {
457
+ const handle = handleOf(ctx);
458
+ return !!handle && bags.get(handle)?.kek !== undefined;
459
+ }
460
+ /** The user KEK this context's session holds, if any. */
461
+ export function kekFor(ctx) {
462
+ const handle = handleOf(ctx);
463
+ return handle ? getUnlockedKek(handle) : undefined;
464
+ }
465
+ /**
466
+ * The one door for a session ending (#1626): whatever ends it — logout, idle
467
+ * timeout, a password change, an admin revoke, a new sign-in on the same
468
+ * browser — calls this with the session's private-store handle before the
469
+ * session is gone, so its keys are zeroed and dropped and no recovery words
470
+ * stay pending. Takes the raw session value, so a caller never re-implements
471
+ * the "is there a handle" check.
472
+ */
473
+ export function endSessionKeys(handle) {
474
+ if (typeof handle !== 'string' || !handle)
475
+ return;
476
+ lockPrivateStores(handle);
477
+ dropPendingWords(handle);
478
+ }
479
+ /**
480
+ * Drop every unlocked bag whose handle no live session holds (#1626): a
481
+ * session that expired in the store, or one destroyed by a path that could
482
+ * not reach endSessionKeys, leaves its keys with no owner. Returns how many
483
+ * bags were dropped.
484
+ */
485
+ export function sweepOrphanedKeys(liveHandles) {
486
+ let dropped = 0;
487
+ for (const handle of [...bags.keys()]) {
488
+ if (liveHandles.has(handle))
489
+ continue;
490
+ endSessionKeys(handle);
491
+ dropped++;
492
+ }
493
+ return dropped;
494
+ }
495
+ /** Test teardown only. */
496
+ export function clearUnlockedPrivateStores() {
497
+ for (const id of [...bags.keys()])
498
+ lockPrivateStores(id);
499
+ }