From 3882284f14918d5c7c39f1b1b6cfc44c9abebf4e Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Tue, 28 Nov 2023 17:23:47 +0000 Subject: [PATCH] Allow creating x25519 key pairs from JS (#5846) --- CHANGELOG.md | 10 ++++ include/ccf/crypto/curve.h | 6 +- include/ccf/crypto/jwk.h | 9 ++- js/ccf-app/src/global.ts | 6 +- js/ccf-app/src/polyfill.ts | 40 ++++++++----- js/ccf-app/test/polyfill.test.ts | 41 ++++++++++++- src/crypto/openssl/eddsa_key_pair.cpp | 16 +++++- src/crypto/openssl/eddsa_public_key.cpp | 22 +++++-- src/js/crypto.cpp | 6 +- tests/infra/crypto.py | 13 +++-- tests/js-modules/modules.py | 76 +++++++++++++++---------- 11 files changed, 182 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3aa2ef16..dd777eac15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [5.0.0-dev8] + +NOTE: UNRELEASED + +- `ccf.crypto.generateEddsaKeyPair`, `pubEddsaPemToJwk` and `eddsaPemToJwk` now support `x25519` as well as `curve25519` (#5846). + +[5.0.0-dev8]: https://github.com/microsoft/CCF/releases/tag/ccf-5.0.0-dev8 + +- `POST /recovery/members/{memberId}:recover` is now authenticated by COSE Sign1, making it consistent with the other `POST` endpoints in governance, and avoiding a potential denial of service where un-authenticated and un-authorised clients could submit invalid shares repeatedly. The `submit_recovery_share.sh` script has been amended accordingly, and now takes a `--member-id-privk` and `--member-id-cert` (#5821). + ## [5.0.0-dev7] [5.0.0-dev7]: https://github.com/microsoft/CCF/releases/tag/ccf-5.0.0-dev7 diff --git a/include/ccf/crypto/curve.h b/include/ccf/crypto/curve.h index 35da288e5c..77151db53b 100644 --- a/include/ccf/crypto/curve.h +++ b/include/ccf/crypto/curve.h @@ -25,7 +25,8 @@ namespace crypto /// The SECP256K1 curve SECP256K1, /// The CURVE25519 curve - CURVE25519 + CURVE25519, + X25519 }; DECLARE_JSON_ENUM( @@ -34,7 +35,8 @@ namespace crypto {CurveID::SECP384R1, "Secp384R1"}, {CurveID::SECP256R1, "Secp256R1"}, {CurveID::SECP256K1, "Secp256K1"}, - {CurveID::CURVE25519, "Curve25519"}}); + {CurveID::CURVE25519, "Curve25519"}, + {CurveID::X25519, "X25519"}}); static constexpr CurveID service_identity_curve_choice = CurveID::SECP384R1; // SNIPPET_END: supported_curves diff --git a/include/ccf/crypto/jwk.h b/include/ccf/crypto/jwk.h index 4b8ad851d7..2a797de631 100644 --- a/include/ccf/crypto/jwk.h +++ b/include/ccf/crypto/jwk.h @@ -82,10 +82,13 @@ namespace crypto enum class JsonWebKeyEdDSACurve { - ED25519 = 0 + ED25519 = 0, + X25519 = 1 }; DECLARE_JSON_ENUM( - JsonWebKeyEdDSACurve, {{JsonWebKeyEdDSACurve::ED25519, "Ed25519"}}); + JsonWebKeyEdDSACurve, + {{JsonWebKeyEdDSACurve::ED25519, "Ed25519"}, + {JsonWebKeyEdDSACurve::X25519, "X25519"}}); static JsonWebKeyEdDSACurve curve_id_to_jwk_eddsa_curve(CurveID curve_id) { @@ -93,6 +96,8 @@ namespace crypto { case CurveID::CURVE25519: return JsonWebKeyEdDSACurve::ED25519; + case CurveID::X25519: + return JsonWebKeyEdDSACurve::X25519; default: throw std::logic_error(fmt::format("Unknown EdDSA curve {}", curve_id)); } diff --git a/js/ccf-app/src/global.ts b/js/ccf-app/src/global.ts index fe7baf2d39..e1bd1fe9a1 100644 --- a/js/ccf-app/src/global.ts +++ b/js/ccf-app/src/global.ts @@ -371,7 +371,7 @@ export interface CCFCrypto { /** * Generate an EdDSA key pair. * - * @param curve The name of the curve. Currently only "curve25519" is supported. + * @param curve The name of the curve. Only "curve25519" and "x25519" are supported. */ generateEddsaKeyPair(curve: string): CryptoKeyPair; @@ -453,7 +453,7 @@ export interface CCFCrypto { /** * Converts an EdDSA public key as PEM to JSON Web Key (JWK) object. - * Currently only Curve25519 is supported. + * Only Curve25519 and X25519 are supported. * * @param pem EdDSA public key as PEM * @param kid Key identifier (optional) @@ -462,7 +462,7 @@ export interface CCFCrypto { /** * Converts an EdDSA private key as PEM to JSON Web Key (JWK) object. - * Currently only Curve25519 is supported. + * Only Curve25519 and X25519 are supported. * * @param pem EdDSA private key as PEM * @param kid Key identifier (optional) diff --git a/js/ccf-app/src/polyfill.ts b/js/ccf-app/src/polyfill.ts index 9b8735c636..ccf19c0edc 100644 --- a/js/ccf-app/src/polyfill.ts +++ b/js/ccf-app/src/polyfill.ts @@ -252,19 +252,33 @@ class CCFPolyfill implements CCF { return ecdsaKeyPair; }, generateEddsaKeyPair(curve: string): CryptoKeyPair { - // `type` is always "ed25519" because currently only "curve25519" is supported for `curve`. - const type = "ed25519"; - const ecdsaKeyPair = jscrypto.generateKeyPairSync(type, { - publicKeyEncoding: { - type: "spki", - format: "pem", - }, - privateKeyEncoding: { - type: "pkcs8", - format: "pem", - }, - }); - return ecdsaKeyPair; + if (curve === "curve25519") { + return jscrypto.generateKeyPairSync("ed25519", { + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + }); + } else { + if (curve !== "x25519") + throw new Error( + "Unsupported curve for EdDSA key pair generation: " + curve, + ); + return jscrypto.generateKeyPairSync("x25519", { + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + }); + } }, wrapKey( key: ArrayBuffer, diff --git a/js/ccf-app/test/polyfill.test.ts b/js/ccf-app/test/polyfill.test.ts index 965f4f5a48..3f4c7274d5 100644 --- a/js/ccf-app/test/polyfill.test.ts +++ b/js/ccf-app/test/polyfill.test.ts @@ -76,6 +76,13 @@ describe("polyfill", function () { assert.isTrue(pair.privateKey.startsWith("-----BEGIN PRIVATE KEY-----")); }); }); + describe("generateEddsaKeyPair/X25519", function () { + it("generates a random EdDSA X25519 key pair", function () { + const pair = ccf.crypto.generateEddsaKeyPair("x25519"); + assert.isTrue(pair.publicKey.startsWith("-----BEGIN PUBLIC KEY-----")); + assert.isTrue(pair.privateKey.startsWith("-----BEGIN PRIVATE KEY-----")); + }); + }); describe("wrapKey", function () { it("performs RSA-OAEP wrapping correctly", function () { const key = ccf.crypto.generateAesKey(128); @@ -608,7 +615,7 @@ describe("polyfill", function () { assert.equal(pem, pair.privateKey); } }); - it("EdDSA", function () { + it("Ed25119", function () { const my_kid = "my_kid"; const pair = ccf.crypto.generateEddsaKeyPair("curve25519"); { @@ -640,6 +647,38 @@ describe("polyfill", function () { assert.equal(pem, pair.privateKey); } }); + it("X25119", function () { + const my_kid = "my_kid"; + const pair = ccf.crypto.generateEddsaKeyPair("x25519"); + { + const jwk = ccf.crypto.pubEddsaPemToJwk(pair.publicKey); + assert.equal(jwk.kty, "OKP"); + assert.notEqual(jwk.kid, my_kid); + const pem = ccf.crypto.pubEddsaJwkToPem(jwk); + assert.equal(pem, pair.publicKey); + } + { + const jwk = ccf.crypto.pubEddsaPemToJwk(pair.publicKey, my_kid); + assert.equal(jwk.kty, "OKP"); + assert.equal(jwk.kid, my_kid); + const pem = ccf.crypto.pubEddsaJwkToPem(jwk); + assert.equal(pem, pair.publicKey); + } + { + const jwk = ccf.crypto.eddsaPemToJwk(pair.privateKey); + assert.equal(jwk.kty, "OKP"); + assert.notEqual(jwk.kid, my_kid); + const pem = ccf.crypto.eddsaJwkToPem(jwk); + assert.equal(pem, pair.privateKey); + } + { + const jwk = ccf.crypto.eddsaPemToJwk(pair.privateKey, my_kid); + assert.equal(jwk.kty, "OKP"); + assert.equal(jwk.kid, my_kid); + const pem = ccf.crypto.eddsaJwkToPem(jwk); + assert.equal(pem, pair.privateKey); + } + }); }); describe("kv", function () { it("basic", function () { diff --git a/src/crypto/openssl/eddsa_key_pair.cpp b/src/crypto/openssl/eddsa_key_pair.cpp index effdf913c0..fee2f384db 100644 --- a/src/crypto/openssl/eddsa_key_pair.cpp +++ b/src/crypto/openssl/eddsa_key_pair.cpp @@ -34,16 +34,26 @@ namespace crypto "Cannot construct EdDSA key pair from non-OKP JWK"); } - if (jwk.crv != JsonWebKeyEdDSACurve::ED25519) + int curve = 0; + if (jwk.crv == JsonWebKeyEdDSACurve::ED25519) + { + curve = EVP_PKEY_ED25519; + } + else if (jwk.crv == JsonWebKeyEdDSACurve::X25519) + { + curve = EVP_PKEY_X25519; + } + else { throw std::logic_error( - "Cannot construct EdDSA key pair from non-Ed25519 JWK"); + "Cannot construct EdDSA key pair from JWK that is neither Ed25519 nor " + "X25519"); } auto d_raw = raw_from_b64url(jwk.d); OpenSSL::CHECKNULL( key = EVP_PKEY_new_raw_private_key( - EVP_PKEY_ED25519, nullptr, d_raw.data(), d_raw.size())); + curve, nullptr, d_raw.data(), d_raw.size())); } Pem EdDSAKeyPair_OpenSSL::private_key_pem() const diff --git a/src/crypto/openssl/eddsa_public_key.cpp b/src/crypto/openssl/eddsa_public_key.cpp index f9d73f680d..1c98c0ac39 100644 --- a/src/crypto/openssl/eddsa_public_key.cpp +++ b/src/crypto/openssl/eddsa_public_key.cpp @@ -28,15 +28,25 @@ namespace crypto "Cannot construct EdDSA public key from non-OKP JWK"); } - if (jwk.crv != JsonWebKeyEdDSACurve::ED25519) + int curve = 0; + if (jwk.crv == JsonWebKeyEdDSACurve::ED25519) + { + curve = EVP_PKEY_ED25519; + } + else if (jwk.crv == JsonWebKeyEdDSACurve::X25519) + { + curve = EVP_PKEY_X25519; + } + else { throw std::logic_error( - "Cannot construct EdDSA public key from non-Ed25519 JWK"); + "Cannot construct EdDSA key pair from JWK that is neither Ed25519 nor " + "X25519"); } auto x_raw = raw_from_b64url(jwk.x); - key = EVP_PKEY_new_raw_public_key( - EVP_PKEY_ED25519, nullptr, x_raw.data(), x_raw.size()); + key = + EVP_PKEY_new_raw_public_key(curve, nullptr, x_raw.data(), x_raw.size()); if (key == nullptr) { throw std::logic_error("Error constructing EdDSA public key from JWK"); @@ -83,6 +93,8 @@ namespace crypto { case CurveID::CURVE25519: return EVP_PKEY_ED25519; + case CurveID::X25519: + return EVP_PKEY_X25519; default: throw std::logic_error( fmt::format("unsupported OpenSSL CurveID {}", gid)); @@ -97,6 +109,8 @@ namespace crypto { case NID_ED25519: return CurveID::CURVE25519; + case NID_X25519: + return CurveID::X25519; default: throw std::runtime_error(fmt::format("Unknown OpenSSL curve {}", nid)); } diff --git a/src/js/crypto.cpp b/src/js/crypto.cpp index 778de50df6..07cc0fd7fd 100644 --- a/src/js/crypto.cpp +++ b/src/js/crypto.cpp @@ -186,10 +186,14 @@ namespace ccf::js { cid = crypto::CurveID::CURVE25519; } + else if (curve == "x25519") + { + cid = crypto::CurveID::X25519; + } else { return JS_ThrowRangeError( - ctx, "Unsupported curve id, supported: curve25519"); + ctx, "Unsupported curve id, supported: curve25519, x25519"); } try diff --git a/tests/infra/crypto.py b/tests/infra/crypto.py index fc935d28f0..75c9c1c534 100644 --- a/tests/infra/crypto.py +++ b/tests/infra/crypto.py @@ -16,7 +16,7 @@ from cryptography.x509 import ( load_pem_x509_certificate, load_der_x509_certificate, ) -from cryptography.hazmat.primitives.asymmetric import ec, rsa, padding, ed25519 +from cryptography.hazmat.primitives.asymmetric import ec, rsa, padding, ed25519, x25519 from cryptography.hazmat.primitives.asymmetric.utils import ( decode_dss_signature, encode_dss_signature, @@ -100,9 +100,14 @@ def generate_ec_keypair(curve: ec.EllipticCurve = ec.SECP256R1) -> Tuple[str, st return priv_pem, pub_pem -def generate_eddsa_keypair() -> Tuple[str, str]: - # Currently only Curve25519 is supported - priv = ed25519.Ed25519PrivateKey.generate() +def generate_eddsa_keypair(curve: str) -> Tuple[str, str]: + key_class = { + "curve25519": ed25519.Ed25519PrivateKey, + "x25519": x25519.X25519PrivateKey, + } + if curve not in key_class: + raise ValueError(f"Unsupported curve: {curve}") + priv = key_class[curve].generate() pub = priv.public_key() priv_pem = priv.private_bytes( Encoding.PEM, PrivateFormat.PKCS8, NoEncryption() diff --git a/tests/js-modules/modules.py b/tests/js-modules/modules.py index e3176acefd..ffb82e6de4 100644 --- a/tests/js-modules/modules.py +++ b/tests/js-modules/modules.py @@ -19,6 +19,9 @@ import openapi_spec_validator from jwcrypto import jwk from cryptography.hazmat.primitives.asymmetric import ec from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey import hmac import random @@ -117,37 +120,38 @@ def generate_and_verify_jwk(client): assert body["pem"] == pub_pem # EdDSA - priv_pem, pub_pem = infra.crypto.generate_eddsa_keypair() + # Note: x25519 is not supported by jwcrypto just yet + for curve in ["curve25519"]: + priv_pem, pub_pem = infra.crypto.generate_eddsa_keypair(curve) + # Private + ref_priv_jwk = jwk.JWK.from_pem(priv_pem.encode()).export_private(as_dict=True) + r = client.post( + "/app/eddsaPemToJwk", body={"pem": priv_pem, "kid": ref_priv_jwk["kid"]} + ) + body = r.body.json() + assert r.status_code == http.HTTPStatus.OK + assert body["kty"] == "OKP" + assert body == ref_priv_jwk, f"{body} != {ref_priv_jwk}" - # Private - ref_priv_jwk = jwk.JWK.from_pem(priv_pem.encode()).export(as_dict=True) - r = client.post( - "/app/eddsaPemToJwk", body={"pem": priv_pem, "kid": ref_priv_jwk["kid"]} - ) - body = r.body.json() - assert r.status_code == http.HTTPStatus.OK - assert body["kty"] == "OKP" - assert body == ref_priv_jwk, f"{body} != {ref_priv_jwk}" + r = client.post("/app/eddsaJwkToPem", body={"jwk": body}) + body = r.body.json() + assert r.status_code == http.HTTPStatus.OK + assert body["pem"] == priv_pem - r = client.post("/app/eddsaJwkToPem", body={"jwk": body}) - body = r.body.json() - assert r.status_code == http.HTTPStatus.OK - assert body["pem"] == priv_pem + # Public + ref_pub_jwk = jwk.JWK.from_pem(pub_pem.encode()).export(as_dict=True) + r = client.post( + "/app/pubEddsaPemToJwk", body={"pem": pub_pem, "kid": ref_pub_jwk["kid"]} + ) + body = r.body.json() + assert r.status_code == http.HTTPStatus.OK + assert body["kty"] == "OKP" + assert body == ref_pub_jwk, f"{body} != {ref_pub_jwk}" - # Public - ref_pub_jwk = jwk.JWK.from_pem(pub_pem.encode()).export(as_dict=True) - r = client.post( - "/app/pubEddsaPemToJwk", body={"pem": pub_pem, "kid": ref_pub_jwk["kid"]} - ) - body = r.body.json() - assert r.status_code == http.HTTPStatus.OK - assert body["kty"] == "OKP" - assert body == ref_pub_jwk, f"{body} != {ref_pub_jwk}" - - r = client.post("/app/pubEddsaJwkToPem", body={"jwk": body}) - body = r.body.json() - assert r.status_code == http.HTTPStatus.OK - assert body["pem"] == pub_pem + r = client.post("/app/pubEddsaJwkToPem", body={"jwk": body}) + body = r.body.json() + assert r.status_code == http.HTTPStatus.OK + assert body["pem"] == pub_pem @reqs.description("Test module import") @@ -540,6 +544,18 @@ def test_npm_app(network, args): r.body.json()["privateKey"], r.body.json()["publicKey"] ) + private_key = load_pem_private_key(r.body.json()["privateKey"].encode(), None) + assert isinstance(private_key, Ed25519PrivateKey) + + r = c.post("/app/generateEddsaKeyPair", {"curve": "x25519"}) + assert r.status_code == http.HTTPStatus.OK, r.status_code + assert infra.crypto.check_key_pair_pem( + r.body.json()["privateKey"], r.body.json()["publicKey"] + ) + + private_key = load_pem_private_key(r.body.json()["privateKey"].encode(), None) + assert isinstance(private_key, X25519PrivateKey) + aes_key_to_wrap = infra.crypto.generate_aes_key(256) wrapping_key_priv_pem, wrapping_key_pub_pem = infra.crypto.generate_rsa_keypair( 2048 @@ -719,7 +735,7 @@ def test_npm_app(network, args): pass # Test EDDSA signing + verification - key_priv_pem, key_pub_pem = infra.crypto.generate_eddsa_keypair() + key_priv_pem, key_pub_pem = infra.crypto.generate_eddsa_keypair("curve25519") algorithm = {"name": "EdDSA"} r = c.post( "/app/sign", @@ -799,7 +815,7 @@ def test_npm_app(network, args): assert r.status_code == http.HTTPStatus.OK, r.status_code assert r.body.json() is True, r.body - key_priv_pem, key_pub_pem = infra.crypto.generate_eddsa_keypair() + key_priv_pem, key_pub_pem = infra.crypto.generate_eddsa_keypair("curve25519") algorithm = {"name": "EdDSA"} signature = infra.crypto.sign(algorithm, key_priv_pem, data) r = c.post(