kxco-pq-hsm 1.0.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/LICENSE ADDED
@@ -0,0 +1,34 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction, and distribution.
10
+ "Licensor" shall mean KXCO by Knightsbridge.
11
+ "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity.
12
+ "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
13
+ "Source" form shall mean the preferred form for making modifications.
14
+ "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form.
15
+ "Work" shall mean the work of authorship made available under the License.
16
+ "Derivative Works" shall mean any work that is based on the Work.
17
+ "Contribution" shall mean any work of authorship submitted to the Licensor for inclusion in the Work.
18
+ "Contributor" shall mean Licensor and any Legal Entity on behalf of whom a Contribution has been received by the Licensor and incorporated within the Work.
19
+
20
+ 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
21
+
22
+ 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work.
23
+
24
+ 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work; and (d) If the Work includes a "NOTICE" text file, You must include a readable copy of the attribution notices contained within such NOTICE file.
25
+
26
+ 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions.
27
+
28
+ 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor.
29
+
30
+ 7. Disclaimer of Warranty. THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
31
+
32
+ 8. Limitation of Liability. IN NO EVENT SHALL ANY CONTRIBUTOR BE LIABLE FOR ANY DAMAGES ARISING FROM THIS LICENSE OR THE USE OF THE WORK.
33
+
34
+ Copyright 2026 KXCO by Knightsbridge
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "kxco-pq-hsm",
3
+ "version": "1.0.0",
4
+ "description": "HSM-backed post-quantum key management: ML-DSA-65 signing and ML-KEM-768 decapsulation through a hardware security module boundary. Three backends: in-memory (dev), Argon2id-encrypted file, and PKCS#11 (SoftHSM2, Luna, Utimaco, YubiKey).",
5
+ "keywords": [
6
+ "post-quantum",
7
+ "pqc",
8
+ "hsm",
9
+ "pkcs11",
10
+ "ml-dsa",
11
+ "ml-kem",
12
+ "nist",
13
+ "fips-203",
14
+ "fips-204",
15
+ "key-management",
16
+ "kxco"
17
+ ],
18
+ "license": "Apache-2.0",
19
+ "author": "KXCO by Knightsbridge <hello@kxco.ai>",
20
+ "homepage": "https://kxco.ai",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/JackKXCO/kxco-pq-hsm.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/JackKXCO/kxco-pq-hsm/issues"
27
+ },
28
+ "type": "module",
29
+ "main": "src/index.js",
30
+ "exports": {
31
+ ".": "./src/index.js"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "LICENSE"
36
+ ],
37
+ "engines": {
38
+ "node": ">=20.19"
39
+ },
40
+ "dependencies": {
41
+ "@noble/ciphers": "^1.0.0",
42
+ "@noble/hashes": "^1.7.0",
43
+ "kxco-post-quantum": "^1.1.6"
44
+ },
45
+ "optionalDependencies": {
46
+ "pkcs11js": "^2.1.0"
47
+ },
48
+ "scripts": {
49
+ "test": "node --test --test-timeout=60000 test/hsm.test.js"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
@@ -0,0 +1,92 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
2
+ import { argon2id } from '@noble/hashes/argon2'
3
+ import { gcm } from '@noble/ciphers/aes'
4
+ import { randomBytes } from '@noble/ciphers/webcrypto'
5
+ import { KxcoPqHsmError } from '../errors.js'
6
+
7
+ const VERSION = '1'
8
+ // Argon2id params: OWASP recommended minimum for sensitive key material
9
+ const KDF = { t: 3, m: 65536, p: 1 }
10
+
11
+ const b64u = (b) => Buffer.from(b).toString('base64url')
12
+ const unb64u = (s) => new Uint8Array(Buffer.from(s, 'base64url'))
13
+
14
+ export class FileBackend {
15
+ #path
16
+ #password
17
+ #store
18
+ #derivedKey = null // cached after first derivation
19
+
20
+ constructor({ path, password }) {
21
+ if (!path) throw new KxcoPqHsmError('FileBackend: path is required')
22
+ if (!password) throw new KxcoPqHsmError('FileBackend: password is required')
23
+ this.#path = path
24
+ this.#password = typeof password === 'string'
25
+ ? new TextEncoder().encode(password)
26
+ : new Uint8Array(password)
27
+ this.#store = this.#load()
28
+ }
29
+
30
+ #load() {
31
+ if (!existsSync(this.#path)) {
32
+ const store = {
33
+ 'kxco-hsm': VERSION,
34
+ kdf: { alg: 'argon2id', ...KDF, salt: b64u(randomBytes(32)) },
35
+ keys: {},
36
+ }
37
+ writeFileSync(this.#path, JSON.stringify(store, null, 2), 'utf-8')
38
+ return store
39
+ }
40
+ const store = JSON.parse(readFileSync(this.#path, 'utf-8'))
41
+ if (store['kxco-hsm'] !== VERSION) {
42
+ throw new KxcoPqHsmError(`unsupported store version: ${store['kxco-hsm']}`)
43
+ }
44
+ return store
45
+ }
46
+
47
+ #save() {
48
+ writeFileSync(this.#path, JSON.stringify(this.#store, null, 2), 'utf-8')
49
+ }
50
+
51
+ #key() {
52
+ if (this.#derivedKey) return this.#derivedKey
53
+ const { salt, t, m, p } = this.#store.kdf
54
+ this.#derivedKey = argon2id(this.#password, unb64u(salt), { t, m, p, dkLen: 32 })
55
+ return this.#derivedKey
56
+ }
57
+
58
+ async store(label, alg, publicKey, secretKey) {
59
+ const nonce = randomBytes(12)
60
+ const ct = gcm(this.#key(), nonce).encrypt(new Uint8Array(secretKey))
61
+ this.#store.keys[label] = {
62
+ alg,
63
+ publicKey: b64u(publicKey),
64
+ nonce: b64u(nonce),
65
+ ciphertext: b64u(ct),
66
+ }
67
+ this.#save()
68
+ }
69
+
70
+ async loadSecret(label) {
71
+ const entry = this.#store.keys[label]
72
+ if (!entry) throw new KxcoPqHsmError(`key not found: ${label}`)
73
+ const secretKey = gcm(this.#key(), unb64u(entry.nonce)).decrypt(unb64u(entry.ciphertext))
74
+ return { alg: entry.alg, secretKey }
75
+ }
76
+
77
+ async getPublicKey(label) {
78
+ const entry = this.#store.keys[label]
79
+ if (!entry) throw new KxcoPqHsmError(`key not found: ${label}`)
80
+ return { alg: entry.alg, publicKey: unb64u(entry.publicKey) }
81
+ }
82
+
83
+ async listKeys() {
84
+ return Object.entries(this.#store.keys).map(([label, { alg }]) => ({ label, alg }))
85
+ }
86
+
87
+ async deleteKey(label) {
88
+ if (!this.#store.keys[label]) throw new KxcoPqHsmError(`key not found: ${label}`)
89
+ delete this.#store.keys[label]
90
+ this.#save()
91
+ }
92
+ }
@@ -0,0 +1,36 @@
1
+ import { KxcoPqHsmError } from '../errors.js'
2
+
3
+ export class MemoryBackend {
4
+ #keys = new Map() // label → { alg, publicKey, secretKey }
5
+
6
+ async store(label, alg, publicKey, secretKey) {
7
+ this.#keys.set(label, {
8
+ alg,
9
+ publicKey: new Uint8Array(publicKey),
10
+ secretKey: new Uint8Array(secretKey),
11
+ })
12
+ }
13
+
14
+ async loadSecret(label) {
15
+ const entry = this.#keys.get(label)
16
+ if (!entry) throw new KxcoPqHsmError(`key not found: ${label}`)
17
+ return { alg: entry.alg, secretKey: new Uint8Array(entry.secretKey) }
18
+ }
19
+
20
+ async getPublicKey(label) {
21
+ const entry = this.#keys.get(label)
22
+ if (!entry) throw new KxcoPqHsmError(`key not found: ${label}`)
23
+ return { alg: entry.alg, publicKey: new Uint8Array(entry.publicKey) }
24
+ }
25
+
26
+ async listKeys() {
27
+ return [...this.#keys.entries()].map(([label, { alg }]) => ({ label, alg }))
28
+ }
29
+
30
+ async deleteKey(label) {
31
+ const entry = this.#keys.get(label)
32
+ if (!entry) throw new KxcoPqHsmError(`key not found: ${label}`)
33
+ entry.secretKey.fill(0)
34
+ this.#keys.delete(label)
35
+ }
36
+ }
@@ -0,0 +1,169 @@
1
+ import { KxcoPqHsmError } from '../errors.js'
2
+
3
+ // Lazy-load pkcs11js so the package installs cleanly without it if only using other backends
4
+ let _pkcs11mod = null
5
+ async function loadMod() {
6
+ if (_pkcs11mod) return _pkcs11mod
7
+ try {
8
+ const imported = await import('pkcs11js')
9
+ // Native CJS addon: ESM import() wraps it in { default: exports }; named exports are unavailable
10
+ _pkcs11mod = imported.default ?? imported
11
+ return _pkcs11mod
12
+ } catch {
13
+ throw new KxcoPqHsmError('pkcs11js is not installed — run: npm install pkcs11js')
14
+ }
15
+ }
16
+
17
+ const b64u = (b) => Buffer.from(b).toString('base64url')
18
+ const unb64u = (s) => Buffer.from(s, 'base64url')
19
+
20
+ export class Pkcs11Backend {
21
+ #lib
22
+ #slotIndex
23
+ #pin
24
+ #wrapLabel
25
+ #p11 = null
26
+ #session = null
27
+ #wrapKey = null
28
+ // Wrapped key store: label → { alg, publicKey: b64u, nonce: b64u, wrapped: b64u }
29
+ #store = new Map()
30
+
31
+ /**
32
+ * @param {object} opts
33
+ * @param {string} opts.libraryPath Path to PKCS#11 shared library (.so / .dll)
34
+ * @param {number} [opts.slot=0] Index into C_GetSlotList(true) result
35
+ * @param {string} opts.pin User PIN
36
+ * @param {string} [opts.wrapKeyLabel='kxco-pq-wrap'] Label for the AES-256 wrapping key
37
+ */
38
+ constructor({ libraryPath, slot = 0, pin, wrapKeyLabel = 'kxco-pq-wrap' }) {
39
+ if (!libraryPath) throw new KxcoPqHsmError('Pkcs11Backend: libraryPath is required')
40
+ if (!pin) throw new KxcoPqHsmError('Pkcs11Backend: pin is required')
41
+ this.#lib = libraryPath
42
+ this.#slotIndex = slot
43
+ this.#pin = pin
44
+ this.#wrapLabel = wrapKeyLabel
45
+ }
46
+
47
+ /** Connect to the HSM, login, and locate or create the AES wrapping key. */
48
+ async open() {
49
+ const mod = await loadMod()
50
+ const { PKCS11,
51
+ CKF_SERIAL_SESSION, CKF_RW_SESSION, CKU_USER,
52
+ CKM_AES_KEY_GEN, CKO_SECRET_KEY, CKK_AES,
53
+ CKA_CLASS, CKA_KEY_TYPE, CKA_VALUE_LEN, CKA_LABEL,
54
+ CKA_TOKEN, CKA_SENSITIVE, CKA_EXTRACTABLE,
55
+ CKA_ENCRYPT, CKA_DECRYPT,
56
+ } = mod
57
+
58
+ this.#p11 = new PKCS11()
59
+ this.#p11.load(this.#lib)
60
+ this.#p11.C_Initialize()
61
+
62
+ const slots = this.#p11.C_GetSlotList(true)
63
+ if (this.#slotIndex >= slots.length) {
64
+ throw new KxcoPqHsmError(
65
+ `PKCS#11 slot index ${this.#slotIndex} out of range (${slots.length} slot(s) available)`
66
+ )
67
+ }
68
+
69
+ this.#session = this.#p11.C_OpenSession(
70
+ slots[this.#slotIndex],
71
+ CKF_SERIAL_SESSION | CKF_RW_SESSION
72
+ )
73
+ this.#p11.C_Login(this.#session, CKU_USER, this.#pin)
74
+
75
+ // Find or generate the persistent AES-256 wrapping key
76
+ this.#p11.C_FindObjectsInit(this.#session, [
77
+ { type: CKA_CLASS, value: CKO_SECRET_KEY },
78
+ { type: CKA_LABEL, value: this.#wrapLabel },
79
+ ])
80
+ const found = this.#p11.C_FindObjects(this.#session, 1)
81
+ this.#p11.C_FindObjectsFinal(this.#session)
82
+
83
+ if (found.length > 0) {
84
+ this.#wrapKey = found[0]
85
+ } else {
86
+ this.#wrapKey = this.#p11.C_GenerateKey(
87
+ this.#session,
88
+ { mechanism: CKM_AES_KEY_GEN },
89
+ [
90
+ { type: CKA_CLASS, value: CKO_SECRET_KEY },
91
+ { type: CKA_KEY_TYPE, value: CKK_AES },
92
+ { type: CKA_VALUE_LEN, value: 32 },
93
+ { type: CKA_LABEL, value: this.#wrapLabel },
94
+ { type: CKA_TOKEN, value: true }, // persists across sessions
95
+ { type: CKA_SENSITIVE, value: true },
96
+ { type: CKA_EXTRACTABLE, value: false }, // never leaves the HSM
97
+ { type: CKA_ENCRYPT, value: true },
98
+ { type: CKA_DECRYPT, value: true },
99
+ ]
100
+ )
101
+ }
102
+ return this
103
+ }
104
+
105
+ /** Logout and finalise — call when done. */
106
+ close() {
107
+ if (!this.#p11) return
108
+ try { this.#p11.C_Logout(this.#session) } catch { /* best-effort */ }
109
+ try { this.#p11.C_CloseSession(this.#session) } catch { /* best-effort */ }
110
+ try { this.#p11.C_Finalize() } catch { /* best-effort */ }
111
+ this.#p11 = null
112
+ }
113
+
114
+ #assertOpen() {
115
+ if (!this.#p11) throw new KxcoPqHsmError('Pkcs11Backend is not open — call .open() first')
116
+ }
117
+
118
+ #cbcParams(mod, iv) {
119
+ return { mechanism: mod.CKM_AES_CBC_PAD, parameter: Buffer.from(iv) }
120
+ }
121
+
122
+ async store(label, alg, publicKey, secretKey) {
123
+ this.#assertOpen()
124
+ const mod = await loadMod()
125
+ const iv = this.#p11.C_GenerateRandom(this.#session, Buffer.alloc(16))
126
+ const data = Buffer.from(secretKey)
127
+ // pkcs11js v2: C_Encrypt(session, input, outputBuffer) — AES-CBC-PAD always adds one full padding block
128
+ const encOut = Buffer.alloc((Math.floor(data.length / 16) + 1) * 16)
129
+ this.#p11.C_EncryptInit(this.#session, this.#cbcParams(mod, iv), this.#wrapKey)
130
+ const wrapped = this.#p11.C_Encrypt(this.#session, data, encOut)
131
+
132
+ this.#store.set(label, {
133
+ alg,
134
+ publicKey: b64u(publicKey),
135
+ iv: b64u(iv),
136
+ wrapped: b64u(wrapped),
137
+ })
138
+ }
139
+
140
+ async loadSecret(label) {
141
+ this.#assertOpen()
142
+ const mod = await loadMod()
143
+ const entry = this.#store.get(label)
144
+ if (!entry) throw new KxcoPqHsmError(`key not found: ${label}`)
145
+
146
+ const enc = unb64u(entry.wrapped)
147
+ // pkcs11js v2: C_Decrypt(session, input, outputBuffer) — output ≤ input length after padding removal
148
+ const decOut = Buffer.alloc(enc.length)
149
+ this.#p11.C_DecryptInit(this.#session, this.#cbcParams(mod, unb64u(entry.iv)), this.#wrapKey)
150
+ const secretKey = this.#p11.C_Decrypt(this.#session, enc, decOut)
151
+ return { alg: entry.alg, secretKey: new Uint8Array(secretKey) }
152
+ }
153
+
154
+ async getPublicKey(label) {
155
+ this.#assertOpen()
156
+ const entry = this.#store.get(label)
157
+ if (!entry) throw new KxcoPqHsmError(`key not found: ${label}`)
158
+ return { alg: entry.alg, publicKey: new Uint8Array(unb64u(entry.publicKey)) }
159
+ }
160
+
161
+ async listKeys() {
162
+ return [...this.#store.entries()].map(([label, { alg }]) => ({ label, alg }))
163
+ }
164
+
165
+ async deleteKey(label) {
166
+ if (!this.#store.has(label)) throw new KxcoPqHsmError(`key not found: ${label}`)
167
+ this.#store.delete(label)
168
+ }
169
+ }
package/src/errors.js ADDED
@@ -0,0 +1,3 @@
1
+ export class KxcoPqHsmError extends Error {
2
+ constructor(message) { super(message); this.name = 'KxcoPqHsmError' }
3
+ }
package/src/hsm.js ADDED
@@ -0,0 +1,60 @@
1
+ import { mlDsa, mlKem } from 'kxco-post-quantum'
2
+ import { KxcoPqHsmError } from './errors.js'
3
+
4
+ export class PqHsm {
5
+ constructor(backend) {
6
+ if (!backend) throw new KxcoPqHsmError('backend is required')
7
+ this._backend = backend
8
+ }
9
+
10
+ async keygen(label, alg = 'ml-dsa-65') {
11
+ if (alg !== 'ml-dsa-65' && alg !== 'ml-kem-768') {
12
+ throw new KxcoPqHsmError(`unsupported algorithm '${alg}' — use 'ml-dsa-65' or 'ml-kem-768'`)
13
+ }
14
+ const kp = alg === 'ml-dsa-65'
15
+ ? mlDsa.ml_dsa65.keygen()
16
+ : mlKem.ml_kem768.keygen()
17
+
18
+ await this._backend.store(label, alg, kp.publicKey, kp.secretKey)
19
+ kp.secretKey.fill(0)
20
+ return { publicKey: kp.publicKey }
21
+ }
22
+
23
+ async sign(label, message) {
24
+ const { alg, secretKey } = await this._backend.loadSecret(label)
25
+ if (alg !== 'ml-dsa-65') {
26
+ throw new KxcoPqHsmError(`key '${label}' is ${alg} — sign requires ml-dsa-65`)
27
+ }
28
+ try {
29
+ return mlDsa.ml_dsa65.sign(secretKey, new Uint8Array(message))
30
+ } finally {
31
+ secretKey.fill(0)
32
+ }
33
+ }
34
+
35
+ async decapsulate(label, ciphertext) {
36
+ const { alg, secretKey } = await this._backend.loadSecret(label)
37
+ if (alg !== 'ml-kem-768') {
38
+ throw new KxcoPqHsmError(`key '${label}' is ${alg} — decapsulate requires ml-kem-768`)
39
+ }
40
+ try {
41
+ return new Uint8Array(
42
+ mlKem.ml_kem768.decapsulate(new Uint8Array(ciphertext), new Uint8Array(secretKey))
43
+ )
44
+ } finally {
45
+ secretKey.fill(0)
46
+ }
47
+ }
48
+
49
+ async getPublicKey(label) {
50
+ return (await this._backend.getPublicKey(label)).publicKey
51
+ }
52
+
53
+ async listKeys() {
54
+ return this._backend.listKeys()
55
+ }
56
+
57
+ async deleteKey(label) {
58
+ return this._backend.deleteKey(label)
59
+ }
60
+ }
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { PqHsm } from './hsm.js'
2
+ export { MemoryBackend } from './backends/memory.js'
3
+ export { FileBackend } from './backends/file.js'
4
+ export { Pkcs11Backend } from './backends/pkcs11.js'
5
+ export { KxcoPqHsmError } from './errors.js'