diff --git a/.github/workflows/secrets-vault.yml b/.github/workflows/secrets-vault.yml new file mode 100644 index 0000000..76dd88b --- /dev/null +++ b/.github/workflows/secrets-vault.yml @@ -0,0 +1,27 @@ +name: Secrets Vault + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Lint + run: | + cargo clippy -- --no-deps + + - name: Test + run: | + cargo test + + - name: Build + run: | + cargo build --release diff --git a/secrets/secrets-vault/Cargo.toml b/secrets/secrets-vault/Cargo.toml new file mode 100644 index 0000000..61f61f8 --- /dev/null +++ b/secrets/secrets-vault/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "secrets-vault" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = { version = "1.0.86", default-features = false, features = ["std"] } +async-nats = { version = "0.33", default-features = false, features = ["server_2_10"] } +axum = { version = "0.7.5", default-features = false, features = ["http1", "json", "tokio", "tracing"] } +bytes = { version = "1", default-features = false } +clap = { version = "4.5.4", features = ["derive", "env", "string"] } +ed25519-dalek = { version = "2.1.1", features = ["alloc", "pkcs8"] } +futures = { version = "0.3.30", default-features = false, features = [] } +jsonwebtoken = { version = "9.3.0" } +nkeys = { git = "https://github.com/wasmcloud/nkeys.git", features = ["jwk", "xkeys"], rev = "7e52c00793bd1c10a0fb8ad17acdf6e6e1f76ae6" } +nkeys_041 = { package = "nkeys", version = "0.4.1", features = ["xkeys"] } +serde = { version = "1.0.203", default-features = false, features = ["std"] } +serde_json = { version = "1.0.117", default-features = false, features = ["std"] } +tokio = { version = "1.38.0", default-features = false, features = ["full"] } +tracing = { version = "0.1.40", default-features = false, features = [] } +tracing-subscriber = { version = "0.3.18", default-features = false, features = ["fmt"] } +vaultrs = { version = "0.7.2", default-features = false, features = ["rustls"] } +wasmcloud-secrets-client = { version = "0.1.0", git = "https://github.com/wasmcloud/wasmCloud.git", branch = "feat/host-secrets-impl" } +wasmcloud-secrets-types = { version = "0.1.0", git = "https://github.com/wasmcloud/wasmCloud.git", branch = "feat/host-secrets-impl" } +wascap = { version = "0.15.0" } + +[dev-dependencies] +testcontainers = { version = "0.17.0", default-features = false } diff --git a/secrets/secrets-vault/README.md b/secrets/secrets-vault/README.md new file mode 100644 index 0000000..a67472f --- /dev/null +++ b/secrets/secrets-vault/README.md @@ -0,0 +1,7 @@ +# secrets-vault + +[wasmCloud Secrets][wasmcloud-secrets] Server implementation for HashiCorp Vault that uses the [JWT Auth method][vault-jwt-auth] to fetch secrets stored in the [KV Secrets Engine - version][vault-kv-secrets]. + +[vault-jwt-auth]: https://developer.hashicorp.com/vault/docs/auth/jwt +[vault-kv-secrets]: https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2 +[wasmcloud-secrets]: https://github.com/wasmCloud/wasmCloud/issues/2190 \ No newline at end of file diff --git a/secrets/secrets-vault/src/jwks.rs b/secrets/secrets-vault/src/jwks.rs new file mode 100644 index 0000000..de4f460 --- /dev/null +++ b/secrets/secrets-vault/src/jwks.rs @@ -0,0 +1,58 @@ +use anyhow::Result; +use axum::{extract::State, http::StatusCode, routing::get, Json, Router}; +use nkeys::{JsonWebKey, KeyPair}; +use serde::{Deserialize, Serialize}; +use std::{net::SocketAddrV4, sync::Arc}; + +#[derive(Debug)] +pub(crate) struct VaultSecretsJwksServer { + keys: Vec, + listen_address: SocketAddrV4, +} + +struct SharedState { + keys: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct JwksResponse { + keys: Vec, +} + +impl VaultSecretsJwksServer { + pub fn new(nkeys: Vec, listen_address: SocketAddrV4) -> Result { + let mut keys = vec![]; + for kp in nkeys { + keys.push(JsonWebKey::try_from(kp)?); + } + Ok(Self { + keys, + listen_address, + }) + } + + pub async fn serve(&self) -> Result<()> { + let state = Arc::new(SharedState { + keys: self.keys.clone(), + }); + let app = Router::new() + .route("/.well-known/keys", get(handle_well_known_keys)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(self.listen_address).await?; + axum::serve(listener, app).await?; + + Ok(()) + } +} + +async fn handle_well_known_keys( + State(state): State>, +) -> (StatusCode, Json) { + ( + StatusCode::OK, + Json(JwksResponse { + keys: state.keys.clone(), + }), + ) +} diff --git a/secrets/secrets-vault/src/lib.rs b/secrets/secrets-vault/src/lib.rs new file mode 100644 index 0000000..75fcb0a --- /dev/null +++ b/secrets/secrets-vault/src/lib.rs @@ -0,0 +1,482 @@ +use anyhow::Result; +use async_nats::{HeaderMap, Message, Subject}; +use ed25519_dalek::pkcs8::EncodePrivateKey; +use futures::StreamExt; +use jsonwebtoken::{get_current_timestamp, Algorithm, EncodingKey}; +use nkeys::{KeyPair, XKey}; +use serde::{Deserialize, Serialize}; +use std::{net::SocketAddrV4, result::Result as StdResult, str::FromStr}; +use tracing::debug; +use vaultrs::{ + api::kv2::{requests::ReadSecretRequest, responses::ReadSecretResponse}, + client::{Client, VaultClient, VaultClientSettingsBuilder}, +}; +use wascap::{ + jwt::{CapabilityProvider, Component, Host}, + prelude::Claims, +}; +use wasmcloud_secrets_types::{ + GetSecretError, Secret, SecretRequest, SecretResponse, RESPONSE_XKEY, WASMCLOUD_HOST_XKEY, +}; + +mod jwks; +use jwks::VaultSecretsJwksServer; + +const SECRETS_API_VERSION: &str = "v0"; + +#[derive(Debug, PartialEq)] +pub(crate) enum Operation { + Get, + ServerXkey, +} + +impl FromStr for Operation { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "get" => Ok(Self::Get), + "server_xkey" => Ok(Self::ServerXkey), + subject => { + anyhow::bail!("unsupported subject: {subject:?}") + } + } + } +} + +struct SecretRequestClaims { + host: Claims, + component: Option>, + provider: Option>, +} + +impl TryFrom<&SecretRequest> for SecretRequestClaims { + type Error = &'static str; + + fn try_from(request: &SecretRequest) -> StdResult { + let host = Claims::::decode(&request.context.host_jwt) + .map_err(|_| "failed to decode host claims")?; + + let component = Claims::::decode(&request.context.entity_jwt); + let provider = Claims::::decode(&request.context.entity_jwt); + + if component.is_err() && provider.is_err() { + return Err("failed to decode component and provider claims"); + } + + Ok(Self { + host, + component: component.ok(), + provider: provider.ok(), + }) + } +} + +impl SecretRequestClaims { + pub(crate) fn entity_id(&self) -> String { + if let Some(component) = &self.component { + component.id.clone() + } else if let Some(provider) = &self.provider { + provider.id.clone() + } else { + "Unknown".to_string() + } + } +} + +#[derive(Serialize, Deserialize)] +struct VaultAuthClaims { + aud: String, + iss: String, + sub: String, + exp: u64, + host: Claims, + #[serde(skip_serializing_if = "Option::is_none")] + component: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + provider: Option>, +} + +struct VaultSecretRef { + engine_mount_path: String, + secret_path: String, + version: Option, +} + +impl TryFrom<&SecretRequest> for VaultSecretRef { + type Error = &'static str; + + fn try_from(request: &SecretRequest) -> StdResult { + let (engine_mount_path, secret_path) = request + .name + .trim_end_matches('/') + .split_once('/') + .map(|s| (s.0.to_string(), s.1.to_string())) + .ok_or( + "unable to parse secret engine mount and secret path for requested secret name", + )?; + + let version = if let Some(version) = request.version.clone() { + version + .parse::() + .map(Some) + .map_err(|_| "unable to convert requested version to integer")? + } else { + None + }; + + Ok(Self { + engine_mount_path, + secret_path, + version, + }) + } +} + +pub struct SubjectMapper { + prefix: String, + service_name: String, +} + +impl SubjectMapper { + pub fn new(prefix: &str, service_name: &str) -> Result { + Ok(Self { + prefix: prefix.to_string(), + service_name: service_name.to_string(), + }) + } + + fn queue_group_name(&self) -> String { + format!("{}.{}", self.prefix, self.service_name) + } + + fn secrets_subject(&self) -> String { + format!( + "{}.{}.{}", + self.prefix, SECRETS_API_VERSION, self.service_name + ) + } + + fn secrets_wildcard_subject(&self) -> String { + format!("{}.>", self.secrets_subject()) + } +} + +pub struct VaultSecretsBackend { + nats_client: async_nats::Client, + nkey: nkeys::KeyPair, + xkey: nkeys::XKey, + jwks_address: SocketAddrV4, + subject_mapper: SubjectMapper, + vault_config: VaultConfig, +} + +pub struct VaultConfig { + pub address: String, + pub auth_mount: String, + pub default_role: Option, +} + +impl VaultSecretsBackend { + pub fn new( + nats_client: async_nats::Client, + nkey: KeyPair, + xkey: XKey, + jwks_address: SocketAddrV4, + subject_mapper: SubjectMapper, + vault_config: VaultConfig, + ) -> Self { + Self { + nats_client, + nkey, + xkey, + jwks_address, + subject_mapper, + vault_config, + } + } + + pub async fn serve(&self) -> anyhow::Result<()> { + let pk = KeyPair::from_public_key(&self.nkey.public_key())?; + tokio::spawn({ + let listen_address = self.jwks_address.to_owned(); + async move { + VaultSecretsJwksServer::new(vec![pk], listen_address) + .unwrap() + .serve() + .await + .unwrap() + } + }); + self.start_nats_subscriber().await?; + Ok(()) + } + + async fn start_nats_subscriber(&self) -> Result<()> { + debug!( + "Subscribing to messages addressed to {} under queue group {}", + self.subject_mapper.secrets_wildcard_subject(), + self.subject_mapper.queue_group_name(), + ); + + let subject_prefix = self.subject_mapper.secrets_subject(); + let mut subscriber = self + .nats_client + .queue_subscribe( + self.subject_mapper.secrets_wildcard_subject(), + self.subject_mapper.queue_group_name(), + ) + .await?; + + while let Some(message) = subscriber.next().await { + // We check to see if there's a reply inbox, otherwise just ignore the message. + let Some(reply_to) = message.reply.clone() else { + continue; + }; + + match parse_op_from_subject(&message.subject, &subject_prefix) { + Ok(Operation::Get) => { + if let Err(err) = self.handle_get_request(reply_to.clone(), message).await { + self.handle_get_request_error(reply_to, err).await; + } + } + Ok(Operation::ServerXkey) => { + self.handle_server_xkey_request(reply_to).await; + } + Err(err) => { + self.handle_unsupported_request(reply_to, err).await; + } + } + } + + Ok(()) + } + + async fn handle_unsupported_request(&self, reply_to: Subject, error: anyhow::Error) { + // TODO: handle the potential publish error + let _ = self + .nats_client + .publish(reply_to, error.to_string().into()) + .await; + } + + async fn handle_get_request_error(&self, reply_to: Subject, error: GetSecretError) { + // TODO: handle the potential publish error + let _ = self + .nats_client + .publish(reply_to, SecretResponse::from(error).into()) + .await; + } + + async fn handle_get_request( + &self, + reply_to: Subject, + message: Message, + ) -> StdResult<(), GetSecretError> { + if message.payload.is_empty() { + return Err(GetSecretError::Other("missing payload".to_string())); + } + + let host_xkey = Self::extract_host_xkey(&message)?; + + let secret_request = Self::extract_secret_request(&message, &self.xkey, &host_xkey)?; + + let request_claims = Self::validate_and_extract_claims(&secret_request)?; + + let auth_claims = VaultAuthClaims { + iss: self.nkey.public_key(), + aud: "Vault".to_string(), + sub: request_claims.entity_id(), + exp: get_current_timestamp() + 60, + host: request_claims.host, + component: request_claims.component, + provider: request_claims.provider, + }; + + let encoding_key = Self::convert_nkey_to_encoding_key(&self.nkey)?; + + let auth_jwt = Self::encode_claims_to_jwt(auth_claims, &encoding_key)?; + + let vault_client = Self::authenticate_with_vault(&self.vault_config, &auth_jwt).await?; + + let secret_ref = VaultSecretRef::try_from(&secret_request) + .map_err(|e| GetSecretError::Other(e.to_string()))?; + + let secret = Self::fetch_secret(&vault_client, secret_ref).await?; + + let secret_response = SecretResponse { + secret: Some(Secret { + name: secret_request.name, + version: secret.metadata.version.to_string(), + string_secret: Some(secret.data.to_string()), + binary_secret: None, + }), + error: None, + }; + + let response_xkey = XKey::new(); + + let encrypted = Self::encrypt_response(secret_response, &response_xkey, &host_xkey)?; + + let mut headers = HeaderMap::new(); + headers.insert(RESPONSE_XKEY, response_xkey.public_key().as_str()); + + // TODO: handle the potential publish error + let _ = self + .nats_client + .publish_with_headers(reply_to, headers, encrypted.into()) + .await; + + Ok(()) + } + + async fn handle_server_xkey_request(&self, reply_to: Subject) { + // TODO: handle the potential publish error + let _ = self + .nats_client + .publish(reply_to, self.xkey.public_key().into()) + .await; + } + + fn extract_host_xkey(message: &async_nats::Message) -> StdResult { + let wasmcloud_host_xkey = message + .headers + .clone() + .unwrap_or_default() + .get(WASMCLOUD_HOST_XKEY) + .map(|key| key.to_string()) + .ok_or_else(|| { + GetSecretError::Other(format!("missing {} header", WASMCLOUD_HOST_XKEY)) + })?; + + XKey::from_public_key(&wasmcloud_host_xkey).map_err(|_| GetSecretError::InvalidXKey) + } + + fn extract_secret_request( + message: &async_nats::Message, + recipient: &XKey, + sender: &XKey, + ) -> StdResult { + let payload = recipient + .open(&message.payload, sender) + .map_err(|_| GetSecretError::DecryptionError)?; + + serde_json::from_slice::(&payload) + .map_err(|_| GetSecretError::Other("unable to deserialize the request".to_string())) + } + + fn validate_and_extract_claims( + request: &SecretRequest, + ) -> StdResult { + // Ensure we have valid claims before we attempt to use them to fetch secrets. + request + .context + .valid_claims() + .map_err(|e| GetSecretError::InvalidEntityJWT(e.to_string()))?; + + SecretRequestClaims::try_from(request) + .map_err(|e| GetSecretError::InvalidEntityJWT(e.to_string())) + } + + fn convert_nkey_to_encoding_key(nkey: &KeyPair) -> StdResult { + let seed = nkey + .seed() + .map_err(|_| GetSecretError::Other("failed to access nkey seed".to_string()))?; + + let (_prefix, seed_bytes) = nkeys::decode_seed(&seed) + .map_err(|_| GetSecretError::Other("unable to decode nkey seed".to_string()))?; + + let secret_document = ed25519_dalek::SigningKey::from_bytes(&seed_bytes) + .to_pkcs8_der() + .map_err(|_| { + GetSecretError::Other("failed to generate signing for encoding".to_string()) + })?; + + Ok(EncodingKey::from_ed_der(secret_document.as_bytes())) + } + + fn encode_claims_to_jwt( + claims: VaultAuthClaims, + encoding_key: &EncodingKey, + ) -> StdResult { + jsonwebtoken::encode( + &jsonwebtoken::Header::new(Algorithm::EdDSA), + &claims, + encoding_key, + ) + .map_err(|_| GetSecretError::Other("failed to encode claims to jwt".to_string())) + } + + async fn authenticate_with_vault( + vault_config: &VaultConfig, + jwt: &str, + ) -> StdResult { + let vault_client_settings = VaultClientSettingsBuilder::default() + .address(vault_config.address.clone()) + .build() + .map_err(|_| { + GetSecretError::Other("failed to initialize vault client settings".into()) + })?; + + let mut vault_client = VaultClient::new(vault_client_settings) + .map_err(|_| GetSecretError::Other("failed to initialize Vault client".into()))?; + + // Authenticate against Vault + let auth = vaultrs::auth::oidc::login( + &vault_client, + &vault_config.auth_mount, + jwt, + vault_config.default_role.clone(), + ) + .await + .map_err(|e| GetSecretError::UpstreamError(e.to_string()))?; + + // Use the returned token + vault_client.set_token(&auth.client_token); + + Ok(vault_client) + } + + async fn fetch_secret( + vault_client: &VaultClient, + secret_ref: VaultSecretRef, + ) -> Result { + let request = ReadSecretRequest::builder() + .mount(secret_ref.engine_mount_path) + .path(secret_ref.secret_path) + .version(secret_ref.version) + .build() + .unwrap(); + + vaultrs::api::exec_with_result(vault_client, request) + .await + .map_err(|e| GetSecretError::UpstreamError(e.to_string())) + } + + fn encrypt_response( + response: SecretResponse, + sender: &XKey, + recipient: &XKey, + ) -> Result, GetSecretError> { + let encoded = serde_json::to_vec(&response) + .map_err(|_| GetSecretError::Other("unable to encode secret response".to_string()))?; + + sender + .seal(&encoded, recipient) + .map_err(|_| GetSecretError::Other("unable to encrypt secret response".to_string())) + } +} + +fn parse_op_from_subject(subject: &str, subject_prefix: &str) -> anyhow::Result { + let partial = subject + .trim_start_matches(subject_prefix) + .trim_start_matches('.') + .split('.') + .collect::>(); + + if partial.len() > 1 { + anyhow::bail!("unsupported request: {:?}", partial.join(".")); + } + + partial[0].parse() +} diff --git a/secrets/secrets-vault/src/main.rs b/secrets/secrets-vault/src/main.rs new file mode 100644 index 0000000..1856e93 --- /dev/null +++ b/secrets/secrets-vault/src/main.rs @@ -0,0 +1,122 @@ +use std::net::SocketAddrV4; + +use clap::{command, Parser}; +use nkeys::{KeyPair, XKey}; +use secrets_vault::{SubjectMapper, VaultConfig, VaultSecretsBackend}; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + #[command(flatten)] + pub nats_client_opts: NatsClientOpts, + + #[command(flatten)] + pub secrets_server_opts: SecretsServerOpts, + + #[command(flatten)] + pub vault_opts: VaultOpts, +} + +#[derive(Parser, Debug)] +struct NatsClientOpts { + /// NATS Server address to connect to listen for secrets requests. + #[arg(long = "nats-server", env = "SV_NATS_SERVER")] + pub server: String, +} + +impl NatsClientOpts { + pub(crate) async fn into_nats_client( + self, + ) -> Result { + async_nats::connect(self.server).await + } +} + +#[derive(Parser, Debug)] +struct SecretsServerOpts { + /// Address for serving the JWKS endpoint, for example: 127.0.0.1:8080 + #[arg(long = "jwks-address", env = "SV_JWKS_ADDRESS")] + pub jwks_address: SocketAddrV4, + + /// Nkey to be used for representing the Server's identity to Vault. Used for JWKS and signing payloads. + #[arg(long = "server-nkey-seed", env = "SV_SECRETS_NKEY_SEED")] + pub nkey_seed: String, + + /// Xkey seed to be used to encrypt communication from hosts to the backend, this will be used to serve the public key via `server_xkey` operation. + #[arg(long = "server-xkey-seed", env = "SV_SERVER_XKEY_SEED")] + pub xkey_seed: String, + + /// Secrets subject prefix to listen on. Defaults to `wasmcloud.secrets`. + #[arg( + long = "secrets-prefix", + env = "SV_SECRETS_PREFIX", + default_value = "wasmcloud.secrets" + )] + pub prefix: String, + + /// Service name to be used to identify the subject this backend should listen on for secrets requests. + #[arg(long = "secrets-service-name", env = "SV_SERVICE_NAME")] + pub service_name: String, +} + +#[derive(Parser, Debug)] +struct VaultOpts { + /// Vault server address to connect to. + #[arg(long = "vault-address", env = "SV_VAULT_ADDRESS")] + pub vault_address: String, + + /// Vault mount path for the JWT authentication method to be used. + #[arg(long = "vault-auth-mount", env = "SV_VAULT_AUTH_MOUNT")] + pub vault_auth_mount: String, + + /// Default role to use if one is not provided in the secret request. + #[arg(long = "vault-default-role", env = "SV_VAULT_DEFAULT_ROLE")] + pub vault_default_role: Option, +} + +impl From for VaultConfig { + fn from(opts: VaultOpts) -> Self { + Self { + address: opts.vault_address, + auth_mount: opts.vault_auth_mount, + default_role: opts.vault_default_role, + } + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + tracing_subscriber::fmt::init(); + + let nkey = KeyPair::from_seed(&args.secrets_server_opts.nkey_seed) + .expect("failed to initialize NKey from provided --server-nkey-seed"); + let xkey = XKey::from_seed(&args.secrets_server_opts.xkey_seed) + .expect("failed to initialize XKey from provided --server-xkey-seed"); + + let subject_mapper = SubjectMapper::new( + &args.secrets_server_opts.prefix, + &args.secrets_server_opts.service_name, + )?; + let nats_client = args + .nats_client_opts + .into_nats_client() + .await + .expect("failed to connect to provided --nats-server"); + + let vault_config = VaultConfig::from(args.vault_opts); + + let backend = VaultSecretsBackend::new( + nats_client, + nkey, + xkey, + args.secrets_server_opts.jwks_address, + subject_mapper, + vault_config, + ); + + // TODO: Accept argument for JWKS Server port to listen on + backend.serve().await?; + Ok(()) +} diff --git a/secrets/secrets-vault/tests/integration.rs b/secrets/secrets-vault/tests/integration.rs new file mode 100644 index 0000000..1be7662 --- /dev/null +++ b/secrets/secrets-vault/tests/integration.rs @@ -0,0 +1,313 @@ +use std::net::TcpListener; +use std::{collections::HashMap, net::SocketAddrV4}; + +use anyhow::Result; +use nkeys::KeyPair; +use nkeys_041::XKey as UpstreamXKey; +use secrets_vault::{SubjectMapper, VaultConfig, VaultSecretsBackend}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use testcontainers::{ + core::{Host as TestHost, WaitFor}, + runners::AsyncRunner, + ContainerAsync, GenericImage, RunnableImage, +}; +use vaultrs::{ + api::auth::oidc::requests::{SetConfigurationRequest, SetRoleRequest}, + client::{VaultClient, VaultClientSettingsBuilder}, +}; +use wascap::jwt::{Claims, ClaimsBuilder, Component, Host}; +use wasmcloud_secrets_types::{Context as SecretsContext, SecretRequest}; + +const SECRETS_BACKEND_PREFIX: &str = "wasmcloud.secrets"; +const SECRETS_SERVICE_NAME: &str = "vault-test"; +const SECRETS_ROLE_NAME: &str = "vault-test-role"; +const SECRETS_AUTH_METHOD_MOUNT: &str = "secrets-jwt"; +const SECRETS_ENGINE_MOUNT: &str = "secret"; +const SECRETS_SECRET_NAME: &str = "test-secret"; + +const NATS_SERVER_PORT: u16 = 4222; +const VAULT_SERVER_PORT: u16 = 8200; +const VAULT_ROOT_TOKEN_ID: &str = "vault-root-token-id"; + +#[derive(Serialize, Deserialize)] +struct StoredSecret { + value: String, +} + +#[tokio::test] +async fn test_server_xkey() -> Result<()> { + let xkey = nkeys::XKey::new(); + + let nats_server = start_nats().await?; + let nats_address = address_for_scheme_on_port(&nats_server, "nats", NATS_SERVER_PORT).await?; + let nats_client = async_nats::connect(nats_address) + .await + .expect("connect to nats"); + + let vault_server = start_vault(VAULT_ROOT_TOKEN_ID).await?; + let vault_address = + address_for_scheme_on_port(&vault_server, "http", VAULT_SERVER_PORT).await?; + + let jwks_port = find_open_port().await?; + let jwks_address = format!("127.0.0.1:{jwks_port}").parse::()?; + tokio::spawn({ + let vault_config = VaultConfig { + address: vault_address, + auth_mount: SECRETS_AUTH_METHOD_MOUNT.to_string(), + default_role: Some(SECRETS_ROLE_NAME.to_string()), + }; + let subject_mapper = SubjectMapper::new(SECRETS_BACKEND_PREFIX, SECRETS_SERVICE_NAME)?; + let secrets_nkey = nkeys::KeyPair::new_account(); + let secrets_xkey = nkeys::XKey::from_seed(&xkey.seed().unwrap()).unwrap(); + let secrets_nats_client = nats_client.clone(); + async move { + VaultSecretsBackend::new( + secrets_nats_client, + secrets_nkey, + secrets_xkey, + jwks_address, + subject_mapper, + vault_config, + ) + .serve() + .await + } + }); + // Give the server a second to start before we query + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + let server_xkey_subject = format!( + "{}.v0.{}.server_xkey", + SECRETS_BACKEND_PREFIX, SECRETS_SERVICE_NAME + ); + let resp = nats_client + .request(server_xkey_subject, "".into()) + .await + .expect("request server_xkey via nats"); + + let actual = + std::str::from_utf8(&resp.payload).expect("convert server_xkey response payload to str"); + let expected = xkey.public_key(); + + assert_eq!(actual, &expected); + Ok(()) +} + +#[tokio::test] +async fn test_get() -> Result<()> { + let nkey = nkeys::KeyPair::new_account(); + let xkey = nkeys::XKey::new(); + + let nats_server = start_nats().await?; + let nats_address = address_for_scheme_on_port(&nats_server, "nats", NATS_SERVER_PORT).await?; + let nats_client = async_nats::connect(nats_address) + .await + .expect("connection to nats"); + + let vault_server = start_vault(VAULT_ROOT_TOKEN_ID).await?; + let vault_address = + address_for_scheme_on_port(&vault_server, "http", VAULT_SERVER_PORT).await?; + let vault_client = VaultClient::new( + VaultClientSettingsBuilder::default() + .address(&vault_address) + .token(VAULT_ROOT_TOKEN_ID) + .build() + .expect("should build VaultClientSettings"), + ) + .expect("should initialize a VaultClient"); + + let jwks_port = find_open_port().await?; + tokio::spawn({ + let vault_config = VaultConfig { + address: vault_address, + auth_mount: SECRETS_AUTH_METHOD_MOUNT.to_string(), + default_role: Some(SECRETS_ROLE_NAME.to_string()), + }; + let jwks_address = format!("127.0.0.1:{jwks_port}").parse::()?; + let subject_mapper = SubjectMapper::new(SECRETS_BACKEND_PREFIX, SECRETS_SERVICE_NAME)?; + let vault_xkey = nkeys::XKey::from_seed(&xkey.seed().unwrap()).unwrap(); + let vault_nats_client = nats_client.clone(); + async move { + VaultSecretsBackend::new( + vault_nats_client, + nkey, + vault_xkey, + jwks_address, + subject_mapper, + vault_config, + ) + .serve() + .await + } + }); + // Give the server time to start before we query + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + configure_vault_jwt_auth(&vault_client, jwks_port).await?; + + let stored_secret = StoredSecret { + value: "this-is-a-secret".to_string(), + }; + store_secret_in_engine_at_path( + &vault_client, + &json!(stored_secret), + SECRETS_ENGINE_MOUNT, + SECRETS_SECRET_NAME, + ) + .await?; + + let wsc = wasmcloud_secrets_client::Client::new("vault-test", "wasmcloud.secrets", nats_client) + .await + .expect("should be able to instantiate wasmcloud-secrets-client"); + + // TODO remove this once wascap uses the latest version of nkeys + let claims_signer = wascap::prelude::KeyPair::new_account(); + let component_key = KeyPair::new_module(); + let host_key = KeyPair::new_server(); + let entity_claims: Claims = ClaimsBuilder::new() + .issuer(claims_signer.public_key().as_str()) + .subject(component_key.public_key().as_str()) + .build(); + let host_claims: Claims = ClaimsBuilder::new() + .issuer(claims_signer.public_key().as_str()) + .subject(host_key.public_key().as_str()) + .with_metadata(Host::new("test".to_string(), HashMap::new())) + .build(); + + let request_xkey = UpstreamXKey::new(); + let secret_request = SecretRequest { + name: format!("{}/{}", SECRETS_ENGINE_MOUNT, SECRETS_SECRET_NAME), + version: None, + context: SecretsContext { + entity_jwt: entity_claims.encode(&claims_signer).unwrap(), + host_jwt: host_claims.encode(&claims_signer).unwrap(), + application: None, + }, + }; + let secret = wsc + .get(secret_request, request_xkey) + .await + .expect("should have gotten a secret"); + + let actual: StoredSecret = serde_json::from_str(secret.string_secret.unwrap().as_str()) + .expect("should have deserialized secret.string_secret into StoredSecret"); + let expected = stored_secret.value; + + assert_eq!(actual.value, expected); + + Ok(()) +} + +async fn find_open_port() -> Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let socket_addr = listener.local_addr()?; + Ok(socket_addr.port()) +} + +async fn start_nats() -> Result> { + Ok(GenericImage::new("nats", "2.10.16-linux") + .with_exposed_port(NATS_SERVER_PORT) + .with_wait_for(WaitFor::message_on_stderr("Server is ready")) + .start() + .await + .expect("nats to start")) +} + +async fn start_vault(root_token: &str) -> Result> { + let image = GenericImage::new("hashicorp/vault", "1.16.3") + .with_exposed_port(VAULT_SERVER_PORT) + .with_wait_for(WaitFor::message_on_stdout("==> Vault server started!")) + .with_env_var("VAULT_DEV_ROOT_TOKEN_ID", root_token); + Ok(RunnableImage::from(image) + .with_host("host.docker.internal", TestHost::HostGateway) + .start() + .await + .expect("vault to start")) +} + +async fn address_for_scheme_on_port( + service: &ContainerAsync, + scheme: &str, + port: u16, +) -> Result { + Ok(format!( + "{}://{}:{}", + scheme, + service.get_host().await?, + service.get_host_port_ipv4(port).await? + )) +} + +async fn configure_vault_jwt_auth(vault_client: &VaultClient, jwks_port: u16) -> Result<()> { + let jwks_url = format!("http://host.docker.internal:{}/.well-known/keys", jwks_port); + + // vault auth enable jwt + vaultrs::sys::auth::enable(vault_client, SECRETS_AUTH_METHOD_MOUNT, "jwt", None) + .await + .unwrap_or_else(|_| { + panic!( + "should have enabled the 'jwt' auth method at '{}'", + SECRETS_AUTH_METHOD_MOUNT + ) + }); + + // vault write auth//config jwks_url="http://localhost:3000/.well-known/keys" default_role="test-role" + let mut config_builder = SetConfigurationRequest::builder(); + config_builder + .jwks_url(jwks_url.clone()) + .default_role(SECRETS_ROLE_NAME); + + vaultrs::auth::oidc::config::set(vault_client, SECRETS_AUTH_METHOD_MOUNT, Some(&mut config_builder)) + .await + .unwrap_or_else(|_| panic!("should have configured the 'jwt' auth method at '{}' with the default role '{}' and jwks_url '{}'", SECRETS_AUTH_METHOD_MOUNT, SECRETS_ROLE_NAME, jwks_url)); + + // cat role-config.json | vault write auth/jwt/role/test-role - + let user_claim = "sub"; + let allowed_redirect_uris = vec![]; + let mut role_builder = SetRoleRequest::builder(); + role_builder + .role_type("jwt") + .bound_audiences(vec!["Vault".to_string()]) + .token_policies(vec![SECRETS_ROLE_NAME.to_string()]); + vaultrs::auth::oidc::role::set( + vault_client, + SECRETS_AUTH_METHOD_MOUNT, + SECRETS_ROLE_NAME, + user_claim, + allowed_redirect_uris, + Some(&mut role_builder), + ) + .await + .unwrap_or_else(|_| { + panic!( + "should have configured the default role '{}' for 'jwt' auth method", + SECRETS_ROLE_NAME + ) + }); + + // vault policy set ... + let policy = r#" + path "secret/*" { + capabilities = ["create", "read", "update", "delete", "list"] + }"#; + vaultrs::sys::policy::set(vault_client, SECRETS_ROLE_NAME, policy) + .await + .unwrap_or_else(|_| { + panic!( + "should have set up policy for the '{}' role", + SECRETS_ROLE_NAME + ) + }); + Ok(()) +} + +async fn store_secret_in_engine_at_path( + vault_client: &VaultClient, + value: &impl Serialize, + mount: &str, + path: &str, +) -> Result<()> { + vaultrs::kv2::set(vault_client, mount, path, &value).await?; + Ok(()) +}