From f718f739b42db065339240bbfb0b85800018033b Mon Sep 17 00:00:00 2001 From: sasa Date: Wed, 21 Oct 2026 21:52:24 +0300 Subject: [PATCH] Implement client configuration delivery and reissue lifecycle --- Cargo.lock | 7 + Cargo.toml | 1 + PLANS.md | 401 +++++++- README.md | 25 + docs/DELIVERY.md | 200 ++++ src/app.rs | 1888 +++++++++++++++++++++++++++++++++++++- src/delivery.rs | 807 ++++++++++++++++ src/delivery/files.rs | 880 ++++++++++++++++++ src/delivery/qr.rs | 120 +++ src/lib.rs | 1 + src/persistence.rs | 48 + src/vault.rs | 142 ++- tests/client_delivery.rs | 411 +++++++++ 13 files changed, 4911 insertions(+), 20 deletions(-) create mode 100644 docs/DELIVERY.md create mode 100644 src/delivery.rs create mode 100644 src/delivery/files.rs create mode 100644 src/delivery/qr.rs create mode 100644 tests/client_delivery.rs diff --git a/Cargo.lock b/Cargo.lock index 25e7020..1439370 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1374,6 +1374,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quote" version = "1.0.47" @@ -2095,6 +2101,7 @@ dependencies = [ "base64", "getrandom 0.3.4", "ipnet", + "qrcode", "rustix", "serde", "tar", diff --git a/Cargo.toml b/Cargo.toml index 256e4e7..62973ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ age = { version = "=0.12.1", default-features = false } base64 = "=0.22.1" getrandom = "=0.3.4" ipnet = "=2.11.0" +qrcode = { version = "=0.14.1", default-features = false } rustix = { version = "=1.1.5", default-features = false, features = ["event", "fs", "net", "process", "std"] } serde = { version = "=1.0.229", features = ["derive"] } thiserror = "=2.0.20" diff --git a/PLANS.md b/PLANS.md index 627b761..2d5507f 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1823,9 +1823,401 @@ without a separate request. ### Milestone 6 — Client configuration delivery and reissue lifecycle -Generate full-tunnel IPv4/IPv6 configurations using settings and preserved existing parameters where applicable. Save a root-only plaintext file and optionally render a matching QR code. Enforce post-Commit initial delivery and per-client password-based reissue with session-scoped retry after file errors. +Status: accepted (2026-10-21T21:50:26+03:00; independent integrated re-review closed findings and all scoped M6.7 gates passed). +Prerequisites: milestones 1–5 accepted; pinned native Rust environment and +compatible approved dependencies. Validation uses generated keys, synthetic +saved stores and temporary output directories without root, a terminal, Docker, +production inputs or any actual runtime/service call. -Acceptance: file/QR payloads agree; no delivery before Commit; output failure permits directory change/retry; successful initial delivery or session exit ends password-free eligibility; overwrite requires confirmation; unrelated plaintext imports cannot bypass authentication. +#### Outcome and scope + +Deliver testable application/library operations to create a new client with a +session-held private key, Commit its encrypted record and server entry, save its +plaintext WireGuard configuration after Commit, and optionally produce a QR code +for the same delivery. Subsequent issuance requires a fresh password per selected +client. Output errors permit changing the destination and retrying initial +issuance during the current creation session without password entry. + +Read SPEC.md New client parameters, Encrypted vault, Connection delivery, +Importing existing clients and Operations and states; ADRs 0001–0003; +VAULT_FORMAT.md, CONFIG_FORMAT.md, PERSISTENCE.md, RUNTIME.md, DEVELOPMENT.md, +DEPENDENCIES.md, TESTING.md and the current Application/VaultBundle code. + +Out of scope: TUI/password entry widgets, rendering live terminal screens, +plaintext source-file fallback, automatic key rotation/reconciliation, adding +connection settings, runtime mutation, automatic restart, actual phone/client +import, Alpine harness and VM tests. Keep startup safely incomplete. Generation +and saving an output file do not apply configuration to WireGuard. + +#### Existing code and integration constraints + +- `Application::add_client` currently accepts a prepared public `Client` and edits + server/domain drafts. It does not generate or own a private key or authorize + first delivery. Add a coordinated creation operation rather than treating any + existing/imported public key as eligible for password-free output. +- `WireGuardPrivateKey::generate`, `public_key`, and explicit Base64 exposure + exist. `VaultBundle::encrypt_record` encrypts a new key using the recipient, + while `retrieve_client_record` returns short-lived retained connection fields + only after password-based decryption and authenticated conflict checks. +- New-key records currently have no retained client parameters. Build their + configuration from authoritative saved peer addresses and current saved + application settings, not empty record defaults. Imported records can contain + DNS/MTU/endpoint/routes/keepalive/PSK; preserve supported existing parameters + rather than silently converting every imported client to a new default template. +- `RetrievedClientRecord` exposes its key and optional preshared key explicitly + and redacts Debug; do not turn it into a globally cached unlocked vault or add a + password-free retrieval API to work around first-delivery state. +- `Application::commit` saves staged document/vault/state only after backup and + journaled installation succeeds. `delete_client`, `discard_draft`, restore, + saved settings and restart choices can affect delivery eligibility. Integrate + with these transitions without regressing M4/M5 or bypassing recovery-required + handling. `Application::document()` is a draft; output must use committed bytes. +- M5 observes public technical runtime fields and does not supply a trusted server + private key for generation. Derive the server public key from the authoritative + saved `[Interface] PrivateKey` through existing X25519 code, in a narrow secret + boundary, or use an already validated authoritative derivation if available. + Missing/ambiguous/invalid server identity blocks output rather than guessing or + reading another file/interface. Never print or serialize the server private key. +- Cargo already contains the key/crypto, file-safety and time dependencies. + Approved `qrcode` may be added with minimal features (no image/SVG output needed). + No decoder, image, CLI, TUI, random, hash or new helper crate is implicitly + approved. Check official APIs/version compatibility before adding the crate. + +#### Context and implementation steps + +Run bounded tasks sequentially, one writer at a time. GPT-6-Sol/medium coordinates; +GPT-6-Luna/high implements, independently reviews and repairs. Dispatch file +ownership, prerequisite interfaces, acceptance IDs and actual required checks. +Only the coordinator updates milestone acceptance and evidence in PLANS.md. + +| Task | Owner | Files and deliverable | +| --- | --- | --- | +| M6.1 | implementer | `src/delivery.rs` and child modules as needed, library exports, `docs/DELIVERY.md`: secret-safe payload model and deterministic configuration generation | +| M6.2 | implementer | `src/app.rs`, narrow delivery/domain integration: coordinated new-client creation and session-scoped first-issue state | +| M6.3 | implementer | delivery/files module, existing safe filesystem helpers: file preparation, explicit overwrite confirmation, secure output and retry results | +| M6.4 | implementer | delivery/QR module, Cargo files, app integration: optional matrix/text output bound to the same authorized payload/file action | +| M6.5 | implementer | `tests/client_delivery.rs`, focused unit tests and README/DELIVERY guidance: full first-issue/reissue and failure lifecycle | +| M6.6 | reviewer, repairer | Independent integrated review, bounded findings repair and re-review | +| M6.7 | coordinator | Integrated validation, evidence ledger and scoped acceptance/handoff in PLANS.md | + +M6.1 renders a deterministic standard WireGuard client file: `[Interface]` with +client private key, saved addresses and DNS; `[Peer]` with server public key, +endpoint and routes. New clients have IPv4/IPv6 and routes `0.0.0.0/0, ::/0`, +using the configured template and authoritative saved host addresses. Define the +client Address prefix policy explicitly, keeping host identity separate from +server-side AllowedIPs; do not infer client addresses from the public endpoint. +No new MTU, keepalive or PSK defaults are introduced. + +For imported reissue, retain validated existing optional fields and IPv4-only +shape where applicable. Never execute or copy hooks/unknown arbitrary directives +from source configs. Validate decrypted fields again at rendering boundaries so +newlines/control text cannot inject sections/directives. Compare retained identity +and addresses/server binding with fresh saved authoritative values; a stale or +mismatched record fails closed instead of silently regenerating a different +connection. Respect authenticated reissue_blocked, missing server/file-only cases, +and M2 unsupported projection without truncating routes to get a valid output. +Document precedence between saved server state, application defaults for new +clients, and retained imported fields; do not invent a conflict-resolution UI. + +Keep plaintext payloads in explicitly exposed, short-lived zeroizing owners, +redacted Debug/errors and no generic Serialize/Display. Raw source/client output +is never an ordinary status object. Bound payload size. Passwords, encoded private +keys and rendered files must not appear in logs, panic output, snapshots or test +failure diagnostics. Document unavoidable encoding/QR-library copies honestly. + +M6.2 coordinates address selection, key generation, server/client draft insertion, +vault encryption and temporary first-issue tracking as one preparation. Use M1 +allocation/reservations and optional manual selection; reuse M2 edits and M3 key +matching. Failure at any preparation step leaves prior draft and eligibility +unchanged. Existing vault setup may require its initial password separately; +creating additional clients in an existing vault does not request that password. + +Define an internal lifecycle such as pending-draft, committed-awaiting-first-file, +and issued/invalidated, keyed by PublicKey and actual creation/committed record +context rather than alias, UI row, or historical public-key presence. Store no +first-issue private key, password, or eligibility in settings, internal state, +backup, vault plaintext fields or recovery journal. Session state is not recreated +by reopen/restore/import. A failed/incomplete Commit cannot promote eligibility. +Unrelated valid commits/renames must not accidentally authorize another key or +consume an unused first issue; compare the relevant saved client/record context. + +Discard/delete/restore/reopen/session end and any relevant key/record replacement +invalidate stale pending material. Define transition timing so cancellation and +failed backup/pre-install validation preserve unchanged work, while a successful +replacement or unresolved partial persistence cannot retain unsafe output authority. +A confirmed restart applying an unchanged saved client must not require a password +merely because runtime state changes. Do not conflate access activation with the +first-output permission. Successful saved deletion revokes output authorization +immediately, even if the old runtime peer remains active until restart. + +M6.3 prepares destination selection without writing, then requires explicit +confirmation if a file exists. Never derive a path directly from an alias that +may repeat or contain separators; use a safe suggested filename and explicit +selected destination. Reject symlink/special-file destinations and unsafe ancestor +traversal; do not overwrite internal application files, server config, vault, +settings, journal or backups as a client output. Output goes only to the chosen +location; no directory-wide chmod/deletion or source-file cleanup. + +Use secure temporary/staged writes, mode 0600 from creation, complete flush/sync, +atomic publication and parent sync as appropriate. New-file publication must not +silently replace a file; overwrite requires a confirmed bounded action for that +destination. Document behavior if failure follows publication (for example parent +sync failure): the file may already exist and must be reported honestly, with +explicit confirmation on retry, not claimed absent or automatically removed. + +Output failure before confirmed success preserves retry eligibility for the same +initial client in the current session, allowing a different directory. A normal +plaintext file must be saved for every delivery action; QR-only success is not +allowed. Success consumes initial eligibility and drops the cached private key. +Later file/QR issuance for that client requires a fresh password. Password reissue +is per action/client, not session-wide vault unlocking; wrong password, conflict +or missing key has no source-file bypass. Retries may keep only bounded +operation-local material, never a reusable unlocked identity across unrelated actions. + +M6.4 generates an optional QR from the exact bytes saved in that delivery action, +using qrcode's matrix/public rendering API without PNG/image features or external +qrencode. Support a future terminal renderer through a matrix/controlled text +representation with quiet zone and documented dimensions. Keep secret-bearing +matrix/text out of Debug, snapshots and ordinary progress messages. Do not expose +an unguarded second QR path that reconstructs private material from cached state. + +One authorized action may include both file and QR; showing that same prepared +QR does not require a second password. If QR encoding exceeds capacity or fails, +the file can still be successfully delivered: report file success and QR failure +separately, consume first-issue eligibility, and do not report a total failure +that misleadingly permits another first file. A later new QR/delivery action then +requires password authorization. A future TUI displays/clears the operation-local +QR; terminal lifecycle implementation remains M7. + +M6.5 covers complete library flows with private temporary stores and generated +synthetic keys. Use fault-injected file writes and unit-tested authorization +transitions, not privileged filesystem behavior. Assert file content through +redacted comparisons/parsed public values and explicit key correspondence without +printing secrets. Reuse M3 parsing for compatible generated files to demonstrate +identity/server/routes semantics. File/QR equality should be guaranteed by a +single payload source and tested at the encoding boundary; do not claim external +QR decoding/real-device compatibility unless actually checked or add a decoder +crate without approval. + +#### Acceptance and validation + +- [x] M6-A1: new clients yield valid full-tunnel dual-stack configurations using + committed addresses/settings and a correctly derived server key; missing or + ambiguous authoritative identity fails without guessing or exposing secrets. +- [x] M6-A2: imported reissue preserves supported existing optional parameters + and IPv4-only shape; stale identity/address/server bindings, authenticated + conflict flags and unsupported data block unsafe delivery without truncation. +- [x] M6-A3: coordinated creation stages a matching public client and encrypted + key; failed preparation leaves prior drafts intact. No output is authorized + before successful Commit or from an imported/existing key without a password. +- [x] M6-A4: failed/incomplete Commit does not promote first issue; a successful + Commit permits first output for the newly created client in that session. + Draft discard/delete/restore/reopen and record replacement cannot restore or + transfer stale eligibility; duplicate aliases remain independent by public key. +- [x] M6-A5: failed file save supports same-session retry/new directory without + password for initial issuance; success consumes eligibility. Reissue requires + fresh per-client authorization with no cached identity/plaintext-file fallback. +- [x] M6-A6: mandatory plaintext file creation uses 0600 from first write, safe + paths, bounded payloads, explicit overwrite confirmation and honest partial- + publication failure reporting; protected application/source files stay untouched. +- [x] M6-A7: optional QR uses the exact same payload as the saved file; QR failure + does not erase file success or grant another first issue. No independent QR-only + bypass or retained cross-action secret cache exists. +- [x] M6-A8: outputs warn when saved changes are not verified applied. Unavailable + runtime is not silently treated as active; delivery never invokes service or + WireGuard operations, releases reservations or changes apply history. +- [x] M6-A9: rendering/input injection, wrong password, missing vault/record, + duplicate/conflicting metadata, occupied/manual address failures, overwrite + decline, symlink/special paths, and write/flush/publication/sync failures are tested. +- [x] M6-A10: secret-bearing types, errors/Debug and tests do not disclose keys, + passwords, configuration payloads or QR matrices; ephemeral material is never + persisted in backup/journal/state and supported owned buffers clear on drop. +- [x] M6-A11: M1–M5 regressions and integrated gates pass unprivileged with no real + network, system bus, runtime, production paths or terminal required. Independent + review closes actionable auth-lifecycle/rendering/output-safety findings. + +Planned commands from the repository root after M6 targets and lockfile exist: + +```sh +cargo test --locked delivery +cargo test --locked --test client_delivery +cargo test --locked --test runtime_restart +cargo test --locked --test commit_restore +cargo test --locked --test vault_import +cargo test --locked --test config_preservation +cargo fmt --all -- --check +cargo clippy --locked --all-targets -- -D warnings +cargo test --locked +cargo build --locked --release +./target/release/wireguard-admin --help +./target/release/wireguard-admin --version +git diff --check +``` + +Ensure focused filters run actual tests. Resolve approved qrcode and update +Cargo.lock before locked checks; do not add Ratatui/Crossterm prematurely. Record +commands, counts and exit results. All commands should exit zero; negative cases +are passing assertions. No phone QR scan, real tunnel, terminal screenshot, Alpine +or systemd result is required or claimed for this library/file-output milestone. + +#### Progress + +- [x] Description prepared against accepted M1–M5 code and current documents. +- [x] 2026-10-20T23:12:02+03:00 execution preparation: verified active coordinator `gpt-6-sol`/`medium` in this session's latest turn context and project worker roles `gpt-6-luna`/`high`; confirmed M1–M5 accepted, read required specification, ADRs and supporting documents, and inspected the clean source baseline. The pre-existing expanded M6 plan edit in this worktree is preserved. +- [x] M6.1 — implementer delivered bounded, zeroizing payload rendering in `src/delivery.rs`, library export and `docs/DELIVERY.md`, with new-client and authenticated-import paths. Independent R1 found an unchecked caller-supplied server identity; repairer moved derivation to the saved `[Interface] PrivateKey` boundary and added malformed/missing/ambiguous cases. Independent re-review closed M6.1-R1. Final focused `cargo test --locked delivery::tests` passed 7 tests; fmt, Clippy and diff checks passed. Fixture evidence only; no file/QR or host behavior claimed. +- [x] M6.2 — implementer added transactional dual-stack creation and encrypted vault staging, plus session-only first-file state in `src/app.rs` and a narrow encrypted-record context accessor in `src/vault.rs`. Independent R1 identified duplicate live authorizations; repairer made issuance single-flight with explicit retry states, and re-review closed it. The same reviewer identified stale standalone plaintext after app mutation; second repair moved output behind an app-owned `deliver_first_issue` closure holding the mutable session borrow, and re-review closed it. Final `cargo test --locked app::tests::` passed 41 synthetic tests; fmt, Clippy and diff checks passed. M6.3 still must connect actual file results to these states. +- [x] M6.3 — implementer added secure file-output adapter in `src/delivery/files.rs`, app first-issue bridge, narrow SavedStore protected-path accessor and documentation. Initial focused `cargo test --locked delivery` passed 16 and `cargo test --locked app` passed 47; fmt, Clippy and diff checks passed. Fresh independent review found two actionable findings (M6.3-R1/R2 below). Three targeted repair rounds and independent re-review closed both under the documented single-administrator threat model; final focused `cargo test --locked delivery` passed 20 and file-module tests passed 12; fmt, Clippy and diff checks passed. No production filesystem behavior is claimed. +- [x] M6.4 — implementer added approved `qrcode` 0.14.1 with default features disabled, bounded/zeroizing operation-local matrix representation, file-first optional QR integration and focused tests. Initial `cargo test --locked delivery` passed 22 and `cargo test --locked app` passed 51; fmt, Clippy and diff checks passed. Independent R1 found a public QR-only encoder path; repairer made it crate-private with a compile-fail doctest, and independent re-review closed the finding. Final focused delivery (22) and doctests (2) passed. No phone scan or terminal rendering is claimed. +- [x] M6.5 — implementer added fresh password-per-action reissue from installed vault bytes, validated authenticated key-only/imported provenance, a live-generation activation warning, five public synthetic lifecycle tests in `tests/client_delivery.rs`, and README/DELIVERY guidance. Initial focused integration (5), delivery (22), app (51) and partial-provenance regression (1) passed with fmt, Clippy and diff checks. Fresh independent review found first-issue outputs omitted the activation warning. Repairer added it to file-only, file+QR and definite-publication/bookkeeping-uncertain results; re-review found uncertain file publication still omitted it, so a second repair added warning to failed outcomes and injected file-only/QR uncertainty tests. Independent re-review closed the finding. At M6.5 closure, `cargo test --locked app::` passed 50 and `--test client_delivery` passed 5; fmt, Clippy and diff checks passed. Integrated gates ran later in M6.7. +- [x] M6.6 — a fresh independent integrated reviewer inspected M6-A1–A11 and found three actionable issues: secret-bearing assertion diagnostics, optional imported server-key binding in the public renderer, and unreported temporary-file cleanup failure. Repairer fixed all three; independent re-review closed them with no new actionable issue. The reviewer found M6-A1–A10 supported by scoped synthetic evidence and left M6-A11 pending coordinator integrated gates. +- [x] M6.7 — coordinator ran every planned command against the integrated revision, verified all exit zero and nonempty focused test filters, checked changed files and reviewer evidence, and accepted M6-A1–A11 for the explicitly scoped fixture/file-output milestone. No real WireGuard, systemd, terminal or phone scan is claimed. + +#### Review and repair + +M6.1-R1 (P2, `src/delivery.rs`): the initial renderers accepted a caller-supplied +server public key without proving it came from the saved server configuration; +the initial valid-looking test even used a different key. Repairer made both +renderers derive it from exactly one valid saved `[Interface] PrivateKey`, reject +missing/duplicate/malformed sources with a redacted error, and compare imported +retained identity to that key. Synthetic regression tests passed (7 focused); +the independent reviewer re-reviewed and closed the finding. + +M6.2-R1 (P2, `src/app.rs`): two outstanding first-issue capabilities could +both expose plaintext before either was consumed. Repairer made authorization +single-flight/token-bound and added explicit pre-publication versus uncertain- +publication retry handling. The independent reviewer closed this part after +focused tests. + +M6.2-R2 (P2, `src/app.rs`): invalidating a standalone token after deletion or +replacement did not revoke a caller-held plaintext payload. Repairer removed +that accessor and introduced an app-owned publication closure that validates +saved peer/record context immediately before the file writer runs while holding +`&mut Application`; stale deletion/replacement tests show the closure is not +invoked. Independent re-review closed the finding. The real file adapter is +still M6.3 work, not evidence from these synthetic closures. + +M6.3-R1 (P2, `src/delivery/files.rs`): a confirmed overwrite rechecked the +destination inode before `renameat`, but another entry could replace it between +the check and publication and then be overwritten without confirmation. The +independent reviewer supplied a deterministic `Checkpoint::Publish` substitution +reproducer. Repairer changed confirmed replacement to atomic `RENAME_EXCHANGE`, +checks the displaced inode and rolls back without deleting unexpected entries; +rollback identity and parent sync failures report uncertainty. A later re-review +found the normal-success temp-name unlink still vulnerable to a different local +writer. Final repair requires a selected parent owned by root/current effective +user without group/world write, checks existing ancestors, and uses an exclusive +0600 temporary name with 128 bits of OS randomness. Independent re-review closed +R1 under the single-administrator threat model. Same-privilege concurrent name +changes remain explicitly outside scope; temporary 0600 output retained after a +rollback is documented for manual cleanup rather than risking deletion of an +unconfirmed entry. + +M6.3-R2 (P2, `src/app.rs`): after a definite file publication, a fallible +post-publication saved-state read/mark could return an undifferentiated app error, +concealing that a plaintext file was already created. The independent reviewer +requested an explicit published-but-bookkeeping-uncertain result and fault test. +Repairer added `PublishedButBookkeepingUncertain` with definite file-success +evidence and invalidated the session-held first-issue key on bookkeeping failure. +The independent reviewer closed R2 after a synthetic post-publication saved-store +failure test. No password-free retry is authorized by that result. + +M6.4-R1 (P2, `src/delivery/qr.rs`): a public QR encoder allowed a library caller +to render a payload and generate a QR without the mandatory file action. +Repairer restricted the encoder and module to crate visibility, kept public +output/error value types for the guarded app result, and added a compile-fail +doctest. Independent re-review closed the finding after file-first and +QR-failure tests. QR scanning and terminal rendering remain unverified/future. + +M6.5-R1 (P2, `src/app.rs`): initial file-only and file-plus-QR delivery did +not report whether the saved client had been verified active. Repairer bound an +activation warning to the exact installed generation and the live verified +saved/applied snapshot, never the historical timestamp. Re-review found the +possibly-published parent-sync failure still lost the warning; second repair +carried it in the failed outcome and tested uncertain publication for both +variants with QR skipped. Independent re-review closed R1. All evidence is +synthetic/fault-injected, not live VPN or device compatibility. + +M6.6-R1 (P2, `src/app.rs` tests): `assert_eq!` on saved and rendered client +configuration bytes could print a private key if a test failed. Repairer changed +those to boolean assertions and audited related tests. Independent integrated +re-review closed this secret-diagnostic finding. + +M6.6-R2 (P2, `src/delivery.rs`/`src/vault.rs`): the public imported renderer +accepted a missing saved-server binding from a mutable retrieved record. +Repairer made retained fields private/read-only, required imported provenance and +an exact present saved-server binding, and added missing/stale binding tests. +Independent integrated re-review closed it. + +M6.6-R3 (P2, `src/delivery/files.rs`/`src/app.rs`): ignored temp-file cleanup +errors could leave a plaintext 0600 staging file while reporting an ordinary +pre-publication failure. Repairer added a distinct `ManualCleanupRequired` +result, retry gating until explicit cleanup acknowledgement, documentation and +an injected cleanup-failure test confirming no selected-file publication. +Independent integrated re-review closed it. The user may still need to inspect +and remove a retained staging file; the application does not silently erase an +uncertain entry. + +All actionable M6 task-local and integrated review findings are closed after +repair and independent re-review. The integrated reviewer found M6-A1–A10 +supported by synthetic/fault-injected evidence; M6-A11 then passed the +coordinator's final integrated gates. No password check was weakened and no +preservation test was disabled. + +#### Evidence and handoff + +Initial planning inspected current Application creation/Commit/discard/restart interfaces, +VaultBundle key-only encryption and password-authorized retained fields, current +Cargo dependencies, M5 acceptance record and runtime documentation, plus product +specification/secret-storage ADR. M5 records scoped fixture acceptance and passing +integrated gates with independent re-review; real kernel/systemd checks remain +M8/M9. That planning-only turn did not rerun application tests, add crates/source, generate +private keys/output/QR, dispatch agents or contact host runtime/services. + +Final M6.7 validation on the integrated revision (all exit 0): +`cargo test --locked delivery` (23 matching tests), +`cargo test --locked --test client_delivery` (5), +`cargo test --locked --test runtime_restart` (2), +`cargo test --locked --test commit_restore` (9), +`cargo test --locked --test vault_import` (5), +`cargo test --locked --test config_preservation` (9), +`cargo fmt --all -- --check`, +`cargo clippy --locked --all-targets -- -D warnings`, +`cargo test --locked` (205 library unit, 30 integration and two doctests passed; +binary tests 0), `cargo build --locked --release`, release `--help` and +`--version`, and `git diff --check`. Release `--help` correctly states the +management interface is not implemented. Checks used synthetic keys, +temporary stores/output directories and fake adapters without root, Docker, +host WireGuard/system bus, production paths or a terminal. The approved +`qrcode` 0.14.1 was added with default features disabled; no other direct +dependency was introduced. No phone QR scan, live tunnel, Alpine test or +disposable systemd VM result is claimed. Those validations remain future +M7–M9 work, not passing M6 evidence. + +Next action: stop after accepted M6. M7 may begin only on a separate request; +do not create a Git commit or deploy. + +#### Decisions and discoveries + +New vault records contain a key but no template parameters, while imported +records retain supported connection fields. Delivery needs an explicit provenance +and precedence rule rather than assuming both are interchangeable. Creation must +retain ephemeral private material separately from password-only vault retrieval; +otherwise first delivery without a password is impossible. That ephemeral +material cannot be persisted or reconstructed after session loss. + +M5 applies saved state and M6 exports saved state; neither should call the other +implicitly. File and QR are two representations of one authorized delivery action, +with file success as the first-issue completion boundary. M7 supplies the terminal +interaction around these library contracts. + +#### Outcome + +M6.1–M6.7 are complete. Accepted for the milestone's explicitly scoped +synthetic/file-output checks after independent integrated review, repair, +re-review and all planned coordinator gates. The TUI, phone compatibility, +real WireGuard and actual systemd/D-Bus behavior are not claimed; M7–M9 own +their later acceptance. No Git commit, deployment or production access occurred. +Stop after M6; do not start M7 without a separate request. ### Milestone 7 — Complete terminal management workflow @@ -1847,14 +2239,15 @@ Acceptance: real D-Bus job handling, separate restart verification, address rele ## Current progress and resume point -- Milestones 1–5 are accepted; milestones 6–9 remain pending. +- Milestones 1–6 are accepted; milestones 7–9 remain pending. - The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md). - Milestone 1 is expanded and the Rust environment is pinned. - Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests. - Milestone 3 delivered secret-safe key handling, a versioned encrypted vault, caller-buffer reconciliation, and a bounded read-only source-directory import adapter. - Milestone 4 delivered versioned private backups, journaled saved-file Commit and recovery, previewed full restore, durable address reservations and historical verification bookkeeping, with independent R5 closure and coordinator gates. - Milestone 5 delivered public runtime observations and technical comparison, an approved-rustix bounded read-only netlink adapter, a zbus systemd job adapter, restart orchestration, and synthetic integration tests. Independent integrated review and repairs closed all actionable scoped findings; all planned gates passed. Real kernel/systemd validation remains deferred to M8/M9. -- M1–M5 integrated gates and independent reviews passed. No Alpine or VM results are claimed. +- Milestone 6 delivered session-bound creation/first-file authorization, saved-state rendering and password-per-action reissue, secure plaintext file output, optional same-payload QR, and synthetic lifecycle tests. Independent integrated review and repair closed all actionable findings; all planned gates passed. TUI and real-system validation remain M7–M9. +- M1–M6 integrated gates and independent reviews passed. No Alpine or VM results are claimed. ## Plan maintenance diff --git a/README.md b/README.md index 1b9acc8..b527fc9 100644 --- a/README.md +++ b/README.md @@ -116,3 +116,28 @@ examined. Enumeration, read, or limit failures mark the scan incomplete. The original files remain untouched. Adapter examples and behavior are covered with synthetic temporary directories in `tests/vault_import.rs`. +## Client delivery and reissue + +`Application::prepare_new_client` stages a dual-stack peer and encrypted key. +After a successful `commit`, `deliver_first_issue_file` can publish its first +configuration without a vault password during that same session; a failed file +publication can be retried to a newly selected destination. The first file is +mandatory. `deliver_first_issue_file_and_qr` optionally returns a QR matrix from +the same authorized bytes after file publication. Both success results carry an +activation warning unless this session has verified the exact installed saved +generation and its matching applied peer snapshot; historical verification and +unknown runtime state do not suppress it. + +Later delivery uses `Application::reissue_client_file`, which takes the client +public key, a password for that action, a prepared destination, and an optional +QR choice. It freshly unlocks the installed saved vault each time, reads saved +server/settings bytes rather than draft state, preserves validated imported +parameters, and rejects blocked or stale records. It never reads original +plaintext client files as a fallback. The result reports a secret-free failure +stage and warns when the current saved peer set has not been verified as applied +in this live session. Historical verification timestamps do not suppress that +warning after reopen. File output uses mode `0600`, explicit overwrite +confirmation, and separate QR failure reporting. These APIs have synthetic +fixture coverage; the TUI password prompt and terminal QR rendering are not +implemented. + diff --git a/docs/DELIVERY.md b/docs/DELIVERY.md new file mode 100644 index 0000000..15c6e3f --- /dev/null +++ b/docs/DELIVERY.md @@ -0,0 +1,200 @@ +# Client configuration rendering + +`delivery` contains pure rendering functions. They accept the committed +byte-preserving `ConfigDocument`, validated saved settings, and either the new +client's private key or a freshly password-retrieved `RetrievedClientRecord`. +Each renderer derives the server public key inside its boundary from exactly one +valid `[Interface] PrivateKey` in that saved document. Missing or duplicate +interface sections, missing or duplicate private-key directives, malformed key +text, and structurally malformed private-key directives fail closed. Callers +cannot supply or retain an unrelated server identity. The derivation keeps the +source key borrowed and the parsed secret local to the call; neither is copied +into an error or retained object. The renderer does not read paths, inspect the +host, access the running interface, save a file, or authorize a QR request. + +The renderer locates the client's peer by public key and requires one unique +peer with one valid public key field and supported authoritative address routes. +Addresses come from that saved peer's `AllowedIPs` host entries. Each assigned +address is rendered as a host prefix (`/32` for IPv4 and `/128` for IPv6); these +client `[Interface] Address` values remain distinct from server-side +`[Peer] AllowedIPs` routes. A new client requires one IPv4 and one IPv6 saved +address, uses `0.0.0.0/0, ::/0`, and gets endpoint and DNS from the saved +application settings. No MTU, keepalive, or preshared-key defaults are added. + +For imported reissue, the record's client addresses and optional saved server +public key are checked against current authoritative saved values. A mismatch, +stale address, missing or ambiguous peer, unsupported peer shape, or malformed +retained field fails closed. The renderer never truncates routes. It preserves +validated retained DNS, MTU, endpoint, AllowedIPs, keepalive, and preshared key +when present; application DNS and endpoint settings supply values absent from +the imported record. Saved peer addresses and derived server identity always +remain authoritative. DNS and endpoint values are parsed and emitted as +canonical single-line text; unknown directives and hooks are never copied. + +The precedence is therefore: + +1. The saved interface private key determines the server public identity, and + saved server peer identity and host addresses are authoritative for both + rendering paths. Imported retained server identity, when present, must match + this derived identity. +2. For new clients, saved application endpoint and DNS settings fill the + template; full-tunnel routes are the fixed new-client template. +3. For imported clients, authenticated retained optional values take precedence + over current endpoint and DNS defaults when present; retained server identity + and addresses must still match saved state. Retained AllowedIPs are preserved + after validation. + +`ClientConfigPayload` owns rendered plaintext in a zeroizing byte buffer. Its +`Debug` output reports only length and redaction; it has no generic serialization +or display implementation. `as_bytes()` is the explicit disclosure boundary +that later delivery code must keep operation-local. The payload is capped at +64 KiB. Temporary Base64 encodings and application-owned copies created after +`as_bytes()` cannot be erased by this owner; delivery code must minimize and +bound those copies. No payload, password, or key is returned in errors. + +Fresh retrieval is required for imported records: `VaultBundle` checks the +authenticated reissue-blocked state before constructing a +`RetrievedClientRecord`. This renderer offers no password-free retrieval API +and does not create first-issue eligibility. Retained fields are readable +through reference accessors, while their authenticated values cannot be changed +by library callers after retrieval. The imported renderer requires imported +provenance and a present matching server public key; a key-only new-template +record has no such binding and cannot use the imported path. The application grants initial +output only after Commit in the same creation session and supplies the payload +only inside its file-publication closure. + +Later reissue is exposed by `Application::reissue_client_file`. Every call +reads installed saved bytes, freshly decrypts the installed vault identity with +the supplied per-action password, checks the authenticated blocked status, and +renders against the saved server document and settings. Draft peers and +retained original client files are never consulted. The short-lived decrypted +record is dropped after the file and optional QR operation; no unlocked vault +or identity is cached in the application. + +Authenticated key-only records created by this application are classified as +new-template records only when every retained connection field is absent or +empty. That validated provenance path renders the current saved peer addresses +with the saved endpoint/DNS template. An empty address list combined with any +retained field is treated as a corrupt partial record. Imported records must +carry valid addresses and a server identity; those values are checked against +the saved peer and derived server identity, and retained optional parameters +are validated before rendering. Missing or malformed required retained values +fail closed instead of falling through to the new-client template. + +First-issue file and file-plus-QR success results, including definite +publication with uncertain post-publication bookkeeping, carry an activation +warning. The warning is suppressed only when the current application session +has verified the exact installed saved bytes and matching saved/applied peer +snapshot. Unknown runtime state and historical verification timestamps do not +suppress it. + +Reissue failures expose a fixed stage (`SavedState`, `Authorization`, +`Rendering`, or `FilePublication`) and secret-free category. The mandatory file +is published before optional QR encoding; QR failure is returned separately +after file success. Reissue uses the same generation-bound activation warning. +Delivery performs no runtime read, service operation, restart, or applied-state +mutation. + +## Plaintext file publication + +Initial delivery asks the caller for an explicit destination path; aliases are +never used to construct a path. Destination preparation performs no writes. +It rejects non-absolute paths, parent traversal, symlinks in existing path +components, symlink or special-file targets, the settings/server/internal-state/ +vault files, the data and archive directories, and every existing entry in the +configured original-client directory. An existing regular file can be selected, +but preparation records its filesystem identity and requires a separate +confirmation tied to that prepared destination. The configured source directory +is enumerated with the same entry bound used by import; enumeration failures or +an unsafe source directory stop output preparation. + +Publication requires the chosen output parent to be owned by the effective +user or root. The output parent cannot be group- or world-writable; existing +ancestors cannot be group- or world-writable unless the sticky bit protects +entries (for example `/tmp`). These checks are repeated immediately before +opening the parent, and the opened directory is checked as well. This keeps +other users from substituting the temporary entry or destination inside the +output parent. A bounded payload is written to a same-directory temporary +file created with mode `0600` and a 128-bit operating-system-random name, then +all bytes are flushed and synced before publication. A new destination uses +no-replace publication, so a file created after preparation is not silently +overwritten. A confirmed replacement uses an atomic exchange: the displaced +entry is checked against the confirmed inode before it is removed. If a +different entry was substituted at publication, the adapter attempts a second +exchange and checks both resulting identities. It never unlinks by name during +rollback: a same-privilege concurrent substitution could otherwise cause an +unconfirmed entry to be destroyed. Even after an identity-verified rollback, +the staged configuration remains under its mode-`0600` temporary name because +unlink-by-name cannot be made conditional on inode identity. A completed +rollback is reported as `DestinationChanged` only after the parent directory +sync succeeds. If the second exchange, identity checks, or sync fail, +publication is reported as uncertain and all entries still reachable by their +names are retained. + +Missing parent directories are created with mode `0700`; existing directories +are not chmodded. The destination parent is synced after publication. On a +pre-publication error, the temporary file is removed and first-issue eligibility +remains retryable. If removal of a pre-publication temporary file fails, the +operation reports that a private temporary configuration may remain and +requires manual cleanup. First-issue retry requires an explicit cleanup +acknowledgement; inspect the selected output parent for +`.wg-admin-client-*.tmp` files and remove the retained file after verifying its +identity. This result does not mean the selected destination was published. +If parent sync fails after publication, the output may +already exist: it is reported as uncertain, retained, and a retry requires a +fresh selection and explicit overwrite confirmation. The application never +removes a published file to pretend an uncertain save did not happen. + +The Linux exchange operation is atomic for the two names but cannot condition +the exchange on previously observed inode identities. The parent-directory +ownership and permission checks plus the unpredictable temporary name prevent +other users from racing those names. A process with the same effective +privilege can still change names after identity checks; concurrent edits by the +administrator are outside this adapter's single-administrator model. Callers +must treat `PublicationUncertain` as requiring inspection before retrying. A +retained temporary file is private to the current user and may need explicit +cleanup after the output state is understood. + +Successful file publication consumes the session's first-issue eligibility. If +the file is published but the subsequent saved-state read needed to record that +transition fails, the result explicitly reports the file as published with +bookkeeping uncertain and drops the cached first-issue key; a later issuance +requires password authorization. +The selected path and confirmation are operation-local and contain no +configuration bytes or key material. These checks are filesystem adapter tests +using synthetic payloads and temporary directories; they do not represent a +privileged production-path test. + +The renderer is verified with synthetic keys and configurations, including +round-trip parsing through `vault::import::parse_client_config`. The file +adapter is verified with synthetic payloads and temporary paths. This is not +evidence of a phone import, live tunnel, production filesystem, or QR scan. + +## Optional QR output + +An authorized first-file operation may request a QR representation in the same +operation. The app first publishes the mandatory configuration file, then gives +the same borrowed `ClientConfigPayload` to the QR encoder. There is no QR-only +entry point and no reconstruction from cached key material. File publication +failure skips QR encoding and preserves the existing retry behavior. If QR +encoding fails after the file was published, the result reports the file as +delivered and the QR error separately; first-issue eligibility is consumed. + +The QR module and encoder are crate-private. External callers can request QR +output only through the application file-and-QR operation; the returned matrix +and fixed-category error types remain public so callers can display the result. + +The encoder uses `qrcode` 0.14.1 with default features disabled (no image, SVG, +or external utility). The application owns a row-major matrix of 0/1 modules with +a four-module quiet zone. Its side is at most 185 modules (version 40 plus the +quiet zone). Matrix access is explicit; its `Debug` output redacts module data, +and the owner zeroizes its buffer on drop. The qrcode encoder necessarily keeps +internal payload/codeword and matrix copies that this application cannot +zeroize; the app drops its local encoder result immediately after copying the +modules into the bounded owner. QR errors contain only a fixed category. + +Tests establish that the encoder receives bytes equal to the just-published file, +that encoding succeeds for a synthetic payload, and that capacity failure does +not undo file delivery. They do not decode the matrix, establish phone scanning +compatibility, or test terminal rendering; those remain future UI/integration +checks. diff --git a/src/app.rs b/src/app.rs index 927eb6e..3b48b29 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ //! encrypted vault data, backups, the recoverable saved-file adapter, and the //! explicit runtime-restart confirmation boundary. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::net::IpAddr; use std::time::{Duration, Instant}; @@ -14,7 +14,14 @@ use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use crate::backup::{self, BackupError, BackupReason, HistoricalApplyStatus, SavedBytes}; -use crate::domain::{Client, ClientStates, DomainError, PublicKey, RuntimeObservation, Snapshot}; +use crate::delivery::files::{self, FileOutputError, PreparedDestination}; +use crate::delivery::qr; +use crate::delivery::{self, ClientConfigPayload, DeliverySettings, QrCodeOutput, QrError}; +use crate::domain::{ + AddressAvailability, Client, ClientAddresses, ClientStates, DomainError, PublicKey, + RuntimeObservation, Snapshot, allocate_ipv4, allocate_ipv6, validate_manual_ipv4, + validate_manual_ipv6, +}; use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore}; use crate::runtime::transport::TransportError; use crate::runtime::{ @@ -22,7 +29,7 @@ use crate::runtime::{ }; use crate::service::{self, RestartDeadline, RestartExecution, RestartOutcome}; use crate::vault::source::{PreparedImportSetup, SourceImportReport}; -use crate::vault::{VaultBundle, VaultError}; +use crate::vault::{VaultBundle, VaultError, WireGuardPrivateKey}; use crate::wireguard::SectionKind; use crate::wireguard::{ConfigDocument, DocumentError, EditError}; @@ -38,6 +45,154 @@ pub struct Application { historical_apply: HistoricalApplyStatus, active_restart_attempt: Option, next_restart_attempt: u64, + next_issue_authorization: u64, + initial_issues: BTreeMap, +} + +#[allow(dead_code)] // M6.3 consumes the capability and its session-held key. +struct InitialIssue { + private_key: WireGuardPrivateKey, + context: IssueContext, + stage: IssueStage, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum IssueStage { + PendingDraft, + AwaitingFirstFile, + RetryAvailable, + Authorizing(u64), + RetryNeedsOverwriteConfirmation, + RetryNeedsManualCleanup, +} + +#[derive(Clone, Eq, PartialEq)] +struct IssueContext { + peer_directives: String, + encrypted_record: String, +} + +/// Private, operation-local authorization for a single first-file publication. +/// Plaintext is never returned to a caller outside `deliver_first_issue`. +struct FirstIssueAuthorization { + identity: PublicKey, + context: IssueContext, + token: u64, + payload: ClientConfigPayload, +} + +/// How a failed file operation affected publication. The caller must report +/// uncertainty honestly and request overwrite confirmation before retrying it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[allow(dead_code)] // M6.3 constructs the delivery outcomes in production. +pub(crate) enum FirstIssueDeliveryFailure { + BeforePublication, + PublicationUncertain, + CleanupRequired, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FirstIssueRetry { + Available, + OverwriteConfirmationRequired, + /// Inspect and remove any retained private temporary file before retrying. + ManualCleanupRequired, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// Confirmation flags acknowledge the specific recovery action reported by the +/// preceding attempt; they do not authorize deleting any file automatically. +pub enum FirstIssueAttempt { + Initial, + Retry { + confirm_possible_overwrite: bool, + confirm_manual_cleanup: bool, + }, +} + +/// Result of one bounded publication closure. The returned value can carry +/// operation-local material (for example a QR derived from the same payload). +#[allow(dead_code)] // M6.3 consumes the result of the publication boundary. +pub enum FirstIssueDeliveryOutcome { + Delivered(T), + /// Publication completed, but the saved-state check that consumes the + /// first-issue capability failed. The file exists; reissue now requires a + /// fresh password authorization. + PublishedButBookkeepingUncertain { + value: T, + error: AppError, + }, + Failed { + error: E, + retry: FirstIssueRetry, + /// The committed client is not verified as applied to the live + /// interface. Keep this warning available even when publication is + /// uncertain and the caller must ask before retrying. + activation_warning: bool, + }, +} + +/// Result of one mandatory file publication with an optional QR request. +/// The outer first-issue outcome reports file/bookkeeping status; this value +/// reports QR status independently so encoding failure cannot undo publication. +pub struct FirstIssueFileAndQr { + pub activation_warning: bool, + pub qr: Result, +} + +/// Result metadata for a successful first-issue file publication. +pub struct FirstIssueFileDelivery { + pub activation_warning: bool, +} + +/// Stage at which a password-authorized reissue stopped. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReissueStage { + SavedState, + Authorization, + Rendering, + FilePublication, +} + +/// Fixed, secret-free reissue failure categories. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReissueFailureKind { + SavedStateUnavailable, + VaultUnavailable, + PasswordOrRecordRejected, + ConfigurationRejected, + FileOutput(FileOutputError), +} + +/// A reissue failure identifies its stage without retaining password or payload data. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Error)] +#[error("client reissue failed during {stage:?}: {kind:?}")] +pub struct ReissueError { + pub stage: ReissueStage, + pub kind: ReissueFailureKind, +} + +/// Result of mandatory file delivery and optional QR encoding from the same bytes. +pub struct ReissueDelivery { + pub activation_warning: bool, + pub qr: Option>, +} + +impl fmt::Debug for ReissueDelivery { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ReissueDelivery") + .field("activation_warning", &self.activation_warning) + .field("qr_requested", &self.qr.is_some()) + .field( + "qr", + &self + .qr + .as_ref() + .map(|result| result.as_ref().map(|_| "[REDACTED]")), + ) + .finish() + } } /// Read-only, saved-generation-bound restart preview for a future confirmation UI. @@ -171,6 +326,7 @@ impl fmt::Debug for Application { "pending_reservation_count", &self.draft_unsupported_reservations.len(), ) + .field("initial_issue_count", &self.initial_issues.len()) .finish() } } @@ -204,6 +360,8 @@ impl Application { historical_apply, active_restart_attempt: None, next_restart_attempt: 1, + next_issue_authorization: 1, + initial_issues: BTreeMap::new(), }) } @@ -228,10 +386,617 @@ impl Application { ) -> Result<(), AppError> { let edited = self.document.add_client(&client, created_utc)?; self.states.draft_mut().insert(client)?; + self.invalidate_active_issue_authorizations(); self.document = edited; Ok(()) } + /// Prepare a new dual-stack client and its encrypted key as one in-memory + /// operation. `manual_addresses == None` selects the lowest available host + /// in each configured range. No file is written and no output is authorized + /// until a later successful Commit. + pub fn prepare_new_client( + &mut self, + alias: &str, + manual_addresses: Option, + created_utc: Option<&str>, + ) -> Result { + let installed = self.store.read_saved()?; + let settings = self.store.resolved_settings_for(&installed)?; + let key = WireGuardPrivateKey::generate().map_err(|_| AppError::KeyGenerationFailed)?; + let identity = key.public_key(); + + let server_addresses = server_interface_addresses(&self.document)?; + let mut occupied = self + .states + .addresses_in_use() + .into_iter() + .collect::>(); + occupied.extend(document_host_addresses(&self.document)); + let reserved = self + .states + .reserved_addresses() + .iter() + .copied() + .chain(self.draft_unsupported_reservations.iter().copied()) + .collect::>(); + let availability = AddressAvailability { + server: &server_addresses, + occupied: &occupied, + reserved: &reserved, + }; + let addresses = match manual_addresses { + Some(addresses) => { + let ipv6 = addresses.ipv6.ok_or(AppError::NewClientMustBeDualStack)?; + validate_manual_ipv4( + addresses.ipv4, + settings.ipv4_allocation_range, + &availability, + )?; + validate_manual_ipv6(ipv6, settings.ipv6_allocation_range, &availability)?; + ClientAddresses { + ipv4: addresses.ipv4, + ipv6: Some(ipv6), + } + } + None => ClientAddresses { + ipv4: allocate_ipv4(settings.ipv4_allocation_range, &availability)?, + ipv6: Some(allocate_ipv6( + settings.ipv6_allocation_range, + &availability, + )?), + }, + }; + let client = Client::new(identity, alias, addresses)?; + + // Prepare every fallible replacement off to the side. The live app is + // mutated only after document editing, state insertion, and encryption + // have all succeeded. + let mut next_document = self.document.add_client(&client, created_utc)?; + let mut next_states = self.states.clone(); + next_states.draft_mut().insert(client)?; + let existing_vault = self + .vault + .as_ref() + .ok_or(AppError::VaultMissingForCreation)?; + let encoded = existing_vault.to_toml()?; + let mut next_vault = VaultBundle::from_toml(&encoded)?; + next_vault.encrypt_record(identity, &key)?; + // Parse once before publication so a malformed editor result cannot + // leave the three in-memory drafts out of sync. + next_document = ConfigDocument::parse(next_document.render())?; + let context = issue_context(&next_document, &next_vault, identity)?; + self.invalidate_active_issue_authorizations(); + self.document = next_document; + self.states = next_states; + self.vault = Some(next_vault); + self.initial_issues.insert( + identity, + InitialIssue { + private_key: key, + context, + stage: IssueStage::PendingDraft, + }, + ); + Ok(identity) + } + + /// Publish a password-free first issue while the committed peer, encrypted + /// record, and one-use token are valid. The mutable app borrow remains held + /// across `publish`, so no application mutation can interleave after the + /// final saved-state validation and before the file adapter runs. + #[allow(dead_code)] // M6.3 invokes this for its initial-file action. + pub(crate) fn deliver_first_issue( + &mut self, + identity: PublicKey, + attempt: FirstIssueAttempt, + publish: impl FnOnce(&ClientConfigPayload) -> Result, + ) -> Result, AppError> { + self.deliver_first_issue_with_warning(identity, attempt, |payload, _| publish(payload)) + } + + fn deliver_first_issue_with_warning( + &mut self, + identity: PublicKey, + attempt: FirstIssueAttempt, + publish: impl FnOnce(&ClientConfigPayload, bool) -> Result, + ) -> Result, AppError> { + let (is_retry, confirm_possible_overwrite, confirm_manual_cleanup) = match attempt { + FirstIssueAttempt::Initial => (false, false, false), + FirstIssueAttempt::Retry { + confirm_possible_overwrite, + confirm_manual_cleanup, + } => (true, confirm_possible_overwrite, confirm_manual_cleanup), + }; + let authorization = self.authorize_first_issue_inner( + identity, + is_retry, + confirm_possible_overwrite, + confirm_manual_cleanup, + )?; + let activation_warning = match self.validate_first_issue_authorization(&authorization) { + Ok(warning) => warning, + Err(error) => { + self.require_overwrite_confirmation(identity); + return Err(error); + } + }; + match publish(&authorization.payload, activation_warning) { + Ok(value) => { + if let Err(error) = self.mark_first_issue_delivered(authorization) { + // Publication is definite. Drop the session-held key even + // when saved-state bookkeeping cannot be completed. + self.initial_issues.remove(&identity); + return Ok( + FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain { + value, + error, + }, + ); + } + Ok(FirstIssueDeliveryOutcome::Delivered(value)) + } + Err((error, failure)) => { + let retry = match self.mark_first_issue_delivery_failed(authorization, failure) { + Ok(retry) => retry, + Err(error) => { + self.require_overwrite_confirmation(identity); + return Err(error); + } + }; + Ok(FirstIssueDeliveryOutcome::Failed { + error, + retry, + activation_warning, + }) + } + } + } + + /// Prepare a caller-selected plaintext destination without writing it. The + /// returned destination records any existing regular file so a later + /// overwrite confirmation is bound to this exact selection. + pub fn prepare_first_issue_destination( + &self, + path: impl AsRef, + ) -> Result { + let protection = self.store.delivery_output_protection()?; + files::prepare_destination(path, &protection) + } + + /// Deliver the committed new-client configuration through the app-owned + /// one-use authorization closure. A failed write retains retry eligibility; + /// a post-publication sync failure requires an explicit overwrite retry. + pub fn deliver_first_issue_file( + &mut self, + identity: PublicKey, + attempt: FirstIssueAttempt, + destination: PreparedDestination, + ) -> Result, AppError> { + self.deliver_first_issue_file_with(identity, attempt, destination, files::publish) + } + + fn deliver_first_issue_file_with( + &mut self, + identity: PublicKey, + attempt: FirstIssueAttempt, + destination: PreparedDestination, + publish_file: impl FnOnce( + PreparedDestination, + &ClientConfigPayload, + ) -> Result<(), FileOutputError>, + ) -> Result, AppError> { + self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| { + publish_file(destination, payload).map_err(|error| { + let failure = match error { + FileOutputError::PublicationUncertain => { + FirstIssueDeliveryFailure::PublicationUncertain + } + FileOutputError::ManualCleanupRequired => { + FirstIssueDeliveryFailure::CleanupRequired + } + _ => FirstIssueDeliveryFailure::BeforePublication, + }; + (error, failure) + })?; + Ok(FirstIssueFileDelivery { activation_warning }) + }) + } + + /// Publish the mandatory plaintext file, then optionally encode a QR from + /// that same authorized payload. QR failure is returned separately and the + /// successful file publication still consumes first-issue eligibility. + pub fn deliver_first_issue_file_and_qr( + &mut self, + identity: PublicKey, + attempt: FirstIssueAttempt, + destination: PreparedDestination, + ) -> Result, AppError> { + self.deliver_first_issue_file_and_qr_with(identity, attempt, destination, |payload| { + qr::encode_payload(payload) + }) + } + + fn deliver_first_issue_file_and_qr_with( + &mut self, + identity: PublicKey, + attempt: FirstIssueAttempt, + destination: PreparedDestination, + encode: impl FnOnce(&ClientConfigPayload) -> Result, + ) -> Result, AppError> { + self.deliver_first_issue_file_and_qr_with_publisher( + identity, + attempt, + destination, + files::publish, + encode, + ) + } + + fn deliver_first_issue_file_and_qr_with_publisher( + &mut self, + identity: PublicKey, + attempt: FirstIssueAttempt, + destination: PreparedDestination, + publish_file: impl FnOnce( + PreparedDestination, + &ClientConfigPayload, + ) -> Result<(), FileOutputError>, + encode: impl FnOnce(&ClientConfigPayload) -> Result, + ) -> Result, AppError> { + self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| { + publish_file(destination, payload).map_err(|error| { + let failure = match error { + FileOutputError::PublicationUncertain => { + FirstIssueDeliveryFailure::PublicationUncertain + } + FileOutputError::ManualCleanupRequired => { + FirstIssueDeliveryFailure::CleanupRequired + } + _ => FirstIssueDeliveryFailure::BeforePublication, + }; + (error, failure) + })?; + Ok(FirstIssueFileAndQr { + activation_warning, + qr: encode(payload), + }) + }) + } + + /// Reissue a saved client's configuration after freshly unlocking its + /// installed vault record with this action's password. This operation reads + /// only installed saved bytes, never uses a draft or retained source file, + /// and publishes the required file before attempting optional QR encoding. + pub fn reissue_client_file( + &self, + identity: PublicKey, + password: &str, + destination: PreparedDestination, + include_qr: bool, + ) -> Result { + let installed = self.store.read_saved().map_err(|_| ReissueError { + stage: ReissueStage::SavedState, + kind: ReissueFailureKind::SavedStateUnavailable, + })?; + let document = + ConfigDocument::parse(&installed.server_config).map_err(|_| ReissueError { + stage: ReissueStage::SavedState, + kind: ReissueFailureKind::SavedStateUnavailable, + })?; + let encoded_vault = installed.vault.as_deref().ok_or(ReissueError { + stage: ReissueStage::Authorization, + kind: ReissueFailureKind::VaultUnavailable, + })?; + let vault_text = std::str::from_utf8(encoded_vault).map_err(|_| ReissueError { + stage: ReissueStage::Authorization, + kind: ReissueFailureKind::VaultUnavailable, + })?; + let vault = VaultBundle::from_toml(vault_text).map_err(|_| ReissueError { + stage: ReissueStage::Authorization, + kind: ReissueFailureKind::VaultUnavailable, + })?; + // Retrieval performs the password unlock and authenticated conflict + // check on every invocation. The short-lived owner is dropped when + // this method returns and is never kept in Application state. + let record = vault + .retrieve_client_record(identity, password) + .map_err(|_| ReissueError { + stage: ReissueStage::Authorization, + kind: ReissueFailureKind::PasswordOrRecordRejected, + })?; + let resolved = self + .store + .resolved_settings_for(&installed) + .map_err(|_| ReissueError { + stage: ReissueStage::SavedState, + kind: ReissueFailureKind::SavedStateUnavailable, + })?; + let delivery_settings = DeliverySettings { + server_endpoint: &resolved.server_endpoint, + dns_addresses: &resolved.dns_addresses, + }; + let payload = if record.is_new_template() { + delivery::render_new_client( + identity, + record.private_key().ok_or(ReissueError { + stage: ReissueStage::Authorization, + kind: ReissueFailureKind::PasswordOrRecordRejected, + })?, + &document, + &delivery_settings, + ) + } else { + delivery::render_imported_client(identity, &record, &document, &delivery_settings) + } + .map_err(|_| ReissueError { + stage: ReissueStage::Rendering, + kind: ReissueFailureKind::ConfigurationRejected, + })?; + + files::publish(destination, &payload).map_err(|error| ReissueError { + stage: ReissueStage::FilePublication, + kind: ReissueFailureKind::FileOutput(error), + })?; + + // A historical timestamp is deliberately insufficient: after reopen, + // the runtime observation is unknown. Warn unless this live session + // has verified the exact saved peer snapshot currently on disk. + let activation_warning = !self.saved_generation_is_verified_applied(&installed, &document); + let qr = include_qr.then(|| qr::encode_payload(&payload)); + Ok(ReissueDelivery { + activation_warning, + qr, + }) + } + + fn saved_generation_is_verified_applied( + &self, + installed: &SavedBytes, + document: &ConfigDocument, + ) -> bool { + if !same_saved_bytes(&self.saved, installed) { + return false; + } + let Ok(snapshot) = checked_snapshot_from_document(document) else { + return false; + }; + self.states.applied() == &RuntimeObservation::Verified + && self.states.saved() == &snapshot + && self.states.applied_snapshot() == Some(&snapshot) + } + + /// Recheck both the one-use token and installed saved generation as the + /// final step before handing plaintext to the bounded writer closure. + fn validate_first_issue_authorization( + &mut self, + authorization: &FirstIssueAuthorization, + ) -> Result { + let installed = match self.store.read_saved() { + Ok(saved) => saved, + Err(error) => { + self.initial_issues.clear(); + return Err(error.into()); + } + }; + let document = ConfigDocument::parse(&installed.server_config)?; + let vault = installed + .vault + .as_deref() + .map(std::str::from_utf8) + .transpose() + .map_err(|_| AppError::InvalidSavedState)? + .map(VaultBundle::from_toml) + .transpose()?; + self.retain_matching_issues(&document, vault.as_ref()); + let current = issue_context( + &document, + vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?, + authorization.identity, + )?; + if current != authorization.context + || !matches!( + self.initial_issues.get(&authorization.identity), + Some(issue) + if issue.stage == IssueStage::Authorizing(authorization.token) + && issue.context == authorization.context + ) + { + return Err(AppError::FirstIssueUnavailable); + } + Ok(!self.saved_generation_is_verified_applied(&installed, &document)) + } + + fn require_overwrite_confirmation(&mut self, identity: PublicKey) { + if let Some(issue) = self.initial_issues.get_mut(&identity) + && matches!(issue.stage, IssueStage::Authorizing(_)) + { + issue.stage = IssueStage::RetryNeedsOverwriteConfirmation; + } + } + + fn authorize_first_issue_inner( + &mut self, + identity: PublicKey, + is_retry: bool, + confirm_possible_overwrite: bool, + confirm_manual_cleanup: bool, + ) -> Result { + let installed = match self.store.read_saved() { + Ok(saved) => saved, + Err(error) => { + self.initial_issues.clear(); + return Err(error.into()); + } + }; + let document = ConfigDocument::parse(&installed.server_config)?; + let vault = installed + .vault + .as_deref() + .map(std::str::from_utf8) + .transpose() + .map_err(|_| AppError::InvalidSavedState)? + .map(VaultBundle::from_toml) + .transpose()?; + self.retain_matching_issues(&document, vault.as_ref()); + let Some(issue) = self.initial_issues.get(&identity) else { + return Err(AppError::FirstIssueUnavailable); + }; + let allowed = match issue.stage { + IssueStage::AwaitingFirstFile => !is_retry, + IssueStage::RetryAvailable => is_retry, + IssueStage::RetryNeedsOverwriteConfirmation => is_retry && confirm_possible_overwrite, + IssueStage::RetryNeedsManualCleanup => is_retry && confirm_manual_cleanup, + IssueStage::PendingDraft | IssueStage::Authorizing(_) => false, + }; + if !allowed { + return Err(AppError::FirstIssueUnavailable); + } + let private_key = &issue.private_key; + let context = issue.context.clone(); + let current_context = issue_context( + &document, + vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?, + identity, + )?; + if current_context != context { + self.initial_issues.remove(&identity); + return Err(AppError::FirstIssueUnavailable); + } + let resolved = self.store.resolved_settings_for(&installed)?; + let delivery_settings = DeliverySettings { + server_endpoint: &resolved.server_endpoint, + dns_addresses: &resolved.dns_addresses, + }; + let payload = + delivery::render_new_client(identity, private_key, &document, &delivery_settings)?; + let token = self.next_issue_authorization; + let next_token = token + .checked_add(1) + .ok_or(AppError::IssueAuthorizationExhausted)?; + self.next_issue_authorization = next_token; + let issue = self + .initial_issues + .get_mut(&identity) + .ok_or(AppError::FirstIssueUnavailable)?; + issue.stage = IssueStage::Authorizing(token); + Ok(FirstIssueAuthorization { + identity, + context, + token, + payload, + }) + } + + /// Consume first-issue eligibility after complete file publication. + fn mark_first_issue_delivered( + &mut self, + authorization: FirstIssueAuthorization, + ) -> Result<(), AppError> { + let installed = match self.store.read_saved() { + Ok(saved) => saved, + Err(error) => { + self.initial_issues.clear(); + return Err(error.into()); + } + }; + let document = ConfigDocument::parse(&installed.server_config)?; + let vault = installed + .vault + .as_deref() + .map(std::str::from_utf8) + .transpose() + .map_err(|_| AppError::InvalidSavedState)? + .map(VaultBundle::from_toml) + .transpose()?; + self.retain_matching_issues(&document, vault.as_ref()); + let current = issue_context( + &document, + vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?, + authorization.identity, + )?; + let matches_authorization = matches!( + self.initial_issues.get(&authorization.identity), + Some(issue) + if issue.stage == IssueStage::Authorizing(authorization.token) + && issue.context == authorization.context + && issue.context == current + ); + if !matches_authorization { + return Err(AppError::FirstIssueUnavailable); + } + self.initial_issues.remove(&authorization.identity); + Ok(()) + } + + /// Record failed publication and explicitly return the session to a retry + /// state. Uncertain publication requires confirmed overwrite on the retry. + fn mark_first_issue_delivery_failed( + &mut self, + authorization: FirstIssueAuthorization, + failure: FirstIssueDeliveryFailure, + ) -> Result { + let installed = match self.store.read_saved() { + Ok(saved) => saved, + Err(error) => { + self.initial_issues.clear(); + return Err(error.into()); + } + }; + let document = ConfigDocument::parse(&installed.server_config)?; + let vault = installed + .vault + .as_deref() + .map(std::str::from_utf8) + .transpose() + .map_err(|_| AppError::InvalidSavedState)? + .map(VaultBundle::from_toml) + .transpose()?; + self.retain_matching_issues(&document, vault.as_ref()); + let current = issue_context( + &document, + vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?, + authorization.identity, + )?; + let Some(issue) = self.initial_issues.get_mut(&authorization.identity) else { + return Err(AppError::FirstIssueUnavailable); + }; + if issue.stage != IssueStage::Authorizing(authorization.token) + || issue.context != authorization.context + || issue.context != current + { + return Err(AppError::FirstIssueUnavailable); + } + let retry = match failure { + FirstIssueDeliveryFailure::BeforePublication => { + issue.stage = IssueStage::RetryAvailable; + FirstIssueRetry::Available + } + FirstIssueDeliveryFailure::PublicationUncertain => { + issue.stage = IssueStage::RetryNeedsOverwriteConfirmation; + FirstIssueRetry::OverwriteConfirmationRequired + } + FirstIssueDeliveryFailure::CleanupRequired => { + issue.stage = IssueStage::RetryNeedsManualCleanup; + FirstIssueRetry::ManualCleanupRequired + } + }; + Ok(retry) + } + + #[allow(dead_code)] // Used by the gated M6.3 capability methods. + fn retain_matching_issues(&mut self, document: &ConfigDocument, vault: Option<&VaultBundle>) { + retain_matching_issue_map(&mut self.initial_issues, document, vault); + } + + fn invalidate_active_issue_authorizations(&mut self) { + for issue in self.initial_issues.values_mut() { + if matches!(issue.stage, IssueStage::Authorizing(_)) { + issue.stage = IssueStage::RetryNeedsOverwriteConfirmation; + } + } + } + pub fn vault(&self) -> Option<&VaultBundle> { self.vault.as_ref() } @@ -244,6 +1009,7 @@ impl Application { let replacement = Client::new(identity, alias, client.addresses)?; self.states.draft_mut().insert(replacement)?; } + self.invalidate_active_issue_authorizations(); self.document = edited; Ok(()) } @@ -287,6 +1053,8 @@ impl Application { self.draft_unsupported_reservations .extend(additional_reservations); self.document = edited; + self.invalidate_active_issue_authorizations(); + self.initial_issues.remove(&identity); Ok(removed_record) } @@ -340,6 +1108,13 @@ impl Application { self.states.mark_runtime_unknown(); } self.historical_apply = historical_apply_from_saved(&self.saved)?; + self.initial_issues + .retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile); + retain_matching_issue_map( + &mut self.initial_issues, + &self.document, + self.vault.as_ref(), + ); Ok(()) } @@ -868,7 +1643,10 @@ impl Application { ¤t, )?; let resolved = self.store.resolved_settings_for(&preview.saved_bytes)?; - install(&self.store, &preview.saved_bytes, BackupComplete(()))?; + if let Err(error) = install(&self.store, &preview.saved_bytes, BackupComplete(())) { + self.initial_issues.clear(); + return Err(error.into()); + } let document = ConfigDocument::parse(&preview.saved_bytes.server_config)?; let vault = preview .saved_bytes @@ -889,6 +1667,7 @@ impl Application { self.document = document; self.vault = vault; self.states = states; + self.initial_issues.clear(); self.draft_unsupported_reservations.clear(); self.historical_apply = HistoricalApplyStatus::Unknown; self.active_restart_attempt = None; @@ -937,7 +1716,30 @@ impl Application { let mut proposed = installed; proposed.internal_state = Some(persisted.to_toml()?); self.store.validate_saved(&proposed)?; - install(&self.store, &proposed, BackupComplete(()))?; + if let Err(error) = install(&self.store, &proposed, BackupComplete(())) { + self.invalidate_active_issue_authorizations(); + match self.store.read_saved() { + Ok(current) => { + let document = ConfigDocument::parse(¤t.server_config).ok(); + let vault = current + .vault + .as_deref() + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .and_then(|text| VaultBundle::from_toml(text).ok()); + if let Some(document) = document { + retain_matching_issue_map( + &mut self.initial_issues, + &document, + vault.as_ref(), + ); + } else { + self.initial_issues.clear(); + } + } + Err(_) => self.initial_issues.clear(), + } + return Err(error.into()); + } self.saved = proposed; self.states = next_states; self.historical_apply = historical_apply; @@ -1017,12 +1819,40 @@ impl Application { historical_apply_from_saved(&installed)?, &installed, )?; - install(&self.store, &proposed, BackupComplete(()))?; + if let Err(error) = install(&self.store, &proposed, BackupComplete(())) { + match self.store.read_saved() { + Ok(current) => { + let document = ConfigDocument::parse(¤t.server_config).ok(); + let vault = current + .vault + .as_deref() + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .and_then(|text| VaultBundle::from_toml(text).ok()); + if let Some(document) = document { + retain_matching_issue_map( + &mut self.initial_issues, + &document, + vault.as_ref(), + ); + } else { + self.initial_issues.clear(); + } + } + Err(_) => self.initial_issues.clear(), + } + return Err(error.into()); + } self.saved = proposed; self.states = next_states; self.draft_unsupported_reservations.clear(); self.document = ConfigDocument::parse(&self.saved.server_config)?; + self.invalidate_active_issue_authorizations(); + promote_matching_issue_map( + &mut self.initial_issues, + &self.document, + self.vault.as_ref(), + ); self.historical_apply = HistoricalApplyStatus::Unknown; self.active_restart_attempt = None; Ok(archive) @@ -1065,6 +1895,138 @@ fn clone_saved(saved: &SavedBytes) -> SavedBytes { } } +fn retain_matching_issue_map( + issues: &mut BTreeMap, + document: &ConfigDocument, + vault: Option<&VaultBundle>, +) { + issues.retain(|identity, issue| { + if issue.stage == IssueStage::PendingDraft { + return true; + } + vault + .and_then(|vault| issue_context(document, vault, *identity).ok()) + .is_some_and(|actual| actual == issue.context) + }); +} + +fn promote_matching_issue_map( + issues: &mut BTreeMap, + document: &ConfigDocument, + vault: Option<&VaultBundle>, +) { + issues.retain(|identity, issue| { + let matches = vault + .and_then(|vault| issue_context(document, vault, *identity).ok()) + .is_some_and(|actual| actual == issue.context); + if matches && issue.stage == IssueStage::PendingDraft { + issue.stage = IssueStage::AwaitingFirstFile; + } + matches + }); +} + +fn issue_context( + document: &ConfigDocument, + vault: &VaultBundle, + identity: PublicKey, +) -> Result { + let matching = document + .peers() + .iter() + .filter(|peer| { + peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity) + }) + .collect::>(); + let [peer] = matching.as_slice() else { + return Err(AppError::FirstIssueUnavailable); + }; + if peer.identity != Some(identity) || !peer.issues.is_empty() { + return Err(AppError::FirstIssueUnavailable); + } + // Alias/admin comments are descriptive metadata; only WireGuard directives + // bind first-issue authority, so renames do not invalidate the creation. + let peer_directives = document + .source_slice(peer.section) + .ok_or(AppError::FirstIssueUnavailable)? + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with(['#', ';'])) + .collect::>() + .join("\n"); + let encrypted_record = vault + .record_context(identity) + .ok_or(AppError::FirstIssueUnavailable)? + .to_owned(); + Ok(IssueContext { + peer_directives, + encrypted_record, + }) +} + +fn server_interface_addresses(document: &ConfigDocument) -> Result, AppError> { + let interfaces = document + .sections() + .iter() + .filter(|section| section.kind == SectionKind::Interface) + .collect::>(); + let [interface] = interfaces.as_slice() else { + return Err(AppError::InvalidSavedState); + }; + let source = document + .source_slice(interface.span) + .ok_or(AppError::InvalidSavedState)?; + let mut addresses = Vec::new(); + let mut address_directive_seen = false; + for line in source.lines() { + let trimmed = line.trim_matches(|ch: char| ch.is_ascii_whitespace()); + if trimmed.is_empty() || trimmed.starts_with(['#', ';']) { + continue; + } + let Some((name, value)) = trimmed.split_once('=') else { + continue; + }; + if !name.trim().eq_ignore_ascii_case("Address") { + continue; + } + if address_directive_seen { + return Err(AppError::InvalidSavedState); + } + address_directive_seen = true; + for item in value.split(',') { + let network = item + .trim() + .parse::() + .map_err(|_| AppError::InvalidSavedState)?; + addresses.push(match network { + ipnet::IpNet::V4(network) => IpAddr::V4(network.addr()), + ipnet::IpNet::V6(network) => IpAddr::V6(network.addr()), + }); + } + } + if addresses.is_empty() { + return Err(AppError::InvalidSavedState); + } + Ok(addresses) +} + +fn document_host_addresses(document: &ConfigDocument) -> Vec { + document + .peers() + .iter() + .flat_map(|peer| peer.allowed_ips.iter()) + .filter_map(|network| match network { + ipnet::IpNet::V4(network) if network.prefix_len() == 32 => { + Some(IpAddr::V4(network.addr())) + } + ipnet::IpNet::V6(network) if network.prefix_len() == 128 => { + Some(IpAddr::V6(network.addr())) + } + _ => None, + }) + .collect() +} + fn checked_snapshot_from_document(document: &ConfigDocument) -> Result { if !document.issues().is_empty() { return Err(AppError::UnsupportedSavedShape); @@ -1118,6 +2080,16 @@ pub enum AppError { VaultAlreadyInstalled, #[error("the installed vault identity cannot be replaced")] VaultIdentityChange, + #[error("a vault must be installed before creating a client")] + VaultMissingForCreation, + #[error("a new client must have both IPv4 and IPv6 addresses")] + NewClientMustBeDualStack, + #[error("first file issuance is not authorized for this client in this session")] + FirstIssueUnavailable, + #[error("first-issue authorization identifiers are exhausted for this session")] + IssueAuthorizationExhausted, + #[error("client key generation failed")] + KeyGenerationFailed, #[error("the selected backup name is invalid")] InvalidArchiveSelection, #[error("the selected unsupported peer has no exact host route to reserve safely")] @@ -1144,6 +2116,8 @@ pub enum AppError { Edit(#[from] EditError), #[error(transparent)] Domain(#[from] DomainError), + #[error(transparent)] + Delivery(#[from] crate::delivery::DeliveryError), } #[cfg(test)] @@ -1157,6 +2131,7 @@ mod tests { use crate::backup::{ArchiveCheckpoint, create_archive_with_hook}; use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive}; use crate::config::SettingsFile; + use crate::delivery::qr::QrError; use crate::domain::{ Client, ClientAddresses, ClientStates, PublicKey, RuntimeObservation, Snapshot, }; @@ -1171,10 +2146,12 @@ mod tests { use crate::service::{RestartExecution, RestartFailure, RestartOutcome}; use crate::vault::{VaultBundle, WireGuardPrivateKey}; use crate::wireguard::ConfigDocument; + use zeroize::Zeroizing; use super::{ - AppError, Application, RestartChoice, RestartFailureKind, RestartStage, clone_saved, - snapshot_from_document, + AppError, Application, FileOutputError, FirstIssueAttempt, FirstIssueDeliveryFailure, + FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind, + RestartStage, clone_saved, snapshot_from_document, }; const SETTINGS: &str = r#" @@ -1201,9 +2178,11 @@ restart_timeout_seconds = 30 let resolved = settings_file.resolve_paths(root); fs::write(root.join("wireguard-admin.toml"), SETTINGS).expect("settings installed"); let secret = WireGuardPrivateKey::generate().expect("synthetic peer key"); + let server_secret = WireGuardPrivateKey::generate().expect("synthetic server key"); let key = secret.public_key(); let server = format!( - "[Interface]\nPrivateKey = synthetic\nAddress = 10.20.0.1/24\n\n# admin note\n[Peer]\nPublicKey = {key}\nAllowedIPs = 10.20.0.2/32, fd20::2/128\n" + "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n\n# admin note\n[Peer]\nPublicKey = {key}\nAllowedIPs = 10.20.0.2/32, fd20::2/128\n", + server_secret.to_base64() ); fs::write(root.join("server.conf"), server.as_bytes()).expect("server installed"); fs::set_permissions(root.join("server.conf"), fs::Permissions::from_mode(0o600)) @@ -1259,6 +2238,17 @@ restart_timeout_seconds = 30 (app, key) } + fn setup_with_vault(root: &Path) -> Application { + let (store, states, _, _) = setup(root, false); + let mut app = Application::open(store, states).expect("application opens"); + app.prepare_initial_vault( + VaultBundle::create("synthetic vault password").expect("synthetic vault"), + ) + .expect("vault stages"); + app.commit().expect("initial vault commits"); + app + } + fn observed_runtime(app: &Application) -> ObservedRuntime { let records = app.document().peers().iter().map(|peer| { let identity = peer.identity.expect("synthetic peer identity"); @@ -1281,6 +2271,886 @@ restart_timeout_seconds = 30 } } + #[test] + fn new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let before_document = app.document().render().to_vec(); + let before_saved = clone_saved(app.saved_bytes()); + let before_draft = app.states().draft().clone(); + let before_records = app.vault().expect("vault exists").record_count(); + let existing_identity = app + .states() + .saved() + .clients() + .next() + .expect("legacy client exists") + .identity; + assert!(matches!( + app.deliver_first_issue(existing_identity, FirstIssueAttempt::Initial, |_| Ok::< + _, + ((), _), + >( + () + )), + Err(AppError::FirstIssueUnavailable) + )); + + assert!(matches!( + app.prepare_new_client("invalid\nalias", None, None), + Err(AppError::Domain(_)) + )); + assert_eq!(app.document().render(), before_document); + assert_eq!(app.saved_bytes().server_config, before_saved.server_config); + assert_eq!(app.states().draft(), &before_draft); + assert_eq!( + app.vault().expect("vault exists").record_count(), + before_records + ); + + let identity = app + .prepare_new_client("shared alias", None, Some("2026-10-20T00:00:00Z")) + .expect("new client is staged"); + assert!(app.initial_issues.contains_key(&identity)); + assert!(matches!( + app.prepare_new_client("invalid\nalias", None, None), + Err(AppError::Domain(_)) + )); + assert!(app.initial_issues.contains_key(&identity)); + let client = app.states().draft().get(&identity).expect("draft client"); + assert_eq!(client.addresses.ipv4, Ipv4Addr::new(10, 20, 0, 3)); + assert!(client.addresses.ipv6.is_some()); + assert_eq!(app.states().saved().get(&identity), None); + assert_eq!( + app.vault().expect("vault exists").record_count(), + before_records + 1 + ); + assert!(matches!( + app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>( + () + )), + Err(AppError::FirstIssueUnavailable) + )); + } + + #[test] + fn manual_addresses_reject_occupied_and_out_of_range_without_partial_staging() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let before_document = app.document().render().to_vec(); + let before_records = app.vault().expect("vault exists").record_count(); + + assert!(matches!( + app.prepare_new_client( + "occupied", + Some(ClientAddresses { + ipv4: Ipv4Addr::new(10, 20, 0, 2), + ipv6: Some("fd20::3".parse().expect("IPv6")), + }), + None, + ), + Err(AppError::Domain( + crate::domain::DomainError::AddressUnavailable + )) + )); + assert!(matches!( + app.prepare_new_client( + "outside", + Some(ClientAddresses { + ipv4: Ipv4Addr::new(10, 21, 0, 3), + ipv6: Some("fd20::3".parse().expect("IPv6")), + }), + None, + ), + Err(AppError::Domain( + crate::domain::DomainError::AddressOutsideRange + )) + )); + assert_eq!(app.document().render(), before_document); + assert_eq!( + app.vault().expect("vault exists").record_count(), + before_records + ); + } + + #[test] + fn commit_failure_keeps_pending_issue_and_successful_retry_authorizes_it() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("retry", None, None) + .expect("client stages"); + let archives = root.path().join("Backup/archives"); + let moved_archives = root.path().join("Backup/archives-saved"); + fs::rename(&archives, &moved_archives).expect("move archive directory"); + fs::write(&archives, b"block archive creation").expect("install blocker"); + + assert!(matches!(app.commit(), Err(AppError::Backup(_)))); + assert!(matches!( + app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>( + () + )), + Err(AppError::FirstIssueUnavailable) + )); + + fs::remove_file(&archives).expect("remove temporary blocker"); + fs::rename(&moved_archives, &archives).expect("restore archive directory"); + assert!(matches!( + app.commit_with_adapters(crate::backup::create_archive, |_, _, _| Err( + PersistenceError::RecoveryRequired + ),), + Err(AppError::Persistence(PersistenceError::RecoveryRequired)) + )); + assert!(matches!( + app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>( + () + )), + Err(AppError::FirstIssueUnavailable) + )); + app.commit().expect("retry commits"); + let mut payload_was_available = false; + let result = app + .deliver_first_issue(identity, FirstIssueAttempt::Initial, |payload| { + payload_was_available = !payload.is_empty(); + Ok::<_, ((), _)>(()) + }) + .expect("committed creation can issue"); + assert!(payload_was_available); + assert!(matches!(result, FirstIssueDeliveryOutcome::Delivered(()))); + } + + #[test] + fn unrelated_rename_and_verified_restart_preserve_independent_first_issues() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let first = app + .prepare_new_client("same alias", None, None) + .expect("first client stages"); + let second = app + .prepare_new_client("same alias", None, None) + .expect("second client stages"); + assert_ne!(first, second); + app.commit().expect("both clients commit"); + let unrelated = app + .states() + .saved() + .clients() + .find(|client| client.alias == "legacy-view") + .expect("saved client") + .identity; + app.rename_client(unrelated, "renamed unrelated client") + .expect("unrelated rename stages"); + app.commit().expect("unrelated rename commits"); + + let snapshot = snapshot_from_document(app.document()); + test_mark_verified(&mut app, snapshot, "2026-10-20T01:00:00Z".to_owned()) + .expect("verified restart state persists"); + app.deliver_first_issue(first, FirstIssueAttempt::Initial, |payload| { + assert!(!payload.is_empty()); + Ok::<_, ((), _)>(()) + }) + .expect("first client remains eligible and its file succeeds"); + assert!(matches!( + app.deliver_first_issue(first, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>(())), + Err(AppError::FirstIssueUnavailable) + )); + app.deliver_first_issue(second, FirstIssueAttempt::Initial, |payload| { + assert!(!payload.is_empty()); + Ok::<_, ((), _)>(()) + }) + .expect("second client's outstanding capability remains independent"); + assert!(matches!( + app.deliver_first_issue(second, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>(())), + Err(AppError::FirstIssueUnavailable) + )); + } + + #[test] + fn first_issue_authorization_is_single_flight_and_success_consumes_it() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("one-use", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + + let delivered = app + .deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| { + Ok::<_, ((), _)>(()) + }) + .expect("first authorization succeeds"); + assert!(matches!( + delivered, + FirstIssueDeliveryOutcome::Delivered(()) + )); + assert!(matches!( + app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>( + () + )), + Err(AppError::FirstIssueUnavailable) + )); + // The initial operation already consumed eligibility on success. + } + + #[test] + fn committed_first_issue_publishes_selected_file_and_consumes_eligibility() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("file delivery", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + + let destination_path = root.path().join("chosen/output/client.conf"); + let destination = app + .prepare_first_issue_destination(&destination_path) + .expect("explicit output path is safe"); + assert!(matches!( + app.deliver_first_issue_file(identity, FirstIssueAttempt::Initial, destination) + .expect("authorized file is delivered"), + FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning + )); + let metadata = fs::metadata(&destination_path).expect("client file exists"); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + assert!( + fs::read(&destination_path) + .expect("client configuration readable") + .starts_with(b"[Interface]\nPrivateKey = ") + ); + let retry_path = root.path().join("chosen/output/retry.conf"); + let retry = app + .prepare_first_issue_destination(&retry_path) + .expect("new explicit path remains safe"); + assert!(matches!( + app.deliver_first_issue_file(identity, FirstIssueAttempt::Initial, retry), + Err(AppError::FirstIssueUnavailable) + )); + assert!(!retry_path.exists()); + } + + #[test] + fn file_and_qr_share_payload_and_qr_failure_still_consumes_first_issue() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("file and QR", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + let path = root.path().join("chosen/client.conf"); + let destination = app + .prepare_first_issue_destination(&path) + .expect("destination prepares"); + + let result = app + .deliver_first_issue_file_and_qr_with( + identity, + FirstIssueAttempt::Initial, + destination, + |payload| { + assert!( + payload.as_bytes() == fs::read(&path).expect("published file is readable") + ); + Err(QrError::CapacityExceeded) + }, + ) + .expect("file action completes"); + let FirstIssueDeliveryOutcome::Delivered(delivery) = result else { + panic!("file publication succeeds independently of QR encoding"); + }; + assert!(delivery.activation_warning); + assert!(matches!(delivery.qr, Err(QrError::CapacityExceeded))); + let retry = app + .prepare_first_issue_destination(root.path().join("chosen/retry.conf")) + .expect("retry destination prepares"); + assert!(matches!( + app.deliver_first_issue_file_and_qr(identity, FirstIssueAttempt::Initial, retry), + Err(AppError::FirstIssueUnavailable) + )); + } + + #[test] + fn successful_file_qr_action_encodes_the_published_bytes() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("successful QR", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + let path = root.path().join("chosen/client.conf"); + let destination = app + .prepare_first_issue_destination(&path) + .expect("destination prepares"); + + let result = app + .deliver_first_issue_file_and_qr_with( + identity, + FirstIssueAttempt::Initial, + destination, + |payload| { + assert!( + payload.as_bytes() == fs::read(&path).expect("published file is readable") + ); + crate::delivery::qr::encode_payload(payload) + }, + ) + .expect("file and QR action completes"); + let FirstIssueDeliveryOutcome::Delivered(delivery) = result else { + panic!("file publication succeeds"); + }; + assert!(delivery.activation_warning); + let matrix = delivery.qr.expect("QR encodes"); + assert!((29..=185).contains(&matrix.side())); + } + + #[test] + fn first_issue_warning_tracks_verified_saved_generation_for_file_and_qr() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let legacy = app + .states() + .saved() + .clients() + .next() + .expect("legacy client") + .identity; + app.rename_client(legacy, "legacy-view") + .expect("write authoritative alias metadata"); + let identities = (0..4) + .map(|_| { + app.prepare_new_client("activation", None, None) + .expect("client stages") + }) + .collect::>(); + app.commit().expect("clients commit"); + + for (index, identity) in identities[..2].iter().copied().enumerate() { + let path = root.path().join(format!("unapplied-{index}.conf")); + let destination = app + .prepare_first_issue_destination(path) + .expect("destination prepares"); + if index == 0 { + let result = app + .deliver_first_issue_file(identity, FirstIssueAttempt::Initial, destination) + .expect("file delivery succeeds"); + assert!(matches!( + result, + FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning + )); + } else { + let result = app + .deliver_first_issue_file_and_qr( + identity, + FirstIssueAttempt::Initial, + destination, + ) + .expect("file and QR delivery succeeds"); + assert!(matches!( + result, + FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning + )); + } + } + + let saved_snapshot = app.states().saved().clone(); + test_mark_verified(&mut app, saved_snapshot, "2026-10-21T00:00:00Z".to_owned()) + .expect("same saved generation is verified applied"); + for (index, identity) in identities[2..].iter().copied().enumerate() { + let path = root.path().join(format!("applied-{index}.conf")); + let destination = app + .prepare_first_issue_destination(path) + .expect("destination prepares"); + if index == 0 { + let result = app + .deliver_first_issue_file(identity, FirstIssueAttempt::Initial, destination) + .expect("file delivery succeeds"); + assert!(matches!( + result, + FirstIssueDeliveryOutcome::Delivered(delivery) if !delivery.activation_warning + )); + } else { + let result = app + .deliver_first_issue_file_and_qr( + identity, + FirstIssueAttempt::Initial, + destination, + ) + .expect("file and QR delivery succeeds"); + assert!(matches!( + result, + FirstIssueDeliveryOutcome::Delivered(delivery) if !delivery.activation_warning + )); + } + } + } + + #[test] + fn historical_verified_timestamp_does_not_suppress_reissue_warning_after_reopen() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("history warning", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + let saved = app.states().saved().clone(); + test_mark_verified(&mut app, saved, "2026-10-21T00:00:00Z".to_owned()) + .expect("saved generation verified"); + + let mut runtime_unknown = app.states().clone(); + runtime_unknown.mark_runtime_unknown(); + let reopened = Application::open(app.store.clone(), runtime_unknown) + .expect("application reopens with unknown runtime"); + let destination = reopened + .prepare_first_issue_destination(root.path().join("history-reissue.conf")) + .expect("destination prepares"); + let result = reopened + .reissue_client_file(identity, "synthetic vault password", destination, true) + .expect("password-authorized reissue succeeds"); + assert!(result.activation_warning); + assert!(result.qr.is_some_and(|qr| qr.is_ok())); + } + + #[test] + fn qr_is_not_attempted_when_mandatory_file_publication_fails() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("file failure", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + let path = root.path().join("chosen/existing.conf"); + fs::create_dir_all(path.parent().expect("parent exists")).expect("parent creates"); + fs::write(&path, b"existing synthetic bytes").expect("existing target writes"); + let destination = app + .prepare_first_issue_destination(&path) + .expect("existing destination prepares"); + let qr_called = AtomicBool::new(false); + let result = app + .deliver_first_issue_file_and_qr_with( + identity, + FirstIssueAttempt::Initial, + destination, + |_| { + qr_called.store(true, Ordering::SeqCst); + Err(QrError::EncodingFailed) + }, + ) + .expect("file failure is reported as an operation result"); + assert!(!qr_called.load(Ordering::SeqCst)); + assert!(matches!( + result, + FirstIssueDeliveryOutcome::Failed { + error: FileOutputError::OverwriteDeclined, + retry: FirstIssueRetry::Available, + .. + } + )); + assert_eq!( + fs::read(&path).expect("existing file retained"), + b"existing synthetic bytes" + ); + } + + #[test] + fn published_file_is_reported_when_saved_store_bookkeeping_fails() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("bookkeeping fault", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + let saved_server_config = app.saved_bytes().server_config.clone(); + + let output_path = root.path().join("delivered/client.conf"); + let destination = app + .prepare_first_issue_destination(&output_path) + .expect("destination prepares"); + let result = app.deliver_first_issue_with_warning( + identity, + FirstIssueAttempt::Initial, + |payload, activation_warning| { + crate::delivery::files::publish(destination, payload).map_err(|error| { + let failure = match error { + FileOutputError::PublicationUncertain => { + FirstIssueDeliveryFailure::PublicationUncertain + } + FileOutputError::ManualCleanupRequired => { + FirstIssueDeliveryFailure::CleanupRequired + } + _ => FirstIssueDeliveryFailure::BeforePublication, + }; + (error, failure) + })?; + // Fault injection after definite publication and before the + // application's post-publication saved-state read. + fs::remove_file(root.path().join("server.conf")) + .expect("inject saved-store read failure"); + Ok::< + _, + ( + crate::delivery::files::FileOutputError, + FirstIssueDeliveryFailure, + ), + >(activation_warning) + }, + ); + + assert!(matches!( + result, + Ok( + FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain { + value: true, + error: AppError::Persistence(PersistenceError::Io(_)), + .. + } + ) + )); + assert!( + fs::read(&output_path) + .expect("published file remains available") + .starts_with(b"[Interface]\nPrivateKey = ") + ); + fs::write(root.path().join("server.conf"), saved_server_config) + .expect("restore the synthetic saved config"); + fs::set_permissions( + root.path().join("server.conf"), + fs::Permissions::from_mode(0o600), + ) + .expect("keep restored config private"); + assert!(matches!( + app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| { + Ok::<_, ((), FirstIssueDeliveryFailure)>(()) + }), + Err(AppError::FirstIssueUnavailable) + )); + } + + #[test] + fn failed_file_publication_can_retry_in_a_different_directory() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("retryable file", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + + let first_directory = root.path().join("first-selection"); + fs::create_dir(&first_directory).expect("first output directory"); + let first_path = first_directory.join("client.conf"); + let first = app + .prepare_first_issue_destination(&first_path) + .expect("first selection prepares"); + fs::remove_dir(&first_directory).expect("simulate directory removal before publish"); + fs::write(&first_directory, b"blocker").expect("replace directory with a file"); + let failed = app + .deliver_first_issue_file(identity, FirstIssueAttempt::Initial, first) + .expect("file failure is recorded for retry"); + assert!(matches!( + failed, + FirstIssueDeliveryOutcome::Failed { + retry: FirstIssueRetry::Available, + .. + } + )); + fs::remove_file(&first_directory).expect("remove synthetic blocker"); + + let second_path = root.path().join("new-selection/client.conf"); + let second = app + .prepare_first_issue_destination(&second_path) + .expect("new selection prepares"); + assert!(matches!( + app.deliver_first_issue_file( + identity, + FirstIssueAttempt::Retry { + confirm_possible_overwrite: false, + confirm_manual_cleanup: false, + }, + second, + ) + .expect("same-session retry succeeds"), + FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning + )); + assert!(second_path.exists()); + } + + #[test] + fn failed_first_delivery_requires_consuming_capability_before_retry() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("retry-output", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + + let failed = app + .deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| { + Err::<(), _>(("write failed", FirstIssueDeliveryFailure::BeforePublication)) + }) + .expect("pre-publication failure returns eligibility"); + assert!(matches!( + failed, + FirstIssueDeliveryOutcome::Failed { + error: "write failed", + retry: FirstIssueRetry::Available, + activation_warning: true, + } + )); + + let retry = app + .deliver_first_issue( + identity, + FirstIssueAttempt::Retry { + confirm_possible_overwrite: false, + confirm_manual_cleanup: false, + }, + |_| { + Err::<(), _>(( + "sync uncertain", + FirstIssueDeliveryFailure::PublicationUncertain, + )) + }, + ) + .expect("pre-publication retry needs no overwrite confirmation"); + assert!(matches!( + retry, + FirstIssueDeliveryOutcome::Failed { + error: "sync uncertain", + retry: FirstIssueRetry::OverwriteConfirmationRequired, + activation_warning: true, + } + )); + assert!(matches!( + app.deliver_first_issue( + identity, + FirstIssueAttempt::Retry { + confirm_possible_overwrite: false, + confirm_manual_cleanup: false, + }, + |_| Ok::<_, ((), _)>(()) + ), + Err(AppError::FirstIssueUnavailable) + )); + app.deliver_first_issue( + identity, + FirstIssueAttempt::Retry { + confirm_possible_overwrite: true, + confirm_manual_cleanup: false, + }, + |_| Ok::<_, ((), _)>(()), + ) + .expect("possible existing output requires explicit overwrite confirmation"); + assert!(matches!( + app.deliver_first_issue( + identity, + FirstIssueAttempt::Retry { + confirm_possible_overwrite: true, + confirm_manual_cleanup: false, + }, + |_| Ok::<_, ((), _)>(()) + ), + Err(AppError::FirstIssueUnavailable) + )); + } + + #[test] + fn temporary_cleanup_failure_requires_manual_cleanup_before_first_issue_retry() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("cleanup recovery", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + + let failed = app + .deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| { + Err::<(), _>(( + "cleanup required", + FirstIssueDeliveryFailure::CleanupRequired, + )) + }) + .expect("cleanup failure is reported as recovery-required"); + assert!(matches!( + failed, + FirstIssueDeliveryOutcome::Failed { + error: "cleanup required", + retry: FirstIssueRetry::ManualCleanupRequired, + activation_warning: true, + } + )); + assert!(matches!( + app.deliver_first_issue( + identity, + FirstIssueAttempt::Retry { + confirm_possible_overwrite: false, + confirm_manual_cleanup: false, + }, + |_| Ok::<_, ((), FirstIssueDeliveryFailure)>(()) + ), + Err(AppError::FirstIssueUnavailable) + )); + app.deliver_first_issue( + identity, + FirstIssueAttempt::Retry { + confirm_possible_overwrite: false, + confirm_manual_cleanup: true, + }, + |_| Ok::<_, ((), FirstIssueDeliveryFailure)>(()), + ) + .expect("manual cleanup acknowledgement permits same-session retry"); + } + + #[test] + fn uncertain_first_file_publication_retains_warning_and_skips_qr() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("uncertain publication", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + + let file_path = root.path().join("file-only/client.conf"); + let file_destination = app + .prepare_first_issue_destination(&file_path) + .expect("file destination prepares"); + let file_result = app + .deliver_first_issue_file_with( + identity, + FirstIssueAttempt::Initial, + file_destination, + |_, _| Err(FileOutputError::PublicationUncertain), + ) + .expect("uncertain publication is reported as an operation result"); + assert!(matches!( + file_result, + FirstIssueDeliveryOutcome::Failed { + error: FileOutputError::PublicationUncertain, + retry: FirstIssueRetry::OverwriteConfirmationRequired, + activation_warning: true, + } + )); + + // The uncertain result requires an explicit overwrite-confirmed retry. + let qr_path = root.path().join("file-and-qr/client.conf"); + let qr_destination = app + .prepare_first_issue_destination(&qr_path) + .expect("QR file destination prepares"); + let qr_called = AtomicBool::new(false); + let qr_result = app + .deliver_first_issue_file_and_qr_with_publisher( + identity, + FirstIssueAttempt::Retry { + confirm_possible_overwrite: true, + confirm_manual_cleanup: false, + }, + qr_destination, + |_, _| Err(FileOutputError::PublicationUncertain), + |_| { + qr_called.store(true, Ordering::SeqCst); + Err(QrError::EncodingFailed) + }, + ) + .expect("uncertain retry is reported as an operation result"); + assert!(!qr_called.load(Ordering::SeqCst)); + assert!(matches!( + qr_result, + FirstIssueDeliveryOutcome::Failed { + error: FileOutputError::PublicationUncertain, + retry: FirstIssueRetry::OverwriteConfirmationRequired, + activation_warning: true, + } + )); + } + + #[test] + fn deleted_first_issue_never_invokes_publication_closure() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("delete-capability", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + assert!(app.delete_client(identity).expect("client deletes")); + let mut writer_called = false; + assert!(matches!( + app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| { + writer_called = true; + Ok::<_, ((), _)>(()) + }), + Err(AppError::FirstIssueUnavailable) + )); + assert!( + !writer_called, + "deleted client bytes are never handed to a writer" + ); + } + + #[test] + fn replacing_the_encrypted_record_invalidates_committed_first_issue() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let identity = app + .prepare_new_client("replacement", None, None) + .expect("client stages"); + app.commit().expect("client commits"); + let replacement_bytes = Zeroizing::new( + app.initial_issues + .get(&identity) + .expect("session key retained") + .private_key + .to_base64(), + ); + let replacement_key = WireGuardPrivateKey::parse_base64(&replacement_bytes) + .expect("same synthetic key parses"); + let vault = app.vault_draft_mut().expect("vault exists"); + assert!(vault.remove_record(identity).expect("record removed")); + vault + .encrypt_record(identity, &replacement_key) + .expect("replacement record encrypted"); + app.commit().expect("replacement commits"); + assert!(matches!( + app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>( + () + )), + Err(AppError::FirstIssueUnavailable) + )); + } + + #[test] + fn deletion_and_discard_revoke_only_session_bound_initial_material() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + let pending = app + .prepare_new_client("discard", None, None) + .expect("client stages"); + app.discard_draft().expect("draft discards"); + assert!(matches!( + app.deliver_first_issue( + pending, + FirstIssueAttempt::Initial, + |_| Ok::<_, ((), _)>(()) + ), + Err(AppError::FirstIssueUnavailable) + )); + + let deleted = app + .prepare_new_client("delete", None, None) + .expect("second client stages"); + app.commit().expect("second client commits"); + assert!(app.delete_client(deleted).expect("client deletes")); + app.commit().expect("deletion commits"); + assert!(matches!( + app.deliver_first_issue( + deleted, + FirstIssueAttempt::Initial, + |_| Ok::<_, ((), _)>(()) + ), + Err(AppError::FirstIssueUnavailable) + )); + } + #[test] fn backup_failure_preserves_installed_files_and_draft() { let root = tempfile::tempdir().expect("temp directory"); diff --git a/src/delivery.rs b/src/delivery.rs new file mode 100644 index 0000000..04ac8ca --- /dev/null +++ b/src/delivery.rs @@ -0,0 +1,807 @@ +//! Deterministic, bounded rendering of explicitly authorized client configurations. +//! +//! Rendering is pure: callers supply the authoritative saved document, the derived +//! server identity, and settings. Plaintext output is held in [`ClientConfigPayload`] +//! and exposed only through its explicit byte accessor. +//! +//! QR encoding is intentionally not a public API. It is only reachable through +//! the application's file-and-QR delivery operation, which publishes the +//! mandatory plaintext file before encoding those same bytes. +//! +//! ```compile_fail +//! use wireguard_admin::delivery::qr::encode_payload; +//! ``` + +use std::collections::BTreeSet; +use std::fmt; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::str::FromStr; + +use ipnet::IpNet; +use thiserror::Error; +use zeroize::Zeroizing; + +pub mod files; +pub(crate) mod qr; +pub use qr::{QrCodeOutput, QrError}; + +use crate::config::{EndpointHost, ServerEndpoint}; +use crate::domain::PublicKey; +use crate::vault::{RetrievedClientRecord, WireGuardPrivateKey}; +use crate::wireguard::{ConfigDocument, PeerInspection}; + +const MAX_CONFIG_BYTES: usize = 64 * 1024; +const MAX_DNS_ENTRIES: usize = 32; +const MAX_ROUTE_ENTRIES: usize = 256; + +/// Validated connection defaults used for new clients and missing imported values. +/// +/// Values are checked again here because public settings structs can be constructed +/// directly by library callers without passing through settings-file validation. +pub struct DeliverySettings<'a> { + pub server_endpoint: &'a ServerEndpoint, + pub dns_addresses: &'a [IpAddr], +} + +/// Secret-bearing rendered file bytes. There is no `Display` or serialization API. +pub struct ClientConfigPayload(Zeroizing>); + +impl ClientConfigPayload { + /// Explicitly expose rendered configuration bytes to a delivery operation. + pub fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Debug for ClientConfigPayload { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientConfigPayload") + .field("length", &self.0.len()) + .field("contents", &"[REDACTED]") + .finish() + } +} + +/// Secret-free rendering failures. Variants never contain source text or key material. +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum DeliveryError { + #[error("client identity does not match the authorized private key")] + ClientIdentityMismatch, + #[error("client peer is missing from the authoritative saved configuration")] + MissingSavedPeer, + #[error("client identity is ambiguous in the authoritative saved configuration")] + AmbiguousSavedPeer, + #[error("authoritative saved peer data is malformed or unsupported")] + InvalidSavedPeer, + #[error("retained client addresses do not match authoritative saved addresses")] + AddressMismatch, + #[error("retained server identity does not match the derived saved server identity")] + ServerIdentityMismatch, + #[error("authoritative saved server identity is missing, ambiguous, or invalid")] + InvalidSavedServerIdentity, + #[error("client connection settings are invalid or unsupported")] + InvalidConnectionField, + #[error("rendered client configuration exceeds its size limit")] + TooLarge, +} + +/// Render a newly created client's committed connection using a full-tunnel template. +pub fn render_new_client( + identity: PublicKey, + private_key: &WireGuardPrivateKey, + saved: &ConfigDocument, + settings: &DeliverySettings<'_>, +) -> Result { + if !private_key.matches_public_key(&identity) { + return Err(DeliveryError::ClientIdentityMismatch); + } + let server_public_key = authoritative_server_public_key(saved)?; + let addresses = authoritative_addresses(saved, identity, true)?; + let endpoint = render_endpoint(settings.server_endpoint)?; + let dns = render_dns(settings.dns_addresses)?; + let private_text = Zeroizing::new(private_key.to_base64()); + let mut output = Zeroizing::new(Vec::with_capacity(256)); + append(&mut output, "[Interface]\nPrivateKey = ")?; + append(&mut output, private_text.as_str())?; + append(&mut output, "\nAddress = ")?; + append(&mut output, &addresses)?; + append(&mut output, "\nDNS = ")?; + append(&mut output, &dns)?; + append(&mut output, "\n\n[Peer]\nPublicKey = ")?; + append(&mut output, &server_public_key.to_string())?; + append(&mut output, "\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = ")?; + append(&mut output, &endpoint)?; + append(&mut output, "\n")?; + finish(output) +} + +/// Render a password-authorized imported record, retaining validated supported fields. +/// +/// The record type is only obtainable through fresh vault retrieval, which rejects +/// authenticated `reissue_blocked` records. Its retained fields are immutable to +/// library callers, and the renderer verifies their authenticated provenance and +/// server binding against the authoritative saved configuration. +pub fn render_imported_client( + identity: PublicKey, + record: &RetrievedClientRecord, + saved: &ConfigDocument, + settings: &DeliverySettings<'_>, +) -> Result { + let server_public_key = authoritative_server_public_key(saved)?; + if !record.is_imported() { + return Err(DeliveryError::ServerIdentityMismatch); + } + let private_key = record + .private_key() + .ok_or(DeliveryError::ClientIdentityMismatch)?; + if !private_key.matches_public_key(&identity) { + return Err(DeliveryError::ClientIdentityMismatch); + } + let addresses = authoritative_addresses(saved, identity, false)?; + validate_retained_addresses(record.client_addresses(), &addresses)?; + let expected_server_public_key = server_public_key.to_string(); + if record.server_public_key() != Some(expected_server_public_key.as_str()) { + return Err(DeliveryError::ServerIdentityMismatch); + } + + let interface_addresses = parse_address_string(&addresses)?; + let dns_value = match record.interface_dns() { + Some(value) => validate_dns(value)?, + None => render_dns(settings.dns_addresses)?, + }; + let endpoint = match record.endpoint() { + Some(value) => validate_endpoint(value)?, + None => render_endpoint(settings.server_endpoint)?, + }; + let mtu = record.interface_mtu().map(validate_mtu).transpose()?; + let keepalive = record + .persistent_keepalive() + .map(validate_keepalive) + .transpose()?; + let routes = validate_routes(record.allowed_ips())?; + let psk = record + .preshared_key_base64() + .map(validate_secret_key) + .transpose()?; + let private_text = Zeroizing::new(private_key.to_base64()); + + let mut output = Zeroizing::new(Vec::with_capacity(512)); + append(&mut output, "[Interface]\nPrivateKey = ")?; + append(&mut output, private_text.as_str())?; + append(&mut output, "\nAddress = ")?; + append(&mut output, &interface_addresses)?; + append(&mut output, "\nDNS = ")?; + append(&mut output, &dns_value)?; + if let Some(mtu) = mtu { + append(&mut output, "\nMTU = ")?; + append(&mut output, &mtu)?; + } + append(&mut output, "\n\n[Peer]\nPublicKey = ")?; + append(&mut output, &server_public_key.to_string())?; + if let Some(psk) = psk { + append(&mut output, "\nPresharedKey = ")?; + append(&mut output, psk.as_str())?; + } + append(&mut output, "\nAllowedIPs = ")?; + append(&mut output, &routes)?; + append(&mut output, "\nEndpoint = ")?; + append(&mut output, &endpoint)?; + if let Some(keepalive) = keepalive { + append(&mut output, "\nPersistentKeepalive = ")?; + append(&mut output, &keepalive)?; + } + append(&mut output, "\n")?; + finish(output) +} + +/// Derive the server identity only from one valid `[Interface] PrivateKey` in +/// the authoritative saved document. The private key and source text remain +/// borrowed/local to this call and never enter an error or owned long-lived value. +fn authoritative_server_public_key(saved: &ConfigDocument) -> Result { + use crate::wireguard::SectionKind; + + let interfaces = saved + .sections() + .iter() + .filter(|section| section.kind == SectionKind::Interface) + .collect::>(); + let [interface] = interfaces.as_slice() else { + return Err(DeliveryError::InvalidSavedServerIdentity); + }; + let section = saved + .source_slice(interface.span) + .ok_or(DeliveryError::InvalidSavedServerIdentity)?; + let mut private_key_value = None; + for line in section.lines() { + let trimmed = line.trim_matches(|ch: char| ch.is_ascii_whitespace()); + if trimmed.is_empty() || trimmed.starts_with(['#', ';']) { + continue; + } + let has_private_key_name = trimmed + .split(['=', ' ', '\t']) + .next() + .is_some_and(|name| name.eq_ignore_ascii_case("PrivateKey")); + if !has_private_key_name { + continue; + } + if private_key_value.is_some() { + return Err(DeliveryError::InvalidSavedServerIdentity); + } + let Some((name, value)) = trimmed.split_once('=') else { + return Err(DeliveryError::InvalidSavedServerIdentity); + }; + if !name + .trim_matches(|ch: char| ch.is_ascii_whitespace()) + .eq_ignore_ascii_case("PrivateKey") + { + return Err(DeliveryError::InvalidSavedServerIdentity); + } + private_key_value = Some(value.trim_matches(|ch: char| ch.is_ascii_whitespace())); + } + let private_key = WireGuardPrivateKey::parse_base64( + private_key_value.ok_or(DeliveryError::InvalidSavedServerIdentity)?, + ) + .map_err(|_| DeliveryError::InvalidSavedServerIdentity)?; + Ok(private_key.public_key()) +} + +fn authoritative_addresses( + saved: &ConfigDocument, + identity: PublicKey, + require_dual_stack: bool, +) -> Result { + let matching = saved + .peers() + .iter() + .filter(|peer| { + peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity) + }) + .collect::>(); + let peer = match matching.as_slice() { + [] => return Err(DeliveryError::MissingSavedPeer), + [peer] => *peer, + _ => return Err(DeliveryError::AmbiguousSavedPeer), + }; + validate_peer(peer)?; + let mut v4 = None; + let mut v6 = None; + for network in &peer.allowed_ips { + match network { + IpNet::V4(network) + if network.prefix_len() == 32 && v4.replace(network.addr()).is_none() => {} + IpNet::V6(network) + if network.prefix_len() == 128 && v6.replace(network.addr()).is_none() => {} + _ => return Err(DeliveryError::InvalidSavedPeer), + } + } + let v4 = v4.ok_or(DeliveryError::InvalidSavedPeer)?; + if require_dual_stack && v6.is_none() { + return Err(DeliveryError::InvalidSavedPeer); + } + Ok(format_addresses(v4, v6)) +} + +fn validate_peer(peer: &PeerInspection) -> Result<(), DeliveryError> { + if peer.identity.is_none() + || peer.identity != peer.public_key_candidates.first().copied() + || peer.public_key_candidates.len() != 1 + || !peer.issues.is_empty() + || peer.allowed_ips.is_empty() + { + return Err(DeliveryError::InvalidSavedPeer); + } + Ok(()) +} + +fn format_addresses(v4: Ipv4Addr, v6: Option) -> String { + let mut output = format!("{v4}/32"); + if let Some(v6) = v6 { + output.push_str(&format!(", {v6}/128")); + } + output +} + +fn parse_address_string(value: &str) -> Result { + let items = value.split(", ").collect::>(); + if items.is_empty() || items.len() > 2 { + return Err(DeliveryError::InvalidSavedPeer); + } + Ok(items.join(", ")) +} + +fn validate_retained_addresses( + retained: &[String], + authoritative: &str, +) -> Result<(), DeliveryError> { + if retained.is_empty() || retained.len() > 2 || retained.iter().any(|value| value.len() > 64) { + return Err(DeliveryError::AddressMismatch); + } + let expected = authoritative + .split(", ") + .map(str::to_owned) + .collect::>(); + let mut actual = BTreeSet::new(); + for value in retained { + if value.chars().any(char::is_control) { + return Err(DeliveryError::InvalidConnectionField); + } + let network = IpNet::from_str(value).map_err(|_| DeliveryError::InvalidConnectionField)?; + match network { + IpNet::V4(net) if net.prefix_len() == 32 => {} + IpNet::V6(net) if net.prefix_len() == 128 => {} + _ => return Err(DeliveryError::InvalidConnectionField), + } + actual.insert(network.to_string()); + } + if actual == expected { + Ok(()) + } else { + Err(DeliveryError::AddressMismatch) + } +} + +fn render_dns(dns: &[IpAddr]) -> Result { + if dns.is_empty() || dns.len() > MAX_DNS_ENTRIES { + return Err(DeliveryError::InvalidConnectionField); + } + Ok(dns + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ")) +} + +fn validate_dns(value: &str) -> Result { + if value.len() > 2048 || value.chars().any(char::is_control) { + return Err(DeliveryError::InvalidConnectionField); + } + let items = value.split(',').map(str::trim).collect::>(); + if items.is_empty() || items.len() > MAX_DNS_ENTRIES || items.iter().any(|item| item.is_empty()) + { + return Err(DeliveryError::InvalidConnectionField); + } + let addresses = items + .iter() + .map(|item| { + item.parse::() + .map_err(|_| DeliveryError::InvalidConnectionField) + }) + .collect::, _>>()?; + render_dns(&addresses) +} + +fn render_endpoint(endpoint: &ServerEndpoint) -> Result { + if endpoint.port == 0 { + return Err(DeliveryError::InvalidConnectionField); + } + match &endpoint.host { + EndpointHost::Ip(IpAddr::V6(address)) => Ok(format!("[{address}]:{}", endpoint.port)), + EndpointHost::Ip(address) => Ok(format!("{address}:{}", endpoint.port)), + EndpointHost::Hostname(host) => { + validate_hostname(host)?; + Ok(format!("{host}:{}", endpoint.port)) + } + } +} + +fn validate_endpoint(value: &str) -> Result { + if value.is_empty() || value.chars().any(char::is_control) || value.len() > 255 { + return Err(DeliveryError::InvalidConnectionField); + } + let (host, port) = if let Some(rest) = value.strip_prefix('[') { + let (host, suffix) = rest + .split_once(']') + .ok_or(DeliveryError::InvalidConnectionField)?; + let port = suffix + .strip_prefix(':') + .ok_or(DeliveryError::InvalidConnectionField)?; + let address = host + .parse::() + .map_err(|_| DeliveryError::InvalidConnectionField)?; + (format!("[{address}]"), parse_port(port)?) + } else { + let (host, port) = value + .rsplit_once(':') + .ok_or(DeliveryError::InvalidConnectionField)?; + if host.contains(':') { + return Err(DeliveryError::InvalidConnectionField); + } + let formatted = if let Ok(address) = host.parse::() { + address.to_string() + } else { + validate_hostname(host)?; + host.to_owned() + }; + (formatted, parse_port(port)?) + }; + Ok(format!("{host}:{port}")) +} + +fn validate_hostname(host: &str) -> Result<(), DeliveryError> { + if host.is_empty() + || host.len() > 253 + || host.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label.as_bytes()[0].is_ascii_alphanumeric() + || !label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return Err(DeliveryError::InvalidConnectionField); + } + Ok(()) +} + +fn parse_port(value: &str) -> Result { + let port = value + .parse::() + .map_err(|_| DeliveryError::InvalidConnectionField)?; + if port == 0 || port.to_string() != value { + return Err(DeliveryError::InvalidConnectionField); + } + Ok(port) +} + +fn validate_mtu(value: &str) -> Result { + let mtu = value + .parse::() + .map_err(|_| DeliveryError::InvalidConnectionField)?; + if mtu == 0 || mtu.to_string() != value { + return Err(DeliveryError::InvalidConnectionField); + } + Ok(mtu.to_string()) +} + +fn validate_keepalive(value: &str) -> Result { + if value.len() > 5 { + return Err(DeliveryError::InvalidConnectionField); + } + let keepalive = value + .parse::() + .map_err(|_| DeliveryError::InvalidConnectionField)?; + if keepalive.to_string() != value { + return Err(DeliveryError::InvalidConnectionField); + } + Ok(keepalive.to_string()) +} + +fn validate_routes(routes: &[String]) -> Result { + if routes.is_empty() + || routes.len() > MAX_ROUTE_ENTRIES + || routes.iter().any(|route| route.len() > 128) + { + return Err(DeliveryError::InvalidConnectionField); + } + let mut parsed = Vec::with_capacity(routes.len()); + for route in routes { + if route.chars().any(char::is_control) { + return Err(DeliveryError::InvalidConnectionField); + } + let network = IpNet::from_str(route).map_err(|_| DeliveryError::InvalidConnectionField)?; + parsed.push(network.to_string()); + } + Ok(parsed.join(", ")) +} + +fn validate_secret_key(value: &str) -> Result, DeliveryError> { + let key = WireGuardPrivateKey::parse_base64(value) + .map_err(|_| DeliveryError::InvalidConnectionField)?; + // Parsing confirms canonical 32-byte standard Base64; retain only a zeroizing copy. + Ok(Zeroizing::new(key.to_base64())) +} + +fn append(output: &mut Zeroizing>, value: &str) -> Result<(), DeliveryError> { + if output.len().saturating_add(value.len()) > MAX_CONFIG_BYTES { + return Err(DeliveryError::TooLarge); + } + output.extend_from_slice(value.as_bytes()); + Ok(()) +} + +fn finish(output: Zeroizing>) -> Result { + if output.is_empty() || output.len() > MAX_CONFIG_BYTES { + return Err(DeliveryError::TooLarge); + } + Ok(ClientConfigPayload(output)) +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use crate::config::{EndpointHost, ServerEndpoint}; + use crate::vault::{ + VaultBundle, WireGuardPrivateKey, + import::{parse_client_config, reconcile}, + }; + use crate::wireguard::ConfigDocument; + + use super::{DeliveryError, DeliverySettings, render_imported_client, render_new_client}; + + const PASSWORD: &str = "synthetic delivery test password"; + + fn key(byte: u8) -> WireGuardPrivateKey { + WireGuardPrivateKey::parse_base64( + &base64::engine::general_purpose::STANDARD.encode([byte; 32]), + ) + .expect("synthetic key parses") + } + + fn document(client: &WireGuardPrivateKey, addresses: &str) -> ConfigDocument { + document_with_server(client, addresses, &key(9)) + } + + fn document_with_server( + client: &WireGuardPrivateKey, + addresses: &str, + server: &WireGuardPrivateKey, + ) -> ConfigDocument { + let source = format!( + "[Interface]\nPrivateKey = {}\n\n[Peer]\nPublicKey = {}\nAllowedIPs = {addresses}\n", + server.to_base64(), + client.public_key() + ); + ConfigDocument::parse(source).expect("synthetic server document parses") + } + + fn imported_record( + client: &WireGuardPrivateKey, + addresses: &str, + server: &WireGuardPrivateKey, + ) -> crate::vault::RetrievedClientRecord { + let saved = document_with_server(client, addresses, server); + let source = format!( + "[Interface]\nPrivateKey = {}\nAddress = {addresses}\nDNS = 10.80.0.1, fd80::1\nMTU = 1380\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example.test:51821\nAllowedIPs = 10.0.0.0/8, ::/0\nPersistentKeepalive = 25\n", + client.to_base64(), + server.public_key() + ); + let report = reconcile(&saved, [("fixture.conf".to_owned(), source.into_bytes())]); + let mut bundle = VaultBundle::create(PASSWORD).expect("synthetic vault creates"); + report + .encrypt_candidate(&mut bundle, 0) + .expect("matching imported file encrypts"); + bundle + .retrieve_client_record(client.public_key(), PASSWORD) + .expect("imported record retrieves") + } + + fn settings() -> (ServerEndpoint, Vec) { + ( + ServerEndpoint { + host: EndpointHost::Hostname("vpn.example.test".to_owned()), + port: 51820, + }, + vec![ + "10.80.0.1".parse().expect("valid DNS"), + "fd80::1".parse().expect("valid DNS"), + ], + ) + } + + #[test] + fn new_client_uses_saved_dual_stack_addresses_and_full_tunnel_template() { + let client = key(1); + let server = key(9).public_key(); + let saved = document(&client, "10.80.0.7/32, fd80::7/128"); + let (endpoint, dns) = settings(); + let payload = render_new_client( + client.public_key(), + &client, + &saved, + &DeliverySettings { + server_endpoint: &endpoint, + dns_addresses: &dns, + }, + ) + .expect("valid synthetic values render"); + + let parsed = parse_client_config(payload.as_bytes()).expect("rendered file parses"); + assert_eq!(parsed.identity, client.public_key()); + assert_eq!(parsed.server_public_key, server); + assert_eq!(parsed.addresses.len(), 2); + assert_eq!(parsed.allowed_ips.len(), 2); + assert_eq!(parsed.allowed_ips[0].to_string(), "0.0.0.0/0"); + assert_eq!(parsed.allowed_ips[1].to_string(), "::/0"); + } + + #[test] + fn new_client_rejects_wrong_key_and_missing_or_ambiguous_saved_peer() { + let client = key(1); + let other = key(2); + let (endpoint, dns) = settings(); + let opts = DeliverySettings { + server_endpoint: &endpoint, + dns_addresses: &dns, + }; + let saved = document(&client, "10.80.0.7/32, fd80::7/128"); + assert_eq!( + render_new_client(other.public_key(), &client, &saved, &opts).err(), + Some(DeliveryError::ClientIdentityMismatch) + ); + let empty = ConfigDocument::parse(format!( + "[Interface]\nPrivateKey = {}\n", + key(9).to_base64() + )) + .expect("valid document"); + assert_eq!( + render_new_client(client.public_key(), &client, &empty, &opts).err(), + Some(DeliveryError::MissingSavedPeer) + ); + let duplicate = ConfigDocument::parse(format!( + "[Interface]\nPrivateKey = {}\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n", + key(9).to_base64(), + client.public_key(), client.public_key() + )).expect("duplicate peer document parses"); + assert_eq!( + render_new_client(client.public_key(), &client, &duplicate, &opts).err(), + Some(DeliveryError::AmbiguousSavedPeer) + ); + } + + #[test] + fn new_client_derives_server_identity_and_rejects_invalid_saved_interface_keys() { + let client = key(1); + let (endpoint, dns) = settings(); + let opts = DeliverySettings { + server_endpoint: &endpoint, + dns_addresses: &dns, + }; + let valid_peer = format!( + "\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n", + client.public_key() + ); + let invalid_documents = [ + valid_peer.clone(), + format!( + "[Interface]\nPrivateKey = {}\n[Interface]\nPrivateKey = {}{}", + key(9).to_base64(), + key(9).to_base64(), + valid_peer + ), + format!("[Interface]\nPrivateKey = invalid{valid_peer}"), + format!( + "[Interface]\nPrivateKey = {}\nPrivateKey = {}{}", + key(9).to_base64(), + key(8).to_base64(), + valid_peer + ), + format!("[Interface]\nPrivateKey\n{valid_peer}"), + ]; + for source in invalid_documents { + let saved = ConfigDocument::parse(source).expect("synthetic document parses"); + assert_eq!( + render_new_client(client.public_key(), &client, &saved, &opts).err(), + Some(DeliveryError::InvalidSavedServerIdentity) + ); + } + + let valid = document(&client, "10.80.0.7/32, fd80::7/128"); + let rendered = render_new_client(client.public_key(), &client, &valid, &opts) + .expect("server identity derives from valid saved private key"); + let parsed = parse_client_config(rendered.as_bytes()).expect("rendered file parses"); + assert_eq!(parsed.server_public_key, key(9).public_key()); + } + + #[test] + fn imported_ipv4_only_record_preserves_supported_fields() { + let client = key(4); + let server_private = key(9); + let record = imported_record(&client, "10.80.0.9/32", &server_private); + let saved = document(&client, "10.80.0.9/32"); + let (endpoint, dns) = settings(); + let payload = render_imported_client( + client.public_key(), + &record, + &saved, + &DeliverySettings { + server_endpoint: &endpoint, + dns_addresses: &dns, + }, + ) + .expect("supported imported values render"); + let parsed = + parse_client_config(payload.as_bytes()).expect("rendered imported file parses"); + assert_eq!( + parsed.addresses, + vec!["10.80.0.9/32".parse().expect("valid address")] + ); + assert_eq!(parsed.allowed_ips.len(), 2); + assert_eq!(parsed.mtu.as_deref(), Some("1380")); + assert_eq!(parsed.endpoint.as_deref(), Some("vpn.example.test:51821")); + assert_eq!(parsed.persistent_keepalive.as_deref(), Some("25")); + } + + #[test] + fn imported_rendering_rejects_stale_and_mismatched_bindings() { + let client = key(6); + let server_private = key(9); + let (endpoint, dns) = settings(); + let opts = DeliverySettings { + server_endpoint: &endpoint, + dns_addresses: &dns, + }; + let record = imported_record(&client, "10.80.0.9/32", &server_private); + let stale_saved = document(&client, "10.80.0.8/32"); + assert_eq!( + render_imported_client(client.public_key(), &record, &stale_saved, &opts).err(), + Some(DeliveryError::AddressMismatch) + ); + let changed_server = document_with_server(&client, "10.80.0.9/32", &key(8)); + assert_eq!( + render_imported_client(client.public_key(), &record, &changed_server, &opts).err(), + Some(DeliveryError::ServerIdentityMismatch) + ); + assert!(record.server_public_key().is_some()); + } + + #[test] + fn imported_rendering_requires_saved_server_identity() { + let client = key(12); + let mut bundle = VaultBundle::create(PASSWORD).expect("synthetic vault creates"); + bundle + .encrypt_record(client.public_key(), &client) + .expect("client key encrypts"); + let record = bundle + .retrieve_client_record(client.public_key(), PASSWORD) + .expect("record retrieves"); + let saved = ConfigDocument::parse(format!( + "[Interface]\nPrivateKey = malformed\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.9/32\n", + client.public_key() + )) + .expect("synthetic malformed-key document parses structurally"); + let (endpoint, dns) = settings(); + let opts = DeliverySettings { + server_endpoint: &endpoint, + dns_addresses: &dns, + }; + let valid_saved = document(&client, "10.80.0.9/32"); + assert_eq!( + render_imported_client(client.public_key(), &record, &valid_saved, &opts).err(), + Some(DeliveryError::ServerIdentityMismatch) + ); + assert_eq!( + render_imported_client(client.public_key(), &record, &saved, &opts).err(), + Some(DeliveryError::InvalidSavedServerIdentity) + ); + } + + #[test] + fn payload_debug_and_render_errors_do_not_contain_secret_text() { + let client = key(10); + let saved = document(&client, "10.80.0.7/32, fd80::7/128"); + let (endpoint, dns) = settings(); + let payload = render_new_client( + client.public_key(), + &client, + &saved, + &DeliverySettings { + server_endpoint: &endpoint, + dns_addresses: &dns, + }, + ) + .expect("valid synthetic values render"); + let debug = format!("{payload:?}"); + assert!(debug.contains("REDACTED")); + assert!(!debug.contains(&client.to_base64())); + assert!(Ipv4Addr::LOCALHOST.is_loopback()); + assert_eq!( + "::1".parse::().expect("valid IPv6"), + Ipv6Addr::LOCALHOST + ); + } +} diff --git a/src/delivery/files.rs b/src/delivery/files.rs new file mode 100644 index 0000000..04e3afc --- /dev/null +++ b/src/delivery/files.rs @@ -0,0 +1,880 @@ +//! Secure publication of explicitly selected plaintext client configurations. + +use std::fmt; +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::os::fd::AsFd; +use std::os::unix::fs::{DirBuilderExt, MetadataExt}; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use rustix::fs::{self as rfs, Mode, OFlags, RenameFlags, ResolveFlags}; +use thiserror::Error; +use zeroize::{Zeroize, Zeroizing}; + +use super::{ClientConfigPayload, MAX_CONFIG_BYTES}; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); +const MAX_OUTPUT_BYTES: usize = MAX_CONFIG_BYTES; + +/// Resolved installation files and protected directories supplied by SavedStore. +pub(crate) struct OutputProtection { + protected_files: Vec, + protected_directories: Vec, +} + +impl OutputProtection { + pub(crate) fn new(files: Vec, directories: Vec) -> Self { + Self { + protected_files: files, + protected_directories: directories, + } + } +} + +/// A destination prepared without creating files or directories. +/// +/// The optional existing-file identity binds overwrite confirmation to the +/// precise regular file observed by [`prepare_destination`]. +pub struct PreparedDestination { + path: PathBuf, + parent: PathBuf, + filename: std::ffi::OsString, + existing: Option, + overwrite_confirmed: bool, +} + +impl PreparedDestination { + pub fn path(&self) -> &Path { + &self.path + } + + pub const fn overwrite_confirmation_required(&self) -> bool { + self.existing.is_some() && !self.overwrite_confirmed + } + + /// Confirm replacement of the exact regular file observed during preparation. + pub fn confirm_overwrite(mut self) -> Result { + if self.existing.is_none() { + return Err(FileOutputError::OverwriteNotRequired); + } + self.overwrite_confirmed = true; + Ok(self) + } +} + +impl fmt::Debug for PreparedDestination { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PreparedDestination") + .field("path", &self.path) + .field("existing_file", &self.existing.is_some()) + .field("overwrite_confirmed", &self.overwrite_confirmed) + .finish() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct FileIdentity { + device: u64, + inode: u64, + length: u64, + modified_seconds: i64, + modified_nanoseconds: i64, +} + +impl From<&fs::Metadata> for FileIdentity { + fn from(metadata: &fs::Metadata) -> Self { + Self { + device: metadata.dev(), + inode: metadata.ino(), + length: metadata.len(), + modified_seconds: metadata.mtime(), + modified_nanoseconds: metadata.mtime_nsec(), + } + } +} + +/// Failure categories let the application preserve retry eligibility while +/// distinguishing a failed preparation from a file that may already be visible. +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum FileOutputError { + #[error("selected output path is unsafe or protected")] + UnsafePath, + #[error("selected destination already exists; confirm replacement for this file")] + OverwriteDeclined, + #[error("the selected destination changed after it was prepared")] + DestinationChanged, + #[error("client configuration exceeds the output size limit")] + TooLarge, + #[error("client configuration could not be written before publication")] + BeforePublication, + #[error("a private temporary client configuration may remain and requires manual cleanup")] + ManualCleanupRequired, + #[error("client configuration may have been published, but directory sync failed")] + PublicationUncertain, + #[error("file output failed")] + Io, + #[error("overwrite confirmation is not applicable to a new destination")] + OverwriteNotRequired, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Checkpoint { + Write, + Flush, + FileSync, + Cleanup, + Publish, + Rollback, + ParentSync, +} + +/// Validate a caller-selected absolute destination without performing writes. +/// Existing targets are accepted only when they are regular files; they require +/// an explicit later call to [`PreparedDestination::confirm_overwrite`]. +pub(crate) fn prepare_destination( + path: impl AsRef, + protection: &OutputProtection, +) -> Result { + let path = path.as_ref(); + validate_absolute_path(path)?; + reject_protected(path, protection)?; + let parent = path.parent().ok_or(FileOutputError::UnsafePath)?; + validate_existing_ancestors(parent)?; + let filename = path + .file_name() + .ok_or(FileOutputError::UnsafePath)? + .to_os_string(); + let existing = match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_file() => { + reject_protected_hardlink(&metadata, protection)?; + Some(FileIdentity::from(&metadata)) + } + Ok(_) => return Err(FileOutputError::UnsafePath), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(_) => return Err(FileOutputError::Io), + }; + Ok(PreparedDestination { + path: path.to_path_buf(), + parent: parent.to_path_buf(), + filename, + existing, + overwrite_confirmed: false, + }) +} + +/// Publish the bounded payload using a private same-directory temporary file. +/// New targets use a no-replace rename. Confirmed overwrite uses an atomic +/// exchange and verifies the displaced inode before removing it. +pub fn publish( + destination: PreparedDestination, + payload: &ClientConfigPayload, +) -> Result<(), FileOutputError> { + publish_with_hook(destination, payload, |_| Ok(())) +} + +fn publish_with_hook( + destination: PreparedDestination, + payload: &ClientConfigPayload, + mut hook: impl FnMut(Checkpoint) -> io::Result<()>, +) -> Result<(), FileOutputError> { + if payload.len() > MAX_OUTPUT_BYTES { + return Err(FileOutputError::TooLarge); + } + if destination.overwrite_confirmation_required() { + return Err(FileOutputError::OverwriteDeclined); + } + validate_existing_ancestors(&destination.parent)?; + match ( + &destination.existing, + fs::symlink_metadata(&destination.path), + ) { + (Some(expected), Ok(actual)) + if actual.file_type().is_file() && FileIdentity::from(&actual) == *expected => {} + (None, Err(error)) if error.kind() == io::ErrorKind::NotFound => {} + (None, Ok(_)) => return Err(FileOutputError::DestinationChanged), + _ => return Err(FileOutputError::DestinationChanged), + } + + create_missing_private_directories(&destination.parent)?; + validate_existing_ancestors(&destination.parent)?; + let parent = open_parent_directory(&destination.parent)?; + let mut temp_name = None; + let mut temp_file = None; + for _ in 0..32 { + let mut random = [0_u8; 16]; + getrandom::fill(&mut random).map_err(|_| FileOutputError::Io)?; + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let name = format!(".wg-admin-client-{sequence}-{}.tmp", hex(&random)); + match rfs::openat( + &parent, + name.as_str(), + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::from_raw_mode(0o600), + ) { + Ok(fd) => { + temp_name = Some(name); + temp_file = Some(File::from(fd)); + break; + } + Err(rustix::io::Errno::EXIST) => continue, + Err(_) => return Err(FileOutputError::Io), + } + } + let temp_name = temp_name.ok_or(FileOutputError::Io)?; + let mut file = temp_file.ok_or(FileOutputError::Io)?; + let mut bytes = Zeroizing::new(payload.as_bytes().to_vec()); + let write_result = (|| -> io::Result<()> { + hook(Checkpoint::Write)?; + file.write_all(bytes.as_slice())?; + hook(Checkpoint::Flush)?; + file.flush()?; + hook(Checkpoint::FileSync)?; + file.sync_all()?; + Ok(()) + })(); + bytes.zeroize(); + if write_result.is_err() { + drop(file); + return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) { + FileOutputError::BeforePublication + } else { + FileOutputError::ManualCleanupRequired + }); + } + drop(file); + + let staged_identity = file_identity_at(&parent, &temp_name); + let Some(staged_identity) = staged_identity else { + return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) { + FileOutputError::Io + } else { + FileOutputError::ManualCleanupRequired + }); + }; + if hook(Checkpoint::Publish).is_err() { + return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) { + FileOutputError::BeforePublication + } else { + FileOutputError::ManualCleanupRequired + }); + } + let publication = if destination.existing.is_some() { + rfs::renameat_with( + &parent, + temp_name.as_str(), + &parent, + &destination.filename, + RenameFlags::EXCHANGE, + ) + } else { + rfs::renameat_with( + &parent, + temp_name.as_str(), + &parent, + &destination.filename, + RenameFlags::NOREPLACE, + ) + }; + if publication.is_err() { + let clean = cleanup_temporary(&parent, &temp_name, &mut hook); + return Err(if !clean { + FileOutputError::ManualCleanupRequired + } else if destination.existing.is_some() { + FileOutputError::DestinationChanged + } else { + FileOutputError::BeforePublication + }); + } + if let Some(expected) = destination.existing { + let displaced = file_identity_at(&parent, &temp_name); + if displaced != Some(expected) { + // EXCHANGE never discards either entry. Rollback is another + // unconditional exchange, so a concurrent substitution can change + // either name before or during it. Never unlink by name here. + if hook(Checkpoint::Rollback).is_err() { + return Err(FileOutputError::PublicationUncertain); + } + if rfs::renameat_with( + &parent, + temp_name.as_str(), + &parent, + &destination.filename, + RenameFlags::EXCHANGE, + ) + .is_err() + { + return Err(FileOutputError::PublicationUncertain); + } + if file_identity_at(&parent, &destination.filename) != displaced + || file_identity_at(&parent, &temp_name) != Some(staged_identity) + { + return Err(FileOutputError::PublicationUncertain); + } + if hook(Checkpoint::ParentSync).is_err() || rfs::fsync(&parent).is_err() { + return Err(FileOutputError::PublicationUncertain); + } + // Keep the staged entry under its private temporary name. Removing + // it by name would race with another substitution and could destroy + // an unconfirmed entry. + return Err(FileOutputError::DestinationChanged); + } + // The exchanged entry is the exact confirmed file. The atomic exchange + // closes the check/rename race; remove only after that identity check. + if rfs::unlinkat(&parent, temp_name.as_str(), rfs::AtFlags::empty()).is_err() { + return Err(FileOutputError::PublicationUncertain); + } + } + if file_identity_at(&parent, &destination.filename) != Some(staged_identity) { + return Err(FileOutputError::PublicationUncertain); + } + if hook(Checkpoint::ParentSync).is_err() || rfs::fsync(&parent).is_err() { + return Err(FileOutputError::PublicationUncertain); + } + Ok(()) +} + +fn cleanup_temporary( + parent: &impl AsFd, + name: &str, + hook: &mut impl FnMut(Checkpoint) -> io::Result<()>, +) -> bool { + if hook(Checkpoint::Cleanup).is_err() { + return false; + } + match rfs::unlinkat(parent, name, rfs::AtFlags::empty()) { + Ok(()) | Err(rustix::io::Errno::NOENT) => true, + Err(_) => false, + } +} + +fn file_identity_at(parent: &impl AsFd, name: impl AsRef) -> Option { + let fd = rfs::openat( + parent, + name.as_ref(), + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::empty(), + ) + .ok()?; + let metadata = File::from(fd).metadata().ok()?; + metadata.is_file().then(|| FileIdentity::from(&metadata)) +} + +fn validate_absolute_path(path: &Path) -> Result<(), FileOutputError> { + if !path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::RootDir | Component::Normal(_))) + { + return Err(FileOutputError::UnsafePath); + } + Ok(()) +} + +fn validate_existing_ancestors(path: &Path) -> Result<(), FileOutputError> { + let mut current = PathBuf::from("/"); + let components: Vec<_> = path.components().skip(1).collect(); + for (index, component) in components.iter().enumerate() { + let Component::Normal(name) = component else { + return Err(FileOutputError::UnsafePath); + }; + current.push(name); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_dir() => { + validate_directory_metadata(&metadata, index + 1 == components.len())?; + } + Ok(_) => return Err(FileOutputError::UnsafePath), + Err(error) if error.kind() == io::ErrorKind::NotFound => break, + Err(_) => return Err(FileOutputError::Io), + } + } + Ok(()) +} + +fn create_missing_private_directories(path: &Path) -> Result<(), FileOutputError> { + let mut current = PathBuf::from("/"); + for component in path.components().skip(1) { + let Component::Normal(name) = component else { + return Err(FileOutputError::UnsafePath); + }; + current.push(name); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_dir() => {} + Ok(_) => return Err(FileOutputError::UnsafePath), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + builder.create(¤t).map_err(|_| FileOutputError::Io)?; + } + Err(_) => return Err(FileOutputError::Io), + } + } + Ok(()) +} + +fn open_parent_directory(path: &Path) -> Result { + let fd = rfs::openat2( + rfs::ABS, + path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ResolveFlags::NO_SYMLINKS, + ) + .map_err(|_| FileOutputError::UnsafePath)?; + let directory = File::from(fd); + let metadata = directory.metadata().map_err(|_| FileOutputError::Io)?; + validate_directory_metadata(&metadata, true)?; + Ok(directory) +} + +fn validate_directory_metadata( + metadata: &fs::Metadata, + is_output_parent: bool, +) -> Result<(), FileOutputError> { + let uid = rustix::process::geteuid().as_raw(); + if is_output_parent && metadata.uid() != 0 && metadata.uid() != uid { + return Err(FileOutputError::UnsafePath); + } + let mode = metadata.mode(); + let group_or_other_writable = mode & 0o022 != 0; + let sticky = mode & 0o1000 != 0; + if group_or_other_writable && (is_output_parent || !sticky) { + return Err(FileOutputError::UnsafePath); + } + Ok(()) +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(char::from(DIGITS[usize::from(byte >> 4)])); + output.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + output +} + +fn reject_protected(path: &Path, protection: &OutputProtection) -> Result<(), FileOutputError> { + for protected in &protection.protected_files { + if lexical_normalize(path)? == lexical_normalize(protected)? { + return Err(FileOutputError::UnsafePath); + } + let target_meta = fs::symlink_metadata(path); + let protected_meta = fs::symlink_metadata(protected); + if let (Ok(target_meta), Ok(protected_meta)) = (target_meta, protected_meta) + && target_meta.dev() == protected_meta.dev() + && target_meta.ino() == protected_meta.ino() + { + return Err(FileOutputError::UnsafePath); + } + } + for directory in &protection.protected_directories { + if lexical_normalize(path)?.starts_with(lexical_normalize(directory)?) { + return Err(FileOutputError::UnsafePath); + } + } + Ok(()) +} + +fn reject_protected_hardlink( + metadata: &fs::Metadata, + protection: &OutputProtection, +) -> Result<(), FileOutputError> { + for protected in &protection.protected_files { + if let Ok(protected_metadata) = fs::symlink_metadata(protected) + && metadata.dev() == protected_metadata.dev() + && metadata.ino() == protected_metadata.ino() + { + return Err(FileOutputError::UnsafePath); + } + } + Ok(()) +} + +fn lexical_normalize(path: &Path) -> Result { + validate_absolute_path(path)?; + let mut normalized = PathBuf::from("/"); + for component in path.components().skip(1) { + if let Component::Normal(name) = component { + normalized.push(name); + } else { + return Err(FileOutputError::UnsafePath); + } + } + Ok(normalized) +} + +#[cfg(test)] +mod tests { + use super::{ + Checkpoint, FileOutputError, OutputProtection, PreparedDestination, prepare_destination, + publish, publish_with_hook, + }; + use crate::delivery::ClientConfigPayload; + use rustix::fs as rfs; + use std::fs; + use std::io; + use std::os::unix::fs::{PermissionsExt, symlink}; + use std::path::Path; + use zeroize::Zeroizing; + + fn payload() -> ClientConfigPayload { + ClientConfigPayload(Zeroizing::new(b"synthetic client configuration\n".to_vec())) + } + + fn protection(root: &Path) -> OutputProtection { + OutputProtection::new( + vec![root.join("settings.toml"), root.join("server.conf")], + vec![root.join("Backup/data"), root.join("Backup/archives")], + ) + } + + #[test] + fn writes_new_file_with_private_mode_and_private_new_directories() { + let root = tempfile::tempdir().expect("temp root"); + let target = root.path().join("chosen/nested/client.conf"); + let prepared = prepare_destination(&target, &protection(root.path())) + .expect("safe explicit destination"); + assert!(!target.exists()); + publish(prepared, &payload()).expect("publication succeeds"); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(root.path().join("chosen")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(root.path().join("chosen/nested")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert!( + !fs::read_dir(root.path().join("chosen/nested")) + .unwrap() + .any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .ends_with(".tmp")) + ); + } + + #[test] + fn existing_file_requires_bound_confirmation_and_decline_preserves_bytes() { + let root = tempfile::tempdir().expect("temp root"); + let target = root.path().join("existing.conf"); + fs::write(&target, b"old synthetic value").unwrap(); + let prepared = prepare_destination(&target, &protection(root.path())).unwrap(); + assert!(prepared.overwrite_confirmation_required()); + assert_eq!( + publish(prepared, &payload()), + Err(FileOutputError::OverwriteDeclined) + ); + assert!(fs::read(&target).unwrap() == b"old synthetic value"); + + let confirmed = prepare_destination(&target, &protection(root.path())) + .unwrap() + .confirm_overwrite() + .unwrap(); + publish(confirmed, &payload()).expect("confirmed bounded replacement"); + assert!(fs::read(&target).unwrap() == b"synthetic client configuration\n"); + assert_eq!( + fs::metadata(target).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + fn refuses_symlink_special_traversal_and_protected_targets() { + let root = tempfile::tempdir().expect("temp root"); + let outside = root.path().join("outside"); + fs::write(&outside, b"untouched").unwrap(); + let link = root.path().join("link"); + symlink(&outside, &link).unwrap(); + assert_eq!( + prepare_destination(&link, &protection(root.path())).unwrap_err(), + FileOutputError::UnsafePath + ); + assert_eq!( + prepare_destination(root.path().join("missing/../bad"), &protection(root.path())) + .unwrap_err(), + FileOutputError::UnsafePath + ); + assert_eq!( + prepare_destination(root.path().join("settings.toml"), &protection(root.path())) + .unwrap_err(), + FileOutputError::UnsafePath + ); + assert_eq!( + prepare_destination( + root.path().join("Backup/data/vault.toml"), + &protection(root.path()) + ) + .unwrap_err(), + FileOutputError::UnsafePath + ); + assert_eq!(fs::read(outside).unwrap(), b"untouched"); + + let fifo = root.path().join("fifo"); + rfs::mkfifoat(rfs::ABS, &fifo, rustix::fs::Mode::from_raw_mode(0o600)) + .expect("synthetic special file"); + assert_eq!( + prepare_destination(&fifo, &protection(root.path())).unwrap_err(), + FileOutputError::UnsafePath + ); + } + + #[test] + fn rejects_symlink_ancestors_and_protects_original_source_entries() { + let root = tempfile::tempdir().expect("temp root"); + let other = tempfile::tempdir().expect("other temp root"); + let link = root.path().join("linked-dir"); + symlink(other.path(), &link).unwrap(); + assert_eq!( + prepare_destination(link.join("client.conf"), &protection(root.path())).unwrap_err(), + FileOutputError::UnsafePath + ); + let source = root.path().join("clients"); + fs::create_dir(&source).unwrap(); + let original = source.join("original.conf"); + fs::write(&original, b"source fixture").unwrap(); + let policy = OutputProtection::new(vec![original.clone()], vec![]); + assert_eq!( + prepare_destination(&original, &policy).unwrap_err(), + FileOutputError::UnsafePath + ); + } + + #[test] + fn rejects_group_or_world_writable_output_parent_before_staging_plaintext() { + let root = tempfile::tempdir().expect("temp root"); + let shared = root.path().join("shared"); + fs::create_dir(&shared).unwrap(); + fs::set_permissions(&shared, fs::Permissions::from_mode(0o777)).unwrap(); + let target = shared.join("client.conf"); + + assert_eq!( + prepare_destination(&target, &protection(root.path())).unwrap_err(), + FileOutputError::UnsafePath + ); + assert_eq!(fs::read_dir(&shared).unwrap().count(), 0); + assert!(!target.exists()); + } + + #[test] + fn injected_write_and_sync_failures_report_publication_state() { + let root = tempfile::tempdir().expect("temp root"); + for (index, failed_stage) in [Checkpoint::Write, Checkpoint::Flush, Checkpoint::FileSync] + .into_iter() + .enumerate() + { + let before_target = root.path().join(format!("pre-publish-{index}.conf")); + let before = prepare_destination(&before_target, &protection(root.path())).unwrap(); + let failure = publish_with_hook(before, &payload(), |stage| { + if stage == failed_stage { + Err(io::Error::other("synthetic pre-publication fault")) + } else { + Ok(()) + } + }); + assert_eq!(failure, Err(FileOutputError::BeforePublication)); + assert!(!before_target.exists()); + } + + let uncertain_target = root.path().join("sync-fail.conf"); + let after = prepare_destination(&uncertain_target, &protection(root.path())).unwrap(); + let failure = publish_with_hook(after, &payload(), |stage| { + if stage == Checkpoint::ParentSync { + Err(io::Error::other("synthetic flush fault")) + } else { + Ok(()) + } + }); + assert_eq!(failure, Err(FileOutputError::PublicationUncertain)); + assert!(uncertain_target.exists()); + assert!(fs::read(uncertain_target).unwrap() == b"synthetic client configuration\n"); + } + + #[test] + fn cleanup_failure_reports_manual_recovery_and_retains_private_temporary_file() { + let root = tempfile::tempdir().expect("temp root"); + let target = root.path().join("cleanup-fail.conf"); + let prepared = prepare_destination(&target, &protection(root.path())).unwrap(); + let failure = publish_with_hook(prepared, &payload(), |stage| match stage { + Checkpoint::Flush | Checkpoint::Cleanup => Err(io::Error::other("synthetic fault")), + _ => Ok(()), + }); + + assert_eq!(failure, Err(FileOutputError::ManualCleanupRequired)); + assert!(!target.exists()); + let temporary = fs::read_dir(root.path()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| { + path.file_name() + .unwrap() + .to_string_lossy() + .starts_with(".wg-admin-client-") + }) + .expect("temporary plaintext is retained for manual cleanup"); + assert_eq!( + fs::metadata(&temporary).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n"); + } + + #[test] + fn no_replace_publication_keeps_a_racing_new_file_untouched() { + let root = tempfile::tempdir().expect("temp root"); + let target = root.path().join("racing.conf"); + let prepared = prepare_destination(&target, &protection(root.path())).unwrap(); + let failure = publish_with_hook(prepared, &payload(), |stage| { + if stage == Checkpoint::Publish { + fs::write(&target, b"racing synthetic content")?; + } + Ok(()) + }); + assert_eq!(failure, Err(FileOutputError::BeforePublication)); + assert!(fs::read(target).unwrap() == b"racing synthetic content"); + } + + #[test] + fn changed_existing_destination_cannot_reuse_old_confirmation() { + let root = tempfile::tempdir().expect("temp root"); + let target = root.path().join("replace.conf"); + fs::write(&target, b"original").unwrap(); + let prepared = prepare_destination(&target, &protection(root.path())) + .unwrap() + .confirm_overwrite() + .unwrap(); + fs::write(&target, b"changed after selection").unwrap(); + assert_eq!( + publish(prepared, &payload()), + Err(FileOutputError::DestinationChanged) + ); + assert!(fs::read(target).unwrap() == b"changed after selection"); + } + + #[test] + fn substitution_at_publish_checkpoint_is_exchanged_back_without_data_loss() { + let root = tempfile::tempdir().expect("temp root"); + let target = root.path().join("substituted.conf"); + fs::write(&target, b"confirmed original").unwrap(); + let prepared = prepare_destination(&target, &protection(root.path())) + .unwrap() + .confirm_overwrite() + .unwrap(); + let result = publish_with_hook(prepared, &payload(), |stage| { + if stage == Checkpoint::Publish { + fs::remove_file(&target)?; + fs::write(&target, b"unconfirmed substitute")?; + } + Ok(()) + }); + assert_eq!(result, Err(FileOutputError::DestinationChanged)); + assert!(fs::read(target).unwrap() == b"unconfirmed substitute"); + let temporary = fs::read_dir(root.path()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| { + path.file_name() + .unwrap() + .to_string_lossy() + .ends_with(".tmp") + }) + .expect("staged output is retained under its private temporary name"); + assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n"); + } + + #[test] + fn substitution_during_rollback_retains_every_entry_and_reports_uncertain() { + let root = tempfile::tempdir().expect("temp root"); + let target = root.path().join("substituted-during-rollback.conf"); + let parked = root.path().join("parked-published.conf"); + fs::write(&target, b"confirmed original").unwrap(); + let prepared = prepare_destination(&target, &protection(root.path())) + .unwrap() + .confirm_overwrite() + .unwrap(); + let result = publish_with_hook(prepared, &payload(), |stage| { + if stage == Checkpoint::Publish { + fs::remove_file(&target)?; + fs::write(&target, b"first unconfirmed substitute")?; + } else if stage == Checkpoint::Rollback { + fs::rename(&target, &parked)?; + fs::write(&target, b"second unconfirmed substitute")?; + } + Ok(()) + }); + assert_eq!(result, Err(FileOutputError::PublicationUncertain)); + assert!(fs::read(&target).unwrap() == b"first unconfirmed substitute"); + assert!(fs::read(parked).unwrap() == b"synthetic client configuration\n"); + let temporary = fs::read_dir(root.path()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| { + path.file_name() + .unwrap() + .to_string_lossy() + .ends_with(".tmp") + }) + .expect("second unconfirmed substitute is retained"); + assert!(fs::read(temporary).unwrap() == b"second unconfirmed substitute"); + } + + #[test] + fn rollback_parent_sync_failure_is_uncertain_and_keeps_both_entries() { + let root = tempfile::tempdir().expect("temp root"); + let target = root.path().join("rollback-sync-fail.conf"); + fs::write(&target, b"confirmed original").unwrap(); + let prepared = prepare_destination(&target, &protection(root.path())) + .unwrap() + .confirm_overwrite() + .unwrap(); + let result = publish_with_hook(prepared, &payload(), |stage| { + if stage == Checkpoint::Publish { + fs::remove_file(&target)?; + fs::write(&target, b"unconfirmed substitute")?; + } else if stage == Checkpoint::ParentSync { + return Err(io::Error::other("synthetic rollback directory sync fault")); + } + Ok(()) + }); + assert_eq!(result, Err(FileOutputError::PublicationUncertain)); + assert!(fs::read(target).unwrap() == b"unconfirmed substitute"); + let temporary = fs::read_dir(root.path()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| { + path.file_name() + .unwrap() + .to_string_lossy() + .ends_with(".tmp") + }) + .expect("published staged output remains retained"); + assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n"); + } + + #[test] + fn confirmation_is_not_available_for_a_new_file() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("new.conf"); + let prepared: PreparedDestination = + prepare_destination(&target, &protection(root.path())).unwrap(); + assert_eq!( + prepared.confirm_overwrite().unwrap_err(), + FileOutputError::OverwriteNotRequired + ); + } +} diff --git a/src/delivery/qr.rs b/src/delivery/qr.rs new file mode 100644 index 0000000..ae3c641 --- /dev/null +++ b/src/delivery/qr.rs @@ -0,0 +1,120 @@ +//! Bounded, operation-local QR representation for an authorized client payload. +//! +//! The encoder necessarily allocates internal copies of the payload and QR +//! codewords that this crate cannot zeroize. This module clears its owned matrix +//! on drop and never formats or serializes it implicitly. + +use std::fmt; + +use qrcode::{QrCode, types::Color}; +use thiserror::Error; +use zeroize::Zeroizing; + +use super::ClientConfigPayload; + +const QUIET_ZONE: usize = 4; +const MAX_QR_SIDE: usize = 185; // Version 40 (177 modules) plus quiet zone. + +/// Secret-bearing QR modules including the four-module quiet zone. +/// +/// The bytes are row-major: `0` is light and `1` is dark. Access is explicit +/// through [`Self::module`]; there is no `Display` or serialization API. +pub struct QrCodeOutput { + side: usize, + modules: Zeroizing>, +} + +impl QrCodeOutput { + pub fn side(&self) -> usize { + self.side + } + + pub fn module(&self, x: usize, y: usize) -> Option { + if x >= self.side || y >= self.side { + return None; + } + self.modules.get(y * self.side + x).map(|value| *value != 0) + } +} + +impl fmt::Debug for QrCodeOutput { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("QrCodeOutput") + .field("side", &self.side) + .field("modules", &"[REDACTED]") + .finish() + } +} + +/// Secret-free QR encoding failures. +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum QrError { + #[error("client configuration exceeds QR capacity")] + CapacityExceeded, + #[error("QR encoding failed")] + EncodingFailed, + #[error("encoded QR dimensions exceed the supported bound")] + DimensionsExceeded, +} + +/// Encode exactly the supplied delivery payload with a four-module quiet zone. +pub(crate) fn encode_payload(payload: &ClientConfigPayload) -> Result { + encode_bytes(payload.as_bytes()) +} + +fn encode_bytes(bytes: &[u8]) -> Result { + let code = QrCode::new(bytes).map_err(|error| match error { + qrcode::types::QrError::DataTooLong => QrError::CapacityExceeded, + _ => QrError::EncodingFailed, + })?; + let inner_side = code.width(); + let side = inner_side + .checked_add(QUIET_ZONE * 2) + .filter(|side| *side <= MAX_QR_SIDE) + .ok_or(QrError::DimensionsExceeded)?; + let mut modules = Zeroizing::new(vec![0; side * side]); + for y in 0..inner_side { + for x in 0..inner_side { + if code[(x, y)] == Color::Dark { + modules[(y + QUIET_ZONE) * side + x + QUIET_ZONE] = 1; + } + } + } + Ok(QrCodeOutput { side, modules }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_bounded_matrix_with_quiet_zone_and_redacted_debug() { + let payload = ClientConfigPayload(Zeroizing::new( + b"[Interface]\nPrivateKey = synthetic-secret\n".to_vec(), + )); + let matrix = encode_payload(&payload).expect("small payload encodes"); + assert!((21 + 8..=MAX_QR_SIDE).contains(&matrix.side())); + assert!((0..QUIET_ZONE).all(|offset| { + (0..matrix.side()).all(|position| { + matrix.module(position, offset) == Some(false) + && matrix.module(offset, position) == Some(false) + }) + })); + let debug = format!("{matrix:?}"); + assert!(debug.contains("REDACTED")); + assert!(!debug.contains("synthetic-secret")); + assert_eq!(matrix.module(matrix.side(), 0), None); + } + + #[test] + fn reports_payload_capacity_without_including_payload_in_error() { + let payload = ClientConfigPayload(Zeroizing::new(vec![b'x'; 65_536])); + let error = encode_payload(&payload).expect_err("payload exceeds QR capacity"); + assert_eq!(error, QrError::CapacityExceeded); + assert_eq!( + error.to_string(), + "client configuration exceeds QR capacity" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2b2a863..78f67ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod backup; pub mod config; +pub mod delivery; pub mod domain; pub mod persistence; pub mod runtime; diff --git a/src/persistence.rs b/src/persistence.rs index 0c38b05..be956d8 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -515,6 +515,54 @@ impl SavedStore { self.vault_path(), ] } + + /// Paths which client plaintext delivery must not replace. This is a + /// read-only view of the resolved installation mapping; source candidates + /// are protected individually so a safe, newly selected sibling output + /// remains available. + pub(crate) fn delivery_output_protection( + &self, + ) -> Result + { + let mut protected_files = self.targets().into_iter().collect::>(); + let source_directory = &self.settings.client_source_directory; + match fs::symlink_metadata(source_directory) { + Ok(metadata) if metadata.file_type().is_dir() => {} + Ok(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(crate::delivery::files::OutputProtection::new( + protected_files, + vec![ + self.settings.data_directory.clone(), + self.settings.archives_directory.clone(), + ], + )); + } + Err(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath), + } + match fs::read_dir(source_directory) { + Ok(entries) => { + let mut count = 0usize; + for entry in entries { + let entry = entry.map_err(|_| crate::delivery::files::FileOutputError::Io)?; + count += 1; + if count > crate::vault::source::MAX_DIRECTORY_ENTRIES { + return Err(crate::delivery::files::FileOutputError::UnsafePath); + } + protected_files.push(entry.path()); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath), + } + Ok(crate::delivery::files::OutputProtection::new( + protected_files, + vec![ + self.settings.data_directory.clone(), + self.settings.archives_directory.clone(), + ], + )) + } } #[derive(Deserialize, Serialize)] diff --git a/src/vault.rs b/src/vault.rs index 0b6eaaa..426093d 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -110,16 +110,25 @@ impl Drop for RecordPayload { /// Password-authorized, short-lived client configuration data for later delivery. pub struct RetrievedClientRecord { private_key: Option, - pub client_addresses: Vec, - pub interface_dns: Option, - pub interface_mtu: Option, - pub server_public_key: Option, - pub endpoint: Option, - pub allowed_ips: Vec, - pub persistent_keepalive: Option, + provenance: RecordProvenance, + client_addresses: Vec, + interface_dns: Option, + interface_mtu: Option, + server_public_key: Option, + endpoint: Option, + allowed_ips: Vec, + persistent_keepalive: Option, preshared_key: Option, } +/// Provenance is assigned only after successful authenticated decryption and +/// validation of the retained field shape. +#[derive(Clone, Copy, Eq, PartialEq)] +pub(crate) enum RecordProvenance { + NewTemplate, + Imported, +} + impl RetrievedClientRecord { pub fn private_key(&self) -> Option<&WireGuardPrivateKey> { self.private_key.as_ref() @@ -130,9 +139,45 @@ impl RetrievedClientRecord { self.preshared_key.as_deref() } + pub fn client_addresses(&self) -> &[String] { + &self.client_addresses + } + + pub fn interface_dns(&self) -> Option<&str> { + self.interface_dns.as_deref() + } + + pub fn interface_mtu(&self) -> Option<&str> { + self.interface_mtu.as_deref() + } + + pub fn server_public_key(&self) -> Option<&str> { + self.server_public_key.as_deref() + } + + pub fn endpoint(&self) -> Option<&str> { + self.endpoint.as_deref() + } + + pub fn allowed_ips(&self) -> &[String] { + &self.allowed_ips + } + + pub fn persistent_keepalive(&self) -> Option<&str> { + self.persistent_keepalive.as_deref() + } + pub fn into_private_key(mut self) -> Result { self.private_key.take().ok_or(VaultError::CorruptRecord) } + + pub(crate) fn is_new_template(&self) -> bool { + self.provenance == RecordProvenance::NewTemplate + } + + pub(crate) fn is_imported(&self) -> bool { + self.provenance == RecordProvenance::Imported + } } impl fmt::Debug for RetrievedClientRecord { @@ -151,6 +196,38 @@ impl Drop for RetrievedClientRecord { } } +fn validated_record_provenance(payload: &RecordPayload) -> Result { + let key_only = payload.client_addresses.is_empty() + && payload.interface_dns.is_none() + && payload.interface_mtu.is_none() + && payload.server_public_key.is_none() + && payload.endpoint.is_none() + && payload.allowed_ips.is_empty() + && payload.persistent_keepalive.is_none() + && payload.preshared_key.is_none(); + if key_only { + return Ok(RecordProvenance::NewTemplate); + } + + // Imported records always carry their source address and server identity. + // Missing either is a partial record, not permission to fall back to the + // new-client template. + if payload.client_addresses.is_empty() + || payload.server_public_key.is_none() + || payload + .client_addresses + .iter() + .any(|value| value.parse::().is_err()) + || payload + .server_public_key + .as_deref() + .is_some_and(|value| value.parse::().is_err()) + { + return Err(VaultError::CorruptRecord); + } + Ok(RecordProvenance::Imported) +} + /// A serializable encrypted vault. New records use the public recipient; retrieval unlocks the /// password-protected identity only for the duration of that operation. #[derive(Serialize, Deserialize)] @@ -258,6 +335,14 @@ impl VaultBundle { self.records.contains_key(&label.to_string()) } + /// Return the encrypted record bytes for session-local authorization binding. + /// The ciphertext is never exposed through the public vault API. + pub(crate) fn record_context(&self, label: PublicKey) -> Option<&str> { + self.records + .get(&label.to_string()) + .map(|record| record.ciphertext.as_str()) + } + /// Remove only the record identified by its canonical WireGuard public key. /// The encrypted identity and every other record remain unchanged. pub fn remove_record(&mut self, label: PublicKey) -> Result { @@ -322,6 +407,7 @@ impl VaultBundle { if payload.reissue_blocked { return Err(VaultError::ReissueBlocked); } + let provenance = validated_record_provenance(&payload)?; let secret = WireGuardPrivateKey::parse_base64(&payload.private_key) .map_err(|_| VaultError::RecordMismatch)?; if secret.public_key() != label { @@ -329,6 +415,7 @@ impl VaultBundle { } Ok(RetrievedClientRecord { private_key: Some(secret), + provenance, client_addresses: payload.client_addresses.clone(), interface_dns: payload.interface_dns.clone(), interface_mtu: payload.interface_mtu.clone(), @@ -679,6 +766,47 @@ mod tests { ); } + #[test] + fn authenticated_retained_shape_never_falls_back_from_partial_import_to_new_template() { + let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed"); + let key = WireGuardPrivateKey::generate().expect("OS randomness should be available"); + let identity = key.public_key(); + bundle + .encrypt_payload( + identity, + RecordPayload { + version: 1, + label: identity.to_string(), + private_key: key.to_base64(), + client_addresses: vec!["10.0.0.2/32".to_owned()], + interface_dns: None, + interface_mtu: None, + server_public_key: None, + endpoint: None, + allowed_ips: Vec::new(), + persistent_keepalive: None, + preshared_key: None, + reissue_blocked: false, + }, + true, + ) + .expect("malformed legacy record can be encrypted for regression test"); + assert_eq!( + bundle.retrieve_client_record(identity, PASSWORD).err(), + Some(VaultError::CorruptRecord) + ); + + let clean_key = WireGuardPrivateKey::generate().expect("OS randomness should be available"); + let mut clean_bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed"); + clean_bundle + .encrypt_record(clean_key.public_key(), &clean_key) + .expect("key-only new-client record encrypts"); + let retrieved = clean_bundle + .retrieve_client_record(clean_key.public_key(), PASSWORD) + .expect("fresh unlock validates key-only provenance"); + assert!(retrieved.is_new_template()); + } + #[test] fn authenticated_record_with_unsupported_version_has_specific_error() { let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed"); diff --git a/tests/client_delivery.rs b/tests/client_delivery.rs new file mode 100644 index 0000000..9e31e01 --- /dev/null +++ b/tests/client_delivery.rs @@ -0,0 +1,411 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use wireguard_admin::app::{ + Application, FirstIssueAttempt, FirstIssueDeliveryOutcome, ReissueFailureKind, ReissueStage, +}; +use wireguard_admin::config::{ResolvedSettings, SettingsFile}; +use wireguard_admin::delivery::files::FileOutputError; +use wireguard_admin::domain::{ + Client, ClientAddresses, ClientStates, RuntimeObservation, Snapshot, +}; +use wireguard_admin::persistence::SavedStore; +use wireguard_admin::vault::import::{parse_client_config, reconcile}; +use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey}; +use wireguard_admin::wireguard::ConfigDocument; + +const SETTINGS: &str = r#" +interface_name = "wg0" +server_config_path = "server.conf" +client_source_directory = "clients" +server_endpoint = "vpn.example.test:51820" +ipv4_allocation_range = "10.20.0.0/24" +ipv6_allocation_range = "fd20::/64" +dns_addresses = ["10.20.0.1"] +systemd_unit = "wg-quick@wg0.service" +restart_timeout_seconds = 30 +"#; + +struct Fixture { + root: tempfile::TempDir, + settings: ResolvedSettings, + store: SavedStore, + states: ClientStates, +} + +fn fixture(server: String, states: ClientStates) -> Fixture { + let root = tempfile::tempdir().expect("private temporary fixture"); + let settings = SettingsFile::parse_toml(SETTINGS) + .expect("fixture settings parse") + .validate() + .expect("fixture settings validate") + .resolve_paths(root.path()); + fs::create_dir_all(&settings.client_source_directory).expect("source directory"); + fs::write(ResolvedSettings::settings_path(root.path()), SETTINGS).expect("settings file"); + fs::write(&settings.server_config_path, server).expect("server configuration"); + fs::set_permissions( + &settings.server_config_path, + fs::Permissions::from_mode(0o600), + ) + .expect("private server configuration"); + let store = SavedStore::new(root.path(), settings.clone()).expect("saved store"); + Fixture { + root, + settings, + store, + states, + } +} + +fn empty_states() -> ClientStates { + ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown).expect("empty state") +} + +fn open(fixture: &Fixture) -> Application { + Application::open(fixture.store.clone(), fixture.states.clone()).expect("application opens") +} + +fn new_client_fixture() -> (Fixture, Application, wireguard_admin::domain::PublicKey) { + let server_key = WireGuardPrivateKey::generate().expect("synthetic server key"); + let server = format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n", + server_key.to_base64() + ); + let fixture = fixture(server, empty_states()); + let mut app = open(&fixture); + app.prepare_initial_vault(VaultBundle::create("fixture vault password").expect("vault")) + .expect("stage vault"); + app.commit().expect("install vault"); + let identity = app + .prepare_new_client("same alias", None, None) + .expect("prepare client"); + app.commit().expect("commit client"); + (fixture, app, identity) +} + +fn destination( + app: &Application, + path: impl AsRef, +) -> wireguard_admin::delivery::files::PreparedDestination { + app.prepare_first_issue_destination(path) + .expect("prepare selected destination") +} + +fn archive_name(path: &Path) -> &str { + path.file_name() + .and_then(|name| name.to_str()) + .expect("synthetic archive filename") +} + +#[test] +fn committed_creation_retries_after_overwrite_decline_and_duplicate_aliases_are_independent() { + let server_key = WireGuardPrivateKey::generate().expect("synthetic server key"); + let server = format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n", + server_key.to_base64() + ); + let fixture = fixture(server, empty_states()); + let mut app = open(&fixture); + app.prepare_initial_vault(VaultBundle::create("fixture vault password").expect("vault")) + .expect("stage vault"); + app.commit().expect("install vault"); + let first = app + .prepare_new_client("duplicate", None, None) + .expect("first client"); + let second = app + .prepare_new_client("duplicate", None, None) + .expect("second client"); + app.commit().expect("commit clients"); + + let existing = fixture.root.path().join("occupied.conf"); + fs::write(&existing, b"existing output").expect("existing regular file"); + let declined = app + .deliver_first_issue_file( + first, + FirstIssueAttempt::Initial, + destination(&app, &existing), + ) + .expect("decline reported as an operation result"); + assert!(matches!( + declined, + FirstIssueDeliveryOutcome::Failed { + error: FileOutputError::OverwriteDeclined, + .. + } + )); + + let retry_path = fixture.root.path().join("new-dir/first.conf"); + let retried = app + .deliver_first_issue_file( + first, + FirstIssueAttempt::Retry { + confirm_possible_overwrite: false, + confirm_manual_cleanup: false, + }, + destination(&app, &retry_path), + ) + .expect("retry remains password free in creation session"); + assert!(matches!( + retried, + FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning + )); + assert_eq!( + fs::metadata(&retry_path) + .expect("delivered file") + .permissions() + .mode() + & 0o777, + 0o600 + ); + + let second_path = fixture.root.path().join("second.conf"); + let delivered_second = app + .deliver_first_issue_file( + second, + FirstIssueAttempt::Initial, + destination(&app, second_path), + ) + .expect("second key has independent eligibility"); + assert!(matches!( + delivered_second, + FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning + )); +} + +#[test] +fn reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen() { + let (fixture, mut app, identity) = new_client_fixture(); + let initial_path = fixture.root.path().join("initial.conf"); + let issued = app + .deliver_first_issue_file( + identity, + FirstIssueAttempt::Initial, + destination(&app, &initial_path), + ) + .expect("first file issued"); + assert!(matches!( + issued, + FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning + )); + + // A draft rename must not change the saved configuration used by reissue. + app.rename_client(identity, "unsaved draft alias") + .expect("prepare unrelated draft rename"); + let wrong_path = fixture.root.path().join("wrong-password.conf"); + let wrong = app.reissue_client_file( + identity, + "wrong password", + destination(&app, &wrong_path), + false, + ); + let error = wrong.expect_err("wrong password rejected"); + assert_eq!(error.stage, ReissueStage::Authorization); + assert_eq!(error.kind, ReissueFailureKind::PasswordOrRecordRejected); + assert!(!wrong_path.exists()); + + let output = fixture.root.path().join("reissue/client.conf"); + let result = app + .reissue_client_file( + identity, + "fixture vault password", + destination(&app, &output), + true, + ) + .expect("fresh password authorizes reissue"); + assert!(result.activation_warning); + assert!(result.qr.is_some_and(|qr| qr.is_ok())); + let bytes = fs::read(&output).expect("mandatory reissue file"); + let parsed = parse_client_config(&bytes).expect("rendered file parses"); + assert_eq!(parsed.identity, identity); + assert_eq!( + fs::metadata(&output) + .expect("private output") + .permissions() + .mode() + & 0o777, + 0o600 + ); + + let next_action_path = fixture.root.path().join("next-action.conf"); + // A successful action does not preserve an unlocked identity for the next + // action in the same session. + let next_action = app.reissue_client_file( + identity, + "wrong password", + destination(&app, next_action_path), + false, + ); + assert_eq!( + next_action + .expect_err("previous action does not cache an unlocked identity") + .kind, + ReissueFailureKind::PasswordOrRecordRejected + ); +} + +#[test] +fn imported_ipv4_only_parameters_are_preserved_and_authenticated_conflict_blocks_reissue() { + let server_key = WireGuardPrivateKey::generate().expect("synthetic server key"); + let client_key = WireGuardPrivateKey::generate().expect("synthetic client key"); + let identity = client_key.public_key(); + let server = format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24\n\n[Peer]\nPublicKey = {identity}\nAllowedIPs = 10.20.0.2/32\n", + server_key.to_base64() + ); + let client = Client::new( + identity, + "imported", + ClientAddresses { + ipv4: "10.20.0.2".parse().expect("IPv4"), + ipv6: None, + }, + ) + .expect("IPv4-only domain client"); + let snapshot = Snapshot::new([client]).expect("snapshot"); + let import_fixture = fixture( + server.clone(), + ClientStates::new(snapshot, None, RuntimeObservation::Unknown).expect("client state"), + ); + let document = ConfigDocument::parse(server.as_bytes()).expect("server document"); + let psk = WireGuardPrivateKey::generate().expect("synthetic preshared key"); + let source = format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\nDNS = 1.1.1.1\nMTU = 1380\n\n[Peer]\nPublicKey = {}\nPresharedKey = {}\nEndpoint = imported.example.test:12345\nAllowedIPs = 0.0.0.0/0\nPersistentKeepalive = 25\n", + client_key.to_base64(), + server_key.public_key(), + psk.to_base64() + ); + let report = reconcile(&document, [("client.conf".to_owned(), source.into_bytes())]); + let mut bundle = VaultBundle::create("fixture vault password").expect("vault"); + report + .encrypt_candidate(&mut bundle, 0) + .expect("encrypt matching import"); + let mut app = open(&import_fixture); + app.prepare_initial_vault(bundle) + .expect("stage imported vault"); + app.commit().expect("commit imported vault"); + + let output = import_fixture.root.path().join("imported.conf"); + app.reissue_client_file( + identity, + "fixture vault password", + destination(&app, &output), + false, + ) + .expect("imported record reissues"); + let rendered = fs::read_to_string(&output).expect("rendered imported file"); + assert!(rendered.contains("Address = 10.20.0.2/32\n")); + assert!(rendered.contains("DNS = 1.1.1.1\n")); + assert!(rendered.contains("MTU = 1380\n")); + assert!(rendered.contains("Endpoint = imported.example.test:12345\n")); + assert!(rendered.contains("AllowedIPs = 0.0.0.0/0\n")); + assert!(rendered.contains("PersistentKeepalive = 25\n")); + assert!(rendered.contains("PresharedKey = ")); + assert!(!rendered.contains("Address = 10.20.0.2/32, fd20::")); + + // A saved peer with a mismatching retained address is authenticated as + // blocked during import; an edited public status bit cannot authorize it. + let conflict_key = WireGuardPrivateKey::generate().expect("second synthetic key"); + let blocked_server = format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.20.0.9/32\n", + server_key.to_base64(), + conflict_key.public_key() + ); + let blocked_document = ConfigDocument::parse(blocked_server.as_bytes()).expect("server"); + let blocked_source = format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example.test:51820\nAllowedIPs = 0.0.0.0/0\n", + conflict_key.to_base64(), + server_key.public_key() + ); + let blocked_report = reconcile( + &blocked_document, + [("blocked.conf".to_owned(), blocked_source.into_bytes())], + ); + let mut blocked_bundle = VaultBundle::create("fixture vault password").expect("vault"); + blocked_report + .encrypt_candidate(&mut blocked_bundle, 0) + .expect("encrypt blocked candidate"); + let blocked_fixture = fixture(blocked_server, empty_states()); + let mut blocked_app = open(&blocked_fixture); + blocked_app + .prepare_initial_vault(blocked_bundle) + .expect("stage blocked vault"); + blocked_app.commit().expect("commit blocked record"); + let blocked_path = blocked_fixture.root.path().join("blocked.conf"); + let blocked = blocked_app.reissue_client_file( + conflict_key.public_key(), + "fixture vault password", + destination(&blocked_app, blocked_path), + false, + ); + assert_eq!( + blocked + .expect_err("authenticated discrepancy blocks reissue") + .kind, + ReissueFailureKind::PasswordOrRecordRejected + ); +} + +#[test] +fn deletion_and_restore_remove_password_free_authority_and_reopen_requires_vault_password() { + let (fixture, mut app, identity) = new_client_fixture(); + let before_delete_archive = app.create_manual_backup().expect("saved client archive"); + assert_eq!( + fs::metadata(&fixture.settings.server_config_path) + .expect("server remains installed") + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert!(app.delete_client(identity).expect("delete client")); + app.commit().expect("commit deletion"); + let deleted = app.reissue_client_file( + identity, + "fixture vault password", + destination(&app, fixture.root.path().join("deleted.conf")), + false, + ); + assert!(deleted.is_err()); + assert!(!fixture.root.path().join("deleted.conf").exists()); + + let preview = app + .preview_restore(archive_name(&before_delete_archive)) + .expect("preview prior saved client"); + app.confirm_restore(preview) + .expect("restore saved client only"); + let reopened = open(&fixture); + let restored_path = fixture.root.path().join("restored.conf"); + let restored = reopened + .reissue_client_file( + identity, + "fixture vault password", + destination(&reopened, &restored_path), + false, + ) + .expect("restored encrypted record requires and accepts password"); + assert!(restored.activation_warning); + assert!(restored_path.exists()); +} + +#[test] +fn overwrite_confirmation_is_bound_to_the_selected_destination() { + let (fixture, app, identity) = new_client_fixture(); + let path: PathBuf = fixture.root.path().join("output.conf"); + fs::write(&path, b"replace only after confirmation").expect("existing file"); + let prepared = destination(&app, &path); + let declined = app.reissue_client_file(identity, "fixture vault password", prepared, false); + assert!(matches!( + declined, + Err(error) if error.stage == ReissueStage::FilePublication + && error.kind == ReissueFailureKind::FileOutput(FileOutputError::OverwriteDeclined) + )); + let confirmed = destination(&app, &path) + .confirm_overwrite() + .expect("confirm exact destination"); + app.reissue_client_file(identity, "fixture vault password", confirmed, false) + .expect("confirmed reissue replaces destination"); + let parsed = parse_client_config(&fs::read(&path).expect("replacement output")) + .expect("replacement is valid config"); + assert_eq!(parsed.identity, identity); +}