From 29e9c9fef717938b1eb8b87b42b5ebbaa9f91cca Mon Sep 17 00:00:00 2001 From: Joonas Bergius Date: Sun, 30 Jun 2024 20:22:26 -0500 Subject: [PATCH] feat(secrets-vault): Add Secrets Server implementation for Vault Co-authored-by: Brooks Townsend Signed-off-by: Joonas Bergius --- .github/workflows/secrets-vault.yml | 99 ++++ .gitignore | 2 + secrets/secrets-vault/.dockerignore | 4 + secrets/secrets-vault/Cargo.toml | 31 ++ secrets/secrets-vault/Dockerfile | 21 + secrets/secrets-vault/README.md | 125 +++++ secrets/secrets-vault/src/jwk.rs | 147 +++++ secrets/secrets-vault/src/jwks.rs | 59 ++ secrets/secrets-vault/src/lib.rs | 525 ++++++++++++++++++ secrets/secrets-vault/src/main.rs | 160 ++++++ .../static/life-of-a-secretrequest.png | Bin 0 -> 38923 bytes secrets/secrets-vault/tests/integration.rs | 345 ++++++++++++ 12 files changed, 1518 insertions(+) create mode 100644 .github/workflows/secrets-vault.yml create mode 100644 secrets/secrets-vault/.dockerignore create mode 100644 secrets/secrets-vault/Cargo.toml create mode 100644 secrets/secrets-vault/Dockerfile create mode 100644 secrets/secrets-vault/README.md create mode 100644 secrets/secrets-vault/src/jwk.rs create mode 100644 secrets/secrets-vault/src/jwks.rs create mode 100644 secrets/secrets-vault/src/lib.rs create mode 100644 secrets/secrets-vault/src/main.rs create mode 100644 secrets/secrets-vault/static/life-of-a-secretrequest.png create mode 100644 secrets/secrets-vault/tests/integration.rs 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 0000000000000000000000000000000000000000..7a20369917cbc97d6e5c1d584ed6fb754c6a88d0 GIT binary patch literal 38923 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 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) + } +}