kxco-post-quantum 1.5.3 → 1.6.1
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 +100 -0
- package/CONFORMANCE.md +77 -5
- package/DEPENDENCIES.md +137 -0
- package/LICENCE-PRODUCT.md +107 -0
- package/README.md +96 -9
- package/package.json +29 -11
- package/src/backend.d.ts +16 -0
- package/src/backend.js +48 -0
- package/src/index.d.ts +6 -0
- package/src/index.js +18 -2
- package/src/jws.d.ts +74 -0
- package/src/jws.js +249 -0
- package/src/ml-dsa-87.d.ts +8 -0
- package/src/ml-dsa-87.js +5 -0
- package/src/ml-dsa.d.ts +8 -0
- package/src/ml-dsa.js +5 -0
- package/src/ml-kem-1024.d.ts +8 -0
- package/src/ml-kem-1024.js +5 -0
- package/src/ml-kem.d.ts +8 -0
- package/src/ml-kem.js +5 -0
- package/src/seed.d.ts +91 -0
- package/src/seed.js +394 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kxco-post-quantum",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "ML-DSA-65, ML-KEM-768 and SLH-DSA-SHA2-192s with key fingerprinting.
|
|
3
|
+
"version": "1.6.1",
|
|
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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"post-quantum",
|
|
7
7
|
"pqc",
|
|
@@ -94,37 +94,55 @@
|
|
|
94
94
|
"./kid": {
|
|
95
95
|
"types": "./src/kid.d.ts",
|
|
96
96
|
"import": "./src/kid.js"
|
|
97
|
+
},
|
|
98
|
+
"./seed": {
|
|
99
|
+
"types": "./src/seed.d.ts",
|
|
100
|
+
"import": "./src/seed.js"
|
|
101
|
+
},
|
|
102
|
+
"./jws": {
|
|
103
|
+
"types": "./src/jws.d.ts",
|
|
104
|
+
"import": "./src/jws.js"
|
|
105
|
+
},
|
|
106
|
+
"./backend": {
|
|
107
|
+
"types": "./src/backend.d.ts",
|
|
108
|
+
"import": "./src/backend.js"
|
|
97
109
|
}
|
|
98
110
|
},
|
|
99
111
|
"files": [
|
|
100
|
-
"src",
|
|
101
|
-
"README.md",
|
|
102
|
-
"LICENSE",
|
|
103
|
-
"CONFORMANCE.md",
|
|
104
112
|
"BENCHMARKS.md",
|
|
105
|
-
"
|
|
113
|
+
"CHANGELOG.md",
|
|
114
|
+
"CONFORMANCE.md",
|
|
115
|
+
"DEPENDENCIES.md",
|
|
116
|
+
"LICENCE-PRODUCT.md",
|
|
117
|
+
"LICENSE",
|
|
106
118
|
"MIGRATION.md",
|
|
119
|
+
"README.md",
|
|
107
120
|
"SECURITY.md",
|
|
108
|
-
"
|
|
121
|
+
"THREAT-MODEL.md",
|
|
122
|
+
"src"
|
|
109
123
|
],
|
|
110
124
|
"engines": {
|
|
111
125
|
"node": ">=20.19"
|
|
112
126
|
},
|
|
113
127
|
"dependencies": {
|
|
114
|
-
"@noble/hashes": "2.
|
|
128
|
+
"@noble/hashes": "2.4.0",
|
|
115
129
|
"@noble/post-quantum": "0.7.0"
|
|
116
130
|
},
|
|
117
131
|
"scripts": {
|
|
118
|
-
"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/browser-smoke.test.js && node test/run-vectors.js",
|
|
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",
|
|
119
133
|
"test:vectors": "node test/run-vectors.js",
|
|
120
134
|
"generate:vectors": "node test/generate-vectors.js > test/vectors.json",
|
|
121
135
|
"bench": "node bench/bench.js",
|
|
122
136
|
"conformance:fetch": "node conformance/fetch-vectors.mjs",
|
|
123
137
|
"conformance:acvp": "node conformance/run-acvp.mjs --json conformance/results/acvp.json",
|
|
124
138
|
"conformance:interop": "node conformance/interop/run-interop.mjs --json conformance/results/interop.json",
|
|
139
|
+
"conformance:protocol": "node conformance/protocol/run-protocol.mjs --json conformance/results/protocol.json",
|
|
140
|
+
"audit:deps": "node audit/run-audit.mjs --json audit/results/dependencies.json",
|
|
125
141
|
"sbom": "npm sbom --sbom-format cyclonedx --sbom-type library",
|
|
126
142
|
"bench:timing": "node --expose-gc bench/timing.mjs --iterations 20000 --json bench/results/timing.json",
|
|
127
|
-
"bench:primitives": "node --expose-gc bench/primitives.mjs --iterations 100 --json bench/results/primitives.json"
|
|
143
|
+
"bench:primitives": "node --expose-gc bench/primitives.mjs --iterations 100 --json bench/results/primitives.json",
|
|
144
|
+
"evidence": "node scripts/build-evidence.mjs",
|
|
145
|
+
"evidence:full": "node scripts/build-evidence.mjs --full"
|
|
128
146
|
},
|
|
129
147
|
"publishConfig": {
|
|
130
148
|
"provenance": true,
|
package/src/backend.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface BackendReport {
|
|
2
|
+
kind: 'openssl' | 'javascript'
|
|
3
|
+
library: string
|
|
4
|
+
/** OpenSSL version, on the native backend only. */
|
|
5
|
+
openssl?: string
|
|
6
|
+
/** Parameter sets the native backend can express. */
|
|
7
|
+
parameterSets?: string[]
|
|
8
|
+
/** Why the native backend is unavailable, on the JavaScript backend only. */
|
|
9
|
+
reason?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Describe the backend doing the maths in this process. Reports; never switches. */
|
|
13
|
+
export function backend(): BackendReport
|
|
14
|
+
|
|
15
|
+
/** Whether a parameter set runs natively here, e.g. isNative('ML-DSA-65'). */
|
|
16
|
+
export function isNative(alg: string): boolean
|
package/src/backend.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Which implementation is actually doing the maths, right now, on this box.
|
|
2
|
+
//
|
|
3
|
+
// The package picks its backend at import time by probing the runtime, not by
|
|
4
|
+
// reading a version number, so the only honest way to state which one a given
|
|
5
|
+
// deployment used is to ask it. This exists so that an evidence bundle, a
|
|
6
|
+
// support ticket or a customer's own conformance run can record the answer
|
|
7
|
+
// instead of inferring it from `process.version`.
|
|
8
|
+
//
|
|
9
|
+
// It reports; it does not switch. There is deliberately no way to force a
|
|
10
|
+
// backend from here: the two produce identical wire bytes, and a runtime flag
|
|
11
|
+
// that changed which one signed would be a flag that changes what a customer's
|
|
12
|
+
// evidence means.
|
|
13
|
+
|
|
14
|
+
import { native } from '#native'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Describe the active backend.
|
|
18
|
+
*
|
|
19
|
+
* @returns {{ kind: 'openssl'|'javascript', library: string, openssl?: string,
|
|
20
|
+
* parameterSets?: string[], reason?: string }}
|
|
21
|
+
*/
|
|
22
|
+
export function backend() {
|
|
23
|
+
if (native === null) {
|
|
24
|
+
return {
|
|
25
|
+
kind: 'javascript',
|
|
26
|
+
library: '@noble/post-quantum',
|
|
27
|
+
reason: 'the runtime does not provide the FIPS 203/204/205 primitives',
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
kind: 'openssl',
|
|
32
|
+
library: 'node:crypto',
|
|
33
|
+
openssl: native.openssl,
|
|
34
|
+
// Only the sets OpenSSL can express. Anything absent here still works, on
|
|
35
|
+
// the JavaScript backend, which is why this is a list rather than a flag.
|
|
36
|
+
parameterSets: native.algorithms(),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a given parameter set runs on the native backend in this process.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} alg — e.g. 'ML-DSA-65'
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
export function isNative(alg) {
|
|
47
|
+
return native !== null && native.supports(alg)
|
|
48
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -11,3 +11,9 @@ export * as mlKem1024 from './ml-kem-1024.js'
|
|
|
11
11
|
export * from './derive.js'
|
|
12
12
|
export * from './kid.js'
|
|
13
13
|
export * as webhook from './webhook.js'
|
|
14
|
+
|
|
15
|
+
/** Seed-form keys: RFC 9964 AKP JWKs and LAMPS seed-form PKCS#8. */
|
|
16
|
+
export * as seed from './seed.js'
|
|
17
|
+
/** Compact JWS using the RFC 9964 algorithm names. Format only, no network. */
|
|
18
|
+
export * as jws from './jws.js'
|
|
19
|
+
export { backend, isNative } from './backend.js'
|
package/src/index.js
CHANGED
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
// KnightsVault, KXCO Bank, KnightsBot, The Exchequer, and Armature L1.
|
|
7
7
|
//
|
|
8
8
|
// This package does NOT reimplement the NIST primitives. It wraps the
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// @noble/post-quantum reference implementation with the integration patterns we
|
|
10
|
+
// have proven in production, and on Node 24+ it prefers the OpenSSL 3.5 backend.
|
|
11
|
+
//
|
|
12
|
+
// The primitives are evidenced here rather than taken on trust: every parameter
|
|
13
|
+
// set is checked against NIST's own ACVP vectors and cross-checked against
|
|
14
|
+
// liboqs, Bouncy Castle and dilithium-py/kyber-py in both directions. See
|
|
15
|
+
// CONFORMANCE.md, and audit/ for the dependency review.
|
|
11
16
|
|
|
12
17
|
export * as mlDsa from './ml-dsa.js'
|
|
13
18
|
export * as mlKem from './ml-kem.js'
|
|
@@ -22,3 +27,14 @@ export * as mlKem1024 from './ml-kem-1024.js'
|
|
|
22
27
|
export * from './derive.js'
|
|
23
28
|
export * from './kid.js'
|
|
24
29
|
export * as webhook from './webhook.js'
|
|
30
|
+
|
|
31
|
+
// Seed-form keys (RFC 9964 AKP JWKs, LAMPS seed-form PKCS#8) and compact JWS
|
|
32
|
+
// with the RFC 9964 algorithm names. Both are format and derivation only: they
|
|
33
|
+
// contact nothing, need no licence, and a token or key they produce stays
|
|
34
|
+
// verifiable offline for as long as the holder keeps the public key.
|
|
35
|
+
export * as seed from './seed.js'
|
|
36
|
+
export * as jws from './jws.js'
|
|
37
|
+
|
|
38
|
+
// Reports which backend is doing the maths in this process, for evidence
|
|
39
|
+
// bundles and support. It reports; it never switches.
|
|
40
|
+
export { backend, isNative } from './backend.js'
|
package/src/jws.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
/** JWS `alg` values this module signs and verifies (RFC 9964 names). */
|
|
4
|
+
export type JwsAlgorithm = 'ML-DSA-65' | 'ML-DSA-87'
|
|
5
|
+
|
|
6
|
+
export const JWS_ALGORITHMS: JwsAlgorithm[]
|
|
7
|
+
|
|
8
|
+
export interface JwsHeader {
|
|
9
|
+
alg: JwsAlgorithm
|
|
10
|
+
typ?: string
|
|
11
|
+
kid?: string
|
|
12
|
+
[key: string]: unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SignJwsOptions {
|
|
16
|
+
/** Defaults to 'ML-DSA-65'. */
|
|
17
|
+
alg?: JwsAlgorithm
|
|
18
|
+
/** Key identifier, e.g. the 16-hex `fingerprint()` of the public key. */
|
|
19
|
+
kid?: string
|
|
20
|
+
typ?: string
|
|
21
|
+
/** Extra protected header members. May not restate alg, kid or typ. */
|
|
22
|
+
header?: Record<string, unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface VerifyJwsSuccess {
|
|
26
|
+
valid: true
|
|
27
|
+
header: JwsHeader
|
|
28
|
+
/** Raw payload bytes. */
|
|
29
|
+
payload: Uint8Array
|
|
30
|
+
/** The payload decoded as UTF-8. JSON payloads are not parsed for you. */
|
|
31
|
+
text: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VerifyJwsFailure {
|
|
35
|
+
valid: false
|
|
36
|
+
error: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type VerifyJwsResult = VerifyJwsSuccess | VerifyJwsFailure
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sign a payload into a compact JWS. Objects are JSON-serialised.
|
|
43
|
+
*
|
|
44
|
+
* @throws {TypeError} on a bad options object or payload type
|
|
45
|
+
* @throws {Error} on an unsupported alg, or a header member that restates
|
|
46
|
+
* alg, kid or typ
|
|
47
|
+
*/
|
|
48
|
+
export function signJws(
|
|
49
|
+
payload: object | string | Uint8Array,
|
|
50
|
+
secretKey: Buffer | Uint8Array,
|
|
51
|
+
opts?: SignJwsOptions,
|
|
52
|
+
): string
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Verify a compact JWS.
|
|
56
|
+
*
|
|
57
|
+
* The algorithm is resolved from this module's allowlist using the header's
|
|
58
|
+
* `alg`; a token cannot name its own verification routine. Pass `{ alg }` to
|
|
59
|
+
* require a specific algorithm and `{ kid }` to require a specific key.
|
|
60
|
+
*
|
|
61
|
+
* Returns `{ valid: false, error }` for every failure. Throws only on caller
|
|
62
|
+
* misuse of `opts`.
|
|
63
|
+
*/
|
|
64
|
+
export function verifyJws(
|
|
65
|
+
token: string,
|
|
66
|
+
publicKey: Buffer | Uint8Array,
|
|
67
|
+
opts?: { alg?: JwsAlgorithm; kid?: string },
|
|
68
|
+
): VerifyJwsResult
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read a token's header without verifying it — for choosing which key to fetch
|
|
72
|
+
* from a `kid`. The header is unauthenticated until `verifyJws` returns valid.
|
|
73
|
+
*/
|
|
74
|
+
export function decodeJwsHeader(token: string): JwsHeader | null
|
package/src/jws.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Compact JWS with ML-DSA, using the RFC 9964 algorithm names.
|
|
2
|
+
//
|
|
3
|
+
// Format only. Nothing here contacts a network, reads a chain, or needs a
|
|
4
|
+
// licence: a token signed by this module verifies in any process that holds
|
|
5
|
+
// the public key, forever, offline. The paid behaviour that decides whether a
|
|
6
|
+
// key is still ALLOWED to sign lives in kxco-pq-network and above, never here.
|
|
7
|
+
//
|
|
8
|
+
// Why a JWS at all when this package already has its own envelopes. Because an
|
|
9
|
+
// institution's existing stack already parses JWS. Their gateway, their IdP,
|
|
10
|
+
// their audit tooling and their partner's verifier all speak it, and RFC 9964
|
|
11
|
+
// registered "ML-DSA-44", "ML-DSA-65" and "ML-DSA-87" as JWS algorithms
|
|
12
|
+
// precisely so a post-quantum signature can travel that path unchanged. Giving
|
|
13
|
+
// them one is the difference between a migration and a rewrite.
|
|
14
|
+
//
|
|
15
|
+
// The header is the protected header and the whole of it is signed. There is
|
|
16
|
+
// no unprotected header, no JSON serialisation and no detached payload in this
|
|
17
|
+
// release: each is a place where a verifier can be talked into checking
|
|
18
|
+
// something other than what it thinks it is checking, and none of them is
|
|
19
|
+
// needed to carry a signature between two services.
|
|
20
|
+
//
|
|
21
|
+
// SLH-DSA is not offered here. FIPS 205 signing takes on the order of a second
|
|
22
|
+
// and a half per signature, which is not something to put on a request path.
|
|
23
|
+
|
|
24
|
+
import * as mlDsa from './ml-dsa.js'
|
|
25
|
+
import * as mlDsa87 from './ml-dsa-87.js'
|
|
26
|
+
|
|
27
|
+
const HAS_BUFFER = typeof Buffer !== 'undefined'
|
|
28
|
+
const enc = new TextEncoder()
|
|
29
|
+
const dec = new TextDecoder()
|
|
30
|
+
|
|
31
|
+
// The `alg` allowlist. A verifier resolves the implementation from THIS table
|
|
32
|
+
// and nowhere else, so a token cannot name its own verification routine — the
|
|
33
|
+
// alg-confusion failure that has broken JWT libraries repeatedly.
|
|
34
|
+
const ALGORITHMS = {
|
|
35
|
+
'ML-DSA-65': { mod: mlDsa, publicKeyBytes: 1952, signatureBytes: 3309 },
|
|
36
|
+
'ML-DSA-87': { mod: mlDsa87, publicKeyBytes: 2592, signatureBytes: 4627 },
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** JWS `alg` values this module will sign or verify. */
|
|
40
|
+
export const JWS_ALGORITHMS = Object.keys(ALGORITHMS)
|
|
41
|
+
|
|
42
|
+
const DEFAULT_ALG = 'ML-DSA-65'
|
|
43
|
+
|
|
44
|
+
function b64url(bytes) {
|
|
45
|
+
if (HAS_BUFFER) return Buffer.from(bytes).toString('base64url')
|
|
46
|
+
let binary = ''
|
|
47
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
|
|
48
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fromB64url(str) {
|
|
52
|
+
if (HAS_BUFFER) return new Uint8Array(Buffer.from(str, 'base64url'))
|
|
53
|
+
const padded = str.replace(/-/g, '+').replace(/_/g, '/')
|
|
54
|
+
const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
|
|
55
|
+
const out = new Uint8Array(binary.length)
|
|
56
|
+
for (let i = 0; i < out.length; i++) out[i] = binary.charCodeAt(i)
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// base64url is not a canonical encoding of arbitrary text: two different
|
|
61
|
+
// strings can decode to the same bytes if one carries padding or non-alphabet
|
|
62
|
+
// characters. A token whose segments re-encode differently from how they
|
|
63
|
+
// arrived is rejected, so a verifier and a downstream parser cannot be shown
|
|
64
|
+
// two different payloads for one signature.
|
|
65
|
+
function strictB64url(str, what) {
|
|
66
|
+
if (typeof str !== 'string' || str.length === 0 || !/^[A-Za-z0-9_-]+$/.test(str)) {
|
|
67
|
+
throw new Error(`malformed JWS: ${what} is not base64url`)
|
|
68
|
+
}
|
|
69
|
+
const bytes = fromB64url(str)
|
|
70
|
+
if (b64url(bytes) !== str) throw new Error(`malformed JWS: ${what} is not canonically encoded`)
|
|
71
|
+
return bytes
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function bytesToHex(bytes) {
|
|
75
|
+
let s = ''
|
|
76
|
+
for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0')
|
|
77
|
+
return s
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hexToBytes(hex) {
|
|
81
|
+
const b = new Uint8Array(hex.length / 2)
|
|
82
|
+
for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
|
|
83
|
+
return b
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function payloadBytes(payload) {
|
|
87
|
+
if (payload instanceof Uint8Array) return payload
|
|
88
|
+
if (typeof payload === 'string') return enc.encode(payload)
|
|
89
|
+
if (payload === null || payload === undefined) {
|
|
90
|
+
throw new TypeError('payload is required')
|
|
91
|
+
}
|
|
92
|
+
if (typeof payload === 'object') return enc.encode(JSON.stringify(payload))
|
|
93
|
+
throw new TypeError('payload must be an object, a string or a Uint8Array')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Sign a payload into a compact JWS.
|
|
98
|
+
*
|
|
99
|
+
* @param {object|string|Uint8Array} payload — objects are JSON-serialised
|
|
100
|
+
* @param {Buffer|Uint8Array} secretKey
|
|
101
|
+
* @param {{ alg?: string, kid?: string, typ?: string, header?: object }} [opts]
|
|
102
|
+
* @returns {string} compact JWS: header.payload.signature
|
|
103
|
+
*/
|
|
104
|
+
export function signJws(payload, secretKey, opts = {}) {
|
|
105
|
+
if (opts === null || typeof opts !== 'object') {
|
|
106
|
+
throw new TypeError('expected an options object such as { kid, alg }')
|
|
107
|
+
}
|
|
108
|
+
const alg = opts.alg ?? DEFAULT_ALG
|
|
109
|
+
const spec = ALGORITHMS[alg]
|
|
110
|
+
if (!spec) {
|
|
111
|
+
throw new Error(`unsupported JWS alg '${alg}' — this module signs ${JWS_ALGORITHMS.join(' and ')}`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Extra header members are allowed but may not restate or override the ones
|
|
115
|
+
// this module is responsible for, which would let a caller sign under one
|
|
116
|
+
// alg while the header advertises another.
|
|
117
|
+
const extra = opts.header ?? {}
|
|
118
|
+
for (const reserved of ['alg', 'kid', 'typ']) {
|
|
119
|
+
if (Object.hasOwn(extra, reserved)) {
|
|
120
|
+
throw new Error(`header.${reserved} is set through opts.${reserved}, not opts.header`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const header = {
|
|
125
|
+
alg,
|
|
126
|
+
...(opts.typ !== undefined ? { typ: opts.typ } : {}),
|
|
127
|
+
...(opts.kid !== undefined ? { kid: opts.kid } : {}),
|
|
128
|
+
...extra,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const protectedB64 = b64url(enc.encode(JSON.stringify(header)))
|
|
132
|
+
const payloadB64 = b64url(payloadBytes(payload))
|
|
133
|
+
const signingInput = enc.encode(`${protectedB64}.${payloadB64}`)
|
|
134
|
+
|
|
135
|
+
// Goes through the module's own sign(), so the OpenSSL backend is used
|
|
136
|
+
// wherever the runtime has it and the JavaScript one everywhere else. The
|
|
137
|
+
// wire bytes are identical either way.
|
|
138
|
+
const sigHex = spec.mod.sign(secretKey, signingInput)
|
|
139
|
+
return `${protectedB64}.${payloadB64}.${b64url(hexToBytes(sigHex))}`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Verify a compact JWS.
|
|
144
|
+
*
|
|
145
|
+
* Returns `{ valid: false, error }` for anything that fails, and throws only on
|
|
146
|
+
* caller misuse, matching how `mlDsa.verify` already behaves.
|
|
147
|
+
*
|
|
148
|
+
* The algorithm is resolved from this module's allowlist using the header's
|
|
149
|
+
* `alg`, and the public key's length must match what that algorithm expects.
|
|
150
|
+
* Pass `{ alg }` to require a specific one, and `{ kid }` to require the header
|
|
151
|
+
* to name a specific key.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} token
|
|
154
|
+
* @param {Buffer|Uint8Array} publicKey
|
|
155
|
+
* @param {{ alg?: string, kid?: string }} [opts]
|
|
156
|
+
* @returns {{ valid: true, header: object, payload: Uint8Array, text: string }
|
|
157
|
+
* | { valid: false, error: string }}
|
|
158
|
+
*/
|
|
159
|
+
export function verifyJws(token, publicKey, opts = {}) {
|
|
160
|
+
if (opts === null || typeof opts !== 'object') {
|
|
161
|
+
throw new TypeError('expected an options object such as { alg, kid }')
|
|
162
|
+
}
|
|
163
|
+
if (typeof token !== 'string') return { valid: false, error: 'token must be a string' }
|
|
164
|
+
|
|
165
|
+
const parts = token.split('.')
|
|
166
|
+
if (parts.length !== 3) {
|
|
167
|
+
return { valid: false, error: 'malformed JWS: expected three dot-separated segments' }
|
|
168
|
+
}
|
|
169
|
+
const [protectedB64, payloadB64, sigB64] = parts
|
|
170
|
+
|
|
171
|
+
let header, payload, signature
|
|
172
|
+
try {
|
|
173
|
+
header = JSON.parse(dec.decode(strictB64url(protectedB64, 'header')))
|
|
174
|
+
payload = strictB64url(payloadB64, 'payload')
|
|
175
|
+
signature = strictB64url(sigB64, 'signature')
|
|
176
|
+
} catch (err) {
|
|
177
|
+
return { valid: false, error: err.message }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!header || typeof header !== 'object' || Array.isArray(header)) {
|
|
181
|
+
return { valid: false, error: 'malformed JWS: header is not an object' }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const spec = ALGORITHMS[header.alg]
|
|
185
|
+
if (!spec) {
|
|
186
|
+
return { valid: false, error: `unsupported JWS alg '${header.alg}'` }
|
|
187
|
+
}
|
|
188
|
+
if (opts.alg !== undefined && header.alg !== opts.alg) {
|
|
189
|
+
return { valid: false, error: `alg mismatch: expected '${opts.alg}', token declares '${header.alg}'` }
|
|
190
|
+
}
|
|
191
|
+
if (opts.kid !== undefined && header.kid !== opts.kid) {
|
|
192
|
+
return { valid: false, error: `kid mismatch: expected '${opts.kid}', token declares '${header.kid ?? '(none)'}'` }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// RFC 7515 section 4.1.11: a verifier that does not understand every member
|
|
196
|
+
// named in `crit` must reject the token. This module understands none of the
|
|
197
|
+
// extensions that would be named there, so any `crit` at all is a rejection.
|
|
198
|
+
if (header.crit !== undefined) {
|
|
199
|
+
return { valid: false, error: 'JWS declares crit header parameters this verifier does not implement' }
|
|
200
|
+
}
|
|
201
|
+
// RFC 7797 unencoded payloads change what the signature covers. Not accepted.
|
|
202
|
+
if (header.b64 !== undefined) {
|
|
203
|
+
return { valid: false, error: 'JWS declares b64, which this verifier does not implement' }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The key must be the size the declared algorithm uses. Without this a token
|
|
207
|
+
// could name ML-DSA-87 while being checked against an ML-DSA-65 key, and the
|
|
208
|
+
// failure would look like a bad signature rather than a mixed-up key.
|
|
209
|
+
const keyBytes = publicKey instanceof Uint8Array ? publicKey : new Uint8Array(publicKey)
|
|
210
|
+
if (keyBytes.length !== spec.publicKeyBytes) {
|
|
211
|
+
return {
|
|
212
|
+
valid: false,
|
|
213
|
+
error: `key is ${keyBytes.length} bytes, but ${header.alg} public keys are ${spec.publicKeyBytes}`,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (signature.length !== spec.signatureBytes) {
|
|
217
|
+
return {
|
|
218
|
+
valid: false,
|
|
219
|
+
error: `signature is ${signature.length} bytes, but ${header.alg} signatures are ${spec.signatureBytes}`,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const signingInput = enc.encode(`${protectedB64}.${payloadB64}`)
|
|
224
|
+
const ok = spec.mod.verify(keyBytes, signingInput, bytesToHex(signature))
|
|
225
|
+
if (!ok) return { valid: false, error: 'signature invalid' }
|
|
226
|
+
|
|
227
|
+
return { valid: true, header, payload, text: dec.decode(payload) }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Read a token's header without verifying anything.
|
|
232
|
+
*
|
|
233
|
+
* For dispatch only — picking which public key to fetch from a `kid`. The
|
|
234
|
+
* header is unauthenticated until `verifyJws` returns valid, and nothing in it
|
|
235
|
+
* should be acted on before that.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} token
|
|
238
|
+
* @returns {object|null} null if the token is not parseable
|
|
239
|
+
*/
|
|
240
|
+
export function decodeJwsHeader(token) {
|
|
241
|
+
if (typeof token !== 'string') return null
|
|
242
|
+
const first = token.split('.')[0]
|
|
243
|
+
try {
|
|
244
|
+
const header = JSON.parse(dec.decode(strictB64url(first, 'header')))
|
|
245
|
+
return header && typeof header === 'object' && !Array.isArray(header) ? header : null
|
|
246
|
+
} catch {
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
}
|
package/src/ml-dsa-87.d.ts
CHANGED
|
@@ -5,6 +5,14 @@ export interface MlDsa87Keypair {
|
|
|
5
5
|
publicKey: Buffer
|
|
6
6
|
/** 4896-byte secret key */
|
|
7
7
|
secretKey: Buffer
|
|
8
|
+
/**
|
|
9
|
+
* The 32-byte seed this keypair was expanded from.
|
|
10
|
+
*
|
|
11
|
+
* An expanded secret key does not contain its seed, so this is the only
|
|
12
|
+
* point at which it can be captured. Pass it to `exportJwk` or
|
|
13
|
+
* `exportSeedPkcs8` for RFC 9964 / LAMPS seed-form storage.
|
|
14
|
+
*/
|
|
15
|
+
seed: Buffer
|
|
8
16
|
}
|
|
9
17
|
|
|
10
18
|
/**
|
package/src/ml-dsa-87.js
CHANGED
|
@@ -82,6 +82,11 @@ export function keypairFromMaster(master, info = 'ml-dsa-87-v1') {
|
|
|
82
82
|
return {
|
|
83
83
|
publicKey: wrap(k.publicKey),
|
|
84
84
|
secretKey: wrap(k.secretKey),
|
|
85
|
+
// The seed this key was expanded from. Additive: callers destructuring
|
|
86
|
+
// { publicKey, secretKey } are unaffected. It is here because an expanded
|
|
87
|
+
// key does not contain its seed, so this is the only moment it can be
|
|
88
|
+
// captured, and seed form is what RFC 9964 JWKs and KMS custody take.
|
|
89
|
+
seed: wrap(seedU8),
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
|
package/src/ml-dsa.d.ts
CHANGED
|
@@ -5,6 +5,14 @@ export interface MlDsaKeypair {
|
|
|
5
5
|
publicKey: Buffer
|
|
6
6
|
/** 4032-byte secret key */
|
|
7
7
|
secretKey: Buffer
|
|
8
|
+
/**
|
|
9
|
+
* The 32-byte seed this keypair was expanded from.
|
|
10
|
+
*
|
|
11
|
+
* An expanded secret key does not contain its seed, so this is the only
|
|
12
|
+
* point at which it can be captured. Pass it to `exportJwk` or
|
|
13
|
+
* `exportSeedPkcs8` for RFC 9964 / LAMPS seed-form storage.
|
|
14
|
+
*/
|
|
15
|
+
seed: Buffer
|
|
8
16
|
}
|
|
9
17
|
|
|
10
18
|
/**
|
package/src/ml-dsa.js
CHANGED
|
@@ -62,6 +62,11 @@ export function keypairFromMaster(master, info = 'ml-dsa-65-v1') {
|
|
|
62
62
|
return {
|
|
63
63
|
publicKey: wrap(k.publicKey),
|
|
64
64
|
secretKey: wrap(k.secretKey),
|
|
65
|
+
// The seed this key was expanded from. Additive: callers destructuring
|
|
66
|
+
// { publicKey, secretKey } are unaffected. It is here because an expanded
|
|
67
|
+
// key does not contain its seed, so this is the only moment it can be
|
|
68
|
+
// captured, and seed form is what RFC 9964 JWKs and KMS custody take.
|
|
69
|
+
seed: wrap(seedU8),
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
72
|
|
package/src/ml-kem-1024.d.ts
CHANGED
|
@@ -5,6 +5,14 @@ export interface MlKem1024Keypair {
|
|
|
5
5
|
publicKey: Buffer
|
|
6
6
|
/** 3168-byte secret key */
|
|
7
7
|
secretKey: Buffer
|
|
8
|
+
/**
|
|
9
|
+
* The 64-byte seed this keypair was expanded from.
|
|
10
|
+
*
|
|
11
|
+
* An expanded secret key does not contain its seed, so this is the only
|
|
12
|
+
* point at which it can be captured. Pass it to `exportJwk` or
|
|
13
|
+
* `exportSeedPkcs8` for RFC 9964 / LAMPS seed-form storage.
|
|
14
|
+
*/
|
|
15
|
+
seed: Buffer
|
|
8
16
|
}
|
|
9
17
|
|
|
10
18
|
export interface MlKem1024Encapsulation {
|
package/src/ml-kem-1024.js
CHANGED
|
@@ -63,6 +63,11 @@ export function keypairFromMaster(master, info = 'ml-kem-1024-v1') {
|
|
|
63
63
|
return {
|
|
64
64
|
publicKey: wrap(k.publicKey),
|
|
65
65
|
secretKey: wrap(k.secretKey),
|
|
66
|
+
// The seed this key was expanded from. Additive: callers destructuring
|
|
67
|
+
// { publicKey, secretKey } are unaffected. It is here because an expanded
|
|
68
|
+
// key does not contain its seed, so this is the only moment it can be
|
|
69
|
+
// captured, and seed form is what RFC 9964 JWKs and KMS custody take.
|
|
70
|
+
seed: wrap(seedU8),
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
|
package/src/ml-kem.d.ts
CHANGED
|
@@ -5,6 +5,14 @@ export interface MlKemKeypair {
|
|
|
5
5
|
publicKey: Buffer
|
|
6
6
|
/** 2400-byte secret key */
|
|
7
7
|
secretKey: Buffer
|
|
8
|
+
/**
|
|
9
|
+
* The 64-byte seed this keypair was expanded from.
|
|
10
|
+
*
|
|
11
|
+
* An expanded secret key does not contain its seed, so this is the only
|
|
12
|
+
* point at which it can be captured. Pass it to `exportJwk` or
|
|
13
|
+
* `exportSeedPkcs8` for RFC 9964 / LAMPS seed-form storage.
|
|
14
|
+
*/
|
|
15
|
+
seed: Buffer
|
|
8
16
|
}
|
|
9
17
|
|
|
10
18
|
export interface MlKemEncapsulation {
|
package/src/ml-kem.js
CHANGED
|
@@ -38,6 +38,11 @@ export function keypairFromMaster(master, info = 'ml-kem-768-v1') {
|
|
|
38
38
|
return {
|
|
39
39
|
publicKey: wrap(k.publicKey),
|
|
40
40
|
secretKey: wrap(k.secretKey),
|
|
41
|
+
// The seed this key was expanded from. Additive: callers destructuring
|
|
42
|
+
// { publicKey, secretKey } are unaffected. It is here because an expanded
|
|
43
|
+
// key does not contain its seed, so this is the only moment it can be
|
|
44
|
+
// captured, and seed form is what RFC 9964 JWKs and KMS custody take.
|
|
45
|
+
seed: wrap(seedU8),
|
|
41
46
|
}
|
|
42
47
|
}
|
|
43
48
|
|