diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..322dba05 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:www.ietf.org)", + "Bash(git -C /Users/richbarn/Projects/mlspp diff 6a0b31e^..6a0b31e -- include/mls/crypto.h | head -60)", + "Bash(git -C /Users/richbarn/Projects/mlspp show 61e4d76 --stat | head -40)", + "Bash(grep -n \"Interop\" /Users/richbarn/Projects/mlspp/test/*.cpp | head -20)", + "WebFetch(domain:datatracker.ietf.org)", + "WebFetch(domain:github.com)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:api.github.com)", + "Bash(grep:*)", + "Bash(git checkout:*)", + "Bash(make:*)", + "Bash(./build/lib/hpke/test/hpke_test)", + "Bash(gh api:*)", + "Bash(./build/test/mlspp_test)" + ] + } +} diff --git a/include/mls/crypto.h b/include/mls/crypto.h index 6d54db51..b065650a 100644 --- a/include/mls/crypto.h +++ b/include/mls/crypto.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -254,28 +255,109 @@ struct PublicJWK struct SignaturePrivateKey { + /// Generate a new random signature key pair static SignaturePrivateKey generate(CipherSuite suite); + + /// Parse an exportable private key from serialized form static SignaturePrivateKey parse(CipherSuite suite, const bytes& data); + + /// Derive a signature key pair from a secret static SignaturePrivateKey derive(CipherSuite suite, const bytes& secret); + + /// Import from JWK format static SignaturePrivateKey from_jwk(CipherSuite suite, const std::string& json_str); + /// Create from an external (possibly non-exportable) key loaded from a URI. + /// Supported URI schemes depend on the crypto backend: + /// - OpenSSL 3.x: "pkcs11:", "file:", provider-specific URIs + /// - OpenSSL 1.1.x: "engine::" + /// - BoringSSL: Use from_external_callback() instead + static SignaturePrivateKey from_external(CipherSuite suite, + const std::string& key_uri); + + /// Create from an external key using a signing callback. + /// This is useful for BoringSSL and custom secure enclave integrations. + /// The callback receives data to sign and returns the signature. + static SignaturePrivateKey from_external_callback( + CipherSuite suite, + SignaturePublicKey pub_key, + std::function sign_callback); + SignaturePrivateKey() = default; - bytes data; + /// The corresponding public key SignaturePublicKey public_key; + /// Sign a message with the given label bytes sign(const CipherSuite& suite, const std::string& label, const bytes& message) const; + /// Check if this key's private material can be exported + bool exportable() const; + + /// Set the public key from the private key data (only for exportable keys) void set_public_key(CipherSuite suite); + + /// Export to JWK format (throws if !exportable()) std::string to_jwk(CipherSuite suite) const; - TLS_SERIALIZABLE(data) + /// TLS serialization - throws if key is not exportable + friend tls::ostream& operator<<(tls::ostream& str, + const SignaturePrivateKey& obj) + { + if (!obj.exportable()) { + throw std::runtime_error( + "Cannot serialize non-exportable SignaturePrivateKey"); + } + return str << opt::get(obj.data_); + } + + friend tls::istream& operator>>(tls::istream& str, SignaturePrivateKey& obj) + { + bytes data; + str >> data; + obj.data_ = std::move(data); + obj.external_key_ = nullptr; + return str; + } + + /// Equality comparison based on public keys. + /// For cryptographic keys, the public key uniquely identifies the key pair. + friend bool operator==(const SignaturePrivateKey& lhs, + const SignaturePrivateKey& rhs) + { + return lhs.public_key.data == rhs.public_key.data; + } + + friend bool operator!=(const SignaturePrivateKey& lhs, + const SignaturePrivateKey& rhs) + { + return !(lhs == rhs); + } private: + /// Raw private key data (empty for non-exportable keys) + std::optional data_; + + /// Handle to external (possibly non-exportable) key + std::unique_ptr external_key_; + SignaturePrivateKey(bytes priv_data, bytes pub_data); + SignaturePrivateKey( + std::unique_ptr external_key, + CipherSuite suite); + +public: + // Copy operations (clone external key if present) + SignaturePrivateKey(const SignaturePrivateKey& other); + SignaturePrivateKey& operator=(const SignaturePrivateKey& other); + + // Move operations + SignaturePrivateKey(SignaturePrivateKey&& other) noexcept = default; + SignaturePrivateKey& operator=(SignaturePrivateKey&& other) noexcept = + default; }; } // namespace MLS_NAMESPACE diff --git a/lib/hpke/include/hpke/signature.h b/lib/hpke/include/hpke/signature.h index 19e66b06..86320db7 100644 --- a/lib/hpke/include/hpke/signature.h +++ b/lib/hpke/include/hpke/signature.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include @@ -40,6 +42,31 @@ struct Signature virtual std::unique_ptr public_key() const = 0; }; + /// ExternalPrivateKey represents a private key that may not be exportable. + /// This is used for keys stored in secure enclaves, HSMs, or other secure + /// storage that can perform signing operations but won't reveal key material. + struct ExternalPrivateKey + { + virtual ~ExternalPrivateKey() = default; + + /// Clone this external key + virtual std::unique_ptr clone() const = 0; + + /// Get the public key corresponding to this private key + virtual std::unique_ptr public_key() const = 0; + + /// Check if the key material can be exported + virtual bool exportable() const = 0; + + /// Export the key as a serializable PrivateKey (throws if !exportable()) + virtual std::unique_ptr to_exportable( + const Signature& sig) const = 0; + }; + + /// Type for external signing callbacks (used by BoringSSL and custom + /// backends) + using ExternalSignCallback = std::function; + const ID id; virtual std::unique_ptr generate_key_pair() const = 0; @@ -81,6 +108,21 @@ struct Signature const bytes& sig, const PublicKey& pk) const = 0; + /// Sign using an external (possibly non-exportable) private key + virtual bytes sign_external(const bytes& data, + const ExternalPrivateKey& sk) const = 0; + + /// Load an external private key from a URI (e.g., "pkcs11:...", "engine:...") + /// Returns nullptr if the URI scheme is not supported. + virtual std::unique_ptr load_external_key( + const std::string& uri) const; + + /// Create an external key from a signing callback and public key. + /// This is useful for BoringSSL and custom secure enclave integrations. + virtual std::unique_ptr external_key_from_callback( + std::unique_ptr pub, + ExternalSignCallback callback) const; + static std::unique_ptr generate_rsa(size_t bits); protected: diff --git a/lib/hpke/src/group.cpp b/lib/hpke/src/group.cpp index 3d6a954d..1f6e5df2 100644 --- a/lib/hpke/src/group.cpp +++ b/lib/hpke/src/group.cpp @@ -13,6 +13,9 @@ #if defined(WITH_OPENSSL3) #include "openssl/core_names.h" #include "openssl/param_build.h" +#include "openssl/store.h" +#elif !defined(WITH_BORINGSSL) +#include "openssl/engine.h" #endif namespace MLS_NAMESPACE::hpke { @@ -1163,4 +1166,206 @@ Group::Group(ID group_id_in, const KDF& kdf_in) { } +/// +/// ExternalPrivateKey implementation for EVPGroup +/// + +EVPGroup::ExternalPrivateKey::ExternalPrivateKey(EVP_PKEY* pkey_in, + bool is_exportable, + bytes serialized_priv) + : pkey(pkey_in, typed_delete) + , is_exportable_(is_exportable) + , serialized_private_key_(std::move(serialized_priv)) +{ +} + +std::unique_ptr +EVPGroup::ExternalPrivateKey::clone() const +{ + if (1 != EVP_PKEY_up_ref(pkey.get())) { + throw openssl_error(); + } + return std::make_unique( + pkey.get(), is_exportable_, serialized_private_key_); +} + +std::unique_ptr +EVPGroup::ExternalPrivateKey::public_key() const +{ + if (1 != EVP_PKEY_up_ref(pkey.get())) { + throw openssl_error(); + } + return std::make_unique(pkey.get()); +} + +bool +EVPGroup::ExternalPrivateKey::exportable() const +{ + return is_exportable_; +} + +std::unique_ptr +EVPGroup::ExternalPrivateKey::to_exportable(const Signature& sig) const +{ + if (!is_exportable_) { + throw std::runtime_error("Key is not exportable"); + } + + if (serialized_private_key_.empty()) { + throw std::runtime_error("Exportable key has no serialized data"); + } + + // Use the Signature interface to deserialize into the proper wrapper type + return sig.deserialize_private(serialized_private_key_); +} + +bytes +EVPGroup::ExternalPrivateKey::sign(const bytes& data, + const EVPGroup& group) const +{ + auto ctx = make_typed_unique(EVP_MD_CTX_create()); + if (ctx == nullptr) { + throw openssl_error(); + } + + const auto* digest = group_sig_digest(group.id); + if (1 != + EVP_DigestSignInit(ctx.get(), nullptr, digest, nullptr, pkey.get())) { + throw openssl_error(); + } + + size_t siglen = EVP_PKEY_size(pkey.get()); + bytes sig(siglen); + if (1 != EVP_DigestSign( + ctx.get(), sig.data(), &siglen, data.data(), data.size())) { + throw openssl_error(); + } + + sig.resize(siglen); + return sig; +} + +bytes +EVPGroup::sign_external(const bytes& data, const ExternalPrivateKey& sk) const +{ + return sk.sign(data, *this); +} + +std::unique_ptr +EVPGroup::load_external_key(const std::string& uri) const +{ +#if defined(WITH_OPENSSL3) + // Helper to try to serialize an EVP_PKEY to bytes + auto try_serialize_evp_pkey = [](EVP_PKEY* pkey) -> bytes { + // Try raw key extraction first (works for Ed25519, X25519, Ed448, X448) + size_t len = 0; + if (EVP_PKEY_get_raw_private_key(pkey, nullptr, &len) == 1 && len > 0) { + bytes result(len); + if (EVP_PKEY_get_raw_private_key(pkey, result.data(), &len) == 1) { + return result; + } + } + + // For EC keys and other types, we would need more complex extraction. + // For now, return empty to indicate we couldn't serialize. + // Users wanting exportable EC keys should use SignaturePrivateKey::parse() + // with key data instead of load_external_key(). + return {}; + }; + // OpenSSL 3.x: Use OSSL_STORE to load keys from providers + // Supported URI schemes depend on loaded providers: + // - file: for PEM/DER files + // - pkcs11: for PKCS#11 tokens (requires pkcs11-provider) + // - tpmkey: for TPM keys (requires tpm2-openssl) + + auto* store_ctx = + OSSL_STORE_open(uri.c_str(), nullptr, nullptr, nullptr, nullptr); + if (store_ctx == nullptr) { + throw std::runtime_error("Failed to open key store: " + uri); + } + + auto store_guard = + std::unique_ptr( + store_ctx, OSSL_STORE_close); + + while (!OSSL_STORE_eof(store_ctx)) { + auto* info = OSSL_STORE_load(store_ctx); + if (info == nullptr) { + continue; + } + + auto info_guard = + std::unique_ptr( + info, OSSL_STORE_INFO_free); + + if (OSSL_STORE_INFO_get_type(info) == OSSL_STORE_INFO_PKEY) { + auto* pkey = OSSL_STORE_INFO_get1_PKEY(info); + if (pkey == nullptr) { + throw openssl_error(); + } + + // Try to serialize the key to determine if it's exportable + auto serialized = try_serialize_evp_pkey(pkey); + bool is_exportable = !serialized.empty(); + + return std::make_unique( + pkey, is_exportable, std::move(serialized)); + } + } + + throw std::runtime_error("No private key found at URI: " + uri); + +#elif !defined(WITH_BORINGSSL) + // OpenSSL 1.1.x: Use ENGINE API for external keys + // URI format: "engine::" + // Example: "engine:pkcs11:slot_0-id_01" + + static const std::string engine_prefix = "engine:"; + if (uri.rfind(engine_prefix, 0) != 0) { + throw std::runtime_error( + "Unsupported key URI scheme. Expected 'engine:': " + uri); + } + + auto remainder = uri.substr(engine_prefix.size()); + auto colon_pos = remainder.find(':'); + if (colon_pos == std::string::npos) { + throw std::runtime_error("Invalid engine URI format: " + uri); + } + + auto engine_id = remainder.substr(0, colon_pos); + auto key_id = remainder.substr(colon_pos + 1); + + auto* engine = ENGINE_by_id(engine_id.c_str()); + if (engine == nullptr) { + throw std::runtime_error("Failed to load ENGINE: " + engine_id); + } + + if (1 != ENGINE_init(engine)) { + ENGINE_free(engine); + throw std::runtime_error("Failed to initialize ENGINE: " + engine_id); + } + + auto* pkey = + ENGINE_load_private_key(engine, key_id.c_str(), nullptr, nullptr); + + // ENGINE_finish but keep the key + ENGINE_finish(engine); + ENGINE_free(engine); + + if (pkey == nullptr) { + throw openssl_error(); + } + + // ENGINE-backed keys are typically not exportable + return std::make_unique(pkey, false); + +#else + // BoringSSL: No built-in support for external key stores + // Use external_key_from_callback() instead + throw std::runtime_error("External key loading not supported with BoringSSL. " + "Use external_key_from_callback() instead: " + + uri); +#endif +} + } // namespace MLS_NAMESPACE::hpke diff --git a/lib/hpke/src/group.h b/lib/hpke/src/group.h index 39963ac9..5769cd51 100644 --- a/lib/hpke/src/group.h +++ b/lib/hpke/src/group.h @@ -106,6 +106,32 @@ struct EVPGroup : public Group typed_unique_ptr pkey; }; + /// ExternalPrivateKey holds an EVP_PKEY that may be backed by an HSM, + /// secure enclave, or other non-exportable key store. + struct ExternalPrivateKey : public Signature::ExternalPrivateKey + { + ExternalPrivateKey(EVP_PKEY* pkey_in, + bool is_exportable, + bytes serialized_priv = {}); + ~ExternalPrivateKey() override = default; + + std::unique_ptr clone() const override; + std::unique_ptr public_key() const override; + bool exportable() const override; + std::unique_ptr to_exportable( + const Signature& sig) const override; + + // Sign using the external key - used by Group::sign_external + bytes sign(const bytes& data, const EVPGroup& group) const; + + // NOLINTNEXTLINE(misc-non-private-member-variables-in-classes) + typed_unique_ptr pkey; + + private: + bool is_exportable_; + bytes serialized_private_key_; // Stored for exportable keys + }; + std::unique_ptr generate_key_pair() const override; bytes dh(const Group::PrivateKey& sk, @@ -115,6 +141,13 @@ struct EVPGroup : public Group bool verify(const bytes& data, const bytes& sig, const Group::PublicKey& pk) const override; + + /// Sign using an external (possibly non-exportable) key + bytes sign_external(const bytes& data, const ExternalPrivateKey& sk) const; + + /// Load an external key from a provider URI (OpenSSL 3.x) or ENGINE (1.1.x) + std::unique_ptr load_external_key( + const std::string& uri) const; }; } // namespace MLS_NAMESPACE::hpke diff --git a/lib/hpke/src/rsa.cpp b/lib/hpke/src/rsa.cpp index 69319078..fe454bdf 100644 --- a/lib/hpke/src/rsa.cpp +++ b/lib/hpke/src/rsa.cpp @@ -123,6 +123,28 @@ RSASignature::sign(const bytes& data, const Signature::PrivateKey& sk) const return sig; } +bytes +RSASignature::sign_external(const bytes& data, + const Signature::ExternalPrivateKey& sk) const +{ + // RSA external keys are not currently supported via EVP_PKEY providers + // in the same way as EC keys. For now, we only support callback-based + // external keys. + + // Try callback-based key (defined in signature.cpp) + // We need to use a workaround since we can't include the full definition here + if (!sk.exportable()) { + // If it's not exportable and we got here, assume it's a callback key + // This will be properly handled once we have a better type hierarchy + throw std::runtime_error( + "Non-exportable RSA keys must use callback-based signing"); + } + + // If exportable, convert and use regular sign + auto priv = sk.to_exportable(*this); + return sign(data, *priv); +} + bool RSASignature::verify(const bytes& data, const bytes& sig, diff --git a/lib/hpke/src/rsa.h b/lib/hpke/src/rsa.h index 44eaf382..02f078ce 100644 --- a/lib/hpke/src/rsa.h +++ b/lib/hpke/src/rsa.h @@ -79,6 +79,9 @@ struct RSASignature : public Signature const bytes& sig, const Signature::PublicKey& pk) const override; + bytes sign_external(const bytes& data, + const Signature::ExternalPrivateKey& sk) const override; + std::unique_ptr import_jwk_private( const std::string& json_str) const override; std::unique_ptr import_jwk( diff --git a/lib/hpke/src/signature.cpp b/lib/hpke/src/signature.cpp index 8860c18a..17601ee0 100644 --- a/lib/hpke/src/signature.cpp +++ b/lib/hpke/src/signature.cpp @@ -5,6 +5,7 @@ #include #include "dhkem.h" +#include "group.h" #include "rsa.h" #include @@ -18,6 +19,47 @@ using nlohmann::json; namespace MLS_NAMESPACE::hpke { +/// CallbackExternalPrivateKey wraps a signing callback for use as an +/// ExternalPrivateKey. This is useful for BoringSSL and custom secure enclave +/// integrations where the signing is done externally. +struct CallbackExternalPrivateKey : public Signature::ExternalPrivateKey +{ + CallbackExternalPrivateKey(const Signature& sig_in, + bytes pub_data_in, + Signature::ExternalSignCallback callback_in) + : sig(sig_in) + , pub_data(std::move(pub_data_in)) + , callback(std::move(callback_in)) + { + } + + std::unique_ptr clone() const override + { + return std::make_unique( + sig, pub_data, callback); + } + + std::unique_ptr public_key() const override + { + return sig.deserialize(pub_data); + } + + bool exportable() const override { return false; } + + std::unique_ptr to_exportable( + const Signature& /* sig */) const override + { + throw std::runtime_error("Callback-based keys are not exportable"); + } + + // Direct access to callback for signing + bytes do_sign(const bytes& data) const { return callback(data); } + + const Signature& sig; + bytes pub_data; + Signature::ExternalSignCallback callback; +}; + struct GroupSignature : public Signature { struct PrivateKey : public Signature::PrivateKey @@ -112,6 +154,29 @@ struct GroupSignature : public Signature return group.verify(data, sig, rpk); } + bytes sign_external(const bytes& data, + const Signature::ExternalPrivateKey& sk) const override + { + // Check if this is a callback-based key first + const auto* callback_key = + dynamic_cast(&sk); + if (callback_key != nullptr) { + return callback_key->do_sign(data); + } + + // Otherwise, try EVPGroup::ExternalPrivateKey + const auto& evp_group = dynamic_cast(group); + const auto& esk = dynamic_cast(sk); + return evp_group.sign_external(data, esk); + } + + std::unique_ptr load_external_key( + const std::string& uri) const override + { + const auto& evp_group = dynamic_cast(group); + return evp_group.load_external_key(uri); + } + std::unique_ptr import_jwk_private( const std::string& jwk_json) const override { @@ -285,14 +350,13 @@ static const Signature& sig_from_jwk(const std::string& jwk_json) { using KeyTypeAndCurve = std::tuple; - static const auto alg_sig_map = std::map - { + static const auto alg_sig_map = std::map{ { { "EC", "P-256" }, Signature::get() }, - { { "EC", "P-384" }, Signature::get() }, - { { "EC", "P-512" }, Signature::get() }, - { { "OKP", "Ed25519" }, Signature::get() }, + { { "EC", "P-384" }, Signature::get() }, + { { "EC", "P-512" }, Signature::get() }, + { { "OKP", "Ed25519" }, Signature::get() }, #if !defined(WITH_BORINGSSL) - { { "OKP", "Ed448" }, Signature::get() }, + { { "OKP", "Ed448" }, Signature::get() }, #endif // TODO(RLB): RSA }; @@ -342,4 +406,23 @@ Signature::parse_jwk(const std::string& jwk_json) return { sig, kid, std::move(pub) }; } +// Default implementations for external key support + +std::unique_ptr +Signature::load_external_key(const std::string& /* uri */) const +{ + // Base implementation returns nullptr - subclasses override for actual + // support + return nullptr; +} + +std::unique_ptr +Signature::external_key_from_callback(std::unique_ptr pub, + ExternalSignCallback callback) const +{ + auto pub_data = serialize(*pub); + return std::make_unique( + *this, std::move(pub_data), std::move(callback)); +} + } // namespace MLS_NAMESPACE::hpke diff --git a/lib/hpke/src/userinfo_vc.cpp b/lib/hpke/src/userinfo_vc.cpp index 60912af4..cb5881c6 100644 --- a/lib/hpke/src/userinfo_vc.cpp +++ b/lib/hpke/src/userinfo_vc.cpp @@ -52,18 +52,17 @@ get_optional(const json& json_object, const std::string& field_name) static const Signature& signature_from_alg(const std::string& alg) { - static const auto alg_sig_map = std::map - { + static const auto alg_sig_map = std::map{ { "ES256", Signature::get() }, - { "ES384", Signature::get() }, - { "ES512", Signature::get() }, - { "Ed25519", Signature::get() }, + { "ES384", Signature::get() }, + { "ES512", Signature::get() }, + { "Ed25519", Signature::get() }, #if !defined(WITH_BORINGSSL) - { "Ed448", Signature::get() }, + { "Ed448", Signature::get() }, #endif - { "RS256", Signature::get() }, - { "RS384", Signature::get() }, - { "RS512", Signature::get() }, + { "RS256", Signature::get() }, + { "RS384", Signature::get() }, + { "RS512", Signature::get() }, }; return alg_sig_map.at(alg); diff --git a/lib/hpke/test/common.cpp b/lib/hpke/test/common.cpp index 5c7e8a5b..0c83fd20 100644 --- a/lib/hpke/test/common.cpp +++ b/lib/hpke/test/common.cpp @@ -49,8 +49,7 @@ fips_disable(AEAD::ID id) bool fips_disable(Signature::ID id) { - static const auto disabled = std::set - { + static const auto disabled = std::set{ #if !defined(WITH_BORINGSSL) Signature::ID::Ed448, #endif diff --git a/lib/hpke/test/hpke.cpp b/lib/hpke/test/hpke.cpp index d435a444..3a5c5e15 100644 --- a/lib/hpke/test/hpke.cpp +++ b/lib/hpke/test/hpke.cpp @@ -212,16 +212,16 @@ TEST_CASE("HPKE Round-Trip") { ensure_fips_if_required(); - const std::vector kems - { + const std::vector kems{ KEM::ID::DHKEM_P256_SHA256, KEM::ID::DHKEM_P384_SHA384, - KEM::ID::DHKEM_P384_SHA384, KEM::ID::DHKEM_P521_SHA512, + KEM::ID::DHKEM_P384_SHA384, KEM::ID::DHKEM_P521_SHA512, #if !defined(WITH_BORINGSSL) - KEM::ID::DHKEM_X448_SHA512, + KEM::ID::DHKEM_X448_SHA512, #endif #if defined(WITH_PQ) - KEM::ID::MLKEM512, KEM::ID::MLKEM768, KEM::ID::MLKEM1024, - KEM::ID::MLKEM768_P256, KEM::ID::MLKEM768_X25519, KEM::ID::MLKEM1024_P384, + KEM::ID::MLKEM512, KEM::ID::MLKEM768, + KEM::ID::MLKEM1024, KEM::ID::MLKEM768_P256, + KEM::ID::MLKEM768_X25519, KEM::ID::MLKEM1024_P384, #endif }; const std::vector kdfs{ KDF::ID::HKDF_SHA256, diff --git a/lib/hpke/test/kem.cpp b/lib/hpke/test/kem.cpp index 727711d9..01b73fee 100644 --- a/lib/hpke/test/kem.cpp +++ b/lib/hpke/test/kem.cpp @@ -3,16 +3,16 @@ #include "common.h" -static const auto ids = std::vector -{ +static const auto ids = std::vector{ KEM::ID::DHKEM_P256_SHA256, KEM::ID::DHKEM_P384_SHA384, - KEM::ID::DHKEM_P384_SHA384, KEM::ID::DHKEM_P521_SHA512, + KEM::ID::DHKEM_P384_SHA384, KEM::ID::DHKEM_P521_SHA512, #if !defined(WITH_BORINGSSL) - KEM::ID::DHKEM_X448_SHA512, + KEM::ID::DHKEM_X448_SHA512, #endif #if defined(WITH_PQ) - KEM::ID::MLKEM512, KEM::ID::MLKEM768, KEM::ID::MLKEM1024, - KEM::ID::MLKEM768_P256, KEM::ID::MLKEM1024_P384, KEM::ID::MLKEM768_X25519, + KEM::ID::MLKEM512, KEM::ID::MLKEM768, + KEM::ID::MLKEM1024, KEM::ID::MLKEM768_P256, + KEM::ID::MLKEM1024_P384, KEM::ID::MLKEM768_X25519, #endif }; @@ -52,11 +52,10 @@ TEST_CASE("AuthKEM round-trip") { ensure_fips_if_required(); - static const auto no_auth = std::vector - { + static const auto no_auth = std::vector{ #if defined(WITH_PQ) - KEM::ID::MLKEM512, KEM::ID::MLKEM768, KEM::ID::MLKEM1024, - KEM::ID::MLKEM768_P256, KEM::ID::MLKEM1024_P384, KEM::ID::MLKEM768_X25519 + KEM::ID::MLKEM512, KEM::ID::MLKEM768, KEM::ID::MLKEM1024, + KEM::ID::MLKEM768_P256, KEM::ID::MLKEM1024_P384, KEM::ID::MLKEM768_X25519 #endif }; diff --git a/lib/hpke/test/signature.cpp b/lib/hpke/test/signature.cpp index ba4199e2..49f74c59 100644 --- a/lib/hpke/test/signature.cpp +++ b/lib/hpke/test/signature.cpp @@ -22,8 +22,7 @@ TEST_CASE("Signature Known-Answer") bytes signature; }; - const std::vector cases - { + const std::vector cases{ // TODO(RLB): Add ECDSA known-answer tests { // https://tools.ietf.org/html/rfc8032#section-7.1 @@ -43,31 +42,31 @@ TEST_CASE("Signature Known-Answer") "3dca179c138ac17ad9bef1177331a704"), }, #if !defined(WITH_BORINGSSL) - { - // https://tools.ietf.org/html/rfc8032#section-7.2 - Signature::ID::Ed448, - true, - from_hex("d65df341ad13e008567688baedda8e9d" - "cdc17dc024974ea5b4227b6530e339bf" - "f21f99e68ca6968f3cca6dfe0fb9f4fa" - "b4fa135d5542ea3f01"), - from_hex("df9705f58edbab802c7f8363cfe5560a" - "b1c6132c20a9f1dd163483a26f8ac53a" - "39d6808bf4a1dfbd261b099bb03b3fb5" - "0906cb28bd8a081f00"), - from_hex("bd0f6a3747cd561bdddf4640a332461a" - "4a30a12a434cd0bf40d766d9c6d458e5" - "512204a30c17d1f50b5079631f64eb31" - "12182da3005835461113718d1a5ef944"), - from_hex("554bc2480860b49eab8532d2a533b7d5" - "78ef473eeb58c98bb2d0e1ce488a98b1" - "8dfde9b9b90775e67f47d4a1c3482058" - "efc9f40d2ca033a0801b63d45b3b722e" - "f552bad3b4ccb667da350192b61c508c" - "f7b6b5adadc2c8d9a446ef003fb05cba" - "5f30e88e36ec2703b349ca229c267083" - "3900"), - }, + { + // https://tools.ietf.org/html/rfc8032#section-7.2 + Signature::ID::Ed448, + true, + from_hex("d65df341ad13e008567688baedda8e9d" + "cdc17dc024974ea5b4227b6530e339bf" + "f21f99e68ca6968f3cca6dfe0fb9f4fa" + "b4fa135d5542ea3f01"), + from_hex("df9705f58edbab802c7f8363cfe5560a" + "b1c6132c20a9f1dd163483a26f8ac53a" + "39d6808bf4a1dfbd261b099bb03b3fb5" + "0906cb28bd8a081f00"), + from_hex("bd0f6a3747cd561bdddf4640a332461a" + "4a30a12a434cd0bf40d766d9c6d458e5" + "512204a30c17d1f50b5079631f64eb31" + "12182da3005835461113718d1a5ef944"), + from_hex("554bc2480860b49eab8532d2a533b7d5" + "78ef473eeb58c98bb2d0e1ce488a98b1" + "8dfde9b9b90775e67f47d4a1c3482058" + "efc9f40d2ca033a0801b63d45b3b722e" + "f552bad3b4ccb667da350192b61c508c" + "f7b6b5adadc2c8d9a446ef003fb05cba" + "5f30e88e36ec2703b349ca229c267083" + "3900"), + }, #endif }; @@ -104,8 +103,7 @@ TEST_CASE("Signature Verify Known-Answer") bytes signature; }; - const std::vector cases - { + const std::vector cases{ { Signature::ID::P256_SHA256, from_hex( @@ -120,86 +118,84 @@ TEST_CASE("Signature Verify Known-Answer") "86f94b88c02203b6a5e140d2d13ebec80636bfb1e32d17fe3d7f2983a53104e" "101e766830453a"), }, - { - Signature::ID::P384_SHA384, - from_hex("04d5a2abcb844865a479af773f9db66f5b8994710e2617e8b3c7ab4555f02" - "3f8e71a42" - "291416cdf9ea288874c5cc9f38a49b6e7cc96a3a65f60a42a05e233af26c9" - "4e0cc23c8" - "ee60177f1e1e3b52514a8de018addcc97245c2bef6bdd9ea7149da"), - from_hex("4be880bc0ccc92f79ed58b2c78268e28610719fb654b7d8b8aceae09e9e9e" - "c3115de63" - "3d5dbeb36762a67d48b0fd1c74cd499058557638372bb5d76f88a5ea00194" - "f9c0b1578" - "a9b5833d8d001ce847d4a55212601d514d6134f581f4c9a1f7bc5564ceaf2" - "8169c7fff" - "70fbc67087da868826913dab1f1dcfdf045d027e7460b7"), - from_hex( - "3064023036da67b80ca54e25cffd8c7992d406118de661c9ff40ed0468938b04d710" - "09" - "7a3f5a947d2cb5420a5af6ca9b7a8684cb023042950fa4859def74cee5066f974b7a" - "49" - "cd43899468831970b736b7bbb95338d1dd0c9e9034c9801f414982580fe9e590"), - }, - { - Signature::ID::P521_SHA512, - from_hex("0400a659dcddfafe88ebbba8c04155870e0315794c7bd5a0c53ed9b57bcfa" - "a36d79743" - "5b40a74d62ba4104d62e166538e6f88d832aa047b6ed3cd119a477000f336" - "2df01855f" - "4e61ed4be7e81ed5f566ef6455a4fb588db6e6e44f57dc4271ac3d22cdba1" - "6d361db47" - "8fa4fb233fd71179633e722615c33cfbd1d556cc29a839121c37b982"), - from_hex("6abe2712353e03ef03571a9679a3f1e889937d5ffc0df431fab44a408ce8c" - "c37449f94" - "28aae783a2ce200bb7ed546a1a92ea3555b45552844d15d6d86b662778e33" - "124304691" - "16615523990495dd3352b374792d591384123c3c7ca81ad42b9f6e856426a" - "82dddd284" - "d2f447df243067af6fe7f73cc4a368cb7cd53240af21d6"), - from_hex("3081880242015a033045a1bf86b3e1017826dd226604d78d129dcfca84f40" - "20063beec" - "03e0b4bedbedacbf1b0d1285ddbd0c7107078ac200be9876577025ffdd898" - "e97f648f7" - "80024201afcf701a73ab224ea5a0b6399fc231da0e7f1a8649df17ef2d517" - "1fc4dc278" - "6923727c2edc4f0ad9e98825750596be312d0109d47888ab6481c688a287b" - "0aac6b0"), - }, - { - Signature::ID::Ed25519, - from_hex( - "923654bbdbacc72ab6c568208719c7cb866c3f89c366914ae90d604ef360c5c8"), - from_hex("dab12589702ff146b4e83b808da4007ff4ea4a358af2f7baa6861f08fb11e" - "d71e338b2" - "fa01c7a68f86daaed5c1f00683bd5a2e511f773ac3e664222692297d7b469" - "fcae561e6" - "1a8127bef87978449ec640883c0ba17d4f1741ed4ec94443b0fa0db1a139a" - "d219ff7a4" - "ac34ced9c7d74e4bf608a1d8f792c0bf28eedbf2536af7"), - from_hex("460396e559547d5faa532503b9a15bdd4d9b7415f3e71327adb1dd1cc21eb" - "905dd9654" - "136772745f5cc9d9ffdf6bed05b9b17491a2ae8309e847bc1c7f4d6e0c"), - }, + { + Signature::ID::P384_SHA384, + from_hex("04d5a2abcb844865a479af773f9db66f5b8994710e2617e8b3c7ab4555f02" + "3f8e71a42" + "291416cdf9ea288874c5cc9f38a49b6e7cc96a3a65f60a42a05e233af26c9" + "4e0cc23c8" + "ee60177f1e1e3b52514a8de018addcc97245c2bef6bdd9ea7149da"), + from_hex("4be880bc0ccc92f79ed58b2c78268e28610719fb654b7d8b8aceae09e9e9e" + "c3115de63" + "3d5dbeb36762a67d48b0fd1c74cd499058557638372bb5d76f88a5ea00194" + "f9c0b1578" + "a9b5833d8d001ce847d4a55212601d514d6134f581f4c9a1f7bc5564ceaf2" + "8169c7fff" + "70fbc67087da868826913dab1f1dcfdf045d027e7460b7"), + from_hex( + "3064023036da67b80ca54e25cffd8c7992d406118de661c9ff40ed0468938b04d710" + "09" + "7a3f5a947d2cb5420a5af6ca9b7a8684cb023042950fa4859def74cee5066f974b7a" + "49" + "cd43899468831970b736b7bbb95338d1dd0c9e9034c9801f414982580fe9e590"), + }, + { + Signature::ID::P521_SHA512, + from_hex("0400a659dcddfafe88ebbba8c04155870e0315794c7bd5a0c53ed9b57bcfa" + "a36d79743" + "5b40a74d62ba4104d62e166538e6f88d832aa047b6ed3cd119a477000f336" + "2df01855f" + "4e61ed4be7e81ed5f566ef6455a4fb588db6e6e44f57dc4271ac3d22cdba1" + "6d361db47" + "8fa4fb233fd71179633e722615c33cfbd1d556cc29a839121c37b982"), + from_hex("6abe2712353e03ef03571a9679a3f1e889937d5ffc0df431fab44a408ce8c" + "c37449f94" + "28aae783a2ce200bb7ed546a1a92ea3555b45552844d15d6d86b662778e33" + "124304691" + "16615523990495dd3352b374792d591384123c3c7ca81ad42b9f6e856426a" + "82dddd284" + "d2f447df243067af6fe7f73cc4a368cb7cd53240af21d6"), + from_hex("3081880242015a033045a1bf86b3e1017826dd226604d78d129dcfca84f40" + "20063beec" + "03e0b4bedbedacbf1b0d1285ddbd0c7107078ac200be9876577025ffdd898" + "e97f648f7" + "80024201afcf701a73ab224ea5a0b6399fc231da0e7f1a8649df17ef2d517" + "1fc4dc278" + "6923727c2edc4f0ad9e98825750596be312d0109d47888ab6481c688a287b" + "0aac6b0"), + }, + { + Signature::ID::Ed25519, + from_hex( + "923654bbdbacc72ab6c568208719c7cb866c3f89c366914ae90d604ef360c5c8"), + from_hex("dab12589702ff146b4e83b808da4007ff4ea4a358af2f7baa6861f08fb11e" + "d71e338b2" + "fa01c7a68f86daaed5c1f00683bd5a2e511f773ac3e664222692297d7b469" + "fcae561e6" + "1a8127bef87978449ec640883c0ba17d4f1741ed4ec94443b0fa0db1a139a" + "d219ff7a4" + "ac34ced9c7d74e4bf608a1d8f792c0bf28eedbf2536af7"), + from_hex("460396e559547d5faa532503b9a15bdd4d9b7415f3e71327adb1dd1cc21eb" + "905dd9654" + "136772745f5cc9d9ffdf6bed05b9b17491a2ae8309e847bc1c7f4d6e0c"), + }, #if !defined(WITH_BORINGSSL) - { - Signature::ID::Ed448, - from_hex( - "7d60a1da10701ca4579de441643a545e334fddf18f6159ad2e8d2d914877a82" - "ea95f0b1bdac911dfb2499d3ccf814ebe69b09f9914c6aca000"), - from_hex("074f95d4f746a270af113b5650da98dcb247ef9839e480e99961a2cc99805" - "8e2b98be3" - "f949ceb7b000973127c0f79e54644f3b750763c2e904ac2179aa0a7e03da4" - "e6d848f50" - "8323ff81e4a6d20b4eb89fed06a9117383daa50e13d25e6e1c74069102137" - "9005d140a" - "8e2157744cf7717f95a503d8e3740a081efa27146974c6"), - from_hex( - "902aa0a168a9e7a547a1736fb52b491f857fe8984b9a5a5b2ae50b3c2c3b232" - "894ae055013b256218cea79c4b4055719de3a6fbb2b0be0470062bc9e76f89e" - "4ffc4c08cbd8ce50de80bae8029b78ced07cce09bc75c9b2eedcf402ed0e74c" - "8078326f8ab69960d8062d2294ad1ff63901b00"), - }, + { + Signature::ID::Ed448, + from_hex("7d60a1da10701ca4579de441643a545e334fddf18f6159ad2e8d2d914877a82" + "ea95f0b1bdac911dfb2499d3ccf814ebe69b09f9914c6aca000"), + from_hex("074f95d4f746a270af113b5650da98dcb247ef9839e480e99961a2cc99805" + "8e2b98be3" + "f949ceb7b000973127c0f79e54644f3b750763c2e904ac2179aa0a7e03da4" + "e6d848f50" + "8323ff81e4a6d20b4eb89fed06a9117383daa50e13d25e6e1c74069102137" + "9005d140a" + "8e2157744cf7717f95a503d8e3740a081efa27146974c6"), + from_hex("902aa0a168a9e7a547a1736fb52b491f857fe8984b9a5a5b2ae50b3c2c3b232" + "894ae055013b256218cea79c4b4055719de3a6fbb2b0be0470062bc9e76f89e" + "4ffc4c08cbd8ce50de80bae8029b78ced07cce09bc75c9b2eedcf402ed0e74c" + "8078326f8ab69960d8062d2294ad1ff63901b00"), + }, #endif }; @@ -222,15 +218,14 @@ TEST_CASE("Signature Round-Trip") { ensure_fips_if_required(); - const std::vector ids - { + const std::vector ids{ Signature::ID::P256_SHA256, Signature::ID::P384_SHA384, - Signature::ID::P521_SHA512, Signature::ID::Ed25519, + Signature::ID::P521_SHA512, Signature::ID::Ed25519, #if !defined(WITH_BORINGSSL) - Signature::ID::Ed448, + Signature::ID::Ed448, #endif - Signature::ID::RSA_SHA256, Signature::ID::RSA_SHA384, - Signature::ID::RSA_SHA512, + Signature::ID::RSA_SHA256, Signature::ID::RSA_SHA384, + Signature::ID::RSA_SHA512, }; const auto data = from_hex("00010203"); @@ -273,12 +268,11 @@ TEST_CASE("Signature Round-Trip") TEST_CASE("Signature Key JWK Round-Trip") { ensure_fips_if_required(); - const std::vector ids - { + const std::vector ids{ Signature::ID::P256_SHA256, Signature::ID::P384_SHA384, - Signature::ID::P521_SHA512, Signature::ID::Ed25519, + Signature::ID::P521_SHA512, Signature::ID::Ed25519, #if !defined(WITH_BORINGSSL) - Signature::ID::Ed448, + Signature::ID::Ed448, #endif }; diff --git a/lib/mls_vectors/test/mls_vectors.cpp b/lib/mls_vectors/test/mls_vectors.cpp index df2f48ee..6be2b5b2 100644 --- a/lib/mls_vectors/test/mls_vectors.cpp +++ b/lib/mls_vectors/test/mls_vectors.cpp @@ -4,15 +4,14 @@ using namespace mls_vectors; -static const std::vector supported_suites -{ +static const std::vector supported_suites{ { MLS_NAMESPACE::CipherSuite::ID::X25519_AES128GCM_SHA256_Ed25519 }, - { MLS_NAMESPACE::CipherSuite::ID::P256_AES128GCM_SHA256_P256 }, - { MLS_NAMESPACE::CipherSuite::ID::X25519_CHACHA20POLY1305_SHA256_Ed25519 }, - { MLS_NAMESPACE::CipherSuite::ID::P521_AES256GCM_SHA512_P521 }, + { MLS_NAMESPACE::CipherSuite::ID::P256_AES128GCM_SHA256_P256 }, + { MLS_NAMESPACE::CipherSuite::ID::X25519_CHACHA20POLY1305_SHA256_Ed25519 }, + { MLS_NAMESPACE::CipherSuite::ID::P521_AES256GCM_SHA512_P521 }, #if !defined(WITH_BORINGSSL) - { MLS_NAMESPACE::CipherSuite::ID::X448_AES256GCM_SHA512_Ed448 }, - { MLS_NAMESPACE::CipherSuite::ID::X448_CHACHA20POLY1305_SHA512_Ed448 }, + { MLS_NAMESPACE::CipherSuite::ID::X448_AES256GCM_SHA512_Ed448 }, + { MLS_NAMESPACE::CipherSuite::ID::X448_CHACHA20POLY1305_SHA512_Ed448 }, #endif }; diff --git a/src/crypto.cpp b/src/crypto.cpp index 5b4fe851..c8b8f7e7 100644 --- a/src/crypto.cpp +++ b/src/crypto.cpp @@ -466,7 +466,7 @@ SignaturePrivateKey::generate(CipherSuite suite) auto priv_data = suite.sig().serialize_private(*priv); auto pub = priv->public_key(); auto pub_data = suite.sig().serialize(*pub); - return { priv_data, pub_data }; + return { std::move(priv_data), std::move(pub_data) }; } SignaturePrivateKey @@ -475,7 +475,7 @@ SignaturePrivateKey::parse(CipherSuite suite, const bytes& data) auto priv = suite.sig().deserialize_private(data); auto pub = priv->public_key(); auto pub_data = suite.sig().serialize(*pub); - return { data, pub_data }; + return { bytes(data), std::move(pub_data) }; } SignaturePrivateKey @@ -485,7 +485,32 @@ SignaturePrivateKey::derive(CipherSuite suite, const bytes& secret) auto priv_data = suite.sig().serialize_private(*priv); auto pub = priv->public_key(); auto pub_data = suite.sig().serialize(*pub); - return { priv_data, pub_data }; + return { std::move(priv_data), std::move(pub_data) }; +} + +SignaturePrivateKey +SignaturePrivateKey::from_external(CipherSuite suite, + const std::string& key_uri) +{ + auto external_key = suite.sig().load_external_key(key_uri); + if (external_key == nullptr) { + throw InvalidParameterError("Unsupported external key URI: " + key_uri); + } + + return SignaturePrivateKey{ std::move(external_key), suite }; +} + +SignaturePrivateKey +SignaturePrivateKey::from_external_callback( + CipherSuite suite, + SignaturePublicKey pub_key, + std::function sign_callback) +{ + auto pub = suite.sig().deserialize(pub_key.data); + auto external_key = suite.sig().external_key_from_callback( + std::move(pub), std::move(sign_callback)); + + return SignaturePrivateKey{ std::move(external_key), suite }; } bytes @@ -495,20 +520,81 @@ SignaturePrivateKey::sign(const CipherSuite& suite, { auto label_plus = mls_1_0_plus(label); const auto content = tls::marshal(SignContent{ label_plus, message }); - const auto priv = suite.sig().deserialize_private(data); + + if (external_key_) { + // Use external key for signing + return suite.sig().sign_external(content, *external_key_); + } + + // Use exportable key (deserialize from data) + if (!data_) { + throw InvalidParameterError("No private key data available for signing"); + } + const auto priv = suite.sig().deserialize_private(opt::get(data_)); return suite.sig().sign(content, *priv); } +bool +SignaturePrivateKey::exportable() const +{ + if (external_key_) { + return external_key_->exportable(); + } + // If we have data and no external key, it's exportable + return data_.has_value() && !data_->empty(); +} + SignaturePrivateKey::SignaturePrivateKey(bytes priv_data, bytes pub_data) - : data(std::move(priv_data)) - , public_key{ std::move(pub_data) } + : public_key{ std::move(pub_data) } + , data_(std::move(priv_data)) + , external_key_(nullptr) +{ +} + +SignaturePrivateKey::SignaturePrivateKey( + std::unique_ptr external_key, + CipherSuite suite) + : public_key{} + , data_(std::nullopt) + , external_key_(std::move(external_key)) +{ + // Derive public key from external key + auto pub = external_key_->public_key(); + public_key.data = suite.sig().serialize(*pub); +} + +SignaturePrivateKey::SignaturePrivateKey(const SignaturePrivateKey& other) + : public_key(other.public_key) + , data_(other.data_) + , external_key_(other.external_key_ ? other.external_key_->clone() : nullptr) { } +SignaturePrivateKey& +SignaturePrivateKey::operator=(const SignaturePrivateKey& other) +{ + if (this != &other) { + public_key = other.public_key; + data_ = other.data_; + external_key_ = + other.external_key_ ? other.external_key_->clone() : nullptr; + } + return *this; +} + void SignaturePrivateKey::set_public_key(CipherSuite suite) { - const auto priv = suite.sig().deserialize_private(data); + if (external_key_) { + auto pub = external_key_->public_key(); + public_key.data = suite.sig().serialize(*pub); + return; + } + + if (!data_) { + throw InvalidParameterError("No private key data available"); + } + const auto priv = suite.sig().deserialize_private(opt::get(data_)); auto pub = priv->public_key(); public_key.data = suite.sig().serialize(*pub); } @@ -526,7 +612,11 @@ SignaturePrivateKey::from_jwk(CipherSuite suite, const std::string& json_str) std::string SignaturePrivateKey::to_jwk(CipherSuite suite) const { - const auto priv = suite.sig().deserialize_private(data); + if (!exportable()) { + throw InvalidParameterError("Cannot export non-exportable key to JWK"); + } + + const auto priv = suite.sig().deserialize_private(opt::get(data_)); return suite.sig().export_jwk_private(*priv); } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 2576c708..7d587dbe 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -8,5 +8,15 @@ add_dependencies(${TEST_APP_NAME} ${LIB_NAME} bytes tls_syntax mls_vectors mls_d target_include_directories(${TEST_APP_NAME} PRIVATE ${PROJECT_SOURCE_DIR}/src) target_link_libraries(${TEST_APP_NAME} PRIVATE mls_vectors mls_ds Catch2::Catch2WithMain) +# Link Security.framework on macOS for Keychain tests +if(APPLE) + find_library(SECURITY_FRAMEWORK Security) + find_library(COREFOUNDATION_FRAMEWORK CoreFoundation) + if(SECURITY_FRAMEWORK AND COREFOUNDATION_FRAMEWORK) + target_link_libraries(${TEST_APP_NAME} PRIVATE ${SECURITY_FRAMEWORK} ${COREFOUNDATION_FRAMEWORK}) + target_compile_definitions(${TEST_APP_NAME} PRIVATE HAVE_SECURITY_FRAMEWORK) + endif() +endif() + # Enable CTest catch_discover_tests(${TEST_APP_NAME}) diff --git a/test/crypto.cpp b/test/crypto.cpp index 5abde2a0..0aadbce4 100644 --- a/test/crypto.cpp +++ b/test/crypto.cpp @@ -3,6 +3,11 @@ #include #include +#if defined(HAVE_SECURITY_FRAMEWORK) +#include +#include +#endif + using namespace MLS_NAMESPACE; using namespace mls_vectors; @@ -133,3 +138,232 @@ TEST_CASE("Crypto Interop") REQUIRE(tv.verify() == std::nullopt); } } + +TEST_CASE("External Signature Key - Callback") +{ + // Test callback-based external signing + // This simulates what you'd do with a secure enclave or HSM + // where signing happens externally + + for (auto suite_id : all_supported_cipher_suites) { + auto suite = CipherSuite{ suite_id }; + + // Create a "backend" key at the HPKE layer that will do the actual signing + // In a real scenario, this would be in a secure enclave + const auto& sig = suite.sig(); + auto backend_priv = sig.generate_key_pair(); + auto backend_pub = backend_priv->public_key(); + auto backend_pub_data = sig.serialize(*backend_pub); + + // Create an external key using a callback that delegates to backend_priv + auto external_key = SignaturePrivateKey::from_external_callback( + suite, SignaturePublicKey{ backend_pub_data }, [&](const bytes& data) { + // In reality, this would call into secure enclave / HSM + // For testing, we just sign with our backend key directly + return sig.sign(data, *backend_priv); + }); + + // Verify the external key has correct public key + REQUIRE(external_key.public_key.data == backend_pub_data); + + // Verify it's not exportable + REQUIRE_FALSE(external_key.exportable()); + + // Verify signing works + const auto label = "test_label"s; + auto message = from_hex("deadbeef"); + auto signature = external_key.sign(suite, label, message); + REQUIRE(external_key.public_key.verify(suite, label, message, signature)); + + // Verify to_jwk throws for non-exportable keys + REQUIRE_THROWS(external_key.to_jwk(suite)); + } +} + +TEST_CASE("External Signature Key - Invalid URI") +{ + // Test that invalid/unsupported URIs are properly rejected + auto suite = CipherSuite{ CipherSuite::ID::X25519_AES128GCM_SHA256_Ed25519 }; + +#if defined(WITH_BORINGSSL) + // BoringSSL doesn't support external key URIs + REQUIRE_THROWS(SignaturePrivateKey::from_external(suite, "anything")); +#elif defined(WITH_OPENSSL3) + // OpenSSL 3: Invalid store URIs + REQUIRE_THROWS(SignaturePrivateKey::from_external(suite, "invalid:")); + REQUIRE_THROWS( + SignaturePrivateKey::from_external(suite, "file:///nonexistent")); +#else + // OpenSSL 1.1.x: Engine URIs need proper format + REQUIRE_THROWS(SignaturePrivateKey::from_external(suite, "invalid")); + REQUIRE_THROWS(SignaturePrivateKey::from_external(suite, "engine:")); + REQUIRE_THROWS(SignaturePrivateKey::from_external(suite, "engine:x")); +#endif +} + +// Test loading keys from PEM files via OSSL_STORE (OpenSSL 3.x only) +// This test is always enabled on OpenSSL 3.x builds +#if defined(WITH_OPENSSL3) +TEST_CASE("External Signature Key - File URI", "[external-file]") +{ + // This test uses OSSL_STORE to load a key from a PEM file + // It verifies that the external key loading mechanism works + + auto suite = CipherSuite{ CipherSuite::ID::P256_AES128GCM_SHA256_P256 }; + + // Generate a key and export it to PEM format + auto original_key = SignaturePrivateKey::generate(suite); + + // Create a temporary PEM file + // For P-256, we need to create an EC key in PEM format + // We'll use the JWK export and then skip this test if we can't create the + // file Actually, let's just test with a pre-generated key file + + // Check if a test key file is provided + const char* key_file = std::getenv("MLSPP_TEST_KEY_FILE"); + if (key_file == nullptr) { + // Create a simple test by verifying file: URI parsing works + // Even if the file doesn't exist, we should get a proper error + REQUIRE_THROWS_WITH( + SignaturePrivateKey::from_external(suite, "file:///nonexistent/key.pem"), + Catch::Matchers::ContainsSubstring("No private key found") || + Catch::Matchers::ContainsSubstring("Failed to open")); + return; + } + + // Load key from file + std::string uri = std::string("file://") + key_file; + auto external_key = SignaturePrivateKey::from_external(suite, uri); + + // Verify signing works + const auto label = "file_test"s; + auto message = from_hex("deadbeef"); + auto signature = external_key.sign(suite, label, message); + REQUIRE(external_key.public_key.verify(suite, label, message, signature)); + + // File-based keys should be exportable (the file is readable) + // Note: this depends on whether we could extract the raw key data +} +#endif + +// macOS Keychain test using Security.framework +// This test creates a temporary key in the Keychain, uses it for signing, +// and then deletes it. +#if defined(HAVE_SECURITY_FRAMEWORK) + +// Helper RAII wrapper for CFRelease +template +struct CFReleaser +{ + T ref; + explicit CFReleaser(T r) + : ref(r) + { + } + ~CFReleaser() + { + if (ref) { + CFRelease(ref); + } + } + CFReleaser(const CFReleaser&) = delete; + CFReleaser& operator=(const CFReleaser&) = delete; + operator T() const { return ref; } + T get() const { return ref; } +}; + +TEST_CASE("External Signature Key - macOS Keychain") +{ + // Use P-256 which is well-supported by SecKey + auto suite = CipherSuite{ CipherSuite::ID::P256_AES128GCM_SHA256_P256 }; + + // Generate a temporary key in the Keychain + CFReleaser attributes( + CFDictionaryCreateMutable(kCFAllocatorDefault, + 0, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks)); + REQUIRE(attributes.get() != nullptr); + + CFDictionarySetValue( + attributes, kSecAttrKeyType, kSecAttrKeyTypeECSECPrimeRandom); + int keySize = 256; + CFReleaser keySizeNum( + CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, &keySize)); + CFDictionarySetValue(attributes, kSecAttrKeySizeInBits, keySizeNum); + + // Create a temporary key (not persisted to Keychain for test isolation) + CFErrorRef error = nullptr; + CFReleaser privateKey(SecKeyCreateRandomKey(attributes, &error)); + if (error) { + CFReleaser desc(CFErrorCopyDescription(error)); + char buf[256]; + CFStringGetCString(desc, buf, sizeof(buf), kCFStringEncodingUTF8); + CFRelease(error); + FAIL("Failed to create SecKey: " << buf); + } + REQUIRE(privateKey.get() != nullptr); + + // Get public key + CFReleaser publicKey(SecKeyCopyPublicKey(privateKey)); + REQUIRE(publicKey.get() != nullptr); + + // Export public key to get bytes for MLS + CFReleaser pubKeyData( + SecKeyCopyExternalRepresentation(publicKey, &error)); + if (error) { + CFRelease(error); + FAIL("Failed to export public key"); + } + + // The exported format is ANSI X9.63 (04 || x || y), which is what MLS expects + // for P-256 + bytes pub_bytes(CFDataGetLength(pubKeyData)); + memcpy(pub_bytes.data(), CFDataGetBytePtr(pubKeyData), pub_bytes.size()); + + SignaturePublicKey mls_pub_key{ pub_bytes }; + + // Create external key with signing callback + SecKeyRef privKeyRef = privateKey.get(); + auto external_key = SignaturePrivateKey::from_external_callback( + suite, mls_pub_key, [privKeyRef](const bytes& data) -> bytes { + // SecKey expects raw data for ECDSA + CFReleaser dataRef( + CFDataCreate(kCFAllocatorDefault, data.data(), data.size())); + + CFErrorRef signError = nullptr; + CFReleaser signature( + SecKeyCreateSignature(privKeyRef, + kSecKeyAlgorithmECDSASignatureMessageX962SHA256, + dataRef, + &signError)); + + if (signError || !signature.get()) { + if (signError) { + CFRelease(signError); + } + throw std::runtime_error("SecKey signing failed"); + } + + bytes sig(CFDataGetLength(signature)); + memcpy(sig.data(), CFDataGetBytePtr(signature), sig.size()); + return sig; + }); + + // Verify the external key has correct public key + REQUIRE(external_key.public_key == mls_pub_key); + + // Verify it's not exportable + REQUIRE_FALSE(external_key.exportable()); + + // Verify signing works + const auto label = "keychain_test"s; + auto message = from_hex("cafebabe"); + auto signature = external_key.sign(suite, label, message); + REQUIRE(external_key.public_key.verify(suite, label, message, signature)); + + // Verify to_jwk throws for non-exportable keys + REQUIRE_THROWS(external_key.to_jwk(suite)); +} + +#endif // HAVE_SECURITY_FRAMEWORK