Skip to main content

vta_config/
lib.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use vti_common::error::AppError;
4
5// Re-export shared config types
6pub use vti_common::config::{
7    AuditConfig, AuthConfig, LogConfig, LogFormat, MessagingConfig, StoreConfig, VaultConfig,
8};
9// The `[secrets]` config shape + its seed-store backends live in the shared
10// `vti-secrets` crate (issue #501). Re-exported here so `AppConfig.secrets`
11// and every `crate::config::SecretsConfig` reference are unchanged.
12pub use vti_secrets::{SecretBackend, SecretsConfig};
13
14/// Policy Decision Point configuration.
15#[derive(Debug, Clone, Deserialize, Serialize, Default)]
16pub struct PolicyConfig {
17    /// When true, every dispatched Trust Task is evaluated by the PDP before its
18    /// handler runs, and a non-`allow` decision rejects the task. **Default
19    /// false** — enforcement is opt-in so a deployment turns it on deliberately,
20    /// after authoring policies. The boot-installed baseline allows current
21    /// flows, so enabling this changes nothing until an operator adds a
22    /// restrictive, higher-priority policy (expand-before-contract).
23    #[serde(default)]
24    pub enforcement: bool,
25    /// Named approver sets a policy's `requireConsent` references by name; each
26    /// maps to the DIDs permitted to approve a task's execution. Empty by
27    /// default — a `requireConsent` naming an unknown or empty set can never be
28    /// satisfied (fail-closed), so operators define sets before using them.
29    ///
30    /// **Seed, not source of truth.** Approver sets now live on the declarative
31    /// approvals row in the policy keyspace, editable at runtime with
32    /// `pnm approvals approvers`. What is here is copied into that row the first
33    /// time the VTA boots without one, so an IaC-provisioned or freshly-restored
34    /// VTA comes up already configured. After that the row wins and this is
35    /// inert: re-reading it every boot would silently undo runtime edits on the
36    /// next restart, which is the trap the old reconcile-every-boot consent
37    /// policy had.
38    #[serde(default)]
39    pub approver_sets: std::collections::HashMap<String, Vec<String>>,
40    /// Declarative approval rules — which tasks require re-authentication or
41    /// consent before they run.
42    ///
43    /// Same seed-once semantics as [`PolicyConfig::approver_sets`]: this is the
44    /// bring-up path for a VTA with no declarative row yet, not a second place
45    /// the rules live. Read them back (and change them) with `pnm approvals`.
46    #[serde(default)]
47    pub approvals: Vec<vta_sdk::approvals::ApprovalRule>,
48    /// Refuse any task for which this build knows no payload schema.
49    ///
50    /// Payload validation always runs where a schema *is* known — that is not
51    /// optional and has no switch. This governs the other case: 62 of the tasks
52    /// this VTA dispatches have no published spec yet, and refusing them outright
53    /// would break them.
54    ///
55    /// So the default is to validate what we can, warn about what we cannot, and
56    /// proceed. An operator who would rather fail closed sets this — and should
57    /// understand what they are choosing: "no schema" currently means "no spec has
58    /// been written", not "this task is suspicious".
59    ///
60    /// **Default false.** It is a stopgap, and the honest fix is to write the
61    /// missing specs.
62    #[serde(default)]
63    pub require_payload_schema: bool,
64    /// Retired: `[[policy.require_consent]]`.
65    ///
66    /// Present only to **refuse** a config that still declares it. It was the
67    /// third way to tell a VTA an operation needs a human — alongside the
68    /// `[auth.step_up]` floors and [`Self::approvals`] — and it behaved
69    /// differently from both: reconciled from the file on every boot, so it
70    /// silently reverted anything an operator changed at runtime.
71    ///
72    /// [`Self::approvals`] replaces it and is seeded once, then owned by
73    /// `pnm approvals` — which is the difference that matters. A rule you can
74    /// change at runtime and read back is a rule you can diagnose.
75    ///
76    /// Absent (the only accepted state) deserializes to `()` via `default`.
77    #[serde(
78        default,
79        deserialize_with = "refuse_retired_require_consent",
80        skip_serializing
81    )]
82    pub require_consent: (),
83}
84
85/// Reject `[[policy.require_consent]]` with the migration the operator needs.
86///
87/// Only ever called when the key is present — `#[serde(default)]` covers its
88/// absence — so reaching this function *is* the error.
89fn refuse_retired_require_consent<'de, D>(_: D) -> Result<(), D::Error>
90where
91    D: serde::Deserializer<'de>,
92{
93    Err(serde::de::Error::custom(
94        "`[[policy.require_consent]]` has been retired. It was a third, separate \
95         way to say \"this task needs a human\", reconciled from the file on \
96         every boot — so it silently reverted anything changed at runtime. \
97         Declare the same requirements under `[policy.approvals]` (seeded once, \
98         then owned by `pnm approvals require <task-uri> --consent --set \
99         <approver-set>`), and delete this section. A VTA upgraded with the old \
100         section still present drops the rule it synthesized on first boot, so \
101         leaving it in place would enforce nothing regardless.",
102    ))
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize)]
106pub struct AppConfig {
107    pub vta_did: Option<String>,
108    #[serde(alias = "community_name")]
109    pub vta_name: Option<String>,
110    pub public_url: Option<String>,
111    /// WebSocket URL of a remote DID resolver (network mode).
112    /// When set, the VTA uses the remote resolver instead of resolving locally.
113    /// Format: `ws://host:port/did/v1/ws`
114    /// In TEE mode, this points to the affinidi-did-resolver-cache-server
115    /// sidecar on the parent, bridged via vsock.
116    #[serde(default)]
117    pub resolver_url: Option<String>,
118    #[serde(default = "default_server_config")]
119    pub server: ServerConfig,
120    #[serde(default)]
121    pub log: LogConfig,
122    #[serde(default = "default_store_config")]
123    pub store: StoreConfig,
124    pub messaging: Option<MessagingConfig>,
125    /// Startup readiness gate + reconnect policy for the mediator DIDComm
126    /// connection: wait until the VTA's own DID resolves over the network before
127    /// initiating the outbound mediator handshake, then keep the connection up.
128    /// See `docs/02-vta/mediator-connection.md`.
129    #[serde(default)]
130    pub mediator_readiness: MediatorReadinessConfig,
131    #[serde(default)]
132    pub services: ServicesConfig,
133    #[serde(default)]
134    pub auth: AuthConfig,
135    #[serde(default)]
136    pub audit: AuditConfig,
137    /// Vault lifecycle tuning (soft-delete grace window). Shared by the
138    /// password vault and the credential store.
139    #[serde(default)]
140    pub vault: VaultConfig,
141    /// Policy Decision Point settings (enforcement toggle).
142    #[serde(default)]
143    pub policy: PolicyConfig,
144    #[serde(default)]
145    pub secrets: SecretsConfig,
146    /// Verifier DIDs the holder **auto-consents** to when answering a
147    /// `credential-exchange/query` (`present_or_defer`'s `ConsentPolicy`). Any
148    /// verifier not listed **defers** to an out-of-band approval. Default empty
149    /// (defer everything) — a safe default; operators trust specific verifiers.
150    #[serde(default)]
151    pub trusted_presentation_verifiers: Vec<String>,
152    /// The VTA-managed holder identity (a registered derived `subject_did`) the
153    /// VTA **auto-accepts** offered credentials for: when set, an inbound
154    /// `credential-exchange/offer` is answered with a `request` binding the new
155    /// credential to this DID. Default unset — the VTA does **not** accept
156    /// unsolicited offers (a safe default; opt in by naming the holder identity).
157    #[serde(default)]
158    pub credential_holder_did: Option<String>,
159    #[cfg(feature = "tee")]
160    #[serde(default)]
161    pub tee: TeeConfig,
162    /// Non-TEE hardened configuration: derive the storage-encryption key and JWT signing
163    /// key from the master seed at boot, keeping both secrets out of
164    /// `config.toml`. See `hardened.rs` for details.
165    #[serde(default)]
166    pub hardened: HardenedConfig,
167    #[serde(skip)]
168    pub config_path: PathBuf,
169    /// Dotted paths of keys present in the parsed `config.toml` that no
170    /// field of `AppConfig` claims — typos, removed/renamed settings, or
171    /// keys meant for a different section. Collected by `load()` (via
172    /// `serde_ignored`) and surfaced as advisory warnings in `validate()`.
173    /// `#[serde(skip)]` so it never round-trips through the file itself.
174    /// We *warn* rather than reject (no `deny_unknown_fields`): an existing
175    /// deployment may legitimately carry a legacy/extra key, and a config
176    /// that boots fine today must keep booting (P0.9b).
177    #[serde(skip)]
178    pub unknown_keys: Vec<String>,
179}
180
181/// How the mediator self-readiness gate behaves when it times out.
182/// See [`MediatorReadinessConfig`].
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
184#[serde(rename_all = "snake_case")]
185pub enum ReadinessTimeoutPolicy {
186    /// Skip DIDComm startup this boot. `/health` stays live so the LB can turn
187    /// the target healthy; a later restart reconnects. Default.
188    #[default]
189    Skip,
190    /// Connect to the mediator anyway (best-effort), accepting that the
191    /// handshake may be rejected because the mediator can't resolve us yet.
192    Proceed,
193    /// Treat a DID that won't resolve as fatal and shut the process down.
194    Fail,
195}
196
197/// Startup readiness gate for the mediator DIDComm connection.
198///
199/// On cold start a VTA can initiate its outbound mediator handshake before its
200/// own DID document is resolvable — the DID host hasn't published it, or the
201/// load-balancer target fronting it isn't healthy yet. The mediator
202/// authenticates the VTA by resolving that DID itself, so it can't get the key
203/// to decrypt the authcrypt handshake and rejects it, producing a burst of 403s.
204///
205/// This gate makes the VTA wait until its own DID **fully resolves over the
206/// network** — through the configured resolver, so it exercises the same path
207/// the mediator takes — before connecting. Only network-resolved methods
208/// (`did:webvh`, `did:web`) are gated; a `did:key` VTA resolves from its own
209/// identifier with no network fetch and skips the wait.
210#[derive(Debug, Clone, Deserialize, Serialize)]
211pub struct MediatorReadinessConfig {
212    /// Enable the gate. Default `true`.
213    #[serde(default = "default_true")]
214    pub enabled: bool,
215    /// Base (initial) seconds between probe attempts. The actual wait uses
216    /// capped exponential backoff with full jitter — attempt `n` sleeps a
217    /// random duration in `[0, min(backoff_cap_secs, retry_secs * 2^n)]` — so a
218    /// fleet of VTAs coming up together doesn't probe in lock-step. Default 5.
219    #[serde(default = "default_readiness_retry_secs")]
220    pub retry_secs: u64,
221    /// Upper bound on the per-attempt backoff interval (the "cap" in the
222    /// exponential-backoff-with-jitter scheme). The jittered wait never exceeds
223    /// this. Default 30.
224    #[serde(default = "default_readiness_backoff_cap_secs")]
225    pub backoff_cap_secs: u64,
226    /// Maximum seconds the gate waits before applying `on_timeout`. The wait is
227    /// cancellable: a shutdown signal abandons it immediately rather than
228    /// holding the process open for the remainder. Default 300.
229    #[serde(default = "default_readiness_max_wait_secs")]
230    pub max_wait_secs: u64,
231    /// What to do when the gate times out. Default `skip`.
232    #[serde(default)]
233    pub on_timeout: ReadinessTimeoutPolicy,
234    /// Persistent reconnect supervisor. After the self-readiness gate passes,
235    /// the mediator connect can still fail — most commonly because the
236    /// mediator's *own* resolver holds a negative-cache entry for the VTA host
237    /// and can't fetch our DID document to complete the authcrypt handshake
238    /// (a `NetworkError{status_code:None}` → 403). That clears itself once the
239    /// mediator's negative cache expires, so rather than give up until the next
240    /// restart, keep retrying with capped exponential backoff + full jitter.
241    /// Each attempt first re-confirms the VTA can resolve its own DID, so the
242    /// mediator is never touched while the VTA is unresolvable.
243    ///
244    /// This also covers an *established* session whose inbound loop ends: the
245    /// supervisor tears the session down and reconnects, instead of leaving the
246    /// VTA silently deaf until an operator restarts it. Setting this `false`
247    /// restores the legacy single-shot behaviour (one attempt, then nothing
248    /// until the next restart). Default `true`.
249    #[serde(default = "default_true")]
250    pub reconnect: bool,
251    /// Upper bound on the reconnect backoff interval (seconds) — the "cap" for
252    /// the persistent-reconnect scheme. The jittered retry wait never exceeds
253    /// this. Larger than `backoff_cap_secs` (the gate's cap) because the
254    /// reconnect horizon must comfortably outlast a resolver negative-cache TTL,
255    /// which can be many minutes. Default 60.
256    #[serde(default = "default_reconnect_backoff_cap_secs")]
257    pub reconnect_backoff_cap_secs: u64,
258    /// Give up reconnecting after this many seconds of *continuous* failure.
259    /// `0` = never give up (retry forever at the capped, jittered interval).
260    /// A bounded retry rate is safe to run indefinitely and lets the VTA
261    /// self-heal without any operator restart.
262    ///
263    /// The clock is measured from the start of the current run of failures, not
264    /// from process start, and resets after any session that stayed up long
265    /// enough to count as healthy — so a VTA that ran for a week and then
266    /// dropped gets the full budget rather than one it exhausted days ago.
267    /// Default 0.
268    #[serde(default)]
269    pub reconnect_max_elapsed_secs: u64,
270}
271
272fn default_readiness_retry_secs() -> u64 {
273    5
274}
275
276fn default_reconnect_backoff_cap_secs() -> u64 {
277    60
278}
279
280fn default_readiness_backoff_cap_secs() -> u64 {
281    30
282}
283
284fn default_readiness_max_wait_secs() -> u64 {
285    300
286}
287
288impl Default for MediatorReadinessConfig {
289    fn default() -> Self {
290        Self {
291            enabled: true,
292            retry_secs: default_readiness_retry_secs(),
293            backoff_cap_secs: default_readiness_backoff_cap_secs(),
294            max_wait_secs: default_readiness_max_wait_secs(),
295            on_timeout: ReadinessTimeoutPolicy::Skip,
296            reconnect: true,
297            reconnect_backoff_cap_secs: default_reconnect_backoff_cap_secs(),
298            reconnect_max_elapsed_secs: 0,
299        }
300    }
301}
302
303#[derive(Debug, Clone, Deserialize, Serialize)]
304pub struct ServicesConfig {
305    #[serde(default = "default_true")]
306    pub rest: bool,
307    #[serde(default = "default_true")]
308    pub didcomm: bool,
309    /// WebAuthn-RP service — the dedicated `/auth/portal` +
310    /// `/auth/passkey-login/*` + `/did/verification-methods/passkey/*`
311    /// surface. Distinct from `rest` so an operator can run a
312    /// REST-less, browser-facing-only VTA (e.g. one that only
313    /// publishes WebAuthn flows for end-users plus DIDComm for
314    /// programmatic peers). Defaults to `false` because legacy
315    /// installs don't have this surface enabled; new installs that
316    /// want browser-side passkey login flip this on explicitly.
317    #[serde(default)]
318    pub webauthn: bool,
319    /// Trust Spanning Protocol transport. Additive and `false` by
320    /// default while TSP rolls out gated — DIDComm stays the default
321    /// transport. When enabled, the VTA advertises a `#tsp`
322    /// `TSPTransport` service (pointing at the same mediator as
323    /// DIDComm). See `docs/05-design-notes/tsp-enablement.md`.
324    #[serde(default)]
325    pub tsp: bool,
326}
327
328fn default_true() -> bool {
329    true
330}
331
332impl Default for ServicesConfig {
333    fn default() -> Self {
334        Self {
335            rest: true,
336            didcomm: true,
337            webauthn: false,
338            tsp: false,
339        }
340    }
341}
342
343#[derive(Debug, Clone, Deserialize, Serialize)]
344pub struct ServerConfig {
345    #[serde(default = "default_host")]
346    pub host: String,
347    #[serde(default = "default_port")]
348    pub port: u16,
349    /// Origins permitted to make cross-origin requests against the
350    /// VTA's REST surface. Empty (default) disables the CORS layer
351    /// entirely — a fresh-install VTA refuses cross-origin requests
352    /// the way the legacy behaviour did. Production deployments
353    /// typically leave this empty (programmatic clients send the
354    /// bearer token directly and don't need browser-side CORS); the
355    /// demo at `examples/vta-auth-demo/` sets it to
356    /// `["http://localhost:8000"]` so an operator can drive the
357    /// auth flow from a browser running on a different localhost
358    /// port.
359    ///
360    /// Each entry is matched exactly against the request's `Origin`
361    /// header. Wildcards are not accepted — bearer credentials must
362    /// not flow to arbitrary origins.
363    #[serde(default)]
364    pub cors_origins: Vec<String>,
365    /// Whether to trust `X-Forwarded-For` / `Forwarded` headers
366    /// for client-IP attribution in the per-IP rate limiter.
367    ///
368    /// Default `false` — the rate limiter keys on the socket
369    /// peer-IP (`PeerIpKeyExtractor`). This is the safe default
370    /// for direct-binding deployments where an attacker can spoof
371    /// `X-Forwarded-For` to evade rate limiting.
372    ///
373    /// Set `true` only when the VTA runs behind a trust-boundary
374    /// reverse proxy (Nginx, Envoy, ALB) that overwrites or
375    /// strips these headers from external requests — the rate
376    /// limiter switches to `SmartIpKeyExtractor` and walks the
377    /// `X-Forwarded-For` chain. Misconfiguring this (`trust_xff =
378    /// true` with no proxy, or a misconfigured proxy that doesn't
379    /// strip the header) is a silent rate-limit bypass.
380    ///
381    /// Closes L2 from the May 2026 security review.
382    #[serde(default)]
383    pub trust_xff: bool,
384    /// Token replenishment interval for the unauth rate limiter, in **seconds
385    /// per token** — not requests per second. One new token every
386    /// `rate_limit_interval_secs`, so *lower is more permissive*. Default: 5.
387    ///
388    /// With the default `rate_limit_burst = 10`: 10 rapid requests, then one
389    /// every 5 s. Local dev that fires a bootstrap flow in a burst wants
390    /// `rate_limit_interval_secs = 1` and a larger `rate_limit_burst`.
391    ///
392    /// Zero is clamped to 1 at router build (`routes::apply_unauth_governor`);
393    /// the limiter cannot be turned off from config.
394    #[serde(default = "default_rate_limit_interval_secs")]
395    pub rate_limit_interval_secs: u64,
396    /// Burst capacity for the unauth rate limiter — how many requests can
397    /// arrive back-to-back before throttling starts. Default: 10. Zero is
398    /// clamped to 1.
399    #[serde(default = "default_rate_limit_burst")]
400    pub rate_limit_burst: u32,
401}
402
403fn default_host() -> String {
404    "0.0.0.0".to_string()
405}
406
407fn default_port() -> u16 {
408    8100
409}
410
411fn default_rate_limit_interval_secs() -> u64 {
412    5
413}
414
415fn default_rate_limit_burst() -> u32 {
416    10
417}
418
419fn default_server_config() -> ServerConfig {
420    ServerConfig::default()
421}
422
423fn default_store_config() -> StoreConfig {
424    StoreConfig {
425        data_dir: PathBuf::from("data/vta"),
426    }
427}
428
429impl Default for ServerConfig {
430    fn default() -> Self {
431        Self {
432            host: default_host(),
433            port: default_port(),
434            cors_origins: Vec::new(),
435            trust_xff: false,
436            rate_limit_interval_secs: default_rate_limit_interval_secs(),
437            rate_limit_burst: default_rate_limit_burst(),
438        }
439    }
440}
441
442/// TEE attestation configuration.
443#[cfg(feature = "tee")]
444#[derive(Debug, Clone, Deserialize, Serialize)]
445pub struct TeeConfig {
446    /// Enforcement mode: required, optional, disabled, simulated.
447    #[serde(default)]
448    pub mode: TeeMode,
449    /// Whether to embed attestation info as a DID document service.
450    #[serde(default)]
451    pub embed_in_did: bool,
452    /// Attestation report cache TTL in seconds (generation is expensive).
453    #[serde(default = "default_attestation_cache_ttl")]
454    pub attestation_cache_ttl: u64,
455    /// KMS-based secret bootstrap configuration (for Nitro Enclaves).
456    #[serde(default)]
457    pub kms: Option<TeeKmsConfig>,
458    /// Storage encryption salt (change to invalidate all stored data).
459    /// WARNING: Changing this value invalidates all encrypted storage.
460    #[serde(default = "default_storage_key_salt")]
461    pub storage_key_salt: String,
462    /// Restrict which DID methods are accepted for ACL entries and authentication.
463    /// When set, only DIDs matching these prefixes are allowed (e.g., `["did:key", "did:webvh"]`).
464    /// When `None`, all DID methods are accepted (less secure with parent-side resolver).
465    #[serde(default)]
466    pub allowed_did_methods: Option<Vec<String>>,
467}
468
469/// KMS configuration for TEE secret bootstrap.
470#[cfg(feature = "tee")]
471#[derive(Debug, Clone, Deserialize, Serialize)]
472pub struct TeeKmsConfig {
473    /// AWS region for KMS calls.
474    pub region: String,
475    /// KMS key ARN used to encrypt/decrypt VTA secrets.
476    pub key_arn: String,
477    /// Template for auto-generating a did:webvh identity on first boot.
478    ///
479    /// Use `{SCID}` as a placeholder for the self-certifying identifier:
480    ///   `did:webvh:{SCID}:example.com:vta`
481    ///
482    /// On first boot, the VTA derives keys from the bootstrapped seed,
483    /// creates the DID, and persists it in the encrypted store.
484    ///
485    /// Ignored if `vta_did` is already set in config or the store.
486    #[serde(default)]
487    pub vta_did_template: Option<String>,
488    /// Context ID used for the auto-bootstrapped admin (default: "default").
489    ///
490    /// On first boot, the VTA auto-creates this context and grants the
491    /// admin_did super-admin access.
492    #[serde(default = "default_admin_context_id")]
493    pub admin_context_id: String,
494    /// DID to grant super-admin access on first boot.
495    ///
496    /// The operator generates a `did:key` locally (e.g., via `pnm setup`),
497    /// sets it here before building the EIF, and connects to the VTA using
498    /// the corresponding private key after boot. The private key never
499    /// touches the TEE or the parent instance.
500    ///
501    /// If not set, the VTA auto-generates a random `did:key` and stores
502    /// the credential in the bootstrap keyspace (retrievable via REST).
503    #[serde(default)]
504    pub admin_did: Option<String>,
505    /// Allow falling back to non-attested KMS calls when the attested path
506    /// fails on real Nitro hardware (`/dev/nsm` present).
507    ///
508    /// **Default: false.** On production hardware a failure to use the
509    /// Nitro `Recipient` parameter must be terminal — otherwise a transient
510    /// NSM hiccup silently downgrades to an IAM-only KMS call, bypassing
511    /// the key policy's PCR conditions (PCR0/PCR8). The fallback path stays
512    /// available for simulated mode (no `/dev/nsm`), which uses the direct
513    /// KMS call regardless of this flag.
514    ///
515    /// Set to `true` only as a break-glass measure during incident response,
516    /// understanding that decrypts will then only require the enclave's IAM
517    /// role, not an attested PCR match.
518    #[serde(default)]
519    pub allow_unattested_fallback: bool,
520    /// Allow initializing the JWT key fingerprint when none is stored.
521    ///
522    /// **Default: false.** A missing fingerprint on a subsequent boot is
523    /// suspicious — the only legitimate cause is first boot after upgrading
524    /// from a pre-fingerprint VTA version. Left unguarded, an attacker with
525    /// write access to the bootstrap keyspace (parent-host / vsock proxy
526    /// compromise) could delete the fingerprint and then substitute a
527    /// rogue key that the enclave would accept as canonical on the next
528    /// restart.
529    ///
530    /// Operators migrating from a pre-fingerprint VTA: set `true`, boot
531    /// once to store the fingerprint, then set back to `false`.
532    #[serde(default)]
533    pub allow_fingerprint_init: bool,
534    /// Allow auto-clearing existing bootstrap ciphertexts when a KMS
535    /// decrypt **other than ACCESS_DENIED** fails on a subsequent boot.
536    ///
537    /// **Default: false.** ACCESS_DENIED is the legitimate post-rebuild
538    /// signal (PCR mismatch — the enclave's measurements changed and KMS
539    /// won't decrypt the old data key); the bootstrap keyspace is
540    /// auto-cleared without this flag in that case. Any other class
541    /// of decrypt failure (transient KMS error, network glitch,
542    /// ciphertext corruption, attacker-induced byte flip) is *not*
543    /// auto-cleared, because doing so would silently delete the VTA's
544    /// identity. Set to `true` only when you have diagnosed the cause
545    /// and intend to reset the VTA to a fresh first-boot state.
546    #[serde(default)]
547    pub allow_kms_reinit: bool,
548    /// Allow establishing the TEE integrity-manifest baseline when none is
549    /// stored (P0.2a anti-rollback anchor).
550    ///
551    /// **Default: false.** The integrity manifest is the MAC'd snapshot of the
552    /// rollback-protected singletons (carve-out sentinel, ACL root, JWT
553    /// fingerprint, key counters). A missing manifest on a configured VTA is
554    /// indistinguishable from a parent-deleted one, so the enclave refuses to
555    /// boot rather than silently baseline whatever (possibly rolled-back) state
556    /// the parent presents.
557    ///
558    /// Operators on first boot, or migrating from a pre-manifest VTA: set
559    /// `true`, boot once to establish the baseline, then set back to `false`.
560    /// Mirrors [`Self::allow_fingerprint_init`].
561    #[serde(default)]
562    pub allow_anchor_init: bool,
563    /// External anti-rollback counter (P0.2b). When set, the integrity manifest
564    /// version is pinned to a DynamoDB single-item counter the parent can't roll
565    /// back, upgrading detection from "deletion / inconsistent tamper" (P0.2a)
566    /// to "consistent storage rollback". Absent → manifest-only (P0.2a).
567    #[serde(default)]
568    pub anchor: Option<TeeAnchorConfig>,
569    /// Break-glass: boot even when the external anchor counter can't be reached
570    /// or disagrees with the local manifest (P0.2b).
571    ///
572    /// **Default: false.** If the parent denies egress to the counter the
573    /// enclave fails closed (a DoS, not an integrity breach). Setting this true
574    /// lets it boot manifest-only when the counter is unreachable, or re-anchor
575    /// the counter to the MAC-trusted local manifest when they diverge — for
576    /// incident recovery only. Safe to expose as config because TEE config is
577    /// baked into the measured EIF, so the parent can't flip it at runtime.
578    #[serde(default)]
579    pub allow_unanchored: bool,
580}
581
582/// External anti-rollback anchor configuration (P0.2b counter + P0.2c writer).
583#[cfg(feature = "tee")]
584#[derive(Debug, Clone, Deserialize, Serialize)]
585pub struct TeeAnchorConfig {
586    /// DynamoDB table holding the single-item monotonic version counter (one
587    /// item per VTA DID). The region is reused from [`TeeKmsConfig::region`].
588    pub table_name: String,
589    /// KMS-attestation-gated writer credential (P0.2c — root-on-parent
590    /// resistance). Base64 of the `vta-anchor-writer` IAM credentials
591    /// (`{"access_key_id","secret_access_key"}`) sealed under the PCR-gated KMS
592    /// key ([`TeeKmsConfig::key_arn`]): only the genuine enclave image can
593    /// `kms:Decrypt` it, so a root-on-parent attacker — who holds the
594    /// *instance-role* credentials but cannot produce a valid attestation —
595    /// cannot obtain the only principal allowed to write the counter (the
596    /// instance role is explicitly denied on the table; see the operator
597    /// runbook). Unset → P0.2b: the counter is written with the instance role
598    /// (resists storage/backup rollback, **not** root-on-parent).
599    #[serde(default)]
600    pub writer_credential_ciphertext: Option<String>,
601}
602
603// KMS ciphertexts (seed, JWT key, fingerprint) are stored as K/V entries
604// in the "bootstrap" keyspace — no file paths needed.
605
606#[cfg(feature = "tee")]
607fn default_admin_context_id() -> String {
608    "default".to_string()
609}
610
611#[cfg(feature = "tee")]
612fn default_attestation_cache_ttl() -> u64 {
613    300
614}
615
616#[cfg(feature = "tee")]
617fn default_storage_key_salt() -> String {
618    "vta-tee-storage-v1".to_string()
619}
620
621/// Fallback salt for configs that predate per-VTA salt generation.
622///
623/// **Do not change this value.** It is not a "default" in the sense of a
624/// recommended setting — it is the compatibility constant that keeps any VTA
625/// whose `config.toml` omits `storage_key_salt` able to re-derive its own
626/// storage key. Changing it makes those deployments unable to read their store.
627///
628/// New installs do not use it: `vta setup` mints a random per-VTA salt
629/// (`hardened_bootstrap::generate_storage_key_salt`) and writes it into
630/// `config.toml`.
631fn default_hardened_storage_key_salt() -> String {
632    "vta-storage-v1".to_string()
633}
634
635/// Non-TEE hardened configuration: derive storage-encryption and JWT signing keys from
636/// the master seed, so neither secret lives in `config.toml` or on disk.
637///
638/// This PoC mirrors the key-derivation that `vta-enclave` performs inside the
639/// Nitro enclave (see `tee::kms_bootstrap`), without requiring KMS or an
640/// enclave. The seed must reside in a real secret-store backend — the
641/// plaintext file fallback (`PlaintextSeedStore`) defeats the protection.
642///
643/// Enable in `config.toml`:
644/// ```toml
645/// [hardened]
646/// enabled = true
647/// storage_key_salt = "my-unique-per-vta-salt"
648/// ```
649///
650/// **Enabling on an existing VTA is handled automatically.** The first boot
651/// after setting this flag converts the existing plaintext rows to the
652/// encrypted format before anything reads them
653/// (`hardened_bootstrap::migrate_store_to_encrypted`). The pass is idempotent
654/// and crash-safe, so it costs one prefix scan per keyspace on later boots and
655/// an interrupted run is finished by the next one.
656///
657/// It has to be automatic: the store's decrypt path is deliberately
658/// fail-closed with no plaintext fallback, so a VTA that started reading an
659/// unconverted store would fail on every pre-existing row — including its own
660/// ACL entries. Take a backup first regardless; the conversion rewrites every
661/// row in place.
662#[derive(Debug, Clone, Serialize, Deserialize)]
663pub struct HardenedConfig {
664    /// When `true`, enables hardened non-TEE configuration:
665    /// - All 23 fjall keyspaces are encrypted with AES-256-GCM (`VAE1` format,
666    ///   same as TEE mode). The storage-encryption key is derived from the
667    ///   master seed via HKDF.
668    /// - The JWT signing key is generated randomly on first boot, AES-GCM
669    ///   sealed under the storage key, and stored in the `bootstrap` keyspace.
670    ///   It is injected into memory only — `[auth] jwt_signing_key` in
671    ///   `config.toml` is absent and ignored.
672    ///
673    /// Default `false` (standard non-TEE behaviour — plaintext fjall, JWT key
674    /// in `config.toml`).
675    #[serde(default)]
676    pub enabled: bool,
677
678    /// Salt for the HKDF storage-key derivation.
679    ///
680    /// **Changing this invalidates all encrypted data.** Set it once at
681    /// initial setup and treat it as permanent. Ignored when
682    /// `enabled = false`.
683    #[serde(default = "default_hardened_storage_key_salt")]
684    pub storage_key_salt: String,
685}
686
687impl Default for HardenedConfig {
688    fn default() -> Self {
689        Self {
690            enabled: false,
691            storage_key_salt: default_hardened_storage_key_salt(),
692        }
693    }
694}
695
696#[cfg(feature = "tee")]
697impl Default for TeeConfig {
698    fn default() -> Self {
699        Self {
700            mode: TeeMode::default(),
701            embed_in_did: false,
702            attestation_cache_ttl: default_attestation_cache_ttl(),
703            kms: None,
704            storage_key_salt: default_storage_key_salt(),
705            allowed_did_methods: None,
706        }
707    }
708}
709
710/// TEE enforcement mode.
711#[cfg(feature = "tee")]
712#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
713#[serde(rename_all = "lowercase")]
714pub enum TeeMode {
715    /// TEE hardware required — VTA refuses to start without it.
716    Required,
717    /// TEE used if available, continues without it.
718    #[default]
719    Optional,
720    /// Simulated TEE for development/testing (NOT for production).
721    Simulated,
722}
723
724impl AppConfig {
725    pub fn load(config_path: Option<PathBuf>) -> Result<Self, AppError> {
726        let path = config_path
727            .or_else(|| std::env::var("VTA_CONFIG_PATH").ok().map(PathBuf::from))
728            .unwrap_or_else(|| PathBuf::from("config.toml"));
729
730        if !path.exists() {
731            return Err(AppError::Config(format!(
732                "configuration file not found: {}",
733                path.display()
734            )));
735        }
736
737        let contents = std::fs::read_to_string(&path).map_err(AppError::Io)?;
738
739        // Deserialize through `serde_ignored` so we can *record* every key the
740        // schema doesn't recognise (typo'd / legacy / mis-sectioned) instead of
741        // silently dropping it. We don't reject — `validate()` warns. (P0.9b)
742        let de = toml::Deserializer::parse(&contents)
743            .map_err(|e| AppError::Config(format!("failed to parse {}: {e}", path.display())))?;
744        let mut unknown_keys: Vec<String> = Vec::new();
745        let mut config: AppConfig = serde_ignored::deserialize(de, |key_path| {
746            unknown_keys.push(key_path.to_string());
747        })
748        .map_err(|e| AppError::Config(format!("failed to parse {}: {e}", path.display())))?;
749
750        config.config_path = path.clone();
751        config.unknown_keys = unknown_keys;
752
753        // =====================================================================
754        // SECURITY: When KMS bootstrap is configured (TEE mode), the config
755        // baked into the EIF is authoritative. ALL env var overrides are blocked
756        // except VTA_LOG_LEVEL and VTA_LOG_FORMAT (operational, no security impact).
757        //
758        // This prevents an attacker with server access from overriding identity
759        // (VTA_DID), endpoints (VTA_PUBLIC_URL, VTA_MESSAGING_*), secrets
760        // (VTA_SECRETS_*, VTA_AUTH_JWT_SIGNING_KEY), or security settings
761        // (VTA_TEE_MODE) via environment variables.
762        //
763        // In Nitro Enclaves, env var injection is already blocked by the enclave
764        // model (no --env flag on nitro-cli run-enclave). This gate provides
765        // defense in depth for non-Nitro TEE deployments (e.g., SEV-SNP).
766        // =====================================================================
767        #[cfg(feature = "tee")]
768        let kms_locked = config.tee.kms.is_some();
769        #[cfg(not(feature = "tee"))]
770        let kms_locked = false;
771
772        if kms_locked {
773            // In KMS mode, only allow log settings
774            if let Ok(level) = std::env::var("VTA_LOG_LEVEL") {
775                config.log.level = level;
776            }
777            if let Ok(format) = std::env::var("VTA_LOG_FORMAT") {
778                config.log.format = match format.to_lowercase().as_str() {
779                    "json" => LogFormat::Json,
780                    "text" => LogFormat::Text,
781                    other => {
782                        return Err(AppError::Config(format!(
783                            "invalid VTA_LOG_FORMAT '{other}', expected 'text' or 'json'"
784                        )));
785                    }
786                };
787            }
788
789            // Log warnings for any env vars that would have been applied
790            let blocked_vars = [
791                "VTA_DID",
792                "VTA_SERVER_HOST",
793                "VTA_SERVER_PORT",
794                "VTA_PUBLIC_URL",
795                "VTA_STORE_DATA_DIR",
796                "VTA_MESSAGING_MEDIATOR_URL",
797                "VTA_MESSAGING_MEDIATOR_DID",
798                "VTA_SECRETS_SEED",
799                "VTA_SECRETS_AWS_SECRET_NAME",
800                "VTA_SECRETS_AWS_REGION",
801                "VTA_SECRETS_GCP_PROJECT",
802                "VTA_SECRETS_GCP_SECRET_NAME",
803                "VTA_SECRETS_AZURE_VAULT_URL",
804                "VTA_SECRETS_AZURE_SECRET_NAME",
805                "VTA_SECRETS_KEYRING_SERVICE",
806                "VTA_SECRETS_K8S_SECRET_NAME",
807                "VTA_SECRETS_K8S_NAMESPACE",
808                "VTA_SECRETS_K8S_SECRET_KEY",
809                "VTA_AUTH_ACCESS_EXPIRY",
810                "VTA_AUTH_REFRESH_EXPIRY",
811                "VTA_AUTH_CHALLENGE_TTL",
812                "VTA_AUTH_SESSION_CLEANUP_INTERVAL",
813                "VTA_AUTH_JWT_SIGNING_KEY",
814                "VTA_TEE_MODE",
815                "VTA_TEE_EMBED_IN_DID",
816                "VTA_TEE_ATTESTATION_CACHE_TTL",
817            ];
818            for var in &blocked_vars {
819                if std::env::var(var).is_ok() {
820                    tracing::warn!(
821                        "SECURITY: {var} env var ignored — config is locked when KMS bootstrap is active"
822                    );
823                }
824            }
825        } else {
826            // Non-KMS mode: apply all env var overrides (existing behavior)
827            Self::apply_env_overrides(&mut config)?;
828        }
829
830        Ok(config)
831    }
832
833    /// Apply environment variable overrides to the config.
834    ///
835    /// Only called in non-KMS mode. When KMS bootstrap is active,
836    /// the baked-in config is authoritative and env overrides are blocked.
837    fn apply_env_overrides(config: &mut AppConfig) -> Result<(), AppError> {
838        if let Ok(vta_did) = std::env::var("VTA_DID") {
839            config.vta_did = Some(vta_did);
840        }
841        if let Ok(host) = std::env::var("VTA_SERVER_HOST") {
842            config.server.host = host;
843        }
844        if let Ok(port) = std::env::var("VTA_SERVER_PORT") {
845            config.server.port = port
846                .parse()
847                .map_err(|e| AppError::Config(format!("invalid VTA_SERVER_PORT: {e}")))?;
848        }
849        if let Ok(level) = std::env::var("VTA_LOG_LEVEL") {
850            config.log.level = level;
851        }
852        if let Ok(format) = std::env::var("VTA_LOG_FORMAT") {
853            config.log.format = match format.to_lowercase().as_str() {
854                "json" => LogFormat::Json,
855                "text" => LogFormat::Text,
856                other => {
857                    return Err(AppError::Config(format!(
858                        "invalid VTA_LOG_FORMAT '{other}', expected 'text' or 'json'"
859                    )));
860                }
861            };
862        }
863        if let Ok(public_url) = std::env::var("VTA_PUBLIC_URL") {
864            config.public_url = Some(public_url);
865        }
866        if let Ok(data_dir) = std::env::var("VTA_STORE_DATA_DIR") {
867            config.store.data_dir = PathBuf::from(data_dir);
868        }
869
870        // Messaging
871        match (
872            std::env::var("VTA_MESSAGING_MEDIATOR_URL"),
873            std::env::var("VTA_MESSAGING_MEDIATOR_DID"),
874        ) {
875            (Ok(url), Ok(did)) => {
876                config.messaging = Some(MessagingConfig {
877                    mediator_url: url,
878                    mediator_did: did,
879                    mediator_host: None,
880                    setup_acl: false,
881                    drain_inbox_on_start: false,
882                });
883            }
884            (Ok(url), Err(_)) => {
885                let messaging = config.messaging.get_or_insert(MessagingConfig {
886                    mediator_url: String::new(),
887                    mediator_did: String::new(),
888                    mediator_host: None,
889                    setup_acl: false,
890                    drain_inbox_on_start: false,
891                });
892                messaging.mediator_url = url;
893            }
894            (Err(_), Ok(did)) => {
895                let messaging = config.messaging.get_or_insert(MessagingConfig {
896                    mediator_url: String::new(),
897                    mediator_did: String::new(),
898                    mediator_host: None,
899                    setup_acl: false,
900                    drain_inbox_on_start: false,
901                });
902                messaging.mediator_did = did;
903            }
904            (Err(_), Err(_)) => {}
905        }
906
907        // Secrets
908        if let Ok(seed) = std::env::var("VTA_SECRETS_SEED") {
909            config.secrets.seed = Some(seed);
910        }
911        if let Ok(name) = std::env::var("VTA_SECRETS_AWS_SECRET_NAME") {
912            config.secrets.aws_secret_name = Some(name);
913        }
914        if let Ok(region) = std::env::var("VTA_SECRETS_AWS_REGION") {
915            config.secrets.aws_region = Some(region);
916        }
917        if let Ok(project) = std::env::var("VTA_SECRETS_GCP_PROJECT") {
918            config.secrets.gcp_project = Some(project);
919        }
920        if let Ok(name) = std::env::var("VTA_SECRETS_GCP_SECRET_NAME") {
921            config.secrets.gcp_secret_name = Some(name);
922        }
923        if let Ok(url) = std::env::var("VTA_SECRETS_AZURE_VAULT_URL") {
924            config.secrets.azure_vault_url = Some(url);
925        }
926        if let Ok(name) = std::env::var("VTA_SECRETS_AZURE_SECRET_NAME") {
927            config.secrets.azure_secret_name = Some(name);
928        }
929        if let Ok(service) = std::env::var("VTA_SECRETS_KEYRING_SERVICE") {
930            config.secrets.keyring_service = service;
931        }
932
933        // Vault. K8s deployments commonly inject these via Secret /
934        // ConfigMap so envs override file-config. `VAULT_ADDR` /
935        // `VAULT_NAMESPACE` / `VAULT_TOKEN` are the canonical names
936        // Vault itself uses; we accept those alongside the
937        // VTA_SECRETS_* prefix for symmetry.
938        if let Ok(addr) =
939            std::env::var("VAULT_ADDR").or_else(|_| std::env::var("VTA_SECRETS_VAULT_ADDR"))
940        {
941            config.secrets.vault_addr = Some(addr);
942        }
943        if let Ok(ns) = std::env::var("VAULT_NAMESPACE")
944            .or_else(|_| std::env::var("VTA_SECRETS_VAULT_NAMESPACE"))
945        {
946            config.secrets.vault_namespace = Some(ns);
947        }
948        if let Ok(path) = std::env::var("VTA_SECRETS_VAULT_SECRET_PATH") {
949            config.secrets.vault_secret_path = Some(path);
950        }
951        if let Ok(key) = std::env::var("VTA_SECRETS_VAULT_SECRET_KEY") {
952            config.secrets.vault_secret_key = key;
953        }
954        if let Ok(mount) = std::env::var("VTA_SECRETS_VAULT_KV_MOUNT") {
955            config.secrets.vault_kv_mount = mount;
956        }
957        if let Ok(method) = std::env::var("VTA_SECRETS_VAULT_AUTH_METHOD") {
958            config.secrets.vault_auth_method = method;
959        }
960        if let Ok(role) = std::env::var("VTA_SECRETS_VAULT_K8S_ROLE") {
961            config.secrets.vault_k8s_role = Some(role);
962        }
963        if let Ok(mount) = std::env::var("VTA_SECRETS_VAULT_K8S_MOUNT") {
964            config.secrets.vault_k8s_mount = mount;
965        }
966        if let Ok(jwt) = std::env::var("VTA_SECRETS_VAULT_K8S_JWT_PATH") {
967            config.secrets.vault_k8s_jwt_path = jwt;
968        }
969        if let Ok(token) = std::env::var("VAULT_TOKEN") {
970            config.secrets.vault_token = Some(token);
971        }
972        if let Ok(rid) = std::env::var("VTA_SECRETS_VAULT_APPROLE_ROLE_ID") {
973            config.secrets.vault_approle_role_id = Some(rid);
974        }
975        if let Ok(sid) = std::env::var("VTA_SECRETS_VAULT_APPROLE_SECRET_ID") {
976            config.secrets.vault_approle_secret_id = Some(sid);
977        }
978        if let Ok(mount) = std::env::var("VTA_SECRETS_VAULT_APPROLE_MOUNT") {
979            config.secrets.vault_approle_mount = mount;
980        }
981        if let Ok(skip) = std::env::var("VAULT_SKIP_VERIFY")
982            .or_else(|_| std::env::var("VTA_SECRETS_VAULT_SKIP_VERIFY"))
983        {
984            config.secrets.vault_skip_verify =
985                matches!(skip.to_ascii_lowercase().as_str(), "1" | "true" | "yes");
986        }
987
988        // Kubernetes Secret backend. K8s deployments commonly inject the
989        // namespace from the pod's own metadata via the Downward API, so an
990        // env override is the natural way to set it.
991        if let Ok(name) = std::env::var("VTA_SECRETS_K8S_SECRET_NAME") {
992            config.secrets.k8s_secret_name = Some(name);
993        }
994        if let Ok(ns) = std::env::var("VTA_SECRETS_K8S_NAMESPACE") {
995            config.secrets.k8s_namespace = Some(ns);
996        }
997        if let Ok(key) = std::env::var("VTA_SECRETS_K8S_SECRET_KEY") {
998            config.secrets.k8s_secret_key = key;
999        }
1000
1001        // Auth
1002        if let Ok(expiry) = std::env::var("VTA_AUTH_ACCESS_EXPIRY") {
1003            config.auth.access_token_expiry = expiry
1004                .parse()
1005                .map_err(|e| AppError::Config(format!("invalid VTA_AUTH_ACCESS_EXPIRY: {e}")))?;
1006        }
1007        if let Ok(expiry) = std::env::var("VTA_AUTH_REFRESH_EXPIRY") {
1008            config.auth.refresh_token_expiry = expiry
1009                .parse()
1010                .map_err(|e| AppError::Config(format!("invalid VTA_AUTH_REFRESH_EXPIRY: {e}")))?;
1011        }
1012        if let Ok(ttl) = std::env::var("VTA_AUTH_CHALLENGE_TTL") {
1013            config.auth.challenge_ttl = ttl
1014                .parse()
1015                .map_err(|e| AppError::Config(format!("invalid VTA_AUTH_CHALLENGE_TTL: {e}")))?;
1016        }
1017        if let Ok(interval) = std::env::var("VTA_AUTH_SESSION_CLEANUP_INTERVAL") {
1018            config.auth.session_cleanup_interval = interval.parse().map_err(|e| {
1019                AppError::Config(format!("invalid VTA_AUTH_SESSION_CLEANUP_INTERVAL: {e}"))
1020            })?;
1021        }
1022        if let Ok(key) = std::env::var("VTA_AUTH_JWT_SIGNING_KEY") {
1023            config.auth.jwt_signing_key = Some(key);
1024        }
1025
1026        // Audit
1027        if let Ok(val) = std::env::var("VTA_AUDIT_RETENTION_DAYS")
1028            && let Ok(days) = val.parse::<u32>()
1029        {
1030            config.audit.retention_days = days;
1031        }
1032
1033        // TEE (non-KMS mode — all overrides allowed)
1034        #[cfg(feature = "tee")]
1035        {
1036            if let Ok(mode) = std::env::var("VTA_TEE_MODE") {
1037                config.tee.mode = match mode.to_lowercase().as_str() {
1038                    "required" => TeeMode::Required,
1039                    "optional" => TeeMode::Optional,
1040                    "simulated" => TeeMode::Simulated,
1041                    "disabled" => {
1042                        tracing::warn!(
1043                            "VTA_TEE_MODE=disabled is deprecated — use 'optional' instead"
1044                        );
1045                        TeeMode::Optional
1046                    }
1047                    other => {
1048                        return Err(AppError::Config(format!(
1049                            "invalid VTA_TEE_MODE '{other}', expected 'required', 'optional', or 'simulated'"
1050                        )));
1051                    }
1052                };
1053            }
1054            if let Ok(val) = std::env::var("VTA_TEE_EMBED_IN_DID") {
1055                config.tee.embed_in_did = val
1056                    .parse()
1057                    .map_err(|e| AppError::Config(format!("invalid VTA_TEE_EMBED_IN_DID: {e}")))?;
1058            }
1059            if let Ok(val) = std::env::var("VTA_TEE_ATTESTATION_CACHE_TTL") {
1060                config.tee.attestation_cache_ttl = val.parse().map_err(|e| {
1061                    AppError::Config(format!("invalid VTA_TEE_ATTESTATION_CACHE_TTL: {e}"))
1062                })?;
1063            }
1064        }
1065
1066        Ok(())
1067    }
1068
1069    /// Validate the loaded runtime config, called at daemon boot
1070    /// (`server::run`). Catches misconfigurations that would otherwise
1071    /// produce a half-started or misbehaving service — the setup wizard
1072    /// validates its *inputs*, but a hand-edited `config.toml` never went
1073    /// through that gate.
1074    ///
1075    /// Conservative by design: it hard-errors only on values that are
1076    /// unambiguously broken (a present-but-empty URL, a zero retention
1077    /// window the sweeper can't honour) and *warns* — never blocks — on
1078    /// cross-field advisories that a working deployment might legitimately
1079    /// have, so it can't reject a config that boots fine today.
1080    pub fn validate(&self) -> Result<(), AppError> {
1081        // Advisory (non-blocking): keys the schema doesn't recognise. Emitted
1082        // here rather than in `load()` because `load()` runs before the tracing
1083        // subscriber is installed, so a warn there would be dropped. A typo'd
1084        // key means the operator's intended setting silently took its default —
1085        // worth flagging, but never a reason to refuse a config that otherwise
1086        // boots (P0.9b — softer than `deny_unknown_fields`).
1087        for key in &self.unknown_keys {
1088            tracing::warn!(
1089                "unknown configuration key `{key}` in {} — ignored. Check for a typo, \
1090                 a removed/renamed setting, or a key placed in the wrong [section].",
1091                self.config_path.display()
1092            );
1093        }
1094
1095        let mut errors: Vec<String> = Vec::new();
1096
1097        // A present-but-empty URL is always a mistake (the operator set the
1098        // key and left it blank); an *absent* key is fine (the default /
1099        // serverless path).
1100        if self
1101            .public_url
1102            .as_deref()
1103            .is_some_and(|u| u.trim().is_empty())
1104        {
1105            errors.push(
1106                "public_url is set to an empty string — remove the key for a \
1107                 serverless VTA, or give it a value (e.g. https://vta.example.com)"
1108                    .into(),
1109            );
1110        }
1111        if self
1112            .resolver_url
1113            .as_deref()
1114            .is_some_and(|u| u.trim().is_empty())
1115        {
1116            errors.push(
1117                "resolver_url is set to an empty string — remove the key to resolve \
1118                 DIDs locally, or give it a ws:// or wss:// URL"
1119                    .into(),
1120            );
1121        }
1122        // retention_days = 0 would silently disable audit retention; the
1123        // sweeper assumes a positive window. (Mirrors the setup-time rule.)
1124        if self.audit.retention_days == 0 {
1125            errors.push("audit.retention_days must be > 0 (default is 28)".into());
1126        }
1127
1128        if !errors.is_empty() {
1129            return Err(AppError::Config(format!(
1130                "invalid configuration in {}:\n  - {}",
1131                self.config_path.display(),
1132                errors.join("\n  - ")
1133            )));
1134        }
1135
1136        // Advisory (non-blocking): a REST-advertising VTA with no public_url
1137        // publishes a DID document with no reachable REST endpoint. We don't
1138        // hard-fail — a dev VTA legitimately runs REST without publishing —
1139        // but the operator should see it.
1140        if self.services.rest && self.public_url.is_none() {
1141            tracing::warn!(
1142                "services.rest = true but public_url is unset — the VTA DID document \
1143                 will advertise no reachable REST endpoint"
1144            );
1145        }
1146
1147        Ok(())
1148    }
1149
1150    pub fn save(&self) -> Result<(), AppError> {
1151        let contents = toml::to_string_pretty(self)
1152            .map_err(|e| AppError::Config(format!("failed to serialize config: {e}")))?;
1153        std::fs::write(&self.config_path, contents).map_err(AppError::Io)?;
1154        Ok(())
1155    }
1156}
1157
1158#[cfg(test)]
1159mod validate_tests {
1160    use super::*;
1161
1162    /// Parse a (possibly empty) TOML snippet into an `AppConfig`. An empty
1163    /// document is valid — every field defaults (Options to None, server /
1164    /// store / audit to their default fns).
1165    fn cfg(toml_str: &str) -> AppConfig {
1166        toml::from_str::<AppConfig>(toml_str).expect("parse test config")
1167    }
1168
1169    #[test]
1170    fn default_config_validates() {
1171        cfg("")
1172            .validate()
1173            .expect("a fully-defaulted config must validate");
1174    }
1175
1176    #[test]
1177    fn zero_retention_days_is_rejected() {
1178        let err = cfg("[audit]\nretention_days = 0\n")
1179            .validate()
1180            .expect_err("retention_days = 0 must be rejected");
1181        assert!(format!("{err:?}").contains("retention_days"), "{err:?}");
1182    }
1183
1184    #[test]
1185    fn present_but_empty_public_url_is_rejected() {
1186        let err = cfg("public_url = \"\"\n")
1187            .validate()
1188            .expect_err("empty public_url must be rejected");
1189        assert!(format!("{err:?}").contains("public_url"), "{err:?}");
1190    }
1191
1192    #[test]
1193    fn present_but_empty_resolver_url_is_rejected() {
1194        let err = cfg("resolver_url = \"   \"\n")
1195            .validate()
1196            .expect_err("whitespace-only resolver_url must be rejected");
1197        assert!(format!("{err:?}").contains("resolver_url"), "{err:?}");
1198    }
1199
1200    #[test]
1201    fn rest_without_public_url_only_warns_does_not_fail() {
1202        // services.rest defaults to true and public_url is absent — this is
1203        // an advisory (a dev VTA legitimately runs REST without publishing),
1204        // so validate must NOT hard-fail.
1205        cfg("")
1206            .validate()
1207            .expect("rest-without-public_url is advisory, not an error");
1208    }
1209
1210    /// Write `contents` to a `config.toml` in a fresh tempdir and run it
1211    /// through the real `AppConfig::load` path (the only path that populates
1212    /// `unknown_keys` — `toml::from_str` doesn't). Returns the loaded config;
1213    /// the `TempDir` is returned too so the file outlives the call.
1214    fn load(contents: &str) -> (AppConfig, tempfile::TempDir) {
1215        let dir = tempfile::tempdir().expect("tempdir");
1216        let path = dir.path().join("config.toml");
1217        std::fs::write(&path, contents).expect("write config");
1218        let config = AppConfig::load(Some(path)).expect("load config");
1219        (config, dir)
1220    }
1221
1222    #[test]
1223    fn unknown_keys_are_collected_not_rejected() {
1224        // A typo'd top-level key and a typo inside a nested table. `load`
1225        // must succeed (no rejection) and record both as dotted paths.
1226        let (config, _dir) = load(
1227            "vta_naem = \"oops\"\n\
1228             [secrets]\nkyring_service = \"vta-2\"\n",
1229        );
1230        assert!(
1231            config.unknown_keys.iter().any(|k| k == "vta_naem"),
1232            "top-level typo should be flagged: {:?}",
1233            config.unknown_keys
1234        );
1235        assert!(
1236            config
1237                .unknown_keys
1238                .iter()
1239                .any(|k| k == "secrets.kyring_service"),
1240            "nested typo should be flagged with a dotted path: {:?}",
1241            config.unknown_keys
1242        );
1243        // Advisory only — a config with unknown keys still validates.
1244        config
1245            .validate()
1246            .expect("unknown keys are advisory, not a hard error");
1247    }
1248
1249    #[test]
1250    fn known_keys_and_aliases_are_not_flagged() {
1251        // `community_name` is a serde alias for `vta_name`; a real nested
1252        // key must not be reported. Nothing should land in `unknown_keys`.
1253        let (config, _dir) = load(
1254            "community_name = \"acme\"\n\
1255             [server]\nport = 9000\n",
1256        );
1257        assert!(
1258            config.unknown_keys.is_empty(),
1259            "known keys + aliases must not be flagged: {:?}",
1260            config.unknown_keys
1261        );
1262        assert_eq!(config.vta_name.as_deref(), Some("acme"));
1263        assert_eq!(config.server.port, 9000);
1264    }
1265}