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.
- package/CHANGELOG.md +149 -93
- package/CREDITS.md +14 -14
- package/LICENSE.md +76 -76
- package/LIMITATIONS.md +33 -23
- package/PRIVACY.md +30 -30
- package/README.md +170 -117
- package/SECURITY.md +78 -46
- package/action/post-comment.mjs +94 -89
- package/action.yml +62 -62
- package/bin/knosky.mjs +279 -105
- package/core/CONTRACT.md +70 -70
- package/core/append-only-checkpoint.mjs +215 -0
- package/core/audit-writer.mjs +317 -0
- package/core/benchmark-results.mjs +225 -225
- package/core/bundle.mjs +178 -178
- package/core/churn.mjs +23 -23
- package/core/ci.mjs +268 -268
- package/core/comparison.mjs +189 -189
- package/core/config.mjs +189 -189
- package/core/constants.mjs +13 -13
- package/core/contract.mjs +123 -123
- package/core/cross-repo.mjs +111 -111
- package/core/decision-codes.mjs +92 -0
- package/core/destination.mjs +161 -161
- package/core/district-classification.mjs +111 -0
- package/core/doctor-scorecard.mjs +369 -0
- package/core/domain-store.mjs +347 -0
- package/core/edges.mjs +43 -43
- package/core/escalate.mjs +68 -68
- package/core/freshness.mjs +198 -194
- package/core/fs-indexer.mjs +218 -218
- package/core/key-store.mjs +348 -348
- package/core/layout.mjs +46 -46
- package/core/ledger.mjs +176 -141
- package/core/local-ipc-identity.mjs +500 -0
- package/core/lod.mjs +155 -155
- package/core/mode-b.mjs +410 -0
- package/core/multi-model-benchmark.mjs +405 -405
- package/core/net-lockdown.mjs +421 -0
- package/core/onboarding.mjs +223 -223
- package/core/operator-auth.mjs +317 -0
- package/core/overlays.mjs +45 -45
- package/core/policy-lattice.mjs +142 -0
- package/core/pr-comment.mjs +198 -198
- package/core/protocol-spec.mjs +460 -460
- package/core/provenance.mjs +320 -0
- package/core/retrieve.mjs +63 -63
- package/core/route.mjs +304 -304
- package/core/schema.mjs +275 -275
- package/core/signing-tiers.mjs +1265 -0
- package/core/swarm-bench.mjs +106 -0
- package/core/swarm-coordinator.mjs +867 -0
- package/core/trust-root-rekey.mjs +410 -0
- package/mcp/server.mjs +264 -108
- package/package.json +56 -46
- package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
- package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
- package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
- package/renderer/art/kenney/sheet_allCars.xml +545 -545
- package/renderer/build-rich.mjs +43 -43
- package/renderer/city.template.html +808 -808
- package/ssot/decision-codes.json +133 -0
- package/ssot/ladder-l0-l3.md +232 -0
- 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
|
+
}
|