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}