@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.
Files changed (34) hide show
  1. package/lib/src/managers/AddonsManager.d.ts +8 -0
  2. package/lib/src/managers/AttachmentManager.d.ts +59 -0
  3. package/lib/src/managers/AuditManager.d.ts +477 -0
  4. package/lib/src/managers/AuthManager.d.ts +247 -15
  5. package/lib/src/managers/AuthManager.js +588 -45
  6. package/lib/src/managers/ConsentManager.d.ts +15 -0
  7. package/lib/src/managers/ImportManager.d.ts +15 -0
  8. package/lib/src/providers/BaseAttachmentProvider.d.ts +4 -0
  9. package/lib/src/providers/BaseAuditProvider.d.ts +492 -0
  10. package/lib/src/providers/BaseAuthProvider.d.ts +2 -0
  11. package/lib/src/providers/BaseCredentialsProvider.d.ts +2 -2
  12. package/lib/src/providers/BaseCredentialsProvider.js +1 -1
  13. package/lib/src/providers/BasicAttachmentProvider.d.ts +9 -0
  14. package/lib/src/providers/FileCredentialsProvider.js +1 -1
  15. package/lib/src/providers/NullAuditProvider.d.ts +82 -0
  16. package/lib/src/providers/TotpAuthProvider.d.ts +55 -0
  17. package/lib/src/providers/TotpAuthProvider.js +145 -0
  18. package/lib/src/types/Consent.d.ts +17 -4
  19. package/lib/src/types/Consent.js +11 -1
  20. package/lib/src/types/Provider.d.ts +6 -0
  21. package/lib/src/types/express.d.ts +22 -0
  22. package/lib/src/utils/auditChain.d.ts +83 -0
  23. package/lib/src/utils/auditHeadWitness.d.ts +97 -0
  24. package/lib/src/utils/auditLifecycle.d.ts +71 -0
  25. package/lib/src/utils/browserDescription.d.ts +18 -0
  26. package/lib/src/utils/browserDescription.js +12 -0
  27. package/lib/src/utils/postureRecord.d.ts +73 -0
  28. package/lib/src/utils/privateStorePath.d.ts +6 -0
  29. package/lib/src/utils/privateStorePath.js +8 -0
  30. package/lib/src/utils/securityPosture.d.ts +52 -0
  31. package/lib/src/utils/tlsConfig.d.ts +59 -0
  32. package/lib/src/utils/totpSeedCipher.d.ts +4 -0
  33. package/lib/src/utils/totpSeedCipher.js +45 -0
  34. package/package.json +5 -2
@@ -0,0 +1,82 @@
1
+ import BaseAuditProvider, { WikiEngine, AuditFilters, AuditSearchResults, AuditStats } from './BaseAuditProvider.js';
2
+ import type { AuditReport } from './BaseAuditProvider.js';
3
+ /**
4
+ * NullAuditProvider - No-op audit provider
5
+ *
6
+ * Used when auditing is disabled or for testing.
7
+ * All audit operations are no-ops that return immediately.
8
+ */
9
+ declare class NullAuditProvider extends BaseAuditProvider {
10
+ /**
11
+ * Not chained (#1119): this provider stores nothing, so there is nothing to
12
+ * chain. A legitimate configuration rather than a hole — provided the
13
+ * instance cannot then claim tamper evidence, which is what
14
+ * getProviderInfo().guarantees reports.
15
+ */
16
+ protected chainEnabled(): boolean;
17
+ /** Nothing is stored, so nothing is guaranteed (#1119). */
18
+ getGuarantees(): AuditReport;
19
+ constructor(engine: WikiEngine);
20
+ /**
21
+ * Initialize the null audit provider (no-op)
22
+ * @returns {Promise<void>}
23
+ */
24
+ initialize(): Promise<void>;
25
+ /**
26
+ * Get provider information
27
+ * @returns {Object} Provider metadata
28
+ */
29
+ getProviderInfo(): {
30
+ name: string;
31
+ version: string;
32
+ description: string;
33
+ features: string[];
34
+ };
35
+ /**
36
+ * Log an audit event (no-op)
37
+ * @param {AuditEvent} _auditEvent - Audit event data
38
+ * @returns {Promise<string>} Dummy event ID
39
+ */
40
+ writeEvent(_record: Record<string, unknown>): Promise<string>;
41
+ /**
42
+ * Search audit logs (returns empty results)
43
+ * @param {AuditFilters} _filters - Search filters
44
+ * @param {Record<string, number>} options - Search options
45
+ * @returns {Promise<AuditSearchResults>} Empty search results
46
+ */
47
+ searchAuditLogs(_filters?: AuditFilters, options?: Record<string, number>): Promise<AuditSearchResults>;
48
+ /**
49
+ * Get audit statistics (returns zeros)
50
+ * @param {AuditFilters} _filters - Optional filters
51
+ * @returns {Promise<AuditStats>} Empty statistics
52
+ */
53
+ getAuditStats(_filters?: AuditFilters): Promise<AuditStats>;
54
+ /**
55
+ * Export audit logs (returns empty data)
56
+ * @param {AuditFilters} _filters - Export filters
57
+ * @param {string} format - Export format ('json', 'csv')
58
+ * @returns {Promise<string>} Empty export data
59
+ */
60
+ exportAuditLogs(_filters?: AuditFilters, format?: 'json' | 'csv'): Promise<string>;
61
+ /**
62
+ * Flush pending audit events (no-op)
63
+ * @returns {Promise<void>}
64
+ */
65
+ flush(): Promise<void>;
66
+ /**
67
+ * Clean up old audit logs (no-op)
68
+ * @returns {Promise<void>}
69
+ */
70
+ cleanup(): Promise<void>;
71
+ /**
72
+ * Check if the audit provider is healthy (always true)
73
+ * @returns {Promise<boolean>} Always true
74
+ */
75
+ isHealthy(): Promise<boolean>;
76
+ /**
77
+ * Close/cleanup the audit provider (no-op)
78
+ * @returns {Promise<void>}
79
+ */
80
+ close(): Promise<void>;
81
+ }
82
+ export default NullAuditProvider;
@@ -0,0 +1,55 @@
1
+ import type { AuthProvider, AuthResult, AuthVerifyCredentials, FactorDescription } from './BaseAuthProvider.js';
2
+ import type { CredentialRecord } from './BaseCredentialsProvider.js';
3
+ /** Steps either side of now that are accepted. */
4
+ export declare const TOTP_DRIFT_STEPS = 1;
5
+ /** The store access AuthManager grants: an account's TOTP rows, and recording a use. */
6
+ export interface TotpStoreAccess {
7
+ rows(username: string): CredentialRecord[];
8
+ used(id: string, at: string, secret: string): Promise<void>;
9
+ }
10
+ /** A new seed, offered while enrolling and kept by the caller until it is confirmed. */
11
+ export interface TotpEnrolment {
12
+ /** Base32, for typing into the app by hand. */
13
+ seed: string;
14
+ /** The `otpauth://` URI the QR code carries. */
15
+ uri: string;
16
+ }
17
+ /**
18
+ * The time step `code` belongs to within the drift window, or null. Pure; the
19
+ * caller decides about replay.
20
+ */
21
+ export declare function matchingStep(seed: string, code: string, now: number): number | null;
22
+ export declare class TotpAuthProvider implements AuthProvider {
23
+ private readonly credentialsKey;
24
+ private readonly issuer;
25
+ private readonly store;
26
+ private readonly now;
27
+ readonly id = "totp";
28
+ readonly displayName = "Authenticator app";
29
+ readonly factor: FactorDescription;
30
+ private readonly serial;
31
+ /**
32
+ * @param credentialsKey `NGDPBASE_CREDENTIALS_KEY`, from which the seed key is derived
33
+ * @param issuer shown in the authenticator app beside the account
34
+ */
35
+ constructor(credentialsKey: string, issuer: string, store: TotpStoreAccess, now?: () => number);
36
+ /** A fresh 160-bit seed and its URI for `username`. Nothing is stored. */
37
+ newEnrolment(username: string): TotpEnrolment;
38
+ /**
39
+ * Seal a seed that is offered but not yet confirmed, so it can wait in the
40
+ * session without being readable there or in a session log (#421). Its own
41
+ * shape, so it never opens as a stored secret.
42
+ */
43
+ sealPending(username: string, seed: string): string;
44
+ /** Open what `sealPending` made for `username`; null when it is not that. */
45
+ openPending(username: string, sealed: unknown): string | null;
46
+ /**
47
+ * Check the code that confirms an enrolment and seal the seed for the store.
48
+ * The confirming code's step is recorded, so it cannot then sign anyone in.
49
+ * Null when the seed is malformed or the code does not match.
50
+ */
51
+ confirmEnrolment(username: string, seed: string, code: string): string | null;
52
+ /** Verify `credentials.code` for `credentials.username`. A failure gives no reason. */
53
+ verify(credentials: AuthVerifyCredentials): Promise<AuthResult | null>;
54
+ private open;
55
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * TOTP — an authenticator app's six-digit code (#421), on `otpauth` (decided
3
+ * on #1523, 2026-10-31).
4
+ *
5
+ * - A second factor only (`primary: false`): something you have, `amr`
6
+ * `otp`, AAL1 on its own. After a password the sign-in covers two factor
7
+ * types and AuthManager's `assess()` lifts it to AAL2. Not phishing-resistant.
8
+ * - The seed lives in the credentials store (#1524), kind `totp`, AES-GCM
9
+ * encrypted under a subkey of `NGDPBASE_CREDENTIALS_KEY`
10
+ * (`utils/totpSeedCipher`). The row's sealed secret also holds the last time
11
+ * step used, so a code cannot be used twice in its window.
12
+ * - A code is accepted for the current 30-second step and one step either
13
+ * side (RFC 6238 §5.2 drift), never for a step at or before the last one used.
14
+ * - Verification for one account runs one at a time, so two requests carrying
15
+ * the same code cannot both pass before the step is recorded.
16
+ */
17
+ import { Secret, TOTP, URI } from 'otpauth';
18
+ import { openTotpSecret, sealTotpSecret } from '../utils/totpSeedCipher.js';
19
+ import { KeyedSerial } from '../utils/keyedSerial.js';
20
+ /** RFC 6238 defaults, which every authenticator app supports. */
21
+ const PERIOD = 30;
22
+ const DIGITS = 6;
23
+ /** Steps either side of now that are accepted. */
24
+ export const TOTP_DRIFT_STEPS = 1;
25
+ function totpFor(seed, issuer = '', label = '') {
26
+ return new TOTP({ issuer, label, algorithm: 'SHA1', digits: DIGITS, period: PERIOD, secret: Secret.fromBase32(seed) });
27
+ }
28
+ function isCode(code) {
29
+ return typeof code === 'string' && /^\d{6}$/.test(code);
30
+ }
31
+ function currentStep(now) {
32
+ return Math.floor(now / 1000 / PERIOD);
33
+ }
34
+ /**
35
+ * The time step `code` belongs to within the drift window, or null. Pure; the
36
+ * caller decides about replay.
37
+ */
38
+ export function matchingStep(seed, code, now) {
39
+ if (!isCode(code))
40
+ return null;
41
+ const delta = totpFor(seed).validate({ token: code, timestamp: now, window: TOTP_DRIFT_STEPS });
42
+ return delta === null ? null : currentStep(now) + delta;
43
+ }
44
+ export class TotpAuthProvider {
45
+ credentialsKey;
46
+ issuer;
47
+ store;
48
+ now;
49
+ id = 'totp';
50
+ displayName = 'Authenticator app';
51
+ factor = { amr: ['otp'], aal: 1, primary: false };
52
+ serial = new KeyedSerial();
53
+ /**
54
+ * @param credentialsKey `NGDPBASE_CREDENTIALS_KEY`, from which the seed key is derived
55
+ * @param issuer shown in the authenticator app beside the account
56
+ */
57
+ constructor(credentialsKey, issuer, store, now = Date.now) {
58
+ this.credentialsKey = credentialsKey;
59
+ this.issuer = issuer;
60
+ this.store = store;
61
+ this.now = now;
62
+ if (!credentialsKey)
63
+ throw new Error('TOTP needs NGDPBASE_CREDENTIALS_KEY (#421)');
64
+ }
65
+ /** A fresh 160-bit seed and its URI for `username`. Nothing is stored. */
66
+ newEnrolment(username) {
67
+ const seed = new Secret({ size: 20 }).base32;
68
+ return { seed, uri: URI.stringify(totpFor(seed, this.issuer, username)) };
69
+ }
70
+ /**
71
+ * Seal a seed that is offered but not yet confirmed, so it can wait in the
72
+ * session without being readable there or in a session log (#421). Its own
73
+ * shape, so it never opens as a stored secret.
74
+ */
75
+ sealPending(username, seed) {
76
+ return sealTotpSecret(this.credentialsKey, username, JSON.stringify({ pendingSeed: seed }));
77
+ }
78
+ /** Open what `sealPending` made for `username`; null when it is not that. */
79
+ openPending(username, sealed) {
80
+ if (typeof sealed !== 'string')
81
+ return null;
82
+ const plain = openTotpSecret(this.credentialsKey, username, sealed);
83
+ if (!plain)
84
+ return null;
85
+ try {
86
+ const p = JSON.parse(plain);
87
+ return typeof p.pendingSeed === 'string' ? p.pendingSeed : null;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ /**
94
+ * Check the code that confirms an enrolment and seal the seed for the store.
95
+ * The confirming code's step is recorded, so it cannot then sign anyone in.
96
+ * Null when the seed is malformed or the code does not match.
97
+ */
98
+ confirmEnrolment(username, seed, code) {
99
+ let step;
100
+ try {
101
+ step = matchingStep(seed, code, this.now());
102
+ }
103
+ catch {
104
+ return null; // not base32
105
+ }
106
+ if (step === null)
107
+ return null;
108
+ return sealTotpSecret(this.credentialsKey, username, JSON.stringify({ seed, lastStep: step }));
109
+ }
110
+ /** Verify `credentials.code` for `credentials.username`. A failure gives no reason. */
111
+ async verify(credentials) {
112
+ const { username, code } = credentials;
113
+ if (typeof username !== 'string' || !username || !isCode(code))
114
+ return null;
115
+ return this.serial.run(username, async () => {
116
+ const now = this.now();
117
+ for (const row of this.store.rows(username)) {
118
+ const secret = this.open(row);
119
+ if (!secret)
120
+ continue;
121
+ const step = matchingStep(secret.seed, code, now);
122
+ if (step === null || step <= secret.lastStep)
123
+ continue;
124
+ const next = sealTotpSecret(this.credentialsKey, username, JSON.stringify({ seed: secret.seed, lastStep: step }));
125
+ await this.store.used(row.id, new Date(now).toISOString(), next);
126
+ return { username };
127
+ }
128
+ return null;
129
+ });
130
+ }
131
+ open(row) {
132
+ if (row.kind !== 'totp')
133
+ return null;
134
+ const plain = openTotpSecret(this.credentialsKey, row.username, row.secret);
135
+ if (!plain)
136
+ return null;
137
+ try {
138
+ const s = JSON.parse(plain);
139
+ return typeof s.seed === 'string' && typeof s.lastStep === 'number' ? s : null;
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ }
@@ -33,6 +33,15 @@ export declare const RECOVERY_WORDS_DOCUMENT = "recovery-words";
33
33
  export declare const SHARED_FOLDERS_WORDS_CONSENT_SCOPE = "shared-folders-recovery-words";
34
34
  /** The page whose `version:` is the version of the shared-folders recovery-words wording (#2124). */
35
35
  export declare const SHARED_FOLDERS_WORDS_DOCUMENT = "shared-folders-recovery-words";
36
+ /**
37
+ * The scope of "Remember this browser" (#1523 slice 1c, Q5): a person's
38
+ * opt-in, at the second-factor step, to a device key on this browser that
39
+ * skips the second-factor prompt next time. Bound to a subject: the remembered
40
+ * device's id, so each browser is its own consent, withdrawn by removing it.
41
+ */
42
+ export declare const REMEMBER_BROWSER_CONSENT_SCOPE = "remember-browser";
43
+ /** The page whose `version:` is the version of the "Remember this browser" wording (#1523 slice 1c). */
44
+ export declare const REMEMBER_BROWSER_DOCUMENT = "remember-browser";
36
45
  /** Core's scopes bound to a subject, each with its one document (#1928, #2124). */
37
46
  export declare const SUBJECT_CONSENT_DOCUMENTS: Readonly<Record<ConsentScope, string>>;
38
47
  /** Scopes whose acceptance is about one thing (a subject), not the account as a whole (#1928). */
@@ -61,17 +70,21 @@ export interface ConsentSeen {
61
70
  version: string;
62
71
  }
63
72
  /**
64
- * One acceptance, one line of the person's file. Append-only: a later
65
- * version is a later line, nothing is rewritten.
73
+ * One acceptance, or its withdrawal, one line of the person's file.
74
+ * Append-only: a later version is a later line, and a withdrawal is a later
75
+ * `withdraw` line naming the same document, version and subject; nothing is
76
+ * rewritten.
66
77
  */
67
78
  export interface ConsentRecord {
68
- kind: 'accept';
79
+ kind: 'accept' | 'withdraw';
69
80
  document: string;
70
81
  scope: ConsentScope;
71
82
  version: string;
72
83
  effective: string | null;
73
- /** ISO 8601 time the person accepted. */
84
+ /** ISO 8601 time the person accepted (on a `withdraw` line, of the acceptance withdrawn). */
74
85
  acceptedAt: string;
86
+ /** ISO 8601 time it was withdrawn; on a `withdraw` line only. */
87
+ withdrawnAt?: string;
75
88
  /**
76
89
  * The account this was accepted on, by its creation time (#1937): a later
77
90
  * account that takes the same username never inherits it.
@@ -31,10 +31,20 @@ export const RECOVERY_WORDS_DOCUMENT = 'recovery-words';
31
31
  export const SHARED_FOLDERS_WORDS_CONSENT_SCOPE = 'shared-folders-recovery-words';
32
32
  /** The page whose `version:` is the version of the shared-folders recovery-words wording (#2124). */
33
33
  export const SHARED_FOLDERS_WORDS_DOCUMENT = 'shared-folders-recovery-words';
34
+ /**
35
+ * The scope of "Remember this browser" (#1523 slice 1c, Q5): a person's
36
+ * opt-in, at the second-factor step, to a device key on this browser that
37
+ * skips the second-factor prompt next time. Bound to a subject: the remembered
38
+ * device's id, so each browser is its own consent, withdrawn by removing it.
39
+ */
40
+ export const REMEMBER_BROWSER_CONSENT_SCOPE = 'remember-browser';
41
+ /** The page whose `version:` is the version of the "Remember this browser" wording (#1523 slice 1c). */
42
+ export const REMEMBER_BROWSER_DOCUMENT = 'remember-browser';
34
43
  /** Core's scopes bound to a subject, each with its one document (#1928, #2124). */
35
44
  export const SUBJECT_CONSENT_DOCUMENTS = {
36
45
  [RECOVERY_WORDS_CONSENT_SCOPE]: RECOVERY_WORDS_DOCUMENT,
37
- [SHARED_FOLDERS_WORDS_CONSENT_SCOPE]: SHARED_FOLDERS_WORDS_DOCUMENT
46
+ [SHARED_FOLDERS_WORDS_CONSENT_SCOPE]: SHARED_FOLDERS_WORDS_DOCUMENT,
47
+ [REMEMBER_BROWSER_CONSENT_SCOPE]: REMEMBER_BROWSER_DOCUMENT
38
48
  };
39
49
  /** Scopes whose acceptance is about one thing (a subject), not the account as a whole (#1928). */
40
50
  export const SUBJECT_CONSENT_SCOPES = Object.keys(SUBJECT_CONSENT_DOCUMENTS);
@@ -630,6 +630,12 @@ export interface StoreFileInput {
630
630
  keptOriginal?: {
631
631
  types: readonly string[];
632
632
  };
633
+ /**
634
+ * The id to store the file under, set only by AttachmentManager for a file
635
+ * a takeout re-import brings back (#2188), after it checked that no other
636
+ * file anywhere holds it. A store that already lists the id refuses it.
637
+ */
638
+ id?: string;
633
639
  }
634
640
  /**
635
641
  * A page in a private store (#1456): its entry in the store's OWN page index,
@@ -40,6 +40,14 @@ declare module 'express-session' {
40
40
  purpose: 'register' | 'authenticate' | 'reauth';
41
41
  expires: number;
42
42
  };
43
+ /**
44
+ * #421: an authenticator-app seed offered on /profile/totp, until a code confirms it (10 minutes).
45
+ * Sealed for the account by `totpEnrolmentStart`; the plain seed never enters the session.
46
+ */
47
+ totpEnrolment?: {
48
+ pending: string;
49
+ expires: number;
50
+ };
43
51
  /** The step-up asked for and not yet given (#2145): its permission, where it returns, whether returning redoes it. */
44
52
  pendingStepUp?: PendingStepUp;
45
53
  /** The path whose next page view says "Confirmed — you can do it now" after a step-up (#2145). */
@@ -49,6 +57,20 @@ declare module 'express-session' {
49
57
  /** A sign-in waiting for its second factor: the handle, and where to go after (#1523). No identity yet. */
50
58
  pendingSignIn?: string;
51
59
  pendingRedirect?: string;
60
+ /**
61
+ * #1523 slice 1c: "Remember this browser" was ticked and a second factor
62
+ * completed the sign-in; the next page has the browser make its key. The
63
+ * version of the page the person was shown, the sign-in's amr, where to go
64
+ * after, until when (epoch ms), and the id of the sign-in's audit record,
65
+ * which the remembered row names. Single use.
66
+ */
67
+ rememberBrowser?: {
68
+ version: string;
69
+ amr: string[];
70
+ next: string;
71
+ until: number;
72
+ auditId?: string;
73
+ };
52
74
  /** A verified outside identity no account has connected, waiting for the person to sign in another way (#1743). */
53
75
  pendingConnection?: PendingConnection;
54
76
  /** #2073: an outside sign-in whose email several accounts share, waiting for the person to choose one. */
@@ -0,0 +1,83 @@
1
+ /** `prevHash` of the first record in a chain. */
2
+ export declare const GENESIS_HASH = "genesis";
3
+ /**
4
+ * Hash a record in its position in the chain.
5
+ *
6
+ * `seq` and `prevHash` are part of the input, not just the payload — otherwise
7
+ * records could be reordered, or a record moved to a different predecessor,
8
+ * without breaking anything.
9
+ */
10
+ export declare function hashRecord(record: Record<string, unknown>, seq: number, prevHash: string): string;
11
+ /** Add `seq`, `prevHash` and `hash` to a record. Returns a new object. */
12
+ export declare function stampRecord(record: Record<string, unknown>, seq: number, prevHash: string): Record<string, unknown>;
13
+ /** Outcome of verifying a chain. `brokenAt` is the 1-based position, not the seq. */
14
+ export interface ChainVerdict {
15
+ ok: boolean;
16
+ checked: number;
17
+ brokenAt?: number;
18
+ reason?: string;
19
+ }
20
+ /**
21
+ * Where a chain that does not begin at genesis begins (#1537): the sequence
22
+ * and hash of the last record before the first one present. A retention
23
+ * checkpoint holds exactly this for the records it removed, so what remains
24
+ * verifies from it rather than from genesis.
25
+ */
26
+ export interface ChainStart {
27
+ seq: number;
28
+ hash: string;
29
+ }
30
+ /**
31
+ * Walk a chain in order and report the first break.
32
+ *
33
+ * @param records - Audit records, oldest first
34
+ * @param options.expectedHead - Hash the last record must have. Supplying it is
35
+ * the only way to detect truncation: removing records from the end breaks no
36
+ * link, so the chain alone cannot see it.
37
+ * @param options.start - The checkpoint the chain continues from (#1537). The
38
+ * first record must then carry `start.seq + 1` and link to `start.hash`;
39
+ * without it the first record must link to genesis.
40
+ */
41
+ export declare function verifyChain(records: readonly Record<string, unknown>[], options?: {
42
+ expectedHead?: string;
43
+ start?: ChainStart;
44
+ }): ChainVerdict;
45
+ /** Event type of a record that declares a deliberate chain discontinuity (#1124). */
46
+ export declare const CHAIN_RESTART_EVENT: "audit-chain-restart";
47
+ /** One contiguous chain within a log. */
48
+ export interface ChainSegment {
49
+ /** Index of the segment's first record in the input. */
50
+ startIndex: number;
51
+ verdict: ChainVerdict;
52
+ /** The restart marker that begins this segment, if it was started by one. */
53
+ restart?: Record<string, unknown>;
54
+ /** True when a break at the END of this segment is followed by a restart marker. */
55
+ explained: boolean;
56
+ /** True when this segment's marker names a previous head that does not match. */
57
+ mismatchedPrevious: boolean;
58
+ }
59
+ export interface LogVerdict {
60
+ ok: boolean;
61
+ segments: ChainSegment[];
62
+ }
63
+ /**
64
+ * Verify a log that may contain deliberate discontinuities (#1124).
65
+ *
66
+ * `verifyChain` answers "is this one chain intact", which is the right question
67
+ * for a chain and the wrong one for a log that has legitimately restarted — a
68
+ * migration, a restore, or a bug that wrote unverifiable records. Without this,
69
+ * one bad record leaves a log unverifiable forever and the only ways forward
70
+ * are dishonest: edit the log, delete it, or ignore the verifier.
71
+ *
72
+ * A break is __explained__ only when the very next record is a restart marker.
73
+ * That is what keeps the marker from being an escape hatch: an attacker who
74
+ * wants a clean chain must write a record saying they broke it, which is a
75
+ * confession rather than a cover-up.
76
+ *
77
+ * The marker's claimed `previousHash` is checked against the head it actually
78
+ * follows. A marker asserting a history that did not happen is itself a
79
+ * finding — otherwise a restart could claim anything.
80
+ */
81
+ export declare function verifyLog(records: readonly Record<string, unknown>[], options?: {
82
+ start?: ChainStart;
83
+ }): LogVerdict;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Off-box witness for the audit chain head (#1138).
3
+ *
4
+ * The chain detects alteration and deletion, and __cannot detect truncation of
5
+ * the tail__: removing records from the end breaks no link. That is also the
6
+ * attack an intruder actually wants — remove the record of what they just did.
7
+ * Everything before their session verifies perfectly, and the log ends looking
8
+ * like a clean shutdown.
9
+ *
10
+ * It cannot be fixed locally, for the reason #1119 made the chain functions
11
+ * pure: an attacker who owns the machine owns anything the machine wrote
12
+ * locally, including a head stored beside the log. __The head has to leave the
13
+ * box, or it is not evidence.__
14
+ *
15
+ * Three decisions from docs/security-posture.md shape what this does and does
16
+ * not do:
17
+ *
18
+ * - __D13__ — deployment methodology does not influence the design. This
19
+ * publishes a head to a configured destination and takes no view on what that
20
+ * destination is. Whether it is genuinely off-box is the operator's decision
21
+ * and the operator's to state.
22
+ * - __D21__ — report facts, not a claim. There is deliberately no `offBox:
23
+ * true` produced here. Nothing on this machine can verify that a path leaves
24
+ * it, and asserting so would be the `durable` defect (#1148) repeated: a
25
+ * boolean derived from something that does not establish it.
26
+ * - __D20__ — no scoring. A verdict says what is observably true of the records
27
+ * and the witness, and stops there.
28
+ */
29
+ /** What is published: one sequence number and one hash. Nothing else. */
30
+ export interface ChainWitness {
31
+ seq: number;
32
+ hash: string;
33
+ /** Which instance the head belongs to, so one store can hold several. */
34
+ instance: string;
35
+ publishedAt: string;
36
+ }
37
+ /** The minimum of a stored record this assessment needs. */
38
+ export interface ChainRecord {
39
+ seq?: number;
40
+ hash?: string;
41
+ }
42
+ export type WitnessVerdict =
43
+ /** The log is consistent with the witness. */
44
+ 'intact'
45
+ /** The witness is ahead of the log: records were removed from the end. */
46
+ | 'truncated'
47
+ /** The witnessed sequence is present with a different hash, or absent. */
48
+ | 'altered'
49
+ /** No witness. NOT the same statement as "intact". */
50
+ | 'unknown';
51
+ export interface WitnessAssessment {
52
+ verdict: WitnessVerdict;
53
+ /** How many records are missing from the end. Only meaningful when truncated. */
54
+ missing?: number;
55
+ reason?: string;
56
+ }
57
+ export interface BuildWitnessInput {
58
+ seq: number;
59
+ hash: string;
60
+ instance: string;
61
+ at: Date;
62
+ }
63
+ /**
64
+ * Build the record to publish.
65
+ *
66
+ * The head is one hash and one sequence number. Publishing more would put
67
+ * audit CONTENT wherever the witness lives, which is a different and much
68
+ * larger trust decision than publishing a fingerprint of it.
69
+ */
70
+ export declare function buildWitness(input: BuildWitnessInput): ChainWitness;
71
+ /**
72
+ * Is it time to publish again?
73
+ *
74
+ * The gap between publications is the window an attacker can truncate within,
75
+ * which makes the interval the actual security parameter — worth setting
76
+ * explicitly rather than inheriting.
77
+ *
78
+ * A zero or negative interval publishes every time rather than never: a
79
+ * misconfigured interval must fail toward more evidence, not toward silently
80
+ * disabling the witness.
81
+ */
82
+ export declare function shouldPublish(lastPublishedAtMs: number | null, nowMs: number, intervalMs: number): boolean;
83
+ /**
84
+ * Compare a published head against the records now on disk.
85
+ *
86
+ * The verdicts are kept distinct because an assessor needs the difference:
87
+ * truncation says records were removed, alteration says one was rewritten, and
88
+ * reporting both as "broken" loses what they would act on.
89
+ *
90
+ * `unknown` is never reported as `intact`. "Nothing is witnessing this log" and
91
+ * "this log is intact" are different statements, and deriving the second from
92
+ * the first is exactly the claim #1148 removed elsewhere.
93
+ */
94
+ export declare function assessWitness(witness: ChainWitness | {
95
+ seq: number;
96
+ hash: string;
97
+ } | null, records: readonly ChainRecord[]): WitnessAssessment;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Process lifecycle as audit events (#1149).
3
+ *
4
+ * Nothing recorded that an instance started or stopped, so an unclean exit was
5
+ * undetectable from the log. That matters because of #1148: on an unclean exit
6
+ * the buffered audit records are lost, the chain resumes from the last WRITTEN
7
+ * record at boot, verification passes, and nothing shows anything is absent.
8
+ * The log does not lie about the records it holds — it simply cannot say that
9
+ * records are missing.
10
+ *
11
+ * A `system-start` with no `system-shutdown` before it says exactly that. It
12
+ * cannot recover the lost records; it marks the window in which records may be
13
+ * absent, which is the difference between an audit trail with a known gap and
14
+ * one that quietly appears complete.
15
+ */
16
+ import type { AuditEvent } from './auditEvents.js';
17
+ /** What the previous run's ending can be established to be. */
18
+ export type PreviousRun =
19
+ /** A shutdown was recorded after the last start. */
20
+ 'clean'
21
+ /** A start was recorded with no shutdown after it. Records may be missing. */
22
+ | 'unclean'
23
+ /** No start on record — a first boot, or history beyond the log. */
24
+ | 'none'
25
+ /** The records present cannot establish an ordering. */
26
+ | 'unknown';
27
+ /** The minimum of a stored record this assessment needs. */
28
+ export interface LifecycleRecord {
29
+ timestamp?: string;
30
+ }
31
+ /**
32
+ * Decide how the previous run ended, from the latest record of each phase.
33
+ *
34
+ * Deliberately conservative in three places, because the whole value of this
35
+ * is that it does not overclaim:
36
+ *
37
+ * - No start on record is `none`, not `unclean`. A first boot must not accuse
38
+ * itself, or every new instance cries wolf and the signal stops being read.
39
+ * - A shutdown with no start is `unknown`, not `clean`. That is only reachable
40
+ * through a truncated or rotated log, and concluding "clean" from half the
41
+ * evidence is the failure this exists to prevent.
42
+ * - Equal timestamps are `unclean`. Same-millisecond records cannot establish
43
+ * ordering, so the shutdown did not *demonstrably* follow the start.
44
+ */
45
+ export declare function assessPreviousRun(lastStart: LifecycleRecord | null, lastShutdown: LifecycleRecord | null): PreviousRun;
46
+ export interface LifecycleEventInput {
47
+ /** The system principal (#631). Falls back to the literal only where no UserManager answers. */
48
+ principal?: string;
49
+ phase: 'start' | 'shutdown';
50
+ /** The running version, so a restart is distinguishable from an upgrade. */
51
+ version: string;
52
+ pid: number;
53
+ /** Required on a start, meaningless on a shutdown. */
54
+ previousRun?: PreviousRun;
55
+ /**
56
+ * The transport the instance actually bound (#1153).
57
+ *
58
+ * "This instance is serving HTTP" is a security fact, and the log had no
59
+ * record of it — only the version and pid.
60
+ */
61
+ scheme?: 'http' | 'https';
62
+ }
63
+ /**
64
+ * Build the audit event for a process starting or stopping.
65
+ *
66
+ * `previousRun` appears on a start only, following the rule `page-rename` and
67
+ * `token-mint` already use: a field present on every event is useless as a
68
+ * filter. A shutdown is a statement about itself and makes no claim about the
69
+ * past.
70
+ */
71
+ export declare function buildLifecycleAuditEvent(input: LifecycleEventInput): AuditEvent;
@@ -9,3 +9,21 @@
9
9
  * Null when the header names neither a browser nor a device we know.
10
10
  */
11
11
  export declare function describeBrowser(userAgent: string | undefined): string | null;
12
+ /**
13
+ * #1523 slice 1c — the browser and operating system a remembered browser's
14
+ * device key was issued to, from the User-Agent and the `Sec-CH-UA-Platform`
15
+ * client hint every Chromium browser sends. Families only ("Chrome",
16
+ * "Windows"), never versions, so an update keeps the browser remembered. No
17
+ * fingerprinting beyond what each request already carries (operator,
18
+ * 2026-10-04). Null when the browser or the system cannot be told: such a
19
+ * browser is never remembered.
20
+ */
21
+ export interface BrowserIdentity {
22
+ browser: string;
23
+ os: string;
24
+ /** The `Sec-CH-UA-Platform` hint, unquoted; empty when the browser sent none. */
25
+ platform: string;
26
+ }
27
+ export declare function browserIdentity(userAgent: string | undefined, platformHint: string | undefined): BrowserIdentity | null;
28
+ /** Whether two identities name the same browser on the same system, the hint included (both sent it, or neither). */
29
+ export declare function sameBrowser(a: BrowserIdentity, b: BrowserIdentity): boolean;