Implement client configuration delivery and reissue lifecycle

This commit is contained in:
2026-10-21 21:52:24 +03:00
parent 6ec593c783
commit f718f739b4
13 changed files with 4911 additions and 20 deletions
Generated
+7
View File
@@ -1374,6 +1374,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]]
name = "quote"
version = "1.0.47"
@@ -2095,6 +2101,7 @@ dependencies = [
"base64",
"getrandom 0.3.4",
"ipnet",
"qrcode",
"rustix",
"serde",
"tar",
+1
View File
@@ -18,6 +18,7 @@ age = { version = "=0.12.1", default-features = false }
base64 = "=0.22.1"
getrandom = "=0.3.4"
ipnet = "=2.11.0"
qrcode = { version = "=0.14.1", default-features = false }
rustix = { version = "=1.1.5", default-features = false, features = ["event", "fs", "net", "process", "std"] }
serde = { version = "=1.0.229", features = ["derive"] }
thiserror = "=2.0.20"
+397 -4
View File
@@ -1823,9 +1823,401 @@ without a separate request.
### Milestone 6 — Client configuration delivery and reissue lifecycle
Generate full-tunnel IPv4/IPv6 configurations using settings and preserved existing parameters where applicable. Save a root-only plaintext file and optionally render a matching QR code. Enforce post-Commit initial delivery and per-client password-based reissue with session-scoped retry after file errors.
Status: accepted (2026-10-21T21:50:26+03:00; independent integrated re-review closed findings and all scoped M6.7 gates passed).
Prerequisites: milestones 1–5 accepted; pinned native Rust environment and
compatible approved dependencies. Validation uses generated keys, synthetic
saved stores and temporary output directories without root, a terminal, Docker,
production inputs or any actual runtime/service call.
Acceptance: file/QR payloads agree; no delivery before Commit; output failure permits directory change/retry; successful initial delivery or session exit ends password-free eligibility; overwrite requires confirmation; unrelated plaintext imports cannot bypass authentication.
#### Outcome and scope
Deliver testable application/library operations to create a new client with a
session-held private key, Commit its encrypted record and server entry, save its
plaintext WireGuard configuration after Commit, and optionally produce a QR code
for the same delivery. Subsequent issuance requires a fresh password per selected
client. Output errors permit changing the destination and retrying initial
issuance during the current creation session without password entry.
Read SPEC.md New client parameters, Encrypted vault, Connection delivery,
Importing existing clients and Operations and states; ADRs 0001–0003;
VAULT_FORMAT.md, CONFIG_FORMAT.md, PERSISTENCE.md, RUNTIME.md, DEVELOPMENT.md,
DEPENDENCIES.md, TESTING.md and the current Application/VaultBundle code.
Out of scope: TUI/password entry widgets, rendering live terminal screens,
plaintext source-file fallback, automatic key rotation/reconciliation, adding
connection settings, runtime mutation, automatic restart, actual phone/client
import, Alpine harness and VM tests. Keep startup safely incomplete. Generation
and saving an output file do not apply configuration to WireGuard.
#### Existing code and integration constraints
- `Application::add_client` currently accepts a prepared public `Client` and edits
server/domain drafts. It does not generate or own a private key or authorize
first delivery. Add a coordinated creation operation rather than treating any
existing/imported public key as eligible for password-free output.
- `WireGuardPrivateKey::generate`, `public_key`, and explicit Base64 exposure
exist. `VaultBundle::encrypt_record` encrypts a new key using the recipient,
while `retrieve_client_record` returns short-lived retained connection fields
only after password-based decryption and authenticated conflict checks.
- New-key records currently have no retained client parameters. Build their
configuration from authoritative saved peer addresses and current saved
application settings, not empty record defaults. Imported records can contain
DNS/MTU/endpoint/routes/keepalive/PSK; preserve supported existing parameters
rather than silently converting every imported client to a new default template.
- `RetrievedClientRecord` exposes its key and optional preshared key explicitly
and redacts Debug; do not turn it into a globally cached unlocked vault or add a
password-free retrieval API to work around first-delivery state.
- `Application::commit` saves staged document/vault/state only after backup and
journaled installation succeeds. `delete_client`, `discard_draft`, restore,
saved settings and restart choices can affect delivery eligibility. Integrate
with these transitions without regressing M4/M5 or bypassing recovery-required
handling. `Application::document()` is a draft; output must use committed bytes.
- M5 observes public technical runtime fields and does not supply a trusted server
private key for generation. Derive the server public key from the authoritative
saved `[Interface] PrivateKey` through existing X25519 code, in a narrow secret
boundary, or use an already validated authoritative derivation if available.
Missing/ambiguous/invalid server identity blocks output rather than guessing or
reading another file/interface. Never print or serialize the server private key.
- Cargo already contains the key/crypto, file-safety and time dependencies.
Approved `qrcode` may be added with minimal features (no image/SVG output needed).
No decoder, image, CLI, TUI, random, hash or new helper crate is implicitly
approved. Check official APIs/version compatibility before adding the crate.
#### Context and implementation steps
Run bounded tasks sequentially, one writer at a time. GPT-6-Sol/medium coordinates;
GPT-6-Luna/high implements, independently reviews and repairs. Dispatch file
ownership, prerequisite interfaces, acceptance IDs and actual required checks.
Only the coordinator updates milestone acceptance and evidence in PLANS.md.
| Task | Owner | Files and deliverable |
| --- | --- | --- |
| M6.1 | implementer | `src/delivery.rs` and child modules as needed, library exports, `docs/DELIVERY.md`: secret-safe payload model and deterministic configuration generation |
| M6.2 | implementer | `src/app.rs`, narrow delivery/domain integration: coordinated new-client creation and session-scoped first-issue state |
| M6.3 | implementer | delivery/files module, existing safe filesystem helpers: file preparation, explicit overwrite confirmation, secure output and retry results |
| M6.4 | implementer | delivery/QR module, Cargo files, app integration: optional matrix/text output bound to the same authorized payload/file action |
| M6.5 | implementer | `tests/client_delivery.rs`, focused unit tests and README/DELIVERY guidance: full first-issue/reissue and failure lifecycle |
| M6.6 | reviewer, repairer | Independent integrated review, bounded findings repair and re-review |
| M6.7 | coordinator | Integrated validation, evidence ledger and scoped acceptance/handoff in PLANS.md |
M6.1 renders a deterministic standard WireGuard client file: `[Interface]` with
client private key, saved addresses and DNS; `[Peer]` with server public key,
endpoint and routes. New clients have IPv4/IPv6 and routes `0.0.0.0/0, ::/0`,
using the configured template and authoritative saved host addresses. Define the
client Address prefix policy explicitly, keeping host identity separate from
server-side AllowedIPs; do not infer client addresses from the public endpoint.
No new MTU, keepalive or PSK defaults are introduced.
For imported reissue, retain validated existing optional fields and IPv4-only
shape where applicable. Never execute or copy hooks/unknown arbitrary directives
from source configs. Validate decrypted fields again at rendering boundaries so
newlines/control text cannot inject sections/directives. Compare retained identity
and addresses/server binding with fresh saved authoritative values; a stale or
mismatched record fails closed instead of silently regenerating a different
connection. Respect authenticated reissue_blocked, missing server/file-only cases,
and M2 unsupported projection without truncating routes to get a valid output.
Document precedence between saved server state, application defaults for new
clients, and retained imported fields; do not invent a conflict-resolution UI.
Keep plaintext payloads in explicitly exposed, short-lived zeroizing owners,
redacted Debug/errors and no generic Serialize/Display. Raw source/client output
is never an ordinary status object. Bound payload size. Passwords, encoded private
keys and rendered files must not appear in logs, panic output, snapshots or test
failure diagnostics. Document unavoidable encoding/QR-library copies honestly.
M6.2 coordinates address selection, key generation, server/client draft insertion,
vault encryption and temporary first-issue tracking as one preparation. Use M1
allocation/reservations and optional manual selection; reuse M2 edits and M3 key
matching. Failure at any preparation step leaves prior draft and eligibility
unchanged. Existing vault setup may require its initial password separately;
creating additional clients in an existing vault does not request that password.
Define an internal lifecycle such as pending-draft, committed-awaiting-first-file,
and issued/invalidated, keyed by PublicKey and actual creation/committed record
context rather than alias, UI row, or historical public-key presence. Store no
first-issue private key, password, or eligibility in settings, internal state,
backup, vault plaintext fields or recovery journal. Session state is not recreated
by reopen/restore/import. A failed/incomplete Commit cannot promote eligibility.
Unrelated valid commits/renames must not accidentally authorize another key or
consume an unused first issue; compare the relevant saved client/record context.
Discard/delete/restore/reopen/session end and any relevant key/record replacement
invalidate stale pending material. Define transition timing so cancellation and
failed backup/pre-install validation preserve unchanged work, while a successful
replacement or unresolved partial persistence cannot retain unsafe output authority.
A confirmed restart applying an unchanged saved client must not require a password
merely because runtime state changes. Do not conflate access activation with the
first-output permission. Successful saved deletion revokes output authorization
immediately, even if the old runtime peer remains active until restart.
M6.3 prepares destination selection without writing, then requires explicit
confirmation if a file exists. Never derive a path directly from an alias that
may repeat or contain separators; use a safe suggested filename and explicit
selected destination. Reject symlink/special-file destinations and unsafe ancestor
traversal; do not overwrite internal application files, server config, vault,
settings, journal or backups as a client output. Output goes only to the chosen
location; no directory-wide chmod/deletion or source-file cleanup.
Use secure temporary/staged writes, mode 0600 from creation, complete flush/sync,
atomic publication and parent sync as appropriate. New-file publication must not
silently replace a file; overwrite requires a confirmed bounded action for that
destination. Document behavior if failure follows publication (for example parent
sync failure): the file may already exist and must be reported honestly, with
explicit confirmation on retry, not claimed absent or automatically removed.
Output failure before confirmed success preserves retry eligibility for the same
initial client in the current session, allowing a different directory. A normal
plaintext file must be saved for every delivery action; QR-only success is not
allowed. Success consumes initial eligibility and drops the cached private key.
Later file/QR issuance for that client requires a fresh password. Password reissue
is per action/client, not session-wide vault unlocking; wrong password, conflict
or missing key has no source-file bypass. Retries may keep only bounded
operation-local material, never a reusable unlocked identity across unrelated actions.
M6.4 generates an optional QR from the exact bytes saved in that delivery action,
using qrcode's matrix/public rendering API without PNG/image features or external
qrencode. Support a future terminal renderer through a matrix/controlled text
representation with quiet zone and documented dimensions. Keep secret-bearing
matrix/text out of Debug, snapshots and ordinary progress messages. Do not expose
an unguarded second QR path that reconstructs private material from cached state.
One authorized action may include both file and QR; showing that same prepared
QR does not require a second password. If QR encoding exceeds capacity or fails,
the file can still be successfully delivered: report file success and QR failure
separately, consume first-issue eligibility, and do not report a total failure
that misleadingly permits another first file. A later new QR/delivery action then
requires password authorization. A future TUI displays/clears the operation-local
QR; terminal lifecycle implementation remains M7.
M6.5 covers complete library flows with private temporary stores and generated
synthetic keys. Use fault-injected file writes and unit-tested authorization
transitions, not privileged filesystem behavior. Assert file content through
redacted comparisons/parsed public values and explicit key correspondence without
printing secrets. Reuse M3 parsing for compatible generated files to demonstrate
identity/server/routes semantics. File/QR equality should be guaranteed by a
single payload source and tested at the encoding boundary; do not claim external
QR decoding/real-device compatibility unless actually checked or add a decoder
crate without approval.
#### Acceptance and validation
- [x] M6-A1: new clients yield valid full-tunnel dual-stack configurations using
committed addresses/settings and a correctly derived server key; missing or
ambiguous authoritative identity fails without guessing or exposing secrets.
- [x] M6-A2: imported reissue preserves supported existing optional parameters
and IPv4-only shape; stale identity/address/server bindings, authenticated
conflict flags and unsupported data block unsafe delivery without truncation.
- [x] M6-A3: coordinated creation stages a matching public client and encrypted
key; failed preparation leaves prior drafts intact. No output is authorized
before successful Commit or from an imported/existing key without a password.
- [x] M6-A4: failed/incomplete Commit does not promote first issue; a successful
Commit permits first output for the newly created client in that session.
Draft discard/delete/restore/reopen and record replacement cannot restore or
transfer stale eligibility; duplicate aliases remain independent by public key.
- [x] M6-A5: failed file save supports same-session retry/new directory without
password for initial issuance; success consumes eligibility. Reissue requires
fresh per-client authorization with no cached identity/plaintext-file fallback.
- [x] M6-A6: mandatory plaintext file creation uses 0600 from first write, safe
paths, bounded payloads, explicit overwrite confirmation and honest partial-
publication failure reporting; protected application/source files stay untouched.
- [x] M6-A7: optional QR uses the exact same payload as the saved file; QR failure
does not erase file success or grant another first issue. No independent QR-only
bypass or retained cross-action secret cache exists.
- [x] M6-A8: outputs warn when saved changes are not verified applied. Unavailable
runtime is not silently treated as active; delivery never invokes service or
WireGuard operations, releases reservations or changes apply history.
- [x] M6-A9: rendering/input injection, wrong password, missing vault/record,
duplicate/conflicting metadata, occupied/manual address failures, overwrite
decline, symlink/special paths, and write/flush/publication/sync failures are tested.
- [x] M6-A10: secret-bearing types, errors/Debug and tests do not disclose keys,
passwords, configuration payloads or QR matrices; ephemeral material is never
persisted in backup/journal/state and supported owned buffers clear on drop.
- [x] M6-A11: M1–M5 regressions and integrated gates pass unprivileged with no real
network, system bus, runtime, production paths or terminal required. Independent
review closes actionable auth-lifecycle/rendering/output-safety findings.
Planned commands from the repository root after M6 targets and lockfile exist:
```sh
cargo test --locked delivery
cargo test --locked --test client_delivery
cargo test --locked --test runtime_restart
cargo test --locked --test commit_restore
cargo test --locked --test vault_import
cargo test --locked --test config_preservation
cargo fmt --all -- --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
cargo build --locked --release
./target/release/wireguard-admin --help
./target/release/wireguard-admin --version
git diff --check
```
Ensure focused filters run actual tests. Resolve approved qrcode and update
Cargo.lock before locked checks; do not add Ratatui/Crossterm prematurely. Record
commands, counts and exit results. All commands should exit zero; negative cases
are passing assertions. No phone QR scan, real tunnel, terminal screenshot, Alpine
or systemd result is required or claimed for this library/file-output milestone.
#### Progress
- [x] Description prepared against accepted M1–M5 code and current documents.
- [x] 2026-10-20T23:12:02+03:00 execution preparation: verified active coordinator `gpt-6-sol`/`medium` in this session's latest turn context and project worker roles `gpt-6-luna`/`high`; confirmed M1–M5 accepted, read required specification, ADRs and supporting documents, and inspected the clean source baseline. The pre-existing expanded M6 plan edit in this worktree is preserved.
- [x] M6.1 — implementer delivered bounded, zeroizing payload rendering in `src/delivery.rs`, library export and `docs/DELIVERY.md`, with new-client and authenticated-import paths. Independent R1 found an unchecked caller-supplied server identity; repairer moved derivation to the saved `[Interface] PrivateKey` boundary and added malformed/missing/ambiguous cases. Independent re-review closed M6.1-R1. Final focused `cargo test --locked delivery::tests` passed 7 tests; fmt, Clippy and diff checks passed. Fixture evidence only; no file/QR or host behavior claimed.
- [x] M6.2 — implementer added transactional dual-stack creation and encrypted vault staging, plus session-only first-file state in `src/app.rs` and a narrow encrypted-record context accessor in `src/vault.rs`. Independent R1 identified duplicate live authorizations; repairer made issuance single-flight with explicit retry states, and re-review closed it. The same reviewer identified stale standalone plaintext after app mutation; second repair moved output behind an app-owned `deliver_first_issue` closure holding the mutable session borrow, and re-review closed it. Final `cargo test --locked app::tests::` passed 41 synthetic tests; fmt, Clippy and diff checks passed. M6.3 still must connect actual file results to these states.
- [x] M6.3 — implementer added secure file-output adapter in `src/delivery/files.rs`, app first-issue bridge, narrow SavedStore protected-path accessor and documentation. Initial focused `cargo test --locked delivery` passed 16 and `cargo test --locked app` passed 47; fmt, Clippy and diff checks passed. Fresh independent review found two actionable findings (M6.3-R1/R2 below). Three targeted repair rounds and independent re-review closed both under the documented single-administrator threat model; final focused `cargo test --locked delivery` passed 20 and file-module tests passed 12; fmt, Clippy and diff checks passed. No production filesystem behavior is claimed.
- [x] M6.4 — implementer added approved `qrcode` 0.14.1 with default features disabled, bounded/zeroizing operation-local matrix representation, file-first optional QR integration and focused tests. Initial `cargo test --locked delivery` passed 22 and `cargo test --locked app` passed 51; fmt, Clippy and diff checks passed. Independent R1 found a public QR-only encoder path; repairer made it crate-private with a compile-fail doctest, and independent re-review closed the finding. Final focused delivery (22) and doctests (2) passed. No phone scan or terminal rendering is claimed.
- [x] M6.5 — implementer added fresh password-per-action reissue from installed vault bytes, validated authenticated key-only/imported provenance, a live-generation activation warning, five public synthetic lifecycle tests in `tests/client_delivery.rs`, and README/DELIVERY guidance. Initial focused integration (5), delivery (22), app (51) and partial-provenance regression (1) passed with fmt, Clippy and diff checks. Fresh independent review found first-issue outputs omitted the activation warning. Repairer added it to file-only, file+QR and definite-publication/bookkeeping-uncertain results; re-review found uncertain file publication still omitted it, so a second repair added warning to failed outcomes and injected file-only/QR uncertainty tests. Independent re-review closed the finding. At M6.5 closure, `cargo test --locked app::` passed 50 and `--test client_delivery` passed 5; fmt, Clippy and diff checks passed. Integrated gates ran later in M6.7.
- [x] M6.6 — a fresh independent integrated reviewer inspected M6-A1–A11 and found three actionable issues: secret-bearing assertion diagnostics, optional imported server-key binding in the public renderer, and unreported temporary-file cleanup failure. Repairer fixed all three; independent re-review closed them with no new actionable issue. The reviewer found M6-A1–A10 supported by scoped synthetic evidence and left M6-A11 pending coordinator integrated gates.
- [x] M6.7 — coordinator ran every planned command against the integrated revision, verified all exit zero and nonempty focused test filters, checked changed files and reviewer evidence, and accepted M6-A1–A11 for the explicitly scoped fixture/file-output milestone. No real WireGuard, systemd, terminal or phone scan is claimed.
#### Review and repair
M6.1-R1 (P2, `src/delivery.rs`): the initial renderers accepted a caller-supplied
server public key without proving it came from the saved server configuration;
the initial valid-looking test even used a different key. Repairer made both
renderers derive it from exactly one valid saved `[Interface] PrivateKey`, reject
missing/duplicate/malformed sources with a redacted error, and compare imported
retained identity to that key. Synthetic regression tests passed (7 focused);
the independent reviewer re-reviewed and closed the finding.
M6.2-R1 (P2, `src/app.rs`): two outstanding first-issue capabilities could
both expose plaintext before either was consumed. Repairer made authorization
single-flight/token-bound and added explicit pre-publication versus uncertain-
publication retry handling. The independent reviewer closed this part after
focused tests.
M6.2-R2 (P2, `src/app.rs`): invalidating a standalone token after deletion or
replacement did not revoke a caller-held plaintext payload. Repairer removed
that accessor and introduced an app-owned publication closure that validates
saved peer/record context immediately before the file writer runs while holding
`&mut Application`; stale deletion/replacement tests show the closure is not
invoked. Independent re-review closed the finding. The real file adapter is
still M6.3 work, not evidence from these synthetic closures.
M6.3-R1 (P2, `src/delivery/files.rs`): a confirmed overwrite rechecked the
destination inode before `renameat`, but another entry could replace it between
the check and publication and then be overwritten without confirmation. The
independent reviewer supplied a deterministic `Checkpoint::Publish` substitution
reproducer. Repairer changed confirmed replacement to atomic `RENAME_EXCHANGE`,
checks the displaced inode and rolls back without deleting unexpected entries;
rollback identity and parent sync failures report uncertainty. A later re-review
found the normal-success temp-name unlink still vulnerable to a different local
writer. Final repair requires a selected parent owned by root/current effective
user without group/world write, checks existing ancestors, and uses an exclusive
0600 temporary name with 128 bits of OS randomness. Independent re-review closed
R1 under the single-administrator threat model. Same-privilege concurrent name
changes remain explicitly outside scope; temporary 0600 output retained after a
rollback is documented for manual cleanup rather than risking deletion of an
unconfirmed entry.
M6.3-R2 (P2, `src/app.rs`): after a definite file publication, a fallible
post-publication saved-state read/mark could return an undifferentiated app error,
concealing that a plaintext file was already created. The independent reviewer
requested an explicit published-but-bookkeeping-uncertain result and fault test.
Repairer added `PublishedButBookkeepingUncertain` with definite file-success
evidence and invalidated the session-held first-issue key on bookkeeping failure.
The independent reviewer closed R2 after a synthetic post-publication saved-store
failure test. No password-free retry is authorized by that result.
M6.4-R1 (P2, `src/delivery/qr.rs`): a public QR encoder allowed a library caller
to render a payload and generate a QR without the mandatory file action.
Repairer restricted the encoder and module to crate visibility, kept public
output/error value types for the guarded app result, and added a compile-fail
doctest. Independent re-review closed the finding after file-first and
QR-failure tests. QR scanning and terminal rendering remain unverified/future.
M6.5-R1 (P2, `src/app.rs`): initial file-only and file-plus-QR delivery did
not report whether the saved client had been verified active. Repairer bound an
activation warning to the exact installed generation and the live verified
saved/applied snapshot, never the historical timestamp. Re-review found the
possibly-published parent-sync failure still lost the warning; second repair
carried it in the failed outcome and tested uncertain publication for both
variants with QR skipped. Independent re-review closed R1. All evidence is
synthetic/fault-injected, not live VPN or device compatibility.
M6.6-R1 (P2, `src/app.rs` tests): `assert_eq!` on saved and rendered client
configuration bytes could print a private key if a test failed. Repairer changed
those to boolean assertions and audited related tests. Independent integrated
re-review closed this secret-diagnostic finding.
M6.6-R2 (P2, `src/delivery.rs`/`src/vault.rs`): the public imported renderer
accepted a missing saved-server binding from a mutable retrieved record.
Repairer made retained fields private/read-only, required imported provenance and
an exact present saved-server binding, and added missing/stale binding tests.
Independent integrated re-review closed it.
M6.6-R3 (P2, `src/delivery/files.rs`/`src/app.rs`): ignored temp-file cleanup
errors could leave a plaintext 0600 staging file while reporting an ordinary
pre-publication failure. Repairer added a distinct `ManualCleanupRequired`
result, retry gating until explicit cleanup acknowledgement, documentation and
an injected cleanup-failure test confirming no selected-file publication.
Independent integrated re-review closed it. The user may still need to inspect
and remove a retained staging file; the application does not silently erase an
uncertain entry.
All actionable M6 task-local and integrated review findings are closed after
repair and independent re-review. The integrated reviewer found M6-A1–A10
supported by synthetic/fault-injected evidence; M6-A11 then passed the
coordinator's final integrated gates. No password check was weakened and no
preservation test was disabled.
#### Evidence and handoff
Initial planning inspected current Application creation/Commit/discard/restart interfaces,
VaultBundle key-only encryption and password-authorized retained fields, current
Cargo dependencies, M5 acceptance record and runtime documentation, plus product
specification/secret-storage ADR. M5 records scoped fixture acceptance and passing
integrated gates with independent re-review; real kernel/systemd checks remain
M8/M9. That planning-only turn did not rerun application tests, add crates/source, generate
private keys/output/QR, dispatch agents or contact host runtime/services.
Final M6.7 validation on the integrated revision (all exit 0):
`cargo test --locked delivery` (23 matching tests),
`cargo test --locked --test client_delivery` (5),
`cargo test --locked --test runtime_restart` (2),
`cargo test --locked --test commit_restore` (9),
`cargo test --locked --test vault_import` (5),
`cargo test --locked --test config_preservation` (9),
`cargo fmt --all -- --check`,
`cargo clippy --locked --all-targets -- -D warnings`,
`cargo test --locked` (205 library unit, 30 integration and two doctests passed;
binary tests 0), `cargo build --locked --release`, release `--help` and
`--version`, and `git diff --check`. Release `--help` correctly states the
management interface is not implemented. Checks used synthetic keys,
temporary stores/output directories and fake adapters without root, Docker,
host WireGuard/system bus, production paths or a terminal. The approved
`qrcode` 0.14.1 was added with default features disabled; no other direct
dependency was introduced. No phone QR scan, live tunnel, Alpine test or
disposable systemd VM result is claimed. Those validations remain future
M7–M9 work, not passing M6 evidence.
Next action: stop after accepted M6. M7 may begin only on a separate request;
do not create a Git commit or deploy.
#### Decisions and discoveries
New vault records contain a key but no template parameters, while imported
records retain supported connection fields. Delivery needs an explicit provenance
and precedence rule rather than assuming both are interchangeable. Creation must
retain ephemeral private material separately from password-only vault retrieval;
otherwise first delivery without a password is impossible. That ephemeral
material cannot be persisted or reconstructed after session loss.
M5 applies saved state and M6 exports saved state; neither should call the other
implicitly. File and QR are two representations of one authorized delivery action,
with file success as the first-issue completion boundary. M7 supplies the terminal
interaction around these library contracts.
#### Outcome
M6.1–M6.7 are complete. Accepted for the milestone's explicitly scoped
synthetic/file-output checks after independent integrated review, repair,
re-review and all planned coordinator gates. The TUI, phone compatibility,
real WireGuard and actual systemd/D-Bus behavior are not claimed; M7–M9 own
their later acceptance. No Git commit, deployment or production access occurred.
Stop after M6; do not start M7 without a separate request.
### Milestone 7 — Complete terminal management workflow
@@ -1847,14 +2239,15 @@ Acceptance: real D-Bus job handling, separate restart verification, address rele
## Current progress and resume point
- Milestones 1–5 are accepted; milestones 6–9 remain pending.
- Milestones 1–6 are accepted; milestones 7–9 remain pending.
- The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md).
- Milestone 1 is expanded and the Rust environment is pinned.
- Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests.
- Milestone 3 delivered secret-safe key handling, a versioned encrypted vault, caller-buffer reconciliation, and a bounded read-only source-directory import adapter.
- Milestone 4 delivered versioned private backups, journaled saved-file Commit and recovery, previewed full restore, durable address reservations and historical verification bookkeeping, with independent R5 closure and coordinator gates.
- Milestone 5 delivered public runtime observations and technical comparison, an approved-rustix bounded read-only netlink adapter, a zbus systemd job adapter, restart orchestration, and synthetic integration tests. Independent integrated review and repairs closed all actionable scoped findings; all planned gates passed. Real kernel/systemd validation remains deferred to M8/M9.
- M1–M5 integrated gates and independent reviews passed. No Alpine or VM results are claimed.
- Milestone 6 delivered session-bound creation/first-file authorization, saved-state rendering and password-per-action reissue, secure plaintext file output, optional same-payload QR, and synthetic lifecycle tests. Independent integrated review and repair closed all actionable findings; all planned gates passed. TUI and real-system validation remain M7–M9.
- M1–M6 integrated gates and independent reviews passed. No Alpine or VM results are claimed.
## Plan maintenance
+25
View File
@@ -116,3 +116,28 @@ examined. Enumeration, read, or limit failures mark the scan incomplete. The
original files remain untouched. Adapter examples and behavior are covered with
synthetic temporary directories in `tests/vault_import.rs`.
## Client delivery and reissue
`Application::prepare_new_client` stages a dual-stack peer and encrypted key.
After a successful `commit`, `deliver_first_issue_file` can publish its first
configuration without a vault password during that same session; a failed file
publication can be retried to a newly selected destination. The first file is
mandatory. `deliver_first_issue_file_and_qr` optionally returns a QR matrix from
the same authorized bytes after file publication. Both success results carry an
activation warning unless this session has verified the exact installed saved
generation and its matching applied peer snapshot; historical verification and
unknown runtime state do not suppress it.
Later delivery uses `Application::reissue_client_file`, which takes the client
public key, a password for that action, a prepared destination, and an optional
QR choice. It freshly unlocks the installed saved vault each time, reads saved
server/settings bytes rather than draft state, preserves validated imported
parameters, and rejects blocked or stale records. It never reads original
plaintext client files as a fallback. The result reports a secret-free failure
stage and warns when the current saved peer set has not been verified as applied
in this live session. Historical verification timestamps do not suppress that
warning after reopen. File output uses mode `0600`, explicit overwrite
confirmation, and separate QR failure reporting. These APIs have synthetic
fixture coverage; the TUI password prompt and terminal QR rendering are not
implemented.
+200
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+807
View File
@@ -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
);
}
}
+880
View File
@@ -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(&current) {
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(&current) {
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(&current).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
);
}
}
+120
View File
@@ -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
View File
@@ -1,6 +1,7 @@
pub mod app;
pub mod backup;
pub mod config;
pub mod delivery;
pub mod domain;
pub mod persistence;
pub mod runtime;
+48
View File
@@ -515,6 +515,54 @@ impl SavedStore {
self.vault_path(),
]
}
/// Paths which client plaintext delivery must not replace. This is a
/// read-only view of the resolved installation mapping; source candidates
/// are protected individually so a safe, newly selected sibling output
/// remains available.
pub(crate) fn delivery_output_protection(
&self,
) -> Result<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)]
+135 -7
View File
@@ -110,16 +110,25 @@ impl Drop for RecordPayload {
/// Password-authorized, short-lived client configuration data for later delivery.
pub struct RetrievedClientRecord {
private_key: Option<WireGuardPrivateKey>,
pub client_addresses: Vec<String>,
pub interface_dns: Option<String>,
pub interface_mtu: Option<String>,
pub server_public_key: Option<String>,
pub endpoint: Option<String>,
pub allowed_ips: Vec<String>,
pub persistent_keepalive: Option<String>,
provenance: RecordProvenance,
client_addresses: Vec<String>,
interface_dns: Option<String>,
interface_mtu: Option<String>,
server_public_key: Option<String>,
endpoint: Option<String>,
allowed_ips: Vec<String>,
persistent_keepalive: 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 {
pub fn private_key(&self) -> Option<&WireGuardPrivateKey> {
self.private_key.as_ref()
@@ -130,9 +139,45 @@ impl RetrievedClientRecord {
self.preshared_key.as_deref()
}
pub fn client_addresses(&self) -> &[String] {
&self.client_addresses
}
pub fn interface_dns(&self) -> Option<&str> {
self.interface_dns.as_deref()
}
pub fn interface_mtu(&self) -> Option<&str> {
self.interface_mtu.as_deref()
}
pub fn server_public_key(&self) -> Option<&str> {
self.server_public_key.as_deref()
}
pub fn endpoint(&self) -> Option<&str> {
self.endpoint.as_deref()
}
pub fn allowed_ips(&self) -> &[String] {
&self.allowed_ips
}
pub fn persistent_keepalive(&self) -> Option<&str> {
self.persistent_keepalive.as_deref()
}
pub fn into_private_key(mut self) -> Result<WireGuardPrivateKey, VaultError> {
self.private_key.take().ok_or(VaultError::CorruptRecord)
}
pub(crate) fn is_new_template(&self) -> bool {
self.provenance == RecordProvenance::NewTemplate
}
pub(crate) fn is_imported(&self) -> bool {
self.provenance == RecordProvenance::Imported
}
}
impl fmt::Debug for RetrievedClientRecord {
@@ -151,6 +196,38 @@ impl Drop for RetrievedClientRecord {
}
}
fn validated_record_provenance(payload: &RecordPayload) -> Result<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
/// password-protected identity only for the duration of that operation.
#[derive(Serialize, Deserialize)]
@@ -258,6 +335,14 @@ impl VaultBundle {
self.records.contains_key(&label.to_string())
}
/// Return the encrypted record bytes for session-local authorization binding.
/// The ciphertext is never exposed through the public vault API.
pub(crate) fn record_context(&self, label: PublicKey) -> Option<&str> {
self.records
.get(&label.to_string())
.map(|record| record.ciphertext.as_str())
}
/// Remove only the record identified by its canonical WireGuard public key.
/// The encrypted identity and every other record remain unchanged.
pub fn remove_record(&mut self, label: PublicKey) -> Result<bool, VaultError> {
@@ -322,6 +407,7 @@ impl VaultBundle {
if payload.reissue_blocked {
return Err(VaultError::ReissueBlocked);
}
let provenance = validated_record_provenance(&payload)?;
let secret = WireGuardPrivateKey::parse_base64(&payload.private_key)
.map_err(|_| VaultError::RecordMismatch)?;
if secret.public_key() != label {
@@ -329,6 +415,7 @@ impl VaultBundle {
}
Ok(RetrievedClientRecord {
private_key: Some(secret),
provenance,
client_addresses: payload.client_addresses.clone(),
interface_dns: payload.interface_dns.clone(),
interface_mtu: payload.interface_mtu.clone(),
@@ -679,6 +766,47 @@ mod tests {
);
}
#[test]
fn authenticated_retained_shape_never_falls_back_from_partial_import_to_new_template() {
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let identity = key.public_key();
bundle
.encrypt_payload(
identity,
RecordPayload {
version: 1,
label: identity.to_string(),
private_key: key.to_base64(),
client_addresses: vec!["10.0.0.2/32".to_owned()],
interface_dns: None,
interface_mtu: None,
server_public_key: None,
endpoint: None,
allowed_ips: Vec::new(),
persistent_keepalive: None,
preshared_key: None,
reissue_blocked: false,
},
true,
)
.expect("malformed legacy record can be encrypted for regression test");
assert_eq!(
bundle.retrieve_client_record(identity, PASSWORD).err(),
Some(VaultError::CorruptRecord)
);
let clean_key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let mut clean_bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
clean_bundle
.encrypt_record(clean_key.public_key(), &clean_key)
.expect("key-only new-client record encrypts");
let retrieved = clean_bundle
.retrieve_client_record(clean_key.public_key(), PASSWORD)
.expect("fresh unlock validates key-only provenance");
assert!(retrieved.is_new_template());
}
#[test]
fn authenticated_record_with_unsupported_version_has_specific_error() {
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
+411
View File
@@ -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);
}