@jwilleke/ngdpbase-types 5.24.0 → 5.26.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 (63) hide show
  1. package/README.md +2 -2
  2. package/lib/packages/ngdpbase-types/index.d.ts +6 -2
  3. package/lib/packages/ngdpbase-types/testing.d.ts +7 -2
  4. package/lib/packages/ngdpbase-types/testing.js +7 -2
  5. package/lib/src/http/egressPolicy.js +61 -0
  6. package/lib/src/http/guardedElasticsearch.d.ts +27 -0
  7. package/lib/src/http/guardedElasticsearch.js +99 -0
  8. package/lib/src/managers/AddonsManager.d.ts +119 -2
  9. package/lib/src/managers/AttachmentManager.d.ts +4 -5
  10. package/lib/src/managers/AuthManager.d.ts +150 -32
  11. package/lib/src/managers/BackgroundJobManager.d.ts +22 -12
  12. package/lib/src/managers/BackupManager.d.ts +779 -0
  13. package/lib/src/managers/BackupManager.js +2042 -0
  14. package/lib/src/managers/BaseManager.d.ts +156 -6
  15. package/lib/src/managers/BaseManager.js +64 -2
  16. package/lib/src/managers/CommentManager.d.ts +6 -6
  17. package/lib/src/managers/ConsentManager.d.ts +130 -0
  18. package/lib/src/managers/DatabaseManager.d.ts +6 -0
  19. package/lib/src/managers/DatabaseManager.js +25 -15
  20. package/lib/src/managers/FootnoteManager.d.ts +6 -6
  21. package/lib/src/managers/OrganizationManager.d.ts +8 -6
  22. package/lib/src/managers/PageManager.d.ts +16 -0
  23. package/lib/src/managers/PersonManager.d.ts +8 -6
  24. package/lib/src/managers/RoleManager.d.ts +6 -8
  25. package/lib/src/providers/BaseAttachmentProvider.d.ts +4 -4
  26. package/lib/src/providers/BaseBackupProvider.d.ts +77 -0
  27. package/lib/src/providers/BaseBackupProvider.js +52 -0
  28. package/lib/src/providers/BasicAttachmentProvider.d.ts +4 -4
  29. package/lib/src/providers/FileBackupProvider.d.ts +34 -0
  30. package/lib/src/providers/FileBackupProvider.js +116 -0
  31. package/lib/src/providers/FileConsentProvider.d.ts +18 -0
  32. package/lib/src/providers/SqliteDatabaseProvider.d.ts +2 -2
  33. package/lib/src/providers/SqliteDatabaseProvider.js +12 -11
  34. package/lib/src/providers/attachmentFilesBackup.d.ts +5 -4
  35. package/lib/src/providers/databaseMigrations.js +45 -0
  36. package/lib/src/security/PolicyDecisionPoint.d.ts +14 -0
  37. package/lib/src/security/PolicyInformationPoint.d.ts +8 -0
  38. package/lib/src/types/Consent.d.ts +90 -0
  39. package/lib/src/types/express.d.ts +6 -0
  40. package/lib/src/utils/addonHost.d.ts +51 -3
  41. package/lib/src/utils/addonHost.js +29 -2
  42. package/lib/src/utils/addonPageSync.d.ts +2 -2
  43. package/lib/src/utils/auditEventNames.generated.d.ts +20 -0
  44. package/lib/src/utils/auditEventNames.generated.js +20 -0
  45. package/lib/src/utils/backupCrypto.js +275 -0
  46. package/lib/src/utils/backupDocument.d.ts +36 -0
  47. package/lib/src/utils/backupDocument.js +99 -0
  48. package/lib/src/utils/browserDescription.d.ts +11 -0
  49. package/lib/src/utils/directoryBackup.d.ts +48 -48
  50. package/lib/src/utils/directoryBackup.js +291 -0
  51. package/lib/src/utils/instanceEnvBackup.d.ts +46 -0
  52. package/lib/src/utils/instanceEnvBackup.js +120 -0
  53. package/lib/src/utils/instanceEnvSecret.js +79 -0
  54. package/lib/src/utils/ipLocation.d.ts +11 -0
  55. package/lib/src/utils/schedule.js +171 -0
  56. package/lib/src/utils/secretFileMode.js +78 -0
  57. package/lib/src/utils/siteRestore.d.ts +13 -16
  58. package/lib/src/utils/siteRestore.js +33 -6
  59. package/lib/src/utils/treeArchive.d.ts +2 -0
  60. package/lib/src/utils/treeArchive.js +242 -0
  61. package/lib/src/utils/version.js +408 -0
  62. package/package.json +6 -1
  63. package/lib/src/utils/administrators.d.ts +0 -21
@@ -45,6 +45,12 @@ declare module 'express-session' {
45
45
  pendingRedirect?: string;
46
46
  /** A verified outside identity no account has connected, waiting for the person to sign in another way (#1743). */
47
47
  pendingConnection?: PendingConnection;
48
+ /**
49
+ * Set at sign-in when the person has core terms to accept at their current
50
+ * version (#1603); the consent gate holds the session to the prompt until
51
+ * they accept (or sign out). Cleared by the acceptance.
52
+ */
53
+ consentPending?: boolean;
48
54
  /** When this session last made a request, epoch ms — kept only while an idle timeout is set (#1546). */
49
55
  lastActivity?: number;
50
56
  /** When this session signed in, epoch ms: the start of `ngdpbase.session.max-age` (#1767). A re-authentication never moves it. */
@@ -10,6 +10,15 @@
10
10
  * - `recordAuditEvent` and `addonAuditEventName` accept only the audit events
11
11
  * this add-on declares in its own `config/default-config.json`, so one
12
12
  * add-on cannot write records that look like another's or like core's;
13
+ * - `systemContext(reason)` is bound to the running engine, so the system
14
+ * principal's name comes from core (`NGDPBASE_SYSTEM_USER`), never from
15
+ * the add-on (#1973);
16
+ * - `setEnabledForPerson` sets this add-on's own per-person switch (#1963),
17
+ * bound to its slug, so it can never set another add-on's;
18
+ * - the egress tools (`resolveEgressPolicy`, `validateUrl`,
19
+ * `createGuardedElasticsearchClient`, `refusedNodeMessage`) and `isAtLeast`
20
+ * are core's own pure functions, handed on unchanged, so an add-on never
21
+ * imports them from `dist/` (#1813);
13
22
  * - the object is `Object.freeze`d, so no add-on can replace a value.
14
23
  *
15
24
  * Additive only: nothing here is removed or renamed without a major release.
@@ -17,9 +26,14 @@
17
26
  */
18
27
  import type { Logger } from 'winston';
19
28
  import { ApiContext, ApiError, sendApiError } from '../context/ApiContext.js';
20
- import { actorOf } from '../context/ActorContext.js';
21
- import { jobContextFromRequest, jobContextFromSystem } from '../context/JobContext.js';
29
+ import { actorOf, type ActorContext } from '../context/ActorContext.js';
30
+ import { jobContextFromRequest, jobContextFromSystem, type JobContext } from '../context/JobContext.js';
31
+ import { systemContext } from '../context/bootActions.js';
22
32
  import { guardedFetch } from '../http/guardedFetch.js';
33
+ import { resolveEgressPolicy } from '../http/egressPolicy.js';
34
+ import { validateUrl } from '../http/ssrf.js';
35
+ import { createGuardedElasticsearchClient, refusedNodeMessage } from '../http/guardedElasticsearch.js';
36
+ import { isAtLeast } from './version.js';
23
37
  import { recordAuditEvent } from './auditEvents.js';
24
38
  import { addonAuditEventName } from './auditEventNames.js';
25
39
  /** The values core offers an add-on, under the names they were imported as. */
@@ -32,9 +46,39 @@ export interface AddonHost {
32
46
  readonly actorOf: typeof actorOf;
33
47
  readonly jobContextFromRequest: typeof jobContextFromRequest;
34
48
  readonly jobContextFromSystem: typeof jobContextFromSystem;
49
+ /**
50
+ * The context the add-on's own boot work runs under (#1973): core's system
51
+ * principal, with `reason` saying what and why, for the record. Pass it
52
+ * where a manager door asks for a context at start, such as
53
+ * `FormsDataManager.registerShippedForm`.
54
+ */
55
+ readonly systemContext: (reason: string) => JobContext;
35
56
  readonly logger: Logger;
36
57
  readonly guardedFetch: typeof guardedFetch;
58
+ /**
59
+ * #1963: turn this per-person add-on on (`true`) or off (`false`) for the
60
+ * person `ctx` acts as: their preference `<slug>.enabled`. Pass the context
61
+ * you were given (a profile hook's `ctx`). Refused when the add-on's manifest
62
+ * does not say `perPerson: true`, or the person may not manage their profile.
63
+ */
64
+ readonly setEnabledForPerson: PersonSwitchSetter;
65
+ /**
66
+ * The site's egress policy, read through `read` (a `ConfigurationManager.getProperty`
67
+ * reader) each time it is called, so an operator tightening it applies at once.
68
+ * Hand its `policy` to `guardedFetch` or `validateUrl`.
69
+ */
70
+ readonly resolveEgressPolicy: typeof resolveEgressPolicy;
71
+ /** Whether a URL may be fetched under an egress policy, before any socket: http(s) and an allowed literal address. */
72
+ readonly validateUrl: typeof validateUrl;
73
+ /** An Elasticsearch client whose every connection is judged by the egress policy; the only way to build one. */
74
+ readonly createGuardedElasticsearchClient: typeof createGuardedElasticsearchClient;
75
+ /** The one boot-time line for an Elasticsearch node the egress policy refuses. */
76
+ readonly refusedNodeMessage: typeof refusedNodeMessage;
77
+ /** Whether an `x.y.z` version is at least another; throws on a version that is not `x.y.z`. */
78
+ readonly isAtLeast: typeof isAtLeast;
37
79
  }
80
+ /** Sets one add-on's per-person switch; core binds the slug when it builds the host (#1963). */
81
+ export type PersonSwitchSetter = (ctx: ActorContext, on: boolean) => Promise<void>;
38
82
  /**
39
83
  * The audit event names one add-on declares: the permission entries carrying
40
84
  * an `audit` block, and the `ngdpbase.audit.events` entries, in that add-on's
@@ -47,5 +91,9 @@ export declare function addonDeclaredAuditEvents(addonDefaults: Record<string, u
47
91
  * @param slug - The add-on's canonical slug
48
92
  * @param addonDefaults - That add-on's own default-config.json, as folded into
49
93
  * the configuration merge (`ConfigurationManager.getAddonDeclaredDefaults`)
94
+ * @param setEnabledForPerson - This add-on's switch setter, slug already bound
95
+ * (`AddonsManager` supplies it); without one, the host's setter refuses
96
+ * @param engine - The running engine, which names the system principal for
97
+ * `systemContext`; without one (a test) it is core's fallback principal
50
98
  */
51
- export declare function buildAddonHost(slug: string, addonDefaults: Record<string, unknown>): AddonHost;
99
+ export declare function buildAddonHost(slug: string, addonDefaults: Record<string, unknown>, setEnabledForPerson?: PersonSwitchSetter, engine?: Parameters<typeof systemContext>[0]): AddonHost;
@@ -10,6 +10,15 @@
10
10
  * - `recordAuditEvent` and `addonAuditEventName` accept only the audit events
11
11
  * this add-on declares in its own `config/default-config.json`, so one
12
12
  * add-on cannot write records that look like another's or like core's;
13
+ * - `systemContext(reason)` is bound to the running engine, so the system
14
+ * principal's name comes from core (`NGDPBASE_SYSTEM_USER`), never from
15
+ * the add-on (#1973);
16
+ * - `setEnabledForPerson` sets this add-on's own per-person switch (#1963),
17
+ * bound to its slug, so it can never set another add-on's;
18
+ * - the egress tools (`resolveEgressPolicy`, `validateUrl`,
19
+ * `createGuardedElasticsearchClient`, `refusedNodeMessage`) and `isAtLeast`
20
+ * are core's own pure functions, handed on unchanged, so an add-on never
21
+ * imports them from `dist/` (#1813);
13
22
  * - the object is `Object.freeze`d, so no add-on can replace a value.
14
23
  *
15
24
  * Additive only: nothing here is removed or renamed without a major release.
@@ -18,11 +27,18 @@
18
27
  import { ApiContext, ApiError, sendApiError } from '../context/ApiContext.js';
19
28
  import { actorOf } from '../context/ActorContext.js';
20
29
  import { jobContextFromRequest, jobContextFromSystem } from '../context/JobContext.js';
30
+ import { systemContext } from '../context/bootActions.js';
21
31
  import { guardedFetch } from '../http/guardedFetch.js';
32
+ import { resolveEgressPolicy } from '../http/egressPolicy.js';
33
+ import { validateUrl } from '../http/ssrf.js';
34
+ import { createGuardedElasticsearchClient, refusedNodeMessage } from '../http/guardedElasticsearch.js';
35
+ import { isAtLeast } from './version.js';
22
36
  import { recordAuditEvent } from './auditEvents.js';
23
37
  import { addonAuditEventName } from './auditEventNames.js';
24
38
  import { auditDeclarationsFrom } from './auditRegistry.js';
25
39
  import logger from './logger.js';
40
+ /** A host built outside a site (the test kit, with no setter given) has no account store. */
41
+ const noPersonSwitch = () => Promise.reject(new Error('setEnabledForPerson is not available: this host was built without a site'));
26
42
  /**
27
43
  * The audit event names one add-on declares: the permission entries carrying
28
44
  * an `audit` block, and the `ngdpbase.audit.events` entries, in that add-on's
@@ -45,8 +61,12 @@ function refuseUndeclared(slug, name, declared) {
45
61
  * @param slug - The add-on's canonical slug
46
62
  * @param addonDefaults - That add-on's own default-config.json, as folded into
47
63
  * the configuration merge (`ConfigurationManager.getAddonDeclaredDefaults`)
64
+ * @param setEnabledForPerson - This add-on's switch setter, slug already bound
65
+ * (`AddonsManager` supplies it); without one, the host's setter refuses
66
+ * @param engine - The running engine, which names the system principal for
67
+ * `systemContext`; without one (a test) it is core's fallback principal
48
68
  */
49
- export function buildAddonHost(slug, addonDefaults) {
69
+ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPersonSwitch, engine) {
50
70
  const declared = addonDeclaredAuditEvents(addonDefaults);
51
71
  const hostAddonAuditEventName = (name) => {
52
72
  const checked = addonAuditEventName(name);
@@ -66,7 +86,14 @@ export function buildAddonHost(slug, addonDefaults) {
66
86
  actorOf,
67
87
  jobContextFromRequest,
68
88
  jobContextFromSystem,
89
+ systemContext: (reason) => systemContext(engine, reason),
69
90
  logger: logger.child({ addon: slug }),
70
- guardedFetch
91
+ guardedFetch,
92
+ setEnabledForPerson,
93
+ resolveEgressPolicy,
94
+ validateUrl,
95
+ createGuardedElasticsearchClient,
96
+ refusedNodeMessage,
97
+ isAtLeast
71
98
  });
72
99
  }
@@ -34,8 +34,8 @@ export declare function evaluateSeededAddonPage(args: {
34
34
  /**
35
35
  * How a shipped item's sync status reads on an admin screen: the status words
36
36
  * and the edit protection of Admin → Required Pages, which every screen that
37
- * syncs shipped content shares (`views/_sync-status.ejs`, `views/_sync-button.ejs`;
38
- * shipped forms, #1918).
37
+ * syncs shipped content shares (`views/_sync-status.ejs`, `views/_sync-button.ejs`,
38
+ * #1918).
39
39
  *
40
40
  * - `status` — `modified` when the live copy differs from what ships,
41
41
  * `current` when it is the same.
@@ -50,6 +50,10 @@ export declare const AUDIT_EVENT: {
50
50
  readonly CONFIG_CHANGE: "config-change";
51
51
  /** Every custom configuration value discarded; recorded before the reset, which cannot proceed without it */
52
52
  readonly CONFIG_RESET: "config-reset";
53
+ /** A person accepted a document they are asked to accept (#1603): which document (core's terms-of-service or privacy-policy, or an add-on's), its version and effective date, and how (sign-up, sign-in, turning an add-on on). Recorded before the acceptance is stored, which cannot proceed without it: consent must be provable */
54
+ readonly CONSENT_GRANT: "consent-grant";
55
+ /** A person's acceptances ended (#1603): today only when their account is deleted, which removes their consent records; names the documents and versions they had accepted. Recorded before the records are removed */
56
+ readonly CONSENT_WITHDRAW: "consent-withdraw";
53
57
  /** Contact created, changed, deleted or linked to an account; metadata names the kind (person or organization), the operation and the fields changed, never their values */
54
58
  readonly CONTACT_MANAGE: "contact-manage";
55
59
  /** A page's footnote list changed — action add, import, transfer, update or delete; page content written on someone's behalf (#1233) */
@@ -140,6 +144,22 @@ export declare const AUDIT_EVENT: {
140
144
  readonly SHARE_EXTEND: "share-extend";
141
145
  /** Share link revoked; pairs with token-revoke */
142
146
  readonly SHARE_REVOKE: "share-revoke";
147
+ /** Sign-in approval: the browser that was waiting gave up the sign-in (#1532) */
148
+ readonly SIGNINAPPROVAL_ABANDON: "signinapproval-abandon";
149
+ /** Sign-in approval: the person pressed Approve (#1532) */
150
+ readonly SIGNINAPPROVAL_APPROVE: "signinapproval-approve";
151
+ /** Sign-in approval: the person pressed Deny; that sign-in will not complete (#1532) */
152
+ readonly SIGNINAPPROVAL_DENY: "signinapproval-deny";
153
+ /** Sign-in approval: a waiting sign-in expired before it was completed (#1532) */
154
+ readonly SIGNINAPPROVAL_EXPIRE: "signinapproval-expire";
155
+ /** Sign-in approval: the approval page was opened, with the opener's IP and browser; opening changes nothing (#1532) */
156
+ readonly SIGNINAPPROVAL_OPEN: "signinapproval-open";
157
+ /** Sign-in approval: an approval message was sent for a waiting sign-in, saying from where (IP, approximate place, browser) (#1532) */
158
+ readonly SIGNINAPPROVAL_REQUEST: "signinapproval-request";
159
+ /** Sign-in approval: the approval message could not be sent (transport error, or no channel configured) (#1532) */
160
+ readonly SIGNINAPPROVAL_SENDFAIL: "signinapproval-sendfail";
161
+ /** Sign-in approval: another approval message was asked for too soon and not sent (#1532) */
162
+ readonly SIGNINAPPROVAL_THROTTLE: "signinapproval-throttle";
143
163
  /** A user's private store copy created at its door (#1414) — with key material when sealed; never the words or a key in the record */
144
164
  readonly STORE_CREATE: "store-create";
145
165
  /** A takeout imported into its owner's private store; a bulk write — who, which store, pages and files written or skipped */
@@ -57,6 +57,10 @@ export const AUDIT_EVENT = {
57
57
  CONFIG_CHANGE: 'config-change',
58
58
  /** Every custom configuration value discarded; recorded before the reset, which cannot proceed without it */
59
59
  CONFIG_RESET: 'config-reset',
60
+ /** A person accepted a document they are asked to accept (#1603): which document (core's terms-of-service or privacy-policy, or an add-on's), its version and effective date, and how (sign-up, sign-in, turning an add-on on). Recorded before the acceptance is stored, which cannot proceed without it: consent must be provable */
61
+ CONSENT_GRANT: 'consent-grant',
62
+ /** A person's acceptances ended (#1603): today only when their account is deleted, which removes their consent records; names the documents and versions they had accepted. Recorded before the records are removed */
63
+ CONSENT_WITHDRAW: 'consent-withdraw',
60
64
  /** Contact created, changed, deleted or linked to an account; metadata names the kind (person or organization), the operation and the fields changed, never their values */
61
65
  CONTACT_MANAGE: 'contact-manage',
62
66
  /** A page's footnote list changed — action add, import, transfer, update or delete; page content written on someone's behalf (#1233) */
@@ -147,6 +151,22 @@ export const AUDIT_EVENT = {
147
151
  SHARE_EXTEND: 'share-extend',
148
152
  /** Share link revoked; pairs with token-revoke */
149
153
  SHARE_REVOKE: 'share-revoke',
154
+ /** Sign-in approval: the browser that was waiting gave up the sign-in (#1532) */
155
+ SIGNINAPPROVAL_ABANDON: 'signinapproval-abandon',
156
+ /** Sign-in approval: the person pressed Approve (#1532) */
157
+ SIGNINAPPROVAL_APPROVE: 'signinapproval-approve',
158
+ /** Sign-in approval: the person pressed Deny; that sign-in will not complete (#1532) */
159
+ SIGNINAPPROVAL_DENY: 'signinapproval-deny',
160
+ /** Sign-in approval: a waiting sign-in expired before it was completed (#1532) */
161
+ SIGNINAPPROVAL_EXPIRE: 'signinapproval-expire',
162
+ /** Sign-in approval: the approval page was opened, with the opener's IP and browser; opening changes nothing (#1532) */
163
+ SIGNINAPPROVAL_OPEN: 'signinapproval-open',
164
+ /** Sign-in approval: an approval message was sent for a waiting sign-in, saying from where (IP, approximate place, browser) (#1532) */
165
+ SIGNINAPPROVAL_REQUEST: 'signinapproval-request',
166
+ /** Sign-in approval: the approval message could not be sent (transport error, or no channel configured) (#1532) */
167
+ SIGNINAPPROVAL_SENDFAIL: 'signinapproval-sendfail',
168
+ /** Sign-in approval: another approval message was asked for too soon and not sent (#1532) */
169
+ SIGNINAPPROVAL_THROTTLE: 'signinapproval-throttle',
150
170
  /** A user's private store copy created at its door (#1414) — with key material when sealed; never the words or a key in the record */
151
171
  STORE_CREATE: 'store-create',
152
172
  /** A takeout imported into its owner's private store; a bulk write — who, which store, pages and files written or skipped */
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Encrypted backup files (#1561, #1876): the backup document, every file
3
+ * archive beside it, and the credentials store, under `NGDPBASE_BACKUP_KEY`.
4
+ * The database files are encrypted by SQLCipher itself (`sqliteBackup.ts`);
5
+ * everything else a backup writes goes through here.
6
+ *
7
+ * Format, version 1:
8
+ *
9
+ * - `NGDPBK01` (8 bytes), then a big-endian u32 length and that many bytes of
10
+ * JSON header: the cipher, the key derivation (scrypt, its cost and a fresh
11
+ * random salt), the backup key's fingerprint (`backupKeyFingerprint`, never
12
+ * the key), an 8-byte random nonce prefix and the chunk size.
13
+ * - Then records: u32 plaintext length, one byte (1 on the last record, else
14
+ * 0), the ciphertext, and its 16-byte AES-256-GCM tag. Each record's nonce
15
+ * is the prefix and its u32 index; its additional data is the SHA-256 of
16
+ * the header, the index and the last-record byte. So a record moved,
17
+ * dropped, swapped from another file or cut off at the end fails, and so
18
+ * does any change to the header.
19
+ *
20
+ * Streamed both ways, a chunk at a time: a file's size never becomes memory.
21
+ * Decrypting checks the fingerprint first, so a backup made under another key
22
+ * is refused by name ({@link BackupKeyMismatchError}) before any work is done.
23
+ */
24
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt } from 'node:crypto';
25
+ import { Readable, Transform, Writable } from 'node:stream';
26
+ import { pipeline } from 'node:stream/promises';
27
+ import { backupKeyFingerprint, BACKUP_KEY_ENV } from './backupKey.js';
28
+ import { open } from 'node:fs/promises';
29
+ /** The first bytes of every encrypted backup file. */
30
+ export const BACKUP_FILE_MAGIC = Buffer.from('NGDPBK01', 'ascii');
31
+ /** What an encrypted backup file's name ends with. */
32
+ export const ENCRYPTED_SUFFIX = '.enc';
33
+ /** The key derivation's cost, written into each file's header so a reader uses the file's own. A format constant, not a tunable: changing it changes no file already written. */
34
+ const KDF = { N: 16384, r: 8, p: 1 };
35
+ const CHUNK_BYTES = 64 * 1024;
36
+ const SALT_BYTES = 16;
37
+ const NONCE_PREFIX_BYTES = 8;
38
+ const TAG_BYTES = 16;
39
+ const RECORD_HEAD = 5;
40
+ /** The largest header a reader accepts: a header is a few hundred bytes. */
41
+ const MAX_HEADER = 4096;
42
+ /** A backup file made under a different backup key than the one set now. */
43
+ export class BackupKeyMismatchError extends Error {
44
+ fileFingerprint;
45
+ keyFingerprint;
46
+ constructor(fileFingerprint, keyFingerprint) {
47
+ super(`it was made with a different backup key (fingerprint ${fileFingerprint}; ${BACKUP_KEY_ENV} now has ${keyFingerprint}). Set ${BACKUP_KEY_ENV} to the key it was made with`);
48
+ this.fileFingerprint = fileFingerprint;
49
+ this.keyFingerprint = keyFingerprint;
50
+ this.name = 'BackupKeyMismatchError';
51
+ }
52
+ }
53
+ function deriveKey(backupKey, salt, kdf) {
54
+ const options = { N: kdf.N, r: kdf.r, p: kdf.p, maxmem: 256 * kdf.N * kdf.r };
55
+ return new Promise((resolve, reject) => scrypt(backupKey, salt, 32, options, (err, key) => (err ? reject(err) : resolve(key))));
56
+ }
57
+ function nonceFor(prefix, index) {
58
+ const nonce = Buffer.alloc(12);
59
+ prefix.copy(nonce, 0, 0, 8);
60
+ nonce.writeUInt32BE(index, 8);
61
+ return nonce;
62
+ }
63
+ function aadFor(headerHash, index, last) {
64
+ const tail = Buffer.alloc(5);
65
+ tail.writeUInt32BE(index, 0);
66
+ tail.writeUInt8(last ? 1 : 0, 4);
67
+ return Buffer.concat([headerHash, tail]);
68
+ }
69
+ function requireKey(backupKey) {
70
+ if (!backupKey.trim())
71
+ throw new Error(`a backup key is required: ${BACKUP_KEY_ENV} is not set`);
72
+ }
73
+ /** Whether these bytes (a file's first) are an encrypted backup file. */
74
+ export function isEncryptedBackup(head) {
75
+ return head.length >= BACKUP_FILE_MAGIC.length && head.subarray(0, BACKUP_FILE_MAGIC.length).equals(BACKUP_FILE_MAGIC);
76
+ }
77
+ /** Whether the file on disk is an encrypted backup file, read from its first bytes only. */
78
+ export async function fileIsEncrypted(file) {
79
+ const handle = await open(file, 'r');
80
+ try {
81
+ const head = Buffer.alloc(BACKUP_FILE_MAGIC.length);
82
+ const { bytesRead } = await handle.read(head, 0, head.length, 0);
83
+ return isEncryptedBackup(head.subarray(0, bytesRead));
84
+ }
85
+ finally {
86
+ await handle.close();
87
+ }
88
+ }
89
+ /** A stream that encrypts what is written to it under `backupKey`. */
90
+ export function encryptStream(backupKey) {
91
+ requireKey(backupKey);
92
+ let key = null;
93
+ let headerHash;
94
+ let prefix;
95
+ let index = 0;
96
+ let pending = Buffer.alloc(0);
97
+ const record = (plain, last) => {
98
+ const cipher = createCipheriv('aes-256-gcm', key, nonceFor(prefix, index));
99
+ cipher.setAAD(aadFor(headerHash, index, last));
100
+ const ct = Buffer.concat([cipher.update(plain), cipher.final()]);
101
+ const head = Buffer.alloc(RECORD_HEAD);
102
+ head.writeUInt32BE(plain.length, 0);
103
+ head.writeUInt8(last ? 1 : 0, 4);
104
+ index++;
105
+ return Buffer.concat([head, ct, cipher.getAuthTag()]);
106
+ };
107
+ const start = async (stream) => {
108
+ if (key)
109
+ return;
110
+ const salt = randomBytes(SALT_BYTES);
111
+ prefix = randomBytes(NONCE_PREFIX_BYTES);
112
+ const header = {
113
+ format: 1,
114
+ cipher: 'aes-256-gcm',
115
+ kdf: { alg: 'scrypt', ...KDF, salt: salt.toString('base64') },
116
+ keyFingerprint: backupKeyFingerprint(backupKey),
117
+ noncePrefix: prefix.toString('base64'),
118
+ chunkBytes: CHUNK_BYTES
119
+ };
120
+ const json = Buffer.from(JSON.stringify(header), 'utf8');
121
+ const len = Buffer.alloc(4);
122
+ len.writeUInt32BE(json.length, 0);
123
+ const headerBytes = Buffer.concat([BACKUP_FILE_MAGIC, len, json]);
124
+ headerHash = createHash('sha256').update(headerBytes).digest();
125
+ key = await deriveKey(backupKey, salt, KDF);
126
+ stream.push(headerBytes);
127
+ };
128
+ return new Transform({
129
+ transform(chunk, _enc, done) {
130
+ start(this).then(() => {
131
+ pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]);
132
+ // The last record is written at the end, so a full chunk is held back
133
+ // until more follows: the last-record byte is then always right.
134
+ while (pending.length > CHUNK_BYTES) {
135
+ this.push(record(pending.subarray(0, CHUNK_BYTES), false));
136
+ pending = pending.subarray(CHUNK_BYTES);
137
+ }
138
+ done();
139
+ }, done);
140
+ },
141
+ flush(done) {
142
+ start(this).then(() => {
143
+ this.push(record(pending, true));
144
+ done();
145
+ }, done);
146
+ }
147
+ });
148
+ }
149
+ /**
150
+ * A stream that decrypts an encrypted backup file written to it. Fails —
151
+ * naming why — on a file that is not one, a different backup key
152
+ * ({@link BackupKeyMismatchError}), any record that does not verify, and a
153
+ * file cut short or with bytes after its last record.
154
+ */
155
+ export function decryptStream(backupKey) {
156
+ requireKey(backupKey);
157
+ let buffer = Buffer.alloc(0);
158
+ let header = null;
159
+ let headerHash;
160
+ let prefix;
161
+ let key;
162
+ let index = 0;
163
+ let ended = false;
164
+ const parseHeader = async () => {
165
+ if (buffer.length < BACKUP_FILE_MAGIC.length + 4)
166
+ return false;
167
+ if (!isEncryptedBackup(buffer))
168
+ throw new Error('not an encrypted backup file');
169
+ const len = buffer.readUInt32BE(BACKUP_FILE_MAGIC.length);
170
+ if (len === 0 || len > MAX_HEADER)
171
+ throw new Error('the encrypted backup file has a damaged header');
172
+ const total = BACKUP_FILE_MAGIC.length + 4 + len;
173
+ if (buffer.length < total)
174
+ return false;
175
+ const headerBytes = buffer.subarray(0, total);
176
+ let parsed;
177
+ try {
178
+ parsed = JSON.parse(headerBytes.subarray(BACKUP_FILE_MAGIC.length + 4).toString('utf8'));
179
+ }
180
+ catch {
181
+ throw new Error('the encrypted backup file has a damaged header');
182
+ }
183
+ // The header is not authenticated until the first record verifies, so
184
+ // nothing in it may set the cost of the work before that: format 1 has
185
+ // one key derivation cost, and a header asking for another is refused
186
+ // (a crafted file must not make the server run a huge scrypt).
187
+ const salt = typeof parsed.kdf?.salt === 'string' ? Buffer.from(parsed.kdf.salt, 'base64') : Buffer.alloc(0);
188
+ const noncePrefix = typeof parsed.noncePrefix === 'string' ? Buffer.from(parsed.noncePrefix, 'base64') : Buffer.alloc(0);
189
+ if (parsed.format !== 1 || parsed.cipher !== 'aes-256-gcm' || parsed.kdf?.alg !== 'scrypt'
190
+ || parsed.kdf.N !== KDF.N || parsed.kdf.r !== KDF.r || parsed.kdf.p !== KDF.p
191
+ || salt.length !== SALT_BYTES || noncePrefix.length !== NONCE_PREFIX_BYTES
192
+ || !(parsed.chunkBytes > 0 && parsed.chunkBytes <= 16 * CHUNK_BYTES)) {
193
+ throw new Error('the encrypted backup file is in a format this build does not read');
194
+ }
195
+ const fingerprint = backupKeyFingerprint(backupKey);
196
+ if (parsed.keyFingerprint !== fingerprint)
197
+ throw new BackupKeyMismatchError(String(parsed.keyFingerprint), fingerprint);
198
+ headerHash = createHash('sha256').update(headerBytes).digest();
199
+ prefix = noncePrefix;
200
+ key = await deriveKey(backupKey, salt, KDF);
201
+ header = parsed;
202
+ buffer = buffer.subarray(total);
203
+ return true;
204
+ };
205
+ const drain = (stream) => {
206
+ while (buffer.length >= RECORD_HEAD) {
207
+ if (ended)
208
+ throw new Error('the encrypted backup file has bytes after its last record');
209
+ const len = buffer.readUInt32BE(0);
210
+ const last = buffer.readUInt8(4);
211
+ if (len > header.chunkBytes || last > 1)
212
+ throw new Error('the encrypted backup file is damaged');
213
+ const size = RECORD_HEAD + len + TAG_BYTES;
214
+ if (buffer.length < size)
215
+ return;
216
+ const decipher = createDecipheriv('aes-256-gcm', key, nonceFor(prefix, index));
217
+ decipher.setAAD(aadFor(headerHash, index, last === 1));
218
+ decipher.setAuthTag(buffer.subarray(RECORD_HEAD + len, size));
219
+ let plain;
220
+ try {
221
+ plain = Buffer.concat([decipher.update(buffer.subarray(RECORD_HEAD, RECORD_HEAD + len)), decipher.final()]);
222
+ }
223
+ catch {
224
+ throw new Error('the encrypted backup file does not verify: it is damaged or was changed');
225
+ }
226
+ index++;
227
+ buffer = buffer.subarray(size);
228
+ if (last === 1)
229
+ ended = true;
230
+ if (plain.length > 0)
231
+ stream.push(plain);
232
+ }
233
+ };
234
+ return new Transform({
235
+ transform(chunk, _enc, done) {
236
+ buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
237
+ (header ? Promise.resolve(true) : parseHeader()).then((ready) => {
238
+ if (ready)
239
+ drain(this);
240
+ done();
241
+ }).catch(done);
242
+ },
243
+ flush(done) {
244
+ try {
245
+ if (!header)
246
+ throw new Error(buffer.length === 0 ? 'the encrypted backup file is empty' : 'the encrypted backup file has a damaged header');
247
+ drain(this);
248
+ if (!ended || buffer.length > 0)
249
+ throw new Error('the encrypted backup file is cut short');
250
+ done();
251
+ }
252
+ catch (err) {
253
+ done(err);
254
+ }
255
+ }
256
+ });
257
+ }
258
+ async function collect(source, transform) {
259
+ const out = [];
260
+ await pipeline(Readable.from([source]), transform, new Writable({
261
+ write(chunk, _enc, done) {
262
+ out.push(chunk);
263
+ done();
264
+ }
265
+ }));
266
+ return Buffer.concat(out);
267
+ }
268
+ /** Encrypt a whole buffer (the backup document, already in memory). */
269
+ export function encryptBuffer(data, backupKey) {
270
+ return collect(data, encryptStream(backupKey));
271
+ }
272
+ /** Decrypt a whole buffer. */
273
+ export function decryptBuffer(data, backupKey) {
274
+ return collect(data, decryptStream(backupKey));
275
+ }
@@ -0,0 +1,36 @@
1
+ /** A backup document's shape: what every backup holds at its top level. */
2
+ export interface BackupDocument {
3
+ version: string;
4
+ timestamp: string;
5
+ application: string;
6
+ managers: Record<string, unknown>;
7
+ }
8
+ /** Throws, naming what is missing, when `doc` is not a backup document. */
9
+ export declare function validateBackupDocument(doc: BackupDocument): void;
10
+ /**
11
+ * The document in `bytes`, read as a restore reads it: an encrypted one only
12
+ * under `backupKey`, gzip by its first bytes whatever the name says.
13
+ *
14
+ * @param name - the file's name, for the messages
15
+ * @param options.validate - check the shape (default true)
16
+ */
17
+ export declare function readBackupDocument(bytes: Buffer, backupKey: string, name: string, options?: {
18
+ validate?: boolean;
19
+ }): Promise<BackupDocument>;
20
+ /** What a read-back found: the document's timestamp and how many manager parts it holds. */
21
+ export interface VerifiedBackup {
22
+ timestamp: string;
23
+ managers: number;
24
+ }
25
+ /** The worker's job: the document's bytes, the key and the name. */
26
+ export interface VerifyJob {
27
+ bytes: Uint8Array;
28
+ backupKey: string;
29
+ name: string;
30
+ }
31
+ /**
32
+ * Read a backup document back on a worker thread (#1563): rejects, naming
33
+ * why, when it does not decrypt (a wrong key, a changed byte, a bad tag), does
34
+ * not decompress, does not parse, or is not a backup document.
35
+ */
36
+ export declare function verifyBackupOffThread(bytes: Buffer, backupKey: string, name: string, timeoutMs: number): Promise<VerifiedBackup>;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Reading a backup document back (#1563): decrypt it under the backup key
3
+ * (the GCM tag of every record is checked), decompress it, parse it, and
4
+ * check its shape. The one place a document is read: a restore reads it this
5
+ * way, and every backup is read back this way before it counts as one.
6
+ *
7
+ * `verifyBackupOffThread` does it on a worker thread, so a large document is
8
+ * never decrypted, unzipped and parsed on the thread that serves requests.
9
+ */
10
+ import { promisify } from 'node:util';
11
+ import zlib from 'node:zlib';
12
+ import { Worker } from 'node:worker_threads';
13
+ import { decryptBuffer, isEncryptedBackup } from './backupCrypto.js';
14
+ import { BACKUP_KEY_ENV } from './backupKey.js';
15
+ const gunzip = promisify(zlib.gunzip);
16
+ /** Throws, naming what is missing, when `doc` is not a backup document. */
17
+ export function validateBackupDocument(doc) {
18
+ if (!doc || typeof doc !== 'object')
19
+ throw new Error('Invalid backup: not a backup document');
20
+ if (!doc.version)
21
+ throw new Error('Invalid backup: missing version');
22
+ if (!doc.timestamp)
23
+ throw new Error('Invalid backup: missing timestamp');
24
+ if (!doc.managers || typeof doc.managers !== 'object')
25
+ throw new Error('Invalid backup: missing or invalid managers data');
26
+ }
27
+ /**
28
+ * The document in `bytes`, read as a restore reads it: an encrypted one only
29
+ * under `backupKey`, gzip by its first bytes whatever the name says.
30
+ *
31
+ * @param name - the file's name, for the messages
32
+ * @param options.validate - check the shape (default true)
33
+ */
34
+ export async function readBackupDocument(bytes, backupKey, name, options = {}) {
35
+ let plain = bytes;
36
+ if (isEncryptedBackup(bytes)) {
37
+ if (!backupKey)
38
+ throw new Error(`The backup ${name} is encrypted under the backup key, and ${BACKUP_KEY_ENV} is not set`);
39
+ try {
40
+ plain = await decryptBuffer(bytes, backupKey);
41
+ }
42
+ catch (err) {
43
+ throw new Error(`The backup ${name} could not be read: ${err.message}`, { cause: err });
44
+ }
45
+ }
46
+ let json;
47
+ if (plain[0] === 0x1f && plain[1] === 0x8b) {
48
+ try {
49
+ json = (await gunzip(plain)).toString('utf8');
50
+ }
51
+ catch (err) {
52
+ throw new Error(`The backup ${name} does not decompress: ${err.message}`, { cause: err });
53
+ }
54
+ }
55
+ else {
56
+ json = plain.toString('utf8');
57
+ }
58
+ let doc;
59
+ try {
60
+ doc = JSON.parse(json);
61
+ }
62
+ catch (err) {
63
+ throw new Error(`The backup ${name} is not a readable document: ${err.message}`, { cause: err });
64
+ }
65
+ if (options.validate !== false)
66
+ validateBackupDocument(doc);
67
+ return doc;
68
+ }
69
+ /**
70
+ * Read a backup document back on a worker thread (#1563): rejects, naming
71
+ * why, when it does not decrypt (a wrong key, a changed byte, a bad tag), does
72
+ * not decompress, does not parse, or is not a backup document.
73
+ */
74
+ export function verifyBackupOffThread(bytes, backupKey, name, timeoutMs) {
75
+ // Compiled, the worker is the .js beside this file. From source (vitest, tsx) it is the .ts, which needs tsx's loader.
76
+ const fromSource = import.meta.url.endsWith('.ts');
77
+ const entry = new URL(fromSource ? './backupVerifyWorker.ts' : './backupVerifyWorker.js', import.meta.url);
78
+ return new Promise((resolve, reject) => {
79
+ const workerData = { bytes, backupKey, name };
80
+ const worker = new Worker(entry, { workerData, ...(fromSource ? { execArgv: ['--import', 'tsx'] } : {}) });
81
+ let settled = false;
82
+ const settle = (fn) => {
83
+ if (settled)
84
+ return;
85
+ settled = true;
86
+ clearTimeout(timer);
87
+ fn();
88
+ };
89
+ const timer = setTimeout(() => {
90
+ settle(() => reject(new Error(`reading the backup ${name} back did not finish within ${timeoutMs} ms (ngdpbase.backup.verify-timeout-ms)`)));
91
+ void worker.terminate();
92
+ }, timeoutMs);
93
+ worker.once('message', (m) => {
94
+ settle(() => (m.ok ? resolve(m.result) : reject(new Error(m.error))));
95
+ });
96
+ worker.once('error', (err) => settle(() => reject(err)));
97
+ worker.once('exit', (code) => settle(() => reject(new Error(`the backup read-back worker exited (code ${code}) without a result`))));
98
+ });
99
+ }