@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.
- package/lib/packages/ngdpbase-types/index.d.ts +1 -1
- package/lib/src/context/ApiContext.d.ts +3 -2
- package/lib/src/context/ApiContext.js +4 -2
- package/lib/src/ice/fileHandling.d.ts +6 -1
- package/lib/src/ice/givenName.d.ts +19 -0
- package/lib/src/ice/runExport.d.ts +41 -0
- package/lib/src/ice/runImport.d.ts +37 -8
- package/lib/src/ice/shims/csv.d.ts +24 -0
- package/lib/src/ice/shims/export.d.ts +23 -0
- package/lib/src/ice/shims/index.d.ts +3 -1
- package/lib/src/ice/shims/svg.d.ts +27 -0
- package/lib/src/ice/shims/takeout.d.ts +83 -0
- package/lib/src/ice/types.d.ts +301 -5
- package/lib/src/ice/vet.d.ts +19 -0
- package/lib/src/managers/AttachmentManager.d.ts +100 -36
- package/lib/src/managers/AuthManager.d.ts +152 -12
- package/lib/src/managers/AuthManager.js +3830 -0
- package/lib/src/managers/BackgroundJobManager.d.ts +25 -0
- package/lib/src/managers/BackupManager.d.ts +24 -0
- package/lib/src/managers/BackupManager.js +48 -1
- package/lib/src/managers/ExportManager.d.ts +16 -0
- package/lib/src/managers/ExportManager.js +25 -0
- package/lib/src/managers/FileManager.d.ts +182 -0
- package/lib/src/managers/ImportManager.d.ts +279 -16
- package/lib/src/managers/MediaManager.d.ts +2 -0
- package/lib/src/managers/PageManager.d.ts +53 -0
- package/lib/src/managers/SearchManager.d.ts +4 -0
- package/lib/src/managers/SharedFolderManager.d.ts +320 -0
- package/lib/src/managers/ValidationManager.d.ts +5 -2
- package/lib/src/middleware/csrf.js +99 -0
- package/lib/src/providers/AgentTokenAuthProvider.js +46 -0
- package/lib/src/providers/AuthentikBearerAuthProvider.js +176 -0
- package/lib/src/providers/BaseCredentialsProvider.js +21 -0
- package/lib/src/providers/BaseMediaProvider.d.ts +4 -0
- package/lib/src/providers/BasePageProvider.d.ts +8 -0
- package/lib/src/providers/CloudflareAccessAuthProvider.js +160 -0
- package/lib/src/providers/FileCredentialsProvider.js +188 -0
- package/lib/src/providers/FileSystemMediaProvider.d.ts +6 -0
- package/lib/src/providers/MagicLinkAuthProvider.js +333 -0
- package/lib/src/providers/OidcAuthProvider.js +350 -0
- package/lib/src/providers/OidcBearerAuthProvider.js +27 -0
- package/lib/src/providers/PasskeyAuthProvider.js +147 -0
- package/lib/src/providers/PasswordAuthProvider.js +44 -0
- package/lib/src/security/PolicyDecisionPoint.d.ts +3 -2
- package/lib/src/security/pageActions.js +43 -0
- package/lib/src/security/permissions.generated.d.ts +7 -3
- package/lib/src/types/File.d.ts +90 -0
- package/lib/src/types/Provider.d.ts +15 -0
- package/lib/src/types/express.d.ts +7 -1
- package/lib/src/types/guards.js +366 -0
- package/lib/src/types/linkedom.d.ts +2 -0
- package/lib/src/utils/atomicWrite.js +129 -0
- package/lib/src/utils/auditEventNames.generated.d.ts +12 -0
- package/lib/src/utils/auditEventNames.generated.js +12 -0
- package/lib/src/utils/auditEvents.d.ts +15 -0
- package/lib/src/utils/auditEvents.js +17 -1
- package/lib/src/utils/bip39English.js +2 -0
- package/lib/src/utils/browserDescription.js +28 -0
- package/lib/src/utils/delegation.js +79 -0
- package/lib/src/utils/deriveUsername.js +38 -0
- package/lib/src/utils/emailForLog.js +28 -0
- package/lib/src/utils/fileMigrationPlan.d.ts +81 -0
- package/lib/src/utils/instanceEnvBackup.d.ts +6 -0
- package/lib/src/utils/instanceEnvBackup.js +13 -0
- package/lib/src/utils/ipLocation.js +106 -0
- package/lib/src/utils/magicLinkDeviceState.js +97 -0
- package/lib/src/utils/pageUrl.d.ts +1 -0
- package/lib/src/utils/passwordHash.js +150 -0
- package/lib/src/utils/permissionCatalog.js +102 -0
- package/lib/src/utils/privateStoreAccess.d.ts +25 -0
- package/lib/src/utils/privateStoreAccess.js +22 -1
- package/lib/src/utils/privateStoreCatalogs.js +57 -0
- package/lib/src/utils/privateStoreCrypto.js +266 -0
- package/lib/src/utils/privateStoreDoor.d.ts +10 -0
- package/lib/src/utils/privateStoreDoor.js +298 -0
- package/lib/src/utils/privateStoreImport.d.ts +0 -10
- package/lib/src/utils/privateStoreMeta.js +123 -0
- package/lib/src/utils/privateStorePath.d.ts +37 -1
- package/lib/src/utils/privateStorePath.js +60 -3
- package/lib/src/utils/privateStoreTakeout.js +149 -0
- package/lib/src/utils/privateStoreUnlock.d.ts +3 -0
- package/lib/src/utils/privateStoreUnlock.js +499 -0
- package/lib/src/utils/recoveryWordsFile.d.ts +54 -0
- package/lib/src/utils/safeRedirect.js +55 -0
- package/lib/src/utils/sessionAccount.js +55 -0
- package/lib/src/utils/sessionEnd.d.ts +1 -0
- package/lib/src/utils/sessionEnd.js +42 -0
- package/lib/src/utils/sessionGeneration.js +29 -0
- package/lib/src/utils/sessionIdle.d.ts +4 -0
- package/lib/src/utils/sessionIdle.js +72 -0
- package/lib/src/utils/sessionLimits.js +72 -0
- package/lib/src/utils/sessionSecurity.js +122 -0
- package/lib/src/utils/shareLockbox.js +101 -0
- package/lib/src/utils/sharedFolderAccess.d.ts +57 -0
- package/lib/src/utils/sharedFolderAccess.js +80 -0
- package/lib/src/utils/userCreateError.js +82 -0
- package/lib/src/utils/username.d.ts +2 -0
- package/lib/src/utils/username.js +105 -0
- package/package.json +2 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password hashing — scrypt, with a per-user salt and in-place upgrade (#1042).
|
|
3
|
+
*
|
|
4
|
+
* ## What was wrong
|
|
5
|
+
*
|
|
6
|
+
* Passwords were stored as ONE round of SHA-256 with a single instance-wide
|
|
7
|
+
* salt (`amdwiki-salt` by default):
|
|
8
|
+
*
|
|
9
|
+
* sha256(password + globalSalt)
|
|
10
|
+
*
|
|
11
|
+
* SHA-256 is built to be fast, which is the opposite of what a password hash
|
|
12
|
+
* needs — an attacker holding `users.json` can try billions of candidates a
|
|
13
|
+
* second on commodity hardware. And because every account shared one salt,
|
|
14
|
+
* two users with the same password produced byte-identical hashes, so the
|
|
15
|
+
* store leaked which accounts shared a password without any cracking at all.
|
|
16
|
+
* The comparison was `===`, which is not constant-time.
|
|
17
|
+
*
|
|
18
|
+
* ## What replaces it
|
|
19
|
+
*
|
|
20
|
+
* scrypt from Node's standard library — no new dependency — with a random
|
|
21
|
+
* 16-byte salt per password and a constant-time comparison.
|
|
22
|
+
*
|
|
23
|
+
* The stored value is self-describing:
|
|
24
|
+
*
|
|
25
|
+
* scrypt$131072$8$1$<salt base64>$<hash base64>
|
|
26
|
+
*
|
|
27
|
+
* Carrying the parameters in the hash is what makes this maintainable: the
|
|
28
|
+
* cost can be raised later and old hashes keep verifying under the parameters
|
|
29
|
+
* they were written with, then upgrade on next login. A bare digest cannot do
|
|
30
|
+
* that, which is how the SHA-256 scheme became impossible to move off.
|
|
31
|
+
*
|
|
32
|
+
* ## Migration
|
|
33
|
+
*
|
|
34
|
+
* Existing hashes cannot be converted — the plaintext is not recoverable. So
|
|
35
|
+
* legacy values keep verifying, `needsRehash()` reports them, and the caller
|
|
36
|
+
* rewrites them after a successful login. The store ages over as people sign
|
|
37
|
+
* in; nobody is locked out and no reset email is needed.
|
|
38
|
+
*
|
|
39
|
+
* An empty stored hash stays unusable by design: `isExternal` accounts (magic
|
|
40
|
+
* link, OIDC) store `''`, and no password may ever match it.
|
|
41
|
+
*/
|
|
42
|
+
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'crypto';
|
|
43
|
+
/**
|
|
44
|
+
* scrypt off the main thread (#1632). The synchronous form froze every request
|
|
45
|
+
* for the length of each hash; at N=2^17 that is ~0.2 s per sign-in, and a
|
|
46
|
+
* burst of attempts would stall the whole site. This runs on libuv's pool.
|
|
47
|
+
*/
|
|
48
|
+
function scryptAsync(password, salt, keylen, options) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
scrypt(password, salt, keylen, options, (err, key) => (err ? reject(err) : resolve(key)));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Marks the current scheme. Anything else is treated as legacy. */
|
|
54
|
+
const SCHEME = 'scrypt';
|
|
55
|
+
/**
|
|
56
|
+
* CPU/memory cost. 2^17 (#1632, was 2^14): ~0.2 s per hash, off the main
|
|
57
|
+
* thread, so offline guessing of a leaked user store is eight times harder and
|
|
58
|
+
* no request waits on another's sign-in. Raising it is safe: the value used is
|
|
59
|
+
* recorded in each hash, and needsRehash() upgrades a weaker one at the next
|
|
60
|
+
* successful sign-in.
|
|
61
|
+
*/
|
|
62
|
+
const N = 131072;
|
|
63
|
+
/** A stored hash naming a cost above this is a corrupt record, not a match (bounds memory). */
|
|
64
|
+
const MAX_N = 1 << 20;
|
|
65
|
+
const R = 8;
|
|
66
|
+
const P = 1;
|
|
67
|
+
const KEYLEN = 64;
|
|
68
|
+
const SALT_BYTES = 16;
|
|
69
|
+
/** scrypt throws unless maxmem covers 128 * N * r; the default is too low. */
|
|
70
|
+
const MAXMEM = 128 * N * R * 4;
|
|
71
|
+
/** Hash a password under the current scheme. */
|
|
72
|
+
export async function hashPassword(password) {
|
|
73
|
+
const salt = randomBytes(SALT_BYTES);
|
|
74
|
+
const derived = await scryptAsync(password, salt, KEYLEN, { N, r: R, p: P, maxmem: MAXMEM });
|
|
75
|
+
return [SCHEME, N, R, P, salt.toString('base64'), derived.toString('base64')].join('$');
|
|
76
|
+
}
|
|
77
|
+
/** The legacy scheme, kept only so old hashes still verify. */
|
|
78
|
+
function legacyHash(password, globalSalt) {
|
|
79
|
+
return createHash('sha256').update(password + globalSalt).digest('hex');
|
|
80
|
+
}
|
|
81
|
+
/** Constant-time compare that tolerates length differences. */
|
|
82
|
+
function safeEqual(a, b) {
|
|
83
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
84
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
85
|
+
// timingSafeEqual throws on unequal lengths, and the lengths themselves are
|
|
86
|
+
// not secret here — the scheme fixes them.
|
|
87
|
+
if (bufA.length !== bufB.length)
|
|
88
|
+
return false;
|
|
89
|
+
return timingSafeEqual(bufA, bufB);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Verify `password` against a stored value in EITHER scheme.
|
|
93
|
+
*
|
|
94
|
+
* @param stored the value from the user record
|
|
95
|
+
* @param legacySalt instance-wide salt, for pre-#1042 hashes only
|
|
96
|
+
*/
|
|
97
|
+
export async function verifyPassword(password, stored, legacySalt) {
|
|
98
|
+
// External accounts store '' and must never match. Checked first so no
|
|
99
|
+
// amount of parsing can turn an empty hash into a successful comparison.
|
|
100
|
+
if (!stored)
|
|
101
|
+
return false;
|
|
102
|
+
if (!stored.startsWith(`${SCHEME}$`)) {
|
|
103
|
+
return safeEqual(legacyHash(password, legacySalt), stored);
|
|
104
|
+
}
|
|
105
|
+
const parts = stored.split('$');
|
|
106
|
+
if (parts.length !== 6)
|
|
107
|
+
return false;
|
|
108
|
+
const [, nRaw, rRaw, pRaw, saltB64, hashB64] = parts;
|
|
109
|
+
const n = Number(nRaw);
|
|
110
|
+
const r = Number(rRaw);
|
|
111
|
+
const p = Number(pRaw);
|
|
112
|
+
if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p) || n > MAX_N)
|
|
113
|
+
return false;
|
|
114
|
+
let derived;
|
|
115
|
+
let expected;
|
|
116
|
+
try {
|
|
117
|
+
expected = Buffer.from(hashB64, 'base64');
|
|
118
|
+
derived = await scryptAsync(password, Buffer.from(saltB64, 'base64'), expected.length, {
|
|
119
|
+
N: n, r, p, maxmem: 128 * n * r * 4
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// A malformed or absurd parameter set is a corrupt record, not a match.
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (derived.length !== expected.length)
|
|
127
|
+
return false;
|
|
128
|
+
return timingSafeEqual(derived, expected);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Should this stored value be rewritten after a successful login?
|
|
132
|
+
*
|
|
133
|
+
* True for legacy hashes, and for current-scheme hashes written under weaker
|
|
134
|
+
* parameters than today's — which is how a future cost increase rolls out.
|
|
135
|
+
* An empty hash is never rehashed; external accounts have no password.
|
|
136
|
+
*/
|
|
137
|
+
export function needsRehash(stored) {
|
|
138
|
+
if (!stored)
|
|
139
|
+
return false;
|
|
140
|
+
if (!stored.startsWith(`${SCHEME}$`))
|
|
141
|
+
return true;
|
|
142
|
+
const parts = stored.split('$');
|
|
143
|
+
if (parts.length !== 6)
|
|
144
|
+
return true;
|
|
145
|
+
return Number(parts[1]) < N;
|
|
146
|
+
}
|
|
147
|
+
/** True when the value was written by the pre-#1042 scheme. */
|
|
148
|
+
export function isLegacyHash(stored) {
|
|
149
|
+
return Boolean(stored) && !stored.startsWith(`${SCHEME}$`);
|
|
150
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which permission entries are actions someone may hold (#1638 slice 3).
|
|
3
|
+
*
|
|
4
|
+
* Every audit event is declared on a permission entry, so the catalog also
|
|
5
|
+
* holds records that no one is ever permitted to do — `system-start`,
|
|
6
|
+
* `job-failed`, `authentication-failed`. Those entries carry
|
|
7
|
+
* `"grantable": false`. This is the one predicate that says so; everything
|
|
8
|
+
* that hands out, offers or lists permissions asks it: app (OIDC) scopes,
|
|
9
|
+
* the generated `CorePermission` type, the admin roles matrix and the
|
|
10
|
+
* permission listings. Omitted means grantable.
|
|
11
|
+
*/
|
|
12
|
+
/** True unless the entry says `"grantable": false`. */
|
|
13
|
+
export function isGrantable(entry) {
|
|
14
|
+
return !(entry && typeof entry === 'object' && entry.grantable === false);
|
|
15
|
+
}
|
|
16
|
+
/** The names of the grantable entries in a permission catalog, in catalog order. */
|
|
17
|
+
export function grantablePermissionNames(definitions) {
|
|
18
|
+
if (!definitions || typeof definitions !== 'object' || Array.isArray(definitions))
|
|
19
|
+
return [];
|
|
20
|
+
return Object.entries(definitions)
|
|
21
|
+
.filter(([, entry]) => isGrantable(entry))
|
|
22
|
+
.map(([name]) => name);
|
|
23
|
+
}
|
|
24
|
+
/** The grantable entries of a permission catalog, as a map in catalog order. */
|
|
25
|
+
export function grantablePermissions(definitions) {
|
|
26
|
+
return Object.fromEntries(Object.entries(definitions ?? {}).filter(([, entry]) => isGrantable(entry)));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Permissions that were split into narrower ones, and what each stood for.
|
|
30
|
+
*
|
|
31
|
+
* A policy, an agent token or an app grant written before the split still
|
|
32
|
+
* names the old permission; it is read as all of the new ones, so nobody loses
|
|
33
|
+
* an ability on upgrade. New writing should name the new permissions.
|
|
34
|
+
*/
|
|
35
|
+
export const RETIRED_PERMISSIONS = new Map([
|
|
36
|
+
// #1638: one name per action — create, extend and revoke are separate.
|
|
37
|
+
['share-manage', ['share-create', 'share-extend', 'share-revoke']],
|
|
38
|
+
// #2038: renamed (identity model, step 3); the old names are read for one release only.
|
|
39
|
+
['contact-read', ['identity-read']],
|
|
40
|
+
['contact-manage', ['identity-manage']]
|
|
41
|
+
]);
|
|
42
|
+
/** The names with every retired permission replaced by what it stood for, de-duplicated, order kept. */
|
|
43
|
+
export function withRetiredExpanded(names) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const name of names) {
|
|
46
|
+
for (const n of RETIRED_PERMISSIONS.get(name) ?? [name]) {
|
|
47
|
+
if (!out.includes(n))
|
|
48
|
+
out.push(n);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* What the standard OpenID Connect sign-in scopes let an app see, in plain
|
|
55
|
+
* words (#1572). They are not permissions, so they are not in the catalog;
|
|
56
|
+
* this is their one declaration — the consent page and the "My tokens and
|
|
57
|
+
* devices" list both read it through `describeScopes`.
|
|
58
|
+
*/
|
|
59
|
+
export const SIGN_IN_SCOPE_LABELS = {
|
|
60
|
+
openid: 'Confirm who you are (your username)',
|
|
61
|
+
profile: 'Your name',
|
|
62
|
+
email: 'Your email address',
|
|
63
|
+
address: 'Your postal address',
|
|
64
|
+
phone: 'Your phone number',
|
|
65
|
+
offline_access: 'Stay connected while you are not using it, until you revoke it'
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* A permission entry in plain words (#1764): its `label` — the end-user line
|
|
69
|
+
* shown wherever a person reads what a permission does — else its
|
|
70
|
+
* `description` (an add-on that declares no label), else nothing.
|
|
71
|
+
*/
|
|
72
|
+
export function permissionLabel(entry) {
|
|
73
|
+
if (!entry || typeof entry !== 'object')
|
|
74
|
+
return null;
|
|
75
|
+
const { label, description } = entry;
|
|
76
|
+
if (typeof label === 'string' && label.trim())
|
|
77
|
+
return label;
|
|
78
|
+
if (typeof description === 'string' && description.trim())
|
|
79
|
+
return description;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Scope or permission names in plain words (#1764), in order, without
|
|
84
|
+
* repeats: a sign-in scope by `SIGN_IN_SCOPE_LABELS`, a permission by
|
|
85
|
+
* `permissionLabel` of its catalog entry. A name declared nowhere (an app's
|
|
86
|
+
* own scope, such as a host's `patient/*.read`) is shown as itself.
|
|
87
|
+
*/
|
|
88
|
+
export function describeScopes(names, definitions) {
|
|
89
|
+
const catalog = definitions && typeof definitions === 'object' && !Array.isArray(definitions) ? definitions : {};
|
|
90
|
+
const out = [];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
for (const name of names) {
|
|
93
|
+
if (!name || seen.has(name))
|
|
94
|
+
continue;
|
|
95
|
+
seen.add(name);
|
|
96
|
+
const description = Object.hasOwn(SIGN_IN_SCOPE_LABELS, name)
|
|
97
|
+
? SIGN_IN_SCOPE_LABELS[name]
|
|
98
|
+
: (Object.hasOwn(catalog, name) ? permissionLabel(catalog[name]) : null);
|
|
99
|
+
out.push({ name, description: description ?? name });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
@@ -38,6 +38,16 @@ export interface VaultDecider {
|
|
|
38
38
|
permit: boolean;
|
|
39
39
|
}>;
|
|
40
40
|
}
|
|
41
|
+
/** The PDP surface {@link holdsInVault} needs. */
|
|
42
|
+
export interface VaultHolder {
|
|
43
|
+
holds(subject: unknown, action: string, on: {
|
|
44
|
+
resource: {
|
|
45
|
+
type: string;
|
|
46
|
+
id: string;
|
|
47
|
+
};
|
|
48
|
+
attributes: Record<string, unknown>;
|
|
49
|
+
}): Promise<boolean>;
|
|
50
|
+
}
|
|
41
51
|
export declare function mayActInPrivateContainer(ctx: ActorContext, owner: string, opts?: {
|
|
42
52
|
vault?: string;
|
|
43
53
|
}): boolean;
|
|
@@ -49,9 +59,24 @@ export declare function mayActInPrivateContainer(ctx: ActorContext, owner: strin
|
|
|
49
59
|
* vault page's name, or a file in the vault); it never matches `page: *`, so a
|
|
50
60
|
* site-wide role does not reach in. The one rule for vault pages
|
|
51
61
|
* (`BaseContext.hasPermissionOn`) and vault files (`AttachmentManager`).
|
|
62
|
+
*
|
|
63
|
+
* A shared folder (#2125) is asked by permission instead:
|
|
64
|
+
* `shared-folder-read` or `shared-folder-file` on the folder
|
|
65
|
+
* (`permitsInSharedFolder`), never the vault policy.
|
|
52
66
|
*/
|
|
53
67
|
export declare function permitsInVault(pdp: VaultDecider | null | undefined, ctx: ActorContext, action: string, where: {
|
|
54
68
|
owner: string;
|
|
55
69
|
vault: string;
|
|
56
70
|
resource: string;
|
|
57
71
|
}): Promise<boolean>;
|
|
72
|
+
/**
|
|
73
|
+
* Does `ctx` HOLD `action` inside `owner`'s vault (#2120)? {@link permitsInVault}
|
|
74
|
+
* without the step-up rule, for an affordance, and to tell a refusal for a
|
|
75
|
+
* stale sign-in (answered with the step-up prompt) from a plain one. Shared
|
|
76
|
+
* folders are not asked: `permitsInVault` decides them by permission alone.
|
|
77
|
+
*/
|
|
78
|
+
export declare function holdsInVault(pdp: VaultHolder | null | undefined, ctx: ActorContext, action: string, where: {
|
|
79
|
+
owner: string;
|
|
80
|
+
vault: string;
|
|
81
|
+
resource: string;
|
|
82
|
+
}): Promise<boolean>;
|
|
@@ -27,8 +27,12 @@
|
|
|
27
27
|
import { isJobContext } from '../context/ActorContext.js';
|
|
28
28
|
import { shareNamesVault } from '../types/Share.js';
|
|
29
29
|
import { mixesProxyWithDelegation, proxyCarries } from './proxyClaim.js';
|
|
30
|
+
import { isSharedFolderOwner } from './privateStorePath.js';
|
|
31
|
+
import { permitsInSharedFolder } from './sharedFolderAccess.js';
|
|
30
32
|
export function mayActInPrivateContainer(ctx, owner, opts = {}) {
|
|
31
|
-
|
|
33
|
+
// #2125: the shared folders' container has no owner who acts; a folder is
|
|
34
|
+
// decided by permission (`permitsInSharedFolder`), never by this rule.
|
|
35
|
+
if (!owner || isSharedFolderOwner(owner))
|
|
32
36
|
return false;
|
|
33
37
|
// #1762: a proxy's subject carries the represented person's username, so
|
|
34
38
|
// the owner test below would admit it unasked. It gets in only when the
|
|
@@ -55,9 +59,26 @@ export function mayActInPrivateContainer(ctx, owner, opts = {}) {
|
|
|
55
59
|
* vault page's name, or a file in the vault); it never matches `page: *`, so a
|
|
56
60
|
* site-wide role does not reach in. The one rule for vault pages
|
|
57
61
|
* (`BaseContext.hasPermissionOn`) and vault files (`AttachmentManager`).
|
|
62
|
+
*
|
|
63
|
+
* A shared folder (#2125) is asked by permission instead:
|
|
64
|
+
* `shared-folder-read` or `shared-folder-file` on the folder
|
|
65
|
+
* (`permitsInSharedFolder`), never the vault policy.
|
|
58
66
|
*/
|
|
59
67
|
export async function permitsInVault(pdp, ctx, action, where) {
|
|
68
|
+
if (isSharedFolderOwner(where.owner))
|
|
69
|
+
return permitsInSharedFolder(pdp, ctx, action, where.vault);
|
|
60
70
|
if (!pdp || !mayActInPrivateContainer(ctx, where.owner, { vault: where.vault }))
|
|
61
71
|
return false;
|
|
62
72
|
return (await pdp.decide(ctx, { action, resource: { type: 'page', id: where.resource }, attributes: { vault: where.vault } })).permit;
|
|
63
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Does `ctx` HOLD `action` inside `owner`'s vault (#2120)? {@link permitsInVault}
|
|
76
|
+
* without the step-up rule, for an affordance, and to tell a refusal for a
|
|
77
|
+
* stale sign-in (answered with the step-up prompt) from a plain one. Shared
|
|
78
|
+
* folders are not asked: `permitsInVault` decides them by permission alone.
|
|
79
|
+
*/
|
|
80
|
+
export async function holdsInVault(pdp, ctx, action, where) {
|
|
81
|
+
if (isSharedFolderOwner(where.owner) || typeof pdp?.holds !== 'function' || !mayActInPrivateContainer(ctx, where.owner, { vault: where.vault }))
|
|
82
|
+
return false;
|
|
83
|
+
return pdp.holds(ctx, action, { resource: { type: 'page', id: where.resource }, attributes: { vault: where.vault } });
|
|
84
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The encrypted user-level page catalog (#1385, epic #1382).
|
|
3
|
+
*
|
|
4
|
+
* `user-index.json` is wrapped with the user KEK; login decrypts it into the
|
|
5
|
+
* process session bag. Global `page-index.json` never receives sealed-store
|
|
6
|
+
* titles.
|
|
7
|
+
*
|
|
8
|
+
* What is left here is a MIGRATION path, not a live index: a store keeps its
|
|
9
|
+
* own page index (#1456), its own search index (#1458) and its own trash
|
|
10
|
+
* (#1459), and `adoptUserPageCatalog` moves what this file still holds into
|
|
11
|
+
* them and deletes it. The sibling `user-versions.json` and `user-trash.json`
|
|
12
|
+
* catalogs this module also wrote were never read by anything and went with
|
|
13
|
+
* #1459.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'fs-extra';
|
|
16
|
+
import { writeFileAtomic } from './atomicWrite.js';
|
|
17
|
+
import { decryptJson, encryptJson } from './privateStoreCrypto.js';
|
|
18
|
+
import { privateUserIndexPath, privateUserDir } from './privateStorePath.js';
|
|
19
|
+
function looksLikeWrap(value) {
|
|
20
|
+
if (!value || typeof value !== 'object')
|
|
21
|
+
return false;
|
|
22
|
+
const blob = value;
|
|
23
|
+
return typeof blob.iv === 'string' && typeof blob.tag === 'string' && typeof blob.ct === 'string';
|
|
24
|
+
}
|
|
25
|
+
export function emptyUserCatalog() {
|
|
26
|
+
return { version: 1, pages: {} };
|
|
27
|
+
}
|
|
28
|
+
export async function readUserCatalog(pagesDirectory, username, kek) {
|
|
29
|
+
const file = privateUserIndexPath(pagesDirectory, username);
|
|
30
|
+
if (!await fs.pathExists(file))
|
|
31
|
+
return emptyUserCatalog();
|
|
32
|
+
const raw = await fs.readJson(file);
|
|
33
|
+
if (!raw || typeof raw !== 'object')
|
|
34
|
+
return emptyUserCatalog();
|
|
35
|
+
const rec = raw;
|
|
36
|
+
if (rec.version !== 1 || !looksLikeWrap(rec.wrap))
|
|
37
|
+
return emptyUserCatalog();
|
|
38
|
+
const payload = decryptJson(kek, rec.wrap);
|
|
39
|
+
if (!payload || payload.version !== 1 || !payload.pages || typeof payload.pages !== 'object') {
|
|
40
|
+
return emptyUserCatalog();
|
|
41
|
+
}
|
|
42
|
+
return payload;
|
|
43
|
+
}
|
|
44
|
+
export async function writeUserCatalog(pagesDirectory, username, kek, catalog) {
|
|
45
|
+
await fs.ensureDir(privateUserDir(pagesDirectory, username));
|
|
46
|
+
const file = {
|
|
47
|
+
version: 1,
|
|
48
|
+
wrap: encryptJson(kek, catalog)
|
|
49
|
+
};
|
|
50
|
+
await writeFileAtomic(privateUserIndexPath(pagesDirectory, username), JSON.stringify(file, null, 2), 'utf8');
|
|
51
|
+
}
|
|
52
|
+
export async function upsertUserIndexPage(pagesDirectory, username, kek, page) {
|
|
53
|
+
const catalog = await readUserCatalog(pagesDirectory, username, kek);
|
|
54
|
+
catalog.pages[page.uuid] = page;
|
|
55
|
+
await writeUserCatalog(pagesDirectory, username, kek, catalog);
|
|
56
|
+
return catalog;
|
|
57
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private-store crypto (#1384, epic #1382).
|
|
3
|
+
*
|
|
4
|
+
* Random user KEK, wrapped by the login password and by a BIP39 12-word
|
|
5
|
+
* phrase. Each encrypted store has its own DEK wrapped by that KEK.
|
|
6
|
+
* Not on PageManager — see docs/private-stores.md.
|
|
7
|
+
*/
|
|
8
|
+
import { createCipheriv, createDecipheriv, createHash, timingSafeEqual, pbkdf2Sync, randomBytes, scrypt } from 'crypto';
|
|
9
|
+
import { BIP39_ENGLISH } from './bip39English.js';
|
|
10
|
+
import { newLinkKeyPair, openLockbox, parseLinkPublicKey, sealForLink } from './shareLockbox.js';
|
|
11
|
+
export const mnemonicWordCount = 12;
|
|
12
|
+
/** Production wrap cost — same ballpark as login hashes. */
|
|
13
|
+
export const PRIVATE_STORE_KDF = { N: 16384, r: 8, p: 1 };
|
|
14
|
+
/** Unit tests only — still a valid scrypt N, cheap enough for the suite. */
|
|
15
|
+
export const TEST_PRIVATE_STORE_KDF = { N: 16, r: 8, p: 1 };
|
|
16
|
+
const KEYLEN = 32;
|
|
17
|
+
const IVLEN = 12;
|
|
18
|
+
function maxmem(kdf) {
|
|
19
|
+
return 128 * kdf.N * kdf.r * 4;
|
|
20
|
+
}
|
|
21
|
+
function wrap(key, plaintext) {
|
|
22
|
+
const iv = randomBytes(IVLEN);
|
|
23
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
24
|
+
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
25
|
+
return {
|
|
26
|
+
iv: iv.toString('base64'),
|
|
27
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
28
|
+
ct: ct.toString('base64')
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function unwrap(key, blob, kind) {
|
|
32
|
+
try {
|
|
33
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(blob.iv, 'base64'));
|
|
34
|
+
decipher.setAuthTag(Buffer.from(blob.tag, 'base64'));
|
|
35
|
+
return Buffer.concat([
|
|
36
|
+
decipher.update(Buffer.from(blob.ct, 'base64')),
|
|
37
|
+
decipher.final()
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
if (kind === 'password')
|
|
42
|
+
throw new Error('password does not unwrap the user KEK');
|
|
43
|
+
if (kind === 'recovery')
|
|
44
|
+
throw new Error('recovery phrase does not unwrap the user KEK');
|
|
45
|
+
if (kind === 'catalog')
|
|
46
|
+
throw new Error('cannot decrypt catalog');
|
|
47
|
+
if (kind === 'person')
|
|
48
|
+
throw new Error('cannot unwrap the person key');
|
|
49
|
+
throw new Error('cannot unwrap store DEK');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Off the main thread (#1632): a sign-in that unlocks stores never stalls other requests. */
|
|
53
|
+
function derivePasswordKey(password, salt, kdf) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
scrypt(password, salt, KEYLEN, { N: kdf.N, r: kdf.r, p: kdf.p, maxmem: maxmem(kdf) }, (err, key) => (err ? reject(err) : resolve(key)));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function normalizeMnemonic(mnemonic) {
|
|
59
|
+
return mnemonic.normalize('NFKD').trim().toLowerCase().split(/\s+/).join(' ');
|
|
60
|
+
}
|
|
61
|
+
function mnemonicToWrapKey(mnemonic) {
|
|
62
|
+
const seed = pbkdf2Sync(normalizeMnemonic(mnemonic), 'mnemonic', 2048, 64, 'sha512');
|
|
63
|
+
return seed.subarray(0, KEYLEN);
|
|
64
|
+
}
|
|
65
|
+
function entropyToMnemonic(entropy) {
|
|
66
|
+
if (entropy.length !== 16)
|
|
67
|
+
throw new Error('12-word BIP39 uses 128 bits of entropy');
|
|
68
|
+
const checksum = createHash('sha256').update(entropy).digest()[0] >> 4;
|
|
69
|
+
let bits = '';
|
|
70
|
+
for (const byte of entropy)
|
|
71
|
+
bits += byte.toString(2).padStart(8, '0');
|
|
72
|
+
bits += checksum.toString(2).padStart(4, '0');
|
|
73
|
+
const words = [];
|
|
74
|
+
for (let i = 0; i < mnemonicWordCount; i++) {
|
|
75
|
+
const idx = Number.parseInt(bits.slice(i * 11, i * 11 + 11), 2);
|
|
76
|
+
words.push(BIP39_ENGLISH[idx]);
|
|
77
|
+
}
|
|
78
|
+
return words.join(' ');
|
|
79
|
+
}
|
|
80
|
+
export async function createUserKeys(password, opts = {}) {
|
|
81
|
+
const kdf = opts.kdf ?? PRIVATE_STORE_KDF;
|
|
82
|
+
const kek = randomBytes(KEYLEN);
|
|
83
|
+
const passwordSalt = randomBytes(16);
|
|
84
|
+
const mnemonic = entropyToMnemonic(randomBytes(16));
|
|
85
|
+
return {
|
|
86
|
+
kek,
|
|
87
|
+
mnemonic,
|
|
88
|
+
envelope: {
|
|
89
|
+
version: 1,
|
|
90
|
+
kdf: { ...kdf, salt: passwordSalt.toString('base64') },
|
|
91
|
+
passwordWrap: wrap(await derivePasswordKey(password, passwordSalt, kdf), kek),
|
|
92
|
+
recoveryKdf: { alg: 'bip39-pbkdf2' },
|
|
93
|
+
recoveryWrap: wrap(mnemonicToWrapKey(mnemonic), kek),
|
|
94
|
+
personKey: await newPersonKey(kek)
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** A fresh key pair for a person (#1762 slice 5), its private half wrapped under their KEK. */
|
|
99
|
+
export async function newPersonKey(kek) {
|
|
100
|
+
const pair = await newLinkKeyPair();
|
|
101
|
+
const secret = Buffer.from(pair.linkKey, 'utf8');
|
|
102
|
+
try {
|
|
103
|
+
return { publicKey: pair.publicKey, privateWrap: wrap(kek, secret) };
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
secret.fill(0);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** The public half of a person's key pair, checked strictly; null when they have none. */
|
|
110
|
+
export function personPublicKey(envelope) {
|
|
111
|
+
return parseLinkPublicKey(envelope.personKey?.publicKey);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Seal a copy of `kek` to `recipient`'s public key (#1762 slice 5): the copy a
|
|
115
|
+
* proxy grant carries. Only the recipient's private key opens it.
|
|
116
|
+
*/
|
|
117
|
+
export function sealKekCopy(recipient, kek) {
|
|
118
|
+
return sealForLink(recipient, kek);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Open a KEK copy sealed to this person (#1762 slice 5): `ownKek` unwraps
|
|
122
|
+
* their private key from `envelope`, which opens `copy`. Throws when any step
|
|
123
|
+
* fails; the private key never leaves this function.
|
|
124
|
+
*/
|
|
125
|
+
export async function openKekCopy(envelope, ownKek, copy) {
|
|
126
|
+
if (!envelope.personKey)
|
|
127
|
+
throw new Error('cannot unwrap the person key');
|
|
128
|
+
const secret = unwrap(ownKek, envelope.personKey.privateWrap, 'person');
|
|
129
|
+
let opened;
|
|
130
|
+
try {
|
|
131
|
+
opened = await openLockbox(secret.toString('utf8'), copy);
|
|
132
|
+
if (opened.length !== KEYLEN)
|
|
133
|
+
throw new Error('not a key copy');
|
|
134
|
+
return Buffer.from(opened);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new Error('cannot open the key copy');
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
secret.fill(0);
|
|
141
|
+
opened?.fill(0);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export async function unwrapKekWithPassword(envelope, password) {
|
|
145
|
+
const salt = Buffer.from(envelope.kdf.salt, 'base64');
|
|
146
|
+
const key = await derivePasswordKey(password, salt, envelope.kdf);
|
|
147
|
+
return unwrap(key, envelope.passwordWrap, 'password');
|
|
148
|
+
}
|
|
149
|
+
export function unwrapKekWithMnemonic(envelope, mnemonic) {
|
|
150
|
+
return unwrap(mnemonicToWrapKey(mnemonic), envelope.recoveryWrap, 'recovery');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A fresh set of 12 recovery words for a KEK that has not been committed yet
|
|
154
|
+
* (#1414): the store door's words screen, where a failed confirmation discards
|
|
155
|
+
* the words it showed and never re-shows them. The KEK and its password wrap
|
|
156
|
+
* are kept; only the recovery wrap is replaced.
|
|
157
|
+
*/
|
|
158
|
+
export function newRecoveryWords(kek) {
|
|
159
|
+
const mnemonic = entropyToMnemonic(randomBytes(16));
|
|
160
|
+
return { mnemonic, recoveryWrap: wrap(mnemonicToWrapKey(mnemonic), kek) };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Which set of recovery words an envelope holds (#1928), as a short id: a
|
|
164
|
+
* digest of the recovery wrap, which a new set of words replaces and nothing
|
|
165
|
+
* else changes (a password change or a new person key keep it). Reveals
|
|
166
|
+
* nothing about the words or the key; it is what a recorded "I saved my
|
|
167
|
+
* recovery words" is bound to, so a new set asks again.
|
|
168
|
+
*/
|
|
169
|
+
export function recoveryWordsId(envelope) {
|
|
170
|
+
const w = envelope.recoveryWrap;
|
|
171
|
+
return createHash('sha256').update(`${w.iv}.${w.tag}.${w.ct}`).digest('hex').slice(0, 32);
|
|
172
|
+
}
|
|
173
|
+
/** True when two phrases are the same 12 words, compared as the recovery wrap reads them. */
|
|
174
|
+
export function sameMnemonic(entered, expected) {
|
|
175
|
+
const a = Buffer.from(normalizeMnemonic(entered));
|
|
176
|
+
const b = Buffer.from(normalizeMnemonic(expected));
|
|
177
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
178
|
+
}
|
|
179
|
+
export async function rewrapPassword(envelope, oldPassword, newPassword, opts = {}) {
|
|
180
|
+
return rewrapPasswordWithKek(envelope, await unwrapKekWithPassword(envelope, oldPassword), newPassword, opts);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Replace the password wrap, given the KEK itself (#1452): how the recovery
|
|
184
|
+
* words set a new password when the old one is forgotten. The KEK bytes and
|
|
185
|
+
* the recovery wrap are unchanged, so every store's wrapped DEK stays valid
|
|
186
|
+
* and the same words keep working.
|
|
187
|
+
*/
|
|
188
|
+
export async function rewrapPasswordWithKek(envelope, kek, newPassword, opts = {}) {
|
|
189
|
+
const kdf = opts.kdf ?? { N: envelope.kdf.N, r: envelope.kdf.r, p: envelope.kdf.p };
|
|
190
|
+
const passwordSalt = randomBytes(16);
|
|
191
|
+
return {
|
|
192
|
+
...envelope,
|
|
193
|
+
kdf: { ...kdf, salt: passwordSalt.toString('base64') },
|
|
194
|
+
passwordWrap: wrap(await derivePasswordKey(newPassword, passwordSalt, kdf), kek)
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
export function createEncryptedStore(kek) {
|
|
198
|
+
const dek = randomBytes(KEYLEN);
|
|
199
|
+
return { encrypt: true, dekWrap: wrap(kek, dek) };
|
|
200
|
+
}
|
|
201
|
+
export function unwrapDek(kek, store) {
|
|
202
|
+
return unwrap(kek, store.dekWrap, 'DEK');
|
|
203
|
+
}
|
|
204
|
+
/** Encrypt a JSON value with the user KEK (user-index / versions / trash). */
|
|
205
|
+
export function encryptJson(key, value) {
|
|
206
|
+
return wrap(key, Buffer.from(JSON.stringify(value), 'utf8'));
|
|
207
|
+
}
|
|
208
|
+
/** Decrypt a JSON value wrapped by {@link encryptJson}. */
|
|
209
|
+
export function decryptJson(key, blob) {
|
|
210
|
+
const buf = unwrap(key, blob, 'catalog');
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(buf.toString('utf8'));
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
throw new Error('cannot decrypt catalog');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* A file in an encrypted store on disk (#1415): `SEALED_MAGIC`, then the IV,
|
|
220
|
+
* the GCM tag and the ciphertext. Binary, so page text and attachment bytes
|
|
221
|
+
* (#1400) share one format; the magic lets a reader tell a sealed file from a
|
|
222
|
+
* plaintext one without trying the key.
|
|
223
|
+
*/
|
|
224
|
+
const SEALED_MAGIC = Buffer.from('NGDPSEAL1', 'ascii');
|
|
225
|
+
const TAGLEN = 16;
|
|
226
|
+
/** True when `bytes` start with the sealed-file magic. */
|
|
227
|
+
export function isSealedBytes(bytes) {
|
|
228
|
+
return bytes.length >= SEALED_MAGIC.length + IVLEN + TAGLEN
|
|
229
|
+
&& bytes.subarray(0, SEALED_MAGIC.length).equals(SEALED_MAGIC);
|
|
230
|
+
}
|
|
231
|
+
/** Encrypt the bytes of one store file with the store DEK. */
|
|
232
|
+
export function sealBytes(dek, plaintext) {
|
|
233
|
+
if (dek.length !== KEYLEN)
|
|
234
|
+
throw new Error('encrypted store is locked: missing DEK');
|
|
235
|
+
const iv = randomBytes(IVLEN);
|
|
236
|
+
const cipher = createCipheriv('aes-256-gcm', dek, iv);
|
|
237
|
+
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
238
|
+
return Buffer.concat([SEALED_MAGIC, iv, cipher.getAuthTag(), ct]);
|
|
239
|
+
}
|
|
240
|
+
/** Decrypt a store file written by {@link sealBytes}. */
|
|
241
|
+
export function openBytes(dek, sealed) {
|
|
242
|
+
if (!isSealedBytes(sealed))
|
|
243
|
+
throw new Error('not a sealed store file');
|
|
244
|
+
const ivStart = SEALED_MAGIC.length;
|
|
245
|
+
const tagStart = ivStart + IVLEN;
|
|
246
|
+
const ctStart = tagStart + TAGLEN;
|
|
247
|
+
try {
|
|
248
|
+
const decipher = createDecipheriv('aes-256-gcm', dek, sealed.subarray(ivStart, tagStart));
|
|
249
|
+
decipher.setAuthTag(sealed.subarray(tagStart, ctStart));
|
|
250
|
+
return Buffer.concat([decipher.update(sealed.subarray(ctStart)), decipher.final()]);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
throw new Error('cannot open sealed store file');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Shared encrypt-on write gate. Callers pass the DEK from the session bag.
|
|
258
|
+
* Not a PageManager method.
|
|
259
|
+
*/
|
|
260
|
+
export function assertEncryptedStoreWritable(args) {
|
|
261
|
+
if (!args.encrypt)
|
|
262
|
+
return;
|
|
263
|
+
if (!args.dek || args.dek.length !== KEYLEN) {
|
|
264
|
+
throw new Error('encrypted store is locked: missing DEK');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -121,6 +121,16 @@ export declare function holdWordsForConfirmation(handle: string, args: {
|
|
|
121
121
|
}): void;
|
|
122
122
|
/** Whether this session is part-way through the words screen for `store`. */
|
|
123
123
|
export declare function hasPendingWords(handle: string, username: string, store: string): boolean;
|
|
124
|
+
/**
|
|
125
|
+
* The words this session is part-way through confirming for `store`, to
|
|
126
|
+
* download or print (#2144), or undefined: none, or they expired. Once
|
|
127
|
+
* confirmed or abandoned they are gone.
|
|
128
|
+
*/
|
|
129
|
+
export declare function pendingWordsOf(handle: string, username: string, store: string): {
|
|
130
|
+
words: string[];
|
|
131
|
+
madeAt: Date;
|
|
132
|
+
envelope: UserKeyEnvelope;
|
|
133
|
+
} | undefined;
|
|
124
134
|
export type ConfirmOutcome = {
|
|
125
135
|
status: 'confirmed';
|
|
126
136
|
kek: Buffer;
|