knosky 0.6.3 → 0.7.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 (64) hide show
  1. package/CHANGELOG.md +149 -93
  2. package/CREDITS.md +14 -14
  3. package/LICENSE.md +76 -76
  4. package/LIMITATIONS.md +33 -23
  5. package/PRIVACY.md +30 -30
  6. package/README.md +170 -117
  7. package/SECURITY.md +78 -46
  8. package/action/post-comment.mjs +94 -89
  9. package/action.yml +62 -62
  10. package/bin/knosky.mjs +279 -105
  11. package/core/CONTRACT.md +70 -70
  12. package/core/append-only-checkpoint.mjs +215 -0
  13. package/core/audit-writer.mjs +317 -0
  14. package/core/benchmark-results.mjs +225 -225
  15. package/core/bundle.mjs +178 -178
  16. package/core/churn.mjs +23 -23
  17. package/core/ci.mjs +268 -268
  18. package/core/comparison.mjs +189 -189
  19. package/core/config.mjs +189 -189
  20. package/core/constants.mjs +13 -13
  21. package/core/contract.mjs +123 -123
  22. package/core/cross-repo.mjs +111 -111
  23. package/core/decision-codes.mjs +92 -0
  24. package/core/destination.mjs +161 -161
  25. package/core/district-classification.mjs +111 -0
  26. package/core/doctor-scorecard.mjs +369 -0
  27. package/core/domain-store.mjs +347 -0
  28. package/core/edges.mjs +43 -43
  29. package/core/escalate.mjs +68 -68
  30. package/core/freshness.mjs +198 -194
  31. package/core/fs-indexer.mjs +218 -218
  32. package/core/key-store.mjs +348 -348
  33. package/core/layout.mjs +46 -46
  34. package/core/ledger.mjs +176 -141
  35. package/core/local-ipc-identity.mjs +500 -0
  36. package/core/lod.mjs +155 -155
  37. package/core/mode-b.mjs +410 -0
  38. package/core/multi-model-benchmark.mjs +405 -405
  39. package/core/net-lockdown.mjs +421 -0
  40. package/core/onboarding.mjs +223 -223
  41. package/core/operator-auth.mjs +317 -0
  42. package/core/overlays.mjs +45 -45
  43. package/core/policy-lattice.mjs +142 -0
  44. package/core/pr-comment.mjs +198 -198
  45. package/core/protocol-spec.mjs +460 -460
  46. package/core/provenance.mjs +320 -0
  47. package/core/retrieve.mjs +63 -63
  48. package/core/route.mjs +304 -304
  49. package/core/schema.mjs +275 -275
  50. package/core/signing-tiers.mjs +1265 -0
  51. package/core/swarm-bench.mjs +106 -0
  52. package/core/swarm-coordinator.mjs +867 -0
  53. package/core/trust-root-rekey.mjs +410 -0
  54. package/mcp/server.mjs +264 -108
  55. package/package.json +56 -46
  56. package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
  57. package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
  58. package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
  59. package/renderer/art/kenney/sheet_allCars.xml +545 -545
  60. package/renderer/build-rich.mjs +43 -43
  61. package/renderer/city.template.html +808 -808
  62. package/ssot/decision-codes.json +133 -0
  63. package/ssot/ladder-l0-l3.md +232 -0
  64. package/ssot/tool-menu.json +130 -0
@@ -0,0 +1,1265 @@
1
+ // KnoSky F0.1 — Three-tier key protection (SAT-544).
2
+ //
3
+ // Implements a three-tier hierarchy for signing credentials:
4
+ //
5
+ // Tier 1 — TPM / Secure Enclave non-extractable key
6
+ // Detected via the SubtleCrypto `extractable: false` API. Honestly
7
+ // labeled "software" whenever hardware backing cannot be positively
8
+ // confirmed — this module never assumes a platform has a TPM/Secure
9
+ // Enclave just because the OS could theoretically have one.
10
+ //
11
+ // Tier 2 — FIDO2 / WebAuthn attested hardware key
12
+ // A pre-registered hardware authenticator credential, verified with
13
+ // @simplewebauthn/server (full x5c attestation certificate-chain
14
+ // verification against caller-supplied trusted root CAs, FIDO
15
+ // conformance-level certificate field checks, AAGUID cross-check).
16
+ // Self-asserted "backup eligible" flag alone is spoofable; a
17
+ // synced/backup-eligible credential (independently confirmed via
18
+ // the verified attestation, not a self-report) is demoted to
19
+ // Tier-3-equivalent. Fresh 32-byte challenge per ceremony, never
20
+ // reused.
21
+ //
22
+ // Tier 3 — RFC 6238 TOTP (software + TOTP)
23
+ // Standard 30-second window TOTP via otplib, backed by Node's own
24
+ // `node:crypto` HMAC (no additional crypto implementation pulled
25
+ // in — @otplib/plugin-crypto-node is a thin wrapper over
26
+ // `createHmac`/`randomBytes`/`timingSafeEqual`). Rate-limited: 5
27
+ // attempts per 10-minute window; backoff (exponential, max 60 s)
28
+ // after 3 consecutive failures. `knosky doctor` emits a structured
29
+ // warning when Tier 3 is active and the system clock appears
30
+ // unsynced (drift > 24 h from reference).
31
+ //
32
+ // governance.yml / minSigningTier:
33
+ // `governance.yml` in the repo root may contain a `minSigningTier` key scoped
34
+ // per district class. Enforcement is strict at evaluation time: a signer whose
35
+ // tier is below the required minimum is rejected outright, not just flagged.
36
+ //
37
+ // Mixed-tier quorum:
38
+ // Each signer's tier is recorded in the ledger entry. The quorum summary tier
39
+ // is the WEAKEST tier across all signers (the highest TIER.* number, since
40
+ // lower numbers are stronger) — never averaged, never strengthened by a
41
+ // stronger co-signer.
42
+ //
43
+ // Unforgeable logging:
44
+ // The raw WebAuthn assertion bytes are SHA-256 hashed into the EXCEPTION_GRANTED
45
+ // ledger entry so the tool cannot self-report a tier it did not actually use.
46
+ //
47
+ // Downgrade-attack protection:
48
+ // The tier-detection result together with the active minSigningTier setting are
49
+ // hashed into a checkpoint (buildTierCheckpoint/verifyTierCheckpoint) and signed
50
+ // via signManifest/verifyManifest (key-store.mjs) so those values cannot be
51
+ // rolled back without invalidating the manifest signature. The wiring is in
52
+ // signTierCheckpoint / verifySignedTierCheckpoint (SAT-562, this module).
53
+ //
54
+ // Process-spawning in _probeTpmPresence (SAT-564 reviewed relaxation):
55
+ // This module spawns two platform CLIs as an explicit, reviewed relaxation of
56
+ // the "no spawning" rule:
57
+ // darwin x64 — /usr/sbin/ioreg -c AppleKeyStoreController (IOKit, read-only)
58
+ // win32 — powershell.exe -NonInteractive -Command (Get-Tpm).TpmPresent
59
+ // Both are bounded by a 2-second timeout, require no elevated privileges, accept
60
+ // no caller-controlled input, and degrade gracefully to tpmPresent=false on any
61
+ // error. These probes are strictly separate from the general process-spawning ban
62
+ // (which remains in effect everywhere else). Authority: SAT-564, D-193.
63
+ //
64
+ // Design references: D-193, D-194, OUTPUTS/2026-07-05-KnoSky-F0-F1-DesignGate-v3-Combined.md §F0.1
65
+ // Authority: SAT-544.
66
+ //
67
+ // Mature libraries, no hand-rolled crypto/CBOR/X.509 parsing:
68
+ // - @simplewebauthn/server — WebAuthn attestation + assertion verification,
69
+ // CBOR decoding, X.509 chain validation (Tier 2).
70
+ // - otplib (TOTP class) + @otplib/plugin-crypto-node (Node-native HMAC via
71
+ // node:crypto, zero extra crypto dependency) + @otplib/plugin-base32-scure
72
+ // (audited, zero-dependency Base32) — RFC 6238 TOTP (Tier 3).
73
+ // Requires Node >= 20 (SettingsService/verifyRegistrationResponse's engines
74
+ // floor; this raised the package's overall `engines.node` from >=18 — flagged
75
+ // for Paul's awareness since it affects every knosky user, not just F0).
76
+
77
+ import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
78
+ import * as fs from 'node:fs';
79
+ import * as os from 'node:os';
80
+ import { spawnSync } from 'node:child_process';
81
+
82
+ import { signManifest, verifyManifest } from './key-store.mjs';
83
+
84
+ import {
85
+ verifyRegistrationResponse,
86
+ verifyAuthenticationResponse,
87
+ SettingsService,
88
+ } from '@simplewebauthn/server';
89
+ import { TOTP, ScureBase32Plugin } from 'otplib';
90
+ import { NodeCryptoPlugin } from '@otplib/plugin-crypto-node';
91
+ import { decodeCBOR } from '@levischuck/tiny-cbor';
92
+ import { X509Certificate as PeculiarX509Certificate, CRLDistributionPointsExtension } from '@peculiar/x509';
93
+
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Tier constants
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /** @enum {number} */
100
+ export const TIER = Object.freeze({
101
+ /** Tier 1 — TPM / Secure Enclave non-extractable key */
102
+ TPM: 1,
103
+ /** Tier 2 — FIDO2 / WebAuthn hardware key with full x5c attestation */
104
+ WEBAUTHN: 2,
105
+ /** Tier 3 — Software key + RFC 6238 TOTP */
106
+ TOTP: 3,
107
+ });
108
+
109
+ export const TIER_LABELS = Object.freeze({
110
+ [TIER.TPM]: 'TPM/Secure Enclave (non-extractable)',
111
+ [TIER.WEBAUTHN]: 'FIDO2/WebAuthn (hardware-attested)',
112
+ [TIER.TOTP]: 'software+TOTP',
113
+ });
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // TIER 1 — TPM / Secure Enclave detection
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Attempt to generate a non-extractable ECDSA P-256 key backed by the platform
121
+ * TPM or Secure Enclave. Returns a `{ tier, key, label }` object.
122
+ *
123
+ * When the platform's hardware key store presence cannot be POSITIVELY
124
+ * confirmed, this function reports `tpmPresent: false` and falls back to
125
+ * TIER.TOTP (software), regardless of which OS is running. This module never
126
+ * infers "this OS version normally ships with a TPM" as a substitute for an
127
+ * actual, verifiable signal — that inference is exactly the failure mode the
128
+ * ticket's "honestly labeled software fallback when absent" requirement
129
+ * exists to prevent.
130
+ *
131
+ * Three detection paths are implemented:
132
+ *
133
+ * Linux — kernel TPM resource-manager device node (/dev/tpmrm0 | /dev/tpm0).
134
+ * No spawn required.
135
+ *
136
+ * macOS — two sub-probes, one spawn-free and one that spawns:
137
+ * (a) Architecture check (spawn-free, SAT-564): on arm64 the CPU is
138
+ * Apple Silicon; the Secure Enclave is a hardware constant of
139
+ * every Apple Silicon SoC (A-series / M-series). No ioreg needed.
140
+ * (b) IOKit ioreg(8) probe (spawned, SAT-564 explicit relaxation):
141
+ * on x64 (Intel Mac with T2), `ioreg -c AppleKeyStoreController`
142
+ * is run and its output inspected for a class entry that
143
+ * positively confirms the Secure Enclave bridge. A missing binary,
144
+ * non-zero exit, or timeout is treated as `tpmPresent: false`.
145
+ *
146
+ * Windows — PowerShell `Get-Tpm` WMI/CIM probe (spawned, SAT-564 explicit
147
+ * relaxation): `powershell.exe -NonInteractive -Command (Get-Tpm).TpmPresent`
148
+ * is run. A missing binary, non-zero exit, timeout, or output that
149
+ * is not literally "True" is treated as `tpmPresent: false`.
150
+ *
151
+ * The spawn-based probes are the explicit constraint relaxation reviewed in
152
+ * SAT-564 for exactly this purpose. They are scoped to the single function
153
+ * `_probeTpmPresence` and use a short, bounded timeout (2 s) so they cannot
154
+ * stall the evaluator. A failed spawn (ENOENT, EACCES, timeout, crash) is
155
+ * treated identically to "hardware absent": the probe degrades gracefully to
156
+ * `tpmPresent: false`. This means the probes can only under-claim tier
157
+ * strength, never over-claim it — the same safe-default invariant the
158
+ * pre-SAT-564 Linux-only code maintained.
159
+ *
160
+ * @returns {Promise<{ tier: number, key: CryptoKey|null, label: string, probeInfo: object }>}
161
+ */
162
+ export async function detectTier1Key() {
163
+ const probe = await _probeTpmPresence();
164
+
165
+ let key = null;
166
+ try {
167
+ const { webcrypto } = await import('node:crypto');
168
+ key = (await webcrypto.subtle.generateKey(
169
+ { name: 'ECDSA', namedCurve: 'P-256' },
170
+ false, // non-extractable
171
+ ['sign', 'verify'],
172
+ )).privateKey;
173
+ } catch {
174
+ // SubtleCrypto unavailable — fall through to software tier
175
+ }
176
+
177
+ if (probe.tpmPresent && key !== null) {
178
+ return {
179
+ tier: TIER.TPM,
180
+ key,
181
+ label: TIER_LABELS[TIER.TPM],
182
+ probeInfo: probe,
183
+ };
184
+ }
185
+
186
+ // Honest fallback: label it software
187
+ return {
188
+ tier: TIER.TOTP,
189
+ key: null,
190
+ label: 'software (no TPM detected)',
191
+ probeInfo: probe,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * @internal
197
+ * Probe for TPM / Secure Enclave presence. Returns a structured result rather
198
+ * than throwing. Only reports `tpmPresent: true` when a real, verifiable signal
199
+ * exists; a failed or inconclusive probe degrades to `tpmPresent: false`.
200
+ *
201
+ * Platforms and signals (SAT-544 + SAT-564):
202
+ * linux — kernel device node (/dev/tpmrm0 | /dev/tpm0), no spawn.
203
+ * darwin — ioreg(8) spawn for all architectures (see _probeDarwin).
204
+ * win32 — PowerShell Get-Tpm spawn.
205
+ *
206
+ * @param {{ arch?: string, _spawnFn?: Function }} [_opts] Injection seam used
207
+ * by tests to override os.arch() and spawnSync for platform-negative / positive
208
+ * scenarios without requiring a real macOS or Windows machine.
209
+ * @returns {Promise<{ tpmPresent: boolean, mechanism: string|null, detail: string }>}
210
+ */
211
+ async function _probeTpmPresence({ arch, _spawnFn } = {}) {
212
+ const platform = os.platform();
213
+ const cpuArch = arch ?? os.arch();
214
+ const spawn = _spawnFn ?? spawnSync;
215
+
216
+ if (platform === 'linux') {
217
+ for (const dev of ['/dev/tpmrm0', '/dev/tpm0']) {
218
+ try {
219
+ fs.accessSync(dev, fs.constants.F_OK);
220
+ return { tpmPresent: true, mechanism: 'Linux TPM (/dev/tpmrm0|/dev/tpm0)', detail: dev };
221
+ } catch { /* dev not present */ }
222
+ }
223
+ return { tpmPresent: false, mechanism: null, detail: 'no /dev/tpmrm0 or /dev/tpm0 device node found' };
224
+ }
225
+
226
+ if (platform === 'darwin') {
227
+ return _probeDarwin(cpuArch, spawn);
228
+ }
229
+
230
+ if (platform === 'win32') {
231
+ return _probeWindows(spawn);
232
+ }
233
+
234
+ return { tpmPresent: false, mechanism: null, detail: 'no hardware key store detection implemented for platform ' + platform };
235
+ }
236
+
237
+ /**
238
+ * @internal
239
+ * macOS Secure Enclave probe (SAT-564 explicit spawn-constraint relaxation).
240
+ *
241
+ * All macOS paths use the IOKit ioreg(8) probe regardless of reported process
242
+ * architecture. os.arch() returns the running process ABI, not the underlying
243
+ * silicon: under Rosetta 2 an Intel Mac process reports 'arm64', making an
244
+ * arch-based shortcut an unreliable signal for hardware presence. The ioreg
245
+ * probe is the only verifiable, hardware-level mechanism available without root.
246
+ *
247
+ * `ioreg -c AppleKeyStoreController` lists the IOKit registry for that class;
248
+ * presence of an "AppleKeyStoreController" entry is the definitive confirmation
249
+ * of a T2 chip or Apple Silicon Secure Enclave bridge. A non-zero exit, a
250
+ * missing ioreg binary, or a 2-second timeout is treated as absent rather than
251
+ * throwing.
252
+ *
253
+ * Spawn constraints: spawning `ioreg` is the relaxation explicitly reviewed in
254
+ * SAT-564. The binary lives at a fixed OS path (/usr/sbin/ioreg), is shipped
255
+ * with every macOS install, requires no root, takes no input from callers, and
256
+ * is given a 2-second wall-clock timeout. stdout is checked only for a known
257
+ * safe string pattern.
258
+ *
259
+ * @param {string} _arch Ignored — retained for call-site compatibility.
260
+ * @param {Function} spawnFn spawnSync-compatible function
261
+ * @returns {{ tpmPresent: boolean, mechanism: string|null, detail: string }}
262
+ */
263
+ export function _probeDarwin(_arch, spawnFn) {
264
+ // -------------------------------------------------------------------
265
+ // IOKit ioreg(8) probe — used for all macOS architectures (SAT-564).
266
+ // os.arch() reflects the process ABI, not the underlying silicon, so
267
+ // it cannot reliably distinguish Apple Silicon from an Intel Mac running
268
+ // a process under Rosetta 2. The ioreg probe is the verifiable signal.
269
+ // -------------------------------------------------------------------
270
+ let result;
271
+ try {
272
+ result = spawnFn('/usr/sbin/ioreg', ['-c', 'AppleKeyStoreController'], {
273
+ encoding: 'utf8',
274
+ timeout: 2000, // 2-second wall-clock cap; stall → absent
275
+ stdio: ['ignore', 'pipe', 'ignore'],
276
+ });
277
+ } catch {
278
+ return { tpmPresent: false, mechanism: null, detail: 'ioreg spawn failed (exception); treating as Secure Enclave absent' };
279
+ }
280
+
281
+ if (result.status !== 0 || typeof result.stdout !== 'string' || result.stdout.length === 0) {
282
+ return {
283
+ tpmPresent: false,
284
+ mechanism: null,
285
+ detail: 'ioreg -c AppleKeyStoreController exited non-zero or produced no output; treating as Secure Enclave absent',
286
+ };
287
+ }
288
+
289
+ // A real T2/Secure Enclave bridge appears as an IOKit object whose class name
290
+ // is "AppleKeyStoreController" in the registry output. The ioreg class filter
291
+ // (-c) already selects only objects of that exact class, so any non-empty line
292
+ // containing "AppleKeyStoreController" is a positive hit.
293
+ const hasEntry = result.stdout.includes('AppleKeyStoreController');
294
+ if (hasEntry) {
295
+ return {
296
+ tpmPresent: true,
297
+ mechanism: 'darwin x64 IOKit (ioreg -c AppleKeyStoreController)',
298
+ detail: 'AppleKeyStoreController IOKit class entry found; T2/Secure Enclave bridge confirmed',
299
+ };
300
+ }
301
+
302
+ return {
303
+ tpmPresent: false,
304
+ mechanism: null,
305
+ detail: 'ioreg -c AppleKeyStoreController returned output but no AppleKeyStoreController entry found; treating as Secure Enclave absent',
306
+ };
307
+ }
308
+
309
+ /**
310
+ * @internal
311
+ * Windows TPM 2.0 probe via PowerShell Get-Tpm (SAT-564 explicit
312
+ * spawn-constraint relaxation).
313
+ *
314
+ * `powershell.exe -NonInteractive -Command (Get-Tpm).TpmPresent` writes
315
+ * exactly "True" or "False" to stdout. Any other output (including empty),
316
+ * non-zero exit, or spawn error is treated as `tpmPresent: false`.
317
+ *
318
+ * Spawn constraints: spawning PowerShell is the relaxation explicitly reviewed
319
+ * in SAT-564. The binary name is fixed ("powershell.exe", the built-in inbox
320
+ * PowerShell present on every supported Windows version), the command is a
321
+ * hard-coded literal with no caller-controlled interpolation, and the
322
+ * invocation is given a 2-second wall-clock timeout.
323
+ *
324
+ * @param {Function} spawnFn spawnSync-compatible function
325
+ * @returns {{ tpmPresent: boolean, mechanism: string|null, detail: string }}
326
+ */
327
+ export function _probeWindows(spawnFn) {
328
+ let result;
329
+ try {
330
+ result = spawnFn(
331
+ 'powershell.exe',
332
+ ['-NonInteractive', '-Command', '(Get-Tpm).TpmPresent'],
333
+ {
334
+ encoding: 'utf8',
335
+ timeout: 2000, // 2-second wall-clock cap; stall → absent
336
+ stdio: ['ignore', 'pipe', 'ignore'],
337
+ },
338
+ );
339
+ } catch {
340
+ return { tpmPresent: false, mechanism: null, detail: 'powershell.exe spawn failed (exception); treating as TPM absent' };
341
+ }
342
+
343
+ if (result.status !== 0 || typeof result.stdout !== 'string') {
344
+ return {
345
+ tpmPresent: false,
346
+ mechanism: null,
347
+ detail: 'powershell.exe Get-Tpm exited non-zero or produced no output; treating as TPM absent',
348
+ };
349
+ }
350
+
351
+ const out = result.stdout.trim();
352
+ if (out === 'True') {
353
+ return {
354
+ tpmPresent: true,
355
+ mechanism: 'win32 PowerShell Get-Tpm',
356
+ detail: '(Get-Tpm).TpmPresent returned "True"',
357
+ };
358
+ }
359
+
360
+ return {
361
+ tpmPresent: false,
362
+ mechanism: null,
363
+ detail: `(Get-Tpm).TpmPresent returned ${JSON.stringify(out)} (expected "True"); treating as TPM absent`,
364
+ };
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // TIER 2 — WebAuthn / FIDO2 registration and assertion (@simplewebauthn/server)
369
+ // ---------------------------------------------------------------------------
370
+
371
+ /**
372
+ * Generate a fresh, unpredictable 32-byte challenge for a WebAuthn ceremony.
373
+ * Challenges must never be reused; callers are responsible for tracking their
374
+ * one-time use (e.g. store in a nonce set, delete after first consumption).
375
+ *
376
+ * @returns {{ challenge: Buffer, challengeB64: string }}
377
+ */
378
+ export function generateWebAuthnChallenge() {
379
+ const challenge = randomBytes(32);
380
+ return {
381
+ challenge,
382
+ challengeB64: challenge.toString('base64url'),
383
+ };
384
+ }
385
+
386
+ /** @internal base64url-encode raw bytes for the wire-format envelope the library expects. */
387
+ function _b64u(bufLike) {
388
+ return Buffer.isBuffer(bufLike) ? bufLike.toString('base64url') : Buffer.from(bufLike).toString('base64url');
389
+ }
390
+
391
+ /** @internal constant-time challenge comparator, used as the library's `expectedChallenge` function-form. */
392
+ function _makeChallengeComparator(expectedChallenge) {
393
+ const expB64 = typeof expectedChallenge === 'string' ? expectedChallenge : _b64u(expectedChallenge);
394
+ return (receivedChallengeB64) => {
395
+ try {
396
+ const a = Buffer.from(receivedChallengeB64, 'base64url');
397
+ const b = Buffer.from(expB64, 'base64url');
398
+ return a.length === b.length && timingSafeEqual(a, b);
399
+ } catch {
400
+ return false;
401
+ }
402
+ };
403
+ }
404
+
405
+ // Placeholder credential id/rawId used only to satisfy the library's
406
+ // RegistrationResponseJSON/AuthenticationResponseJSON shape. The library does
407
+ // NOT cross-validate this against the credential id embedded in the
408
+ // attestation object/authenticatorData for either verification path — it only
409
+ // requires `id === rawId` and both being present. The authoritative
410
+ // credential id is `result.registrationInfo.credential.id`, derived by the
411
+ // library from the parsed authenticatorData itself.
412
+ const _PLACEHOLDER_CRED_ID = Buffer.from('knosky-f01-placeholder-credential-id').toString('base64url');
413
+
414
+ /**
415
+ * @internal
416
+ * KnoSky is a no-egress tool (SECURITY.md). @simplewebauthn/server's x5c
417
+ * chain validation (`validateCertificatePath` -> `isCertRevoked`) performs a
418
+ * real outbound `fetch()` to a certificate's CRL Distribution Point URL IF
419
+ * the presented certificate embeds one — this is attacker/authenticator-
420
+ * influenced input, not something KnoSky's own code chooses to do. Real FIDO
421
+ * authenticator attestation certs conventionally omit CRL distribution
422
+ * points, but nothing stops a crafted attestation from including one.
423
+ *
424
+ * To preserve the no-egress guarantee unconditionally, this module decodes
425
+ * the attestation object's x5c chain itself (via the same mature CBOR/X.509
426
+ * libraries @simplewebauthn/server already depends on — no hand-rolled
427
+ * parsing) and rejects outright, before ever calling into the library, if any
428
+ * certificate in the chain carries a CRL Distribution Points extension.
429
+ *
430
+ * @param {Buffer} attestationObjectBytes
431
+ * @returns {string|null} a rejection reason, or null if no CRL extension was found
432
+ */
433
+ function _rejectIfCrlDistributionPoint(attestationObjectBytes) {
434
+ let decoded;
435
+ try {
436
+ decoded = decodeCBOR(new Uint8Array(attestationObjectBytes));
437
+ } catch {
438
+ // Malformed CBOR — let verifyRegistrationResponse's own parsing produce
439
+ // the real, user-facing error message for this case.
440
+ return null;
441
+ }
442
+
443
+ const attStmt = decoded instanceof Map ? decoded.get('attStmt') : null;
444
+ const x5c = attStmt instanceof Map ? attStmt.get('x5c') : null;
445
+ if (!Array.isArray(x5c)) return null;
446
+
447
+ for (const certDer of x5c) {
448
+ try {
449
+ const bytes = certDer instanceof Uint8Array ? certDer : new Uint8Array(certDer);
450
+ const cert = new PeculiarX509Certificate(bytes);
451
+ const hasCrlExtension = cert.extensions.some(ext => ext instanceof CRLDistributionPointsExtension);
452
+ if (hasCrlExtension) {
453
+ return 'certificate in x5c embeds a CRL Distribution Points extension; rejected to preserve KnoSky\'s no-egress guarantee (no revocation-check network fetch is ever performed)';
454
+ }
455
+ } catch {
456
+ // Malformed certificate — let the real verification path's own parsing
457
+ // surface this error with better context than we could here.
458
+ continue;
459
+ }
460
+ }
461
+ return null;
462
+ }
463
+
464
+ /**
465
+ * @internal
466
+ * @simplewebauthn/server's SettingsService.setRootCertificates is process-wide
467
+ * state, not per-call. Two concurrent verifyWebAuthnAttestation calls with
468
+ * DIFFERENT trustedRoots could otherwise race: call A sets its roots, call B
469
+ * overwrites them with its own before A's verifyRegistrationResponse actually
470
+ * reads them, and A ends up verifying against B's trust anchors. This
471
+ * serializes every call through this single async chain so the
472
+ * "set roots -> verify" critical section can never interleave, regardless of
473
+ * how many verifyWebAuthnAttestation calls are in flight at once. A failed
474
+ * ceremony does not wedge the chain for later callers.
475
+ * @type {Promise<void>}
476
+ */
477
+ let _attestationLockChain = Promise.resolve();
478
+
479
+ /** @internal Run `fn` after any in-flight attestation verification has finished. */
480
+ function _withAttestationLock(fn) {
481
+ const run = _attestationLockChain.then(fn, fn);
482
+ _attestationLockChain = run.then(() => undefined, () => undefined);
483
+ return run;
484
+ }
485
+
486
+ /**
487
+ * Verify a WebAuthn attestation registration response.
488
+ *
489
+ * Delegates all CBOR/X.509/COSE parsing and signature verification to
490
+ * @simplewebauthn/server's `verifyRegistrationResponse`. This module's job is
491
+ * limited to: (1) adapting KnoSky's raw-buffer inputs into the wire-format
492
+ * envelope the library expects, (2) configuring the library's trusted-root
493
+ * store per call from caller-supplied `trustedRoots`, (3) applying KnoSky's
494
+ * own tier-demotion rule for backup-eligible credentials, and (4) mapping the
495
+ * library's thrown errors into this module's `{ ok: false, reason }` contract
496
+ * so callers never need a try/catch of their own.
497
+ *
498
+ * Security properties enforced by the library (all real chain/field checks,
499
+ * not self-asserted flags):
500
+ * 1. Full x5c certificate-chain verification against the supplied
501
+ * trustedRoots, including certificate OU/O/C field conformance,
502
+ * basicConstraints, validity window, and AAGUID cross-check against the
503
+ * leaf certificate's extension.
504
+ * 2. Backup-eligible credentials (independently confirmed via the verified
505
+ * attestation's `credentialDeviceType === 'multiDevice'`, derived from
506
+ * the authenticatorData BE bit — not a self-report) are demoted to
507
+ * Tier-3-equivalent.
508
+ * 3. Challenge match (constant-time, via a custom comparator — see below).
509
+ * 4. rpID match.
510
+ * 5. Origin match (NEW vs. the pre-rework implementation, which never
511
+ * checked origin at all — a real gap this rework closes).
512
+ * 6. User presence (UP) must be asserted.
513
+ *
514
+ * This implementation supports whichever attestation formats
515
+ * @simplewebauthn/server supports (packed, fido-u2f, android-safetynet,
516
+ * android-key, tpm, apple, none) rather than hand-rolling support for only
517
+ * "packed" as the pre-rework code did.
518
+ *
519
+ * @param {object} opts
520
+ * @param {Buffer} opts.attestationObject Raw CBOR attestation object
521
+ * @param {Buffer} opts.clientDataJSON Raw client data JSON bytes
522
+ * @param {Buffer|string} opts.expectedChallenge The 32-byte challenge this server issued
523
+ * @param {string} opts.expectedRpId RP ID (usually hostname, e.g. "knosky.local")
524
+ * @param {string} opts.expectedOrigin Origin the ceremony must have occurred on
525
+ * (REQUIRED — new vs. pre-rework, which never
526
+ * validated origin)
527
+ * @param {Buffer[]} opts.trustedRoots PEM or DER buffers of trusted root CA certs
528
+ * @returns {Promise<{
529
+ * ok: boolean,
530
+ * tier: number|null,
531
+ * reason?: string,
532
+ * credId: string, // base64url credential id (was a raw Buffer pre-rework)
533
+ * credentialPublicKey: Uint8Array, // COSE-encoded public key — needed for later assertion
534
+ * // verification; the pre-rework code never returned this
535
+ * // at all, which meant the assertion step had no way to
536
+ * // retrieve the key it needed.
537
+ * aaguid: string, // formatted AAGUID string (was a raw 16-byte Buffer pre-rework)
538
+ * beFlag: boolean,
539
+ * signCount: number,
540
+ * }>}
541
+ */
542
+ export async function verifyWebAuthnAttestation(opts) {
543
+ const { attestationObject, clientDataJSON, expectedChallenge, expectedRpId, expectedOrigin, trustedRoots } = opts;
544
+
545
+ if (!expectedOrigin || typeof expectedOrigin !== 'string') {
546
+ return { ok: false, reason: 'expectedOrigin is required (the origin the WebAuthn ceremony occurred on)', tier: null };
547
+ }
548
+
549
+ if (!Array.isArray(trustedRoots) || trustedRoots.length === 0) {
550
+ return { ok: false, reason: 'no trusted root CA certificates supplied', tier: null };
551
+ }
552
+
553
+ const attestationObjectBuf = Buffer.isBuffer(attestationObject) ? attestationObject : Buffer.from(attestationObject);
554
+ const crlRejectReason = _rejectIfCrlDistributionPoint(attestationObjectBuf);
555
+ if (crlRejectReason) {
556
+ return { ok: false, reason: crlRejectReason, tier: null };
557
+ }
558
+
559
+ // Root certs are a process-wide setting in @simplewebauthn/server, keyed
560
+ // by attestation format identifier — set immediately before the verify
561
+ // call, both serialized through _withAttestationLock so no other
562
+ // verifyWebAuthnAttestation call can interleave and overwrite these roots
563
+ // before this call's verifyRegistrationResponse has read them.
564
+ const pemRoots = trustedRoots.map(r => (Buffer.isBuffer(r) ? r : Buffer.from(r)));
565
+
566
+ const response = {
567
+ id: _PLACEHOLDER_CRED_ID,
568
+ rawId: _PLACEHOLDER_CRED_ID,
569
+ type: 'public-key',
570
+ clientExtensionResults: {},
571
+ response: {
572
+ clientDataJSON: _b64u(clientDataJSON),
573
+ attestationObject: _b64u(attestationObject),
574
+ },
575
+ };
576
+
577
+ let result;
578
+ try {
579
+ result = await _withAttestationLock(async () => {
580
+ for (const fmt of ['packed', 'fido-u2f', 'android-key', 'android-safetynet', 'tpm', 'apple']) {
581
+ SettingsService.setRootCertificates({ identifier: fmt, certificates: pemRoots });
582
+ }
583
+ return verifyRegistrationResponse({
584
+ response,
585
+ expectedChallenge: _makeChallengeComparator(expectedChallenge),
586
+ expectedOrigin,
587
+ expectedRPID: expectedRpId,
588
+ expectedType: 'webauthn.create',
589
+ requireUserPresence: true,
590
+ requireUserVerification: false,
591
+ });
592
+ });
593
+ } catch (err) {
594
+ return { ok: false, reason: err.message, tier: null };
595
+ }
596
+
597
+ if (!result.verified) {
598
+ return { ok: false, reason: 'attestation verification failed', tier: null };
599
+ }
600
+
601
+ const info = result.registrationInfo;
602
+ // "Backup eligible" (BE, WebAuthn authenticatorData bit 3) means the
603
+ // credential CAN be synced/multi-device; the library surfaces this as
604
+ // credentialDeviceType === 'multiDevice'. This is distinct from BS
605
+ // ("backup state" — IS it currently backed up right now, surfaced as
606
+ // credentialBackedUp), which is not what the ticket's AC is about: a
607
+ // synced-CAPABLE credential is the downgrade risk regardless of whether
608
+ // it happens to be backed up at this exact moment.
609
+ const beFlag = info.credentialDeviceType === 'multiDevice';
610
+ const tier = beFlag ? TIER.TOTP : TIER.WEBAUTHN;
611
+ const tierLabel = beFlag
612
+ ? 'software+TOTP (synced/backup-eligible WebAuthn credential demoted from Tier 2)'
613
+ : TIER_LABELS[TIER.WEBAUTHN];
614
+
615
+ return {
616
+ ok: true,
617
+ tier,
618
+ tierLabel,
619
+ credId: info.credential.id,
620
+ credentialPublicKey: info.credential.publicKey,
621
+ aaguid: info.aaguid,
622
+ beFlag,
623
+ signCount: info.credential.counter,
624
+ };
625
+ }
626
+
627
+ /**
628
+ * Verify a WebAuthn assertion (authentication, not registration).
629
+ *
630
+ * Delegates to @simplewebauthn/server's `verifyAuthenticationResponse`, which
631
+ * verifies the assertion signature, challenge, rpID, origin, user presence,
632
+ * and signCount replay detection (throws if the reported counter did not
633
+ * advance past the stored value), then returns the raw assertion bytes hashed
634
+ * into the ledger entry (unforgeable logging AC).
635
+ *
636
+ * @param {object} opts
637
+ * @param {Buffer} opts.authData authenticatorData from the assertion
638
+ * @param {Buffer} opts.signature assertion signature
639
+ * @param {Buffer} opts.clientDataJSON raw clientDataJSON bytes
640
+ * @param {Buffer|string} opts.expectedChallenge the challenge the server issued
641
+ * @param {string} opts.expectedRpId RP ID hostname
642
+ * @param {string} opts.expectedOrigin origin the ceremony must have occurred on (REQUIRED)
643
+ * @param {Uint8Array} opts.credentialPublicKey the registered credential's COSE public key
644
+ * (from verifyWebAuthnAttestation's return value)
645
+ * @param {string} [opts.credentialId] base64url credential id, if the caller tracks
646
+ * multiple credentials per principal (not
647
+ * cross-validated by the library; informational)
648
+ * @param {number} opts.storedSignCount previously stored signCount for this credential
649
+ * @returns {Promise<{
650
+ * ok: boolean,
651
+ * reason?: string,
652
+ * newSignCount: number,
653
+ * assertionHash: string, // SHA-256 hex of (authData ‖ signature ‖ clientDataJSON)
654
+ * }>}
655
+ */
656
+ export async function verifyWebAuthnAssertion(opts) {
657
+ const {
658
+ authData, signature, clientDataJSON, expectedChallenge, expectedRpId, expectedOrigin,
659
+ credentialPublicKey, credentialId, storedSignCount,
660
+ } = opts;
661
+
662
+ if (!expectedOrigin || typeof expectedOrigin !== 'string') {
663
+ return { ok: false, reason: 'expectedOrigin is required (the origin the WebAuthn ceremony occurred on)' };
664
+ }
665
+
666
+ const idB64 = typeof credentialId === 'string' && credentialId ? credentialId : _PLACEHOLDER_CRED_ID;
667
+
668
+ const response = {
669
+ id: idB64,
670
+ rawId: idB64,
671
+ type: 'public-key',
672
+ clientExtensionResults: {},
673
+ response: {
674
+ clientDataJSON: _b64u(clientDataJSON),
675
+ authenticatorData: _b64u(authData),
676
+ signature: _b64u(signature),
677
+ },
678
+ };
679
+
680
+ const credential = {
681
+ id: idB64,
682
+ publicKey: credentialPublicKey instanceof Uint8Array ? credentialPublicKey : new Uint8Array(Buffer.from(credentialPublicKey)),
683
+ counter: storedSignCount,
684
+ };
685
+
686
+ let result;
687
+ try {
688
+ result = await verifyAuthenticationResponse({
689
+ response,
690
+ expectedChallenge: _makeChallengeComparator(expectedChallenge),
691
+ expectedOrigin,
692
+ expectedRPID: expectedRpId,
693
+ expectedType: 'webauthn.get',
694
+ credential,
695
+ requireUserVerification: false,
696
+ });
697
+ } catch (err) {
698
+ return { ok: false, reason: err.message };
699
+ }
700
+
701
+ if (!result.verified) {
702
+ return { ok: false, reason: 'assertion signature verification failed' };
703
+ }
704
+
705
+ const assertionHash = createHash('sha256')
706
+ .update(Buffer.isBuffer(authData) ? authData : Buffer.from(authData))
707
+ .update(Buffer.isBuffer(signature) ? signature : Buffer.from(signature))
708
+ .update(Buffer.isBuffer(clientDataJSON) ? clientDataJSON : Buffer.from(clientDataJSON))
709
+ .digest('hex');
710
+
711
+ return { ok: true, newSignCount: result.authenticationInfo.newCounter, assertionHash };
712
+ }
713
+
714
+ // ---------------------------------------------------------------------------
715
+ // TIER 3 — RFC 6238 TOTP (otplib + @otplib/plugin-crypto-node)
716
+ // ---------------------------------------------------------------------------
717
+
718
+ const _totpCrypto = new NodeCryptoPlugin();
719
+ const _totpBase32 = new ScureBase32Plugin();
720
+
721
+ /** @internal Build a TOTP instance sharing KnoSky's fixed algorithm/digit/period policy. */
722
+ function _makeTotp() {
723
+ return new TOTP({
724
+ crypto: _totpCrypto,
725
+ base32: _totpBase32,
726
+ algorithm: 'sha1', // RFC 6238 default; matches the pre-rework HMAC-SHA1 implementation
727
+ digits: 6,
728
+ period: 30,
729
+ });
730
+ }
731
+
732
+ /**
733
+ * Generate a new TOTP secret (random 160-bit, Base32-encoded).
734
+ * The secret is suitable for use with RFC 6238 30-second window authenticator apps.
735
+ *
736
+ * @returns {{ secret: Buffer, secretBase32: string }}
737
+ */
738
+ export function generateTotpSecret() {
739
+ const secret = randomBytes(20); // 160 bits (recommended per RFC 4226)
740
+ return { secret, secretBase32: _totpBase32.encode(secret) };
741
+ }
742
+
743
+ /**
744
+ * Compute the RFC 6238 TOTP code for `secret` at unix time `ts` (seconds).
745
+ * Uses a 30-second step, 6-digit output.
746
+ *
747
+ * NOTE: this function is now async (it was synchronous pre-rework). otplib's
748
+ * TOTP class is async-first so the same code path works uniformly whether the
749
+ * configured crypto plugin is synchronous (ours is, via node:crypto) or not.
750
+ * No production call site in this repo currently calls this function
751
+ * synchronously — verified via a full-repo search before making this change.
752
+ *
753
+ * @param {Buffer} secret Raw HMAC-SHA1 key bytes.
754
+ * @param {number} ts Unix timestamp (seconds since epoch). Defaults to now.
755
+ * @returns {Promise<string>} 6-digit zero-padded TOTP code.
756
+ */
757
+ export async function computeTotpCode(secret, ts = Math.floor(Date.now() / 1000)) {
758
+ const totp = _makeTotp();
759
+ return totp.generate({ secret, epoch: ts });
760
+ }
761
+
762
+ /**
763
+ * Verify a TOTP token against a secret, allowing ±1 step (±30 s drift window,
764
+ * checked both into the past and the future — symmetric tolerance).
765
+ *
766
+ * Note: callers MUST apply rate-limiting before calling this. See
767
+ * {@link createTotpRateLimiter} for the required guard.
768
+ *
769
+ * @param {Buffer} secret Raw HMAC-SHA1 key bytes.
770
+ * @param {string} token 6-digit TOTP code to verify.
771
+ * @param {number} ts Unix timestamp (seconds). Defaults to now.
772
+ * @returns {Promise<boolean>}
773
+ */
774
+ export async function verifyTotpToken(secret, token, ts = Math.floor(Date.now() / 1000)) {
775
+ if (typeof token !== 'string' || !/^\d{6}$/.test(token)) return false;
776
+ const totp = _makeTotp();
777
+ const result = await totp.verify(token, { secret, epoch: ts, epochTolerance: 30 });
778
+ return result.valid;
779
+ }
780
+
781
+ /**
782
+ * Create a TOTP rate limiter.
783
+ *
784
+ * Policy (SAT-544 AC Tier 3):
785
+ * - Maximum 5 attempts in any rolling 10-minute window.
786
+ * - After 3 consecutive failures: exponential backoff starting at 5 s,
787
+ * doubling each failure, capped at 60 s. Backoff resets on success.
788
+ *
789
+ * @returns {{ check: (id: string) => { ok: boolean, waitMs: number, reason?: string },
790
+ * record: (id: string, success: boolean) => void,
791
+ * status: (id: string) => object }}
792
+ */
793
+ export function createTotpRateLimiter() {
794
+ // state[id] = { attempts: [{ts}...], consecutiveFails: number, backoffUntil: number }
795
+ const state = new Map();
796
+
797
+ const WINDOW_MS = 10 * 60 * 1000; // 10-minute window
798
+ const MAX_ATTEMPTS = 5; // max attempts per window
799
+ const BACKOFF_THRESHOLD = 3; // consecutive fails before backoff kicks in
800
+ const BASE_BACKOFF_MS = 5_000; // 5 s
801
+ const MAX_BACKOFF_MS = 60_000; // 60 s cap
802
+
803
+ function _get(id) {
804
+ if (!state.has(id)) {
805
+ state.set(id, { attempts: [], consecutiveFails: 0, backoffUntil: 0 });
806
+ }
807
+ return state.get(id);
808
+ }
809
+
810
+ function _pruneWindow(entry, now) {
811
+ entry.attempts = entry.attempts.filter(a => now - a.ts < WINDOW_MS);
812
+ }
813
+
814
+ return {
815
+ /**
816
+ * Check whether `id` is currently allowed to attempt a TOTP verification.
817
+ * Does NOT consume the attempt slot — call `record()` after the attempt.
818
+ *
819
+ * @param {string} id Opaque identifier (e.g. user/device id).
820
+ * @param {number} [now] Current timestamp ms. Defaults to Date.now().
821
+ * @returns {{ ok: boolean, waitMs: number, reason?: string }}
822
+ */
823
+ check(id, now = Date.now()) {
824
+ const entry = _get(id);
825
+ _pruneWindow(entry, now);
826
+
827
+ // Backoff in effect?
828
+ if (now < entry.backoffUntil) {
829
+ return { ok: false, waitMs: entry.backoffUntil - now, reason: 'backoff_in_effect' };
830
+ }
831
+
832
+ // Rate limit?
833
+ if (entry.attempts.length >= MAX_ATTEMPTS) {
834
+ const oldestTs = entry.attempts[0].ts;
835
+ const windowReset = oldestTs + WINDOW_MS;
836
+ return { ok: false, waitMs: Math.max(0, windowReset - now), reason: 'rate_limit_exceeded' };
837
+ }
838
+
839
+ return { ok: true, waitMs: 0 };
840
+ },
841
+
842
+ /**
843
+ * Record the outcome of a TOTP attempt.
844
+ *
845
+ * @param {string} id
846
+ * @param {boolean} success true = token matched; false = failed.
847
+ * @param {number} [now]
848
+ */
849
+ record(id, success, now = Date.now()) {
850
+ const entry = _get(id);
851
+ _pruneWindow(entry, now);
852
+ entry.attempts.push({ ts: now });
853
+
854
+ if (success) {
855
+ entry.consecutiveFails = 0;
856
+ entry.backoffUntil = 0;
857
+ } else {
858
+ entry.consecutiveFails++;
859
+ if (entry.consecutiveFails >= BACKOFF_THRESHOLD) {
860
+ const backoffMs = Math.min(
861
+ BASE_BACKOFF_MS * Math.pow(2, entry.consecutiveFails - BACKOFF_THRESHOLD),
862
+ MAX_BACKOFF_MS,
863
+ );
864
+ entry.backoffUntil = now + backoffMs;
865
+ }
866
+ }
867
+ },
868
+
869
+ /**
870
+ * Return the current rate-limit state for `id` (for diagnostics / knosky doctor).
871
+ *
872
+ * @param {string} id
873
+ * @param {number} [now]
874
+ * @returns {{attemptsInWindow: number, consecutiveFails: number, backoffUntilMs: number}}
875
+ */
876
+ status(id, now = Date.now()) {
877
+ const entry = _get(id);
878
+ _pruneWindow(entry, now);
879
+ return {
880
+ attemptsInWindow: entry.attempts.length,
881
+ consecutiveFails: entry.consecutiveFails,
882
+ backoffUntilMs: entry.backoffUntil,
883
+ };
884
+ },
885
+ };
886
+ }
887
+
888
+ // ---------------------------------------------------------------------------
889
+ // Clock-sync check for knosky doctor (Tier 3 AC)
890
+ // ---------------------------------------------------------------------------
891
+
892
+ /**
893
+ * Check whether the system clock might be significantly drifted.
894
+ *
895
+ * A rough drift estimate is computed by comparing the local clock against
896
+ * the process start time and optionally an `expectedUtcMs` caller-supplied
897
+ * reference (e.g. from a trusted ledger entry timestamp).
898
+ *
899
+ * `knosky doctor` warns when Tier 3 is active + drift > 24 h (air-gapped
900
+ * environments may not sync their clock, causing TOTP codes to be invalid).
901
+ *
902
+ * @param {object} opts
903
+ * @param {number} [opts.expectedUtcMs] Reference UTC ms from a trusted source.
904
+ * @param {number} [opts.now] Override for Date.now() in tests.
905
+ * @returns {{
906
+ * driftMs: number|null,
907
+ * drifted: boolean,
908
+ * warnThresholdMs: number,
909
+ * message: string|null,
910
+ * }}
911
+ */
912
+ export function checkClockSync(opts = {}) {
913
+ const WARN_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
914
+ const now = opts.now ?? Date.now();
915
+
916
+ if (typeof opts.expectedUtcMs !== 'number') {
917
+ return {
918
+ driftMs: null,
919
+ drifted: false,
920
+ warnThresholdMs: WARN_THRESHOLD_MS,
921
+ message: null,
922
+ };
923
+ }
924
+
925
+ const driftMs = Math.abs(now - opts.expectedUtcMs);
926
+ const drifted = driftMs > WARN_THRESHOLD_MS;
927
+
928
+ return {
929
+ driftMs,
930
+ drifted,
931
+ warnThresholdMs: WARN_THRESHOLD_MS,
932
+ message: drifted
933
+ ? `system clock drifted ${(driftMs / 3600000).toFixed(1)} h from expected; TOTP codes may be invalid (knosky doctor: Tier-3 clock-sync warning)`
934
+ : null,
935
+ };
936
+ }
937
+
938
+ // ---------------------------------------------------------------------------
939
+ // governance.yml — minSigningTier enforcement
940
+ // ---------------------------------------------------------------------------
941
+
942
+ /**
943
+ * Parse a `governance.yml` file for `minSigningTier` entries.
944
+ *
945
+ * governance.yml schema (minimal YAML subset — same parser as config.mjs):
946
+ *
947
+ * minSigningTier:
948
+ * default: 3 # applies to all districts not listed below
949
+ * core: 2 # "core" district class requires Tier 2+
950
+ * regulated: 1 # "regulated" district class requires Tier 1+
951
+ *
952
+ * All entries default to 3 (Tier 3) if not specified.
953
+ * Lines starting with `#` are comments.
954
+ * An absent or empty `governance.yml` is valid — means no minimum is enforced.
955
+ *
956
+ * Off by default: `minSigningTier` is absent unless explicitly set.
957
+ * Free for every user/plan — enforcement is purely local.
958
+ *
959
+ * @param {string} text Raw `governance.yml` file contents.
960
+ * @returns {{ minSigningTier: Record<string, number>|null }}
961
+ */
962
+ export function parseGovernanceYml(text) {
963
+ const lines = text.split(/\r?\n/);
964
+ let inMinSigningTier = false;
965
+ const result = {};
966
+ let found = false;
967
+
968
+ for (const raw of lines) {
969
+ const line = raw.replace(/#.*$/, '').trimEnd();
970
+ if (!line.trim()) continue;
971
+
972
+ if (line === 'minSigningTier:') {
973
+ inMinSigningTier = true;
974
+ found = true;
975
+ continue;
976
+ }
977
+
978
+ if (inMinSigningTier) {
979
+ // Indented key: value line under minSigningTier
980
+ const m = line.match(/^\s{2,}([A-Za-z_][A-Za-z0-9_]*):\s*(\d+)\s*$/);
981
+ if (m) {
982
+ const tier = parseInt(m[2], 10);
983
+ if (tier < 1 || tier > 3) {
984
+ throw new Error(`governance.yml: minSigningTier.${m[1]} must be 1, 2, or 3, got ${tier}`);
985
+ }
986
+ result[m[1]] = tier;
987
+ continue;
988
+ }
989
+ // Non-indented line means we've left the block
990
+ inMinSigningTier = false;
991
+ }
992
+ }
993
+
994
+ return { minSigningTier: found ? result : null };
995
+ }
996
+
997
+ /**
998
+ * Enforce `minSigningTier` for a given district class.
999
+ *
1000
+ * Returns `{ ok: true }` when the signer's tier meets or exceeds the minimum.
1001
+ * Returns `{ ok: false, required, actual, reason }` when the signer's tier is
1002
+ * below the minimum — the evaluation MUST outright reject (not just flag) the
1003
+ * signing operation in this case.
1004
+ *
1005
+ * @param {{ minSigningTier: Record<string,number>|null }} governanceConfig
1006
+ * @param {string} districtClass e.g. "core", "regulated", "default"
1007
+ * @param {number} actualTier TIER.TPM | TIER.WEBAUTHN | TIER.TOTP
1008
+ * @returns {{ ok: boolean, required: number, actual: number, reason?: string }}
1009
+ */
1010
+ export function enforceMinSigningTier(governanceConfig, districtClass, actualTier) {
1011
+ const cfg = governanceConfig?.minSigningTier;
1012
+ if (!cfg) {
1013
+ // No minSigningTier configured — off by default, all tiers accepted
1014
+ return { ok: true, required: TIER.TOTP, actual: actualTier };
1015
+ }
1016
+
1017
+ const required = cfg[districtClass] ?? cfg['default'] ?? TIER.TOTP;
1018
+
1019
+ // Lower tier number = stronger security (Tier 1 is strongest)
1020
+ if (actualTier <= required) {
1021
+ return { ok: true, required, actual: actualTier };
1022
+ }
1023
+
1024
+ return {
1025
+ ok: false,
1026
+ required,
1027
+ actual: actualTier,
1028
+ reason: `district class "${districtClass}" requires signing tier ${required} (${TIER_LABELS[required]}); actual tier is ${actualTier} (${TIER_LABELS[actualTier]}) — rejected outright`,
1029
+ };
1030
+ }
1031
+
1032
+ // ---------------------------------------------------------------------------
1033
+ // Mixed-tier quorum accounting
1034
+ // ---------------------------------------------------------------------------
1035
+
1036
+ /**
1037
+ * Record a signer's tier in a quorum manifest ledger entry.
1038
+ *
1039
+ * Returns a new `signerTiers` array with the new entry appended.
1040
+ *
1041
+ * @param {Array<{signerId: string, tier: number}>} signerTiers Existing signer records.
1042
+ * @param {string} signerId
1043
+ * @param {number} tier TIER.TPM | TIER.WEBAUTHN | TIER.TOTP
1044
+ * @returns {Array<{signerId: string, tier: number}>}
1045
+ */
1046
+ export function recordSignerTier(signerTiers, signerId, tier) {
1047
+ if (!TIER_LABELS[tier]) {
1048
+ throw new TypeError(`recordSignerTier: invalid tier ${JSON.stringify(tier)}`);
1049
+ }
1050
+ return [...signerTiers, { signerId, tier }];
1051
+ }
1052
+
1053
+ /**
1054
+ * Compute the quorum summary tier for a set of signers.
1055
+ *
1056
+ * TIER.* numbers run strongest-to-weakest (1 = TPM, 2 = WebAuthn, 3 = TOTP),
1057
+ * so the quorum's overall tier is the WEAKEST signer, i.e. the numerically
1058
+ * HIGHEST tier value — `Math.max()` over the tier numbers. This is
1059
+ * intentional: a quorum is only as strong as its weakest signer, and it must
1060
+ * never be reported as stronger than that just because other signers used a
1061
+ * better tier. (The pre-rework module's own comments here said "MINIMUM
1062
+ * across all signers" while the code correctly did `Math.max()` — that
1063
+ * comment was simply wrong relative to the numeric encoding and has been
1064
+ * corrected in place; the behavior is unchanged.)
1065
+ *
1066
+ * @param {Array<{signerId: string, tier: number}>} signerTiers
1067
+ * @returns {{ summaryTier: number, label: string, signerCount: number }}
1068
+ */
1069
+ export function quorumSummaryTier(signerTiers) {
1070
+ if (!Array.isArray(signerTiers) || signerTiers.length === 0) {
1071
+ throw new TypeError('quorumSummaryTier: signerTiers must be a non-empty array');
1072
+ }
1073
+ // Weakest signer defines the quorum. Since lower TIER.* numbers are
1074
+ // stronger, the weakest signer has the numerically highest tier value —
1075
+ // Math.max() over the tier numbers is correct.
1076
+ const summaryTier = Math.max(...signerTiers.map(s => s.tier));
1077
+ return {
1078
+ summaryTier,
1079
+ label: TIER_LABELS[summaryTier],
1080
+ signerCount: signerTiers.length,
1081
+ };
1082
+ }
1083
+
1084
+ // ---------------------------------------------------------------------------
1085
+ // Downgrade-attack protection: tier-checkpoint hash
1086
+ // ---------------------------------------------------------------------------
1087
+
1088
+ /**
1089
+ * Build a tier-checkpoint object that can be fed into signManifest (F0.2) so
1090
+ * the tier-detection result and minSigningTier setting cannot be silently
1091
+ * rolled back (e.g. stripping the TPM detection or lowering the minimum tier
1092
+ * claim) without invalidating the manifest signature.
1093
+ *
1094
+ * This object MUST be included in the signed manifest payload for that
1095
+ * protection to apply. Use {@link signTierCheckpoint} to produce a manifest
1096
+ * signed by the key store, and {@link verifySignedTierCheckpoint} to verify
1097
+ * it before trusting the claimed tier.
1098
+ *
1099
+ * @param {number} detectedTier Tier from detectTier1Key()
1100
+ * @param {number} minSigningTier Currently active minimum
1101
+ * @param {string} districtClass District class in scope
1102
+ * @returns {{
1103
+ * f01_detected_tier: number,
1104
+ * f01_min_signing_tier: number,
1105
+ * f01_district_class: string,
1106
+ * f01_checkpoint_hash: string, // SHA-256 of the above three fields
1107
+ * }}
1108
+ */
1109
+ export function buildTierCheckpoint(detectedTier, minSigningTier, districtClass) {
1110
+ if (!TIER_LABELS[detectedTier]) {
1111
+ throw new TypeError(`buildTierCheckpoint: invalid detectedTier ${JSON.stringify(detectedTier)}`);
1112
+ }
1113
+ if (typeof minSigningTier !== 'number' || minSigningTier < 1 || minSigningTier > 3) {
1114
+ throw new TypeError(`buildTierCheckpoint: minSigningTier must be 1–3, got ${JSON.stringify(minSigningTier)}`);
1115
+ }
1116
+
1117
+ // Canonical JSON over the three fields (keys alphabetically sorted)
1118
+ const canonical = JSON.stringify({
1119
+ district_class: districtClass,
1120
+ detected_tier: detectedTier,
1121
+ min_signing_tier: minSigningTier,
1122
+ });
1123
+
1124
+ const checkpointHash = createHash('sha256').update(canonical).digest('hex');
1125
+
1126
+ return {
1127
+ f01_detected_tier: detectedTier,
1128
+ f01_min_signing_tier: minSigningTier,
1129
+ f01_district_class: districtClass,
1130
+ f01_checkpoint_hash: checkpointHash,
1131
+ };
1132
+ }
1133
+
1134
+ /**
1135
+ * Verify a tier checkpoint extracted from a signed manifest.
1136
+ *
1137
+ * Returns `{ ok: true }` when the checkpoint hash matches the recomputed value.
1138
+ * Returns `{ ok: false, reason }` otherwise.
1139
+ *
1140
+ * @param {{ f01_detected_tier, f01_min_signing_tier, f01_district_class, f01_checkpoint_hash }} checkpoint
1141
+ * @returns {{ ok: boolean, reason?: string }}
1142
+ */
1143
+ export function verifyTierCheckpoint(checkpoint) {
1144
+ const { f01_detected_tier, f01_min_signing_tier, f01_district_class, f01_checkpoint_hash } = checkpoint ?? {};
1145
+
1146
+ if (typeof f01_checkpoint_hash !== 'string' || f01_checkpoint_hash.length !== 64) {
1147
+ return { ok: false, reason: 'missing or malformed f01_checkpoint_hash' };
1148
+ }
1149
+
1150
+ const canonical = JSON.stringify({
1151
+ district_class: f01_district_class,
1152
+ detected_tier: f01_detected_tier,
1153
+ min_signing_tier: f01_min_signing_tier,
1154
+ });
1155
+
1156
+ const expected = createHash('sha256').update(canonical).digest('hex');
1157
+
1158
+ const eBuf = Buffer.from(expected, 'hex');
1159
+ const aBuf = Buffer.from(f01_checkpoint_hash, 'hex');
1160
+ let match = false;
1161
+ try { match = timingSafeEqual(eBuf, aBuf); } catch { match = false; }
1162
+
1163
+ return match ? { ok: true } : { ok: false, reason: 'checkpoint hash mismatch — possible downgrade attack' };
1164
+ }
1165
+
1166
+ /**
1167
+ * Sign a tier-checkpoint by wrapping it in a manifest and calling
1168
+ * {@link signManifest} from the key store.
1169
+ *
1170
+ * Returns a signed manifest object (all checkpoint fields + `key_id` + `sig`)
1171
+ * that can be persisted or transmitted. Consumers MUST call
1172
+ * {@link verifySignedTierCheckpoint} before trusting the claimed tier.
1173
+ *
1174
+ * Throws if there is no active key in the store (same contract as
1175
+ * {@link signManifest}).
1176
+ *
1177
+ * @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks Key store
1178
+ * @param {number} detectedTier Tier from {@link detectTier1Key}
1179
+ * @param {number} minSigningTier Currently active minimum tier
1180
+ * @param {string} districtClass District class in scope
1181
+ * @returns {object} Signed manifest: tier-checkpoint fields + `key_id` + `sig`
1182
+ */
1183
+ export function signTierCheckpoint(ks, detectedTier, minSigningTier, districtClass) {
1184
+ const checkpoint = buildTierCheckpoint(detectedTier, minSigningTier, districtClass);
1185
+ return signManifest(ks, checkpoint);
1186
+ }
1187
+
1188
+ /**
1189
+ * Verify a signed tier-checkpoint produced by {@link signTierCheckpoint}.
1190
+ *
1191
+ * Performs two independent checks in order:
1192
+ * 1. {@link verifyManifest} — confirms the HMAC-SHA256 signature is valid and
1193
+ * the signing key is present and not revoked in the store.
1194
+ * 2. {@link verifyTierCheckpoint} — confirms the embedded SHA-256 checkpoint
1195
+ * hash is internally consistent (downgrade-attack guard).
1196
+ *
1197
+ * Returns `{ ok: true }` only when both checks pass.
1198
+ * Returns `{ ok: false, reason }` for any failure, with the `reason` from
1199
+ * whichever check failed first.
1200
+ *
1201
+ * Never throws on malformed input (inherits that contract from
1202
+ * {@link verifyManifest}).
1203
+ *
1204
+ * @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
1205
+ * @param {object} signedCheckpoint Value returned by {@link signTierCheckpoint}
1206
+ * @returns {{ ok: boolean, reason?: string }}
1207
+ */
1208
+ export function verifySignedTierCheckpoint(ks, signedCheckpoint) {
1209
+ const manifestResult = verifyManifest(ks, signedCheckpoint);
1210
+ if (!manifestResult.ok) return manifestResult;
1211
+
1212
+ const checkpointResult = verifyTierCheckpoint(signedCheckpoint);
1213
+ if (!checkpointResult.ok) return checkpointResult;
1214
+
1215
+ return { ok: true };
1216
+ }
1217
+
1218
+ // ---------------------------------------------------------------------------
1219
+ // Unforgeable ledger entry assembly
1220
+ // ---------------------------------------------------------------------------
1221
+
1222
+ /**
1223
+ * Assemble an EXCEPTION_GRANTED ledger entry with an embedded assertion hash.
1224
+ *
1225
+ * The `assertionHash` field is the SHA-256 of the raw WebAuthn assertion bytes
1226
+ * computed by {@link verifyWebAuthnAssertion}. It cannot be self-reported by
1227
+ * the tool — the hash must come from a verified assertion, not from a claim.
1228
+ *
1229
+ * This function intentionally performs no authorization/quorum check of its
1230
+ * own — that is out of scope here and belongs at the real ledger-write call
1231
+ * site (not yet wired in this diff; tracked separately, same pattern as
1232
+ * SAT-561 for the append-only checkpoint module).
1233
+ *
1234
+ * @param {object} opts
1235
+ * @param {string} opts.signerId Authoritative agent/signer id
1236
+ * @param {number} opts.tier TIER.* constant of this signer
1237
+ * @param {string} opts.assertionHash Hex SHA-256 from verifyWebAuthnAssertion
1238
+ * @param {string} opts.districtClass District class in scope
1239
+ * @param {string} opts.reason Human-readable reason for the exception
1240
+ * @param {object} opts.tierCheckpoint Output of buildTierCheckpoint
1241
+ * @returns {{ event: string, signer_id: string, tier: number, tier_label: string,
1242
+ * assertion_hash: string, district_class: string, reason: string,
1243
+ * tier_checkpoint: object }}
1244
+ */
1245
+ export function assembleLedgerEntry(opts) {
1246
+ const { signerId, tier, assertionHash, districtClass, reason, tierCheckpoint } = opts;
1247
+
1248
+ if (!TIER_LABELS[tier]) {
1249
+ throw new TypeError(`assembleLedgerEntry: invalid tier ${JSON.stringify(tier)}`);
1250
+ }
1251
+ if (typeof assertionHash !== 'string' || !/^[0-9a-f]{64}$/.test(assertionHash)) {
1252
+ throw new TypeError('assembleLedgerEntry: assertionHash must be a 64-char hex string');
1253
+ }
1254
+
1255
+ return {
1256
+ event: 'EXCEPTION_GRANTED',
1257
+ signer_id: signerId,
1258
+ tier,
1259
+ tier_label: TIER_LABELS[tier],
1260
+ assertion_hash: assertionHash,
1261
+ district_class: districtClass,
1262
+ reason: reason ?? '',
1263
+ tier_checkpoint: tierCheckpoint,
1264
+ };
1265
+ }