From d5689894f68bac6efe716859490300961dee85d4 Mon Sep 17 00:00:00 2001 From: larabr <7375870+larabr@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:45:42 +0100 Subject: [PATCH 1/2] Re-enable using WebCrypto for X25519 when available Reverting commit ccb040ae96acd127a29161ffaf3b82b5b18c062f . Firefox has fixed support in v132 (https://bugzilla.mozilla.org/show_bug.cgi?id=1918354) usage of v130 and 131, which have a broken implementation, is now below 1%. Also, Chrome has released support in v133. --- src/crypto/public_key/elliptic/ecdh_x.js | 116 +++++++++++++++++++---- test/crypto/ecdh.js | 4 +- 2 files changed, 100 insertions(+), 20 deletions(-) diff --git a/src/crypto/public_key/elliptic/ecdh_x.js b/src/crypto/public_key/elliptic/ecdh_x.js index b293d4e5..608c990a 100644 --- a/src/crypto/public_key/elliptic/ecdh_x.js +++ b/src/crypto/public_key/elliptic/ecdh_x.js @@ -11,6 +11,7 @@ import enums from '../../../enums'; import util from '../../../util'; import computeHKDF from '../../hkdf'; import { getCipherParams } from '../../cipher'; +import { b64ToUint8Array, uint8ArrayToB64 } from '../../../encoding/base64'; const HKDF_INFO = { x25519: util.encodeUTF8('OpenPGP X25519'), @@ -24,12 +25,27 @@ const HKDF_INFO = { */ export async function generate(algo) { switch (algo) { - case enums.publicKey.x25519: { - // k stays in little-endian, unlike legacy ECDH over curve25519 - const k = getRandomBytes(32); - const { publicKey: A } = x25519.box.keyPair.fromSecretKey(k); - return { A, k }; - } + case enums.publicKey.x25519: + try { + const webCrypto = util.getWebCrypto(); + const webCryptoKey = await webCrypto.generateKey('X25519', true, ['deriveKey', 'deriveBits']); + + const privateKey = await webCrypto.exportKey('jwk', webCryptoKey.privateKey); + const publicKey = await webCrypto.exportKey('jwk', webCryptoKey.publicKey); + + return { + A: new Uint8Array(b64ToUint8Array(publicKey.x)), + k: b64ToUint8Array(privateKey.d) + }; + } catch (err) { + if (err.name !== 'NotSupportedError') { + throw err; + } + // k stays in little-endian, unlike legacy ECDH over curve25519 + const k = getRandomBytes(32); + const { publicKey: A } = x25519.box.keyPair.fromSecretKey(k); + return { A, k }; + } case enums.publicKey.x448: { const x448 = await util.getNobleCurve(enums.publicKey.x448); @@ -171,13 +187,32 @@ export function getPayloadSize(algo) { */ export async function generateEphemeralEncryptionMaterial(algo, recipientA) { switch (algo) { - case enums.publicKey.x25519: { - const ephemeralSecretKey = getRandomBytes(getPayloadSize(algo)); - const sharedSecret = x25519.scalarMult(ephemeralSecretKey, recipientA); - assertNonZeroArray(sharedSecret); - const { publicKey: ephemeralPublicKey } = x25519.box.keyPair.fromSecretKey(ephemeralSecretKey); - return { ephemeralPublicKey, sharedSecret }; - } + case enums.publicKey.x25519: + try { + const webCrypto = util.getWebCrypto(); + const jwk = publicKeyToJWK(algo, recipientA); + const ephemeralKeyPair = await webCrypto.generateKey('X25519', true, ['deriveKey', 'deriveBits']); + const recipientPublicKey = await webCrypto.importKey('jwk', jwk, 'X25519', false, []); + const sharedSecretBuffer = await webCrypto.deriveBits( + { name: 'X25519', public: recipientPublicKey }, + ephemeralKeyPair.privateKey, + getPayloadSize(algo) * 8 // in bits + ); + const ephemeralPublicKeyJwt = await webCrypto.exportKey('jwk', ephemeralKeyPair.publicKey); + return { + sharedSecret: new Uint8Array(sharedSecretBuffer), + ephemeralPublicKey: new Uint8Array(b64ToUint8Array(ephemeralPublicKeyJwt.x)) + }; + } catch (err) { + if (err.name !== 'NotSupportedError') { + throw err; + } + const ephemeralSecretKey = getRandomBytes(getPayloadSize(algo)); + const sharedSecret = x25519.scalarMult(ephemeralSecretKey, recipientA); + assertNonZeroArray(sharedSecret); + const { publicKey: ephemeralPublicKey } = x25519.box.keyPair.fromSecretKey(ephemeralSecretKey); + return { ephemeralPublicKey, sharedSecret }; + } case enums.publicKey.x448: { const x448 = await util.getNobleCurve(enums.publicKey.x448); const ephemeralSecretKey = x448.utils.randomPrivateKey(); @@ -193,11 +228,27 @@ export async function generateEphemeralEncryptionMaterial(algo, recipientA) { export async function recomputeSharedSecret(algo, ephemeralPublicKey, A, k) { switch (algo) { - case enums.publicKey.x25519: { - const sharedSecret = x25519.scalarMult(k, ephemeralPublicKey); - assertNonZeroArray(sharedSecret); - return sharedSecret; - } + case enums.publicKey.x25519: + try { + const webCrypto = util.getWebCrypto(); + const privateKeyJWK = privateKeyToJWK(algo, A, k); + const ephemeralPublicKeyJWK = publicKeyToJWK(algo, ephemeralPublicKey); + const privateKey = await webCrypto.importKey('jwk', privateKeyJWK, 'X25519', false, ['deriveKey', 'deriveBits']); + const ephemeralPublicKeyReference = await webCrypto.importKey('jwk', ephemeralPublicKeyJWK, 'X25519', false, []); + const sharedSecretBuffer = await webCrypto.deriveBits( + { name: 'X25519', public: ephemeralPublicKeyReference }, + privateKey, + getPayloadSize(algo) * 8 // in bits + ); + return new Uint8Array(sharedSecretBuffer); + } catch (err) { + if (err.name !== 'NotSupportedError') { + throw err; + } + const sharedSecret = x25519.scalarMult(k, ephemeralPublicKey); + assertNonZeroArray(sharedSecret); + return sharedSecret; + } case enums.publicKey.x448: { const x448 = await util.getNobleCurve(enums.publicKey.x448); const sharedSecret = x448.getSharedSecret(k, ephemeralPublicKey); @@ -224,3 +275,32 @@ function assertNonZeroArray(sharedSecret) { throw new Error('Unexpected low order point'); } } + + +function publicKeyToJWK(algo, publicKey) { + switch (algo) { + case enums.publicKey.x25519: { + const jwk = { + kty: 'OKP', + crv: 'X25519', + x: uint8ArrayToB64(publicKey, true), + ext: true + }; + return jwk; + } + default: + throw new Error('Unsupported ECDH algorithm'); + } +} + +function privateKeyToJWK(algo, publicKey, privateKey) { + switch (algo) { + case enums.publicKey.x25519: { + const jwk = publicKeyToJWK(algo, publicKey); + jwk.d = uint8ArrayToB64(privateKey, true); + return jwk; + } + default: + throw new Error('Unsupported ECDH algorithm'); + } +} diff --git a/test/crypto/ecdh.js b/test/crypto/ecdh.js index 997538a9..0e73cdcb 100644 --- a/test/crypto/ecdh.js +++ b/test/crypto/ecdh.js @@ -234,9 +234,9 @@ export default () => describe('ECDH key exchange @lightweight', function () { for (const { vector } of vectors) { const lowOrderPoint = util.hexToUint8Array(vector); const { A: K_A, k: a } = await elliptic_curves.ecdhX.generate(openpgp.enums.publicKey.x25519); - await expect(elliptic_curves.ecdhX.encrypt(openpgp.enums.publicKey.x25519, data, lowOrderPoint)).to.be.rejectedWith(/low order point/); + await expect(elliptic_curves.ecdhX.encrypt(openpgp.enums.publicKey.x25519, data, lowOrderPoint)).to.be.rejected; // OperationError, DataError or 'low order point', depending on platform const dummyWrappedKey = new Uint8Array(32); // expected to be unused - await expect(elliptic_curves.ecdhX.decrypt(openpgp.enums.publicKey.x25519, lowOrderPoint, dummyWrappedKey, K_A, a)).to.be.rejectedWith(/low order point/); + await expect(elliptic_curves.ecdhX.decrypt(openpgp.enums.publicKey.x25519, lowOrderPoint, dummyWrappedKey, K_A, a)).to.be.rejected; // OperationError, DataError or 'low order point', depending on platform } }); From 4762d2c7623eccaf297a2bf9f4c7aa957aa32c6f Mon Sep 17 00:00:00 2001 From: larabr <7375870+larabr@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:49:08 +0100 Subject: [PATCH 2/2] CI: do not test Webkit on Linux The tests work correctly in Epiphany, but not in the WebKit build, where the native X25519 implementation throws non-standard errors on importKey (DataError) and generateKey (OperationError). Patching this would be simply a matter of catching such errors and falling back to the JS implementation, but since only the CI WebKit build seems to be affected, we prefer not to relax fallback checks in the context of crypto operations without issues reported in the wild. --- .github/workflows/tests.yml | 1 + test/web-test-runner.config.js | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f467f178..8380ae50 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -106,6 +106,7 @@ jobs: npx playwright install --with-deps firefox - name: Install WebKit # caching not possible, external shared libraries required + if: ${{ matrix.runner == 'macos-latest' }} # do not install on ubuntu, since the X25519 WebCrypto implementation has issues run: npx playwright install --with-deps webkit - name: Run browser tests diff --git a/test/web-test-runner.config.js b/test/web-test-runner.config.js index 45e18856..5fc9c6b2 100644 --- a/test/web-test-runner.config.js +++ b/test/web-test-runner.config.js @@ -1,10 +1,10 @@ -import { playwrightLauncher } from '@web/test-runner-playwright'; +import { existsSync } from 'fs'; +import { playwrightLauncher, playwright } from '@web/test-runner-playwright'; const sharedPlaywrightCIOptions = { // createBrowserContext: ({ browser }) => browser.newContext({ ignoreHTTPSErrors: true }), headless: true }; - export default { nodeResolve: true, // to resolve npm module imports in `unittests.html` files: './test/unittests.html', @@ -29,11 +29,13 @@ export default { ...sharedPlaywrightCIOptions, product: 'firefox' }), - playwrightLauncher({ + // try setting up webkit, but ignore if not available + // (e.g. on ubuntu, where we don't want to test webkit as the WebCrypto X25519 implementation has issues) + existsSync(playwright.webkit.executablePath()) && playwrightLauncher({ ...sharedPlaywrightCIOptions, product: 'webkit' }) - ] + ].filter(Boolean) } ] };