kxco-verify 1.3.2 → 1.4.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 +30 -0
- package/README.md +9 -8
- package/bin/kxco-verify.js +1 -1
- package/package.json +7 -3
- package/src/index.d.ts +1 -1
- package/src/index.js +21 -9
- package/src/parse.d.ts +1 -1
- package/src/parse.js +35 -12
- package/src/verify.d.ts +8 -1
- package/src/verify.js +40 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0
|
|
4
|
+
ML-DSA-87 manifests verify alongside ML-DSA-65. parseManifest accepts
|
|
5
|
+
`"ML-DSA-87"` where all three alg fields name it, and requires the public key
|
|
6
|
+
and signature to be exactly that set's sizes: 2592 and 4627 bytes for
|
|
7
|
+
ML-DSA-87, 1952 and 3309 for ML-DSA-65. A manifest whose alg fields disagree,
|
|
8
|
+
or whose key is the size of the other set, is refused with `invalid_field`.
|
|
9
|
+
verifyManifest verifies under the manifest's set and reports it as
|
|
10
|
+
`algorithm`.
|
|
11
|
+
|
|
12
|
+
verifySignature takes an optional fourth argument, `alg`. Without it the public
|
|
13
|
+
key decides the set by its length; with it, a key or signature of the other set
|
|
14
|
+
returns false, and an algorithm it does not speak throws a TypeError. Every
|
|
15
|
+
ML-DSA-65 manifest and call that verified before verifies unchanged.
|
|
16
|
+
|
|
17
|
+
The ML-DSA-87 path uses `ml_dsa87` from the same exactly pinned
|
|
18
|
+
`@noble/post-quantum` 0.7.0, so the runtime dependency is unchanged.
|
|
19
|
+
|
|
20
|
+
## 1.3.3
|
|
21
|
+
|
|
22
|
+
A manifest body that is valid JSON but not an object is refused with
|
|
23
|
+
`invalid_input` by parseManifest, verifyManifest and verifyUrl, and `--file`
|
|
24
|
+
exits 2 with the parse error. hexToBytes refuses characters that are not hex
|
|
25
|
+
digits. verifyUrl reports a live key endpoint serving `null` as
|
|
26
|
+
`live_pubkey_missing`, and a key that is not hex as the soft error
|
|
27
|
+
`invalid_live_pubkey`.
|
|
28
|
+
|
|
29
|
+
The README and the command line state exactly what a `"valid"` result covers:
|
|
30
|
+
the manifest's `signedMessage`, which for KXCO manifests is the key identifier,
|
|
31
|
+
commit and environment.
|
|
32
|
+
|
|
3
33
|
## 1.3.2
|
|
4
34
|
|
|
5
35
|
Documentation. No source change.
|
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ VALID target150.com
|
|
|
62
62
|
algorithm ML-DSA-65
|
|
63
63
|
kid 680f9af0bb44de3f
|
|
64
64
|
|
|
65
|
-
This means the
|
|
65
|
+
This means the key this manifest publishes signed its signedMessage.
|
|
66
66
|
It is not an endorsement of the site, its owner, or its content.
|
|
67
67
|
```
|
|
68
68
|
|
|
@@ -126,12 +126,12 @@ const result = await verifyManifest(body)
|
|
|
126
126
|
|
|
127
127
|
| `state` | Meaning |
|
|
128
128
|
|---|---|
|
|
129
|
-
| `"valid"` | Signature checks out against the manifest-declared key, and that key matches the live well-known endpoint. |
|
|
129
|
+
| `"valid"` | Signature checks out against the manifest-declared key, and, where the manifest declares `pinAt`, that key matches the live well-known endpoint. |
|
|
130
130
|
| `"rotated"` | Signature checks out, but the live well-known endpoint now serves a different key. The site is mid key-rotation: retry shortly. |
|
|
131
131
|
| `"invalid"` | The signature does not verify under the manifest-declared key, or the key identifier is inconsistent with the published key bytes. |
|
|
132
132
|
| `"error"` | The verifier could not run: network failure, malformed JSON, or unsupported algorithm. |
|
|
133
133
|
|
|
134
|
-
A `"valid"` result is a precise cryptographic statement: the
|
|
134
|
+
A `"valid"` result is a precise cryptographic statement: the key this manifest publishes signed its `signedMessage`, and where the manifest declares `pinAt`, `verifyUrl` found that key served there now. For KXCO's manifests `signedMessage` is `{kid}.{deployment.git_commit}.{deployment.env}`, so the key identifier, commit and environment are signed. `site`, `msgFormat` and the other `deployment` fields are read from the manifest as served. Who operates the site is a separate question, covered in [`docs/threat-model.md`](./docs/threat-model.md).
|
|
135
135
|
|
|
136
136
|
## For institutions
|
|
137
137
|
|
|
@@ -159,7 +159,7 @@ All exports are re-exported from the main entry point (`import ... from 'kxco-ve
|
|
|
159
159
|
|
|
160
160
|
### `verifyUrl(attestationUrl, opts?) → Promise<VerifyResult>`
|
|
161
161
|
|
|
162
|
-
Fetches the attestation at `attestationUrl`, verifies the ML-DSA-65
|
|
162
|
+
Fetches the attestation at `attestationUrl`, verifies the signature under the parameter set the manifest names (ML-DSA-65 or ML-DSA-87), then fetches the live well-known pubkey endpoint declared in the manifest (`publicKey.pinAt`) to detect key rotation. May return `"rotated"` if the live endpoint now serves a different key identifier.
|
|
163
163
|
|
|
164
164
|
```ts
|
|
165
165
|
verifyUrl(
|
|
@@ -186,7 +186,7 @@ verifyManifest(manifestBody: string | object): Promise<VerifyResult>
|
|
|
186
186
|
```ts
|
|
187
187
|
interface VerifyResult {
|
|
188
188
|
state: 'valid' | 'rotated' | 'invalid' | 'error'
|
|
189
|
-
algorithm?: 'ML-DSA-65'
|
|
189
|
+
algorithm?: 'ML-DSA-65' | 'ML-DSA-87'
|
|
190
190
|
manifestKid?: string // key identifier declared in the manifest
|
|
191
191
|
livePubkeyKid?: string // key identifier currently at the well-known endpoint
|
|
192
192
|
site?: string // site identifier declared by the manifest
|
|
@@ -212,22 +212,23 @@ These are exported for callers who want to compose their own verification logic.
|
|
|
212
212
|
|
|
213
213
|
#### `parseManifest(input) → ParseResult`
|
|
214
214
|
|
|
215
|
-
Parse and validate a raw manifest body without running signature verification.
|
|
215
|
+
Parse and validate a raw manifest body without running signature verification. `manifest.alg`, `signature.alg` and `publicKey.alg` must name the same parameter set, `ML-DSA-65` or `ML-DSA-87`, and the public key and signature must be exactly that set's sizes (1952 and 3309 bytes for ML-DSA-65, 2592 and 4627 for ML-DSA-87). A key of one set labelled as the other is refused.
|
|
216
216
|
|
|
217
217
|
```ts
|
|
218
218
|
parseManifest(input: string | object): ParseResult
|
|
219
219
|
// ParseResult is { ok: true; manifest: ParsedManifest } | { ok: false; error: ParseError }
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
-
#### `verifySignature(publicKey, message, signature) → boolean`
|
|
222
|
+
#### `verifySignature(publicKey, message, signature, alg?) → boolean`
|
|
223
223
|
|
|
224
|
-
Run ML-DSA-65 signature verification directly.
|
|
224
|
+
Run ML-DSA-65 or ML-DSA-87 signature verification directly. The public key decides the parameter set: 1952 bytes is ML-DSA-65 and 2592 is ML-DSA-87. Pass `alg` to require a set, and a key or signature of the other set returns `false`. An `alg` other than these two throws a `TypeError`.
|
|
225
225
|
|
|
226
226
|
```ts
|
|
227
227
|
verifySignature(
|
|
228
228
|
publicKey: Uint8Array | string, // hex or bytes
|
|
229
229
|
message: Uint8Array | string, // UTF-8 string or bytes
|
|
230
230
|
signature: Uint8Array | string, // hex or bytes
|
|
231
|
+
alg?: 'ML-DSA-65' | 'ML-DSA-87',
|
|
231
232
|
): boolean
|
|
232
233
|
```
|
|
233
234
|
|
package/bin/kxco-verify.js
CHANGED
|
@@ -126,7 +126,7 @@ function render(result, live, opts) {
|
|
|
126
126
|
|
|
127
127
|
// A valid result means one thing, and people read more into it than it says.
|
|
128
128
|
if (result.state === 'valid') {
|
|
129
|
-
console.log(`\n${ANSI.dim}This means the
|
|
129
|
+
console.log(`\n${ANSI.dim}This means the key this manifest publishes signed its signedMessage.`)
|
|
130
130
|
console.log(`It is not an endorsement of the site, its owner, or its content.${ANSI.reset}`)
|
|
131
131
|
}
|
|
132
132
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kxco-verify",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Free, browser-safe verifier for KXCO ML-DSA-65 post-quantum signatures, with no KXCO server in the trust path. An auditor, regulator or counterparty confirms a signature from the artefact alone, years later, and can check the key is still live against the on-chain registry.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"post-quantum",
|
|
@@ -83,8 +83,8 @@
|
|
|
83
83
|
"@noble/post-quantum": "0.7.0"
|
|
84
84
|
},
|
|
85
85
|
"scripts": {
|
|
86
|
-
"test": "node --test --test-reporter=spec test/parse.test.js test/verify.test.js test/fetch.test.js test/index.test.js",
|
|
87
|
-
"test:cov": "node --test --experimental-test-coverage test/parse.test.js test/verify.test.js test/fetch.test.js test/index.test.js",
|
|
86
|
+
"test": "node --test --test-reporter=spec test/parse.test.js test/verify.test.js test/fetch.test.js test/index.test.js test/ml-dsa-87.test.js test/property.test.js",
|
|
87
|
+
"test:cov": "node --test --experimental-test-coverage test/parse.test.js test/verify.test.js test/fetch.test.js test/index.test.js test/ml-dsa-87.test.js",
|
|
88
88
|
"smoke": "node scripts/smoke.js",
|
|
89
89
|
"lint": "node -c src/index.js && node -c src/parse.js && node -c src/verify.js && node -c src/fetch.js"
|
|
90
90
|
},
|
|
@@ -102,5 +102,9 @@
|
|
|
102
102
|
"kxco-pq-network": {
|
|
103
103
|
"optional": true
|
|
104
104
|
}
|
|
105
|
+
},
|
|
106
|
+
"devDependencies": {
|
|
107
|
+
"fast-check": "4.10.2",
|
|
108
|
+
"kxco-post-quantum": "1.7.7"
|
|
105
109
|
}
|
|
106
110
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface VerifyResultError {
|
|
|
11
11
|
|
|
12
12
|
export interface VerifyResult {
|
|
13
13
|
state: VerifyState
|
|
14
|
-
algorithm?: 'ML-DSA-65'
|
|
14
|
+
algorithm?: 'ML-DSA-65' | 'ML-DSA-87'
|
|
15
15
|
/** kid as declared inside the manifest itself */
|
|
16
16
|
manifestKid?: string
|
|
17
17
|
/** kid currently served at the live well-known pubkey endpoint (when fetched) */
|
package/src/index.js
CHANGED
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
// whether a site's post-quantum deploy attestation is mathematically valid.
|
|
6
6
|
//
|
|
7
7
|
// What it deliberately does NOT do:
|
|
8
|
-
// - Endorse any site. A "valid" result means "the
|
|
9
|
-
//
|
|
10
|
-
// whether its content
|
|
8
|
+
// - Endorse any site. A "valid" result means "the key this manifest
|
|
9
|
+
// publishes signed its signedMessage" (for KXCO manifests, the kid, commit
|
|
10
|
+
// and environment), nothing about who the site is or whether its content
|
|
11
|
+
// is trustworthy.
|
|
11
12
|
// - Maintain a registry of approved (domain, kid) pairs. That is a future
|
|
12
13
|
// phase of kxco-post-quantum / verify.kxco.ai and not in this library.
|
|
13
|
-
// - Speak any algorithm other than ML-DSA-65 with hex
|
|
14
|
-
// release. SLH-DSA-128s and hybrid envelopes are
|
|
14
|
+
// - Speak any algorithm other than ML-DSA-65 or ML-DSA-87 with hex
|
|
15
|
+
// encoding in this release. SLH-DSA-128s and hybrid envelopes are
|
|
16
|
+
// deferred.
|
|
15
17
|
//
|
|
16
18
|
// The result envelope is intentionally three-valued so the UI can distinguish
|
|
17
19
|
// signature failure from in-flight key rotation:
|
|
@@ -40,7 +42,7 @@ import { getJsonBody } from './fetch.js'
|
|
|
40
42
|
*
|
|
41
43
|
* @typedef {Object} VerifyResult
|
|
42
44
|
* @property {VerifyState} state
|
|
43
|
-
* @property {string=} algorithm —
|
|
45
|
+
* @property {string=} algorithm — "ML-DSA-65" or "ML-DSA-87"
|
|
44
46
|
* @property {string=} manifestKid — kid the manifest declared
|
|
45
47
|
* @property {string=} livePubkeyKid — kid currently served at the well-known endpoint (only present when fetched)
|
|
46
48
|
* @property {string=} site — site identifier as declared by the manifest
|
|
@@ -87,7 +89,10 @@ export async function verifyManifest(manifestBody) {
|
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
|
|
92
|
+
// The manifest's algorithm, which parseManifest has already bound to the
|
|
93
|
+
// key and signature sizes, is required here as well, so verification runs
|
|
94
|
+
// under that parameter set and no other.
|
|
95
|
+
const sigValid = verifySignature(m.publicKeyHex, m.signedMessage, m.signatureHex, m.alg)
|
|
91
96
|
return {
|
|
92
97
|
state: sigValid ? 'valid' : 'invalid',
|
|
93
98
|
algorithm: m.alg,
|
|
@@ -174,7 +179,7 @@ export async function verifyUrl(attestationUrl, opts = {}) {
|
|
|
174
179
|
|
|
175
180
|
// Two ways the publisher may serve the kid: explicit "kid" field, or we
|
|
176
181
|
// recompute from the publicKey hex. Prefer recompute as the source of truth.
|
|
177
|
-
const livePubkeyHex = typeof liveJson
|
|
182
|
+
const livePubkeyHex = typeof liveJson?.publicKey === 'string'
|
|
178
183
|
? liveJson.publicKey
|
|
179
184
|
: (typeof liveJson?.value === 'string' ? liveJson.value : null)
|
|
180
185
|
if (!livePubkeyHex) {
|
|
@@ -183,7 +188,14 @@ export async function verifyUrl(attestationUrl, opts = {}) {
|
|
|
183
188
|
result.verifiedAtMs = Date.now()
|
|
184
189
|
return result
|
|
185
190
|
}
|
|
186
|
-
|
|
191
|
+
let liveKid
|
|
192
|
+
try { liveKid = await computeKid(livePubkeyHex) }
|
|
193
|
+
catch (err) {
|
|
194
|
+
result.pubkeyUrl = pubkeyUrl
|
|
195
|
+
result.error = { kind: 'parse', code: 'invalid_live_pubkey', message: `live pubkey is not hex: ${err.message}`, soft: true }
|
|
196
|
+
result.verifiedAtMs = Date.now()
|
|
197
|
+
return result
|
|
198
|
+
}
|
|
187
199
|
result.pubkeyUrl = pubkeyUrl
|
|
188
200
|
result.livePubkeyKid = liveKid
|
|
189
201
|
|
package/src/parse.d.ts
CHANGED
package/src/parse.js
CHANGED
|
@@ -21,15 +21,27 @@
|
|
|
21
21
|
// "publicKey": { "alg": "ML-DSA-65", "encoding": "hex", "value": "<3904 hex chars>", "kid": "<same as manifest.kid>", "pinAt": "<relative URL>" }
|
|
22
22
|
// }
|
|
23
23
|
//
|
|
24
|
-
// We accept
|
|
25
|
-
// and
|
|
24
|
+
// We accept ML-DSA-65 and ML-DSA-87 with hex encoding. The three alg fields
|
|
25
|
+
// must name the same parameter set, and the public key and signature must be
|
|
26
|
+
// exactly that set's sizes, so the algorithm is bound to the key rather than
|
|
27
|
+
// taken from a label. SLH-DSA-128s and Ed25519+ML-DSA hybrid envelopes are
|
|
28
|
+
// reserved for later.
|
|
26
29
|
|
|
27
30
|
const HEX_RE = /^[0-9a-f]+$/i
|
|
28
31
|
|
|
32
|
+
// FIPS 204 sizes in bytes. A manifest whose key is the size of one set and
|
|
33
|
+
// whose alg names the other is refused here, before any verification.
|
|
34
|
+
const ML_DSA_SIZES = {
|
|
35
|
+
'ML-DSA-65': { publicKey: 1952, signature: 3309 },
|
|
36
|
+
'ML-DSA-87': { publicKey: 2592, signature: 4627 },
|
|
37
|
+
}
|
|
38
|
+
const SUPPORTED = Object.keys(ML_DSA_SIZES)
|
|
39
|
+
const isSupported = (alg) => typeof alg === 'string' && Object.hasOwn(ML_DSA_SIZES, alg)
|
|
40
|
+
|
|
29
41
|
/**
|
|
30
42
|
* @typedef {Object} ParsedManifest
|
|
31
43
|
* @property {string} site
|
|
32
|
-
* @property {string} alg —
|
|
44
|
+
* @property {string} alg — "ML-DSA-65" or "ML-DSA-87", matching the key and signature sizes
|
|
33
45
|
* @property {string} kid
|
|
34
46
|
* @property {string} signedMessage — the exact bytes the signature covers
|
|
35
47
|
* @property {string} signatureHex
|
|
@@ -60,6 +72,10 @@ export function parseManifest(input) {
|
|
|
60
72
|
if (typeof input === 'string') {
|
|
61
73
|
try { raw = JSON.parse(input) }
|
|
62
74
|
catch (err) { return err_('invalid_json', `body is not valid JSON: ${err.message}`) }
|
|
75
|
+
// Valid JSON is not yet a manifest. `null`, a number, a string or a
|
|
76
|
+
// boolean has no fields to read, so it is refused here like any other
|
|
77
|
+
// input that is not an object.
|
|
78
|
+
if (!raw || typeof raw !== 'object') return err_('invalid_input', 'body must be a JSON object')
|
|
63
79
|
} else if (input && typeof input === 'object') {
|
|
64
80
|
raw = input
|
|
65
81
|
} else {
|
|
@@ -74,11 +90,16 @@ export function parseManifest(input) {
|
|
|
74
90
|
if (!s || typeof s !== 'object') return err_('missing_field', 'top-level "signature" is missing or not an object', 'signature')
|
|
75
91
|
if (!pk || typeof pk !== 'object') return err_('missing_field', 'top-level "publicKey" is missing or not an object', 'publicKey')
|
|
76
92
|
|
|
77
|
-
// We
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
if (
|
|
93
|
+
// We verify ML-DSA-65 and ML-DSA-87 with hex-encoded signature + pubkey.
|
|
94
|
+
// Anything else is a feature we deferred to a later version.
|
|
95
|
+
const allowed = SUPPORTED.map((a) => `"${a}"`).join(' or ')
|
|
96
|
+
if (!isSupported(m.alg)) return err_('unsupported_algorithm', `manifest.alg must be ${allowed} (got ${JSON.stringify(m.alg)})`, 'manifest.alg')
|
|
97
|
+
if (!isSupported(s.alg)) return err_('unsupported_algorithm', `signature.alg must be ${allowed} (got ${JSON.stringify(s.alg)})`, 'signature.alg')
|
|
98
|
+
if (!isSupported(pk.alg)) return err_('unsupported_algorithm', `publicKey.alg must be ${allowed} (got ${JSON.stringify(pk.alg)})`, 'publicKey.alg')
|
|
99
|
+
// One parameter set per manifest: a signature or key labelled as the other
|
|
100
|
+
// set is a manifest that disagrees with itself.
|
|
101
|
+
if (s.alg !== m.alg) return err_('invalid_field', `signature.alg (${JSON.stringify(s.alg)}) must match manifest.alg (${JSON.stringify(m.alg)})`, 'signature.alg')
|
|
102
|
+
if (pk.alg !== m.alg) return err_('invalid_field', `publicKey.alg (${JSON.stringify(pk.alg)}) must match manifest.alg (${JSON.stringify(m.alg)})`, 'publicKey.alg')
|
|
82
103
|
if (s.encoding && s.encoding !== 'hex') return err_('unsupported_encoding', `signature.encoding must be "hex" (got ${JSON.stringify(s.encoding)})`, 'signature.encoding')
|
|
83
104
|
if (pk.encoding && pk.encoding !== 'hex') return err_('unsupported_encoding', `publicKey.encoding must be "hex" (got ${JSON.stringify(pk.encoding)})`, 'publicKey.encoding')
|
|
84
105
|
|
|
@@ -89,10 +110,12 @@ export function parseManifest(input) {
|
|
|
89
110
|
if (typeof pk.value !== 'string' || !HEX_RE.test(pk.value)) return err_('invalid_field', 'publicKey.value must be a hex string', 'publicKey.value')
|
|
90
111
|
if (typeof pk.kid !== 'string' || !HEX_RE.test(pk.kid)) return err_('invalid_field', 'publicKey.kid must be a hex string', 'publicKey.kid')
|
|
91
112
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
113
|
+
// Size sanity, per parameter set. Don't trust the label saying which set it
|
|
114
|
+
// is: the byte counts must be that set's, so an ML-DSA-65 key labelled
|
|
115
|
+
// ML-DSA-87 (or the reverse) is refused here. Catches malformed payloads early.
|
|
116
|
+
const size = ML_DSA_SIZES[m.alg]
|
|
117
|
+
if (pk.value.length !== size.publicKey * 2) return err_('invalid_field', `publicKey.value must be ${size.publicKey} bytes (${size.publicKey * 2} hex chars) for ${m.alg}; got ${pk.value.length / 2}`, 'publicKey.value')
|
|
118
|
+
if (s.value.length !== size.signature * 2) return err_('invalid_field', `signature.value must be ${size.signature} bytes (${size.signature * 2} hex chars) for ${m.alg}; got ${s.value.length / 2}`, 'signature.value')
|
|
96
119
|
|
|
97
120
|
return {
|
|
98
121
|
ok: true,
|
package/src/verify.d.ts
CHANGED
|
@@ -13,11 +13,18 @@ export function utf8(s: string): Uint8Array
|
|
|
13
13
|
*/
|
|
14
14
|
export function computeKid(publicKey: Uint8Array | string): Promise<string>
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Verify an ML-DSA-65 or ML-DSA-87 signature.
|
|
18
|
+
*
|
|
19
|
+
* The public key decides the parameter set: 1952 bytes is ML-DSA-65, 2592 is
|
|
20
|
+
* ML-DSA-87. Pass `alg` to require a set; a key or signature that is not that
|
|
21
|
+
* set's size returns false. An unsupported `alg` throws a TypeError.
|
|
22
|
+
*/
|
|
17
23
|
export function verifySignature(
|
|
18
24
|
publicKey: Uint8Array | string,
|
|
19
25
|
message: Uint8Array | string,
|
|
20
26
|
signature: Uint8Array | string,
|
|
27
|
+
alg?: 'ML-DSA-65' | 'ML-DSA-87',
|
|
21
28
|
): boolean
|
|
22
29
|
|
|
23
30
|
/** Length-equal hex comparison. */
|
package/src/verify.js
CHANGED
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
// from the Web Crypto API where available (browser, Node 20+), fallback to
|
|
6
6
|
// node:crypto's createHash where SubtleCrypto.digest is not present.
|
|
7
7
|
|
|
8
|
-
import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js'
|
|
8
|
+
import { ml_dsa65, ml_dsa87 } from '@noble/post-quantum/ml-dsa.js'
|
|
9
|
+
|
|
10
|
+
// The parameter sets this verifier speaks, with their FIPS 204 public-key sizes.
|
|
11
|
+
// The key decides which one a signature is checked under: its length picks
|
|
12
|
+
// the set, and a key that is not exactly a set's size verifies nothing.
|
|
13
|
+
const ML_DSA = {
|
|
14
|
+
'ML-DSA-65': { primitive: ml_dsa65, publicKey: 1952 },
|
|
15
|
+
'ML-DSA-87': { primitive: ml_dsa87, publicKey: 2592 },
|
|
16
|
+
}
|
|
9
17
|
|
|
10
18
|
/**
|
|
11
19
|
* Hex string → Uint8Array. Throws on malformed input.
|
|
@@ -16,12 +24,12 @@ export function hexToBytes(hex) {
|
|
|
16
24
|
if (typeof hex !== 'string' || hex.length % 2 !== 0) {
|
|
17
25
|
throw new TypeError('hex input must be a string of even length')
|
|
18
26
|
}
|
|
27
|
+
// Checked up front because parseInt reads a prefix: on its own it would take
|
|
28
|
+
// '0g' as 0, '+a' as 10 and '-1' as 255, giving one byte several spellings.
|
|
29
|
+
const bad = hex.search(/[^0-9a-fA-F]/)
|
|
30
|
+
if (bad !== -1) throw new TypeError(`malformed hex at offset ${bad}`)
|
|
19
31
|
const out = new Uint8Array(hex.length / 2)
|
|
20
|
-
for (let i = 0; i < out.length; i++)
|
|
21
|
-
const byte = parseInt(hex.substr(i * 2, 2), 16)
|
|
22
|
-
if (Number.isNaN(byte)) throw new TypeError(`malformed hex at offset ${i * 2}`)
|
|
23
|
-
out[i] = byte
|
|
24
|
-
}
|
|
32
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16)
|
|
25
33
|
return out
|
|
26
34
|
}
|
|
27
35
|
|
|
@@ -68,21 +76,41 @@ export async function computeKid(publicKey) {
|
|
|
68
76
|
return bytesToHex(hashBytes.subarray(0, 8))
|
|
69
77
|
}
|
|
70
78
|
|
|
79
|
+
// The ML-DSA parameter set a public key's bytes belong to, by length, or null.
|
|
80
|
+
const algorithmOfKey = (pk) => Object.keys(ML_DSA).find((alg) => ML_DSA[alg].publicKey === pk?.length) ?? null
|
|
81
|
+
|
|
71
82
|
/**
|
|
72
|
-
* Verify an ML-DSA-65 signature.
|
|
73
|
-
*
|
|
83
|
+
* Verify an ML-DSA-65 or ML-DSA-87 signature.
|
|
84
|
+
*
|
|
85
|
+
* The public key decides the parameter set: 1952 bytes is ML-DSA-65 and 2592
|
|
86
|
+
* is ML-DSA-87. Pass `alg` to require a set; a key or signature that is not
|
|
87
|
+
* that set's size then returns false, so a key of one set is never checked
|
|
88
|
+
* as the other. An `alg` this verifier does not speak throws, as malformed
|
|
89
|
+
* hex does, because it is a caller error rather than a failed signature.
|
|
90
|
+
*
|
|
91
|
+
* @param {string|Uint8Array} publicKey — 1952 or 2592 bytes (3904 or 5184 hex chars)
|
|
74
92
|
* @param {string|Uint8Array} message — string (utf8'd) or raw bytes
|
|
75
|
-
* @param {string|Uint8Array} signature — 3309 bytes (6618 hex chars)
|
|
93
|
+
* @param {string|Uint8Array} signature — 3309 or 4627 bytes (6618 or 9254 hex chars)
|
|
94
|
+
* @param {'ML-DSA-65'|'ML-DSA-87'} [alg] — the set the caller requires
|
|
76
95
|
* @returns {boolean}
|
|
77
96
|
*/
|
|
78
|
-
export function verifySignature(publicKey, message, signature) {
|
|
97
|
+
export function verifySignature(publicKey, message, signature, alg) {
|
|
98
|
+
if (alg !== undefined && !(typeof alg === 'string' && Object.hasOwn(ML_DSA, alg))) {
|
|
99
|
+
throw new TypeError(`unsupported algorithm ${JSON.stringify(alg)}: expected "ML-DSA-65" or "ML-DSA-87"`)
|
|
100
|
+
}
|
|
79
101
|
const pk = typeof publicKey === 'string' ? hexToBytes(publicKey) : publicKey
|
|
80
102
|
const sig = typeof signature === 'string' ? hexToBytes(signature) : signature
|
|
81
103
|
const msg = typeof message === 'string' ? utf8(message) : message
|
|
104
|
+
const set = ML_DSA[alg ?? algorithmOfKey(pk)]
|
|
105
|
+
if (!set) return false
|
|
106
|
+
// Checked here, not left to the primitive: a named set must match the key.
|
|
107
|
+
if (pk?.length !== set.publicKey) return false
|
|
82
108
|
// @noble/post-quantum ≥0.6 signature order: (signature, message, publicKey).
|
|
83
|
-
// Matches the canonical wrapper in kxco-post-quantum/src/ml-dsa.js.
|
|
109
|
+
// Matches the canonical wrapper in kxco-post-quantum/src/ml-dsa.js. The
|
|
110
|
+
// primitive refuses a key or signature that is not its set's length, so a
|
|
111
|
+
// key of one set is never checked as the other.
|
|
84
112
|
try {
|
|
85
|
-
return
|
|
113
|
+
return set.primitive.verify(sig, msg, pk)
|
|
86
114
|
} catch {
|
|
87
115
|
return false
|
|
88
116
|
}
|