kxco-pq-tls 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-tls",
3
+ "version": "1.0.0",
4
+ "description": "Hybrid post-quantum secure channels: ML-KEM-768 + X25519 key exchange, AES-256-GCM data encryption. Wraps Node.js streams and Cloudflare Workers WebSockets. Drop-in quantum-safe channel layer.",
5
+ "keywords": [
6
+ "post-quantum",
7
+ "pqc",
8
+ "ml-kem",
9
+ "kyber",
10
+ "x25519",
11
+ "tls",
12
+ "secure-channel",
13
+ "cloudflare-workers",
14
+ "nist",
15
+ "fips-203",
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-tls.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/JackKXCO/kxco-pq-tls/issues"
27
+ },
28
+ "type": "module",
29
+ "main": "src/index.js",
30
+ "exports": {
31
+ ".": "./src/index.js"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "engines": {
39
+ "node": ">=20.19"
40
+ },
41
+ "dependencies": {
42
+ "@noble/ciphers": "^1.0.0",
43
+ "@noble/curves": "^1.8.0",
44
+ "@noble/hashes": "^1.7.0",
45
+ "kxco-post-quantum": "^1.1.6"
46
+ },
47
+ "devDependencies": {},
48
+ "scripts": {
49
+ "test": "node --test --test-timeout=15000 test/primitives.test.js test/handshake.test.js test/stream.test.js"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
package/src/errors.js ADDED
@@ -0,0 +1,3 @@
1
+ export class KxcoPqTlsError extends Error {
2
+ constructor(message) { super(message); this.name = 'KxcoPqTlsError' }
3
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * kxco-pq-tls handshake protocol v1
3
+ *
4
+ * ClientHello (1218 bytes):
5
+ * [1] version = 0x01
6
+ * [1] flags (bit 0 = mutual_auth_requested)
7
+ * [1184] ML-KEM-768 ephemeral encap key
8
+ * [32] X25519 ephemeral public key
9
+ *
10
+ * ServerHello (1122 bytes):
11
+ * [1] version = 0x01
12
+ * [1] flags
13
+ * [1088] ML-KEM-768 ciphertext
14
+ * [32] X25519 ephemeral public key
15
+ *
16
+ * Session keys: HKDF(ss_kem || ss_dh, salt=c_x25519_pk||s_x25519_pk, info="kxco-pq-tls-v1")
17
+ * then split into keyC2S and keyS2C.
18
+ *
19
+ * If mutual auth requested, after key establishment both sides exchange a
20
+ * Finished frame (sent encrypted over the new session) containing their
21
+ * ML-DSA-65 identity public key and a signature over SHA-256(clientHello||serverHello).
22
+ *
23
+ * Finished plaintext (5262 bytes):
24
+ * [1] msg_type = 0x01
25
+ * [1952] ML-DSA-65 public key
26
+ * [3309] ML-DSA-65 signature over SHA-256(clientHello || serverHello)
27
+ */
28
+
29
+ import {
30
+ generateKemKeypair, generateX25519Keypair,
31
+ kemEncapsulate, kemDecapsulate, x25519DH,
32
+ deriveKeys, sealFrame, openFrame,
33
+ dsaSign, dsaVerify, sha256,
34
+ } from './primitives.js'
35
+ import { KxcoPqTlsError } from './errors.js'
36
+
37
+ const VERSION = 0x01
38
+ const FLAG_AUTH = 0x01
39
+ const MSG_FINISHED = 0x01
40
+
41
+ const CLIENT_HELLO_SIZE = 1218 // 1 + 1 + 1184 + 32
42
+ const SERVER_HELLO_SIZE = 1122 // 1 + 1 + 1088 + 32
43
+ const FINISHED_SIZE = 5262 // 1 + 1952 + 3309
44
+
45
+ /**
46
+ * Perform handshake as initiator.
47
+ * send(buf) → Promise<void> — write one framed message
48
+ * recv(n) → Promise<Buffer> — read exactly n bytes (stream) or one message (WS)
49
+ * options.identity → optional { publicKey, secretKey } (ML-DSA-65) for mutual auth
50
+ * Returns { txKey, rxKey } — tx is initiator→responder (C2S), rx is S2C
51
+ */
52
+ export async function initiatorHandshake(send, recv, options = {}) {
53
+ const kem = generateKemKeypair()
54
+ const dh = generateX25519Keypair()
55
+ const flags = options.identity ? FLAG_AUTH : 0x00
56
+
57
+ const clientHello = buildClientHello(flags, kem.publicKey, dh.publicKey)
58
+ await send(clientHello)
59
+
60
+ const serverHello = await recv(SERVER_HELLO_SIZE)
61
+ validateHello(serverHello, SERVER_HELLO_SIZE, 'ServerHello')
62
+
63
+ const kemCt = serverHello.slice(2, 2 + 1088)
64
+ const serverX25519 = serverHello.slice(2 + 1088)
65
+
66
+ const ssKem = kemDecapsulate(kemCt, kem.secretKey)
67
+ const ssDh = x25519DH(dh.secretKey, serverX25519)
68
+ const salt = concat(dh.publicKey, serverX25519)
69
+ const { keyC2S, keyS2C } = deriveKeys(ssKem, ssDh, salt)
70
+
71
+ if (options.identity) {
72
+ const transcript = sha256.create()
73
+ .update(clientHello).update(serverHello).digest()
74
+ await exchangeFinished(send, recv, keyC2S, keyS2C, options.identity, transcript, 'initiator')
75
+ }
76
+
77
+ return { txKey: keyC2S, rxKey: keyS2C }
78
+ }
79
+
80
+ /**
81
+ * Perform handshake as responder.
82
+ * Returns { txKey, rxKey } — tx is responder→initiator (S2C), rx is C2S
83
+ */
84
+ export async function responderHandshake(send, recv, options = {}) {
85
+ const clientHello = await recv(CLIENT_HELLO_SIZE)
86
+ validateHello(clientHello, CLIENT_HELLO_SIZE, 'ClientHello')
87
+
88
+ const flags = clientHello[1]
89
+ const clientKemEk = clientHello.slice(2, 2 + 1184)
90
+ const clientX25519 = clientHello.slice(2 + 1184)
91
+
92
+ const { ciphertext, sharedSecret: ssKem } = kemEncapsulate(clientKemEk)
93
+ const dh = generateX25519Keypair()
94
+ const ssDh = x25519DH(dh.secretKey, clientX25519)
95
+ const salt = concat(clientX25519, dh.publicKey)
96
+ const { keyC2S, keyS2C } = deriveKeys(ssKem, ssDh, salt)
97
+
98
+ const serverHello = buildServerHello(flags, ciphertext, dh.publicKey)
99
+ await send(serverHello)
100
+
101
+ if ((flags & FLAG_AUTH) && options.identity) {
102
+ const transcript = sha256.create()
103
+ .update(clientHello).update(serverHello).digest()
104
+ await exchangeFinished(send, recv, keyS2C, keyC2S, options.identity, transcript, 'responder')
105
+ }
106
+
107
+ return { txKey: keyS2C, rxKey: keyC2S }
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Internal helpers
112
+ // ---------------------------------------------------------------------------
113
+
114
+ function buildClientHello(flags, kemEk, x25519Pk) {
115
+ const buf = new Uint8Array(CLIENT_HELLO_SIZE)
116
+ buf[0] = VERSION
117
+ buf[1] = flags
118
+ buf.set(kemEk, 2)
119
+ buf.set(x25519Pk, 2 + 1184)
120
+ return Buffer.from(buf)
121
+ }
122
+
123
+ function buildServerHello(flags, kemCt, x25519Pk) {
124
+ const buf = new Uint8Array(SERVER_HELLO_SIZE)
125
+ buf[0] = VERSION
126
+ buf[1] = flags
127
+ buf.set(kemCt, 2)
128
+ buf.set(x25519Pk, 2 + 1088)
129
+ return Buffer.from(buf)
130
+ }
131
+
132
+ function validateHello(buf, expectedLen, name) {
133
+ if (buf.length !== expectedLen)
134
+ throw new KxcoPqTlsError(`${name}: expected ${expectedLen} bytes, got ${buf.length}`)
135
+ if (buf[0] !== VERSION)
136
+ throw new KxcoPqTlsError(`${name}: unsupported version 0x${buf[0].toString(16)}`)
137
+ }
138
+
139
+ async function exchangeFinished(send, recv, txKey, rxKey, identity, transcript, role) {
140
+ // Send our Finished first, then receive theirs.
141
+ // The encrypted sequence starts at 0 for each direction.
142
+ const sig = dsaSign(identity.secretKey, transcript)
143
+ const finished = buildFinished(identity.publicKey, sig)
144
+ await send(sealFrame(txKey, 0, finished))
145
+
146
+ const rxBuf = await recv(FINISHED_SIZE + 16) // +16 GCM tag
147
+ const plaintext = openFrame(rxKey, 0, Buffer.from(rxBuf))
148
+ verifyFinished(plaintext, transcript, role)
149
+ }
150
+
151
+ function buildFinished(identityPk, sig) {
152
+ const buf = new Uint8Array(FINISHED_SIZE)
153
+ buf[0] = MSG_FINISHED
154
+ buf.set(identityPk, 1)
155
+ buf.set(sig, 1 + 1952)
156
+ return buf
157
+ }
158
+
159
+ function verifyFinished(plaintext, transcript, role) {
160
+ if (plaintext[0] !== MSG_FINISHED)
161
+ throw new KxcoPqTlsError('unexpected finished message type')
162
+ const identityPk = plaintext.slice(1, 1 + 1952)
163
+ const sig = plaintext.slice(1 + 1952)
164
+ if (!dsaVerify(identityPk, transcript, sig))
165
+ throw new KxcoPqTlsError(`${role}: peer identity verification failed`)
166
+ }
167
+
168
+ function concat(...arrays) {
169
+ const total = arrays.reduce((n, a) => n + a.length, 0)
170
+ const out = new Uint8Array(total)
171
+ let off = 0
172
+ for (const a of arrays) { out.set(a, off); off += a.length }
173
+ return out
174
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { wrapStream } from './stream.js'
2
+ export { wrapWebSocket, PqTlsWebSocket } from './websocket.js'
3
+ export { initiatorHandshake, responderHandshake } from './handshake.js'
4
+ export { KxcoPqTlsError } from './errors.js'
@@ -0,0 +1,82 @@
1
+ import { mlKem, mlDsa } from 'kxco-post-quantum'
2
+ import { x25519 } from '@noble/curves/ed25519'
3
+ import { hkdf } from '@noble/hashes/hkdf'
4
+ import { sha256 } from '@noble/hashes/sha256'
5
+ import { gcm } from '@noble/ciphers/aes'
6
+ import { randomBytes } from '@noble/ciphers/webcrypto'
7
+ import { KxcoPqTlsError } from './errors.js'
8
+
9
+ const PROTOCOL = new TextEncoder().encode('kxco-pq-tls-v1')
10
+ const INFO_C2S = new TextEncoder().encode('kxco-pq-tls-v1-c2s')
11
+ const INFO_S2C = new TextEncoder().encode('kxco-pq-tls-v1-s2c')
12
+
13
+ export function generateKemKeypair() {
14
+ return mlKem.ml_kem768.keygen()
15
+ }
16
+
17
+ export function generateX25519Keypair() {
18
+ const secretKey = x25519.utils.randomPrivateKey()
19
+ const publicKey = x25519.getPublicKey(secretKey)
20
+ return { publicKey, secretKey }
21
+ }
22
+
23
+ export function kemEncapsulate(publicKey) {
24
+ const { cipherText, sharedSecret } = mlKem.ml_kem768.encapsulate(publicKey)
25
+ return { ciphertext: new Uint8Array(cipherText), sharedSecret: new Uint8Array(sharedSecret) }
26
+ }
27
+
28
+ export function kemDecapsulate(ciphertext, secretKey) {
29
+ return mlKem.ml_kem768.decapsulate(new Uint8Array(ciphertext), new Uint8Array(secretKey))
30
+ }
31
+
32
+ export function x25519DH(sk, pk) {
33
+ return x25519.getSharedSecret(sk, pk) // Uint8Array(32)
34
+ }
35
+
36
+ // Derive per-direction session keys from the two shared secrets.
37
+ // salt = initiator_x25519_pk || responder_x25519_pk (64 bytes, transcript-bound)
38
+ export function deriveKeys(ssKem, ssDh, salt) {
39
+ const ikm = new Uint8Array(64)
40
+ ikm.set(ssKem)
41
+ ikm.set(ssDh, 32)
42
+ const base = hkdf(sha256, ikm, salt, PROTOCOL, 32)
43
+ return {
44
+ keyC2S: hkdf(sha256, base, new Uint8Array(0), INFO_C2S, 32),
45
+ keyS2C: hkdf(sha256, base, new Uint8Array(0), INFO_S2C, 32),
46
+ }
47
+ }
48
+
49
+ // Returns ciphertext with 16-byte GCM tag appended.
50
+ export function sealFrame(key, seq, plaintext) {
51
+ const nonce = seqNonce(seq)
52
+ return gcm(key, nonce).encrypt(plaintext)
53
+ }
54
+
55
+ // Throws KxcoPqTlsError on authentication failure.
56
+ export function openFrame(key, seq, ciphertext) {
57
+ try {
58
+ return gcm(key, seqNonce(seq)).decrypt(ciphertext)
59
+ } catch {
60
+ throw new KxcoPqTlsError('frame authentication failed')
61
+ }
62
+ }
63
+
64
+ function seqNonce(seq) {
65
+ const nonce = new Uint8Array(12)
66
+ const v = new DataView(nonce.buffer)
67
+ // 64-bit big-endian sequence number in bytes 0–7; bytes 8–11 remain zero
68
+ v.setUint32(0, Math.floor(seq / 0x100000000) >>> 0, false)
69
+ v.setUint32(4, seq >>> 0, false)
70
+ return nonce
71
+ }
72
+
73
+ // ML-DSA-65 identity helpers for mutual auth
74
+ export function dsaSign(secretKey, message) {
75
+ return mlDsa.ml_dsa65.sign(new Uint8Array(secretKey), message)
76
+ }
77
+
78
+ export function dsaVerify(publicKey, message, signature) {
79
+ return mlDsa.ml_dsa65.verify(new Uint8Array(publicKey), message, new Uint8Array(signature))
80
+ }
81
+
82
+ export { randomBytes, sha256 }
package/src/stream.js ADDED
@@ -0,0 +1,116 @@
1
+ import { Duplex } from 'node:stream'
2
+ import { initiatorHandshake, responderHandshake } from './handshake.js'
3
+ import { sealFrame, openFrame } from './primitives.js'
4
+ import { KxcoPqTlsError } from './errors.js'
5
+
6
+ /**
7
+ * Wrap a Node.js Duplex (net.Socket, etc.) with a PQ-TLS channel.
8
+ * Returns a Promise<Duplex> that resolves once the handshake completes.
9
+ *
10
+ * options.role — 'initiator' | 'responder' (required)
11
+ * options.identity — { publicKey, secretKey } (ML-DSA-65, optional — mutual auth)
12
+ */
13
+ export async function wrapStream(socket, options = {}) {
14
+ if (!options.role) throw new KxcoPqTlsError('wrapStream: options.role is required')
15
+
16
+ // Handshake messages have fixed known sizes so no framing needed.
17
+ const send = (data) => new Promise((res, rej) =>
18
+ socket.write(Buffer.from(data), (err) => err ? rej(err) : res())
19
+ )
20
+ const recv = (n) => readExactly(socket, n)
21
+
22
+ const { txKey, rxKey } =
23
+ options.role === 'initiator'
24
+ ? await initiatorHandshake(send, recv, options)
25
+ : await responderHandshake(send, recv, options)
26
+
27
+ return new PqTlsStream(socket, txKey, rxKey)
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Encrypted Duplex stream
32
+ // ---------------------------------------------------------------------------
33
+
34
+ class PqTlsStream extends Duplex {
35
+ constructor(socket, txKey, rxKey) {
36
+ super()
37
+ this._socket = socket
38
+ this._txKey = txKey
39
+ this._rxKey = rxKey
40
+ this._txSeq = 0
41
+ this._rxSeq = 0
42
+ this._buf = Buffer.alloc(0)
43
+
44
+ socket.on('data', (chunk) => this._onData(chunk))
45
+ socket.once('end', () => this.push(null))
46
+ socket.once('error', (err) => this.destroy(err))
47
+ this.once('finish', () => socket.end())
48
+ }
49
+
50
+ _write(chunk, _enc, cb) {
51
+ const ct = sealFrame(this._txKey, this._txSeq++, Buffer.from(chunk))
52
+ const hdr = Buffer.allocUnsafe(4)
53
+ hdr.writeUInt32BE(ct.length, 0)
54
+ this._socket.write(Buffer.concat([hdr, ct]), cb)
55
+ }
56
+
57
+ _read() {} // push-based; driven by socket 'data' events
58
+
59
+ _onData(chunk) {
60
+ this._buf = Buffer.concat([this._buf, chunk])
61
+ while (this._buf.length >= 4) {
62
+ const frameLen = this._buf.readUInt32BE(0)
63
+ if (this._buf.length < 4 + frameLen) break
64
+ const ct = this._buf.slice(4, 4 + frameLen)
65
+ this._buf = this._buf.slice(4 + frameLen)
66
+ let plain
67
+ try {
68
+ plain = openFrame(this._rxKey, this._rxSeq++, ct)
69
+ } catch (err) {
70
+ this.destroy(err)
71
+ return
72
+ }
73
+ if (!this.push(Buffer.from(plain))) this._socket.pause()
74
+ }
75
+ }
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Stream I/O helpers for handshake
80
+ // ---------------------------------------------------------------------------
81
+
82
+ async function readExactly(socket, n) {
83
+ return new Promise((resolve, reject) => {
84
+ const buf = Buffer.allocUnsafe(n)
85
+ let offset = 0
86
+
87
+ const cleanup = () => {
88
+ socket.off('data', onData)
89
+ socket.off('error', onError)
90
+ socket.off('end', onEnd)
91
+ }
92
+
93
+ const onData = (chunk) => {
94
+ const needed = n - offset
95
+ if (chunk.length <= needed) {
96
+ chunk.copy(buf, offset)
97
+ offset += chunk.length
98
+ } else {
99
+ chunk.copy(buf, offset, 0, needed)
100
+ socket.unshift(chunk.slice(needed))
101
+ offset = n
102
+ }
103
+ if (offset === n) {
104
+ cleanup()
105
+ resolve(buf)
106
+ }
107
+ }
108
+
109
+ const onError = (err) => { cleanup(); reject(err) }
110
+ const onEnd = () => { cleanup(); reject(new KxcoPqTlsError('stream ended during handshake')) }
111
+
112
+ socket.on('data', onData)
113
+ socket.once('error', onError)
114
+ socket.once('end', onEnd)
115
+ })
116
+ }
@@ -0,0 +1,134 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { initiatorHandshake, responderHandshake } from './handshake.js'
3
+ import { sealFrame, openFrame } from './primitives.js'
4
+ import { KxcoPqTlsError } from './errors.js'
5
+
6
+ /**
7
+ * Wrap a WebSocket with a PQ-TLS channel.
8
+ * Compatible with the `ws` npm package (Node.js) and the native WebSocket API
9
+ * (Cloudflare Workers, browsers, Node.js 22+).
10
+ *
11
+ * Returns a Promise<PqTlsWebSocket> that resolves once the handshake completes.
12
+ *
13
+ * options.role — 'initiator' | 'responder' (required)
14
+ * options.identity — { publicKey, secretKey } (ML-DSA-65, optional — mutual auth)
15
+ */
16
+ export async function wrapWebSocket(ws, options = {}) {
17
+ if (!options.role) throw new KxcoPqTlsError('wrapWebSocket: options.role is required')
18
+
19
+ const send = (data) => wsSend(ws, data)
20
+ const recv = () => wsRecv(ws)
21
+
22
+ const { txKey, rxKey } =
23
+ options.role === 'initiator'
24
+ ? await initiatorHandshake(send, recv, options)
25
+ : await responderHandshake(send, recv, options)
26
+
27
+ return new PqTlsWebSocket(ws, txKey, rxKey)
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Encrypted WebSocket wrapper
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export class PqTlsWebSocket extends EventEmitter {
35
+ constructor(ws, txKey, rxKey) {
36
+ super()
37
+ this._ws = ws
38
+ this._txKey = txKey
39
+ this._rxKey = rxKey
40
+ this._txSeq = 0
41
+ this._rxSeq = 0
42
+
43
+ const onMsg = (data) => {
44
+ try {
45
+ const plain = openFrame(this._rxKey, this._rxSeq++, toBuffer(data))
46
+ this.emit('message', Buffer.from(plain))
47
+ } catch (err) {
48
+ this.emit('error', err)
49
+ }
50
+ }
51
+
52
+ const onClose = (code, reason) => this.emit('close', code, reason)
53
+ const onError = (err) => this.emit('error', err)
54
+
55
+ // Support both ws (Node.js) and native WebSocket (Workers/browser) event APIs
56
+ if (typeof ws.on === 'function') {
57
+ ws.on('message', onMsg)
58
+ ws.on('close', onClose)
59
+ ws.on('error', onError)
60
+ } else {
61
+ ws.addEventListener('message', (e) => onMsg(e.data))
62
+ ws.addEventListener('close', (e) => this.emit('close', e.code, e.reason))
63
+ ws.addEventListener('error', (e) => this.emit('error', e))
64
+ }
65
+ }
66
+
67
+ send(data) {
68
+ const ct = sealFrame(this._txKey, this._txSeq++, toBuffer(data))
69
+ wsSend(this._ws, ct)
70
+ }
71
+
72
+ close(code, reason) { this._ws.close(code, reason) }
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // WebSocket helpers
77
+ // ---------------------------------------------------------------------------
78
+
79
+ function wsSend(ws, data) {
80
+ return new Promise((resolve, reject) => {
81
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data)
82
+ if (typeof ws.send === 'function') {
83
+ // ws package: send(data, callback)
84
+ try {
85
+ const result = ws.send(buf, (err) => err ? reject(err) : resolve())
86
+ // Native WebSocket (Workers/browser) returns undefined and has no callback
87
+ if (result === undefined && typeof ws.readyState !== 'undefined') resolve()
88
+ } catch (err) {
89
+ reject(err)
90
+ }
91
+ } else {
92
+ reject(new KxcoPqTlsError('ws.send is not a function'))
93
+ }
94
+ })
95
+ }
96
+
97
+ function wsRecv(ws) {
98
+ return new Promise((resolve, reject) => {
99
+ const onMsg = (data) => { cleanup(); resolve(toBuffer(data)) }
100
+ const onClose = () => { cleanup(); reject(new KxcoPqTlsError('WebSocket closed during handshake')) }
101
+ const onError = (err) => { cleanup(); reject(err) }
102
+
103
+ const cleanup = () => {
104
+ if (typeof ws.off === 'function') {
105
+ ws.off('message', onMsg)
106
+ ws.off('close', onClose)
107
+ ws.off('error', onError)
108
+ } else {
109
+ ws.removeEventListener('message', onMsgNative)
110
+ ws.removeEventListener('close', onClose)
111
+ ws.removeEventListener('error', onError)
112
+ }
113
+ }
114
+
115
+ const onMsgNative = (e) => onMsg(e.data)
116
+
117
+ if (typeof ws.once === 'function') {
118
+ ws.once('message', onMsg)
119
+ ws.once('close', onClose)
120
+ ws.once('error', onError)
121
+ } else {
122
+ ws.addEventListener('message', onMsgNative, { once: true })
123
+ ws.addEventListener('close', onClose, { once: true })
124
+ ws.addEventListener('error', onError, { once: true })
125
+ }
126
+ })
127
+ }
128
+
129
+ function toBuffer(data) {
130
+ if (Buffer.isBuffer(data)) return data
131
+ if (data instanceof Uint8Array) return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
132
+ if (data instanceof ArrayBuffer) return Buffer.from(data)
133
+ return Buffer.from(String(data))
134
+ }