@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/CLA.md +38 -0
- package/LICENSE-COMMERCIAL +10 -0
- package/LICENSING.md +15 -0
- package/README.md +25 -9
- package/dist/index.d.ts +65 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +492 -19
- package/dist/index.js.map +1 -1
- package/package.json +8 -2
- package/src/__tests__/XKeyConvert.ts +6 -0
- package/src/__tests__/XUtils/bytesEqual.ts +6 -0
- package/src/__tests__/XUtils/emptyHeader.ts +6 -0
- package/src/__tests__/XUtils/numberToUint8Arr.ts +6 -0
- package/src/__tests__/XUtils/packMessage.ts +6 -0
- package/src/__tests__/XUtils/stringEncoding.ts +6 -0
- package/src/__tests__/XUtils/uint8ArrToNumber.ts +6 -0
- package/src/__tests__/XUtils/unpackMessage.ts +6 -0
- package/src/__tests__/cryptoProfile.ts +64 -0
- package/src/__tests__/xAsyncApi.extended.test.ts +84 -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 +810 -21
package/dist/index.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
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
|
+
import { numberToBytesBE } from "@noble/curves/abstract/utils";
|
|
8
|
+
import { p256 } from "@noble/curves/p256";
|
|
1
9
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
2
10
|
import { hmac } from "@noble/hashes/hmac.js";
|
|
3
11
|
import { pbkdf2 as noblePbkdf2 } from "@noble/hashes/pbkdf2.js";
|
|
@@ -9,6 +17,218 @@ import ed2curve from "ed2curve";
|
|
|
9
17
|
import { Packr } from "msgpackr";
|
|
10
18
|
import nacl from "tweetnacl";
|
|
11
19
|
import { z } from "zod/v4";
|
|
20
|
+
function getWebCrypto() {
|
|
21
|
+
const cryptoCandidate = globalThis.crypto;
|
|
22
|
+
if (!isWebCryptoLike(cryptoCandidate)) {
|
|
23
|
+
throw new Error("Web Crypto API is not available in this runtime for fips profile operations.");
|
|
24
|
+
}
|
|
25
|
+
return cryptoCandidate;
|
|
26
|
+
}
|
|
27
|
+
function isWebCryptoLike(value) {
|
|
28
|
+
if (typeof value !== "object" || value === null) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return ("getRandomValues" in value &&
|
|
32
|
+
typeof value.getRandomValues === "function" &&
|
|
33
|
+
"subtle" in value &&
|
|
34
|
+
typeof value.subtle === "object" &&
|
|
35
|
+
value.subtle !== null);
|
|
36
|
+
}
|
|
37
|
+
function randomBytesWebCrypto(length) {
|
|
38
|
+
if (!Number.isInteger(length) || length < 0) {
|
|
39
|
+
throw new Error(`Expected non-negative integer length, received ${String(length)}.`);
|
|
40
|
+
}
|
|
41
|
+
const cryptoImpl = getWebCrypto();
|
|
42
|
+
const result = new Uint8Array(length);
|
|
43
|
+
let offset = 0;
|
|
44
|
+
while (offset < length) {
|
|
45
|
+
const chunkLength = Math.min(65536, length - offset);
|
|
46
|
+
const chunk = result.subarray(offset, offset + chunkLength);
|
|
47
|
+
cryptoImpl.getRandomValues(chunk);
|
|
48
|
+
offset += chunkLength;
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
function unsupported(profile, operation) {
|
|
53
|
+
throw new Error(`Crypto profile "${profile}" does not implement ${operation} yet.`);
|
|
54
|
+
}
|
|
55
|
+
const tweetnaclProvider = {
|
|
56
|
+
boxBefore: (myPrivateKey, theirPublicKey) => nacl.box.before(theirPublicKey, myPrivateKey),
|
|
57
|
+
boxKeyPair: () => nacl.box.keyPair(),
|
|
58
|
+
boxKeyPairFromSecret: (secretKey) => nacl.box.keyPair.fromSecretKey(secretKey),
|
|
59
|
+
profile: "tweetnacl",
|
|
60
|
+
randomBytes: (length) => nacl.randomBytes(length),
|
|
61
|
+
secretbox: (plaintext, nonce, key) => nacl.secretbox(plaintext, nonce, key),
|
|
62
|
+
secretboxOpen: (ciphertext, nonce, key) => nacl.secretbox.open(ciphertext, nonce, key),
|
|
63
|
+
sign: (message, secretKey) => nacl.sign(message, secretKey),
|
|
64
|
+
signKeyPair: () => nacl.sign.keyPair(),
|
|
65
|
+
signKeyPairFromSecret: (secretKey) => nacl.sign.keyPair.fromSecretKey(secretKey),
|
|
66
|
+
signOpen: (signedMessage, publicKey) => nacl.sign.open(signedMessage, publicKey),
|
|
67
|
+
};
|
|
68
|
+
const fipsProvider = {
|
|
69
|
+
boxBefore: (myPrivateKey, theirPublicKey) => {
|
|
70
|
+
void myPrivateKey;
|
|
71
|
+
void theirPublicKey;
|
|
72
|
+
return unsupported("fips", "xDH");
|
|
73
|
+
},
|
|
74
|
+
boxKeyPair: () => unsupported("fips", "xBoxKeyPair"),
|
|
75
|
+
boxKeyPairFromSecret: (secretKey) => {
|
|
76
|
+
void secretKey;
|
|
77
|
+
return unsupported("fips", "xBoxKeyPairFromSecret");
|
|
78
|
+
},
|
|
79
|
+
profile: "fips",
|
|
80
|
+
randomBytes: (length) => randomBytesWebCrypto(length),
|
|
81
|
+
secretbox: (plaintext, nonce, key) => {
|
|
82
|
+
void plaintext;
|
|
83
|
+
void nonce;
|
|
84
|
+
void key;
|
|
85
|
+
return unsupported("fips", "xSecretbox");
|
|
86
|
+
},
|
|
87
|
+
secretboxOpen: (ciphertext, nonce, key) => {
|
|
88
|
+
void ciphertext;
|
|
89
|
+
void nonce;
|
|
90
|
+
void key;
|
|
91
|
+
return unsupported("fips", "xSecretboxOpen");
|
|
92
|
+
},
|
|
93
|
+
sign: (message, secretKey) => {
|
|
94
|
+
void message;
|
|
95
|
+
void secretKey;
|
|
96
|
+
return unsupported("fips", "xSign");
|
|
97
|
+
},
|
|
98
|
+
signKeyPair: () => unsupported("fips", "xSignKeyPair"),
|
|
99
|
+
signKeyPairFromSecret: (secretKey) => {
|
|
100
|
+
void secretKey;
|
|
101
|
+
return unsupported("fips", "xSignKeyPairFromSecret");
|
|
102
|
+
},
|
|
103
|
+
signOpen: (signedMessage, publicKey) => {
|
|
104
|
+
void signedMessage;
|
|
105
|
+
void publicKey;
|
|
106
|
+
return unsupported("fips", "xSignOpen");
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const providers = {
|
|
110
|
+
fips: fipsProvider,
|
|
111
|
+
tweetnacl: tweetnaclProvider,
|
|
112
|
+
};
|
|
113
|
+
let activeCryptoProfile = "tweetnacl";
|
|
114
|
+
let activeCryptoProvider = providers[activeCryptoProfile];
|
|
115
|
+
/** Returns the currently configured crypto profile. */
|
|
116
|
+
export function getCryptoProfile() {
|
|
117
|
+
return activeCryptoProfile;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Sets the runtime crypto profile.
|
|
121
|
+
*
|
|
122
|
+
* `tweetnacl` preserves existing behavior.
|
|
123
|
+
* `fips` currently enables only backend-agnostic helpers; NaCl-coupled
|
|
124
|
+
* primitives throw until a FIPS backend implementation is wired in.
|
|
125
|
+
*/
|
|
126
|
+
export function setCryptoProfile(profile) {
|
|
127
|
+
activeCryptoProfile = profile;
|
|
128
|
+
activeCryptoProvider = providers[profile];
|
|
129
|
+
}
|
|
130
|
+
function bytesToBase64Url(bytes) {
|
|
131
|
+
const BufferCtor = getNodeBufferCtor();
|
|
132
|
+
if (BufferCtor !== undefined) {
|
|
133
|
+
return BufferCtor.from(bytes).toString("base64url");
|
|
134
|
+
}
|
|
135
|
+
return globalThis
|
|
136
|
+
.btoa(String.fromCodePoint(...Array.from(bytes)))
|
|
137
|
+
.replace(/\+/g, "-")
|
|
138
|
+
.replace(/\//g, "_")
|
|
139
|
+
.replace(/=+/g, "");
|
|
140
|
+
}
|
|
141
|
+
function concatBytes(a, b) {
|
|
142
|
+
const out = new Uint8Array(a.length + b.length);
|
|
143
|
+
out.set(a, 0);
|
|
144
|
+
out.set(b, a.length);
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
function decodeFipsSignedMessage(signedMessage) {
|
|
148
|
+
if (signedMessage.length < 2) {
|
|
149
|
+
throw new Error("Invalid FIPS signed message: missing signature length prefix.");
|
|
150
|
+
}
|
|
151
|
+
const signatureLength = (signedMessage[0] ?? 0) * 256 + (signedMessage[1] ?? 0);
|
|
152
|
+
const signatureStart = 2;
|
|
153
|
+
const signatureEnd = signatureStart + signatureLength;
|
|
154
|
+
if (signedMessage.length < signatureEnd) {
|
|
155
|
+
throw new Error("Invalid FIPS signed message: signature length exceeds message length.");
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
message: signedMessage.slice(signatureEnd),
|
|
159
|
+
signature: signedMessage.slice(signatureStart, signatureEnd),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function encodeFipsSignedMessage(signature, message) {
|
|
163
|
+
if (signature.length > 65535) {
|
|
164
|
+
throw new Error("FIPS signature too long to encode.");
|
|
165
|
+
}
|
|
166
|
+
const prefix = new Uint8Array(2);
|
|
167
|
+
prefix[0] = (signature.length >> 8) & 0xff;
|
|
168
|
+
prefix[1] = signature.length & 0xff;
|
|
169
|
+
return concatBytes(concatBytes(prefix, signature), message);
|
|
170
|
+
}
|
|
171
|
+
async function fipsEcdhKeyPairFrom32ByteSeed(secretKey, subtle) {
|
|
172
|
+
if (secretKey.length !== 32) {
|
|
173
|
+
throw new Error("FIPS: expected a 32-byte IKM/seed for ECDH from-secret.");
|
|
174
|
+
}
|
|
175
|
+
const d = p256.utils.normPrivateKeyToScalar(Uint8Array.from(secretKey));
|
|
176
|
+
const d32 = numberToBytesBE(d, 32);
|
|
177
|
+
const rawPub = p256.getPublicKey(d, false);
|
|
178
|
+
if (rawPub[0] !== 0x04) {
|
|
179
|
+
throw new Error("FIPS: expected uncompressed P-256 public key.");
|
|
180
|
+
}
|
|
181
|
+
const jwk = {
|
|
182
|
+
crv: "P-256",
|
|
183
|
+
d: bytesToBase64Url(d32),
|
|
184
|
+
kty: "EC",
|
|
185
|
+
x: bytesToBase64Url(rawPub.subarray(1, 33)),
|
|
186
|
+
y: bytesToBase64Url(rawPub.subarray(33, 65)),
|
|
187
|
+
};
|
|
188
|
+
const ecdhPriv = await subtle.importKey("jwk", jwk, { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
189
|
+
const ecdhPubJwk = await subtle.exportKey("jwk", ecdhPriv);
|
|
190
|
+
const ecdhPubJwkNoD = { ...ecdhPubJwk };
|
|
191
|
+
delete ecdhPubJwkNoD.d;
|
|
192
|
+
ecdhPubJwkNoD.key_ops = [];
|
|
193
|
+
const ecdhPub = await subtle.importKey("jwk", ecdhPubJwkNoD, { name: "ECDH", namedCurve: "P-256" }, true, []);
|
|
194
|
+
return {
|
|
195
|
+
publicKey: new Uint8Array(await subtle.exportKey("raw", ecdhPub)),
|
|
196
|
+
secretKey: new Uint8Array(await subtle.exportKey("pkcs8", ecdhPriv)),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
async function fipsEcdhKeyPairFromPkcs8(secretKey, subtle) {
|
|
200
|
+
const ecdhPriv = await subtle.importKey("pkcs8", toBufferSource(secretKey), { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
201
|
+
const jwk = await subtle.exportKey("jwk", ecdhPriv);
|
|
202
|
+
const ecdhPubJwk = { ...jwk };
|
|
203
|
+
delete ecdhPubJwk.d;
|
|
204
|
+
ecdhPubJwk.key_ops = [];
|
|
205
|
+
const ecdhPub = await subtle.importKey("jwk", ecdhPubJwk, { name: "ECDH", namedCurve: "P-256" }, true, []);
|
|
206
|
+
return {
|
|
207
|
+
publicKey: new Uint8Array(await subtle.exportKey("raw", ecdhPub)),
|
|
208
|
+
secretKey: new Uint8Array(await subtle.exportKey("pkcs8", ecdhPriv)),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function getNodeBufferCtor() {
|
|
212
|
+
if (!("Buffer" in globalThis)) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
return globalThis.Buffer;
|
|
216
|
+
}
|
|
217
|
+
function getSubtleCrypto() {
|
|
218
|
+
return getWebCrypto().subtle;
|
|
219
|
+
}
|
|
220
|
+
function provider() {
|
|
221
|
+
return activeCryptoProvider;
|
|
222
|
+
}
|
|
223
|
+
function requireAesGcmNonce(nonce) {
|
|
224
|
+
if (nonce.length < 12) {
|
|
225
|
+
throw new Error(`AES-GCM requires a nonce of at least 12 bytes, received ${String(nonce.length)}.`);
|
|
226
|
+
}
|
|
227
|
+
return nonce.slice(0, 12);
|
|
228
|
+
}
|
|
229
|
+
function toBufferSource(bytes) {
|
|
230
|
+
return Uint8Array.from(bytes).buffer;
|
|
231
|
+
}
|
|
12
232
|
// msgpackr with useRecords:false emits standard msgpack (no nonstandard record extension).
|
|
13
233
|
// moreTypes:false keeps the extension set to only what other decoders understand.
|
|
14
234
|
// pack() returns Node Buffer (tight view) so consumers like axios send the correct bytes.
|
|
@@ -85,12 +305,48 @@ export class XUtils {
|
|
|
85
305
|
c: ITERATIONS,
|
|
86
306
|
dkLen: 32,
|
|
87
307
|
});
|
|
88
|
-
const DECRYPTED_SIGNKEY =
|
|
308
|
+
const DECRYPTED_SIGNKEY = provider().secretboxOpen(ENCRYPTED_KEY, ENCRYPTION_NONCE, DERIVED_KEY);
|
|
89
309
|
if (DECRYPTED_SIGNKEY === null) {
|
|
90
310
|
throw new Error("Decryption failed. Wrong password?");
|
|
91
311
|
}
|
|
92
312
|
return XUtils.encodeHex(DECRYPTED_SIGNKEY);
|
|
93
313
|
};
|
|
314
|
+
/**
|
|
315
|
+
* Async variant of decryptKeyData for cross-runtime/FIPS backends.
|
|
316
|
+
* Supports both profile formats emitted by encryptKeyDataAsync.
|
|
317
|
+
*/
|
|
318
|
+
static decryptKeyDataAsync = async (keyData, password) => {
|
|
319
|
+
const ITERATIONS = XUtils.uint8ArrToNumber(keyData.slice(0, 6));
|
|
320
|
+
const PKBDF_SALT = keyData.slice(6, 30);
|
|
321
|
+
const ENCRYPTION_NONCE = keyData.slice(30, 54);
|
|
322
|
+
const ENCRYPTED_KEY = keyData.slice(54);
|
|
323
|
+
const DERIVED_KEY = noblePbkdf2(sha512, password, PKBDF_SALT, {
|
|
324
|
+
c: ITERATIONS,
|
|
325
|
+
dkLen: 32,
|
|
326
|
+
});
|
|
327
|
+
const decrypted = activeCryptoProfile === "fips"
|
|
328
|
+
? await xSecretboxOpenAsync(ENCRYPTED_KEY, ENCRYPTION_NONCE, DERIVED_KEY)
|
|
329
|
+
: xSecretboxOpen(ENCRYPTED_KEY, ENCRYPTION_NONCE, DERIVED_KEY);
|
|
330
|
+
if (decrypted === null) {
|
|
331
|
+
throw new Error("Decryption failed. Wrong password?");
|
|
332
|
+
}
|
|
333
|
+
return XUtils.encodeHex(decrypted);
|
|
334
|
+
};
|
|
335
|
+
/**
|
|
336
|
+
* 32-byte AES-256 key for local at-rest encryption (e.g. sqlite) derived from
|
|
337
|
+
* identity `secretKey`. For `tweetnacl` this is the 32-byte X25519 private key.
|
|
338
|
+
* For `fips` the identity secret is PKCS#8; HKDF is applied so AES keys never
|
|
339
|
+
* equal the raw private key material.
|
|
340
|
+
*/
|
|
341
|
+
static deriveLocalAtRestAesKey(identitySk, profile) {
|
|
342
|
+
if (profile === "tweetnacl") {
|
|
343
|
+
if (identitySk.length < 32) {
|
|
344
|
+
throw new Error("Expected at least 32 bytes of identity secret in tweetnacl mode.");
|
|
345
|
+
}
|
|
346
|
+
return identitySk.subarray(0, 32);
|
|
347
|
+
}
|
|
348
|
+
return new Uint8Array(hkdf(sha256, identitySk, new Uint8Array(0), new TextEncoder().encode("vex:at-rest:2.1.0-fips"), 32));
|
|
349
|
+
}
|
|
94
350
|
/**
|
|
95
351
|
* Returns the empty header (32 0's)
|
|
96
352
|
*
|
|
@@ -122,7 +378,7 @@ export class XUtils {
|
|
|
122
378
|
static encryptKeyData = (password, keyToSave, iterationOverride) => {
|
|
123
379
|
const UNENCRYPTED_SIGNKEY = XUtils.decodeHex(keyToSave);
|
|
124
380
|
const OFFSET = 1000;
|
|
125
|
-
const rand =
|
|
381
|
+
const rand = provider().randomBytes(2);
|
|
126
382
|
const [N1 = 0, N2 = 0] = rand;
|
|
127
383
|
const iterations = iterationOverride !== undefined && iterationOverride !== 0
|
|
128
384
|
? iterationOverride
|
|
@@ -134,7 +390,43 @@ export class XUtils {
|
|
|
134
390
|
dkLen: 32,
|
|
135
391
|
});
|
|
136
392
|
const NONCE = xMakeNonce();
|
|
137
|
-
const ENCRYPTED_SIGNKEY =
|
|
393
|
+
const ENCRYPTED_SIGNKEY = provider().secretbox(UNENCRYPTED_SIGNKEY, NONCE, ENCRYPTION_KEY);
|
|
394
|
+
const result = new Uint8Array(ITERATIONS.length +
|
|
395
|
+
PKBDF_SALT.length +
|
|
396
|
+
NONCE.length +
|
|
397
|
+
ENCRYPTED_SIGNKEY.length);
|
|
398
|
+
let offset = 0;
|
|
399
|
+
result.set(ITERATIONS, offset);
|
|
400
|
+
offset += ITERATIONS.length;
|
|
401
|
+
result.set(PKBDF_SALT, offset);
|
|
402
|
+
offset += PKBDF_SALT.length;
|
|
403
|
+
result.set(NONCE, offset);
|
|
404
|
+
offset += NONCE.length;
|
|
405
|
+
result.set(ENCRYPTED_SIGNKEY, offset);
|
|
406
|
+
return result;
|
|
407
|
+
};
|
|
408
|
+
/**
|
|
409
|
+
* Async variant of encryptKeyData for cross-runtime/FIPS backends.
|
|
410
|
+
* Format remains [iterations(6)|salt(24)|nonce(24)|ciphertext(N)].
|
|
411
|
+
*/
|
|
412
|
+
static encryptKeyDataAsync = async (password, keyToSave, iterationOverride) => {
|
|
413
|
+
const UNENCRYPTED_SIGNKEY = XUtils.decodeHex(keyToSave);
|
|
414
|
+
const OFFSET = 1000;
|
|
415
|
+
const rand = xRandomBytes(2);
|
|
416
|
+
const [N1 = 0, N2 = 0] = rand;
|
|
417
|
+
const iterations = iterationOverride !== undefined && iterationOverride !== 0
|
|
418
|
+
? iterationOverride
|
|
419
|
+
: N1 * N2 + OFFSET;
|
|
420
|
+
const ITERATIONS = XUtils.numberToUint8Arr(iterations);
|
|
421
|
+
const PKBDF_SALT = xMakeNonce();
|
|
422
|
+
const ENCRYPTION_KEY = noblePbkdf2(sha512, password, PKBDF_SALT, {
|
|
423
|
+
c: iterations,
|
|
424
|
+
dkLen: 32,
|
|
425
|
+
});
|
|
426
|
+
const NONCE = xMakeNonce();
|
|
427
|
+
const ENCRYPTED_SIGNKEY = activeCryptoProfile === "fips"
|
|
428
|
+
? await xSecretboxAsync(UNENCRYPTED_SIGNKEY, NONCE, ENCRYPTION_KEY)
|
|
429
|
+
: xSecretbox(UNENCRYPTED_SIGNKEY, NONCE, ENCRYPTION_KEY);
|
|
138
430
|
const result = new Uint8Array(ITERATIONS.length +
|
|
139
431
|
PKBDF_SALT.length +
|
|
140
432
|
NONCE.length +
|
|
@@ -245,14 +537,64 @@ export const xConstants = {
|
|
|
245
537
|
KEY_LENGTH: 32,
|
|
246
538
|
MIN_OTK_SUPPLY: 100,
|
|
247
539
|
};
|
|
540
|
+
/**
|
|
541
|
+
* FIPS: `device.signKey` in the database is the P-256 ECDSA public key (SPKI),
|
|
542
|
+
* used for account/device signature verification. X3DH on the client expects
|
|
543
|
+
* the same curve point as Web Crypto "raw" P-256 ECDH public bytes for
|
|
544
|
+
* `importEcdhPublicKey`. This converts SPKI → raw without a private key.
|
|
545
|
+
*/
|
|
546
|
+
export async function fipsEcdhRawPublicKeyFromEcdsaSpkiAsync(ecdsaSpki) {
|
|
547
|
+
if (ecdsaSpki.length === 0) {
|
|
548
|
+
throw new Error("FIPS: empty ECDSA SPKI.");
|
|
549
|
+
}
|
|
550
|
+
const subtle = getSubtleCrypto();
|
|
551
|
+
const ecdsaPub = await importEcdsaPublicKey(ecdsaSpki);
|
|
552
|
+
const jwk = await subtle.exportKey("jwk", ecdsaPub);
|
|
553
|
+
if (jwk.x === undefined ||
|
|
554
|
+
jwk.y === undefined ||
|
|
555
|
+
jwk.x.length === 0 ||
|
|
556
|
+
jwk.y.length === 0) {
|
|
557
|
+
throw new Error("FIPS: could not export ECDSA public as JWK.");
|
|
558
|
+
}
|
|
559
|
+
const ecdhJwk = { ...jwk };
|
|
560
|
+
delete ecdhJwk.d;
|
|
561
|
+
ecdhJwk.key_ops = [];
|
|
562
|
+
ecdhJwk.ext = true;
|
|
563
|
+
const ecdhPub = await subtle.importKey("jwk", ecdhJwk, { name: "ECDH", namedCurve: "P-256" }, true, []);
|
|
564
|
+
return new Uint8Array(await subtle.exportKey("raw", ecdhPub));
|
|
565
|
+
}
|
|
248
566
|
/** Generate a fresh X25519 box key pair. */
|
|
249
567
|
export function xBoxKeyPair() {
|
|
250
|
-
return
|
|
568
|
+
return provider().boxKeyPair();
|
|
569
|
+
}
|
|
570
|
+
/** Async box keypair generation for the active profile. */
|
|
571
|
+
export async function xBoxKeyPairAsync() {
|
|
572
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
573
|
+
return xBoxKeyPair();
|
|
574
|
+
}
|
|
575
|
+
const subtle = getSubtleCrypto();
|
|
576
|
+
const pair = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
577
|
+
return {
|
|
578
|
+
publicKey: new Uint8Array(await subtle.exportKey("raw", pair.publicKey)),
|
|
579
|
+
secretKey: new Uint8Array(await subtle.exportKey("pkcs8", pair.privateKey)),
|
|
580
|
+
};
|
|
251
581
|
}
|
|
252
582
|
/** Restore an X25519 box key pair from a 32-byte secret key. */
|
|
253
583
|
export function xBoxKeyPairFromSecret(secretKey) {
|
|
254
|
-
return
|
|
584
|
+
return provider().boxKeyPairFromSecret(secretKey);
|
|
255
585
|
}
|
|
586
|
+
// ── Key pair type ───────────────────────────────────────────────────────────
|
|
587
|
+
/** Async box key restore from private key material. */
|
|
588
|
+
export async function xBoxKeyPairFromSecretAsync(secretKey) {
|
|
589
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
590
|
+
return xBoxKeyPairFromSecret(secretKey);
|
|
591
|
+
}
|
|
592
|
+
if (secretKey.length === 32) {
|
|
593
|
+
return fipsEcdhKeyPairFrom32ByteSeed(secretKey, getSubtleCrypto());
|
|
594
|
+
}
|
|
595
|
+
return fipsEcdhKeyPairFromPkcs8(secretKey, getSubtleCrypto());
|
|
596
|
+
}
|
|
597
|
+
// ── Key generation ─────────────────────────────────────────────────────────
|
|
256
598
|
/**
|
|
257
599
|
* Concatanates multiple Uint8Arrays.
|
|
258
600
|
*
|
|
@@ -282,9 +624,48 @@ export function xConcat(...arrays) {
|
|
|
282
624
|
* @returns The derived shared secret, SK.
|
|
283
625
|
*/
|
|
284
626
|
export function xDH(myPrivateKey, theirPublicKey) {
|
|
285
|
-
return
|
|
627
|
+
return provider().boxBefore(myPrivateKey, theirPublicKey);
|
|
628
|
+
}
|
|
629
|
+
/** Async DH for cross-runtime/FIPS backends. */
|
|
630
|
+
export async function xDHAsync(myPrivateKey, theirPublicKey) {
|
|
631
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
632
|
+
return xDH(myPrivateKey, theirPublicKey);
|
|
633
|
+
}
|
|
634
|
+
const subtle = getSubtleCrypto();
|
|
635
|
+
const privateKey = await importEcdhPrivateKey(myPrivateKey);
|
|
636
|
+
const publicKey = await importEcdhPublicKey(theirPublicKey);
|
|
637
|
+
const shared = await subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256);
|
|
638
|
+
return new Uint8Array(shared);
|
|
286
639
|
}
|
|
287
|
-
|
|
640
|
+
/**
|
|
641
|
+
* In `fips` mode only: derive a P-256 ECDH `KeyPair` (raw public + pkcs8 secret)
|
|
642
|
+
* from a P-256 ECDSA `KeyPair` (spki + pkcs8) using the same private scalar in Web Crypto.
|
|
643
|
+
* In `tweetnacl` mode, use `XKeyConvert.convertKeyPair` to map Ed25519 → X25519 instead.
|
|
644
|
+
*/
|
|
645
|
+
export async function xEcdhKeyPairFromEcdsaKeyPairAsync(sign) {
|
|
646
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
647
|
+
return Promise.reject(new Error('xEcdhKeyPairFromEcdsaKeyPairAsync is for crypto profile "fips" only. Use XKeyConvert.convertKeyPair in tweetnacl mode.'));
|
|
648
|
+
}
|
|
649
|
+
const subtle = getSubtleCrypto();
|
|
650
|
+
const ecdsaPriv = await importEcdsaPrivateKey(sign.secretKey);
|
|
651
|
+
const jwk = await subtle.exportKey("jwk", ecdsaPriv);
|
|
652
|
+
if (typeof jwk.d !== "string" || jwk.d.length === 0) {
|
|
653
|
+
throw new Error("FIPS: could not export ECDSA private as JWK.");
|
|
654
|
+
}
|
|
655
|
+
const ecdhJwk = { ...jwk, key_ops: ["deriveBits"] };
|
|
656
|
+
ecdhJwk.ext = true;
|
|
657
|
+
const ecdhPriv = await subtle.importKey("jwk", ecdhJwk, { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
658
|
+
const ecdhPubJwk = await subtle.exportKey("jwk", ecdhPriv);
|
|
659
|
+
const ecdhPubJwkNoD = { ...ecdhPubJwk };
|
|
660
|
+
delete ecdhPubJwkNoD.d;
|
|
661
|
+
ecdhPubJwkNoD.key_ops = [];
|
|
662
|
+
const ecdhPub = await subtle.importKey("jwk", ecdhPubJwkNoD, { name: "ECDH", namedCurve: "P-256" }, true, []);
|
|
663
|
+
return {
|
|
664
|
+
publicKey: new Uint8Array(await subtle.exportKey("raw", ecdhPub)),
|
|
665
|
+
secretKey: new Uint8Array(await subtle.exportKey("pkcs8", ecdhPriv)),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
// ── Signing ────────────────────────────────────────────────────────────────
|
|
288
669
|
/**
|
|
289
670
|
* Encode an X25519 or X448 public key PK into a byte sequence.
|
|
290
671
|
* The encoding consists of 0 or 1 to represent the type of curve, followed by l
|
|
@@ -318,7 +699,6 @@ export function xEncode(curveType, publicKey) {
|
|
|
318
699
|
}
|
|
319
700
|
return Uint8Array.from(bytes);
|
|
320
701
|
}
|
|
321
|
-
// ── Key generation ─────────────────────────────────────────────────────────
|
|
322
702
|
/**
|
|
323
703
|
* Hashes some data.
|
|
324
704
|
*
|
|
@@ -328,6 +708,7 @@ export function xEncode(curveType, publicKey) {
|
|
|
328
708
|
export function xHash(data) {
|
|
329
709
|
return XUtils.encodeHex(sha512(data));
|
|
330
710
|
}
|
|
711
|
+
// ── Symmetric encryption (XSalsa20-Poly1305) ──────────────────────────────
|
|
331
712
|
export function xKDF(IKM) {
|
|
332
713
|
return hkdf(sha512, IKM, xMakeSalt(xConstants.CURVE), new TextEncoder().encode(xConstants.INFO), xConstants.KEY_LENGTH);
|
|
333
714
|
}
|
|
@@ -335,38 +716,130 @@ export function xKDF(IKM) {
|
|
|
335
716
|
* Returns a 24 byte random nonce of cryptographic quality.
|
|
336
717
|
*/
|
|
337
718
|
export function xMakeNonce() {
|
|
338
|
-
return
|
|
719
|
+
return provider().randomBytes(24);
|
|
339
720
|
}
|
|
721
|
+
// ── Random ─────────────────────────────────────────────────────────────────
|
|
340
722
|
/** Cryptographically secure random bytes. */
|
|
341
723
|
export function xRandomBytes(length) {
|
|
342
|
-
return
|
|
724
|
+
return provider().randomBytes(length);
|
|
343
725
|
}
|
|
344
|
-
// ── Signing ────────────────────────────────────────────────────────────────
|
|
345
726
|
/** Encrypt with a shared secret key. */
|
|
346
727
|
export function xSecretbox(plaintext, nonce, key) {
|
|
347
|
-
return
|
|
728
|
+
return provider().secretbox(plaintext, nonce, key);
|
|
729
|
+
}
|
|
730
|
+
/** Async authenticated encryption for cross-runtime/FIPS backends. */
|
|
731
|
+
export async function xSecretboxAsync(plaintext, nonce, key) {
|
|
732
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
733
|
+
return xSecretbox(plaintext, nonce, key);
|
|
734
|
+
}
|
|
735
|
+
const subtle = getSubtleCrypto();
|
|
736
|
+
const iv = requireAesGcmNonce(nonce);
|
|
737
|
+
const aesKey = await importAesKey(key, ["encrypt"]);
|
|
738
|
+
const ciphertext = await subtle.encrypt({ iv: toBufferSource(iv), name: "AES-GCM" }, aesKey, toBufferSource(plaintext));
|
|
739
|
+
return new Uint8Array(ciphertext);
|
|
348
740
|
}
|
|
349
741
|
/** Decrypt with a shared secret key. Returns null if authentication fails. */
|
|
350
742
|
export function xSecretboxOpen(ciphertext, nonce, key) {
|
|
351
|
-
return
|
|
743
|
+
return provider().secretboxOpen(ciphertext, nonce, key);
|
|
744
|
+
}
|
|
745
|
+
/** Async authenticated decryption for cross-runtime/FIPS backends. */
|
|
746
|
+
export async function xSecretboxOpenAsync(ciphertext, nonce, key) {
|
|
747
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
748
|
+
return xSecretboxOpen(ciphertext, nonce, key);
|
|
749
|
+
}
|
|
750
|
+
const subtle = getSubtleCrypto();
|
|
751
|
+
const iv = requireAesGcmNonce(nonce);
|
|
752
|
+
const aesKey = await importAesKey(key, ["decrypt"]);
|
|
753
|
+
try {
|
|
754
|
+
const plaintext = await subtle.decrypt({ iv: toBufferSource(iv), name: "AES-GCM" }, aesKey, toBufferSource(ciphertext));
|
|
755
|
+
return new Uint8Array(plaintext);
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
352
760
|
}
|
|
353
|
-
// ── Symmetric encryption (XSalsa20-Poly1305) ──────────────────────────────
|
|
354
761
|
/** Sign a message with an Ed25519 secret key. Returns signed message (64-byte signature prefix + message). */
|
|
355
762
|
export function xSign(message, secretKey) {
|
|
356
|
-
return
|
|
763
|
+
return provider().sign(message, secretKey);
|
|
764
|
+
}
|
|
765
|
+
/** Async signing for cross-runtime/FIPS backends. */
|
|
766
|
+
export async function xSignAsync(message, secretKey) {
|
|
767
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
768
|
+
return xSign(message, secretKey);
|
|
769
|
+
}
|
|
770
|
+
const subtle = getSubtleCrypto();
|
|
771
|
+
const privateKey = await importEcdsaPrivateKey(secretKey);
|
|
772
|
+
const signature = new Uint8Array(await subtle.sign({ hash: "SHA-256", name: "ECDSA" }, privateKey, toBufferSource(message)));
|
|
773
|
+
return encodeFipsSignedMessage(signature, message);
|
|
357
774
|
}
|
|
358
775
|
/** Generate a fresh Ed25519 signing key pair. */
|
|
359
776
|
export function xSignKeyPair() {
|
|
360
|
-
return
|
|
777
|
+
return provider().signKeyPair();
|
|
778
|
+
}
|
|
779
|
+
/** Async keypair generation for the active profile. */
|
|
780
|
+
export async function xSignKeyPairAsync() {
|
|
781
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
782
|
+
return xSignKeyPair();
|
|
783
|
+
}
|
|
784
|
+
const subtle = getSubtleCrypto();
|
|
785
|
+
const pair = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
|
|
786
|
+
return {
|
|
787
|
+
publicKey: new Uint8Array(await subtle.exportKey("spki", pair.publicKey)),
|
|
788
|
+
secretKey: new Uint8Array(await subtle.exportKey("pkcs8", pair.privateKey)),
|
|
789
|
+
};
|
|
361
790
|
}
|
|
362
|
-
// ── Random ─────────────────────────────────────────────────────────────────
|
|
363
791
|
/** Restore an Ed25519 signing key pair from a 64-byte secret key. */
|
|
364
792
|
export function xSignKeyPairFromSecret(secretKey) {
|
|
365
|
-
return
|
|
793
|
+
return provider().signKeyPairFromSecret(secretKey);
|
|
794
|
+
}
|
|
795
|
+
/** Async restore of signing keypair for the active profile. */
|
|
796
|
+
export async function xSignKeyPairFromSecretAsync(secretKey) {
|
|
797
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
798
|
+
return xSignKeyPairFromSecret(secretKey);
|
|
799
|
+
}
|
|
800
|
+
return fipsEcdsaKeyPairFromPkcs8(secretKey, getSubtleCrypto());
|
|
366
801
|
}
|
|
367
802
|
/** Verify and open a signed message. Returns the original message, or null if verification fails. */
|
|
368
803
|
export function xSignOpen(signedMessage, publicKey) {
|
|
369
|
-
return
|
|
804
|
+
return provider().signOpen(signedMessage, publicKey);
|
|
805
|
+
}
|
|
806
|
+
/** Async verify/open for cross-runtime/FIPS backends. */
|
|
807
|
+
export async function xSignOpenAsync(signedMessage, publicKey) {
|
|
808
|
+
if (activeCryptoProfile === "tweetnacl") {
|
|
809
|
+
return xSignOpen(signedMessage, publicKey);
|
|
810
|
+
}
|
|
811
|
+
const subtle = getSubtleCrypto();
|
|
812
|
+
const parsed = decodeFipsSignedMessage(signedMessage);
|
|
813
|
+
const verifyKey = await importEcdsaPublicKey(publicKey);
|
|
814
|
+
const valid = await subtle.verify({ hash: "SHA-256", name: "ECDSA" }, verifyKey, toBufferSource(parsed.signature), toBufferSource(parsed.message));
|
|
815
|
+
return valid ? parsed.message : null;
|
|
816
|
+
}
|
|
817
|
+
async function fipsEcdsaKeyPairFromPkcs8(secretKey, subtle) {
|
|
818
|
+
const ecdsaPriv = await importEcdsaPrivateKey(secretKey);
|
|
819
|
+
const jwk = await subtle.exportKey("jwk", ecdsaPriv);
|
|
820
|
+
const ecdsaPubJwk = { ...jwk };
|
|
821
|
+
delete ecdsaPubJwk.d;
|
|
822
|
+
ecdsaPubJwk.key_ops = ["verify"];
|
|
823
|
+
const ecdsaPub = await subtle.importKey("jwk", ecdsaPubJwk, { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]);
|
|
824
|
+
return {
|
|
825
|
+
publicKey: new Uint8Array(await subtle.exportKey("spki", ecdsaPub)),
|
|
826
|
+
secretKey: Uint8Array.from(secretKey),
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
async function importAesKey(key, usages) {
|
|
830
|
+
return getSubtleCrypto().importKey("raw", toBufferSource(key), { name: "AES-GCM" }, false, usages);
|
|
831
|
+
}
|
|
832
|
+
async function importEcdhPrivateKey(secretKey) {
|
|
833
|
+
return getSubtleCrypto().importKey("pkcs8", toBufferSource(secretKey), { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
834
|
+
}
|
|
835
|
+
async function importEcdhPublicKey(publicKey) {
|
|
836
|
+
return getSubtleCrypto().importKey("raw", toBufferSource(publicKey), { name: "ECDH", namedCurve: "P-256" }, true, []);
|
|
837
|
+
}
|
|
838
|
+
async function importEcdsaPrivateKey(secretKey) {
|
|
839
|
+
return getSubtleCrypto().importKey("pkcs8", toBufferSource(secretKey), { name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]);
|
|
840
|
+
}
|
|
841
|
+
async function importEcdsaPublicKey(publicKey) {
|
|
842
|
+
return getSubtleCrypto().importKey("spki", toBufferSource(publicKey), { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]);
|
|
370
843
|
}
|
|
371
844
|
/**
|
|
372
845
|
* @internal
|