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