Handle client keys, encrypted vault
This commit is contained in:
@@ -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.
|
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.
|
- 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.
|
- 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
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -14,9 +14,16 @@ name = "wireguard-admin"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
age = { version = "=0.12.1", default-features = false }
|
||||||
base64 = "=0.22.1"
|
base64 = "=0.22.1"
|
||||||
|
getrandom = "=0.3.4"
|
||||||
ipnet = "=2.11.0"
|
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"] }
|
serde = { version = "=1.0.229", features = ["derive"] }
|
||||||
thiserror = "=2.0.20"
|
thiserror = "=2.0.20"
|
||||||
toml = "=1.1.8"
|
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"
|
||||||
|
|||||||
@@ -266,11 +266,11 @@ No Alpine build, container tunnel, or VM/systemd check is claimed by M1 acceptan
|
|||||||
|
|
||||||
#### Review and repair
|
#### Review and repair
|
||||||
|
|
||||||
| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State |
|
| 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-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-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 |
|
| 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
|
#### 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
|
### 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
|
### 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
|
## 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).
|
- The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md).
|
||||||
- Milestone 1 is expanded and the Rust environment is pinned.
|
- Milestone 1 is expanded and the Rust environment is pinned.
|
||||||
- Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests.
|
- Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests.
|
||||||
- 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.
|
- M1 integrated gates and independent re-review passed. No container or VM results are claimed.
|
||||||
|
|
||||||
## Plan maintenance
|
## Plan maintenance
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# wireguard-admin
|
# wireguard-admin
|
||||||
|
|
||||||
This repository contains the initial Rust package, startup boundary, validated
|
This repository contains the Rust package, startup boundary, validated settings
|
||||||
settings loader, client domain model, and a pure in-memory WireGuard server
|
loader, client domain model, a byte-preserving in-memory WireGuard configuration
|
||||||
configuration parser/editor. The terminal management interface, file
|
editor, and library APIs for encrypted client vaults and read-only client import.
|
||||||
persistence, vault, client import workflow, backups, and VPN operations have not
|
The terminal management interface, installed vault persistence, backups, and VPN
|
||||||
been implemented yet.
|
operations have not been implemented yet.
|
||||||
|
|
||||||
The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From
|
The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From
|
||||||
the repository root, run:
|
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
|
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.
|
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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
pub mod startup;
|
pub mod startup;
|
||||||
|
pub mod vault;
|
||||||
pub mod wireguard;
|
pub mod wireguard;
|
||||||
|
|||||||
+723
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user