@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/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 = nacl.secretbox.open(
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 = nacl.randomBytes(2);
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 = nacl.secretbox(
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 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,7 +992,82 @@ 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
  /**