Lightweight build: lazy load tweetnacl dependency module (curve25519 JS fallback)

Since all major browsers have shipped support for the curve
in WebCrypto, we only load the JS fallback if needed.

Also, add native/non-native ECDH test for Curve25519Legacy.
(The more modern X25519/X448 algo implementations cannot be
tested that way since they include an HKDF step for which
we assume native support and do not implement a fallback.)
This commit is contained in:
larabr 2025-07-28 15:12:55 +02:00
parent 721b918296
commit ed5554e114
No known key found for this signature in database
GPG Key ID: 2A4BEC40729185DD
8 changed files with 220 additions and 44 deletions

View File

@ -6,9 +6,8 @@
import enums from '../enums';
import util from '../util';
const webCrypto = util.getWebCrypto();
export default async function computeHKDF(hashAlgo, inputKey, salt, info, outLen) {
const webCrypto = util.getWebCrypto();
const hash = enums.read(enums.webHash, hashAlgo);
if (!hash) throw new Error('Hash algo not supported with HKDF');

View File

@ -30,9 +30,6 @@ import * as pkcs5 from '../../pkcs5';
import { getCipherParams } from '../../cipher';
import { generateEphemeralEncryptionMaterial as ecdhXGenerateEphemeralEncryptionMaterial, recomputeSharedSecret as ecdhXRecomputeSharedSecret } from './ecdh_x';
const webCrypto = util.getWebCrypto();
const nodeCrypto = util.getNodeCrypto();
/**
* Validate ECDH parameters
* @param {module:type/oid} oid - Elliptic curve object identifier
@ -238,6 +235,7 @@ async function jsPublicEphemeralKey(curve, Q) {
* @async
*/
async function webPrivateEphemeralKey(curve, V, Q, d) {
const webCrypto = util.getWebCrypto();
const recipient = privateToJWK(curve.payloadSize, curve.web, Q, d);
let privateKey = webCrypto.importKey(
'jwk',
@ -289,6 +287,7 @@ async function webPrivateEphemeralKey(curve, V, Q, d) {
* @async
*/
async function webPublicEphemeralKey(curve, Q) {
const webCrypto = util.getWebCrypto();
const jwk = rawPublicToJWK(curve.payloadSize, curve.web, Q);
let keyPair = webCrypto.generateKey(
{
@ -338,6 +337,7 @@ async function webPublicEphemeralKey(curve, Q) {
* @async
*/
async function nodePrivateEphemeralKey(curve, V, d) {
const nodeCrypto = util.getNodeCrypto();
const recipient = nodeCrypto.createECDH(curve.node);
recipient.setPrivateKey(d);
const sharedKey = new Uint8Array(recipient.computeSecret(V));
@ -354,6 +354,7 @@ async function nodePrivateEphemeralKey(curve, V, d) {
* @async
*/
async function nodePublicEphemeralKey(curve, Q) {
const nodeCrypto = util.getNodeCrypto();
const sender = nodeCrypto.createECDH(curve.node);
sender.generateKeys();
const sharedKey = new Uint8Array(sender.computeSecret(Q));

View File

@ -3,9 +3,7 @@
* @module crypto/public_key/elliptic/ecdh
*/
import x25519 from '@openpgp/tweetnacl';
import * as aesKW from '../../aes_kw';
import { getRandomBytes } from '../../random';
import enums from '../../../enums';
import util from '../../../util';
@ -55,9 +53,9 @@ export async function generate(algo) {
if (err.name !== 'NotSupportedError') {
throw err;
}
const { default: x25519 } = await import('@openpgp/tweetnacl');
// k stays in little-endian, unlike legacy ECDH over curve25519
const k = getRandomBytes(32);
const { publicKey: A } = x25519.box.keyPair.fromSecretKey(k);
const { secretKey: k, publicKey: A } = x25519.box.keyPair();
return { A, k };
}
@ -240,10 +238,10 @@ export async function generateEphemeralEncryptionMaterial(algo, recipientA) {
if (err.name !== 'NotSupportedError') {
throw err;
}
const ephemeralSecretKey = getRandomBytes(getPayloadSize(algo));
const { default: x25519 } = await import('@openpgp/tweetnacl');
const { secretKey: ephemeralSecretKey, publicKey: ephemeralPublicKey } = x25519.box.keyPair();
const sharedSecret = x25519.scalarMult(ephemeralSecretKey, recipientA);
assertNonZeroArray(sharedSecret);
const { publicKey: ephemeralPublicKey } = x25519.box.keyPair.fromSecretKey(ephemeralSecretKey);
return { ephemeralPublicKey, sharedSecret };
}
case enums.publicKey.x448: {
@ -278,6 +276,7 @@ export async function recomputeSharedSecret(algo, ephemeralPublicKey, A, k) {
if (err.name !== 'NotSupportedError') {
throw err;
}
const { default: x25519 } = await import('@openpgp/tweetnacl');
const sharedSecret = x25519.scalarMult(k, ephemeralPublicKey);
assertNonZeroArray(sharedSecret);
return sharedSecret;

View File

@ -20,10 +20,9 @@
* @module crypto/public_key/elliptic/eddsa
*/
import ed25519 from '@openpgp/tweetnacl';
import util from '../../../util';
import enums from '../../../enums';
import { computeDigest, getHashByteLength } from '../../hash';
import { getHashByteLength } from '../../hash';
import { getRandomBytes } from '../../random';
import { b64ToUint8Array, uint8ArrayToB64 } from '../../../encoding/base64';
@ -59,7 +58,9 @@ export async function generate(algo) {
if (err.name !== 'NotSupportedError') {
throw err;
}
const { default: ed25519 } = await import('@openpgp/tweetnacl');
const seed = getRandomBytes(getPayloadSize(algo));
// not using `ed25519.sign.keyPair` since it returns the expanded secret, so using `fromSeed` instead is more straightforward
const { publicKey: A } = ed25519.sign.keyPair.fromSeed(seed);
return { A, seed };
}
@ -111,6 +112,7 @@ export async function sign(algo, hashAlgo, message, publicKey, privateKey, hashe
if (err.name !== 'NotSupportedError') {
throw err;
}
const { default: ed25519 } = await import('@openpgp/tweetnacl');
const secretKey = util.concatUint8Array([privateKey, publicKey]);
const signature = ed25519.sign.detached(hashed, secretKey);
return { RS: signature };
@ -157,6 +159,7 @@ export async function verify(algo, hashAlgo, { RS }, m, publicKey, hashed) {
if (err.name !== 'NotSupportedError') {
throw err;
}
const { default: ed25519 } = await import('@openpgp/tweetnacl');
return ed25519.sign.detached.verify(hashed, RS, publicKey);
}
@ -203,7 +206,7 @@ export async function validateParams(algo, A, seed) {
if (err.name !== 'NotSupportedError') {
return false;
}
const { default: ed25519 } = await import('@openpgp/tweetnacl');
const { publicKey } = ed25519.sign.keyPair.fromSeed(seed);
return util.equalsUint8Array(A, publicKey);
}

View File

@ -19,7 +19,6 @@
* @fileoverview Wrapper of an instance of an Elliptic Curve
* @module crypto/public_key/elliptic/curve
*/
import nacl from '@openpgp/tweetnacl';
import enums from '../../../enums';
import util from '../../../util';
import { uint8ArrayToB64, b64ToUint8Array } from '../../../encoding/base64';

View File

@ -323,7 +323,10 @@ export default () => describe('ECDH key exchange @lightweight', function () {
const disableNative = () => {
enableNative();
// stubbed functions return undefined
getWebCryptoStub = sinonSandbox.stub(util, 'getWebCrypto');
getWebCryptoStub = sinonSandbox.stub(util, 'getWebCrypto').returns({
generateKey: () => { const e = new Error('getWebCrypto is mocked'); e.name = 'NotSupportedError'; throw e; },
importKey: () => { const e = new Error('getWebCrypto is mocked'); e.name = 'NotSupportedError'; throw e; }
});
getNodeCryptoStub = sinonSandbox.stub(util, 'getNodeCrypto');
};
const enableNative = () => {
@ -331,6 +334,46 @@ export default () => describe('ECDH key exchange @lightweight', function () {
getNodeCryptoStub && getNodeCryptoStub.restore();
};
/**
* Test that the result of `encryptFunction` can be decrypted by `decryptFunction`
* with and without native crypto support.
* @param encryptFunction - `(data: Uint8Array) => encryptFunctionResult`
* @param decryptFunction - `(encryptFunctionResult) => <decryption result>`
* @param expectNative - whether native usage is expected for the algorithm
*/
const testRountripWithAndWithoutNative = async (
encryptFunction,
decryptFunction, // (encryptFunctionResult) => decryption result
expectNative
) => {
const nodeCrypto = util.getNodeCrypto();
const webCrypto = util.getWebCrypto();
const data = random.getRandomBytes(16);
const nativeSpy = webCrypto ? sinonSandbox.spy(webCrypto, 'deriveBits') : sinonSandbox.spy(nodeCrypto, 'createECDH'); // functions used both for encryption and decryption
const nativeResult = await encryptFunction(data);
const expectedNativeEncryptCallCount = nativeSpy.callCount;
disableNative();
const nonNativeResult = await encryptFunction(data);
expect(nativeSpy.callCount).to.equal(expectedNativeEncryptCallCount); // assert that fallback implementation was called
if (expectNative) {
expect(nativeSpy.calledOnce).to.be.true;
}
enableNative();
expect(await decryptFunction(nativeResult)).to.deep.equal(data);
expect(await decryptFunction(nonNativeResult)).to.deep.equal(data);
const expectedNativeCallCount = nativeSpy.callCount;
disableNative();
expect(await decryptFunction(nativeResult)).to.deep.equal(data);
expect(await decryptFunction(nonNativeResult)).to.deep.equal(data);
expect(nativeSpy.callCount).to.equal(expectedNativeCallCount); // assert that fallback implementation was called
if (expectNative) {
expect(nativeSpy.callCount).to.equal(3); // one encryption + two decryptions
}
};
allCurves.forEach(curveName => {
it(`${curveName}`, async function () {
const nodeCrypto = util.getNodeCrypto();
@ -344,21 +387,27 @@ export default () => describe('ECDH key exchange @lightweight', function () {
const curve = new elliptic_curves.CurveWithOID(curveName);
const oid = new OID(curve.oid);
const kdfParams = new KDFParams({ hash: curve.hash, cipher: curve.cipher });
const data = random.getRandomBytes(16);
const Q = key_data[curveName].pub;
const d = key_data[curveName].priv;
const { publicKey: V, wrappedKey: C } = await ecdh.encrypt(oid, kdfParams, data, Q, fingerprint1);
const nativeDecryptSpy = webCrypto ? sinonSandbox.spy(webCrypto, 'deriveBits') : sinonSandbox.spy(nodeCrypto, 'createECDH');
expect(await ecdh.decrypt(oid, kdfParams, V, C, Q, d, fingerprint1)).to.deep.equal(data);
const expectedNativeCallCount = nativeDecryptSpy.callCount;
disableNative();
expect(await ecdh.decrypt(oid, kdfParams, V, C, Q, d, fingerprint1)).to.deep.equal(data);
expect(nativeDecryptSpy.callCount).to.equal(expectedNativeCallCount); // assert that fallback implementation was called
if (expectNativeWeb.has(curveName)) {
expect(nativeDecryptSpy.calledOnce).to.be.true;
}
});
await testRountripWithAndWithoutNative(
data => ecdh.encrypt(oid, kdfParams, data, Q, fingerprint1),
encryptResult => ecdh.decrypt(oid, kdfParams, encryptResult.publicKey, encryptResult.wrappedKey, Q, d, fingerprint1),
expectNativeWeb.has(curveName) // all major browsers implement x25519
);
});
});
it('Successful exchange x25519 (legacy)', async function () {
const curve = new elliptic_curves.CurveWithOID(openpgp.enums.curve.curve25519Legacy);
const oid = new OID(curve.oid);
const kdfParams = new KDFParams({ hash: curve.hash, cipher: curve.cipher });
await testRountripWithAndWithoutNative(
data => ecdh.encrypt(oid, kdfParams, data, Q1, fingerprint1),
encryptResult => ecdh.decrypt(oid, kdfParams, encryptResult.publicKey, encryptResult.wrappedKey, Q1, d1, fingerprint1),
false // all major browsers implement x25519, but webkit linux falls back due to bugs
);
});
});
});

View File

@ -4,17 +4,59 @@ import chaiAsPromised from 'chai-as-promised'; // eslint-disable-line import/new
chaiUse(chaiAsPromised);
import openpgp from '../initOpenpgp.js';
import * as elliptic_curves from '../../src/crypto/public_key/elliptic';
import { computeDigest } from '../../src/crypto/hash';
import config from '../../src/config';
import * as elliptic_curves from '../../src/crypto/public_key/elliptic/index.js';
import { computeDigest } from '../../src/crypto/hash/index.js';
import config from '../../src/config/index.js';
import util from '../../src/util.js';
import elliptic_data from './elliptic_data';
import elliptic_data from './elliptic_data.js';
import OID from '../../src/type/oid.js';
import { getRandomBytes } from '../../src/crypto/random.js';
/**
* Test that the result of `signFunction` can be verified by `verifyFunction`
* with and without native crypto support.
* @param signFunction - `(data: Uint8Array) => signFunctionResult`
* @param verifyFunction - `(encryptFunctionResult) => <decryption result>`
* @param expectNative - whether native usage is expected for the algorithm
*/
const testRountripWithAndWithoutNative = async (
{ sinonSandbox, enableNative, disableNative },
signFunction,
verifyFunction, // (signFunctionResult) => verification result
expectNative
) => {
const nodeCrypto = util.getNodeCrypto();
const webCrypto = util.getWebCrypto();
const data = getRandomBytes(16);
const dataDigest = await computeDigest(openpgp.enums.hash.sha512, data);
const nativeSpySign = webCrypto ? sinonSandbox.spy(webCrypto, 'sign') : sinonSandbox.spy(nodeCrypto, 'createSign');
const nativeResult = await signFunction(data, dataDigest);
const expectedNativeSignCallCount = nativeSpySign.callCount;
disableNative();
const nonNativeResult = await signFunction(data, dataDigest);
expect(nativeSpySign.callCount).to.equal(expectedNativeSignCallCount); // assert that fallback implementation was called
if (expectNative) {
expect(nativeSpySign.calledOnce).to.be.true;
}
const nativeSpyVerify = webCrypto ? sinonSandbox.spy(webCrypto, 'verify') : sinonSandbox.spy(nodeCrypto, 'createVerify');
enableNative();
expect(await verifyFunction(nativeResult, data, dataDigest)).to.be.true;
expect(await verifyFunction(nonNativeResult, data, dataDigest)).to.be.true;
const expectedNativeVerifyCallCount = nativeSpyVerify.callCount;
disableNative();
expect(await verifyFunction(nativeResult, data, dataDigest)).to.be.true;
expect(await verifyFunction(nonNativeResult, data, dataDigest)).be.true;
expect(nativeSpyVerify.callCount).to.equal(expectedNativeVerifyCallCount); // assert that fallback implementation was called
if (expectNative) {
expect(nativeSpyVerify.callCount).to.equal(2);
}
};
const key_data = elliptic_data.key_data;
/* eslint-disable no-invalid-this */
export default () => describe('Elliptic Curve Cryptography @lightweight', function () {
export default () => describe('ECC signatures', function () {
const signature_data = {
priv: new Uint8Array([
0x14, 0x2B, 0xE2, 0xB7, 0x4D, 0xBD, 0x1B, 0x22,
@ -241,19 +283,103 @@ export default () => describe('Elliptic Curve Cryptography @lightweight', functi
);
});
const curves = ['secp256k1' , 'nistP256', 'nistP384', 'nistP521', 'brainpoolP256r1', 'brainpoolP384r1', 'brainpoolP512r1'];
curves.forEach(curveName => it(`${curveName} - Sign and verify message`, async function () {
curves.forEach(curveName => it(`${curveName} - Sign and verify message with generated key`, async function () {
const sinonState = { sinonSandbox, enableNative, disableNative };
const curve = new elliptic_curves.CurveWithOID(curveName);
const oid = new OID(curve.oid);
const { Q: keyPublic, secret: keyPrivate } = await elliptic_curves.generate(curveName);
const message = new Uint8Array([
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF
]);
const messageDigest = await computeDigest(openpgp.enums.hash.sha512, message);
await testNativeAndFallback(async () => {
const signature = await elliptic_curves.ecdsa.sign(oid, openpgp.enums.hash.sha512, message, keyPublic, keyPrivate, messageDigest);
await expect(elliptic_curves.ecdsa.verify(oid, openpgp.enums.hash.sha512, signature, message, keyPublic, messageDigest)).to.eventually.be.true;
const expectNativeWeb = new Set(['nistP256', 'nistP384']); // older versions of safari do not implement nistP521
const nativeKey = await elliptic_curves.generate(curveName);
await testRountripWithAndWithoutNative(
sinonState,
(data, dataDigest) => elliptic_curves.ecdsa.sign(oid, openpgp.enums.hash.sha512, data, nativeKey.Q, nativeKey.secret, dataDigest),
(signature, data, dataDigest) => elliptic_curves.ecdsa.verify(oid, openpgp.enums.hash.sha512, signature, data, nativeKey.Q, dataDigest),
expectNativeWeb.has(curveName)
);
sinonSandbox.restore(); // reset spies
disableNative();
const nonNativeKey = await elliptic_curves.generate(curveName);
enableNative();
await testRountripWithAndWithoutNative(
sinonState,
(data, dataDigest) => elliptic_curves.ecdsa.sign(oid, openpgp.enums.hash.sha512, data, nonNativeKey.Q, nonNativeKey.secret, dataDigest),
(signature, data, dataDigest) => elliptic_curves.ecdsa.verify(oid, openpgp.enums.hash.sha512, signature, data, nonNativeKey.Q, dataDigest),
expectNativeWeb.has(curveName)
);
}));
});
describe('EdDSA signature', function () {
let sinonSandbox;
let getWebCryptoStub;
let getNodeCryptoStub;
beforeEach(function () {
sinonSandbox = sinon.createSandbox();
});
afterEach(function () {
sinonSandbox.restore();
});
const disableNative = () => {
enableNative();
// stubbed functions return undefined
getWebCryptoStub = sinonSandbox.stub(util, 'getWebCrypto').returns({
generateKey: () => { const e = new Error('getWebCrypto is mocked'); e.name = 'NotSupportedError'; throw e; },
importKey: () => { const e = new Error('getWebCrypto is mocked'); e.name = 'NotSupportedError'; throw e; }
});
getNodeCryptoStub = sinonSandbox.stub(util, 'getNodeCrypto');
};
const enableNative = () => {
getWebCryptoStub && getWebCryptoStub.restore();
getNodeCryptoStub && getNodeCryptoStub.restore();
};
it('ed25519Legacy - Sign and verify message with generated key', async function () {
const sinonState = { sinonSandbox, enableNative, disableNative };
const curve = new elliptic_curves.CurveWithOID(openpgp.enums.curve.ed25519Legacy);
const oid = new OID(curve.oid);
const nativeKey = await elliptic_curves.generate(openpgp.enums.curve.ed25519Legacy);
await testRountripWithAndWithoutNative(
sinonState,
(data, dataDigest) => elliptic_curves.eddsaLegacy.sign(oid, openpgp.enums.hash.sha512, data, nativeKey.Q, nativeKey.secret, dataDigest),
(signature, data, dataDigest) => elliptic_curves.eddsaLegacy.verify(oid, openpgp.enums.hash.sha512, signature, data, nativeKey.Q, dataDigest)
);
sinonSandbox.restore(); // reset spies
disableNative();
const nonNativeKey = await elliptic_curves.generate(openpgp.enums.curve.ed25519Legacy);
enableNative();
await testRountripWithAndWithoutNative(
sinonState,
(data, dataDigest) => elliptic_curves.eddsaLegacy.sign(oid, openpgp.enums.hash.sha512, data, nonNativeKey.Q, nonNativeKey.secret, dataDigest),
(signature, data, dataDigest) => elliptic_curves.eddsaLegacy.verify(oid, openpgp.enums.hash.sha512, signature, data, nonNativeKey.Q, dataDigest)
);
});
['ed25519', 'ed448'].forEach(algoName => it(`${algoName} - Sign and verify message with native generated key`, async function () {
const sinonState = { sinonSandbox, enableNative, disableNative };
const algo = openpgp.enums.publicKey[algoName];
const nativeKey = await elliptic_curves.eddsa.generate(algo);
await testRountripWithAndWithoutNative(
sinonState,
(data, dataDigest) => elliptic_curves.eddsa.sign(algo, openpgp.enums.hash.sha512, data, nativeKey.A, nativeKey.seed, dataDigest),
(signature, data, dataDigest) => elliptic_curves.eddsa.verify(algo, openpgp.enums.hash.sha512, signature, data, nativeKey.A, dataDigest)
);
sinonSandbox.restore(); // reset spies
disableNative();
const nonNativeKey = await elliptic_curves.eddsa.generate(algo);
enableNative();
await testRountripWithAndWithoutNative(
sinonState,
(data, dataDigest) => elliptic_curves.eddsa.sign(algo, openpgp.enums.hash.sha512, data, nonNativeKey.A, nonNativeKey.seed, dataDigest),
(signature, data, dataDigest) => elliptic_curves.eddsa.verify(algo, openpgp.enums.hash.sha512, signature, data, nonNativeKey.A, dataDigest)
);
}));
});
});

View File

@ -2,7 +2,7 @@ import testBigInteger from './biginteger';
import testCipher from './cipher';
import testHash from './hash';
import testCrypto from './crypto';
import testElliptic from './elliptic';
import testElliptic from './ecdsa_eddsa';
import testBrainpoolRFC7027 from './brainpool_rfc7027';
import testECDH from './ecdh';
import testPKCS5 from './pkcs5';