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