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 25, 2024
1 parent fda93b6 commit a40bf32
Show file tree
Hide file tree
Showing 12 changed files with 1,400 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ Cargo.lock

# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb

.DS_Store
4 changes: 4 additions & 0 deletions secrets/secrets-vault/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Dockerfile*
**/target
target
.cargo
31 changes: 31 additions & 0 deletions secrets/secrets-vault/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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.2.0", git = "https://github.com/wasmcloud/wasmCloud.git", branch = "main" }
wascap = { version = "0.15.0" }

[dev-dependencies]
testcontainers = { version = "0.20.0", default-features = false }
wasmcloud-secrets-client = { version = "0.1.0", git = "https://github.com/wasmcloud/wasmCloud.git", branch = "main" }
21 changes: 21 additions & 0 deletions secrets/secrets-vault/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1.4
FROM rust:1.79-slim-bookworm AS builder

WORKDIR /build
COPY . /build

RUN <<EOF
cargo build --release
EOF

# Debug release using the distroless debug image:
# * https://github.com/GoogleContainerTools/distroless/tree/main?tab=readme-ov-file#debug-images
#
# Includes /busybox/shell for debugging, which should be used to override the default entrypoint.
FROM gcr.io/distroless/cc-debian12:debug AS debug
COPY --from=builder /build/target/release/secrets-vault /bin/secrets-vault
ENTRYPOINT ["/bin/secrets-vault"]

FROM gcr.io/distroless/cc-debian12:nonroot AS release
COPY --from=builder /build/target/release/secrets-vault /bin/secrets-vault
ENTRYPOINT ["/bin/secrets-vault"]
119 changes: 119 additions & 0 deletions secrets/secrets-vault/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 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`.
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.

## 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 <NAME> 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
```

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
spec:
policies:
- name: vault-example
type: secrets.wasmcloud.dev/v1/vault
properties:
role: demo-role
backend: vault
```
And then in your component or provider's `properties` section you will need the a `secrets` section that references the policy:

```yaml
secrets:
- name: "referenced-in-your-code"
source:
policy: vault-example
key: "path/to/secret"
```

[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
147 changes: 147 additions & 0 deletions secrets/secrets-vault/src/jwk.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

impl JsonWebKey {
pub fn from_seed(source: &str) -> Result<Self> {
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<Self> {
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<String> {
// 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<u8> = 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<KeyPair> for JsonWebKey {
type Error = anyhow::Error;

fn try_from(value: KeyPair) -> Result<Self> {
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);
}
}
Loading

0 comments on commit a40bf32

Please sign in to comment.