@vex-chat/crypto 2.0.1 → 2.1.1

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,5 +1,14 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ * Commercial licenses available at vex.wtf
6
+ */
7
+
1
8
  import type { BaseMsg } from "@vex-chat/types";
2
9
 
10
+ import { numberToBytesBE } from "@noble/curves/abstract/utils";
11
+ import { p256 } from "@noble/curves/p256";
3
12
  import { hkdf } from "@noble/hashes/hkdf.js";
4
13
  import { hmac } from "@noble/hashes/hmac.js";
5
14
  import { pbkdf2 as noblePbkdf2 } from "@noble/hashes/pbkdf2.js";
@@ -15,6 +24,347 @@ import { Packr } from "msgpackr";
15
24
  import nacl from "tweetnacl";
16
25
  import { z } from "zod/v4";
17
26
 
27
+ /** Runtime crypto profile selector. */
28
+ export type CryptoProfile = "fips" | "tweetnacl";
29
+
30
+ interface CryptoProvider {
31
+ boxBefore(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array;
32
+ boxKeyPair(): KeyPair;
33
+ boxKeyPairFromSecret(secretKey: Uint8Array): KeyPair;
34
+ profile: CryptoProfile;
35
+ randomBytes(length: number): Uint8Array;
36
+ secretbox(
37
+ plaintext: Uint8Array,
38
+ nonce: Uint8Array,
39
+ key: Uint8Array,
40
+ ): Uint8Array;
41
+ secretboxOpen(
42
+ ciphertext: Uint8Array,
43
+ nonce: Uint8Array,
44
+ key: Uint8Array,
45
+ ): null | Uint8Array;
46
+ sign(message: Uint8Array, secretKey: Uint8Array): Uint8Array;
47
+ signKeyPair(): KeyPair;
48
+ signKeyPairFromSecret(secretKey: Uint8Array): KeyPair;
49
+ signOpen(
50
+ signedMessage: Uint8Array,
51
+ publicKey: Uint8Array,
52
+ ): null | Uint8Array;
53
+ }
54
+
55
+ type WebCryptoKeyUsage =
56
+ | "decrypt"
57
+ | "deriveBits"
58
+ | "deriveKey"
59
+ | "encrypt"
60
+ | "sign"
61
+ | "unwrapKey"
62
+ | "verify"
63
+ | "wrapKey";
64
+
65
+ interface WebCryptoLike {
66
+ getRandomValues: Crypto["getRandomValues"];
67
+ subtle: SubtleCrypto;
68
+ }
69
+
70
+ function getWebCrypto(): WebCryptoLike {
71
+ const cryptoCandidate: unknown = globalThis.crypto;
72
+ if (!isWebCryptoLike(cryptoCandidate)) {
73
+ throw new Error(
74
+ "Web Crypto API is not available in this runtime for fips profile operations.",
75
+ );
76
+ }
77
+ return cryptoCandidate;
78
+ }
79
+
80
+ function isWebCryptoLike(value: unknown): value is WebCryptoLike {
81
+ if (typeof value !== "object" || value === null) {
82
+ return false;
83
+ }
84
+ return (
85
+ "getRandomValues" in value &&
86
+ typeof value.getRandomValues === "function" &&
87
+ "subtle" in value &&
88
+ typeof value.subtle === "object" &&
89
+ value.subtle !== null
90
+ );
91
+ }
92
+
93
+ function randomBytesWebCrypto(length: number): Uint8Array {
94
+ if (!Number.isInteger(length) || length < 0) {
95
+ throw new Error(
96
+ `Expected non-negative integer length, received ${String(length)}.`,
97
+ );
98
+ }
99
+ const cryptoImpl = getWebCrypto();
100
+ const result = new Uint8Array(length);
101
+ let offset = 0;
102
+ while (offset < length) {
103
+ const chunkLength = Math.min(65536, length - offset);
104
+ const chunk = result.subarray(offset, offset + chunkLength);
105
+ cryptoImpl.getRandomValues(chunk);
106
+ offset += chunkLength;
107
+ }
108
+ return result;
109
+ }
110
+
111
+ function unsupported(profile: CryptoProfile, operation: string): never {
112
+ throw new Error(
113
+ `Crypto profile "${profile}" does not implement ${operation} yet.`,
114
+ );
115
+ }
116
+
117
+ const tweetnaclProvider: CryptoProvider = {
118
+ boxBefore: (myPrivateKey, theirPublicKey) =>
119
+ nacl.box.before(theirPublicKey, myPrivateKey),
120
+ boxKeyPair: () => nacl.box.keyPair(),
121
+ boxKeyPairFromSecret: (secretKey) =>
122
+ nacl.box.keyPair.fromSecretKey(secretKey),
123
+ profile: "tweetnacl",
124
+ randomBytes: (length) => nacl.randomBytes(length),
125
+ secretbox: (plaintext, nonce, key) => nacl.secretbox(plaintext, nonce, key),
126
+ secretboxOpen: (ciphertext, nonce, key) =>
127
+ nacl.secretbox.open(ciphertext, nonce, key),
128
+ sign: (message, secretKey) => nacl.sign(message, secretKey),
129
+ signKeyPair: () => nacl.sign.keyPair(),
130
+ signKeyPairFromSecret: (secretKey) =>
131
+ nacl.sign.keyPair.fromSecretKey(secretKey),
132
+ signOpen: (signedMessage, publicKey) =>
133
+ nacl.sign.open(signedMessage, publicKey),
134
+ };
135
+
136
+ const fipsProvider: CryptoProvider = {
137
+ boxBefore: (myPrivateKey, theirPublicKey) => {
138
+ void myPrivateKey;
139
+ void theirPublicKey;
140
+ return unsupported("fips", "xDH");
141
+ },
142
+ boxKeyPair: () => unsupported("fips", "xBoxKeyPair"),
143
+ boxKeyPairFromSecret: (secretKey) => {
144
+ void secretKey;
145
+ return unsupported("fips", "xBoxKeyPairFromSecret");
146
+ },
147
+ profile: "fips",
148
+ randomBytes: (length) => randomBytesWebCrypto(length),
149
+ secretbox: (plaintext, nonce, key) => {
150
+ void plaintext;
151
+ void nonce;
152
+ void key;
153
+ return unsupported("fips", "xSecretbox");
154
+ },
155
+ secretboxOpen: (ciphertext, nonce, key) => {
156
+ void ciphertext;
157
+ void nonce;
158
+ void key;
159
+ return unsupported("fips", "xSecretboxOpen");
160
+ },
161
+ sign: (message, secretKey) => {
162
+ void message;
163
+ void secretKey;
164
+ return unsupported("fips", "xSign");
165
+ },
166
+ signKeyPair: () => unsupported("fips", "xSignKeyPair"),
167
+ signKeyPairFromSecret: (secretKey) => {
168
+ void secretKey;
169
+ return unsupported("fips", "xSignKeyPairFromSecret");
170
+ },
171
+ signOpen: (signedMessage, publicKey) => {
172
+ void signedMessage;
173
+ void publicKey;
174
+ return unsupported("fips", "xSignOpen");
175
+ },
176
+ };
177
+
178
+ const providers: Record<CryptoProfile, CryptoProvider> = {
179
+ fips: fipsProvider,
180
+ tweetnacl: tweetnaclProvider,
181
+ };
182
+
183
+ let activeCryptoProfile: CryptoProfile = "tweetnacl";
184
+ let activeCryptoProvider: CryptoProvider = providers[activeCryptoProfile];
185
+
186
+ /** `globalThis` may omit `Buffer` in browsers; Node types can still attach `Buffer` to `globalThis` unconditionally. */
187
+ type NodeBufferish = {
188
+ from(data: ArrayBuffer | Uint8Array): {
189
+ toString(encoding: "base64url"): string;
190
+ };
191
+ };
192
+
193
+ /** Returns the currently configured crypto profile. */
194
+ export function getCryptoProfile(): CryptoProfile {
195
+ return activeCryptoProfile;
196
+ }
197
+
198
+ /**
199
+ * Sets the runtime crypto profile.
200
+ *
201
+ * `tweetnacl` preserves existing behavior.
202
+ * `fips` currently enables only backend-agnostic helpers; NaCl-coupled
203
+ * primitives throw until a FIPS backend implementation is wired in.
204
+ */
205
+ export function setCryptoProfile(profile: CryptoProfile): void {
206
+ activeCryptoProfile = profile;
207
+ activeCryptoProvider = providers[profile];
208
+ }
209
+
210
+ function bytesToBase64Url(bytes: Uint8Array): string {
211
+ const BufferCtor = getNodeBufferCtor();
212
+ if (BufferCtor !== undefined) {
213
+ return BufferCtor.from(bytes).toString("base64url");
214
+ }
215
+ return globalThis
216
+ .btoa(String.fromCodePoint(...Array.from(bytes)))
217
+ .replace(/\+/g, "-")
218
+ .replace(/\//g, "_")
219
+ .replace(/=+/g, "");
220
+ }
221
+
222
+ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
223
+ const out = new Uint8Array(a.length + b.length);
224
+ out.set(a, 0);
225
+ out.set(b, a.length);
226
+ return out;
227
+ }
228
+
229
+ function decodeFipsSignedMessage(signedMessage: Uint8Array): {
230
+ message: Uint8Array;
231
+ signature: Uint8Array;
232
+ } {
233
+ if (signedMessage.length < 2) {
234
+ throw new Error(
235
+ "Invalid FIPS signed message: missing signature length prefix.",
236
+ );
237
+ }
238
+ const signatureLength =
239
+ (signedMessage[0] ?? 0) * 256 + (signedMessage[1] ?? 0);
240
+ const signatureStart = 2;
241
+ const signatureEnd = signatureStart + signatureLength;
242
+ if (signedMessage.length < signatureEnd) {
243
+ throw new Error(
244
+ "Invalid FIPS signed message: signature length exceeds message length.",
245
+ );
246
+ }
247
+ return {
248
+ message: signedMessage.slice(signatureEnd),
249
+ signature: signedMessage.slice(signatureStart, signatureEnd),
250
+ };
251
+ }
252
+
253
+ function encodeFipsSignedMessage(
254
+ signature: Uint8Array,
255
+ message: Uint8Array,
256
+ ): Uint8Array {
257
+ if (signature.length > 65535) {
258
+ throw new Error("FIPS signature too long to encode.");
259
+ }
260
+ const prefix = new Uint8Array(2);
261
+ prefix[0] = (signature.length >> 8) & 0xff;
262
+ prefix[1] = signature.length & 0xff;
263
+ return concatBytes(concatBytes(prefix, signature), message);
264
+ }
265
+
266
+ async function fipsEcdhKeyPairFrom32ByteSeed(
267
+ secretKey: Uint8Array,
268
+ subtle: SubtleCrypto,
269
+ ): Promise<KeyPair> {
270
+ if (secretKey.length !== 32) {
271
+ throw new Error(
272
+ "FIPS: expected a 32-byte IKM/seed for ECDH from-secret.",
273
+ );
274
+ }
275
+ const d = p256.utils.normPrivateKeyToScalar(Uint8Array.from(secretKey));
276
+ const d32 = numberToBytesBE(d, 32);
277
+ const rawPub = p256.getPublicKey(d, false);
278
+ if (rawPub[0] !== 0x04) {
279
+ throw new Error("FIPS: expected uncompressed P-256 public key.");
280
+ }
281
+ const jwk: JsonWebKey = {
282
+ crv: "P-256",
283
+ d: bytesToBase64Url(d32),
284
+ kty: "EC",
285
+ x: bytesToBase64Url(rawPub.subarray(1, 33)),
286
+ y: bytesToBase64Url(rawPub.subarray(33, 65)),
287
+ };
288
+ const ecdhPriv = await subtle.importKey(
289
+ "jwk",
290
+ jwk,
291
+ { name: "ECDH", namedCurve: "P-256" },
292
+ true,
293
+ ["deriveBits"],
294
+ );
295
+ const ecdhPubJwk = await subtle.exportKey("jwk", ecdhPriv);
296
+ const ecdhPubJwkNoD: JsonWebKey = { ...ecdhPubJwk };
297
+ delete ecdhPubJwkNoD.d;
298
+ ecdhPubJwkNoD.key_ops = [];
299
+ const ecdhPub = await subtle.importKey(
300
+ "jwk",
301
+ ecdhPubJwkNoD,
302
+ { name: "ECDH", namedCurve: "P-256" },
303
+ true,
304
+ [],
305
+ );
306
+ return {
307
+ publicKey: new Uint8Array(await subtle.exportKey("raw", ecdhPub)),
308
+ secretKey: new Uint8Array(await subtle.exportKey("pkcs8", ecdhPriv)),
309
+ };
310
+ }
311
+
312
+ async function fipsEcdhKeyPairFromPkcs8(
313
+ secretKey: Uint8Array,
314
+ subtle: SubtleCrypto,
315
+ ): Promise<KeyPair> {
316
+ const ecdhPriv = await subtle.importKey(
317
+ "pkcs8",
318
+ toBufferSource(secretKey),
319
+ { name: "ECDH", namedCurve: "P-256" },
320
+ true,
321
+ ["deriveBits"],
322
+ );
323
+ const jwk = await subtle.exportKey("jwk", ecdhPriv);
324
+ const ecdhPubJwk: JsonWebKey = { ...jwk };
325
+ delete ecdhPubJwk.d;
326
+ ecdhPubJwk.key_ops = [];
327
+ const ecdhPub = await subtle.importKey(
328
+ "jwk",
329
+ ecdhPubJwk,
330
+ { name: "ECDH", namedCurve: "P-256" },
331
+ true,
332
+ [],
333
+ );
334
+ return {
335
+ publicKey: new Uint8Array(await subtle.exportKey("raw", ecdhPub)),
336
+ secretKey: new Uint8Array(await subtle.exportKey("pkcs8", ecdhPriv)),
337
+ };
338
+ }
339
+
340
+ function getNodeBufferCtor(): NodeBufferish | undefined {
341
+ if (!("Buffer" in globalThis)) {
342
+ return undefined;
343
+ }
344
+ return (globalThis as { Buffer: NodeBufferish }).Buffer;
345
+ }
346
+
347
+ function getSubtleCrypto(): SubtleCrypto {
348
+ return getWebCrypto().subtle;
349
+ }
350
+
351
+ function provider(): CryptoProvider {
352
+ return activeCryptoProvider;
353
+ }
354
+
355
+ function requireAesGcmNonce(nonce: Uint8Array): Uint8Array {
356
+ if (nonce.length < 12) {
357
+ throw new Error(
358
+ `AES-GCM requires a nonce of at least 12 bytes, received ${String(nonce.length)}.`,
359
+ );
360
+ }
361
+ return nonce.slice(0, 12);
362
+ }
363
+
364
+ function toBufferSource(bytes: Uint8Array): ArrayBuffer {
365
+ return Uint8Array.from(bytes).buffer;
366
+ }
367
+
18
368
  // msgpackr with useRecords:false emits standard msgpack (no nonstandard record extension).
19
369
  // moreTypes:false keeps the extension set to only what other decoders understand.
20
370
  // pack() returns Node Buffer (tight view) so consumers like axios send the correct bytes.
@@ -106,7 +456,7 @@ export class XUtils {
106
456
  c: ITERATIONS,
107
457
  dkLen: 32,
108
458
  });
109
- const DECRYPTED_SIGNKEY = nacl.secretbox.open(
459
+ const DECRYPTED_SIGNKEY = provider().secretboxOpen(
110
460
  ENCRYPTED_KEY,
111
461
  ENCRYPTION_NONCE,
112
462
  DERIVED_KEY,
@@ -118,6 +468,66 @@ export class XUtils {
118
468
  return XUtils.encodeHex(DECRYPTED_SIGNKEY);
119
469
  };
120
470
 
471
+ /**
472
+ * Async variant of decryptKeyData for cross-runtime/FIPS backends.
473
+ * Supports both profile formats emitted by encryptKeyDataAsync.
474
+ */
475
+ public static decryptKeyDataAsync = async (
476
+ keyData: Uint8Array,
477
+ password: string,
478
+ ): Promise<string> => {
479
+ const ITERATIONS = XUtils.uint8ArrToNumber(keyData.slice(0, 6));
480
+ const PKBDF_SALT = keyData.slice(6, 30);
481
+ const ENCRYPTION_NONCE = keyData.slice(30, 54);
482
+ const ENCRYPTED_KEY = keyData.slice(54);
483
+ const DERIVED_KEY = noblePbkdf2(sha512, password, PKBDF_SALT, {
484
+ c: ITERATIONS,
485
+ dkLen: 32,
486
+ });
487
+ const decrypted =
488
+ activeCryptoProfile === "fips"
489
+ ? await xSecretboxOpenAsync(
490
+ ENCRYPTED_KEY,
491
+ ENCRYPTION_NONCE,
492
+ DERIVED_KEY,
493
+ )
494
+ : xSecretboxOpen(ENCRYPTED_KEY, ENCRYPTION_NONCE, DERIVED_KEY);
495
+
496
+ if (decrypted === null) {
497
+ throw new Error("Decryption failed. Wrong password?");
498
+ }
499
+ return XUtils.encodeHex(decrypted);
500
+ };
501
+
502
+ /**
503
+ * 32-byte AES-256 key for local at-rest encryption (e.g. sqlite) derived from
504
+ * identity `secretKey`. For `tweetnacl` this is the 32-byte X25519 private key.
505
+ * For `fips` the identity secret is PKCS#8; HKDF is applied so AES keys never
506
+ * equal the raw private key material.
507
+ */
508
+ public static deriveLocalAtRestAesKey(
509
+ identitySk: Uint8Array,
510
+ profile: CryptoProfile,
511
+ ): Uint8Array {
512
+ if (profile === "tweetnacl") {
513
+ if (identitySk.length < 32) {
514
+ throw new Error(
515
+ "Expected at least 32 bytes of identity secret in tweetnacl mode.",
516
+ );
517
+ }
518
+ return identitySk.subarray(0, 32);
519
+ }
520
+ return new Uint8Array(
521
+ hkdf(
522
+ sha256,
523
+ identitySk,
524
+ new Uint8Array(0),
525
+ new TextEncoder().encode("vex:at-rest:2.1.0-fips"),
526
+ 32,
527
+ ),
528
+ );
529
+ }
530
+
121
531
  /**
122
532
  * Returns the empty header (32 0's)
123
533
  *
@@ -158,7 +568,7 @@ export class XUtils {
158
568
  ): Uint8Array => {
159
569
  const UNENCRYPTED_SIGNKEY = XUtils.decodeHex(keyToSave);
160
570
  const OFFSET = 1000;
161
- const rand = nacl.randomBytes(2);
571
+ const rand = provider().randomBytes(2);
162
572
  const [N1 = 0, N2 = 0] = rand;
163
573
  const iterations =
164
574
  iterationOverride !== undefined && iterationOverride !== 0
@@ -171,7 +581,7 @@ export class XUtils {
171
581
  dkLen: 32,
172
582
  });
173
583
  const NONCE = xMakeNonce();
174
- const ENCRYPTED_SIGNKEY = nacl.secretbox(
584
+ const ENCRYPTED_SIGNKEY = provider().secretbox(
175
585
  UNENCRYPTED_SIGNKEY,
176
586
  NONCE,
177
587
  ENCRYPTION_KEY,
@@ -194,6 +604,56 @@ export class XUtils {
194
604
  return result;
195
605
  };
196
606
 
607
+ /**
608
+ * Async variant of encryptKeyData for cross-runtime/FIPS backends.
609
+ * Format remains [iterations(6)|salt(24)|nonce(24)|ciphertext(N)].
610
+ */
611
+ public static encryptKeyDataAsync = async (
612
+ password: string,
613
+ keyToSave: string,
614
+ iterationOverride?: number,
615
+ ): Promise<Uint8Array> => {
616
+ const UNENCRYPTED_SIGNKEY = XUtils.decodeHex(keyToSave);
617
+ const OFFSET = 1000;
618
+ const rand = xRandomBytes(2);
619
+ const [N1 = 0, N2 = 0] = rand;
620
+ const iterations =
621
+ iterationOverride !== undefined && iterationOverride !== 0
622
+ ? iterationOverride
623
+ : N1 * N2 + OFFSET;
624
+ const ITERATIONS = XUtils.numberToUint8Arr(iterations);
625
+ const PKBDF_SALT = xMakeNonce();
626
+ const ENCRYPTION_KEY = noblePbkdf2(sha512, password, PKBDF_SALT, {
627
+ c: iterations,
628
+ dkLen: 32,
629
+ });
630
+ const NONCE = xMakeNonce();
631
+ const ENCRYPTED_SIGNKEY =
632
+ activeCryptoProfile === "fips"
633
+ ? await xSecretboxAsync(
634
+ UNENCRYPTED_SIGNKEY,
635
+ NONCE,
636
+ ENCRYPTION_KEY,
637
+ )
638
+ : xSecretbox(UNENCRYPTED_SIGNKEY, NONCE, ENCRYPTION_KEY);
639
+
640
+ const result = new Uint8Array(
641
+ ITERATIONS.length +
642
+ PKBDF_SALT.length +
643
+ NONCE.length +
644
+ ENCRYPTED_SIGNKEY.length,
645
+ );
646
+ let offset = 0;
647
+ result.set(ITERATIONS, offset);
648
+ offset += ITERATIONS.length;
649
+ result.set(PKBDF_SALT, offset);
650
+ offset += PKBDF_SALT.length;
651
+ result.set(NONCE, offset);
652
+ offset += NONCE.length;
653
+ result.set(ENCRYPTED_SIGNKEY, offset);
654
+ return result;
655
+ };
656
+
197
657
  /**
198
658
  * Returns a six bit Uint8Array representation of an integer.
199
659
  * The integer must be positive, and it must be able to be stored
@@ -335,16 +795,91 @@ export interface XConstants {
335
795
  MIN_OTK_SUPPLY: number;
336
796
  }
337
797
 
798
+ /**
799
+ * FIPS: `device.signKey` in the database is the P-256 ECDSA public key (SPKI),
800
+ * used for account/device signature verification. X3DH on the client expects
801
+ * the same curve point as Web Crypto "raw" P-256 ECDH public bytes for
802
+ * `importEcdhPublicKey`. This converts SPKI → raw without a private key.
803
+ */
804
+ export async function fipsEcdhRawPublicKeyFromEcdsaSpkiAsync(
805
+ ecdsaSpki: Uint8Array,
806
+ ): Promise<Uint8Array> {
807
+ if (ecdsaSpki.length === 0) {
808
+ throw new Error("FIPS: empty ECDSA SPKI.");
809
+ }
810
+ const subtle = getSubtleCrypto();
811
+ const ecdsaPub = await importEcdsaPublicKey(ecdsaSpki);
812
+ const jwk = await subtle.exportKey("jwk", ecdsaPub);
813
+ if (
814
+ jwk.x === undefined ||
815
+ jwk.y === undefined ||
816
+ jwk.x.length === 0 ||
817
+ jwk.y.length === 0
818
+ ) {
819
+ throw new Error("FIPS: could not export ECDSA public as JWK.");
820
+ }
821
+ const ecdhJwk: JsonWebKey = { ...jwk };
822
+ delete ecdhJwk.d;
823
+ ecdhJwk.key_ops = [];
824
+ ecdhJwk.ext = true;
825
+ const ecdhPub = await subtle.importKey(
826
+ "jwk",
827
+ ecdhJwk,
828
+ { name: "ECDH", namedCurve: "P-256" },
829
+ true,
830
+ [],
831
+ );
832
+ return new Uint8Array(await subtle.exportKey("raw", ecdhPub));
833
+ }
834
+
338
835
  /** Generate a fresh X25519 box key pair. */
339
836
  export function xBoxKeyPair(): KeyPair {
340
- return nacl.box.keyPair();
837
+ return provider().boxKeyPair();
838
+ }
839
+
840
+ /** Async box keypair generation for the active profile. */
841
+ export async function xBoxKeyPairAsync(): Promise<KeyPair> {
842
+ if (activeCryptoProfile === "tweetnacl") {
843
+ return xBoxKeyPair();
844
+ }
845
+ const subtle = getSubtleCrypto();
846
+ const pair = await subtle.generateKey(
847
+ { name: "ECDH", namedCurve: "P-256" },
848
+ true,
849
+ ["deriveBits"],
850
+ );
851
+ return {
852
+ publicKey: new Uint8Array(
853
+ await subtle.exportKey("raw", pair.publicKey),
854
+ ),
855
+ secretKey: new Uint8Array(
856
+ await subtle.exportKey("pkcs8", pair.privateKey),
857
+ ),
858
+ };
341
859
  }
342
860
 
343
861
  /** Restore an X25519 box key pair from a 32-byte secret key. */
344
862
  export function xBoxKeyPairFromSecret(secretKey: Uint8Array): KeyPair {
345
- return nacl.box.keyPair.fromSecretKey(secretKey);
863
+ return provider().boxKeyPairFromSecret(secretKey);
346
864
  }
347
865
 
866
+ // ── Key pair type ───────────────────────────────────────────────────────────
867
+
868
+ /** Async box key restore from private key material. */
869
+ export async function xBoxKeyPairFromSecretAsync(
870
+ secretKey: Uint8Array,
871
+ ): Promise<KeyPair> {
872
+ if (activeCryptoProfile === "tweetnacl") {
873
+ return xBoxKeyPairFromSecret(secretKey);
874
+ }
875
+ if (secretKey.length === 32) {
876
+ return fipsEcdhKeyPairFrom32ByteSeed(secretKey, getSubtleCrypto());
877
+ }
878
+ return fipsEcdhKeyPairFromPkcs8(secretKey, getSubtleCrypto());
879
+ }
880
+
881
+ // ── Key generation ─────────────────────────────────────────────────────────
882
+
348
883
  /**
349
884
  * Concatanates multiple Uint8Arrays.
350
885
  *
@@ -382,10 +917,76 @@ export function xDH(
382
917
  myPrivateKey: Uint8Array,
383
918
  theirPublicKey: Uint8Array,
384
919
  ): Uint8Array {
385
- return nacl.box.before(theirPublicKey, myPrivateKey);
920
+ return provider().boxBefore(myPrivateKey, theirPublicKey);
386
921
  }
387
922
 
388
- // ── Key pair type ───────────────────────────────────────────────────────────
923
+ /** Async DH for cross-runtime/FIPS backends. */
924
+ export async function xDHAsync(
925
+ myPrivateKey: Uint8Array,
926
+ theirPublicKey: Uint8Array,
927
+ ): Promise<Uint8Array> {
928
+ if (activeCryptoProfile === "tweetnacl") {
929
+ return xDH(myPrivateKey, theirPublicKey);
930
+ }
931
+ const subtle = getSubtleCrypto();
932
+ const privateKey = await importEcdhPrivateKey(myPrivateKey);
933
+ const publicKey = await importEcdhPublicKey(theirPublicKey);
934
+ const shared = await subtle.deriveBits(
935
+ { name: "ECDH", public: publicKey },
936
+ privateKey,
937
+ 256,
938
+ );
939
+ return new Uint8Array(shared);
940
+ }
941
+
942
+ /**
943
+ * In `fips` mode only: derive a P-256 ECDH `KeyPair` (raw public + pkcs8 secret)
944
+ * from a P-256 ECDSA `KeyPair` (spki + pkcs8) using the same private scalar in Web Crypto.
945
+ * In `tweetnacl` mode, use `XKeyConvert.convertKeyPair` to map Ed25519 → X25519 instead.
946
+ */
947
+ export async function xEcdhKeyPairFromEcdsaKeyPairAsync(
948
+ sign: KeyPair,
949
+ ): Promise<KeyPair> {
950
+ if (activeCryptoProfile === "tweetnacl") {
951
+ return Promise.reject(
952
+ new Error(
953
+ 'xEcdhKeyPairFromEcdsaKeyPairAsync is for crypto profile "fips" only. Use XKeyConvert.convertKeyPair in tweetnacl mode.',
954
+ ),
955
+ );
956
+ }
957
+ const subtle = getSubtleCrypto();
958
+ const ecdsaPriv = await importEcdsaPrivateKey(sign.secretKey);
959
+ const jwk = await subtle.exportKey("jwk", ecdsaPriv);
960
+ if (typeof jwk.d !== "string" || jwk.d.length === 0) {
961
+ throw new Error("FIPS: could not export ECDSA private as JWK.");
962
+ }
963
+ const ecdhJwk: JsonWebKey = { ...jwk, key_ops: ["deriveBits"] };
964
+ ecdhJwk.ext = true;
965
+ const ecdhPriv = await subtle.importKey(
966
+ "jwk",
967
+ ecdhJwk,
968
+ { name: "ECDH", namedCurve: "P-256" },
969
+ true,
970
+ ["deriveBits"],
971
+ );
972
+ const ecdhPubJwk = await subtle.exportKey("jwk", ecdhPriv);
973
+ const ecdhPubJwkNoD: JsonWebKey = { ...ecdhPubJwk };
974
+ delete ecdhPubJwkNoD.d;
975
+ ecdhPubJwkNoD.key_ops = [];
976
+ const ecdhPub = await subtle.importKey(
977
+ "jwk",
978
+ ecdhPubJwkNoD,
979
+ { name: "ECDH", namedCurve: "P-256" },
980
+ true,
981
+ [],
982
+ );
983
+ return {
984
+ publicKey: new Uint8Array(await subtle.exportKey("raw", ecdhPub)),
985
+ secretKey: new Uint8Array(await subtle.exportKey("pkcs8", ecdhPriv)),
986
+ };
987
+ }
988
+
989
+ // ── Signing ────────────────────────────────────────────────────────────────
389
990
 
390
991
  /**
391
992
  * Encode an X25519 or X448 public key PK into a byte sequence.
@@ -431,8 +1032,6 @@ export function xEncode(
431
1032
  return Uint8Array.from(bytes);
432
1033
  }
433
1034
 
434
- // ── Key generation ─────────────────────────────────────────────────────────
435
-
436
1035
  /**
437
1036
  * Hashes some data.
438
1037
  *
@@ -443,6 +1042,8 @@ export function xHash(data: Uint8Array) {
443
1042
  return XUtils.encodeHex(sha512(data));
444
1043
  }
445
1044
 
1045
+ // ── Symmetric encryption (XSalsa20-Poly1305) ──────────────────────────────
1046
+
446
1047
  export function xKDF(IKM: Uint8Array): Uint8Array {
447
1048
  return hkdf(
448
1049
  sha512,
@@ -457,23 +1058,43 @@ export function xKDF(IKM: Uint8Array): Uint8Array {
457
1058
  * Returns a 24 byte random nonce of cryptographic quality.
458
1059
  */
459
1060
  export function xMakeNonce(): Uint8Array {
460
- return nacl.randomBytes(24);
1061
+ return provider().randomBytes(24);
461
1062
  }
462
1063
 
1064
+ // ── Random ─────────────────────────────────────────────────────────────────
1065
+
463
1066
  /** Cryptographically secure random bytes. */
464
1067
  export function xRandomBytes(length: number): Uint8Array {
465
- return nacl.randomBytes(length);
1068
+ return provider().randomBytes(length);
466
1069
  }
467
1070
 
468
- // ── Signing ────────────────────────────────────────────────────────────────
469
-
470
1071
  /** Encrypt with a shared secret key. */
471
1072
  export function xSecretbox(
472
1073
  plaintext: Uint8Array,
473
1074
  nonce: Uint8Array,
474
1075
  key: Uint8Array,
475
1076
  ): Uint8Array {
476
- return nacl.secretbox(plaintext, nonce, key);
1077
+ return provider().secretbox(plaintext, nonce, key);
1078
+ }
1079
+
1080
+ /** Async authenticated encryption for cross-runtime/FIPS backends. */
1081
+ export async function xSecretboxAsync(
1082
+ plaintext: Uint8Array,
1083
+ nonce: Uint8Array,
1084
+ key: Uint8Array,
1085
+ ): Promise<Uint8Array> {
1086
+ if (activeCryptoProfile === "tweetnacl") {
1087
+ return xSecretbox(plaintext, nonce, key);
1088
+ }
1089
+ const subtle = getSubtleCrypto();
1090
+ const iv = requireAesGcmNonce(nonce);
1091
+ const aesKey = await importAesKey(key, ["encrypt"]);
1092
+ const ciphertext = await subtle.encrypt(
1093
+ { iv: toBufferSource(iv), name: "AES-GCM" },
1094
+ aesKey,
1095
+ toBufferSource(plaintext),
1096
+ );
1097
+ return new Uint8Array(ciphertext);
477
1098
  }
478
1099
 
479
1100
  /** Decrypt with a shared secret key. Returns null if authentication fails. */
@@ -482,26 +1103,97 @@ export function xSecretboxOpen(
482
1103
  nonce: Uint8Array,
483
1104
  key: Uint8Array,
484
1105
  ): null | Uint8Array {
485
- return nacl.secretbox.open(ciphertext, nonce, key);
1106
+ return provider().secretboxOpen(ciphertext, nonce, key);
486
1107
  }
487
1108
 
488
- // ── Symmetric encryption (XSalsa20-Poly1305) ──────────────────────────────
1109
+ /** Async authenticated decryption for cross-runtime/FIPS backends. */
1110
+ export async function xSecretboxOpenAsync(
1111
+ ciphertext: Uint8Array,
1112
+ nonce: Uint8Array,
1113
+ key: Uint8Array,
1114
+ ): Promise<null | Uint8Array> {
1115
+ if (activeCryptoProfile === "tweetnacl") {
1116
+ return xSecretboxOpen(ciphertext, nonce, key);
1117
+ }
1118
+ const subtle = getSubtleCrypto();
1119
+ const iv = requireAesGcmNonce(nonce);
1120
+ const aesKey = await importAesKey(key, ["decrypt"]);
1121
+ try {
1122
+ const plaintext = await subtle.decrypt(
1123
+ { iv: toBufferSource(iv), name: "AES-GCM" },
1124
+ aesKey,
1125
+ toBufferSource(ciphertext),
1126
+ );
1127
+ return new Uint8Array(plaintext);
1128
+ } catch {
1129
+ return null;
1130
+ }
1131
+ }
489
1132
 
490
1133
  /** Sign a message with an Ed25519 secret key. Returns signed message (64-byte signature prefix + message). */
491
1134
  export function xSign(message: Uint8Array, secretKey: Uint8Array): Uint8Array {
492
- return nacl.sign(message, secretKey);
1135
+ return provider().sign(message, secretKey);
1136
+ }
1137
+
1138
+ /** Async signing for cross-runtime/FIPS backends. */
1139
+ export async function xSignAsync(
1140
+ message: Uint8Array,
1141
+ secretKey: Uint8Array,
1142
+ ): Promise<Uint8Array> {
1143
+ if (activeCryptoProfile === "tweetnacl") {
1144
+ return xSign(message, secretKey);
1145
+ }
1146
+ const subtle = getSubtleCrypto();
1147
+ const privateKey = await importEcdsaPrivateKey(secretKey);
1148
+ const signature = new Uint8Array(
1149
+ await subtle.sign(
1150
+ { hash: "SHA-256", name: "ECDSA" },
1151
+ privateKey,
1152
+ toBufferSource(message),
1153
+ ),
1154
+ );
1155
+ return encodeFipsSignedMessage(signature, message);
493
1156
  }
494
1157
 
495
1158
  /** Generate a fresh Ed25519 signing key pair. */
496
1159
  export function xSignKeyPair(): KeyPair {
497
- return nacl.sign.keyPair();
1160
+ return provider().signKeyPair();
498
1161
  }
499
1162
 
500
- // ── Random ─────────────────────────────────────────────────────────────────
1163
+ /** Async keypair generation for the active profile. */
1164
+ export async function xSignKeyPairAsync(): Promise<KeyPair> {
1165
+ if (activeCryptoProfile === "tweetnacl") {
1166
+ return xSignKeyPair();
1167
+ }
1168
+ const subtle = getSubtleCrypto();
1169
+ const pair = await subtle.generateKey(
1170
+ { name: "ECDSA", namedCurve: "P-256" },
1171
+ true,
1172
+ ["sign", "verify"],
1173
+ );
1174
+ return {
1175
+ publicKey: new Uint8Array(
1176
+ await subtle.exportKey("spki", pair.publicKey),
1177
+ ),
1178
+ secretKey: new Uint8Array(
1179
+ await subtle.exportKey("pkcs8", pair.privateKey),
1180
+ ),
1181
+ };
1182
+ }
501
1183
 
502
1184
  /** Restore an Ed25519 signing key pair from a 64-byte secret key. */
503
1185
  export function xSignKeyPairFromSecret(secretKey: Uint8Array): KeyPair {
504
- return nacl.sign.keyPair.fromSecretKey(secretKey);
1186
+ return provider().signKeyPairFromSecret(secretKey);
1187
+ }
1188
+
1189
+ /** Async restore of signing keypair for the active profile. */
1190
+ export async function xSignKeyPairFromSecretAsync(
1191
+ secretKey: Uint8Array,
1192
+ ): Promise<KeyPair> {
1193
+ if (activeCryptoProfile === "tweetnacl") {
1194
+ return xSignKeyPairFromSecret(secretKey);
1195
+ }
1196
+ return fipsEcdsaKeyPairFromPkcs8(secretKey, getSubtleCrypto());
505
1197
  }
506
1198
 
507
1199
  /** Verify and open a signed message. Returns the original message, or null if verification fails. */
@@ -509,7 +1201,104 @@ export function xSignOpen(
509
1201
  signedMessage: Uint8Array,
510
1202
  publicKey: Uint8Array,
511
1203
  ): null | Uint8Array {
512
- return nacl.sign.open(signedMessage, publicKey);
1204
+ return provider().signOpen(signedMessage, publicKey);
1205
+ }
1206
+
1207
+ /** Async verify/open for cross-runtime/FIPS backends. */
1208
+ export async function xSignOpenAsync(
1209
+ signedMessage: Uint8Array,
1210
+ publicKey: Uint8Array,
1211
+ ): Promise<null | Uint8Array> {
1212
+ if (activeCryptoProfile === "tweetnacl") {
1213
+ return xSignOpen(signedMessage, publicKey);
1214
+ }
1215
+ const subtle = getSubtleCrypto();
1216
+ const parsed = decodeFipsSignedMessage(signedMessage);
1217
+ const verifyKey = await importEcdsaPublicKey(publicKey);
1218
+ const valid = await subtle.verify(
1219
+ { hash: "SHA-256", name: "ECDSA" },
1220
+ verifyKey,
1221
+ toBufferSource(parsed.signature),
1222
+ toBufferSource(parsed.message),
1223
+ );
1224
+ return valid ? parsed.message : null;
1225
+ }
1226
+
1227
+ async function fipsEcdsaKeyPairFromPkcs8(
1228
+ secretKey: Uint8Array,
1229
+ subtle: SubtleCrypto,
1230
+ ): Promise<KeyPair> {
1231
+ const ecdsaPriv = await importEcdsaPrivateKey(secretKey);
1232
+ const jwk = await subtle.exportKey("jwk", ecdsaPriv);
1233
+ const ecdsaPubJwk: JsonWebKey = { ...jwk };
1234
+ delete ecdsaPubJwk.d;
1235
+ ecdsaPubJwk.key_ops = ["verify"];
1236
+ const ecdsaPub = await subtle.importKey(
1237
+ "jwk",
1238
+ ecdsaPubJwk,
1239
+ { name: "ECDSA", namedCurve: "P-256" },
1240
+ true,
1241
+ ["verify"],
1242
+ );
1243
+ return {
1244
+ publicKey: new Uint8Array(await subtle.exportKey("spki", ecdsaPub)),
1245
+ secretKey: Uint8Array.from(secretKey),
1246
+ };
1247
+ }
1248
+
1249
+ async function importAesKey(
1250
+ key: Uint8Array,
1251
+ usages: WebCryptoKeyUsage[],
1252
+ ): Promise<CryptoKey> {
1253
+ return getSubtleCrypto().importKey(
1254
+ "raw",
1255
+ toBufferSource(key),
1256
+ { name: "AES-GCM" },
1257
+ false,
1258
+ usages,
1259
+ );
1260
+ }
1261
+
1262
+ async function importEcdhPrivateKey(secretKey: Uint8Array): Promise<CryptoKey> {
1263
+ return getSubtleCrypto().importKey(
1264
+ "pkcs8",
1265
+ toBufferSource(secretKey),
1266
+ { name: "ECDH", namedCurve: "P-256" },
1267
+ true,
1268
+ ["deriveBits"],
1269
+ );
1270
+ }
1271
+
1272
+ async function importEcdhPublicKey(publicKey: Uint8Array): Promise<CryptoKey> {
1273
+ return getSubtleCrypto().importKey(
1274
+ "raw",
1275
+ toBufferSource(publicKey),
1276
+ { name: "ECDH", namedCurve: "P-256" },
1277
+ true,
1278
+ [],
1279
+ );
1280
+ }
1281
+
1282
+ async function importEcdsaPrivateKey(
1283
+ secretKey: Uint8Array,
1284
+ ): Promise<CryptoKey> {
1285
+ return getSubtleCrypto().importKey(
1286
+ "pkcs8",
1287
+ toBufferSource(secretKey),
1288
+ { name: "ECDSA", namedCurve: "P-256" },
1289
+ true,
1290
+ ["sign"],
1291
+ );
1292
+ }
1293
+
1294
+ async function importEcdsaPublicKey(publicKey: Uint8Array): Promise<CryptoKey> {
1295
+ return getSubtleCrypto().importKey(
1296
+ "spki",
1297
+ toBufferSource(publicKey),
1298
+ { name: "ECDSA", namedCurve: "P-256" },
1299
+ true,
1300
+ ["verify"],
1301
+ );
513
1302
  }
514
1303
 
515
1304
  /**