nsauditor-ai 0.1.69 → 0.1.70
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/data/license-revocations.json +7 -0
- package/package.json +2 -1
- package/utils/license.mjs +385 -1
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"issued_at": "2026-05-22T00:00:00.000Z",
|
|
4
|
+
"revoked": [],
|
|
5
|
+
"signature": "",
|
|
6
|
+
"_comment": "Initial CE 0.1.70 empty blocklist. The license-manager service signs this envelope with the production ES256 private key (same key as JWT signing) and ships updates via CE patch releases. An empty/unsigned blocklist is a valid initial state — the verifier returns [] on signature failure (fail-open at the blocklist layer; tampering provides no escalation since the file lives inside the npm package and supply-chain attackers already have full privilege)."
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nsauditor-ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.70",
|
|
4
4
|
"description": "Modular AI-assisted network security audit platform — Community Edition",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"bin/",
|
|
18
18
|
"config/",
|
|
19
|
+
"data/",
|
|
19
20
|
"docs/EULA-nsauditor-ai.md",
|
|
20
21
|
"plugins/",
|
|
21
22
|
"utils/",
|
package/utils/license.mjs
CHANGED
|
@@ -5,11 +5,32 @@
|
|
|
5
5
|
// KEY ROTATION: If the private key is compromised, generate a new EC P-256 key
|
|
6
6
|
// pair, update PUBLIC_KEY_PEM below, and ship a CE update. All existing JWTs
|
|
7
7
|
// become invalid. See license-manager docs/architecture.md for full procedure.
|
|
8
|
+
//
|
|
9
|
+
// CE 0.1.70 (EE 0.9.1 paired) — air-gap operational hardening per external
|
|
10
|
+
// audit 2026-05-22:
|
|
11
|
+
// • D-HIGH-1: per-host licenseId replay defense (persisted on first
|
|
12
|
+
// activation; subsequent loads with a different licenseId fail-closed
|
|
13
|
+
// to CE). Closes the seat-cloning class — a `seats:N` token cannot be
|
|
14
|
+
// installed on N+1 machines without operator-noticeable rejection.
|
|
15
|
+
// • D-HIGH-2: signed revocation blocklist baked into the package
|
|
16
|
+
// (`data/license-revocations.json`). The license-manager service
|
|
17
|
+
// signs the envelope with the production ES256 key; this verifier
|
|
18
|
+
// validates the signature before honoring the revocation list.
|
|
19
|
+
// Vendors can revoke individual licenses via CE patch bump without
|
|
20
|
+
// PUBLIC_KEY_PEM rotation (which would invalidate ALL licenses).
|
|
21
|
+
// • D-HIGH-3: monotonic-clock anchor — persisted `last_seen_unix_ts`
|
|
22
|
+
// is checked on each load; a wall-clock rewind beyond CLOCK_ROLLBACK
|
|
23
|
+
// _TOLERANCE_S fails-closed to CE. Defeats `faketime`-style attacks
|
|
24
|
+
// against the JWT `exp` claim in air-gap deployments where NTP cannot
|
|
25
|
+
// be consulted at verification time.
|
|
8
26
|
|
|
9
27
|
import { jwtVerify, importSPKI } from 'jose';
|
|
10
28
|
import { promises as fsp } from 'node:fs';
|
|
29
|
+
import * as fsSync from 'node:fs';
|
|
11
30
|
import { homedir, platform } from 'node:os';
|
|
12
31
|
import { dirname, join } from 'node:path';
|
|
32
|
+
import { createPublicKey, createVerify } from 'node:crypto';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
13
34
|
import dotenv from 'dotenv';
|
|
14
35
|
import { keychainGet, keychainSet, resolveSecret } from './keychain.mjs';
|
|
15
36
|
|
|
@@ -25,6 +46,69 @@ u8cSY2dN6mfevYnOydP0SXLHCfWHr+SlpZpA2BiU6GKEk+QdIlWXOgGZsA==
|
|
|
25
46
|
// CE (Community Edition) is free and always works without a license.
|
|
26
47
|
let _verifiedTier = null;
|
|
27
48
|
|
|
49
|
+
// ── CE 0.1.70 — Air-gap hardening constants ──────────────────────────────────
|
|
50
|
+
//
|
|
51
|
+
// Wall-clock rewind tolerance: a 5-minute slack covers NTP step adjustments,
|
|
52
|
+
// DST transitions on misconfigured systems, and brief clock skew between
|
|
53
|
+
// suspend/resume cycles. Beyond this, treat as clock-rollback attempt.
|
|
54
|
+
// Tunable via NSAUDITOR_LICENSE_CLOCK_TOLERANCE_S for support-only use.
|
|
55
|
+
const CLOCK_ROLLBACK_TOLERANCE_S = 5 * 60;
|
|
56
|
+
|
|
57
|
+
// Persisted state file basename. Lives at:
|
|
58
|
+
// • Linux: $XDG_STATE_HOME/nsauditor/license-state.json
|
|
59
|
+
// (falls back to ~/.local/state/nsauditor/license-state.json,
|
|
60
|
+
// then ~/.nsauditor/license-state.json)
|
|
61
|
+
// • Windows: %LOCALAPPDATA%\nsauditor\license-state.json
|
|
62
|
+
// • macOS: ~/.nsauditor/license-state.json (licenseId ALSO persisted in
|
|
63
|
+
// Keychain via service=nsauditor-ai account=NSAUDITOR_LICENSE_ID;
|
|
64
|
+
// Keychain wins on read when both exist).
|
|
65
|
+
const LICENSE_STATE_BASENAME = 'license-state.json';
|
|
66
|
+
const LICENSE_ID_KEYCHAIN_ACCOUNT = 'NSAUDITOR_LICENSE_ID';
|
|
67
|
+
|
|
68
|
+
// Shipped revocation blocklist. ES256-signed envelope at this path; updated
|
|
69
|
+
// by the license-manager service and shipped in each CE patch via the
|
|
70
|
+
// package.json `files` array. Verifier reads, validates signature against
|
|
71
|
+
// the same PUBLIC_KEY_PEM above, and returns the `revoked` array.
|
|
72
|
+
const REVOCATION_BLOCKLIST_PATH = fileURLToPath(
|
|
73
|
+
new URL('../data/license-revocations.json', import.meta.url),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Escape-hatch env vars — DOCUMENTED AS SUPPORT-ONLY. Operators with edge-
|
|
77
|
+
// case deployment requirements (hardware migration without vendor support,
|
|
78
|
+
// emergency clock rollback for a stuck system, etc.) can disable individual
|
|
79
|
+
// defenses. Accepted disable values per `_envDisabled` are case-insensitive:
|
|
80
|
+
// `0` / `false` / `no` / `off` / `disabled`. Default ENABLED for all three.
|
|
81
|
+
//
|
|
82
|
+
// Persistent audit trail (operator-visible record of which defenses were
|
|
83
|
+
// disabled across loadLicense calls) is deferred to CE 0.1.71 — see
|
|
84
|
+
// `tasks/external-audit-findings-2026-05-22.md` next-cycle item. For 0.9.1
|
|
85
|
+
// the disables are runtime-only; operators concerned about defense hygiene
|
|
86
|
+
// should grep their env for these names at deployment time.
|
|
87
|
+
const ENV_REPLAY_DEFENSE = 'NSAUDITOR_LICENSE_ID_REPLAY_DEFENSE';
|
|
88
|
+
const ENV_REVOCATION_CHECK = 'NSAUDITOR_LICENSE_REVOCATION_CHECK';
|
|
89
|
+
const ENV_CLOCK_ANCHOR = 'NSAUDITOR_LICENSE_CLOCK_ANCHOR';
|
|
90
|
+
|
|
91
|
+
// P1.D R2-HIGH fold: accept all common falsy notations + normalize case
|
|
92
|
+
// per [[aws_string_case_normalization]] discipline. Operators who set
|
|
93
|
+
// `=False` (capital F) or `=off` or `=no` previously left the defense
|
|
94
|
+
// enabled — silently broke the support-only docstring promise.
|
|
95
|
+
const _ENV_DISABLE_VALUES = new Set(['0', 'false', 'no', 'off', 'disabled']);
|
|
96
|
+
function _envDisabled(name) {
|
|
97
|
+
const raw = process.env[name];
|
|
98
|
+
if (raw === undefined || raw === null) return false;
|
|
99
|
+
return _ENV_DISABLE_VALUES.has(String(raw).trim().toLowerCase());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _resolveClockToleranceS() {
|
|
103
|
+
const v = parseInt(process.env.NSAUDITOR_LICENSE_CLOCK_TOLERANCE_S ?? '', 10);
|
|
104
|
+
if (!Number.isFinite(v) || v < 0) return CLOCK_ROLLBACK_TOLERANCE_S;
|
|
105
|
+
// P1.D R2-MED fold: cap at 24h. Anything beyond a day is not a "clock
|
|
106
|
+
// skew" — it's a backdoor disable of D-HIGH-3 without going through the
|
|
107
|
+
// documented NSAUDITOR_LICENSE_CLOCK_ANCHOR=0 escape hatch. Cap surfaces
|
|
108
|
+
// the abuse via the more-visible explicit env var.
|
|
109
|
+
return Math.min(v, 24 * 60 * 60);
|
|
110
|
+
}
|
|
111
|
+
|
|
28
112
|
/**
|
|
29
113
|
* Synchronous tier detection.
|
|
30
114
|
* Returns 'pro' | 'enterprise' | 'ce'.
|
|
@@ -269,6 +353,228 @@ export function mergeLicenseIntoEnvFile(existingContent, key) {
|
|
|
269
353
|
return `${existingContent}${sep}${newLine}\n`;
|
|
270
354
|
}
|
|
271
355
|
|
|
356
|
+
// ── CE 0.1.70 — Air-gap hardening helpers (D-HIGH-1, D-HIGH-2, D-HIGH-3) ─────
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Resolve the platform-appropriate path to the persisted license-state file.
|
|
360
|
+
* Tracks `licenseId` (replay defense) and `lastSeenUnixTs` (clock anchor).
|
|
361
|
+
*
|
|
362
|
+
* The state file is mode 0600 (POSIX) or profile-ACL-restricted (Windows).
|
|
363
|
+
* macOS additionally persists `licenseId` to the Keychain via the
|
|
364
|
+
* keychainGet/keychainSet helpers; the state file is the source of truth
|
|
365
|
+
* for `lastSeenUnixTs` on every platform.
|
|
366
|
+
*
|
|
367
|
+
* Exported for test inspection.
|
|
368
|
+
* @internal
|
|
369
|
+
*/
|
|
370
|
+
export function _getLicenseStateFilePath(opts = {}) {
|
|
371
|
+
if (opts._stateFile) return opts._stateFile;
|
|
372
|
+
// Env-var override (test infra + advanced operators who want a custom path).
|
|
373
|
+
// Honors the same NSAUDITOR_LICENSE_STATE_FILE shape used by existing
|
|
374
|
+
// tests to redirect to a temp directory during test runs.
|
|
375
|
+
if (process.env.NSAUDITOR_LICENSE_STATE_FILE) {
|
|
376
|
+
return process.env.NSAUDITOR_LICENSE_STATE_FILE;
|
|
377
|
+
}
|
|
378
|
+
const plat = opts._platform ?? platform();
|
|
379
|
+
if (plat === 'win32') {
|
|
380
|
+
const base = process.env.LOCALAPPDATA
|
|
381
|
+
?? join(homedir(), 'AppData', 'Local');
|
|
382
|
+
return join(base, 'nsauditor', LICENSE_STATE_BASENAME);
|
|
383
|
+
}
|
|
384
|
+
// Linux: prefer XDG_STATE_HOME; fall back to ~/.local/state; finally
|
|
385
|
+
// co-locate with the existing ~/.nsauditor/.env for operators who
|
|
386
|
+
// already know that directory.
|
|
387
|
+
const xdgState = process.env.XDG_STATE_HOME;
|
|
388
|
+
if (xdgState) {
|
|
389
|
+
return join(xdgState, 'nsauditor', LICENSE_STATE_BASENAME);
|
|
390
|
+
}
|
|
391
|
+
return join(homedir(), '.nsauditor', LICENSE_STATE_BASENAME);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Read the persisted license-state file. Returns `{ licenseId, lastSeenUnixTs }`
|
|
396
|
+
* or `null` when the file does not exist or cannot be parsed.
|
|
397
|
+
*
|
|
398
|
+
* On macOS we additionally consult the Keychain for `licenseId`; when both
|
|
399
|
+
* exist and disagree, Keychain wins (it's the more tamper-resistant store).
|
|
400
|
+
* @internal
|
|
401
|
+
*/
|
|
402
|
+
export async function _readLicenseState(opts = {}) {
|
|
403
|
+
const statePath = _getLicenseStateFilePath(opts);
|
|
404
|
+
let state = null;
|
|
405
|
+
try {
|
|
406
|
+
const text = await fsp.readFile(statePath, 'utf8');
|
|
407
|
+
const parsed = JSON.parse(text);
|
|
408
|
+
if (parsed && typeof parsed === 'object') {
|
|
409
|
+
state = {
|
|
410
|
+
licenseId: typeof parsed.licenseId === 'string' ? parsed.licenseId : null,
|
|
411
|
+
lastSeenUnixTs: Number.isFinite(parsed.lastSeenUnixTs)
|
|
412
|
+
? parsed.lastSeenUnixTs
|
|
413
|
+
: null,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
} catch { /* missing or malformed — treat as no state */ }
|
|
417
|
+
|
|
418
|
+
const plat = opts._platform ?? platform();
|
|
419
|
+
// Skip Keychain when the state-file env override is set: that's the
|
|
420
|
+
// test-mode signal (no test should write to the operator's real
|
|
421
|
+
// Keychain, and no test should read from it either — bleeds state
|
|
422
|
+
// across test cases via a side channel).
|
|
423
|
+
if (plat === 'darwin' && !process.env.NSAUDITOR_LICENSE_STATE_FILE) {
|
|
424
|
+
const kget = opts._keychainGet ?? keychainGet;
|
|
425
|
+
try {
|
|
426
|
+
const kcId = await kget(LICENSE_ID_KEYCHAIN_ACCOUNT);
|
|
427
|
+
if (kcId) {
|
|
428
|
+
state = state ?? { licenseId: null, lastSeenUnixTs: null };
|
|
429
|
+
state.licenseId = kcId; // Keychain wins
|
|
430
|
+
}
|
|
431
|
+
} catch { /* keychain unavailable — keep state-file value */ }
|
|
432
|
+
}
|
|
433
|
+
return state;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Persist license state (licenseId + lastSeenUnixTs) atomically.
|
|
438
|
+
* Writes to `${path}.tmp` then renames; survives partial-crash without
|
|
439
|
+
* leaving an in-flight half-written state file.
|
|
440
|
+
*
|
|
441
|
+
* On macOS the licenseId is ALSO written to the Keychain (best-effort;
|
|
442
|
+
* a Keychain failure does not abort the file write since the file is the
|
|
443
|
+
* cross-platform fallback path).
|
|
444
|
+
*
|
|
445
|
+
* @internal
|
|
446
|
+
*/
|
|
447
|
+
export async function _writeLicenseState(state, opts = {}) {
|
|
448
|
+
const statePath = _getLicenseStateFilePath(opts);
|
|
449
|
+
const dir = dirname(statePath);
|
|
450
|
+
await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
451
|
+
const body = JSON.stringify({
|
|
452
|
+
schema_version: 1,
|
|
453
|
+
licenseId: state.licenseId ?? null,
|
|
454
|
+
lastSeenUnixTs: Number.isFinite(state.lastSeenUnixTs) ? state.lastSeenUnixTs : null,
|
|
455
|
+
}, null, 2);
|
|
456
|
+
const tmp = `${statePath}.tmp`;
|
|
457
|
+
await fsp.writeFile(tmp, body, { mode: 0o600 });
|
|
458
|
+
await fsp.rename(tmp, statePath);
|
|
459
|
+
// Re-chmod on overwrite (writeFile honors mode only on CREATE).
|
|
460
|
+
const plat = opts._platform ?? platform();
|
|
461
|
+
if (plat !== 'win32') {
|
|
462
|
+
try { await fsp.chmod(statePath, 0o600); } catch { /* not fatal */ }
|
|
463
|
+
}
|
|
464
|
+
// Skip Keychain when the state-file env override is set: matches the
|
|
465
|
+
// read-side test-mode-skip in _readLicenseState so the test suite's
|
|
466
|
+
// per-case file-path isolation isn't bypassed by the macOS Keychain
|
|
467
|
+
// side channel.
|
|
468
|
+
if (plat === 'darwin' && state.licenseId && !process.env.NSAUDITOR_LICENSE_STATE_FILE) {
|
|
469
|
+
const kset = opts._keychainSet ?? keychainSet;
|
|
470
|
+
try { await kset(LICENSE_ID_KEYCHAIN_ACCOUNT, state.licenseId); } catch { /* not fatal */ }
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Load and verify the shipped revocation blocklist. Returns the list of
|
|
476
|
+
* revoked licenseId strings on success, or `[]` on ANY failure (file
|
|
477
|
+
* missing, malformed JSON, invalid signature, unsupported schema version).
|
|
478
|
+
*
|
|
479
|
+
* Fail-open posture on tampering is deliberate: the blocklist lives in the
|
|
480
|
+
* package's `data/` directory, which is only writable by parties who
|
|
481
|
+
* already have the privilege to swap binaries (i.e., supply-chain
|
|
482
|
+
* attackers); for those, tampering provides no additional escalation. A
|
|
483
|
+
* fail-closed posture (refuse ALL licenses when the blocklist is corrupt)
|
|
484
|
+
* would brick legitimate customers via a single bad CE patch.
|
|
485
|
+
*
|
|
486
|
+
* Test seam: `opts._blocklistData` short-circuits the file load and
|
|
487
|
+
* returns a pre-loaded array. `opts._publicKeyPem` overrides the embedded
|
|
488
|
+
* production key for signature-verification unit tests.
|
|
489
|
+
*
|
|
490
|
+
* @internal
|
|
491
|
+
*/
|
|
492
|
+
export async function _loadAndVerifyBlocklist(opts = {}) {
|
|
493
|
+
if (Array.isArray(opts._blocklistData)) {
|
|
494
|
+
return opts._blocklistData;
|
|
495
|
+
}
|
|
496
|
+
// Env-var override mirrors the state-file env override pattern; lets
|
|
497
|
+
// existing tests redirect to a temp file without touching loadLicense
|
|
498
|
+
// call sites.
|
|
499
|
+
const path = opts._blocklistPath
|
|
500
|
+
?? process.env.NSAUDITOR_LICENSE_REVOCATIONS_FILE
|
|
501
|
+
?? REVOCATION_BLOCKLIST_PATH;
|
|
502
|
+
let raw;
|
|
503
|
+
try {
|
|
504
|
+
raw = await fsp.readFile(path, 'utf8');
|
|
505
|
+
} catch { return []; }
|
|
506
|
+
|
|
507
|
+
let envelope;
|
|
508
|
+
try {
|
|
509
|
+
envelope = JSON.parse(raw);
|
|
510
|
+
} catch { return []; }
|
|
511
|
+
|
|
512
|
+
if (!envelope || typeof envelope !== 'object') return [];
|
|
513
|
+
if (envelope.schema_version !== 1) return [];
|
|
514
|
+
if (!Array.isArray(envelope.revoked)) return [];
|
|
515
|
+
|
|
516
|
+
const publicKeyPem = opts._publicKeyPem ?? PUBLIC_KEY_PEM;
|
|
517
|
+
if (!_verifyBlocklistSignature(envelope, publicKeyPem)) return [];
|
|
518
|
+
|
|
519
|
+
return envelope.revoked.filter((s) => typeof s === 'string' && s.length > 0);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Verify the ES256 detached signature over the blocklist envelope. The
|
|
524
|
+
* signature covers the canonical JSON of the envelope MINUS the
|
|
525
|
+
* `signature` field itself: `JSON.stringify({schema_version, issued_at, revoked})`
|
|
526
|
+
* with stable key order.
|
|
527
|
+
*
|
|
528
|
+
* Signature is base64-encoded over the SHA-256-of-canonical-JSON,
|
|
529
|
+
* verified against the ES256 (P-256) public key.
|
|
530
|
+
*
|
|
531
|
+
* Returns `true` only on cryptographic match; any error returns `false`
|
|
532
|
+
* (fail-open at the blocklist layer is documented in _loadAndVerifyBlocklist).
|
|
533
|
+
*
|
|
534
|
+
* @internal
|
|
535
|
+
*/
|
|
536
|
+
export function _verifyBlocklistSignature(envelope, publicKeyPem) {
|
|
537
|
+
if (!envelope || typeof envelope.signature !== 'string') return false;
|
|
538
|
+
if (envelope.signature.length === 0) return false;
|
|
539
|
+
const canonical = _canonicalizeBlocklistForSigning(envelope);
|
|
540
|
+
if (!canonical) return false;
|
|
541
|
+
try {
|
|
542
|
+
const sigBuf = Buffer.from(envelope.signature, 'base64');
|
|
543
|
+
const pub = createPublicKey({ key: publicKeyPem, format: 'pem' });
|
|
544
|
+
// P1.D R2-HIGH fold: pin to ES256 (EC P-256) algorithm explicitly.
|
|
545
|
+
// Without this assertion, a future PUBLIC_KEY_PEM rotation to an RSA
|
|
546
|
+
// key would silently enable RS256 forgery of the blocklist (the
|
|
547
|
+
// verify code path is generic over algorithm). Mirror of the audit's
|
|
548
|
+
// own canonical-JWT-verifier hardening pattern.
|
|
549
|
+
if (pub.asymmetricKeyType !== 'ec') return false;
|
|
550
|
+
const details = pub.asymmetricKeyDetails;
|
|
551
|
+
if (!details || details.namedCurve !== 'prime256v1') return false;
|
|
552
|
+
const verifier = createVerify('SHA256');
|
|
553
|
+
verifier.update(canonical, 'utf8');
|
|
554
|
+
verifier.end();
|
|
555
|
+
return verifier.verify({ key: pub, dsaEncoding: 'der' }, sigBuf);
|
|
556
|
+
} catch {
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Canonical signing input: stable key order, no signature field.
|
|
563
|
+
* @internal
|
|
564
|
+
*/
|
|
565
|
+
export function _canonicalizeBlocklistForSigning(envelope) {
|
|
566
|
+
if (!envelope || typeof envelope !== 'object') return null;
|
|
567
|
+
const canonical = {
|
|
568
|
+
schema_version: envelope.schema_version,
|
|
569
|
+
issued_at: envelope.issued_at,
|
|
570
|
+
revoked: Array.isArray(envelope.revoked) ? [...envelope.revoked] : [],
|
|
571
|
+
};
|
|
572
|
+
// Deterministic stringification: sort revoked alphabetically to remove
|
|
573
|
+
// ordering ambiguity from the signing input.
|
|
574
|
+
canonical.revoked.sort();
|
|
575
|
+
return JSON.stringify(canonical);
|
|
576
|
+
}
|
|
577
|
+
|
|
272
578
|
/**
|
|
273
579
|
* Full async JWT verification. Call once at startup.
|
|
274
580
|
* On success, caches verified tier so subsequent getTierFromEnv() calls
|
|
@@ -279,10 +585,12 @@ export function mergeLicenseIntoEnvFile(existingContent, key) {
|
|
|
279
585
|
* @param {string} [keyStr] - License key; if omitted, runs the multi-source
|
|
280
586
|
* resolution chain (env var → Keychain → ~/.nsauditor/.env). See
|
|
281
587
|
* resolveLicenseKey() above.
|
|
588
|
+
* @param {object} [opts] - Test seams (see internal helpers). All options
|
|
589
|
+
* prefixed with `_` are documented as test-only.
|
|
282
590
|
* @returns {Promise<{valid: boolean, tier: string, org?: string, seats?: number,
|
|
283
591
|
* licenseId?: string, capabilities?: string[], expiresAt?: string, reason?: string}>}
|
|
284
592
|
*/
|
|
285
|
-
export async function loadLicense(keyStr) {
|
|
593
|
+
export async function loadLicense(keyStr, opts = {}) {
|
|
286
594
|
// Explicit keyStr argument wins (preserves the existing behavior for
|
|
287
595
|
// callers like the `license --status` subcommand which passes the env
|
|
288
596
|
// var directly). When omitted, run the multi-source resolution chain.
|
|
@@ -326,6 +634,61 @@ export async function loadLicense(keyStr) {
|
|
|
326
634
|
return { valid: false, tier: 'ce', reason: 'tier mismatch' };
|
|
327
635
|
}
|
|
328
636
|
|
|
637
|
+
// ── CE 0.1.70 air-gap hardening: 3 fail-closed checks ────────────────
|
|
638
|
+
// Order matters. (1) Revocation runs first — a revoked license should
|
|
639
|
+
// be rejected even before the per-host bind. (2) Replay defense second
|
|
640
|
+
// — a non-revoked but mismatched-host install is the next-most-likely
|
|
641
|
+
// abuse path. (3) Clock anchor third — only relevant if the prior two
|
|
642
|
+
// pass; a clock-rollback attack against a legitimately installed
|
|
643
|
+
// license is the least-common case. Each check has an env-var escape
|
|
644
|
+
// hatch documented as support-only.
|
|
645
|
+
|
|
646
|
+
const now = (opts._now ?? Date.now)();
|
|
647
|
+
|
|
648
|
+
// (1) D-HIGH-2: signed revocation blocklist.
|
|
649
|
+
if (!_envDisabled(ENV_REVOCATION_CHECK)) {
|
|
650
|
+
try {
|
|
651
|
+
const revoked = await _loadAndVerifyBlocklist(opts);
|
|
652
|
+
if (payload.licenseId && revoked.includes(payload.licenseId)) {
|
|
653
|
+
_verifiedTier = 'ce';
|
|
654
|
+
return { valid: false, tier: 'ce', reason: 'license_revoked' };
|
|
655
|
+
}
|
|
656
|
+
} catch { /* fail-open per _loadAndVerifyBlocklist contract */ }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Read prior persisted state once for checks (2) + (3) + first-activation write.
|
|
660
|
+
let persistedState = null;
|
|
661
|
+
try { persistedState = await _readLicenseState(opts); } catch { /* missing fine */ }
|
|
662
|
+
|
|
663
|
+
// (2) D-HIGH-1: per-host licenseId replay defense.
|
|
664
|
+
if (!_envDisabled(ENV_REPLAY_DEFENSE)) {
|
|
665
|
+
if (persistedState && persistedState.licenseId
|
|
666
|
+
&& payload.licenseId
|
|
667
|
+
&& persistedState.licenseId !== payload.licenseId) {
|
|
668
|
+
_verifiedTier = 'ce';
|
|
669
|
+
return { valid: false, tier: 'ce', reason: 'license_id_mismatch' };
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// (3) D-HIGH-3: monotonic-clock anchor.
|
|
674
|
+
if (!_envDisabled(ENV_CLOCK_ANCHOR)) {
|
|
675
|
+
if (persistedState && Number.isFinite(persistedState.lastSeenUnixTs)) {
|
|
676
|
+
const tolerance = _resolveClockToleranceS() * 1000;
|
|
677
|
+
if (now < persistedState.lastSeenUnixTs - tolerance) {
|
|
678
|
+
_verifiedTier = 'ce';
|
|
679
|
+
return { valid: false, tier: 'ce', reason: 'clock_rollback_detected' };
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// All checks passed — update state (first-activation persist + heartbeat).
|
|
685
|
+
try {
|
|
686
|
+
await _writeLicenseState({
|
|
687
|
+
licenseId: payload.licenseId ?? persistedState?.licenseId ?? null,
|
|
688
|
+
lastSeenUnixTs: now,
|
|
689
|
+
}, opts);
|
|
690
|
+
} catch { /* state write failure must not block a verified license */ }
|
|
691
|
+
|
|
329
692
|
// Cache verified tier for synchronous access
|
|
330
693
|
_verifiedTier = payload.tier;
|
|
331
694
|
|
|
@@ -366,6 +729,11 @@ export async function loadLicense(keyStr) {
|
|
|
366
729
|
/**
|
|
367
730
|
* @internal Test-only. Reset cached verified tier between tests.
|
|
368
731
|
* Disabled in production to prevent accidental tier cache clearing.
|
|
732
|
+
*
|
|
733
|
+
* Clears in-memory state only (`_verifiedTier` + permissive-warned-paths).
|
|
734
|
+
* Does NOT touch the persisted license-state file — tests that need that
|
|
735
|
+
* cleared should either rotate the state-file path via beforeEach OR call
|
|
736
|
+
* `_clearLicenseStateFileForTests()` explicitly.
|
|
369
737
|
*/
|
|
370
738
|
export function _resetCache() {
|
|
371
739
|
if (process.env.NODE_ENV === 'production') {
|
|
@@ -374,3 +742,19 @@ export function _resetCache() {
|
|
|
374
742
|
_verifiedTier = null;
|
|
375
743
|
_permissiveWarnedPaths.clear();
|
|
376
744
|
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* @internal Test-only. Remove the persisted license-state file at the path
|
|
748
|
+
* resolved by the env var or platform default. Refuses to touch the real
|
|
749
|
+
* platform-default path (only honors NSAUDITOR_LICENSE_STATE_FILE override)
|
|
750
|
+
* so a production process that accidentally imports this helper cannot
|
|
751
|
+
* wipe operator state.
|
|
752
|
+
*/
|
|
753
|
+
export function _clearLicenseStateFileForTests() {
|
|
754
|
+
if (process.env.NODE_ENV === 'production') {
|
|
755
|
+
throw new Error('_clearLicenseStateFileForTests is test-only');
|
|
756
|
+
}
|
|
757
|
+
const override = process.env.NSAUDITOR_LICENSE_STATE_FILE;
|
|
758
|
+
if (!override) return; // refuse to touch platform default
|
|
759
|
+
try { fsSync.unlinkSync(override); } catch { /* not fatal — file may not exist */ }
|
|
760
|
+
}
|