@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,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
+ }