diff --git a/README.md b/README.md index 1ef092259..49ed884a4 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ The repository includes several ready-to-use examples to help you get started wi | [`basic-example-metrics`](./examples/basic-example-metrics/) | Setup with Prometheus and Grafana metrics | | [`vault-secret-signer`](./examples/vault-secret-signer/) | Using HashiCorp Vault for key management | | [`vault-transit-signer`](./examples/vault-transit-signer/) | Using Vault Transit for secure signing | +| [`stellar-vault-transit-signer`](./examples/stellar-vault-transit-signer/) | Using Vault Transit for Stellar secure signing | | [`evm-azure-key-vault-signer`](./examples/evm-azure-key-vault-signer/) | Using Azure Key Vault for EVM secure signing | | [`evm-turnkey-signer`](./examples/evm-turnkey-signer/) | Using Turnkey Signer for EVM secure signing | | [`solana-turnkey-signer`](./examples/solana-turnkey-signer/) | Using Turnkey Signer for Solana secure signing | diff --git a/docs/configuration/signers.mdx b/docs/configuration/signers.mdx index 63377306e..646edfb8a 100644 --- a/docs/configuration/signers.mdx +++ b/docs/configuration/signers.mdx @@ -48,7 +48,7 @@ The following table shows which signer types are compatible with each network ty | --- | --- | --- | --- | | `local` | ✅ Supported | ✅ Supported | ✅ Supported | | `vault` | ✅ Supported | ✅ Supported | ❌ Not supported | -| `vault_transit` | ❌ Not supported | ✅ Supported | ❌ Not supported | +| `vault_transit` | ❌ Not supported | ✅ Supported | ✅ Supported | | `turnkey` | ✅ Supported | ✅ Supported | ✅ Supported | | `google_cloud_kms` | ✅ Supported | ✅ Supported | ✅ Supported | | `aws_kms` | ✅ Supported | ✅ Supported | ✅ Supported | @@ -62,7 +62,7 @@ The following table shows which signer types are compatible with each network ty * ***EVM Networks***: Use secp256k1 cryptography. Most signers support EVM networks with proper key generation. * ***Solana Networks***: Use ed25519 cryptography. Ensure your signer supports ed25519 key generation and signing. -* ***Stellar Networks***: Use ed25519 cryptography with specific Stellar requirements. Supported by local, AWS KMS, Google Cloud KMS, and Turnkey signers. +* ***Stellar Networks***: Use ed25519 cryptography with specific Stellar requirements. Supported by local, Vault Transit, AWS KMS, Google Cloud KMS, and Turnkey signers. * ***AWS KMS***: Supports secp256k1 (EVM) and ed25519 (Solana, Stellar) key types. * ***Azure Key Vault***: Supports EVM networks with secp256k1 keys stored in Azure Key Vault. * ***Google Cloud KMS***: Supports secp256k1 (EVM) and ed25519 (Solana, Stellar) key types. @@ -179,7 +179,12 @@ Configuration fields: | key_name | String | The name of the cryptographic key within Vault’s Transit engine that is used for signing operations | | mount_point | String | The mount point for the Transit secrets engine in Vault. Defaults to `transit` if not explicitly specified. Optional. | | namespace | String | The Vault namespace for API calls. This is used only in Vault Enterprise environments. Optional. | -| pubkey | String | Public key of the cryptographic key within Vault’s Transit engine that is used for signing operations | +| pubkey | String | Public key of the cryptographic key within Vault’s Transit engine that is used for signing operations. For Solana and Stellar, provide the raw Ed25519 public key encoded in standard base64 as returned by Vault Transit | + +Vault Transit-specific requirements: +* Supported for Solana and Stellar networks +* Use an Ed25519 key in Vault Transit +* Grant the configured AppRole permission to sign with the Transit key ## Turnkey Signer Configuration @@ -442,4 +447,10 @@ Configuration fields: * Check AppRole credentials and permissions * Ensure the secret/transit engine is properly mounted and configured +***Vault Transit signing failures*** + +* Confirm the Transit key is an Ed25519 key and the configured `pubkey` matches the key exported by Vault +* Verify the AppRole policy allows `update` on the Transit signing path for the configured key +* Check that the Transit mount point is correct if you are not using the default `transit` + For additional troubleshooting help, check the application logs and refer to the specific cloud provider or service documentation. diff --git a/docs/index.mdx b/docs/index.mdx index 499f3448a..18300a756 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -218,6 +218,7 @@ For quick setup with various configurations, check the [examples directory](http * `basic-example-metrics`: Setup with Prometheus and Grafana metrics * `vault-secret-signer`: Using HashiCorp Vault for key management * `vault-transit-signer`: Using Vault Transit for secure signing +* `stellar-vault-transit-signer`: Using Vault Transit for Stellar secure signing * `evm-gcp-kms-signer`: Using Google Cloud KMS for EVM secure signing * `evm-turnkey-signer`: Using Turnkey for EVM secure signing * `solana-turnkey-signer`: Using Turnkey for Solana secure signing diff --git a/docs/stellar.mdx b/docs/stellar.mdx index 10293d3d0..ca29454d6 100644 --- a/docs/stellar.mdx +++ b/docs/stellar.mdx @@ -526,12 +526,16 @@ Soroban operations support different authorization modes: Stellar networks support the following signer types: * ***Local Signer***: Uses encrypted keystore files (suitable for development) +* ***Vault Transit***: Uses HashiCorp Vault Transit with an Ed25519 key for hosted signing +* ***AWS KMS***: Uses AWS Key Management Service with Ed25519 keys (recommended for production) * ***Google Cloud KMS***: Uses Google Cloud Key Management Service with ED25519 keys (recommended for production) * ***Turnkey***: Uses Turnkey’s secure key management infrastructure with ED25519 keys (recommended for production) For detailed signer configuration, see the [Signers Configuration](/relayer/configuration/signers) guide. For complete examples: +- Vault Transit: [vault-transit-signer example](https://github.com/OpenZeppelin/openzeppelin-relayer/tree/main/examples/vault-transit-signer) +- AWS KMS: [aws-kms-signer example](https://github.com/OpenZeppelin/openzeppelin-relayer/tree/main/examples/aws-kms-signer) - Google Cloud KMS: [stellar-gcp-kms-signer example](https://github.com/OpenZeppelin/openzeppelin-relayer/tree/main/examples/stellar-gcp-kms-signer) - Turnkey: [stellar-turnkey-signer example](https://github.com/OpenZeppelin/openzeppelin-relayer/tree/main/examples/stellar-turnkey-signer) diff --git a/examples/stellar-vault-transit-signer/.env.example b/examples/stellar-vault-transit-signer/.env.example new file mode 100644 index 000000000..05e0d352e --- /dev/null +++ b/examples/stellar-vault-transit-signer/.env.example @@ -0,0 +1,10 @@ +# Redis Configuration +REDIS_URL=redis://redis:6379 + +# API Configuration +API_KEY= +WEBHOOK_SIGNING_KEY= + +# Vault Transit AppRole Configuration +VAULT_ROLE_ID= +VAULT_SECRET_ID= diff --git a/examples/stellar-vault-transit-signer/README.md b/examples/stellar-vault-transit-signer/README.md new file mode 100644 index 000000000..7cb9e3075 --- /dev/null +++ b/examples/stellar-vault-transit-signer/README.md @@ -0,0 +1,213 @@ +# Using HashiCorp Vault Transit for Secure Stellar Transaction Signing in OpenZeppelin Relayer + +This example demonstrates how to use HashiCorp Vault's Transit engine to securely sign Stellar transactions in OpenZeppelin Relayer. It uses a Stellar testnet relayer with a Vault Transit Ed25519 key, and includes a Docker Compose setup with Vault running in development mode. + +> **Note:** This example uses Vault in development mode, which is not suitable for production. For production deployments, use a properly configured and sealed Vault instance with appropriate security controls. + +## Prerequisites + +1. [Docker](https://docs.docker.com/get-docker/) +2. [Docker Compose](https://docs.docker.com/compose/install/) +3. [HashiCorp Vault CLI](https://developer.hashicorp.com/vault/tutorials/get-started/install-binary?productSlug=vault&tutorialSlug=getting-started&tutorialSlug=getting-started-install) (optional but recommended) +4. Rust and Cargo installed +5. Git + +## Getting Started + +### Step 1: Clone the Repository + +```bash +git clone https://github.com/OpenZeppelin/openzeppelin-relayer +cd openzeppelin-relayer +``` + +### Step 2: Start Vault + +Start the Vault service: + +```bash +docker compose -f examples/stellar-vault-transit-signer/docker-compose.yaml up vault +``` + +Vault will run in dev mode and be available at [http://localhost:8200](http://localhost:8200). + +### Step 3: Configure the Vault CLI + +If you have the Vault CLI installed, point it at the dev server: + +```bash +export VAULT_ADDR='http://0.0.0.0:8200' +export VAULT_TOKEN='dev-only-token' +``` + +### Step 4: Enable Transit and Create a Stellar Signing Key + +Enable the Transit engine: + +```bash +vault secrets enable transit +``` + +Create an exportable Ed25519 signing key: + +```bash +vault write -f transit/keys/my_signing_key type=ed25519 exportable=true +``` + +### Step 5: Create the Vault Policy + +Create a policy that allows signing and verification with the Transit key: + +```bash +vault policy write transit-sign-policy -<), Vault(VaultSigner), + VaultTransit(VaultTransitSigner), GoogleCloudKms(Box), AwsKms(AwsKmsSigner), Turnkey(TurnkeySigner), @@ -92,6 +95,7 @@ impl Signer for StellarSigner { match self { Self::Local(s) => s.address().await, Self::Vault(s) => s.address().await, + Self::VaultTransit(s) => s.address().await, Self::GoogleCloudKms(s) => s.address().await, Self::AwsKms(s) => s.address().await, Self::Turnkey(s) => s.address().await, @@ -105,6 +109,7 @@ impl Signer for StellarSigner { match self { Self::Local(s) => s.sign_transaction(tx).await, Self::Vault(s) => s.sign_transaction(tx).await, + Self::VaultTransit(s) => s.sign_transaction(tx).await, Self::GoogleCloudKms(s) => s.sign_transaction(tx).await, Self::AwsKms(s) => s.sign_transaction(tx).await, Self::Turnkey(s) => s.sign_transaction(tx).await, @@ -128,6 +133,10 @@ impl StellarSignTrait for StellarSigner { s.sign_xdr_transaction(unsigned_xdr, network_passphrase) .await } + Self::VaultTransit(s) => { + s.sign_xdr_transaction(unsigned_xdr, network_passphrase) + .await + } Self::GoogleCloudKms(s) => { s.sign_xdr_transaction(unsigned_xdr, network_passphrase) .await @@ -147,6 +156,7 @@ impl StellarSignTrait for StellarSigner { pub struct StellarSignerFactory; impl StellarSignerFactory { + /// Creates a Stellar signer implementation from the provided signer configuration. pub fn create_stellar_signer( m: &SignerDomainModel, ) -> Result { @@ -196,14 +206,32 @@ impl StellarSignerFactory { })?; StellarSigner::AwsKms(AwsKmsSigner::new(aws_kms_service)) } + SignerConfig::VaultTransit(config) => { + let vault_service = VaultService::new(VaultConfig { + address: config.address.clone(), + namespace: config.namespace.clone(), + role_id: config.role_id.clone(), + secret_id: config.secret_id.clone(), + mount_path: config + .mount_point + .clone() + .unwrap_or_else(|| "transit".to_string()), + token_ttl: None, + }); + + StellarSigner::VaultTransit(VaultTransitSigner::new(m, vault_service).map_err( + |e| { + SignerFactoryError::InvalidConfig(format!( + "Failed to create Vault Transit signer: {e}" + )) + }, + )?) + } SignerConfig::AzureKeyVault(_) => { return Err(SignerFactoryError::UnsupportedType( "Azure Key Vault".into(), )) } - SignerConfig::VaultTransit(_) => { - return Err(SignerFactoryError::UnsupportedType("Vault Transit".into())) - } SignerConfig::Cdp(_) => return Err(SignerFactoryError::UnsupportedType("CDP".into())), }; Ok(signer) @@ -213,6 +241,105 @@ impl StellarSignerFactory { #[cfg(test)] mod tests { use super::*; + use crate::models::{ + AzureKeyVaultSignerConfig, LocalSignerConfig, SecretString, StellarTransactionData, + TransactionInput, TurnkeySignerConfig, VaultTransitSignerConfig, + }; + use base64::Engine; + use secrets::SecretVec; + use soroban_rs::xdr::{ + Limits, SequenceNumber, TransactionEnvelope, TransactionV0, TransactionV0Envelope, Uint256, + WriteXdr, + }; + + /// Returns deterministic private key bytes for local Stellar signer tests. + fn test_key_bytes() -> SecretVec { + let seed = [1u8; 32]; + SecretVec::new(seed.len(), |v| v.copy_from_slice(&seed)) + } + + /// Builds a local Stellar signer model backed by deterministic test key material. + fn create_local_signer_model() -> SignerDomainModel { + SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::Local(LocalSignerConfig { + raw_key: test_key_bytes(), + }), + } + } + + /// Builds a Vault Transit signer model with deterministic public key material. + fn create_vault_transit_signer_model() -> SignerDomainModel { + let pubkey = base64::engine::general_purpose::STANDARD.encode([1u8; 32]); + SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::VaultTransit(VaultTransitSignerConfig { + key_name: "test-key".to_string(), + address: "https://vault.test.com".to_string(), + namespace: None, + role_id: SecretString::new("test-role-id"), + secret_id: SecretString::new("test-secret-id"), + pubkey, + mount_point: None, + }), + } + } + + /// Builds a Turnkey signer model with deterministic public key material. + fn create_turnkey_signer_model() -> SignerDomainModel { + SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::Turnkey(TurnkeySignerConfig { + api_private_key: SecretString::new( + "1111111111111111111111111111111111111111111111111111111111111111", + ), + api_public_key: "api-public-key".to_string(), + organization_id: "organization-id".to_string(), + private_key_id: "private-key-id".to_string(), + public_key: "0101010101010101010101010101010101010101010101010101010101010101" + .to_string(), + }), + } + } + + /// Creates a minimal unsigned Stellar transaction envelope encoded as base64 XDR. + fn create_unsigned_xdr(source_account: &str) -> String { + let source_pk = stellar_strkey::ed25519::PublicKey::from_string(source_account).unwrap(); + let tx = TransactionV0 { + source_account_ed25519: Uint256(source_pk.0), + fee: 100, + seq_num: SequenceNumber(1), + time_bounds: None, + memo: soroban_rs::xdr::Memo::None, + operations: vec![].try_into().unwrap(), + ext: soroban_rs::xdr::TransactionV0Ext::V0, + }; + + TransactionEnvelope::TxV0(TransactionV0Envelope { + tx, + signatures: vec![].try_into().unwrap(), + }) + .to_xdr_base64(Limits::none()) + .unwrap() + } + + /// Builds minimal Stellar transaction data for module-level dispatch tests. + fn create_stellar_transaction_data(source_account: String) -> StellarTransactionData { + StellarTransactionData { + source_account, + fee: Some(100), + sequence_number: Some(1), + transaction_input: TransactionInput::Operations(vec![]), + memo: None, + valid_until: None, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + signatures: Vec::new(), + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + } + } #[test] fn test_derive_signature_hint_valid_stellar_address() { @@ -270,4 +397,192 @@ mod tests { let result = derive_signature_hint(&address); assert!(result.is_err()); } + + #[test] + /// Verifies that the factory creates a local Stellar signer. + fn test_create_stellar_signer_local() { + let signer = + StellarSignerFactory::create_stellar_signer(&create_local_signer_model()).unwrap(); + + assert!(matches!(signer, StellarSigner::Local(_))); + } + + #[test] + /// Verifies that the factory creates a Vault-backed Stellar signer. + fn test_create_stellar_signer_vault() { + let signer_model = SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::Vault(VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: SecretString::new("test-role-id"), + secret_id: SecretString::new("test-secret-id"), + key_name: "test-key".to_string(), + mount_point: Some("secret".to_string()), + }), + }; + + let signer = StellarSignerFactory::create_stellar_signer(&signer_model).unwrap(); + + assert!(matches!(signer, StellarSigner::Vault(_))); + } + + #[test] + /// Verifies that the factory creates a Vault Transit Stellar signer. + fn test_create_stellar_signer_vault_transit() { + let signer = + StellarSignerFactory::create_stellar_signer(&create_vault_transit_signer_model()) + .unwrap(); + + assert!(matches!(signer, StellarSigner::VaultTransit(_))); + } + + #[test] + /// Verifies that the factory creates a Turnkey-backed Stellar signer. + fn test_create_stellar_signer_turnkey() { + let signer = + StellarSignerFactory::create_stellar_signer(&create_turnkey_signer_model()).unwrap(); + + assert!(matches!(signer, StellarSigner::Turnkey(_))); + } + + #[test] + /// Verifies that Azure Key Vault remains unsupported for Stellar signers. + fn test_create_stellar_signer_azure_key_vault_unsupported() { + let signer_model = SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::AzureKeyVault(AzureKeyVaultSignerConfig { + auth_type: None, + tenant_id: Some(SecretString::new("tenant-id")), + client_id: Some(SecretString::new("client-id")), + client_secret: Some(SecretString::new("client-secret")), + federated_token_file: None, + vault_url: SecretString::new("https://example.vault.azure.net"), + key_name: SecretString::new("test-key"), + key_version: None, + }), + }; + + let result = StellarSignerFactory::create_stellar_signer(&signer_model); + + assert!(matches!( + result, + Err(SignerFactoryError::UnsupportedType(ref provider)) + if provider == "Azure Key Vault" + )); + } + + #[test] + /// Verifies that CDP remains unsupported for Stellar signers. + fn test_create_stellar_signer_cdp_unsupported() { + let signer_model = SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::Cdp(crate::models::CdpSignerConfig { + api_key_id: "api-key-id".to_string(), + api_key_secret: SecretString::new("dGVzdA=="), + wallet_secret: SecretString::new("dGVzdA=="), + account_address: "0xb726167dc2ef2ac582f0a3de4c08ac4abb90626a".to_string(), + }), + }; + + let result = StellarSignerFactory::create_stellar_signer(&signer_model); + + assert!(matches!( + result, + Err(SignerFactoryError::UnsupportedType(ref provider)) if provider == "CDP" + )); + } + + #[tokio::test] + /// Verifies that address dispatch works for the local Stellar signer variant. + async fn test_stellar_signer_local_address_dispatch() { + let signer = + StellarSignerFactory::create_stellar_signer(&create_local_signer_model()).unwrap(); + + let address = signer.address().await.unwrap(); + + match address { + Address::Stellar(addr) => { + assert!(addr.starts_with('G')); + assert!(!addr.is_empty()); + } + _ => panic!("Expected Stellar address"), + } + } + + #[tokio::test] + /// Verifies that address dispatch works for the Vault Transit Stellar signer variant. + async fn test_stellar_signer_vault_transit_address_dispatch() { + let signer = + StellarSignerFactory::create_stellar_signer(&create_vault_transit_signer_model()) + .unwrap(); + + let address = signer.address().await.unwrap(); + + match address { + Address::Stellar(addr) => { + assert!(addr.starts_with('G')); + assert_eq!(addr.len(), 56); + } + _ => panic!("Expected Stellar address"), + } + } + + #[tokio::test] + /// Verifies that address dispatch works for the Turnkey Stellar signer variant. + async fn test_stellar_signer_turnkey_address_dispatch() { + let signer = + StellarSignerFactory::create_stellar_signer(&create_turnkey_signer_model()).unwrap(); + + let address = signer.address().await.unwrap(); + + match address { + Address::Stellar(addr) => { + assert!(addr.starts_with('G')); + assert_eq!(addr.len(), 56); + } + _ => panic!("Expected Stellar address"), + } + } + + #[tokio::test] + /// Verifies that transaction signing dispatch works for the local Stellar signer variant. + async fn test_stellar_signer_local_sign_transaction_dispatch() { + let signer = + StellarSignerFactory::create_stellar_signer(&create_local_signer_model()).unwrap(); + let source_account = signer.address().await.unwrap().to_string(); + + let result = signer + .sign_transaction(NetworkTransactionData::Stellar( + create_stellar_transaction_data(source_account), + )) + .await + .unwrap(); + + match result { + SignTransactionResponse::Stellar(response) => { + assert_eq!(response.signature.hint.0.len(), 4); + assert_eq!(response.signature.signature.0.len(), 64); + } + _ => panic!("Expected Stellar signature response"), + } + } + + #[tokio::test] + /// Verifies that XDR signing dispatch works for the local Stellar signer variant. + async fn test_stellar_signer_local_sign_xdr_transaction_dispatch() { + let signer = + StellarSignerFactory::create_stellar_signer(&create_local_signer_model()).unwrap(); + let source_account = signer.address().await.unwrap().to_string(); + let unsigned_xdr = create_unsigned_xdr(&source_account); + + let result = signer + .sign_xdr_transaction(&unsigned_xdr, "Test SDF Network ; September 2015") + .await + .unwrap(); + + assert!(!result.signed_xdr.is_empty()); + assert_eq!(result.signature.hint.0.len(), 4); + assert_eq!(result.signature.signature.0.len(), 64); + } } diff --git a/src/services/signer/stellar/vault_transit_signer.rs b/src/services/signer/stellar/vault_transit_signer.rs new file mode 100644 index 000000000..bbf3f0aab --- /dev/null +++ b/src/services/signer/stellar/vault_transit_signer.rs @@ -0,0 +1,514 @@ +//! # Vault Transit Signer for Stellar +//! +//! This module provides a Stellar signer implementation that uses HashiCorp Vault's Transit +//! engine for secure Ed25519 signing operations without exporting the private key. + +use async_trait::async_trait; +use sha2::{Digest, Sha256}; +use soroban_rs::xdr::{ + DecoratedSignature, Hash, Limits, ReadXdr, Signature, SignatureHint, Transaction, + TransactionEnvelope, WriteXdr, +}; +use tokio::sync::OnceCell; +use tracing::debug; + +use crate::{ + domain::{ + attach_signatures_to_envelope, parse_transaction_xdr, + stellar::{create_signature_payload, create_transaction_signature_payload}, + SignTransactionResponse, SignXdrTransactionResponseStellar, + }, + models::{Address, NetworkTransactionData, Signer as SignerDomainModel, SignerError}, + services::{signer::Signer, VaultService, VaultServiceTrait}, + utils::base64_decode, +}; + +use super::StellarSignTrait; + +pub type DefaultVaultService = VaultService; + +pub struct VaultTransitSigner +where + T: VaultServiceTrait, +{ + vault_service: T, + pubkey: String, + key_name: String, + cached_hint: OnceCell, +} + +impl VaultTransitSigner { + /// Builds a Stellar Vault Transit signer from a validated signer model. + pub fn new( + signer_model: &SignerDomainModel, + vault_service: DefaultVaultService, + ) -> Result { + let config = signer_model + .config + .get_vault_transit() + .ok_or_else(|| SignerError::Configuration("vault transit config not found".into()))?; + + Ok(Self { + vault_service, + pubkey: config.pubkey.clone(), + key_name: config.key_name.clone(), + cached_hint: OnceCell::new(), + }) + } +} + +#[cfg(test)] +impl VaultTransitSigner { + /// Builds a test signer from a signer model and injected Vault service. + pub fn new_with_service( + signer_model: &SignerDomainModel, + vault_service: T, + ) -> Result { + let config = signer_model + .config + .get_vault_transit() + .ok_or_else(|| SignerError::Configuration("vault transit config not found".into()))?; + + Ok(Self { + vault_service, + pubkey: config.pubkey.clone(), + key_name: config.key_name.clone(), + cached_hint: OnceCell::new(), + }) + } + + /// Builds a test signer directly from raw constructor inputs. + pub fn new_for_testing(key_name: String, pubkey: String, vault_service: T) -> Self { + Self { + vault_service, + pubkey, + key_name, + cached_hint: OnceCell::new(), + } + } +} + +impl VaultTransitSigner { + /// Converts the configured Vault Transit public key into a Stellar address. + fn stellar_address_from_pubkey(&self) -> Result { + let raw_pubkey = + base64_decode(&self.pubkey).map_err(|e| SignerError::KeyError(e.to_string()))?; + let public_key_bytes: [u8; 32] = raw_pubkey.as_slice().try_into().map_err(|_| { + SignerError::KeyError(format!( + "Invalid Stellar Vault Transit public key length: expected 32 bytes, got {}", + raw_pubkey.len() + )) + })?; + + let stellar_address = stellar_strkey::ed25519::PublicKey(public_key_bytes).to_string(); + Ok(Address::Stellar(stellar_address)) + } + + /// Requests a signature from Vault Transit and validates the Ed25519 length. + async fn sign_hash(&self, hash: &[u8]) -> Result, SignerError> { + let vault_signature_str = self.vault_service.sign(&self.key_name, hash).await?; + + debug!(vault_signature_str = %vault_signature_str, "vault signature string"); + + let base64_sig = vault_signature_str + .strip_prefix("vault:v1:") + .unwrap_or(&vault_signature_str); + + let signature_bytes = base64_decode(base64_sig) + .map_err(|e| SignerError::SigningError(format!("Failed to decode signature: {e}")))?; + + if signature_bytes.len() != 64 { + return Err(SignerError::SigningError(format!( + "Vault Transit returned invalid Ed25519 signature length: expected 64 bytes, got {}", + signature_bytes.len() + ))); + } + + Ok(signature_bytes) + } + + /// Signs a parsed Stellar envelope using Vault Transit. + async fn sign_envelope( + &self, + envelope: &TransactionEnvelope, + network_id: &Hash, + ) -> Result { + let payload = create_signature_payload(envelope, network_id) + .map_err(|e| SignerError::SigningError(format!("Failed to create payload: {e}")))?; + + let payload_bytes = payload + .to_xdr(Limits::none()) + .map_err(|e| SignerError::SigningError(format!("Failed to serialize payload: {e}")))?; + + let hash = Sha256::digest(&payload_bytes); + let signature_bytes = self.sign_hash(&hash).await?; + + self.create_decorated_signature(signature_bytes).await + } + + /// Signs an operations-based Stellar transaction by constructing its signature payload. + async fn sign_transaction_directly( + &self, + transaction: &Transaction, + network_id: &Hash, + ) -> Result { + let payload = create_transaction_signature_payload(transaction, network_id); + + let payload_bytes = payload + .to_xdr(Limits::none()) + .map_err(|e| SignerError::SigningError(format!("Failed to serialize payload: {e}")))?; + + let hash = Sha256::digest(&payload_bytes); + let signature_bytes = self.sign_hash(&hash).await?; + + self.create_decorated_signature(signature_bytes).await + } + + /// Wraps raw signature bytes with the cached Stellar signature hint. + async fn create_decorated_signature( + &self, + signature_bytes: Vec, + ) -> Result { + let hint = self.get_signature_hint().await?; + let signature_bytes_m = + soroban_rs::xdr::BytesM::try_from(signature_bytes).map_err(|_| { + SignerError::SigningError( + "Failed to convert signature to BytesM format".to_string(), + ) + })?; + + Ok(DecoratedSignature { + hint, + signature: Signature(signature_bytes_m), + }) + } + + /// Computes and caches the Stellar signature hint derived from the configured public key. + async fn get_signature_hint(&self) -> Result { + self.cached_hint + .get_or_try_init(|| async { + let address = self.stellar_address_from_pubkey()?; + super::derive_signature_hint(&address) + }) + .await + .cloned() + } +} + +#[async_trait] +impl Signer for VaultTransitSigner { + /// Returns the Stellar address derived from the configured Vault Transit public key. + async fn address(&self) -> Result { + self.stellar_address_from_pubkey() + } + + /// Signs Stellar transaction data using Vault Transit for either operations or XDR inputs. + async fn sign_transaction( + &self, + tx: NetworkTransactionData, + ) -> Result { + let stellar_data = tx + .get_stellar_transaction_data() + .map_err(|e| SignerError::SigningError(format!("Failed to get tx data: {e}")))?; + + let passphrase = &stellar_data.network_passphrase; + let hash_bytes: [u8; 32] = Sha256::digest(passphrase.as_bytes()).into(); + let network_id = Hash(hash_bytes); + + let signature = match &stellar_data.transaction_input { + crate::models::TransactionInput::Operations(_) => { + let transaction = Transaction::try_from(stellar_data).map_err(|e| { + SignerError::SigningError(format!( + "Failed to build Stellar transaction from operations: {e}" + )) + })?; + + self.sign_transaction_directly(&transaction, &network_id) + .await? + } + crate::models::TransactionInput::UnsignedXdr(xdr) + | crate::models::TransactionInput::SignedXdr { xdr, .. } + | crate::models::TransactionInput::SorobanGasAbstraction { xdr, .. } => { + let envelope = + TransactionEnvelope::from_xdr_base64(xdr, Limits::none()).map_err(|e| { + SignerError::SigningError(format!( + "Failed to parse Stellar transaction XDR '{}...': {}", + &xdr[..std::cmp::min(50, xdr.len())], + e + )) + })?; + + self.sign_envelope(&envelope, &network_id).await? + } + }; + + Ok(SignTransactionResponse::Stellar( + crate::domain::SignTransactionResponseStellar { signature }, + )) + } +} + +#[async_trait] +impl StellarSignTrait for VaultTransitSigner { + /// Signs an unsigned Stellar XDR envelope and returns the signed XDR plus decorated signature. + async fn sign_xdr_transaction( + &self, + unsigned_xdr: &str, + network_passphrase: &str, + ) -> Result { + debug!("Signing Stellar XDR transaction with Vault Transit"); + + let mut envelope = parse_transaction_xdr(unsigned_xdr, false) + .map_err(|e| SignerError::SigningError(format!("Invalid XDR: {e}")))?; + + let hash_bytes: [u8; 32] = Sha256::digest(network_passphrase.as_bytes()).into(); + let network_id = Hash(hash_bytes); + + let signature = self.sign_envelope(&envelope, &network_id).await?; + + attach_signatures_to_envelope(&mut envelope, vec![signature.clone()]) + .map_err(|e| SignerError::SigningError(format!("Failed to attach signature: {e}")))?; + + let signed_xdr = envelope.to_xdr_base64(Limits::none()).map_err(|e| { + SignerError::SigningError(format!("Failed to serialize signed XDR: {e}")) + })?; + + Ok(SignXdrTransactionResponseStellar { + signed_xdr, + signature, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + models::{ + LocalSignerConfig, SecretString, SignerConfig, StellarTransactionData, + TransactionInput, VaultTransitSignerConfig, + }, + services::{vault::VaultError, MockVaultServiceTrait}, + }; + use base64::Engine; + use mockall::predicate::*; + use secrets::SecretVec; + use soroban_rs::xdr::{SequenceNumber, TransactionV0, TransactionV0Envelope, Uint256}; + + /// Returns deterministic public key bytes for Vault Transit unit tests. + fn create_test_public_key_bytes() -> [u8; 32] { + [7u8; 32] + } + + /// Encodes the deterministic test public key as base64, matching Vault output format. + fn create_test_pubkey_base64() -> String { + base64::engine::general_purpose::STANDARD.encode(create_test_public_key_bytes()) + } + + /// Converts the deterministic test public key into a Stellar account address. + fn create_test_address() -> String { + stellar_strkey::ed25519::PublicKey(create_test_public_key_bytes()).to_string() + } + + /// Builds a valid Vault Transit signer model for Stellar unit tests. + fn create_test_signer_model() -> SignerDomainModel { + SignerDomainModel { + id: "test-vault-transit-signer".to_string(), + config: SignerConfig::VaultTransit(VaultTransitSignerConfig { + key_name: "transit-key".to_string(), + address: "https://vault.example.com".to_string(), + namespace: None, + role_id: SecretString::new("role-123"), + secret_id: SecretString::new("secret-456"), + pubkey: create_test_pubkey_base64(), + mount_point: None, + }), + } + } + + /// Creates a minimal unsigned Stellar XDR envelope for signing tests. + fn create_unsigned_xdr(source_address: &str) -> String { + let source_pk = stellar_strkey::ed25519::PublicKey::from_string(source_address).unwrap(); + let tx = TransactionV0 { + source_account_ed25519: Uint256(source_pk.0), + fee: 100, + seq_num: SequenceNumber(1), + time_bounds: None, + memo: soroban_rs::xdr::Memo::None, + operations: vec![].try_into().unwrap(), + ext: soroban_rs::xdr::TransactionV0Ext::V0, + }; + + let envelope = TransactionEnvelope::TxV0(TransactionV0Envelope { + tx, + signatures: vec![].try_into().unwrap(), + }); + + envelope.to_xdr_base64(Limits::none()).unwrap() + } + + #[test] + /// Verifies that constructor state is copied from Vault Transit config. + fn test_new_with_service() { + let model = create_test_signer_model(); + let mock_vault_service = MockVaultServiceTrait::new(); + + let signer = VaultTransitSigner::new_with_service(&model, mock_vault_service).unwrap(); + + assert_eq!(signer.key_name, "transit-key"); + assert_eq!(signer.pubkey, create_test_pubkey_base64()); + } + + #[test] + /// Verifies that missing Vault Transit config surfaces as a configuration error. + fn test_new_with_service_missing_config_returns_error() { + let model = SignerDomainModel { + id: "test-vault-transit-signer".to_string(), + config: SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1u8; 32])), + }), + }; + let mock_vault_service = MockVaultServiceTrait::new(); + + let result = VaultTransitSigner::new_with_service(&model, mock_vault_service); + + assert!(matches!( + result, + Err(SignerError::Configuration(ref msg)) if msg == "vault transit config not found" + )); + } + + #[tokio::test] + /// Verifies that address resolution returns the expected Stellar StrKey. + async fn test_address_returns_stellar_strkey() { + let mock_vault_service = MockVaultServiceTrait::new(); + let signer = VaultTransitSigner::new_for_testing( + "test-key".to_string(), + create_test_pubkey_base64(), + mock_vault_service, + ); + + let result = signer.address().await.unwrap(); + assert_eq!(result, Address::Stellar(create_test_address())); + } + + #[tokio::test] + /// Verifies that a signed XDR response contains a decorated signature and signed envelope. + async fn test_sign_xdr_transaction_success() { + let test_address = create_test_address(); + let unsigned_xdr = create_unsigned_xdr(&test_address); + + let mut mock_vault_service = MockVaultServiceTrait::new(); + mock_vault_service + .expect_sign() + .times(1) + .with(eq("transit-key"), always()) + .returning(|_, _| { + let signature = vec![1u8; 64]; + let encoded = base64::engine::general_purpose::STANDARD.encode(signature); + Box::pin(async move { Ok(format!("vault:v1:{encoded}")) }) + }); + + let signer = VaultTransitSigner::new_for_testing( + "transit-key".to_string(), + create_test_pubkey_base64(), + mock_vault_service, + ); + + let result = signer + .sign_xdr_transaction(&unsigned_xdr, "Test SDF Network ; September 2015") + .await + .unwrap(); + + assert!(!result.signed_xdr.is_empty()); + assert_eq!(result.signature.hint.0.len(), 4); + assert_eq!(result.signature.signature.0.len(), 64); + + let signed_envelope = + TransactionEnvelope::from_xdr_base64(&result.signed_xdr, Limits::none()).unwrap(); + match signed_envelope { + TransactionEnvelope::TxV0(v0_env) => assert_eq!(v0_env.signatures.len(), 1), + _ => panic!("Expected V0 envelope"), + } + } + + #[tokio::test] + /// Verifies that invalid signature sizes returned by Vault Transit are rejected. + async fn test_sign_transaction_invalid_signature_length() { + let mut mock_vault_service = MockVaultServiceTrait::new(); + mock_vault_service.expect_sign().times(1).returning(|_, _| { + let encoded = base64::engine::general_purpose::STANDARD.encode(vec![2u8; 32]); + Box::pin(async move { Ok(format!("vault:v1:{encoded}")) }) + }); + + let signer = VaultTransitSigner::new_for_testing( + "transit-key".to_string(), + create_test_pubkey_base64(), + mock_vault_service, + ); + + let tx_data = StellarTransactionData { + source_account: create_test_address(), + fee: Some(100), + sequence_number: Some(1), + transaction_input: TransactionInput::Operations(vec![]), + memo: None, + valid_until: None, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + signatures: Vec::new(), + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + let result = signer + .sign_transaction(NetworkTransactionData::Stellar(tx_data)) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + SignerError::SigningError(msg) => { + assert!(msg.contains("invalid Ed25519 signature length")); + assert!(msg.contains("expected 64 bytes, got 32")); + } + other => panic!("Expected SigningError, got {other:?}"), + } + } + + #[tokio::test] + /// Verifies that Vault signing failures propagate through transaction signing. + async fn test_sign_propagates_vault_error() { + let mut mock_vault_service = MockVaultServiceTrait::new(); + mock_vault_service.expect_sign().times(1).returning(|_, _| { + Box::pin(async move { Err(VaultError::SigningError("vault unavailable".into())) }) + }); + + let signer = VaultTransitSigner::new_for_testing( + "transit-key".to_string(), + create_test_pubkey_base64(), + mock_vault_service, + ); + + let tx_data = StellarTransactionData { + source_account: create_test_address(), + fee: Some(100), + sequence_number: Some(1), + transaction_input: TransactionInput::Operations(vec![]), + memo: None, + valid_until: None, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + signatures: Vec::new(), + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + let result = signer + .sign_transaction(NetworkTransactionData::Stellar(tx_data)) + .await; + + assert!(result.is_err()); + } +}