kxco-post-quantum 1.2.1 → 1.3.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,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 - unreleased
4
+
5
+ Adds FIPS 204 / FIPS 205 context string support. Purely additive: the
6
+ cryptographic surface for every existing call site is byte-for-byte unchanged,
7
+ and all 39 pinned vectors still match.
8
+
9
+ ### Added
10
+ - **Optional `context` on `mlDsa.sign` / `mlDsa.verify`** via a trailing options
11
+ object, `{ context }`. At most 255 bytes per FIPS 204 section 5.2; strings are
12
+ encoded as UTF-8. A signature made under a context does not verify without it
13
+ or under a different one. Closes a real incompleteness relative to FIPS 204:
14
+ the library previously could not verify a counterparty's signature that used a
15
+ context.
16
+ - **The same option on `slhDsa.sign` / `slhDsa.verify`**, on the same terms.
17
+ Added alongside ML-DSA rather than after it, because one signature module
18
+ accepting an options object while its sibling silently ignored one would be a
19
+ footgun.
20
+ - `MAX_CONTEXT_BYTES` (255) exported from both modules.
21
+ - `test/context.test.js`, 26 tests, including cross-verification against
22
+ third-party ML-DSA-65 signatures produced by OpenSSL through Python
23
+ `cryptography`, covering five context shapes: short, single-byte, the 255-byte
24
+ maximum, binary, and multi-byte UTF-8.
25
+
26
+ ### Notes
27
+ - **No behaviour change without the new argument.** Omitting `opts` takes the
28
+ identical code path as before. An empty context is collapsed to no context,
29
+ which is what FIPS 204 specifies and what was verified empirically.
30
+ - **Misuse throws rather than returning `false`.** A context over 255 bytes, a
31
+ wrongly typed context, or a bare value passed where an options object belongs
32
+ raises `RangeError` / `TypeError`. These are caller bugs, not failed
33
+ verifications, and swallowing them would hide a signature that silently
34
+ carried no domain separation. No existing call site passes the argument, so
35
+ nothing can regress.
36
+ - Works identically on `@noble/post-quantum` 0.6.1 and 0.7.0, verified on both,
37
+ so this release is independent of the 0.7.0 upgrade.
38
+
3
39
  ## 1.2.1 — 2026-07-22
4
40
 
5
41
  Metadata alignment. No code changes; cryptographic surface is byte-for-byte
package/README.md CHANGED
@@ -48,6 +48,44 @@ const recovered = mlKem.decapsulate(ciphertext, kemKeys.secretKey)
48
48
 
49
49
  `masterSecret` is a `Buffer` or `Uint8Array` with at least 16 bytes of entropy (typically 32–64 bytes from an env var or KMS).
50
50
 
51
+ ### Context strings (FIPS 204 / FIPS 205)
52
+
53
+ `sign` and `verify` take an optional context string, at most 255 bytes. A
54
+ signature made under a context does not verify without it, or under a different
55
+ one.
56
+
57
+ ```js
58
+ const sig = mlDsa.sign(secretKey, 'hello', { context: 'kxco-nexus-v1' })
59
+
60
+ mlDsa.verify(publicKey, 'hello', sig, { context: 'kxco-nexus-v1' }) // true
61
+ mlDsa.verify(publicKey, 'hello', sig) // false
62
+ mlDsa.verify(publicKey, 'hello', sig, { context: 'other-v1' }) // false
63
+ ```
64
+
65
+ The parameter is optional and defaults to no context, so every existing call
66
+ site is unaffected. An empty context is identical to omitting it. `slhDsa` takes
67
+ the same option.
68
+
69
+ **Context separates at the signature level; `keypairFromMaster(master, info)`
70
+ separates at the key level.** They are complementary. Use a context when one key
71
+ legitimately signs for several purposes and you need a signature from one
72
+ purpose to be unusable in another. Use a distinct derived key when the purposes
73
+ should not share a key at all.
74
+
75
+ Strings are encoded as UTF-8, so the 255-byte limit is bytes and not
76
+ characters. Over-length or wrongly typed input throws (`RangeError` /
77
+ `TypeError`) rather than returning `false`, because that is a caller bug and not
78
+ a failed verification:
79
+
80
+ ```js
81
+ mlDsa.sign(secretKey, 'hello', 'kxco-nexus-v1') // throws TypeError
82
+ // (needs { context: ... })
83
+ ```
84
+
85
+ That last case is worth guarding: without the throw it would silently sign with
86
+ *no* context and produce a valid-looking signature carrying none of the intended
87
+ separation.
88
+
51
89
  ---
52
90
 
53
91
  ## API
@@ -129,7 +167,7 @@ Install this package directly when you need ML-DSA or ML-KEM without the rest of
129
167
 
130
168
  Cryptographic operations delegate entirely to [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) and [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) — this package does not reimplement any NIST primitive. `@noble/hashes` falls under Cure53's 2023 audit of the `@noble` ecosystem (`ciphers`, `curves`, `hashes`); `@noble/post-quantum` was **not** in that audit's scope and has been self-audited by its maintainer. See [AUDIT.md](./AUDIT.md) for the full posture.
131
169
 
132
- To report a vulnerability: [open a private security advisory](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/security/advisories/new) or email **security@kxco.ai**.
170
+ To report a vulnerability: [open a private security advisory](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/security/advisories/new) or email **john@knightsbridgelaw.com**. Acknowledgement within 2 business days, triage decision within 5. Full policy, including safe harbour for good-faith research: <https://kxco.ai/security>.
133
171
 
134
172
  ## License
135
173
 
package/SECURITY.md CHANGED
@@ -1,9 +1,22 @@
1
1
  # Security Policy
2
2
 
3
3
  ## Reporting a vulnerability
4
- Email **security@kxco.ai**. Do not open public issues for security reports.
5
- PGP key available on request. We respond within 48 hours and credit reporters
6
- in `CHANGELOG.md` unless they request otherwise.
4
+ Email **john@knightsbridgelaw.com**. Do not open public issues for security reports.
5
+ PGP key available on request. We credit reporters in `CHANGELOG.md` unless they
6
+ request otherwise.
7
+
8
+ Acknowledgement within **2 business days**. Triage decision within **5 business days**.
9
+
10
+ Full policy: <https://kxco.ai/security>
11
+
12
+ ## Safe harbour
13
+ If you make a good-faith effort to comply with this policy, we will treat your
14
+ research as authorised, and we will not pursue or support legal action against
15
+ you. Good faith means: do not access, modify, exfiltrate, or destroy data that
16
+ is not yours; use test accounts where possible; do not degrade service for
17
+ others; and stop and report as soon as you have established that a
18
+ vulnerability exists. This cannot bind third parties, and it does not cover
19
+ extortion, data sale, or public disclosure ahead of the window below.
7
20
 
8
21
  ## Scope
9
22
  In scope:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kxco-post-quantum",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "ML-DSA-65, ML-KEM-768 and SLH-DSA-SHA2-192s primitives with key fingerprinting. The base layer for all kxco-pq-* packages.",
5
5
  "keywords": [
6
6
  "post-quantum",
@@ -23,7 +23,6 @@
23
23
  "deterministic-keys",
24
24
  "fingerprint",
25
25
  "replay-protection",
26
- "constant-time",
27
26
  "hybrid-signing",
28
27
  "non-repudiation"
29
28
  ],
@@ -41,10 +40,10 @@
41
40
  "funding": "https://kxco.ai",
42
41
  "repository": {
43
42
  "type": "git",
44
- "url": "https://github.com/JackKXCO/kxco-post-quantum.git"
43
+ "url": "https://github.com/KnightsbridgeAIQ/kxco-post-quantum.git"
45
44
  },
46
45
  "bugs": {
47
- "url": "https://github.com/JackKXCO/kxco-post-quantum/issues"
46
+ "url": "https://github.com/KnightsbridgeAIQ/kxco-post-quantum/issues"
48
47
  },
49
48
  "type": "module",
50
49
  "sideEffects": false,
@@ -91,11 +90,11 @@
91
90
  "node": ">=20.19"
92
91
  },
93
92
  "dependencies": {
94
- "@noble/hashes": "^2.2.0",
95
- "@noble/post-quantum": "^0.6.1"
93
+ "@noble/hashes": "2.3.0",
94
+ "@noble/post-quantum": "0.7.0"
96
95
  },
97
96
  "scripts": {
98
- "test": "node --test test/basic.test.js && node --test test/browser-smoke.test.js && node test/run-vectors.js",
97
+ "test": "node --test test/basic.test.js && node --test test/context.test.js && node --test test/browser-smoke.test.js && node test/run-vectors.js",
99
98
  "test:vectors": "node test/run-vectors.js",
100
99
  "generate:vectors": "node test/generate-vectors.js > test/vectors.json",
101
100
  "bench": "node bench/bench.js"
@@ -0,0 +1,69 @@
1
+ // Internal: FIPS 204 / FIPS 205 signature context strings.
2
+ //
3
+ // Not part of the public exports map. Shared by ml-dsa.js and slh-dsa.js
4
+ // because a validator for security-relevant input should exist once, even
5
+ // though the small byte/hex helpers in those modules are duplicated.
6
+ //
7
+ // FIPS 204 section 5.2 (and FIPS 205 equivalently) allow an optional context
8
+ // string of at most 255 bytes, mixed into the message representative. It gives
9
+ // domain separation: a signature made under one context does not verify under
10
+ // another, or under no context at all.
11
+ //
12
+ // KXCO derives keys per domain via deriveSeed(master, info), which separates at
13
+ // the KEY level. Context separates at the SIGNATURE level, so one key can sign
14
+ // for several domains without a signature being replayable across them. The two
15
+ // are complementary, not alternatives.
16
+
17
+ const enc = new TextEncoder()
18
+
19
+ /** FIPS 204 section 5.2: |ctx| <= 255. */
20
+ export const MAX_CONTEXT_BYTES = 255
21
+
22
+ /**
23
+ * Normalise the options bag into a context byte string, or undefined.
24
+ *
25
+ * Returns undefined for "no context", which makes the caller take the exact
26
+ * code path it took before this parameter existed. An empty context is
27
+ * cryptographically identical to no context, so it also returns undefined
28
+ * rather than passing a zero-length array down.
29
+ *
30
+ * THROWS on caller misuse (wrong type, over-length). This is deliberate and it
31
+ * differs from verify()'s usual fail-closed behaviour: a malformed context is a
32
+ * programming error, not a bad signature, and silently returning false would
33
+ * hide the bug behind an outcome that looks like a normal verification failure.
34
+ * No existing call site passes this argument, so nothing can regress.
35
+ *
36
+ * @param {{ context?: Uint8Array|Buffer|string }} [opts]
37
+ * @returns {Uint8Array|undefined}
38
+ */
39
+ export function normalizeContext(opts) {
40
+ if (opts === undefined || opts === null) return undefined
41
+
42
+ if (typeof opts !== 'object' || Array.isArray(opts) || opts instanceof Uint8Array) {
43
+ throw new TypeError(
44
+ 'expected an options object such as { context }, not a bare value',
45
+ )
46
+ }
47
+
48
+ const { context } = opts
49
+ if (context === undefined || context === null) return undefined
50
+
51
+ let bytes
52
+ if (context instanceof Uint8Array) {
53
+ bytes = context
54
+ } else if (typeof context === 'string') {
55
+ bytes = enc.encode(context)
56
+ } else {
57
+ throw new TypeError('context must be a Uint8Array or a string')
58
+ }
59
+
60
+ if (bytes.length > MAX_CONTEXT_BYTES) {
61
+ throw new RangeError(
62
+ `context must be at most ${MAX_CONTEXT_BYTES} bytes (FIPS 204 section 5.2), got ${bytes.length}`,
63
+ )
64
+ }
65
+
66
+ // Empty context is identical to no context under FIPS 204, so collapse it
67
+ // and keep the legacy call path byte-for-byte.
68
+ return bytes.length === 0 ? undefined : bytes
69
+ }
package/src/ml-dsa.d.ts CHANGED
@@ -18,23 +18,56 @@ export function keypairFromMaster(
18
18
  info?: string,
19
19
  ): MlDsaKeypair
20
20
 
21
+ /** Maximum context length in bytes (FIPS 204 section 5.2). */
22
+ export const MAX_CONTEXT_BYTES: 255
23
+
24
+ export interface SignatureOptions {
25
+ /**
26
+ * Optional FIPS 204 section 5.2 context string, at most 255 bytes.
27
+ * Strings are encoded as UTF-8.
28
+ *
29
+ * Gives domain separation at the signature level: a signature made under a
30
+ * context does not verify without it, or under a different one. An empty
31
+ * context is identical to omitting it.
32
+ *
33
+ * Complements `keypairFromMaster(master, info)`, which separates domains at
34
+ * the key level.
35
+ */
36
+ context?: Buffer | Uint8Array | string
37
+ }
38
+
21
39
  /**
22
40
  * Sign a message under an ML-DSA-65 secret key. Returns the signature
23
41
  * as a hex string (3309 bytes = 6618 hex characters).
42
+ *
43
+ * @throws {TypeError} if `opts` is not an options object, or `context` is
44
+ * neither a string nor a Uint8Array
45
+ * @throws {RangeError} if `context` exceeds 255 bytes
24
46
  */
25
47
  export function sign(
26
48
  secretKey: Buffer | Uint8Array,
27
- message: Buffer | string,
49
+ message: Buffer | Uint8Array | string,
50
+ opts?: SignatureOptions,
28
51
  ): string
29
52
 
30
53
  /**
31
54
  * Verify a hex-encoded ML-DSA-65 signature against a public key + message.
32
- * Returns `false` on any error (invalid hex, wrong length, mismatch).
55
+ *
56
+ * Returns `false` on any cryptographic failure (invalid hex, wrong length,
57
+ * mismatch, or a missing/incorrect context).
58
+ *
59
+ * Throws only on caller misuse of `opts`, which is a programming error rather
60
+ * than a failed verification and is not swallowed.
61
+ *
62
+ * @throws {TypeError} if `opts` is not an options object, or `context` is
63
+ * neither a string nor a Uint8Array
64
+ * @throws {RangeError} if `context` exceeds 255 bytes
33
65
  */
34
66
  export function verify(
35
67
  publicKey: Buffer | Uint8Array,
36
- message: Buffer | string,
68
+ message: Buffer | Uint8Array | string,
37
69
  sigHex: string,
70
+ opts?: SignatureOptions,
38
71
  ): boolean
39
72
 
40
73
  /**
package/src/ml-dsa.js CHANGED
@@ -8,6 +8,9 @@
8
8
 
9
9
  import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js'
10
10
  import { deriveSeed } from './derive.js'
11
+ import { normalizeContext, MAX_CONTEXT_BYTES } from './_context.js'
12
+
13
+ export { MAX_CONTEXT_BYTES }
11
14
 
12
15
  const HAS_BUFFER = typeof Buffer !== 'undefined'
13
16
  const enc = new TextEncoder()
@@ -50,26 +53,47 @@ export function keypairFromMaster(master, info = 'ml-dsa-65-v1') {
50
53
  /**
51
54
  * Sign a message. Returns the signature as a hex string.
52
55
  *
56
+ * An optional FIPS 204 section 5.2 context string gives domain separation: a
57
+ * signature made under a context does not verify without it. Omit it and the
58
+ * behaviour is exactly as before this parameter existed.
59
+ *
53
60
  * @param {Buffer|Uint8Array} secretKey
54
61
  * @param {Buffer|Uint8Array|string} message
62
+ * @param {{ context?: Uint8Array|Buffer|string }} [opts] at most 255 context bytes
55
63
  * @returns {string} hex-encoded signature (6618 chars)
56
64
  */
57
- export function sign(secretKey, message) {
58
- const sig = ml_dsa65.sign(toBytes(message), secretKey)
65
+ export function sign(secretKey, message, opts) {
66
+ const context = normalizeContext(opts)
67
+ const sig = context === undefined
68
+ ? ml_dsa65.sign(toBytes(message), secretKey)
69
+ : ml_dsa65.sign(toBytes(message), secretKey, { context })
59
70
  return bytesToHex(sig)
60
71
  }
61
72
 
62
73
  /**
63
74
  * Verify a hex-encoded signature.
64
75
  *
76
+ * Pass the same context the signer used. A signature made under a context
77
+ * returns false here if the context is omitted or differs, which is the point
78
+ * of it.
79
+ *
80
+ * Returns false for any cryptographic failure. Throws only on caller misuse of
81
+ * `opts` (wrong type, or a context over 255 bytes), because that is a bug
82
+ * rather than a failed verification and should not be silently swallowed.
83
+ *
65
84
  * @param {Buffer|Uint8Array} publicKey
66
85
  * @param {Buffer|Uint8Array|string} message
67
86
  * @param {string} sigHex
87
+ * @param {{ context?: Uint8Array|Buffer|string }} [opts]
68
88
  * @returns {boolean}
69
89
  */
70
- export function verify(publicKey, message, sigHex) {
90
+ export function verify(publicKey, message, sigHex, opts) {
91
+ // Outside the try: misuse must surface, not be swallowed as "invalid".
92
+ const context = normalizeContext(opts)
71
93
  try {
72
- return ml_dsa65.verify(hexToBytes(sigHex), toBytes(message), publicKey)
94
+ return context === undefined
95
+ ? ml_dsa65.verify(hexToBytes(sigHex), toBytes(message), publicKey)
96
+ : ml_dsa65.verify(hexToBytes(sigHex), toBytes(message), publicKey, { context })
73
97
  } catch {
74
98
  return false
75
99
  }
package/src/slh-dsa.d.ts CHANGED
@@ -19,23 +19,49 @@ export function keypairFromMaster(
19
19
  info?: string,
20
20
  ): SlhDsaKeypair
21
21
 
22
+ /** Maximum context length in bytes (FIPS 205, matching FIPS 204). */
23
+ export const MAX_CONTEXT_BYTES: 255
24
+
25
+ export interface SignatureOptions {
26
+ /**
27
+ * Optional context string, at most 255 bytes. Strings are encoded as UTF-8.
28
+ * A signature made under a context does not verify without it. An empty
29
+ * context is identical to omitting it.
30
+ */
31
+ context?: Buffer | Uint8Array | string
32
+ }
33
+
22
34
  /**
23
35
  * Sign a message under an SLH-DSA-SHA2-192s secret key. Returns the signature
24
36
  * as a hex string (16224 bytes = 32448 hex characters).
37
+ *
38
+ * @throws {TypeError} if `opts` is not an options object, or `context` is
39
+ * neither a string nor a Uint8Array
40
+ * @throws {RangeError} if `context` exceeds 255 bytes
25
41
  */
26
42
  export function sign(
27
43
  secretKey: Buffer | Uint8Array,
28
- message: Buffer | string,
44
+ message: Buffer | Uint8Array | string,
45
+ opts?: SignatureOptions,
29
46
  ): string
30
47
 
31
48
  /**
32
49
  * Verify a hex-encoded SLH-DSA-SHA2-192s signature against a public key +
33
- * message. Returns `false` on any error (invalid hex, wrong length, mismatch).
50
+ * message.
51
+ *
52
+ * Returns `false` on any cryptographic failure (invalid hex, wrong length,
53
+ * mismatch, or a missing/incorrect context). Throws only on caller misuse of
54
+ * `opts`.
55
+ *
56
+ * @throws {TypeError} if `opts` is not an options object, or `context` is
57
+ * neither a string nor a Uint8Array
58
+ * @throws {RangeError} if `context` exceeds 255 bytes
34
59
  */
35
60
  export function verify(
36
61
  publicKey: Buffer | Uint8Array,
37
- message: Buffer | string,
62
+ message: Buffer | Uint8Array | string,
38
63
  sigHex: string,
64
+ opts?: SignatureOptions,
39
65
  ): boolean
40
66
 
41
67
  /**
package/src/slh-dsa.js CHANGED
@@ -15,6 +15,9 @@
15
15
 
16
16
  import { slh_dsa_sha2_192s } from '@noble/post-quantum/slh-dsa.js'
17
17
  import { deriveSeed } from './derive.js'
18
+ import { normalizeContext, MAX_CONTEXT_BYTES } from './_context.js'
19
+
20
+ export { MAX_CONTEXT_BYTES }
18
21
 
19
22
  const HAS_BUFFER = typeof Buffer !== 'undefined'
20
23
  const enc = new TextEncoder()
@@ -60,26 +63,41 @@ export function keypairFromMaster(master, info = 'slh-dsa-sha2-192s-v1') {
60
63
  /**
61
64
  * Sign a message. Returns the signature as a hex string.
62
65
  *
66
+ * Accepts the same optional context string as ml-dsa.js (FIPS 205 allows it on
67
+ * the same terms as FIPS 204, at most 255 bytes). Omit it and the behaviour is
68
+ * exactly as before this parameter existed.
69
+ *
63
70
  * @param {Buffer|Uint8Array} secretKey
64
71
  * @param {Buffer|Uint8Array|string} message
72
+ * @param {{ context?: Uint8Array|Buffer|string }} [opts] at most 255 context bytes
65
73
  * @returns {string} hex-encoded signature (32448 chars)
66
74
  */
67
- export function sign(secretKey, message) {
68
- const sig = slh_dsa_sha2_192s.sign(toBytes(message), secretKey)
75
+ export function sign(secretKey, message, opts) {
76
+ const context = normalizeContext(opts)
77
+ const sig = context === undefined
78
+ ? slh_dsa_sha2_192s.sign(toBytes(message), secretKey)
79
+ : slh_dsa_sha2_192s.sign(toBytes(message), secretKey, { context })
69
80
  return bytesToHex(sig)
70
81
  }
71
82
 
72
83
  /**
73
84
  * Verify a hex-encoded signature.
74
85
  *
86
+ * Pass the same context the signer used. Returns false for any cryptographic
87
+ * failure; throws only on caller misuse of `opts`.
88
+ *
75
89
  * @param {Buffer|Uint8Array} publicKey
76
90
  * @param {Buffer|Uint8Array|string} message
77
91
  * @param {string} sigHex
92
+ * @param {{ context?: Uint8Array|Buffer|string }} [opts]
78
93
  * @returns {boolean}
79
94
  */
80
- export function verify(publicKey, message, sigHex) {
95
+ export function verify(publicKey, message, sigHex, opts) {
96
+ const context = normalizeContext(opts)
81
97
  try {
82
- return slh_dsa_sha2_192s.verify(hexToBytes(sigHex), toBytes(message), publicKey)
98
+ return context === undefined
99
+ ? slh_dsa_sha2_192s.verify(hexToBytes(sigHex), toBytes(message), publicKey)
100
+ : slh_dsa_sha2_192s.verify(hexToBytes(sigHex), toBytes(message), publicKey, { context })
83
101
  } catch {
84
102
  return false
85
103
  }