Skip to main content

boatramp_server/signer/
mod.rs

1//! External [`Signer`] backends for the control-plane token issuer.
2//!
3//! The token format ([`boatramp_core::cose`]) signs through the [`Signer`] trait,
4//! so the **root signing key can live outside the process**: a cloud KMS *signer*
5//! (AWS / GCP / Azure), a Vault Transit key, or a PKCS#11 HSM. Verification still
6//! needs only the public key, so only *minting* touches the backend.
7//!
8//! Every backend is feature-gated (heavy SDKs stay out of the lean build) and
9//! resolves its public key at construction (the trust anchor other nodes verify
10//! against), so `public_key()` is a cheap cached read on the hot mint path. The
11//! deterministic request/response + signature-format logic is unit-tested; the
12//! live network / HSM round-trip is exercised only in integration testing (`#[ignore]`), matching the
13//! project's `fc_live` / container-live norm.
14//!
15//! Signature normalization: AWS + GCP KMS return **DER** ECDSA signatures, which
16//! [`boatramp_core::cose::p256_der_sig_to_raw`] converts to the raw `r‖s` COSE
17//! form; Vault (`marshaling_algorithm=jws`), Azure Key Vault, and PKCS#11
18//! (`CKM_ECDSA`) already return raw. All ECDSA backends are **ES256** (P-256) —
19//! the portable algorithm every KMS can sign; Ed25519 is offered by the local and
20//! (optionally) Vault/PKCS#11 backends only.
21
22use std::sync::Arc;
23
24use boatramp_core::cose::{LocalSigner, Signer, TokenAlg};
25
26#[cfg(feature = "signer-aws")]
27mod aws_kms;
28#[cfg(feature = "signer-azure")]
29mod azure;
30#[cfg(feature = "signer-gcp")]
31mod gcp;
32#[cfg(feature = "signer-pkcs11")]
33mod pkcs11;
34#[cfg(any(
35    feature = "signer-vault",
36    feature = "signer-gcp",
37    feature = "signer-azure"
38))]
39mod rest;
40#[cfg(feature = "signer-vault")]
41mod vault;
42
43/// A failure constructing or driving an external signer.
44#[derive(Debug, thiserror::Error)]
45pub enum SignerError {
46    /// The signer backend requested is not built into this binary (its feature is
47    /// off).
48    #[error("signer backend `{0}` is not enabled in this build")]
49    Unsupported(&'static str),
50    /// A required environment variable (a token / PIN) is unset.
51    #[error("environment variable `{0}` is not set")]
52    MissingEnv(String),
53    /// The backend rejected the key algorithm (e.g. Ed25519 on GCP/Azure KMS).
54    #[error("unsupported algorithm for this backend: {0:?}")]
55    UnsupportedAlg(TokenAlg),
56    /// Key material failed to parse/load.
57    #[error("key: {0}")]
58    Key(String),
59    /// A transport / API error talking to the backend.
60    #[error("backend `{backend}`: {message}")]
61    Backend {
62        /// The backend name (`vault`, `aws-kms`, …).
63        backend: &'static str,
64        /// The human-readable failure.
65        message: String,
66    },
67}
68
69impl SignerError {
70    /// A backend transport/API error. Used only by the feature-gated external
71    /// backends (dead when none are enabled).
72    #[allow(dead_code)]
73    pub(crate) fn backend(backend: &'static str, message: impl std::fmt::Display) -> Self {
74        Self::Backend {
75            backend,
76            message: message.to_string(),
77        }
78    }
79
80    /// Resolve a required secret from the environment (a token or PIN), never
81    /// baked into config on disk. Feature-gated
82    /// backends only.
83    #[allow(dead_code)]
84    pub(crate) fn env(name: &str) -> Result<String, Self> {
85        std::env::var(name).map_err(|_| Self::MissingEnv(name.to_string()))
86    }
87}
88
89/// Which signer backend issues control-plane tokens, and its parameters. Selected
90/// by the operator (config `[auth.signer]`); the default is [`SignerConfig::Local`].
91#[derive(Debug, Clone)]
92pub enum SignerConfig {
93    /// An in-process key (`"<alg>:<hex>"`) — the default / dev backend.
94    Local {
95        /// The private key spec, `"<alg>:<hex>"` (from `boatramp auth init`).
96        private_key: String,
97    },
98    /// A HashiCorp Vault Transit key. Token from `token_env`.
99    Vault {
100        /// Vault base address, e.g. `https://vault.example:8200`.
101        address: String,
102        /// The Transit key name.
103        key: String,
104        /// Env var holding the Vault token.
105        token_env: String,
106        /// The key's algorithm (ES256 or Ed25519).
107        alg: TokenAlg,
108    },
109    /// An AWS KMS asymmetric signing key (ES256 only). Credentials from the
110    /// standard AWS provider chain (env / profile / IMDS).
111    AwsKms {
112        /// The KMS key id or ARN.
113        key_id: String,
114        /// Optional region override (else the provider chain's region).
115        region: Option<String>,
116    },
117    /// A GCP Cloud KMS asymmetric signing key version (ES256 only). Access token
118    /// from `access_token_env` (a sidecar / workload-identity refreshes it).
119    GcpKms {
120        /// The full key-version resource name
121        /// (`projects/…/cryptoKeyVersions/N`).
122        key_version: String,
123        /// Env var holding a GCP OAuth2 access token.
124        access_token_env: String,
125    },
126    /// An Azure Key Vault signing key (ES256 only). Access token from
127    /// `access_token_env` (managed identity / a sidecar refreshes it).
128    AzureKv {
129        /// The vault base URL, e.g. `https://kv.vault.azure.net`.
130        vault_url: String,
131        /// The key name.
132        key: String,
133        /// The key version (a specific version id).
134        key_version: String,
135        /// Env var holding an Azure AD access token for the Key Vault resource.
136        access_token_env: String,
137    },
138    /// A PKCS#11 HSM key. PIN from `pin_env`.
139    Pkcs11 {
140        /// Path to the PKCS#11 module (`.so`).
141        module: String,
142        /// The token label to open a session on.
143        token_label: String,
144        /// The signing key's `CKA_LABEL`.
145        key_label: String,
146        /// Env var holding the user PIN.
147        pin_env: String,
148        /// The key's algorithm (ES256 or Ed25519).
149        alg: TokenAlg,
150    },
151}
152
153/// Construct the configured [`Signer`], resolving its public key (the trust
154/// anchor) from the backend. Async because external backends do a network / HSM
155/// round-trip at construction. The returned signer's `public_key()` is then a
156/// cached read on the hot mint path.
157pub async fn build_signer(config: &SignerConfig) -> Result<Arc<dyn Signer>, SignerError> {
158    match config {
159        SignerConfig::Local { private_key } => Ok(Arc::new(
160            LocalSigner::from_private_hex(private_key)
161                .map_err(|e| SignerError::Key(e.to_string()))?,
162        )),
163        #[cfg(feature = "signer-vault")]
164        SignerConfig::Vault {
165            address,
166            key,
167            token_env,
168            alg,
169        } => Ok(Arc::new(
170            vault::VaultSigner::connect(address, key, token_env, *alg).await?,
171        )),
172        #[cfg(not(feature = "signer-vault"))]
173        SignerConfig::Vault { .. } => Err(SignerError::Unsupported("vault")),
174        #[cfg(feature = "signer-aws")]
175        SignerConfig::AwsKms { key_id, region } => Ok(Arc::new(
176            aws_kms::AwsKmsSigner::connect(key_id, region.as_deref()).await?,
177        )),
178        #[cfg(not(feature = "signer-aws"))]
179        SignerConfig::AwsKms { .. } => Err(SignerError::Unsupported("aws-kms")),
180        #[cfg(feature = "signer-gcp")]
181        SignerConfig::GcpKms {
182            key_version,
183            access_token_env,
184        } => Ok(Arc::new(
185            gcp::GcpKmsSigner::connect(key_version, access_token_env).await?,
186        )),
187        #[cfg(not(feature = "signer-gcp"))]
188        SignerConfig::GcpKms { .. } => Err(SignerError::Unsupported("gcp-kms")),
189        #[cfg(feature = "signer-azure")]
190        SignerConfig::AzureKv {
191            vault_url,
192            key,
193            key_version,
194            access_token_env,
195        } => Ok(Arc::new(
196            azure::AzureKvSigner::connect(vault_url, key, key_version, access_token_env).await?,
197        )),
198        #[cfg(not(feature = "signer-azure"))]
199        SignerConfig::AzureKv { .. } => Err(SignerError::Unsupported("azure-kv")),
200        #[cfg(feature = "signer-pkcs11")]
201        SignerConfig::Pkcs11 {
202            module,
203            token_label,
204            key_label,
205            pin_env,
206            alg,
207        } => Ok(Arc::new(pkcs11::Pkcs11Signer::connect(
208            module,
209            token_label,
210            key_label,
211            pin_env,
212            *alg,
213        )?)),
214        #[cfg(not(feature = "signer-pkcs11"))]
215        SignerConfig::Pkcs11 { .. } => Err(SignerError::Unsupported("pkcs11")),
216    }
217}
218
219/// SHA-256 of `data` (the digest KMS/HSM ECDSA signing consumes). Uses the
220/// `aws-lc-rs` provider already in the tree (the mesh + rustls use it).
221#[cfg(any(
222    feature = "signer-gcp",
223    feature = "signer-azure",
224    feature = "signer-pkcs11"
225))]
226pub(crate) fn sha256(data: &[u8]) -> Vec<u8> {
227    aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, data)
228        .as_ref()
229        .to_vec()
230}
231
232/// Live-test helper: mint a token **through `signer`** and verify it against the
233/// public key the signer resolved at connect — the full end-to-end round-trip
234/// that proves an external backend actually signs valid boatramp tokens. Shared by
235/// the `#[ignore]` live tests (each constructs its backend from env, then calls
236/// this). Run e.g. `cargo test -p boatramp-server --features signer-vault -- --ignored`.
237#[cfg(all(
238    test,
239    any(
240        feature = "signer-vault",
241        feature = "signer-gcp",
242        feature = "signer-azure",
243        feature = "signer-aws",
244        feature = "signer-pkcs11"
245    )
246))]
247pub(crate) async fn assert_signs_and_verifies(signer: &dyn Signer) {
248    use boatramp_core::authz::GrantedRole;
249    use boatramp_core::cose::{self, Claims};
250    let now = std::time::SystemTime::now()
251        .duration_since(std::time::UNIX_EPOCH)
252        .map(|d| d.as_secs())
253        .unwrap_or(0);
254    let claims = Claims {
255        roles: vec![GrantedRole::global("admin")],
256        kind: cose::KIND_ROLE.to_string(),
257        ttl_secs: Some(300),
258        now_unix: now,
259    };
260    let token = cose::mint(&claims, signer)
261        .await
262        .expect("mint through the external signer");
263    let verified =
264        cose::verify(&token, &signer.public_key(), now).expect("verify against the resolved key");
265    assert_eq!(verified.roles, vec![GrantedRole::global("admin")]);
266    assert_eq!(verified.kind, cose::KIND_ROLE);
267}