@jwilleke/ngdpbase-types 5.41.1 → 5.42.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/src/managers/AddonsManager.d.ts +8 -0
- package/lib/src/managers/AttachmentManager.d.ts +59 -0
- package/lib/src/managers/AuditManager.d.ts +477 -0
- package/lib/src/managers/AuthManager.d.ts +247 -15
- package/lib/src/managers/AuthManager.js +588 -45
- package/lib/src/managers/ConsentManager.d.ts +15 -0
- package/lib/src/managers/ImportManager.d.ts +15 -0
- package/lib/src/providers/BaseAttachmentProvider.d.ts +4 -0
- package/lib/src/providers/BaseAuditProvider.d.ts +492 -0
- package/lib/src/providers/BaseAuthProvider.d.ts +2 -0
- package/lib/src/providers/BaseCredentialsProvider.d.ts +2 -2
- package/lib/src/providers/BaseCredentialsProvider.js +1 -1
- package/lib/src/providers/BasicAttachmentProvider.d.ts +9 -0
- package/lib/src/providers/FileCredentialsProvider.js +1 -1
- package/lib/src/providers/NullAuditProvider.d.ts +82 -0
- package/lib/src/providers/TotpAuthProvider.d.ts +55 -0
- package/lib/src/providers/TotpAuthProvider.js +145 -0
- package/lib/src/types/Consent.d.ts +17 -4
- package/lib/src/types/Consent.js +11 -1
- package/lib/src/types/Provider.d.ts +6 -0
- package/lib/src/types/express.d.ts +22 -0
- package/lib/src/utils/auditChain.d.ts +83 -0
- package/lib/src/utils/auditHeadWitness.d.ts +97 -0
- package/lib/src/utils/auditLifecycle.d.ts +71 -0
- package/lib/src/utils/browserDescription.d.ts +18 -0
- package/lib/src/utils/browserDescription.js +12 -0
- package/lib/src/utils/postureRecord.d.ts +73 -0
- package/lib/src/utils/privateStorePath.d.ts +6 -0
- package/lib/src/utils/privateStorePath.js +8 -0
- package/lib/src/utils/securityPosture.d.ts +52 -0
- package/lib/src/utils/tlsConfig.d.ts +59 -0
- package/lib/src/utils/totpSeedCipher.d.ts +4 -0
- package/lib/src/utils/totpSeedCipher.js +45 -0
- package/package.json +5 -2
|
@@ -26,3 +26,15 @@ export function describeBrowser(userAgent) {
|
|
|
26
26
|
return `${browser} on ${device}`;
|
|
27
27
|
return browser ?? device;
|
|
28
28
|
}
|
|
29
|
+
export function browserIdentity(userAgent, platformHint) {
|
|
30
|
+
const described = describeBrowser(userAgent);
|
|
31
|
+
const [browser, os] = described?.split(' on ') ?? [];
|
|
32
|
+
if (!browser || !os)
|
|
33
|
+
return null;
|
|
34
|
+
const platform = (platformHint ?? '').trim().replace(/^"|"$/g, '').slice(0, 40);
|
|
35
|
+
return { browser, os, platform };
|
|
36
|
+
}
|
|
37
|
+
/** Whether two identities name the same browser on the same system, the hint included (both sent it, or neither). */
|
|
38
|
+
export function sameBrowser(a, b) {
|
|
39
|
+
return a.browser === b.browser && a.os === b.os && a.platform === b.platform;
|
|
40
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record the security posture at boot and compare it against the previous boot
|
|
3
|
+
* (#1156, D19 of docs/security-posture.md).
|
|
4
|
+
*
|
|
5
|
+
* `config-change` (#1150) audits every change made through `setProperty()`,
|
|
6
|
+
* which leaves two holes:
|
|
7
|
+
*
|
|
8
|
+
* - An `app-custom-config.json` edited __directly on disk__ never passes
|
|
9
|
+
* through `setProperty()` and emits nothing.
|
|
10
|
+
* - The state an instance __started in__ is never stated, only its deltas.
|
|
11
|
+
*
|
|
12
|
+
* Recording the posture at every start closes the second. Comparing that record
|
|
13
|
+
* against the previous start's closes the first: a change made on disk, or
|
|
14
|
+
* while the process was stopped, shows up as a difference between two
|
|
15
|
+
* consecutive boots __even though nothing observed the edit itself__.
|
|
16
|
+
*
|
|
17
|
+
* This is not the self-scoring D20 rejects. The comparison is against this
|
|
18
|
+
* instance's own previous state, a fact it holds, rather than against a
|
|
19
|
+
* recommended value set nobody can define.
|
|
20
|
+
*/
|
|
21
|
+
import type { PostureGroup } from './securityPosture.js';
|
|
22
|
+
/** What a secret ingredient records instead of its value. */
|
|
23
|
+
export declare const SECRET_PLACEHOLDER = "[secret]";
|
|
24
|
+
/** The posture as recorded: one flat map of ingredient to value. */
|
|
25
|
+
export type FlatPosture = Record<string, unknown>;
|
|
26
|
+
/**
|
|
27
|
+
* Flatten the grouped posture for recording.
|
|
28
|
+
*
|
|
29
|
+
* A secret records that it is __set__ and never what it is set to. An entry
|
|
30
|
+
* naming a key alongside its value would reintroduce the disclosure
|
|
31
|
+
* `ngdpbase.config.sensitive-values` (its `secret` entries) exists to prevent, by a different route and
|
|
32
|
+
* into a file with longer retention than the logs it already guards.
|
|
33
|
+
*/
|
|
34
|
+
export declare function flattenPosture(groups: readonly PostureGroup[]): FlatPosture;
|
|
35
|
+
export interface PostureDiff {
|
|
36
|
+
/**
|
|
37
|
+
* False when there is no previous record to compare against.
|
|
38
|
+
*
|
|
39
|
+
* Kept separate from "nothing changed", because they are different facts and
|
|
40
|
+
* only one of them is reassuring.
|
|
41
|
+
*/
|
|
42
|
+
comparable: boolean;
|
|
43
|
+
changed: Array<{
|
|
44
|
+
key: string;
|
|
45
|
+
from: unknown;
|
|
46
|
+
to: unknown;
|
|
47
|
+
}>;
|
|
48
|
+
added: string[];
|
|
49
|
+
removed: string[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Compare the posture now against the posture at the previous boot.
|
|
53
|
+
*
|
|
54
|
+
* __No previous record is `comparable: false`, never "no change".__ Only the
|
|
55
|
+
* last 1000 log lines are loaded for search (`FileAuditProvider.ts:610`), so on
|
|
56
|
+
* a busy instance the previous boot's record can fall outside the window.
|
|
57
|
+
* Reporting that as "nothing changed" would be a false all-clear at exactly the
|
|
58
|
+
* moment an operator is relying on the check.
|
|
59
|
+
*
|
|
60
|
+
* An ingredient added to or removed from the __view__ is reported separately
|
|
61
|
+
* from a __value__ change: removing an ingredient changes nothing about what
|
|
62
|
+
* the instance does (D4, D16), and folding both into "posture changed" would
|
|
63
|
+
* lose the distinction that matters when reading the log back.
|
|
64
|
+
*/
|
|
65
|
+
export declare function diffPostures(previous: FlatPosture | null, current: FlatPosture): PostureDiff;
|
|
66
|
+
/**
|
|
67
|
+
* The line an operator reads at startup.
|
|
68
|
+
*
|
|
69
|
+
* A difference found here says explicitly that nothing observed the edit —
|
|
70
|
+
* otherwise the obvious question is why it is being reported at boot rather
|
|
71
|
+
* than as a `config-change`, and the answer is the whole point.
|
|
72
|
+
*/
|
|
73
|
+
export declare function describePostureDiff(diff: PostureDiff): string;
|
|
@@ -155,6 +155,12 @@ export interface PrivatePageName {
|
|
|
155
155
|
* also its URL. No account can be called this (`RESERVED_USERNAMES`).
|
|
156
156
|
*/
|
|
157
157
|
export declare const SHARED_FOLDER_OWNER = "shared-folders";
|
|
158
|
+
/**
|
|
159
|
+
* Whether `id` has the shape a store gives a file (`crypto.randomUUID()`), so
|
|
160
|
+
* a takeout's file can be stored under it again (#2188). Anything else, a
|
|
161
|
+
* path or a pool's content hash among them, is never reused as a file name.
|
|
162
|
+
*/
|
|
163
|
+
export declare function isRestorableFileId(id: string): boolean;
|
|
158
164
|
/** A shared folder page's name, and its URL without the leading slash: `shared/{folder}/{title}`. */
|
|
159
165
|
export declare const SHARED_PAGE_NAME_PREFIX = "shared/";
|
|
160
166
|
/** The first path segment of a shared folder's URL: `/shared/{folder}/{title}`. */
|
|
@@ -212,6 +212,14 @@ export const PRIVATE_SLUG_PREFIX = 'vaults--';
|
|
|
212
212
|
* also its URL. No account can be called this (`RESERVED_USERNAMES`).
|
|
213
213
|
*/
|
|
214
214
|
export const SHARED_FOLDER_OWNER = 'shared-folders';
|
|
215
|
+
/**
|
|
216
|
+
* Whether `id` has the shape a store gives a file (`crypto.randomUUID()`), so
|
|
217
|
+
* a takeout's file can be stored under it again (#2188). Anything else, a
|
|
218
|
+
* path or a pool's content hash among them, is never reused as a file name.
|
|
219
|
+
*/
|
|
220
|
+
export function isRestorableFileId(id) {
|
|
221
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id);
|
|
222
|
+
}
|
|
215
223
|
/** A shared folder page's name, and its URL without the leading slash: `shared/{folder}/{title}`. */
|
|
216
224
|
export const SHARED_PAGE_NAME_PREFIX = 'shared/';
|
|
217
225
|
/** The first path segment of a shared folder's URL: `/shared/{folder}/{title}`. */
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The security posture — a view over the settings that determine what an
|
|
3
|
+
* instance guarantees (#1145).
|
|
4
|
+
*
|
|
5
|
+
* An operator cannot otherwise see what their instance's security settings
|
|
6
|
+
* are: they sit in `config/app-default-config.json` among some five hundred
|
|
7
|
+
* other keys, with nothing presenting them as one subject. One of them is
|
|
8
|
+
* where a gap becomes visible as it ships — `ngdpbase.auth.factors` offers
|
|
9
|
+
* only single factors, which is where the absence of MFA becomes a visible
|
|
10
|
+
* fact rather than tribal knowledge.
|
|
11
|
+
*
|
|
12
|
+
* __This is a view, not a resolution layer (D3).__ Every item is an ordinary
|
|
13
|
+
* key with its own shipped default, read by live code. The posture decides
|
|
14
|
+
* which settings are presented together and shows what each is currently set
|
|
15
|
+
* to. It adds no resolution step and changes no value on its own — which is
|
|
16
|
+
* also why removing an ingredient is safe: the key keeps whatever it is set
|
|
17
|
+
* to and the code keeps reading it, it simply stops being displayed (D4).
|
|
18
|
+
*
|
|
19
|
+
* See docs/security-posture.md.
|
|
20
|
+
*/
|
|
21
|
+
/** The shape of `ConfigurationManager.getProperty`. */
|
|
22
|
+
export type ConfigReader = (key: string, fallback?: unknown) => unknown;
|
|
23
|
+
export declare const POSTURE_KEY = "ngdpbase.security.posture";
|
|
24
|
+
export interface PostureItem {
|
|
25
|
+
key: string;
|
|
26
|
+
/** The current value. Absent when the key is a declared secret. */
|
|
27
|
+
value?: unknown;
|
|
28
|
+
/** Whether a change takes effect only after a restart (D6). */
|
|
29
|
+
restart: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* True when the key is named in `ngdpbase.config.sensitive-values` (its `secret` entries).
|
|
32
|
+
*
|
|
33
|
+
* Reported as present but masked rather than dropped: an operator who added
|
|
34
|
+
* it deserves to know it is set, and silently omitting it would make the
|
|
35
|
+
* section quietly incomplete.
|
|
36
|
+
*/
|
|
37
|
+
secret: boolean;
|
|
38
|
+
/** A plain-words caution when the value, read with another setting, does not do what it seems to. */
|
|
39
|
+
note?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface PostureGroup {
|
|
42
|
+
group: string;
|
|
43
|
+
items: PostureItem[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read the posture and each ingredient's current value.
|
|
47
|
+
*
|
|
48
|
+
* A malformed or absent posture yields nothing rather than throwing. This
|
|
49
|
+
* feeds an admin screen, and an operator who mistyped the object should get an
|
|
50
|
+
* empty section and a chance to fix it, not a page that fails to render.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolvePosture(read: ConfigReader): PostureGroup[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve HTTPS when a certificate and key are configured (#1153).
|
|
3
|
+
*
|
|
4
|
+
* There was no TLS path at all: `app.listen()` is plain HTTP and no cert or
|
|
5
|
+
* key key existed, so an operator holding a certificate had nowhere to put it.
|
|
6
|
+
* The concrete consequence was a security setting that could not be turned on
|
|
7
|
+
* safely — `ngdpbase.session.secure` ships `false` because a LAN instance on
|
|
8
|
+
* plain HTTP that set it `true` would issue cookies the browser refuses to
|
|
9
|
+
* send back and lock itself out of its own sessions.
|
|
10
|
+
*
|
|
11
|
+
* __A broken TLS configuration does not silently become HTTP.__ That is a
|
|
12
|
+
* transport downgrade: the operator configured TLS, believes traffic is
|
|
13
|
+
* encrypted, and it is in the clear. It takes #1152's survivable-failure path
|
|
14
|
+
* instead — the instance boots into maintenance mode, names the file that is
|
|
15
|
+
* wrong, and keeps `/admin` and `/login` reachable so it can be repaired
|
|
16
|
+
* without filesystem access.
|
|
17
|
+
*
|
|
18
|
+
* __Expiry is the deliberate exception.__ Node does not validate `notAfter`
|
|
19
|
+
* when building a server context; the client rejects the certificate instead.
|
|
20
|
+
* So an expired certificate still serves and is reported loudly. Falling back
|
|
21
|
+
* would downgrade the transport over a certificate that is merely stale, and
|
|
22
|
+
* blocking would take down an instance whose operator may be mid-renewal —
|
|
23
|
+
* while a stale certificate is already loudly visible to every client, which a
|
|
24
|
+
* silent downgrade is not.
|
|
25
|
+
*/
|
|
26
|
+
/** The shape of `ConfigurationManager.getProperty`. */
|
|
27
|
+
export type ConfigReader = (key: string, fallback?: unknown) => unknown;
|
|
28
|
+
export declare const TLS_CERT_KEY = "ngdpbase.server.tls.cert-file";
|
|
29
|
+
export declare const TLS_KEY_KEY = "ngdpbase.server.tls.key-file";
|
|
30
|
+
export type TlsResolution =
|
|
31
|
+
/** No certificate configured. The ordinary case; not a problem. */
|
|
32
|
+
{
|
|
33
|
+
mode: 'http';
|
|
34
|
+
}
|
|
35
|
+
/** Usable. `expired` is true when the certificate's own validity has passed. */
|
|
36
|
+
| {
|
|
37
|
+
mode: 'https';
|
|
38
|
+
cert: Buffer;
|
|
39
|
+
key: Buffer;
|
|
40
|
+
expired: boolean;
|
|
41
|
+
expiresAt: string | null;
|
|
42
|
+
}
|
|
43
|
+
/** Configured and unusable. Takes the #1152 survivable-failure path. */
|
|
44
|
+
| {
|
|
45
|
+
mode: 'blocked';
|
|
46
|
+
reasons: string[];
|
|
47
|
+
};
|
|
48
|
+
/** Injectable so the classification is testable without real certificates. */
|
|
49
|
+
export interface TlsDeps {
|
|
50
|
+
readFile?: (path: string) => Buffer;
|
|
51
|
+
createSecureContext?: (options: {
|
|
52
|
+
cert: Buffer;
|
|
53
|
+
key: Buffer;
|
|
54
|
+
}) => unknown;
|
|
55
|
+
now?: () => Date;
|
|
56
|
+
/** ISO expiry of the certificate, or null when it cannot be read. */
|
|
57
|
+
certExpiry?: (cert: Buffer) => string | null;
|
|
58
|
+
}
|
|
59
|
+
export declare function resolveTlsConfig(read: ConfigReader, deps?: TlsDeps): TlsResolution;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Seal `plain` for `username`; returns `v1.<iv>.<ciphertext+tag>`, base64url. */
|
|
2
|
+
export declare function sealTotpSecret(credentialsKey: string, username: string, plain: string): string;
|
|
3
|
+
/** Open a sealed value for `username`; null when it is malformed, for someone else, or under another key. */
|
|
4
|
+
export declare function openTotpSecret(credentialsKey: string, username: string, sealed: string): string | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encryption for TOTP seeds in the credentials store (#421).
|
|
3
|
+
*
|
|
4
|
+
* The store signs every row with `NGDPBASE_CREDENTIALS_KEY` but keeps it
|
|
5
|
+
* readable; a TOTP seed is a shared secret, so a reader of the file must not
|
|
6
|
+
* get it. The seed is AES-256-GCM encrypted under a subkey derived from the
|
|
7
|
+
* same key by HKDF (decided on #1523, 2026-10-31), so there is no new key to
|
|
8
|
+
* back up. The account name is the associated data: a sealed seed copied onto
|
|
9
|
+
* another person's row does not open.
|
|
10
|
+
*/
|
|
11
|
+
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
|
|
12
|
+
const INFO = 'ngdpbase totp seed v1';
|
|
13
|
+
const PREFIX = 'v1';
|
|
14
|
+
function subkey(credentialsKey) {
|
|
15
|
+
if (!credentialsKey)
|
|
16
|
+
throw new Error('A TOTP seed needs NGDPBASE_CREDENTIALS_KEY (#421)');
|
|
17
|
+
return Buffer.from(hkdfSync('sha256', credentialsKey, Buffer.alloc(0), INFO, 32));
|
|
18
|
+
}
|
|
19
|
+
/** Seal `plain` for `username`; returns `v1.<iv>.<ciphertext+tag>`, base64url. */
|
|
20
|
+
export function sealTotpSecret(credentialsKey, username, plain) {
|
|
21
|
+
const iv = randomBytes(12);
|
|
22
|
+
const cipher = createCipheriv('aes-256-gcm', subkey(credentialsKey), iv);
|
|
23
|
+
cipher.setAAD(Buffer.from(username, 'utf8'));
|
|
24
|
+
const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final(), cipher.getAuthTag()]);
|
|
25
|
+
return `${PREFIX}.${iv.toString('base64url')}.${body.toString('base64url')}`;
|
|
26
|
+
}
|
|
27
|
+
/** Open a sealed value for `username`; null when it is malformed, for someone else, or under another key. */
|
|
28
|
+
export function openTotpSecret(credentialsKey, username, sealed) {
|
|
29
|
+
const parts = sealed.split('.');
|
|
30
|
+
if (parts.length !== 3 || parts[0] !== PREFIX)
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
const iv = Buffer.from(parts[1], 'base64url');
|
|
34
|
+
const body = Buffer.from(parts[2], 'base64url');
|
|
35
|
+
if (iv.length !== 12 || body.length < 16)
|
|
36
|
+
return null;
|
|
37
|
+
const decipher = createDecipheriv('aes-256-gcm', subkey(credentialsKey), iv);
|
|
38
|
+
decipher.setAAD(Buffer.from(username, 'utf8'));
|
|
39
|
+
decipher.setAuthTag(body.subarray(body.length - 16));
|
|
40
|
+
return Buffer.concat([decipher.update(body.subarray(0, body.length - 16)), decipher.final()]).toString('utf8');
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jwilleke/ngdpbase-types",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.42.0",
|
|
4
4
|
"description": "TypeScript types for ngdpbase add-ons, and a test kit of core's own values. A dev dependency, versioned with ngdpbase.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Jim Willeke",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"@types/markdown-it": "^14.2.0",
|
|
41
41
|
"@types/multer": "^2.3.0",
|
|
42
42
|
"@types/node": "^24.19.2",
|
|
43
|
+
"@types/qrcode": "^1.5.6",
|
|
43
44
|
"better-sqlite3-multiple-ciphers": "^13.0.3",
|
|
44
45
|
"dotenv": "^18.0.9",
|
|
45
46
|
"express": "^5.3.0",
|
|
@@ -47,12 +48,14 @@
|
|
|
47
48
|
"fast-xml-parser": "^5.11.2",
|
|
48
49
|
"fs-extra": "^11.4.1",
|
|
49
50
|
"ipaddr.js": "^2.5.0",
|
|
50
|
-
"jose": "^6.2.
|
|
51
|
+
"jose": "^6.2.14",
|
|
51
52
|
"linkedom": "^0.18.14",
|
|
52
53
|
"markdown-it": "^14.3.3",
|
|
53
54
|
"minisearch": "7.2.0",
|
|
54
55
|
"mmdb-lib": "^3.0.3",
|
|
55
56
|
"multer": "^2.4.0",
|
|
57
|
+
"otpauth": "^9.5.2",
|
|
58
|
+
"qrcode": "^1.5.4",
|
|
56
59
|
"rrule-temporal": "2.2.8",
|
|
57
60
|
"tar": "^7.5.22",
|
|
58
61
|
"temporal-polyfill": "1.0.6",
|