kxco-verify 1.3.3 → 1.4.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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.1
4
+
5
+ Documentation. No source change.
6
+
7
+ The package description, the opening of the README and the keywords now say
8
+ what 1.4.0 already does: it verifies ML-DSA-87 signatures as well as ML-DSA-65.
9
+ The description now fits the 255 characters the npm registry keeps, so it no
10
+ longer ends mid-sentence. `ml-dsa-87` joins the keywords.
11
+
12
+ ## 1.4.0
13
+ ML-DSA-87 manifests verify alongside ML-DSA-65. parseManifest accepts
14
+ `"ML-DSA-87"` where all three alg fields name it, and requires the public key
15
+ and signature to be exactly that set's sizes: 2592 and 4627 bytes for
16
+ ML-DSA-87, 1952 and 3309 for ML-DSA-65. A manifest whose alg fields disagree,
17
+ or whose key is the size of the other set, is refused with `invalid_field`.
18
+ verifyManifest verifies under the manifest's set and reports it as
19
+ `algorithm`.
20
+
21
+ verifySignature takes an optional fourth argument, `alg`. Without it the public
22
+ key decides the set by its length; with it, a key or signature of the other set
23
+ returns false, and an algorithm it does not speak throws a TypeError. Every
24
+ ML-DSA-65 manifest and call that verified before verifies unchanged.
25
+
26
+ The ML-DSA-87 path uses `ml_dsa87` from the same exactly pinned
27
+ `@noble/post-quantum` 0.7.0, so the runtime dependency is unchanged.
28
+
3
29
  ## 1.3.3
4
30
 
5
31
  A manifest body that is valid JSON but not an object is refused with
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![node](https://img.shields.io/node/v/kxco-verify.svg)](https://nodejs.org)
12
12
  [![verify.kxco.ai](https://img.shields.io/badge/verify.kxco.ai-live-22c55e)](https://verify.kxco.ai)
13
13
 
14
- Standalone post-quantum credential and attestation verifier for KXCO ML-DSA-65 signed documents. One dependency, pinned exactly. Works in any modern browser and in Node.js 20.19 and later.
14
+ Standalone post-quantum credential and attestation verifier for KXCO ML-DSA-87 and ML-DSA-65 signed documents. One dependency, pinned exactly. Works in any modern browser and in Node.js 20.19 and later.
15
15
 
16
16
  - **No KXCO server in the trust path.** `verifyManifest()` makes no network request at all, and `verifyUrl()` contacts only the attestation URL you pass and the key endpoint the manifest names.
17
17
  - **Runs in the browser as shipped.** No `Buffer`, no `process` and SHA-256 from WebCrypto, which is how [verify.kxco.ai](https://verify.kxco.ai) checks a signature entirely in the tab.
@@ -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
 
@@ -321,7 +322,7 @@ version, and every GitHub Action is pinned by commit SHA.
321
322
 
322
323
  ## Security
323
324
 
324
- **ML-DSA-65** (NIST FIPS 204) via [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) 0.7.0, pinned exactly. No custom cryptography.
325
+ **ML-DSA-87** and **ML-DSA-65** (NIST FIPS 204) via [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) 0.7.0, pinned exactly. No custom cryptography.
325
326
 
326
327
  Evidenced, and reproducible on your own machine:
327
328
 
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "kxco-verify",
3
- "version": "1.3.3",
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.",
3
+ "version": "1.4.1",
4
+ "description": "Free, browser-safe verifier for KXCO ML-DSA-87 and ML-DSA-65 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 on chain.",
5
5
  "keywords": [
6
6
  "post-quantum",
7
7
  "pqc",
8
8
  "ml-dsa",
9
+ "ml-dsa-87",
9
10
  "dilithium",
10
11
  "nist",
11
12
  "fips-204",
@@ -83,8 +84,8 @@
83
84
  "@noble/post-quantum": "0.7.0"
84
85
  },
85
86
  "scripts": {
86
- "test": "node --test --test-reporter=spec test/parse.test.js test/verify.test.js test/fetch.test.js test/index.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",
87
+ "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",
88
+ "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
89
  "smoke": "node scripts/smoke.js",
89
90
  "lint": "node -c src/index.js && node -c src/parse.js && node -c src/verify.js && node -c src/fetch.js"
90
91
  },
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
@@ -11,8 +11,9 @@
11
11
  // is trustworthy.
12
12
  // - Maintain a registry of approved (domain, kid) pairs. That is a future
13
13
  // phase of kxco-post-quantum / verify.kxco.ai and not in this library.
14
- // - Speak any algorithm other than ML-DSA-65 with hex encoding in this
15
- // 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.
16
17
  //
17
18
  // The result envelope is intentionally three-valued so the UI can distinguish
18
19
  // signature failure from in-flight key rotation:
@@ -41,7 +42,7 @@ import { getJsonBody } from './fetch.js'
41
42
  *
42
43
  * @typedef {Object} VerifyResult
43
44
  * @property {VerifyState} state
44
- * @property {string=} algorithm — e.g. "ML-DSA-65"
45
+ * @property {string=} algorithm — "ML-DSA-65" or "ML-DSA-87"
45
46
  * @property {string=} manifestKid — kid the manifest declared
46
47
  * @property {string=} livePubkeyKid — kid currently served at the well-known endpoint (only present when fetched)
47
48
  * @property {string=} site — site identifier as declared by the manifest
@@ -88,7 +89,10 @@ export async function verifyManifest(manifestBody) {
88
89
  }
89
90
  }
90
91
 
91
- 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)
92
96
  return {
93
97
  state: sigValid ? 'valid' : 'invalid',
94
98
  algorithm: m.alg,
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
@@ -78,11 +90,16 @@ export function parseManifest(input) {
78
90
  if (!s || typeof s !== 'object') return err_('missing_field', 'top-level "signature" is missing or not an object', 'signature')
79
91
  if (!pk || typeof pk !== 'object') return err_('missing_field', 'top-level "publicKey" is missing or not an object', 'publicKey')
80
92
 
81
- // We only verify ML-DSA-65 with hex-encoded signature + pubkey in this
82
- // release. Anything else is a feature we deferred to a later version.
83
- if (m.alg !== 'ML-DSA-65') return err_('unsupported_algorithm', `manifest.alg must be "ML-DSA-65" (got ${JSON.stringify(m.alg)})`, 'manifest.alg')
84
- if (s.alg !== 'ML-DSA-65') return err_('unsupported_algorithm', `signature.alg must be "ML-DSA-65" (got ${JSON.stringify(s.alg)})`, 'signature.alg')
85
- 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')
86
103
  if (s.encoding && s.encoding !== 'hex') return err_('unsupported_encoding', `signature.encoding must be "hex" (got ${JSON.stringify(s.encoding)})`, 'signature.encoding')
87
104
  if (pk.encoding && pk.encoding !== 'hex') return err_('unsupported_encoding', `publicKey.encoding must be "hex" (got ${JSON.stringify(pk.encoding)})`, 'publicKey.encoding')
88
105
 
@@ -93,10 +110,12 @@ export function parseManifest(input) {
93
110
  if (typeof pk.value !== 'string' || !HEX_RE.test(pk.value)) return err_('invalid_field', 'publicKey.value must be a hex string', 'publicKey.value')
94
111
  if (typeof pk.kid !== 'string' || !HEX_RE.test(pk.kid)) return err_('invalid_field', 'publicKey.kid must be a hex string', 'publicKey.kid')
95
112
 
96
- // ML-DSA-65 size sanity. Don't trust the message saying "ML-DSA-65" —
97
- // verify the byte counts match the spec. Catches malformed payloads early.
98
- 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')
99
- 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')
100
119
 
101
120
  return {
102
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.
@@ -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
  }