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