kxco-post-quantum 1.6.3 → 1.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 CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0
4
+
5
+ Additive. No existing export changes shape, no wire format moves, and the
6
+ default behaviour of the package is unchanged.
7
+
8
+ **`requireNativeBackend([algorithms])`.** The package picks OpenSSL where the
9
+ runtime has the FIPS 203/204/205 primitives and the JavaScript implementation
10
+ otherwise. Both produce identical bytes, so the fallback is right nearly always.
11
+ It is wrong under a control that says cryptography must execute inside a
12
+ validated module: there the fallback means the control is not in force and
13
+ nothing says so. This throws `ERR_KXCO_PQ_BACKEND` instead, carrying `actual`,
14
+ `missing` and `available` so the failure says which requirement was not met.
15
+
16
+ **`KXCO_PQ_REQUIRE_NATIVE=1`.** The same requirement from the environment,
17
+ because the team under the control is usually not the team calling the library.
18
+ A process that has landed on the JavaScript backend then fails at import rather
19
+ than at its first signature.
20
+
21
+ It asserts, it does not switch. There is still no way to force a backend, for
22
+ the reason there never was: a flag that changed which implementation signed
23
+ would change what a customer's evidence means. This only refuses to continue on
24
+ the wrong one.
25
+
26
+ It also claims only what it can see. Whether the OpenSSL underneath is a
27
+ FIPS-validated module is a property of the operator's build; no library can
28
+ determine that from the inside, and this does not pretend to. It removes the
29
+ silent fallback, which is the part this package owns.
30
+
31
+ Tested on both backends without mocking: CI runs Node 20 and 22, which have no
32
+ native primitives and exercise the refusal, and Node 24, which exercises the
33
+ accept path.
34
+
35
+ ## 1.6.4
36
+
37
+ Documentation. The npm description said "2,103 NIST ACVP vectors and 225
38
+ interop checks, 0 failed", which reads as 2,103 passing. The evidence records
39
+ 2,103 tested, 1,793 passed, 0 failed, 310 skipped, and CONFORMANCE.md says a
40
+ skip is not a pass.
41
+
42
+ Twelve sibling READMEs were corrected for this yesterday and this package's own
43
+ README has been right for months. The description was the one place it
44
+ survived, and a description only changes when the package is published, so it
45
+ needed a release rather than a commit. It is the first thing anyone reads.
46
+
47
+ Also corrects the count of what the downloadable bundle contains, from 1,479
48
+ to the 1,551 actually present in the published v1.6.3 assets.
49
+
3
50
  ## 1.6.3
4
51
 
5
52
  Release plumbing only. No source change.
package/README.md CHANGED
@@ -36,7 +36,7 @@ languages are **JavaScript and C** (OpenSSL 3.5 on Node 24+).
36
36
 
37
37
  **Evidence, not adjectives:**
38
38
 
39
- - [CONFORMANCE.md](./CONFORMANCE.md): NIST ACVP vectors for FIPS 203/204/205: **2,103 vectors, 1,793 passed, 0 failed, 310 skipped**, where every skip is this library refusing a pre-hash weaker than the parameter set and is listed individually with its reason. CONFORMANCE.md says a skip is not a pass, so the headline says so too. Of those, **1,479 vectors are in the downloadable evidence bundle**; the remaining 624 are SLH-DSA signature generation, which signs in seconds per operation and so is reproduced on demand with `node conformance/run-acvp.mjs --set SLH-DSA-sigGen-FIPS205` rather than shipped. The bundle names that gap rather than leaving it to be noticed. Plus a cross-implementation interop matrix against liboqs, Bouncy Castle and two pure-Python implementations (225 checks, 0 failed, both directions, with negative controls), run against both backends. Reproducible: `npm run conformance:acvp`, `npm run conformance:interop`.
39
+ - [CONFORMANCE.md](./CONFORMANCE.md): NIST ACVP vectors for FIPS 203/204/205: **2,103 vectors, 1,793 passed, 0 failed, 310 skipped**, where every skip is this library refusing a pre-hash weaker than the parameter set and is listed individually with its reason. CONFORMANCE.md says a skip is not a pass, so the headline says so too. Of those, **1,551 are in the downloadable evidence bundle**, measured from the published v1.6.3 assets: 855 in `02-conformance-acvp.json`, 624 in `02b-conformance-acvp-fips205.json` and a 72-vector sample of SLH-DSA signature generation in `02d-conformance-acvp-fips205-siggen.json`. Signature generation is sampled rather than shipped whole because it signs in seconds per operation; the full set is reproduced on demand with `node conformance/run-acvp.mjs --set SLH-DSA-sigGen-FIPS205`. The bundle names that gap rather than leaving it to be noticed. Plus a cross-implementation interop matrix against liboqs, Bouncy Castle and two pure-Python implementations (225 checks, 0 failed, both directions, with negative controls), run against both backends. Reproducible: `npm run conformance:acvp`, `npm run conformance:interop`.
40
40
  - [BENCHMARKS.md](./BENCHMARKS.md): per-algorithm latency at p95/p99 on both backends and on x86-64 and arm64, plus memory. Two figures worth designing around: ML-DSA signing keeps a rejection-sampling tail on either backend (5.1x median-to-p99 in JavaScript, 3.5x on OpenSSL), and SLH-DSA-SHA2-192s signs in seconds rather than milliseconds (4.3 s and 1.7 s).
41
41
  - [THREAT-MODEL.md](./THREAT-MODEL.md): what this defends against and what it does not. Read the side-channel section before deciding where a signing key lives.
42
42
  - [MIGRATION.md](./MIGRATION.md): moving an RSA or ECDSA system across, and moving between versions of this package.
@@ -271,6 +271,46 @@ Low-level helpers for the KXCO hybrid webhook pattern: `envelope`, `hmacHex`, `v
271
271
 
272
272
  ---
273
273
 
274
+ ## Requiring the native backend
275
+
276
+ This package picks its implementation at import time: OpenSSL 3.5 where the
277
+ runtime provides the FIPS 203/204/205 primitives, the JavaScript implementation
278
+ otherwise. Both produce identical wire bytes, so falling back is the right
279
+ default and nothing about a signature changes.
280
+
281
+ It is the wrong default in one situation: a deployment under a control that says
282
+ cryptography must execute inside a validated module. There, a silent fallback
283
+ means the control is not in force and nothing says so.
284
+
285
+ ```js
286
+ import { requireNativeBackend } from 'kxco-post-quantum'
287
+
288
+ requireNativeBackend(['ML-DSA-65', 'ML-KEM-768'])
289
+ ```
290
+
291
+ It throws `ERR_KXCO_PQ_BACKEND` if the JavaScript backend is live, or if the
292
+ OpenSSL present cannot provide a parameter set you named. The error carries
293
+ `actual`, `missing` and `available` so a failure says which, rather than only
294
+ that.
295
+
296
+ Operators can enforce it without touching application code, which matters
297
+ because the team under the control is usually not the team calling the library:
298
+
299
+ ```
300
+ KXCO_PQ_REQUIRE_NATIVE=1
301
+ ```
302
+
303
+ Set that and a process which has landed on the JavaScript backend fails at
304
+ import, before its first signature rather than after.
305
+
306
+ **What this does and does not claim.** It asserts that OpenSSL is doing the
307
+ maths. Whether that OpenSSL is a FIPS-validated module is a property of your
308
+ build, not of this package, and no library can see it from the inside. What it
309
+ removes is the silent fallback, which is the part this package is responsible
310
+ for. It is an assertion, never a switch: it cannot change which backend runs,
311
+ because a flag that changed which implementation signed would change what your
312
+ evidence means.
313
+
274
314
  ## Where this fits
275
315
 
276
316
  This is the primitive layer, and it stays that: keys, signatures, encapsulation
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kxco-post-quantum",
3
- "version": "1.6.3",
4
- "description": "ML-DSA-65, ML-KEM-768 and SLH-DSA-SHA2-192s with key fingerprinting. OpenSSL 3.5 primitives on Node 24+, JavaScript elsewhere. 2,103 NIST ACVP vectors and 225 cross-implementation interop checks, 0 failed. Reproducible builds and SLSA provenance.",
3
+ "version": "1.7.0",
4
+ "description": "ML-DSA-65, ML-KEM-768 and SLH-DSA-SHA2-192s with key fingerprinting. OpenSSL 3.5 primitives on Node 24+, JavaScript elsewhere. 2,103 NIST ACVP vectors: 1,793 passed, 0 failed, 310 skipped. 225 interop checks, 0 failed. Reproducible builds and provenance.",
5
5
  "keywords": [
6
6
  "post-quantum",
7
7
  "pqc",
@@ -129,7 +129,7 @@
129
129
  "@noble/post-quantum": "0.7.0"
130
130
  },
131
131
  "scripts": {
132
- "test": "node --test test/basic.test.js && node --test test/context.test.js && node --test test/category5.test.js && node --test test/edge-cases.test.js && node --test test/seed.test.js && node --test test/browser-smoke.test.js && node test/run-vectors.js",
132
+ "test": "node --test test/basic.test.js && node --test test/backend.test.js && node --test test/context.test.js && node --test test/category5.test.js && node --test test/edge-cases.test.js && node --test test/seed.test.js && node --test test/browser-smoke.test.js && node test/run-vectors.js",
133
133
  "test:vectors": "node test/run-vectors.js",
134
134
  "generate:vectors": "node test/generate-vectors.js > test/vectors.json",
135
135
  "bench": "node bench/bench.js",
package/src/backend.js CHANGED
@@ -10,9 +10,31 @@
10
10
  // backend from here: the two produce identical wire bytes, and a runtime flag
11
11
  // that changed which one signed would be a flag that changes what a customer's
12
12
  // evidence means.
13
+ //
14
+ // `requireNativeBackend` at the bottom is not an exception to that. It cannot
15
+ // change which backend runs. It refuses to let the process continue on the
16
+ // wrong one, which is a different thing, and the thing a deployment under a
17
+ // validated-module control actually needs.
13
18
 
14
19
  import { native } from '#native'
15
20
 
21
+ // An operator control. A deployment under a validated-module requirement is
22
+ // usually not the same team as the one calling this library, so the
23
+ // requirement has to be enforceable from the environment rather than only from
24
+ // application code. Set KXCO_PQ_REQUIRE_NATIVE=1 and a process that has landed
25
+ // on the JavaScript backend fails at import, not at its first signature.
26
+ //
27
+ // Read defensively: this module is also loaded in browsers, where `process`
28
+ // does not exist.
29
+ const REQUIRE_NATIVE = (() => {
30
+ try {
31
+ const v = globalThis.process?.env?.KXCO_PQ_REQUIRE_NATIVE
32
+ return v === '1' || v === 'true'
33
+ } catch {
34
+ return false
35
+ }
36
+ })()
37
+
16
38
  /**
17
39
  * Describe the active backend.
18
40
  *
@@ -46,3 +68,54 @@ export function backend() {
46
68
  export function isNative(alg) {
47
69
  return native !== null && native.supports(alg)
48
70
  }
71
+
72
+ /**
73
+ * Refuse to run unless the cryptography is executing in the native backend.
74
+ *
75
+ * This is an assertion, not a switch. It cannot change which implementation
76
+ * signs, for the reason given at the top of this file. What it does is stop a
77
+ * process that has silently landed on the JavaScript backend when the operator
78
+ * required otherwise, before it produces its first signature rather than after.
79
+ *
80
+ * The default behaviour of this package is to fall back, and that default is
81
+ * correct: the two backends produce identical wire bytes. It is wrong in one
82
+ * specific situation, which is a deployment under a control that says the
83
+ * cryptography must execute inside a validated module. There, a silent fallback
84
+ * means the control is not in force and nothing says so.
85
+ *
86
+ * Note what this does and does not claim. It asserts that OpenSSL is doing the
87
+ * maths. Whether that OpenSSL is a FIPS-validated module is a property of the
88
+ * operator's build, not of this package, and this function cannot see it. It
89
+ * removes the fallback, which is the part we can be responsible for.
90
+ *
91
+ * @param {string[]} [algorithms] — parameter sets that must run natively.
92
+ * Omit to require only that the native backend is present at all.
93
+ * @throws {Error} with `code: 'ERR_KXCO_PQ_BACKEND'` when the requirement fails.
94
+ */
95
+ export function requireNativeBackend(algorithms) {
96
+ const b = backend()
97
+ if (b.kind !== 'openssl') {
98
+ throw backendError(
99
+ `the native backend is required and is not present: ${b.reason}`,
100
+ { required: 'openssl', actual: b.kind, reason: b.reason },
101
+ )
102
+ }
103
+ const missing = (algorithms ?? []).filter((a) => !isNative(a))
104
+ if (missing.length) {
105
+ throw backendError(
106
+ `the native backend is required for ${missing.join(', ')}, ` +
107
+ `and this OpenSSL does not provide ${missing.length > 1 ? 'them' : 'it'}`,
108
+ { required: 'openssl', actual: b.kind, missing, available: b.parameterSets },
109
+ )
110
+ }
111
+ return b
112
+ }
113
+
114
+ function backendError(message, detail) {
115
+ const err = new Error(message)
116
+ err.code = 'ERR_KXCO_PQ_BACKEND'
117
+ Object.assign(err, detail)
118
+ return err
119
+ }
120
+
121
+ if (REQUIRE_NATIVE) requireNativeBackend()
package/src/index.js CHANGED
@@ -37,4 +37,4 @@ export * as jws from './jws.js'
37
37
 
38
38
  // Reports which backend is doing the maths in this process, for evidence
39
39
  // bundles and support. It reports; it never switches.
40
- export { backend, isNative } from './backend.js'
40
+ export { backend, isNative, requireNativeBackend } from './backend.js'