diff --git a/PLANS.md b/PLANS.md index 2e18f7a..de629ac 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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 diff --git a/docs/PROJECT_LAYOUT.md b/docs/PROJECT_LAYOUT.md index 962dd26..667aa3e 100644 --- a/docs/PROJECT_LAYOUT.md +++ b/docs/PROJECT_LAYOUT.md @@ -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 diff --git a/docs/evidence/milestone-24.md b/docs/evidence/milestone-24.md new file mode 100644 index 0000000..6c3eb9e --- /dev/null +++ b/docs/evidence/milestone-24.md @@ -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. diff --git a/src/app.rs b/src/app.rs index e421552..c22d8c7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, active_restart_attempt: Option, next_restart_attempt: u64, - next_issue_authorization: u64, - initial_issues: BTreeMap, -} - -#[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( - &mut self, - identity: PublicKey, - attempt: FirstIssueAttempt, - publish: impl FnOnce(&ClientConfigPayload) -> Result, - ) -> Result, AppError> { - self.deliver_first_issue_with_warning(identity, attempt, |payload, _| publish(payload)) - } - - fn deliver_first_issue_with_warning( - &mut self, - identity: PublicKey, - attempt: FirstIssueAttempt, - publish: impl FnOnce(&ClientConfigPayload, bool) -> Result, - ) -> Result, 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, - ) -> Result { - 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, 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, 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, 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, - ) -> Result, 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, - ) -> Result, 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 { - 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 { 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, - 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, - 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 { - let matching = document - .peers() - .iter() - .filter(|peer| { - peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity) - }) - .collect::>(); - 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::>() - .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, 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] diff --git a/src/app/commit.rs b/src/app/commit.rs index ce8ae3d..84faa30 100644 --- a/src/app/commit.rs +++ b/src/app/commit.rs @@ -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(), } } diff --git a/src/app/first_delivery.rs b/src/app/first_delivery.rs new file mode 100644 index 0000000..ad4d4ef --- /dev/null +++ b/src/app/first_delivery.rs @@ -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, +} + +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, + ) -> Result { + 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 { + 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 { + let matching = document + .peers() + .iter() + .filter(|peer| { + peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity) + }) + .collect::>(); + 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::>() + .join("\n"); + let encrypted_record = vault + .record_context(identity) + .ok_or(AppError::FirstIssueUnavailable)? + .to_owned(); + Ok(IssueContext { + peer_directives, + encrypted_record, + }) +} diff --git a/src/app/first_file.rs b/src/app/first_file.rs new file mode 100644 index 0000000..d071cb6 --- /dev/null +++ b/src/app/first_file.rs @@ -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( + &mut self, + identity: PublicKey, + attempt: FirstIssueAttempt, + publish: impl FnOnce(&ClientConfigPayload) -> Result, + ) -> Result, AppError> { + self.deliver_first_issue_with_warning(identity, attempt, |payload, _| publish(payload)) + } + + pub(super) fn deliver_first_issue_with_warning( + &mut self, + identity: PublicKey, + attempt: FirstIssueAttempt, + publish: impl FnOnce(&ClientConfigPayload, bool) -> Result, + ) -> Result, 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, + ) -> Result { + 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, 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, 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, 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, + ) -> Result, 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, + ) -> Result, 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), + }) + }) + } +} diff --git a/src/app/reissue.rs b/src/app/reissue.rs new file mode 100644 index 0000000..0beded6 --- /dev/null +++ b/src/app/reissue.rs @@ -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 { + 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, + }) + } +} diff --git a/src/app/restore.rs b/src/app/restore.rs index c75729a..3c6e97a 100644 --- a/src/app/restore.rs +++ b/src/app/restore.rs @@ -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;