vti-secrets 0.1.6

Pluggable secret-store backends + integration onboarding flow for Verifiable Trust Infrastructure
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;

use tokio::sync::{OnceCell, RwLock};
use tracing::{debug, error, info, warn};
use vaultrs::auth::approle;
use vaultrs::auth::kubernetes;
use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder};
use vaultrs::error::ClientError;
use vaultrs::kv2;

use vti_common::error::AppError;

/// Vault recommends renewing well before expiry; renewing at half the
/// lease keeps the token comfortably within its window even if a single
/// renewal request fails. The 10s floor stops very short test TTLs from
/// busy-looping.
const RENEW_FACTOR: u32 = 2;
const RENEW_MIN_INTERVAL: Duration = Duration::from_secs(10);
/// Backoff after a re-auth failure. Long enough that flapping doesn't
/// spam Vault; short enough that the VTA recovers quickly once it's back.
const RENEW_RETRY_INTERVAL: Duration = Duration::from_secs(30);
/// Polling cadence when Vault returned a non-renewable token (e.g.
/// static token auth). Picks up manual rotations without forcing the
/// renewal task to no-op forever.
const NON_RENEWABLE_POLL_INTERVAL: Duration = Duration::from_secs(300);

/// Authentication method for Vault. Each variant carries everything the
/// renewal task needs to re-authenticate from scratch when a lease can
/// no longer be renewed.
#[derive(Clone)]
enum VaultAuth {
    Kubernetes {
        mount: String,
        role: String,
        jwt_path: String,
    },
    Token {
        token: String,
    },
    AppRole {
        mount: String,
        role_id: String,
        secret_id: String,
    },
}

impl VaultAuth {
    /// Authenticate against Vault and return `(token, lease_secs, renewable)`.
    async fn login(&self, client: &VaultClient) -> Result<(String, u64, bool), ClientError> {
        match self {
            VaultAuth::Kubernetes {
                mount,
                role,
                jwt_path,
            } => {
                // SA JWTs are short-lived (kubelet rotates them ~1h by
                // default) so we re-read the file every time we authenticate.
                let jwt = std::fs::read_to_string(jwt_path).map_err(|e| {
                    ClientError::FileNotFoundError {
                        path: format!("{jwt_path}: {e}"),
                    }
                })?;
                let info = kubernetes::login(client, mount, role, jwt.trim()).await?;
                Ok((info.client_token, info.lease_duration, info.renewable))
            }
            VaultAuth::Token { token } => {
                // Static tokens have no auth-time lease; treat as
                // non-renewable. The renewal task will still poll
                // periodically in case the operator rotates the token.
                Ok((token.clone(), 0, false))
            }
            VaultAuth::AppRole {
                mount,
                role_id,
                secret_id,
            } => {
                let info = approle::login(client, mount, role_id, secret_id).await?;
                Ok((info.client_token, info.lease_duration, info.renewable))
            }
        }
    }
}

/// Connection parameters captured at construction time. The actual
/// `VaultClient` is built lazily on first use so `create_seed_store`
/// can stay synchronous (matching AWS/GCP/Azure).
struct ConnectParams {
    addr: String,
    namespace: Option<String>,
    skip_verify: bool,
    auth: VaultAuth,
}

/// A live Vault connection plus its background token-renewal task.
struct ConnectedState {
    client: Arc<RwLock<VaultClient>>,
    /// Held so we can abort the renewal task on `Drop`.
    renewal_task: tokio::task::JoinHandle<()>,
}

impl Drop for ConnectedState {
    fn drop(&mut self) {
        self.renewal_task.abort();
    }
}

/// Seed store backed by HashiCorp Vault's KV v2 engine.
///
/// Authenticates via Kubernetes ServiceAccount JWT (default), a static
/// token, or AppRole. The Vault token is auto-renewed in a background
/// task; if a renewal fails (max-TTL reached, lease expired) the task
/// re-authenticates from scratch using the configured method.
///
/// The seed is stored as a hex-encoded string under
/// `secret_path` -> `secret_key` (default `seed`).
pub struct VaultSeedStore {
    /// Lazily initialised on first `get` / `set` / `delete` call.
    state: OnceCell<ConnectedState>,
    params: ConnectParams,
    secret_path: String,
    secret_key: String,
    kv_mount: String,
}

impl VaultSeedStore {
    fn new(
        addr: String,
        namespace: Option<String>,
        skip_verify: bool,
        secret_path: String,
        secret_key: String,
        kv_mount: String,
        auth: VaultAuth,
    ) -> Self {
        Self {
            state: OnceCell::new(),
            params: ConnectParams {
                addr,
                namespace,
                skip_verify,
                auth,
            },
            secret_path,
            secret_key,
            kv_mount,
        }
    }

    /// Lazily build the Vault client, authenticate, and spawn the
    /// renewal task. Subsequent calls reuse the same connection.
    async fn connect(&self) -> Result<&Arc<RwLock<VaultClient>>, AppError> {
        let state = self
            .state
            .get_or_try_init(|| async {
                let mut builder = VaultClientSettingsBuilder::default();
                builder
                    .address(self.params.addr.as_str())
                    .verify(!self.params.skip_verify);
                if let Some(ref ns) = self.params.namespace {
                    builder.namespace(Some(ns.clone()));
                }
                let settings = builder
                    .build()
                    .map_err(|e| AppError::Config(format!("invalid Vault settings: {e}")))?;
                let mut client = VaultClient::new(settings).map_err(|e| {
                    AppError::SecretStore(format!("failed to build Vault client: {e}"))
                })?;

                let (token, lease, renewable) =
                    self.params.auth.login(&client).await.map_err(|e| {
                        AppError::SecretStore(format!("Vault authentication failed: {e}"))
                    })?;
                client.set_token(&token);
                info!(
                    addr = %self.params.addr,
                    renewable,
                    lease_secs = lease,
                    "authenticated to Vault"
                );

                let client = Arc::new(RwLock::new(client));
                let renewal_task = spawn_renewal_task(
                    Arc::clone(&client),
                    self.params.auth.clone(),
                    lease,
                    renewable,
                );
                Ok::<_, AppError>(ConnectedState {
                    client,
                    renewal_task,
                })
            })
            .await?;
        Ok(&state.client)
    }
}

/// Spawn the background task that renews the Vault token before its
/// lease expires, falling back to full re-auth when the lease can no
/// longer be extended.
fn spawn_renewal_task(
    client: Arc<RwLock<VaultClient>>,
    auth: VaultAuth,
    initial_lease: u64,
    renewable: bool,
) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        let mut current_lease = initial_lease;
        let mut current_renewable = renewable;
        loop {
            let sleep = if current_lease == 0 {
                NON_RENEWABLE_POLL_INTERVAL
            } else {
                Duration::from_secs((current_lease / RENEW_FACTOR as u64).max(1))
                    .max(RENEW_MIN_INTERVAL)
            };
            debug!(?sleep, current_lease, "vault renewal task sleeping");
            tokio::time::sleep(sleep).await;

            if current_renewable {
                let renew_result = {
                    let c = client.read().await;
                    c.renew(None).await
                };
                match renew_result {
                    Ok(info) => {
                        current_lease = info.lease_duration;
                        current_renewable = info.renewable;
                        debug!(lease_secs = current_lease, "vault token renewed");
                        continue;
                    }
                    Err(e) => {
                        warn!("vault token renewal failed: {e} — re-authenticating");
                    }
                }
            }

            // Re-auth from scratch (covers max-TTL exhaustion and the
            // non-renewable poll path).
            let login_result = {
                let c = client.read().await;
                auth.login(&c).await
            };
            match login_result {
                Ok((token, lease, renewable)) => {
                    let mut c = client.write().await;
                    c.set_token(&token);
                    current_lease = lease;
                    current_renewable = renewable;
                    info!(lease_secs = lease, renewable, "vault re-authenticated");
                }
                Err(e) => {
                    error!(
                        "vault re-authentication failed: {e} — retrying in {}s",
                        RENEW_RETRY_INTERVAL.as_secs()
                    );
                    tokio::time::sleep(RENEW_RETRY_INTERVAL).await;
                }
            }
        }
    })
}

impl super::SeedStore for VaultSeedStore {
    fn get(&self) -> Pin<Box<dyn Future<Output = Result<Option<Vec<u8>>, AppError>> + Send + '_>> {
        Box::pin(async {
            let client = self.connect().await?;
            let client = client.read().await;
            let result: Result<HashMap<String, String>, ClientError> =
                kv2::read(&*client, &self.kv_mount, &self.secret_path).await;
            match result {
                Ok(map) => {
                    let hex_seed = map.get(&self.secret_key).ok_or_else(|| {
                        AppError::SecretStore(format!(
                            "Vault secret at {}/{} has no field '{}'",
                            self.kv_mount, self.secret_path, self.secret_key
                        ))
                    })?;
                    let bytes = hex::decode(hex_seed.trim()).map_err(|e| {
                        AppError::SecretStore(format!("failed to decode hex seed from Vault: {e}"))
                    })?;
                    debug!(path = %self.secret_path, "seed loaded from Vault");
                    Ok(Some(bytes))
                }
                Err(ClientError::APIError { code: 404, .. }) => {
                    debug!(path = %self.secret_path, "secret not found in Vault");
                    Ok(None)
                }
                Err(e) => Err(AppError::SecretStore(format!(
                    "failed to read seed from Vault: {e}"
                ))),
            }
        })
    }

    fn set(&self, seed: &[u8]) -> Pin<Box<dyn Future<Output = Result<(), AppError>> + Send + '_>> {
        let hex_seed = hex::encode(seed);
        Box::pin(async move {
            let client = self.connect().await?;
            let client = client.read().await;
            let mut payload = HashMap::new();
            payload.insert(self.secret_key.clone(), hex_seed);
            kv2::set(&*client, &self.kv_mount, &self.secret_path, &payload)
                .await
                .map_err(|e| {
                    AppError::SecretStore(format!("failed to store seed in Vault: {e}"))
                })?;
            debug!(path = %self.secret_path, "seed stored in Vault");
            Ok(())
        })
    }

    fn delete(&self) -> Pin<Box<dyn Future<Output = Result<(), AppError>> + Send + '_>> {
        Box::pin(async {
            let client = self.connect().await?;
            let client = client.read().await;
            kv2::delete_latest(&*client, &self.kv_mount, &self.secret_path)
                .await
                .map_err(|e| {
                    AppError::SecretStore(format!("failed to delete seed from Vault: {e}"))
                })?;
            debug!(path = %self.secret_path, "seed deleted in Vault (latest version)");
            Ok(())
        })
    }
}

/// Config-agnostic inputs for [`from_params`]. Mirrors the `vault_*`
/// config fields so any service can build a Vault backend from its own
/// config shape without depending on a specific `SecretsConfig` type —
/// the VTA builds these from [`crate::SecretsConfig`] via [`from_config`];
/// the VTC builds them from its own config. Fields are borrowed (the
/// builder owns only what it keeps), and the string-defaulted fields
/// (`secret_key`, `kv_mount`, the `*_mount`s, `auth_method`) are the
/// caller's already-resolved values.
pub struct VaultParams<'a> {
    pub addr: Option<&'a str>,
    pub namespace: Option<&'a str>,
    pub skip_verify: bool,
    pub secret_path: Option<&'a str>,
    pub secret_key: &'a str,
    pub kv_mount: &'a str,
    pub auth_method: &'a str,
    pub k8s_role: Option<&'a str>,
    pub k8s_mount: &'a str,
    pub k8s_jwt_path: &'a str,
    pub token: Option<&'a str>,
    pub approle_role_id: Option<&'a str>,
    pub approle_secret_id: Option<&'a str>,
    pub approle_mount: &'a str,
}

/// Build a [`VaultSeedStore`] from config-agnostic [`VaultParams`].
/// Validates the auth-method-specific fields and surfaces actionable
/// errors when something required is missing.
pub fn from_params(p: &VaultParams<'_>) -> Result<VaultSeedStore, AppError> {
    let addr = p
        .addr
        .ok_or_else(|| AppError::Config("secrets.vault_addr is required".into()))?
        .to_string();
    let path = p
        .secret_path
        .ok_or_else(|| {
            AppError::Config(
                "secrets.vault_secret_path is required when secrets.vault_addr is set".into(),
            )
        })?
        .to_string();

    let auth = match p.auth_method {
        "kubernetes" => {
            let role = p
                .k8s_role
                .ok_or_else(|| {
                    AppError::Config(
                        "secrets.vault_k8s_role is required for kubernetes auth method".into(),
                    )
                })?
                .to_string();
            VaultAuth::Kubernetes {
                mount: p.k8s_mount.to_string(),
                role,
                jwt_path: p.k8s_jwt_path.to_string(),
            }
        }
        "token" => {
            let token = p
                .token
                .map(str::to_string)
                .or_else(|| std::env::var("VAULT_TOKEN").ok())
                .ok_or_else(|| {
                    AppError::Config(
                        "token auth requires secrets.vault_token or the VAULT_TOKEN env var".into(),
                    )
                })?;
            VaultAuth::Token { token }
        }
        "approle" => {
            let role_id = p
                .approle_role_id
                .ok_or_else(|| {
                    AppError::Config("secrets.vault_approle_role_id is required for approle".into())
                })?
                .to_string();
            let secret_id = p
                .approle_secret_id
                .ok_or_else(|| {
                    AppError::Config(
                        "secrets.vault_approle_secret_id is required for approle".into(),
                    )
                })?
                .to_string();
            VaultAuth::AppRole {
                mount: p.approle_mount.to_string(),
                role_id,
                secret_id,
            }
        }
        other => {
            return Err(AppError::Config(format!(
                "unknown secrets.vault_auth_method '{other}', expected kubernetes|token|approle"
            )));
        }
    };

    Ok(VaultSeedStore::new(
        addr,
        p.namespace.map(str::to_string),
        p.skip_verify,
        path,
        p.secret_key.to_string(),
        p.kv_mount.to_string(),
        auth,
    ))
}

/// Build a [`VaultSeedStore`] from the workspace [`SecretsConfig`]. Thin
/// adapter over [`from_params`].
pub fn from_config(secrets: &crate::config::SecretsConfig) -> Result<VaultSeedStore, AppError> {
    from_params(&VaultParams {
        addr: secrets.vault_addr.as_deref(),
        namespace: secrets.vault_namespace.as_deref(),
        skip_verify: secrets.vault_skip_verify,
        secret_path: secrets.vault_secret_path.as_deref(),
        secret_key: &secrets.vault_secret_key,
        kv_mount: &secrets.vault_kv_mount,
        auth_method: &secrets.vault_auth_method,
        k8s_role: secrets.vault_k8s_role.as_deref(),
        k8s_mount: &secrets.vault_k8s_mount,
        k8s_jwt_path: &secrets.vault_k8s_jwt_path,
        token: secrets.vault_token.as_deref(),
        approle_role_id: secrets.vault_approle_role_id.as_deref(),
        approle_secret_id: secrets.vault_approle_secret_id.as_deref(),
        approle_mount: &secrets.vault_approle_mount,
    })
}