kxco-verify 1.2.0 → 1.2.2

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/src/parse.js CHANGED
@@ -1,116 +1,116 @@
1
- // Manifest parsing.
2
- //
3
- // Takes the raw JSON body served by an `/api/attestation`-style endpoint and
4
- // returns a normalised, typed-ish shape that the verifier can work with —
5
- // or a structured error if the shape is wrong.
6
- //
7
- // The reference manifest shape is what target150.com/api/attestation and
8
- // chain.kxco.ai/wallet/api/.well-known/kxco-pq-attestation emit today:
9
- //
10
- // {
11
- // "manifest": {
12
- // "site": "example.com",
13
- // "alg": "ML-DSA-65",
14
- // "spec": "NIST FIPS 204",
15
- // "kid": "<16-hex-char fingerprint>",
16
- // "deployment": { ...site-defined fields... },
17
- // "msgFormat": "<template describing what was signed>"
18
- // },
19
- // "signedMessage": "<the actual bytes that were signed, as a string>",
20
- // "signature": { "alg": "ML-DSA-65", "encoding": "hex", "value": "<6618 hex chars>" },
21
- // "publicKey": { "alg": "ML-DSA-65", "encoding": "hex", "value": "<3904 hex chars>", "kid": "<same as manifest.kid>", "pinAt": "<relative URL>" }
22
- // }
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.
26
-
27
- const HEX_RE = /^[0-9a-f]+$/i
28
-
29
- /**
30
- * @typedef {Object} ParsedManifest
31
- * @property {string} site
32
- * @property {string} alg — must be "ML-DSA-65" in this version
33
- * @property {string} kid
34
- * @property {string} signedMessage — the exact bytes the signature covers
35
- * @property {string} signatureHex
36
- * @property {string} publicKeyHex
37
- * @property {string} publicKeyKid — kid as declared inside the publicKey block
38
- * @property {string=} pinAt — relative path where the publisher recommends re-fetching the pubkey
39
- * @property {object=} deployment — site-defined metadata, opaque to us
40
- * @property {object} raw — the parsed JSON in its entirety, for the UI to display
41
- */
42
-
43
- /**
44
- * @typedef {Object} ParseError
45
- * @property {'parse'} kind
46
- * @property {string} code — short stable identifier (e.g. "missing_field")
47
- * @property {string} message
48
- * @property {string=} field
49
- */
50
-
51
- /**
52
- * Parse an attestation manifest from a JSON body. Returns either
53
- * { ok: true, manifest } or { ok: false, error }.
54
- *
55
- * @param {string|object} input — raw JSON string OR an already-parsed object
56
- * @returns {{ ok: true, manifest: ParsedManifest } | { ok: false, error: ParseError }}
57
- */
58
- export function parseManifest(input) {
59
- let raw
60
- if (typeof input === 'string') {
61
- try { raw = JSON.parse(input) }
62
- catch (err) { return err_('invalid_json', `body is not valid JSON: ${err.message}`) }
63
- } else if (input && typeof input === 'object') {
64
- raw = input
65
- } else {
66
- return err_('invalid_input', 'input must be a JSON string or an object')
67
- }
68
-
69
- const m = raw.manifest
70
- const s = raw.signature
71
- const pk = raw.publicKey
72
-
73
- if (!m || typeof m !== 'object') return err_('missing_field', 'top-level "manifest" is missing or not an object', 'manifest')
74
- if (!s || typeof s !== 'object') return err_('missing_field', 'top-level "signature" is missing or not an object', 'signature')
75
- if (!pk || typeof pk !== 'object') return err_('missing_field', 'top-level "publicKey" is missing or not an object', 'publicKey')
76
-
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')
82
- if (s.encoding && s.encoding !== 'hex') return err_('unsupported_encoding', `signature.encoding must be "hex" (got ${JSON.stringify(s.encoding)})`, 'signature.encoding')
83
- if (pk.encoding && pk.encoding !== 'hex') return err_('unsupported_encoding', `publicKey.encoding must be "hex" (got ${JSON.stringify(pk.encoding)})`, 'publicKey.encoding')
84
-
85
- if (typeof m.kid !== 'string' || !HEX_RE.test(m.kid)) return err_('invalid_field', 'manifest.kid must be a hex string', 'manifest.kid')
86
- if (typeof m.site !== 'string' || !m.site.length) return err_('invalid_field', 'manifest.site must be a non-empty string', 'manifest.site')
87
- if (typeof raw.signedMessage !== 'string') return err_('missing_field', 'top-level "signedMessage" must be a string', 'signedMessage')
88
- if (typeof s.value !== 'string' || !HEX_RE.test(s.value)) return err_('invalid_field', 'signature.value must be a hex string', 'signature.value')
89
- if (typeof pk.value !== 'string' || !HEX_RE.test(pk.value)) return err_('invalid_field', 'publicKey.value must be a hex string', 'publicKey.value')
90
- if (typeof pk.kid !== 'string' || !HEX_RE.test(pk.kid)) return err_('invalid_field', 'publicKey.kid must be a hex string', 'publicKey.kid')
91
-
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')
96
-
97
- return {
98
- ok: true,
99
- manifest: {
100
- site: m.site,
101
- alg: m.alg,
102
- kid: m.kid.toLowerCase(),
103
- signedMessage: raw.signedMessage,
104
- signatureHex: s.value.toLowerCase(),
105
- publicKeyHex: pk.value.toLowerCase(),
106
- publicKeyKid: pk.kid.toLowerCase(),
107
- pinAt: typeof pk.pinAt === 'string' ? pk.pinAt : undefined,
108
- deployment: m.deployment && typeof m.deployment === 'object' ? m.deployment : undefined,
109
- raw,
110
- },
111
- }
112
- }
113
-
114
- function err_(code, message, field) {
115
- return { ok: false, error: { kind: 'parse', code, message, field } }
116
- }
1
+ // Manifest parsing.
2
+ //
3
+ // Takes the raw JSON body served by an `/api/attestation`-style endpoint and
4
+ // returns a normalised, typed-ish shape that the verifier can work with —
5
+ // or a structured error if the shape is wrong.
6
+ //
7
+ // The reference manifest shape is what target150.com/api/attestation and
8
+ // chain.kxco.ai/wallet/api/.well-known/kxco-pq-attestation emit today:
9
+ //
10
+ // {
11
+ // "manifest": {
12
+ // "site": "example.com",
13
+ // "alg": "ML-DSA-65",
14
+ // "spec": "NIST FIPS 204",
15
+ // "kid": "<16-hex-char fingerprint>",
16
+ // "deployment": { ...site-defined fields... },
17
+ // "msgFormat": "<template describing what was signed>"
18
+ // },
19
+ // "signedMessage": "<the actual bytes that were signed, as a string>",
20
+ // "signature": { "alg": "ML-DSA-65", "encoding": "hex", "value": "<6618 hex chars>" },
21
+ // "publicKey": { "alg": "ML-DSA-65", "encoding": "hex", "value": "<3904 hex chars>", "kid": "<same as manifest.kid>", "pinAt": "<relative URL>" }
22
+ // }
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.
26
+
27
+ const HEX_RE = /^[0-9a-f]+$/i
28
+
29
+ /**
30
+ * @typedef {Object} ParsedManifest
31
+ * @property {string} site
32
+ * @property {string} alg — must be "ML-DSA-65" in this version
33
+ * @property {string} kid
34
+ * @property {string} signedMessage — the exact bytes the signature covers
35
+ * @property {string} signatureHex
36
+ * @property {string} publicKeyHex
37
+ * @property {string} publicKeyKid — kid as declared inside the publicKey block
38
+ * @property {string=} pinAt — relative path where the publisher recommends re-fetching the pubkey
39
+ * @property {object=} deployment — site-defined metadata, opaque to us
40
+ * @property {object} raw — the parsed JSON in its entirety, for the UI to display
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} ParseError
45
+ * @property {'parse'} kind
46
+ * @property {string} code — short stable identifier (e.g. "missing_field")
47
+ * @property {string} message
48
+ * @property {string=} field
49
+ */
50
+
51
+ /**
52
+ * Parse an attestation manifest from a JSON body. Returns either
53
+ * { ok: true, manifest } or { ok: false, error }.
54
+ *
55
+ * @param {string|object} input — raw JSON string OR an already-parsed object
56
+ * @returns {{ ok: true, manifest: ParsedManifest } | { ok: false, error: ParseError }}
57
+ */
58
+ export function parseManifest(input) {
59
+ let raw
60
+ if (typeof input === 'string') {
61
+ try { raw = JSON.parse(input) }
62
+ catch (err) { return err_('invalid_json', `body is not valid JSON: ${err.message}`) }
63
+ } else if (input && typeof input === 'object') {
64
+ raw = input
65
+ } else {
66
+ return err_('invalid_input', 'input must be a JSON string or an object')
67
+ }
68
+
69
+ const m = raw.manifest
70
+ const s = raw.signature
71
+ const pk = raw.publicKey
72
+
73
+ if (!m || typeof m !== 'object') return err_('missing_field', 'top-level "manifest" is missing or not an object', 'manifest')
74
+ if (!s || typeof s !== 'object') return err_('missing_field', 'top-level "signature" is missing or not an object', 'signature')
75
+ if (!pk || typeof pk !== 'object') return err_('missing_field', 'top-level "publicKey" is missing or not an object', 'publicKey')
76
+
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')
82
+ if (s.encoding && s.encoding !== 'hex') return err_('unsupported_encoding', `signature.encoding must be "hex" (got ${JSON.stringify(s.encoding)})`, 'signature.encoding')
83
+ if (pk.encoding && pk.encoding !== 'hex') return err_('unsupported_encoding', `publicKey.encoding must be "hex" (got ${JSON.stringify(pk.encoding)})`, 'publicKey.encoding')
84
+
85
+ if (typeof m.kid !== 'string' || !HEX_RE.test(m.kid)) return err_('invalid_field', 'manifest.kid must be a hex string', 'manifest.kid')
86
+ if (typeof m.site !== 'string' || !m.site.length) return err_('invalid_field', 'manifest.site must be a non-empty string', 'manifest.site')
87
+ if (typeof raw.signedMessage !== 'string') return err_('missing_field', 'top-level "signedMessage" must be a string', 'signedMessage')
88
+ if (typeof s.value !== 'string' || !HEX_RE.test(s.value)) return err_('invalid_field', 'signature.value must be a hex string', 'signature.value')
89
+ if (typeof pk.value !== 'string' || !HEX_RE.test(pk.value)) return err_('invalid_field', 'publicKey.value must be a hex string', 'publicKey.value')
90
+ if (typeof pk.kid !== 'string' || !HEX_RE.test(pk.kid)) return err_('invalid_field', 'publicKey.kid must be a hex string', 'publicKey.kid')
91
+
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')
96
+
97
+ return {
98
+ ok: true,
99
+ manifest: {
100
+ site: m.site,
101
+ alg: m.alg,
102
+ kid: m.kid.toLowerCase(),
103
+ signedMessage: raw.signedMessage,
104
+ signatureHex: s.value.toLowerCase(),
105
+ publicKeyHex: pk.value.toLowerCase(),
106
+ publicKeyKid: pk.kid.toLowerCase(),
107
+ pinAt: typeof pk.pinAt === 'string' ? pk.pinAt : undefined,
108
+ deployment: m.deployment && typeof m.deployment === 'object' ? m.deployment : undefined,
109
+ raw,
110
+ },
111
+ }
112
+ }
113
+
114
+ function err_(code, message, field) {
115
+ return { ok: false, error: { kind: 'parse', code, message, field } }
116
+ }
package/src/verify.d.ts CHANGED
@@ -1,24 +1,24 @@
1
- /** Hex string → Uint8Array. Throws on malformed input. */
2
- export function hexToBytes(hex: string): Uint8Array
3
-
4
- /** Uint8Array → hex string (lowercase). */
5
- export function bytesToHex(bytes: Uint8Array): string
6
-
7
- /** UTF-8 string → Uint8Array. */
8
- export function utf8(s: string): Uint8Array
9
-
10
- /**
11
- * Compute the kxco kid (key identifier) of a public key —
12
- * first 16 hex chars of SHA-256(rawBytes).
13
- */
14
- export function computeKid(publicKey: Uint8Array | string): Promise<string>
15
-
16
- /** Verify an ML-DSA-65 signature. */
17
- export function verifySignature(
18
- publicKey: Uint8Array | string,
19
- message: Uint8Array | string,
20
- signature: Uint8Array | string,
21
- ): boolean
22
-
23
- /** Length-equal hex comparison. */
24
- export function hexEquals(a: string, b: string): boolean
1
+ /** Hex string → Uint8Array. Throws on malformed input. */
2
+ export function hexToBytes(hex: string): Uint8Array
3
+
4
+ /** Uint8Array → hex string (lowercase). */
5
+ export function bytesToHex(bytes: Uint8Array): string
6
+
7
+ /** UTF-8 string → Uint8Array. */
8
+ export function utf8(s: string): Uint8Array
9
+
10
+ /**
11
+ * Compute the kxco kid (key identifier) of a public key —
12
+ * first 16 hex chars of SHA-256(rawBytes).
13
+ */
14
+ export function computeKid(publicKey: Uint8Array | string): Promise<string>
15
+
16
+ /** Verify an ML-DSA-65 signature. */
17
+ export function verifySignature(
18
+ publicKey: Uint8Array | string,
19
+ message: Uint8Array | string,
20
+ signature: Uint8Array | string,
21
+ ): boolean
22
+
23
+ /** Length-equal hex comparison. */
24
+ export function hexEquals(a: string, b: string): boolean
package/src/verify.js CHANGED
@@ -1,103 +1,103 @@
1
- // Signature math + kid math. Pure functions. No network, no I/O.
2
- //
3
- // We call @noble/post-quantum directly with Uint8Array so the same code runs
4
- // in Node 18+ and in any modern browser without a polyfill. SHA-256 is taken
5
- // from the Web Crypto API where available (browser, Node 20+), fallback to
6
- // node:crypto's createHash where SubtleCrypto.digest is not present.
7
-
8
- import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js'
9
-
10
- /**
11
- * Hex string → Uint8Array. Throws on malformed input.
12
- * @param {string} hex
13
- * @returns {Uint8Array}
14
- */
15
- export function hexToBytes(hex) {
16
- if (typeof hex !== 'string' || hex.length % 2 !== 0) {
17
- throw new TypeError('hex input must be a string of even length')
18
- }
19
- 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
- }
25
- return out
26
- }
27
-
28
- /**
29
- * Uint8Array → hex string (lowercase).
30
- * @param {Uint8Array} bytes
31
- * @returns {string}
32
- */
33
- export function bytesToHex(bytes) {
34
- let out = ''
35
- for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, '0')
36
- return out
37
- }
38
-
39
- /**
40
- * UTF-8 string → Uint8Array.
41
- * @param {string} s
42
- * @returns {Uint8Array}
43
- */
44
- export function utf8(s) {
45
- return new TextEncoder().encode(s)
46
- }
47
-
48
- /**
49
- * Compute the kxco kid (key identifier) of a public key — first 16 hex chars
50
- * of SHA-256(rawBytes). Matches the algorithm in kxco-post-quantum's
51
- * `fingerprint()`. Async because SubtleCrypto.digest is async.
52
- *
53
- * @param {Uint8Array|string} publicKey — raw bytes or hex string
54
- * @returns {Promise<string>} 16-char lowercase hex
55
- */
56
- export async function computeKid(publicKey) {
57
- const bytes = typeof publicKey === 'string' ? hexToBytes(publicKey) : publicKey
58
- const subtle = globalThis.crypto && globalThis.crypto.subtle
59
- let hashBytes
60
- if (subtle && typeof subtle.digest === 'function') {
61
- const ab = await subtle.digest('SHA-256', bytes)
62
- hashBytes = new Uint8Array(ab)
63
- } else {
64
- // Node fallback. Only reached on Node <20 without globalThis.crypto.
65
- const { createHash } = await import('node:crypto')
66
- hashBytes = createHash('sha256').update(bytes).digest()
67
- }
68
- return bytesToHex(hashBytes.subarray(0, 8))
69
- }
70
-
71
- /**
72
- * Verify an ML-DSA-65 signature.
73
- * @param {string|Uint8Array} publicKey — 1952 bytes (3904 hex chars)
74
- * @param {string|Uint8Array} message — string (utf8'd) or raw bytes
75
- * @param {string|Uint8Array} signature — 3309 bytes (6618 hex chars)
76
- * @returns {boolean}
77
- */
78
- export function verifySignature(publicKey, message, signature) {
79
- const pk = typeof publicKey === 'string' ? hexToBytes(publicKey) : publicKey
80
- const sig = typeof signature === 'string' ? hexToBytes(signature) : signature
81
- const msg = typeof message === 'string' ? utf8(message) : message
82
- // @noble/post-quantum ≥0.6 signature order: (signature, message, publicKey).
83
- // Matches the canonical wrapper in kxco-post-quantum/src/ml-dsa.js.
84
- try {
85
- return ml_dsa65.verify(sig, msg, pk)
86
- } catch {
87
- return false
88
- }
89
- }
90
-
91
- /**
92
- * Constant-time-ish hex comparison. Both inputs are hex strings.
93
- * @param {string} a
94
- * @param {string} b
95
- * @returns {boolean}
96
- */
97
- export function hexEquals(a, b) {
98
- if (typeof a !== 'string' || typeof b !== 'string') return false
99
- if (a.length !== b.length) return false
100
- let diff = 0
101
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
102
- return diff === 0
103
- }
1
+ // Signature math + kid math. Pure functions. No network, no I/O.
2
+ //
3
+ // We call @noble/post-quantum directly with Uint8Array so the same code runs
4
+ // in Node 18+ and in any modern browser without a polyfill. SHA-256 is taken
5
+ // from the Web Crypto API where available (browser, Node 20+), fallback to
6
+ // node:crypto's createHash where SubtleCrypto.digest is not present.
7
+
8
+ import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js'
9
+
10
+ /**
11
+ * Hex string → Uint8Array. Throws on malformed input.
12
+ * @param {string} hex
13
+ * @returns {Uint8Array}
14
+ */
15
+ export function hexToBytes(hex) {
16
+ if (typeof hex !== 'string' || hex.length % 2 !== 0) {
17
+ throw new TypeError('hex input must be a string of even length')
18
+ }
19
+ 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
+ }
25
+ return out
26
+ }
27
+
28
+ /**
29
+ * Uint8Array → hex string (lowercase).
30
+ * @param {Uint8Array} bytes
31
+ * @returns {string}
32
+ */
33
+ export function bytesToHex(bytes) {
34
+ let out = ''
35
+ for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, '0')
36
+ return out
37
+ }
38
+
39
+ /**
40
+ * UTF-8 string → Uint8Array.
41
+ * @param {string} s
42
+ * @returns {Uint8Array}
43
+ */
44
+ export function utf8(s) {
45
+ return new TextEncoder().encode(s)
46
+ }
47
+
48
+ /**
49
+ * Compute the kxco kid (key identifier) of a public key — first 16 hex chars
50
+ * of SHA-256(rawBytes). Matches the algorithm in kxco-post-quantum's
51
+ * `fingerprint()`. Async because SubtleCrypto.digest is async.
52
+ *
53
+ * @param {Uint8Array|string} publicKey — raw bytes or hex string
54
+ * @returns {Promise<string>} 16-char lowercase hex
55
+ */
56
+ export async function computeKid(publicKey) {
57
+ const bytes = typeof publicKey === 'string' ? hexToBytes(publicKey) : publicKey
58
+ const subtle = globalThis.crypto && globalThis.crypto.subtle
59
+ let hashBytes
60
+ if (subtle && typeof subtle.digest === 'function') {
61
+ const ab = await subtle.digest('SHA-256', bytes)
62
+ hashBytes = new Uint8Array(ab)
63
+ } else {
64
+ // Node fallback. Only reached on Node <20 without globalThis.crypto.
65
+ const { createHash } = await import('node:crypto')
66
+ hashBytes = createHash('sha256').update(bytes).digest()
67
+ }
68
+ return bytesToHex(hashBytes.subarray(0, 8))
69
+ }
70
+
71
+ /**
72
+ * Verify an ML-DSA-65 signature.
73
+ * @param {string|Uint8Array} publicKey — 1952 bytes (3904 hex chars)
74
+ * @param {string|Uint8Array} message — string (utf8'd) or raw bytes
75
+ * @param {string|Uint8Array} signature — 3309 bytes (6618 hex chars)
76
+ * @returns {boolean}
77
+ */
78
+ export function verifySignature(publicKey, message, signature) {
79
+ const pk = typeof publicKey === 'string' ? hexToBytes(publicKey) : publicKey
80
+ const sig = typeof signature === 'string' ? hexToBytes(signature) : signature
81
+ const msg = typeof message === 'string' ? utf8(message) : message
82
+ // @noble/post-quantum ≥0.6 signature order: (signature, message, publicKey).
83
+ // Matches the canonical wrapper in kxco-post-quantum/src/ml-dsa.js.
84
+ try {
85
+ return ml_dsa65.verify(sig, msg, pk)
86
+ } catch {
87
+ return false
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Constant-time-ish hex comparison. Both inputs are hex strings.
93
+ * @param {string} a
94
+ * @param {string} b
95
+ * @returns {boolean}
96
+ */
97
+ export function hexEquals(a, b) {
98
+ if (typeof a !== 'string' || typeof b !== 'string') return false
99
+ if (a.length !== b.length) return false
100
+ let diff = 0
101
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
102
+ return diff === 0
103
+ }