From 721b918296d2b7653149a566fe79b91c343d43c6 Mon Sep 17 00:00:00 2001 From: larabr <7375870+larabr@users.noreply.github.com> Date: Fri, 25 Jul 2025 18:54:25 +0200 Subject: [PATCH 1/2] Key validation: use WebCrypto API when available for curve25519 For Ed25519/Ed25519Legacy native validation code does a sign-verify check over random data. This is faster than re-deriving the public point using tweetnacl. If the native implementation is not available, we fall back to re-deriving the public point only. For X25519/Curve25519Legacy, both the native and fallback flows do an ecdh exchange; in the fallback case, this results in slower performance compared to the existing check, but encryption subkeys are hardly ever validated directly (only in case of gnu-dummy keys), and this solution keeps the code simpler. Separately, all validation tests have been updated to use valid params from a different key, rather than corrupted parameters. --- src/crypto/public_key/elliptic/ecdh_x.js | 21 +++++---- src/crypto/public_key/elliptic/eddsa.js | 39 +++++++++++----- .../public_key/elliptic/eddsa_legacy.js | 16 +++---- src/crypto/public_key/elliptic/oid_curves.js | 15 +++---- test/crypto/validate.js | 44 +++++++++++-------- 5 files changed, 79 insertions(+), 56 deletions(-) diff --git a/src/crypto/public_key/elliptic/ecdh_x.js b/src/crypto/public_key/elliptic/ecdh_x.js index 43ce91b2..1dac58b9 100644 --- a/src/crypto/public_key/elliptic/ecdh_x.js +++ b/src/crypto/public_key/elliptic/ecdh_x.js @@ -82,14 +82,19 @@ export async function generate(algo) { */ export async function validateParams(algo, A, k) { switch (algo) { - case enums.publicKey.x25519: { - /** - * Derive public point A' from private key - * and expect A == A' - */ - const { publicKey } = x25519.box.keyPair.fromSecretKey(k); - return util.equalsUint8Array(A, publicKey); - } + case enums.publicKey.x25519: + // Validation is typically not run for ECDH, since encryption subkeys are only validated + // for gnu-dummy keys. + // So, for simplicity, we do an encrypt-decrypt round even if WebCrypto support is not available + try { + const { ephemeralPublicKey, sharedSecret } = await generateEphemeralEncryptionMaterial(algo, A); + const recomputedSharedSecret = await recomputeSharedSecret(algo, ephemeralPublicKey, A, k); + + return util.equalsUint8Array(sharedSecret, recomputedSharedSecret); + } catch (_) { + return false; + } + case enums.publicKey.x448: { const x448 = await util.getNobleCurve(enums.publicKey.x448); /** diff --git a/src/crypto/public_key/elliptic/eddsa.js b/src/crypto/public_key/elliptic/eddsa.js index 86d9f17e..7afdde85 100644 --- a/src/crypto/public_key/elliptic/eddsa.js +++ b/src/crypto/public_key/elliptic/eddsa.js @@ -23,7 +23,7 @@ import ed25519 from '@openpgp/tweetnacl'; import util from '../../../util'; import enums from '../../../enums'; -import { getHashByteLength } from '../../hash'; +import { computeDigest, getHashByteLength } from '../../hash'; import { getRandomBytes } from '../../random'; import { b64ToUint8Array, uint8ArrayToB64 } from '../../../encoding/base64'; @@ -179,15 +179,34 @@ export async function verify(algo, hashAlgo, { RS }, m, publicKey, hashed) { */ export async function validateParams(algo, A, seed) { switch (algo) { - case enums.publicKey.ed25519: { - /** - * Derive public point A' from private key - * and expect A == A' - * TODO: move to sign-verify using WebCrypto (same as ECDSA) when curve is more widely implemented - */ - const { publicKey } = ed25519.sign.keyPair.fromSeed(seed); - return util.equalsUint8Array(A, publicKey); - } + case enums.publicKey.ed25519: + // If webcrypto support is available, we sign-verify random data, as the import-export + // functions might not implement validity checks. + // If we need to fallback to JS, we instead only re-derive the public key, + // as this is much faster than sign-verify. + try { + const webCrypto = util.getWebCrypto(); + const jwkPrivate = privateKeyToJWK(algo, A, seed); + const jwkPublic = publicKeyToJWK(algo, A); + + const privateCryptoKey = await webCrypto.importKey('jwk', jwkPrivate, 'Ed25519', false, ['sign']); + const publicCryptoKey = await webCrypto.importKey('jwk', jwkPublic, 'Ed25519', false, ['verify']); + + const randomData = getRandomBytes(8); + const signature = new Uint8Array( + await webCrypto.sign('Ed25519', privateCryptoKey, randomData) + ); + + const verified = await webCrypto.verify('Ed25519', publicCryptoKey, signature, randomData); + return verified; + } catch (err) { + if (err.name !== 'NotSupportedError') { + return false; + } + + const { publicKey } = ed25519.sign.keyPair.fromSeed(seed); + return util.equalsUint8Array(A, publicKey); + } case enums.publicKey.ed448: { const ed448 = await util.getNobleCurve(enums.publicKey.ed448); diff --git a/src/crypto/public_key/elliptic/eddsa_legacy.js b/src/crypto/public_key/elliptic/eddsa_legacy.js index 682f53bb..9ab45da8 100644 --- a/src/crypto/public_key/elliptic/eddsa_legacy.js +++ b/src/crypto/public_key/elliptic/eddsa_legacy.js @@ -21,12 +21,11 @@ * @module crypto/public_key/elliptic/eddsa_legacy */ -import nacl from '@openpgp/tweetnacl'; import util from '../../../util'; import enums from '../../../enums'; import { getHashByteLength } from '../../hash'; import { CurveWithOID, checkPublicPointEnconding } from './oid_curves'; -import { sign as eddsaSign, verify as eddsaVerify } from './eddsa'; +import { sign as eddsaSign, verify as eddsaVerify, validateParams as eddsaValidateParams } from './eddsa'; /** * Sign a message using the provided legacy EdDSA key @@ -97,12 +96,9 @@ export async function validateParams(oid, Q, k) { return false; } - /** - * Derive public point Q' = dG from private key - * and expect Q == Q' - */ - const { publicKey } = nacl.sign.keyPair.fromSeed(k); - const dG = new Uint8Array([0x40, ...publicKey]); // Add public key prefix - return util.equalsUint8Array(Q, dG); - + // First byte is relevant for encoding purposes only + if (Q.length < 1 || Q[0] !== 0x40) { + return false; + } + return eddsaValidateParams(enums.publicKey.ed25519, Q.subarray(1), k); } diff --git a/src/crypto/public_key/elliptic/oid_curves.js b/src/crypto/public_key/elliptic/oid_curves.js index 7404cbe6..975dcd8e 100644 --- a/src/crypto/public_key/elliptic/oid_curves.js +++ b/src/crypto/public_key/elliptic/oid_curves.js @@ -26,7 +26,7 @@ import { uint8ArrayToB64, b64ToUint8Array } from '../../../encoding/base64'; import OID from '../../../type/oid'; import { UnsupportedError } from '../../../packet/packet'; import { generate as eddsaGenerate } from './eddsa'; -import { generate as ecdhXGenerate } from './ecdh_x'; +import { generate as ecdhXGenerate, validateParams as ecdhXValidateParams } from './ecdh_x'; const webCrypto = util.getWebCrypto(); const nodeCrypto = util.getNodeCrypto(); @@ -252,17 +252,12 @@ async function validateStandardParams(algo, oid, Q, d) { } if (curveName === enums.curve.curve25519Legacy) { - d = d.slice().reverse(); - // Re-derive public point Q' - const { publicKey } = nacl.box.keyPair.fromSecretKey(d); - - Q = new Uint8Array(Q); - const dG = new Uint8Array([0x40, ...publicKey]); // Add public key prefix - if (!util.equalsUint8Array(dG, Q)) { + const dLittleEndian = d.slice().reverse(); + // First byte is relevant for encoding purposes only + if (Q.length < 1 || Q[0] !== 0x40) { return false; } - - return true; + return ecdhXValidateParams(enums.publicKey.x25519, Q.subarray(1), dLittleEndian); } const nobleCurve = await util.getNobleCurve(enums.publicKey.ecdsa, curveName); // excluding curve25519Legacy, ecdh and ecdsa use the same curves diff --git a/test/crypto/validate.js b/test/crypto/validate.js index 8ed63089..7fd74124 100644 --- a/test/crypto/validate.js +++ b/test/crypto/validate.js @@ -90,8 +90,10 @@ async function generatePrivateKeyObject(options) { export default () => { describe('EdDSA parameter validation (legacy format)', function() { let eddsaKey; + let anotherEddsaKey; before(async () => { eddsaKey = await generatePrivateKeyObject({ curve: 'ed25519Legacy' }); + anotherEddsaKey = await generatePrivateKeyObject({ curve: 'ed25519Legacy' }); }); it('EdDSA params should be valid', async function() { @@ -100,11 +102,10 @@ export default () => { it('detect invalid edDSA Q', async function() { const eddsaKeyPacket = await cloneKeyPacket(eddsaKey); - const Q = eddsaKeyPacket.publicParams.Q; - Q[0]++; + eddsaKeyPacket.publicParams.Q = anotherEddsaKey.keyPacket.publicParams.Q; await expect(eddsaKeyPacket.validate()).to.be.rejectedWith('Key is invalid'); - const infQ = new Uint8Array(Q.length); + const infQ = new Uint8Array(eddsaKeyPacket.publicParams.Q.length); eddsaKeyPacket.publicParams.Q = infQ; await expect(eddsaKeyPacket.validate()).to.be.rejectedWith('Key is invalid'); }); @@ -198,13 +199,19 @@ export default () => { describe(`ECC ${curve} parameter validation`, () => { let ecdsaKey; let ecdhKey; + let anotherEcdsaKey; + let anotherEcdhKey; before(async () => { if (curve !== 'curve25519Legacy') { ecdsaKey = await generatePrivateKeyObject({ curve }); ecdhKey = ecdsaKey.subkeys[0]; + anotherEcdsaKey = await generatePrivateKeyObject({ curve }); + anotherEcdhKey = anotherEcdsaKey.subkeys[0]; } else { const eddsaKey = await generatePrivateKeyObject({ curve: 'ed25519Legacy' }); ecdhKey = eddsaKey.subkeys[0]; + const anotherEddsaKey = await generatePrivateKeyObject({ curve: 'ed25519Legacy' }); + anotherEcdhKey = anotherEddsaKey.subkeys[0]; } }); @@ -220,10 +227,9 @@ export default () => { this.skip(); } const keyPacket = await cloneKeyPacket(ecdsaKey); - const Q = keyPacket.publicParams.Q; - Q[16]++; + keyPacket.publicParams.Q = anotherEcdsaKey.keyPacket.publicParams.Q; await expect(keyPacket.validate()).to.be.rejectedWith('Key is invalid'); - const infQ = new Uint8Array(Q.length); + const infQ = new Uint8Array(anotherEcdsaKey.keyPacket.publicParams.Q.length); infQ[0] = 4; keyPacket.publicParams.Q = infQ; await expect(keyPacket.validate()).to.be.rejectedWith('Key is invalid'); @@ -235,11 +241,10 @@ export default () => { it(`ECDH ${curve} - detect invalid Q`, async function() { const keyPacket = await cloneKeyPacket(ecdhKey); - const Q = keyPacket.publicParams.Q; - Q[16]++; + keyPacket.publicParams.Q = anotherEcdhKey.keyPacket.publicParams.Q; await expect(keyPacket.validate()).to.be.rejectedWith('Key is invalid'); - const infQ = new Uint8Array(Q.length); + const infQ = new Uint8Array(keyPacket.publicParams.Q.length); keyPacket.publicParams.Q = infQ; infQ[0] = 4; await expect(keyPacket.validate()).to.be.rejectedWith('Key is invalid'); @@ -252,9 +257,13 @@ export default () => { describe(`Ed${curveID}/X${curveID} parameter validation`, function() { let eddsaKey; let ecdhXKey; + let anotherEddsaKey; + let anotherEcdhXKey; before(async () => { eddsaKey = await generatePrivateKeyObject({ type: `curve${curveID}` }); ecdhXKey = eddsaKey.subkeys[0]; + anotherEddsaKey = await generatePrivateKeyObject({ type: `curve${curveID}` }); + anotherEcdhXKey = anotherEddsaKey.subkeys[0]; }); it(`Ed${curveID} params should be valid`, async function() { @@ -263,11 +272,10 @@ export default () => { it(`detect invalid Ed${curveID} public point`, async function() { const eddsaKeyPacket = await cloneKeyPacket(eddsaKey); - const A = eddsaKeyPacket.publicParams.A; - A[0]++; + eddsaKeyPacket.publicParams.A = anotherEddsaKey.keyPacket.publicParams.A; await expect(eddsaKeyPacket.validate()).to.be.rejectedWith('Key is invalid'); - const infA = new Uint8Array(A.length); + const infA = new Uint8Array(eddsaKeyPacket.publicParams.A.length); eddsaKeyPacket.publicParams.A = infA; await expect(eddsaKeyPacket.validate()).to.be.rejectedWith('Key is invalid'); }); @@ -278,11 +286,10 @@ export default () => { it(`detect invalid X${curveID} public point`, async function() { const ecdhXKeyPacket = await cloneKeyPacket(ecdhXKey); - const A = ecdhXKeyPacket.publicParams.A; - A[0]++; + ecdhXKeyPacket.publicParams.A = anotherEcdhXKey.keyPacket.publicParams.A; await expect(ecdhXKeyPacket.validate()).to.be.rejectedWith('Key is invalid'); - const infA = new Uint8Array(A.length); + const infA = new Uint8Array(ecdhXKeyPacket.publicParams.A.length); ecdhXKeyPacket.publicParams.A = infA; await expect(ecdhXKeyPacket.validate()).to.be.rejectedWith('Key is invalid'); }); @@ -291,8 +298,10 @@ export default () => { describe('RSA parameter validation', function() { let rsaKey; + let anotherRsaKey; before(async () => { rsaKey = await generatePrivateKeyObject({ type: 'rsa', rsaBits: 2048 }); + anotherRsaKey = await generatePrivateKeyObject({ type: 'rsa', rsaBits: 2048 }); }); it('generated RSA params are valid', async function() { @@ -301,15 +310,14 @@ export default () => { it('detect invalid RSA n', async function() { const keyPacket = await cloneKeyPacket(rsaKey); - const n = keyPacket.publicParams.n; - n[0]++; + keyPacket.publicParams.n = anotherRsaKey.keyPacket.publicParams.n; await expect(keyPacket.validate()).to.be.rejectedWith('Key is invalid'); }); it('detect invalid RSA e', async function() { const keyPacket = await cloneKeyPacket(rsaKey); const e = keyPacket.publicParams.e; - e[0]++; + e[0]++; // e is hard-coded so we don't take it from `anotherRsaKey` await expect(keyPacket.validate()).to.be.rejectedWith('Key is invalid'); }); }); From ed5554e114643995b47775e0a1b416283029a9d2 Mon Sep 17 00:00:00 2001 From: larabr <7375870+larabr@users.noreply.github.com> Date: Mon, 28 Jul 2025 15:12:55 +0200 Subject: [PATCH 2/2] 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.) --- src/crypto/hkdf.js | 3 +- src/crypto/public_key/elliptic/ecdh.js | 7 +- src/crypto/public_key/elliptic/ecdh_x.js | 11 +- src/crypto/public_key/elliptic/eddsa.js | 9 +- src/crypto/public_key/elliptic/oid_curves.js | 1 - test/crypto/ecdh.js | 73 +++++++-- test/crypto/{elliptic.js => ecdsa_eddsa.js} | 158 +++++++++++++++++-- test/crypto/index.js | 2 +- 8 files changed, 220 insertions(+), 44 deletions(-) rename test/crypto/{elliptic.js => ecdsa_eddsa.js} (59%) diff --git a/src/crypto/hkdf.js b/src/crypto/hkdf.js index 361703b1..69a85ae7 100644 --- a/src/crypto/hkdf.js +++ b/src/crypto/hkdf.js @@ -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'); diff --git a/src/crypto/public_key/elliptic/ecdh.js b/src/crypto/public_key/elliptic/ecdh.js index 66937b2c..4b3d6606 100644 --- a/src/crypto/public_key/elliptic/ecdh.js +++ b/src/crypto/public_key/elliptic/ecdh.js @@ -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)); diff --git a/src/crypto/public_key/elliptic/ecdh_x.js b/src/crypto/public_key/elliptic/ecdh_x.js index 1dac58b9..ba959380 100644 --- a/src/crypto/public_key/elliptic/ecdh_x.js +++ b/src/crypto/public_key/elliptic/ecdh_x.js @@ -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; diff --git a/src/crypto/public_key/elliptic/eddsa.js b/src/crypto/public_key/elliptic/eddsa.js index 7afdde85..d94924b8 100644 --- a/src/crypto/public_key/elliptic/eddsa.js +++ b/src/crypto/public_key/elliptic/eddsa.js @@ -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); } diff --git a/src/crypto/public_key/elliptic/oid_curves.js b/src/crypto/public_key/elliptic/oid_curves.js index 975dcd8e..38edb609 100644 --- a/src/crypto/public_key/elliptic/oid_curves.js +++ b/src/crypto/public_key/elliptic/oid_curves.js @@ -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'; diff --git a/test/crypto/ecdh.js b/test/crypto/ecdh.js index 0e73cdcb..de17e9a8 100644 --- a/test/crypto/ecdh.js +++ b/test/crypto/ecdh.js @@ -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) => ` + * @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 + ); + }); }); }); diff --git a/test/crypto/elliptic.js b/test/crypto/ecdsa_eddsa.js similarity index 59% rename from test/crypto/elliptic.js rename to test/crypto/ecdsa_eddsa.js index 7f98cd3e..5b10621f 100644 --- a/test/crypto/elliptic.js +++ b/test/crypto/ecdsa_eddsa.js @@ -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) => ` + * @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) + ); })); }); }); diff --git a/test/crypto/index.js b/test/crypto/index.js index a4c73ae5..5e7567ff 100644 --- a/test/crypto/index.js +++ b/test/crypto/index.js @@ -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';