diff --git a/.github/workflows/secrets-vault.yml b/.github/workflows/secrets-vault.yml new file mode 100644 index 0000000..e6061de --- /dev/null +++ b/.github/workflows/secrets-vault.yml @@ -0,0 +1,35 @@ +name: Secrets Vault + +permissions: + contents: read + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +defaults: + run: + shell: bash + working-directory: ./secrets/secrets-vault + +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..d4b06c5 --- /dev/null +++ b/secrets/secrets-vault/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "secrets-vault" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = { version = "1.0.86", default-features = false, features = ["std"] } +# TODO: re-enable upstream async-nats once it supports the feature set needed by wRPC. +# async-nats = { version = "0.35", default-features = false, features = ["ring", "server_2_10"] } +async-nats = { package = "async-nats-wrpc", version = "0.35.1", features = ["ring", "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", "env-filter"] } +vaultrs = { version = "0.7.2", default-features = false, features = ["rustls"] } +wasmcloud-secrets-client = { version = "*", 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.20.0", default-features = false } diff --git a/secrets/secrets-vault/README.md b/secrets/secrets-vault/README.md new file mode 100644 index 0000000..2d5d37d --- /dev/null +++ b/secrets/secrets-vault/README.md @@ -0,0 +1,33 @@ +# 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 2][vault-kv2-secrets]. + +## How it works + +When the Secrets Vault Server starts, it does the following: + +1. Subscribes to NATS messages on the configured wasmCloud secrets subject (`wasmcloud.secrets.v0.` by default) for Server Xkey requests on `.server_xkey` and for Secret Requests from wasmCloud hosts on `.get`. +2. Starts serving a JWKS endpoint (on `http:///.well-known/keys`) that lists JWKs used to sign the authentication requests sent to Vault for fetching the secrets described in incoming `SecretRequest`s. + +### Life of a `SecretRequest` + +![Life of a SecretRequest](./static/life-of-a-secretrequest.png) + +When the server receives a `SecretRequest` via the NATS subject (`wasmcloud.secrets.v0..get`), it runs through the following order of operations: + +1. wasmCloud Host sends a `SecretRequest` to Secrets Vault Server. +2. Secrets Vault Server attempts to decrypt the `SecretRequest` using it's own XKey and the requesting wasmCloud Host's public key attached to the request and proceeds to validate the attached Host and Entity claims. + * Entity refers to either a Component or Provider depending on which the Host is making the SecretRequest for. +3. Secrets Vault Server calls Vault with the [`jwt` authentication method][vault-jwt-auth] using a JWT token derived from the claims attached to the `SecretRequest`. See [Vault Authentication][] section for more details. +4. Vault validates that the authentication JWT has been signed with keys listed on the JWKS endpoint served by the Secret Vault Server and then matches the attached claims in JWT against a set of pre-configured [bound claims configuration][vault-bound-claims]. +5. Once Vault has successfully validated the authentication JWT and succesfully matched it against a role, Vault responds with a client token for the Secrets Vault Server to use for fetching secrets. +6. Secrets Vault Server will then attempt to access the secret by referencing `name` and optionally the `version` fields stored in the `SecretRequest`. The `name` field is expected to represent a combination of the KeyValue (version 2) engine mount and the actual path where the secret is stored, represented as `/`. + * For example, using the `Usage` section from [Vault's documentation for KV Secrets Engine version 2][vault-kv2-usage] if a secret is stored under mount of `secret` and named `my-secret`, the `name` value in the `SecretRequest` should be set to `secret/my-secret`. +7. Once Secrets Vault Server is able to successfully access the secret from Vault, it will serialize the stored secret data along with the Vault's secret version in a `SecretResponse`, encrypt the resulting payload using it's configured XKey and the wasmCloud Host's public key so that only the wasmCloud host that requested the secret can decrypt it and respond back to the requesting wasmCloud Host with the encrypted payload. + + +[vault-bound-claims]: https://developer.hashicorp.com/vault/docs/auth/jwt#bound-claims +[vault-jwt-auth]: https://developer.hashicorp.com/vault/docs/auth/jwt#jwt-authentication +[vault-kv2-secrets]: https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2 +[vault-kv2-usage]: https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2#usage +[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..9eee095 --- /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 { + pub prefix: String, + pub 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..a1a6880 --- /dev/null +++ b/secrets/secrets-vault/src/main.rs @@ -0,0 +1,139 @@ +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-address", env = "SV_NATS_ADDRESS")] + pub address: String, + + /// JWT for authenticating the NATS connection + #[arg(long = "nats-jwt", env = "SV_NATS_JWT", requires = "seed")] + pub jwt: Option, + + /// NATS Seed for signing the nonce for JWT authentication. Can be the same as server-nkey-seed. + #[arg(long = "nats-seed", env = "SV_NATS_SEED")] + pub seed: Option, +} + +impl NatsClientOpts { + pub(crate) async fn into_nats_client(self) -> anyhow::Result { + let mut options = async_nats::ConnectOptions::new(); + if self.jwt.is_some() && self.seed.is_some() { + let keypair = std::sync::Arc::new(KeyPair::from_seed(&self.seed.unwrap())?); + options = options.jwt(self.jwt.unwrap(), move |nonce| { + let kp = keypair.clone(); + async move { kp.sign(&nonce).map_err(async_nats::AuthError::new) } + }); + } + Ok(async_nats::connect_with_options(self.address, options).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 = match KeyPair::from_seed(&args.secrets_server_opts.nkey_seed) { + Ok(nk) => nk, + Err(e) => anyhow::bail!("Could not parse provided NKey: {e}"), + }; + let xkey = match XKey::from_seed(&args.secrets_server_opts.xkey_seed) { + Ok(nk) => nk, + Err(e) => anyhow::bail!("Could not parse provided XKey: {e}"), + }; + + let subject_mapper = SubjectMapper::new( + &args.secrets_server_opts.prefix, + &args.secrets_server_opts.service_name, + )?; + + let nats_client = match args.nats_client_opts.into_nats_client().await { + Ok(nc) => nc, + Err(e) => anyhow::bail!("Could not connect to NATS with the provided configuration: {e}"), + }; + + 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, + ); + + backend.serve().await?; + Ok(()) +} diff --git a/secrets/secrets-vault/static/life-of-a-secretrequest.png b/secrets/secrets-vault/static/life-of-a-secretrequest.png new file mode 100644 index 0000000..7a20369 Binary files /dev/null and b/secrets/secrets-vault/static/life-of-a-secretrequest.png differ diff --git a/secrets/secrets-vault/tests/integration.rs b/secrets/secrets-vault/tests/integration.rs new file mode 100644 index 0000000..36d4d5e --- /dev/null +++ b/secrets/secrets-vault/tests/integration.rs @@ -0,0 +1,334 @@ +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, ImageExt, +}; +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!("0.0.0.0:{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!("0.0.0.0:{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, resolve_jwks_url(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 nats_wrpc_client = async_nats::connect(nats_address) + //.await + //.expect("should be able to create a connection to nats"); + 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("0.0.0.0: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.into()) + .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.into()) + .with_wait_for(WaitFor::message_on_stdout("==> Vault server started!")) + .with_env_var("VAULT_DEV_ROOT_TOKEN_ID", root_token); + Ok(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_url: String) -> Result<()> { + // 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(()) +} + +// Resolves the platform-specific endpoint where the docker containers can +// reach the host in order for the Vault Server running inside the docker +// container can connect to the JWKS endpoint exposed by the Vault Secret Backend. +fn resolve_jwks_url(jwks_port: u16) -> String { + // TODO: Add the option to provide a configuration option via environment + // variable, or fall back to one of the OS-specific defaults below. + #[cfg(target_os = "linux")] + { + // Default bridge network IP set up by Docker: + // https://docs.docker.com/network/network-tutorial-standalone/#use-the-default-bridge-network + format!("http://172.17.0.1:{}/.well-known/keys", jwks_port) + } + #[cfg(target_os = "macos")] + { + // Magic hostname set up by Docker for Mac Desktop: + // https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host + format!("http://host.docker.internal:{}/.well-known/keys", jwks_port) + } +}