@7h3/protocol-pq 0.5.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.
@@ -0,0 +1,294 @@
1
+ const textEncoder = new TextEncoder();
2
+ const HMAC_KEY_CACHE_LIMIT = 256;
3
+ const hmacKeyCache = new Map();
4
+ const ED25519_KEY_CACHE_LIMIT = 256;
5
+ const ed25519PrivateKeyCache = new Map();
6
+ const ed25519PublicKeyCache = new Map();
7
+ function getBufferLike() {
8
+ const candidate = globalThis;
9
+ return candidate.Buffer ?? null;
10
+ }
11
+ function serializeHeaderCanonical(header) {
12
+ const parts = [
13
+ `"messageId":${JSON.stringify(header.messageId)}`,
14
+ `"nonce":${JSON.stringify(header.nonce)}`,
15
+ ];
16
+ if (header.recipient !== undefined) {
17
+ parts.push(`"recipient":${JSON.stringify(header.recipient)}`);
18
+ }
19
+ parts.push(`"sender":${JSON.stringify(header.sender)}`);
20
+ parts.push(`"timestampMs":${header.timestampMs}`);
21
+ parts.push(`"ttlMs":${header.ttlMs}`);
22
+ parts.push(`"version":${JSON.stringify(header.version)}`);
23
+ return `{${parts.join(',')}}`;
24
+ }
25
+ function serializeBodyCanonical(body) {
26
+ const parts = [];
27
+ if (body.capability !== undefined) {
28
+ parts.push(`"capability":${JSON.stringify(body.capability)}`);
29
+ }
30
+ parts.push(`"content":${JSON.stringify(body.content)}`);
31
+ if (body.correlationId !== undefined) {
32
+ parts.push(`"correlationId":${JSON.stringify(body.correlationId)}`);
33
+ }
34
+ parts.push(`"intent":${JSON.stringify(body.intent)}`);
35
+ return `{${parts.join(',')}}`;
36
+ }
37
+ function getCachedHmacKey(secret) {
38
+ const cacheKey = `hs256:${secret}`;
39
+ const cached = hmacKeyCache.get(cacheKey);
40
+ if (cached)
41
+ return cached;
42
+ if (hmacKeyCache.size >= HMAC_KEY_CACHE_LIMIT) {
43
+ hmacKeyCache.clear();
44
+ }
45
+ const subtle = requireCryptoSubtle();
46
+ const imported = subtle
47
+ .importKey('raw', textEncoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])
48
+ .catch((error) => {
49
+ hmacKeyCache.delete(cacheKey);
50
+ throw error;
51
+ });
52
+ hmacKeyCache.set(cacheKey, imported);
53
+ return imported;
54
+ }
55
+ function getCachedEd25519PrivateKey(privateKeyPkcs8Base64Url) {
56
+ const cacheKey = `ed25519:pkcs8:${privateKeyPkcs8Base64Url}`;
57
+ const cached = ed25519PrivateKeyCache.get(cacheKey);
58
+ if (cached)
59
+ return cached;
60
+ if (ed25519PrivateKeyCache.size >= ED25519_KEY_CACHE_LIMIT) {
61
+ ed25519PrivateKeyCache.clear();
62
+ }
63
+ const subtle = requireCryptoSubtle();
64
+ const imported = subtle
65
+ .importKey('pkcs8', toArrayBuffer(fromBase64Url(privateKeyPkcs8Base64Url)), { name: 'Ed25519' }, false, ['sign'])
66
+ .catch((error) => {
67
+ ed25519PrivateKeyCache.delete(cacheKey);
68
+ throw error;
69
+ });
70
+ ed25519PrivateKeyCache.set(cacheKey, imported);
71
+ return imported;
72
+ }
73
+ function getCachedEd25519PublicKey(publicKeySpkiBase64Url) {
74
+ const cacheKey = `ed25519:spki:${publicKeySpkiBase64Url}`;
75
+ const cached = ed25519PublicKeyCache.get(cacheKey);
76
+ if (cached)
77
+ return cached;
78
+ if (ed25519PublicKeyCache.size >= ED25519_KEY_CACHE_LIMIT) {
79
+ ed25519PublicKeyCache.clear();
80
+ }
81
+ const subtle = requireCryptoSubtle();
82
+ const imported = subtle
83
+ .importKey('spki', toArrayBuffer(fromBase64Url(publicKeySpkiBase64Url)), { name: 'Ed25519' }, false, ['verify'])
84
+ .catch((error) => {
85
+ ed25519PublicKeyCache.delete(cacheKey);
86
+ throw error;
87
+ });
88
+ ed25519PublicKeyCache.set(cacheKey, imported);
89
+ return imported;
90
+ }
91
+ function toBase64Url(bytes) {
92
+ const bufferLike = getBufferLike();
93
+ const base64 = bufferLike ? bufferLike.from(bytes).toString('base64') : btoa(String.fromCharCode(...bytes));
94
+ return base64
95
+ .replace(/\+/g, '-')
96
+ .replace(/\//g, '_')
97
+ .replace(/=+$/g, '');
98
+ }
99
+ function fromBase64Url(value) {
100
+ const padded = value
101
+ .replace(/-/g, '+')
102
+ .replace(/_/g, '/')
103
+ .padEnd(Math.ceil(value.length / 4) * 4, '=');
104
+ const bufferLike = getBufferLike();
105
+ if (bufferLike) {
106
+ return new Uint8Array(bufferLike.from(padded, 'base64'));
107
+ }
108
+ const binary = atob(padded);
109
+ const bytes = new Uint8Array(binary.length);
110
+ for (let i = 0; i < binary.length; i += 1) {
111
+ bytes[i] = binary.charCodeAt(i);
112
+ }
113
+ return bytes;
114
+ }
115
+ function toArrayBuffer(bytes) {
116
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
117
+ }
118
+ export function canonicalizeEnvelope(envelope) {
119
+ return `{"body":${serializeBodyCanonical(envelope.body)},"header":${serializeHeaderCanonical(envelope.header)}}`;
120
+ }
121
+ function requireCryptoSubtle() {
122
+ if (typeof crypto === 'undefined' || !crypto.subtle) {
123
+ throw new Error('Web Crypto API is not available in this runtime');
124
+ }
125
+ return crypto.subtle;
126
+ }
127
+ async function hmacSign(payload, secret) {
128
+ const subtle = requireCryptoSubtle();
129
+ const key = await getCachedHmacKey(secret);
130
+ const signature = await subtle.sign('HMAC', key, textEncoder.encode(payload));
131
+ return toBase64Url(new Uint8Array(signature));
132
+ }
133
+ async function hmacVerify(payload, signature, secret) {
134
+ const subtle = requireCryptoSubtle();
135
+ const key = await getCachedHmacKey(secret);
136
+ const signatureBytes = fromBase64Url(signature);
137
+ return subtle.verify('HMAC', key, signatureBytes.buffer, textEncoder.encode(payload));
138
+ }
139
+ async function ed25519Sign(payload, privateKeyPkcs8Base64Url) {
140
+ const subtle = requireCryptoSubtle();
141
+ const key = await getCachedEd25519PrivateKey(privateKeyPkcs8Base64Url);
142
+ const signature = await subtle.sign('Ed25519', key, textEncoder.encode(payload));
143
+ return toBase64Url(new Uint8Array(signature));
144
+ }
145
+ async function ed25519Verify(payload, signature, publicKeySpkiBase64Url) {
146
+ const subtle = requireCryptoSubtle();
147
+ const key = await getCachedEd25519PublicKey(publicKeySpkiBase64Url);
148
+ const signatureBytes = fromBase64Url(signature);
149
+ return subtle.verify('Ed25519', key, signatureBytes.buffer, textEncoder.encode(payload));
150
+ }
151
+ export async function signCanonicalPayloadHmac(payload, secret) {
152
+ return hmacSign(payload, secret);
153
+ }
154
+ export async function verifyCanonicalPayloadHmac(payload, signature, secret) {
155
+ return hmacVerify(payload, signature, secret);
156
+ }
157
+ export async function generateEd25519KeypairBase64Url() {
158
+ const subtle = requireCryptoSubtle();
159
+ const pair = await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
160
+ const privateKeyRaw = await subtle.exportKey('pkcs8', pair.privateKey);
161
+ const publicKeyRaw = await subtle.exportKey('spki', pair.publicKey);
162
+ return {
163
+ privateKey: toBase64Url(new Uint8Array(privateKeyRaw)),
164
+ publicKey: toBase64Url(new Uint8Array(publicKeyRaw)),
165
+ };
166
+ }
167
+ export async function signCanonicalPayloadEd25519(payload, privateKeyPkcs8Base64Url) {
168
+ return ed25519Sign(payload, privateKeyPkcs8Base64Url);
169
+ }
170
+ export async function verifyCanonicalPayloadEd25519(payload, signature, publicKeySpkiBase64Url) {
171
+ return ed25519Verify(payload, signature, publicKeySpkiBase64Url);
172
+ }
173
+ export async function signEnvelopeHmac(envelope, secret, keyId = 'local-dev-key') {
174
+ const payload = canonicalizeEnvelope(envelope);
175
+ const signature = await hmacSign(payload, secret);
176
+ return {
177
+ ...envelope,
178
+ signature: {
179
+ alg: 'HS256',
180
+ keyId,
181
+ value: signature,
182
+ },
183
+ };
184
+ }
185
+ export async function verifyEnvelopeHmac(envelope, secret) {
186
+ if (!envelope.signature)
187
+ return false;
188
+ if (envelope.signature.alg !== 'HS256')
189
+ return false;
190
+ const unsigned = {
191
+ header: envelope.header,
192
+ body: envelope.body,
193
+ };
194
+ const payload = canonicalizeEnvelope(unsigned);
195
+ return hmacVerify(payload, envelope.signature.value, secret);
196
+ }
197
+ export async function signEnvelopeEd25519(envelope, privateKeyPkcs8Base64Url, keyId = 'local-ed25519-key') {
198
+ const payload = canonicalizeEnvelope(envelope);
199
+ const signature = await ed25519Sign(payload, privateKeyPkcs8Base64Url);
200
+ return {
201
+ ...envelope,
202
+ signature: {
203
+ alg: 'ED25519',
204
+ keyId,
205
+ value: signature,
206
+ },
207
+ };
208
+ }
209
+ export async function verifyEnvelopeEd25519(envelope, publicKeySpkiBase64Url) {
210
+ if (!envelope.signature)
211
+ return false;
212
+ if (envelope.signature.alg !== 'ED25519')
213
+ return false;
214
+ const unsigned = {
215
+ header: envelope.header,
216
+ body: envelope.body,
217
+ };
218
+ const payload = canonicalizeEnvelope(unsigned);
219
+ return ed25519Verify(payload, envelope.signature.value, publicKeySpkiBase64Url);
220
+ }
221
+ export async function verifyEnvelopeSignature(envelope, material) {
222
+ if (!envelope.signature)
223
+ return false;
224
+ if (envelope.signature.alg !== material.alg)
225
+ return false;
226
+ if (material.alg === 'HS256') {
227
+ return verifyEnvelopeHmac(envelope, material.secret);
228
+ }
229
+ return verifyEnvelopeEd25519(envelope, material.publicKey);
230
+ }
231
+ export async function verifyCanonicalPayloadSignature(payload, signature, material) {
232
+ if (!signature)
233
+ return false;
234
+ if (signature.alg !== material.alg)
235
+ return false;
236
+ if (material.alg === 'HS256') {
237
+ return verifyCanonicalPayloadHmac(payload, signature.value, material.secret);
238
+ }
239
+ return verifyCanonicalPayloadEd25519(payload, signature.value, material.publicKey);
240
+ }
241
+ export function validateEnvelope(envelope, nowMs = Date.now()) {
242
+ const diagnostics = [];
243
+ const header = envelope.header ?? {};
244
+ const body = envelope.body ?? {};
245
+ const version = typeof header.version === 'string' ? header.version : '';
246
+ const messageId = typeof header.messageId === 'string' ? header.messageId : '';
247
+ const sender = typeof header.sender === 'string' ? header.sender : '';
248
+ const nonce = typeof header.nonce === 'string' ? header.nonce : '';
249
+ const timestampMs = typeof header.timestampMs === 'number' ? header.timestampMs : 0;
250
+ const ttlMs = typeof header.ttlMs === 'number' ? header.ttlMs : 0;
251
+ const content = typeof body.content === 'string' ? body.content : '';
252
+ if (version !== '7h3/0.1') {
253
+ diagnostics.push({ level: 'error', message: `Unsupported protocol version '${version}'` });
254
+ }
255
+ if (!messageId.trim()) {
256
+ diagnostics.push({ level: 'error', message: 'Missing messageId' });
257
+ }
258
+ if (!sender.trim()) {
259
+ diagnostics.push({ level: 'error', message: 'Missing sender identity' });
260
+ }
261
+ if (!nonce.trim()) {
262
+ diagnostics.push({ level: 'error', message: 'Missing nonce — replay protection requires a unique nonce per message' });
263
+ }
264
+ if (ttlMs <= 0) {
265
+ diagnostics.push({ level: 'error', message: 'ttlMs must be greater than zero' });
266
+ }
267
+ if (timestampMs + ttlMs < nowMs) {
268
+ diagnostics.push({ level: 'error', message: 'Message TTL expired' });
269
+ }
270
+ if (!content.trim()) {
271
+ diagnostics.push({ level: 'warning', message: 'Empty content payload' });
272
+ }
273
+ return diagnostics;
274
+ }
275
+ export function createEnvelope(input) {
276
+ const nowMs = input.nowMs ?? Date.now();
277
+ return {
278
+ header: {
279
+ version: '7h3/0.1',
280
+ messageId: input.messageId ?? `msg-${nowMs}-${Math.random().toString(36).slice(2, 10)}`,
281
+ timestampMs: nowMs,
282
+ ttlMs: input.ttlMs ?? 60_000,
283
+ sender: input.sender,
284
+ recipient: input.recipient,
285
+ nonce: input.nonce ?? Math.random().toString(36).slice(2, 12),
286
+ },
287
+ body: {
288
+ intent: input.intent,
289
+ content: input.content,
290
+ capability: input.capability,
291
+ correlationId: input.correlationId,
292
+ },
293
+ };
294
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@7h3/protocol-pq",
3
+ "version": "0.5.0",
4
+ "description": "7h3 Protocol — Post-quantum signatures (ML-DSA-65, ML-DSA-87) via @noble/post-quantum",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "types": "./index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./index.js"
12
+ }
13
+ },
14
+ "dependencies": {
15
+ "@noble/post-quantum": "^0.2.0"
16
+ },
17
+ "peerDependencies": {
18
+ "@7h3/protocol": "^0.5.0"
19
+ },
20
+ "license": "MIT",
21
+ "keywords": [
22
+ "7h3",
23
+ "protocol",
24
+ "post-quantum",
25
+ "ml-dsa",
26
+ "dilithium",
27
+ "signing"
28
+ ],
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/IceMasterT/7h3-protocol.git"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "test": "vitest run src/index.test.ts"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^26.1.0",
39
+ "typescript": "~6.0.3",
40
+ "vitest": "^4.1.8"
41
+ }
42
+ }
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ generatePqKeyPair,
4
+ signEnvelopePq,
5
+ verifyEnvelopePq,
6
+ signEnvelopeMlDsa65,
7
+ signEnvelopeMlDsa87,
8
+ verifyEnvelopeMlDsa65,
9
+ verifyEnvelopeMlDsa87,
10
+ createEnvelope,
11
+ } from './index.js'
12
+
13
+ // Conformance vector envelope (deterministic for tests)
14
+ const baseEnvelope = {
15
+ header: {
16
+ version: '7h3/0.1' as const,
17
+ messageId: 'test-msg-001',
18
+ timestampMs: 1_700_000_000_000,
19
+ ttlMs: 60_000,
20
+ sender: 'agent-alpha',
21
+ recipient: 'agent-beta',
22
+ nonce: 'abc123xyz',
23
+ },
24
+ body: {
25
+ intent: 'TASK' as const,
26
+ content: 'Hello, post-quantum world!',
27
+ },
28
+ }
29
+
30
+ describe('generatePqKeyPair', () => {
31
+ it('1. ML-DSA-65 returns keypair with correct algorithm field', () => {
32
+ const kp = generatePqKeyPair('ML-DSA-65')
33
+ expect(kp.algorithm).toBe('ML-DSA-65')
34
+ expect(typeof kp.publicKey).toBe('string')
35
+ expect(typeof kp.privateKey).toBe('string')
36
+ expect(typeof kp.createdAt).toBe('number')
37
+ expect(kp.publicKey.length).toBeGreaterThan(0)
38
+ expect(kp.privateKey.length).toBeGreaterThan(0)
39
+ })
40
+
41
+ it('2. ML-DSA-87 returns keypair', () => {
42
+ const kp = generatePqKeyPair('ML-DSA-87')
43
+ expect(kp.algorithm).toBe('ML-DSA-87')
44
+ expect(typeof kp.publicKey).toBe('string')
45
+ expect(typeof kp.privateKey).toBe('string')
46
+ })
47
+
48
+ it('7. ML-DSA-65 public key is correct size (1952 bytes → ~2603 base64url chars)', () => {
49
+ const kp = generatePqKeyPair('ML-DSA-65')
50
+ // base64url: ceil(1952 / 3) * 4 = 2604 chars, minus up to 2 padding = 2603 or 2604
51
+ const decoded = Buffer.from(
52
+ kp.publicKey.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(kp.publicKey.length / 4) * 4, '='),
53
+ 'base64'
54
+ )
55
+ expect(decoded.length).toBe(1952)
56
+ })
57
+
58
+ it('8. ML-DSA-65 private key is correct size (4032 bytes secretKey)', () => {
59
+ const kp = generatePqKeyPair('ML-DSA-65')
60
+ const decoded = Buffer.from(
61
+ kp.privateKey.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(kp.privateKey.length / 4) * 4, '='),
62
+ 'base64'
63
+ )
64
+ expect(decoded.length).toBe(4032)
65
+ })
66
+ })
67
+
68
+ describe('ML-DSA-65 sign/verify', () => {
69
+ it('3. ML-DSA-65 sign + verify round trip', async () => {
70
+ const kp = generatePqKeyPair('ML-DSA-65')
71
+ const signed = await signEnvelopePq(baseEnvelope, kp.privateKey, 'ML-DSA-65')
72
+ expect(signed.signature?.alg).toBe('ML-DSA-65')
73
+ expect(typeof signed.signature?.value).toBe('string')
74
+ expect(signed.signature!.value.length).toBeGreaterThan(0)
75
+ const valid = await verifyEnvelopePq(signed, kp.publicKey)
76
+ expect(valid).toBe(true)
77
+ })
78
+
79
+ it('4. ML-DSA-65 verify fails on tampered envelope', async () => {
80
+ const kp = generatePqKeyPair('ML-DSA-65')
81
+ const signed = await signEnvelopePq(baseEnvelope, kp.privateKey, 'ML-DSA-65')
82
+ const tampered = {
83
+ ...signed,
84
+ body: { ...signed.body, content: 'TAMPERED content' },
85
+ }
86
+ const valid = await verifyEnvelopePq(tampered, kp.publicKey)
87
+ expect(valid).toBe(false)
88
+ })
89
+ })
90
+
91
+ describe('ML-DSA-87 sign/verify', () => {
92
+ it('5. ML-DSA-87 sign + verify round trip', async () => {
93
+ const kp = generatePqKeyPair('ML-DSA-87')
94
+ const signed = await signEnvelopePq(baseEnvelope, kp.privateKey, 'ML-DSA-87')
95
+ expect(signed.signature?.alg).toBe('ML-DSA-87')
96
+ const valid = await verifyEnvelopePq(signed, kp.publicKey)
97
+ expect(valid).toBe(true)
98
+ })
99
+
100
+ it('6. ML-DSA-87 verify fails with wrong public key', async () => {
101
+ const kp = generatePqKeyPair('ML-DSA-87')
102
+ const wrongKp = generatePqKeyPair('ML-DSA-87')
103
+ const signed = await signEnvelopePq(baseEnvelope, kp.privateKey, 'ML-DSA-87')
104
+ const valid = await verifyEnvelopePq(signed, wrongKp.publicKey)
105
+ expect(valid).toBe(false)
106
+ })
107
+ })
108
+
109
+ describe('algorithm-specific aliases', () => {
110
+ it('signEnvelopeMlDsa65 / verifyEnvelopeMlDsa65 round trip', async () => {
111
+ const kp = generatePqKeyPair('ML-DSA-65')
112
+ const envelope = createEnvelope({
113
+ sender: 'agent-a',
114
+ recipient: 'agent-b',
115
+ intent: 'PING',
116
+ content: 'alias test',
117
+ })
118
+ const signed = await signEnvelopeMlDsa65(envelope, kp.privateKey)
119
+ expect(signed.signature?.alg).toBe('ML-DSA-65')
120
+ const valid = await verifyEnvelopeMlDsa65(signed, kp.publicKey)
121
+ expect(valid).toBe(true)
122
+ })
123
+
124
+ it('signEnvelopeMlDsa87 / verifyEnvelopeMlDsa87 round trip', async () => {
125
+ const kp = generatePqKeyPair('ML-DSA-87')
126
+ const envelope = createEnvelope({
127
+ sender: 'agent-a',
128
+ recipient: 'agent-b',
129
+ intent: 'PONG',
130
+ content: 'ml-dsa-87 alias test',
131
+ })
132
+ const signed = await signEnvelopeMlDsa87(envelope, kp.privateKey)
133
+ expect(signed.signature?.alg).toBe('ML-DSA-87')
134
+ const valid = await verifyEnvelopeMlDsa87(signed, kp.publicKey)
135
+ expect(valid).toBe(true)
136
+ })
137
+
138
+ it('verifyEnvelopePq returns false when no signature', async () => {
139
+ const unsigned = { ...baseEnvelope }
140
+ const valid = await verifyEnvelopePq(unsigned, 'fakepublickey')
141
+ expect(valid).toBe(false)
142
+ })
143
+ })
package/src/index.ts ADDED
@@ -0,0 +1,166 @@
1
+ import { ml_dsa65, ml_dsa87 } from '@noble/post-quantum/ml-dsa'
2
+
3
+ // Re-export from @7h3/protocol
4
+ export type { ProtocolEnvelope, ProtocolHeader, ProtocolBody } from '../../../src/protocol.js'
5
+ export { canonicalizeEnvelope, createEnvelope } from '../../../src/protocol.js'
6
+
7
+ import type { ProtocolEnvelope, ProtocolHeader, ProtocolBody } from '../../../src/protocol.js'
8
+ import { canonicalizeEnvelope } from '../../../src/protocol.js'
9
+
10
+ // ─── Types ──────────────────────────────────────────────────────────────────
11
+
12
+ export type PqAlgorithm = 'ML-DSA-65' | 'ML-DSA-87'
13
+
14
+ export interface PqKeyPair {
15
+ algorithm: PqAlgorithm
16
+ publicKey: string // base64url, no padding
17
+ privateKey: string // base64url, no padding (secretKey bytes from noble)
18
+ createdAt: number
19
+ }
20
+
21
+ /**
22
+ * Extended envelope type that carries a PQ signature.
23
+ * Structurally identical to ProtocolEnvelope but alg is widened.
24
+ */
25
+ export interface PqProtocolEnvelope {
26
+ header: ProtocolHeader
27
+ body: ProtocolBody
28
+ signature?: {
29
+ alg: PqAlgorithm
30
+ keyId: string
31
+ value: string
32
+ }
33
+ }
34
+
35
+ // ─── Base64url helpers ───────────────────────────────────────────────────────
36
+
37
+ function toBase64Url(bytes: Uint8Array): string {
38
+ const g = globalThis as unknown as {
39
+ Buffer?: { from: (b: Uint8Array) => { toString: (enc: string) => string } }
40
+ }
41
+ if (g.Buffer) {
42
+ return g.Buffer.from(bytes)
43
+ .toString('base64')
44
+ .replace(/\+/g, '-')
45
+ .replace(/\//g, '_')
46
+ .replace(/=+$/g, '')
47
+ }
48
+ let binary = ''
49
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
50
+ return btoa(binary)
51
+ .replace(/\+/g, '-')
52
+ .replace(/\//g, '_')
53
+ .replace(/=+$/g, '')
54
+ }
55
+
56
+ function fromBase64Url(value: string): Uint8Array {
57
+ const padded = value
58
+ .replace(/-/g, '+')
59
+ .replace(/_/g, '/')
60
+ .padEnd(Math.ceil(value.length / 4) * 4, '=')
61
+
62
+ const g = globalThis as unknown as {
63
+ Buffer?: { from: (s: string, enc: string) => Uint8Array }
64
+ }
65
+ if (g.Buffer) {
66
+ return new Uint8Array(g.Buffer.from(padded, 'base64'))
67
+ }
68
+ const binary = atob(padded)
69
+ const bytes = new Uint8Array(binary.length)
70
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
71
+ return bytes
72
+ }
73
+
74
+ // ─── Key generation ──────────────────────────────────────────────────────────
75
+
76
+ export function generatePqKeyPair(algorithm: PqAlgorithm = 'ML-DSA-65'): PqKeyPair {
77
+ const impl = algorithm === 'ML-DSA-65' ? ml_dsa65 : ml_dsa87
78
+ const seed = crypto.getRandomValues(new Uint8Array(32))
79
+ const { publicKey, secretKey } = impl.keygen(seed)
80
+ return {
81
+ algorithm,
82
+ publicKey: toBase64Url(publicKey),
83
+ privateKey: toBase64Url(secretKey),
84
+ createdAt: Date.now(),
85
+ }
86
+ }
87
+
88
+ // ─── Sign ────────────────────────────────────────────────────────────────────
89
+
90
+ export async function signEnvelopePq(
91
+ envelope: Omit<ProtocolEnvelope, 'signature'>,
92
+ privateKeyBase64Url: string,
93
+ algorithm: PqAlgorithm = 'ML-DSA-65',
94
+ ): Promise<PqProtocolEnvelope> {
95
+ const impl = algorithm === 'ML-DSA-65' ? ml_dsa65 : ml_dsa87
96
+ const canonical = canonicalizeEnvelope(envelope)
97
+ const message = new TextEncoder().encode(canonical)
98
+ const secretKey = fromBase64Url(privateKeyBase64Url)
99
+ const sigBytes = impl.sign(secretKey, message)
100
+ // keyId derived from first 16 chars of the secretKey base64url
101
+ const keyId = privateKeyBase64Url.slice(0, 16)
102
+
103
+ return {
104
+ header: envelope.header,
105
+ body: envelope.body,
106
+ signature: {
107
+ alg: algorithm,
108
+ keyId,
109
+ value: toBase64Url(sigBytes),
110
+ },
111
+ }
112
+ }
113
+
114
+ // ─── Verify ──────────────────────────────────────────────────────────────────
115
+
116
+ export async function verifyEnvelopePq(
117
+ envelope: PqProtocolEnvelope,
118
+ publicKeyBase64Url: string,
119
+ ): Promise<boolean> {
120
+ if (!envelope.signature) return false
121
+ const alg = envelope.signature.alg
122
+ if (alg !== 'ML-DSA-65' && alg !== 'ML-DSA-87') return false
123
+
124
+ const impl = alg === 'ML-DSA-65' ? ml_dsa65 : ml_dsa87
125
+ const unsigned = { header: envelope.header, body: envelope.body }
126
+ const canonical = canonicalizeEnvelope(unsigned)
127
+ const message = new TextEncoder().encode(canonical)
128
+ const publicKey = fromBase64Url(publicKeyBase64Url)
129
+ const sigBytes = fromBase64Url(envelope.signature.value)
130
+
131
+ try {
132
+ return impl.verify(publicKey, message, sigBytes)
133
+ } catch {
134
+ return false
135
+ }
136
+ }
137
+
138
+ // ─── Algorithm-specific aliases ──────────────────────────────────────────────
139
+
140
+ export async function signEnvelopeMlDsa65(
141
+ envelope: Omit<ProtocolEnvelope, 'signature'>,
142
+ privateKeyBase64Url: string,
143
+ ): Promise<PqProtocolEnvelope> {
144
+ return signEnvelopePq(envelope, privateKeyBase64Url, 'ML-DSA-65')
145
+ }
146
+
147
+ export async function signEnvelopeMlDsa87(
148
+ envelope: Omit<ProtocolEnvelope, 'signature'>,
149
+ privateKeyBase64Url: string,
150
+ ): Promise<PqProtocolEnvelope> {
151
+ return signEnvelopePq(envelope, privateKeyBase64Url, 'ML-DSA-87')
152
+ }
153
+
154
+ export async function verifyEnvelopeMlDsa65(
155
+ envelope: PqProtocolEnvelope,
156
+ publicKeyBase64Url: string,
157
+ ): Promise<boolean> {
158
+ return verifyEnvelopePq(envelope, publicKeyBase64Url)
159
+ }
160
+
161
+ export async function verifyEnvelopeMlDsa87(
162
+ envelope: PqProtocolEnvelope,
163
+ publicKeyBase64Url: string,
164
+ ): Promise<boolean> {
165
+ return verifyEnvelopePq(envelope, publicKeyBase64Url)
166
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "declaration": true,
7
+ "outDir": "./dist",
8
+ "rootDir": "../../../",
9
+ "strict": true,
10
+ "skipLibCheck": true,
11
+ "types": ["node"]
12
+ },
13
+ "include": ["src/index.ts", "../../../src/protocol.ts"]
14
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: false,
6
+ },
7
+ })