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