vti_common/config.rs
1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4#[derive(Debug, Clone, Deserialize, Serialize)]
5pub struct ServerConfig {
6 #[serde(default = "default_host")]
7 pub host: String,
8 /// Port number. No default — each service must provide its own via
9 /// `#[serde(default = "...")]` or by composing this struct.
10 pub port: u16,
11}
12
13#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct LogConfig {
15 #[serde(default = "default_log_level")]
16 pub level: String,
17 #[serde(default)]
18 pub format: LogFormat,
19}
20
21#[derive(Debug, Clone, Deserialize, Serialize)]
22pub struct StoreConfig {
23 /// Data directory. No default — each service provides its own
24 /// (e.g., "data/vta" vs "data/vtc").
25 pub data_dir: PathBuf,
26}
27
28#[derive(Clone, Deserialize, Serialize)]
29pub struct AuthConfig {
30 #[serde(default = "default_access_token_expiry")]
31 pub access_token_expiry: u64,
32 #[serde(default = "default_refresh_token_expiry")]
33 pub refresh_token_expiry: u64,
34 #[serde(default = "default_challenge_ttl")]
35 pub challenge_ttl: u64,
36 #[serde(default = "default_session_cleanup_interval")]
37 pub session_cleanup_interval: u64,
38 /// Base64url-no-pad encoded 32-byte Ed25519 private key for JWT signing.
39 pub jwt_signing_key: Option<String>,
40 /// Retired: the `[auth.step_up]` policy floors.
41 ///
42 /// This field exists only to **refuse** a config that still carries the
43 /// section, rather than parse it and silently ignore it. An operator whose
44 /// `config.toml` says `[auth.step_up] enabled = true` believes their VTA is
45 /// gating operations. Dropping the field outright would leave them
46 /// believing it, with the file still saying so and nothing enforcing it —
47 /// the worst of the three outcomes. A VTA that will not start is at least
48 /// unambiguous, and the error names the command that replaces it.
49 ///
50 /// Absent (the only accepted state) deserializes to `()` via `default`.
51 #[serde(default, deserialize_with = "refuse_retired_step_up", skip_serializing)]
52 pub step_up: (),
53}
54
55/// Reject `[auth.step_up]` with the migration the operator needs.
56///
57/// Only ever called when the key is present — `#[serde(default)]` covers its
58/// absence — so reaching this function *is* the error.
59fn refuse_retired_step_up<'de, D>(_: D) -> Result<(), D::Error>
60where
61 D: serde::Deserializer<'de>,
62{
63 Err(serde::de::Error::custom(
64 "`[auth.step_up]` has been retired. The step-up floors were a second, \
65 parallel answer to \"does this operation need another human decision?\", \
66 resolved separately from the policy rules — which is how a VTA could \
67 demand a step-up that no rule explained. Approvals are now one model: \
68 delete the `[auth.step_up]` section and express the same requirement as \
69 a rule with `pnm approvals require <task-uri> --reauth` (or \
70 `--consent`). `pnm approvals list` then shows every gated operation, \
71 which the floors never could.",
72 ))
73}
74
75// Manual Debug so a `tracing::debug!(?config, ...)`, panic-with-debug,
76// or `format!("{:?}", app_config)` in a downstream crate cannot dump
77// the JWT signing key into logs (which in enclave mode are forwarded
78// over vsock to the host). Non-secret fields stay visible for
79// diagnostics; `Serialize` is intentionally untouched since these
80// structs round-trip to the on-disk config file.
81impl std::fmt::Debug for AuthConfig {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct("AuthConfig")
84 .field("access_token_expiry", &self.access_token_expiry)
85 .field("refresh_token_expiry", &self.refresh_token_expiry)
86 .field("challenge_ttl", &self.challenge_ttl)
87 .field("session_cleanup_interval", &self.session_cleanup_interval)
88 .field(
89 "jwt_signing_key",
90 &self.jwt_signing_key.as_ref().map(|_| "<redacted>"),
91 )
92 .finish()
93 }
94}
95
96#[derive(Debug, Clone, Deserialize, Serialize)]
97pub struct MessagingConfig {
98 /// Mediator URL. Optional — the TDK resolves the endpoint from mediator_did.
99 /// Kept for display/status purposes and backward compatibility.
100 #[serde(default)]
101 pub mediator_url: String,
102 pub mediator_did: String,
103 /// Real external hostname of the mediator (e.g., "mediator.example.com").
104 /// Used by the parent proxy to establish the TLS connection.
105 /// Not used by the VTA itself (which connects via the local vsock proxy).
106 #[serde(default)]
107 pub mediator_host: Option<String>,
108 /// Automatically provision a per-DID allow-all ACL on the mediator after
109 /// establishing the DIDComm connection. Required when the mediator uses
110 /// `ExplicitAllow` mode; harmless (and default-off) with `ExplicitDeny`.
111 /// Set `setup_acl = true` during setup to enable. Defaults to `false`.
112 #[serde(default)]
113 pub setup_acl: bool,
114 /// Drain this DID's mediator inbox over REST at startup, *before* the live
115 /// DIDComm/TSP listener enables live delivery.
116 ///
117 /// Recovery lever for a wedged listener: the mediator enforces one live
118 /// websocket stream per DID, and an undeliverable/poison message queued for
119 /// this DID can stall the live-delivery handshake so the listener never comes
120 /// up (taking DIDComm *and* TSP down, since they share the socket). Because
121 /// REST auth + pickup work even when the websocket stalls, the VTA can fetch
122 /// and clear its own queued messages first: each is best-effort processed,
123 /// and anything that fails to unpack/handle is logged loudly and deleted so
124 /// it can't wedge startup again.
125 ///
126 /// **Default off** — it deletes queued messages that can't be handled, so it
127 /// is opt-in. Turn it on when a mediator-side backlog is blocking boot.
128 #[serde(default)]
129 pub drain_inbox_on_start: bool,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct AuditConfig {
134 /// Number of days to retain audit logs (default 28).
135 #[serde(default = "default_audit_retention_days")]
136 pub retention_days: u32,
137}
138
139fn default_audit_retention_days() -> u32 {
140 28
141}
142
143impl Default for AuditConfig {
144 fn default() -> Self {
145 Self {
146 retention_days: default_audit_retention_days(),
147 }
148 }
149}
150
151/// Vault lifecycle tuning. Shared shape so both the VTA password vault and
152/// the VTA credential store read the same grace window.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct VaultConfig {
155 /// Days a soft-deleted (tombstoned) vault entry or credential remains
156 /// recoverable before the sweeper hard-purges it. Applied at delete time
157 /// (`grace_until = now + grace_days`); the sweeper only compares against
158 /// the stored `grace_until`. Default 30. A `delete --force` / `purge`
159 /// bypasses the window entirely.
160 #[serde(default = "default_vault_grace_days")]
161 pub grace_days: u32,
162
163 /// PEM-encoded **IACA root certificates** this VTA accepts as mdoc issuers
164 /// (ISO/IEC 18013-5). Each entry may hold several `CERTIFICATE` blocks, so
165 /// a Member State trusted-list bundle can be pasted as one value.
166 ///
167 /// Inline PEM rather than file paths, for two reasons: an enclave has no
168 /// convenient filesystem to read them from, and inline values are covered
169 /// by the effective-config digest that boot attestation commits to — so a
170 /// verifier can see *which issuers a TEE VTA was trusting* at the time it
171 /// was attested. A path would leave that outside the measurement.
172 ///
173 /// **Empty means mdoc receive is unavailable, not "trust anything".** The
174 /// resolver fails closed on an empty anchor set. mdoc is the one credential
175 /// format here whose issuer is not a resolvable DID, so there is no safe
176 /// default to fall back to.
177 #[serde(default)]
178 pub mdoc_iaca_trust_anchors: Vec<String>,
179}
180
181fn default_vault_grace_days() -> u32 {
182 30
183}
184
185/// Application-state store tuning (`vta/app-state/*`).
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct AppStateConfig {
188 /// Days a deleted record's **tombstone** is retained before the sweeper
189 /// reaps it. Default 30, matching the vault's grace window.
190 ///
191 /// This is a correctness parameter, not just housekeeping. A tombstone is
192 /// how a consumer syncing from a watermark learns a record was deleted;
193 /// once it is reaped, any watermark from before that point can no longer
194 /// converge, and the VTA answers such a resume with
195 /// `vta/app-state/list:watermarkTooOld` so the consumer rebuilds instead of
196 /// being served a feed that silently omits deletions.
197 ///
198 /// So the window is really "how long may a consumer be offline and still
199 /// resume incrementally". Too short and a client that was away for a
200 /// weekend pays for a full rebuild; too long and deletions are not real.
201 /// Raising it is always safe; lowering it strands consumers whose
202 /// watermarks predate the new cutoff.
203 ///
204 /// `0` disables reaping entirely — tombstones are kept forever, no watermark
205 /// ever expires, and the keyspace grows without bound. Legitimate for a
206 /// deployment that would rather spend disk than ever force a rebuild.
207 #[serde(default = "default_tombstone_retention_days")]
208 pub tombstone_retention_days: u32,
209}
210
211fn default_tombstone_retention_days() -> u32 {
212 30
213}
214
215impl Default for AppStateConfig {
216 fn default() -> Self {
217 Self {
218 tombstone_retention_days: default_tombstone_retention_days(),
219 }
220 }
221}
222
223impl Default for VaultConfig {
224 fn default() -> Self {
225 Self {
226 grace_days: default_vault_grace_days(),
227 mdoc_iaca_trust_anchors: Vec::new(),
228 }
229 }
230}
231
232#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
233#[serde(rename_all = "lowercase")]
234pub enum LogFormat {
235 #[default]
236 Text,
237 Json,
238}
239
240fn default_host() -> String {
241 "0.0.0.0".to_string()
242}
243
244fn default_log_level() -> String {
245 "info".to_string()
246}
247
248fn default_access_token_expiry() -> u64 {
249 900
250}
251
252fn default_refresh_token_expiry() -> u64 {
253 86400
254}
255
256fn default_challenge_ttl() -> u64 {
257 300
258}
259
260fn default_session_cleanup_interval() -> u64 {
261 600
262}
263
264impl Default for AuthConfig {
265 fn default() -> Self {
266 Self {
267 access_token_expiry: default_access_token_expiry(),
268 refresh_token_expiry: default_refresh_token_expiry(),
269 challenge_ttl: default_challenge_ttl(),
270 session_cleanup_interval: default_session_cleanup_interval(),
271 jwt_signing_key: None,
272 step_up: (),
273 }
274 }
275}
276
277impl Default for LogConfig {
278 fn default() -> Self {
279 Self {
280 level: default_log_level(),
281 format: LogFormat::default(),
282 }
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 /// `AuthConfig`'s Debug impl MUST NOT print the JWT signing key —
291 /// it's the Ed25519 private key used to sign every access token. A
292 /// stray `tracing::debug!(?config, ...)` or panic-with-debug
293 /// formatter would otherwise dump it into logs.
294 #[test]
295 fn auth_config_debug_redacts_jwt_signing_key() {
296 let cfg = AuthConfig {
297 access_token_expiry: 900,
298 refresh_token_expiry: 86400,
299 challenge_ttl: 300,
300 session_cleanup_interval: 600,
301 jwt_signing_key: Some("SUPER_SECRET_KEY_MATERIAL_MUST_NOT_LEAK".into()),
302 step_up: (),
303 };
304 let dbg = format!("{cfg:?}");
305 assert!(
306 !dbg.contains("SUPER_SECRET_KEY_MATERIAL"),
307 "AuthConfig Debug leaked jwt_signing_key contents: {dbg}"
308 );
309 assert!(
310 dbg.contains("<redacted>"),
311 "expected redaction marker in Debug, got: {dbg}"
312 );
313 // Non-secret fields must remain visible for diagnostics.
314 assert!(
315 dbg.contains("900"),
316 "access_token_expiry must still be visible: {dbg}"
317 );
318 }
319
320 #[test]
321 fn auth_config_debug_none_signing_key_renders_none() {
322 let cfg = AuthConfig::default();
323 let dbg = format!("{cfg:?}");
324 // `Option<&str>` Debug prints `None` for the absent case.
325 assert!(dbg.contains("jwt_signing_key: None"), "got: {dbg}");
326 }
327
328 /// Serialize must remain unaffected — these structs round-trip to
329 /// the config file, and redacting them on serialize would break
330 /// persistence. Use JSON here since serde_json is already a
331 /// dev-dep; the wire format (TOML on disk) shares the same serde
332 /// derive so this is sufficient to prove non-redaction.
333 #[test]
334 fn auth_config_serialize_still_carries_jwt_signing_key() {
335 let cfg = AuthConfig {
336 access_token_expiry: 900,
337 refresh_token_expiry: 86400,
338 challenge_ttl: 300,
339 session_cleanup_interval: 600,
340 jwt_signing_key: Some("key-material".into()),
341 step_up: (),
342 };
343 let json = serde_json::to_string(&cfg).expect("serialize");
344 assert!(
345 json.contains("key-material"),
346 "Serialize must not redact — config persistence relies on round-trip: {json}"
347 );
348 }
349
350 /// A config still carrying `[auth.step_up]` refuses to load.
351 ///
352 /// Silently ignoring it is the outcome to avoid: the file would keep
353 /// asserting that operations are gated, the operator would keep believing
354 /// it, and nothing would enforce it. Failing to start is unambiguous, and
355 /// the message has to carry the migration or it just moves the confusion.
356 #[test]
357 fn a_config_still_carrying_the_retired_floors_is_refused() {
358 let with_floors = r#"{
359 "jwt_signing_key": null,
360 "step_up": { "enabled": true, "floors": [{ "operation": "*", "mode": "self" }] }
361 }"#;
362 let err = serde_json::from_str::<AuthConfig>(with_floors)
363 .expect_err("`[auth.step_up]` must be refused, not ignored");
364 let msg = err.to_string();
365 assert!(msg.contains("retired"), "got: {msg}");
366 assert!(
367 msg.contains("pnm approvals require"),
368 "the refusal must name what replaces it, got: {msg}"
369 );
370
371 // Even an empty section is refused — an operator who wrote
372 // `[auth.step_up]` and nothing else still has a stale file to fix.
373 assert!(
374 serde_json::from_str::<AuthConfig>(r#"{"jwt_signing_key":null,"step_up":{}}"#).is_err()
375 );
376 }
377
378 /// …and the ordinary case, a config with no such section, still loads.
379 #[test]
380 fn a_config_without_the_retired_section_loads() {
381 let cfg: AuthConfig =
382 serde_json::from_str(r#"{ "jwt_signing_key": null }"#).expect("loads");
383 assert_eq!(cfg.access_token_expiry, default_access_token_expiry());
384 }
385}
386
387#[cfg(test)]
388mod mdoc_trust_anchor_config_tests {
389 use super::*;
390
391 /// The field must default to empty, so an existing config that predates it
392 /// still loads. Combined with the resolver failing closed, that means an
393 /// upgrade neither breaks a deployment nor silently starts trusting mdocs.
394 #[test]
395 fn trust_anchors_default_to_empty_and_an_old_config_still_loads() {
396 let cfg: VaultConfig = toml::from_str("grace_days = 30").expect("legacy config loads");
397 assert_eq!(cfg.grace_days, 30);
398 assert!(
399 cfg.mdoc_iaca_trust_anchors.is_empty(),
400 "absent means no mdoc issuer is trusted, not a permissive default"
401 );
402 }
403
404 /// An existing deployment's config has no `[app_state]` section at all, and
405 /// must keep loading with the documented default rather than failing or
406 /// silently disabling retention.
407 #[test]
408 fn app_state_config_defaults_when_absent() {
409 let cfg: AppStateConfig = toml::from_str("").expect("an absent section loads");
410 assert_eq!(cfg.tombstone_retention_days, 30);
411 assert_eq!(AppStateConfig::default().tombstone_retention_days, 30);
412 }
413
414 /// `0` is a meaningful value, not a missing one: it disables reaping. The
415 /// distinction matters because the sweeper treats a zero *cutoff* as "expire
416 /// everything", so this must survive as 0 rather than falling back to 30.
417 #[test]
418 fn app_state_retention_zero_survives_as_zero() {
419 let cfg: AppStateConfig =
420 toml::from_str("tombstone_retention_days = 0").expect("explicit zero loads");
421 assert_eq!(
422 cfg.tombstone_retention_days, 0,
423 "an explicit 0 must not be rewritten to the default"
424 );
425 }
426
427 #[test]
428 fn trust_anchors_round_trip_through_toml() {
429 let cfg: VaultConfig = toml::from_str(
430 r#"
431 grace_days = 7
432 mdoc_iaca_trust_anchors = ["-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n"]
433 "#,
434 )
435 .expect("config with anchors loads");
436 assert_eq!(cfg.mdoc_iaca_trust_anchors.len(), 1);
437 assert!(cfg.mdoc_iaca_trust_anchors[0].contains("BEGIN CERTIFICATE"));
438 }
439}