Implement client configuration delivery and reissue lifecycle
This commit is contained in:
Generated
+7
@@ -1374,6 +1374,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -2095,6 +2101,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
|
"qrcode",
|
||||||
"rustix",
|
"rustix",
|
||||||
"serde",
|
"serde",
|
||||||
"tar",
|
"tar",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ age = { version = "=0.12.1", default-features = false }
|
|||||||
base64 = "=0.22.1"
|
base64 = "=0.22.1"
|
||||||
getrandom = "=0.3.4"
|
getrandom = "=0.3.4"
|
||||||
ipnet = "=2.11.0"
|
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"] }
|
rustix = { version = "=1.1.5", default-features = false, features = ["event", "fs", "net", "process", "std"] }
|
||||||
serde = { version = "=1.0.229", features = ["derive"] }
|
serde = { version = "=1.0.229", features = ["derive"] }
|
||||||
thiserror = "=2.0.20"
|
thiserror = "=2.0.20"
|
||||||
|
|||||||
@@ -1823,9 +1823,401 @@ without a separate request.
|
|||||||
|
|
||||||
### Milestone 6 — Client configuration delivery and reissue lifecycle
|
### 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
|
### 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
|
## 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).
|
- 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 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 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 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 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.
|
- 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
|
## Plan maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
original files remain untouched. Adapter examples and behavior are covered with
|
||||||
synthetic temporary directories in `tests/vault_import.rs`.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+1879
-9
File diff suppressed because it is too large
Load Diff
+807
@@ -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<Vec<u8>>);
|
||||||
|
|
||||||
|
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<ClientConfigPayload, DeliveryError> {
|
||||||
|
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<ClientConfigPayload, DeliveryError> {
|
||||||
|
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<PublicKey, DeliveryError> {
|
||||||
|
use crate::wireguard::SectionKind;
|
||||||
|
|
||||||
|
let interfaces = saved
|
||||||
|
.sections()
|
||||||
|
.iter()
|
||||||
|
.filter(|section| section.kind == SectionKind::Interface)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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<String, DeliveryError> {
|
||||||
|
let matching = saved
|
||||||
|
.peers()
|
||||||
|
.iter()
|
||||||
|
.filter(|peer| {
|
||||||
|
peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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<Ipv6Addr>) -> 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<String, DeliveryError> {
|
||||||
|
let items = value.split(", ").collect::<Vec<_>>();
|
||||||
|
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::<BTreeSet<_>>();
|
||||||
|
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<String, DeliveryError> {
|
||||||
|
if dns.is_empty() || dns.len() > MAX_DNS_ENTRIES {
|
||||||
|
return Err(DeliveryError::InvalidConnectionField);
|
||||||
|
}
|
||||||
|
Ok(dns
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_dns(value: &str) -> Result<String, DeliveryError> {
|
||||||
|
if value.len() > 2048 || value.chars().any(char::is_control) {
|
||||||
|
return Err(DeliveryError::InvalidConnectionField);
|
||||||
|
}
|
||||||
|
let items = value.split(',').map(str::trim).collect::<Vec<_>>();
|
||||||
|
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::<IpAddr>()
|
||||||
|
.map_err(|_| DeliveryError::InvalidConnectionField)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
render_dns(&addresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_endpoint(endpoint: &ServerEndpoint) -> Result<String, DeliveryError> {
|
||||||
|
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<String, DeliveryError> {
|
||||||
|
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::<Ipv6Addr>()
|
||||||
|
.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::<Ipv4Addr>() {
|
||||||
|
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<u16, DeliveryError> {
|
||||||
|
let port = value
|
||||||
|
.parse::<u16>()
|
||||||
|
.map_err(|_| DeliveryError::InvalidConnectionField)?;
|
||||||
|
if port == 0 || port.to_string() != value {
|
||||||
|
return Err(DeliveryError::InvalidConnectionField);
|
||||||
|
}
|
||||||
|
Ok(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_mtu(value: &str) -> Result<String, DeliveryError> {
|
||||||
|
let mtu = value
|
||||||
|
.parse::<u16>()
|
||||||
|
.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<String, DeliveryError> {
|
||||||
|
if value.len() > 5 {
|
||||||
|
return Err(DeliveryError::InvalidConnectionField);
|
||||||
|
}
|
||||||
|
let keepalive = value
|
||||||
|
.parse::<u16>()
|
||||||
|
.map_err(|_| DeliveryError::InvalidConnectionField)?;
|
||||||
|
if keepalive.to_string() != value {
|
||||||
|
return Err(DeliveryError::InvalidConnectionField);
|
||||||
|
}
|
||||||
|
Ok(keepalive.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_routes(routes: &[String]) -> Result<String, DeliveryError> {
|
||||||
|
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<Zeroizing<String>, 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<Vec<u8>>, 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<Vec<u8>>) -> Result<ClientConfigPayload, DeliveryError> {
|
||||||
|
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<IpAddr>) {
|
||||||
|
(
|
||||||
|
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::<Ipv6Addr>().expect("valid IPv6"),
|
||||||
|
Ipv6Addr::LOCALHOST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PathBuf>,
|
||||||
|
protected_directories: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutputProtection {
|
||||||
|
pub(crate) fn new(files: Vec<PathBuf>, directories: Vec<PathBuf>) -> 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<FileIdentity>,
|
||||||
|
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<Self, FileOutputError> {
|
||||||
|
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<Path>,
|
||||||
|
protection: &OutputProtection,
|
||||||
|
) -> Result<PreparedDestination, FileOutputError> {
|
||||||
|
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<std::ffi::OsStr>) -> Option<FileIdentity> {
|
||||||
|
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<File, FileOutputError> {
|
||||||
|
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<PathBuf, FileOutputError> {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QrCodeOutput {
|
||||||
|
pub fn side(&self) -> usize {
|
||||||
|
self.side
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn module(&self, x: usize, y: usize) -> Option<bool> {
|
||||||
|
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<QrCodeOutput, QrError> {
|
||||||
|
encode_bytes(payload.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_bytes(bytes: &[u8]) -> Result<QrCodeOutput, QrError> {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod delivery;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
pub mod persistence;
|
pub mod persistence;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
|||||||
@@ -515,6 +515,54 @@ impl SavedStore {
|
|||||||
self.vault_path(),
|
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<crate::delivery::files::OutputProtection, crate::delivery::files::FileOutputError>
|
||||||
|
{
|
||||||
|
let mut protected_files = self.targets().into_iter().collect::<Vec<_>>();
|
||||||
|
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)]
|
#[derive(Deserialize, Serialize)]
|
||||||
|
|||||||
+135
-7
@@ -110,16 +110,25 @@ impl Drop for RecordPayload {
|
|||||||
/// Password-authorized, short-lived client configuration data for later delivery.
|
/// Password-authorized, short-lived client configuration data for later delivery.
|
||||||
pub struct RetrievedClientRecord {
|
pub struct RetrievedClientRecord {
|
||||||
private_key: Option<WireGuardPrivateKey>,
|
private_key: Option<WireGuardPrivateKey>,
|
||||||
pub client_addresses: Vec<String>,
|
provenance: RecordProvenance,
|
||||||
pub interface_dns: Option<String>,
|
client_addresses: Vec<String>,
|
||||||
pub interface_mtu: Option<String>,
|
interface_dns: Option<String>,
|
||||||
pub server_public_key: Option<String>,
|
interface_mtu: Option<String>,
|
||||||
pub endpoint: Option<String>,
|
server_public_key: Option<String>,
|
||||||
pub allowed_ips: Vec<String>,
|
endpoint: Option<String>,
|
||||||
pub persistent_keepalive: Option<String>,
|
allowed_ips: Vec<String>,
|
||||||
|
persistent_keepalive: Option<String>,
|
||||||
preshared_key: Option<String>,
|
preshared_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
impl RetrievedClientRecord {
|
||||||
pub fn private_key(&self) -> Option<&WireGuardPrivateKey> {
|
pub fn private_key(&self) -> Option<&WireGuardPrivateKey> {
|
||||||
self.private_key.as_ref()
|
self.private_key.as_ref()
|
||||||
@@ -130,9 +139,45 @@ impl RetrievedClientRecord {
|
|||||||
self.preshared_key.as_deref()
|
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<WireGuardPrivateKey, VaultError> {
|
pub fn into_private_key(mut self) -> Result<WireGuardPrivateKey, VaultError> {
|
||||||
self.private_key.take().ok_or(VaultError::CorruptRecord)
|
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 {
|
impl fmt::Debug for RetrievedClientRecord {
|
||||||
@@ -151,6 +196,38 @@ impl Drop for RetrievedClientRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validated_record_provenance(payload: &RecordPayload) -> Result<RecordProvenance, VaultError> {
|
||||||
|
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::<ipnet::IpNet>().is_err())
|
||||||
|
|| payload
|
||||||
|
.server_public_key
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| value.parse::<PublicKey>().is_err())
|
||||||
|
{
|
||||||
|
return Err(VaultError::CorruptRecord);
|
||||||
|
}
|
||||||
|
Ok(RecordProvenance::Imported)
|
||||||
|
}
|
||||||
|
|
||||||
/// A serializable encrypted vault. New records use the public recipient; retrieval unlocks the
|
/// A serializable encrypted vault. New records use the public recipient; retrieval unlocks the
|
||||||
/// password-protected identity only for the duration of that operation.
|
/// password-protected identity only for the duration of that operation.
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@@ -258,6 +335,14 @@ impl VaultBundle {
|
|||||||
self.records.contains_key(&label.to_string())
|
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.
|
/// Remove only the record identified by its canonical WireGuard public key.
|
||||||
/// The encrypted identity and every other record remain unchanged.
|
/// The encrypted identity and every other record remain unchanged.
|
||||||
pub fn remove_record(&mut self, label: PublicKey) -> Result<bool, VaultError> {
|
pub fn remove_record(&mut self, label: PublicKey) -> Result<bool, VaultError> {
|
||||||
@@ -322,6 +407,7 @@ impl VaultBundle {
|
|||||||
if payload.reissue_blocked {
|
if payload.reissue_blocked {
|
||||||
return Err(VaultError::ReissueBlocked);
|
return Err(VaultError::ReissueBlocked);
|
||||||
}
|
}
|
||||||
|
let provenance = validated_record_provenance(&payload)?;
|
||||||
let secret = WireGuardPrivateKey::parse_base64(&payload.private_key)
|
let secret = WireGuardPrivateKey::parse_base64(&payload.private_key)
|
||||||
.map_err(|_| VaultError::RecordMismatch)?;
|
.map_err(|_| VaultError::RecordMismatch)?;
|
||||||
if secret.public_key() != label {
|
if secret.public_key() != label {
|
||||||
@@ -329,6 +415,7 @@ impl VaultBundle {
|
|||||||
}
|
}
|
||||||
Ok(RetrievedClientRecord {
|
Ok(RetrievedClientRecord {
|
||||||
private_key: Some(secret),
|
private_key: Some(secret),
|
||||||
|
provenance,
|
||||||
client_addresses: payload.client_addresses.clone(),
|
client_addresses: payload.client_addresses.clone(),
|
||||||
interface_dns: payload.interface_dns.clone(),
|
interface_dns: payload.interface_dns.clone(),
|
||||||
interface_mtu: payload.interface_mtu.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]
|
#[test]
|
||||||
fn authenticated_record_with_unsupported_version_has_specific_error() {
|
fn authenticated_record_with_unsupported_version_has_specific_error() {
|
||||||
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
|
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
|
||||||
|
|||||||
@@ -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<Path>,
|
||||||
|
) -> 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user