Implement session-owned first delivery and password reissue scenarios
This commit is contained in:
@@ -133,7 +133,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
|
|||||||
| 21 | Domain ownership of reservations and apply knowledge | 20 | accepted |
|
| 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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(¤t.server_config).ok();
|
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(|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
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
//! Session-local ownership of password-free first-delivery eligibility.
|
||||||
|
//!
|
||||||
|
//! The session keeps each generated private key beside the saved peer and
|
||||||
|
//! encrypted-record context that authorizes its single first-file publication.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::delivery::ClientConfigPayload;
|
||||||
|
use crate::domain::PublicKey;
|
||||||
|
use crate::vault::{VaultBundle, WireGuardPrivateKey};
|
||||||
|
use crate::wireguard::ConfigDocument;
|
||||||
|
|
||||||
|
use super::{AppError, FirstIssueAttempt, FirstIssueDeliveryFailure, FirstIssueRetry};
|
||||||
|
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub(super) struct IssueContext {
|
||||||
|
peer_directives: String,
|
||||||
|
encrypted_record: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct FirstDeliverySession {
|
||||||
|
next_authorization: u64,
|
||||||
|
issues: BTreeMap<PublicKey, InitialIssue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InitialIssue {
|
||||||
|
private_key: WireGuardPrivateKey,
|
||||||
|
context: IssueContext,
|
||||||
|
stage: IssueStage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||||
|
enum IssueStage {
|
||||||
|
PendingDraft,
|
||||||
|
AwaitingFirstFile,
|
||||||
|
RetryAvailable,
|
||||||
|
Authorizing(u64),
|
||||||
|
RetryNeedsOverwriteConfirmation,
|
||||||
|
RetryNeedsManualCleanup,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct FirstIssueAuthorization {
|
||||||
|
identity: PublicKey,
|
||||||
|
context: IssueContext,
|
||||||
|
token: u64,
|
||||||
|
payload: ClientConfigPayload,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FirstIssueAuthorization {
|
||||||
|
pub(super) fn payload(&self) -> &ClientConfigPayload {
|
||||||
|
&self.payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FirstDeliverySession {
|
||||||
|
pub(super) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
next_authorization: 1,
|
||||||
|
issues: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn len(&self) -> usize {
|
||||||
|
self.issues.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn is_empty(&self) -> bool {
|
||||||
|
self.issues.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn contains(&self, identity: &PublicKey) -> bool {
|
||||||
|
self.issues.contains_key(identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remember_pending(
|
||||||
|
&mut self,
|
||||||
|
identity: PublicKey,
|
||||||
|
private_key: WireGuardPrivateKey,
|
||||||
|
context: IssueContext,
|
||||||
|
) {
|
||||||
|
self.issues.insert(
|
||||||
|
identity,
|
||||||
|
InitialIssue {
|
||||||
|
private_key,
|
||||||
|
context,
|
||||||
|
stage: IssueStage::PendingDraft,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remove(&mut self, identity: &PublicKey) {
|
||||||
|
self.issues.remove(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn clear(&mut self) {
|
||||||
|
self.issues.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-flight output can no longer be retried silently after a draft or
|
||||||
|
/// runtime transition. Keep unrelated clients' rights intact.
|
||||||
|
pub(super) fn invalidate_active_authorizations(&mut self) {
|
||||||
|
for issue in self.issues.values_mut() {
|
||||||
|
if matches!(issue.stage, IssueStage::Authorizing(_)) {
|
||||||
|
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep only already committed first-file rights when a draft is dropped.
|
||||||
|
pub(super) fn discard_draft(&mut self, document: &ConfigDocument, vault: Option<&VaultBundle>) {
|
||||||
|
self.issues
|
||||||
|
.retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile);
|
||||||
|
self.retain_matching(document, vault);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn retain_matching(
|
||||||
|
&mut self,
|
||||||
|
document: &ConfigDocument,
|
||||||
|
vault: Option<&VaultBundle>,
|
||||||
|
) {
|
||||||
|
self.issues.retain(|identity, issue| {
|
||||||
|
if issue.stage == IssueStage::PendingDraft {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
vault
|
||||||
|
.and_then(|vault| context_for(document, vault, *identity).ok())
|
||||||
|
.is_some_and(|actual| actual == issue.context)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn promote_matching(
|
||||||
|
&mut self,
|
||||||
|
document: &ConfigDocument,
|
||||||
|
vault: Option<&VaultBundle>,
|
||||||
|
) {
|
||||||
|
self.issues.retain(|identity, issue| {
|
||||||
|
let matches = vault
|
||||||
|
.and_then(|vault| context_for(document, vault, *identity).ok())
|
||||||
|
.is_some_and(|actual| actual == issue.context);
|
||||||
|
if matches && issue.stage == IssueStage::PendingDraft {
|
||||||
|
issue.stage = IssueStage::AwaitingFirstFile;
|
||||||
|
}
|
||||||
|
matches
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn begin_attempt(
|
||||||
|
&mut self,
|
||||||
|
identity: PublicKey,
|
||||||
|
attempt: FirstIssueAttempt,
|
||||||
|
document: &ConfigDocument,
|
||||||
|
vault: Option<&VaultBundle>,
|
||||||
|
render: impl FnOnce(&WireGuardPrivateKey) -> Result<ClientConfigPayload, AppError>,
|
||||||
|
) -> Result<FirstIssueAuthorization, AppError> {
|
||||||
|
self.retain_matching(document, vault);
|
||||||
|
let (is_retry, confirm_possible_overwrite, confirm_manual_cleanup) = match attempt {
|
||||||
|
FirstIssueAttempt::Initial => (false, false, false),
|
||||||
|
FirstIssueAttempt::Retry {
|
||||||
|
confirm_possible_overwrite,
|
||||||
|
confirm_manual_cleanup,
|
||||||
|
} => (true, confirm_possible_overwrite, confirm_manual_cleanup),
|
||||||
|
};
|
||||||
|
let issue = self
|
||||||
|
.issues
|
||||||
|
.get(&identity)
|
||||||
|
.ok_or(AppError::FirstIssueUnavailable)?;
|
||||||
|
let allowed = match issue.stage {
|
||||||
|
IssueStage::AwaitingFirstFile => !is_retry,
|
||||||
|
IssueStage::RetryAvailable => is_retry,
|
||||||
|
IssueStage::RetryNeedsOverwriteConfirmation => is_retry && confirm_possible_overwrite,
|
||||||
|
IssueStage::RetryNeedsManualCleanup => is_retry && confirm_manual_cleanup,
|
||||||
|
IssueStage::PendingDraft | IssueStage::Authorizing(_) => false,
|
||||||
|
};
|
||||||
|
if !allowed {
|
||||||
|
return Err(AppError::FirstIssueUnavailable);
|
||||||
|
}
|
||||||
|
let current_context = context_for(
|
||||||
|
document,
|
||||||
|
vault.ok_or(AppError::FirstIssueUnavailable)?,
|
||||||
|
identity,
|
||||||
|
)?;
|
||||||
|
if current_context != issue.context {
|
||||||
|
self.issues.remove(&identity);
|
||||||
|
return Err(AppError::FirstIssueUnavailable);
|
||||||
|
}
|
||||||
|
let payload = render(&issue.private_key)?;
|
||||||
|
let context = issue.context.clone();
|
||||||
|
let token = self.next_authorization;
|
||||||
|
let next_token = token
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(AppError::IssueAuthorizationExhausted)?;
|
||||||
|
let issue = self
|
||||||
|
.issues
|
||||||
|
.get_mut(&identity)
|
||||||
|
.ok_or(AppError::FirstIssueUnavailable)?;
|
||||||
|
self.next_authorization = next_token;
|
||||||
|
issue.stage = IssueStage::Authorizing(token);
|
||||||
|
Ok(FirstIssueAuthorization {
|
||||||
|
identity,
|
||||||
|
context,
|
||||||
|
token,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_authorization(
|
||||||
|
&mut self,
|
||||||
|
authorization: &FirstIssueAuthorization,
|
||||||
|
document: &ConfigDocument,
|
||||||
|
vault: Option<&VaultBundle>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
self.retain_matching(document, vault);
|
||||||
|
let current = context_for(
|
||||||
|
document,
|
||||||
|
vault.ok_or(AppError::FirstIssueUnavailable)?,
|
||||||
|
authorization.identity,
|
||||||
|
)?;
|
||||||
|
if current != authorization.context
|
||||||
|
|| !matches!(
|
||||||
|
self.issues.get(&authorization.identity),
|
||||||
|
Some(issue)
|
||||||
|
if issue.stage == IssueStage::Authorizing(authorization.token)
|
||||||
|
&& issue.context == authorization.context
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err(AppError::FirstIssueUnavailable);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn require_overwrite_confirmation(&mut self, identity: PublicKey) {
|
||||||
|
if let Some(issue) = self.issues.get_mut(&identity)
|
||||||
|
&& matches!(issue.stage, IssueStage::Authorizing(_))
|
||||||
|
{
|
||||||
|
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete_publication(
|
||||||
|
&mut self,
|
||||||
|
authorization: FirstIssueAuthorization,
|
||||||
|
document: &ConfigDocument,
|
||||||
|
vault: Option<&VaultBundle>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
self.validate_authorization(&authorization, document, vault)?;
|
||||||
|
self.issues.remove(&authorization.identity);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn fail_publication(
|
||||||
|
&mut self,
|
||||||
|
authorization: FirstIssueAuthorization,
|
||||||
|
failure: FirstIssueDeliveryFailure,
|
||||||
|
document: &ConfigDocument,
|
||||||
|
vault: Option<&VaultBundle>,
|
||||||
|
) -> Result<FirstIssueRetry, AppError> {
|
||||||
|
self.validate_authorization(&authorization, document, vault)?;
|
||||||
|
let issue = self
|
||||||
|
.issues
|
||||||
|
.get_mut(&authorization.identity)
|
||||||
|
.ok_or(AppError::FirstIssueUnavailable)?;
|
||||||
|
let retry = match failure {
|
||||||
|
FirstIssueDeliveryFailure::BeforePublication => {
|
||||||
|
issue.stage = IssueStage::RetryAvailable;
|
||||||
|
FirstIssueRetry::Available
|
||||||
|
}
|
||||||
|
FirstIssueDeliveryFailure::PublicationUncertain => {
|
||||||
|
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
|
||||||
|
FirstIssueRetry::OverwriteConfirmationRequired
|
||||||
|
}
|
||||||
|
FirstIssueDeliveryFailure::CleanupRequired => {
|
||||||
|
issue.stage = IssueStage::RetryNeedsManualCleanup;
|
||||||
|
FirstIssueRetry::ManualCleanupRequired
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(retry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn context_for(
|
||||||
|
document: &ConfigDocument,
|
||||||
|
vault: &VaultBundle,
|
||||||
|
identity: PublicKey,
|
||||||
|
) -> Result<IssueContext, AppError> {
|
||||||
|
let matching = document
|
||||||
|
.peers()
|
||||||
|
.iter()
|
||||||
|
.filter(|peer| {
|
||||||
|
peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let [peer] = matching.as_slice() else {
|
||||||
|
return Err(AppError::FirstIssueUnavailable);
|
||||||
|
};
|
||||||
|
if peer.identity != Some(identity) || !peer.issues.is_empty() {
|
||||||
|
return Err(AppError::FirstIssueUnavailable);
|
||||||
|
}
|
||||||
|
// Alias/admin comments are descriptive metadata; only WireGuard directives
|
||||||
|
// bind first-issue authority, so renames do not invalidate the creation.
|
||||||
|
let peer_directives = document
|
||||||
|
.source_slice(peer.section)
|
||||||
|
.ok_or(AppError::FirstIssueUnavailable)?
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty() && !line.starts_with(['#', ';']))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let encrypted_record = vault
|
||||||
|
.record_context(identity)
|
||||||
|
.ok_or(AppError::FirstIssueUnavailable)?
|
||||||
|
.to_owned();
|
||||||
|
Ok(IssueContext {
|
||||||
|
peer_directives,
|
||||||
|
encrypted_record,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//! Password-free first-file delivery after Commit, with optional QR disclosure.
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
AppError, Application, FirstIssueAttempt, FirstIssueDeliveryFailure, FirstIssueDeliveryOutcome,
|
||||||
|
FirstIssueFileAndQr, FirstIssueFileDelivery,
|
||||||
|
};
|
||||||
|
use crate::delivery::files::{self, FileOutputError, PreparedDestination};
|
||||||
|
use crate::delivery::qr;
|
||||||
|
use crate::delivery::{ClientConfigPayload, QrCodeOutput, QrError};
|
||||||
|
use crate::domain::PublicKey;
|
||||||
|
|
||||||
|
impl Application {
|
||||||
|
/// Publish a password-free first issue while the committed peer, encrypted
|
||||||
|
/// record, and one-use token are valid. The mutable app borrow remains held
|
||||||
|
/// across `publish`, so no application mutation can interleave after the
|
||||||
|
/// final saved-state validation and before the file adapter runs.
|
||||||
|
#[allow(dead_code)] // M6.3 invokes this for its initial-file action.
|
||||||
|
pub(crate) fn deliver_first_issue<T, E>(
|
||||||
|
&mut self,
|
||||||
|
identity: PublicKey,
|
||||||
|
attempt: FirstIssueAttempt,
|
||||||
|
publish: impl FnOnce(&ClientConfigPayload) -> Result<T, (E, FirstIssueDeliveryFailure)>,
|
||||||
|
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
|
||||||
|
self.deliver_first_issue_with_warning(identity, attempt, |payload, _| publish(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn deliver_first_issue_with_warning<T, E>(
|
||||||
|
&mut self,
|
||||||
|
identity: PublicKey,
|
||||||
|
attempt: FirstIssueAttempt,
|
||||||
|
publish: impl FnOnce(&ClientConfigPayload, bool) -> Result<T, (E, FirstIssueDeliveryFailure)>,
|
||||||
|
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
|
||||||
|
let authorization = self.authorize_first_issue_inner(identity, attempt)?;
|
||||||
|
let activation_warning = match self.validate_first_issue_authorization(&authorization) {
|
||||||
|
Ok(warning) => warning,
|
||||||
|
Err(error) => {
|
||||||
|
self.require_overwrite_confirmation(identity);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match publish(authorization.payload(), activation_warning) {
|
||||||
|
Ok(value) => {
|
||||||
|
if let Err(error) = self.mark_first_issue_delivered(authorization) {
|
||||||
|
// Publication is definite. Drop the session-held key even
|
||||||
|
// when saved-state bookkeeping cannot be completed.
|
||||||
|
self.first_delivery.remove(&identity);
|
||||||
|
return Ok(
|
||||||
|
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
|
||||||
|
value,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(FirstIssueDeliveryOutcome::Delivered(value))
|
||||||
|
}
|
||||||
|
Err((error, failure)) => {
|
||||||
|
let retry = match self.mark_first_issue_delivery_failed(authorization, failure) {
|
||||||
|
Ok(retry) => retry,
|
||||||
|
Err(error) => {
|
||||||
|
self.require_overwrite_confirmation(identity);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(FirstIssueDeliveryOutcome::Failed {
|
||||||
|
error,
|
||||||
|
retry,
|
||||||
|
activation_warning,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepare a caller-selected plaintext destination without writing it. The
|
||||||
|
/// returned destination records any existing regular file so a later
|
||||||
|
/// overwrite confirmation is bound to this exact selection.
|
||||||
|
pub fn prepare_first_issue_destination(
|
||||||
|
&self,
|
||||||
|
path: impl AsRef<std::path::Path>,
|
||||||
|
) -> Result<PreparedDestination, FileOutputError> {
|
||||||
|
let protection = self.store.delivery_output_protection()?;
|
||||||
|
files::prepare_destination(path, &protection)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deliver the committed new-client configuration through the app-owned
|
||||||
|
/// one-use authorization closure. A failed write retains retry eligibility;
|
||||||
|
/// a post-publication sync failure requires an explicit overwrite retry.
|
||||||
|
pub fn deliver_first_issue_file(
|
||||||
|
&mut self,
|
||||||
|
identity: PublicKey,
|
||||||
|
attempt: FirstIssueAttempt,
|
||||||
|
destination: PreparedDestination,
|
||||||
|
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, AppError> {
|
||||||
|
self.deliver_first_issue_file_with(identity, attempt, destination, files::publish)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn deliver_first_issue_file_with(
|
||||||
|
&mut self,
|
||||||
|
identity: PublicKey,
|
||||||
|
attempt: FirstIssueAttempt,
|
||||||
|
destination: PreparedDestination,
|
||||||
|
publish_file: impl FnOnce(
|
||||||
|
PreparedDestination,
|
||||||
|
&ClientConfigPayload,
|
||||||
|
) -> Result<(), FileOutputError>,
|
||||||
|
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, AppError> {
|
||||||
|
self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| {
|
||||||
|
publish_file(destination, payload).map_err(|error| {
|
||||||
|
let failure = match error {
|
||||||
|
FileOutputError::PublicationUncertain => {
|
||||||
|
FirstIssueDeliveryFailure::PublicationUncertain
|
||||||
|
}
|
||||||
|
FileOutputError::ManualCleanupRequired => {
|
||||||
|
FirstIssueDeliveryFailure::CleanupRequired
|
||||||
|
}
|
||||||
|
_ => FirstIssueDeliveryFailure::BeforePublication,
|
||||||
|
};
|
||||||
|
(error, failure)
|
||||||
|
})?;
|
||||||
|
Ok(FirstIssueFileDelivery { activation_warning })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish the mandatory plaintext file, then optionally encode a QR from
|
||||||
|
/// that same authorized payload. QR failure is returned separately and the
|
||||||
|
/// successful file publication still consumes first-issue eligibility.
|
||||||
|
pub fn deliver_first_issue_file_and_qr(
|
||||||
|
&mut self,
|
||||||
|
identity: PublicKey,
|
||||||
|
attempt: FirstIssueAttempt,
|
||||||
|
destination: PreparedDestination,
|
||||||
|
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
|
||||||
|
self.deliver_first_issue_file_and_qr_with(identity, attempt, destination, |payload| {
|
||||||
|
qr::encode_payload(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn deliver_first_issue_file_and_qr_with(
|
||||||
|
&mut self,
|
||||||
|
identity: PublicKey,
|
||||||
|
attempt: FirstIssueAttempt,
|
||||||
|
destination: PreparedDestination,
|
||||||
|
encode: impl FnOnce(&ClientConfigPayload) -> Result<QrCodeOutput, QrError>,
|
||||||
|
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
|
||||||
|
self.deliver_first_issue_file_and_qr_with_publisher(
|
||||||
|
identity,
|
||||||
|
attempt,
|
||||||
|
destination,
|
||||||
|
files::publish,
|
||||||
|
encode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn deliver_first_issue_file_and_qr_with_publisher(
|
||||||
|
&mut self,
|
||||||
|
identity: PublicKey,
|
||||||
|
attempt: FirstIssueAttempt,
|
||||||
|
destination: PreparedDestination,
|
||||||
|
publish_file: impl FnOnce(
|
||||||
|
PreparedDestination,
|
||||||
|
&ClientConfigPayload,
|
||||||
|
) -> Result<(), FileOutputError>,
|
||||||
|
encode: impl FnOnce(&ClientConfigPayload) -> Result<QrCodeOutput, QrError>,
|
||||||
|
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
|
||||||
|
self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| {
|
||||||
|
publish_file(destination, payload).map_err(|error| {
|
||||||
|
let failure = match error {
|
||||||
|
FileOutputError::PublicationUncertain => {
|
||||||
|
FirstIssueDeliveryFailure::PublicationUncertain
|
||||||
|
}
|
||||||
|
FileOutputError::ManualCleanupRequired => {
|
||||||
|
FirstIssueDeliveryFailure::CleanupRequired
|
||||||
|
}
|
||||||
|
_ => FirstIssueDeliveryFailure::BeforePublication,
|
||||||
|
};
|
||||||
|
(error, failure)
|
||||||
|
})?;
|
||||||
|
Ok(FirstIssueFileAndQr {
|
||||||
|
activation_warning,
|
||||||
|
qr: encode(payload),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! Freshly authenticated publication of a saved client's configuration.
|
||||||
|
|
||||||
|
use super::{Application, ReissueDelivery, ReissueError, ReissueFailureKind, ReissueStage};
|
||||||
|
use crate::delivery::files::{self, PreparedDestination};
|
||||||
|
use crate::delivery::qr;
|
||||||
|
use crate::delivery::{self, DeliverySettings};
|
||||||
|
use crate::domain::PublicKey;
|
||||||
|
use crate::vault::VaultBundle;
|
||||||
|
use crate::wireguard::ConfigDocument;
|
||||||
|
|
||||||
|
impl Application {
|
||||||
|
/// Reissue a saved client's configuration after freshly unlocking its
|
||||||
|
/// installed vault record with this action's password. This operation reads
|
||||||
|
/// only installed saved bytes, never uses a draft or retained source file,
|
||||||
|
/// and publishes the required file before attempting optional QR encoding.
|
||||||
|
pub fn reissue_client_file(
|
||||||
|
&self,
|
||||||
|
identity: PublicKey,
|
||||||
|
password: &str,
|
||||||
|
destination: PreparedDestination,
|
||||||
|
include_qr: bool,
|
||||||
|
) -> Result<ReissueDelivery, ReissueError> {
|
||||||
|
let installed = self.store.read_saved().map_err(|_| ReissueError {
|
||||||
|
stage: ReissueStage::SavedState,
|
||||||
|
kind: ReissueFailureKind::SavedStateUnavailable,
|
||||||
|
})?;
|
||||||
|
let document =
|
||||||
|
ConfigDocument::parse(&installed.server_config).map_err(|_| ReissueError {
|
||||||
|
stage: ReissueStage::SavedState,
|
||||||
|
kind: ReissueFailureKind::SavedStateUnavailable,
|
||||||
|
})?;
|
||||||
|
let encoded_vault = installed.vault.as_deref().ok_or(ReissueError {
|
||||||
|
stage: ReissueStage::Authorization,
|
||||||
|
kind: ReissueFailureKind::VaultUnavailable,
|
||||||
|
})?;
|
||||||
|
let vault_text = std::str::from_utf8(encoded_vault).map_err(|_| ReissueError {
|
||||||
|
stage: ReissueStage::Authorization,
|
||||||
|
kind: ReissueFailureKind::VaultUnavailable,
|
||||||
|
})?;
|
||||||
|
let vault = VaultBundle::from_toml(vault_text).map_err(|_| ReissueError {
|
||||||
|
stage: ReissueStage::Authorization,
|
||||||
|
kind: ReissueFailureKind::VaultUnavailable,
|
||||||
|
})?;
|
||||||
|
// Retrieval performs the password unlock and authenticated conflict
|
||||||
|
// check on every invocation. The short-lived owner is dropped when
|
||||||
|
// this method returns and is never kept in Application state.
|
||||||
|
let record = vault
|
||||||
|
.retrieve_client_record(identity, password)
|
||||||
|
.map_err(|_| ReissueError {
|
||||||
|
stage: ReissueStage::Authorization,
|
||||||
|
kind: ReissueFailureKind::PasswordOrRecordRejected,
|
||||||
|
})?;
|
||||||
|
let resolved = self
|
||||||
|
.store
|
||||||
|
.resolved_settings_for(&installed)
|
||||||
|
.map_err(|_| ReissueError {
|
||||||
|
stage: ReissueStage::SavedState,
|
||||||
|
kind: ReissueFailureKind::SavedStateUnavailable,
|
||||||
|
})?;
|
||||||
|
let delivery_settings = DeliverySettings {
|
||||||
|
server_endpoint: &resolved.server_endpoint,
|
||||||
|
dns_addresses: &resolved.dns_addresses,
|
||||||
|
};
|
||||||
|
let payload = if record.is_new_template() {
|
||||||
|
delivery::render_new_client(
|
||||||
|
identity,
|
||||||
|
record.private_key().ok_or(ReissueError {
|
||||||
|
stage: ReissueStage::Authorization,
|
||||||
|
kind: ReissueFailureKind::PasswordOrRecordRejected,
|
||||||
|
})?,
|
||||||
|
&document,
|
||||||
|
&delivery_settings,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
delivery::render_imported_client(identity, &record, &document, &delivery_settings)
|
||||||
|
}
|
||||||
|
.map_err(|_| ReissueError {
|
||||||
|
stage: ReissueStage::Rendering,
|
||||||
|
kind: ReissueFailureKind::ConfigurationRejected,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
files::publish(destination, &payload).map_err(|error| ReissueError {
|
||||||
|
stage: ReissueStage::FilePublication,
|
||||||
|
kind: ReissueFailureKind::FileOutput(error),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// A historical timestamp is deliberately insufficient: after reopen,
|
||||||
|
// the runtime observation is unknown. Warn unless this live session
|
||||||
|
// has verified the exact saved peer snapshot currently on disk.
|
||||||
|
let activation_warning = !self.saved_generation_is_verified_applied(&installed, &document);
|
||||||
|
let qr = include_qr.then(|| qr::encode_payload(&payload));
|
||||||
|
Ok(ReissueDelivery {
|
||||||
|
activation_warning,
|
||||||
|
qr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -125,7 +125,7 @@ pub(super) fn confirm_restore(
|
|||||||
// A partial journaled install may leave disk in a recovery-required
|
// 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user