Handle client keys, encrypted vault

This commit is contained in:
2026-10-19 23:11:29 +03:00
parent 766059e6d1
commit c879a8101b
11 changed files with 4267 additions and 21 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
These instructions apply to the entire repository. Keep documentation, code comments, and identifiers in English; use the user's language in conversation.
The project contains a Rust package with startup, settings validation, a pure client domain model, and an in-memory, byte-preserving WireGuard configuration editor. The management interface, persistence, and VPN operations are not implemented yet. Do not report planned modules, tests, or commands as already working.
The project contains a Rust package with startup, settings validation, a pure client domain model, an in-memory, byte-preserving WireGuard configuration editor, and library APIs for an encrypted vault and read-only client import. The management interface, installed persistence, and VPN operations are not implemented yet. Do not report planned modules, tests, or commands as already working.
- Read [SPEC.md](docs/SPEC.md) and [CONTEXT.md](CONTEXT.md) before implementing behavior. The approved specification is the product contract; do not change it to make tests pass.
- For milestone execution, read and maintain [PLANS.md](PLANS.md), following [IMPLEMENTATION.md](docs/IMPLEMENTATION.md). Execute numbered milestones sequentially through implementation, independent review, repair, and acceptance with evidence.
Generated
+1409 -5
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -14,9 +14,16 @@ name = "wireguard-admin"
path = "src/main.rs"
[dependencies]
age = { version = "=0.12.1", default-features = false }
base64 = "=0.22.1"
getrandom = "=0.3.4"
ipnet = "=2.11.0"
rustix = { version = "=1.1.5", default-features = false, features = ["process"] }
rustix = { version = "=1.1.5", default-features = false, features = ["fs", "process", "std"] }
serde = { version = "=1.0.229", features = ["derive"] }
thiserror = "=2.0.20"
toml = "=1.1.8"
x25519-dalek = { version = "=2.0.1", default-features = false, features = ["static_secrets", "zeroize"] }
zeroize = "=1.9.0"
[dev-dependencies]
tempfile = "=3.23.0"
+410 -9
View File
@@ -266,11 +266,11 @@ No Alpine build, container tunnel, or VM/systemd check is claimed by M1 acceptan
#### Review and repair
| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State |
| --- | --- | --- | --- | --- | --- |
| M1-R1-01 | R1/R2 | `src/domain.rs` constructor can claim verified without a matching applied snapshot; misleading state | repairer | constructor rejects inconsistent Verified; Commit/mismatch demote stale Verified; 13 focused, 22 total tests passed; R2 confirmed | closed |
| M1-R1-02 | R1/R2 | `AGENTS.md` still claims no Rust application exists | coordinator | opening statement updated to limited scaffold; R2 confirmed | closed |
| M1-R1-03 | R1/R2 | `Cargo.lock` existed outside Git index | coordinator | `git add -N Cargo.lock`; `git ls-files --error-unmatch Cargo.lock` succeeds; R2 confirmed; no commit/content staging | closed |
| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State |
| -------- | --------- | ---------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | ------ |
| M1-R1-01 | R1/R2 | `src/domain.rs` constructor can claim verified without a matching applied snapshot; misleading state | repairer | constructor rejects inconsistent Verified; Commit/mismatch demote stale Verified; 13 focused, 22 total tests passed; R2 confirmed | closed |
| M1-R1-02 | R1/R2 | `AGENTS.md` still claims no Rust application exists | coordinator | opening statement updated to limited scaffold; R2 confirmed | closed |
| M1-R1-03 | R1/R2 | `Cargo.lock` existed outside Git index | coordinator | `git add -N Cargo.lock`; `git ls-files --error-unmatch Cargo.lock` succeeds; R2 confirmed; no commit/content staging | closed |
#### Evidence and handoff
@@ -619,9 +619,409 @@ remain. Stop here; milestone 3 is the next action only on a new request.
### Milestone 3 — Client keys, encrypted vault, and existing-client import
Implement separate vault/client key pairs, recipient encryption, password-protected vault identity, and versioned secret storage. Import from the configured directory on initial setup without deleting originals. Verify key correspondence and classify anomalies; conflicts block reissue until resolved.
Status: accepted (2026-10-19T20:08:32Z; independent R7 review and coordinator gates passed).
Prerequisites: milestones 1 and 2 accepted; pinned native Rust environment and
access to compatible approved cryptographic dependencies or a populated cache.
No root, running WireGuard, systemd, Docker, VM, or production input is needed.
Acceptance: new secrets encrypt without password entry, correct/incorrect password and corruption paths are covered, original files are unchanged, duplicates/conflicts are represented correctly, and persisted application data contains no plaintext client private keys. WireGuard key handling uses approved libraries, not utility subprocesses.
#### Outcome and scope
Deliver library operations for WireGuard client key generation, encrypted vault
initialization, password-free encryption of new records, password-authorized
secret retrieval, and safe reconciliation/import of existing client files.
Reuse M1 identities/settings and M2 server inspection without rewriting originals.
Read SPEC.md Data sources, Encrypted vault, Importing existing clients, Connection
delivery, and Backups and restoration; ADRs 0002 and 0003; CONFIG_FORMAT.md;
DEPENDENCIES.md, DEVELOPMENT.md, and TESTING.md. Preserve the distinction between
vault encryption, server secrets, and an unencrypted output file.
This milestone prepares versioned vault bytes and encrypted import results;
filesystem persistence/Commit and its mandatory backups belong to milestone 4.
Initial setup prepares encryption of valid existing files immediately in the
same setup operation, but must not install a vault, rewrite the server, or bypass
the later persistence gate. Use supplied buffers and an explicit read-only source
adapter; no automatic scan at application startup. TUI/password prompts, delivery
files/QR and first-delivery session orchestration belong to milestones 6 and 7.
No runtime or service operations are introduced.
#### Existing code and integration constraints
- M1 provides `domain::PublicKey`, `Client`, `ClientAddresses`, `Snapshot`,
`ClientStates`, and validated/resolved settings, including the source directory
and data paths. Public keys, not filenames or repeated aliases, identify secrets.
- M2 exposes `wireguard::ConfigDocument::parse`, `render`, `peers`, `sections`,
and `source_slice`, plus explicit add/delete/rename/legacy-metadata edits.
`PeerInspection` provides identity candidates, AllowedIPs, metadata, and issues.
Its raw source may contain secrets; never expose it through import reports.
- `project_client` requires valid attached metadata and a supported address shape.
Do not make successful projection a prerequisite for preserving or reconciling
existing records. Missing metadata, IPv6-only peers, extra routes, and ambiguous
keys must remain inspectable rather than being silently dropped or normalized.
- Managed metadata is already v1: Base64url alias and optional strict UTC creation
string in an attached comment block. Keep that grammar unchanged. The server
alias wins; a filename/public-key fallback is display-only until an explicit
metadata edit. Do not assign import time as an unknown creation date.
- Server parsing is not client parsing: a client file's `[Interface] PrivateKey`
identifies the client, while its `[Peer] PublicKey` normally identifies the
server. Never use that server key as the client's identity.
- Cargo currently contains base64, ipnet, rustix, serde, thiserror, and toml.
Add only the approved crypto crates needed here: age, x25519-dalek, getrandom,
secrecy, and zeroize; tempfile may be a dev-dependency for source-adapter tests.
Verify current official APIs, maintained compatible versions, feature flags,
and secret-type compatibility before choosing them. No custom cipher/KDF,
OpenSSL subprocess, `wg genkey`, unapproved crate, or unrelated upgrade.
#### Context and implementation steps
Run these tasks sequentially with one writer. GPT-6-Sol/medium coordinates;
GPT-6-Luna/high implements, reviews independently, and repairs. The coordinator
supplies scope, owned files, relevant documents, accepted interfaces, checks, and
required report contents. Workers do not accept milestones or edit PLANS.md.
| Task | Owner | Files and deliverable |
| --- | --- | --- |
| M3.1 | implementer | `src/vault.rs`, optional `src/vault/keys.rs`, `src/lib.rs`, Cargo files: secret-safe WireGuard key types, generation and matching |
| M3.2 | implementer | `src/vault/` modules as needed, `docs/VAULT_FORMAT.md`: asymmetric vault setup, password-protected identity, versioned envelopes and load validation |
| M3.3 | implementer | `src/vault/import.rs` or a narrow import module: caller-buffer client parsing, reconciliation reports and encrypted import preparation |
| M3.4 | implementer | read-only configured-directory adapter, `tests/vault_import.rs`, synthetic temporary inputs and README guidance: initial setup/import workflow and failure coverage |
| M3.5 | reviewer, repairer | Independent crypto/use-of-library, import and integrated regression review; bounded fixes and re-review |
| M3.6 | coordinator | Integrated gates, findings/evidence and acceptance/handoff in PLANS.md |
M3.1 wraps private keys/passwords in secret-aware containers with redacted or no
Debug, no implicit Display/Serialize, and explicit exposure only at necessary
cryptographic/encoding boundaries. Generate WireGuard-compatible private keys
using OS randomness and derive their public key using x25519-dalek. Preserve
canonical WireGuard Base64 on output; validate decoded length/encoding on input.
Keep imported private-key bytes intact rather than replacing them with newly
generated keys. Test generation/derivation and matching without leaking values
in assertion failures. Handle random-source errors as recoverable errors.
M3.2 initializes a separate age recipient/identity pair, never reusing a WireGuard
key. Store only the recipient and password-encrypted vault identity in the public
serialized bundle; encrypt each client record to that recipient without requiring
the password. Use the age library's established authenticated encryption and
password protection; do not implement the cryptographic construction manually.
Document exact v1 vault/record formats, component filenames/layout intended for
M4, version handling, public record identifiers, and size limits before encoding
production data. Use serde/TOML or opaque age payloads as appropriate, without
silently introducing a second serializer dependency. Ensure serialization cannot
write plaintext secrets through a generic derive. Reject unknown versions and
truncated/oversized structures with redacted typed errors.
Bind the decrypted payload to its expected client public key and vault context;
derive its public key again and reject mislabelled/swapped records. On unlocking,
verify the decrypted vault identity matches the declared recipient. Do not allow
public envelope metadata alone to authorize a key's identity or reissue status.
Do not invent cryptography to authenticate a plaintext index: document the limits
of unauthenticated public metadata and perform the necessary checks after decrypt.
Each authorized retrieval accepts a fresh password, decrypts only for that
operation, validates binding, and returns an explicit short-lived secret result.
Do not cache an unlocked identity, retain the password between requests, or fall
back to original plaintext files after wrong password, missing/corrupt records,
or password loss. Keep cloning/exposure of secrets minimal; zeroize supported
owned buffers on drop and document unavoidable library-managed copies without
claiming all process memory can be erased. Password-policy/UI additions are not
part of this milestone.
M3.3 parses supplied client content without generic lossy INI serialization.
Require one unambiguous client private key and derive its PublicKey for matching.
Validate client Address values while retaining enough information to report
unsupported shapes and compare addresses rather than conflating client prefixes
with server host routes. Preserve existing optional connection parameters needed
later, including any valid preshared secret, inside the encrypted payload; do not
introduce new MTU/keepalive/PSK settings or generation. Reject ambiguous or malformed
secret fields rather than choosing the last occurrence. Do not run PostUp/hooks,
resolve DNS, mutate routes, or execute text from imported files.
Reconcile against the authoritative M2 server inspection and provide explicit,
secret-free results for server-only, file-only, matched, mismatched, invalid,
and ambiguous/duplicate candidates. Missing/invalid managed metadata and narrow
projection failures are represented separately from cryptographic key mismatch.
Compare the client file's server peer key with the server identity when it can
be determined safely; absent/ambiguous server identity is unresolved, not assumed
valid. Do not infer identity from filenames or friendly aliases.
Matching key material may be encrypted even when address parameters conflict,
but record/report that reissue is blocked pending explicit reconciliation. Do
not truncate routes, overwrite server metadata, automatically add a file-only
client to the VPN, or treat public filename fallback as authoritative. Duplicate
files or existing vault entries must not cause silent overwrite or last-wins
selection; preserve the prior record and return an explicit resolution issue.
A repeat import without decryption must not claim plaintext equality it cannot
verify. Define deterministic handling and tests rather than guessing equivalence.
M3.4 supplies a bounded read-only adapter for the configured source directory:
regular files only, documented candidate selection, no recursive traversal or
symlink following, bounded content sizes, and redacted per-file errors. A source
file failure must not abort other valid imports; a directory-enumeration failure
must be visible and must not be called a successful complete scan. Use safe file
opening for privileged future callers; expanding rustix's approved feature set
is permitted when required, but no new direct crate is implicitly approved.
Initial setup combines vault creation with attempted import of valid candidates.
Return the encrypted prepared bundle and a report; invalid/conflicting candidates
remain visible and originals are untouched. Explicit later import reuses the
vault recipient without asking for a password or replacing the protected identity.
Reissue authorization remains separate. Keep directory IO out of pure parsing and
crypto tests; all adapter tests use synthetic temporary directories, never actual
configured server/client paths. All writes in this milestone are test artifacts
or explicit serialization buffers, not installed application state.
#### Acceptance and validation
- [x] M3-A1: generated WireGuard keys derive valid PublicKey values; imported key
correspondence and malformed Base64/length handling are tested without tools.
- [x] M3-A2: vault setup uses an independent key pair and password-protected
identity; serialized application vault data contains no plaintext private keys,
passwords, or imported secret-bearing configurations.
- [x] M3-A3: new record encryption and later explicit import need only the public
recipient, not a password/unlocked identity; loading ciphertext needs no password.
- [x] M3-A4: correct-password retrieval returns the selected secret; wrong/missing
password, unknown versions, malformed/truncated ciphertext, recipient mismatch,
and record relabelling fail without fallback or loss of the original bundle.
- [x] M3-A5: independent retrieval operations require fresh authorization, with no
reusable unlocked identity/password exposed through ordinary vault APIs. Errors,
reports and Debug are redacted; secret serialization is explicit and protected.
- [x] M3-A6: matching uses the public key derived from client Interface PrivateKey,
not its server peer key or alias. Reports cover matched/server-only/file-only,
conflicts, invalid files, duplicate/ambiguous keys and unsupported address shapes.
- [x] M3-A7: address/server-binding discrepancies remain explicit and block the
authorized reissue path until resolved; a valid secret may still be encrypted.
Duplicate imports never silently replace existing secrets or trusted metadata.
- [x] M3-A8: setup prepares encrypted imports from valid source files while skipping
invalid candidates with explanations. Regular-file selection, limits, symlink
rejection and enumeration failures are tested using temporary data only.
- [x] M3-A9: source files and original server bytes are unchanged; no metadata is
implicitly inserted, no creation dates invented, and M2 parsing/editing guarantees
remain intact. Missing metadata does not prevent encrypted preservation of a key.
- [x] M3-A10: serialized bundle round-trip preserves encrypted identity/records
without decryption, ready for M4 backup/persistence; no filesystem installation,
implicit Commit/runtime change, UI delivery or first-issue bypass is introduced.
- [x] M3-A11: standard integrated gates and M1/M2 regressions pass; independent
review closes actionable crypto, import, secret-lifetime and boundary findings.
Planned commands from the repository root after M3 tests and lockfile exist:
```sh
cargo test --locked vault
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 the focused filter runs tests and record counts/results; all gates should
exit zero. Resolve approved new dependencies/update the lockfile before locked
checks. Keep password-encryption tests bounded while exercising real library
crypto; do not weaken production KDF parameters simply to speed up tests. Use
synthetic generated keys and no sensitive panic/assert output. No root, Docker,
VM, actual WireGuard interface, or production config is required or claimed.
#### Progress
- [x] Description prepared against accepted M1/M2 code and current documents.
- [x] M3.1 — implementer (`/root/implementer_m3_1`) completed 2026-10-18T19:41:39Z; secret-safe WireGuard key generation, parse, derivation and matching.
- [x] M3.2 — implementer (`/root/implementer_m3_2`) completed 2026-10-18T19:46:09Z; versioned age-encrypted bundle, fresh-password retrieval, format documentation.
- [x] M3.3 — implementer (`/root/implementer_m3_3`) completed 2026-10-18T19:52:45Z; caller-buffer parse/reconciliation and authenticated import status.
- [x] M3.4 — implementer (`/root/implementer_m3_4`) completed 2026-10-18T19:58:55Z; bounded read-only directory adapter, initial setup/import flow and synthetic tests.
- [x] M3.5 — fresh reviewer (`/root/reviewer_m3_r1`) completed R1–R7; repairer (`/root/repairer_m3_r1`) resolved all findings; R7 found no actionable issue.
- [x] M3.6 — coordinator integrated gates passed and acceptance recorded.
#### Review and repair
| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State |
| --- | --- | --- | --- | --- | --- |
| M3-R1-01 | R1 | `src/vault.rs:134` Debug printed untrusted imported connection fields | repairer | Redacted imported payload Debug; sentinel regression and R2 passed | closed |
| M3-R1-02 | R1 | `src/vault/source.rs:110` directory path was reopened after secure FD open, allowing enumeration/read inventory mismatch | repairer | Enumerates through pinned `rustix::fs::Dir`; path-replacement regression and R2 passed | closed |
| M3-R1-03 | R1 | `src/vault/import.rs:409` unknown server identity was collapsed into a known key mismatch | repairer | Added Matches/Mismatch/Unresolved and blocked retrieval regression; R2 passed | closed |
| M3-R1-04 | R1 | `src/vault/source.rs:206` duplicate existing vault entry was generic encryption failure | repairer | Added equality-unverified duplicate issue; repeat-import regression and R2 passed | closed |
| M3-R2-01 | R2 | `docs/VAULT_FORMAT.md` omitted intended component filename/layout for M4 | repairer | Documented intended `data_directory/vault.toml` without persistence; R3 confirmed | closed |
| M3-R2-02 | R2 | Secret-bearing test `assert_eq!` output could disclose synthetic key/config bytes on failure | repairer | Replaced with redacted boolean assertions; R3 confirmed | closed |
| M3-R3-01 | R3 | `src/vault.rs:306` unsupported encrypted payload version reported as `RecordMismatch`, not actionable `UnsupportedVersion` | repairer | Split version/label checks; valid encrypted unsupported-version regression and R4 passed | closed |
| M3-R4-01 | R4 | `src/vault.rs:48` canonical Base64 comparison created a plaintext key `String` without zeroization | repairer | Wrapped in `Zeroizing<String>`; R5 confirmed | closed |
| M3-R5-01 | R5 | `src/vault.rs:38` Base64 decode could drop partially decoded key bytes on malformed input before zeroizing wrapper | repairer | Decode into zeroizing buffer from allocation onward; R6 confirmed | closed |
| M3-R5-02 | R5 | `src/vault.rs:287` identity UTF-8 conversion cloned decrypted identity into ordinary Vec, vulnerable on error | repairer | Borrow from zeroizing plaintext; R6 confirmed | closed |
| M3-R6-01 | R6 | `src/vault/import.rs:112` allocated ordinary directive String before knowing whether value was secret/ignored | repairer | Borrowed dispatch; zeroizing PrivateKey/PSK allocations, duplicate/error paths and regressions passed; R7 confirmed | closed |
The reviewer otherwise found crypto binding, fresh authorization and bounded
scan behavior sound at fixture level. No finding may be closed by hiding a
conflict, disabling tests, or relaxing encryption.
#### Evidence and handoff
Planning inspected Cargo.toml, exported M2 document/edit/metadata APIs,
CONFIG_FORMAT.md, M1 model constraints, approved dependencies and ADR 0002.
M2 records independent R3 acceptance, 42 unit plus 9 integration tests, and passing
integrated gates. Those results are historical evidence; this planning task did
not rerun application tests, select crypto versions, or add dependencies/source.
No workers were dispatched and no source directory, private key, service, or
network environment was accessed.
Next execution action: verify configured model roles and the current repository,
review official APIs/compatibility for the approved cryptographic libraries, then
dispatch M3.1 with the bounded task and acceptance criteria. Escalate unavailable
packages as dependency/environment blockers rather than changing approved crypto.
Execution verified the coordinator session as `gpt-6-sol`/`medium`; M1 and M2
are accepted. At start, the only worktree change was this prepared M3 plan.
Rustc/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 match the pinned baseline.
Official crate documentation was checked for `age` 0.12.1 (Rust 1.74 minimum,
recipient and passphrase APIs, no default features), `x25519-dalek`,
`getrandom`, and `secrecy`. `age` re-exports its compatible `secrecy` via
`age-core`; use that re-export rather than adding a conflicting direct version.
The local cache initially lacked `age`; a read-only `cargo info age@0.12.1`
failed under sandbox DNS restrictions, then succeeded with approved network
escalation. No manifest or source was changed during this prerequisite check.
Next: assign M3.1 to the configured implementer.
M3.1 added `vault::WireGuardPrivateKey` using approved `x25519-dalek` 2.0.1,
`getrandom` 0.3.4, and `zeroize` 1.9.0; no `age`/`secrecy` direct dependency was
introduced prematurely. The coordinator inspected the new key API and manifest.
The implementer ran `cargo test --locked vault` (3 tests), `cargo test --locked`
(45 unit and 9 preservation integration tests), fmt, Clippy, release build and
diff check successfully. Its turn context was verified `gpt-6-luna`/`high`.
No production data or service was accessed. Next: M3.2 versioned age vault
format and authorized retrieval on top of this key type.
M3.2 added approved `age` 0.12.1, `VaultBundle` recipient encryption and
passphrase-protected identity, bounded TOML loading, ciphertext/recipient/key
binding checks, and `docs/VAULT_FORMAT.md`. The coordinator inspected source,
format and manifest. The worker ran `cargo test --locked vault` (9 tests),
`cargo test --locked` (51 unit and 9 preservation integration tests), fmt,
Clippy, release build and diff check successfully; its turn context was
verified `gpt-6-luna`/`high`. This is task progress, not integrated M3
acceptance. M3.3 must extend encrypted records to retain imported connection
parameters and enforce duplicate/no-overwrite and conflict reissue semantics;
independent review must scrutinize the current public `reissue_blocked` flag's
authority and secret lifetime. Next: M3.3 caller-buffer import/reconciliation.
M3.3 added `src/vault/import.rs` and extended the encrypted v1 record payload
to retain imported connection parameters, including valid PSKs. It changed
duplicate insertion to fail without overwrite and moved the conflict gate into
the authenticated payload; the public flag is advisory. The coordinator
inspected implementation and updated format documentation. The worker ran
`cargo test --locked vault` (15 tests), `cargo test --locked` (57 unit and 9
preservation integration tests), fmt, Clippy, release build and diff check
successfully; its turn context was verified `gpt-6-luna`/`high`. This remains
pending independent review. Potential review focus: unresolved versus
mismatched server identity classification, preservation and lifetime of source
buffers, and absence of conflict-clearing bypass. Next: M3.4 bounded read-only
directory adapter and setup/import workflow.
M3.4 added `src/vault/source.rs`, `tests/vault_import.rs`, README guidance,
approved `tempfile` 3.23.0 as a development dependency, and `rustix` fs/std
features. It uses Linux `openat2` no-symlink file opens, per-file/aggregate
limits and incomplete-scan reporting. The coordinator inspected the adapter,
fixture tests and manifest. The worker ran `cargo test --locked vault` (16
tests), `cargo test --locked --test vault_import` (5),
`cargo test --locked --test config_preservation` (9), `cargo test --locked`
(58 unit, 9 preservation and 5 vault-import tests), fmt, Clippy, release build
and diff check successfully; its turn context was verified `gpt-6-luna`/`high`.
No production path or service was accessed. Possible review focus: path-based
directory enumeration after secure fd open, explicit duplicate-existing-record
reporting, and whether server-key unknown is distinct from mismatch. Linux
`openat2` absence yields an incomplete scan, not success. Next: fresh integrated
independent review M3.5 before acceptance.
Fresh R1 reviewer turn used `gpt-6-luna`/`high`. It inspected integrated code
and ran focused vault (16), vault-import (5), and M2 preservation (9) tests;
the full suite passed 58 unit plus 14 integration tests, and fmt, Clippy,
release build, help/version and diff check passed. It found M3-R1-01 through
M3-R1-04 above. These passing tests do not close the review findings. Next:
repair, re-review, then coordinator integrated gates.
The repairer (`/root/repairer_m3_r1`, `gpt-6-luna`/`high`) reported all four
R1 findings repaired with focused regressions. It ran `cargo test --locked
vault` (18), vault-import (5), M2 preservation (9), full tests (60 unit plus
14 integration), fmt, Clippy, release build and diff check successfully. The
coordinator inspected changed paths. Findings remain pending independent R2
re-review; no acceptance is claimed yet.
R2 independently confirmed M3-R1-01 through M3-R1-04 and found no remaining
defect in those paths. It found M3-R2-01 and M3-R2-02 above. Focused vault
(18), vault-import (5), preservation (9), full tests (60 unit plus 14
integration), fmt, Clippy, release build, help/version and diff check passed;
the passing gates do not resolve the documentation and test-output gaps. Next:
narrow repair and R3 re-review.
R3 independently closed M3-R2-01/02 and confirmed R1 repairs remain intact.
It found M3-R3-01 and noted an M3-A7 evidence gap: the existing known
server-key mismatch test also has unsupported address projection, so an
isolated mismatch-blocks-reissue test is needed. Aggregate scan-byte and
directory-entry limits are implemented but lack dedicated boundary tests;
their coverage should be strengthened if practical. R3 reran focused vault
(18), vault-import (5), preservation (9), full suite (60 unit plus 14
integration), fmt, Clippy, release build, help/version and diff check
successfully. Passing checks do not close M3-R3-01. Next: repair and R4.
The repairer (`/root/repairer_m3_r1`) split encrypted payload version and
label checks, added a valid age-encrypted unsupported-version regression, an
isolated known server-key-mismatch/reissue-block regression, and inexpensive
aggregate-byte/directory-entry cap tests through private testable limits. It
reported 22 focused vault tests, 5 vault-import tests, 9 M2 preservation
tests, 64 unit plus 14 integration tests in the full suite, and passing fmt,
Clippy, release build and diff check. The coordinator inspected the new code;
R4 independent re-review is pending. No M3 acceptance is claimed yet.
R4 independently confirmed M3-R3-01 and all earlier repairs. It reran focused
vault (22), vault-import (5), M2 preservation (9), full tests (64 unit plus
14 integration), fmt, Clippy, release build, help/version and diff check,
all passing. It found M3-R4-01 above; this must be repaired before M3
acceptance. No production access or real-system result is claimed.
R5 independently confirmed M3-R4-01 but identified M3-R5-01/02 in adjacent
secret-lifetime paths. Focused vault (22), vault-import (5), preservation (9),
full tests (64 unit plus 14 integration), fmt, Clippy, release build,
help/version and diff check all passed; these checks do not prove zeroization
on failure. Earlier repairs remain intact. Next: targeted cleanup and R6
re-review; M3 is not accepted.
R6 independently confirmed M3-R5-01/02: base64 decoding now occurs inside a
zeroizing Vec even on failure, and age identity parsing borrows from zeroizing
bytes. It found M3-R6-01 in the import parser. It reran focused vault (22),
vault-import (5), M2 preservation (9), full suite (64 unit plus 14
integration), fmt, Clippy, release build, help/version and diff check, all
passing. No production access or real-system result was claimed. Next:
targeted parser cleanup and R7.
The repairer changed `src/vault/import.rs` to borrow section, directive key and
value until dispatch; ignored/misplaced values are not allocated. Recognized
PrivateKey/PSK values enter zeroizing storage immediately, including
duplicate/error paths. Redaction regressions were added. It reported focused
vault (22), full tests (64 unit plus 14 integration), fmt, Clippy, release
build and diff check passing; the coordinator inspected the changed parser.
Independent R7 re-review was the next gate after this repair.
R7 independent reviewer (configured `gpt-6-luna`/`high`) inspected the final
parser, prior repairs, cryptographic/secret lifetimes, source-directory safety
and M3-A1–A11. It found no remaining actionable issue and recommended
acceptance. The coordinator reran the full planned command set on the final
integrated source on 2026-10-19: `cargo test --locked vault` passed 22 tests;
`cargo test --locked --test vault_import` passed 5; `cargo test --locked --test
config_preservation` passed 9; `cargo fmt --all -- --check`, `cargo clippy
--locked --all-targets -- -D warnings`, `cargo build --locked --release`, and
`git diff --check` exited zero; `cargo test --locked` passed 64 unit tests,
9 preservation integration tests and 5 vault-import integration tests (0 doc
tests); release `--help` and `--version` both exited zero with existing safe
startup output. The focused vault filter ran 22 actual tests. These are
unprivileged synthetic/unit fixture results only. No production files,
running VPN, host service, Alpine container or systemd VM was accessed;
container/VM checks are not required for this M3 library milestone and are
not claimed. No M3 blocker remains.
#### Decisions and discoveries
The client's Interface private key and the server's Peer public key have different
roles. Existing M2 source/span APIs support authoritative reconciliation but must
not be reused as an unsafe source-dump parser for secrets. M1 projection cannot
represent all legacy peers; use richer import inspection without changing that
model or metadata v1 merely to suppress anomalies.
Prepared vault encoding is the M3/M4 interface: M3 supplies versioned encrypted
components; M4 owns atomic installation, backup-before-mutation, restore, and
Commit deletion. M6 owns file/QR generation and first-delivery session policy.
This boundary avoids persisting unbacked state during an import/crypto milestone.
#### Outcome
Accepted after independent R7 review, closure of all findings and passing
M3-A1–A11 gates. Stop here; milestone 4 is the next action only on a new
request. No vault installation, Git commit, deployment or production access
occurred.
### Milestone 4 — Backups, safe Commit, and full restoration
@@ -661,11 +1061,12 @@ Acceptance: real D-Bus job handling, separate restart verification, address rele
## Current progress and resume point
- Milestones 1 and 2 are accepted; milestones 3–9 remain pending.
- Milestones 1, 2 and 3 are accepted; milestones 4–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.
- Next action: stop after accepted M2; begin M3 only if separately requested.
- Milestone 3 delivered secret-safe key handling, a versioned encrypted vault, caller-buffer reconciliation, and a bounded read-only source-directory import adapter.
- Next action: stop after accepted M3; begin M4 only if separately requested.
- M1 integrated gates and independent re-review passed. No container or VM results are claimed.
## Plan maintenance
+23 -5
View File
@@ -1,10 +1,10 @@
# wireguard-admin
This repository contains the initial Rust package, startup boundary, validated
settings loader, client domain model, and a pure in-memory WireGuard server
configuration parser/editor. The terminal management interface, file
persistence, vault, client import workflow, backups, and VPN operations have not
been implemented yet.
This repository contains the Rust package, startup boundary, validated settings
loader, client domain model, a byte-preserving in-memory WireGuard configuration
editor, and library APIs for encrypted client vaults and read-only client import.
The terminal management interface, installed vault persistence, backups, and VPN
operations have not been implemented yet.
The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From
the repository root, run:
@@ -69,3 +69,21 @@ let bytes_to_review = updated.render(); // no persistence or VPN operation
Management startup still exits with an explicit “not implemented” error after
its root check; the library APIs are not yet connected to a management session.
## Vault and existing-client import library
`wireguard_admin::vault` provides WireGuard key handling, an encrypted versioned
vault bundle, caller-buffer client configuration parsing, and reconciliation
against a supplied server document. The source adapter can prepare an initial
encrypted bundle or explicitly import candidates into an existing prepared
bundle. These operations return in-memory data only: they do not install or save
a vault, edit source files or server configuration, Commit, or touch the VPN.
Directory scans use the resolved `client_source_directory` only. A candidate is
an immediate regular file whose name ends in `.conf` (case-insensitive); the
adapter does not recurse, follow symlinks, or read files larger than 1 MiB. A
scan is limited to 4,096 directory entries, 1,024 candidates, and 32 MiB total
input. Per-file failures are redacted and do not prevent other files from being
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`.
+133
View File
@@ -0,0 +1,133 @@
# Encrypted vault format
The application stores the vault as UTF-8 TOML, schema version 1. The bundle is
an opaque encrypted unit suitable for persistence, backup, and restoration
without the vault password. Parsing is bounded and rejects unsupported versions,
unknown fields, malformed labels, invalid Base64, and oversized bundles.
## Version 1 structure
For M4, the current encrypted bundle is intended to live at
`ResolvedSettings::data_directory.join("vault.toml")`. This stable filename is
relative to the resolved data directory, whose default is `Backup/data` beside
the executable. M3 only prepares and serializes vault bytes in memory; it does
not create this file or install application state. M4 persistence will own that
write.
The top level contains:
| Field | Content |
| --- | --- |
| `version` | Integer `1`. Other values are rejected. |
| `recipient` | Canonical age x25519 public recipient (`age1...`). |
| `identity_ciphertext` | Standard Base64 of an age binary file encrypted with the passphrase recipient format (scrypt). Its plaintext is the age x25519 secret identity. |
| `records` | TOML table keyed by each client's canonical WireGuard public key. |
Each record table contains `ciphertext`, standard Base64 of an age binary file
encrypted to `recipient`, and `reissue_blocked`, an advisory status snapshot.
The snapshot is not authorization. Retrieval decrypts the record and enforces its
authenticated `reissue_blocked` value; editing the TOML boolean cannot authorize
a blocked key. Record insertion rejects an existing public-key label and never
silently replaces ciphertext.
The TOML shape is:
```toml
version = 1
recipient = "age1..."
identity_ciphertext = "<base64 age passphrase-encrypted identity>"
[records."<canonical WireGuard public key>"]
ciphertext = "<base64 age recipient-encrypted record>"
reissue_blocked = false
```
The plaintext of a decrypted client record is UTF-8 TOML with this schema:
```text
version = 1
label = "<canonical WireGuard public key>"
private_key = "<canonical Base64 WireGuard private key>"
client_addresses = ["<original client interface Address item>"]
interface_dns = "<optional original DNS value>"
interface_mtu = "<optional original MTU value>"
server_public_key = "<canonical WireGuard server public key>"
endpoint = "<optional original Endpoint value>"
allowed_ips = ["<original client AllowedIPs item>"]
persistent_keepalive = "<optional original PersistentKeepalive value>"
preshared_key = "<optional canonical Base64 preshared key>"
reissue_blocked = true
```
Optional fields are omitted when absent. Unknown input directives such as hooks
are not interpreted or executed, and are not part of the retained delivery
parameters. The record label is authenticated by age encryption and repeated
inside the ciphertext. On retrieval the implementation checks the embedded label,
derives the public key from the decrypted private key, and enforces the encrypted
conflict bit. Swapping a ciphertext to another public-key table entry therefore
fails closed. No alias, filename, or server peer key is used as client identity.
Client imports derive identity only from `[Interface] PrivateKey`. The `[Peer]
PublicKey` is retained as the server identity. Address discrepancies, a confirmed
server-key mismatch, an unresolved authoritative server identity, file-only
clients, unsupported address projection, invalid inputs, and duplicate candidates
are separate report outcomes. An unresolved identity is not treated as a match.
A valid key may be encrypted while a conflict is present, but password-authorized
retrieval remains blocked. Clearing that authenticated state is deferred until an explicit reconciliation operation
can verify both client material and a fresh authoritative server configuration;
the current API intentionally provides no public-boolean or metadata-only bypass.
## Read-only source scan
The configured client source directory is scanned non-recursively. Immediate
regular files ending in `.conf` (ASCII case-insensitive) are candidates; other
names and nested directories are ignored. Symlinks and non-regular files are
not read. Each candidate is capped at 1 MiB, a scan at 4,096 directory entries,
1,024 candidates, and 32 MiB of input. File/open/read/size errors are reported
by safe category and do not stop other candidates. A directory or enumeration
failure and any per-file read/limit failure mark the scan incomplete. Initial setup returns
the encrypted in-memory bundle and report; installing it and committing remain
separate operations. Explicit later scans encrypt to the existing public
recipient and preserve the protected identity. Source files are never modified.
If a later import finds an existing public-key record, the prior ciphertext is
preserved and the report identifies that duplicate; the scanner cannot establish
plaintext equality without decrypting the prior record, so it does not claim the
files are equal.
## Limits and failure behavior
- Serialized TOML bundle: 16 MiB maximum.
- Records: 4,096 maximum.
- One encrypted record: 64 KiB maximum, before Base64 encoding.
- Password-encrypted identity: 16 KiB maximum, before Base64 encoding.
- The serialized representation must fit within the bundle limit.
Malformed/truncated ciphertext, a recipient that differs from the protected
identity, unknown versions, missing records, and relabelled records return
secret-free typed errors. An unsupported decrypted record schema returns the
version error, while a payload whose label does not match the requested public
key returns the record mismatch error. There is no fallback to plaintext files.
The bundle loader does not unlock the identity, so backup and restore require no
password.
## Secret lifetime during encoding
New-client encryption needs only the public age recipient. Retrieval constructs
an age scrypt identity for one call, decrypts the protected x25519 identity,
verifies it against the public recipient, decrypts one record, and drops the
temporary identity and plaintext on return. The application API does not expose
an unlocked vault identity or retain a password between calls.
The WireGuard key's explicit Base64 representation and the record plaintext
containing it must exist briefly in process memory before age can encrypt them.
Age also creates and buffers its file key, stream state, and output ciphertext.
These copies are inherent to the selected age streaming API; no plaintext is
written into the TOML bundle. The in-memory private-key type, passphrase type,
and temporary plaintext buffers use zeroizing secret containers where supported.
Age's standard passphrase format uses its normal scrypt work factor; the
application does not override or weaken it. The caller remains responsible for
keeping the input password string short-lived.
The password protects only the vault identity. It does not encrypt server
configuration, retained original client files, the entire backup archive, or
WireGuard server secrets stored elsewhere.
+1
View File
@@ -1,4 +1,5 @@
pub mod config;
pub mod domain;
pub mod startup;
pub mod vault;
pub mod wireguard;
+723
View File
@@ -0,0 +1,723 @@
//! Secret-safe primitives used by the encrypted client vault.
pub mod import;
pub mod source;
use std::collections::BTreeMap;
use std::fmt;
use std::io::Write;
use std::str::FromStr;
use age::Encryptor;
use age::secrecy::{ExposeSecret, SecretString};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
use zeroize::{Zeroize, Zeroizing};
use crate::domain::PublicKey;
/// A WireGuard private key held in memory and erased when dropped.
///
/// Private key bytes are neither formatted nor serialized implicitly. Use
/// [`Self::to_base64`] only at an explicit WireGuard configuration boundary.
pub struct WireGuardPrivateKey(StaticSecret);
impl WireGuardPrivateKey {
/// Generate key material from the operating system's random source.
pub fn generate() -> Result<Self, KeyError> {
let mut bytes = Zeroizing::new([0_u8; 32]);
getrandom::fill(bytes.as_mut()).map_err(|_| KeyError::RandomUnavailable)?;
Ok(Self(StaticSecret::from(*bytes)))
}
/// Parse canonical standard Base64 encoding of exactly 32 private key bytes.
pub fn parse_base64(value: &str) -> Result<Self, KeyError> {
if value.len() != 44 {
return Err(KeyError::InvalidPrivateKey);
}
let mut decoded = Zeroizing::new(Vec::with_capacity(32));
STANDARD
.decode_vec(value, &mut decoded)
.map_err(|_| KeyError::InvalidPrivateKey)?;
let bytes: Zeroizing<[u8; 32]> = Zeroizing::new(
decoded
.as_slice()
.try_into()
.map_err(|_| KeyError::InvalidPrivateKey)?,
);
let canonical = Zeroizing::new(STANDARD.encode(*bytes));
if canonical.as_str() != value {
return Err(KeyError::InvalidPrivateKey);
}
Ok(Self(StaticSecret::from(*bytes)))
}
/// Explicitly encode this private key for a WireGuard configuration.
///
/// The returned `String` contains plaintext key material and must be kept
/// short-lived by the caller.
pub fn to_base64(&self) -> String {
STANDARD.encode(self.0.as_bytes())
}
/// Derive the corresponding WireGuard public key using X25519.
pub fn public_key(&self) -> PublicKey {
let public = X25519PublicKey::from(&self.0);
PublicKey::from_bytes(public.to_bytes())
}
/// Check correspondence with an expected public identity.
pub fn matches_public_key(&self, expected: &PublicKey) -> bool {
self.public_key() == *expected
}
}
const BUNDLE_VERSION: u32 = 1;
const MAX_BUNDLE_BYTES: usize = 16 * 1024 * 1024;
const MAX_RECORDS: usize = 4096;
const MAX_RECORD_CIPHERTEXT_BYTES: usize = 64 * 1024;
const MAX_IDENTITY_CIPHERTEXT_BYTES: usize = 16 * 1024;
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RecordPayload {
version: u32,
label: String,
private_key: String,
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>,
reissue_blocked: bool,
}
impl Drop for RecordPayload {
fn drop(&mut self) {
self.private_key.zeroize();
if let Some(value) = &mut self.preshared_key {
value.zeroize();
}
}
}
/// 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>,
preshared_key: Option<String>,
}
impl RetrievedClientRecord {
pub fn private_key(&self) -> Option<&WireGuardPrivateKey> {
self.private_key.as_ref()
}
/// Explicitly expose the optional preshared key for a delivery boundary.
pub fn preshared_key_base64(&self) -> Option<&str> {
self.preshared_key.as_deref()
}
pub fn into_private_key(mut self) -> Result<WireGuardPrivateKey, VaultError> {
self.private_key.take().ok_or(VaultError::CorruptRecord)
}
}
impl fmt::Debug for RetrievedClientRecord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RetrievedClientRecord")
.field("imported_fields", &"[REDACTED]")
.finish()
}
}
impl Drop for RetrievedClientRecord {
fn drop(&mut self) {
if let Some(value) = &mut self.preshared_key {
value.zeroize();
}
}
}
/// 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)]
#[serde(deny_unknown_fields)]
pub struct VaultBundle {
version: u32,
recipient: String,
identity_ciphertext: String,
records: BTreeMap<String, VaultRecord>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct VaultRecord {
ciphertext: String,
reissue_blocked: bool,
}
impl fmt::Debug for VaultBundle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VaultBundle")
.field("version", &self.version)
.field("record_count", &self.records.len())
.field("encrypted_material", &"[REDACTED]")
.finish()
}
}
impl VaultBundle {
/// Create a separate age x25519 identity protected by age's standard passphrase format.
pub fn create(password: &str) -> Result<Self, VaultError> {
if password.is_empty() {
return Err(VaultError::InvalidInput);
}
let identity = age::x25519::Identity::generate();
let recipient = identity.to_public().to_string();
let identity_text = identity.to_string();
let encryptor =
Encryptor::with_user_passphrase(SecretString::new(password.to_owned().into()));
let mut ciphertext = Vec::new();
let mut writer = encryptor
.wrap_output(&mut ciphertext)
.map_err(|_| VaultError::Crypto)?;
writer
.write_all(identity_text.expose_secret().as_bytes())
.map_err(|_| VaultError::Crypto)?;
writer.finish().map_err(|_| VaultError::Crypto)?;
if ciphertext.len() > MAX_IDENTITY_CIPHERTEXT_BYTES {
return Err(VaultError::TooLarge);
}
Ok(Self {
version: BUNDLE_VERSION,
recipient,
identity_ciphertext: STANDARD.encode(ciphertext),
records: BTreeMap::new(),
})
}
/// Encrypt a client private key with the public recipient only; no password is required.
pub fn encrypt_record(
&mut self,
label: PublicKey,
secret: &WireGuardPrivateKey,
) -> Result<(), VaultError> {
self.encrypt_record_with_status(label, secret, false)
}
/// Encrypt a key and bind its conflict status inside the authenticated payload.
pub(crate) fn encrypt_record_with_status(
&mut self,
label: PublicKey,
secret: &WireGuardPrivateKey,
reissue_blocked: bool,
) -> Result<(), VaultError> {
self.validate_shape()?;
if secret.public_key() != label {
return Err(VaultError::RecordMismatch);
}
let payload = RecordPayload {
version: 1,
label: label.to_string(),
private_key: secret.to_base64(),
client_addresses: Vec::new(),
interface_dns: None,
interface_mtu: None,
server_public_key: None,
endpoint: None,
allowed_ips: Vec::new(),
persistent_keepalive: None,
preshared_key: None,
reissue_blocked,
};
self.encrypt_payload(label, payload, true)
}
/// Return the unauthenticated display snapshot; retrieval checks encrypted status instead.
pub fn reissue_blocked(&self, label: PublicKey) -> Result<bool, VaultError> {
self.records
.get(&label.to_string())
.map(|record| record.reissue_blocked)
.ok_or(VaultError::MissingRecord)
}
pub fn contains_record(&self, label: PublicKey) -> bool {
self.records.contains_key(&label.to_string())
}
/// Retrieve a single record, freshly unlocking and verifying the identity on every call.
pub fn retrieve_record(
&self,
label: PublicKey,
password: &str,
) -> Result<WireGuardPrivateKey, VaultError> {
self.retrieve_client_record(label, password)?
.into_private_key()
}
/// Retrieve all retained connection parameters with fresh password authorization.
pub fn retrieve_client_record(
&self,
label: PublicKey,
password: &str,
) -> Result<RetrievedClientRecord, VaultError> {
self.validate_shape()?;
if password.is_empty() {
return Err(VaultError::InvalidInput);
}
let encoded_identity = STANDARD
.decode(&self.identity_ciphertext)
.map_err(|_| VaultError::CorruptVault)?;
let password_identity =
age::scrypt::Identity::new(SecretString::new(password.to_owned().into()));
let identity_bytes = Zeroizing::new(
age::decrypt(&password_identity, &encoded_identity)
.map_err(|_| VaultError::UnlockFailed)?,
);
let identity_text =
std::str::from_utf8(&identity_bytes).map_err(|_| VaultError::CorruptVault)?;
let identity =
age::x25519::Identity::from_str(identity_text).map_err(|_| VaultError::CorruptVault)?;
if identity.to_public().to_string() != self.recipient {
return Err(VaultError::RecipientMismatch);
}
let record = self
.records
.get(&label.to_string())
.ok_or(VaultError::MissingRecord)?;
let ciphertext = STANDARD
.decode(&record.ciphertext)
.map_err(|_| VaultError::CorruptRecord)?;
let plaintext =
age::decrypt(&identity, &ciphertext).map_err(|_| VaultError::CorruptRecord)?;
let plaintext = Zeroizing::new(plaintext);
let text = std::str::from_utf8(&plaintext).map_err(|_| VaultError::CorruptRecord)?;
let payload: RecordPayload = toml::from_str(text).map_err(|_| VaultError::CorruptRecord)?;
if payload.version != 1 {
return Err(VaultError::UnsupportedVersion);
}
if payload.label != label.to_string() {
return Err(VaultError::RecordMismatch);
}
if payload.reissue_blocked {
return Err(VaultError::ReissueBlocked);
}
let secret = WireGuardPrivateKey::parse_base64(&payload.private_key)
.map_err(|_| VaultError::RecordMismatch)?;
if secret.public_key() != label {
return Err(VaultError::RecordMismatch);
}
Ok(RetrievedClientRecord {
private_key: Some(secret),
client_addresses: payload.client_addresses.clone(),
interface_dns: payload.interface_dns.clone(),
interface_mtu: payload.interface_mtu.clone(),
server_public_key: payload.server_public_key.clone(),
endpoint: payload.endpoint.clone(),
allowed_ips: payload.allowed_ips.clone(),
persistent_keepalive: payload.persistent_keepalive.clone(),
preshared_key: payload.preshared_key.clone(),
})
}
/// Serialize encrypted bundle contents for persistence or backup without unlocking.
pub fn to_toml(&self) -> Result<String, VaultError> {
self.validate_shape()?;
let encoded = toml::to_string(self).map_err(|_| VaultError::Serialization)?;
if encoded.len() > MAX_BUNDLE_BYTES {
return Err(VaultError::TooLarge);
}
Ok(encoded)
}
/// Load bounded serialized contents without requesting the password.
pub fn from_toml(encoded: &str) -> Result<Self, VaultError> {
if encoded.len() > MAX_BUNDLE_BYTES {
return Err(VaultError::TooLarge);
}
let bundle: Self = toml::from_str(encoded).map_err(|_| VaultError::CorruptVault)?;
if bundle.version != BUNDLE_VERSION {
return Err(VaultError::UnsupportedVersion);
}
bundle.validate_shape()?;
Ok(bundle)
}
pub fn recipient(&self) -> &str {
&self.recipient
}
pub fn record_count(&self) -> usize {
self.records.len()
}
fn encrypt_payload(
&mut self,
label: PublicKey,
payload: RecordPayload,
reject_duplicate: bool,
) -> Result<(), VaultError> {
self.validate_shape()?;
let key = label.to_string();
if reject_duplicate && self.records.contains_key(&key) {
return Err(VaultError::DuplicateRecord);
}
if !self.records.contains_key(&key) && self.records.len() >= MAX_RECORDS {
return Err(VaultError::TooManyRecords);
}
let plaintext =
Zeroizing::new(toml::to_string(&payload).map_err(|_| VaultError::Serialization)?);
let recipient = age::x25519::Recipient::from_str(&self.recipient)
.map_err(|_| VaultError::CorruptVault)?;
let ciphertext =
age::encrypt(&recipient, plaintext.as_bytes()).map_err(|_| VaultError::Crypto)?;
if ciphertext.len() > MAX_RECORD_CIPHERTEXT_BYTES {
return Err(VaultError::TooLarge);
}
self.records.insert(
key,
VaultRecord {
ciphertext: STANDARD.encode(ciphertext),
// Advisory only. Retrieval trusts the authenticated encrypted payload.
reissue_blocked: payload.reissue_blocked,
},
);
Ok(())
}
fn validate_shape(&self) -> Result<(), VaultError> {
if self.version != BUNDLE_VERSION {
return Err(VaultError::UnsupportedVersion);
}
if self.records.len() > MAX_RECORDS
|| age::x25519::Recipient::from_str(&self.recipient).is_err()
{
return Err(VaultError::CorruptVault);
}
let identity_ciphertext = STANDARD
.decode(&self.identity_ciphertext)
.map_err(|_| VaultError::CorruptVault)?;
if identity_ciphertext.len() > MAX_IDENTITY_CIPHERTEXT_BYTES
|| STANDARD.encode(&identity_ciphertext) != self.identity_ciphertext
|| age::Decryptor::new(identity_ciphertext.as_slice()).is_err()
{
return Err(VaultError::CorruptVault);
}
for (label, record) in &self.records {
let parsed = label
.parse::<PublicKey>()
.map_err(|_| VaultError::CorruptVault)?;
let ciphertext = STANDARD
.decode(&record.ciphertext)
.map_err(|_| VaultError::CorruptRecord)?;
if parsed.to_string() != *label
|| ciphertext.len() > MAX_RECORD_CIPHERTEXT_BYTES
|| STANDARD.encode(&ciphertext) != record.ciphertext
|| age::Decryptor::new(ciphertext.as_slice()).is_err()
{
return Err(VaultError::CorruptRecord);
}
}
Ok(())
}
}
/// Secret-safe errors for vault operations.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum VaultError {
#[error("vault input is invalid")]
InvalidInput,
#[error("cryptographic operation failed")]
Crypto,
#[error("vault serialization failed")]
Serialization,
#[error("vault data is corrupt or malformed")]
CorruptVault,
#[error("vault record is corrupt or malformed")]
CorruptRecord,
#[error("vault version is not supported")]
UnsupportedVersion,
#[error("vault data exceeds a size limit")]
TooLarge,
#[error("vault contains too many records")]
TooManyRecords,
#[error("vault identity could not be unlocked")]
UnlockFailed,
#[error("vault recipient does not match its protected identity")]
RecipientMismatch,
#[error("vault record does not match its public-key label")]
RecordMismatch,
#[error("vault record is missing")]
MissingRecord,
#[error("vault already contains a record for this public key")]
DuplicateRecord,
#[error("client reissue is blocked pending reconciliation")]
ReissueBlocked,
}
/// Recoverable errors from private-key generation and parsing.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum KeyError {
#[error("operating system random source is unavailable")]
RandomUnavailable,
#[error("private key must be canonical standard Base64 encoding of exactly 32 bytes")]
InvalidPrivateKey,
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use super::{KeyError, RecordPayload, VaultBundle, VaultError, WireGuardPrivateKey};
use crate::domain::PublicKey;
const PASSWORD: &str = "synthetic vault passphrase for tests";
fn prepared() -> (VaultBundle, WireGuardPrivateKey) {
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
bundle
.encrypt_record(key.public_key(), &key)
.expect("recipient encryption must not need a password");
(bundle, key)
}
#[test]
fn generated_keys_derive_matching_public_identities() {
let first = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let second = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let first_public = first.public_key();
let second_public = second.public_key();
assert!(first.matches_public_key(&first_public));
assert!(!first.matches_public_key(&second_public));
assert_ne!(first_public, second_public);
}
#[test]
fn explicit_base64_round_trip_preserves_key_identity() {
let original = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let public = original.public_key();
let encoded = original.to_base64();
assert_eq!(encoded.len(), 44);
let imported = WireGuardPrivateKey::parse_base64(&encoded)
.expect("generated key encoding should parse canonically");
assert!(imported.matches_public_key(&public));
}
#[test]
fn malformed_private_key_encodings_are_rejected() {
let short = STANDARD.encode([0_u8; 31]);
let full = STANDARD.encode([0_u8; 32]);
let without_padding = full.trim_end_matches('=').to_owned();
let invalid_after_decoded_prefix = format!("{}!", &full[..43]);
assert_eq!(
WireGuardPrivateKey::parse_base64(&short).err(),
Some(KeyError::InvalidPrivateKey)
);
assert_eq!(
WireGuardPrivateKey::parse_base64(&without_padding).err(),
Some(KeyError::InvalidPrivateKey)
);
assert_eq!(
WireGuardPrivateKey::parse_base64("not base64").err(),
Some(KeyError::InvalidPrivateKey)
);
assert_eq!(
WireGuardPrivateKey::parse_base64(&invalid_after_decoded_prefix).err(),
Some(KeyError::InvalidPrivateKey)
);
}
#[test]
fn encrypted_bundle_round_trips_without_unlocking_and_retrieves_with_password() {
let (bundle, key) = prepared();
let serialized = bundle.to_toml().expect("bundle should serialize");
assert!(!serialized.contains(&key.to_base64()));
assert!(!serialized.contains(PASSWORD));
let loaded = VaultBundle::from_toml(&serialized).expect("bundle loads without password");
assert_eq!(loaded.record_count(), 1);
assert_eq!(loaded.recipient(), bundle.recipient());
let retrieved = loaded
.retrieve_record(key.public_key(), PASSWORD)
.expect("correct password should retrieve matching secret");
assert!(retrieved.matches_public_key(&key.public_key()));
}
#[test]
fn every_retrieval_requires_the_correct_password() {
let (bundle, key) = prepared();
assert_eq!(
bundle
.retrieve_record(key.public_key(), "wrong password")
.err(),
Some(VaultError::UnlockFailed)
);
assert_eq!(
bundle.retrieve_record(key.public_key(), "").err(),
Some(VaultError::InvalidInput)
);
assert!(
bundle
.retrieve_record(key.public_key(), PASSWORD)
.expect("fresh correct authorization should work")
.matches_public_key(&key.public_key())
);
}
#[test]
fn malformed_truncated_and_unknown_version_data_are_rejected() {
let (bundle, key) = prepared();
let serialized = bundle.to_toml().expect("bundle should serialize");
assert_eq!(
VaultBundle::from_toml(&serialized[..serialized.len() / 2]).err(),
Some(VaultError::CorruptVault)
);
assert_eq!(
VaultBundle::from_toml(&serialized.replace("version = 1", "version = 9")).err(),
Some(VaultError::UnsupportedVersion)
);
let mut corrupt_record = bundle;
let record = corrupt_record
.records
.get_mut(&key.public_key().to_string())
.expect("record exists");
let mut bytes = STANDARD
.decode(&record.ciphertext)
.expect("ciphertext is base64");
bytes.truncate(bytes.len() / 2);
record.ciphertext = STANDARD.encode(bytes);
assert_eq!(
corrupt_record
.retrieve_record(key.public_key(), PASSWORD)
.err(),
Some(VaultError::CorruptRecord)
);
}
#[test]
fn protected_identity_detects_corruption_and_recipient_replacement() {
let (mut bundle, key) = prepared();
let mut identity_ciphertext = STANDARD
.decode(&bundle.identity_ciphertext)
.expect("identity ciphertext is base64");
identity_ciphertext.truncate(identity_ciphertext.len() / 2);
bundle.identity_ciphertext = STANDARD.encode(identity_ciphertext);
assert!(matches!(
bundle.retrieve_record(key.public_key(), PASSWORD),
Err(VaultError::UnlockFailed | VaultError::CorruptVault)
));
let (mut bundle, key) = prepared();
let different_identity = age::x25519::Identity::generate();
bundle.recipient = different_identity.to_public().to_string();
assert_eq!(
bundle.retrieve_record(key.public_key(), PASSWORD).err(),
Some(VaultError::RecipientMismatch)
);
}
#[test]
fn relabelled_record_is_rejected_and_authenticated_conflict_blocks_reissue() {
let (mut bundle, key) = prepared();
let other_label = WireGuardPrivateKey::generate()
.expect("OS randomness should be available")
.public_key();
let record = bundle
.records
.remove(&key.public_key().to_string())
.expect("record exists");
bundle.records.insert(other_label.to_string(), record);
assert_eq!(
bundle.retrieve_record(other_label, PASSWORD).err(),
Some(VaultError::RecordMismatch)
);
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
bundle
.encrypt_record_with_status(key.public_key(), &key, true)
.expect("blocked record should encrypt");
assert_eq!(
bundle.retrieve_record(key.public_key(), PASSWORD).err(),
Some(VaultError::ReissueBlocked)
);
let serialized = bundle
.to_toml()
.expect("blocked record should serialize")
.replace("reissue_blocked = true", "reissue_blocked = false");
let changed = VaultBundle::from_toml(&serialized).expect("public status is advisory");
assert_eq!(changed.reissue_blocked(key.public_key()), Ok(false));
assert_eq!(
changed.retrieve_record(key.public_key(), PASSWORD).err(),
Some(VaultError::ReissueBlocked)
);
}
#[test]
fn authenticated_record_with_unsupported_version_has_specific_error() {
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let label = key.public_key();
bundle
.encrypt_payload(
label,
RecordPayload {
version: 2,
label: label.to_string(),
private_key: key.to_base64(),
client_addresses: Vec::new(),
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("test record should be encrypted with the normal age path");
assert_eq!(
bundle.retrieve_record(label, PASSWORD).err(),
Some(VaultError::UnsupportedVersion)
);
}
#[test]
fn vault_debug_and_errors_do_not_include_secret_material() {
let (bundle, key) = prepared();
let debug = format!("{bundle:?}");
assert!(!debug.contains(PASSWORD));
assert!(!debug.contains(&key.to_base64()));
let error = VaultError::UnlockFailed.to_string();
assert!(!error.contains(PASSWORD));
assert!(!error.contains(&key.to_base64()));
let unknown = PublicKey::from_bytes([0xA5; 32]);
assert_eq!(
bundle.retrieve_record(unknown, PASSWORD).err(),
Some(VaultError::MissingRecord)
);
}
}
+815
View File
@@ -0,0 +1,815 @@
//! Caller-buffer parsing and reconciliation for existing WireGuard client files.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use ipnet::IpNet;
use zeroize::Zeroizing;
use crate::domain::PublicKey;
use crate::wireguard::{ConfigDocument, MetadataAttachment, PeerIssue, SectionKind};
use super::{RecordPayload, VaultBundle, VaultError, WireGuardPrivateKey};
/// Secret-free outcome of parsing a supplied client configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClientFileIssue {
InvalidEncoding,
InvalidStructure,
MissingPrivateKey,
AmbiguousPrivateKey,
InvalidPrivateKey,
MissingAddress,
InvalidAddress,
AmbiguousAddress,
MissingServerKey,
AmbiguousServerKey,
InvalidServerKey,
AmbiguousPresharedKey,
InvalidPresharedKey,
}
/// Parsed client parameters. Secret-bearing fields are intentionally not formatted.
pub struct ParsedClientConfig {
private_key: WireGuardPrivateKey,
preshared_key: Option<Zeroizing<String>>,
pub identity: PublicKey,
pub addresses: Vec<IpNet>,
address_text: Vec<String>,
pub dns: Option<String>,
pub mtu: Option<String>,
pub server_public_key: PublicKey,
pub endpoint: Option<String>,
pub allowed_ips: Vec<IpNet>,
allowed_ips_text: Vec<String>,
pub persistent_keepalive: Option<String>,
pub unsupported_address_shape: bool,
}
impl fmt::Debug for ParsedClientConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ParsedClientConfig")
.field("identity", &self.identity)
.field("addresses", &self.addresses)
.field("server_public_key", &self.server_public_key)
.field("unsupported_address_shape", &self.unsupported_address_shape)
.field("secret_parameters", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Copy)]
enum ClientSection {
Interface,
Peer,
Other,
}
/// Parse a caller-supplied client configuration without reading files or exposing its source.
pub fn parse_client_config(input: &[u8]) -> Result<ParsedClientConfig, ClientFileIssue> {
let text = std::str::from_utf8(input).map_err(|_| ClientFileIssue::InvalidEncoding)?;
let mut section: Option<ClientSection> = None;
let mut interface_count = 0;
let mut peer_count = 0;
let mut private_keys = Vec::<Zeroizing<String>>::new();
let mut psk = Vec::<Zeroizing<String>>::new();
let (mut addresses, mut dns, mut mtu) = (Vec::new(), Vec::new(), Vec::new());
let (mut server_keys, mut endpoints, mut allowed, mut keepalive) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
for raw_line in text.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') || line.ends_with(']') {
let Some(name) = line.strip_prefix('[').and_then(|n| n.strip_suffix(']')) else {
return Err(ClientFileIssue::InvalidStructure);
};
let name = name.trim();
section = if name.eq_ignore_ascii_case("interface") {
interface_count += 1;
Some(ClientSection::Interface)
} else if name.eq_ignore_ascii_case("peer") {
peer_count += 1;
Some(ClientSection::Peer)
} else {
Some(ClientSection::Other)
};
continue;
}
let Some((key, value)) = line.split_once('=') else {
return Err(ClientFileIssue::InvalidStructure);
};
let key = key.trim();
let value = value.trim();
match section {
Some(ClientSection::Interface) => {
if key.eq_ignore_ascii_case("privatekey") {
private_keys.push(Zeroizing::new(value.to_owned()));
} else if key.eq_ignore_ascii_case("address") {
addresses.push(value.to_owned());
} else if key.eq_ignore_ascii_case("dns") {
dns.push(value.to_owned());
} else if key.eq_ignore_ascii_case("mtu") {
mtu.push(value.to_owned());
} // Hooks and unknown fields are never interpreted or executed.
}
Some(ClientSection::Peer) => {
if key.eq_ignore_ascii_case("publickey") {
server_keys.push(value.to_owned());
} else if key.eq_ignore_ascii_case("presharedkey") {
psk.push(Zeroizing::new(value.to_owned()));
} else if key.eq_ignore_ascii_case("endpoint") {
endpoints.push(value.to_owned());
} else if key.eq_ignore_ascii_case("allowedips") {
allowed.push(value.to_owned());
} else if key.eq_ignore_ascii_case("persistentkeepalive") {
keepalive.push(value.to_owned());
}
}
_ => return Err(ClientFileIssue::InvalidStructure),
}
}
if interface_count != 1 || peer_count != 1 {
return Err(ClientFileIssue::InvalidStructure);
}
let private_text = match private_keys.as_slice() {
[] => return Err(ClientFileIssue::MissingPrivateKey),
[value] => value,
_ => return Err(ClientFileIssue::AmbiguousPrivateKey),
};
let private_key = WireGuardPrivateKey::parse_base64(private_text)
.map_err(|_| ClientFileIssue::InvalidPrivateKey)?;
let address_text = match addresses.as_slice() {
[] => return Err(ClientFileIssue::MissingAddress),
[value] => value,
_ => return Err(ClientFileIssue::AmbiguousAddress),
};
let address_items = split_network_list(address_text).ok_or(ClientFileIssue::InvalidAddress)?;
let parsed_addresses = address_items
.iter()
.map(|item| IpNet::from_str(item).map_err(|_| ClientFileIssue::InvalidAddress))
.collect::<Result<Vec<_>, _>>()?;
let dns = single_optional(dns).ok_or(ClientFileIssue::InvalidStructure)?;
let mtu = single_optional(mtu).ok_or(ClientFileIssue::InvalidStructure)?;
let server_key_text = match server_keys.as_slice() {
[] => return Err(ClientFileIssue::MissingServerKey),
[value] => value,
_ => return Err(ClientFileIssue::AmbiguousServerKey),
};
let server_public_key =
PublicKey::from_str(server_key_text).map_err(|_| ClientFileIssue::InvalidServerKey)?;
let preshared_key = match psk.as_slice() {
[] => None,
[value] => {
WireGuardPrivateKey::parse_base64(value)
.map_err(|_| ClientFileIssue::InvalidPresharedKey)?;
Some(value.clone())
}
_ => return Err(ClientFileIssue::AmbiguousPresharedKey),
};
let endpoint = single_optional(endpoints).ok_or(ClientFileIssue::InvalidStructure)?;
let mut allowed_ips_text = Vec::new();
for value in &allowed {
allowed_ips_text.extend(split_network_list(value).ok_or(ClientFileIssue::InvalidAddress)?);
}
let allowed_ips = allowed_ips_text
.iter()
.map(|item| IpNet::from_str(item).map_err(|_| ClientFileIssue::InvalidAddress))
.collect::<Result<Vec<_>, _>>()?;
let persistent_keepalive =
single_optional(keepalive).ok_or(ClientFileIssue::InvalidStructure)?;
let unsupported_address_shape = parsed_addresses.iter().any(|net| match net {
IpNet::V4(v4) => v4.prefix_len() != 32,
IpNet::V6(v6) => v6.prefix_len() != 128,
});
Ok(ParsedClientConfig {
identity: private_key.public_key(),
private_key,
preshared_key,
addresses: parsed_addresses,
address_text: address_items,
dns,
mtu,
server_public_key,
endpoint,
allowed_ips,
allowed_ips_text,
persistent_keepalive,
unsupported_address_shape,
})
}
fn single_optional(values: Vec<String>) -> Option<Option<String>> {
match values.as_slice() {
[] => Some(None),
[value] => Some(Some(value.clone())),
_ => None,
}
}
fn split_network_list(value: &str) -> Option<Vec<String>> {
let items = value.split(',').map(str::trim).collect::<Vec<_>>();
if items.is_empty() || items.iter().any(|item| item.is_empty()) {
return None;
}
items
.into_iter()
.map(|item| {
IpNet::from_str(item).ok()?;
Some(item.to_owned())
})
.collect()
}
/// Reconciliation status for one supplied candidate; it contains no source text or secrets.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CandidateStatus {
Invalid(ClientFileIssue),
DuplicateFileIdentity,
AmbiguousServerPeer,
FileOnly,
Matched {
address_discrepancy: bool,
server_identity: ServerIdentityComparison,
missing_metadata: bool,
invalid_metadata: bool,
unsupported_projection: bool,
},
}
/// Whether the client file's peer key could be checked against the authoritative server identity.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ServerIdentityComparison {
Matches,
Mismatch,
Unresolved,
}
struct Candidate {
display_name: String,
parsed: Result<ParsedClientConfig, ClientFileIssue>,
status: CandidateStatus,
}
/// Secret-free reconciliation summary. The display name is never used as identity.
pub struct ImportReport {
candidates: Vec<Candidate>,
server_only: Vec<PublicKey>,
}
impl fmt::Debug for ImportReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ImportReport")
.field("candidate_count", &self.candidates.len())
.field("server_only", &self.server_only)
.field("details", &"[REDACTED SOURCE DETAILS]")
.finish()
}
}
impl ImportReport {
pub fn candidate_summaries(&self) -> Vec<(&str, CandidateStatus)> {
self.candidates
.iter()
.map(|item| (item.display_name.as_str(), item.status.clone()))
.collect()
}
pub fn server_only(&self) -> &[PublicKey] {
&self.server_only
}
/// Encrypt one valid candidate. Duplicate vault records are rejected without replacement.
pub fn encrypt_candidate(
&self,
bundle: &mut VaultBundle,
candidate_index: usize,
) -> Result<(), VaultError> {
let candidate = self
.candidates
.get(candidate_index)
.ok_or(VaultError::InvalidInput)?;
let parsed = candidate
.parsed
.as_ref()
.map_err(|_| VaultError::InvalidInput)?;
if matches!(
candidate.status,
CandidateStatus::DuplicateFileIdentity | CandidateStatus::Invalid(_)
) {
return Err(VaultError::InvalidInput);
}
let blocked = match candidate.status {
CandidateStatus::Matched {
address_discrepancy,
server_identity,
unsupported_projection,
..
} => {
address_discrepancy
|| server_identity != ServerIdentityComparison::Matches
|| unsupported_projection
}
CandidateStatus::AmbiguousServerPeer | CandidateStatus::FileOnly => true,
CandidateStatus::Invalid(_) | CandidateStatus::DuplicateFileIdentity => true,
};
let payload = RecordPayload {
version: 1,
label: parsed.identity.to_string(),
private_key: parsed.private_key.to_base64(),
client_addresses: parsed.address_text.clone(),
interface_dns: parsed.dns.clone(),
interface_mtu: parsed.mtu.clone(),
server_public_key: Some(parsed.server_public_key.to_string()),
endpoint: parsed.endpoint.clone(),
allowed_ips: parsed.allowed_ips_text.clone(),
persistent_keepalive: parsed.persistent_keepalive.clone(),
preshared_key: parsed.preshared_key.as_ref().map(|value| value.to_string()),
reissue_blocked: blocked,
};
bundle.encrypt_payload(parsed.identity, payload, true)
}
}
/// Reconcile supplied caller-buffer files with the authoritative server document.
pub fn reconcile(
server: &ConfigDocument,
files: impl IntoIterator<Item = (String, Vec<u8>)>,
) -> ImportReport {
reconcile_zeroizing(
server,
files
.into_iter()
.map(|(display_name, bytes)| (display_name, Zeroizing::new(bytes))),
)
}
pub(crate) fn reconcile_zeroizing(
server: &ConfigDocument,
files: impl IntoIterator<Item = (String, Zeroizing<Vec<u8>>)>,
) -> ImportReport {
let mut candidates = files
.into_iter()
.map(|(display_name, bytes)| Candidate {
display_name,
parsed: parse_client_config(&bytes),
status: CandidateStatus::FileOnly,
})
.collect::<Vec<_>>();
let mut identity_counts = BTreeMap::<PublicKey, usize>::new();
for candidate in &candidates {
if let Ok(parsed) = &candidate.parsed {
*identity_counts.entry(parsed.identity).or_default() += 1;
}
}
let server_key = interface_public_key(server);
let mut represented = BTreeSet::new();
for candidate in &mut candidates {
let parsed = match &candidate.parsed {
Ok(parsed) => parsed,
Err(issue) => {
candidate.status = CandidateStatus::Invalid(*issue);
continue;
}
};
represented.insert(parsed.identity);
if identity_counts
.get(&parsed.identity)
.copied()
.unwrap_or_default()
> 1
{
candidate.status = CandidateStatus::DuplicateFileIdentity;
continue;
}
let peers = server
.peers()
.iter()
.filter(|peer| peer.public_key_candidates.contains(&parsed.identity))
.collect::<Vec<_>>();
if peers.is_empty() {
candidate.status = CandidateStatus::FileOnly;
continue;
}
if peers.len() != 1
|| peers[0].identity != Some(parsed.identity)
|| peers[0].issues.iter().any(|issue| {
matches!(
issue,
PeerIssue::DuplicatePeerIdentity { .. }
| PeerIssue::DuplicatePublicKeyField { .. }
| PeerIssue::MalformedPublicKey { .. }
)
})
{
candidate.status = CandidateStatus::AmbiguousServerPeer;
continue;
}
let peer = peers[0];
let file_addresses = parsed
.addresses
.iter()
.map(|net| net.addr())
.collect::<BTreeSet<_>>();
let server_addresses = peer
.allowed_ips
.iter()
.map(|net| net.addr())
.collect::<BTreeSet<_>>();
let address_discrepancy = file_addresses != server_addresses;
let server_identity = match server_key {
Some(server_key) if server_key == parsed.server_public_key => {
ServerIdentityComparison::Matches
}
Some(_) => ServerIdentityComparison::Mismatch,
None => ServerIdentityComparison::Unresolved,
};
let missing_metadata = matches!(peer.metadata, MetadataAttachment::Absent);
let invalid_metadata = matches!(peer.metadata, MetadataAttachment::Invalid(_));
let server_unsupported = peer.issues.iter().any(|issue| {
matches!(
issue,
PeerIssue::MalformedAllowedIp { .. }
| PeerIssue::UnsupportedAllowedIpShape { .. }
| PeerIssue::MissingIpv4HostRoute
)
});
candidate.status = CandidateStatus::Matched {
address_discrepancy,
server_identity,
missing_metadata,
invalid_metadata,
unsupported_projection: parsed.unsupported_address_shape || server_unsupported,
};
}
let server_only = server
.peers()
.iter()
.filter_map(|peer| peer.identity)
.filter(|identity| !represented.contains(identity))
.collect();
ImportReport {
candidates,
server_only,
}
}
fn interface_public_key(document: &ConfigDocument) -> Option<PublicKey> {
let interfaces = document
.sections()
.iter()
.filter(|section| section.kind == SectionKind::Interface)
.collect::<Vec<_>>();
if interfaces.len() != 1 {
return None;
}
let text = document.source_slice(interfaces[0].span)?;
let keys = text
.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
key.trim()
.eq_ignore_ascii_case("PrivateKey")
.then(|| value.trim())
})
.collect::<Vec<_>>();
let [private] = keys.as_slice() else {
return None;
};
WireGuardPrivateKey::parse_base64(private)
.ok()
.map(|key| key.public_key())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::{VaultBundle, VaultError};
const PASSWORD: &str = "synthetic passphrase";
fn generated() -> (String, String, String) {
let client = WireGuardPrivateKey::generate().unwrap();
let server = WireGuardPrivateKey::generate().unwrap();
let psk = WireGuardPrivateKey::generate().unwrap();
(client.to_base64(), server.to_base64(), psk.to_base64())
}
fn client_config(client: &str, server: &str, psk: &str, address: &str) -> String {
let server_public = WireGuardPrivateKey::parse_base64(server)
.unwrap()
.public_key();
format!(
"[Interface]\nPrivateKey = {client}\nAddress = {address}\nDNS = 1.1.1.1\nMTU = 1380\n[Peer]\nPublicKey = {server_public}\nPresharedKey = {psk}\nEndpoint = example.invalid:51820\nAllowedIPs = 0.0.0.0/0, ::/0\nPersistentKeepalive = 25\n"
)
}
fn server_config(server: &str, client_public: &PublicKey, address: &str) -> String {
format!(
"[Interface]\nPrivateKey = {server}\n[Peer]\nPublicKey = {client_public}\nAllowedIPs = {address}\n"
)
}
#[test]
fn client_interface_private_key_is_identity_and_peer_public_key_is_server() {
let (client, server, psk) = generated();
let parsed =
parse_client_config(client_config(&client, &server, &psk, "10.0.0.2/32").as_bytes())
.unwrap();
let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap();
let server_key = WireGuardPrivateKey::parse_base64(&server).unwrap();
assert_eq!(parsed.identity, client_key.public_key());
assert_ne!(parsed.identity, server_key.public_key());
assert_eq!(parsed.server_public_key, server_key.public_key());
}
#[test]
fn import_preserves_secrets_and_parameters_only_inside_encrypted_record() {
let (client, server, psk) = generated();
let private = WireGuardPrivateKey::parse_base64(&client).unwrap();
let server_private = WireGuardPrivateKey::parse_base64(&server).unwrap();
let source = client_config(&client, &server, &psk, "10.0.0.2/32, fd00::2/128");
let parsed = parse_client_config(source.as_bytes()).unwrap();
assert!(parsed.preshared_key.as_ref().map(|value| value.as_str()) == Some(psk.as_str()));
let server_doc = ConfigDocument::parse(server_config(
&server,
&private.public_key(),
"10.0.0.2/32, fd00::2/128",
))
.unwrap();
let report = reconcile(
&server_doc,
[("alias-looking-name.conf".into(), source.as_bytes().to_vec())],
);
let summaries = report.candidate_summaries();
assert_eq!(
summaries[0].1,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: ServerIdentityComparison::Matches,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
);
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
report.encrypt_candidate(&mut bundle, 0).unwrap();
let serialized = bundle.to_toml().unwrap();
assert!(!serialized.contains(&client));
assert!(!serialized.contains(&psk));
assert!(!serialized.contains("example.invalid"));
let retrieved = bundle
.retrieve_client_record(parsed.identity, PASSWORD)
.unwrap();
assert!(retrieved.preshared_key_base64() == Some(psk.as_str()));
assert_eq!(retrieved.client_addresses, ["10.0.0.2/32", "fd00::2/128"]);
assert_eq!(retrieved.endpoint.as_deref(), Some("example.invalid:51820"));
assert_eq!(retrieved.interface_dns.as_deref(), Some("1.1.1.1"));
assert_eq!(retrieved.interface_mtu.as_deref(), Some("1380"));
assert_eq!(retrieved.allowed_ips, ["0.0.0.0/0", "::/0"]);
let debug = format!("{retrieved:?}");
for raw_value in [
psk.as_str(),
"10.0.0.2/32",
"1.1.1.1",
"1380",
"example.invalid:51820",
"0.0.0.0/0",
"25",
] {
assert!(!debug.contains(raw_value));
}
assert!(debug.contains("[REDACTED]"));
let _ = server_private;
}
#[test]
fn conflicts_invalid_duplicates_and_server_only_are_reported_and_blocked() {
let (client, server, psk) = generated();
let key = WireGuardPrivateKey::parse_base64(&client).unwrap();
let server_private = WireGuardPrivateKey::parse_base64(&server).unwrap();
let file = client_config(&client, &server, &psk, "10.0.0.9/32");
let doc = ConfigDocument::parse(server_config(&server, &key.public_key(), "10.0.0.2/32"))
.unwrap();
let report = reconcile(
&doc,
[
("one.conf".into(), file.as_bytes().to_vec()),
("two.conf".into(), file.as_bytes().to_vec()),
(
"bad.conf".into(),
b"[Interface]\nPrivateKey = nope\n".to_vec(),
),
],
);
let statuses = report.candidate_summaries();
assert_eq!(statuses[0].1, CandidateStatus::DuplicateFileIdentity);
assert_eq!(statuses[1].1, CandidateStatus::DuplicateFileIdentity);
assert!(matches!(statuses[2].1, CandidateStatus::Invalid(_)));
assert!(report.server_only().is_empty());
let single = reconcile(&doc, [("file.conf".into(), file.as_bytes().to_vec())]);
assert_eq!(
single.candidate_summaries()[0].1,
CandidateStatus::Matched {
address_discrepancy: true,
server_identity: ServerIdentityComparison::Matches,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
);
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
single.encrypt_candidate(&mut bundle, 0).unwrap();
assert_eq!(bundle.reissue_blocked(key.public_key()), Ok(true));
assert_eq!(
bundle.retrieve_record(key.public_key(), PASSWORD).err(),
Some(VaultError::ReissueBlocked)
);
let empty_doc =
ConfigDocument::parse(format!("[Interface]\nPrivateKey = {server}\n")).unwrap();
let file_only = reconcile(&empty_doc, [("file.conf".into(), file.as_bytes().to_vec())]);
assert_eq!(
file_only.candidate_summaries()[0].1,
CandidateStatus::FileOnly
);
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
file_only.encrypt_candidate(&mut bundle, 0).unwrap();
assert!(bundle.reissue_blocked(key.public_key()).unwrap());
let unused_key = server_private.public_key();
assert!(!report.server_only().contains(&unused_key));
}
#[test]
fn mutable_public_gate_cannot_authorize_reissue_and_duplicates_never_overwrite() {
let (client, server, psk) = generated();
let key = WireGuardPrivateKey::parse_base64(&client).unwrap();
let doc = ConfigDocument::parse(server_config(&server, &key.public_key(), "10.0.0.2/32"))
.unwrap();
let report = reconcile(
&doc,
[(
"file.conf".into(),
client_config(&client, &server, &psk, "10.0.0.3/32").into_bytes(),
)],
);
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
report.encrypt_candidate(&mut bundle, 0).unwrap();
let before = bundle.to_toml().unwrap();
assert_eq!(
bundle.encrypt_record(key.public_key(), &key),
Err(VaultError::DuplicateRecord)
);
let mut changed = before.replace("reissue_blocked = true", "reissue_blocked = false");
let loaded = VaultBundle::from_toml(&changed).unwrap();
assert_eq!(loaded.reissue_blocked(key.public_key()), Ok(false));
assert_eq!(
loaded.retrieve_record(key.public_key(), PASSWORD).err(),
Some(VaultError::ReissueBlocked)
);
changed.clear();
}
#[test]
fn parse_and_report_debug_are_redacted() {
let (client, server, psk) = generated();
let source = format!(
"{}PrivateKey = SYNTHETIC-MISPLACED-PRIVATE-VALUE\n",
client_config(&client, &server, &psk, "10.0.0.2/32")
);
let parsed = parse_client_config(source.as_bytes()).unwrap();
let debug = format!("{parsed:?}");
assert!(!debug.contains(&client));
assert!(!debug.contains(&psk));
assert!(!debug.contains("SYNTHETIC-MISPLACED-PRIVATE-VALUE"));
let malformed =
format!("{source}[Unknown]\nCredential = SYNTHETIC-REJECTED-SECRET-VALUE\n");
let error = parse_client_config(malformed.as_bytes()).unwrap_err();
assert_eq!(error, ClientFileIssue::InvalidStructure);
assert!(!format!("{error:?}").contains("SYNTHETIC-REJECTED-SECRET-VALUE"));
let invalid_psk = client_config(
&client,
&server,
"SYNTHETIC-INVALID-PSK-VALUE",
"10.0.0.2/32",
);
let error = parse_client_config(invalid_psk.as_bytes()).unwrap_err();
assert_eq!(error, ClientFileIssue::InvalidPresharedKey);
assert!(!format!("{error:?}").contains("SYNTHETIC-INVALID-PSK-VALUE"));
let report_debug = format!(
"{:?}",
reconcile(
&ConfigDocument::parse("[Interface]\n").unwrap(),
[("file".into(), malformed.into_bytes())]
)
);
assert!(!report_debug.contains("SYNTHETIC-MISPLACED-PRIVATE-VALUE"));
assert!(!report_debug.contains("SYNTHETIC-REJECTED-SECRET-VALUE"));
}
#[test]
fn report_separates_server_only_binding_and_projection_anomalies() {
let (client, server, psk) = generated();
let (_, other_server, _) = generated();
let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap();
let unmatched = WireGuardPrivateKey::generate().unwrap().public_key();
let config = format!(
"[Interface]\nPrivateKey = {server}\n# wg-admin:begin\ninvalid metadata\n# wg-admin:end\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.0.0.2/32\n[Peer]\nPublicKey = {unmatched}\nAllowedIPs = 10.9.0.1/24\n",
client_key.public_key()
);
let client_file = client_config(&client, &other_server, &psk, "10.0.0.2/24");
let report = reconcile(
&ConfigDocument::parse(config).unwrap(),
[("candidate.conf".into(), client_file.into_bytes())],
);
assert_eq!(report.server_only(), &[unmatched]);
assert_eq!(
report.candidate_summaries()[0].1,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: ServerIdentityComparison::Mismatch,
missing_metadata: false,
invalid_metadata: true,
unsupported_projection: true,
}
);
}
#[test]
fn unresolved_server_identity_is_distinct_and_blocks_reissue() {
let (client, server, psk) = generated();
let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap();
let server_doc = ConfigDocument::parse(format!(
"[Interface]\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.0.0.2/32\n",
client_key.public_key()
))
.unwrap();
let file = client_config(&client, &server, &psk, "10.0.0.2/32");
let report = reconcile(&server_doc, [("client.conf".into(), file.into_bytes())]);
assert_eq!(
report.candidate_summaries()[0].1,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: ServerIdentityComparison::Unresolved,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
);
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
report.encrypt_candidate(&mut bundle, 0).unwrap();
assert_eq!(bundle.reissue_blocked(client_key.public_key()), Ok(true));
assert_eq!(
bundle
.retrieve_record(client_key.public_key(), PASSWORD)
.err(),
Some(VaultError::ReissueBlocked)
);
}
#[test]
fn server_key_mismatch_alone_allows_encryption_but_blocks_reissue() {
let (client, server, psk) = generated();
let (_, other_server, _) = generated();
let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap();
let server_doc = ConfigDocument::parse(server_config(
&server,
&client_key.public_key(),
"10.0.0.2/32",
))
.unwrap();
let file = client_config(&client, &other_server, &psk, "10.0.0.2/32");
let report = reconcile(&server_doc, [("client.conf".into(), file.into_bytes())]);
assert_eq!(
report.candidate_summaries()[0].1,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: ServerIdentityComparison::Mismatch,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
);
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
assert!(report.encrypt_candidate(&mut bundle, 0).is_ok());
assert_eq!(bundle.record_count(), 1);
assert_eq!(bundle.reissue_blocked(client_key.public_key()), Ok(true));
assert_eq!(
bundle
.retrieve_record(client_key.public_key(), PASSWORD)
.err(),
Some(VaultError::ReissueBlocked)
);
}
}
+511
View File
@@ -0,0 +1,511 @@
//! Bounded, read-only access to configured client source files.
use std::ffi::OsString;
use std::fs::File;
use std::io::{Read, Take};
use std::path::{Path, PathBuf};
use rustix::fd::OwnedFd;
use rustix::fs::{self as rfs, FileType, Mode, OFlags, ResolveFlags};
use thiserror::Error;
use crate::config::ResolvedSettings;
use crate::wireguard::ConfigDocument;
use super::import::{ImportReport, reconcile, reconcile_zeroizing};
use super::{VaultBundle, VaultError};
use zeroize::Zeroizing;
/// Only immediate `*.conf` entries are candidates. Matching is ASCII-case-insensitive.
pub const MAX_CANDIDATE_BYTES: usize = 1024 * 1024;
pub const MAX_SCAN_BYTES: usize = 32 * 1024 * 1024;
pub const MAX_CANDIDATES: usize = 1024;
pub const MAX_DIRECTORY_ENTRIES: usize = 4096;
/// A source failure safe to show in a UI or log. It never contains I/O error text.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SourceFailureKind {
DirectoryUnavailable,
EnumerationFailed,
TooManyEntries,
FileUnavailable,
NotRegularFile,
FileTooLarge,
ScanTooLarge,
ReadFailed,
EncryptionFailed,
DuplicateRecordEqualityUnverified,
}
/// Per-file issue with a display-only filename and a redacted failure category.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourceIssue {
pub file_name: Option<String>,
pub kind: SourceFailureKind,
}
/// Directory scan and reconciliation result. `scan_complete` is false whenever
/// enumeration or a configured limit prevented a complete inventory.
pub struct SourceImportReport {
pub scan_complete: bool,
pub issues: Vec<SourceIssue>,
pub reconciliation: ImportReport,
}
impl std::fmt::Debug for SourceImportReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SourceImportReport")
.field("scan_complete", &self.scan_complete)
.field("issues", &self.issues)
.field("reconciliation", &self.reconciliation)
.finish()
}
}
/// Bundle prepared for a later explicit persistence/Commit operation.
pub struct PreparedImportSetup {
pub bundle: VaultBundle,
pub report: SourceImportReport,
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum SourceImportError {
#[error("vault setup failed")]
Vault(#[from] VaultError),
#[error("source directory is invalid")]
InvalidDirectory,
}
/// Create a new password-protected vault and encrypt valid candidates from the
/// configured source directory. This only returns prepared in-memory data.
pub fn prepare_initial_setup(
settings: &ResolvedSettings,
server: &ConfigDocument,
vault_password: &str,
) -> Result<PreparedImportSetup, SourceImportError> {
let mut bundle = VaultBundle::create(vault_password)?;
let report = import_directory(&mut bundle, settings, server)?;
Ok(PreparedImportSetup { bundle, report })
}
/// Explicitly import configured candidates into an existing prepared bundle.
/// It uses the bundle's public recipient and never unlocks or replaces its identity.
pub fn import_configured_directory(
bundle: &mut VaultBundle,
settings: &ResolvedSettings,
server: &ConfigDocument,
) -> Result<SourceImportReport, SourceImportError> {
import_directory(bundle, settings, server)
}
fn import_directory(
bundle: &mut VaultBundle,
settings: &ResolvedSettings,
server: &ConfigDocument,
) -> Result<SourceImportReport, SourceImportError> {
let source = &settings.client_source_directory;
if !source.is_absolute() {
return Err(SourceImportError::InvalidDirectory);
}
let directory = match open_source_directory(source) {
Ok(fd) => fd,
Err(_) => {
return Ok(SourceImportReport {
scan_complete: false,
issues: vec![SourceIssue {
file_name: None,
kind: SourceFailureKind::DirectoryUnavailable,
}],
reconciliation: reconcile(server, []),
});
}
};
let entries = match rfs::Dir::read_from(&directory) {
Ok(entries) => entries,
Err(_) => {
return Ok(SourceImportReport {
scan_complete: false,
issues: vec![SourceIssue {
file_name: None,
kind: SourceFailureKind::EnumerationFailed,
}],
reconciliation: reconcile(server, []),
});
}
};
Ok(import_open_directory(bundle, server, &directory, entries))
}
fn import_open_directory(
bundle: &mut VaultBundle,
server: &ConfigDocument,
directory: &OwnedFd,
mut entries: rfs::Dir,
) -> SourceImportReport {
let entries = std::iter::from_fn(move || {
entries.read().map(|entry| {
entry.map_err(std::io::Error::from).map(|entry| {
use std::os::unix::ffi::OsStrExt;
OsString::from(std::ffi::OsStr::from_bytes(entry.file_name().to_bytes()))
})
})
});
import_entries(bundle, server, directory, entries)
}
fn import_entries(
bundle: &mut VaultBundle,
server: &ConfigDocument,
directory: &OwnedFd,
entries: impl IntoIterator<Item = std::io::Result<OsString>>,
) -> SourceImportReport {
import_entries_with_limits(
bundle,
server,
directory,
entries,
MAX_SCAN_BYTES,
MAX_DIRECTORY_ENTRIES,
)
}
fn import_entries_with_limits(
bundle: &mut VaultBundle,
server: &ConfigDocument,
directory: &OwnedFd,
entries: impl IntoIterator<Item = std::io::Result<OsString>>,
max_scan_bytes: usize,
max_directory_entries: usize,
) -> SourceImportReport {
let mut complete = true;
let mut issues = Vec::new();
let mut candidates = Vec::new();
let mut aggregate_bytes = 0usize;
let mut candidate_count = 0usize;
for (examined_entries, entry) in entries.into_iter().enumerate() {
if examined_entries >= max_directory_entries {
complete = false;
issues.push(SourceIssue {
file_name: None,
kind: SourceFailureKind::TooManyEntries,
});
break;
}
let entry = match entry {
Ok(entry) => entry,
Err(_) => {
complete = false;
issues.push(SourceIssue {
file_name: None,
kind: SourceFailureKind::EnumerationFailed,
});
continue;
}
};
let name = entry;
if !is_candidate_name(&name) {
continue;
}
if candidate_count >= MAX_CANDIDATES {
complete = false;
issues.push(SourceIssue {
file_name: None,
kind: SourceFailureKind::TooManyEntries,
});
break;
}
candidate_count += 1;
let display_name = safe_file_name(&name);
match read_candidate(directory, &name, aggregate_bytes, max_scan_bytes) {
Ok(bytes) => {
aggregate_bytes += bytes.len();
candidates.push((display_name, bytes));
}
Err(kind) => {
complete = false;
issues.push(SourceIssue {
file_name: Some(display_name),
kind,
});
}
}
}
let reconciliation = reconcile_zeroizing(server, candidates);
let summaries = reconciliation.candidate_summaries();
for (index, (name, status)) in summaries.iter().enumerate() {
if matches!(
status,
super::import::CandidateStatus::Invalid(_)
| super::import::CandidateStatus::DuplicateFileIdentity
) {
continue;
}
if let Err(error) = reconciliation.encrypt_candidate(bundle, index) {
complete = false;
issues.push(SourceIssue {
file_name: Some((*name).to_owned()),
kind: match error {
VaultError::DuplicateRecord => {
SourceFailureKind::DuplicateRecordEqualityUnverified
}
_ => SourceFailureKind::EncryptionFailed,
},
});
}
}
SourceImportReport {
scan_complete: complete,
issues,
reconciliation,
}
}
#[cfg(target_os = "linux")]
fn open_source_directory(path: &Path) -> std::io::Result<OwnedFd> {
rfs::openat2(
rfs::CWD,
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
)
.map_err(std::io::Error::from)
}
#[cfg(not(target_os = "linux"))]
fn open_source_directory(_path: &Path) -> std::io::Result<OwnedFd> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"secure directory opening is unavailable",
))
}
fn read_candidate(
directory: &OwnedFd,
name: &std::ffi::OsStr,
aggregate_bytes: usize,
max_scan_bytes: usize,
) -> Result<Zeroizing<Vec<u8>>, SourceFailureKind> {
let file = open_source_file(directory, name).map_err(|_| SourceFailureKind::FileUnavailable)?;
let stat = rfs::fstat(&file).map_err(|_| SourceFailureKind::FileUnavailable)?;
if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile {
return Err(SourceFailureKind::NotRegularFile);
}
let size = usize::try_from(stat.st_size).map_err(|_| SourceFailureKind::FileTooLarge)?;
if size > MAX_CANDIDATE_BYTES {
return Err(SourceFailureKind::FileTooLarge);
}
let remaining_scan_bytes = max_scan_bytes.saturating_sub(aggregate_bytes);
if size > remaining_scan_bytes {
return Err(SourceFailureKind::ScanTooLarge);
}
let mut file = File::from(file);
let mut bytes = Zeroizing::new(Vec::with_capacity(size.min(MAX_CANDIDATE_BYTES)));
let read_limit = MAX_CANDIDATE_BYTES.min(remaining_scan_bytes);
let mut reader: Take<&mut File> = (&mut file).take((read_limit + 1) as u64);
reader
.read_to_end(&mut bytes)
.map_err(|_| SourceFailureKind::ReadFailed)?;
if bytes.len() > MAX_CANDIDATE_BYTES {
return Err(SourceFailureKind::FileTooLarge);
}
if bytes.len() > remaining_scan_bytes {
return Err(SourceFailureKind::ScanTooLarge);
}
Ok(bytes)
}
#[cfg(target_os = "linux")]
fn open_source_file(directory: &OwnedFd, name: &std::ffi::OsStr) -> std::io::Result<OwnedFd> {
rfs::openat2(
directory,
PathBuf::from(name),
OFlags::RDONLY | OFlags::NONBLOCK | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
)
.map_err(std::io::Error::from)
}
#[cfg(not(target_os = "linux"))]
fn open_source_file(_directory: &OwnedFd, _name: &std::ffi::OsStr) -> std::io::Result<OwnedFd> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"secure file opening is unavailable",
))
}
fn is_candidate_name(name: &std::ffi::OsStr) -> bool {
Path::new(name)
.extension()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|extension| extension.eq_ignore_ascii_case("conf"))
}
fn safe_file_name(name: &std::ffi::OsStr) -> String {
let raw = name.to_string_lossy();
raw.chars()
.map(|character| {
if character.is_control() {
'\u{fffd}'
} else {
character
}
})
.take(255)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::WireGuardPrivateKey;
use std::fs;
#[test]
fn entry_enumeration_error_is_visible_and_other_files_still_import() {
let directory = tempfile::tempdir().unwrap();
let server_key = WireGuardPrivateKey::generate().unwrap();
let client_key = WireGuardPrivateKey::generate().unwrap();
let server = ConfigDocument::parse(
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n",
server_key.to_base64(),
client_key.public_key()
)
.as_bytes(),
)
.unwrap();
let client_bytes = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\n",
client_key.to_base64(),
server_key.public_key()
);
fs::write(directory.path().join("valid.conf"), &client_bytes).unwrap();
let dirfd = open_source_directory(directory.path()).unwrap();
let entries = [
Ok(OsString::from("valid.conf")),
Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
];
let mut bundle = VaultBundle::create("password").unwrap();
let report = import_entries(&mut bundle, &server, &dirfd, entries);
assert!(!report.scan_complete);
assert_eq!(bundle.record_count(), 1);
assert!(
report
.issues
.iter()
.any(|issue| issue.kind == SourceFailureKind::EnumerationFailed)
);
assert!(fs::read(directory.path().join("valid.conf")).unwrap() == client_bytes.as_bytes());
}
#[test]
fn enumeration_and_reads_stay_pinned_after_source_path_is_replaced() {
let parent = tempfile::tempdir().unwrap();
let source = parent.path().join("source");
let pinned_path = parent.path().join("pinned");
fs::create_dir(&source).unwrap();
let server_key = WireGuardPrivateKey::generate().unwrap();
let client_key = WireGuardPrivateKey::generate().unwrap();
let decoy_key = WireGuardPrivateKey::generate().unwrap();
let server = ConfigDocument::parse(format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n",
server_key.to_base64(), client_key.public_key()
))
.unwrap();
let bytes = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\n",
client_key.to_base64(),
server_key.public_key()
);
fs::write(source.join("client.conf"), &bytes).unwrap();
let directory = open_source_directory(&source).unwrap();
fs::rename(&source, &pinned_path).unwrap();
fs::create_dir(&source).unwrap();
fs::write(
source.join("replacement.conf"),
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.3/32\n\n[Peer]\nPublicKey = {}\n",
decoy_key.to_base64(),
server_key.public_key()
),
)
.unwrap();
let entries = rfs::Dir::read_from(&directory).unwrap();
let mut bundle = VaultBundle::create("password").unwrap();
let report = import_open_directory(&mut bundle, &server, &directory, entries);
assert!(report.scan_complete);
assert_eq!(bundle.record_count(), 1);
assert!(report.reconciliation.server_only().is_empty());
assert_eq!(
report.reconciliation.candidate_summaries()[0].1,
super::super::import::CandidateStatus::Matched {
address_discrepancy: false,
server_identity: super::super::import::ServerIdentityComparison::Matches,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
);
assert!(fs::read(pinned_path.join("client.conf")).unwrap() == bytes.as_bytes());
assert!(fs::read_dir(&source).unwrap().next().is_some());
}
#[test]
fn aggregate_scan_byte_limit_is_enforced_after_successful_reads() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("one.conf"), b"one").unwrap();
fs::write(directory.path().join("two.conf"), b"two").unwrap();
let server = ConfigDocument::parse("[Interface]\n").unwrap();
let dirfd = open_source_directory(directory.path()).unwrap();
let entries = [
Ok(OsString::from("one.conf")),
Ok(OsString::from("two.conf")),
];
let mut bundle = VaultBundle::create("password").unwrap();
let report = import_entries_with_limits(&mut bundle, &server, &dirfd, entries, 4, 10);
assert!(!report.scan_complete);
assert_eq!(
report.issues,
[SourceIssue {
file_name: Some("two.conf".to_owned()),
kind: SourceFailureKind::ScanTooLarge,
}]
);
}
#[test]
fn directory_entry_limit_marks_inventory_incomplete() {
let directory = tempfile::tempdir().unwrap();
let dirfd = open_source_directory(directory.path()).unwrap();
let server = ConfigDocument::parse("[Interface]\n").unwrap();
let entries = [
Ok(OsString::from("one.txt")),
Ok(OsString::from("two.txt")),
Ok(OsString::from("three.txt")),
];
let mut bundle = VaultBundle::create("password").unwrap();
let report = import_entries_with_limits(&mut bundle, &server, &dirfd, entries, 4, 2);
assert!(!report.scan_complete);
assert_eq!(
report.issues,
[SourceIssue {
file_name: None,
kind: SourceFailureKind::TooManyEntries,
}]
);
}
}
+233
View File
@@ -0,0 +1,233 @@
use std::fs;
use std::path::Path;
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
use wireguard_admin::domain::PublicKey;
use wireguard_admin::vault::import::{CandidateStatus, parse_client_config};
use wireguard_admin::vault::source::{
MAX_CANDIDATE_BYTES, SourceFailureKind, import_configured_directory, prepare_initial_setup,
};
use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey};
use wireguard_admin::wireguard::ConfigDocument;
fn settings(source: &Path) -> ResolvedSettings {
let raw = format!(
r#"
interface_name = "wg0"
server_config_path = "server.conf"
client_source_directory = {:?}
server_endpoint = "vpn.example:51820"
ipv4_allocation_range = "10.8.0.0/24"
ipv6_allocation_range = "fd00::/64"
dns_addresses = ["10.8.0.1"]
systemd_unit = "wg-quick@wg0.service"
restart_timeout_seconds = 30
"#,
source.display().to_string()
);
SettingsFile::parse_toml(&raw)
.unwrap()
.validate()
.unwrap()
.resolve_paths(Path::new("/synthetic/app"))
}
fn server_and_client() -> (ConfigDocument, WireGuardPrivateKey, PublicKey) {
let server_key = WireGuardPrivateKey::generate().unwrap();
let client_key = WireGuardPrivateKey::generate().unwrap();
let client_public = client_key.public_key();
let server = ConfigDocument::parse(
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n# admin note\n",
server_key.to_base64(),
client_public
)
.as_bytes(),
)
.unwrap();
(server, client_key, client_public)
}
fn client_file(client: &WireGuardPrivateKey, server: &ConfigDocument) -> Vec<u8> {
let server_key = server
.source_slice(server.sections()[0].span)
.unwrap()
.lines()
.find_map(|line| line.strip_prefix("PrivateKey = "))
.map(|value| {
WireGuardPrivateKey::parse_base64(value)
.unwrap()
.public_key()
})
.unwrap();
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example:51820\nAllowedIPs = 0.0.0.0/0, ::/0\n",
client.to_base64(), server_key
)
.into_bytes()
}
#[test]
fn initial_setup_encrypts_valid_files_and_leaves_sources_and_server_unchanged() {
let directory = tempfile::tempdir().unwrap();
let (server, client, identity) = server_and_client();
let original_server = server.render();
let source_bytes = client_file(&client, &server);
fs::write(directory.path().join("laptop.conf"), &source_bytes).unwrap();
fs::write(
directory.path().join("broken.conf"),
b"not a WireGuard file",
)
.unwrap();
fs::write(directory.path().join("ignored.txt"), &source_bytes).unwrap();
fs::create_dir(directory.path().join("nested")).unwrap();
fs::write(directory.path().join("nested/server.conf"), &source_bytes).unwrap();
let prepared =
prepare_initial_setup(&settings(directory.path()), &server, "setup password").unwrap();
assert!(prepared.report.scan_complete);
assert_eq!(prepared.bundle.record_count(), 1);
let statuses = prepared.report.reconciliation.candidate_summaries();
assert!(statuses.iter().any(|(_, status)| matches!(
status,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: wireguard_admin::vault::import::ServerIdentityComparison::Matches,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
)));
assert!(
statuses
.iter()
.any(|(_, status)| matches!(status, CandidateStatus::Invalid(_)))
);
let encoded = prepared.bundle.to_toml().unwrap();
assert!(!encoded.contains(&client.to_base64()));
let loaded = VaultBundle::from_toml(&encoded).unwrap();
assert_eq!(loaded.record_count(), 1);
assert!(loaded.contains_record(identity));
assert_eq!(
loaded
.retrieve_record(identity, "setup password")
.unwrap()
.public_key(),
identity
);
assert!(loaded.retrieve_record(identity, "wrong password").is_err());
assert!(fs::read(directory.path().join("laptop.conf")).unwrap() == source_bytes);
assert_eq!(
fs::read(directory.path().join("broken.conf")).unwrap(),
b"not a WireGuard file"
);
assert!(server.render() == original_server);
}
#[test]
fn symlinks_and_oversized_candidates_are_reported_without_import() {
let directory = tempfile::tempdir().unwrap();
let external = tempfile::NamedTempFile::new().unwrap();
fs::write(external.path(), b"outside source root").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(external.path(), directory.path().join("linked.conf")).unwrap();
fs::write(
directory.path().join("large.conf"),
vec![b'x'; MAX_CANDIDATE_BYTES + 1],
)
.unwrap();
let (server, _, _) = server_and_client();
let mut bundle = VaultBundle::create("password").unwrap();
let report =
import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap();
assert_eq!(bundle.record_count(), 0);
assert!(!report.scan_complete);
assert!(report.issues.iter().any(|issue| {
issue.kind == SourceFailureKind::FileTooLarge
&& issue.file_name.as_deref() == Some("large.conf")
}));
#[cfg(unix)]
assert!(report.issues.iter().any(|issue| {
issue.kind == SourceFailureKind::FileUnavailable
&& issue.file_name.as_deref() == Some("linked.conf")
}));
}
#[test]
fn enumeration_failure_is_visible_and_does_not_claim_a_complete_scan() {
let absent = tempfile::tempdir().unwrap().path().join("missing");
let (server, _, _) = server_and_client();
let mut bundle = VaultBundle::create("password").unwrap();
let report = import_configured_directory(&mut bundle, &settings(&absent), &server).unwrap();
assert!(!report.scan_complete);
assert_eq!(
report.issues[0].kind,
SourceFailureKind::DirectoryUnavailable
);
assert_eq!(bundle.record_count(), 0);
}
#[test]
fn candidate_count_limit_marks_the_scan_incomplete() {
let directory = tempfile::tempdir().unwrap();
for index in 0..=wireguard_admin::vault::source::MAX_CANDIDATES {
fs::write(
directory.path().join(format!("candidate-{index}.conf")),
b"bad",
)
.unwrap();
}
let (server, _, _) = server_and_client();
let mut bundle = VaultBundle::create("password").unwrap();
let report =
import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap();
assert!(!report.scan_complete);
assert!(
report
.issues
.iter()
.any(|issue| issue.kind == SourceFailureKind::TooManyEntries)
);
assert_eq!(bundle.record_count(), 0);
}
#[test]
fn explicit_import_uses_existing_identity_and_rejects_duplicate_without_replacement() {
let directory = tempfile::tempdir().unwrap();
let (server, client, identity) = server_and_client();
let initial_source = tempfile::tempdir().unwrap();
let initial =
prepare_initial_setup(&settings(initial_source.path()), &server, "password").unwrap();
let original_recipient = initial.bundle.recipient().to_owned();
let mut bundle = initial.bundle;
fs::write(
directory.path().join("client.conf"),
client_file(&client, &server),
)
.unwrap();
let report =
import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap();
assert_eq!(bundle.recipient(), original_recipient);
assert_eq!(bundle.record_count(), 1);
assert!(
report
.reconciliation
.candidate_summaries()
.iter()
.any(|(_, status)| matches!(status, CandidateStatus::Matched { .. }))
);
let before_repeat = bundle.to_toml().unwrap();
let repeated =
import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap();
assert_eq!(bundle.to_toml().unwrap(), before_repeat);
assert_eq!(bundle.record_count(), 1);
assert!(
repeated
.issues
.iter()
.any(|issue| issue.kind == SourceFailureKind::DuplicateRecordEqualityUnverified)
);
let result = bundle.retrieve_record(identity, "password").unwrap();
assert_eq!(result.public_key(), identity);
assert!(parse_client_config(&client_file(&client, &server)).is_ok());
}