@vex-chat/crypto 2.0.0 → 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 +48 -19
- package/dist/index.d.ts +64 -24
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +375 -42
- package/dist/index.js.map +1 -1
- package/package.json +14 -5
- 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 +606 -48
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.
|
|
@@ -44,6 +291,7 @@ export class XUtils {
|
|
|
44
291
|
|
|
45
292
|
/**
|
|
46
293
|
* Checks if two buffer-like objects are equal.
|
|
294
|
+
* When lengths match, comparison is constant-time in the inputs (no early exit on first differing byte).
|
|
47
295
|
*
|
|
48
296
|
* @param buf1
|
|
49
297
|
* @param buf2
|
|
@@ -59,12 +307,16 @@ export class XUtils {
|
|
|
59
307
|
if (a.byteLength !== b.byteLength) {
|
|
60
308
|
return false;
|
|
61
309
|
}
|
|
310
|
+
let diff = 0;
|
|
62
311
|
for (let i = 0; i !== a.byteLength; i++) {
|
|
63
|
-
|
|
312
|
+
const x = a[i];
|
|
313
|
+
const y = b[i];
|
|
314
|
+
if (x === undefined || y === undefined) {
|
|
64
315
|
return false;
|
|
65
316
|
}
|
|
317
|
+
diff |= x ^ y;
|
|
66
318
|
}
|
|
67
|
-
return
|
|
319
|
+
return diff === 0;
|
|
68
320
|
}
|
|
69
321
|
|
|
70
322
|
/**
|
|
@@ -101,7 +353,7 @@ export class XUtils {
|
|
|
101
353
|
c: ITERATIONS,
|
|
102
354
|
dkLen: 32,
|
|
103
355
|
});
|
|
104
|
-
const DECRYPTED_SIGNKEY =
|
|
356
|
+
const DECRYPTED_SIGNKEY = provider().secretboxOpen(
|
|
105
357
|
ENCRYPTED_KEY,
|
|
106
358
|
ENCRYPTION_NONCE,
|
|
107
359
|
DERIVED_KEY,
|
|
@@ -113,6 +365,37 @@ export class XUtils {
|
|
|
113
365
|
return XUtils.encodeHex(DECRYPTED_SIGNKEY);
|
|
114
366
|
};
|
|
115
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
|
+
|
|
116
399
|
/**
|
|
117
400
|
* Returns the empty header (32 0's)
|
|
118
401
|
*
|
|
@@ -135,21 +418,17 @@ export class XUtils {
|
|
|
135
418
|
}
|
|
136
419
|
|
|
137
420
|
/**
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
* @param keyToSave The hex-encoded secret key to encrypt.
|
|
150
|
-
* @param iterationOverride Optional PBKDF2 iteration count (random if omitted).
|
|
151
|
-
* @returns The encrypted key data as a Uint8Array.
|
|
152
|
-
*/
|
|
421
|
+
* Encrypts a secret key into a portable binary format.
|
|
422
|
+
* The result can be written to disk, sent over the network, etc.
|
|
423
|
+
* No I/O — the caller handles persistence.
|
|
424
|
+
*
|
|
425
|
+
* Format: [iterations(6)|salt(24)|nonce(24)|ciphertext(N)]
|
|
426
|
+
*
|
|
427
|
+
* @param password The password to derive the encryption key from.
|
|
428
|
+
* @param keyToSave The hex-encoded secret key to encrypt.
|
|
429
|
+
* @param iterationOverride Optional PBKDF2 iteration count (random if omitted).
|
|
430
|
+
* @returns The encrypted key data as a Uint8Array.
|
|
431
|
+
*/
|
|
153
432
|
public static encryptKeyData = (
|
|
154
433
|
password: string,
|
|
155
434
|
keyToSave: string,
|
|
@@ -157,7 +436,7 @@ export class XUtils {
|
|
|
157
436
|
): Uint8Array => {
|
|
158
437
|
const UNENCRYPTED_SIGNKEY = XUtils.decodeHex(keyToSave);
|
|
159
438
|
const OFFSET = 1000;
|
|
160
|
-
const rand =
|
|
439
|
+
const rand = provider().randomBytes(2);
|
|
161
440
|
const [N1 = 0, N2 = 0] = rand;
|
|
162
441
|
const iterations =
|
|
163
442
|
iterationOverride !== undefined && iterationOverride !== 0
|
|
@@ -170,7 +449,7 @@ export class XUtils {
|
|
|
170
449
|
dkLen: 32,
|
|
171
450
|
});
|
|
172
451
|
const NONCE = xMakeNonce();
|
|
173
|
-
const ENCRYPTED_SIGNKEY =
|
|
452
|
+
const ENCRYPTED_SIGNKEY = provider().secretbox(
|
|
174
453
|
UNENCRYPTED_SIGNKEY,
|
|
175
454
|
NONCE,
|
|
176
455
|
ENCRYPTION_KEY,
|
|
@@ -193,6 +472,56 @@ export class XUtils {
|
|
|
193
472
|
return result;
|
|
194
473
|
};
|
|
195
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
|
+
|
|
196
525
|
/**
|
|
197
526
|
* Returns a six bit Uint8Array representation of an integer.
|
|
198
527
|
* The integer must be positive, and it must be able to be stored
|
|
@@ -219,7 +548,8 @@ export class XUtils {
|
|
|
219
548
|
/**
|
|
220
549
|
* Packs a javascript object and a 32 byte header into a vex message.
|
|
221
550
|
*
|
|
222
|
-
* @param
|
|
551
|
+
* @param msg Message body (msgpack-serialized).
|
|
552
|
+
* @param header Optional 32-byte header; defaults to an empty header.
|
|
223
553
|
* @returns the packed message.
|
|
224
554
|
*/
|
|
225
555
|
public static packMessage(msg: unknown, header?: Uint8Array) {
|
|
@@ -244,9 +574,9 @@ export class XUtils {
|
|
|
244
574
|
|
|
245
575
|
/**
|
|
246
576
|
* Takes a vex message and unpacks it into its header and a javascript object
|
|
247
|
-
*
|
|
577
|
+
* representation of its body.
|
|
248
578
|
*
|
|
249
|
-
* @param
|
|
579
|
+
* @param msg Full wire message (32-byte header + msgpack body).
|
|
250
580
|
* @returns [32 byte header, message body]
|
|
251
581
|
*/
|
|
252
582
|
public static unpackMessage(
|
|
@@ -323,10 +653,8 @@ export interface KeyPair {
|
|
|
323
653
|
// );
|
|
324
654
|
// }
|
|
325
655
|
|
|
326
|
-
/**
|
|
327
|
-
|
|
328
|
-
*/
|
|
329
|
-
interface XConstants {
|
|
656
|
+
/** Shape of the {@link xConstants} runtime object. */
|
|
657
|
+
export interface XConstants {
|
|
330
658
|
CURVE: "X25519";
|
|
331
659
|
HASH: "SHA-512";
|
|
332
660
|
HEADER_SIZE: 32;
|
|
@@ -337,14 +665,52 @@ 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,11 +992,86 @@ 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
|
/**
|
|
516
|
-
* @
|
|
1074
|
+
* @internal
|
|
517
1075
|
*/
|
|
518
1076
|
function isEven(value: bigint) {
|
|
519
1077
|
if (value % BigInt(2) === BigInt(0)) {
|
|
@@ -524,14 +1082,14 @@ function isEven(value: bigint) {
|
|
|
524
1082
|
}
|
|
525
1083
|
|
|
526
1084
|
/**
|
|
527
|
-
* @
|
|
1085
|
+
* @internal
|
|
528
1086
|
*/
|
|
529
1087
|
function keyLength(curve: "X448" | "X25519"): number {
|
|
530
1088
|
return curve === "X25519" ? 32 : 57;
|
|
531
1089
|
}
|
|
532
1090
|
|
|
533
1091
|
/**
|
|
534
|
-
* @
|
|
1092
|
+
* @internal
|
|
535
1093
|
*/
|
|
536
1094
|
function xMakeSalt(curve: "X448" | "X25519"): Uint8Array {
|
|
537
1095
|
const salt = new Uint8Array(keyLength(curve));
|