Implement session-owned first delivery and password reissue scenarios

This commit is contained in:
2026-10-30 21:25:49 +02:00
parent ed602f0596
commit 7cef400b9a
9 changed files with 726 additions and 575 deletions
+11 -11
View File
@@ -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 | | 21 | Domain ownership of reservations and apply knowledge | 20 | accepted |
| 22 | Transactional draft editing and client creation vertical slice | 21 | accepted | | 22 | Transactional draft editing and client creation vertical slice | 21 | accepted |
| 23 | Readable saved-state Commit and restore scenarios | 22 | 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 | | 25 | Explicit restart verification scenario | 24 | pending |
| 26 | Application read models and thin TUI command adapter | 25 | pending | | 26 | Application read models and thin TUI command adapter | 25 | pending |
| 27 | Integrated refactoring acceptance and architecture handoff | 26 | 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 ### 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 #### Outcome and scope
@@ -4051,9 +4051,9 @@ Isolate the session-first-delivery state machine and expose readable authenticat
| Task | Owner | State | Bounded deliverable | | Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| M24.1 | implementer | pending | FirstDeliverySession eligibility, invalidation and retry transitions | | 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 | pending | First-file/QR and password-reissue scenario extraction | | 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; repairer; coordinator | pending | Secret/authorization/failure review, repair and RUST acceptance | | 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. 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. 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 #### 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. - [x] 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. - [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.
- [ ] M24-A3: reissue asks per client/action and respects import mismatch policy; independent security-relevant path review and RUST pass. - [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 ### 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 ## 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. - 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 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. - 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 ## Plan maintenance
+3
View File
@@ -32,6 +32,9 @@ src/
app.rs # Application operations and orchestration app.rs # Application operations and orchestration
app/ app/
draft.rs # Coordinated in-memory document, client-state, and vault drafts 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 commit.rs # Prepare, backup, install, and publish saved-state Commit
restore.rs # Bound preview and confirmed saved-state replacement restore.rs # Bound preview and confirmed saved-state replacement
backups.rs # Manual/pre-operation backup and archive-list scenarios backups.rs # Manual/pre-operation backup and archive-list scenarios
+43
View File
@@ -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.
+54 -543
View File
@@ -7,9 +7,13 @@
mod backups; mod backups;
mod commit; mod commit;
mod draft; mod draft;
mod first_delivery;
mod first_file;
mod reissue;
mod restore; mod restore;
use draft::DraftChanges; use draft::DraftChanges;
use first_delivery::{FirstDeliverySession, FirstIssueAuthorization};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr; use std::ffi::OsStr;
@@ -22,9 +26,8 @@ use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339; use time::format_description::well_known::Rfc3339;
use crate::backup::{self, BackupError, BackupReason, HistoricalApplyStatus, SavedBytes}; use crate::backup::{self, BackupError, BackupReason, HistoricalApplyStatus, SavedBytes};
use crate::delivery::files::{self, FileOutputError, PreparedDestination}; use crate::delivery::files::FileOutputError;
use crate::delivery::qr; use crate::delivery::{self, DeliverySettings, QrCodeOutput, QrError};
use crate::delivery::{self, ClientConfigPayload, DeliverySettings, QrCodeOutput, QrError};
use crate::domain::{ use crate::domain::{
AddressAvailability, ApplyStatus, Client, ClientAddresses, ClientStates, DomainError, AddressAvailability, ApplyStatus, Client, ClientAddresses, ClientStates, DomainError,
PublicKey, Snapshot, allocate_ipv4, allocate_ipv6, validate_manual_ipv4, validate_manual_ipv6, PublicKey, Snapshot, allocate_ipv4, allocate_ipv6, validate_manual_ipv4, validate_manual_ipv6,
@@ -90,40 +93,7 @@ pub struct Application {
verified_saved_projection: Option<SavedPeerProjection>, verified_saved_projection: Option<SavedPeerProjection>,
active_restart_attempt: Option<u64>, active_restart_attempt: Option<u64>,
next_restart_attempt: u64, next_restart_attempt: u64,
next_issue_authorization: u64, first_delivery: FirstDeliverySession,
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,
} }
/// How a failed file operation affected publication. The caller must report /// How a failed file operation affected publication. The caller must report
@@ -489,7 +459,7 @@ impl fmt::Debug for Application {
.pending_host_routes() .pending_host_routes()
.count(), .count(),
) )
.field("initial_issue_count", &self.initial_issues.len()) .field("initial_issue_count", &self.first_delivery.len())
.finish() .finish()
} }
} }
@@ -543,8 +513,7 @@ impl Application {
verified_saved_projection: None, verified_saved_projection: None,
active_restart_attempt: None, active_restart_attempt: None,
next_restart_attempt: 1, next_restart_attempt: 1,
next_issue_authorization: 1, first_delivery: FirstDeliverySession::new(),
initial_issues: BTreeMap::new(),
}) })
} }
@@ -623,7 +592,7 @@ impl Application {
next_states.stage_client(client)?; next_states.stage_client(client)?;
let replacement = DraftChanges::stage_clients(edited, next_states); let replacement = DraftChanges::stage_clients(edited, next_states);
self.draft.publish_replacement(replacement); self.draft.publish_replacement(replacement);
self.invalidate_active_issue_authorizations(); self.first_delivery.invalidate_active_authorizations();
Ok(()) Ok(())
} }
@@ -704,291 +673,15 @@ impl Application {
let next_document = ConfigDocument::parse(next_document.render())?; let next_document = ConfigDocument::parse(next_document.render())?;
ensure_document_client(&next_document, &client)?; ensure_document_client(&next_document, &client)?;
next_vault.to_toml()?; 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 = let replacement =
DraftChanges::stage_clients_and_vault(next_document, next_states, Some(next_vault)); 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.draft.publish_replacement(replacement);
self.initial_issues.insert( self.first_delivery.remember_pending(identity, key, context);
identity,
InitialIssue {
private_key: key,
context,
stage: IssueStage::PendingDraft,
},
);
Ok(identity) 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( fn saved_generation_is_verified_applied(
&self, &self,
installed: &SavedBytes, installed: &SavedBytes,
@@ -1013,7 +706,7 @@ impl Application {
let installed = match self.store.read_saved() { let installed = match self.store.read_saved() {
Ok(saved) => saved, Ok(saved) => saved,
Err(error) => { Err(error) => {
self.initial_issues.clear(); self.first_delivery.clear();
return Err(error.into()); return Err(error.into());
} }
}; };
@@ -1026,44 +719,24 @@ impl Application {
.map_err(|_| AppError::InvalidSavedState)? .map_err(|_| AppError::InvalidSavedState)?
.map(VaultBundle::from_toml) .map(VaultBundle::from_toml)
.transpose()?; .transpose()?;
self.retain_matching_issues(&document, vault.as_ref()); self.first_delivery
let current = issue_context( .validate_authorization(authorization, &document, vault.as_ref())?;
&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);
}
Ok(!self.saved_generation_is_verified_applied(&installed, &document)) Ok(!self.saved_generation_is_verified_applied(&installed, &document))
} }
fn require_overwrite_confirmation(&mut self, identity: PublicKey) { fn require_overwrite_confirmation(&mut self, identity: PublicKey) {
if let Some(issue) = self.initial_issues.get_mut(&identity) self.first_delivery.require_overwrite_confirmation(identity);
&& matches!(issue.stage, IssueStage::Authorizing(_))
{
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
}
} }
fn authorize_first_issue_inner( fn authorize_first_issue_inner(
&mut self, &mut self,
identity: PublicKey, identity: PublicKey,
is_retry: bool, attempt: FirstIssueAttempt,
confirm_possible_overwrite: bool,
confirm_manual_cleanup: bool,
) -> Result<FirstIssueAuthorization, AppError> { ) -> Result<FirstIssueAuthorization, AppError> {
let installed = match self.store.read_saved() { let installed = match self.store.read_saved() {
Ok(saved) => saved, Ok(saved) => saved,
Err(error) => { Err(error) => {
self.initial_issues.clear(); self.first_delivery.clear();
return Err(error.into()); return Err(error.into());
} }
}; };
@@ -1076,54 +749,26 @@ impl Application {
.map_err(|_| AppError::InvalidSavedState)? .map_err(|_| AppError::InvalidSavedState)?
.map(VaultBundle::from_toml) .map(VaultBundle::from_toml)
.transpose()?; .transpose()?;
self.retain_matching_issues(&document, vault.as_ref()); let store = &self.store;
let Some(issue) = self.initial_issues.get(&identity) else { self.first_delivery.begin_attempt(
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(
&document,
vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?,
identity, identity,
)?; attempt,
if current_context != context { &document,
self.initial_issues.remove(&identity); vault.as_ref(),
return Err(AppError::FirstIssueUnavailable); |private_key| {
} let resolved = store.resolved_settings_for(&installed)?;
let resolved = self.store.resolved_settings_for(&installed)?;
let delivery_settings = DeliverySettings { let delivery_settings = DeliverySettings {
server_endpoint: &resolved.server_endpoint, server_endpoint: &resolved.server_endpoint,
dns_addresses: &resolved.dns_addresses, dns_addresses: &resolved.dns_addresses,
}; };
let payload = Ok(delivery::render_new_client(
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, identity,
context, private_key,
token, &document,
payload, &delivery_settings,
}) )?)
},
)
} }
/// Consume first-issue eligibility after complete file publication. /// Consume first-issue eligibility after complete file publication.
@@ -1134,7 +779,7 @@ impl Application {
let installed = match self.store.read_saved() { let installed = match self.store.read_saved() {
Ok(saved) => saved, Ok(saved) => saved,
Err(error) => { Err(error) => {
self.initial_issues.clear(); self.first_delivery.clear();
return Err(error.into()); return Err(error.into());
} }
}; };
@@ -1147,24 +792,8 @@ impl Application {
.map_err(|_| AppError::InvalidSavedState)? .map_err(|_| AppError::InvalidSavedState)?
.map(VaultBundle::from_toml) .map(VaultBundle::from_toml)
.transpose()?; .transpose()?;
self.retain_matching_issues(&document, vault.as_ref()); self.first_delivery
let current = issue_context( .complete_publication(authorization, &document, vault.as_ref())
&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(())
} }
/// Record failed publication and explicitly return the session to a retry /// Record failed publication and explicitly return the session to a retry
@@ -1177,7 +806,7 @@ impl Application {
let installed = match self.store.read_saved() { let installed = match self.store.read_saved() {
Ok(saved) => saved, Ok(saved) => saved,
Err(error) => { Err(error) => {
self.initial_issues.clear(); self.first_delivery.clear();
return Err(error.into()); return Err(error.into());
} }
}; };
@@ -1190,49 +819,8 @@ impl Application {
.map_err(|_| AppError::InvalidSavedState)? .map_err(|_| AppError::InvalidSavedState)?
.map(VaultBundle::from_toml) .map(VaultBundle::from_toml)
.transpose()?; .transpose()?;
self.retain_matching_issues(&document, vault.as_ref()); self.first_delivery
let current = issue_context( .fail_publication(authorization, failure, &document, vault.as_ref())
&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;
}
}
} }
pub fn vault(&self) -> Option<&VaultBundle> { pub fn vault(&self) -> Option<&VaultBundle> {
@@ -1295,7 +883,7 @@ impl Application {
} }
let replacement = DraftChanges::stage_clients(edited, next_states); let replacement = DraftChanges::stage_clients(edited, next_states);
self.draft.publish_replacement(replacement); self.draft.publish_replacement(replacement);
self.invalidate_active_issue_authorizations(); self.first_delivery.invalidate_active_authorizations();
Ok(()) Ok(())
} }
@@ -1340,8 +928,8 @@ impl Application {
next_states.remove_draft_client(&identity); next_states.remove_draft_client(&identity);
let replacement = DraftChanges::stage_clients_and_vault(edited, next_states, next_vault); let replacement = DraftChanges::stage_clients_and_vault(edited, next_states, next_vault);
self.draft.publish_replacement(replacement); self.draft.publish_replacement(replacement);
self.invalidate_active_issue_authorizations(); self.first_delivery.invalidate_active_authorizations();
self.initial_issues.remove(&identity); self.first_delivery.remove(&identity);
Ok(removed_record) Ok(removed_record)
} }
@@ -1415,13 +1003,8 @@ impl Application {
self.verified_saved_projection = None; self.verified_saved_projection = None;
} }
self.historical_apply = next_historical_apply; self.historical_apply = next_historical_apply;
self.initial_issues self.first_delivery
.retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile); .discard_draft(&self.draft.document, self.draft.vault.as_ref());
retain_matching_issue_map(
&mut self.initial_issues,
&self.draft.document,
self.draft.vault.as_ref(),
);
Ok(()) Ok(())
} }
@@ -1951,7 +1534,7 @@ impl Application {
proposed.internal_state = Some(persisted.to_toml()?); proposed.internal_state = Some(persisted.to_toml()?);
self.store.validate_saved(&proposed)?; self.store.validate_saved(&proposed)?;
if let Err(error) = install(&self.store, &proposed, BackupComplete(())) { 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() { match self.store.read_saved() {
Ok(current) => { Ok(current) => {
let document = ConfigDocument::parse(&current.server_config).ok(); let document = ConfigDocument::parse(&current.server_config).ok();
@@ -1961,16 +1544,13 @@ impl Application {
.and_then(|bytes| std::str::from_utf8(bytes).ok()) .and_then(|bytes| std::str::from_utf8(bytes).ok())
.and_then(|text| VaultBundle::from_toml(text).ok()); .and_then(|text| VaultBundle::from_toml(text).ok());
if let Some(document) = document { if let Some(document) = document {
retain_matching_issue_map( self.first_delivery
&mut self.initial_issues, .retain_matching(&document, vault.as_ref());
&document,
vault.as_ref(),
);
} else { } else {
self.initial_issues.clear(); self.first_delivery.clear();
} }
} }
Err(_) => self.initial_issues.clear(), Err(_) => self.first_delivery.clear(),
} }
return Err(error.into()); 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> { fn server_interface_addresses(document: &ConfigDocument) -> Result<Vec<IpAddr>, AppError> {
let interfaces = document let interfaces = document
.sections() .sections()
@@ -2556,12 +2067,12 @@ restart_timeout_seconds = 30
let identity = app let identity = app
.prepare_new_client("shared alias", None, Some("2026-10-20T00:00:00Z")) .prepare_new_client("shared alias", None, Some("2026-10-20T00:00:00Z"))
.expect("new client is staged"); .expect("new client is staged");
assert!(app.initial_issues.contains_key(&identity)); assert!(app.first_delivery.contains(&identity));
assert!(matches!( assert!(matches!(
app.prepare_new_client("invalid\nalias", None, None), app.prepare_new_client("invalid\nalias", None, None),
Err(AppError::Domain(_)) 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"); let client = app.states().draft().get(&identity).expect("draft client");
assert_eq!(client.addresses.ipv4, Ipv4Addr::new(10, 20, 0, 3)); assert_eq!(client.addresses.ipv4, Ipv4Addr::new(10, 20, 0, 3));
assert!(client.addresses.ipv6.is_some()); assert!(client.addresses.ipv6.is_some());
@@ -2594,7 +2105,7 @@ restart_timeout_seconds = 30
assert_eq!(app.document().render(), before_document); assert_eq!(app.document().render(), before_document);
assert_eq!(app.states().draft(), &before_draft); assert_eq!(app.states().draft(), &before_draft);
assert!(app.vault().is_none()); assert!(app.vault().is_none());
assert!(app.initial_issues.is_empty()); assert!(app.first_delivery.is_empty());
} }
#[test] #[test]
@@ -2654,7 +2165,7 @@ restart_timeout_seconds = 30
.unwrap(), .unwrap(),
before_vault before_vault
); );
assert!(app.initial_issues.contains_key(&identity)); assert!(app.first_delivery.contains(&identity));
} }
#[test] #[test]
@@ -3658,7 +3169,7 @@ restart_timeout_seconds = 30
.unwrap(), .unwrap(),
before_vault before_vault
); );
assert!(app.initial_issues.contains_key(&issue)); assert!(app.first_delivery.contains(&issue));
} }
#[test] #[test]
+10 -14
View File
@@ -12,10 +12,7 @@ use crate::persistence::{BackupComplete, PersistedState, PersistenceError, Saved
use crate::vault::VaultBundle; use crate::vault::VaultBundle;
use crate::wireguard::ConfigDocument; use crate::wireguard::ConfigDocument;
use super::{ use super::{AppError, Application, historical_apply_from_saved};
AppError, Application, historical_apply_from_saved, promote_matching_issue_map,
retain_matching_issue_map,
};
struct PreparedCommit { struct PreparedCommit {
proposed: SavedBytes, proposed: SavedBytes,
@@ -109,9 +106,10 @@ pub(super) fn commit(
application.saved = prepared.proposed; application.saved = prepared.proposed;
application.draft.states = prepared.states; application.draft.states = prepared.states;
application.draft.document = prepared.document; application.draft.document = prepared.document;
application.invalidate_active_issue_authorizations(); application
promote_matching_issue_map( .first_delivery
&mut application.initial_issues, .invalidate_active_authorizations();
application.first_delivery.promote_matching(
&application.draft.document, &application.draft.document,
application.draft.vault.as_ref(), 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(|bytes| std::str::from_utf8(bytes).ok())
.and_then(|text| VaultBundle::from_toml(text).ok()); .and_then(|text| VaultBundle::from_toml(text).ok());
if let Some(document) = document { if let Some(document) = document {
retain_matching_issue_map( application
&mut application.initial_issues, .first_delivery
&document, .retain_matching(&document, vault.as_ref());
vault.as_ref(),
);
} else { } else {
application.initial_issues.clear(); application.first_delivery.clear();
} }
} }
Err(_) => application.initial_issues.clear(), Err(_) => application.first_delivery.clear(),
} }
} }
+318
View File
@@ -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,
})
}
+183
View File
@@ -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),
})
})
}
}
+97
View File
@@ -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
View File
@@ -125,7 +125,7 @@ pub(super) fn confirm_restore(
// A partial journaled install may leave disk in a recovery-required // A partial journaled install may leave disk in a recovery-required
// state. Revoke session-only delivery rights until the store is read // state. Revoke session-only delivery rights until the store is read
// again and the actual installed generation is known. // again and the actual installed generation is known.
application.initial_issues.clear(); application.first_delivery.clear();
return Err(error.into()); return Err(error.into());
} }
@@ -136,7 +136,7 @@ pub(super) fn confirm_restore(
application.draft.document = prepared.document; application.draft.document = prepared.document;
application.draft.vault = prepared.vault; application.draft.vault = prepared.vault;
application.draft.states = prepared.states; application.draft.states = prepared.states;
application.initial_issues.clear(); application.first_delivery.clear();
application.historical_apply = crate::backup::HistoricalApplyStatus::Unknown; application.historical_apply = crate::backup::HistoricalApplyStatus::Unknown;
application.verified_saved_projection = None; application.verified_saved_projection = None;
application.active_restart_attempt = None; application.active_restart_attempt = None;