@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,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery words to keep (#2144): the sheet an admin or a user downloads as a
|
|
3
|
+
* file or prints while their new words still wait to be confirmed, and the
|
|
4
|
+
* audit record of handing them out.
|
|
5
|
+
*
|
|
6
|
+
* One sheet for both sets of words: the shared folders' (SharedFolderManager)
|
|
7
|
+
* and a user's vault key (the store door, `privateStoreDoor.ts`). The words
|
|
8
|
+
* come only from the set still held for confirmation; once confirmed they are
|
|
9
|
+
* gone from the server and cannot be made again, so nothing here stores,
|
|
10
|
+
* caches or logs them. No key material is on the sheet.
|
|
11
|
+
*/
|
|
12
|
+
import { type ActorContext } from '../context/ActorContext.js';
|
|
13
|
+
import { type AuditEventSink } from './auditEvents.js';
|
|
14
|
+
/** Whose words: the organization's shared folders', or a user's vault key. */
|
|
15
|
+
export type RecoveryWordsSet = 'shared-folders' | 'store';
|
|
16
|
+
/** How they left the page: a plain text file, or the print view. */
|
|
17
|
+
export type RecoveryWordsExport = 'download' | 'print';
|
|
18
|
+
/** Everything the sheet shows, for the text file and the print view alike. */
|
|
19
|
+
export interface RecoveryWordsSheet {
|
|
20
|
+
title: string;
|
|
21
|
+
siteName: string;
|
|
22
|
+
siteUrl: string;
|
|
23
|
+
/** When the words were made, in the server's local time with its offset. */
|
|
24
|
+
made: string;
|
|
25
|
+
words: readonly string[];
|
|
26
|
+
purpose: string;
|
|
27
|
+
notStored: string;
|
|
28
|
+
}
|
|
29
|
+
/** `date` in the server's local time: `2026-10-30 05:28 (UTC-04:00)`. */
|
|
30
|
+
export declare function serverLocalTime(date: Date): string;
|
|
31
|
+
/** The sheet for `words` of `set`, made at `madeAt`, on the site named `siteName` at `siteUrl`. */
|
|
32
|
+
export declare function recoveryWordsSheet(args: {
|
|
33
|
+
set: RecoveryWordsSet;
|
|
34
|
+
siteName: string;
|
|
35
|
+
siteUrl: string;
|
|
36
|
+
words: readonly string[];
|
|
37
|
+
madeAt: Date;
|
|
38
|
+
}): RecoveryWordsSheet;
|
|
39
|
+
/** The sheet as a plain text file: the site, when made, the numbered words, what they are for. */
|
|
40
|
+
export declare function recoveryWordsText(sheet: RecoveryWordsSheet): string;
|
|
41
|
+
/** The file name: the site's name and whose words, safe for any file system. */
|
|
42
|
+
export declare function recoveryWordsFilename(siteName: string, set: RecoveryWordsSet): string;
|
|
43
|
+
/**
|
|
44
|
+
* Record that `ctx` downloaded or opened to print the set `wordsId` of `set`
|
|
45
|
+
* (`recovery-words-export`, on-failure refuse): called before the words are
|
|
46
|
+
* sent, so a record that cannot be written sends nothing. Who, which set and
|
|
47
|
+
* how; never the words.
|
|
48
|
+
*/
|
|
49
|
+
export declare function recordRecoveryWordsExport(sink: AuditEventSink | null | undefined, ctx: ActorContext, what: {
|
|
50
|
+
set: RecoveryWordsSet;
|
|
51
|
+
wordsId: string;
|
|
52
|
+
how: RecoveryWordsExport;
|
|
53
|
+
store?: string;
|
|
54
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safeRedirect — constrain a request-supplied redirect target to this site (#1041).
|
|
3
|
+
*
|
|
4
|
+
* `POST /login` passed `req.body.redirect` straight to `res.redirect`, so the
|
|
5
|
+
* login endpoint sent the browser anywhere the request named:
|
|
6
|
+
*
|
|
7
|
+
* redirect=//example.com/ → HTTP/1.1 302 Location: //example.com/
|
|
8
|
+
*
|
|
9
|
+
* That is the worst place in the application to have an open redirect. The
|
|
10
|
+
* victim authenticates against the real site, sees a genuine successful login,
|
|
11
|
+
* and is then handed to an attacker-controlled page — a clone asking them to
|
|
12
|
+
* "sign in again" is very convincing at that point.
|
|
13
|
+
*
|
|
14
|
+
* The rule is deliberately a strict allow-list rather than a blocklist of bad
|
|
15
|
+
* prefixes: a single leading slash, and nothing that could be read as a host or
|
|
16
|
+
* a scheme. Blocklists here fail to the open case, which is the wrong direction
|
|
17
|
+
* for a redirect sink.
|
|
18
|
+
*/
|
|
19
|
+
/** Where a rejected target sends the browser instead. */
|
|
20
|
+
const FALLBACK = '/';
|
|
21
|
+
/**
|
|
22
|
+
* Return `target` when it is a same-site path, otherwise `/`.
|
|
23
|
+
*
|
|
24
|
+
* Accepts: `/`, `/view/Welcome`, `/search?q=x#frag`
|
|
25
|
+
*
|
|
26
|
+
* Rejects, among others:
|
|
27
|
+
* - `//evil.example` — protocol-relative; the browser reads it as a host
|
|
28
|
+
* - `https://evil.example`, `javascript:…`, `data:…` — any scheme
|
|
29
|
+
* - `/\evil.example` — browsers normalise the backslash to `/`
|
|
30
|
+
* - `\\evil.example`, or anything not starting with `/`
|
|
31
|
+
* - a CR/LF, which would let a caller inject a second header
|
|
32
|
+
*/
|
|
33
|
+
export function safeRedirect(target) {
|
|
34
|
+
if (typeof target !== 'string')
|
|
35
|
+
return FALLBACK;
|
|
36
|
+
const value = target.trim();
|
|
37
|
+
if (value === '')
|
|
38
|
+
return FALLBACK;
|
|
39
|
+
// Header injection: a raw CR or LF in a Location value is never legitimate.
|
|
40
|
+
if (/[\r\n]/.test(value))
|
|
41
|
+
return FALLBACK;
|
|
42
|
+
// Must be an absolute path on this site.
|
|
43
|
+
if (!value.startsWith('/'))
|
|
44
|
+
return FALLBACK;
|
|
45
|
+
// `//host` and `/\host` are both read as protocol-relative by browsers —
|
|
46
|
+
// the second because they normalise backslashes to forward slashes.
|
|
47
|
+
if (value.startsWith('//') || value.startsWith('/\\'))
|
|
48
|
+
return FALLBACK;
|
|
49
|
+
// A scheme cannot appear in a path-only target. Catches `/redirect?to=http://…`
|
|
50
|
+
// being handed on verbatim as well as odd encodings that reach us decoded.
|
|
51
|
+
if (value.includes('://'))
|
|
52
|
+
return FALLBACK;
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
export default safeRedirect;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a signed-in session still belongs to its account (#1937, #1482).
|
|
3
|
+
*
|
|
4
|
+
* A session names its account by username, and a username outlives the
|
|
5
|
+
* account: deleted, the name is free for a new account. So a session is
|
|
6
|
+
* bound, at sign-in, to the account it signed in to (`accountCreatedAt`, the
|
|
7
|
+
* account's `createdAt`), and on every request, and in the store sweep, it is
|
|
8
|
+
* held to that account as it is now:
|
|
9
|
+
*
|
|
10
|
+
* - no account by that name: the account was deleted (`account-deleted`);
|
|
11
|
+
* - an account by that name that is not the one it signed in to (another
|
|
12
|
+
* `createdAt`, or created after the session signed in): the account was
|
|
13
|
+
* deleted and the name reused, also `account-deleted`;
|
|
14
|
+
* - the account is turned off (`isActive === false`): `account-disabled`;
|
|
15
|
+
* - the password changed since it signed in (#1482): `password-changed`.
|
|
16
|
+
*
|
|
17
|
+
* One rule, asked by the session middleware in `app.ts` and by
|
|
18
|
+
* `UserManager.endStaleSessions` at startup; the end goes through `endSession`.
|
|
19
|
+
*/
|
|
20
|
+
import { sessionIsCurrent } from './sessionGeneration.js';
|
|
21
|
+
/** The value a session is bound to: the account's creation time, or '' for a record without one. */
|
|
22
|
+
export function accountBindingOf(account) {
|
|
23
|
+
return typeof account?.createdAt === 'string' ? account.createdAt : '';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* What one request (or the sweep) does to a signed-in session, given its
|
|
27
|
+
* account as stored now (null when there is none). `bind` is set on a session
|
|
28
|
+
* signed in before the binding existed that still belongs to its account: the
|
|
29
|
+
* caller writes it to `accountCreatedAt`, once.
|
|
30
|
+
*/
|
|
31
|
+
export function checkSessionAccount(stored, account) {
|
|
32
|
+
const session = stored;
|
|
33
|
+
if (!account)
|
|
34
|
+
return { end: 'account-deleted' };
|
|
35
|
+
const binding = accountBindingOf(account);
|
|
36
|
+
if (typeof session.accountCreatedAt === 'string') {
|
|
37
|
+
if (session.accountCreatedAt !== binding)
|
|
38
|
+
return { end: 'account-deleted' };
|
|
39
|
+
}
|
|
40
|
+
else if (createdAfter(account, session.signedInAt)) {
|
|
41
|
+
// Signed in before this account existed: it belonged to an earlier one of the same name.
|
|
42
|
+
return { end: 'account-deleted' };
|
|
43
|
+
}
|
|
44
|
+
if (account.isActive === false)
|
|
45
|
+
return { end: 'account-disabled' };
|
|
46
|
+
if (!sessionIsCurrent(session.sessionGeneration, account))
|
|
47
|
+
return { end: 'password-changed' };
|
|
48
|
+
return typeof session.accountCreatedAt === 'string' ? { end: null } : { end: null, bind: binding };
|
|
49
|
+
}
|
|
50
|
+
function createdAfter(account, signedInAt) {
|
|
51
|
+
if (typeof signedInAt !== 'number' || !Number.isFinite(signedInAt))
|
|
52
|
+
return false;
|
|
53
|
+
const created = typeof account.createdAt === 'string' ? Date.parse(account.createdAt) : NaN;
|
|
54
|
+
return Number.isFinite(created) && created > signedInAt;
|
|
55
|
+
}
|
|
@@ -2,6 +2,7 @@ export type SessionEndReason = 'logout' | 'idle-timeout' | 'max-age' | 'password
|
|
|
2
2
|
export declare function endSession(engine: {
|
|
3
3
|
getManager(name: string): unknown;
|
|
4
4
|
}, session: {
|
|
5
|
+
id?: unknown;
|
|
5
6
|
username?: unknown;
|
|
6
7
|
privateStoreHandle?: unknown;
|
|
7
8
|
pendingConnection?: unknown;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one door for a signed-in session ending (#1626, #1670).
|
|
3
|
+
*
|
|
4
|
+
* Whatever ends it — the person signs out, the session idles out or reaches
|
|
5
|
+
* `ngdpbase.session.max-age` from its sign-in (#1767), a password
|
|
6
|
+
* change ends the account's other sessions, or someone signs in again on the
|
|
7
|
+
* same browser — goes through here, so two things can never be forgotten:
|
|
8
|
+
*
|
|
9
|
+
* - its unlocked private-store keys are zeroed and dropped (#1626), and
|
|
10
|
+
* - the end is recorded as `authentication-logout`, with who, from where and
|
|
11
|
+
* why (#1670). Until this, only the idle timeout was recorded.
|
|
12
|
+
* - a choice to act for someone (`actingFor`, #1762) ends with it.
|
|
13
|
+
* - so do the form POSTs it kept for a fresh sign-in (#2176), by its `id`.
|
|
14
|
+
*
|
|
15
|
+
* An account deleted or turned off ends its sessions here too (#1937), with
|
|
16
|
+
* the reason `account-deleted` / `account-disabled`: on the session's next
|
|
17
|
+
* request (`checkSessionAccount`), and in the store when the account is
|
|
18
|
+
* closed (`UserManager.deleteUserSessions`) or at startup (`endStaleSessions`).
|
|
19
|
+
*
|
|
20
|
+
* An admin revoking another person's session is recorded separately as
|
|
21
|
+
* `session-revoke`, by the route that does it. A session that simply expires
|
|
22
|
+
* in the store has no hook to call this; its keys are swept (#1626).
|
|
23
|
+
*/
|
|
24
|
+
import { endSessionKeys } from './privateStoreUnlock.js';
|
|
25
|
+
import logger from './logger.js';
|
|
26
|
+
export function endSession(engine, session, reason, from) {
|
|
27
|
+
endSessionKeys(session?.privateStoreHandle);
|
|
28
|
+
// #1743: an outside identity waiting to be connected never outlives the session it waited in.
|
|
29
|
+
if (session)
|
|
30
|
+
delete session.pendingConnection;
|
|
31
|
+
// #1762 slice 4: nor does a choice to act for someone.
|
|
32
|
+
if (session)
|
|
33
|
+
delete session.actingFor;
|
|
34
|
+
// #2176: nor do the form POSTs it kept for a fresh sign-in.
|
|
35
|
+
if (typeof session?.id === 'string')
|
|
36
|
+
engine.getManager('AuthManager')?.dropStepUpReplays?.(session.id);
|
|
37
|
+
const username = typeof session?.username === 'string' && session.username ? session.username : null;
|
|
38
|
+
if (!username)
|
|
39
|
+
return; // nobody was signed in: nothing ended
|
|
40
|
+
const audit = engine.getManager('AuditManager');
|
|
41
|
+
void audit?.logAuthentication?.({ username, ipAddress: from.ipAddress, userAgent: from.userAgent, loginMethod: 'session' }, 'logout', reason).catch((err) => logger.warn(`[SESSION] Could not record the end of ${username}'s session (${reason}):`, err));
|
|
42
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password-change session generation (#1482).
|
|
3
|
+
*
|
|
4
|
+
* A user record carries `sessionGeneration`, a counter raised whenever the
|
|
5
|
+
* account's password changes — by the owner, an administrator, the recovery
|
|
6
|
+
* words (#1452) or the offline reset script. A session is stamped with the
|
|
7
|
+
* counter when it signs in; a request whose session carries an older value is
|
|
8
|
+
* signed out. That ends every other session of the account without finding
|
|
9
|
+
* sessions on disk, at the cost of one comparison per request.
|
|
10
|
+
*
|
|
11
|
+
* An account that has never changed its password is at 0, and a session
|
|
12
|
+
* signed in before this existed carries nothing, which reads as 0: existing
|
|
13
|
+
* sessions stay signed in until the first password change.
|
|
14
|
+
*/
|
|
15
|
+
/** The account's current generation; 0 when it has never been raised. */
|
|
16
|
+
export function sessionGenerationOf(user) {
|
|
17
|
+
const g = user?.sessionGeneration;
|
|
18
|
+
return typeof g === 'number' && Number.isInteger(g) && g >= 0 ? g : 0;
|
|
19
|
+
}
|
|
20
|
+
/** Raise the account's generation, in place, and return the new value. */
|
|
21
|
+
export function bumpSessionGeneration(user) {
|
|
22
|
+
user.sessionGeneration = sessionGenerationOf(user) + 1;
|
|
23
|
+
return user.sessionGeneration;
|
|
24
|
+
}
|
|
25
|
+
/** Whether a session stamped with `stamped` still belongs to the account as it is now. */
|
|
26
|
+
export function sessionIsCurrent(stamped, user) {
|
|
27
|
+
const s = typeof stamped === 'number' && Number.isInteger(stamped) && stamped >= 0 ? stamped : 0;
|
|
28
|
+
return s === sessionGenerationOf(user);
|
|
29
|
+
}
|
|
@@ -39,3 +39,7 @@ export declare function warnBeforeMs(timeoutMs: number): number;
|
|
|
39
39
|
export declare function idleRemainingMs(lastActivity: unknown, now: number, timeoutMs: number): number | null;
|
|
40
40
|
/** The status poll does not count as activity, or the warning would keep the session alive by itself. */
|
|
41
41
|
export declare const IDLE_STATUS_PATH = "/api/session/idle-status";
|
|
42
|
+
/** #2193: nor does a page's poll for its step-up prompt, or a page left waiting would keep its session alive. */
|
|
43
|
+
export declare const STEP_UP_STATUS_PATH = "/auth/reauth/status";
|
|
44
|
+
/** Whether a request to `path` counts as the person's activity: every one but the polls above. */
|
|
45
|
+
export declare function countsAsActivity(path: string): boolean;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session idle timeout (#1546).
|
|
3
|
+
*
|
|
4
|
+
* `ngdpbase.session.idle-timeout-minutes` ends a signed-in session after that
|
|
5
|
+
* many minutes with no request. Shipped at 10080 (7 days, #1767); `0` is off.
|
|
6
|
+
* It is separate from `ngdpbase.session.max-age`, the absolute cap from
|
|
7
|
+
* sign-in (`sessionLimits.ts`); a value at or above that cap has no effect.
|
|
8
|
+
*
|
|
9
|
+
* Enforced server-side on the next request — never only by a browser timer,
|
|
10
|
+
* which a closed laptop or a copied cookie would not run.
|
|
11
|
+
*
|
|
12
|
+
* Writing the last-activity time on every request would save the session on
|
|
13
|
+
* every request, which the file store pays for, so it is written at most once
|
|
14
|
+
* per `touchEveryMs`: a session may then end up to that much later than the
|
|
15
|
+
* exact limit, never earlier.
|
|
16
|
+
*/
|
|
17
|
+
/** The configured limit in milliseconds; 0 when off or not a positive number. */
|
|
18
|
+
export function idleTimeoutMs(minutes) {
|
|
19
|
+
return typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0 ? minutes * 60_000 : 0;
|
|
20
|
+
}
|
|
21
|
+
/** How stale the recorded activity may get before it is written again. */
|
|
22
|
+
export function touchEveryMs(timeoutMs) {
|
|
23
|
+
return Math.min(60_000, Math.floor(timeoutMs / 4));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Whether a session last active at `lastActivity` (epoch ms) has been idle
|
|
27
|
+
* past the limit. A session with no recorded activity — one signed in before
|
|
28
|
+
* the limit was set — is not expired; it starts counting now.
|
|
29
|
+
*/
|
|
30
|
+
export function idleExpired(lastActivity, now, timeoutMs) {
|
|
31
|
+
if (timeoutMs <= 0 || typeof lastActivity !== 'number' || !Number.isFinite(lastActivity))
|
|
32
|
+
return false;
|
|
33
|
+
return now - lastActivity > timeoutMs;
|
|
34
|
+
}
|
|
35
|
+
/** Whether the recorded activity should be written on this request. */
|
|
36
|
+
export function shouldTouch(lastActivity, now, timeoutMs) {
|
|
37
|
+
if (timeoutMs <= 0)
|
|
38
|
+
return false;
|
|
39
|
+
if (typeof lastActivity !== 'number' || !Number.isFinite(lastActivity))
|
|
40
|
+
return true;
|
|
41
|
+
return now - lastActivity >= touchEveryMs(timeoutMs);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The limit that applies to one person (#1546): the shortest positive value
|
|
45
|
+
* among the site setting and their roles' `idle-timeout-minutes`. A role can
|
|
46
|
+
* only shorten the site value, never lengthen it; 0 or absent means "no limit
|
|
47
|
+
* from here", so with nothing set anywhere the timeout stays off.
|
|
48
|
+
*/
|
|
49
|
+
export function effectiveIdleTimeoutMs(siteMinutes, roleMinutes = []) {
|
|
50
|
+
const candidates = [siteMinutes, ...roleMinutes].map(idleTimeoutMs).filter(ms => ms > 0);
|
|
51
|
+
return candidates.length === 0 ? 0 : Math.min(...candidates);
|
|
52
|
+
}
|
|
53
|
+
/** How long before sign-out the page warns: two minutes, or a quarter of a shorter limit. */
|
|
54
|
+
export function warnBeforeMs(timeoutMs) {
|
|
55
|
+
return Math.min(120_000, Math.floor(timeoutMs / 4));
|
|
56
|
+
}
|
|
57
|
+
/** What is left of the limit, never negative; null when no limit applies. */
|
|
58
|
+
export function idleRemainingMs(lastActivity, now, timeoutMs) {
|
|
59
|
+
if (timeoutMs <= 0)
|
|
60
|
+
return null;
|
|
61
|
+
if (typeof lastActivity !== 'number' || !Number.isFinite(lastActivity))
|
|
62
|
+
return timeoutMs;
|
|
63
|
+
return Math.max(0, timeoutMs - (now - lastActivity));
|
|
64
|
+
}
|
|
65
|
+
/** The status poll does not count as activity, or the warning would keep the session alive by itself. */
|
|
66
|
+
export const IDLE_STATUS_PATH = '/api/session/idle-status';
|
|
67
|
+
/** #2193: nor does a page's poll for its step-up prompt, or a page left waiting would keep its session alive. */
|
|
68
|
+
export const STEP_UP_STATUS_PATH = '/auth/reauth/status';
|
|
69
|
+
/** Whether a request to `path` counts as the person's activity: every one but the polls above. */
|
|
70
|
+
export function countsAsActivity(path) {
|
|
71
|
+
return path !== IDLE_STATUS_PATH && path !== STEP_UP_STATUS_PATH;
|
|
72
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session limits (#1767): the absolute cap on a signed-in session, and how it
|
|
3
|
+
* sits beside the idle timeout (#1546, `sessionIdle.ts`).
|
|
4
|
+
*
|
|
5
|
+
* `ngdpbase.session.max-age` (milliseconds) is the longest a sign-in lasts:
|
|
6
|
+
* a signed-in session ends that long after it signed in, however active it
|
|
7
|
+
* has been. Shipped at 30 days; the idle timeout (7 days) usually ends a
|
|
8
|
+
* session first.
|
|
9
|
+
*
|
|
10
|
+
* Why it is enforced here and not by the cookie (found 2026-10-12, #1767):
|
|
11
|
+
* before this, `max-age` only set express-session's `cookie.maxAge` and the
|
|
12
|
+
* file store's `ttl`, and neither is a cap.
|
|
13
|
+
*
|
|
14
|
+
* - express-session (`rolling: false`, the default) re-issues the cookie with
|
|
15
|
+
* a fresh expiry, now + maxAge, on every response whose session changed;
|
|
16
|
+
* the app changes it often (CSRF token, IP capture, the idle timer's
|
|
17
|
+
* last-activity write).
|
|
18
|
+
* - On every other request it calls the store's `touch()`, and
|
|
19
|
+
* session-file-store rewrites `__lastAccess`; a stored session expires
|
|
20
|
+
* `cookie.originalMaxAge` after its LAST access, not its first.
|
|
21
|
+
*
|
|
22
|
+
* So `max-age` was a rolling lifetime: a session in use never ended. The cap
|
|
23
|
+
* is now checked on every request from `signedInAt`, which only sign-in
|
|
24
|
+
* completion writes (`AuthManager.completeSignIn`, on the new session after
|
|
25
|
+
* regeneration); a re-authentication (step-up) does not move it.
|
|
26
|
+
*
|
|
27
|
+
* The cookie and the store keep their rolling behaviour and only have to be
|
|
28
|
+
* long enough: a signed-in session's cookie lives `max-age` from its last
|
|
29
|
+
* change (set at sign-in), so it never ends a session before the cap. An
|
|
30
|
+
* anonymous session (every visitor gets one, for the CSRF token) keeps the
|
|
31
|
+
* shorter `ngdpbase.session.anonymous-max-age`, so a 30-day cap does not keep a month of
|
|
32
|
+
* one-visit sessions in the store.
|
|
33
|
+
*/
|
|
34
|
+
import { idleExpired, shouldTouch } from './sessionIdle.js';
|
|
35
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
36
|
+
/** The shipped `ngdpbase.session.max-age`: 30 days. */
|
|
37
|
+
export const DEFAULT_SESSION_MAX_AGE_MS = 30 * DAY_MS;
|
|
38
|
+
/** The shipped `ngdpbase.session.anonymous-max-age`: a day, the old shipped `max-age`. */
|
|
39
|
+
export const DEFAULT_ANONYMOUS_SESSION_MS = DAY_MS;
|
|
40
|
+
/**
|
|
41
|
+
* How long a session nobody signed in to lives after its last change:
|
|
42
|
+
* `ngdpbase.session.anonymous-max-age` (the shipped day when not a positive
|
|
43
|
+
* number), or the cap when that is shorter.
|
|
44
|
+
*/
|
|
45
|
+
export function anonymousSessionMs(capMs, configured = DEFAULT_ANONYMOUS_SESSION_MS) {
|
|
46
|
+
const anonymousMs = typeof configured === 'number' && Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_ANONYMOUS_SESSION_MS;
|
|
47
|
+
return Math.min(anonymousMs, capMs);
|
|
48
|
+
}
|
|
49
|
+
/** The configured cap in milliseconds; the shipped 30 days when not a positive number. There is no "off". */
|
|
50
|
+
export function sessionCapMs(value) {
|
|
51
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_SESSION_MAX_AGE_MS;
|
|
52
|
+
}
|
|
53
|
+
/** Whether a session signed in at `signedInAt` (epoch ms) is past the cap. No recorded time is not expired. */
|
|
54
|
+
export function capExpired(signedInAt, now, capMs) {
|
|
55
|
+
if (typeof signedInAt !== 'number' || !Number.isFinite(signedInAt))
|
|
56
|
+
return false;
|
|
57
|
+
return now - signedInAt > capMs;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The per-request check of a signed-in session against both limits: the cap
|
|
61
|
+
* first (it holds whatever the activity), then the idle timeout. A session
|
|
62
|
+
* signed in before `signedInAt` was recorded starts its cap now
|
|
63
|
+
* (`stampSignedIn`), as the idle timer does for a missing last activity.
|
|
64
|
+
*/
|
|
65
|
+
export function checkSessionLimits(session, now, limits) {
|
|
66
|
+
if (capExpired(session.signedInAt, now, limits.capMs))
|
|
67
|
+
return { end: 'max-age' };
|
|
68
|
+
if (idleExpired(session.lastActivity, now, limits.idleMs))
|
|
69
|
+
return { end: 'idle-timeout' };
|
|
70
|
+
const stamped = typeof session.signedInAt === 'number' && Number.isFinite(session.signedInAt);
|
|
71
|
+
return { end: null, stampSignedIn: !stamped, touch: shouldTouch(session.lastActivity, now, limits.idleMs) };
|
|
72
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolveSessionSecurity — keep `session.secure` and `trust proxy` consistent (#1046).
|
|
3
|
+
*
|
|
4
|
+
* #1043 made the session cookie `secure` actually turn on in production. On
|
|
5
|
+
* every HTTPS deployment that terminates TLS upstream — Cloudflare, an ingress,
|
|
6
|
+
* a tunnel — that silently broke login:
|
|
7
|
+
*
|
|
8
|
+
* 1. `secure` resolves true, so express-session will only emit `Set-Cookie`
|
|
9
|
+
* on a connection it believes is https.
|
|
10
|
+
* 2. `trust proxy` defaults to false, so Express reads the raw socket and
|
|
11
|
+
* believes the connection is plain http. It is not wrong — the hop from
|
|
12
|
+
* the proxy really is http; it simply has not been told to read
|
|
13
|
+
* X-Forwarded-Proto.
|
|
14
|
+
* 3. No session cookie ever reaches the browser. Every following request
|
|
15
|
+
* builds a fresh session, so `req.session.csrfToken` is missing and the
|
|
16
|
+
* CSRF middleware answers `403 Forbidden — invalid CSRF token` — on
|
|
17
|
+
* `POST /login` included. Nobody can sign in.
|
|
18
|
+
*
|
|
19
|
+
* The two settings are not independent, so they are resolved together here.
|
|
20
|
+
* Turning on `secure` without `trust proxy` is not a working configuration;
|
|
21
|
+
* `secure` is the operator's stated intent to be behind TLS, and behind
|
|
22
|
+
* terminated TLS `trust proxy` is what makes Express able to see it.
|
|
23
|
+
*
|
|
24
|
+
* The operator's explicit choice still wins for both keys. The shipped
|
|
25
|
+
* app-default-config.json pins `ngdpbase.session.secure` and
|
|
26
|
+
* `ngdpbase.server.trust-proxy` to false, so a plain getProperty() read cannot
|
|
27
|
+
* tell "operator asked for false" from "nobody has said anything" — hence
|
|
28
|
+
* reading getCustomProperties() rather than the merged view.
|
|
29
|
+
*
|
|
30
|
+
* ## #1160 — native TLS breaks the assumption
|
|
31
|
+
*
|
|
32
|
+
* All of the above assumed `secure: true` meant TLS terminated UPSTREAM, so a
|
|
33
|
+
* proxy existed whose `X-Forwarded-Proto` Express had to be told to read. #1153
|
|
34
|
+
* made the server able to terminate TLS itself, and then there is no proxy —
|
|
35
|
+
* and trusting a forwarded header that nothing sets is worse than not trusting
|
|
36
|
+
* it. With `trust proxy` on and nothing in front, a caller can assert their own
|
|
37
|
+
* address and scheme, which the login throttle counts by and the audit log
|
|
38
|
+
* records.
|
|
39
|
+
*
|
|
40
|
+
* So the derivation now asks what is actually true rather than inferring from
|
|
41
|
+
* `secure` alone. The same applies to the misconfiguration warning: `secure`
|
|
42
|
+
* with `trust-proxy` explicitly false is the CORRECT configuration under native
|
|
43
|
+
* TLS, and warning about it would train an operator to ignore a warning that is
|
|
44
|
+
* load-bearing in the upstream case.
|
|
45
|
+
*/
|
|
46
|
+
/** Config keys this resolution reads, spelled once. */
|
|
47
|
+
const SECURE_KEY = 'ngdpbase.session.secure';
|
|
48
|
+
const TRUST_PROXY_KEY = 'ngdpbase.server.trust-proxy';
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the session cookie's `secure` flag and the matching `trust proxy`.
|
|
51
|
+
*
|
|
52
|
+
* `secure`: the operator's explicit boolean, else on when NODE_ENV is
|
|
53
|
+
* production. Inferring it from the request would get the terminated-TLS case
|
|
54
|
+
* exactly backwards, which is the case that matters.
|
|
55
|
+
*
|
|
56
|
+
* `trustProxy`: the operator's explicit value, else `true` whenever `secure`
|
|
57
|
+
* ends up on. `true` — rather than a hop count — because req.ip must come out
|
|
58
|
+
* as the end client. Under `1` behind Cloudflare, req.ip would resolve to the
|
|
59
|
+
* edge address shared by every visitor, and the login throttle's `ip:` bucket
|
|
60
|
+
* (#1044) would lock out all users at once on ten failures from anyone. An
|
|
61
|
+
* operator whose instance is also reachable directly should pin a hop count or
|
|
62
|
+
* a subnet list, since X-Forwarded-For is client-spoofable on that path.
|
|
63
|
+
*
|
|
64
|
+
* @param customProperties operator overrides only — NOT the merged config
|
|
65
|
+
* @param nodeEnv typically process.env.NODE_ENV
|
|
66
|
+
*/
|
|
67
|
+
export function resolveSessionSecurity(customProperties, nodeEnv, context = {}) {
|
|
68
|
+
const custom = customProperties ?? {};
|
|
69
|
+
const nativeTls = context.nativeTls === true;
|
|
70
|
+
const customSecure = custom[SECURE_KEY];
|
|
71
|
+
const secure = typeof customSecure === 'boolean'
|
|
72
|
+
? customSecure
|
|
73
|
+
: nodeEnv === 'production';
|
|
74
|
+
const customTrustProxy = custom[TRUST_PROXY_KEY];
|
|
75
|
+
const trustProxyConfigured = typeof customTrustProxy === 'boolean' ||
|
|
76
|
+
typeof customTrustProxy === 'number' ||
|
|
77
|
+
typeof customTrustProxy === 'string';
|
|
78
|
+
if (trustProxyConfigured) {
|
|
79
|
+
const trustProxy = customTrustProxy;
|
|
80
|
+
return {
|
|
81
|
+
secure,
|
|
82
|
+
trustProxy,
|
|
83
|
+
trustProxyDerived: false,
|
|
84
|
+
// #1160: under native TLS this combination is CORRECT, not suspicious —
|
|
85
|
+
// the server is the TLS endpoint and there is no proxy whose headers to
|
|
86
|
+
// read. Warning about it would train an operator to ignore a warning
|
|
87
|
+
// that is load-bearing when TLS really is terminated upstream.
|
|
88
|
+
misconfigured: secure && trustProxy === false && !nativeTls
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// #1160: only derive when something in FRONT is terminating TLS. With native
|
|
92
|
+
// TLS there is no proxy, and trusting a forwarded header that nothing sets
|
|
93
|
+
// lets a caller assert their own address and scheme — which the login
|
|
94
|
+
// throttle counts by and the audit log records.
|
|
95
|
+
const derive = secure && !nativeTls;
|
|
96
|
+
return {
|
|
97
|
+
secure,
|
|
98
|
+
trustProxy: derive ? true : false,
|
|
99
|
+
trustProxyDerived: derive,
|
|
100
|
+
misconfigured: false
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* #2071 — what an administrator and a visitor are told when the session
|
|
105
|
+
* cookie cannot reach the browser, spelled once: the server log, the admin
|
|
106
|
+
* dashboard, the CSRF refusal and the login page all say this.
|
|
107
|
+
*/
|
|
108
|
+
export const SESSION_COOKIE_WITHHELD_MESSAGE = 'Sign-in cannot work on this connection: this site sends its session cookie only over https, ' +
|
|
109
|
+
'and this request came over plain http, so the browser never receives it. ' +
|
|
110
|
+
'Serve the site over https, put it behind a proxy that sets X-Forwarded-Proto, ' +
|
|
111
|
+
'or set ngdpbase.session.secure to false.';
|
|
112
|
+
/**
|
|
113
|
+
* True when express-session will NOT send the session cookie on this request
|
|
114
|
+
* (#2071): the cookie is `secure` and the request is not https as Express
|
|
115
|
+
* sees it, after `trust proxy`. express-session makes exactly this check and
|
|
116
|
+
* then silently sends nothing, so the browser has no session, every POST
|
|
117
|
+
* meets a fresh one, and the CSRF middleware refuses it. A boolean read of
|
|
118
|
+
* two fields, cheap enough for every request.
|
|
119
|
+
*/
|
|
120
|
+
export function sessionCookieWithheld(req) {
|
|
121
|
+
return req.session?.cookie?.secure === true && req.secure !== true;
|
|
122
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lockboxes for links to encrypted vaults (#1388).
|
|
3
|
+
*
|
|
4
|
+
* A link to an encrypted vault is `/share/{token}#{key}`. The part after `#`
|
|
5
|
+
* is the link's private key; browsers never send it, so the server never sees
|
|
6
|
+
* it. The key pair is made in the owner's browser, which sends the server
|
|
7
|
+
* only the public half (`LinkPublicKey`).
|
|
8
|
+
*
|
|
9
|
+
* The server prepares each covered page, and the files it uses, and locks
|
|
10
|
+
* them for that public key: a {@link Lockbox}. It can lock, never open. The
|
|
11
|
+
* recipient's browser opens each lockbox with the private key from the link.
|
|
12
|
+
*
|
|
13
|
+
* The algorithm is WebCrypto's own, so the server (Node's `crypto.webcrypto`)
|
|
14
|
+
* and the browser (`window.crypto.subtle`, public/share/_assets/share-viewer.js) run the
|
|
15
|
+
* same steps: ECDH on P-256 between a fresh ephemeral key and the link's key,
|
|
16
|
+
* HKDF-SHA-256 over the shared secret, AES-256-GCM over the content.
|
|
17
|
+
*/
|
|
18
|
+
import { webcrypto } from 'crypto';
|
|
19
|
+
const subtle = webcrypto.subtle;
|
|
20
|
+
const CURVE = 'P-256';
|
|
21
|
+
/** The HKDF salt and info; a lockbox of another version will not open with this one. */
|
|
22
|
+
const HKDF_LABEL = new TextEncoder().encode('ngdp-share-lockbox-v1');
|
|
23
|
+
const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
24
|
+
/**
|
|
25
|
+
* The public key a browser sent, checked strictly: a P-256 point and nothing
|
|
26
|
+
* else. Anything malformed is refused rather than stored.
|
|
27
|
+
*/
|
|
28
|
+
export function parseLinkPublicKey(value) {
|
|
29
|
+
if (!value || typeof value !== 'object')
|
|
30
|
+
return null;
|
|
31
|
+
const v = value;
|
|
32
|
+
if (v.kty !== 'EC' || v.crv !== CURVE)
|
|
33
|
+
return null;
|
|
34
|
+
if (typeof v.x !== 'string' || typeof v.y !== 'string' || !B64URL.test(v.x) || !B64URL.test(v.y))
|
|
35
|
+
return null;
|
|
36
|
+
// A P-256 coordinate is 32 bytes: 43 base64url characters.
|
|
37
|
+
if (v.x.length !== 43 || v.y.length !== 43)
|
|
38
|
+
return null;
|
|
39
|
+
if ('d' in v)
|
|
40
|
+
return null;
|
|
41
|
+
return { kty: 'EC', crv: CURVE, x: v.x, y: v.y };
|
|
42
|
+
}
|
|
43
|
+
async function importPublic(key) {
|
|
44
|
+
return subtle.importKey('jwk', { ...key, ext: true }, { name: 'ECDH', namedCurve: CURVE }, true, []);
|
|
45
|
+
}
|
|
46
|
+
async function contentKey(privateKey, publicKey, usage) {
|
|
47
|
+
const shared = await subtle.deriveBits({ name: 'ECDH', public: publicKey }, privateKey, 256);
|
|
48
|
+
const hkdf = await subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
|
|
49
|
+
return subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: HKDF_LABEL, info: HKDF_LABEL }, hkdf, { name: 'AES-GCM', length: 256 }, false, [usage]);
|
|
50
|
+
}
|
|
51
|
+
/** Lock `plaintext` for the link whose public key is `linkKey`. Only that link's private key opens it. */
|
|
52
|
+
export async function sealForLink(linkKey, plaintext) {
|
|
53
|
+
const ephemeral = await subtle.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);
|
|
54
|
+
const key = await contentKey(ephemeral.privateKey, await importPublic(linkKey), 'encrypt');
|
|
55
|
+
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
|
56
|
+
const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext));
|
|
57
|
+
const epk = await subtle.exportKey('jwk', ephemeral.publicKey);
|
|
58
|
+
return {
|
|
59
|
+
v: 1,
|
|
60
|
+
epk: { kty: 'EC', crv: CURVE, x: String(epk.x), y: String(epk.y) },
|
|
61
|
+
iv: Buffer.from(iv).toString('base64url'),
|
|
62
|
+
ct: Buffer.from(ct).toString('base64url')
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The key part of a link, `{d}.{x}.{y}` in base64url: the private key the
|
|
67
|
+
* owner's browser made. The server never holds one; this is for the tests,
|
|
68
|
+
* and mirrors what the viewer does.
|
|
69
|
+
*/
|
|
70
|
+
export function linkKeyFromParts(jwk) {
|
|
71
|
+
return `${jwk.d}.${jwk.x}.${jwk.y}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Make a key pair on the server. A link's pair is made in the owner's browser,
|
|
75
|
+
* so for links this is the tests' stand-in; a person's own key pair for proxy
|
|
76
|
+
* access (#1762 slice 5, `privateStoreCrypto.newPersonKey`) is made here.
|
|
77
|
+
*/
|
|
78
|
+
export async function newLinkKeyPair() {
|
|
79
|
+
const pair = await subtle.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);
|
|
80
|
+
const jwk = await subtle.exportKey('jwk', pair.privateKey);
|
|
81
|
+
return {
|
|
82
|
+
publicKey: { kty: 'EC', crv: CURVE, x: String(jwk.x), y: String(jwk.y) },
|
|
83
|
+
linkKey: linkKeyFromParts({ d: String(jwk.d), x: String(jwk.x), y: String(jwk.y) })
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Open a lockbox with a link's key, as the recipient's browser does. */
|
|
87
|
+
export async function openLockbox(linkKey, box) {
|
|
88
|
+
const [d, x, y] = linkKey.split('.');
|
|
89
|
+
if (!d || !x || !y)
|
|
90
|
+
throw new Error('not a link key');
|
|
91
|
+
const privateKey = await subtle.importKey('jwk', { kty: 'EC', crv: CURVE, d, x, y, ext: true }, { name: 'ECDH', namedCurve: CURVE }, false, ['deriveBits']);
|
|
92
|
+
const key = await contentKey(privateKey, await importPublic(box.epk), 'decrypt');
|
|
93
|
+
return new Uint8Array(await subtle.decrypt({ name: 'AES-GCM', iv: Buffer.from(box.iv, 'base64url') }, key, Buffer.from(box.ct, 'base64url')));
|
|
94
|
+
}
|
|
95
|
+
/** True when `value` has the shape of a stored lockbox. */
|
|
96
|
+
export function isLockbox(value) {
|
|
97
|
+
if (!value || typeof value !== 'object')
|
|
98
|
+
return false;
|
|
99
|
+
const v = value;
|
|
100
|
+
return v.v === 1 && typeof v.iv === 'string' && typeof v.ct === 'string' && parseLinkPublicKey(v.epk) !== null;
|
|
101
|
+
}
|