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 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 site signed its own manifest with a key it published.
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 site signed this manifest with the key it publishes. Who operates the site is a separate question, covered in [`docs/threat-model.md`](./docs/threat-model.md).
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 signature, 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.
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
 
@@ -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 site signed its own manifest with a key it published.`)
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.2",
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 site signed its own
9
- // manifest with a key it published" — nothing about who the site is or
10
- // whether its content is trustworthy.
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 encoding in this
14
- // release. SLH-DSA-128s and hybrid envelopes are deferred.
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 — e.g. "ML-DSA-65"
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
- const sigValid = verifySignature(m.publicKeyHex, m.signedMessage, m.signatureHex)
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.publicKey === 'string'
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
- const liveKid = await computeKid(livePubkeyHex)
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
@@ -1,6 +1,6 @@
1
1
  export interface ParsedManifest {
2
2
  site: string
3
- alg: 'ML-DSA-65'
3
+ alg: 'ML-DSA-65' | 'ML-DSA-87'
4
4
  kid: string
5
5
  signedMessage: string
6
6
  signatureHex: string
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 only ML-DSA-65 with hex encoding in this version. SLH-DSA-128s
25
- // and Ed25519+ML-DSA hybrid envelopes are reserved for later.
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 — must be "ML-DSA-65" in this version
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 only verify ML-DSA-65 with hex-encoded signature + pubkey in this
78
- // release. Anything else is a feature we deferred to a later version.
79
- if (m.alg !== 'ML-DSA-65') return err_('unsupported_algorithm', `manifest.alg must be "ML-DSA-65" (got ${JSON.stringify(m.alg)})`, 'manifest.alg')
80
- if (s.alg !== 'ML-DSA-65') return err_('unsupported_algorithm', `signature.alg must be "ML-DSA-65" (got ${JSON.stringify(s.alg)})`, 'signature.alg')
81
- if (pk.alg !== 'ML-DSA-65') return err_('unsupported_algorithm', `publicKey.alg must be "ML-DSA-65" (got ${JSON.stringify(pk.alg)})`, 'publicKey.alg')
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
- // ML-DSA-65 size sanity. Don't trust the message saying "ML-DSA-65" —
93
- // verify the byte counts match the spec. Catches malformed payloads early.
94
- if (pk.value.length !== 3904) return err_('invalid_field', `publicKey.value must be 1952 bytes (3904 hex chars); got ${pk.value.length / 2}`, 'publicKey.value')
95
- if (s.value.length !== 6618) return err_('invalid_field', `signature.value must be 3309 bytes (6618 hex chars); got ${s.value.length / 2}`, 'signature.value')
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
- /** Verify an ML-DSA-65 signature. */
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
- * @param {string|Uint8Array} publicKey — 1952 bytes (3904 hex chars)
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 ml_dsa65.verify(sig, msg, pk)
113
+ return set.primitive.verify(sig, msg, pk)
86
114
  } catch {
87
115
  return false
88
116
  }