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}