diff --git a/.github/workflows/secrets-vault.yml b/.github/workflows/secrets-vault.yml new file mode 100644 index 0000000..ece17d9 --- /dev/null +++ b/.github/workflows/secrets-vault.yml @@ -0,0 +1,99 @@ +name: Secrets Vault + +permissions: + contents: read + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +env: + REGISTRY: ghcr.io + IMAGE_NAME: wasmcloud/contrib/secrets-vault + +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 + + release: + if: startswith(github.ref, 'refs/tags/secrets-vault-v') # Only run on tag push + runs-on: ubuntu-latest + needs: + - check + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log into GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta_release + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=match,pattern=secrets-vault-v(.*),group=1 + + - name: Extract metadata (tags, labels) + id: meta_debug + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=match,pattern=secrets-vault-v(.*),group=1,suffix=-debug + + - name: Build and push the release image + uses: docker/build-push-action@v6 + with: + target: release + push: true + context: secrets/secrets-vault/ + tags: ${{ steps.meta_release.outputs.tags }} + labels: ${{ steps.meta_release.outputs.labels }} + platforms: linux/amd64,linux/arm64 + + - name: Build and push the debug image + uses: docker/build-push-action@v6 + with: + target: debug + push: true + context: secrets/secrets-vault/ + tags: ${{ steps.meta_debug.outputs.tags }} + labels: ${{ steps.meta_debug.outputs.labels }} + platforms: linux/amd64,linux/arm64 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6985cf1..e506f20 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb + +.DS_Store diff --git a/secrets/secrets-vault/.dockerignore b/secrets/secrets-vault/.dockerignore new file mode 100644 index 0000000..11fdfdd --- /dev/null +++ b/secrets/secrets-vault/.dockerignore @@ -0,0 +1,4 @@ +Dockerfile* +**/target +target +.cargo diff --git a/secrets/secrets-vault/Cargo.toml b/secrets/secrets-vault/Cargo.toml new file mode 100644 index 0000000..7e4bffd --- /dev/null +++ b/secrets/secrets-vault/Cargo.toml @@ -0,0 +1,31 @@ +[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"] } +data-encoding = { version = "2.6.0" } +ed25519-dalek = { version = "2.1.1", features = ["alloc", "pkcs8"] } +futures = { version = "0.3.30", default-features = false, features = [] } +jsonwebtoken = { version = "9.3.0" } +nkeys = { version = "0.4.2", features = ["xkeys"] } +serde = { version = "1.0.203", default-features = false, features = ["std"] } +serde_json = { version = "1.0.117", default-features = false, features = ["std"] } +sha2 = { version = "0.10.8" } +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-types = { version = "0.1.0" } +wascap = { version = "0.15.0" } + +[dev-dependencies] +testcontainers = { version = "0.20.0", default-features = false } +wasmcloud-secrets-client = { version = "0.1.0" } diff --git a/secrets/secrets-vault/Dockerfile b/secrets/secrets-vault/Dockerfile new file mode 100644 index 0000000..2b2ced4 --- /dev/null +++ b/secrets/secrets-vault/Dockerfile @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1.4 +FROM rust:1.79-slim-bookworm AS builder + +WORKDIR /build +COPY . /build + +RUN <` 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.v1alpha1..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 derived from the claims attached to the `SecretRequest`. +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 secrets engine mount path and role name to be used for fetching the secrets can be configured in the `policies` entry associated with the given secret. + * See the [Referencing Secrets](#referencing-secrets) section below for an example. +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. + +## How to use it + +### Install with Helm + +If you are looking to run Secrets Vault Server as part of an existing Kubernetes-based deployment, you can easily deploy it using the bundled [Helm chart][helm-chart] using the following command: + +```shell +helm install wasmcloud-secrets-vault oci://ghcr.io/wasmcloud/charts/secrets-vault + +For detailed information on the available configuration options, please see the [Helm chart README][helm-chart]. + +### Configuring Vault + +In order for Secrets Vault Server to work as intended, it will need the following to be pre-configured on the Vault Server it's talking to: + +1. Vault Server will need to an instance of the [JWT auth method enabled][vault-jwt-auth-enabled] specifically for the Secrets Vault Server. +2. The JWT auth method will need to be configured to with the [`jwks_url`][vault-jwks-url] configured to point at the JWKS endpoint exposed by the Secrets Vault Service (configured via `--jwks-address` flag `SV_JWKS_ADDRESS` environment variable). Optionally you can also include a default_role for the backend + +An example of configuring the above steps might look like this: + +```shell +# Enable jwt auth method at provided path +$ vault auth enable -path=jwt jwt + +# Configure jwt auth to point it's jwks_url at the JWKS endpoint provided by the Secrets Vault Server +# Please note that the Secrets Vault Server needs to be running in order for Vault to verify the endpoint. +$ vault write auth/jwt/config jwks_url="http://localhost:3000/.well-known/keys" + +# Create a named role with configuration from demo-role.json, see below for example. +$ vault write auth/jwt/role/demo-role @demo-role.json +``` + +Example `demo-json.role`: + +```json +{ + "role_type": "jwt", + "policies": ["demo-role-policy"], + "bound_audiences": "Vault", + "bound_claims": { + "application": ["rust-hello-world", "rust-http-kv", "tinygo-hello-world"] + }, + "user_claim": "sub" +} +``` + +Once you have enabled the jwt auth method and created a named role, you will also need to create the `demo-role-policy` policy: + +```shell +# Create the policy named demo-role-policy with the contents from stdin: +$ vault policy write demo-role-policy - << EOF +# Dev servers have version 2 of KV secrets engine mounted by default, so will +# need this path to grant permissions: +path "secret/data/*" { + capabilities = ["create", "update", "read"] +} +EOF +``` + +### Referencing Secrets + +With the role created and configured, you can now reference any secrets you write on the default `secret` path. + +To configure a wadm application to use policies, you will need to add `policies` section in your wadm manifest: + +```yaml +# ... beginning of the manifest ... +spec: + policies: + - name: vault-secrets-example + type: policy.secret.wasmcloud.dev/v1alpha1 + properties: + backend: 'vault' + role_name: 'demo-role' + mount_path: 'jwt' +# ... rest of the manifest ... +``` + +And then in your component or provider's `properties` section you will need the a `secrets` section that references the policy: + +```yaml +# ... rest of the component or provider definition ... + secrets: + - name: 'secret-name-in-your-code' + source: + policy: 'vault-secrets-example' + key: 'path/to/secret/in/vault' +# ... rest of the manifest ... +``` + +[helm-chart]: https://github.com/wasmCloud/wasmCloud-contrib/blob/main/secrets/secrets-vault/charts/secrets-vault/README.md +[vault-bound-claims]: https://developer.hashicorp.com/vault/docs/auth/jwt#bound-claims +[vault-jwks-url]: https://developer.hashicorp.com/vault/api-docs/auth/jwt#jwks_url +[vault-jwt-auth]: https://developer.hashicorp.com/vault/docs/auth/jwt#jwt-authentication +[vault-jwt-auth-enabled]: https://developer.hashicorp.com/vault/docs/auth/jwt#configuration +[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 diff --git a/secrets/secrets-vault/src/jwk.rs b/secrets/secrets-vault/src/jwk.rs new file mode 100644 index 0000000..443a734 --- /dev/null +++ b/secrets/secrets-vault/src/jwk.rs @@ -0,0 +1,147 @@ +use std::collections::BTreeMap; + +use anyhow::{Context as _, Result}; +use data_encoding::BASE64URL_NOPAD; +use ed25519_dalek::{SigningKey, VerifyingKey}; +use nkeys::{KeyPair, KeyPairType}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; + +// We hard code the value here, because we're using Edwards-curve keys, which OKP represents: +// https://datatracker.ietf.org/doc/html/draft-ietf-jose-cfrg-curves-06#section-2 +const JWK_KEY_TYPE: &str = "OKP"; +// https://datatracker.ietf.org/doc/html/draft-ietf-jose-cfrg-curves-06#section-3.1 +const JWK_ALGORITHM: &str = "EdDSA"; +const JWK_SUBTYPE: &str = "Ed25519"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonWebKey { + /// Intended use of the JWK, this is based on the KeyPairType of the KeyPair the JWK is based on, using "enc" for KeyPairType::Curve, otherwise "sig" + #[serde(rename = "use")] + intended_use: String, + /// Key type, which we default to OKP (Octet Key Pair) to represent Edwards-curve keys + #[serde(rename = "kty")] + key_type: String, + /// Key ID, which will be represented by the thumbprint calculated over the subtype (crv), key_type (kty) and public_key (x) components of the JWK. + /// See https://datatracker.ietf.org/doc/html/rfc7639 for more details. + #[serde(rename = "kid")] + key_id: String, + /// Algorithm used for the JWK, defaults to EdDSA + #[serde(rename = "alg")] + algorithm: String, + /// Subtype of the key (from the "JSON Web Elliptic Curve" registry) + #[serde(rename = "crv")] + subtype: String, + // Public key value encoded using base64url encoding + #[serde(rename = "x")] + public_key: String, + // Private key value, if provided, encoded using base64url encoding + #[serde(rename = "d", skip_serializing_if = "Option::is_none")] + private_key: Option, +} + +impl JsonWebKey { + pub fn from_seed(source: &str) -> Result { + let (prefix, seed) = nkeys::decode_seed(source)?; + let sk = SigningKey::from_bytes(&seed); + let kp_type = &KeyPairType::from(prefix); + let public_key = BASE64URL_NOPAD.encode(sk.verifying_key().as_bytes()); + let thumbprint = Self::calculate_thumbprint(&public_key)?; + + Ok(JsonWebKey { + intended_use: Self::intended_use_for_key_pair_type(kp_type), + key_id: thumbprint, + public_key, + private_key: Some(BASE64URL_NOPAD.encode(sk.as_bytes())), + ..Default::default() + }) + } + + pub fn from_public_key(source: &str) -> Result { + let (prefix, bytes) = nkeys::from_public_key(source)?; + let vk = VerifyingKey::from_bytes(&bytes)?; + let public_key = BASE64URL_NOPAD.encode(vk.as_bytes()); + let thumbprint = Self::calculate_thumbprint(&public_key)?; + + Ok(JsonWebKey { + intended_use: Self::intended_use_for_key_pair_type(&KeyPairType::from(prefix)), + key_id: thumbprint, + public_key, + ..Default::default() + }) + } + + fn intended_use_for_key_pair_type(typ: &KeyPairType) -> String { + match typ { + KeyPairType::Server + | KeyPairType::Cluster + | KeyPairType::Operator + | KeyPairType::Account + | KeyPairType::User + | KeyPairType::Module + | KeyPairType::Service => "sig".to_owned(), + KeyPairType::Curve => "enc".to_owned(), + } + } + + /// For details on how fingerprints are calculated, see: https://datatracker.ietf.org/doc/html/rfc7638#section-3.1 + /// For OKP specific details, see https://datatracker.ietf.org/doc/html/draft-ietf-jose-cfrg-curves-06#appendix-A.3 + pub fn calculate_thumbprint(public_key: &str) -> Result { + // We use BTreeMap here, because the order needs to be lexicographically sorted: + // https://datatracker.ietf.org/doc/html/rfc7638#section-3.3 + let components = BTreeMap::from([ + ("crv", JWK_SUBTYPE), + ("kty", JWK_KEY_TYPE), + ("x", public_key), + ]); + let value = json!(components); + let mut bytes: Vec = Vec::new(); + serde_json::to_writer(&mut bytes, &value).context("unable to serialize public key")?; + let mut hasher = Sha256::new(); + hasher.update(&*bytes); + let hash = hasher.finalize(); + Ok(BASE64URL_NOPAD.encode(&hash)) + } +} + +impl Default for JsonWebKey { + fn default() -> Self { + Self { + intended_use: Default::default(), + key_type: JWK_KEY_TYPE.to_string(), + key_id: Default::default(), + algorithm: JWK_ALGORITHM.to_string(), + subtype: JWK_SUBTYPE.to_string(), + public_key: Default::default(), + private_key: None, + } + } +} + +impl TryFrom for JsonWebKey { + type Error = anyhow::Error; + + fn try_from(value: KeyPair) -> Result { + if let Ok(seed) = value.seed() { + Ok(Self::from_seed(&seed)?) + } else { + Ok(Self::from_public_key(&value.public_key())?) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Using the example values from https://datatracker.ietf.org/doc/html/draft-ietf-jose-cfrg-curves-06#appendix-A.3 + #[test] + fn calculate_thumbprint_provides_correct_thumbprint() { + let input_public_key = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"; + let expected_thumbprint = "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k"; + let actual_thumbprint = JsonWebKey::calculate_thumbprint(input_public_key).unwrap(); + + assert_eq!(expected_thumbprint, actual_thumbprint); + } +} diff --git a/secrets/secrets-vault/src/jwks.rs b/secrets/secrets-vault/src/jwks.rs new file mode 100644 index 0000000..9c8647f --- /dev/null +++ b/secrets/secrets-vault/src/jwks.rs @@ -0,0 +1,59 @@ +use anyhow::Result; +use axum::{extract::State, http::StatusCode, routing::get, Json, Router}; +use nkeys::KeyPair; +use crate::jwk::JsonWebKey; +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..5a9505a --- /dev/null +++ b/secrets/secrets-vault/src/lib.rs @@ -0,0 +1,525 @@ +use anyhow::{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 jwk; +mod jwks; +use jwks::VaultSecretsJwksServer; + +const SECRETS_API_VERSION: &str = "v1alpha1"; + +#[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 RequestClaims { + host: Claims, + component: Option>, + provider: Option>, +} + +impl TryFrom<&SecretRequest> for RequestClaims { + type Error = anyhow::Error; + + fn try_from(request: &SecretRequest) -> StdResult { + let host = Claims::::decode(&request.context.host_jwt) + .map_err(|_| anyhow!("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(anyhow!("failed to decode component and provider claims")); + } + + Ok(Self { + host, + component: component.ok(), + provider: provider.ok(), + }) + } +} + +impl RequestClaims { + 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 VaultPolicy { + #[serde(alias = "roleName")] + role_name: String, + #[serde(alias = "secretEnginePath")] + secret_engine_path: Option, + namespace: Option, +} + +impl TryFrom<&SecretRequest> for VaultPolicy { + type Error = anyhow::Error; + + fn try_from(request: &SecretRequest) -> StdResult { + serde_json::from_str::(&request.context.application.policy) + .map_err(|e| anyhow!("failed to deserialize policy: {}", e.to_string())) + } +} + +#[derive(Serialize, Deserialize)] +struct VaultAuthClaims { + aud: String, + iss: String, + sub: String, + exp: u64, + application: String, + host: Claims, + #[serde(skip_serializing_if = "Option::is_none")] + component: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + provider: Option>, +} + +struct VaultSecretRef { + secret_path: String, + version: Option, +} + +impl TryFrom<&SecretRequest> for VaultSecretRef { + type Error = anyhow::Error; + + fn try_from(request: &SecretRequest) -> StdResult { + let secret_path = request.name.clone(); + let version = if let Some(version) = request.version.clone() { + version + .parse::() + .map(Some) + .map_err(|_| anyhow!("unable to convert requested version to integer"))? + } else { + None + }; + + Ok(Self { + 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 jwt_audience: String, + pub default_secret_engine: String, + pub default_namespace: 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: GetSecretError) { + // TODO: handle the potential publish error + let _ = self + .nats_client + .publish(reply_to, SecretResponse::from(error).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 policy = Self::validate_and_extract_policy(&secret_request)?; + + let auth_claims = VaultAuthClaims { + iss: self.nkey.public_key(), + aud: self.vault_config.jwt_audience.clone(), + sub: request_claims.entity_id(), + exp: get_current_timestamp() + 60, + application: secret_request + .context + .application + .name + .clone() + .unwrap_or_default(), + 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, &policy, &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, + &policy + .secret_engine_path + .unwrap_or_else(|| self.vault_config.default_secret_engine.to_owned()), + 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 {WASMCLOUD_HOST_XKEY} header")) + })?; + + 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()))?; + + RequestClaims::try_from(request) + .map_err(|e| GetSecretError::InvalidEntityJWT(e.to_string())) + } + + fn validate_and_extract_policy( + request: &SecretRequest, + ) -> StdResult { + VaultPolicy::try_from(request).map_err(|e| GetSecretError::Other(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( + config: &VaultConfig, + policy: &VaultPolicy, + jwt: &str, + ) -> StdResult { + let namespace = policy + .namespace + .clone() + .or(config.default_namespace.clone()); + let settings = VaultClientSettingsBuilder::default() + .address(config.address.clone()) + .namespace(namespace) + .build() + .map_err(|_| { + GetSecretError::Other("failed to initialize vault client settings".into()) + })?; + + let mut client = VaultClient::new(settings) + .map_err(|_| GetSecretError::Other("failed to initialize Vault client".into()))?; + + // Authenticate against Vault + let auth = vaultrs::auth::oidc::login( + &client, + &config.auth_mount, + jwt, + Some(policy.role_name.clone()), + ) + .await + .map_err(|e| GetSecretError::UpstreamError(e.to_string()))?; + + // Use the returned token + client.set_token(&auth.client_token); + + Ok(client) + } + + async fn fetch_secret( + client: &VaultClient, + mount: &str, + secret_ref: VaultSecretRef, + ) -> Result { + let request = ReadSecretRequest::builder() + .mount(mount) + .path(secret_ref.secret_path) + .version(secret_ref.version) + .build() + .unwrap(); + + vaultrs::api::exec_with_result(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) -> Result { + let partial = subject + .trim_start_matches(subject_prefix) + .trim_start_matches('.') + .split('.') + .collect::>(); + + if partial.len() > 1 { + return Err(GetSecretError::InvalidRequest); + } + + partial[0] + .parse() + .map_err(|_| GetSecretError::InvalidRequest) +} diff --git a/secrets/secrets-vault/src/main.rs b/secrets/secrets-vault/src/main.rs new file mode 100644 index 0000000..a10b5e7 --- /dev/null +++ b/secrets/secrets-vault/src/main.rs @@ -0,0 +1,160 @@ +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", requires = "jwt")] + 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_SERVER_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", + default_value = "vault" + )] + pub service_name: String, +} + +#[derive(Parser, Debug)] +struct VaultOpts { + /// Vault server address to connect to. + #[arg(long = "vault-address", env = "SV_VAULT_ADDRESS")] + pub address: String, + + /// Path where the JWT auth method is mounted + #[arg(long = "vault-auth-method", env = "SV_VAULT_AUTH_METHOD")] + pub auth_method_path: String, + + /// JWT (aud)ience value to be passed to Vault as part of authentication + #[arg( + long = "jwt-auth-audience", + env = "SV_JWT_AUTH_AUDIENCE", + default_value = "Vault" + )] + pub jwt_audience: String, + + /// Default secret engine path to use. This can be overridden on a per-request basis. + #[arg( + long = "vault-default-secret-engine", + env = "SV_VAULT_DEFAULT_SECRET_ENGINE" + )] + pub default_secret_engine: String, + + /// Optional: Default Vault namespace to use, only use this with Vault Enterprise. + #[arg(long = "vault-default-namespace", env = "SV_VAULT_DEFAULT_NAMESPACE")] + pub default_namespace: Option, +} + +impl From for VaultConfig { + fn from(opts: VaultOpts) -> Self { + Self { + address: opts.address, + auth_mount: opts.auth_method_path.trim_matches('/').to_owned(), + jwt_audience: opts.jwt_audience, + default_secret_engine: opts.default_secret_engine.trim_matches('/').to_owned(), + default_namespace: opts.default_namespace, + } + } +} + +#[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..ec5161d --- /dev/null +++ b/secrets/secrets-vault/tests/integration.rs @@ -0,0 +1,345 @@ +use std::net::TcpListener; +use std::{collections::HashMap, net::SocketAddrV4}; + +use anyhow::Result; +use nkeys::{KeyPair, XKey}; +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::{Application, Context as SecretsContext, SecretRequest}; + +const AUTH_METHOD_MOUNT: &str = "secrets-jwt"; +const DEFAULT_AUDIENCE: &str = "Vault"; +const SECRETS_BACKEND_PREFIX: &str = "wasmcloud.secrets"; +const SECRETS_SERVICE_NAME: &str = "vault-test"; +const SECRETS_ROLE_NAME: &str = "vault-test-role"; +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: AUTH_METHOD_MOUNT.to_string(), + jwt_audience: DEFAULT_AUDIENCE.to_string(), + default_secret_engine: SECRETS_ENGINE_MOUNT.to_string(), + default_namespace: None, + }; + 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!("{SECRETS_BACKEND_PREFIX}.v1alpha1.{SECRETS_SERVICE_NAME}.server_xkey"); + 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: AUTH_METHOD_MOUNT.to_string(), + jwt_audience: DEFAULT_AUDIENCE.to_string(), + default_secret_engine: SECRETS_ENGINE_MOUNT.to_owned(), + default_namespace: None, + }; + 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 = XKey::new(); + let secret_request = SecretRequest { + name: SECRETS_SECRET_NAME.to_string(), + version: None, + context: SecretsContext { + entity_jwt: entity_claims.encode(&claims_signer).unwrap(), + host_jwt: host_claims.encode(&claims_signer).unwrap(), + application: Application { + name: Some("test-app".to_string()), + policy: json!({ + "role_name": SECRETS_ROLE_NAME, + "namespace": "foobar" + }) + .to_string(), + }, + }, + }; + 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, AUTH_METHOD_MOUNT, "jwt", None) + .await + .unwrap_or_else(|_| { + panic!( + "should have enabled the 'jwt' auth method at '{}'", + AUTH_METHOD_MOUNT + ) + }); + + // vault write auth//config jwks_url="http://localhost:3000/.well-known/keys" + let mut config_builder = SetConfigurationRequest::builder(); + config_builder.jwks_url(jwks_url.clone()); + + vaultrs::auth::oidc::config::set(vault_client, 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 '{}'", 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()]) + .bound_claims(HashMap::from([( + "application".to_string(), + "test-app".to_string(), + )])) + .token_policies(vec![SECRETS_ROLE_NAME.to_string()]); + vaultrs::auth::oidc::role::set( + vault_client, + 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) + } +}