@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/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
- if (a[i] !== b[i]) {
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 true;
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 = nacl.secretbox.open(
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
- * Encrypts a secret key with a password and saves it as a file.
139
- *
140
- * @param path The path to save the keyfile.
141
- /**
142
- * Encrypts a secret key into a portable binary format.
143
- * The result can be written to disk, sent over the network, etc.
144
- * No I/O the caller handles persistence.
145
- *
146
- * Format: [iterations(6)|salt(24)|nonce(24)|ciphertext(N)]
147
- *
148
- * @param password The password to derive the encryption key from.
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 = nacl.randomBytes(2);
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 = nacl.secretbox(
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 arr The array to convert.
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
- * respresentation of its body.
577
+ * representation of its body.
248
578
  *
249
- * @param arr The array to convert.
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
- * @ignore
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 nacl.box.keyPair();
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 nacl.box.keyPair.fromSecretKey(secretKey);
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 nacl.box.before(theirPublicKey, myPrivateKey);
753
+ return provider().boxBefore(myPrivateKey, theirPublicKey);
386
754
  }
387
755
 
388
- // ── Key pair type ───────────────────────────────────────────────────────────
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 nacl.randomBytes(24);
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 nacl.randomBytes(length);
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 nacl.secretbox(plaintext, nonce, key);
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 nacl.secretbox.open(ciphertext, nonce, key);
892
+ return provider().secretboxOpen(ciphertext, nonce, key);
486
893
  }
487
894
 
488
- // ── Symmetric encryption (XSalsa20-Poly1305) ──────────────────────────────
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 nacl.sign(message, secretKey);
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 nacl.sign.keyPair();
946
+ return provider().signKeyPair();
498
947
  }
499
948
 
500
- // ── Random ─────────────────────────────────────────────────────────────────
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 nacl.sign.keyPair.fromSecretKey(secretKey);
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 nacl.sign.open(signedMessage, publicKey);
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
- * @ignore
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
- * @ignore
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
- * @ignore
1092
+ * @internal
535
1093
  */
536
1094
  function xMakeSalt(curve: "X448" | "X25519"): Uint8Array {
537
1095
  const salt = new Uint8Array(keyLength(curve));