Skip to content

Commit

Permalink
feat(secrets-vault): Add Secrets Server implementation for Vault
Browse files Browse the repository at this point in the history
Signed-off-by: Joonas Bergius <joonas@cosmonic.com>
  • Loading branch information
joonas committed Jul 15, 2024
1 parent fda93b6 commit ddfc9b2
Show file tree
Hide file tree
Showing 8 changed files with 1,106 additions and 0 deletions.
35 changes: 35 additions & 0 deletions .github/workflows/secrets-vault.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Secrets Vault

permissions:
contents: read

on:
push:
branches:
- "main"
pull_request:
branches:
- "main"

defaults:
run:
shell: bash
working-directory: ./secrets/secrets-vault

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Lint
run: |
cargo clippy -- --no-deps
- name: Test
run: |
cargo test
- name: Build
run: |
cargo build --release
28 changes: 28 additions & 0 deletions secrets/secrets-vault/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "secrets-vault"
version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = { version = "1.0.86", default-features = false, features = ["std"] }
async-nats = { version = "0.33", default-features = false, features = ["server_2_10"] }
axum = { version = "0.7.5", default-features = false, features = ["http1", "json", "tokio", "tracing"] }
bytes = { version = "1", default-features = false }
clap = { version = "4.5.4", features = ["derive", "env", "string"] }
ed25519-dalek = { version = "2.1.1", features = ["alloc", "pkcs8"] }
futures = { version = "0.3.30", default-features = false, features = [] }
jsonwebtoken = { version = "9.3.0" }
nkeys = { git = "https://github.com/wasmcloud/nkeys.git", features = ["jwk", "xkeys"], rev = "7e52c00793bd1c10a0fb8ad17acdf6e6e1f76ae6" }
nkeys_041 = { package = "nkeys", version = "0.4.1", features = ["xkeys"] }
serde = { version = "1.0.203", default-features = false, features = ["std"] }
serde_json = { version = "1.0.117", default-features = false, features = ["std"] }
tokio = { version = "1.38.0", default-features = false, features = ["full"] }
tracing = { version = "0.1.40", default-features = false, features = [] }
tracing-subscriber = { version = "0.3.18", default-features = false, features = ["fmt", "env-filter"] }
vaultrs = { version = "0.7.2", default-features = false, features = ["rustls"] }
wasmcloud-secrets-client = { version = "0.1.0", git = "https://github.com/wasmcloud/wasmCloud.git", branch = "feat/host-secrets-impl" }
wasmcloud-secrets-types = { version = "0.1.0", git = "https://github.com/wasmcloud/wasmCloud.git", branch = "feat/host-secrets-impl" }
wascap = { version = "0.15.0" }

[dev-dependencies]
testcontainers = { version = "0.20.0", default-features = false }
33 changes: 33 additions & 0 deletions secrets/secrets-vault/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# secrets-vault

[wasmCloud Secrets][wasmcloud-secrets] Server implementation for HashiCorp Vault that uses the [JWT Auth method][vault-jwt-auth] to fetch secrets stored in the [KV Secrets Engine - version 2][vault-kv2-secrets].

## How it works

When the Secrets Vault Server starts, it does the following:

1. Subscribes to NATS messages on the configured wasmCloud secrets subject (`wasmcloud.secrets.v0.<service-name>` by default) for Server Xkey requests on `<wasmcloud-secrets-subject>.server_xkey` and for Secret Requests from wasmCloud hosts on `<wasmcloud-secrets-subject>.get`.
2. Starts serving a JWKS endpoint (on `http://<jwks-address-flag>/.well-known/keys`) that lists JWKs used to sign the authentication requests sent to Vault for fetching the secrets described in incoming `SecretRequest`s.

### Life of a `SecretRequest`

![Life of a SecretRequest](./static/life-of-a-secretrequest.png)

When the server receives a `SecretRequest` via the NATS subject (`wasmcloud.secrets.v0.<service-name>.get`), it runs through the following order of operations:

1. wasmCloud Host sends a `SecretRequest` to Secrets Vault Server.
2. Secrets Vault Server attempts to decrypt the `SecretRequest` using it's own XKey and the requesting wasmCloud Host's public key attached to the request and proceeds to validate the attached Host and Entity claims.
* Entity refers to either a Component or Provider depending on which the Host is making the SecretRequest for.
3. Secrets Vault Server calls Vault with the [`jwt` authentication method][vault-jwt-auth] using a JWT token derived from the claims attached to the `SecretRequest`. See [Vault Authentication][] section for more details.
4. Vault validates that the authentication JWT has been signed with keys listed on the JWKS endpoint served by the Secret Vault Server and then matches the attached claims in JWT against a set of pre-configured [bound claims configuration][vault-bound-claims].
5. Once Vault has successfully validated the authentication JWT and succesfully matched it against a role, Vault responds with a client token for the Secrets Vault Server to use for fetching secrets.
6. Secrets Vault Server will then attempt to access the secret by referencing `name` and optionally the `version` fields stored in the `SecretRequest`. The `name` field is expected to represent a combination of the KeyValue (version 2) engine mount and the actual path where the secret is stored, represented as `<secret-engine-mount>/<path/to/the/secret>`.
* For example, using the `Usage` section from [Vault's documentation for KV Secrets Engine version 2][vault-kv2-usage] if a secret is stored under mount of `secret` and named `my-secret`, the `name` value in the `SecretRequest` should be set to `secret/my-secret`.
7. Once Secrets Vault Server is able to successfully access the secret from Vault, it will serialize the stored secret data along with the Vault's secret version in a `SecretResponse`, encrypt the resulting payload using it's configured XKey and the wasmCloud Host's public key so that only the wasmCloud host that requested the secret can decrypt it and respond back to the requesting wasmCloud Host with the encrypted payload.


[vault-bound-claims]: https://developer.hashicorp.com/vault/docs/auth/jwt#bound-claims
[vault-jwt-auth]: https://developer.hashicorp.com/vault/docs/auth/jwt#jwt-authentication
[vault-kv2-secrets]: https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2
[vault-kv2-usage]: https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2#usage
[wasmcloud-secrets]: https://github.com/wasmCloud/wasmCloud/issues/2190
58 changes: 58 additions & 0 deletions secrets/secrets-vault/src/jwks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
use anyhow::Result;
use axum::{extract::State, http::StatusCode, routing::get, Json, Router};
use nkeys::{JsonWebKey, KeyPair};
use serde::{Deserialize, Serialize};
use std::{net::SocketAddrV4, sync::Arc};

#[derive(Debug)]
pub(crate) struct VaultSecretsJwksServer {
keys: Vec<JsonWebKey>,
listen_address: SocketAddrV4,
}

struct SharedState {
keys: Vec<JsonWebKey>,
}

#[derive(Debug, Serialize, Deserialize)]
struct JwksResponse {
keys: Vec<JsonWebKey>,
}

impl VaultSecretsJwksServer {
pub fn new(nkeys: Vec<KeyPair>, listen_address: SocketAddrV4) -> Result<Self> {
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<Arc<SharedState>>,
) -> (StatusCode, Json<JwksResponse>) {
(
StatusCode::OK,
Json(JwksResponse {
keys: state.keys.clone(),
}),
)
}
Loading

0 comments on commit ddfc9b2

Please sign in to comment.