@vex-chat/crypto 2.0.1 → 2.1.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/CLA.md +38 -0
- package/LICENSE-COMMERCIAL +10 -0
- package/LICENSING.md +15 -0
- package/README.md +25 -9
- package/dist/index.d.ts +45 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +350 -19
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/__tests__/XKeyConvert.ts +6 -0
- package/src/__tests__/XUtils/bytesEqual.ts +6 -0
- package/src/__tests__/XUtils/emptyHeader.ts +6 -0
- package/src/__tests__/XUtils/numberToUint8Arr.ts +6 -0
- package/src/__tests__/XUtils/packMessage.ts +6 -0
- package/src/__tests__/XUtils/stringEncoding.ts +6 -0
- package/src/__tests__/XUtils/uint8ArrToNumber.ts +6 -0
- package/src/__tests__/XUtils/unpackMessage.ts +6 -0
- package/src/__tests__/cryptoProfile.ts +64 -0
- package/src/__tests__/xAsyncProfile.ts +69 -0
- package/src/__tests__/xConcat.ts +6 -0
- package/src/__tests__/xDH.ts +6 -0
- package/src/__tests__/xEncode.ts +6 -0
- package/src/__tests__/xHMAC.ts +6 -0
- package/src/__tests__/xMnemonic.ts +6 -0
- package/src/index.ts +579 -21
package/src/index.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
import type { BaseMsg } from "@vex-chat/types";
|
|
2
8
|
|
|
3
9
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
@@ -15,6 +21,247 @@ import { Packr } from "msgpackr";
|
|
|
15
21
|
import nacl from "tweetnacl";
|
|
16
22
|
import { z } from "zod/v4";
|
|
17
23
|
|
|
24
|
+
/** Runtime crypto profile selector. */
|
|
25
|
+
export type CryptoProfile = "fips" | "tweetnacl";
|
|
26
|
+
|
|
27
|
+
interface CryptoProvider {
|
|
28
|
+
boxBefore(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array;
|
|
29
|
+
boxKeyPair(): KeyPair;
|
|
30
|
+
boxKeyPairFromSecret(secretKey: Uint8Array): KeyPair;
|
|
31
|
+
profile: CryptoProfile;
|
|
32
|
+
randomBytes(length: number): Uint8Array;
|
|
33
|
+
secretbox(
|
|
34
|
+
plaintext: Uint8Array,
|
|
35
|
+
nonce: Uint8Array,
|
|
36
|
+
key: Uint8Array,
|
|
37
|
+
): Uint8Array;
|
|
38
|
+
secretboxOpen(
|
|
39
|
+
ciphertext: Uint8Array,
|
|
40
|
+
nonce: Uint8Array,
|
|
41
|
+
key: Uint8Array,
|
|
42
|
+
): null | Uint8Array;
|
|
43
|
+
sign(message: Uint8Array, secretKey: Uint8Array): Uint8Array;
|
|
44
|
+
signKeyPair(): KeyPair;
|
|
45
|
+
signKeyPairFromSecret(secretKey: Uint8Array): KeyPair;
|
|
46
|
+
signOpen(
|
|
47
|
+
signedMessage: Uint8Array,
|
|
48
|
+
publicKey: Uint8Array,
|
|
49
|
+
): null | Uint8Array;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type WebCryptoKeyUsage =
|
|
53
|
+
| "decrypt"
|
|
54
|
+
| "deriveBits"
|
|
55
|
+
| "deriveKey"
|
|
56
|
+
| "encrypt"
|
|
57
|
+
| "sign"
|
|
58
|
+
| "unwrapKey"
|
|
59
|
+
| "verify"
|
|
60
|
+
| "wrapKey";
|
|
61
|
+
|
|
62
|
+
interface WebCryptoLike {
|
|
63
|
+
getRandomValues: Crypto["getRandomValues"];
|
|
64
|
+
subtle: SubtleCrypto;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getWebCrypto(): WebCryptoLike {
|
|
68
|
+
const cryptoCandidate: unknown = globalThis.crypto;
|
|
69
|
+
if (!isWebCryptoLike(cryptoCandidate)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"Web Crypto API is not available in this runtime for fips profile operations.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return cryptoCandidate;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isWebCryptoLike(value: unknown): value is WebCryptoLike {
|
|
78
|
+
if (typeof value !== "object" || value === null) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return (
|
|
82
|
+
"getRandomValues" in value &&
|
|
83
|
+
typeof value.getRandomValues === "function" &&
|
|
84
|
+
"subtle" in value &&
|
|
85
|
+
typeof value.subtle === "object" &&
|
|
86
|
+
value.subtle !== null
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function randomBytesWebCrypto(length: number): Uint8Array {
|
|
91
|
+
if (!Number.isInteger(length) || length < 0) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Expected non-negative integer length, received ${String(length)}.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const cryptoImpl = getWebCrypto();
|
|
97
|
+
const result = new Uint8Array(length);
|
|
98
|
+
let offset = 0;
|
|
99
|
+
while (offset < length) {
|
|
100
|
+
const chunkLength = Math.min(65536, length - offset);
|
|
101
|
+
const chunk = result.subarray(offset, offset + chunkLength);
|
|
102
|
+
cryptoImpl.getRandomValues(chunk);
|
|
103
|
+
offset += chunkLength;
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function unsupported(profile: CryptoProfile, operation: string): never {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Crypto profile "${profile}" does not implement ${operation} yet.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const tweetnaclProvider: CryptoProvider = {
|
|
115
|
+
boxBefore: (myPrivateKey, theirPublicKey) =>
|
|
116
|
+
nacl.box.before(theirPublicKey, myPrivateKey),
|
|
117
|
+
boxKeyPair: () => nacl.box.keyPair(),
|
|
118
|
+
boxKeyPairFromSecret: (secretKey) =>
|
|
119
|
+
nacl.box.keyPair.fromSecretKey(secretKey),
|
|
120
|
+
profile: "tweetnacl",
|
|
121
|
+
randomBytes: (length) => nacl.randomBytes(length),
|
|
122
|
+
secretbox: (plaintext, nonce, key) => nacl.secretbox(plaintext, nonce, key),
|
|
123
|
+
secretboxOpen: (ciphertext, nonce, key) =>
|
|
124
|
+
nacl.secretbox.open(ciphertext, nonce, key),
|
|
125
|
+
sign: (message, secretKey) => nacl.sign(message, secretKey),
|
|
126
|
+
signKeyPair: () => nacl.sign.keyPair(),
|
|
127
|
+
signKeyPairFromSecret: (secretKey) =>
|
|
128
|
+
nacl.sign.keyPair.fromSecretKey(secretKey),
|
|
129
|
+
signOpen: (signedMessage, publicKey) =>
|
|
130
|
+
nacl.sign.open(signedMessage, publicKey),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const fipsProvider: CryptoProvider = {
|
|
134
|
+
boxBefore: (myPrivateKey, theirPublicKey) => {
|
|
135
|
+
void myPrivateKey;
|
|
136
|
+
void theirPublicKey;
|
|
137
|
+
return unsupported("fips", "xDH");
|
|
138
|
+
},
|
|
139
|
+
boxKeyPair: () => unsupported("fips", "xBoxKeyPair"),
|
|
140
|
+
boxKeyPairFromSecret: (secretKey) => {
|
|
141
|
+
void secretKey;
|
|
142
|
+
return unsupported("fips", "xBoxKeyPairFromSecret");
|
|
143
|
+
},
|
|
144
|
+
profile: "fips",
|
|
145
|
+
randomBytes: (length) => randomBytesWebCrypto(length),
|
|
146
|
+
secretbox: (plaintext, nonce, key) => {
|
|
147
|
+
void plaintext;
|
|
148
|
+
void nonce;
|
|
149
|
+
void key;
|
|
150
|
+
return unsupported("fips", "xSecretbox");
|
|
151
|
+
},
|
|
152
|
+
secretboxOpen: (ciphertext, nonce, key) => {
|
|
153
|
+
void ciphertext;
|
|
154
|
+
void nonce;
|
|
155
|
+
void key;
|
|
156
|
+
return unsupported("fips", "xSecretboxOpen");
|
|
157
|
+
},
|
|
158
|
+
sign: (message, secretKey) => {
|
|
159
|
+
void message;
|
|
160
|
+
void secretKey;
|
|
161
|
+
return unsupported("fips", "xSign");
|
|
162
|
+
},
|
|
163
|
+
signKeyPair: () => unsupported("fips", "xSignKeyPair"),
|
|
164
|
+
signKeyPairFromSecret: (secretKey) => {
|
|
165
|
+
void secretKey;
|
|
166
|
+
return unsupported("fips", "xSignKeyPairFromSecret");
|
|
167
|
+
},
|
|
168
|
+
signOpen: (signedMessage, publicKey) => {
|
|
169
|
+
void signedMessage;
|
|
170
|
+
void publicKey;
|
|
171
|
+
return unsupported("fips", "xSignOpen");
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const providers: Record<CryptoProfile, CryptoProvider> = {
|
|
176
|
+
fips: fipsProvider,
|
|
177
|
+
tweetnacl: tweetnaclProvider,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
let activeCryptoProfile: CryptoProfile = "tweetnacl";
|
|
181
|
+
let activeCryptoProvider: CryptoProvider = providers[activeCryptoProfile];
|
|
182
|
+
|
|
183
|
+
/** Returns the currently configured crypto profile. */
|
|
184
|
+
export function getCryptoProfile(): CryptoProfile {
|
|
185
|
+
return activeCryptoProfile;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Sets the runtime crypto profile.
|
|
190
|
+
*
|
|
191
|
+
* `tweetnacl` preserves existing behavior.
|
|
192
|
+
* `fips` currently enables only backend-agnostic helpers; NaCl-coupled
|
|
193
|
+
* primitives throw until a FIPS backend implementation is wired in.
|
|
194
|
+
*/
|
|
195
|
+
export function setCryptoProfile(profile: CryptoProfile): void {
|
|
196
|
+
activeCryptoProfile = profile;
|
|
197
|
+
activeCryptoProvider = providers[profile];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
201
|
+
const out = new Uint8Array(a.length + b.length);
|
|
202
|
+
out.set(a, 0);
|
|
203
|
+
out.set(b, a.length);
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function decodeFipsSignedMessage(signedMessage: Uint8Array): {
|
|
208
|
+
message: Uint8Array;
|
|
209
|
+
signature: Uint8Array;
|
|
210
|
+
} {
|
|
211
|
+
if (signedMessage.length < 2) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
"Invalid FIPS signed message: missing signature length prefix.",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const signatureLength =
|
|
217
|
+
(signedMessage[0] ?? 0) * 256 + (signedMessage[1] ?? 0);
|
|
218
|
+
const signatureStart = 2;
|
|
219
|
+
const signatureEnd = signatureStart + signatureLength;
|
|
220
|
+
if (signedMessage.length < signatureEnd) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
"Invalid FIPS signed message: signature length exceeds message length.",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
message: signedMessage.slice(signatureEnd),
|
|
227
|
+
signature: signedMessage.slice(signatureStart, signatureEnd),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function encodeFipsSignedMessage(
|
|
232
|
+
signature: Uint8Array,
|
|
233
|
+
message: Uint8Array,
|
|
234
|
+
): Uint8Array {
|
|
235
|
+
if (signature.length > 65535) {
|
|
236
|
+
throw new Error("FIPS signature too long to encode.");
|
|
237
|
+
}
|
|
238
|
+
const prefix = new Uint8Array(2);
|
|
239
|
+
prefix[0] = (signature.length >> 8) & 0xff;
|
|
240
|
+
prefix[1] = signature.length & 0xff;
|
|
241
|
+
return concatBytes(concatBytes(prefix, signature), message);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getSubtleCrypto(): SubtleCrypto {
|
|
245
|
+
return getWebCrypto().subtle;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function provider(): CryptoProvider {
|
|
249
|
+
return activeCryptoProvider;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function requireAesGcmNonce(nonce: Uint8Array): Uint8Array {
|
|
253
|
+
if (nonce.length < 12) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`AES-GCM requires a nonce of at least 12 bytes, received ${String(nonce.length)}.`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return nonce.slice(0, 12);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
|
262
|
+
return Uint8Array.from(bytes).buffer;
|
|
263
|
+
}
|
|
264
|
+
|
|
18
265
|
// msgpackr with useRecords:false emits standard msgpack (no nonstandard record extension).
|
|
19
266
|
// moreTypes:false keeps the extension set to only what other decoders understand.
|
|
20
267
|
// pack() returns Node Buffer (tight view) so consumers like axios send the correct bytes.
|
|
@@ -106,7 +353,7 @@ export class XUtils {
|
|
|
106
353
|
c: ITERATIONS,
|
|
107
354
|
dkLen: 32,
|
|
108
355
|
});
|
|
109
|
-
const DECRYPTED_SIGNKEY =
|
|
356
|
+
const DECRYPTED_SIGNKEY = provider().secretboxOpen(
|
|
110
357
|
ENCRYPTED_KEY,
|
|
111
358
|
ENCRYPTION_NONCE,
|
|
112
359
|
DERIVED_KEY,
|
|
@@ -118,6 +365,37 @@ export class XUtils {
|
|
|
118
365
|
return XUtils.encodeHex(DECRYPTED_SIGNKEY);
|
|
119
366
|
};
|
|
120
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Async variant of decryptKeyData for cross-runtime/FIPS backends.
|
|
370
|
+
* Supports both profile formats emitted by encryptKeyDataAsync.
|
|
371
|
+
*/
|
|
372
|
+
public static decryptKeyDataAsync = async (
|
|
373
|
+
keyData: Uint8Array,
|
|
374
|
+
password: string,
|
|
375
|
+
): Promise<string> => {
|
|
376
|
+
const ITERATIONS = XUtils.uint8ArrToNumber(keyData.slice(0, 6));
|
|
377
|
+
const PKBDF_SALT = keyData.slice(6, 30);
|
|
378
|
+
const ENCRYPTION_NONCE = keyData.slice(30, 54);
|
|
379
|
+
const ENCRYPTED_KEY = keyData.slice(54);
|
|
380
|
+
const DERIVED_KEY = noblePbkdf2(sha512, password, PKBDF_SALT, {
|
|
381
|
+
c: ITERATIONS,
|
|
382
|
+
dkLen: 32,
|
|
383
|
+
});
|
|
384
|
+
const decrypted =
|
|
385
|
+
activeCryptoProfile === "fips"
|
|
386
|
+
? await xSecretboxOpenAsync(
|
|
387
|
+
ENCRYPTED_KEY,
|
|
388
|
+
ENCRYPTION_NONCE,
|
|
389
|
+
DERIVED_KEY,
|
|
390
|
+
)
|
|
391
|
+
: xSecretboxOpen(ENCRYPTED_KEY, ENCRYPTION_NONCE, DERIVED_KEY);
|
|
392
|
+
|
|
393
|
+
if (decrypted === null) {
|
|
394
|
+
throw new Error("Decryption failed. Wrong password?");
|
|
395
|
+
}
|
|
396
|
+
return XUtils.encodeHex(decrypted);
|
|
397
|
+
};
|
|
398
|
+
|
|
121
399
|
/**
|
|
122
400
|
* Returns the empty header (32 0's)
|
|
123
401
|
*
|
|
@@ -158,7 +436,7 @@ export class XUtils {
|
|
|
158
436
|
): Uint8Array => {
|
|
159
437
|
const UNENCRYPTED_SIGNKEY = XUtils.decodeHex(keyToSave);
|
|
160
438
|
const OFFSET = 1000;
|
|
161
|
-
const rand =
|
|
439
|
+
const rand = provider().randomBytes(2);
|
|
162
440
|
const [N1 = 0, N2 = 0] = rand;
|
|
163
441
|
const iterations =
|
|
164
442
|
iterationOverride !== undefined && iterationOverride !== 0
|
|
@@ -171,7 +449,7 @@ export class XUtils {
|
|
|
171
449
|
dkLen: 32,
|
|
172
450
|
});
|
|
173
451
|
const NONCE = xMakeNonce();
|
|
174
|
-
const ENCRYPTED_SIGNKEY =
|
|
452
|
+
const ENCRYPTED_SIGNKEY = provider().secretbox(
|
|
175
453
|
UNENCRYPTED_SIGNKEY,
|
|
176
454
|
NONCE,
|
|
177
455
|
ENCRYPTION_KEY,
|
|
@@ -194,6 +472,56 @@ export class XUtils {
|
|
|
194
472
|
return result;
|
|
195
473
|
};
|
|
196
474
|
|
|
475
|
+
/**
|
|
476
|
+
* Async variant of encryptKeyData for cross-runtime/FIPS backends.
|
|
477
|
+
* Format remains [iterations(6)|salt(24)|nonce(24)|ciphertext(N)].
|
|
478
|
+
*/
|
|
479
|
+
public static encryptKeyDataAsync = async (
|
|
480
|
+
password: string,
|
|
481
|
+
keyToSave: string,
|
|
482
|
+
iterationOverride?: number,
|
|
483
|
+
): Promise<Uint8Array> => {
|
|
484
|
+
const UNENCRYPTED_SIGNKEY = XUtils.decodeHex(keyToSave);
|
|
485
|
+
const OFFSET = 1000;
|
|
486
|
+
const rand = xRandomBytes(2);
|
|
487
|
+
const [N1 = 0, N2 = 0] = rand;
|
|
488
|
+
const iterations =
|
|
489
|
+
iterationOverride !== undefined && iterationOverride !== 0
|
|
490
|
+
? iterationOverride
|
|
491
|
+
: N1 * N2 + OFFSET;
|
|
492
|
+
const ITERATIONS = XUtils.numberToUint8Arr(iterations);
|
|
493
|
+
const PKBDF_SALT = xMakeNonce();
|
|
494
|
+
const ENCRYPTION_KEY = noblePbkdf2(sha512, password, PKBDF_SALT, {
|
|
495
|
+
c: iterations,
|
|
496
|
+
dkLen: 32,
|
|
497
|
+
});
|
|
498
|
+
const NONCE = xMakeNonce();
|
|
499
|
+
const ENCRYPTED_SIGNKEY =
|
|
500
|
+
activeCryptoProfile === "fips"
|
|
501
|
+
? await xSecretboxAsync(
|
|
502
|
+
UNENCRYPTED_SIGNKEY,
|
|
503
|
+
NONCE,
|
|
504
|
+
ENCRYPTION_KEY,
|
|
505
|
+
)
|
|
506
|
+
: xSecretbox(UNENCRYPTED_SIGNKEY, NONCE, ENCRYPTION_KEY);
|
|
507
|
+
|
|
508
|
+
const result = new Uint8Array(
|
|
509
|
+
ITERATIONS.length +
|
|
510
|
+
PKBDF_SALT.length +
|
|
511
|
+
NONCE.length +
|
|
512
|
+
ENCRYPTED_SIGNKEY.length,
|
|
513
|
+
);
|
|
514
|
+
let offset = 0;
|
|
515
|
+
result.set(ITERATIONS, offset);
|
|
516
|
+
offset += ITERATIONS.length;
|
|
517
|
+
result.set(PKBDF_SALT, offset);
|
|
518
|
+
offset += PKBDF_SALT.length;
|
|
519
|
+
result.set(NONCE, offset);
|
|
520
|
+
offset += NONCE.length;
|
|
521
|
+
result.set(ENCRYPTED_SIGNKEY, offset);
|
|
522
|
+
return result;
|
|
523
|
+
};
|
|
524
|
+
|
|
197
525
|
/**
|
|
198
526
|
* Returns a six bit Uint8Array representation of an integer.
|
|
199
527
|
* The integer must be positive, and it must be able to be stored
|
|
@@ -337,14 +665,52 @@ export interface XConstants {
|
|
|
337
665
|
|
|
338
666
|
/** Generate a fresh X25519 box key pair. */
|
|
339
667
|
export function xBoxKeyPair(): KeyPair {
|
|
340
|
-
return
|
|
668
|
+
return provider().boxKeyPair();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Async box keypair generation for the active profile. */
|
|
672
|
+
export async function xBoxKeyPairAsync(): Promise<KeyPair> {
|
|
673
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
674
|
+
return xBoxKeyPair();
|
|
675
|
+
}
|
|
676
|
+
const subtle = getSubtleCrypto();
|
|
677
|
+
const pair = await subtle.generateKey(
|
|
678
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
679
|
+
true,
|
|
680
|
+
["deriveBits"],
|
|
681
|
+
);
|
|
682
|
+
return {
|
|
683
|
+
publicKey: new Uint8Array(
|
|
684
|
+
await subtle.exportKey("raw", pair.publicKey),
|
|
685
|
+
),
|
|
686
|
+
secretKey: new Uint8Array(
|
|
687
|
+
await subtle.exportKey("pkcs8", pair.privateKey),
|
|
688
|
+
),
|
|
689
|
+
};
|
|
341
690
|
}
|
|
342
691
|
|
|
343
692
|
/** Restore an X25519 box key pair from a 32-byte secret key. */
|
|
344
693
|
export function xBoxKeyPairFromSecret(secretKey: Uint8Array): KeyPair {
|
|
345
|
-
return
|
|
694
|
+
return provider().boxKeyPairFromSecret(secretKey);
|
|
346
695
|
}
|
|
347
696
|
|
|
697
|
+
/** Async box key restore from private key material. */
|
|
698
|
+
export function xBoxKeyPairFromSecretAsync(
|
|
699
|
+
secretKey: Uint8Array,
|
|
700
|
+
): Promise<KeyPair> {
|
|
701
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
702
|
+
return Promise.resolve(xBoxKeyPairFromSecret(secretKey));
|
|
703
|
+
}
|
|
704
|
+
void secretKey;
|
|
705
|
+
return Promise.reject(
|
|
706
|
+
new Error(
|
|
707
|
+
'Crypto profile "fips" does not implement xBoxKeyPairFromSecretAsync yet.',
|
|
708
|
+
),
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── Key pair type ───────────────────────────────────────────────────────────
|
|
713
|
+
|
|
348
714
|
/**
|
|
349
715
|
* Concatanates multiple Uint8Arrays.
|
|
350
716
|
*
|
|
@@ -370,6 +736,8 @@ export function xConcat(...arrays: Uint8Array[]): Uint8Array {
|
|
|
370
736
|
return result;
|
|
371
737
|
}
|
|
372
738
|
|
|
739
|
+
// ── Key generation ─────────────────────────────────────────────────────────
|
|
740
|
+
|
|
373
741
|
/**
|
|
374
742
|
* Derives a shared Secret Key from a known private key and
|
|
375
743
|
* a peer's known public key.
|
|
@@ -382,10 +750,27 @@ export function xDH(
|
|
|
382
750
|
myPrivateKey: Uint8Array,
|
|
383
751
|
theirPublicKey: Uint8Array,
|
|
384
752
|
): Uint8Array {
|
|
385
|
-
return
|
|
753
|
+
return provider().boxBefore(myPrivateKey, theirPublicKey);
|
|
386
754
|
}
|
|
387
755
|
|
|
388
|
-
|
|
756
|
+
/** Async DH for cross-runtime/FIPS backends. */
|
|
757
|
+
export async function xDHAsync(
|
|
758
|
+
myPrivateKey: Uint8Array,
|
|
759
|
+
theirPublicKey: Uint8Array,
|
|
760
|
+
): Promise<Uint8Array> {
|
|
761
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
762
|
+
return xDH(myPrivateKey, theirPublicKey);
|
|
763
|
+
}
|
|
764
|
+
const subtle = getSubtleCrypto();
|
|
765
|
+
const privateKey = await importEcdhPrivateKey(myPrivateKey);
|
|
766
|
+
const publicKey = await importEcdhPublicKey(theirPublicKey);
|
|
767
|
+
const shared = await subtle.deriveBits(
|
|
768
|
+
{ name: "ECDH", public: publicKey },
|
|
769
|
+
privateKey,
|
|
770
|
+
256,
|
|
771
|
+
);
|
|
772
|
+
return new Uint8Array(shared);
|
|
773
|
+
}
|
|
389
774
|
|
|
390
775
|
/**
|
|
391
776
|
* Encode an X25519 or X448 public key PK into a byte sequence.
|
|
@@ -431,8 +816,6 @@ export function xEncode(
|
|
|
431
816
|
return Uint8Array.from(bytes);
|
|
432
817
|
}
|
|
433
818
|
|
|
434
|
-
// ── Key generation ─────────────────────────────────────────────────────────
|
|
435
|
-
|
|
436
819
|
/**
|
|
437
820
|
* Hashes some data.
|
|
438
821
|
*
|
|
@@ -443,6 +826,8 @@ export function xHash(data: Uint8Array) {
|
|
|
443
826
|
return XUtils.encodeHex(sha512(data));
|
|
444
827
|
}
|
|
445
828
|
|
|
829
|
+
// ── Signing ────────────────────────────────────────────────────────────────
|
|
830
|
+
|
|
446
831
|
export function xKDF(IKM: Uint8Array): Uint8Array {
|
|
447
832
|
return hkdf(
|
|
448
833
|
sha512,
|
|
@@ -457,23 +842,45 @@ export function xKDF(IKM: Uint8Array): Uint8Array {
|
|
|
457
842
|
* Returns a 24 byte random nonce of cryptographic quality.
|
|
458
843
|
*/
|
|
459
844
|
export function xMakeNonce(): Uint8Array {
|
|
460
|
-
return
|
|
845
|
+
return provider().randomBytes(24);
|
|
461
846
|
}
|
|
462
847
|
|
|
848
|
+
// ── Symmetric encryption (XSalsa20-Poly1305) ──────────────────────────────
|
|
849
|
+
|
|
463
850
|
/** Cryptographically secure random bytes. */
|
|
464
851
|
export function xRandomBytes(length: number): Uint8Array {
|
|
465
|
-
return
|
|
852
|
+
return provider().randomBytes(length);
|
|
466
853
|
}
|
|
467
854
|
|
|
468
|
-
// ── Signing ────────────────────────────────────────────────────────────────
|
|
469
|
-
|
|
470
855
|
/** Encrypt with a shared secret key. */
|
|
471
856
|
export function xSecretbox(
|
|
472
857
|
plaintext: Uint8Array,
|
|
473
858
|
nonce: Uint8Array,
|
|
474
859
|
key: Uint8Array,
|
|
475
860
|
): Uint8Array {
|
|
476
|
-
return
|
|
861
|
+
return provider().secretbox(plaintext, nonce, key);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// ── Random ─────────────────────────────────────────────────────────────────
|
|
865
|
+
|
|
866
|
+
/** Async authenticated encryption for cross-runtime/FIPS backends. */
|
|
867
|
+
export async function xSecretboxAsync(
|
|
868
|
+
plaintext: Uint8Array,
|
|
869
|
+
nonce: Uint8Array,
|
|
870
|
+
key: Uint8Array,
|
|
871
|
+
): Promise<Uint8Array> {
|
|
872
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
873
|
+
return xSecretbox(plaintext, nonce, key);
|
|
874
|
+
}
|
|
875
|
+
const subtle = getSubtleCrypto();
|
|
876
|
+
const iv = requireAesGcmNonce(nonce);
|
|
877
|
+
const aesKey = await importAesKey(key, ["encrypt"]);
|
|
878
|
+
const ciphertext = await subtle.encrypt(
|
|
879
|
+
{ iv: toBufferSource(iv), name: "AES-GCM" },
|
|
880
|
+
aesKey,
|
|
881
|
+
toBufferSource(plaintext),
|
|
882
|
+
);
|
|
883
|
+
return new Uint8Array(ciphertext);
|
|
477
884
|
}
|
|
478
885
|
|
|
479
886
|
/** Decrypt with a shared secret key. Returns null if authentication fails. */
|
|
@@ -482,26 +889,102 @@ export function xSecretboxOpen(
|
|
|
482
889
|
nonce: Uint8Array,
|
|
483
890
|
key: Uint8Array,
|
|
484
891
|
): null | Uint8Array {
|
|
485
|
-
return
|
|
892
|
+
return provider().secretboxOpen(ciphertext, nonce, key);
|
|
486
893
|
}
|
|
487
894
|
|
|
488
|
-
|
|
895
|
+
/** Async authenticated decryption for cross-runtime/FIPS backends. */
|
|
896
|
+
export async function xSecretboxOpenAsync(
|
|
897
|
+
ciphertext: Uint8Array,
|
|
898
|
+
nonce: Uint8Array,
|
|
899
|
+
key: Uint8Array,
|
|
900
|
+
): Promise<null | Uint8Array> {
|
|
901
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
902
|
+
return xSecretboxOpen(ciphertext, nonce, key);
|
|
903
|
+
}
|
|
904
|
+
const subtle = getSubtleCrypto();
|
|
905
|
+
const iv = requireAesGcmNonce(nonce);
|
|
906
|
+
const aesKey = await importAesKey(key, ["decrypt"]);
|
|
907
|
+
try {
|
|
908
|
+
const plaintext = await subtle.decrypt(
|
|
909
|
+
{ iv: toBufferSource(iv), name: "AES-GCM" },
|
|
910
|
+
aesKey,
|
|
911
|
+
toBufferSource(ciphertext),
|
|
912
|
+
);
|
|
913
|
+
return new Uint8Array(plaintext);
|
|
914
|
+
} catch {
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
489
918
|
|
|
490
919
|
/** Sign a message with an Ed25519 secret key. Returns signed message (64-byte signature prefix + message). */
|
|
491
920
|
export function xSign(message: Uint8Array, secretKey: Uint8Array): Uint8Array {
|
|
492
|
-
return
|
|
921
|
+
return provider().sign(message, secretKey);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Async signing for cross-runtime/FIPS backends. */
|
|
925
|
+
export async function xSignAsync(
|
|
926
|
+
message: Uint8Array,
|
|
927
|
+
secretKey: Uint8Array,
|
|
928
|
+
): Promise<Uint8Array> {
|
|
929
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
930
|
+
return xSign(message, secretKey);
|
|
931
|
+
}
|
|
932
|
+
const subtle = getSubtleCrypto();
|
|
933
|
+
const privateKey = await importEcdsaPrivateKey(secretKey);
|
|
934
|
+
const signature = new Uint8Array(
|
|
935
|
+
await subtle.sign(
|
|
936
|
+
{ hash: "SHA-256", name: "ECDSA" },
|
|
937
|
+
privateKey,
|
|
938
|
+
toBufferSource(message),
|
|
939
|
+
),
|
|
940
|
+
);
|
|
941
|
+
return encodeFipsSignedMessage(signature, message);
|
|
493
942
|
}
|
|
494
943
|
|
|
495
944
|
/** Generate a fresh Ed25519 signing key pair. */
|
|
496
945
|
export function xSignKeyPair(): KeyPair {
|
|
497
|
-
return
|
|
946
|
+
return provider().signKeyPair();
|
|
498
947
|
}
|
|
499
948
|
|
|
500
|
-
|
|
949
|
+
/** Async keypair generation for the active profile. */
|
|
950
|
+
export async function xSignKeyPairAsync(): Promise<KeyPair> {
|
|
951
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
952
|
+
return xSignKeyPair();
|
|
953
|
+
}
|
|
954
|
+
const subtle = getSubtleCrypto();
|
|
955
|
+
const pair = await subtle.generateKey(
|
|
956
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
957
|
+
true,
|
|
958
|
+
["sign", "verify"],
|
|
959
|
+
);
|
|
960
|
+
return {
|
|
961
|
+
publicKey: new Uint8Array(
|
|
962
|
+
await subtle.exportKey("spki", pair.publicKey),
|
|
963
|
+
),
|
|
964
|
+
secretKey: new Uint8Array(
|
|
965
|
+
await subtle.exportKey("pkcs8", pair.privateKey),
|
|
966
|
+
),
|
|
967
|
+
};
|
|
968
|
+
}
|
|
501
969
|
|
|
502
970
|
/** Restore an Ed25519 signing key pair from a 64-byte secret key. */
|
|
503
971
|
export function xSignKeyPairFromSecret(secretKey: Uint8Array): KeyPair {
|
|
504
|
-
return
|
|
972
|
+
return provider().signKeyPairFromSecret(secretKey);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** Async restore of signing keypair for the active profile. */
|
|
976
|
+
export function xSignKeyPairFromSecretAsync(
|
|
977
|
+
secretKey: Uint8Array,
|
|
978
|
+
): Promise<KeyPair> {
|
|
979
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
980
|
+
return Promise.resolve(xSignKeyPairFromSecret(secretKey));
|
|
981
|
+
}
|
|
982
|
+
void secretKey;
|
|
983
|
+
return Promise.reject(
|
|
984
|
+
new Error(
|
|
985
|
+
'Crypto profile "fips" does not implement xSignKeyPairFromSecretAsync yet.',
|
|
986
|
+
),
|
|
987
|
+
);
|
|
505
988
|
}
|
|
506
989
|
|
|
507
990
|
/** Verify and open a signed message. Returns the original message, or null if verification fails. */
|
|
@@ -509,7 +992,82 @@ export function xSignOpen(
|
|
|
509
992
|
signedMessage: Uint8Array,
|
|
510
993
|
publicKey: Uint8Array,
|
|
511
994
|
): null | Uint8Array {
|
|
512
|
-
return
|
|
995
|
+
return provider().signOpen(signedMessage, publicKey);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** Async verify/open for cross-runtime/FIPS backends. */
|
|
999
|
+
export async function xSignOpenAsync(
|
|
1000
|
+
signedMessage: Uint8Array,
|
|
1001
|
+
publicKey: Uint8Array,
|
|
1002
|
+
): Promise<null | Uint8Array> {
|
|
1003
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
1004
|
+
return xSignOpen(signedMessage, publicKey);
|
|
1005
|
+
}
|
|
1006
|
+
const subtle = getSubtleCrypto();
|
|
1007
|
+
const parsed = decodeFipsSignedMessage(signedMessage);
|
|
1008
|
+
const verifyKey = await importEcdsaPublicKey(publicKey);
|
|
1009
|
+
const valid = await subtle.verify(
|
|
1010
|
+
{ hash: "SHA-256", name: "ECDSA" },
|
|
1011
|
+
verifyKey,
|
|
1012
|
+
toBufferSource(parsed.signature),
|
|
1013
|
+
toBufferSource(parsed.message),
|
|
1014
|
+
);
|
|
1015
|
+
return valid ? parsed.message : null;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async function importAesKey(
|
|
1019
|
+
key: Uint8Array,
|
|
1020
|
+
usages: WebCryptoKeyUsage[],
|
|
1021
|
+
): Promise<CryptoKey> {
|
|
1022
|
+
return getSubtleCrypto().importKey(
|
|
1023
|
+
"raw",
|
|
1024
|
+
toBufferSource(key),
|
|
1025
|
+
{ name: "AES-GCM" },
|
|
1026
|
+
false,
|
|
1027
|
+
usages,
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
async function importEcdhPrivateKey(secretKey: Uint8Array): Promise<CryptoKey> {
|
|
1032
|
+
return getSubtleCrypto().importKey(
|
|
1033
|
+
"pkcs8",
|
|
1034
|
+
toBufferSource(secretKey),
|
|
1035
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
1036
|
+
true,
|
|
1037
|
+
["deriveBits"],
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async function importEcdhPublicKey(publicKey: Uint8Array): Promise<CryptoKey> {
|
|
1042
|
+
return getSubtleCrypto().importKey(
|
|
1043
|
+
"raw",
|
|
1044
|
+
toBufferSource(publicKey),
|
|
1045
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
1046
|
+
true,
|
|
1047
|
+
[],
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function importEcdsaPrivateKey(
|
|
1052
|
+
secretKey: Uint8Array,
|
|
1053
|
+
): Promise<CryptoKey> {
|
|
1054
|
+
return getSubtleCrypto().importKey(
|
|
1055
|
+
"pkcs8",
|
|
1056
|
+
toBufferSource(secretKey),
|
|
1057
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
1058
|
+
true,
|
|
1059
|
+
["sign"],
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async function importEcdsaPublicKey(publicKey: Uint8Array): Promise<CryptoKey> {
|
|
1064
|
+
return getSubtleCrypto().importKey(
|
|
1065
|
+
"spki",
|
|
1066
|
+
toBufferSource(publicKey),
|
|
1067
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
1068
|
+
true,
|
|
1069
|
+
["verify"],
|
|
1070
|
+
);
|
|
513
1071
|
}
|
|
514
1072
|
|
|
515
1073
|
/**
|