Skip to main content

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
164fn default_vault_grace_days() -> u32 {
165    30
166}
167
168impl Default for VaultConfig {
169    fn default() -> Self {
170        Self {
171            grace_days: default_vault_grace_days(),
172        }
173    }
174}
175
176#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
177#[serde(rename_all = "lowercase")]
178pub enum LogFormat {
179    #[default]
180    Text,
181    Json,
182}
183
184fn default_host() -> String {
185    "0.0.0.0".to_string()
186}
187
188fn default_log_level() -> String {
189    "info".to_string()
190}
191
192fn default_access_token_expiry() -> u64 {
193    900
194}
195
196fn default_refresh_token_expiry() -> u64 {
197    86400
198}
199
200fn default_challenge_ttl() -> u64 {
201    300
202}
203
204fn default_session_cleanup_interval() -> u64 {
205    600
206}
207
208impl Default for AuthConfig {
209    fn default() -> Self {
210        Self {
211            access_token_expiry: default_access_token_expiry(),
212            refresh_token_expiry: default_refresh_token_expiry(),
213            challenge_ttl: default_challenge_ttl(),
214            session_cleanup_interval: default_session_cleanup_interval(),
215            jwt_signing_key: None,
216            step_up: (),
217        }
218    }
219}
220
221impl Default for LogConfig {
222    fn default() -> Self {
223        Self {
224            level: default_log_level(),
225            format: LogFormat::default(),
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    /// `AuthConfig`'s Debug impl MUST NOT print the JWT signing key —
235    /// it's the Ed25519 private key used to sign every access token. A
236    /// stray `tracing::debug!(?config, ...)` or panic-with-debug
237    /// formatter would otherwise dump it into logs.
238    #[test]
239    fn auth_config_debug_redacts_jwt_signing_key() {
240        let cfg = AuthConfig {
241            access_token_expiry: 900,
242            refresh_token_expiry: 86400,
243            challenge_ttl: 300,
244            session_cleanup_interval: 600,
245            jwt_signing_key: Some("SUPER_SECRET_KEY_MATERIAL_MUST_NOT_LEAK".into()),
246            step_up: (),
247        };
248        let dbg = format!("{cfg:?}");
249        assert!(
250            !dbg.contains("SUPER_SECRET_KEY_MATERIAL"),
251            "AuthConfig Debug leaked jwt_signing_key contents: {dbg}"
252        );
253        assert!(
254            dbg.contains("<redacted>"),
255            "expected redaction marker in Debug, got: {dbg}"
256        );
257        // Non-secret fields must remain visible for diagnostics.
258        assert!(
259            dbg.contains("900"),
260            "access_token_expiry must still be visible: {dbg}"
261        );
262    }
263
264    #[test]
265    fn auth_config_debug_none_signing_key_renders_none() {
266        let cfg = AuthConfig::default();
267        let dbg = format!("{cfg:?}");
268        // `Option<&str>` Debug prints `None` for the absent case.
269        assert!(dbg.contains("jwt_signing_key: None"), "got: {dbg}");
270    }
271
272    /// Serialize must remain unaffected — these structs round-trip to
273    /// the config file, and redacting them on serialize would break
274    /// persistence. Use JSON here since serde_json is already a
275    /// dev-dep; the wire format (TOML on disk) shares the same serde
276    /// derive so this is sufficient to prove non-redaction.
277    #[test]
278    fn auth_config_serialize_still_carries_jwt_signing_key() {
279        let cfg = AuthConfig {
280            access_token_expiry: 900,
281            refresh_token_expiry: 86400,
282            challenge_ttl: 300,
283            session_cleanup_interval: 600,
284            jwt_signing_key: Some("key-material".into()),
285            step_up: (),
286        };
287        let json = serde_json::to_string(&cfg).expect("serialize");
288        assert!(
289            json.contains("key-material"),
290            "Serialize must not redact — config persistence relies on round-trip: {json}"
291        );
292    }
293
294    /// A config still carrying `[auth.step_up]` refuses to load.
295    ///
296    /// Silently ignoring it is the outcome to avoid: the file would keep
297    /// asserting that operations are gated, the operator would keep believing
298    /// it, and nothing would enforce it. Failing to start is unambiguous, and
299    /// the message has to carry the migration or it just moves the confusion.
300    #[test]
301    fn a_config_still_carrying_the_retired_floors_is_refused() {
302        let with_floors = r#"{
303            "jwt_signing_key": null,
304            "step_up": { "enabled": true, "floors": [{ "operation": "*", "mode": "self" }] }
305        }"#;
306        let err = serde_json::from_str::<AuthConfig>(with_floors)
307            .expect_err("`[auth.step_up]` must be refused, not ignored");
308        let msg = err.to_string();
309        assert!(msg.contains("retired"), "got: {msg}");
310        assert!(
311            msg.contains("pnm approvals require"),
312            "the refusal must name what replaces it, got: {msg}"
313        );
314
315        // Even an empty section is refused — an operator who wrote
316        // `[auth.step_up]` and nothing else still has a stale file to fix.
317        assert!(
318            serde_json::from_str::<AuthConfig>(r#"{"jwt_signing_key":null,"step_up":{}}"#).is_err()
319        );
320    }
321
322    /// …and the ordinary case, a config with no such section, still loads.
323    #[test]
324    fn a_config_without_the_retired_section_loads() {
325        let cfg: AuthConfig =
326            serde_json::from_str(r#"{ "jwt_signing_key": null }"#).expect("loads");
327        assert_eq!(cfg.access_token_expiry, default_access_token_expiry());
328    }
329}