Implement session-owned first delivery and password reissue scenarios
This commit is contained in:
@@ -133,7 +133,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
|
||||
| 21 | Domain ownership of reservations and apply knowledge | 20 | accepted |
|
||||
| 22 | Transactional draft editing and client creation vertical slice | 21 | accepted |
|
||||
| 23 | Readable saved-state Commit and restore scenarios | 22 | accepted |
|
||||
| 24 | Session-owned first delivery and password reissue scenarios | 23 | pending |
|
||||
| 24 | Session-owned first delivery and password reissue scenarios | 23 | accepted |
|
||||
| 25 | Explicit restart verification scenario | 24 | pending |
|
||||
| 26 | Application read models and thin TUI command adapter | 25 | pending |
|
||||
| 27 | Integrated refactoring acceptance and architecture handoff | 26 | pending |
|
||||
@@ -4043,7 +4043,7 @@ M23.1 and M23.2 focused checks passed. Fresh reviewer found M23-R1: over-reserva
|
||||
|
||||
### Milestone 24 — Session-owned first delivery and password reissue scenarios
|
||||
|
||||
Status: pending. Prerequisite: M23 accepted.
|
||||
Status: accepted (2026-10-30T21:24:34+02:00; independent security-path review, M24-R1 re-review and integrated Rust gates passed). Prerequisite: M23 accepted. M1–8 and M10–23 are accepted; M9 is retired by user direction and is not a prerequisite. The user confirmed this session's GPT-6-Sol/medium coordinator; `.codex/config.toml` specifies GPT-6-Luna/high workers, dispatched explicitly. Initial tree clean at `ed602f0`; pinned Rust/Cargo 1.97.1 environment verified.
|
||||
|
||||
#### Outcome and scope
|
||||
|
||||
@@ -4051,9 +4051,9 @@ Isolate the session-first-delivery state machine and expose readable authenticat
|
||||
|
||||
| Task | Owner | State | Bounded deliverable |
|
||||
| --- | --- | --- | --- |
|
||||
| M24.1 | implementer | pending | FirstDeliverySession eligibility, invalidation and retry transitions |
|
||||
| M24.2 | implementer | pending | First-file/QR and password-reissue scenario extraction |
|
||||
| M24.3 | reviewer; repairer; coordinator | pending | Secret/authorization/failure review, repair and RUST acceptance |
|
||||
| M24.1 | implementer `/root/m24_state` | complete | Private `FirstDeliverySession` owns issue keys, contexts, stages, tokens and transitions; app/client-delivery tests, fmt and Clippy passed |
|
||||
| M24.2 | implementer `/root/m24_scenarios` | complete | First-file/QR and password reissue moved to cohesive modules; all five focused commands, fmt, Clippy and diff check passed |
|
||||
| M24.3 | reviewer `/root/m24_review`; coordinator | complete | No source findings; M24-R1 evidence gap closed on independent re-review; integrated RUST passed |
|
||||
|
||||
1. Move InitialIssue/IssueStage/IssueContext/token bookkeeping and matching/promote/retain helpers under one private owner. Keep ephemeral private keys in zeroizing/secret wrappers with no ordinary Clone/Debug/serialization. Use M19's transition table rather than simplifying PendingDraft/AwaitingFirstFile/RetryAvailable/Authorizing/overwrite/manual-cleanup states into one bool.
|
||||
2. Expose narrow internal transitions for draft publication, successful Commit, discard/delete, changed peer/encrypted record, begin attempt and completed/failed publication. Tokens remain single-use, session-local, bound to client and saved context; no caller can manufacture a first-issue authorization. Preserve unrelated clients' rights when rename/restart does not invalidate their context.
|
||||
@@ -4063,11 +4063,11 @@ Isolate the session-first-delivery state machine and expose readable authenticat
|
||||
|
||||
#### Acceptance and validation
|
||||
|
||||
- [ ] M24-A1: all first-delivery transitions/invalidation rules have one owner; pre-Commit, stale, consumed and reopened sessions cannot deliver without authorization.
|
||||
- [ ] M24-A2: retry/uncertain publication/manual cleanup/bookkeeping failure and same-payload QR behavior remain covered; passwords and secrets absent from errors/Debug/projections.
|
||||
- [ ] M24-A3: reissue asks per client/action and respects import mismatch policy; independent security-relevant path review and RUST pass.
|
||||
- [x] M24-A1: all first-delivery transitions/invalidation rules have one owner; pre-Commit, stale, consumed and reopened sessions cannot deliver without authorization.
|
||||
- [x] M24-A2: retry/uncertain publication/manual cleanup/bookkeeping failure and same-payload QR behavior remain covered; passwords and secrets absent from errors/Debug/projections.
|
||||
- [x] M24-A3: reissue asks per client/action and respects import mismatch policy; independent security-relevant path review and RUST pass.
|
||||
|
||||
Focused commands: `cargo test --locked app::`, `cargo test --locked delivery::`, `cargo test --locked vault::`, `cargo test --locked --test client_delivery`, `cargo test --locked --test tui_workflow`. Evidence: docs/evidence/milestone-24.md. Outcome: not accepted. Handoff: M25 after acceptance.
|
||||
Focused commands `cargo test --locked app::` (67), `delivery::` (22), `vault::` (24), `--test client_delivery` (5) and `--test tui_workflow` (8) exited 0 on the integrated tree. Final coordinator `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (library 337 passed, 2 ignored; integration/doc tests passed), `cargo build --locked --release`, and `git diff --check` exited 0. Fresh reviewer found no source issue. M24-R1: specified evidence file absent; coordinator created it and the reviewer independently verified all source/artifact hashes, check records and limits, closing the finding. [M24 evidence](docs/evidence/milestone-24.md) identifies inputs and host/synthetic scope. No fresh Alpine or VM/systemd run is claimed. Outcome: accepted for M24 host/synthetic scope. Stop after M24; M25 starts only on a separate request.
|
||||
|
||||
### Milestone 25 — Explicit restart verification scenario
|
||||
|
||||
@@ -4159,7 +4159,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
|
||||
|
||||
## Current progress and resume point
|
||||
|
||||
- M23 is accepted for host/synthetic scope: saved-only Commit/backup/restore extraction, exact-host reservation repair, independent M23-R1/R2 closure and required Rust gates passed. [M23 evidence](docs/evidence/milestone-23.md) records final inputs and limits. M24–M27 remain pending; M9 remains retired/unaccepted and its real-system limitations remain unchanged.
|
||||
- M24 is accepted for host/synthetic scope: session-owned first-delivery transitions, first-file/QR and per-action password-reissue scenarios passed independent security-path review and required Rust gates. M24-R1 evidence gap was closed on re-review. [M24 evidence](docs/evidence/milestone-24.md) records final inputs and limits. M25–M27 remain pending; M9 remains retired/unaccepted and its real-system limitations remain unchanged.
|
||||
|
||||
- M18 was accepted on 2026-10-29: structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. At that handoff, M19 had not started.
|
||||
|
||||
@@ -4188,7 +4188,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
|
||||
|
||||
- Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope.
|
||||
- Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked.
|
||||
- M18–M23 handoffs remain complete. Stop after M23; begin M24.1 only on a separate request. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work.
|
||||
- M18–M24 handoffs remain complete. Stop after M24; begin M25.1 only on a separate request. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work.
|
||||
|
||||
## Plan maintenance
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ src/
|
||||
app.rs # Application operations and orchestration
|
||||
app/
|
||||
draft.rs # Coordinated in-memory document, client-state, and vault drafts
|
||||
first_delivery.rs # Session-local first-issue authorization and retry state
|
||||
first_file.rs # Password-free first-file and optional QR scenario
|
||||
reissue.rs # Per-action vault authentication and saved-client delivery
|
||||
commit.rs # Prepare, backup, install, and publish saved-state Commit
|
||||
restore.rs # Bound preview and confirmed saved-state replacement
|
||||
backups.rs # Manual/pre-operation backup and archive-list scenarios
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Milestone 24 evidence — session-owned delivery scenarios
|
||||
|
||||
Run date: 2026-10-30T21:23:54+02:00. Baseline commit: `ed602f0`; the working tree contains the uncommitted M24 source and documentation changes listed below, plus coordinator-owned `PLANS.md`. Rust/Cargo: 1.97.1, host native target. Checks used synthetic fixtures. No production configuration, keys, VPN, or host service was accessed.
|
||||
|
||||
## Integrated inputs
|
||||
|
||||
| Input | SHA-256 |
|
||||
| --- | --- |
|
||||
| `src/app.rs` | `a337a6c25db64f0a1422e0efa1bf67ecae06f7c59e74f6144d2964345d06117d` |
|
||||
| `src/app/first_delivery.rs` | `0814de038423a0b8886d216aa3cf2b0913337595fbde4624da93bdb610f1df62` |
|
||||
| `src/app/first_file.rs` | `770f833c99c42ac2b138a071bf4df26bbcaffcc431733380b846d600d8be8b6b` |
|
||||
| `src/app/reissue.rs` | `2bf5b2f967c55f08d53b6d1e5d3d727d2774d5ad97454b2c4eb2425fdacdd563` |
|
||||
| `src/app/commit.rs` | `eac859338dd11b8cb2cac8db2dc621d6862215998ad89701f6eadd6a6c2609d8` |
|
||||
| `src/app/restore.rs` | `1ac76556d31d3f1502c644195c3f479fa092c321ebb53e671911a632ecc91f35` |
|
||||
| `docs/PROJECT_LAYOUT.md` | `302f2a7eb16def7e2c0810780a3e9805caea416915737ce4ab4033900a379306` |
|
||||
| `target/release/wireguard-admin` | `3aa6c56f32ed3da82c25c6da1f209c6dc55293998e497f57de0a2bd2c6c7a39a` |
|
||||
|
||||
No dependency, manifest, or lockfile changes. The release artifact hash identifies the binary built from the current integrated source; it is not a production deployment.
|
||||
|
||||
## Checks on integrated tree
|
||||
|
||||
| Command | Result |
|
||||
| --- | --- |
|
||||
| `cargo test --locked app::` | Exit 0; 67 app tests passed. |
|
||||
| `cargo test --locked delivery::` | Exit 0; 22 delivery tests passed. |
|
||||
| `cargo test --locked vault::` | Exit 0; 24 vault tests passed. |
|
||||
| `cargo test --locked --test client_delivery` | Exit 0; 5 tests passed. |
|
||||
| `cargo test --locked --test tui_workflow` | Exit 0; 8 tests passed. |
|
||||
| `cargo fmt --all -- --check` | Exit 0. |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
|
||||
| `cargo test --locked` | Exit 0; library 337 passed, 2 ignored; integration and doc suites passed. Ignored Alpine suites were not selected. |
|
||||
| `cargo build --locked --release` | Exit 0. |
|
||||
| `git diff --check` | Exit 0. |
|
||||
|
||||
## Acceptance mapping and review
|
||||
|
||||
- **M24-A1:** `FirstDeliverySession` privately owns keys, bound context, stages and tokens. Existing app and client-delivery tests pass for pre-Commit, stale, consumed and reopened rights, Commit promotion, discard/delete, and independent rights across rename/restart.
|
||||
- **M24-A2:** App tests pass for retry to a different directory, uncertain publication and explicit overwrite/manual cleanup acknowledgement, post-publication bookkeeping uncertainty, activation warnings, same-payload file/QR, and independent QR failure. The new owner and authorization have no ordinary `Debug`, `Clone`, or serialization; public errors and projections remain redacted.
|
||||
- **M24-A3:** Reissue reads installed saved state, unlocks the selected record with the password supplied for that action, renders authoritative server values, writes the mandatory file before optional QR, and retains imported-record mismatch checks. `client_delivery` and vault tests pass.
|
||||
|
||||
Fresh independent reviewer `/root/m24_review` examined the combined implementation and ran the focused suites, formatting, Clippy, full tests, release build and diff check. No source correctness or security finding remained. **M24-R1** was a missing evidence record named by `PLANS.md`; the coordinator created this file after independently rerunning the required checks. Independent re-review of this record and its source hashes remains the next action before acceptance.
|
||||
|
||||
This is host/synthetic evidence only. No fresh Alpine Testcontainers scenario or disposable systemd VM test was run; Alpine evidence would not establish systemd behavior. The retired M9 real-system acceptance limitation remains outside M24.
|
||||
+59
-548
@@ -7,9 +7,13 @@
|
||||
mod backups;
|
||||
mod commit;
|
||||
mod draft;
|
||||
mod first_delivery;
|
||||
mod first_file;
|
||||
mod reissue;
|
||||
mod restore;
|
||||
|
||||
use draft::DraftChanges;
|
||||
use first_delivery::{FirstDeliverySession, FirstIssueAuthorization};
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::ffi::OsStr;
|
||||
@@ -22,9 +26,8 @@ use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::backup::{self, BackupError, BackupReason, HistoricalApplyStatus, SavedBytes};
|
||||
use crate::delivery::files::{self, FileOutputError, PreparedDestination};
|
||||
use crate::delivery::qr;
|
||||
use crate::delivery::{self, ClientConfigPayload, DeliverySettings, QrCodeOutput, QrError};
|
||||
use crate::delivery::files::FileOutputError;
|
||||
use crate::delivery::{self, DeliverySettings, QrCodeOutput, QrError};
|
||||
use crate::domain::{
|
||||
AddressAvailability, ApplyStatus, Client, ClientAddresses, ClientStates, DomainError,
|
||||
PublicKey, Snapshot, allocate_ipv4, allocate_ipv6, validate_manual_ipv4, validate_manual_ipv6,
|
||||
@@ -90,40 +93,7 @@ pub struct Application {
|
||||
verified_saved_projection: Option<SavedPeerProjection>,
|
||||
active_restart_attempt: Option<u64>,
|
||||
next_restart_attempt: u64,
|
||||
next_issue_authorization: u64,
|
||||
initial_issues: BTreeMap<PublicKey, InitialIssue>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // M6.3 consumes the capability and its session-held key.
|
||||
struct InitialIssue {
|
||||
private_key: WireGuardPrivateKey,
|
||||
context: IssueContext,
|
||||
stage: IssueStage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum IssueStage {
|
||||
PendingDraft,
|
||||
AwaitingFirstFile,
|
||||
RetryAvailable,
|
||||
Authorizing(u64),
|
||||
RetryNeedsOverwriteConfirmation,
|
||||
RetryNeedsManualCleanup,
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
struct IssueContext {
|
||||
peer_directives: String,
|
||||
encrypted_record: String,
|
||||
}
|
||||
|
||||
/// Private, operation-local authorization for a single first-file publication.
|
||||
/// Plaintext is never returned to a caller outside `deliver_first_issue`.
|
||||
struct FirstIssueAuthorization {
|
||||
identity: PublicKey,
|
||||
context: IssueContext,
|
||||
token: u64,
|
||||
payload: ClientConfigPayload,
|
||||
first_delivery: FirstDeliverySession,
|
||||
}
|
||||
|
||||
/// How a failed file operation affected publication. The caller must report
|
||||
@@ -489,7 +459,7 @@ impl fmt::Debug for Application {
|
||||
.pending_host_routes()
|
||||
.count(),
|
||||
)
|
||||
.field("initial_issue_count", &self.initial_issues.len())
|
||||
.field("initial_issue_count", &self.first_delivery.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -543,8 +513,7 @@ impl Application {
|
||||
verified_saved_projection: None,
|
||||
active_restart_attempt: None,
|
||||
next_restart_attempt: 1,
|
||||
next_issue_authorization: 1,
|
||||
initial_issues: BTreeMap::new(),
|
||||
first_delivery: FirstDeliverySession::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -623,7 +592,7 @@ impl Application {
|
||||
next_states.stage_client(client)?;
|
||||
let replacement = DraftChanges::stage_clients(edited, next_states);
|
||||
self.draft.publish_replacement(replacement);
|
||||
self.invalidate_active_issue_authorizations();
|
||||
self.first_delivery.invalidate_active_authorizations();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -704,291 +673,15 @@ impl Application {
|
||||
let next_document = ConfigDocument::parse(next_document.render())?;
|
||||
ensure_document_client(&next_document, &client)?;
|
||||
next_vault.to_toml()?;
|
||||
let context = issue_context(&next_document, &next_vault, identity)?;
|
||||
let context = first_delivery::context_for(&next_document, &next_vault, identity)?;
|
||||
let replacement =
|
||||
DraftChanges::stage_clients_and_vault(next_document, next_states, Some(next_vault));
|
||||
self.invalidate_active_issue_authorizations();
|
||||
self.first_delivery.invalidate_active_authorizations();
|
||||
self.draft.publish_replacement(replacement);
|
||||
self.initial_issues.insert(
|
||||
identity,
|
||||
InitialIssue {
|
||||
private_key: key,
|
||||
context,
|
||||
stage: IssueStage::PendingDraft,
|
||||
},
|
||||
);
|
||||
self.first_delivery.remember_pending(identity, key, context);
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Publish a password-free first issue while the committed peer, encrypted
|
||||
/// record, and one-use token are valid. The mutable app borrow remains held
|
||||
/// across `publish`, so no application mutation can interleave after the
|
||||
/// final saved-state validation and before the file adapter runs.
|
||||
#[allow(dead_code)] // M6.3 invokes this for its initial-file action.
|
||||
pub(crate) fn deliver_first_issue<T, E>(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
publish: impl FnOnce(&ClientConfigPayload) -> Result<T, (E, FirstIssueDeliveryFailure)>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
|
||||
self.deliver_first_issue_with_warning(identity, attempt, |payload, _| publish(payload))
|
||||
}
|
||||
|
||||
fn deliver_first_issue_with_warning<T, E>(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
publish: impl FnOnce(&ClientConfigPayload, bool) -> Result<T, (E, FirstIssueDeliveryFailure)>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
|
||||
let (is_retry, confirm_possible_overwrite, confirm_manual_cleanup) = match attempt {
|
||||
FirstIssueAttempt::Initial => (false, false, false),
|
||||
FirstIssueAttempt::Retry {
|
||||
confirm_possible_overwrite,
|
||||
confirm_manual_cleanup,
|
||||
} => (true, confirm_possible_overwrite, confirm_manual_cleanup),
|
||||
};
|
||||
let authorization = self.authorize_first_issue_inner(
|
||||
identity,
|
||||
is_retry,
|
||||
confirm_possible_overwrite,
|
||||
confirm_manual_cleanup,
|
||||
)?;
|
||||
let activation_warning = match self.validate_first_issue_authorization(&authorization) {
|
||||
Ok(warning) => warning,
|
||||
Err(error) => {
|
||||
self.require_overwrite_confirmation(identity);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
match publish(&authorization.payload, activation_warning) {
|
||||
Ok(value) => {
|
||||
if let Err(error) = self.mark_first_issue_delivered(authorization) {
|
||||
// Publication is definite. Drop the session-held key even
|
||||
// when saved-state bookkeeping cannot be completed.
|
||||
self.initial_issues.remove(&identity);
|
||||
return Ok(
|
||||
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
|
||||
value,
|
||||
error,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(FirstIssueDeliveryOutcome::Delivered(value))
|
||||
}
|
||||
Err((error, failure)) => {
|
||||
let retry = match self.mark_first_issue_delivery_failed(authorization, failure) {
|
||||
Ok(retry) => retry,
|
||||
Err(error) => {
|
||||
self.require_overwrite_confirmation(identity);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
Ok(FirstIssueDeliveryOutcome::Failed {
|
||||
error,
|
||||
retry,
|
||||
activation_warning,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare a caller-selected plaintext destination without writing it. The
|
||||
/// returned destination records any existing regular file so a later
|
||||
/// overwrite confirmation is bound to this exact selection.
|
||||
pub fn prepare_first_issue_destination(
|
||||
&self,
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<PreparedDestination, FileOutputError> {
|
||||
let protection = self.store.delivery_output_protection()?;
|
||||
files::prepare_destination(path, &protection)
|
||||
}
|
||||
|
||||
/// Deliver the committed new-client configuration through the app-owned
|
||||
/// one-use authorization closure. A failed write retains retry eligibility;
|
||||
/// a post-publication sync failure requires an explicit overwrite retry.
|
||||
pub fn deliver_first_issue_file(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_file_with(identity, attempt, destination, files::publish)
|
||||
}
|
||||
|
||||
fn deliver_first_issue_file_with(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
publish_file: impl FnOnce(
|
||||
PreparedDestination,
|
||||
&ClientConfigPayload,
|
||||
) -> Result<(), FileOutputError>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| {
|
||||
publish_file(destination, payload).map_err(|error| {
|
||||
let failure = match error {
|
||||
FileOutputError::PublicationUncertain => {
|
||||
FirstIssueDeliveryFailure::PublicationUncertain
|
||||
}
|
||||
FileOutputError::ManualCleanupRequired => {
|
||||
FirstIssueDeliveryFailure::CleanupRequired
|
||||
}
|
||||
_ => FirstIssueDeliveryFailure::BeforePublication,
|
||||
};
|
||||
(error, failure)
|
||||
})?;
|
||||
Ok(FirstIssueFileDelivery { activation_warning })
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish the mandatory plaintext file, then optionally encode a QR from
|
||||
/// that same authorized payload. QR failure is returned separately and the
|
||||
/// successful file publication still consumes first-issue eligibility.
|
||||
pub fn deliver_first_issue_file_and_qr(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_file_and_qr_with(identity, attempt, destination, |payload| {
|
||||
qr::encode_payload(payload)
|
||||
})
|
||||
}
|
||||
|
||||
fn deliver_first_issue_file_and_qr_with(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
encode: impl FnOnce(&ClientConfigPayload) -> Result<QrCodeOutput, QrError>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_file_and_qr_with_publisher(
|
||||
identity,
|
||||
attempt,
|
||||
destination,
|
||||
files::publish,
|
||||
encode,
|
||||
)
|
||||
}
|
||||
|
||||
fn deliver_first_issue_file_and_qr_with_publisher(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
publish_file: impl FnOnce(
|
||||
PreparedDestination,
|
||||
&ClientConfigPayload,
|
||||
) -> Result<(), FileOutputError>,
|
||||
encode: impl FnOnce(&ClientConfigPayload) -> Result<QrCodeOutput, QrError>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| {
|
||||
publish_file(destination, payload).map_err(|error| {
|
||||
let failure = match error {
|
||||
FileOutputError::PublicationUncertain => {
|
||||
FirstIssueDeliveryFailure::PublicationUncertain
|
||||
}
|
||||
FileOutputError::ManualCleanupRequired => {
|
||||
FirstIssueDeliveryFailure::CleanupRequired
|
||||
}
|
||||
_ => FirstIssueDeliveryFailure::BeforePublication,
|
||||
};
|
||||
(error, failure)
|
||||
})?;
|
||||
Ok(FirstIssueFileAndQr {
|
||||
activation_warning,
|
||||
qr: encode(payload),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Reissue a saved client's configuration after freshly unlocking its
|
||||
/// installed vault record with this action's password. This operation reads
|
||||
/// only installed saved bytes, never uses a draft or retained source file,
|
||||
/// and publishes the required file before attempting optional QR encoding.
|
||||
pub fn reissue_client_file(
|
||||
&self,
|
||||
identity: PublicKey,
|
||||
password: &str,
|
||||
destination: PreparedDestination,
|
||||
include_qr: bool,
|
||||
) -> Result<ReissueDelivery, ReissueError> {
|
||||
let installed = self.store.read_saved().map_err(|_| ReissueError {
|
||||
stage: ReissueStage::SavedState,
|
||||
kind: ReissueFailureKind::SavedStateUnavailable,
|
||||
})?;
|
||||
let document =
|
||||
ConfigDocument::parse(&installed.server_config).map_err(|_| ReissueError {
|
||||
stage: ReissueStage::SavedState,
|
||||
kind: ReissueFailureKind::SavedStateUnavailable,
|
||||
})?;
|
||||
let encoded_vault = installed.vault.as_deref().ok_or(ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::VaultUnavailable,
|
||||
})?;
|
||||
let vault_text = std::str::from_utf8(encoded_vault).map_err(|_| ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::VaultUnavailable,
|
||||
})?;
|
||||
let vault = VaultBundle::from_toml(vault_text).map_err(|_| ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::VaultUnavailable,
|
||||
})?;
|
||||
// Retrieval performs the password unlock and authenticated conflict
|
||||
// check on every invocation. The short-lived owner is dropped when
|
||||
// this method returns and is never kept in Application state.
|
||||
let record = vault
|
||||
.retrieve_client_record(identity, password)
|
||||
.map_err(|_| ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::PasswordOrRecordRejected,
|
||||
})?;
|
||||
let resolved = self
|
||||
.store
|
||||
.resolved_settings_for(&installed)
|
||||
.map_err(|_| ReissueError {
|
||||
stage: ReissueStage::SavedState,
|
||||
kind: ReissueFailureKind::SavedStateUnavailable,
|
||||
})?;
|
||||
let delivery_settings = DeliverySettings {
|
||||
server_endpoint: &resolved.server_endpoint,
|
||||
dns_addresses: &resolved.dns_addresses,
|
||||
};
|
||||
let payload = if record.is_new_template() {
|
||||
delivery::render_new_client(
|
||||
identity,
|
||||
record.private_key().ok_or(ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::PasswordOrRecordRejected,
|
||||
})?,
|
||||
&document,
|
||||
&delivery_settings,
|
||||
)
|
||||
} else {
|
||||
delivery::render_imported_client(identity, &record, &document, &delivery_settings)
|
||||
}
|
||||
.map_err(|_| ReissueError {
|
||||
stage: ReissueStage::Rendering,
|
||||
kind: ReissueFailureKind::ConfigurationRejected,
|
||||
})?;
|
||||
|
||||
files::publish(destination, &payload).map_err(|error| ReissueError {
|
||||
stage: ReissueStage::FilePublication,
|
||||
kind: ReissueFailureKind::FileOutput(error),
|
||||
})?;
|
||||
|
||||
// A historical timestamp is deliberately insufficient: after reopen,
|
||||
// the runtime observation is unknown. Warn unless this live session
|
||||
// has verified the exact saved peer snapshot currently on disk.
|
||||
let activation_warning = !self.saved_generation_is_verified_applied(&installed, &document);
|
||||
let qr = include_qr.then(|| qr::encode_payload(&payload));
|
||||
Ok(ReissueDelivery {
|
||||
activation_warning,
|
||||
qr,
|
||||
})
|
||||
}
|
||||
|
||||
fn saved_generation_is_verified_applied(
|
||||
&self,
|
||||
installed: &SavedBytes,
|
||||
@@ -1013,7 +706,7 @@ impl Application {
|
||||
let installed = match self.store.read_saved() {
|
||||
Ok(saved) => saved,
|
||||
Err(error) => {
|
||||
self.initial_issues.clear();
|
||||
self.first_delivery.clear();
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
@@ -1026,44 +719,24 @@ impl Application {
|
||||
.map_err(|_| AppError::InvalidSavedState)?
|
||||
.map(VaultBundle::from_toml)
|
||||
.transpose()?;
|
||||
self.retain_matching_issues(&document, vault.as_ref());
|
||||
let current = issue_context(
|
||||
&document,
|
||||
vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?,
|
||||
authorization.identity,
|
||||
)?;
|
||||
if current != authorization.context
|
||||
|| !matches!(
|
||||
self.initial_issues.get(&authorization.identity),
|
||||
Some(issue)
|
||||
if issue.stage == IssueStage::Authorizing(authorization.token)
|
||||
&& issue.context == authorization.context
|
||||
)
|
||||
{
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
self.first_delivery
|
||||
.validate_authorization(authorization, &document, vault.as_ref())?;
|
||||
Ok(!self.saved_generation_is_verified_applied(&installed, &document))
|
||||
}
|
||||
|
||||
fn require_overwrite_confirmation(&mut self, identity: PublicKey) {
|
||||
if let Some(issue) = self.initial_issues.get_mut(&identity)
|
||||
&& matches!(issue.stage, IssueStage::Authorizing(_))
|
||||
{
|
||||
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
|
||||
}
|
||||
self.first_delivery.require_overwrite_confirmation(identity);
|
||||
}
|
||||
|
||||
fn authorize_first_issue_inner(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
is_retry: bool,
|
||||
confirm_possible_overwrite: bool,
|
||||
confirm_manual_cleanup: bool,
|
||||
attempt: FirstIssueAttempt,
|
||||
) -> Result<FirstIssueAuthorization, AppError> {
|
||||
let installed = match self.store.read_saved() {
|
||||
Ok(saved) => saved,
|
||||
Err(error) => {
|
||||
self.initial_issues.clear();
|
||||
self.first_delivery.clear();
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
@@ -1076,54 +749,26 @@ impl Application {
|
||||
.map_err(|_| AppError::InvalidSavedState)?
|
||||
.map(VaultBundle::from_toml)
|
||||
.transpose()?;
|
||||
self.retain_matching_issues(&document, vault.as_ref());
|
||||
let Some(issue) = self.initial_issues.get(&identity) else {
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
};
|
||||
let allowed = match issue.stage {
|
||||
IssueStage::AwaitingFirstFile => !is_retry,
|
||||
IssueStage::RetryAvailable => is_retry,
|
||||
IssueStage::RetryNeedsOverwriteConfirmation => is_retry && confirm_possible_overwrite,
|
||||
IssueStage::RetryNeedsManualCleanup => is_retry && confirm_manual_cleanup,
|
||||
IssueStage::PendingDraft | IssueStage::Authorizing(_) => false,
|
||||
};
|
||||
if !allowed {
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
let private_key = &issue.private_key;
|
||||
let context = issue.context.clone();
|
||||
let current_context = issue_context(
|
||||
let store = &self.store;
|
||||
self.first_delivery.begin_attempt(
|
||||
identity,
|
||||
attempt,
|
||||
&document,
|
||||
vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?,
|
||||
identity,
|
||||
)?;
|
||||
if current_context != context {
|
||||
self.initial_issues.remove(&identity);
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
let resolved = self.store.resolved_settings_for(&installed)?;
|
||||
let delivery_settings = DeliverySettings {
|
||||
server_endpoint: &resolved.server_endpoint,
|
||||
dns_addresses: &resolved.dns_addresses,
|
||||
};
|
||||
let payload =
|
||||
delivery::render_new_client(identity, private_key, &document, &delivery_settings)?;
|
||||
let token = self.next_issue_authorization;
|
||||
let next_token = token
|
||||
.checked_add(1)
|
||||
.ok_or(AppError::IssueAuthorizationExhausted)?;
|
||||
self.next_issue_authorization = next_token;
|
||||
let issue = self
|
||||
.initial_issues
|
||||
.get_mut(&identity)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?;
|
||||
issue.stage = IssueStage::Authorizing(token);
|
||||
Ok(FirstIssueAuthorization {
|
||||
identity,
|
||||
context,
|
||||
token,
|
||||
payload,
|
||||
})
|
||||
vault.as_ref(),
|
||||
|private_key| {
|
||||
let resolved = store.resolved_settings_for(&installed)?;
|
||||
let delivery_settings = DeliverySettings {
|
||||
server_endpoint: &resolved.server_endpoint,
|
||||
dns_addresses: &resolved.dns_addresses,
|
||||
};
|
||||
Ok(delivery::render_new_client(
|
||||
identity,
|
||||
private_key,
|
||||
&document,
|
||||
&delivery_settings,
|
||||
)?)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Consume first-issue eligibility after complete file publication.
|
||||
@@ -1134,7 +779,7 @@ impl Application {
|
||||
let installed = match self.store.read_saved() {
|
||||
Ok(saved) => saved,
|
||||
Err(error) => {
|
||||
self.initial_issues.clear();
|
||||
self.first_delivery.clear();
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
@@ -1147,24 +792,8 @@ impl Application {
|
||||
.map_err(|_| AppError::InvalidSavedState)?
|
||||
.map(VaultBundle::from_toml)
|
||||
.transpose()?;
|
||||
self.retain_matching_issues(&document, vault.as_ref());
|
||||
let current = issue_context(
|
||||
&document,
|
||||
vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?,
|
||||
authorization.identity,
|
||||
)?;
|
||||
let matches_authorization = matches!(
|
||||
self.initial_issues.get(&authorization.identity),
|
||||
Some(issue)
|
||||
if issue.stage == IssueStage::Authorizing(authorization.token)
|
||||
&& issue.context == authorization.context
|
||||
&& issue.context == current
|
||||
);
|
||||
if !matches_authorization {
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
self.initial_issues.remove(&authorization.identity);
|
||||
Ok(())
|
||||
self.first_delivery
|
||||
.complete_publication(authorization, &document, vault.as_ref())
|
||||
}
|
||||
|
||||
/// Record failed publication and explicitly return the session to a retry
|
||||
@@ -1177,7 +806,7 @@ impl Application {
|
||||
let installed = match self.store.read_saved() {
|
||||
Ok(saved) => saved,
|
||||
Err(error) => {
|
||||
self.initial_issues.clear();
|
||||
self.first_delivery.clear();
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
@@ -1190,49 +819,8 @@ impl Application {
|
||||
.map_err(|_| AppError::InvalidSavedState)?
|
||||
.map(VaultBundle::from_toml)
|
||||
.transpose()?;
|
||||
self.retain_matching_issues(&document, vault.as_ref());
|
||||
let current = issue_context(
|
||||
&document,
|
||||
vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?,
|
||||
authorization.identity,
|
||||
)?;
|
||||
let Some(issue) = self.initial_issues.get_mut(&authorization.identity) else {
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
};
|
||||
if issue.stage != IssueStage::Authorizing(authorization.token)
|
||||
|| issue.context != authorization.context
|
||||
|| issue.context != current
|
||||
{
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
let retry = match failure {
|
||||
FirstIssueDeliveryFailure::BeforePublication => {
|
||||
issue.stage = IssueStage::RetryAvailable;
|
||||
FirstIssueRetry::Available
|
||||
}
|
||||
FirstIssueDeliveryFailure::PublicationUncertain => {
|
||||
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
|
||||
FirstIssueRetry::OverwriteConfirmationRequired
|
||||
}
|
||||
FirstIssueDeliveryFailure::CleanupRequired => {
|
||||
issue.stage = IssueStage::RetryNeedsManualCleanup;
|
||||
FirstIssueRetry::ManualCleanupRequired
|
||||
}
|
||||
};
|
||||
Ok(retry)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used by the gated M6.3 capability methods.
|
||||
fn retain_matching_issues(&mut self, document: &ConfigDocument, vault: Option<&VaultBundle>) {
|
||||
retain_matching_issue_map(&mut self.initial_issues, document, vault);
|
||||
}
|
||||
|
||||
fn invalidate_active_issue_authorizations(&mut self) {
|
||||
for issue in self.initial_issues.values_mut() {
|
||||
if matches!(issue.stage, IssueStage::Authorizing(_)) {
|
||||
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
|
||||
}
|
||||
}
|
||||
self.first_delivery
|
||||
.fail_publication(authorization, failure, &document, vault.as_ref())
|
||||
}
|
||||
|
||||
pub fn vault(&self) -> Option<&VaultBundle> {
|
||||
@@ -1295,7 +883,7 @@ impl Application {
|
||||
}
|
||||
let replacement = DraftChanges::stage_clients(edited, next_states);
|
||||
self.draft.publish_replacement(replacement);
|
||||
self.invalidate_active_issue_authorizations();
|
||||
self.first_delivery.invalidate_active_authorizations();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1340,8 +928,8 @@ impl Application {
|
||||
next_states.remove_draft_client(&identity);
|
||||
let replacement = DraftChanges::stage_clients_and_vault(edited, next_states, next_vault);
|
||||
self.draft.publish_replacement(replacement);
|
||||
self.invalidate_active_issue_authorizations();
|
||||
self.initial_issues.remove(&identity);
|
||||
self.first_delivery.invalidate_active_authorizations();
|
||||
self.first_delivery.remove(&identity);
|
||||
Ok(removed_record)
|
||||
}
|
||||
|
||||
@@ -1415,13 +1003,8 @@ impl Application {
|
||||
self.verified_saved_projection = None;
|
||||
}
|
||||
self.historical_apply = next_historical_apply;
|
||||
self.initial_issues
|
||||
.retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile);
|
||||
retain_matching_issue_map(
|
||||
&mut self.initial_issues,
|
||||
&self.draft.document,
|
||||
self.draft.vault.as_ref(),
|
||||
);
|
||||
self.first_delivery
|
||||
.discard_draft(&self.draft.document, self.draft.vault.as_ref());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1951,7 +1534,7 @@ impl Application {
|
||||
proposed.internal_state = Some(persisted.to_toml()?);
|
||||
self.store.validate_saved(&proposed)?;
|
||||
if let Err(error) = install(&self.store, &proposed, BackupComplete(())) {
|
||||
self.invalidate_active_issue_authorizations();
|
||||
self.first_delivery.invalidate_active_authorizations();
|
||||
match self.store.read_saved() {
|
||||
Ok(current) => {
|
||||
let document = ConfigDocument::parse(¤t.server_config).ok();
|
||||
@@ -1961,16 +1544,13 @@ impl Application {
|
||||
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
||||
.and_then(|text| VaultBundle::from_toml(text).ok());
|
||||
if let Some(document) = document {
|
||||
retain_matching_issue_map(
|
||||
&mut self.initial_issues,
|
||||
&document,
|
||||
vault.as_ref(),
|
||||
);
|
||||
self.first_delivery
|
||||
.retain_matching(&document, vault.as_ref());
|
||||
} else {
|
||||
self.initial_issues.clear();
|
||||
self.first_delivery.clear();
|
||||
}
|
||||
}
|
||||
Err(_) => self.initial_issues.clear(),
|
||||
Err(_) => self.first_delivery.clear(),
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
@@ -2042,75 +1622,6 @@ fn clone_saved(saved: &SavedBytes) -> SavedBytes {
|
||||
}
|
||||
}
|
||||
|
||||
fn retain_matching_issue_map(
|
||||
issues: &mut BTreeMap<PublicKey, InitialIssue>,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) {
|
||||
issues.retain(|identity, issue| {
|
||||
if issue.stage == IssueStage::PendingDraft {
|
||||
return true;
|
||||
}
|
||||
vault
|
||||
.and_then(|vault| issue_context(document, vault, *identity).ok())
|
||||
.is_some_and(|actual| actual == issue.context)
|
||||
});
|
||||
}
|
||||
|
||||
fn promote_matching_issue_map(
|
||||
issues: &mut BTreeMap<PublicKey, InitialIssue>,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) {
|
||||
issues.retain(|identity, issue| {
|
||||
let matches = vault
|
||||
.and_then(|vault| issue_context(document, vault, *identity).ok())
|
||||
.is_some_and(|actual| actual == issue.context);
|
||||
if matches && issue.stage == IssueStage::PendingDraft {
|
||||
issue.stage = IssueStage::AwaitingFirstFile;
|
||||
}
|
||||
matches
|
||||
});
|
||||
}
|
||||
|
||||
fn issue_context(
|
||||
document: &ConfigDocument,
|
||||
vault: &VaultBundle,
|
||||
identity: PublicKey,
|
||||
) -> Result<IssueContext, AppError> {
|
||||
let matching = document
|
||||
.peers()
|
||||
.iter()
|
||||
.filter(|peer| {
|
||||
peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let [peer] = matching.as_slice() else {
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
};
|
||||
if peer.identity != Some(identity) || !peer.issues.is_empty() {
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
// Alias/admin comments are descriptive metadata; only WireGuard directives
|
||||
// bind first-issue authority, so renames do not invalidate the creation.
|
||||
let peer_directives = document
|
||||
.source_slice(peer.section)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with(['#', ';']))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let encrypted_record = vault
|
||||
.record_context(identity)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?
|
||||
.to_owned();
|
||||
Ok(IssueContext {
|
||||
peer_directives,
|
||||
encrypted_record,
|
||||
})
|
||||
}
|
||||
|
||||
fn server_interface_addresses(document: &ConfigDocument) -> Result<Vec<IpAddr>, AppError> {
|
||||
let interfaces = document
|
||||
.sections()
|
||||
@@ -2556,12 +2067,12 @@ restart_timeout_seconds = 30
|
||||
let identity = app
|
||||
.prepare_new_client("shared alias", None, Some("2026-10-20T00:00:00Z"))
|
||||
.expect("new client is staged");
|
||||
assert!(app.initial_issues.contains_key(&identity));
|
||||
assert!(app.first_delivery.contains(&identity));
|
||||
assert!(matches!(
|
||||
app.prepare_new_client("invalid\nalias", None, None),
|
||||
Err(AppError::Domain(_))
|
||||
));
|
||||
assert!(app.initial_issues.contains_key(&identity));
|
||||
assert!(app.first_delivery.contains(&identity));
|
||||
let client = app.states().draft().get(&identity).expect("draft client");
|
||||
assert_eq!(client.addresses.ipv4, Ipv4Addr::new(10, 20, 0, 3));
|
||||
assert!(client.addresses.ipv6.is_some());
|
||||
@@ -2594,7 +2105,7 @@ restart_timeout_seconds = 30
|
||||
assert_eq!(app.document().render(), before_document);
|
||||
assert_eq!(app.states().draft(), &before_draft);
|
||||
assert!(app.vault().is_none());
|
||||
assert!(app.initial_issues.is_empty());
|
||||
assert!(app.first_delivery.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2654,7 +2165,7 @@ restart_timeout_seconds = 30
|
||||
.unwrap(),
|
||||
before_vault
|
||||
);
|
||||
assert!(app.initial_issues.contains_key(&identity));
|
||||
assert!(app.first_delivery.contains(&identity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3658,7 +3169,7 @@ restart_timeout_seconds = 30
|
||||
.unwrap(),
|
||||
before_vault
|
||||
);
|
||||
assert!(app.initial_issues.contains_key(&issue));
|
||||
assert!(app.first_delivery.contains(&issue));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+10
-14
@@ -12,10 +12,7 @@ use crate::persistence::{BackupComplete, PersistedState, PersistenceError, Saved
|
||||
use crate::vault::VaultBundle;
|
||||
use crate::wireguard::ConfigDocument;
|
||||
|
||||
use super::{
|
||||
AppError, Application, historical_apply_from_saved, promote_matching_issue_map,
|
||||
retain_matching_issue_map,
|
||||
};
|
||||
use super::{AppError, Application, historical_apply_from_saved};
|
||||
|
||||
struct PreparedCommit {
|
||||
proposed: SavedBytes,
|
||||
@@ -109,9 +106,10 @@ pub(super) fn commit(
|
||||
application.saved = prepared.proposed;
|
||||
application.draft.states = prepared.states;
|
||||
application.draft.document = prepared.document;
|
||||
application.invalidate_active_issue_authorizations();
|
||||
promote_matching_issue_map(
|
||||
&mut application.initial_issues,
|
||||
application
|
||||
.first_delivery
|
||||
.invalidate_active_authorizations();
|
||||
application.first_delivery.promote_matching(
|
||||
&application.draft.document,
|
||||
application.draft.vault.as_ref(),
|
||||
);
|
||||
@@ -131,15 +129,13 @@ fn retain_first_issue_rights_after_failed_install(application: &mut Application)
|
||||
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
||||
.and_then(|text| VaultBundle::from_toml(text).ok());
|
||||
if let Some(document) = document {
|
||||
retain_matching_issue_map(
|
||||
&mut application.initial_issues,
|
||||
&document,
|
||||
vault.as_ref(),
|
||||
);
|
||||
application
|
||||
.first_delivery
|
||||
.retain_matching(&document, vault.as_ref());
|
||||
} else {
|
||||
application.initial_issues.clear();
|
||||
application.first_delivery.clear();
|
||||
}
|
||||
}
|
||||
Err(_) => application.initial_issues.clear(),
|
||||
Err(_) => application.first_delivery.clear(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Session-local ownership of password-free first-delivery eligibility.
|
||||
//!
|
||||
//! The session keeps each generated private key beside the saved peer and
|
||||
//! encrypted-record context that authorizes its single first-file publication.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::delivery::ClientConfigPayload;
|
||||
use crate::domain::PublicKey;
|
||||
use crate::vault::{VaultBundle, WireGuardPrivateKey};
|
||||
use crate::wireguard::ConfigDocument;
|
||||
|
||||
use super::{AppError, FirstIssueAttempt, FirstIssueDeliveryFailure, FirstIssueRetry};
|
||||
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub(super) struct IssueContext {
|
||||
peer_directives: String,
|
||||
encrypted_record: String,
|
||||
}
|
||||
|
||||
pub(super) struct FirstDeliverySession {
|
||||
next_authorization: u64,
|
||||
issues: BTreeMap<PublicKey, InitialIssue>,
|
||||
}
|
||||
|
||||
struct InitialIssue {
|
||||
private_key: WireGuardPrivateKey,
|
||||
context: IssueContext,
|
||||
stage: IssueStage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum IssueStage {
|
||||
PendingDraft,
|
||||
AwaitingFirstFile,
|
||||
RetryAvailable,
|
||||
Authorizing(u64),
|
||||
RetryNeedsOverwriteConfirmation,
|
||||
RetryNeedsManualCleanup,
|
||||
}
|
||||
|
||||
pub(super) struct FirstIssueAuthorization {
|
||||
identity: PublicKey,
|
||||
context: IssueContext,
|
||||
token: u64,
|
||||
payload: ClientConfigPayload,
|
||||
}
|
||||
|
||||
impl FirstIssueAuthorization {
|
||||
pub(super) fn payload(&self) -> &ClientConfigPayload {
|
||||
&self.payload
|
||||
}
|
||||
}
|
||||
|
||||
impl FirstDeliverySession {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
next_authorization: 1,
|
||||
issues: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn len(&self) -> usize {
|
||||
self.issues.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.issues.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn contains(&self, identity: &PublicKey) -> bool {
|
||||
self.issues.contains_key(identity)
|
||||
}
|
||||
|
||||
pub(super) fn remember_pending(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
private_key: WireGuardPrivateKey,
|
||||
context: IssueContext,
|
||||
) {
|
||||
self.issues.insert(
|
||||
identity,
|
||||
InitialIssue {
|
||||
private_key,
|
||||
context,
|
||||
stage: IssueStage::PendingDraft,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn remove(&mut self, identity: &PublicKey) {
|
||||
self.issues.remove(identity);
|
||||
}
|
||||
|
||||
pub(super) fn clear(&mut self) {
|
||||
self.issues.clear();
|
||||
}
|
||||
|
||||
/// In-flight output can no longer be retried silently after a draft or
|
||||
/// runtime transition. Keep unrelated clients' rights intact.
|
||||
pub(super) fn invalidate_active_authorizations(&mut self) {
|
||||
for issue in self.issues.values_mut() {
|
||||
if matches!(issue.stage, IssueStage::Authorizing(_)) {
|
||||
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only already committed first-file rights when a draft is dropped.
|
||||
pub(super) fn discard_draft(&mut self, document: &ConfigDocument, vault: Option<&VaultBundle>) {
|
||||
self.issues
|
||||
.retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile);
|
||||
self.retain_matching(document, vault);
|
||||
}
|
||||
|
||||
pub(super) fn retain_matching(
|
||||
&mut self,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) {
|
||||
self.issues.retain(|identity, issue| {
|
||||
if issue.stage == IssueStage::PendingDraft {
|
||||
return true;
|
||||
}
|
||||
vault
|
||||
.and_then(|vault| context_for(document, vault, *identity).ok())
|
||||
.is_some_and(|actual| actual == issue.context)
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn promote_matching(
|
||||
&mut self,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) {
|
||||
self.issues.retain(|identity, issue| {
|
||||
let matches = vault
|
||||
.and_then(|vault| context_for(document, vault, *identity).ok())
|
||||
.is_some_and(|actual| actual == issue.context);
|
||||
if matches && issue.stage == IssueStage::PendingDraft {
|
||||
issue.stage = IssueStage::AwaitingFirstFile;
|
||||
}
|
||||
matches
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn begin_attempt(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
render: impl FnOnce(&WireGuardPrivateKey) -> Result<ClientConfigPayload, AppError>,
|
||||
) -> Result<FirstIssueAuthorization, AppError> {
|
||||
self.retain_matching(document, vault);
|
||||
let (is_retry, confirm_possible_overwrite, confirm_manual_cleanup) = match attempt {
|
||||
FirstIssueAttempt::Initial => (false, false, false),
|
||||
FirstIssueAttempt::Retry {
|
||||
confirm_possible_overwrite,
|
||||
confirm_manual_cleanup,
|
||||
} => (true, confirm_possible_overwrite, confirm_manual_cleanup),
|
||||
};
|
||||
let issue = self
|
||||
.issues
|
||||
.get(&identity)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?;
|
||||
let allowed = match issue.stage {
|
||||
IssueStage::AwaitingFirstFile => !is_retry,
|
||||
IssueStage::RetryAvailable => is_retry,
|
||||
IssueStage::RetryNeedsOverwriteConfirmation => is_retry && confirm_possible_overwrite,
|
||||
IssueStage::RetryNeedsManualCleanup => is_retry && confirm_manual_cleanup,
|
||||
IssueStage::PendingDraft | IssueStage::Authorizing(_) => false,
|
||||
};
|
||||
if !allowed {
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
let current_context = context_for(
|
||||
document,
|
||||
vault.ok_or(AppError::FirstIssueUnavailable)?,
|
||||
identity,
|
||||
)?;
|
||||
if current_context != issue.context {
|
||||
self.issues.remove(&identity);
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
let payload = render(&issue.private_key)?;
|
||||
let context = issue.context.clone();
|
||||
let token = self.next_authorization;
|
||||
let next_token = token
|
||||
.checked_add(1)
|
||||
.ok_or(AppError::IssueAuthorizationExhausted)?;
|
||||
let issue = self
|
||||
.issues
|
||||
.get_mut(&identity)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?;
|
||||
self.next_authorization = next_token;
|
||||
issue.stage = IssueStage::Authorizing(token);
|
||||
Ok(FirstIssueAuthorization {
|
||||
identity,
|
||||
context,
|
||||
token,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn validate_authorization(
|
||||
&mut self,
|
||||
authorization: &FirstIssueAuthorization,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) -> Result<(), AppError> {
|
||||
self.retain_matching(document, vault);
|
||||
let current = context_for(
|
||||
document,
|
||||
vault.ok_or(AppError::FirstIssueUnavailable)?,
|
||||
authorization.identity,
|
||||
)?;
|
||||
if current != authorization.context
|
||||
|| !matches!(
|
||||
self.issues.get(&authorization.identity),
|
||||
Some(issue)
|
||||
if issue.stage == IssueStage::Authorizing(authorization.token)
|
||||
&& issue.context == authorization.context
|
||||
)
|
||||
{
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn require_overwrite_confirmation(&mut self, identity: PublicKey) {
|
||||
if let Some(issue) = self.issues.get_mut(&identity)
|
||||
&& matches!(issue.stage, IssueStage::Authorizing(_))
|
||||
{
|
||||
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn complete_publication(
|
||||
&mut self,
|
||||
authorization: FirstIssueAuthorization,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) -> Result<(), AppError> {
|
||||
self.validate_authorization(&authorization, document, vault)?;
|
||||
self.issues.remove(&authorization.identity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn fail_publication(
|
||||
&mut self,
|
||||
authorization: FirstIssueAuthorization,
|
||||
failure: FirstIssueDeliveryFailure,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) -> Result<FirstIssueRetry, AppError> {
|
||||
self.validate_authorization(&authorization, document, vault)?;
|
||||
let issue = self
|
||||
.issues
|
||||
.get_mut(&authorization.identity)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?;
|
||||
let retry = match failure {
|
||||
FirstIssueDeliveryFailure::BeforePublication => {
|
||||
issue.stage = IssueStage::RetryAvailable;
|
||||
FirstIssueRetry::Available
|
||||
}
|
||||
FirstIssueDeliveryFailure::PublicationUncertain => {
|
||||
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
|
||||
FirstIssueRetry::OverwriteConfirmationRequired
|
||||
}
|
||||
FirstIssueDeliveryFailure::CleanupRequired => {
|
||||
issue.stage = IssueStage::RetryNeedsManualCleanup;
|
||||
FirstIssueRetry::ManualCleanupRequired
|
||||
}
|
||||
};
|
||||
Ok(retry)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn context_for(
|
||||
document: &ConfigDocument,
|
||||
vault: &VaultBundle,
|
||||
identity: PublicKey,
|
||||
) -> Result<IssueContext, AppError> {
|
||||
let matching = document
|
||||
.peers()
|
||||
.iter()
|
||||
.filter(|peer| {
|
||||
peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let [peer] = matching.as_slice() else {
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
};
|
||||
if peer.identity != Some(identity) || !peer.issues.is_empty() {
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
// Alias/admin comments are descriptive metadata; only WireGuard directives
|
||||
// bind first-issue authority, so renames do not invalidate the creation.
|
||||
let peer_directives = document
|
||||
.source_slice(peer.section)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with(['#', ';']))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let encrypted_record = vault
|
||||
.record_context(identity)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?
|
||||
.to_owned();
|
||||
Ok(IssueContext {
|
||||
peer_directives,
|
||||
encrypted_record,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Password-free first-file delivery after Commit, with optional QR disclosure.
|
||||
|
||||
use super::{
|
||||
AppError, Application, FirstIssueAttempt, FirstIssueDeliveryFailure, FirstIssueDeliveryOutcome,
|
||||
FirstIssueFileAndQr, FirstIssueFileDelivery,
|
||||
};
|
||||
use crate::delivery::files::{self, FileOutputError, PreparedDestination};
|
||||
use crate::delivery::qr;
|
||||
use crate::delivery::{ClientConfigPayload, QrCodeOutput, QrError};
|
||||
use crate::domain::PublicKey;
|
||||
|
||||
impl Application {
|
||||
/// Publish a password-free first issue while the committed peer, encrypted
|
||||
/// record, and one-use token are valid. The mutable app borrow remains held
|
||||
/// across `publish`, so no application mutation can interleave after the
|
||||
/// final saved-state validation and before the file adapter runs.
|
||||
#[allow(dead_code)] // M6.3 invokes this for its initial-file action.
|
||||
pub(crate) fn deliver_first_issue<T, E>(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
publish: impl FnOnce(&ClientConfigPayload) -> Result<T, (E, FirstIssueDeliveryFailure)>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
|
||||
self.deliver_first_issue_with_warning(identity, attempt, |payload, _| publish(payload))
|
||||
}
|
||||
|
||||
pub(super) fn deliver_first_issue_with_warning<T, E>(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
publish: impl FnOnce(&ClientConfigPayload, bool) -> Result<T, (E, FirstIssueDeliveryFailure)>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
|
||||
let authorization = self.authorize_first_issue_inner(identity, attempt)?;
|
||||
let activation_warning = match self.validate_first_issue_authorization(&authorization) {
|
||||
Ok(warning) => warning,
|
||||
Err(error) => {
|
||||
self.require_overwrite_confirmation(identity);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
match publish(authorization.payload(), activation_warning) {
|
||||
Ok(value) => {
|
||||
if let Err(error) = self.mark_first_issue_delivered(authorization) {
|
||||
// Publication is definite. Drop the session-held key even
|
||||
// when saved-state bookkeeping cannot be completed.
|
||||
self.first_delivery.remove(&identity);
|
||||
return Ok(
|
||||
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
|
||||
value,
|
||||
error,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(FirstIssueDeliveryOutcome::Delivered(value))
|
||||
}
|
||||
Err((error, failure)) => {
|
||||
let retry = match self.mark_first_issue_delivery_failed(authorization, failure) {
|
||||
Ok(retry) => retry,
|
||||
Err(error) => {
|
||||
self.require_overwrite_confirmation(identity);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
Ok(FirstIssueDeliveryOutcome::Failed {
|
||||
error,
|
||||
retry,
|
||||
activation_warning,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare a caller-selected plaintext destination without writing it. The
|
||||
/// returned destination records any existing regular file so a later
|
||||
/// overwrite confirmation is bound to this exact selection.
|
||||
pub fn prepare_first_issue_destination(
|
||||
&self,
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<PreparedDestination, FileOutputError> {
|
||||
let protection = self.store.delivery_output_protection()?;
|
||||
files::prepare_destination(path, &protection)
|
||||
}
|
||||
|
||||
/// Deliver the committed new-client configuration through the app-owned
|
||||
/// one-use authorization closure. A failed write retains retry eligibility;
|
||||
/// a post-publication sync failure requires an explicit overwrite retry.
|
||||
pub fn deliver_first_issue_file(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_file_with(identity, attempt, destination, files::publish)
|
||||
}
|
||||
|
||||
pub(super) fn deliver_first_issue_file_with(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
publish_file: impl FnOnce(
|
||||
PreparedDestination,
|
||||
&ClientConfigPayload,
|
||||
) -> Result<(), FileOutputError>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| {
|
||||
publish_file(destination, payload).map_err(|error| {
|
||||
let failure = match error {
|
||||
FileOutputError::PublicationUncertain => {
|
||||
FirstIssueDeliveryFailure::PublicationUncertain
|
||||
}
|
||||
FileOutputError::ManualCleanupRequired => {
|
||||
FirstIssueDeliveryFailure::CleanupRequired
|
||||
}
|
||||
_ => FirstIssueDeliveryFailure::BeforePublication,
|
||||
};
|
||||
(error, failure)
|
||||
})?;
|
||||
Ok(FirstIssueFileDelivery { activation_warning })
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish the mandatory plaintext file, then optionally encode a QR from
|
||||
/// that same authorized payload. QR failure is returned separately and the
|
||||
/// successful file publication still consumes first-issue eligibility.
|
||||
pub fn deliver_first_issue_file_and_qr(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_file_and_qr_with(identity, attempt, destination, |payload| {
|
||||
qr::encode_payload(payload)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn deliver_first_issue_file_and_qr_with(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
encode: impl FnOnce(&ClientConfigPayload) -> Result<QrCodeOutput, QrError>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_file_and_qr_with_publisher(
|
||||
identity,
|
||||
attempt,
|
||||
destination,
|
||||
files::publish,
|
||||
encode,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn deliver_first_issue_file_and_qr_with_publisher(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
publish_file: impl FnOnce(
|
||||
PreparedDestination,
|
||||
&ClientConfigPayload,
|
||||
) -> Result<(), FileOutputError>,
|
||||
encode: impl FnOnce(&ClientConfigPayload) -> Result<QrCodeOutput, QrError>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| {
|
||||
publish_file(destination, payload).map_err(|error| {
|
||||
let failure = match error {
|
||||
FileOutputError::PublicationUncertain => {
|
||||
FirstIssueDeliveryFailure::PublicationUncertain
|
||||
}
|
||||
FileOutputError::ManualCleanupRequired => {
|
||||
FirstIssueDeliveryFailure::CleanupRequired
|
||||
}
|
||||
_ => FirstIssueDeliveryFailure::BeforePublication,
|
||||
};
|
||||
(error, failure)
|
||||
})?;
|
||||
Ok(FirstIssueFileAndQr {
|
||||
activation_warning,
|
||||
qr: encode(payload),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Freshly authenticated publication of a saved client's configuration.
|
||||
|
||||
use super::{Application, ReissueDelivery, ReissueError, ReissueFailureKind, ReissueStage};
|
||||
use crate::delivery::files::{self, PreparedDestination};
|
||||
use crate::delivery::qr;
|
||||
use crate::delivery::{self, DeliverySettings};
|
||||
use crate::domain::PublicKey;
|
||||
use crate::vault::VaultBundle;
|
||||
use crate::wireguard::ConfigDocument;
|
||||
|
||||
impl Application {
|
||||
/// Reissue a saved client's configuration after freshly unlocking its
|
||||
/// installed vault record with this action's password. This operation reads
|
||||
/// only installed saved bytes, never uses a draft or retained source file,
|
||||
/// and publishes the required file before attempting optional QR encoding.
|
||||
pub fn reissue_client_file(
|
||||
&self,
|
||||
identity: PublicKey,
|
||||
password: &str,
|
||||
destination: PreparedDestination,
|
||||
include_qr: bool,
|
||||
) -> Result<ReissueDelivery, ReissueError> {
|
||||
let installed = self.store.read_saved().map_err(|_| ReissueError {
|
||||
stage: ReissueStage::SavedState,
|
||||
kind: ReissueFailureKind::SavedStateUnavailable,
|
||||
})?;
|
||||
let document =
|
||||
ConfigDocument::parse(&installed.server_config).map_err(|_| ReissueError {
|
||||
stage: ReissueStage::SavedState,
|
||||
kind: ReissueFailureKind::SavedStateUnavailable,
|
||||
})?;
|
||||
let encoded_vault = installed.vault.as_deref().ok_or(ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::VaultUnavailable,
|
||||
})?;
|
||||
let vault_text = std::str::from_utf8(encoded_vault).map_err(|_| ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::VaultUnavailable,
|
||||
})?;
|
||||
let vault = VaultBundle::from_toml(vault_text).map_err(|_| ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::VaultUnavailable,
|
||||
})?;
|
||||
// Retrieval performs the password unlock and authenticated conflict
|
||||
// check on every invocation. The short-lived owner is dropped when
|
||||
// this method returns and is never kept in Application state.
|
||||
let record = vault
|
||||
.retrieve_client_record(identity, password)
|
||||
.map_err(|_| ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::PasswordOrRecordRejected,
|
||||
})?;
|
||||
let resolved = self
|
||||
.store
|
||||
.resolved_settings_for(&installed)
|
||||
.map_err(|_| ReissueError {
|
||||
stage: ReissueStage::SavedState,
|
||||
kind: ReissueFailureKind::SavedStateUnavailable,
|
||||
})?;
|
||||
let delivery_settings = DeliverySettings {
|
||||
server_endpoint: &resolved.server_endpoint,
|
||||
dns_addresses: &resolved.dns_addresses,
|
||||
};
|
||||
let payload = if record.is_new_template() {
|
||||
delivery::render_new_client(
|
||||
identity,
|
||||
record.private_key().ok_or(ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::PasswordOrRecordRejected,
|
||||
})?,
|
||||
&document,
|
||||
&delivery_settings,
|
||||
)
|
||||
} else {
|
||||
delivery::render_imported_client(identity, &record, &document, &delivery_settings)
|
||||
}
|
||||
.map_err(|_| ReissueError {
|
||||
stage: ReissueStage::Rendering,
|
||||
kind: ReissueFailureKind::ConfigurationRejected,
|
||||
})?;
|
||||
|
||||
files::publish(destination, &payload).map_err(|error| ReissueError {
|
||||
stage: ReissueStage::FilePublication,
|
||||
kind: ReissueFailureKind::FileOutput(error),
|
||||
})?;
|
||||
|
||||
// A historical timestamp is deliberately insufficient: after reopen,
|
||||
// the runtime observation is unknown. Warn unless this live session
|
||||
// has verified the exact saved peer snapshot currently on disk.
|
||||
let activation_warning = !self.saved_generation_is_verified_applied(&installed, &document);
|
||||
let qr = include_qr.then(|| qr::encode_payload(&payload));
|
||||
Ok(ReissueDelivery {
|
||||
activation_warning,
|
||||
qr,
|
||||
})
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -125,7 +125,7 @@ pub(super) fn confirm_restore(
|
||||
// A partial journaled install may leave disk in a recovery-required
|
||||
// state. Revoke session-only delivery rights until the store is read
|
||||
// again and the actual installed generation is known.
|
||||
application.initial_issues.clear();
|
||||
application.first_delivery.clear();
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ pub(super) fn confirm_restore(
|
||||
application.draft.document = prepared.document;
|
||||
application.draft.vault = prepared.vault;
|
||||
application.draft.states = prepared.states;
|
||||
application.initial_issues.clear();
|
||||
application.first_delivery.clear();
|
||||
application.historical_apply = crate::backup::HistoricalApplyStatus::Unknown;
|
||||
application.verified_saved_projection = None;
|
||||
application.active_restart_attempt = None;
|
||||
|
||||
Reference in New Issue
Block a user