From 6eef9a546e5d94b1e910b1fe7d004f816e86c642 Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:26:13 -0400 Subject: [PATCH 01/28] Reconcile errata + ledger: close persistence deferrals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 shipped durable runtime-state persistence, so several deferred notes are now resolved: S2 PoR round counter (cat::POR), own-card version floor (cat::CARD_VERSION), S4 envelope-digest gating (F1), and §14 split-state sealing are all live. W2-gc (old-epoch blob GC) stays open, now a disk-growth concern. Option B recorded as shipped in the ledger. --- docs/conformance-ledger.md | 13 +++--- docs/spec-errata.md | 90 ++++++++++++++++++-------------------- 2 files changed, 50 insertions(+), 53 deletions(-) diff --git a/docs/conformance-ledger.md b/docs/conformance-ledger.md index 070e708..30870cd 100644 --- a/docs/conformance-ledger.md +++ b/docs/conformance-ledger.md @@ -57,10 +57,13 @@ fixed and committed: was dropped, so a later open could release a share despite a valid abort. Fixed with a durable per-signer abort set consulted at open time. - **§8.4:** recovery stopped at `K_root` and never fetched the user's data. Now - replicas retain + serve the (HPKE-sealed) `FileGrant` to a recovering owner-device - (Option A), and recovery reconstructs actual file content — proven by the new - `recovery_reconstruct` ceremony→replica→reconstruct e2e. Option B (chunk keys in - the manifest) deferred as a spec-level wire-format decision. + implemented as Option B: the wire `FileEntry` chunk list carries `(ChunkID, + pt_hash, len)`, and a recovering claimant re-derives per-chunk keys from the + manifest's `pt_hash` + `K_content` (`chunk_keys_from_manifest`), needing no + `FileGrant` for owner sync or recovery — proven by + `crates/carapaced/tests/recovery_reconstruct.rs`. Supersedes the earlier Option-A + `FileGrant`-on-replica workaround; see "Note — §8.4 recovery data-fetch: + self-sufficient manifest (Option B)" in `spec-errata.md`. - **§11:** a routine edit did not push the new epoch to enrolled replicas (they served stale content); `publish_vault` now pushes the new manifest+chunks. - **§8.3:** the over-cap *extend* path dropped the mandated warning + re-split @@ -68,5 +71,5 @@ fixed and committed: Defensible divergences, SHOULD-level items, physical advisories, and superseded wire messages (§4 node-key manifest authorship, §6/§10 SHOULDs, §9 fallbacks, §10.2 -drift surface-not-auto, §14 at-rest sealing + single-root, §12 Hello/ManifestOffer/ +drift surface-not-auto, §14 single-root split SHOULD, §12 Hello/ManifestOffer/ AuditNotice) are documented in `spec-errata.md`, not code-changed. diff --git a/docs/spec-errata.md b/docs/spec-errata.md index 6b08c76..91c9e93 100644 --- a/docs/spec-errata.md +++ b/docs/spec-errata.md @@ -107,7 +107,7 @@ the card, the grant is local policy. A formal bilateral over-the-wire storage-agreement message is a possible future spec addition; the card-offer + local-grant model satisfies it for now. -## Note — PoR round counter is in-memory; unreachable is not retention loss (§10.1) +## Note — PoR round counter persists across restart; unreachable is not retention loss (§10.1) Phase 4 audit findings C1/S2. Two related PoR (§10.1) points settled in code: @@ -123,15 +123,13 @@ Phase 4 audit findings C1/S2. Two related PoR (§10.1) points settled in code: (unreachable), a connected-but-empty answer returns per-sample `None` (content loss). -- **S2 (accepted, not fixed): the per-replica round counter is in-memory.** The - entire daemon runtime state (members, epochs, vault blob refs, the PoR tracker) - lives in `Shared` and is not persisted; only the node/root keys are on disk. - On restart the round counter reseeds at 0 and re-arms every member's audit, so - a restart re-issues the `(epoch, round=0)` challenge and bursts audits at - startup - a marginal aid to a pre-staging proxy. Persisting only the round - counter while the rest of the set state stays ephemeral would be inconsistent; - this is deferred until the daemon grows a runtime-state store, at which point - the round counter is persisted alongside the member set. +- **S2 (fixed): the per-replica round counter persists.** `Shared` (members, + epochs, vault blob refs, the PoR tracker, and the rest of the daemon runtime + state) now persists to redb (`persist.rs`, `cat::POR`) alongside the node/root + keys. A restart reloads the round counter instead of reseeding at 0, so it no + longer re-arms every member's audit or bursts `(epoch, round=0)` challenges at + startup. Proven by `crates/carapaced/tests/por_reboot_replay.rs`, which + restarts the daemon and asserts the round counter survives. ## Note — attestation liveness binds to the enrolled roster (§10.2) @@ -173,22 +171,24 @@ Phase 5 audit dispositions for the owner-side blob-read gate (`authorize_fetch`) old chunk to no non-device - it no longer regresses to the residual and is not served to any dialer. -- **W2-gc (accepted, not fixed): old-epoch blobs are not GC'd.** Superseded chunk - blobs remain in the in-memory store and their ChunkIDs remain in `owned_chunks`, - so both grow with the distinct chunks published over the daemon's life. This is a - resource concern, not a confidentiality or gate one (the gate is retained above). - Bounded eviction of old-epoch blobs under a retention policy - dropping the blob - and its `owned_chunks` entry together - is deferred until the daemon grows a - runtime-state/blob store with a GC pass; the two must be evicted in lockstep so - the gate never outlives, or is outlived by, the blob. - -- **S4 (accepted, not fixed): the manifest envelope digest is outside the gate.** - `authorize_fetch` gates chunk ChunkIDs, not the per-vault manifest-envelope blob - (`VaultBlobs.digest`), so the envelope is served to any dialer on the inherited - residual. It is AEAD-sealed under `K_manifest`, which friends never hold, so only - its ciphertext and size leak - acceptable. Folding the envelope digest into - `owned_chunks` (gated to own devices + replica set) would tighten it if envelope - metadata size/among-friends exposure ever matters. +- **W2-gc (accepted, still not fixed): old-epoch blobs are not GC'd.** Blobs now + live in an on-disk iroh `FsStore` (`blobs.rs`) instead of memory, and their + ChunkIDs remain in `owned_chunks`, but neither is pruned: superseded chunk blobs + and their gate entries both grow with the distinct chunks published over the + daemon's life. Moving the blob store to disk turns this from a memory-growth + concern into a disk-growth one - it no longer clears on restart, so it is a + real, still-open resource concern. This is not a confidentiality or gate issue + (the gate is retained above). Bounded eviction of old-epoch blobs under a + retention policy - dropping the blob and its `owned_chunks` entry together - + remains future work; the two must be evicted in lockstep so the gate never + outlives, or is outlived by, the blob. + +- **S4 (fixed): the manifest envelope digest is now inside the gate.** `publish_vault` + inserts the manifest-envelope digest (`VaultBlobs.digest`) into `owned_chunks` + alongside the chunk ChunkIDs (`lib.rs:2481`, F1), retained across epoch bumps like + the rest of the owner-gated set. `authorize_fetch` therefore serves the envelope + only to own devices / the replica set, never to a bare dialer or audience friend on + the inherited residual. ## Note — self-hosted NAT-traversal audit dispositions (§6, §14) @@ -284,23 +284,17 @@ daemon-wide `Hello.protocol` enforcement. consecutive failed probes before withdrawing - the first two are tentative and do not re-issue - and resets the streak on the first success (which re-advertises). Covered by `w6_relay_probe_hysteresis`. - - **Own-card version rollback survival across restart (implemented, with a - residual).** All daemon state (including the own card and its flap-bumped - version) is in-memory, so a naive restart would re-issue the card at v1; - friends who already hold a higher-versioned card from the prior run's relay - flaps would reject the fresh one as a rollback (`DocStore`), stranding the - node. The own card's *initial* version is therefore seeded from a wall-clock - floor (`unix_now()`, unix seconds) rather than 1, and the existing - `version += 1` re-issue logic rides on top. A later restart's base (a larger - timestamp) exceeds the prior run's flap-bumped versions in the common case - (elapsed seconds >> number of flaps). **Residual:** a pathological - rapid-restart under heavy flapping (elapsed wall-clock seconds < number of - flaps in the prior run) can still re-issue below a version a friend holds. - The complete fix is a persisted monotonic counter, folded into the same - in-memory-state persistence deferral as the rest of `Shared` (the maintenance - round counter, the friend/replica set, etc.; see the persistence note above): - when `Shared` gains on-disk durability, persist the own card's last-issued - version alongside it and seed from `max(persisted + 1, unix_now())`. + - **Own-card version rollback survival across restart (implemented, complete + fix).** A naive restart that reseeds the card version at 1 would be rejected + as a rollback by friends already holding a higher-versioned card from the + prior run's relay flaps (`DocStore`), stranding the node. The own-card + version floor now persists (`persist.rs`, `cat::CARD_VERSION`), and the card + version is seeded from `unix_now().max(card_version_floor.saturating_add(1))` + (`lib.rs:2174`) rather than a bare wall-clock floor. This closes the prior + residual: a rapid restart under heavy flapping (elapsed wall-clock seconds < + number of flaps in the prior run) can no longer re-issue below a version a + friend holds, because the persisted floor - not just elapsed time - lower-bounds + the new version. - **W6 (implemented): the relay's TCP/HTTP port is now NAT-mapped.** The earlier errata that "iroh's portmapper maps only the endpoint UDP port, never the relay's @@ -542,12 +536,12 @@ mechanisms. Each is documented here rather than code-changed. not autonomously start an extend or re-split (only the unfriend path auto-drives a re-split, under the destroy gate). This matches the §9.3.4 decision to prompt the owner rather than act unattended: the recommendation is surfaced, the owner starts it via the recovery API. -- **§14 split-state at-rest sealing is correct but not exercised.** The seal primitive is +- **§14 split-state at-rest sealing is now exercised.** The seal primitive is `HKDF(K_root, "carapace/v1/split-state")` + XChaCha20-Poly1305 with `aad = rsid‖M` - (`state_seal`), unit-tested, but the daemon holds split-state only in memory (all daemon - runtime state is in-memory, no persistence yet), so nothing is ever written unsealed. When - persistence lands, seal on write. Endpoint compromise of an owner device is out of scope - (§14). + (`state_seal`). `split_states` persists as a SEAL-category row in redb (`persist.rs`), + sealed under `K_root` on every write, so the primitive is now exercised on the live + persistence path rather than only unit-tested in isolation. Endpoint compromise of an + owner device is out of scope (§14). - **§14 weakest-split rule "K_root split once" (SHOULD) is not enforced.** `recovery_split` accepts more than one root split; nothing rejects a second `K_root` door. The scope distinction (`RecoveryScope::Root` vs `Vault`) exists and the default flow splits once, but From 35b16db3db35486c933da5e28c518a1cb34eff56 Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:37:35 -0400 Subject: [PATCH 02/28] Trim comment cruft in replica, recovery, vault Collapse WHAT-narration and multi-line spec-clause comment essays to one-line WHY where non-obvious, delete the rest. Verified comment-only: stripping comments+blanks leaves byte-identical code. Per-crate tests, clippy (-D warnings), and fmt all green. por.rs's 64-line module block is now 18. --- crates/carapace-recovery/src/ceremony.rs | 118 ++++++-------- crates/carapace-recovery/src/grant.rs | 25 ++- crates/carapace-recovery/src/lib.rs | 2 +- crates/carapace-recovery/src/split.rs | 11 +- crates/carapace-replica/src/lib.rs | 27 ++-- crates/carapace-replica/src/por.rs | 198 +++++++---------------- crates/carapace-vault/src/lib.rs | 113 +++++-------- crates/carapace-vault/src/merge.rs | 130 ++++++--------- 8 files changed, 208 insertions(+), 416 deletions(-) diff --git a/crates/carapace-recovery/src/ceremony.rs b/crates/carapace-recovery/src/ceremony.rs index 3dd810a..6793be1 100644 --- a/crates/carapace-recovery/src/ceremony.rs +++ b/crates/carapace-recovery/src/ceremony.rs @@ -1,9 +1,6 @@ -//! The recovery ceremony (protocol §8.5, normative). A testable state machine plus the message -//! builders/verifiers. No protocol can cryptographically prove a key-less claimant is the owner, -//! so the ceremony structures human verification and makes silent takeover loud and slow: -//! only a trustee may open, the subject's own key can abort unforgeably, and no share moves before -//! both the delay elapses and `M` trustees approve. Wall-clock time is injected as a `now` -//! parameter so tests never sleep. +//! The recovery ceremony (protocol §8.5): state machine plus message builders/verifiers. Only a +//! trustee may open, the subject's key aborts unforgeably, and no share moves before the delay +//! elapses and `M` trustees approve. Wall-clock time is injected as `now` so tests never sleep. use carapace_crypto::seal::{open, seal, HpkePrivateKey, HpkePublicKey}; use carapace_wire::{ @@ -32,8 +29,8 @@ pub enum CeremonyPhase { Aborted, } -/// Per-subject rate limiter for `RecoveryOpen` (protocol §8.5: "rate-limited per subject"). -/// A sliding window of recent opens; opens beyond `max_per_window` inside `window_secs` are refused. +/// Per-subject rate limiter for `RecoveryOpen` (protocol §8.5): a sliding window of recent opens; +/// opens beyond `max_per_window` inside `window_secs` are refused. pub struct RecoveryRateLimiter { window_secs: u64, max_per_window: usize, @@ -52,7 +49,7 @@ impl RecoveryRateLimiter { } /// Record an open for `subject` at `now`, or refuse with [`RecoveryError::RateLimited`] if the - /// window is already full. Prunes events older than the window on each call. + /// window is full. Prunes events older than the window on each call. pub fn check_and_record(&mut self, subject: [u8; 32], now: u64) -> Result<(), RecoveryError> { self.events .retain(|(_, t)| now.saturating_sub(*t) < self.window_secs); @@ -99,8 +96,6 @@ pub fn open_recovery( /// recovery set (protocol §8.5 step 1 - strangers cannot open). `roster` is the co-trustee user /// pubkeys for the set. pub fn verify_recovery_open(open: &RecoveryOpen, roster: &[[u8; 32]]) -> Result<(), RecoveryError> { - // The wire `rsid` is a u64, but Chela's `recovery_set_id` is 11-bit; a larger value cannot - // reference a real set, so reject it on ingest rather than carrying it inward. if open.rsid > MAX_RSID { return Err(RecoveryError::RsidOutOfRange); } @@ -124,11 +119,11 @@ pub struct CeremonyState { pub ceremony_enc: [u8; 32], /// The recovery delay in seconds (from the `ShareGrant`, default 72 h). pub recovery_delay: u64, - /// The sponsor's claimed open time (from the signed `RecoveryOpen`). Advisory only: the release - /// gate uses `max(opened_at, first_seen)` so a sponsor cannot backdate it (see `can_release`). + /// The sponsor's claimed open time. Advisory only: the gate uses `max(opened_at, first_seen)` + /// so a sponsor cannot backdate it (see [`Self::can_release`]). pub opened_at: u64, - /// This observer's own first-observation time, captured when it began tracking the ceremony. - /// The delay clock is anchored here, not to the sponsor-controlled `opened_at`. + /// This observer's own first-observation time; the delay clock is anchored here, not to the + /// sponsor-controlled `opened_at`. pub first_seen: u64, /// The reconstruction threshold `M`. pub m: u8, @@ -138,12 +133,9 @@ pub struct CeremonyState { } impl CeremonyState { - /// Begin tracking a ceremony from a verified [`RecoveryOpen`]. Verifies the open against the - /// roster (only a trustee may open). `recovery_delay` and `m` come from the `ShareGrant`. `now` - /// is this observer's wall clock at ingest and anchors the delay (see [`Self::can_release`]). - /// - /// Prefer [`Self::open_from_grant`], which binds the open to a verified grant, derives the - /// roster / `m` / `recovery_delay` from it, and applies the per-subject rate limit. This + /// Begin tracking a ceremony from a verified [`RecoveryOpen`], checking it against the roster + /// (only a trustee may open). `now` anchors the delay (see [`Self::can_release`]). Prefer + /// [`Self::open_from_grant`], which binds the open to a verified grant and rate-limits; this /// lower-level form trusts the caller to pair the correct roster with the open. pub fn open( open: &RecoveryOpen, @@ -156,18 +148,10 @@ impl CeremonyState { Ok(Self::track(open, roster, m, recovery_delay, now)) } - /// Begin tracking a ceremony bound to the [`ShareGrant`] that gates it. This is the front door - /// for an inbound [`RecoveryOpen`] and closes the composition gaps of the raw [`Self::open`]: - /// - /// - verifies the grant's signature and decodes its share; - /// - requires `open.subject == grant.subject` and `open.rsid == share.recovery_set_id` - /// (an open cannot be gated on a grant for a different subject or set); - /// - derives the roster (`grant.by` plus every `grant.cotrustees[i].user`), threshold `M` - /// (the share's), and `recovery_delay` from the grant rather than trusting loose arguments; - /// - verifies the open against that derived roster (only a trustee may sponsor); - /// - charges the per-subject rate limit (protocol §8.5: "rate-limited per subject"). - /// - /// `limiter` is the daemon's long-lived [`RecoveryRateLimiter`]; `now` anchors the delay. + /// Begin tracking a ceremony bound to the [`ShareGrant`] that gates it (the front door for an + /// inbound [`RecoveryOpen`]). Verifies the grant, requires the open's subject/rsid to match it, + /// derives the roster / threshold / delay from the grant (not loose arguments), verifies the + /// open against that roster, and charges the per-subject rate limit. `now` anchors the delay. pub fn open_from_grant( open: &RecoveryOpen, grant: &ShareGrant, @@ -195,7 +179,7 @@ impl CeremonyState { } /// Construct the tracking state from already-verified parts. Callers MUST have verified the - /// open against `roster` first (both [`Self::open`] and [`Self::open_from_grant`] do). + /// open against `roster` first. fn track( open: &RecoveryOpen, roster: Vec<[u8; 32]>, @@ -219,7 +203,7 @@ impl CeremonyState { } /// Apply a [`CeremonyApprove`] (protocol §8.5 step 4): verify the signature, the ceremony id, - /// and that the approver is a roster trustee; record a distinct approval. + /// and roster membership; record a distinct approval. pub fn approve(&mut self, ap: &CeremonyApprove) -> Result<(), RecoveryError> { ap.verify()?; if ap.ceremony_id != self.ceremony_id { @@ -235,8 +219,8 @@ impl CeremonyState { Ok(()) } - /// Apply a [`CeremonyAbort`] (protocol §8.5 step 3). Authoritative and unforgeable: it must be - /// signed by the subject's *user* key. A valid abort cancels the ceremony permanently. + /// Apply a [`CeremonyAbort`] (protocol §8.5 step 3): must be signed by the subject's *user* + /// key, and cancels the ceremony permanently. pub fn abort(&mut self, ab: &CeremonyAbort) -> Result<(), RecoveryError> { ab.verify()?; if ab.ceremony_id != self.ceremony_id { @@ -262,12 +246,11 @@ impl CeremonyState { } /// Whether shares may be released at `now`: not aborted, `≥ M` approvals, AND the delay has - /// elapsed (protocol §8.5 step 5). Both conditions are required. + /// elapsed (protocol §8.5 step 5). /// - /// The delay is anchored to `max(opened_at, first_seen)`, not to the sponsor-controlled - /// `opened_at` alone. A malicious sponsor who backdates `opened_at` (e.g. to 0) cannot collapse - /// the abort window: each honest observer still waits `recovery_delay` from its own first - /// observation. A future-dated `opened_at` only pushes release later. See spec-errata E4. + /// Anchored to `max(opened_at, first_seen)` so a sponsor who backdates `opened_at` cannot + /// collapse the abort window; each observer still waits `recovery_delay` from its own first + /// observation (spec-errata E4). #[must_use] pub fn can_release(&self, now: u64) -> bool { let release_at = self @@ -289,16 +272,11 @@ impl CeremonyState { } } - /// Serialize the complete tracking state to deterministic canonical CBOR (the same restricted - /// profile the wire uses: sorted keys, shortest-form ints, definite lengths) for durable - /// persistence across a daemon reboot. Lossless: every field - including the private roster, - /// approvals, and `aborted` flag, and the local-clock anchor `first_seen` - round-trips through - /// [`Self::from_bytes`]. This state carries pubkeys/sigs/flags only, never share bytes; the - /// persistence layer seals it at rest, so this method does not encrypt. - /// - /// A dropped field here would be a §8.5 security regression: losing `first_seen` resets the - /// delay window on reboot, losing an approval un-approves a trustee, and losing `aborted` - /// re-opens a ceremony the subject already cancelled. + /// Serialize the full tracking state to canonical CBOR for durable persistence across a daemon + /// reboot. Lossless: every field (including roster, approvals, `aborted`, and the local-clock + /// anchor `first_seen`) round-trips through [`Self::from_bytes`]. Dropping any field is a §8.5 + /// regression - losing `first_seen` resets the delay, losing `aborted` re-opens a cancelled + /// ceremony. Carries pubkeys/sigs/flags only, never share bytes, so it does not encrypt. #[must_use] pub fn to_bytes(&self) -> Vec { let mut m = Map::new(); @@ -333,8 +311,8 @@ impl CeremonyState { } /// Reconstruct a [`CeremonyState`] from [`Self::to_bytes`]. Strict: the canonical decoder - /// rejects non-canonical CBOR, an unknown key, a missing field, or a byte string of the wrong - /// length, and `m` must fit in a `u8`. Errors surface as [`RecoveryError::Wire`]. + /// rejects non-canonical CBOR, unknown/missing keys, or a wrong-length byte string, and `m` + /// must fit in a `u8`. Errors surface as [`RecoveryError::Wire`]. pub fn from_bytes(b: &[u8]) -> Result { let mut m = decode(b)?.into_map()?; let ceremony_id = m.take(0)?.into_array_n()?; @@ -377,9 +355,8 @@ impl CeremonyState { } /// Build a [`CeremonyShare`] (protocol §8.5 step 5): HPKE-seal the trustee's `chela.share` JSON to -/// the claimant's `ceremony_enc` pubkey, then sign the message with the trustee key. The sealed -/// bytes are `encapped_key(32) ‖ ciphertext`; the ceremony id is bound as AEAD associated data. -/// No trustee ever sees another's share. +/// the claimant's `ceremony_enc` pubkey, then sign with the trustee key. Sealed bytes are +/// `encapped_key(32) ‖ ciphertext`; the ceremony id is bound as AEAD associated data. pub fn build_ceremony_share( trustee: &SigningKey, ceremony_id: [u8; 16], @@ -393,7 +370,7 @@ pub fn build_ceremony_share( &ceremony_id, share_json.as_bytes(), )?; - let mut sealed = encapped; // X25519 encapped key is 32 bytes + let mut sealed = encapped; sealed.extend_from_slice(&ct); let mut cs = CeremonyShare { @@ -407,10 +384,9 @@ pub fn build_ceremony_share( } /// Open a [`CeremonyShare`] with the claimant's ceremony private key, returning the recovered -/// `chela.share` JSON. Authenticates the sender first - the trustee signature must verify AND -/// `cs.by` must be in `roster` - before decrypting, so a party who merely observed the (semi-public) -/// `ceremony_enc` cannot feed the claimant a bogus share. Then splits the leading 32-byte encapped -/// key from the ciphertext and binds the ceremony id as associated data. +/// `chela.share` JSON. Authenticates the sender (signature valid AND `cs.by` in `roster`) *before* +/// decrypting, so a party who merely observed the semi-public `ceremony_enc` cannot feed the +/// claimant a bogus share. The ceremony id is bound as associated data. pub fn open_ceremony_share( recipient: &HpkePrivateKey, cs: &CeremonyShare, @@ -788,9 +764,8 @@ mod tests { )); } - /// C1: a malicious sponsor who backdates `opened_at` cannot skip the delay. The clock is - /// anchored to the observer's own `first_seen`, so with `opened_at = 0` and M approvals the - /// ceremony is still not releasable until `first_seen + recovery_delay`. + /// C1: a sponsor who backdates `opened_at` to 0 cannot skip the delay; the clock is anchored + /// to the observer's own `first_seen`. #[test] fn delay_anchored_to_first_seen_not_sponsor_opened_at() { let s = setup(); @@ -970,16 +945,14 @@ mod tests { )); } - /// Durable persistence: a mid-ceremony `CeremonyState` (open, some approvals, a set - /// `first_seen`) survives serialize/deserialize byte-for-byte, so a daemon reboot cannot reset - /// the delay clock, drop an approval, or lose an abort (§8.5). Covers both the live and the - /// aborted variant. + /// Durable persistence: a mid-ceremony `CeremonyState` survives serialize/deserialize + /// byte-for-byte, so a reboot cannot reset the delay, drop an approval, or lose an abort + /// (§8.5). Covers both the live and the aborted variant. #[test] fn ceremony_state_persistence_round_trips() { let s = setup(); let open = an_open(&s, u64::from(s.shares[0].recovery_set_id), T0); - // first_seen is anchored to `now` at open; use a distinct value from opened_at to prove the - // E4 local-clock anchor is what survives, not the sponsor-controlled opened_at. + // Distinct from opened_at to prove the E4 local-clock anchor survives, not opened_at. let first_seen = T0 + 5; let mut cer = CeremonyState::open(&open, s.roster.clone(), 3, DELAY, first_seen).unwrap(); // Two of three approvals recorded so far (mid-ceremony, sub-M). @@ -1022,8 +995,7 @@ mod tests { cer.approve(&ap).unwrap(); assert!(cer.can_release(first_seen + DELAY)); - // The abort flag survives: an aborted ceremony round-trips as still aborted and never - // releases, blocking a silent takeover across a reboot. + // The abort flag survives: an aborted ceremony round-trips as still aborted. let mut abort = CeremonyAbort { ceremony_id: [0xCE; 16], by: [0; 32], diff --git a/crates/carapace-recovery/src/grant.rs b/crates/carapace-recovery/src/grant.rs index de04bcf..160740c 100644 --- a/crates/carapace-recovery/src/grant.rs +++ b/crates/carapace-recovery/src/grant.rs @@ -1,7 +1,6 @@ //! `ShareGrant` (wire type 12) and the attestation cycle (protocol §8, §10.2). A grant wraps the -//! `chela.share` JSON carrier verbatim together with the co-trustee roster, recovery delay, and -//! latest announce refs a quorum needs to act. Attestation proves a stored share is still live -//! using label fields only - never the words. +//! `chela.share` JSON carrier verbatim with the co-trustee roster, recovery delay, and announce +//! refs a quorum needs to act. Attestation proves a stored share is live using label fields only. use carapace_wire::{ AnnounceRef, CoTrustee, ShareAttestChallenge, ShareAttestation, ShareGrant, Signed, @@ -13,12 +12,9 @@ use ed25519_dalek::SigningKey; use crate::RecoveryError; /// Build and sign a [`ShareGrant`] for `subject`. The share is serialized to its canonical -/// `chela.share` JSON carrier (SPEC §6.2) and stored verbatim; the roster, recovery delay, and -/// announce refs are what a quorum needs to run the ceremony when the owner is gone (§8). -/// -/// `recovery_delay` is the owner's own abort window (§8.5, default 72 h). A very small value -/// collapses that window to "M approvals"; owners SHOULD keep a floor (see spec-errata E5). It is -/// accepted verbatim here because the spec makes it the owner's choice. +/// `chela.share` JSON carrier (SPEC §6.2) and stored verbatim, with the roster, recovery delay, +/// and announce refs a quorum needs to run the ceremony (§8). `recovery_delay` is the owner's +/// abort window (§8.5, default 72 h), accepted verbatim as the spec makes it the owner's choice. pub fn build_share_grant( signer: &SigningKey, subject: [u8; 32], @@ -93,13 +89,12 @@ pub fn build_attest_challenge( } /// Answer a challenge with a signed [`ShareAttestation`] (protocol §10.2). The share is first -/// self-validated (a corrupt share is [`RecoveryError::Engine`]); the attestation echoes only the -/// label fields (`card_number` = the share's `x`) and the challenge nonce - never the words. +/// self-validated; the attestation echoes only the label fields (`card_number` = the share's `x`) +/// and the challenge nonce - never the words. /// -/// S6: the answered share MUST belong to the recovery set the challenge names -/// (`share.recovery_set_id == challenge.rsid`). Without this pin a trustee could -/// answer a new-set liveness challenge with a valid share from *any* set it holds, -/// so the attested-live count would not bind the actual new-set share (§10.2). +/// S6: the answered share MUST belong to the set the challenge names +/// (`share.recovery_set_id == challenge.rsid`), else a trustee could answer with a valid share +/// from any set it holds and the attested-live count would not bind the actual set's share. pub fn answer_attest_challenge( signer: &SigningKey, challenge: &ShareAttestChallenge, diff --git a/crates/carapace-recovery/src/lib.rs b/crates/carapace-recovery/src/lib.rs index 24e37b9..6c885f8 100644 --- a/crates/carapace-recovery/src/lib.rs +++ b/crates/carapace-recovery/src/lib.rs @@ -1,6 +1,6 @@ //! carapace-recovery: recovery-via-Chela orchestration (protocol §8). //! -//! Carapace consumes Chela's extendable-split profile through four concerns, one module each: +//! Carapace consumes Chela's extendable-split profile through three concerns, one module each: //! //! - [`split`]: split `K_root` (inner circle) and `K_vaultroot(vid)` (scoped, §8.2); extend to //! add a trustee / replace a lost share; the §8.3 issuance cap; owner-side round-trip diff --git a/crates/carapace-recovery/src/split.rs b/crates/carapace-recovery/src/split.rs index 5ea64f2..ec1a9f3 100644 --- a/crates/carapace-recovery/src/split.rs +++ b/crates/carapace-recovery/src/split.rs @@ -15,8 +15,7 @@ use crate::{key_to_mnemonic, mnemonic_to_key, RecoveryError}; /// it a recovering coalition would need at most ⅓ of outstanding shares. #[must_use] pub fn soft_cap(m: u8) -> usize { - // S3: saturating so a bogus m=0 (thresholds are >= 2 in practice) yields 0 - // rather than underflowing/panicking; `3*M - 1` for every real threshold. + // S3: saturating so a bogus m=0 yields 0 rather than underflowing. usize::from(m).saturating_mul(3).saturating_sub(1) } @@ -189,12 +188,8 @@ pub fn extend_split( Ok((shares, warnings)) } -// Adding a trustee / replacing a lost share (§8.1) is `extend_split` on the in-memory -// `SplitState` (issue one more share at a fresh x on the same polynomial); the daemon owns -// the state's at-rest sealing through the single redb state-row seal (design §3.4 "one -// mechanism"). The former `add_trustee`/`replace_lost_share` wrappers (which sealed the -// state a SECOND way, via the retired `state_seal` module) are gone - callers use -// `extend_split` directly (see `Daemon::recovery_extend`). +// Adding a trustee / replacing a lost share (§8.1) is `extend_split` directly: one more share at a +// fresh x on the same polynomial. The daemon owns at-rest sealing via the single redb state-row seal. #[cfg(test)] mod tests { diff --git a/crates/carapace-replica/src/lib.rs b/crates/carapace-replica/src/lib.rs index 4dff26b..27433ec 100644 --- a/crates/carapace-replica/src/lib.rs +++ b/crates/carapace-replica/src/lib.rs @@ -1,24 +1,17 @@ //! carapace-replica: consent-based replica placement and repair (protocol §10.1). //! -//! Per vault the owner maintains invariant `r` (default 3) accepted storage -//! peers, each holding the current [`carapace_wire::ManifestEnvelope`] plus every -//! ciphertext chunk. Placement is consent-based **both directions**: the owner -//! selects a friend and sends a [`carapace_wire::ReplicaInvite`]; the friend -//! either signs a [`carapace_wire::ReplicaAccept`] or declines. Local private -//! policies and deny-lists gate the decision on both sides ([`Policy`]). +//! Per vault the owner maintains invariant `r` (default 3) accepted storage peers, +//! each holding the current [`carapace_wire::ManifestEnvelope`] plus every ciphertext +//! chunk. Placement needs consent both directions (invite/accept), gated by each +//! side's local [`Policy`] (deny-lists + quota). //! -//! - [`peer`]: [`ReplicaPeer`], a friend's storage node - its consent decision -//! ([`ReplicaPeer::consider`]) and blob intake ([`ReplicaPeer::receive`]). -//! - [`owner`]: [`ReplicaSet`], the owner-side manager - place, track membership, -//! evaluate replica health against an injected clock, repair on confirmed loss -//! (unfriended, or unreachable past the grace window), and re-announce -//! ([`carapace_wire::VaultAnnounce`]). -//! - [`policy`]: [`Policy`] (deny-lists + quota) and [`Health`] signals. +//! - [`peer`]: [`ReplicaPeer`], a friend's storage node (consent + blob intake). +//! - [`owner`]: [`ReplicaSet`], the owner-side manager (place, track, repair, announce). +//! - [`policy`]: [`Policy`] and [`Health`] signals. //! -//! Offline is not failure: a replica that is merely unreachable inside the grace -//! window (default 24 h) is kept. Only confirmed loss triggers re-replication to -//! a fresh accepting friend and a new announce reflecting the updated set. Reads -//! succeed while at least one current replica or the owner device is reachable. +//! Offline is not failure: a replica merely unreachable inside the grace window +//! (default 24 h) is kept; only confirmed loss triggers re-replication and a fresh +//! announce. Reads succeed while any current replica or the owner device is reachable. pub mod owner; pub mod peer; diff --git a/crates/carapace-replica/src/por.rs b/crates/carapace-replica/src/por.rs index 3b8bf28..9ad5f8b 100644 --- a/crates/carapace-replica/src/por.rs +++ b/crates/carapace-replica/src/por.rs @@ -1,67 +1,22 @@ //! Proof-of-Retention (PoR) audits (protocol §10.1). //! -//! The owner periodically challenges each replica to prove it still holds the -//! ciphertext chunks it accepted. Challenges are **unpredictable to the peer**: -//! the owner derives which chunks (and which byte ranges within them) to sample -//! from `K_audit(vid)` - a key only the owner holds - mixed with the announce -//! epoch and a per-replica round counter. The sampling is deterministic given -//! `(K_audit, epoch, round)` so the owner can rebuild and verify the same -//! challenge, yet a peer without `K_audit` cannot precompute the answers, so it -//! cannot discard chunks and reconstruct only the sampled ones on demand. +//! The owner samples which chunks/ranges to challenge from `K_audit(vid)` (owner-only) +//! mixed with epoch and a per-replica round counter: deterministic so the owner can +//! rebuild and verify, unpredictable so a peer without `K_audit` cannot precompute +//! answers and keep only the sampled chunks. Responses are content-addressed +//! (ChunkID = `BLAKE3(ct)`), so returned bytes verify by hashing - no owner-held copy. //! -//! A challenge is answered with BLAKE3-verified blob data. Each chunk is -//! content-addressed (its ChunkID is `BLAKE3(ciphertext)`), so returned bytes -//! verify against the sampled ChunkID by hashing - no owner-held copy and no -//! shared secret are needed. A correct response to the whole sampled set is the -//! retention proof ([`run_audit`] / [`verify_audit_response`]). +//! Offline is not loss: an unreachable round feeds [`AuditTracker::record_unreachable`] +//! and leaves the failure streak untouched; only a peer that answers with missing or +//! non-matching bytes advances it. `N` consecutive failures (default 3) yields +//! [`AuditAction::Lost`], which the caller turns into [`crate::Health::AuditLost`] + +//! [`crate::ReplicaSet::repair`]. //! -//! The bytes returned for a sample must *cover* the sampled `offset..offset+len` -//! range; in the wired path the responder returns the whole content-addressed -//! chunk (verified by hash) and the range simply selects a focus sub-range, so a -//! full chunk always covers it. Production SHOULD narrow this to bao -//! verified-range streaming so only the sampled bytes cross the wire; until then -//! the per-sample `offset`/`len` are a focus record, not a fidelity boundary. -//! -//! Transport vs. content: a peer that could not be reached at all is *not* a -//! retention failure. Only a peer that answered but is missing or returns -//! non-matching bytes for a sampled chunk counts toward the loss streak. An -//! unreachable round is fed to [`AuditTracker::record_unreachable`], which -//! reschedules without touching the streak (offline is not loss, §10.1); the -//! separate reachability/grace path (`Health::UnreachableSince`) handles a peer -//! that stays gone. -//! -//! Loss tracking ([`AuditTracker`]): `N` consecutive failures (default 3, §12) -//! marks the replica lost and yields an [`AuditAction::Lost`]; the caller feeds -//! that into the existing repair path by recording [`crate::Health::AuditLost`] -//! and calling [`crate::ReplicaSet::repair`], which drops the peer and -//! re-replicates. Audit timing is randomized per replica (a deterministic -//! per-replica jitter, so scheduling stays testable under an injected clock), -//! and an occasional **wide-coverage** round ([`build_wide_audit`]) samples a -//! large random subset in one window instead of the small per-round spot check. -//! -//! # Proxy limitation (§10.1 / audit D1) -//! -//! A PoR pass proves the sampled bytes are *retrievable through the audited -//! peer* at audit time - **not** that the peer stores them exclusively or even -//! itself. A dishonest peer that discarded its copy could proxy each challenge -//! to another replica that still holds the data and relay the verified bytes -//! back; the response would verify identically. PoR therefore cannot, on its -//! own, distinguish independent storage from friend-proxied storage. The -//! accepted mitigations are all availability-side, not proofs: -//! -//! - **Randomized per-replica timing** (see [`AuditTracker::schedule`]) so a -//! proxy cannot cheaply pre-arrange to have a helper online exactly when each -//! audit lands. -//! - **Occasional wide-coverage audits** ([`build_wide_audit`]) that demand a -//! large subset at once, making live proxying of the whole set expensive. -//! - **Response-time distribution watching**: a proxied answer adds a network -//! hop, so an owner SHOULD watch each replica's latency distribution and treat -//! a shifted tail as suspicious. This module only records the sampled ranges -//! and leaves the timing to the caller (time [`run_audit`] at the call site); -//! it deliberately does not build the statistics here. -//! -//! Residual friend-proxying is an availability risk only and is accepted by the -//! trust model (§10.1); it never exposes plaintext, which stays sealed. +//! Proxy limitation (§10.1): a pass proves the bytes are retrievable *through* the peer, +//! not stored exclusively by it - a dishonest peer could proxy the challenge to another +//! replica. Mitigations are availability-side only (randomized per-replica timing, +//! occasional wide-coverage rounds, caller-side latency watching), never proofs; residual +//! friend-proxying is an accepted availability risk that never exposes plaintext. use std::collections::HashMap; @@ -162,19 +117,9 @@ impl AuditOutcome { } /// Something that can answer a PoR challenge for one sampled chunk. -/// -/// In production this is an iroh-blobs verified-streaming reader that returns the -/// requested range plus a bao proof tying it to the ChunkID. The in-process -/// implementation ([`ReplicaPeer`]) returns the whole content-addressed blob, -/// which [`verify_audit_response`] BLAKE3-checks against the ChunkID (the -/// degenerate bao proof is the leaf itself); the sampled `offset`/`len` then -/// select the focused sub-range. Either way, a returned value that verifies -/// against the ChunkID is proof the responder held the content. pub trait AuditResponder { - /// Return content-addressed bytes covering `sample`'s chunk, or `None` if the - /// chunk is not held. The bytes MUST verify against `sample.chunk_id` - /// (bao-verified in production; guaranteed by the content-addressed store - /// in-process). + /// Return content-addressed bytes covering `sample`'s chunk (must verify against + /// `sample.chunk_id`), or `None` if the chunk is not held. fn respond(&self, sample: &AuditSample) -> Option>; } @@ -224,8 +169,7 @@ fn distinct_indices(r: &mut blake3::OutputReader, n: usize, want: usize) -> Vec< let mut idx: Vec = (0..n).collect(); let k = want.min(n); for i in 0..k { - // Uniform-ish pick in [i, n): modulo bias is negligible for our small n - // and does not affect the security goal (unpredictability, not uniformity). + // Modulo bias is negligible for our small n and the goal is unpredictability, not uniformity. let j = i + (next_u64(r) as usize) % (n - i); idx.swap(i, j); } @@ -340,13 +284,10 @@ pub fn verify_audit_response(audit: &Audit, responses: &[Option>]) -> Au let Some(bytes) = resp else { return AuditOutcome::Fail(AuditFailure::Missing(s.chunk_id)); }; - // BLAKE3-verify the returned bytes against the content address. if chunk_id(bytes) != s.chunk_id { return AuditOutcome::Fail(AuditFailure::Corrupt(s.chunk_id)); } - // The verified content must actually cover the sampled range. S3: - // saturating add so a hostile owner-supplied sample (public fields) cannot - // overflow the range check. + // Saturating: sample fields are public, so a hostile owner cannot overflow this. let need = s.offset.saturating_add(s.len) as usize; if bytes.len() < need { return AuditOutcome::Fail(AuditFailure::ShortRange { @@ -387,20 +328,15 @@ pub enum AuditAction { /// Consecutive failures reached the limit: treat the replica as lost. The /// caller should record [`crate::Health::AuditLost`] and repair. Lost, - /// The replica could not be reached at all this round (transport failure, not - /// a content answer). The failure streak and round counter are left untouched - /// (offline is not retention loss, §10.1) and the next audit is rescheduled. - /// Produced by [`AuditTracker::record_unreachable`], never by - /// [`AuditTracker::record`]. + /// The replica could not be reached this round (transport failure, not a content + /// answer): streak and round counter untouched, next audit rescheduled (§10.1). + /// Produced by [`AuditTracker::record_unreachable`], never [`AuditTracker::record`]. Skipped, } -/// Per-replica PoR bookkeeping against an injected clock: consecutive-failure -/// counts, the next scheduled audit time (randomized per replica), and a round -/// counter that also decides when a round is wide-coverage. -/// -/// Keyed by `(replica_node_id, vid)` so one tracker serves every vault a set of -/// replicas holds. +/// Per-replica PoR bookkeeping against an injected clock, keyed by +/// `(replica_node_id, vid)`: consecutive-failure counts, the randomized next-audit +/// time, and a round counter that also decides when a round is wide-coverage. pub struct AuditTracker { interval: u64, fail_limit: u32, @@ -426,11 +362,10 @@ impl AuditTracker { } } - /// Update ONLY the cadence scalars (`interval`, `fail_limit`, `wide_every`), KEEPING - /// every per-replica round/fail/schedule map (design §10.1 F4). The maintenance loop - /// stamps its configured interval at start; using this instead of a fresh - /// [`AuditTracker::new`] preserves the round counters `load_all` restored, so a reboot - /// never re-issues an already-used (predictable) PoR challenge for a replica. + /// Update only the cadence scalars, keeping every per-replica round/fail/schedule + /// map. The maintenance loop stamps its interval at start via this rather than a fresh + /// [`AuditTracker::new`], so restored round counters survive and a reboot never + /// re-issues an already-used (now-predictable) challenge (§10.1). pub fn restamp(&mut self, interval: u64, fail_limit: u32, wide_every: u64) { self.interval = interval; self.fail_limit = fail_limit; @@ -490,25 +425,21 @@ impl AuditTracker { self.record_outcome(replica, vid, outcome, now) } - /// Mark a challenge as ISSUED to `replica` for the current round: advance the round - /// counter (the unpredictability nonce) and reschedule. The owner MUST persist this - /// BEFORE revealing the challenge on the wire (design §10.1 / audit #6), so a crash - /// after the reveal can never re-issue the same - now predictable - round to the same - /// replica. Does NOT touch the failure streak: that is judged on the answer via - /// [`AuditTracker::record_outcome`]. Callers that both build and grade a challenge - /// atomically (no crash window between reveal and grade) can use [`AuditTracker::record`] - /// instead, which advances the round and grades in one step. + /// Mark a challenge as issued: advance the round nonce and reschedule, without + /// touching the failure streak (graded later via [`AuditTracker::record_outcome`]). + /// The owner MUST persist this BEFORE revealing the challenge on the wire (§10.1), + /// so a crash after reveal cannot re-issue the same now-predictable round. Callers + /// that build and grade atomically can use [`AuditTracker::record`] instead. pub fn mark_issued(&mut self, replica: [u8; 32], vid: [u8; 32], now: u64) { *self.round.entry((replica, vid)).or_insert(0) += 1; self.schedule(replica, vid, now); } - /// Record the CONTENT outcome of an audit whose round was already advanced at issue - /// time via [`AuditTracker::mark_issued`]: reschedule and update the consecutive-failure - /// streak ONLY (never the round counter, which was committed at issue time). On - /// [`AuditOutcome::Pass`] the streak resets; on failure it increments and, at the - /// limit, returns [`AuditAction::Lost`] (and resets the streak, since the caller will - /// repair and drop the replica). + /// Record the content outcome of an audit whose round was already advanced via + /// [`AuditTracker::mark_issued`]: reschedule and update the failure streak only + /// (never the round counter). Pass resets the streak; failure increments it and, at + /// the limit, returns [`AuditAction::Lost`] (resetting the streak, since the caller + /// repairs and drops the replica). pub fn record_outcome( &mut self, replica: [u8; 32], @@ -533,12 +464,10 @@ impl AuditTracker { } } - /// Record that the replica could not be reached this round (C1): reschedule the - /// next audit relative to `now` but leave the failure streak and round counter - /// untouched. A transient offline peer (travel, ISP outage, closed laptop) must - /// not accumulate PoR failures and be evicted without grace - offline is not - /// retention loss (§10.1). Only a peer that *answered* with missing or - /// non-matching bytes advances the streak via [`AuditTracker::record`]. + /// Record that the replica could not be reached this round: reschedule but leave the + /// failure streak and round counter untouched. Offline is not retention loss (§10.1), + /// so a transiently-offline peer must not accumulate PoR failures; only a peer that + /// answered with missing/non-matching bytes advances the streak via [`AuditTracker::record`]. pub fn record_unreachable( &mut self, replica: [u8; 32], @@ -549,25 +478,10 @@ impl AuditTracker { AuditAction::Skipped } - /// Serialize the full tracker to a deterministic, lossless byte string for the - /// durable-persistence funnel (spec §3.3, `por` = PLAIN F4). These are counters - /// and schedule times, not secrets; the caller decides at-rest sealing. - /// - /// Every field that steers a future challenge is captured, so a reboot resumes - /// exactly where it left off instead of replaying a spent challenge sequence - /// (§10.1): - /// - `interval`, `fail_limit`, `wide_every` - the cadence/limit/wide-period the - /// scheduler and loss logic run on; - /// - `round` - the per-(replica,vid) round counter that *is* the unpredictability - /// nonce; losing it re-issues an identical challenge stream for the epoch; - /// - `fails` - the consecutive-failure streak, so a near-lost replica is not - /// handed a fresh streak by a reboot; - /// - `next` - the randomized next-audit time, so timing (a §10.1 anti-proxy - /// mitigation) is not reset to "due now" on every restart. - /// - /// Map entries are emitted in sorted-key order so equal trackers yield identical - /// bytes (stable across `HashMap` iteration order) - byte-stability the redb - /// row-seal AAD and any dedup rely on. + /// Serialize the tracker losslessly for durable persistence (§10.1 replay + /// safety: losing the `round` counter re-issues an identical, now-predictable + /// challenge stream). Map entries are emitted in sorted-key order so equal + /// trackers yield byte-identical output regardless of `HashMap` iteration order. pub fn to_bytes(&self) -> Vec { let mut out = Vec::new(); out.push(POR_STATE_VERSION); @@ -636,8 +550,7 @@ fn write_u64_map(out: &mut Vec, m: &HashMap) { fn read_u32_map(r: &mut Reader) -> Result, ReplicaError> { let count = r.u64()?; - // No `with_capacity(count)`: a corrupt count must not pre-allocate; `take` - // fails as soon as the bytes run out. + // No `with_capacity(count)`: a corrupt count must not pre-allocate; `take` fails when bytes run out. let mut m = HashMap::new(); for _ in 0..count { let key = (r.arr32()?, r.arr32()?); @@ -730,19 +643,18 @@ mod state_tests { } /// Advance two (replica,vid) pairs through several rounds, serialize, reload, - /// and prove the reloaded tracker resumes the *next* challenge (never a spent - /// round) and preserves the failure streak, schedule, and config. + /// and prove the reload resumes the next challenge (never a spent round) and + /// preserves streak, schedule, and config. #[test] fn round_trip_resumes_and_never_repeats_a_round() { - let a = [0x11u8; 32]; // replica A - let b = [0x22u8; 32]; // replica B + let a = [0x11u8; 32]; + let b = [0x22u8; 32]; let vid = [0xAAu8; 32]; let interval = 3600u64; let mut t = AuditTracker::new(interval, DEFAULT_POR_FAIL_LIMIT, 4); - // A: pass, pass, pass -> round 3, streak reset. 3 is a wide round (every 4th - // skips 0, so is_wide is false at 3 but the state must survive regardless). + // A: three passes -> round 3, streak reset. for k in 0..3 { assert_eq!( t.record(a, vid, AuditOutcome::Pass, k * 100), @@ -787,9 +699,7 @@ mod state_tests { assert_eq!(t2.next.get(&(b, vid)).copied(), next_b); assert_eq!(t2.is_wide_round(a, vid), wide_a); - // The core §10.1 guarantee: the NEXT challenge continues from the stored - // round, never re-issuing a spent one. `round()` is the nonce for the next - // build_audit; recording again must advance to round+1 for BOTH pairs. + // §10.1: the next challenge continues from the stored round, never a spent one. let mut t2 = t2; assert_eq!(t2.round(a, vid), 3); // next challenge uses round 3, not 0..2 again t2.record(a, vid, AuditOutcome::Pass, 999); diff --git a/crates/carapace-vault/src/lib.rs b/crates/carapace-vault/src/lib.rs index cf7613b..8ca78e7 100644 --- a/crates/carapace-vault/src/lib.rs +++ b/crates/carapace-vault/src/lib.rs @@ -1,16 +1,7 @@ //! carapace-vault: vault identity, directory ingest into a sealed manifest plus //! a content-addressed chunk store, and reconstruction back to plaintext -//! (protocol §5, §7, §11). Network-independent — no iroh here. -//! -//! - [`vid`] / [`new_vid`]: vault identity `BLAKE3-256(user_pubkey ‖ nonce)`. -//! - [`ChunkStore`]: content-addressed ciphertext store ([`MemoryStore`], -//! [`FsStore`]). -//! - [`ingest_dir`]: walk a tree, FastCDC-chunk + seal each file, populate the -//! store, and build a [`Manifest`] + sealed, node-signed [`ManifestEnvelope`]. -//! - [`open_envelope`] / [`reconstruct`]: verify + decrypt back to bytes/disk. -//! -//! Every cryptographic primitive routes through `carapace-crypto`; every wire -//! encoding routes through `carapace-wire`. Nothing is re-implemented here. +//! (protocol §5, §7, §11). Network-independent. Crypto routes through +//! `carapace-crypto`, wire encoding through `carapace-wire`. pub mod merge; mod store; @@ -52,13 +43,12 @@ pub enum VaultError { /// Recovered file bytes did not match the manifest's `file_hash`. FileHashMismatch(String), /// A decrypted chunk's `BLAKE3(plaintext)` did not match the manifest's stored - /// `pt_hash` for that chunk (Option B integrity check, §4.2). + /// `pt_hash` (Option B integrity check, §4.2). ChunkHashMismatch([u8; 32]), /// A manifest path was absolute or escaped the output root (`..`). UnsafePath(String), - /// A file's name was not valid UTF-8, so it cannot round-trip through the - /// manifest's `String` path without a lossy collapse that could alias it onto - /// a distinct file. Rejected rather than silently merged. + /// A non-UTF8 filename: a lossy collapse could alias it onto a distinct file, + /// so it is refused rather than silently merged. NonUtf8Path(String), /// System clock / metadata could not produce a valid mtime. BadMtime, @@ -125,10 +115,9 @@ pub fn vid(user_pubkey: &[u8; 32], creation_nonce: &[u8; 16]) -> [u8; 32] { /// `vid`. Returns `(vid, nonce)` so the caller can persist the nonce. pub fn new_vid(user_pubkey: &[u8; 32]) -> ([u8; 32], [u8; 16]) { let mut nonce = [0u8; 16]; - // ponytail: OS-CSPRNG failure at vault-mint is unrecoverable and not - // attacker-reachable (S8); `expect` here keeps `new_vid` infallible for its - // callers. The RNG-failure path that a peer *can* reach (`seal_manifest`) - // propagates a `VaultError::Rng` instead. + // ponytail: CSPRNG failure at vault-mint is not attacker-reachable (S8), so + // `expect` keeps `new_vid` infallible; the peer-reachable path + // (`seal_manifest`) propagates `VaultError::Rng` instead. getrandom::getrandom(&mut nonce).expect("CSPRNG"); (vid(user_pubkey, &nonce), nonce) } @@ -160,11 +149,8 @@ impl VaultKeys { // ---------------- chunk key map ----------------------------------------- -/// A chunk's decryption secret. `chunk_key`/`nonce` derive one-way from -/// `K_content` + plaintext hash (`pt_hash`). Since the manifest now stores -/// `pt_hash` per chunk (Option B, §4), a `K_content` holder re-derives these with -/// [`chunk_keys_from_manifest`]; the owner no longer needs to persist or grant them -/// for its own recovery. +/// A chunk's decryption secret, derived one-way from `K_content` + `pt_hash`. A +/// `K_content` holder re-derives it via [`chunk_keys_from_manifest`] (Option B, §4). #[derive(Clone)] pub struct ChunkSecret { /// XChaCha20-Poly1305 key. @@ -176,10 +162,9 @@ pub struct ChunkSecret { /// Map from ChunkID to the secret needed to open that blob. pub type ChunkKeys = HashMap<[u8; 32], ChunkSecret>; -/// Option B (§4.2): re-derive the per-chunk `ChunkKeys` for a manifest from -/// `K_content` alone, using each chunk's stored `pt_hash`. This is what lets an -/// owner (or a `K_root`-holding recovery claimant) reconstruct from the sealed -/// manifest with no `FileGrant`. Deleted files carry no chunks and are skipped. +/// Option B (§4.2): re-derive the per-chunk `ChunkKeys` from `K_content` alone, +/// using each chunk's stored `pt_hash`. Lets an owner or recovery claimant +/// reconstruct from the sealed manifest with no `FileGrant`. pub fn chunk_keys_from_manifest(manifest: &Manifest, k_content: &[u8]) -> ChunkKeys { let mut keys = HashMap::new(); for f in &manifest.files { @@ -207,20 +192,14 @@ pub struct Ingest { // ---------------- ingest (§5, §7) --------------------------------------- -/// Walk `dir`, seal every file's chunks into `store`, and build the manifest + -/// sealed envelope for epoch `epoch`, node-signed by `node_key`. +/// Walk `dir` (sorted path order, deterministic manifest), FastCDC-cut and seal +/// each file's chunks (`aad = vid`) into `store`, and build the manifest + sealed +/// envelope for `epoch`, node-signed by `node_key`. /// -/// Files are visited in sorted path order for a deterministic manifest. Each -/// file's chunks are FastCDC-cut, sealed with `aad = vid`, and stored under -/// their ChunkID. -/// -/// Per-file version vectors follow §11. Pass the device's previously-published -/// [`Manifest`] as `prev` (or `None` for a first ingest): a file that *changed* -/// (or is new, or resurrects a tombstone) bumps this node's component so a -/// concurrent edit on another device is later detectable; an *unchanged* file -/// carries its prior vector forward untouched; a file that *disappeared* from -/// disk becomes a tombstone with this node's component bumped, so the delete -/// propagates. +/// Per-file version vectors follow §11 against `prev` (the device's previously- +/// published manifest, or `None` for a first ingest): a changed/new/resurrected +/// file bumps this node's component, an unchanged file carries its vector forward, +/// and a disappeared file becomes a bumped tombstone so the delete propagates. pub fn ingest_dir( dir: &Path, node_key: &SigningKey, @@ -255,9 +234,7 @@ pub fn ingest_dir( let plaintext = &data[off..off + len]; let sealed = content::seal_chunk(&*keys.k_content, &keys.vid, plaintext)?; store.put(sealed.chunk_id, sealed.ciphertext)?; - // Record pt_hash in the manifest (Option B, §4): a K_content holder - // re-derives this chunk's key/nonce from it, so owner sync and recovery - // never need a FileGrant. + // pt_hash in the manifest lets a K_content holder re-derive key/nonce (Option B, §4). chunk_refs.push((sealed.chunk_id, sealed.pt_hash, len as u64)); key_map.entry(sealed.chunk_id).or_insert(ChunkSecret { chunk_key: sealed.chunk_key, @@ -420,10 +397,8 @@ pub fn reconstruct_file( let ct = store.get(id)?.ok_or(VaultError::MissingChunk(*id))?; let secret = keys.get(id).ok_or(VaultError::MissingKey(*id))?; let pt = content::open_chunk(&secret.chunk_key, &secret.nonce, &ct, vid)?; - // Option B free integrity check (§4.2): the manifest's pt_hash must equal - // BLAKE3(plaintext). A key/nonce re-derived from a tampered manifest pt_hash - // already fails the AEAD open above; this also catches a store that returns - // the wrong (but validly-keyed) chunk for this id. + // Option B integrity check (§4.2): also catches a store returning the wrong + // (but validly-keyed) chunk for this id. if blake3::hash(&pt).as_bytes() != pt_hash { return Err(VaultError::ChunkHashMismatch(*id)); } @@ -457,24 +432,14 @@ pub fn reconstruct( Ok(()) } -/// Write `bytes` to `dest`, then restore the entry's `mtime` (and, on unix, its -/// `mode`) so that a subsequent [`ingest_dir`] of this tree round-trips to the -/// identical [`FileEntry`]. -/// -/// This is what makes reconstructing INTO a watched working directory stable -/// (§11): the daemon's re-ingest of the just-written tree reads back the same -/// mtime/mode and content, so it produces the same per-file version vectors and is -/// a no-op instead of a spurious change - which otherwise ping-pongs metadata -/// between devices (mtime/mode feed conflict resolution) and never converges. The -/// existing file is removed first so a restored read-only mode from a prior round -/// does not block the overwrite. +/// Write `bytes` to `dest`, restoring the entry's `mtime` (and unix `mode`) so a +/// subsequent [`ingest_dir`] round-trips to the identical [`FileEntry`] and does +/// not ping-pong metadata between devices (§11). The existing file is removed +/// first so a restored read-only mode from a prior round cannot block the overwrite. /// -/// ponytail: writes in place (remove + create + write), NOT a temp-file + atomic -/// rename, so a reader (or the working-dir watcher) that peeks mid-write can see a -/// truncated/partial file; the daemon's per-vid publish lock + debounce cover its -/// OWN re-ingest, but a concurrent external reader has no such guard. Upgrade path: -/// write to `dest.tmp` then `fs::rename` for atomic replace (and fsync the dir) if -/// external mid-write reads ever matter. +/// ponytail: in-place write (remove + create + write), NOT temp-file + atomic +/// rename, so a concurrent external reader can peek a partial file. Upgrade path: +/// write `dest.tmp` then `fs::rename` (and fsync the dir) if that ever matters. fn write_file_with_meta(dest: &Path, bytes: &[u8], entry: &FileEntry) -> Result<(), VaultError> { use std::io::Write; let _ = fs::remove_file(dest); @@ -519,10 +484,9 @@ fn collect_files(root: &Path, dir: &Path, out: &mut Vec) -> Result<(), Ok(()) } -/// Join a relative path's normal components with `/`, returning `None` if any -/// component is not valid UTF-8. A lossy collapse (`to_string_lossy` -> U+FFFD) -/// could map two distinct filenames onto one manifest path and silently merge -/// their content, so a non-UTF8 name is refused at the source instead. +/// Join a relative path's normal components with `/`, returning `None` on a +/// non-UTF8 component. A lossy collapse could alias two distinct filenames onto +/// one manifest path and merge their content, so it is refused at the source. fn rel_to_slash(rel: &Path) -> Option { let mut parts = Vec::new(); for c in rel.components() { @@ -554,12 +518,11 @@ fn file_mtime(meta: &fs::Metadata) -> Result { /// Join a manifest-supplied relative path onto `base`, rejecting absolute paths /// and any `..` escape (a manifest may be hostile). /// -/// S9 (deferred to the foreign-manifest phase): two residual gaps remain for a -/// *cross-user* hostile manifest — a Windows alternate-data-stream component -/// (`foo:bar`) is not filtered (a blanket `:` reject would break legitimate unix -/// filenames), and `reconstruct`'s `fs::write` follows a pre-existing symlink at -/// the destination. Phase 1 manifests are same-user-trusted, so this is safe as -/// is; tighten both before honoring a friend's manifest. +/// S9 (deferred to the foreign-manifest phase): for a cross-user hostile manifest, +/// a Windows ADS component (`foo:bar`) is not filtered (a blanket `:` reject would +/// break legit unix names), and reconstruct's write follows a pre-existing symlink +/// at the destination. Phase 1 manifests are same-user-trusted; tighten both before +/// honoring a friend's manifest. fn safe_join(base: &Path, rel: &str) -> Result { let mut out = base.to_path_buf(); for part in rel.split('/') { diff --git a/crates/carapace-vault/src/merge.rs b/crates/carapace-vault/src/merge.rs index 0666d7e..454d676 100644 --- a/crates/carapace-vault/src/merge.rs +++ b/crates/carapace-vault/src/merge.rs @@ -1,26 +1,15 @@ //! §11 live-sync conflict resolution: version-vector algebra and per-file / -//! per-manifest merge. Pure logic, network-independent. +//! per-manifest merge. Pure logic, network-independent. Per-file rules: //! -//! A version vector ([`Vv`]) maps a device pubkey to a per-file change counter. -//! Each local edit bumps that device's component ([`bump`]); comparing two -//! vectors classifies the relationship as one dominating the other (a -//! fast-forward) or the two being *concurrent* (a genuine conflict). §11: +//! - Dominance -> take the dominant entry (live or tombstone). +//! - Concurrent (or equal-VV) with distinct content -> keep BOTH: winner by +//! `(mtime, content-hash)` keeps the path, loser renamed +//! `path.sync-conflict--.`. Both derive from content-intrinsic data, +//! never the joined VV, so 3+ devices converge regardless of fold order. +//! Identical content collapses to one survivor. +//! - Concurrent delete-vs-edit -> the edit survives; delete-vs-delete -> deleted. //! -//! - **Dominance** -> take the dominant entry (live or tombstone). -//! - **Concurrent (or equal-VV) with DISTINCT content** -> BOTH kept: the winner -//! by `(mtime, content-hash)` keeps the path, the loser is renamed -//! `path.sync-conflict--.`. Both the winner tie-break and the loser -//! filename derive from order-independent, content-intrinsic data (mtime + -//! `file_hash`), never the post-merge joined VV, so 3+ devices converge on an -//! identical file set regardless of the order they fold manifests in. Identical -//! content collapses to one survivor (no pointless duplicate). -//! - **Concurrent, delete-vs-edit** -> the edit survives at the path (a -//! concurrent delete does not resurrect-block a live edit). -//! - **Concurrent, delete-vs-delete** -> the file stays deleted. -//! -//! [`merge_manifests`] applies the per-file rule across the union of paths and -//! is deterministic, commutative (equal resulting file set for `merge(a,b)` and -//! `merge(b,a)`), and idempotent (`merge(a,a) == a`). +//! [`merge_manifests`] is deterministic, commutative in the file set, and idempotent. use carapace_wire::{FileEntry, Manifest, Vv}; use std::cmp::Ordering; @@ -37,10 +26,9 @@ fn vv_get(vv: &Vv, dev: &[u8; 32]) -> u64 { .unwrap_or(0) } -/// Canonical form: entries sorted bytewise by device key, zero-valued -/// components dropped, duplicate keys collapsed to their max. Two vectors that -/// are equal as maps have identical canonical forms, which makes [`FileEntry`] -/// equality (a `Vec` compare) order-insensitive and merge output deterministic. +/// Canonical form: sorted bytewise by device key, zero components dropped, +/// duplicate keys collapsed to max. Gives map-equal vectors identical forms, so +/// [`FileEntry`] equality (a `Vec` compare) is order-insensitive. pub fn canon_vv(vv: &Vv) -> Vv { let mut m: BTreeMap<[u8; 32], u64> = BTreeMap::new(); for (d, c) in vv { @@ -142,26 +130,21 @@ pub fn bump(vv: &Vv, dev: &[u8; 32]) -> Vv { // ---------------- per-file merge (§11) ---------------------------------- -/// A deterministic, argument-order-independent total key over a file's -/// content-intrinsic identity: `(mtime, file_hash, size)`. Used both to pick the -/// `(mtime, deviceID)`-style path winner on a genuine conflict and to pick a -/// single survivor for otherwise-identical entries. It is intrinsic to the entry -/// (never the post-merge joined VV), so it is IDENTICAL regardless of pairwise -/// fold order across any number of devices (§11, MAJOR 3): `mtime` is primary -/// (the spec's winner rule) and a tie falls to the content hash rather than an -/// order-dependent device attribution. +/// Order-independent total key over a file's content-intrinsic identity: +/// `(mtime, file_hash, size)`. Picks the conflict path winner and the single +/// survivor for identical entries. Intrinsic to the entry (never the joined VV), +/// so it is identical regardless of pairwise fold order (§11, MAJOR 3): mtime is +/// primary (the spec winner rule), ties fall to content hash. /// -/// ponytail: a `mode`-only difference between two byte-identical, same-mtime -/// entries is not disambiguated here; that corner only picks between two -/// content-identical survivors, so it is cosmetic. Add `mode` to the key if -/// mode-exact convergence is ever required. +/// ponytail: a mode-only difference between byte-identical same-mtime entries is +/// not disambiguated (cosmetic); add `mode` to the key if mode-exact convergence +/// is ever required. fn entry_key(e: &FileEntry) -> (u64, [u8; 32], u64) { (e.mtime, e.file_hash, e.size) } fn hex_short(bytes: &[u8; 32]) -> String { - // First 4 bytes -> 8 lowercase hex chars, enough to disambiguate the loser's - // content in a conflict filename while staying short. + // First 4 bytes -> 8 lowercase hex chars. let mut s = String::with_capacity(8); for b in &bytes[..4] { s.push(char::from_digit((b >> 4) as u32, 16).expect("nibble")); @@ -170,18 +153,12 @@ fn hex_short(bytes: &[u8; 32]) -> String { s } -/// Build the loser's conflict path: `/.sync-conflict--.`, -/// preserving the last extension (`report.txt` -> `report.sync-conflict-….txt`; -/// `archive.tar.gz` -> `archive.tar.sync-conflict-….gz`; `README` and -/// `.gitignore` keep no extension). `ts` is the loser's mtime in unix seconds and -/// `h` is the short (first-4-byte) hex of the loser's `file_hash`. -/// -/// Both inputs are content-intrinsic to the losing entry (§11, MAJOR 3): they do -/// not depend on the post-merge joined version vector, so every device names the -/// same losing content the same way regardless of the order it folded manifests -/// in. Termination of the manifest fold still holds because a rename strictly -/// lengthens the stem (a re-collision nests a second `.sync-conflict-` segment -/// rather than reproducing the same path). +/// Build the loser's conflict path `/.sync-conflict--.`, +/// preserving the last extension (`archive.tar.gz` -> `archive.tar.sync-conflict-….gz`; +/// `.gitignore` keeps none). `ts` is the loser's mtime, `h` the short hex of its +/// `file_hash` - both content-intrinsic, so every device names the loser identically +/// (§11, MAJOR 3). The fold terminates: a rename strictly lengthens the stem, so a +/// re-collision nests a second segment rather than reproducing the path. fn conflict_path(path: &str, ts: u64, file_hash: &[u8; 32]) -> String { let h = hex_short(file_hash); let (dir, base) = match path.rfind('/') { @@ -198,17 +175,13 @@ fn conflict_path(path: &str, ts: u64, file_hash: &[u8; 32]) -> String { } } -/// Merge two entries for the *same path* from two devices per §11. Returns one -/// entry (dominance, identical content, delete-vs-edit resolved to the edit, or -/// both deleted) or two (a concurrent edit-vs-edit conflict over DISTINCT -/// content: winner at the path, loser renamed). Every surviving entry carries the -/// merged version vector. -/// -/// Conflict identity (winner + loser filename) is derived from order-independent, -/// content-intrinsic data ([`entry_key`], `file_hash`), never the mutated joined -/// VV, so 3+ devices converge on an identical file set no matter what pairwise -/// order they fold in (MAJOR 3). Equal-VV-but-distinct-content is treated as a -/// conflict, never a silent drop (MAJOR 2). +/// Merge two entries for the *same path* per §11. Returns one entry (dominance, +/// identical content, delete-vs-edit resolved to the edit, or both deleted) or two +/// (concurrent edit over distinct content: winner at the path, loser renamed). +/// Every survivor carries the merged VV. Conflict identity derives from +/// content-intrinsic data ([`entry_key`], `file_hash`), never the joined VV, so +/// 3+ devices converge (MAJOR 3); equal-VV-distinct-content is a conflict, not a +/// silent drop (MAJOR 2). pub fn merge_entries(a: &FileEntry, b: &FileEntry) -> Vec { debug_assert_eq!(a.path, b.path, "merge_entries requires equal paths"); let mvv = merge_vv(&a.version, &b.version); @@ -225,8 +198,8 @@ pub fn merge_entries(a: &FileEntry, b: &FileEntry) -> Vec { e.version = mvv; return vec![e]; } - // Equal or Concurrent: resolve by delete-state and content below. Equal is - // NOT assumed to mean identical content (MAJOR 2) - the hashes are compared. + // Equal or Concurrent: resolve below. Equal-VV is NOT assumed identical + // content (MAJOR 2) - hashes are compared. Rel::Equal | Rel::Concurrent => {} } @@ -244,8 +217,7 @@ pub fn merge_entries(a: &FileEntry, b: &FileEntry) -> Vec { } (false, false) => { if a.file_hash == b.file_hash { - // Identical content: one survivor, merged VV. No point keeping two - // byte-identical copies, and this keeps the fold terminating. + // Identical content: one survivor, merged VV. let mut e = if entry_key(a) >= entry_key(b) { a.clone() } else { @@ -254,9 +226,8 @@ pub fn merge_entries(a: &FileEntry, b: &FileEntry) -> Vec { e.version = mvv; vec![e] } else { - // Distinct concurrent (or equal-VV) content: keep BOTH. Winner by - // (mtime, content) keeps the path; loser renamed by ITS OWN intrinsic - // (mtime, file_hash) so every device agrees on the name (MAJOR 2/3). + // Distinct content: keep BOTH. Winner by (mtime, content) keeps the + // path; loser renamed by its own intrinsic key (MAJOR 2/3). let (win, lose) = if entry_key(a) >= entry_key(b) { (a, b) } else { @@ -270,8 +241,7 @@ pub fn merge_entries(a: &FileEntry, b: &FileEntry) -> Vec { vec![w, l] } } - // delete-vs-edit: the edit survives at the path; the delete is discarded - // (it does not resurrect-block the live edit). + // delete-vs-edit: the edit survives, the delete is discarded. _ => { let live = if a.deleted { b } else { a }; let mut e = live.clone(); @@ -298,13 +268,10 @@ pub struct MergedManifest { /// across the union of paths (a path present on only one side passes through). /// Deterministic, commutative in the resulting file set, and idempotent. pub fn merge_manifests(local: &Manifest, incoming: &Manifest) -> MergedManifest { - // Fold every entry into a path-keyed map, merging on collision. A - // concurrent edit-vs-edit conflict re-queues its two outputs (winner at the - // original path, loser at a renamed path). - // - // ponytail: worst-case O(n) re-queues; terminates because a conflict rename - // strictly lengthens the path stem, so a renamed loser cannot re-collide - // with the finite set of original paths. + // Fold entries into a path-keyed map, merging on collision; a conflict + // re-queues its two outputs (winner at the path, loser renamed). + // ponytail: worst-case O(n) re-queues; terminates because a rename strictly + // lengthens the stem, so a loser cannot re-collide with the original paths. let mut out: HashMap = HashMap::new(); let mut work: Vec = Vec::with_capacity(local.files.len() + incoming.files.len()); work.extend(local.files.iter().cloned()); @@ -346,10 +313,8 @@ mod tests { items.iter().map(|(d, c)| (dev(*d), *c)).collect() } - /// A file entry whose content identity (`file_hash`) is tagged by `content`, - /// so two "different edits" of the same path get DIFFERENT hashes (as real - /// distinct bytes would) and the same edit gets the same hash. A tombstone has - /// the all-zero hash. + /// A file entry whose `file_hash` is tagged by `content` (distinct edits -> + /// distinct hashes). A tombstone has the all-zero hash. fn entry_h(path: &str, mtime: u64, version: Vv, deleted: bool, content: u64) -> FileEntry { let file_hash = if deleted { [0; 32] @@ -370,8 +335,7 @@ mod tests { } } - /// Default content: distinct per `mtime`, matching the common test pattern of - /// using a bumped mtime to stand in for a distinct edit. + /// Default content: distinct per `mtime` (a bumped mtime stands in for an edit). fn entry(path: &str, mtime: u64, version: Vv, deleted: bool) -> FileEntry { entry_h(path, mtime, version, deleted, mtime) } From 122a5b24b746b16ca80fa9e0233f7743c8016c7e Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:08:52 -0400 Subject: [PATCH 03/28] Trim comment cruft in carapaced (comment-only) Compress multi-paragraph module docs and multi-line spec-clause/audit-narration comment blocks to one-line WHY where non-obvious, delete WHAT-narration; two misattached doc comments split back to their items. lib.rs 8579->7765, persist.rs 1808->1722, state.rs 266->242, tests ~-280. Verified comment-only: stripping comments+blanks leaves byte-identical code. Fixed a doc-lazy-continuation clippy error a compressed doc introduced (por_reboot_replay). Workspace clippy (-D warnings) and fmt clean. --- crates/carapaced/src/lib.rs | 2552 ++++++----------- crates/carapaced/src/persist.rs | 224 +- crates/carapaced/src/state.rs | 72 +- crates/carapaced/tests/attestation_drift.rs | 14 +- crates/carapaced/tests/ceremony.rs | 29 +- .../tests/default_deny_after_reboot.rs | 43 +- crates/carapaced/tests/friend_replica.rs | 52 +- crates/carapaced/tests/kill_durability.rs | 36 +- crates/carapaced/tests/maintenance.rs | 20 +- crates/carapaced/tests/por_reboot_replay.rs | 29 +- crates/carapaced/tests/por_unreachable.rs | 8 +- crates/carapaced/tests/reboot_survival.rs | 95 +- .../carapaced/tests/recovery_reconstruct.rs | 45 +- crates/carapaced/tests/relay_friendship.rs | 26 +- crates/carapaced/tests/replica_hardening.rs | 22 +- .../carapaced/tests/selective_disclosure.rs | 46 +- crates/carapaced/tests/share_grant.rs | 21 +- crates/carapaced/tests/sync_conflict.rs | 49 +- .../tests/three_device_convergence.rs | 21 +- crates/carapaced/tests/two_device_sync.rs | 16 +- crates/carapaced/tests/unfriend.rs | 60 +- crates/carapaced/tests/watch_reingest.rs | 21 +- .../carapaced/tests/watch_sync_interaction.rs | 35 +- 23 files changed, 1187 insertions(+), 2349 deletions(-) diff --git a/crates/carapaced/src/lib.rs b/crates/carapaced/src/lib.rs index 4c871fa..eff87cc 100644 --- a/crates/carapaced/src/lib.rs +++ b/crates/carapaced/src/lib.rs @@ -308,85 +308,47 @@ struct Shared { /// Each friend's newest verified `ContactCard`, keyed by user pubkey. This is /// the address book the control-stream gate consults (W5). friends: HashMap<[u8; 32], ContactCard>, - /// Per-friend storage grant in bytes: how much replica storage THIS node - /// grants THAT friend, keyed by the friend's user pubkey. Agreed at - /// add-friend time (both the initiating `befriend` path and the accepting - /// `serve_friend_accept` path) and enforced by `serve_replica_store` when the - /// friend places a replica on us; defaults to `DEFAULT_QUOTA_BYTES` (1 GiB) - /// when unspecified. - /// - /// This is LOCAL policy and is independent of what the friend advertises to - /// us in their `ContactCard.offers.storage_bytes` (§9.1): the grant is what I - /// enforce, the offer is what they claim to hold for me. A formal bilateral - /// over-the-wire storage-agreement message is a possible future spec addition; - /// the card-offer + local-grant model satisfies it for now (see spec-errata). - /// - /// ponytail: parallel map keyed like `friends`; there is no daemon unfriend - /// path removing entries from `s.friends` yet, so the two cannot drift. Fold - /// into a `FriendRecord { card, grant }` if `friends` ever gains a removal path. + /// Per-friend replica-storage grant in bytes (local policy this node enforces via + /// `serve_replica_store`), keyed by friend user pubkey. Defaults to `DEFAULT_QUOTA_BYTES`. friend_grants: HashMap<[u8; 32], u64>, /// Tickets this daemon has issued and will honor exactly once (§6). tickets: TicketBook, /// Per-owned-vault blob source (digest + ChunkIDs) for replica placement. Holds /// only the CURRENT epoch's source (overwritten on republish). vault_blobs: HashMap<[u8; 32], VaultBlobs>, - /// §3.5 reconciliation: blob sources `{vid -> (digest, chunk_ids)}` of OWNED - /// vaults whose manifest could NOT be re-derived at startup (envelope absent or - /// unopenable in FsStore) — the durable needs-refetch set. Persisted in the same - /// VAULT_BLOBS row as `vault_blobs`, so a failed re-derive NEVER erases the - /// vault's blob-source record: without this, the first post-boot persist rewrote - /// the row from the (empty) re-derived map and the vault silently vanished from - /// every later boot with no warning left to fire. Cleared per-vid by a successful - /// (re)publish or adopted sync baseline; re-populated fresh each boot from the - /// sources whose re-derive fails. + /// Blob sources `{vid -> (digest, chunk_ids)}` of OWNED vaults whose manifest could + /// not be re-derived at startup. Persisted in the same VAULT_BLOBS row as `vault_blobs` + /// so a failed re-derive never erases the vault's blob-source record. Cleared per-vid by + /// a successful (re)publish or adopted sync baseline; repopulated fresh each boot. needs_refetch: HashMap<[u8; 32], persist::BlobSource>, - /// The single authoritative working directory per vault (§11): the SAME tree is - /// the published source, the watched tree, AND the sync/reconstruct target. Set - /// at `publish_vault` time (to the caller's source) and on a first sync (to - /// `out_root/`), so a later sync reconstructs the merged result back into - /// the tree the watcher observes - which makes "absent from disk => tombstone" - /// sound (the working tree is always the full merged set, incl. conflict copies). + /// The single authoritative working directory per vault: the same tree is published + /// source, watched tree, and sync/reconstruct target. Set at `publish_vault` (caller's + /// source) and on first sync (`out_root/`), which makes "absent from disk => + /// tombstone" sound since the working tree is always the full merged set. working_dirs: HashMap<[u8; 32], PathBuf>, - /// Every ChunkID ever published for a vault this daemon OWNS, mapped to that - /// vault's vid and RETAINED across epoch bumps (unlike `vault_blobs`). The - /// blob-read gate ([`authorize_fetch`]) consults this so a superseded-epoch - /// chunk stays in the owner-gated set: a still-disclosed old chunk is served - /// only to its audience, an undisclosed old chunk to no non-device. Without it, - /// a chunk dropped from the current `vault_blobs` on republish would fall out of - /// the owned set and be served to any dialer (W2). ponytail: grows with the - /// distinct owned chunks over the daemon's life; bound it together with - /// old-epoch blob eviction from the store (GC), tracked as a separate resource - /// concern (spec-errata W2-gc). + /// Every ChunkID ever published for a vault this daemon OWNS, mapped to its vid and + /// RETAINED across epoch bumps (unlike `vault_blobs`). [`authorize_fetch`] consults this + /// so a superseded-epoch chunk stays owner-gated instead of being served to any dialer (W2). owned_chunks: HashMap<[u8; 32], [u8; 32]>, /// Owner-side replica membership: vid -> accepted replica node ids. members: HashMap<[u8; 32], Vec<[u8; 32]>>, /// Owner-side replica invariant `r`, per vault. replica_target: HashMap<[u8; 32], usize>, - /// Vids this daemon stores *as a replica* for some owner (blobs live in the - /// iroh store; this records the relationship for read-serving/accounting). + /// Vids this daemon stores *as a replica* for some owner. held: HashSet<[u8; 32]>, - /// Every blob (manifest envelope + ciphertext chunk) this daemon holds *as a - /// replica* for another owner, mapped to the vid it belongs to. The blob-read - /// gate ([`authorize_fetch`]) consults this so a replica-held chunk is served - /// only to that vault owner's delegated devices or a current replica-set member - /// (§7.4 a/b), never to an arbitrary dialer (W8). Populated in - /// [`ControlHandler::serve_replica_store`] from the pushed blob hashes. + /// Every blob this daemon holds *as a replica*, mapped to its vid. [`authorize_fetch`] + /// consults this so a replica-held chunk is served only to the owner's delegated devices + /// or a current replica-set member, never an arbitrary dialer (W8). replica_chunks: HashMap<[u8; 32], [u8; 32]>, - /// For each vid held as a replica, the vault owner's *user* pubkey (derived from - /// the inviting owner node's friend card). The gate uses it to admit that owner's - /// delegated devices (§7.4 a). + /// For each replica-held vid, the vault owner's *user* pubkey; the gate admits that + /// owner's delegated devices. replica_owner: HashMap<[u8; 32], [u8; 32]>, - /// For each vid held as a replica, the current replica-set node ids from the - /// owner-signed `VaultAnnounce` received at placement. The gate admits a member - /// of this set so a co-replica can fetch for repair (§7.4 b). + /// For each replica-held vid, the current replica-set node ids from the owner-signed + /// announce; the gate admits a member so a co-replica can fetch for repair. replica_members: HashMap<[u8; 32], Vec<[u8; 32]>>, - /// For each vid held as a replica, the full owner-signed `VaultAnnounce` received - /// at placement/epoch-push (§8.4). Kept - not just its `replicas` list - so this - /// replica can serve it back to a recovering owner-device that lost every original - /// device: the announce drives that device's `select_targets`, and its old-node - /// signer satisfies the C1 delegated-signer check. With Option B (§4) the recovering - /// device re-derives per-chunk keys from the manifest's `pt_hash`, so no `FileGrant` - /// is retained or served - only this announce + the owner card. + /// For each replica-held vid, the full owner-signed `VaultAnnounce`. Kept so this replica + /// can serve it back to a recovering owner-device that lost every original device (drives + /// its `select_targets`, and its old-node signer satisfies the C1 delegated-signer check). replica_announce: HashMap<[u8; 32], VaultAnnounce>, /// Owner-side deny-list of peer node ids this daemon refuses to place on (S4). replica_deny: HashSet<[u8; 32]>, @@ -410,162 +372,102 @@ struct Shared { /// Per-peer token buckets limiting how much a friend can push into our replica /// store per unit time (W1). Configured from [`ReplicaLimits`] at start. rate: RateLimiter, - /// Embedded-relay reachability lifecycle state (§6/W6). Tracks the URL our own - /// card currently advertises (`None` when the relay is down/withdrawn or we run - /// none), the local URL peer-dialback matches, and the last dialback time. - /// Driven by the maintenance loop's liveness probe; the own card's `relay_url` - /// is kept in lockstep with `advertised_url` (each change bumps the card - /// version, the monotonic rollback counter). + /// Embedded-relay reachability lifecycle state (§6/W6). The own card's `relay_url` is + /// kept in lockstep with `advertised_url`; each change bumps the card version. relay_health: RelayHealth, - /// Owner-side PoR bookkeeping (§10.1): per-`(replica, vid)` audit schedule, - /// round counter, and consecutive-failure streak against an injected clock. - /// Single-writer per vault: only the vault owner's `por_audit_round` mutates it. + /// Owner-side PoR bookkeeping: per-`(replica, vid)` audit schedule, round counter, and + /// consecutive-failure streak. Single-writer per vault (only `por_audit_round` mutates). por: AuditTracker, - /// Owner-side share-health trackers (§10.2), keyed by recovery-set id. Each - /// gates the daily attestation cadence and folds verified attestations into an - /// attested-live count under a freshness window. + /// Owner-side share-health trackers, keyed by recovery-set id: gate the attestation + /// cadence and count attested-live shares under a freshness window. share_sets: HashMap, - /// Trustee-side stored shares this daemon holds for other owners, keyed by - /// recovery-set id, each with its continuous local CRC self-validation monitor - /// (§10.2). Answers `ShareAttestChallenge`s from the owning friend. + /// Trustee-side shares this daemon holds for other owners, keyed by recovery-set id, + /// each with its CRC self-validation monitor. Answers `ShareAttestChallenge`s. held_shares: HashMap, - /// The subject (secret owner) each held share belongs to, keyed by recovery-set id. - /// Populated in [`ControlHandler::serve_grant`] alongside `held_shares`. The - /// `ShareDestroy` handler (§9.3 step 3c) consults it to bind an inbound destroy's - /// `rsid` to its claimed `subject`: without this an authorized-but-wrong owner could - /// name another owner's recovery-set id (which `held_shares` keys blindly) and drop a - /// share it never owned. Kept in lockstep with `held_shares` (serve_grant inserts, - /// `drop_held_share_of` + the destroy handler remove). + /// The subject each held share belongs to, keyed by recovery-set id. The `ShareDestroy` + /// handler binds an inbound destroy's `rsid` to its claimed `subject` through this, so an + /// authorized-but-wrong owner cannot name another owner's rsid and drop a share it never + /// owned. Kept in lockstep with `held_shares`. held_share_subjects: HashMap, - /// Per-owned-vault chunk secrets (key/nonce per ChunkID), retained from ingest - /// so the owner can later disclose a *subset* of files (§7.4) without - /// re-ingesting. Owner-only, in-memory, and zeroized on drop; no weaker than - /// already holding `k_root` (from which every content key derives) in memory. + /// Per-owned-vault chunk secrets (key/nonce per ChunkID), retained from ingest so the + /// owner can disclose a subset of files without re-ingesting. Zeroized on drop; no weaker + /// than already holding `k_root` in memory. vault_keys: HashMap<[u8; 32], ChunkKeys>, - /// Owner-side selective-disclosure table (§7.4 / D3): ChunkID -> audience users - /// authorized to fetch it, recorded from every issued `FileGrant`. The - /// blob-read gate ([`authorize_fetch`]) consults it so a granted chunk is - /// served only to an authenticated member of that grant's audience. + /// Owner-side selective-disclosure table: ChunkID -> audience users, recorded from every + /// issued `FileGrant`. [`authorize_fetch`] serves a granted chunk only to its audience. disclosure: DisclosureTable, - /// Nodes this daemon has authenticated on its `carapace/1` control stream (via - /// NodeID + card delegation, W5), classified as our own device or a specific - /// friend. The blob-read gate keys on this so that a raw `iroh-blobs` dialer is - /// served owned-vault chunks only after it proved, on the authenticated control - /// stream, who it is — closing the §7.4/D3 gap for owner-served granted content. + /// Nodes authenticated on the `carapace/1` control stream (NodeID + card delegation), + /// classified as own device or friend. The blob-read gate keys on this so a raw + /// `iroh-blobs` dialer gets owned-vault chunks only after proving who it is (§7.4/D3). blob_auth: HashMap<[u8; 32], BlobAuth>, - /// Owner-side recovery split-states (§8), keyed by recovery-set id. Holds the - /// open Chela split polynomial (a secret, kept in memory beside `k_root`) so - /// `recovery_extend` can issue further shares on the same polynomial without - /// re-splitting. ponytail: in-memory, daemon-lifetime like the rest of daemon - /// state; persist a sealed split-state blob if extend must survive a restart. + /// Owner-side recovery split-states, keyed by recovery-set id. Holds the open Chela split + /// polynomial (a secret, kept beside `k_root`) so `recovery_extend` issues further shares + /// on the same polynomial without re-splitting. split_states: HashMap, - /// Recovery ceremonies this device tracks AS A TRUSTEE (§8.5), keyed by ceremony - /// id: the primitive state machine plus this trustee's own approval flag and - /// takeover flag. The API drives approve/abort against these; the delay-gated - /// share release reads `state.can_release` here. + /// Recovery ceremonies this device tracks AS A TRUSTEE, keyed by ceremony id: the state + /// machine plus this trustee's approval/takeover flags. The delay-gated release reads + /// `state.can_release` here. ceremonies: HashMap<[u8; 16], TrackedCeremony>, - /// Surfaced ceremony alarms (§8.5 step 2), keyed by ceremony id: every inbound - /// `RecoveryOpen` this device saw, whether or not it is a trustee of the subject, - /// so the status API can raise "recovery of your account started - is this you?" - /// even on the subject's own devices and friends (who hold no grant to track a - /// full ceremony). The anti-silent-takeover signal. + /// Surfaced ceremony alarms, keyed by ceremony id: every inbound `RecoveryOpen` this + /// device saw, so the status API can raise "recovery of your account started" even on the + /// subject's own devices/friends. The anti-silent-takeover signal. ceremony_alarms: HashMap<[u8; 16], AlarmRecord>, - /// Signature-valid subject aborts seen for a ceremony id, retained even when no - /// ceremony/alarm is tracked yet (§8.5 step 3). Best-effort per-peer fan-out has no - /// ordering, so a subject-signed `CeremonyAbort` can reach a trustee BEFORE that - /// trustee's `RecoveryOpen` (and the same open is re-sent later as the claimant's - /// share request). Without a durable record the early abort would be dropped and the - /// later open would re-track a fresh, non-aborted ceremony that releases at - /// delay-expiry - the exact silent takeover step 3 exists to stop. Populated - /// UNCONDITIONALLY by `serve_ceremony_abort` and consulted by `serve_recovery_open` - /// before any release. Authoritative only when a stored abort's `by` equals the - /// open's subject (a stranger cannot abort someone else's recovery). Kept as a - /// per-signer-deduped list, NOT a single slot: a stranger's inert abort must not be - /// able to crowd out the authoritative subject abort that arrives before the open. - /// ponytail: tiny metadata (16-byte id + a signed abort per distinct signer), - /// retained for the daemon's lifetime; no persistence (all daemon state is in-memory - /// per the documented deferral). + /// Signature-valid subject aborts per ceremony id, retained even when no ceremony is + /// tracked yet. Fan-out is unordered, so a subject-signed abort can arrive before the + /// trustee's `RecoveryOpen`; without a durable record the later open would re-track a + /// fresh non-aborted ceremony and release at delay-expiry (the silent takeover step 3 + /// exists to stop). Authoritative only when a stored abort's `by` equals the open's + /// subject. A per-signer-deduped list, not a single slot, so a stranger's inert abort + /// cannot crowd out the authoritative subject abort. aborted_ceremonies: HashMap<[u8; 16], Vec>, - /// Injected wall clock for the ceremony delay gate (0 = real time). Test-only knob - /// (`set_test_clock`) so the 72 h abort delay is exercised without ever sleeping: - /// only the network-triggered ceremony paths (track `first_seen`, release gate) - /// read it; every other clock stays real. + /// Injected wall clock for the ceremony delay gate (0 = real time). Test-only knob so the + /// 72 h abort delay is exercised without sleeping; only the ceremony paths read it. test_now: u64, - /// Trustee-side: the full verified `ShareGrant`s this daemon holds for other - /// owners (W3, §8), keyed by the subject user pubkey whose secret was split. Held - /// verbatim (roster + recovery_delay + announce refs), so at ceremony time the - /// quorum has the co-trustee set to reach and the latest manifest pointers to - /// fetch - unlike a bare `Share`, which locates nothing without a live owner. The - /// embedded share is ALSO stored in `held_shares` for the attestation cadence. + /// Trustee-side: the full verified `ShareGrant`s this daemon holds for other owners, + /// keyed by subject user pubkey. Held verbatim (roster + recovery_delay + announce refs) + /// so at ceremony time the quorum has the co-trustee set and latest manifest pointers. + /// The embedded share is also stored in `held_shares` for the attestation cadence. held_grants: HashMap<[u8; 32], ShareGrant>, - /// Owner-side: the grants this daemon minted per recovery set (W3, §8), keyed by - /// recovery-set id. Retains each trustee's share + hints and the last-delivered - /// announce refs so the maintenance loop can re-issue refreshed grants pointing at - /// the latest manifest as new vault epochs publish (§10.2, §7.3). + /// Owner-side: grants this daemon minted per recovery set, keyed by recovery-set id. + /// Retains each trustee's share + hints and the last-delivered announce refs so the + /// maintenance loop can re-issue refreshed grants as new vault epochs publish. granted: HashMap, - /// §9.3 W5: in-flight re-splits this owner is driving to completion, keyed by the - /// OLD (ex-friend's) recovery-set id. Each wraps the guarded [`Resplit`] state - /// machine plus the dial hints for delivering the new set's grants, challenging it - /// live, and destroying the old shares. The maintenance loop advances them; the old - /// shares are NEVER destroyed until [`Resplit`] proves the new set live (`>= M + - /// slack`), and the daemon routes destruction only through `Resplit::share_destroy`. + /// In-flight re-splits this owner is driving, keyed by the OLD recovery-set id. Old shares + /// are NEVER destroyed until [`Resplit`] proves the new set live (`>= M + slack`), and + /// destruction routes only through `Resplit::share_destroy`. resplits: HashMap, - /// §9.3.4 W5: re-splits an unfriend DETECTED (the ex-friend was a trustee of this - /// old recovery set) but that the user has NOT yet chosen to start, `old_rsid -> - /// [`PendingResplit`]`. §9.3.4 requires the client to PROMPT the user before - /// re-splitting a trustee out, so the unfriend teardown only records the pending - /// prompt here (with the suggested new trustee set = old honest set); nothing stands - /// up until the user hits `POST /api/recovery/{rsid}/resplit-start` - /// ([`Daemon::start_pending_resplit`]), which holds `k_root`. Durable across - /// maintenance ticks so the prompt persists until the user acts. + /// Re-splits an unfriend DETECTED but the user has NOT started, `old_rsid -> + /// PendingResplit`. §9.3.4 requires prompting the user first, so teardown only records the + /// pending prompt; nothing stands up until `POST /api/recovery/{rsid}/resplit-start`. pending_resplits: HashMap, - /// §9.3.1 W5: outbound `DeleteRequest` batches queued by the RECEIVE side of an - /// unfriend (an inbound `FriendshipEnd`, handled in the control handler, has no - /// endpoint to dial out). Each side MUST send DeleteRequests for everything IT placed - /// on the other; the initiator sends inline in [`Daemon::unfriend`], but the receiver - /// defers to the maintenance loop ([`Daemon::drive_pending_delete_sends`]), which - /// drains this. Sending a DeleteRequest never triggers a FriendshipEnd, so there is - /// no unfriend loop. + /// Outbound `DeleteRequest` batches queued by the RECEIVE side of an unfriend (an inbound + /// `FriendshipEnd` has no endpoint to dial out). Drained by the maintenance loop + /// ([`Daemon::drive_pending_delete_sends`]); sending one never triggers a FriendshipEnd. pending_delete_sends: Vec<(Vec, Placement)>, - /// §9.3 W5: node ids of unfriended peers whose replicas of OUR vaults must be - /// re-placed immediately - treated as confirmed lost NOW (no 24 h grace), via a - /// `Health::Unfriended` repair. Drained by `replace_unfriended_replicas`. + /// Node ids of unfriended peers whose replicas of OUR vaults must be re-placed + /// immediately - confirmed lost NOW (no 24 h grace). Drained by `replace_unfriended_replicas`. unfriended_nodes: HashSet<[u8; 32]>, } -/// Consecutive failed liveness probes required before withdrawing an advertised -/// relay (W6 hysteresis). -/// -/// The probe is a 2 s loopback TCP connect; under load a single connect can time -/// out on a perfectly healthy relay, and each spurious withdraw costs two card -/// re-issues (withdraw plus re-advertise). Requiring three consecutive failures -/// rides out a transient stall while still catching a genuinely dead listener -/// within a few maintenance rounds. +/// Consecutive failed liveness probes before withdrawing an advertised relay (W6 +/// hysteresis). The probe is a 2 s loopback TCP connect that can spuriously time out under +/// load; three failures ride out a transient stall while still catching a dead listener. const RELAY_PROBE_FAILURE_THRESHOLD: u32 = 3; -/// Embedded-relay reachability lifecycle state (§6/W6). -/// -/// §6 requires a node's advertised relay to be dialback-verified and to be -/// *advertised on success, withdrawn on loss* - never advertised unconditionally -/// at startup. This tracks: -/// - `advertised_url`: the relay URL currently folded into this node's own card -/// (`None` when the relay is down/withdrawn, or when it runs no relay). The own -/// card's `NodeEntry.relay_url` is kept exactly equal to this; every change is a -/// card re-issue with a bumped (monotonic) version. -/// - `local_url`: the URL our own endpoint registers on and reaches the relay at -/// (loopback-substituted), which is also the URL an inbound relayed QUIC path -/// carries - so peer-dialback matches an inbound relay path against it. -/// - `verified_at`: the last time a friend was observed reaching us *through* our -/// relay (peer-dialback, §6). Surfaced for the operator; see the module note on -/// why it confirms rather than gates advertising. +/// Embedded-relay reachability lifecycle state (§6/W6): advertise on dialback success, +/// withdraw on loss, never advertise unconditionally at startup. #[derive(Default)] struct RelayHealth { + /// Relay URL folded into this node's own card (`None` when down/withdrawn or no relay). + /// The card's `NodeEntry.relay_url` is kept exactly equal to this. advertised_url: Option, + /// URL our endpoint registers on and reaches the relay at (loopback-substituted); also + /// what an inbound relayed QUIC path carries, so peer-dialback matches against it. local_url: Option, + /// Last time a friend was observed reaching us through our relay (confirms, not gates). verified_at: Option, - /// Number of consecutive failed liveness probes since the last success (W6 - /// hysteresis). Reset to 0 on any successful probe; a withdraw fires only - /// when it reaches [`RELAY_PROBE_FAILURE_THRESHOLD`]. + /// Consecutive failed liveness probes since the last success; reset on success, withdraw + /// fires at [`RELAY_PROBE_FAILURE_THRESHOLD`]. consecutive_failures: u32, } @@ -584,18 +486,15 @@ struct OwnerGrants { refs: Vec, } -/// One trustee holding an owner-minted grant: its identity + node hints (for the -/// co-trustee roster and delivery dial) plus its own share, re-signed into a -/// refreshed grant when the refs advance. The share is a secret kept in memory -/// beside `k_root`/`vault_keys`; no weaker than already holding the split source. +/// One trustee holding an owner-minted grant: identity + node hints plus its own share, +/// re-signed into a refreshed grant when the refs advance. #[derive(Clone)] struct GrantedTrustee { user: [u8; 32], node: [u8; 32], relay_url: Option, share: Share, - /// Whether the last delivery to this trustee succeeded (surfaced on the status - /// view so an operator sees which trustees actually hold a current grant). + /// Whether the last delivery to this trustee succeeded (surfaced on the status view). delivered: bool, } @@ -758,24 +657,16 @@ struct RecoverySet { struct TrackedCeremony { /// The delay-anchored ceremony state (approvals, roster, `ceremony_enc`, subject). state: CeremonyState, - /// Whether THIS trustee has approved (its own out-of-band verification, §8.5 step - /// 4). A trustee releases its share only if it approved: a trustee that never - /// verified the claimant must not be dragged into releasing just because `M` - /// others did. `state.can_release` gates on `≥ M` approvals + the delay; this - /// adds the "and I, specifically, approved" requirement. + /// Whether THIS trustee approved. It releases its share only if it did: `can_release` + /// gates on `>= M` approvals + delay, this adds the "and I specifically approved" bit. approved: bool, - /// A valid subject-signed abort flagged this ceremony as an attempted takeover - /// (§8.5 step 3). Once set, this trustee never releases. (The sponsor / claimant / - /// reason for the status surface live in the paired [`AlarmRecord`], always present - /// alongside a tracked ceremony.) + /// A valid subject-signed abort flagged this ceremony as an attempted takeover; once + /// set, this trustee never releases. takeover: bool, } -/// A surfaced ceremony alarm (§8.5 step 2) for the status API: enough to show -/// "recovery of started by " and, on the subject's own device, -/// offer the authoritative abort. Recorded for EVERY inbound `RecoveryOpen`, whether -/// or not this device is a trustee, so the anti-silent-takeover signal reaches the -/// subject's own devices and friends. +/// A surfaced ceremony alarm for the status API, recorded for EVERY inbound `RecoveryOpen` +/// (trustee or not) so the anti-silent-takeover signal reaches the subject's own devices. #[derive(Clone)] struct AlarmRecord { subject: [u8; 32], @@ -843,64 +734,43 @@ enum BlobAuth { OwnDevice, /// A delegated device of the named established friend (the friend branch). Friend([u8; 32]), - /// A delegated device of a vault OWNER we store replicas for, authenticated by a - /// self-consistent card the dialer presented (its user is an owner in - /// `replica_owner`). Grants nothing on our own owned chunks; only unlocks that - /// owner's replica-held chunks (§7.4 a, W8). Used for an owner device this - /// replica does not otherwise know (not enumerated in the stored friend card). + /// A delegated device of a vault OWNER we store replicas for (its user is an owner in + /// `replica_owner`). Unlocks only that owner's replica-held chunks, nothing of ours (W8). ReplicaDevice([u8; 32]), } -/// The `carapace/1` control-stream handler. It authenticates the dialer against -/// the TLS-verified remote node id, then dispatches on the first frame: -/// -/// - `ContactCard` (type 2): a document pull. The dialer presents its card; the -/// handler serves cards/announces/grants **only** if that card is validly -/// self-signed, its user is this daemon's own user or an established friend, -/// and it delegates the connection's authenticated remote node id (W5). Every -/// other dialer gets nothing beyond the `Hello`. -/// - `FriendRequest` (type 3): the acceptor half of the §9.2 handshake, gated by -/// a single-use ticket this daemon issued rather than by friendship. -/// - `ReplicaInvite` (type 10): the storage-peer half of §10.1 placement, gated -/// on the inviting owner being an established friend (or self). +/// The `carapace/1` control-stream handler: authenticates the dialer against the +/// TLS-verified remote node id, then dispatches on the first frame (`ContactCard` doc pull, +/// `FriendRequest` acceptor half, `ReplicaInvite` storage-peer half). #[derive(Clone)] struct ControlHandler { hello: Hello, node_key: SigningKey, user_key: SigningKey, self_user: [u8; 32], - /// The user master key (design §3.2): needed to SEAL secret state rows when a - /// control handler persists after a mutation (e.g. `serve_grant` storing a share). + /// The user master key: needed to SEAL secret state rows when a handler persists. k_root: Zeroizing<[u8; 32]>, - /// The redb source of truth on disk, shared with the owning [`Daemon`]. Control - /// handlers commit the WHOLE state through it BEFORE any externally visible effect - /// (design §3.2.3), e.g. `serve_share_destroy` commits the removal before the ack. + /// The redb source of truth on disk, shared with the owning [`Daemon`]. Handlers commit + /// the whole state through it BEFORE any externally visible effect. db: Arc, blobs: IrohBlobStore, shared: Arc>, - /// Default per-friend storage grant (bytes) recorded when this node ACCEPTS a - /// friend request (`serve_friend_accept`). The per-friend grant is what - /// `serve_replica_store` later enforces as that friend's replica quota (W1); - /// the initiating `befriend` path can agree a different amount explicitly. + /// Default per-friend storage grant recorded when this node ACCEPTS a friend request; + /// `serve_replica_store` later enforces it as that friend's replica quota (W1). default_grant_bytes: u64, - /// Injector for peer addressing hints + relay URLs into the live endpoint, so - /// a friend's card learned on the accept path teaches this node how to dial - /// them back by node id via hole-punch/relay (§6). + /// Injector for peer addressing hints + relay URLs, so a friend's card learned on the + /// accept path teaches this node how to dial them back by node id (§6). hints: PeerHints, - /// Shared with the owning [`Daemon`]: the rollback-guarded store of documents - /// learned from peers (W2). `serve_docs` re-serves the third-party cards + - /// announces here so an owner's `VaultAnnounce` reaches a friend-of-a-friend - /// (anti-entropy store-and-forward, §6), and consults the newest stored self-card - /// so a revoked own device presenting an old self-card is refused (W7). + /// Shared rollback-guarded doc store (W2). `serve_docs` re-serves third-party cards + + /// announces (store-and-forward) and consults the newest self-card so a revoked own + /// device presenting an old self-card is refused (W7). docs: Arc>, } impl ControlHandler { - /// Persist the WHOLE `Shared` + `DocStore` in one txn and commit (design §3.2). The - /// caller holds the `shared` write lock and passes the guard, so RAM and disk mutate - /// in one critical section; the commit happens BEFORE any ack / network effect - /// (§3.2.3). `docs` is locked internally (lock order `shared`->`docs`). Fail-loud on - /// commit failure. + /// Persist the whole `Shared` + `DocStore` in one txn and commit. Caller holds the + /// `shared` write lock; commit happens before any ack / network effect. Lock order + /// `shared`->`docs`. Fail-loud on commit failure. fn persist_locked(&self, s: &Shared) { let docs = self.docs.lock().expect("docs lock"); persist::commit_all(&self.db, s, &docs, &self.k_root); @@ -908,9 +778,8 @@ impl ControlHandler { async fn serve(&self, conn: Connection) -> Result<()> { let remote = *conn.remote_id().as_bytes(); - // W6/§6 peer-dialback: if this friend reached us over our own advertised - // relay, that is external proof the relay is reachable. Recorded here, at - // accept time, before iroh upgrades a relayed path to a direct one. + // W6 peer-dialback: a friend reaching us over our own relay is external proof it + // is reachable. Recorded before iroh upgrades a relayed path to a direct one. self.note_relay_dialback(&conn); let (mut send, mut recv) = conn.accept_bi().await?; @@ -970,17 +839,9 @@ impl ControlHandler { Ok(()) } - /// W6/§6 peer-dialback verification: if this inbound connection has a relay - /// path whose relay is *our* advertised relay, a peer reached us through it, so - /// record the time as external-reachability confirmation. iroh labels an inbound - /// relayed path with the relay URL we received on (a relay in our own set), so a - /// match against `relay_health.local_url` attributes it to our relay - /// specifically. No-op when we run no relay. - /// - /// ponytail (known ceiling): iroh promotes a relayed path to a direct one as - /// soon as hole-punching succeeds, so this catches inbound connections that are - /// still (or only ever) relayed - which is exactly the population for which the - /// relay matters. It is confirmation, not a gate (see `drive_relay_health`). + /// W6 peer-dialback: if this inbound connection has a relay path matching our own + /// advertised relay (`relay_health.local_url`), record the time as external-reachability + /// confirmation. No-op when we run no relay. Confirmation, not a gate. fn note_relay_dialback(&self, conn: &Connection) { let local = { let s = self.shared.read().expect("shared lock"); @@ -1003,14 +864,9 @@ impl ControlHandler { } /// Serve the document set iff the presented card authorizes the connection's - /// authenticated remote node id (W5). Unauthorized dialers get only the Hello. - /// - /// Beyond this node's own cards/announces/grants, an authorized friend also - /// receives the third-party cards + announces this node learned from other - /// friends (anti-entropy store-and-forward, §6/W7): an owner's `VaultAnnounce` - /// reaches a trustee through any mutual friend, and a returning node re-syncs the - /// graph from any one friend. The forwarded set is version/epoch deduped and - /// rollback-guarded per signer by the receiver's own [`DocStore`]. + /// authenticated remote node id (W5); unauthorized dialers get only the Hello. An + /// authorized friend also receives the third-party cards + announces learned from other + /// friends (store-and-forward, §6/W7), deduped and rollback-guarded by the receiver. async fn serve_docs( &self, card: &ContactCard, @@ -1020,8 +876,7 @@ impl ControlHandler { write_msg(send, &self.hello).await?; let now = unix_now(); - // Snapshot the forwardable third-party docs + newest stored self-card under the - // docs lock FIRST, then take the shared lock — never nested, matching + // Snapshot under the docs lock FIRST, then the shared lock - never nested, matching // `sync_impl`'s docs-before-shared order (no lock held across an `.await`). let (fwd_cards, fwd_announces, newest_self) = { let d = self.docs.lock().expect("docs lock"); @@ -1032,16 +887,13 @@ impl ControlHandler { ) }; - // W5: classify the dialer against its authenticated remote node id. On - // success, record the classification so the blob-read gate can bind this - // node's later raw iroh-blobs fetches to a verified identity (§7.4/D3). + // W5: classify the dialer against its authenticated remote node id; the recorded + // classification lets the blob-read gate bind later raw iroh-blobs fetches (§7.4/D3). enum Serve { - // Friend/self (W5): own docs plus the store-and-forward third-party docs. + // Friend/self: own docs plus the store-and-forward third-party docs. Authorized(Vec, Vec, Vec), - // §8.4 recovery: a delegated device of an owner whose vaults we replicate. - // Serve ONLY that owner's card + the announce we retained for its vaults - - // never any other owner's, no grant (Option B: the recovering device - // re-derives keys from the manifest pt_hash), and no store-and-forward dump. + // §8.4 recovery: a delegated device of an owner whose vaults we replicate. Serve + // ONLY that owner's card + retained announce, no grant, no store-and-forward dump. ReplicaOwner(Vec, Vec), No, } @@ -1052,24 +904,17 @@ impl ControlHandler { s.blob_auth.insert(*remote, auth); Serve::Authorized(s.cards.clone(), s.announces.clone(), s.grants.clone()) } - // W8/§7.4 a + §8.4: a dialer we serve no ordinary documents to may be a - // delegated device of an owner whose vault we replicate. Record the - // ReplicaDevice classification so its raw iroh-blobs fetches of that - // owner's replica-held chunks are admitted, AND serve back the owner - // docs we retained at placement so a recovering owner-device that lost - // every original device can: drive `select_targets` (the announce) and - // satisfy the C1 delegated-signer check on that old-node-signed announce - // (the owner card delegating the original signer). Option B (§4): the - // recovering device re-derives per-chunk keys from the manifest's - // `pt_hash`, so no `FileGrant` is served - this is the claimant's ONLY - // authorization + announce path, so it stays. + // W8/§7.4 a + §8.4: a dialer served no ordinary docs may be a delegated device + // of an owner whose vault we replicate. Record ReplicaDevice so its fetches of + // that owner's replica-held chunks are admitted, and serve back the retained + // owner card + announce so a recovering owner-device (that lost every original + // device) can drive `select_targets` and satisfy the C1 delegated-signer check. None => match replica_owner_device(&s, card, remote, now) { Some(owner) => { s.blob_auth.insert(*remote, BlobAuth::ReplicaDevice(owner)); let cards: Vec = s.friends.get(&owner).cloned().into_iter().collect(); - // Only this exact owner's vaults - never leak another owner's - // announce to this device. + // Only this exact owner's vaults - never leak another owner's announce. let mut announces: Vec = Vec::new(); for (vid, ann) in &s.replica_announce { if s.replica_owner.get(vid) == Some(&owner) { @@ -1090,8 +935,8 @@ impl ControlHandler { Serve::Authorized(c, a, g) => (c, true, a, g), Serve::ReplicaOwner(c, a) => (c, false, a, Vec::new()), }; - // Own/owner docs first, then (friend path only) the learned third-party docs we - // re-serve. Overlap is harmless: the receiver dedups/rolls-back per signer. + // Own/owner docs first, then (friend path only) the forwarded third-party docs. + // Overlap is harmless: the receiver dedups/rolls-back per signer. for card in &cards { write_msg(send, card).await?; } @@ -1130,8 +975,7 @@ impl ControlHandler { let requester_user = verify_friend_request(&req, now) .map_err(|e| anyhow::anyhow!("friend request rejected: {e}"))?; - // Acceptor picks `established`; the requester must countersign the same - // core over the wire (never the requester's private key locally). + // Acceptor picks `established`; the requester countersigns the same core over the wire. let established = now; write_u64(send, established).await?; let countersig = read_sig(recv).await?; @@ -1157,21 +1001,16 @@ impl ControlHandler { .map_err(|e| anyhow::anyhow!("friend accept failed: {e}"))?; s.friendships.insert(requester_user, friendship); s.friends.insert(requester_user, req.card.clone()); - // Agree a per-friend replica-storage grant at add-friend time. On the - // accept path we grant this node's configured default; the initiating - // `befriend` path can agree a different amount explicitly. + // Per-friend replica-storage grant: the accept path grants this node's default. s.friend_grants .insert(requester_user, self.default_grant_bytes); - // §3.2.3: commit the friendship (friends/friendships/grant) BEFORE sending - // FriendAccept, so a crash cannot leave the requester believing it befriended - // a node that forgot the friendship. (Tickets are EPH; a lost single-use - // ticket just yields TicketUnknown on retry, §3.3.) + // Commit the friendship BEFORE sending FriendAccept, so a crash cannot leave the + // requester believing it befriended a node that forgot the friendship. self.persist_locked(&s); accept }; - // §6: learn how to reach this new friend by node id (their direct addrs - // and self-hosted relay), so we can dial them back via hole-punch/relay. + // §6: learn how to reach this new friend by node id so we can dial them back. learn_card_hints(&self.hints, &req.card).await; write_msg(send, &accept).await?; @@ -1194,16 +1033,12 @@ impl ControlHandler { inv.verify() .map_err(|e| anyhow::anyhow!("replica invite bad sig: {e}"))?; - // Authorize the inviting owner and rate-limit its push under one write - // lock (all synchronous; the lock is released before any `.await`). The - // invite signer must be an established friend (or our own device) AND the - // connection's authenticated peer, and it must have rate-budget for the - // advertised size (W1: a single friend cannot flood the store). + // Authorize the inviting owner and rate-limit its push under one write lock (released + // before any `.await`). The signer must be an established friend (or own device) AND + // the authenticated peer, with rate-budget for the advertised size (W1: no flooding). let (admitted, owner_user) = { let mut s = self.shared.write().expect("shared lock"); - // The inviting owner must be an established friend (or our own device); - // resolve it to the owner's *user* pubkey so the blob-read gate can later - // admit that owner's delegated devices (§7.4 a, W8). + // Resolve the owner to its *user* pubkey so the gate can later admit its devices. let owner_user = owner_user_of_node(&s, &self.self_user, &inv.by, now); let admitted = owner_user.is_some() && inv.by == *remote @@ -1215,12 +1050,9 @@ impl ControlHandler { return Ok(()); } - // W1 + per-friend grant: the storage quota is the limit THIS node agreed to - // grant the inviting friend at add-friend time (looked up by the friend's - // user pubkey via the node that signed the invite), NOT a global default. A - // placement larger than that friend's grant is declined outright. A node - // with no friend record falls back to DEFAULT_QUOTA_BYTES defensively (S4 - // already gates placement on friendship, so this should not be reached). + // W1: the storage quota is what THIS node granted the inviting friend at add-friend + // time, not a global default; a larger placement is declined. A node with no friend + // record falls back to DEFAULT_QUOTA_BYTES defensively (S4 already gates on friendship). let grant = { let s = self.shared.read().expect("shared lock"); friend_storage_grant(&s, &inv.by, now) @@ -1238,11 +1070,8 @@ impl ControlHandler { accept.sign(&self.node_key); write_msg(send, &accept).await?; - // The owner pushes the current owner-signed VaultAnnounce so this replica - // learns the replica set it belongs to (§7.4 b, W8). Verify it binds the - // inviting owner and this vault before trusting its member list. Option B (§4): - // no FileGrant follows - a recovering owner-device re-derives per-chunk keys - // from the manifest's pt_hash, so the replica retains only the announce. + // The owner pushes the current owner-signed VaultAnnounce so this replica learns its + // replica set (§7.4 b, W8). Verify it binds the inviting owner and this vault first. let announce = read_msg::(recv).await?; announce .verify() @@ -1256,10 +1085,9 @@ impl ControlHandler { "replica announce signer is not the inviting owner" ); - // Receive the pushed blobs: envelope first (verified), then each chunk. - // W1: cap the blob count and track the running received-byte total for this - // (peer, vid), aborting the moment it would exceed the advertised size - // (which is <= the granted quota). The store never grows past the quota. + // Receive the pushed blobs: envelope first (verified), then each chunk. W1: cap the + // blob count and abort the moment the running byte total would exceed the advertised + // size (<= the granted quota), so the store never grows past the quota. let count = read_u64(recv).await?; ensure!( count <= MAX_REPLICA_BLOBS, @@ -1318,14 +1146,9 @@ impl ControlHandler { } /// Trustee half of the §10.2 share-health cadence: answer an owner's - /// `ShareAttestChallenge` for a share this daemon holds. The challenge signer - /// must be an established friend (or our own device) AND the connection's - /// authenticated peer, so only the owning friend can probe liveness. The reply - /// echoes label fields only (`card_number` + nonce) via - /// [`carapace_recovery::answer_attest_challenge`] - never the share words. A - /// daemon holding no share for the named set, or holding a corrupt one, - /// finishes the stream with no attestation frame (a silent non-answer, which the - /// owner counts as "not live"). + /// `ShareAttestChallenge`. The signer must be an established friend (or own device) AND + /// the authenticated peer. The reply echoes label fields only, never the share words; a + /// daemon holding no/corrupt share finishes with no frame (the owner counts "not live"). async fn serve_attest( &self, ch: ShareAttestChallenge, @@ -1336,8 +1159,7 @@ impl ControlHandler { ch.verify() .map_err(|e| anyhow::anyhow!("attest challenge bad sig: {e}"))?; - // Copy the share out under the read lock; answer (and await the write) - // outside it so no lock is held across `.await`. + // Copy the share out under the read lock; answer outside it (no lock across `.await`). let share = { let s = self.shared.read().expect("shared lock"); let authorized = @@ -1357,21 +1179,11 @@ impl ControlHandler { Ok(()) } - /// Trustee half of grant delivery (§8, W3): receive a `ShareGrant` an owner - /// minted for us and, if it is authentic, store the FULL grant (roster + - /// recovery_delay + announce refs) keyed by its subject user - not a bare share, - /// which locates nothing without a live owner. Two independent checks must pass: - /// - /// - the grant's own signature verifies AND its embedded share decodes (the - /// words' CRC self-validates), via [`verify_share_grant`]; and - /// - the connection's authenticated peer signed the grant (`grant.by == remote`) - /// and is an established friend (or our own device) - so only a friend we chose - /// as our owner can plant a grant on us (delegation gate, mirrors `serve_attest`). - /// - /// On success the embedded share is ALSO recorded in `held_shares` so the existing - /// attestation cadence + local self-validation keep working. A grant that fails - /// either check is dropped with no ack frame (a silent decline). The owner learns - /// delivery succeeded from the ack. + /// Trustee half of grant delivery (§8, W3): store an authentic `ShareGrant` in full + /// (roster + recovery_delay + announce refs) keyed by subject user. Two checks must pass: + /// the grant's signature + embedded share verify ([`verify_share_grant`]), and the + /// authenticated peer both signed it and is an established friend (or own device). The + /// share is also recorded in `held_shares`; a failing grant is dropped with no ack. async fn serve_grant( &self, grant: ShareGrant, @@ -1379,8 +1191,7 @@ impl ControlHandler { send: &mut SendStream, ) -> Result<()> { let now = unix_now(); - // Signature + embedded-share (CRC) verification. A tampered grant or a - // corrupt share is rejected here before anything is stored. + // Signature + embedded-share (CRC) verification, before anything is stored. let share = match verify_share_grant(&grant) { Ok(share) => share, Err(_) => { @@ -1392,8 +1203,8 @@ impl ControlHandler { let stored = { let mut s = self.shared.write().expect("shared lock"); - // Delegation gate: the owner node that signed the grant must be the - // connection's authenticated peer AND an established friend (or ours). + // Delegation gate: the grant signer must be the authenticated peer AND an + // established friend (or ours). let authorized = grant.by == *remote && node_is_authorized(&s, &self.self_user, remote, now); if !authorized { @@ -1401,19 +1212,16 @@ impl ControlHandler { } else { let subject = grant.subject; s.held_grants.insert(subject, grant); - // Keep the existing share self-validation + attestation-answer path - // working: the embedded share is the authoritative object the words - // carry. Preserve any existing monitor's cadence state. + // Also record the embedded share for self-validation + attestation, keeping + // any existing monitor's cadence. s.held_shares .entry(rsid) .and_modify(|(sh, _)| *sh = share.clone()) .or_insert_with(|| (share.clone(), ShareMonitor::new())); - // Bind this rsid to its owner so an inbound ShareDestroy naming this - // rsid must also name this subject (§9.3 step 3c authorization). + // Bind this rsid to its owner so an inbound ShareDestroy must also name this + // subject (§9.3 step 3c authorization). s.held_share_subjects.insert(rsid, subject); - // §3.2.3: commit the held share/grant BEFORE acking, so a crash can - // never ack a grant we did not durably store (the owner treats an - // un-acked grant as undelivered and re-delivers). + // Commit BEFORE acking, so a crash never acks a grant we did not durably store. self.persist_locked(&s); true } @@ -1425,21 +1233,14 @@ impl ControlHandler { Ok(()) } - /// Receive a `RecoveryOpen` (§8.5 step 2 fan-out AND the §8.5 step 5 claimant - /// share request - the same signed message serves both). Any dialer may relay an - /// open; only its self-signature is required to record the alarm, so the - /// anti-silent-takeover signal reaches the subject's own devices and friends - /// (which hold no grant). If we hold a grant for the subject we ALSO track the full - /// ceremony - deriving the roster as `{this trustee} ∪ the grant's co-trustees` - /// (the owner-minted grant is owner-signed and excludes the holder, so the ceremony - /// roster is reconstructed here, not from the grant signer) - and, once the gate is - /// open (we approved AND `≥ M` approvals AND the delay elapsed AND no abort), reply - /// with our share HPKE-sealed to `open.ceremony_enc`. No trustee ever sees another's - /// share, and nothing is ever sent unsealed. - /// - /// The delay clock is anchored to the LOCAL `first_seen` captured the first time we - /// track a ceremony id (never reset by a re-send), so a backdated sponsor - /// `opened_at` cannot collapse the abort window (spec-errata E4). + /// Receive a `RecoveryOpen` (§8.5 step-2 fan-out AND the step-5 claimant share request - + /// same signed message). Any dialer may relay one; its self-signature alone records the + /// alarm so the anti-silent-takeover signal reaches the subject's own devices/friends. If + /// we hold a grant for the subject we also track the full ceremony (roster = `{this + /// trustee} ∪ the grant's co-trustees`) and, once the gate is open (we approved AND `>= M` + /// approvals AND the delay elapsed AND no abort), reply with our share HPKE-sealed to + /// `open.ceremony_enc`. The delay clock anchors to a LOCAL `first_seen` never reset by a + /// re-send, so a backdated sponsor `opened_at` cannot collapse the abort window (E4). async fn serve_recovery_open(&self, open: RecoveryOpen, send: &mut SendStream) -> Result<()> { if open.verify().is_err() { send.finish()?; // an unsigned/forged open is neither an alarm nor trackable @@ -1450,12 +1251,10 @@ impl ControlHandler { let mut s = self.shared.write().expect("shared lock"); let now = ceremony_now(&s); let is_self = open.subject == self.self_user; - // Does the SPONSOR (the RecoveryOpen signer) qualify for a durable alarm? - // Mirrors persist::enc_alarms's C1 bound (a stranger's alarm stays RAM-only), - // and gates whether a stranger's open may force a full-state redb commit at - // all: without it an unauthenticated dialer spraying fresh ceremony ids would - // fsync the whole state per dial (I/O-amplification DoS). The subject is NOT a - // qualifier - it is public, so an attacker would just set it to our pubkey. + // Does the SPONSOR qualify for a durable alarm? Mirrors persist::enc_alarms's + // bound and gates whether a stranger's open may force a full-state commit at all + // (else an unauthenticated dialer spraying ceremony ids fsyncs per dial). The + // subject is NOT a qualifier - it is public, so an attacker would set it to us. let sponsor_qualifies = s.held_grants.contains_key(&open.by) || s.friends.contains_key(&open.by) || self @@ -1464,12 +1263,10 @@ impl ControlHandler { .expect("docs lock") .card(&open.by) .is_some(); - // Track whether anything DURABLE actually changed, so a no-op open (a re-send, - // or a stranger's open we neither alarm-persist nor track) skips the commit. + // Track whether anything DURABLE changed, so a no-op open skips the commit. let mut dirty = false; - // Alarm for every observer, deduped by ceremony id (a re-send never clears - // an abort flag). This is what /api/status surfaces. Only a qualifying-sponsor - // alarm is durable (enc_alarms filters the rest), so only that is `dirty`. + // Alarm for every observer, deduped by ceremony id. Only a qualifying-sponsor + // alarm is durable (enc_alarms filters the rest), so only that sets `dirty`. let alarm_new = !s.ceremony_alarms.contains_key(&open.ceremony_id); s.ceremony_alarms .entry(open.ceremony_id) @@ -1485,13 +1282,9 @@ impl ControlHandler { if alarm_new && sponsor_qualifies { dirty = true; } - // A subject-signed abort may have arrived BEFORE this open (fan-out has no - // ordering, and the same open is re-sent as the claimant's later share - // request). It is authoritative iff its signer is THIS open's subject - only - // now, with the open in hand, can we resolve the subject to apply the - // `by == subject` check. Apply it so the ceremony can NEVER release, mirroring - // the normal open-then-abort path (§8.5 step 3). A stranger's stored abort - // fails the subject check and stays inert. + // A subject-signed abort may have arrived BEFORE this open (unordered fan-out). + // It is authoritative iff its signer is THIS open's subject - resolvable only now. + // Apply it so the ceremony can NEVER release; a stranger's abort stays inert. let subject_abort = s .aborted_ceremonies .get(&open.ceremony_id) @@ -1508,8 +1301,8 @@ impl ControlHandler { } // Trustee role: track (once) and, if the gate is open, seal our share. if let Some(grant) = s.held_grants.get(&open.subject).cloned() { - // Track once, anchoring `first_seen` at the first observation - a re-send - // (or the claimant's later share request) never resets it (E4). + // Track once, anchoring `first_seen` at first observation; a re-send never + // resets it (E4). if let std::collections::hash_map::Entry::Vacant(e) = s.ceremonies.entry(open.ceremony_id) { @@ -1535,8 +1328,8 @@ impl ControlHandler { } if let Some(tc) = s.ceremonies.get(&open.ceremony_id) { if tc.approved && !tc.takeover && tc.state.can_release(now) { - // Seal to the claimant's fresh ceremony key, signed with our - // USER key so the claimant authenticates us against the roster. + // Seal to the claimant's ceremony key, signed with our USER key so the + // claimant authenticates us against the roster. if let Ok(cs) = build_ceremony_share( &self.user_key, open.ceremony_id, @@ -1548,12 +1341,10 @@ impl ControlHandler { } } } - // §3.2.3 / §8.5: commit the ceremony tracking (the E4 `first_seen` delay - // anchor, the alarm, and any beat-the-open abort/takeover flag) BEFORE - // sending our share, so a reboot cannot forget an abort and later re-track a - // fresh, non-aborted ceremony that releases at delay-expiry. Skipped when the - // open changed nothing durable (a re-send, or a stranger's RAM-only alarm) so - // an unauthenticated dialer cannot force a commit per dial (audit #3). + // Commit the ceremony tracking (E4 `first_seen` anchor, alarm, beat-the-open + // abort/takeover flag) BEFORE sending our share, so a reboot cannot forget an + // abort and re-track a fresh ceremony that releases at delay-expiry. Skipped on a + // no-op open so an unauthenticated dialer cannot force a commit per dial. if dirty { self.persist_locked(&s); } @@ -1581,8 +1372,7 @@ impl ControlHandler { None => false, }; if approved { - // Persist the folded-in co-trustee approval so the release gate's - // approval count survives a reboot mid-ceremony (§8.5). + // Persist the approval so the release gate's count survives a reboot mid-ceremony. self.persist_locked(&s); } } @@ -1590,20 +1380,16 @@ impl ControlHandler { Ok(()) } - /// Receive a `CeremonyAbort` (§8.5 step 3). `state.abort` enforces `ab.by == - /// subject` (authoritative, unforgeable by an impostor): a valid subject abort - /// cancels the ceremony permanently and this device flags it as an attempted - /// takeover, so no share ever releases afterward. Also flags the alarm record so an - /// alarm-only observer (a subject device / friend holding no grant) still shows it. + /// Receive a `CeremonyAbort` (§8.5 step 3). `state.abort` enforces `ab.by == subject`: a + /// valid subject abort cancels the ceremony permanently and flags an attempted takeover, + /// so no share releases afterward. Also flags the alarm for alarm-only observers. async fn serve_ceremony_abort(&self, ab: CeremonyAbort, send: &mut SendStream) -> Result<()> { if ab.verify().is_ok() { let mut s = self.shared.write().expect("shared lock"); - // Record every signature-valid abort keyed by ceremony id, EVEN IF nothing is - // tracked yet: fan-out has no ordering, so this abort may precede our own - // `RecoveryOpen`. `serve_recovery_open` consults this before any release and - // decides authority (by == subject) once the open supplies the subject. A - // stranger's abort is kept but is inert there. Dedup by signer so a griefing - // stranger cannot crowd out the authoritative subject abort. (§8.5 step 3.) + // Record every signature-valid abort keyed by ceremony id even if nothing is + // tracked yet (unordered fan-out may deliver it before our `RecoveryOpen`); + // `serve_recovery_open` decides authority (by == subject) later. Dedup by signer so + // a griefing stranger cannot crowd out the authoritative subject abort. let seen = s.aborted_ceremonies.entry(ab.ceremony_id).or_default(); if !seen.iter().any(|a| a.by == ab.by) { seen.push(ab.clone()); @@ -1619,25 +1405,20 @@ impl ControlHandler { al.takeover = true; } } - // §8.5 abort durability: persist the recorded abort (bounded to qualifying - // signers, C1) + any takeover flag BEFORE finishing, so the abort still blocks - // a release after a reboot - even if it arrived before our own RecoveryOpen. + // Persist the recorded abort (bounded to qualifying signers) + takeover flag + // BEFORE finishing, so it still blocks a release after a reboot. self.persist_locked(&s); } send.finish()?; Ok(()) } - /// Receive a `FriendshipEnd` (§9.3): the ex-friend terminated unilaterally. Verify - /// it, resolve the ex-friend from the signer node (a node one of our friends' - /// cards delegates), and run the LOCAL unfriend teardown - drop them from the - /// friend graph, delete everything we hold OF them, queue their replicas of our - /// vaults for immediate re-placement, and mark a pending re-split for every - /// recovery set they were a trustee of. The network follow-through (re-placement + - /// re-split prompt + our own reciprocal `DeleteRequest`s) is driven by the maintenance - /// loop, which holds `k_root` and an endpoint. We do NOT echo a `FriendshipEnd` back - /// (that would loop); we DO queue our own `DeleteRequest`s for everything WE placed on - /// them (§9.3.1: each side deletes what it placed on the other), sent from the loop. + /// Receive a `FriendshipEnd` (§9.3): the ex-friend terminated unilaterally. Verify, + /// resolve the ex-friend from the signer node, and run the LOCAL unfriend teardown (drop + /// from the friend graph, delete everything we hold OF them, queue their replicas of our + /// vaults for re-placement, mark a pending re-split per recovery set they were a trustee + /// of). The network follow-through is driven by the maintenance loop. We do NOT echo a + /// `FriendshipEnd` back (that would loop) but DO queue our own reciprocal `DeleteRequest`s. async fn serve_friendship_end( &self, end: FriendshipEnd, @@ -1703,8 +1484,8 @@ impl ControlHandler { match owner { Some(owner_user) => { apply_delete_request(&mut s, &req, &owner_user); - // §3.2.3: commit the deletion of the owner's replica data BEFORE - // acking, so a crash cannot ack a delete we did not durably apply. + // Commit the deletion BEFORE acking, so a crash cannot ack a delete we + // did not durably apply. self.persist_locked(&s); Some(build_delete_ack(&self.node_key, &req, now)) } @@ -1720,14 +1501,11 @@ impl ControlHandler { Ok(()) } - /// Trustee half of the re-split destroy step (§9.3 step 3c): the owner instructs us - /// to destroy the OLD share we hold for `subject`'s old recovery set. Verify the - /// instruction, require the signer to be an established friend (or self) AND the - /// connection's peer, and only if we actually HOLD that old share destroy it and - /// reply with a signed [`ShareDestroyAck`]. A newer re-split grant may have already - /// overwritten our held grant for this subject with the NEW set - we keep that and - /// only drop the grant if it still points at the old recovery set. We never ack a - /// share we do not hold (an honest destroy is the whole point of the step). + /// Trustee half of the re-split destroy step (§9.3 step 3c): the owner instructs us to + /// destroy the OLD share for `subject`'s old recovery set. Verify, require the signer to + /// be an established friend (or self) AND the peer, and only if we HOLD that old share + /// destroy it and reply with a signed [`ShareDestroyAck`]. A newer re-split grant for the + /// NEW set survives: the grant is dropped only if it still points at the old set. async fn serve_share_destroy( &self, ds: ShareDestroy, @@ -1738,17 +1516,15 @@ impl ControlHandler { let ack = if ds.verify().is_ok() { let mut s = self.shared.write().expect("shared lock"); // Bind the destroyer to the subject: the signer node must map to the SUBJECT - // owner (not merely to some current friend), AND the rsid it names must be one - // we actually hold FOR that subject. Without both, any current friend could - // destroy an unrelated owner's share by naming its rsid (held_shares is keyed - // by rsid alone). Mirrors serve_delete_request's owner-binding pattern. + // owner, AND the rsid it names must be one we hold FOR that subject. Without both, + // any friend could destroy an unrelated owner's share by naming its rsid. let authorized = ds.by == *remote && owner_user_of_node(&s, &self.self_user, &ds.by, now) == Some(ds.subject) && s.held_share_subjects.get(&ds.rsid) == Some(&ds.subject); if authorized && s.held_shares.remove(&ds.rsid).is_some() { s.held_share_subjects.remove(&ds.rsid); - // Drop the held grant only if it still names the old set; a re-split - // grant for the NEW set (same subject) must survive the old destroy. + // Drop the held grant only if it still names the old set; a NEW-set re-split + // grant (same subject) must survive. let drop_grant = s .held_grants .get(&ds.subject) @@ -1757,9 +1533,8 @@ impl ControlHandler { if drop_grant { s.held_grants.remove(&ds.subject); } - // §3.2.3 + §9.3 stranding: commit the share REMOVAL BEFORE acking, so a - // crash cannot resurrect a "destroyed" share whose ack the owner already - // counted (which would leave the old set live past a re-split). + // Commit the share REMOVAL BEFORE acking, so a crash cannot resurrect a + // "destroyed" share whose ack the owner already counted. self.persist_locked(&s); Some(build_share_destroy_ack( &self.node_key, @@ -1805,61 +1580,40 @@ pub struct Daemon { node_key: SigningKey, user_key: SigningKey, k_root: Zeroizing<[u8; 32]>, - /// Persistent per-signer document rollback state (cards by version, announces - /// by epoch), kept across `sync_from` calls for the daemon's lifetime so a - /// stale replica cannot roll an already-seen epoch back (W2). Held only for - /// synchronous verification work; never locked across an `.await`. - /// - /// ponytail: in-memory, daemon-lifetime state. The blob store is also - /// in-memory, so nothing survives a restart anyway; persist to disk here and - /// in the blob store together if durable rollback across restarts is needed. + /// Per-signer document rollback state (cards by version, announces by epoch), kept for + /// the daemon's lifetime so a stale replica cannot roll an already-seen epoch back (W2). + /// Held only for synchronous verification; never locked across an `.await`. docs: Arc>, - /// Per-vault publish serialization (§11 / MAJOR 5). A vault's whole publish - - /// read-prev, ingest, commit - runs under its own async lock so the watcher's - /// background `publish_vault` and a sync's `publish_merged`/baseline-persist can - /// never interleave on the same vid (no lost update, no two digests at one - /// epoch, no re-ingest of a half-written merged tree). The outer `Mutex` only - /// guards the get-or-insert of the per-vid lock; it is never held across an - /// `.await`. ponytail: grows one entry per distinct owned/synced vid over the - /// daemon's life; prune alongside vault teardown if that is ever added. + /// Per-vault publish serialization (§11): a vault's whole publish (read-prev, ingest, + /// commit) runs under its own async lock so the watcher's `publish_vault` and a sync's + /// `publish_merged` never interleave on the same vid. The outer `Mutex` only guards the + /// get-or-insert of the per-vid lock and is never held across an `.await`. publish_locks: Mutex>>>, - /// Per-subject recovery-open rate limiter (§8.5): a forged/abusive `RecoveryOpen` - /// cannot exhaust an honest subject's budget. Single long-lived limiter guarded by - /// its own mutex so `ceremony_open` can charge it without touching `shared`. + /// Per-subject recovery-open rate limiter (§8.5): a forged/abusive `RecoveryOpen` cannot + /// exhaust an honest subject's budget. Own mutex so `ceremony_open` need not touch `shared`. recovery_limiter: Mutex, - /// Serializes §9.3 re-split stand-up + drive so the initiating `unfriend` and the - /// maintenance loop never advance the same re-split concurrently. Without it, two - /// concurrent `advance_resplits` runs can each see the same `old_rsid` as pending and - /// both call `begin_resplit` -> two independent fresh splits (two new recovery-set ids, - /// two grant sets), of which only the first is kept - burning an rsid + a Shamir split - /// per race. ponytail: one global re-split lock; split per-rsid only if re-split - /// throughput ever matters (it is a rare unfriend-triggered path). + /// Serializes §9.3 re-split stand-up + drive so `unfriend` and the maintenance loop never + /// advance the same re-split concurrently; without it two runs could `begin_resplit` the + /// same `old_rsid` into two independent fresh splits, burning an rsid + a Shamir split. resplit_lock: tokio::sync::Mutex<()>, - /// The embedded relay server (§6), held to keep it running for the daemon's - /// life. `Some` iff this node runs a relay. Its liveness is probed each - /// maintenance round to drive the advertise/withdraw lifecycle (W6); its mapped - /// WAN address (when the NAT port-mapper resolves one) is what friends advertise. + /// The embedded relay server (§6), held to keep it running. `Some` iff this node runs a + /// relay. Probed each maintenance round to drive the advertise/withdraw lifecycle (W6). relay: Option, - /// Public DNS name / WAN address to advertise for the relay instead of its - /// mapped/bound address (§6), from [`NetConfig::relay_host`]. Preferred over the - /// port-mapper's external address when set (an operator's stable DDNS name). + /// Public DNS name / WAN address to advertise for the relay instead of its mapped/bound + /// address (§6), from [`NetConfig::relay_host`]. Preferred over the port-mapper's address. relay_host: Option, - /// The durable state directory (design §3): holds `blobs/` (FsStore) and - /// `state.redb`. Retained so background/reboot paths can locate durable state. + /// The durable state directory: holds `blobs/` (FsStore) and `state.redb`. state_dir: PathBuf, - /// The redb source of truth on disk (design §3.2). Every compound mutation funnels - /// the WHOLE `Shared` + `DocStore` back through [`persist::persist_all`] into one txn - /// and commits BEFORE any externally visible effect. Wrapped in `Arc` so background - /// tasks persist without borrowing `self`. + /// The redb source of truth on disk. Every compound mutation funnels the whole `Shared` + + /// `DocStore` back through [`persist::persist_all`] in one txn, committed before any + /// externally visible effect. `Arc` so background tasks persist without borrowing `self`. db: Arc, - /// Cleanup guard for a `from_seeds` daemon's process-unique ephemeral state dir: - /// `Some` only when `State::dir` was `None`. Removes the whole tree on drop so a - /// seed-only test daemon leaves nothing behind. + /// Cleanup guard for a `from_seeds` daemon's ephemeral state dir: `Some` only when + /// `State::dir` was `None`. Removes the tree on drop. _ephemeral_dir: Option, /// The iroh protocol router serving `iroh_blobs::ALPN` (gated blob reads) and - /// `carapace/1`. Held for the daemon's lifetime; [`Daemon::shutdown`] shuts it - /// down FIRST, which runs `BlobsProtocol::shutdown` → a clean FsStore shutdown - /// (commits the store's open write batch — see `IrohBlobStore::sync`). + /// `carapace/1`. [`Daemon::shutdown`] shuts it down FIRST, which runs a clean FsStore + /// shutdown (commits the store's open write batch). router: Router, } @@ -1886,46 +1640,34 @@ fn ephemeral_state_dir() -> Result { Ok(dir) } -/// Handle for a live §11 filesystem watcher started by [`Daemon::watch_vault`]. -/// -/// Keep it alive to keep watching; drop it to stop. Drop halts the underlying -/// `notify` watcher (closing the event channel) and aborts the debounce/re-ingest -/// task, so shutdown is clean and cancel-safe (no lock is held across an `.await` -/// in that task). +/// Handle for a live §11 filesystem watcher started by [`Daemon::watch_vault`]. Keep it +/// alive to keep watching; drop it to stop the `notify` watcher and re-ingest task. pub struct VaultWatcher { - // Field order matters for Drop: the notify watcher is dropped first (below via - // the generated Drop glue after our explicit `drop` impl runs), closing the - // event channel. Held to keep fs events flowing while the handle lives. + // Field order matters for Drop: the notify watcher is dropped after our explicit `drop` + // runs, closing the event channel. _watcher: notify::RecommendedWatcher, task: tokio::task::JoinHandle<()>, } impl Drop for VaultWatcher { fn drop(&mut self) { - // Abort the re-ingest task; dropping `_watcher` afterwards closes the - // channel. Abort is safe here: publish_vault holds the `shared` lock only - // for synchronous critical sections, never across an `.await`. + // Abort the re-ingest task; dropping `_watcher` afterwards closes the channel. Safe: + // publish_vault holds the `shared` lock only synchronously, never across `.await`. self.task.abort(); } } -/// Handle for the background maintenance loop started by [`Daemon::run_maintenance`] -/// (§10.1/§10.2). Keep it alive to keep the loop running; drop it (or call -/// [`MaintenanceHandle::stop`]) to tear the loop down. -/// -/// The loop task holds only a [`Weak`] to the daemon and upgrades it per round, so it -/// never keeps the daemon alive: once the last `Arc` is dropped the loop ends -/// on its own. Drop aborts the task; this is cancel-safe because every maintenance -/// action releases its locks before each `.await` (no lock is held across a network -/// round-trip), so an abort mid-round only drops an in-flight future. +/// Handle for the background maintenance loop started by [`Daemon::run_maintenance`]. Keep +/// it alive to keep the loop running; drop it (or [`MaintenanceHandle::stop`]) to tear it +/// down. The loop holds only a [`Weak`] to the daemon, so the last `Arc` drop ends +/// it; abort is cancel-safe (no lock held across an `.await`). pub struct MaintenanceHandle { task: Option>, } impl MaintenanceHandle { - /// Stop the loop and await its full teardown, so the caller can then reclaim the - /// sole `Arc` (e.g. `Arc::try_unwrap` + [`Daemon::shutdown`]) with no - /// lingering strong reference held by an in-flight round. + /// Stop the loop and await its teardown, so the caller can reclaim the sole `Arc` + /// with no strong reference held by an in-flight round. pub async fn stop(mut self) { if let Some(task) = self.task.take() { task.abort(); @@ -1963,35 +1705,27 @@ impl Daemon { Ok(manifest) } - /// Persist the WHOLE `Shared` + `DocStore` in one redb txn and commit it (design - /// §3.2). The caller MUST already hold the `shared` write lock and pass the guard so - /// the RAM mutation and the durable write share one critical section (§3.2.4). A - /// commit failure CRASHES the daemon (§3.2.5 fail-loud: never continue with RAM ahead - /// of disk). Commit happens BEFORE any externally visible effect at every call site - /// (§3.2.3). `docs` is locked internally (lock order: `shared` then `docs`). + /// Persist the whole `Shared` + `DocStore` in one redb txn and commit. Caller holds the + /// `shared` write lock; commit is fail-loud (crashes on failure) and happens before any + /// externally visible effect. `docs` is locked internally (order: `shared` then `docs`). fn persist_locked(&self, s: &Shared) { let docs = self.docs.lock().expect("docs lock"); self.persist_locked_with(s, &docs); } - /// As [`persist_locked`] but for a caller that already holds the `docs` lock too - /// (e.g. a control handler that just mutated the `DocStore`), preserving the single - /// `shared`->`docs` lock order. + /// As [`persist_locked`] but for a caller that already holds the `docs` lock too, + /// preserving the `shared`->`docs` lock order. fn persist_locked_with(&self, s: &Shared, docs: &DocStore) { persist::commit_all(&self.db, s, docs, &self.k_root); } - /// Bind the endpoint from `state`, start serving the blob store and the - /// `carapace/1` control protocol, and publish this device's `ContactCard` - /// (with a user-signed delegation of the node key). Uses the default - /// [`ReplicaLimits`]; see [`Daemon::start_with_limits`] to tune them. + /// Bind the endpoint from `state`, start serving the blob store and the `carapace/1` + /// control protocol, and publish this device's `ContactCard`. Default [`ReplicaLimits`]. pub async fn start(state: State) -> Result { Self::start_with_limits(state, ReplicaLimits::default()).await } - /// Like [`Daemon::start`] but with explicit replica-store limits (W1). Tests - /// use this to set a small quota or a tight rate limit and exercise the - /// cut-offs without pushing gigabytes. + /// Like [`Daemon::start`] but with explicit replica-store limits (W1), for tests. pub async fn start_with_limits(state: State, limits: ReplicaLimits) -> Result { Self::start_on( state, @@ -2007,11 +1741,8 @@ impl Daemon { .await } - /// Like [`Daemon::start_with_limits`] but with full network wiring - /// ([`NetConfig`]): a caller-chosen bind, friends' self-hosted relays to - /// consume, and optionally running this node's own embedded relay (§6). A node - /// that runs a relay advertises its URL in its ContactCard and issued tickets - /// and registers on it so friends can reach it via relay fallback. + /// Like [`Daemon::start_with_limits`] but with full network wiring ([`NetConfig`]): + /// caller-chosen bind, friends' relays to consume, and optionally an embedded relay (§6). pub async fn start_on(state: State, limits: ReplicaLimits, cfg: NetConfig) -> Result { let node_key = state.node_key.clone(); let user_key = state.user_key(); @@ -2020,8 +1751,8 @@ impl Daemon { let self_user = user_key.verifying_key().to_bytes(); let self_node = node_key.verifying_key().to_bytes(); - // Resolve the durable state directory (design §3). A `from_seeds` daemon has - // none, so allocate a process-unique ephemeral dir guarded for cleanup on drop. + // Resolve the durable state directory. A `from_seeds` daemon has none, so allocate an + // ephemeral dir guarded for cleanup on drop. let (state_dir, ephemeral_dir) = match state.dir.clone() { Some(d) => (d, None), None => { @@ -2081,12 +1812,9 @@ impl Daemon { } None => None, }; - // The URL our OWN endpoint registers on and reaches the relay at - // (loopback-substituted, so it works without NAT hairpinning). This is NOT - // the WAN URL we advertise to friends: that is computed per health round - // from the relay host / mapped external address (W6). Keeping registration - // on the local URL guarantees our endpoint stays a client of its own relay, - // which is what lets friends relay *to* us. + // The URL our OWN endpoint registers on and reaches the relay at (loopback- + // substituted). NOT the WAN URL advertised to friends (computed per health round); + // registering on the local URL keeps our endpoint a client of its own relay. let local_relay_url = relay.as_ref().map(|r| r.local_url()); // The endpoint's usable relay set: friends' relays plus our own local URL @@ -2109,23 +1837,21 @@ impl Daemon { }); let ep = CarapaceEndpoint::bind_on(&node_key, bind, &relays).await?; - // Durable served blob store (design §3.1): FsStore at `/blobs`, - // surviving restart. Blobs are already ciphertext, so no extra sealing. + // Durable served blob store: FsStore at `/blobs`. Blobs are already + // ciphertext, so no extra sealing. let blobs = IrohBlobStore::load(&state_dir.join("blobs")).await?; - // DERIVE (design §3.5/A1): re-derive each owned vault's decrypted `Manifest` from - // the envelope in FsStore + `K_manifest` (never persisted in clear). A vault whose - // envelope is absent/unopenable is left out - it becomes needs-refetch and the - // owner can republish from its working dir (reconciliation, not a startup abort). + // DERIVE: re-derive each owned vault's decrypted `Manifest` from the envelope in + // FsStore + `K_manifest` (never persisted in clear). A vault whose envelope is + // absent/unopenable becomes needs-refetch (reconciliation, not a startup abort). // The FsStore fetch is async, so re-derive OFF the lock, then insert under it. let mut rebuilt_vaults = Vec::new(); let mut refetch_vaults = Vec::new(); for (vid, digest, chunk_ids) in vault_blob_sources { match Self::rederive_manifest(&blobs, &k_root, vid, digest).await { Ok(manifest) => { - // EPH rebuild (§3.3): re-derive the per-chunk keys from the manifest's - // pt_hash + K_content so a post-reboot disclose/republish works without - // re-ingesting. Never persisted (a key dump). + // EPH rebuild: re-derive per-chunk keys from the manifest's pt_hash + + // K_content so a post-reboot disclose/republish works. Never persisted. let vkeys = VaultKeys::derive(&*k_root, vid); let keys = chunk_keys_from_manifest(&manifest, &*vkeys.k_content); rebuilt_vaults.push((vid, digest, chunk_ids, manifest, keys)); @@ -2136,9 +1862,8 @@ impl Daemon { ({e}); marked needs-refetch (republish or anti-entropy will repair)", hex32(&vid) ); - // Keep the blob source as the durable needs-refetch record. Dropping - // it here let the next persist rewrite the VAULT_BLOBS row without - // this vault, silently erasing it from every later boot. + // Keep the blob source as the durable needs-refetch record; dropping it + // here would let the next persist silently erase this vault from disk. refetch_vaults.push((vid, digest, chunk_ids)); } } @@ -2161,32 +1886,25 @@ impl Daemon { } } - // This device's ContactCard starts WITHOUT a relay URL (W6/§6: a relay is - // never advertised unconditionally at startup). The advertise happens only - // after a liveness probe confirms the relay is up - the initial - // `drive_relay_health` below, then every maintenance round. + // This device's ContactCard starts WITHOUT a relay URL (W6: never advertise a relay + // unconditionally at startup). Advertise happens only after a liveness probe. let mut card = build_card(&user_key, &node_key, &k_root, None); - // F3 (design §3.5): the own-card version is a persisted monotonic counter. On - // boot the fresh card is minted at `max(unix_now(), persisted + 1)` so it - // STRICTLY exceeds every version the prior run reached (even a rapid restart - // under heavy relay flapping), and a friend's DocStore never rejects it as a - // rollback (§6). The wall-clock floor keeps versions human-meaningful. + // F3: own-card version is a persisted monotonic counter. On boot the fresh card is + // minted at `max(unix_now(), persisted + 1)` so it strictly exceeds every version the + // prior run reached and a friend's DocStore never rejects it as a rollback. card.version = unix_now().max(card_version_floor.saturating_add(1)); card.sign(&user_key); { let mut s = shared.write().expect("shared lock"); - // Replace any persisted prior own card (do NOT accumulate duplicates across - // reboots); the friend arm keys on `friends`, `cards` holds only own cards. + // Replace any persisted prior own card (no duplicates across reboots). s.cards.retain(|c| c.by != self_user); s.cards.push(card); s.rate = RateLimiter::new(limits.rate_capacity, limits.rate_refill_per_sec); s.relay_health.local_url = local_relay_url; } - // The rollback-guarded document store, shared between the daemon's own pull - // path (`sync_from`) and the accept handler's `serve_docs` so learned docs are - // re-served during anti-entropy (store-and-forward, §6/W7). Loaded from disk so - // the §6 rollback high-water marks survive restart. + // The rollback-guarded document store, shared between `sync_from` and `serve_docs` + // (store-and-forward, §6/W7). Loaded from disk so rollback high-water marks survive. let docs = Arc::new(Mutex::new(loaded.docs)); let hello = Hello { @@ -2208,26 +1926,13 @@ impl Daemon { hints: ep.hints(), docs: Arc::clone(&docs), }; - // §7.4 / D3 fetch authorization (closes S5 for owned granted content): the - // blob store no longer answers `iroh_blobs::ALPN` fetches from any dialer. - // Every get-request is gated by `authorize_fetch` against the dialer's - // authenticated node id and the requested ChunkID. Owner-served chunks of a - // vault we own are released only to our own delegated devices, this vault's - // replica-set members, or a friend authenticated as a member of a grant's - // audience covering that chunk — so a leaked grant document alone (presented - // by a non-audience party) authorizes nothing. - // - // W8/§7.4 replica gate: chunks we hold *as a replica* for another owner are - // no longer on the inherited residual. `authorize_fetch` serves them only to - // that vault owner's delegated devices (proved by the card the dialer - // presents on our control stream) or a current replica-set member from the - // owner's announce — an arbitrary dialer is refused. - // F3 (design §6): persist the freshly minted own-card version floor BEFORE the - // router starts accepting, so a peer can never observe a card version that has not - // reached disk. Otherwise a crash between the first serve and the startup snapshot - // would let the floor rewind and re-mint a version a friend's DocStore already - // rejected (clock-rollback self-DoS). The post-relay-probe re-issue is captured by - // the `persist_snapshot` after the daemon is built. + // §7.4/D3 + W8 fetch authorization: every `iroh_blobs::ALPN` get-request is gated by + // `authorize_fetch` against the dialer's authenticated node id and the ChunkID. + // Owner-served chunks go only to own devices, replica-set members, or a grant-audience + // friend; replica-held chunks only to the owner's devices or a current member. + // F3: persist the freshly minted own-card version floor BEFORE the router accepts, so a + // peer can never observe a card version that has not reached disk (else a crash could + // rewind the floor and re-mint a version a friend's DocStore already rejected). { let s = shared.read().expect("shared lock"); let d = docs.lock().expect("docs lock"); @@ -2267,18 +1972,16 @@ impl Daemon { router, }; - // W6/§6: elect the relay only after a liveness probe confirms it is up - - // never unconditionally at startup. On success this re-issues the card at - // version 2 carrying the relay URL; if the relay is not (yet) alive the card - // stays relay-less and the maintenance loop advertises it once it comes up. + // W6: elect the relay only after a liveness probe confirms it up. On success this + // re-issues the card carrying the relay URL; else it stays relay-less until the loop + // advertises it. if daemon.relay.is_some() { let alive = daemon.probe_relay_alive().await; daemon.drive_relay_health(alive); } - // Persist the startup snapshot so the F3 own-card version floor (bumped above, - // and possibly again by the relay-health card re-issue) reaches disk, and any - // load-time normalization (own-card dedupe, share_sets rebuild) is captured. + // Persist the startup snapshot so the F3 version floor (and any relay-health card + // re-issue + load-time normalization) reaches disk. daemon.persist_snapshot(); Ok(daemon) @@ -2314,10 +2017,8 @@ impl Daemon { self.shared.read().expect("shared lock").split_states.len() } - /// The current PoR round counter (the challenge-unpredictability nonce, §10.1) for - /// `(node, vid)`. Test accessor for the audit #1/#6 reboot regression: a reboot must - /// resume from this counter, never rewind to a spent round, and the maintenance-loop - /// restamp must keep it. + /// The current PoR round counter (challenge-unpredictability nonce) for `(node, vid)`. + /// Test accessor for the reboot regression: a reboot must resume, never rewind. #[doc(hidden)] pub fn por_round(&self, node: [u8; 32], vid: [u8; 32]) -> u64 { self.shared @@ -2337,9 +2038,8 @@ impl Daemon { .contains_key(chunk_id) } - /// The durable state directory (design §3): root of `blobs/` (FsStore) and, once - /// the redb state layer lands, `state.redb`. For a `from_seeds` daemon this is a - /// process-unique ephemeral dir cleaned up on drop. + /// The durable state directory: root of `blobs/` (FsStore) and `state.redb`. For a + /// `from_seeds` daemon this is an ephemeral dir cleaned up on drop. pub fn state_dir(&self) -> &Path { &self.state_dir } @@ -2355,9 +2055,8 @@ impl Daemon { new_vid(&self.user_key.verifying_key().to_bytes()) } - /// The per-vid publish lock (§11 / MAJOR 5), created on first use. Held across - /// the WHOLE of `publish_vault` and a sync's apply phase so those two paths - /// serialize on a vid and never clobber each other's read-prev -> commit. + /// The per-vid publish lock (§11), created on first use. Held across the whole of + /// `publish_vault` and a sync's apply phase so they serialize on a vid. fn publish_lock(&self, vid: [u8; 32]) -> Arc> { self.publish_locks .lock() @@ -2367,26 +2066,13 @@ impl Daemon { .clone() } - /// Ingest `src` into vault `vid`: (re-)chunk + seal every file, load the - /// ciphertext + manifest envelope into the served blob store, seal a - /// per-chunk access grant, and publish a freshly signed `VaultAnnounce` + - /// `FileGrant`. Records `src` as this vault's authoritative working directory - /// (§11), so a later sync reconstructs the merged result back into the same - /// tree this ingest reads. - /// - /// Bumps the vault's epoch and republishes ONLY when the re-ingested tree - /// differs from the last published manifest. A no-op re-ingest (e.g. the - /// watcher firing on the daemon's own just-applied merge, whose files and - /// mtimes round-trip exactly) returns the current epoch WITHOUT a bump, so the - /// per-signer announce line stays monotonic and two devices converge instead of - /// ping-ponging epochs. Returns the vault's epoch. - /// - /// The whole read-prev -> ingest -> commit runs under the vid's publish lock so - /// it serializes with a concurrent sync `publish_merged` on the same vid - /// (MAJOR 5): no lost update, and never two different digests at one epoch. - /// - /// ponytail: ingest runs inline on the async worker (fine for a demo); a - /// production daemon would `spawn_blocking` the heavy CPU/IO path. + /// Ingest `src` into vault `vid`: (re-)chunk + seal every file, load ciphertext + + /// manifest envelope into the served blob store, and publish a freshly signed + /// `VaultAnnounce` + `FileGrant`. Records `src` as the vault's authoritative working + /// directory (§11). Bumps the epoch and republishes ONLY when the re-ingested tree + /// differs from the last manifest; a no-op re-ingest returns the current epoch without a + /// bump, so two devices converge instead of ping-ponging epochs. Runs under the vid's + /// publish lock so it serializes with a concurrent sync `publish_merged`. Returns the epoch. pub async fn publish_vault(&self, src: &Path, vid: [u8; 32]) -> Result { let lock = self.publish_lock(vid); let _publish = lock.lock().await; @@ -2394,15 +2080,13 @@ impl Daemon { let vkeys = VaultKeys::derive(&*self.k_root, vid); let (cur_epoch, prev) = { let mut s = self.shared.write().expect("shared lock"); - // §11: this source IS the vault's authoritative working directory - // (watched + sync target). A publish declares it, so overwrite any prior - // (e.g. a first-sync fallback) - a later sync reconstructs merges here. + // §11: this source IS the vault's authoritative working directory; a publish + // declares it, overwriting any prior (e.g. a first-sync fallback). s.working_dirs.insert(vid, src.to_path_buf()); let cur = *s.epochs.get(&vid).unwrap_or(&0); - // §11: carry the previously-published manifest so a re-ingest bumps - // this device's per-file version-vector component on real changes - // (and tombstones local deletions), making a concurrent edit on - // another owner device detectable at merge time. + // §11: carry the previous manifest so a re-ingest bumps this device's per-file + // version-vector component on real changes, making a concurrent edit on another + // owner device detectable at merge time. let prev = s.vault_blobs.get(&vid).map(|vb| vb.manifest.clone()); (cur, prev) }; @@ -2412,11 +2096,9 @@ impl Daemon { let mut mem = MemoryStore::new(); let ingest = ingest_dir(src, &self.node_key, &vkeys, epoch, prev.as_ref(), &mut mem)?; - // No-op guard: if the re-ingested file set is byte-for-byte identical to - // what we last published (same paths, hashes, mtimes, per-file VVs), there - // is nothing to propagate. Do NOT bump the epoch or republish - this is the - // watcher re-observing the daemon's own just-written merged/reconstructed - // tree, and republishing it would spuriously advance the announce line. + // No-op guard: if the re-ingested file set is identical to what we last published, + // do NOT bump the epoch or republish (the watcher re-observing our own just-written + // tree would otherwise spuriously advance the announce line). if let Some(prevm) = &prev { if ingest.manifest.files == prevm.files { return Ok(cur_epoch); @@ -2501,19 +2183,16 @@ impl Daemon { s.announces.push(ann); s.grants.retain(|g| g.vid != vid); s.grants.push(grant); - // §3.2.2-3: commit the whole publish (epoch bump, owned_chunks, vault_blobs, - // announce, grant) as ONE txn BEFORE pushing the new epoch to replicas, so a - // crash can never leave replicas ahead of our own committed epoch line, and - // the default-deny fetch gate is armed durably for the new chunks (F1). - // vault_keys is EPH (never persisted; re-derived on demand), so it is fine - // that it is inserted here but not in the funnel. + // Commit the whole publish (epoch bump, owned_chunks, vault_blobs, announce, + // grant) as ONE txn BEFORE pushing to replicas, so a crash can never leave + // replicas ahead of our committed epoch line, and the default-deny fetch gate is + // armed durably for the new chunks (F1). vault_keys is EPH, so its RAM-only insert + // here (not in the funnel) is fine. self.persist_locked(&s); - // §11: the new epoch must reach the CURRENT enrolled replica set, or those - // replicas keep serving the stale placement-time epoch and §10.1 read - // redundancy collapses to just this owner. Snapshot the members (excluding - // our own devices - they sync via the owner-device path) to their dialable - // addresses now, while we hold the lock, then push after releasing it. + // §11: the new epoch must reach the CURRENT enrolled replica set or those replicas + // keep serving the stale placement-time epoch. Snapshot the members (excluding own + // devices) to dialable addresses under the lock, then push after releasing it. let now = unix_now(); let self_user = self.user_id(); s.members @@ -2533,10 +2212,9 @@ impl Daemon { .unwrap_or_default() }; - // Push the new epoch to enrolled replicas OUTSIDE the shared lock. Best-effort: - // iroh blobs are content-addressed so a replica only pulls the chunks it lacks - // (dedup), and an offline replica is caught by the existing PoR/repair path, so - // a failed push MUST NOT fail the publish (mirror the other best-effort sends). + // Push the new epoch to enrolled replicas OUTSIDE the shared lock. Best-effort: an + // offline replica is caught by the PoR/repair path, so a failed push must not fail + // the publish. if !push_targets.is_empty() { match self.gather_blob_bytes(&vb).await { Ok(blobs) => { @@ -2561,29 +2239,18 @@ impl Daemon { Ok(epoch) } - /// §11 / W12: start a debounced filesystem watcher over `src` that re-ingests - /// vault `vid` (via [`Daemon::publish_vault`]) whenever files under it change, - /// giving Dropbox-like live sync (new chunks + epoch++ manifest, pushed and - /// announced to replicas and other owner devices). - /// - /// Consumes a cloned `Arc` and holds only a [`std::sync::Weak`] to it, so the - /// returned [`VaultWatcher`] never keeps the daemon alive — a caller can still - /// `Arc::try_unwrap` + [`Daemon::shutdown`]. Drop the [`VaultWatcher`] to stop - /// watching; that halts fs events and cancels the re-ingest task. - /// - /// ponytail: re-ingests the *whole* vault on any change (matches the existing - /// one-shot `publish_vault`); a large-vault deployment would want incremental, - /// per-file re-chunking driven off the event paths. + /// §11: start a debounced filesystem watcher over `src` that re-ingests vault `vid` (via + /// [`Daemon::publish_vault`]) whenever files change, giving Dropbox-like live sync. Holds + /// only a [`std::sync::Weak`] to the daemon, so the [`VaultWatcher`] never keeps it alive; + /// drop it to stop watching. pub fn watch_vault(self: Arc, vid: [u8; 32], src: PathBuf) -> Result { use notify::{event::EventKind, recommended_watcher, RecursiveMode, Watcher}; - // Unbounded but each item is zero-sized: an event storm costs bytes, and - // the debounce loop collapses the whole backlog into a single re-ingest. + // Unbounded but each item is zero-sized; the debounce loop collapses the backlog. let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>(); let mut watcher = recommended_watcher(move |res: notify::Result| { if let Ok(ev) = res { - // Skip pure access/open events: only content-affecting changes - // (create/modify/remove/rename) should trigger a re-ingest. + // Skip pure access/open events; only content changes trigger a re-ingest. if !matches!(ev.kind, EventKind::Access(_)) { let _ = tx.send(()); } @@ -2612,9 +2279,8 @@ impl Daemon { Err(_) => break, // quiet period elapsed } } - // Re-ingest once, sequentially — no unbounded fan-out. Upgrade the - // Weak only for the duration of the publish so shutdown can still - // reclaim the sole Arc. + // Re-ingest once, sequentially. Upgrade the Weak only for the publish so + // shutdown can still reclaim the sole Arc. let Some(daemon) = weak.upgrade() else { break; // daemon gone }; @@ -2696,13 +2362,11 @@ impl Daemon { out_root: &Path, ) -> Result> { // ---- anti-entropy pull over the control stream ---- - // Drain the whole stream into buffers first; the verification pass below - // runs synchronously so we never hold the doc lock across an `.await`. + // Drain the whole stream first; the verification pass runs synchronously so we never + // hold the doc lock across an `.await`. let conn = self.ep.connect(doc_peer.clone(), ALPN).await?; let (mut send, mut recv) = conn.open_bi().await?; - // Present our own card so the peer can authorize this pull (W5). The peer - // serves documents only if our card's user is itself or a friend and the - // card delegates our (TLS-authenticated) node id. + // Present our own card so the peer can authorize this pull (W5). let own_card = { let s = self.shared.read().expect("shared lock"); s.cards.first().cloned().context("no own card")? @@ -2711,10 +2375,8 @@ impl Daemon { let mut recv_cards: Vec = Vec::new(); let mut recv_announces: Vec = Vec::new(); - // Option B (§4): reconstruction targets come from announces alone; per-chunk - // keys are re-derived from the manifest `pt_hash`, so no FileGrant is pulled - // here. A friend peer may still forward its own self-grants (disclosure-only); - // they are ignored by this sync path. + // Option B (§4): reconstruction targets come from announces alone; per-chunk keys are + // re-derived from the manifest `pt_hash`, so no FileGrant is pulled here. while let Some((ty, body)) = read_frame_raw(&mut recv).await? { match ty { ContactCard::TYPE => recv_cards.push(ContactCard::from_map(body)?), @@ -2724,9 +2386,8 @@ impl Daemon { } send.finish()?; - // §9.3.4 liveness: a completed doc pull is a real live-reachability signal for the - // peer(s) we just synced with (unlike a cached address). The re-split status surface - // reads `peer_last_seen` to show "who is online now". + // §9.3.4 liveness: a completed doc pull is a real reachability signal (unlike a cached + // address); the re-split status surface reads `peer_last_seen` for "who is online now". { let seen = unix_now(); let mut s = self.shared.write().expect("shared lock"); @@ -2739,9 +2400,8 @@ impl Daemon { let self_user = self.user_key.verifying_key().to_bytes(); let (targets, newer_cards) = { let mut docs = self.docs.lock().expect("docs lock"); - // Admit cards with their own version-rollback rule; a stale/duplicate - // card is ignored, not fatal. Collect the ones that were genuinely - // newer so the friend address book can be refreshed (W2). + // Admit cards with their version-rollback rule (a stale/duplicate card is ignored); + // collect the genuinely newer ones to refresh the friend address book (W2). let mut newer_cards = Vec::new(); for card in &recv_cards { if matches!(docs.offer_card(card), Ok(true)) { @@ -2752,10 +2412,8 @@ impl Daemon { (targets, newer_cards) }; - // W2: refresh `s.friends` with rollback-guarded newer cards so a friend - // that publishes a card dropping a device actually revokes it. The update - // is monotonic on the friend's own stored version, so a first-seen older - // card (accepted by the empty DocStore) cannot roll the address book back. + // W2: refresh `s.friends` with rollback-guarded newer cards so a friend dropping a + // device actually revokes it. Monotonic on the friend's stored version. if !newer_cards.is_empty() { let mut updated: Vec = Vec::new(); { @@ -2769,30 +2427,26 @@ impl Daemon { } } } - // §6: refresh addressing hints (relay + direct addrs) from the newer - // cards, so a friend that moves or changes relay stays reachable. + // §6: refresh addressing hints from the newer cards so a friend that moves stays + // reachable. let hints = self.ep.hints(); for card in &updated { learn_card_hints(&hints, card).await; } } - // §6: persist the DocStore rollback high-water marks (and any friend-card - // refresh) so a replayed old card is still rejected after a reboot. A whole-state - // snapshot; the docs + friends updates above are already applied in RAM. + // §6: persist the DocStore rollback high-water marks (+ friend-card refresh) so a + // replayed old card is still rejected after a reboot. self.persist_snapshot(); - // W8/§7.4 a: when the blobs live on a different peer (a replica), first - // authenticate to that peer's control stream so it can classify us as a - // delegated device of the vault owner. Without it the replica's fetch gate - // has no identity for our node id and refuses every replica-held chunk. When - // blob and doc peer are the same node the doc pull above already did this. + // W8/§7.4 a: when the blobs live on a different peer (a replica), authenticate to its + // control stream first so it can classify us as a delegated device of the owner; else + // its fetch gate refuses every replica-held chunk. Same-node doc pull already did this. if blob_peer.id != doc_peer.id { self.authenticate_to(&blob_peer).await?; } // ---- per-vault: fetch, open, reconstruct ---- - // W3: one poisoned/unfetchable vault must not abort the others; collect - // the error and move on. + // W3: one poisoned/unfetchable vault must not abort the others. let mut out = Vec::new(); for (vid, ann) in &targets { match self.reconstruct_one(&blob_peer, vid, ann, out_root).await { @@ -2815,13 +2469,9 @@ impl Daemon { ) -> Result { let vkeys = VaultKeys::derive(&*self.k_root, *vid); - // W8: fetch into a throwaway store, NOT `self.blobs`, which the router serves - // over `iroh_blobs::ALPN`. Fetching the owner's/replica's ciphertext into the - // served store would re-serve it ungated from this device (the residual - // `authorize_fetch` `true` covers any hash absent from the owned/replica maps), - // voiding the replica fetch gate on any device that reconstructs. We only need - // the bytes to open the manifest and write plaintext to disk. Mirrors the PoR - // probe's `scratch` store. + // W8: fetch into a throwaway store, NOT `self.blobs` (which the router serves over + // `iroh_blobs::ALPN`): fetching the ciphertext into the served store would re-serve it + // ungated, voiding the replica fetch gate. We only need the bytes to open the manifest. let scratch = IrohBlobStore::new(); // Manifest envelope by digest. let bconn = self.ep.connect(blob_peer.clone(), iroh_blobs::ALPN).await?; @@ -2838,25 +2488,21 @@ impl Daemon { "manifest epoch != announce epoch" ); - // Option B (§4.2): we hold `K_root` for this vault (the envelope opened with - // our derived `K_manifest`), so re-derive every per-chunk key from the - // manifest's `pt_hash` + `K_content`. No FileGrant, no owner liveness; the - // BLAKE3(plaintext)==pt_hash check happens inside `reconstruct`. + // Option B (§4.2): we hold `K_root`, so re-derive every per-chunk key from the + // manifest's `pt_hash` + `K_content`; the BLAKE3(plaintext)==pt_hash check is inside + // `reconstruct`. let incoming_keys = chunk_keys_from_manifest(&incoming, &*vkeys.k_content); - // §11 / MAJOR 5: take the vid's publish lock BEFORE reading our local - // baseline and hold it through the reconstruct + commit below, so a - // concurrent `publish_vault` (e.g. the watcher firing on this same tree) - // cannot read-prev/commit in between - that would lose an update or ingest a - // half-written merged tree. The whole apply is serialized on the vid. + // §11: take the vid's publish lock BEFORE reading our local baseline and hold it + // through reconstruct + commit, so a concurrent `publish_vault` cannot read-prev/commit + // in between (which would lose an update or ingest a half-written merged tree). let publish_lock = self.publish_lock(*vid); let _apply = publish_lock.lock().await; - // §11: if THIS device already published (or synced) a manifest for this - // vault, MERGE the two rather than blindly reconstructing the received one - - // otherwise the later reconstruct silently clobbers an earlier edit and - // drops its tombstones (W1/W12, the silent-data-loss hole). A first sync (no - // local manifest for this vid) reconstructs as-is and records a baseline. + // §11: if THIS device already has a manifest for this vault, MERGE rather than + // blindly reconstructing the received one (else the reconstruct clobbers an earlier + // edit and drops its tombstones - the silent-data-loss hole). First sync reconstructs + // as-is and records a baseline. let local = { let s = self.shared.read().expect("shared lock"); s.vault_blobs.get(vid).map(|vb| { @@ -2874,9 +2520,8 @@ impl Daemon { // Concurrent-owner sync: reconcile per §11. Some((local_manifest, local_keys)) => { let merged = merge_manifests(&local_manifest, &incoming); - // Only re-publish when the merge produced state we did not already - // hold; a converged (no-op) merge must not bump the epoch, or the two - // devices would ping-pong announces forever. + // Only re-publish when the merge produced new state; a converged (no-op) merge + // must not bump the epoch or the two devices ping-pong announces forever. let changed = merged.files != local_manifest.files || !vv_equal(&merged.vv, &local_manifest.vv); let epoch = if changed { @@ -2903,9 +2548,8 @@ impl Daemon { } }; - // Materialize every referenced chunk: chunks we already own come from our - // served store, the peer's (conflict-loser or dominant-remote) come from the - // blob peer. On a first sync all of them come from the peer. + // Materialize every referenced chunk: chunks we own come from our served store, the + // peer's from the blob peer. On a first sync all come from the peer. let mut store = MemoryStore::new(); for f in &manifest.files { if f.deleted { @@ -2926,12 +2570,10 @@ impl Daemon { } } - // §11 (BLOCKER 1): reconstruct into the vault's ONE authoritative working - // directory - the same tree that is published and watched - so the merged - // set (winner at path, losers at sync-conflict names, tombstone deletions) - // lands where the watcher will re-observe it, keeping "absent => tombstone" - // sound. If this device has no working dir yet (a pure receiver's first - // sync), fall back to `out_root/` and adopt it as the working dir. + // §11: reconstruct into the vault's ONE authoritative working directory (the tree + // published + watched) so the merged set lands where the watcher re-observes it, + // keeping "absent => tombstone" sound. A pure receiver's first sync (no working dir) + // falls back to `out_root/` and adopts it. let out_dir = { let mut s = self.shared.write().expect("shared lock"); let adopting = !s.working_dirs.contains_key(vid); @@ -2940,10 +2582,9 @@ impl Daemon { .entry(*vid) .or_insert_with(|| out_root.join(hex32(vid))) .clone(); - // §11 (audit #4): `working_dirs` is a persisted category. When we adopt a new - // working dir for a pure receiver's first sync, commit it now - a later - // `publish_merged`/`persist_sync_baseline` may not run (a no-op re-sync), and - // a lost working dir strands this vault's future edits. + // `working_dirs` is persisted: commit an adopted working dir now, since a later + // `publish_merged`/`persist_sync_baseline` may not run (a no-op re-sync) and a + // lost working dir strands this vault's future edits. if adopting { self.persist_locked(&s); } @@ -2959,16 +2600,13 @@ impl Daemon { } if republish { - // Re-publish the merged state so the other device(s) converge on it - // (eventual consistency, §7.3): this device now serves both versions and - // announces the reconciled manifest at a bumped epoch. Skipped on a no-op - // merge (see `changed`) to guarantee termination. + // Re-publish the merged state so the other device(s) converge (§7.3). Skipped on + // a no-op merge (see `changed`) to guarantee termination. self.publish_merged(vid, &manifest, &keys, &store).await?; } else if first_sync { - // MAJOR 4: record the reconstructed manifest + keys + epoch as this - // device's baseline WITHOUT announcing/serving, so a later local edit - // (or watcher re-ingest) diffs against the incoming state instead of - // re-minting every file as new and spawning a spurious conflict copy. + // Record the reconstructed manifest + keys + epoch as this device's baseline + // WITHOUT announcing, so a later local edit diffs against the incoming state + // instead of re-minting every file as new. self.persist_sync_baseline(vid, &manifest, &keys, ann.digest); } @@ -2979,12 +2617,9 @@ impl Daemon { }) } - /// MAJOR 4: persist a first-sync reconstruction as this device's published - /// baseline for `vid` (manifest + per-chunk secrets + epoch) WITHOUT touching - /// announces/grants/owned_chunks - the device is a silent receiver until it - /// makes a local change. `publish_vault`'s prev-diff then works against the - /// incoming state, so an unchanged re-ingest is a no-op and a real local edit - /// bumps cleanly instead of re-minting every file as new. + /// Persist a first-sync reconstruction as this device's baseline for `vid` (manifest + + /// per-chunk secrets + epoch) WITHOUT touching announces/grants/owned_chunks, so + /// `publish_vault`'s prev-diff works against the incoming state instead of re-minting. fn persist_sync_baseline( &self, vid: &[u8; 32], @@ -3012,19 +2647,17 @@ impl Daemon { manifest: manifest.clone(), }, ); - // An adopted sync baseline repairs a needs-refetch vault (§3.5). + // An adopted sync baseline repairs a needs-refetch vault. s.needs_refetch.remove(vid); - // MAJOR 4 (audit #4): epochs + vault_blobs are persisted categories (vault_keys is - // EPH, re-derived at load). Commit the baseline so a post-reboot local edit still - // diffs against the incoming state instead of re-minting every file as new. + // Commit the baseline (epochs + vault_blobs are persisted; vault_keys is EPH) so a + // post-reboot local edit still diffs against the incoming state. self.persist_locked(&s); } - /// §11: adopt an already-merged manifest as this device's new published - /// baseline for `vid` so the reconciliation propagates. Adds every referenced - /// chunk - including the peer's just fetched into `store` - to the served blob - /// store, seals + node-signs a fresh envelope, builds a matching grant, and - /// replaces this vault's announce/grant/blob-source at the bumped epoch. + /// §11: adopt an already-merged manifest as this device's new published baseline for + /// `vid` so the reconciliation propagates: add every referenced chunk to the served + /// store, seal + node-sign a fresh envelope, and replace this vault's + /// announce/grant/blob-source at the bumped epoch. async fn publish_merged( &self, vid: &[u8; 32], @@ -3036,8 +2669,8 @@ impl Daemon { let envelope = seal_manifest(manifest, &vkeys, &self.node_key)?; let digest = self.blobs.add(&envelope.to_bytes()).await?; - // Load every referenced chunk into the served store so this device can serve - // the reconciled manifest to its peers (both its own and the peer's copies). + // Load every referenced chunk into the served store so this device can serve the + // reconciled manifest to its peers. let mut seen = HashSet::new(); let mut chunk_ids = Vec::new(); for f in &manifest.files { @@ -3055,8 +2688,7 @@ impl Daemon { chunk_ids.push(*id); } } - // Durability barrier (§3.2.2): commit the merged blobs before the epoch - // commit below — same rule as `publish_vault`. + // Durability barrier: commit the merged blobs before the epoch commit (as publish_vault). self.blobs.sync().await?; let grant = self.build_file_grant(manifest, keys, *vid, manifest.epoch)?; @@ -3066,8 +2698,8 @@ impl Daemon { for id in &chunk_ids { s.owned_chunks.insert(*id, *vid); } - // F1 (design §3.5): gate the served manifest-envelope digest too (see - // `publish_vault`), so a default-deny gate still serves it to own devices. + // F1: gate the served manifest-envelope digest too, so a default-deny gate still + // serves it to own devices. s.owned_chunks.insert(digest, *vid); s.vault_keys.insert(*vid, keys.clone()); let replicas = replica_list(self.node_id(), s.members.get(vid)); @@ -3092,12 +2724,10 @@ impl Daemon { manifest: manifest.clone(), }, ); - // A merged republish repairs a needs-refetch vault too (§3.5). + // A merged republish repairs a needs-refetch vault too. s.needs_refetch.remove(vid); - // §11 (audit #4): this inserted persisted gate categories (epochs, owned_chunks - // incl. the envelope digest, announces, grants, vault_blobs). Commit them under - // the held write lock, or a reboot default-denies our own re-served blobs and - // rolls back our own-announce for this vault. + // Commit the persisted gate categories under the held lock, or a reboot default-denies + // our own re-served blobs and rolls back our own-announce for this vault. self.persist_locked(&s); Ok(()) } @@ -3128,10 +2758,8 @@ impl Daemon { .cloned() } - /// Test/diagnostic helper: perform a document pull against `peer` and return - /// the counts of `(cards, announces, grants)` frames the peer actually served. - /// A peer that refuses this dialer (W5) serves only its `Hello`, so all three - /// counts are zero; an authorized dialer sees the peer's document set. + /// Test helper: document-pull against `peer` and return the `(cards, announces, grants)` + /// frame counts served. A refused dialer (W5) gets only the `Hello`, so all zero. #[doc(hidden)] pub async fn pull_doc_counts(&self, peer: EndpointAddr) -> Result<(usize, usize, usize)> { let conn = self.ep.connect(peer, ALPN).await?; @@ -3154,10 +2782,9 @@ impl Daemon { Ok((cards, announces, grants)) } - /// Present our own card on `peer`'s `carapace/1` control stream so it can - /// classify our node id (W5/§7.4). We discard whatever documents it serves; the - /// side effect - the peer recording our blob-read authorization - is the point. - /// Used before fetching replica-held blobs from a peer that is not the doc peer. + /// Present our own card on `peer`'s control stream so it classifies our node id (W5/§7.4); + /// the side effect (the peer recording our blob-read authorization) is the point. Used + /// before fetching replica-held blobs from a peer that is not the doc peer. async fn authenticate_to(&self, peer: &EndpointAddr) -> Result<()> { let own_card = { let s = self.shared.read().expect("shared lock"); @@ -3166,8 +2793,7 @@ impl Daemon { let conn = self.ep.connect(peer.clone(), ALPN).await?; let (mut send, mut recv) = conn.open_bi().await?; write_msg(&mut send, &own_card).await?; - // Drain the peer's response (Hello + any served docs) so it processes our - // card fully before we open the blob stream. + // Drain the peer's response so it processes our card fully before we open the blob stream. while (read_frame_raw(&mut recv).await?).is_some() {} send.finish()?; Ok(()) @@ -3207,15 +2833,11 @@ impl Daemon { Ok(ticket) } - /// Drive the requester side of the §9.2 handshake against the ticket issuer at - /// `peer`: send a `FriendRequest`, countersign the friendship core the acceptor - /// chooses, and on a valid `FriendAccept` persist the dual-signed `Friendship` - /// plus the acceptor's card. Returns the completed friendship. - /// - /// `grant_bytes` is the per-friend replica-storage limit THIS node agrees to - /// grant the new friend (enforced later by `serve_replica_store` when they - /// place a replica on us); `None` uses `DEFAULT_QUOTA_BYTES` (1 GiB). This is - /// local policy, independent of the friend's advertised `offers.storage_bytes`. + /// Drive the requester side of the §9.2 handshake against the ticket issuer at `peer`: + /// send a `FriendRequest`, countersign the acceptor's friendship core, and on a valid + /// `FriendAccept` persist the dual-signed `Friendship` + the acceptor's card. + /// `grant_bytes` is the per-friend replica-storage limit this node grants (local policy); + /// `None` uses `DEFAULT_QUOTA_BYTES`. pub async fn befriend( &self, peer: EndpointAddr, @@ -3233,14 +2855,12 @@ impl Daemon { }; let req = build_friend_request(&self.node_key, own_card, ticket.token); - // §6: inject the ticket's addressing hints (issuer node id + direct addrs - // + self-hosted relay URLs) so we can dial the issuer by node id even when - // `peer` carries no direct address (the NAT-blind, relay-only path). + // §6: inject the ticket's addressing hints so we can dial the issuer by node id even + // when `peer` carries no direct address (the NAT-blind, relay-only path). learn_ticket_hints(&self.ep.hints(), ticket).await; - // Record the acceptor's dialable address so the maintenance loop can later - // re-reach it (PoR probes, attestation challenges) without a discovery - // round-trip (§6). Captured before `peer` is consumed by the dial below. + // Record the acceptor's dialable address so the maintenance loop can re-reach it + // without a discovery round-trip. Captured before `peer` is consumed by the dial. let peer_addr = peer.clone(); let peer_node = *peer.id.as_bytes(); let conn = self.ep.connect(peer, ALPN).await?; @@ -3263,9 +2883,8 @@ impl Daemon { let friendship = verify_friend_accept(&accept, now, &self_user) .map_err(|e| anyhow::anyhow!("friend accept invalid: {e}"))?; - // S3: the accept must actually come from the ticket's issuer, and the - // resulting friendship must bind that same party - defense in depth against - // a redirected/substituted acceptor. + // S3: the accept must come from the ticket's issuer and the friendship must bind that + // same party - defense in depth against a redirected/substituted acceptor. ensure!( accept_binds_ticket(&accept, &ticket.user, &friendship), "friend accept does not match the ticket issuer" @@ -3274,16 +2893,14 @@ impl Daemon { let mut s = self.shared.write().expect("shared lock"); s.friendships.insert(acceptor_user, friendship.clone()); s.friends.insert(acceptor_user, accept.card.clone()); - // Agree the per-friend replica-storage grant at add-friend time. s.friend_grants .insert(acceptor_user, grant_bytes.unwrap_or(DEFAULT_QUOTA_BYTES)); s.peer_addrs.insert(peer_node, peer_addr); - // Commit the friendship before returning it to the caller (the acceptor - // already committed its side in serve_friend_accept). peer_addrs is EPH. + // Commit the friendship before returning it (the acceptor already committed its + // side in serve_friend_accept). peer_addrs is EPH. self.persist_locked(&s); } - // §6: learn the acceptor's card hints (relay + direct addrs) for later - // dials (anti-entropy, PoR probes) by node id. + // §6: learn the acceptor's card hints for later dials by node id. learn_card_hints(&self.ep.hints(), &accept.card).await; drop(conn); Ok(friendship) @@ -3292,23 +2909,13 @@ impl Daemon { // ---- unfriend + trustee re-split (§9.3, W5) ------------------------ /// Terminate a friendship unilaterally and run the §9.3 flow. Synchronously tears down - /// local state (drop them from the friend graph, delete everything we hold OF them, - /// queue their replicas of our vaults for immediate re-placement, and record a PENDING - /// re-split for every recovery set they were a trustee of); then, best-effort over the - /// control stream, signs + sends a [`FriendshipEnd`] (effective for us on send) and one - /// [`DeleteRequest`] per placement we made on them (§9.3 step 1); re-places the vaults - /// they replicated for us treating them as lost NOW (§9.3 step 2, no 24 h grace); and - /// drives any already-open re-split forward. Idempotent-ish: unfriending a non-friend - /// returns `was_friend = false` and does nothing. - /// - /// §9.3.4: a trustee re-split is NOT auto-started here - it is recorded pending (with a - /// suggested new set) so the client can prompt the user, who starts it via - /// [`Daemon::start_pending_resplit`]. `resplit_rsids` names those pending sets. - /// - /// The catastrophic-key-loss invariant holds throughout: a re-split's OLD shares are - /// only ever destroyed through [`Resplit::share_destroy`], which refuses until the - /// NEW set attests `>= M + slack`. Outstanding `FileGrant`s to the ex-friend remain - /// disclosed-forever (§7.4); this flow does not and cannot revoke them. + /// local state (drop from the friend graph, delete everything we hold OF them, queue + /// their replicas of our vaults for re-placement, record a PENDING re-split per recovery + /// set they were a trustee of); then best-effort signs + sends a [`FriendshipEnd`] and one + /// [`DeleteRequest`] per placement, re-places the vaults they replicated for us treating + /// them as lost NOW, and drives any already-open re-split. Unfriending a non-friend returns + /// `was_friend = false`. A trustee re-split is recorded pending, not auto-started (§9.3.4). + /// OLD shares are only ever destroyed through [`Resplit::share_destroy`] (`>= M + slack`). pub async fn unfriend(&self, ex_user: [u8; 32]) -> Result { let now = unix_now(); let teardown = { @@ -3317,15 +2924,13 @@ impl Daemon { return Ok(UnfriendOutcome::default()); } let td = teardown_unfriended_state(&mut s, ex_user); - // §3.2.2-3: commit the whole teardown (friend graph drop, deletes, pending - // re-split marks) BEFORE signing/sending the FriendshipEnd + DeleteRequests. + // Commit the whole teardown BEFORE signing/sending the FriendshipEnd + DeleteRequests. self.persist_locked(&s); td }; - // §9.3 step 1: sign a FriendshipEnd (effective for us on send) and push it plus - // one DeleteRequest per placement to the ex-friend's devices (best-effort - an - // offline ex-friend is carried the end via its next card version instead). + // §9.3 step 1: sign a FriendshipEnd and push it plus one DeleteRequest per placement + // to the ex-friend's devices (best-effort). let end = end_friendship(&self.node_key, ex_user, now); let reqs = build_delete_requests(&self.node_key, &teardown.placement); for addr in &teardown.ex_addrs { @@ -3335,9 +2940,8 @@ impl Daemon { } } - // §9.3 step 2: re-place the vaults they replicated for us, treating them as lost - // now (no grace). Drive any ALREADY-OPEN re-split forward; a NEW trustee re-split is - // left pending for the user to start (§9.3.4 prompt), not auto-started here. + // §9.3 step 2: re-place the vaults they replicated for us (no grace). Drive any + // ALREADY-OPEN re-split; a NEW trustee re-split is left pending for the user to start. self.replace_unfriended_replicas().await; self.advance_resplits().await; @@ -3347,10 +2951,9 @@ impl Daemon { }) } - /// Dial `addr` and send one signed [`DeleteRequest`], reading back the (optional) - /// signed [`DeleteAck`] (§9.3 step 1). The ack is verified and returned for the - /// caller's bookkeeping - it is NOT proof of deletion (nothing is), so a missing or - /// bad ack is not an error. Bounded by the connect timeout. + /// Dial `addr` and send one signed [`DeleteRequest`], reading back the optional verified + /// [`DeleteAck`] (§9.3 step 1). The ack is not proof of deletion, so a missing/bad ack is + /// not an error. Bounded by the connect timeout. async fn send_delete_request( &self, addr: &EndpointAddr, @@ -3392,13 +2995,9 @@ impl Daemon { ack } - /// §9.3 step 2: re-replicate every OWNED vault an unfriended peer held a replica of - /// onto other accepting friends, treating the ex-friend as confirmed lost NOW - /// ([`Health::Unfriended`], no 24 h grace). Drains the `unfriended_nodes` queue. - /// Independent of any DeleteAck: repair fires regardless of whether the ex-friend - /// complied. Candidates are the remaining friends' known addresses (an unfriended - /// node is already gone from `peer_addrs` and the friend graph, so it can never be - /// re-selected). + /// §9.3 step 2: re-replicate every OWNED vault an unfriended peer held a replica of onto + /// other accepting friends, treating the ex-friend as lost NOW ([`Health::Unfriended`], no + /// grace). Drains `unfriended_nodes`; fires regardless of any DeleteAck. async fn replace_unfriended_replicas(&self) { let (nodes, vids, candidates) = { let s = self.shared.read().expect("shared lock"); @@ -3422,22 +3021,18 @@ impl Daemon { for vid in vids { let _ = self.repair_vault(vid, &healths, &candidates).await; } - // Clear the queue: a vault still short after this pass is retried by the PoR / - // reachability repair path, not spun on here. + // Clear the queue: a vault still short after this pass is retried by the PoR/ + // reachability repair path. let mut s = self.shared.write().expect("shared lock"); s.unfriended_nodes.retain(|n| !nodes.contains(n)); } - /// Drive every OPEN re-split forward one step (§9.3 step 3). Called from the initiating - /// `unfriend` and from the maintenance loop. It does NOT stand up PENDING re-splits: - /// §9.3.4 requires the user to be prompted first, so a pending re-split becomes open - /// only through [`Daemon::start_pending_resplit`]. Driving delivers new grants, - /// challenges the new set, and - ONLY once [`Resplit`] reports the new set live - sends - /// the old set its destroy instruction. + /// Drive every OPEN re-split forward one step (§9.3 step 3), from `unfriend` and the + /// maintenance loop. Does NOT stand up PENDING re-splits (§9.3.4 needs a user prompt). + /// Delivers new grants, challenges the new set, and - ONLY once [`Resplit`] reports it + /// live - sends the old set its destroy instruction. async fn advance_resplits(&self) { - // Serialize the drive: two concurrent runs of the same open re-split would - // double-deliver / double-challenge. Held across the network drive; only ever - // serializes the rare unfriend-triggered path. + // Serialize the drive so two concurrent runs of the same re-split can't double-deliver. let _guard = self.resplit_lock.lock().await; let open: Vec = { self.shared @@ -3453,17 +3048,11 @@ impl Daemon { } } - /// §9.3.4 W5: start a re-split the user was prompted about (`POST - /// /api/recovery/{rsid}/resplit-start`). Stands up the pending re-split for `old_rsid` - /// into an open one (using `k_root`), removes it from the pending queue, and drives it - /// one step (delivering the new set's grants). `new_trustees`, when given, overrides the - /// suggested new set (each a user pubkey of an established friend or an old trustee); - /// otherwise the suggested set (old honest set) is used. - /// - /// Idempotent-ish: if the re-split is already open it just drives it. Errors if no - /// pending re-split is recorded for `old_rsid`, or if the chosen set cannot form a - /// working set. The destroy-gate invariant is untouched - this only stands up the NEW - /// set; old shares are still destroyed only through [`Resplit::share_destroy`]. + /// §9.3.4: start a re-split the user was prompted about. Stands up the pending re-split + /// for `old_rsid` into an open one (using `k_root`), removes it from the pending queue, and + /// drives it one step. `new_trustees` overrides the suggested new set; otherwise the old + /// honest set is used. If already open it just drives it. The destroy-gate invariant is + /// untouched - old shares are still destroyed only through [`Resplit::share_destroy`]. pub async fn start_pending_resplit( &self, old_rsid: u64, @@ -3511,18 +3100,15 @@ impl Daemon { .with_context(|| format!("re-split for recovery set {old_rsid} vanished")) } - /// §9.3.1 W5: drain the receive-side outbound `DeleteRequest` queue. Each entry is a - /// batch queued by [`ControlHandler::serve_friendship_end`] (our own DeleteRequests for - /// everything WE placed on an ex-friend that unfriended US). Sends each to the - /// ex-friend's last-known addresses, best-effort. A DeleteRequest never triggers a - /// FriendshipEnd, so this cannot loop back into another unfriend. + /// §9.3.1: drain the receive-side outbound `DeleteRequest` queue (batches queued by + /// [`ControlHandler::serve_friendship_end`] for everything WE placed on an ex-friend that + /// unfriended US). Best-effort; a DeleteRequest never triggers a FriendshipEnd. async fn drive_pending_delete_sends(&self) { let batches: Vec<(Vec, Placement)> = { let mut s = self.shared.write().expect("shared lock"); let taken = std::mem::take(&mut s.pending_delete_sends); if !taken.is_empty() { - // Commit the drained queue so a crash mid-send does not resurrect the - // batch and double-send DeleteRequests (harmless but avoidable). + // Commit the drained queue so a crash mid-send does not resurrect the batch. self.persist_locked(&s); } taken @@ -3586,8 +3172,8 @@ impl Daemon { if let Some(o) = s.resplits.get_mut(&old_rsid) { o.delivered.extend(newly_delivered); } - // §3.2.3: commit the delivered-grant set before the next drive treats - // those trustees as done (a crash must not re-deliver a stale grant). + // Commit the delivered-grant set before the next drive treats those + // trustees as done (a crash must not re-deliver a stale grant). self.persist_locked(&s); } @@ -3665,8 +3251,8 @@ impl Daemon { let _ = o.rs.record_destroy_ack(ack); } } - // §3.2.3: commit the recorded destroy-acks before treating the old - // shares as gone (a crash must not re-issue a destroy already acked). + // Commit the recorded destroy-acks before treating the old shares as gone + // (a crash must not re-issue a destroy already acked). self.persist_locked(&s); } // §9.3 step 4: once every old honest trustee has destroy-acked the @@ -3700,10 +3286,8 @@ impl Daemon { .collect() } - /// §9.3.4 W5: the PENDING re-split prompts - re-splits an unfriend detected but the - /// user has not yet started. One row per pending re-split, each with the suggested new - /// trustee set and its members' live reachability, so the GUI can render the prompt and - /// let the user start it via `POST /api/recovery/{rsid}/resplit-start`. + /// §9.3.4: the PENDING re-split prompts - one row per re-split an unfriend detected but + /// the user has not started, with the suggested new trustee set and its live reachability. pub fn pending_resplit_statuses(&self) -> Vec { let s = self.shared.read().expect("shared lock"); let now = unix_now(); @@ -3757,9 +3341,7 @@ impl Daemon { pub fn deny_replica_peer(&self, node: [u8; 32]) { let mut s = self.shared.write().expect("shared lock"); s.replica_deny.insert(node); - // `replica_deny` is a persisted S4 policy category: commit it so the deny - // survives a reboot (audit #5), else a denied peer could be re-placed after - // restart. + // Commit the S4 deny so it survives a reboot, else a denied peer could be re-placed. self.persist_locked(&s); } @@ -3782,10 +3364,9 @@ impl Daemon { .unwrap_or_default() } - /// Invite each friend in `peers` to store a replica of `vid`, targeting - /// invariant `r`. Each accepting peer is pushed the manifest envelope plus - /// every ciphertext chunk and recorded as a member; the announce is re-signed - /// to reflect the new set. Returns the node ids that accepted. + /// Invite each friend in `peers` to store a replica of `vid`, targeting invariant `r`. + /// Each accepting peer is pushed the envelope + every chunk, recorded as a member, and the + /// announce re-signed. Returns the node ids that accepted. pub async fn place_replicas( &self, vid: [u8; 32], @@ -3832,24 +3413,17 @@ impl Daemon { } s.replica_target.insert(vid, r); reannounce(&mut s, vid, self.node_id(), &self.node_key); - // Persist the recorded replica set (members, target, re-announce) so the - // placement + its fetch-gate membership survive restart. + // Persist the replica set so the placement + its fetch-gate membership survive restart. self.persist_locked(&s); } Ok(placed) } - /// Run the §10.1 repair loop for `vid`: drop members confirmed lost by the - /// injected `healths` (unfriended, or unreachable past the 24 h grace), then - /// re-replicate from `candidates` up to the invariant `r`, re-announcing the - /// new set. Returns `true` if the member set changed. - /// - /// ponytail: the re-announce reuses the content epoch rather than bumping it, - /// because `announce.epoch` is bound to the sealed manifest here (reconstruct - /// checks `manifest.epoch == announce.epoch`). A fresh puller therefore always - /// sees the current set; a peer that already cached the announce would not pick - /// up a set change until the next content epoch. Decouple the replica-set - /// version from the content epoch if in-place set propagation is required. + /// Run the §10.1 repair loop for `vid`: drop members confirmed lost by `healths` + /// (unfriended, or unreachable past the 24 h grace), then re-replicate from `candidates` + /// up to invariant `r`, re-announcing the new set. Returns `true` if the set changed. + /// The re-announce reuses the content epoch (announce.epoch is bound to the sealed + /// manifest), so a fresh puller sees the current set but a cached one lags until the next epoch. pub async fn repair_vault( &self, vid: [u8; 32], @@ -3906,9 +3480,8 @@ impl Daemon { return Ok(false); } { - // S7: merge rather than blindly overwrite, so a concurrent placement - // that added a member while we were pushing is not clobbered. Drop the - // members we confirmed lost, then union in the repaired set. + // S7: merge rather than overwrite, so a concurrent placement that added a member + // while we pushed is not clobbered. Drop the lost members, then union the repaired set. let mut s = self.shared.write().expect("shared lock"); let cur = s.members.entry(vid).or_default(); cur.retain(|m| { @@ -3980,11 +3553,9 @@ impl Daemon { "placement exceeds granted quota" ); - // Send the current owner-signed announce so the replica learns the set it - // joins and can gate later fetches on membership (§7.4 b, W8), and can serve it - // back to a recovering owner-device that lost every original device. Option B - // (§4): no FileGrant is pushed - the recovering device re-derives per-chunk keys - // from the manifest pt_hash, so own-device sync and recovery need no grant. + // Send the current owner-signed announce so the replica learns the set it joins, + // gates later fetches on membership (§7.4 b, W8), and can serve it back to a + // recovering owner-device. Option B (§4): no FileGrant is pushed. let announce = { let s = self.shared.read().expect("shared lock"); s.announces @@ -4007,9 +3578,8 @@ impl Daemon { "replica acked {acked} of {} blobs", blobs.len() ); - // §6: remember where this replica lives so the PoR loop can re-audit it by - // node id without a discovery round-trip. §9.3.4: a completed placement is also a - // live-reachability signal for this peer. + // §6: remember where this replica lives (PoR re-audit by node id) and mark it seen + // (§9.3.4 reachability signal). { let mut s = self.shared.write().expect("shared lock"); s.peer_addrs.insert(node, peer.clone()); @@ -4020,24 +3590,12 @@ impl Daemon { // ---- PoR retention audit loop (§10.1) ------------------------------ - /// Run one Proof-of-Retention audit round for `vid` over `members` - /// (`replica node id -> dialable address`), then repair on confirmed loss. - /// - /// For each member due at `now` (per the injected-clock [`AuditTracker`]) the - /// owner derives an unpredictable sample of chunks from `K_audit(vid)` (a key - /// only the owner holds), fetches exactly those chunks *from that replica* into - /// a throwaway store so the transfer genuinely comes off the peer, and BLAKE3- - /// verifies them against their ChunkIDs. A missing or wrong chunk fails the - /// round; [`DEFAULT_POR_FAIL_LIMIT`](carapace_replica::DEFAULT_POR_FAIL_LIMIT) - /// consecutive failures marks the replica lost, which is fed to - /// [`Daemon::repair_vault`] as [`Health::AuditLost`] (re-replicate onto a spare - /// from `candidates`, re-announce). Returns what happened this round. - /// - /// The caller ticks this (a `tokio::time::interval` in a real deployment, an - /// injected `now` in tests); the tracker's per-replica jittered schedule decides - /// which members are actually probed on any given tick (§10.1). No lock is held - /// across the network fetch: audits read a manifest/schedule snapshot, probe off - /// the lock, then record synchronously. + /// Run one Proof-of-Retention audit round for `vid` over `members` (`node id -> dialable + /// address`), then repair on confirmed loss. For each member due at `now` the owner + /// samples chunks from owner-only `K_audit(vid)`, fetches exactly those from that replica + /// into a throwaway store, and BLAKE3-verifies them; [`DEFAULT_POR_FAIL_LIMIT`] consecutive + /// failures marks it lost and feeds [`Daemon::repair_vault`] ([`Health::AuditLost`]). No + /// lock is held across the network fetch. pub async fn por_audit_round( &self, vid: [u8; 32], @@ -4051,19 +3609,16 @@ impl Daemon { let epoch = *s.epochs.get(&vid).context("vault has no epoch")?; (vb.manifest.clone(), epoch) }; - // K_audit(vid) = HKDF(K_vaultroot(vid), "por") - owner-only, so the sample - // set is unpredictable to the replica being probed. + // K_audit(vid) = HKDF(K_vaultroot(vid), "por") - owner-only, so the sample set is + // unpredictable to the probed replica. let vaultroot = kdf::k_vaultroot(&*self.k_root, &vid); let k_audit: [u8; 32] = *kdf::k_audit(&*vaultroot); let mut round = PorRound::default(); for (node, addr) in members { - // Read the round to issue + the wide flag, then IMMEDIATELY advance and - // persist the round counter (a "challenge issued" mark) BEFORE building or - // revealing the challenge on the wire (§10.1 / audit #6). Otherwise a crash - // after the reveal but before the result is recorded would leave the round - // counter at `r`, and the next boot would re-issue the identical - now - // observed, hence predictable - challenge to the same replica. + // Advance and persist the round counter (a "challenge issued" mark) BEFORE + // revealing the challenge on the wire, so a crash after the reveal cannot re-issue + // the identical - now observed, hence predictable - challenge on the next boot. let (r, wide) = { let mut s = self.shared.write().expect("shared lock"); if !s.por.due(*node, vid, now) { @@ -4080,12 +3635,9 @@ impl Daemon { } else { build_audit(&k_audit, vid, epoch, r, &manifest) }; - // C1: an unreachable replica (connect failed) is a transport failure, - // not a retention answer - it must never advance the loss streak, or a - // transiently-offline friend would be evicted without grace. Only a peer - // that actually answered is judged on content via `record_outcome`. The - // round counter was already advanced + persisted at issue time above, so - // neither branch bumps it again. + // C1: an unreachable replica is a transport failure, not a retention answer - it + // must never advance the loss streak (else a transiently-offline friend is evicted + // without grace). Only a peer that answered is judged on content. let action = match self.fetch_audit_samples(addr, &audit).await { None => { let mut s = self.shared.write().expect("shared lock"); @@ -4119,25 +3671,18 @@ impl Daemon { Ok(round) } - /// Probe `addr` for each sampled chunk of `audit`. Returns `Some(responses)` - /// (one `Option>` per sample: `Some` bytes if the chunk was served, - /// `None` if the connected peer did not produce it) when the replica answered, - /// or `None` when the replica could not be reached at all. - /// - /// C1: the connect-failure `None` is distinct from a per-sample `None`. An - /// unreachable peer is a transport failure and must not be scored as a retention - /// loss; only a peer that connected is judged on the content of its answers. - /// Fetches into a fresh empty store so a chunk the owner already holds is still - /// pulled from the replica; a per-sample timeout bounds a stalled/missing probe. + /// Probe `addr` for each sampled chunk of `audit`. `Some(responses)` (per-sample `Some` + /// bytes / `None` not served) when the replica answered, `None` when it could not be + /// reached at all. C1: the connect-failure `None` is distinct from a per-sample `None`. + /// Fetches into a fresh store so a chunk the owner holds is still pulled from the replica. async fn fetch_audit_samples( &self, addr: &EndpointAddr, audit: &Audit, ) -> Option>>> { let scratch = IrohBlobStore::new(); - // Unreachable replica: no content answer at all -> signal transport failure. - // The connect is time-bounded so a dead peer fails fast instead of hanging - // the round on the QUIC handshake timeout (C1). + // Time-bound the connect so a dead peer fails fast to the transport-failure path + // instead of hanging on the QUIC handshake timeout (C1). let conn = match tokio::time::timeout( POR_CONNECT_TIMEOUT, self.ep.connect(addr.clone(), iroh_blobs::ALPN), @@ -4163,17 +3708,9 @@ impl Daemon { // ---- share-health cadence (§10.2) ---------------------------------- - /// Register a recovery set this daemon owns, so [`Daemon::run_share_health_round`] - /// tracks its attested-live count and drift. `tracker` carries the set's `M`, - /// slack, lifetime issued-share count, and cadence (build it with - /// [`AttestTracker::new`] for defaults, or `with_params` to tune the round / - /// freshness intervals). - /// - /// NON-DURABLE TEST HELPER (audit #9): bypasses the write-through funnel, and - /// `share_sets` is a DERIVE category anyway (rebuilt from `granted` at load), so a - /// value set here does not survive a reboot. The production path populates - /// `share_sets` via `serve_grant`/`rebuild_share_sets`. Kept only for the - /// attestation-drift tests. + /// Register a recovery set this daemon owns, so [`Daemon::run_share_health_round`] tracks + /// its attested-live count and drift. NON-DURABLE TEST HELPER: bypasses the funnel, and + /// `share_sets` is DERIVE anyway; production populates it via `serve_grant`. #[doc(hidden)] pub fn register_recovery_set(&self, rsid: u64, tracker: AttestTracker) { self.shared @@ -4183,14 +3720,8 @@ impl Daemon { .insert(rsid, tracker); } - /// Store a share this daemon holds as a trustee for another owner, enabling it - /// to answer that owner's `ShareAttestChallenge`s and to run continuous local - /// CRC self-validation (§10.2). Keyed by the share's recovery-set id. - /// - /// NON-DURABLE TEST HELPER (audit #9): bypasses the write-through funnel, so the - /// stored share is NOT persisted and does not survive a reboot. The production - /// trustee path stores + commits held shares in `ControlHandler::serve_grant`. Kept - /// only for the attestation-drift tests. + /// Store a share this daemon holds as a trustee, keyed by recovery-set id. NON-DURABLE + /// TEST HELPER: bypasses the funnel; production stores + commits in `serve_grant`. #[doc(hidden)] pub fn store_share(&self, share: Share) { let rsid = u64::from(share.recovery_set_id); @@ -4211,16 +3742,10 @@ impl Daemon { .map(|(share, mon)| mon.poll(share, now)) } - /// Owner-side share-health round (§10.2). If a round is due at `now`, challenge - /// every `trustee` (`dialable address`) for the set `rsid` over the control - /// stream, fold each verified attestation into the set's attested-live count, - /// then return the drift decision: [`ShareAction::Healthy`], an - /// [`ShareAction::Extend`] recommendation when live has drifted below `M + slack` - /// with cap headroom, or [`ShareAction::ResplitLargerM`] at the §8.3 cap. When no - /// round is due this just re-reads the current decision without probing. - /// - /// This SURFACES the recommendation; issuing the actual extend / re-split stays - /// in [`carapace_recovery`]. No lock is held across the network round-trips. + /// Owner-side share-health round (§10.2). If a round is due at `now`, challenge every + /// `trustee` for `rsid`, fold each verified attestation into the attested-live count, and + /// return the drift decision (Healthy / Extend / ResplitLargerM); else re-read the current + /// decision without probing. Surfaces the recommendation only; no lock held across the wire. pub async fn run_share_health_round( &self, rsid: u64, @@ -4255,8 +3780,7 @@ impl Daemon { .share_sets .get_mut(&rsid) .context("unknown recovery set")?; - // Fold only attestations that verify against this challenge; a bad or - // mismatched one changes nothing (it simply is not counted live). + // Fold only attestations that verify against this challenge; a bad one is not counted. for att in &atts { let _ = t.record_attestation(att, &challenge, now); } @@ -4339,8 +3863,7 @@ impl Daemon { .iter() .filter_map(|n| resolve_peer(&peer_addrs, n).map(|a| (*n, a))) .collect(); - // Repair candidates: known peers that are not already members of this - // vault (repair itself re-checks friendship + deny-list per candidate). + // Repair candidates: known non-member peers (repair re-checks friendship + deny). let candidates: Vec = peer_addrs .iter() .filter(|(n, _)| !member_ids.contains(n)) @@ -4354,8 +3877,7 @@ impl Daemon { } } - // 2) Owner attestation rounds + drift surfacing over owned recovery sets - // (§10.2). The subject is this owner's user key. + // 2) Owner attestation rounds + drift surfacing over owned recovery sets (§10.2). let subject = self.user_id(); for (rsid, trustee_ids) in recovery_sets { let trustees: Vec = trustee_ids @@ -4378,24 +3900,19 @@ impl Daemon { } } - // 4) Owner grant ref-refresh (W3, §10.2/§7.3): re-issue trustees' grants with - // the latest announce refs whenever a vault epoch has advanced (or a prior - // delivery is still outstanding), so trustees hold current manifest pointers. + // 4) Owner grant ref-refresh (W3): re-issue trustees' grants with the latest announce + // refs whenever a vault epoch advanced (or a delivery is outstanding). report.refreshed_grants = self.refresh_grants_round().await; - // 5) §9.3 W5: re-place unfriended peers' replicas (treated as lost now) and drive - // every open trustee re-split one step - deliver the new set's grants, collect - // attestations, and (only once the new set is proven live) destroy the old - // shares. Begins any re-split an inbound `FriendshipEnd` queued but could not - // stand up itself (no `k_root` in the control handler). + // 5) §9.3: re-place unfriended peers' replicas (lost now) and drive every open + // trustee re-split one step. Begins any re-split an inbound `FriendshipEnd` queued + // but could not stand up itself (no `k_root` in the control handler). self.replace_unfriended_replicas().await; self.drive_pending_delete_sends().await; self.advance_resplits().await; - // 6) §6/W6 relay reachability lifecycle: probe the embedded relay's liveness - // and advertise-on-success / withdraw-on-loss, re-issuing our own card - // (version bumped) whenever the advertised relay URL changes. No-op when - // we run no relay. + // 6) §6/W6 relay reachability: probe the embedded relay and advertise-on-success / + // withdraw-on-loss, re-issuing our card when the URL changes. No-op with no relay. if self.relay.is_some() { let alive = self.probe_relay_alive().await; self.drive_relay_health(alive); @@ -4404,26 +3921,16 @@ impl Daemon { report } - /// Spawn the background maintenance loop (§10.1/§10.2) and return its handle. - /// - /// The loop wakes every `cfg.tick`, runs one [`Daemon::maintenance_round`] against - /// the wall clock, and self-gates each concern on its own cadence. It holds only a - /// [`Weak`] to the daemon (upgraded per round), so it never blocks shutdown; the - /// returned [`MaintenanceHandle`] tears it down on drop or - /// [`MaintenanceHandle::stop`]. Follows the same `Arc` + `Weak` pattern as - /// [`Daemon::watch_vault`]: the production entry point ([`carapace_api`]) already - /// holds an `Arc` and starts this once at boot. - /// - /// `cfg.por_interval` is stamped onto the audit schedule here (the loop owns the - /// PoR cadence), so a deployment or a bounded test tunes it through the config. + /// Spawn the background maintenance loop (§10.1/§10.2) and return its handle. The loop + /// wakes every `cfg.tick`, runs one [`Daemon::maintenance_round`], and self-gates each + /// concern on its cadence. It holds only a [`Weak`] to the daemon so it never blocks + /// shutdown. `cfg.por_interval` is stamped onto the audit schedule here. pub fn run_maintenance(self: Arc, cfg: MaintenanceConfig) -> MaintenanceHandle { { - // The loop owns the PoR audit cadence: stamp it at start, before any audit - // runs, so no accumulated per-replica schedule is discarded mid-flight. - // `restamp` updates ONLY the cadence scalars and KEEPS the round/fail/schedule - // maps that `load_all` restored - a fresh `AuditTracker::new` here would wipe - // the per-(replica,vid) round counters and reopen the §10.1 PoR replay vector - // (audit #1). + // Stamp the PoR cadence before any audit runs. `restamp` updates ONLY the cadence + // scalars and KEEPS the round/fail/schedule maps `load_all` restored - a fresh + // `AuditTracker::new` here would wipe the round counters and reopen the PoR replay + // vector. let mut s = self.shared.write().expect("shared lock"); s.por.restamp( cfg.por_interval.as_secs(), @@ -4445,8 +3952,7 @@ impl Daemon { break; }; let _ = daemon.maintenance_round(unix_now()).await; - // Drop the strong ref between ticks so a concurrent shutdown can - // reclaim the daemon; the loop ends once the last Arc is gone. + // Drop the strong ref between ticks so a concurrent shutdown can reclaim. drop(daemon); } }); @@ -4483,19 +3989,14 @@ impl Daemon { .collect() } - /// The newest `VaultAnnounce` this daemon has learned for `vid` (its signer node - /// and epoch), from the rollback-guarded document store — including third-party - /// announces picked up via anti-entropy store-and-forward (§6/W7). `None` if none - /// is known. + /// The newest `VaultAnnounce` learned for `vid` (signer node + epoch), from the + /// rollback-guarded document store including store-and-forward announces. `None` if unknown. pub fn known_announce(&self, vid: &[u8; 32]) -> Option<([u8; 32], u64)> { let d = self.docs.lock().expect("docs lock"); d.announce_for_vid(vid).map(|a| (a.by, a.epoch)) } - /// Test-only: this daemon's own current announce digest (the manifest-envelope - /// ChunkID) for `vid`, or `None`. A `publish_merged` inserts this digest into - /// `owned_chunks`; used by the audit #4 reboot regression to probe the fetch gate on - /// a merge-unique chunk. + /// Test-only: this daemon's own current announce digest for `vid`, or `None`. #[doc(hidden)] pub fn own_announce_digest(&self, vid: &[u8; 32]) -> Option<[u8; 32]> { self.shared @@ -4508,16 +4009,13 @@ impl Daemon { } /// Test-only: whether the served durable blob store holds `id` right now. - /// Lets the reboot/kill durability tests assert a blob is genuinely present - /// in the FsStore instead of inferring it from higher-level behavior. #[doc(hidden)] pub async fn blob_present(&self, id: [u8; 32]) -> bool { self.blobs.has(id).await.unwrap_or(false) } - /// Test-only: the published blob-source ids for `vid` — the manifest-envelope - /// digest plus every unique ChunkID — or `None` if the vault has no - /// (re-derived) blob source on this daemon. + /// Test-only: the published blob-source ids for `vid` (envelope digest + every unique + /// ChunkID), or `None`. #[doc(hidden)] pub fn vault_blob_ids(&self, vid: &[u8; 32]) -> Option<([u8; 32], Vec<[u8; 32]>)> { let s = self.shared.read().expect("shared lock"); @@ -4526,8 +4024,7 @@ impl Daemon { .map(|vb| (vb.digest, vb.chunk_ids.clone())) } - /// Test-only: the retained needs-refetch blob source for `vid` (§3.5) — the - /// `(digest, chunk_ids)` kept when the startup re-derive failed — or `None`. + /// Test-only: the retained needs-refetch blob source for `vid` (§3.5), or `None`. #[doc(hidden)] pub fn needs_refetch_ids(&self, vid: &[u8; 32]) -> Option<([u8; 32], Vec<[u8; 32]>)> { self.shared @@ -4538,9 +4035,8 @@ impl Daemon { .cloned() } - /// Test-only: record `node` as a replica member of `vid` without pushing it the - /// blobs, modeling a replica that accepted a placement but has since lost its - /// stored copy. The PoR loop then detects the loss on audit. + /// Test-only: record `node` as a replica member of `vid` without pushing the blobs, + /// modeling a replica that accepted but lost its copy (PoR detects the loss on audit). #[doc(hidden)] pub fn inject_lost_member_for_test(&self, vid: [u8; 32], node: [u8; 32]) { let mut s = self.shared.write().expect("shared lock"); @@ -4550,20 +4046,11 @@ impl Daemon { } } - /// Graceful shutdown: stop accepting, cleanly shut down the served blob - /// store, then close the endpoint. The daemon serves nothing afterwards. - /// - /// `Router::shutdown` awaits every protocol handler's shutdown — including - /// `BlobsProtocol::shutdown`, which shuts down the FsStore cleanly - /// (committing its open write batch and persisting ephemeral state; the - /// iroh-blobs fs store otherwise loses writes from the last ~1 s on exit). - /// Closing only the endpoint, as this once did, dropped those writes even - /// on a "graceful" exit. - /// - /// Takes `&self` (idempotently: a second call is a no-op) so the binary's - /// signal path can always flush, even while the API server or a watcher - /// still holds `Arc` clones — a flush must never depend on being - /// the last reference. + /// Graceful shutdown: stop accepting, cleanly shut down the served blob store, then close + /// the endpoint. `Router::shutdown` awaits `BlobsProtocol::shutdown`, which commits the + /// FsStore's open write batch (closing only the endpoint would drop the last ~1 s of + /// writes). Takes `&self` idempotently so the signal path can flush even while other + /// `Arc` clones live. pub async fn shutdown(&self) { if let Err(e) = self.router.shutdown().await { eprintln!("carapaced: router shutdown: {e}"); @@ -4591,11 +4078,9 @@ impl Daemon { let mut grant_id = [0u8; 16]; getrandom::getrandom(&mut grant_id).map_err(|e| anyhow::anyhow!("grant id: {e}"))?; - // Prefix the encapsulated key onto the HPKE ciphertext (Sealed carries no - // separate encap field); split it back off on open. S7: the serialized - // body holds every chunk key in the clear, so scrub it after sealing. S2: - // bind the seal to this exact grant (vid, epoch, grant_id), matching - // `open_file_grant`. + // Prefix the encapsulated key onto the HPKE ciphertext (Sealed has no encap field), + // split back off on open. S7: the serialized body holds chunk keys in the clear, so + // scrub it after sealing. S2: bind the seal to this exact grant (vid, epoch, grant_id). let aad = disclose::grant_aad(&vid, epoch, &grant_id); let body_bytes = Zeroizing::new(body.to_bytes()); let (enc, ct) = seal::seal(&disclose_pub, INFO_DISCLOSE, &aad, &body_bytes) @@ -4621,18 +4106,11 @@ impl Daemon { // ---- selective disclosure to an audience (§7.4) -------------------- - /// Disclose exactly `paths` from owned vault `vid` to `audience` (a list of - /// established-friend user pubkeys — "reveal to all my friends" simply names the - /// current friend list at issuance). Assembles a [`GrantBody`] from the retained - /// per-chunk secrets, HPKE-seals it to each friend's `enc_pub`, signs a - /// [`FileGrant`], and records the owner-side disclosure table so the blob gate - /// will serve exactly those chunks to exactly that audience. Returns the grant to - /// deliver directly to each member (§7.4). - /// - /// NORMATIVE (§7.4): the returned grant is a **snapshot** of the vault's current - /// epoch and is **irrevocable** for the content it discloses — a later edit makes - /// new chunk keys (hence a new grant), and "revoke" means only "issue no future - /// version." This API never implies recall of already-disclosed content. + /// Disclose exactly `paths` from owned vault `vid` to `audience` (established-friend user + /// pubkeys). Assembles a [`GrantBody`] from the retained per-chunk secrets, HPKE-seals it + /// to each friend, signs a [`FileGrant`], and records the disclosure table so the blob gate + /// serves exactly those chunks to exactly that audience. The grant is a snapshot of the + /// current epoch and irrevocable for what it discloses (§7.4). pub fn disclose_files( &self, vid: [u8; 32], @@ -4695,11 +4173,9 @@ impl Daemon { grant .verify() .map_err(|e| anyhow::anyhow!("grant signature invalid: {e}"))?; - // W1: `grant.verify()` only proves self-consistency (signed by whatever - // `grant.by` claims). Authenticate the discloser too: `grant.by` must be a - // device our own user or an established friend currently delegates, so - // disclosed content carries verifiable provenance and an unknown party - // cannot push us a grant to reconstruct. Mirrors C1 on the sync path. + // W1: `grant.verify()` only proves self-consistency. Authenticate the discloser too: + // `grant.by` must be a device our user or an established friend currently delegates, + // so an unknown party cannot push us a grant to reconstruct. Mirrors C1 on the sync path. let now = unix_now(); { let s = self.shared.read().expect("shared lock"); @@ -4715,15 +4191,12 @@ impl Daemon { let body = disclose::open_grant(grant, my_user, &disclose_priv) .map_err(|e| anyhow::anyhow!("open grant: {e}"))?; - // Authenticate on the owner's control stream first, so the owner's blob gate - // binds our node id to our (friend) identity before we open a raw blob - // connection and fetch (§7.4 / D3). + // Authenticate on the owner's control stream first so its blob gate binds our node id + // to our friend identity before we open a raw blob connection (§7.4/D3). self.present_card(&owner).await?; - // W8: fetch into a throwaway store, NOT `self.blobs`, which the router serves. - // Otherwise a friend that fetched disclosed files would re-serve the owner's - // ciphertext ungated from its own node, defeating disclosure revocation. We - // only need the bytes to write the granted plaintext to disk. + // W8: fetch into a throwaway store, NOT `self.blobs` (which the router serves), else a + // friend that fetched disclosed files would re-serve the ciphertext ungated. let scratch = IrohBlobStore::new(); let bconn = self.ep.connect(owner, iroh_blobs::ALPN).await?; let mut chunks: HashMap<[u8; 32], Vec> = HashMap::new(); @@ -4738,9 +4211,8 @@ impl Daemon { .map_err(|e| anyhow::anyhow!("reconstruct disclosed files: {e}")) } - /// Present our own card on `peer`'s control stream and drain the reply. Completes - /// the W5 authentication handshake so `peer` records our node id in its blob-read - /// allow-set before we open a raw blob connection (used by `fetch_disclosed`). + /// Present our own card on `peer`'s control stream and drain the reply (W5), so `peer` + /// records our node id in its blob-read allow-set before we open a raw blob connection. async fn present_card(&self, peer: &EndpointAddr) -> Result<()> { let conn = self.ep.connect(peer.clone(), ALPN).await?; let (mut send, mut recv) = conn.open_bi().await?; @@ -4754,9 +4226,8 @@ impl Daemon { Ok(()) } - /// Test/diagnostic: open `grant` as this user and return the ChunkIDs it - /// discloses (empty if this user is not in the grant's audience or the open - /// fails). Lets a test learn a granted ChunkID to probe the fetch gate with. + /// Test: open `grant` as this user and return the ChunkIDs it discloses (empty if not in + /// the audience or the open fails). #[doc(hidden)] pub fn granted_chunk_ids(&self, grant: &FileGrant) -> Result> { let (disclose_priv, _pub) = self.disclose_keypair(); @@ -4765,10 +4236,8 @@ impl Daemon { Ok(disclose::granted_chunk_ids(&body).into_iter().collect()) } - /// Test-only: attempt a raw single-blob fetch of `chunk_id` from `peer` over the - /// blobs ALPN WITHOUT first authenticating on the control stream — modeling a - /// non-audience party (e.g. a leaked-grant holder) that already knows a ChunkID. - /// The §7.4/D3 gate must refuse it. + /// Test-only: raw single-blob fetch of `chunk_id` from `peer` WITHOUT authenticating on + /// the control stream, modeling a non-audience party that knows a ChunkID. The gate must refuse. #[doc(hidden)] pub async fn try_fetch_chunk(&self, peer: EndpointAddr, chunk_id: [u8; 32]) -> Result> { let conn = self.ep.connect(peer, iroh_blobs::ALPN).await?; @@ -4819,10 +4288,8 @@ impl Daemon { } } - /// This node's own advertised relay URL as a string, iff it *currently* - /// advertises the embedded relay (§6/W6): `None` when it runs no relay or when - /// the relay is down/withdrawn. Surfaced on the status + ticket API so the - /// operator sees exactly what friends will use to reach this node right now. + /// This node's currently-advertised relay URL (§6/W6), or `None` when it runs no relay or + /// the relay is down/withdrawn. pub fn advertised_relay_url(&self) -> Option { self.shared .read() @@ -4832,11 +4299,8 @@ impl Daemon { .clone() } - /// W6/§6: the last time a friend was observed reaching us *through* our - /// advertised relay (peer-dialback), in unix seconds, or `None` if never. This - /// is external-reachability evidence surfaced for the operator; see - /// [`Daemon::drive_relay_health`] for why it confirms rather than gates - /// advertising. + /// W6/§6: the last time a friend was observed reaching us through our advertised relay + /// (peer-dialback), unix seconds, or `None`. Confirms rather than gates advertising. pub fn relay_verified_at(&self) -> Option { self.shared .read() @@ -4845,19 +4309,9 @@ impl Daemon { .verified_at } - /// W4: number of distinct networks in this node's usable relay set (§6/§14) - - /// its own advertised relay plus every relay URL in an established friend's - /// newest card, deduplicated by host. §6 requires warning the user when this - /// drops below 2, since a single relay network is both a single point of - /// failure for reachability and a single metadata choke point. - /// - /// Only a *currently-advertised* own relay counts (W6): once the relay is - /// withdrawn on a health loss it stops contributing to diversity, exactly as a - /// friend would see it. - /// - /// ponytail: "distinct network" == distinct URL host (DNS name or IP literal), - /// lowercased. Upgrade to IP-subnet/ASN grouping if two friends behind the - /// same host must count as one network more precisely. + /// W4: number of distinct networks in this node's usable relay set (own advertised relay + + /// every friend-card relay URL, deduped by host). §6 warns the user below 2. Only a + /// currently-advertised own relay counts; "distinct network" == distinct lowercased host. pub fn relay_network_count(&self) -> usize { let mut urls: Vec = Vec::new(); let s = self.shared.read().expect("shared lock"); @@ -4880,9 +4334,8 @@ impl Daemon { self.relay_network_count() < 2 } - /// W6/§6: probe whether our embedded relay's TCP listener is alive and - /// accepting. `false` when we run no relay. The result drives the - /// advertise/withdraw lifecycle via [`Daemon::drive_relay_health`]. + /// W6/§6: probe whether our embedded relay's TCP listener is alive. `false` when we run no + /// relay. Drives the advertise/withdraw lifecycle via [`Daemon::drive_relay_health`]. async fn probe_relay_alive(&self) -> bool { match &self.relay { Some(r) => r.is_alive().await, @@ -4890,21 +4343,15 @@ impl Daemon { } } - /// W6/§6: the WAN relay URL to advertise to friends, or `None` if we run no - /// relay. Precedence: a configured relay host (the operator's stable DDNS/WAN - /// name) > the NAT port-mapper's mapped external address > the relay's local - /// (loopback/bound) URL. The last is only WAN-reachable on a public bind or for - /// same-host use; when a home node has no host override and no mapping yet, the - /// maintenance loop re-advertises with the mapped address once it resolves. + /// W6/§6: the WAN relay URL to advertise, or `None` if we run no relay. Precedence: + /// configured relay host > NAT-mapped external address > the relay's local URL (only + /// WAN-safe for an explicit loopback bind). fn advertised_url_for(&self) -> Option { let relay = self.relay.as_ref()?; - // Pick a candidate WAN URL by precedence. The local_url fallback is only - // WAN-safe for an EXPLICIT loopback bind (same-host / test): with the - // default 0.0.0.0 home-relay bind, local_url folds a 127.0.0.1 (or a LAN - // 192.168/10.x) address into the signed card sent to friends, advertising - // an unreachable relay and inflating the diversity count. For a - // 0.0.0.0/unspecified or private-LAN bind we stay withdrawn until a - // globally-routable address exists (a relay host or a routable mapping). + // The local_url fallback is WAN-safe only for an explicit loopback bind: with the + // default 0.0.0.0 bind it would fold a 127.0.0.1/LAN address into the signed card, + // advertising an unreachable relay. For a 0.0.0.0/private-LAN bind stay withdrawn until + // a globally-routable address exists. let candidate = if let Some(host) = &self.relay_host { format!("http://{}:{}", host, relay.http_addr().port()) } else if let Some(ext) = relay.external_addr() { @@ -4914,9 +4361,8 @@ impl Daemon { } else { return None; }; - // Only fold a URL iroh can actually parse as a RelayUrl into the card; a - // malformed relay_host would otherwise emit a signed card with a garbage - // relay_url. Treat an unparseable candidate as "no advertised relay". + // Only fold a URL iroh can parse as a RelayUrl into the card; an unparseable candidate + // (e.g. a malformed relay_host) is treated as "no advertised relay". if candidate.parse::().is_ok() { Some(candidate) } else { @@ -4924,41 +4370,18 @@ impl Daemon { } } - /// W6/§6: reconcile the embedded relay's advertised state with a liveness - /// observation, re-issuing this node's own card whenever the advertised relay - /// URL changes. - /// - /// When `alive` the desired advertised URL is [`Daemon::advertised_url_for`]; - /// when not, a withdraw is applied only after - /// [`RELAY_PROBE_FAILURE_THRESHOLD`] consecutive failed probes (hysteresis, so - /// a transient 2 s connect timeout under load does not flap a healthy relay); - /// a single success resets the streak and re-advertises. If the desired URL - /// differs from what the card currently - /// carries, the own card is re-issued with the new `relay_url` and its version - /// BUMPED (§6 rollback rule: a re-issue MUST advance the monotonic per-signer - /// version so peers accept it over the one they hold). The new card propagates - /// through the existing anti-entropy doc path (`serve_docs` re-serves - /// `shared.cards`, and the receiver's `DocStore` accepts the higher version). - /// - /// Advertising is gated on liveness, not on peer-dialback: gating the *initial* - /// advertise on dialback would deadlock (friends can only reach us via the relay - /// once they have learned it from our card), and dialback silence cannot be - /// distinguished from "no friend has dialed lately," so it must not withdraw a - /// live relay. Dialback (`relay_health.verified_at`) is therefore recorded and - /// surfaced as external-reachability confirmation, not used as a withdraw - /// trigger. Full active withdraw-on-unreachability needs a cooperating external - /// prober, which is out of scope (see docs/spec-errata.md, W6). + /// W6/§6: reconcile the embedded relay's advertised state with a liveness observation, + /// re-issuing this node's card (version BUMPED) whenever the advertised URL changes. When + /// `alive` the desired URL is [`Daemon::advertised_url_for`]; a withdraw applies only after + /// [`RELAY_PROBE_FAILURE_THRESHOLD`] consecutive failed probes (hysteresis). Advertising is + /// gated on liveness, not peer-dialback: gating the initial advertise on dialback would + /// deadlock, and dialback silence must not withdraw a live relay. #[doc(hidden)] pub fn drive_relay_health(&self, alive: bool) { let mut s = self.shared.write().expect("shared lock"); - // W6 hysteresis: a single failed probe does not withdraw. A success - // resets the failure streak and (re-)advertises immediately; a failure - // increments the streak and only withdraws once it reaches the - // threshold. Below the threshold the desired URL is left equal to the - // current advertisement, so the tentative failure is a no-op (no - // re-issue, no version churn). `advertised_url_for` reads only - // `self.relay`/`self.relay_host`, never the `shared` lock, so calling it - // here while holding the write guard is safe. + // Hysteresis: a success resets the streak and re-advertises; a failure only withdraws + // at the threshold, else leaves the desired URL equal to the current one (a no-op). + // `advertised_url_for` reads only `self.relay`/`self.relay_host`, never `shared`. let want = if alive { s.relay_health.consecutive_failures = 0; self.advertised_url_for() @@ -4989,9 +4412,8 @@ impl Daemon { *s.cards.first_mut().expect("own card present") = card; } - /// Wait until the endpoint has registered with at least one relay, so it is - /// reachable via relay fallback. Never completes with no relays configured; - /// guard with a timeout. + /// Wait until the endpoint has registered with at least one relay. Never completes with no + /// relays configured; guard with a timeout. pub async fn wait_online(&self) { self.ep.online().await; } @@ -5004,9 +4426,8 @@ impl Daemon { } // ---- address-string wrappers (control-API friendly) ---------------- - // These let the loopback control API drive the network paths with a node id - // (hex) plus dialable socket-address strings, so the API crate never has to - // depend on iroh's `EndpointAddr` directly. + // Let the loopback control API drive network paths with a node id + address strings, so + // the API crate never depends on iroh's `EndpointAddr` directly. /// [`Daemon::befriend`] against a peer named by node id + dialable addresses. pub async fn befriend_at( @@ -5077,7 +4498,7 @@ impl Daemon { let mut s = self.shared.write().expect("shared lock"); s.split_states.insert(rsid, RecoverySet { scope, state }); // Persist the SEALed split-state so `recovery_extend` can extend the same - // polynomial after a restart (design §3.3). + // polynomial after a restart. self.persist_locked(&s); } Ok((jsons, warnings)) @@ -5106,30 +4527,20 @@ impl Daemon { extend_split(&mut set.state, &secret, count, allow_over_cap) .map_err(|e| anyhow::anyhow!("recovery extend failed: {e:?}"))? }; - // `split_states` is a SEAL category and `extend_split` advanced its issued-x - // counter. Persist BEFORE returning the new shares (mirroring `recovery_split`), - // so a crash cannot rewind the counter and re-issue a byte-identical share at the - // same x to a different trustee - which breaks M-of-N distinct-point accounting - // and the §9.3 stranding invariant (audit #2). + // `extend_split` advanced the SEALed split-state's issued-x counter. Persist BEFORE + // returning the shares, so a crash cannot rewind the counter and re-issue a + // byte-identical share at the same x to a different trustee (breaks M-of-N accounting). self.persist_locked(&s); Ok((shares.iter().map(share_to_json).collect(), warnings)) } - /// Split a recovery secret `M`-of-`N` (N = `trustees.len()`) and mint + deliver one - /// signed [`ShareGrant`] per trustee over the `carapace/1` control stream (§8, W3). - /// Each grant wraps that trustee's `chela.share` JSON, the co-trustee roster (every - /// OTHER trustee's user + node + relay, from its established-friend card), the - /// owner's `recovery_delay` abort window (§8.5, default 72 h), and the latest - /// [`AnnounceRef`]s for this owner's published vaults - so a quorum can locate the - /// current manifest + a live replica at ceremony time without the owner present. - /// - /// Records the extendable split-state, an owner-side share-health tracker (§10.2), - /// and the grant set (for the maintenance refresh + status view), all under `rsid` - /// (overwriting any prior record - this is also the re-split path). Every trustee - /// MUST be an established friend whose card names a node; that is where the roster - /// identity and the delivery address come from. A trustee that is unreachable / - /// declines is recorded as undelivered and retried by the refresh round; it does - /// not abort the split (the words are already committed to the polynomial). + /// Split a recovery secret `M`-of-`N` and mint + deliver one signed [`ShareGrant`] per + /// trustee (§8, W3). Each grant wraps that trustee's share JSON, the co-trustee roster, + /// the owner's `recovery_delay` abort window, and the latest [`AnnounceRef`]s, so a quorum + /// can locate the current manifest + a live replica at ceremony time without the owner. + /// Records the extendable split-state, share-health tracker, and grant set under `rsid` + /// (overwriting any prior - the re-split path). Every trustee MUST be an established friend + /// whose card names a node; an unreachable one is recorded undelivered and retried. pub async fn recovery_split_grant( &self, rsid: u64, @@ -5147,9 +4558,8 @@ impl Daemon { let subject = self.user_id(); // Resolve each trustee's roster identity (user + primary node + relay) from its - // established-friend card, plus a dialable address for delivery. A trustee that - // is not a friend, or whose card names no node, fails loudly - we cannot build - // a roster entry or deliver to it. + // established-friend card, plus a dialable address. A non-friend or node-less card + // fails loudly - we cannot build a roster entry or deliver to it. let resolved = { let s = self.shared.read().expect("shared lock"); let mut out: Vec<(CoTrustee, Option)> = @@ -5187,8 +4597,8 @@ impl Daemon { resolved.len() ); - // The latest announce refs over this owner's published vaults - the pointers a - // recovering quorum follows to the current manifest + a live replica (§7.3). + // The latest announce refs - the pointers a recovering quorum follows to the current + // manifest + a live replica (§7.3). let refs = { let s = self.shared.read().expect("shared lock"); current_announce_refs(&s) @@ -5237,8 +4647,7 @@ impl Daemon { }); } - // Record owner-side: the extendable split state, the share-health tracker - // (§10.2 attestation cadence), and the grant set (refresh + status). + // Record owner-side: extendable split state, share-health tracker, and grant set. { let mut s = self.shared.write().expect("shared lock"); s.split_states.insert(rsid, RecoverySet { scope, state }); @@ -5253,21 +4662,16 @@ impl Daemon { refs, }, ); - // Persist the owner-side split record (SEALed split-state + granted shares) - // before returning; share_sets is rebuilt from `granted` on reload. + // Persist the owner-side split record (SEALed) before returning; share_sets is + // rebuilt from `granted` on reload. self.persist_locked(&s); } Ok(report) } - /// Dial `peer`'s control stream and send a `ShareGrant` (§8, W3). Returns `true` - /// iff the trustee acknowledged storing it (a verified + delegated grant). A - /// trustee that is unreachable, declines (bad delegation), or answers with no ack - /// frame yields `false` - the caller records it undelivered and the refresh round - /// retries. The dial is bounded so an offline trustee fails fast. - /// - /// Public so an owner (or a conformance test) can push a single grant directly; the - /// trustee independently re-verifies signature + delegation on receipt. + /// Dial `peer`'s control stream and send a `ShareGrant` (§8, W3). Returns `true` iff the + /// trustee acked storing it; unreachable/declined yields `false` (recorded undelivered, + /// retried by the refresh round). Bounded dial. The trustee re-verifies sig + delegation. pub async fn deliver_grant(&self, peer: &EndpointAddr, grant: &ShareGrant) -> Result { let conn = tokio::time::timeout(POR_CONNECT_TIMEOUT, self.ep.connect(peer.clone(), ALPN)) .await @@ -5275,21 +4679,18 @@ impl Daemon { .context("grant delivery dial failed")?; let (mut send, mut recv) = conn.open_bi().await?; write_msg(&mut send, grant).await?; - // The trustee acks with a single u64 (== 1) on success, or finishes the stream - // with no bytes (decline). A short read is therefore a decline, not an error. + // The trustee acks with u64 == 1 on success, or finishes with no bytes (decline); a + // short read is a decline, not an error. let acked = matches!(read_u64(&mut recv).await, Ok(1)); let _ = send.finish(); Ok(acked) } - /// Owner-side refresh round (§10.2 attestation cycle, §7.3): for each recovery set - /// this owner minted grants for, if the current announce refs over its published - /// vaults differ from what the trustees last received (a new epoch published), - /// re-mint each trustee's grant with the fresh refs and re-deliver it, so trustees - /// always hold current manifest pointers. A trustee that was previously - /// undelivered is retried every round regardless. No lock is held across a dial. - /// - /// Returns the rsids whose grants were refreshed this round (for the report/tests). + /// Owner-side refresh round (§10.2/§7.3): for each recovery set this owner minted grants + /// for, if the current announce refs differ from what trustees last received, re-mint and + /// re-deliver each grant so trustees hold current manifest pointers. A previously + /// undelivered trustee is retried every round. No lock held across a dial. Returns the + /// refreshed rsids. pub async fn refresh_grants_round(&self) -> Vec { // Snapshot the work set + current refs under a read lock, act off-lock. let jobs: Vec = { @@ -5354,8 +4755,7 @@ impl Daemon { delivered, }); } - // Commit the refreshed refs + delivery flags for this set. §9.3.4: a trustee - // that acked a delivery answered us, so it is online now (liveness signal). + // Commit the refreshed refs + delivery flags. §9.3.4: an acking trustee is online now. let seen = unix_now(); let mut s = self.shared.write().expect("shared lock"); for r in &new_records { @@ -5371,8 +4771,7 @@ impl Daemon { false }; if updated { - // Persist the refreshed grant set (SEALed) so the advanced announce refs - // + delivery flags survive a restart mid-refresh. + // Persist the refreshed grant set (SEALed) so it survives a restart mid-refresh. self.persist_locked(&s); } refreshed.push(rsid); @@ -5396,24 +4795,17 @@ impl Daemon { .collect() } - /// W15 (§8, §10.2): render the printable paper cards for one owned recovery set - - /// one page per share, recoverable from the words alone, offline, with no Carapace - /// software (the §10.2 backstop that never goes offline). Pulls the shares this owner - /// already retains for `rsid` from the same `granted` map the maintenance loop - /// re-signs from; no regeneration, no re-split, no change to the issued count. Errors - /// if no such recovery set is owned. Per-rsid when the owner holds several sets. - /// - /// SECURITY: the returned HTML embeds the share WORDS (a bearer secret). It is never - /// logged or persisted here - it is handed straight back over the loopback API to the - /// owner's own authenticated GUI, the same trust boundary as every recovery endpoint. + /// W15 (§8, §10.2): render the printable paper cards for owned recovery set `rsid` - one + /// page per share, recoverable from the words alone offline. Pulls the retained shares from + /// `granted`; no regeneration or re-split. SECURITY: the HTML embeds share WORDS (a bearer + /// secret), never logged or persisted here - handed straight back over the loopback API. pub fn paper_cards(&self, rsid: u64) -> Result { let s = self.shared.read().expect("shared lock"); render_paper_cards(&s, rsid) } - /// Trustee-side: the full [`ShareGrant`] this daemon holds for `subject` (the owner - /// whose secret was split), if any (§8, W3). Carries the co-trustee roster, the - /// recovery delay, and the latest announce refs - what a ceremony needs. + /// Trustee-side: the full [`ShareGrant`] this daemon holds for `subject`, if any (§8, W3): + /// co-trustee roster, recovery delay, and latest announce refs - what a ceremony needs. pub fn held_grant(&self, subject: &[u8; 32]) -> Option { self.shared .read() @@ -5507,18 +4899,16 @@ impl Daemon { takeover: false, }, ); - // §8.5 (audit #7): ceremonies + ceremony_alarms are persisted. Commit our own - // opened ceremony so the E4 delay anchor and alarm survive a reboot mid-flight - // (the sponsor's own card qualifies the alarm for durability, C1). + // Commit our own opened ceremony so the E4 delay anchor and alarm survive a + // reboot (the sponsor's own card qualifies the alarm for durability). self.persist_locked(&s); } Ok((open, ceremony_id)) } - /// Fan a signed `RecoveryOpen` out to every co-trustee named in our grant for the - /// subject, plus the subject's own devices and our friends we can reach (§8.5 step - /// 2): the anti-silent-takeover broadcast. Best-effort - an unreachable target is - /// skipped (the open is a re-sendable signed alarm). Returns the number reached. + /// Fan a signed `RecoveryOpen` out to every co-trustee in our grant, the subject's own + /// devices, and reachable friends (§8.5 step 2 anti-silent-takeover broadcast). + /// Best-effort. Returns the number reached. pub async fn ceremony_fanout(&self, open: &RecoveryOpen) -> Result { let targets = { let s = self.shared.read().expect("shared lock"); @@ -5533,11 +4923,9 @@ impl Daemon { Ok(reached) } - /// Relay a `RecoveryOpen` to one peer's control stream (§8.5 step 2 fan-out). The - /// receiver records the alarm and, if it is a trustee, tracks the ceremony. Any - /// sealed-share reply is discarded here (fan-out precedes the release gate); the - /// claimant collects shares via [`ClaimantDevice::recover`]. Bounded by the connect - /// timeout so an offline target fails fast. + /// Relay a `RecoveryOpen` to one peer's control stream (§8.5 step 2 fan-out). Any + /// sealed-share reply is discarded here (fan-out precedes the release gate); the claimant + /// collects shares via [`ClaimantDevice::recover`]. Bounded by the connect timeout. pub async fn deliver_recovery_open( &self, addr: &EndpointAddr, @@ -5567,12 +4955,9 @@ impl Daemon { Ok(reply) } - /// Approve a tracked ceremony as this trustee (§8.5 step 4). Call this ONLY after - /// verifying the claimant out of band (video, in person). Records our signed - /// approval locally - so we will release our share once the gate opens - and returns - /// the `CeremonyApprove` to broadcast to the co-trustees (via - /// [`Daemon::ceremony_broadcast_approve`] or [`Daemon::send_ceremony_approve`]). - /// Errors if we do not track the ceremony (we never received the open). + /// Approve a tracked ceremony as this trustee (§8.5 step 4), ONLY after verifying the + /// claimant out of band. Records our signed approval locally and returns the + /// `CeremonyApprove` to broadcast. Errors if we do not track the ceremony. pub fn ceremony_approve(&self, ceremony_id: [u8; 16], now: u64) -> Result { let mut ap = CeremonyApprove { ceremony_id, @@ -5592,8 +4977,7 @@ impl Daemon { .map_err(|e| anyhow::anyhow!("ceremony approve rejected: {e:?}"))?; tc.approved = true; } - // §8.5 (audit #7): persist our recorded approval so the release gate's approval - // count survives a reboot mid-ceremony. + // Persist our approval so the release gate's count survives a reboot mid-ceremony. self.persist_locked(&s); Ok(ap) } @@ -5627,13 +5011,10 @@ impl Daemon { Ok(reached) } - /// Sign a `CeremonyAbort` for `ceremony_id` with THIS device's user key (§8.5 step - /// 3) and apply it locally. The abort is *authoritative* only if this device's user - /// key IS the ceremony's subject: every trustee checks `abort.by == subject`, so an - /// abort signed by a non-subject is inert (and `state.abort` rejects it as - /// `NotSubject`, leaving the ceremony untouched). Broadcast the returned message to - /// the trustees with [`Daemon::ceremony_broadcast_abort`] / - /// [`Daemon::send_ceremony_abort`]. + /// Sign a `CeremonyAbort` for `ceremony_id` with THIS device's user key (§8.5 step 3) and + /// apply it locally. Authoritative only if this device's user key IS the subject: every + /// trustee checks `abort.by == subject`, so a non-subject abort is inert. Broadcast the + /// returned message to the trustees. pub fn ceremony_abort(&self, ceremony_id: [u8; 16]) -> Result { let mut ab = CeremonyAbort { ceremony_id, @@ -5653,8 +5034,7 @@ impl Daemon { al.takeover = true; } } - // §8.5 abort durability (audit #7): persist the takeover/abort flags so a reboot - // cannot un-wedge an in-flight recovery this device aborted. + // Persist the takeover/abort flags so a reboot cannot un-wedge a recovery we aborted. self.persist_locked(&s); Ok(ab) } @@ -5682,10 +5062,8 @@ impl Daemon { Ok(reached) } - /// Dial `addr` on the control stream, send one signed message, drain the (optional) - /// reply, and finish. The general one-shot control-frame primitive behind the - /// approve/abort send paths; also used by tests to exercise inbound handlers with a - /// crafted frame. Bounded by the connect timeout. + /// Dial `addr`, send one signed message, drain the optional reply, and finish. The + /// one-shot control-frame primitive behind the approve/abort sends. Bounded dial. #[doc(hidden)] pub async fn send_control_frame( &self, @@ -5703,11 +5081,9 @@ impl Daemon { Ok(()) } - /// The recovery-ceremony status surface for `/api/recovery/ceremony` (§8.5 step - /// 2/6): one row per ceremony this device has seen (an alarm, and - if it is a - /// trustee - the tracked phase and approval count), so a client can raise the - /// anti-silent-takeover signal and show progress. Evaluated against the (injectable) - /// ceremony clock. + /// The recovery-ceremony status surface (§8.5 step 2/6): one row per ceremony this device + /// has seen (the alarm, plus tracked phase + approval count if a trustee). Evaluated + /// against the injectable ceremony clock. pub fn ceremony_statuses(&self) -> Vec { let s = self.shared.read().expect("shared lock"); let now = ceremony_now(&s); @@ -5753,19 +5129,16 @@ impl Daemon { .collect() } - /// Test-only: pin the ceremony delay clock to `now` (0 restores real time). Lets a - /// bounded test advance past the 72 h abort delay instantly instead of sleeping; - /// only the ceremony `first_seen`/release paths read it. + /// Test-only: pin the ceremony delay clock to `now` (0 restores real time), so a test can + /// advance past the 72 h abort delay without sleeping. #[doc(hidden)] pub fn set_test_clock(&self, now: u64) { self.shared.write().expect("shared lock").test_now = now; } } -/// Build a `GrantBody` disclosing exactly the manifest files named in `paths`, -/// pulling each chunk's retained secret from `keys`. Errors if any requested path -/// is absent from (or deleted in) the vault, so a partial/typo'd disclosure fails -/// loudly rather than silently under-disclosing. +/// Build a `GrantBody` disclosing exactly the manifest files named in `paths`. Errors if any +/// path is absent/deleted, so a typo'd disclosure fails loudly rather than under-disclosing. fn select_grant_body(manifest: &Manifest, keys: &ChunkKeys, paths: &[&str]) -> Result { let want: HashSet<&str> = paths.iter().copied().collect(); let mut files = Vec::with_capacity(want.len()); @@ -5801,28 +5174,15 @@ fn select_grant_body(manifest: &Manifest, keys: &ChunkKeys, paths: &[&str]) -> R Ok(GrantBody { files }) } -/// §7.4 / D3 blob-read gate: whether `node` (the dialer's authenticated iroh node -/// id) may fetch `chunk_id` from this daemon. -/// -/// For a chunk of a vault we OWN, release it only to (a) our own delegated devices, -/// (b) this vault's replica-set members (repair), or (c) a friend authenticated on -/// our control stream whose identity an owner-signed grant names in the audience -/// covering that chunk. A dialer that never authenticated, or an authenticated -/// friend outside the audience, is refused — a leaked grant document alone (from a -/// non-audience party) authorizes nothing. -/// -/// "A chunk of a vault we OWN" is any ChunkID in `owned_chunks` — every chunk ever -/// published for an owned vault, retained across epoch bumps — so a superseded -/// chunk keeps its owner gate (W2), not just the current epoch's. -/// -/// A chunk we hold AS A REPLICA for another owner (any hash in `replica_chunks`, -/// envelope or ciphertext) is gated by §7.4 (a)/(b) (W8): released only to that -/// vault owner's delegated devices (proved by the card the dialer presents on our -/// control stream) or a current replica-set member from the owner's announce. Any -/// other blob (a truly foreign chunk) stays on the inherited residual. +/// §7.4/D3 blob-read gate: whether `node` (the dialer's authenticated node id) may fetch +/// `chunk_id`. An OWNED chunk (any ChunkID in `owned_chunks`, retained across epoch bumps) +/// goes only to (a) our own devices, (b) this vault's replica-set members, or (c) a friend +/// in a grant's audience covering the chunk. A REPLICA-held chunk (`replica_chunks`) goes +/// only to that owner's devices or a current replica-set member (W8). Everything else is +/// default-denied. fn authorize_fetch(s: &Shared, node: &[u8; 32], chunk_id: &[u8; 32]) -> bool { - // Consult the RETAINED owned-chunk set, not the current-epoch `vault_blobs`, so - // a superseded chunk stays gated instead of regressing to the residual (W2). + // Consult the RETAINED owned-chunk set, not current-epoch `vault_blobs`, so a superseded + // chunk stays gated instead of regressing to the residual (W2). if let Some(vid) = s.owned_chunks.get(chunk_id).copied() { return match s.blob_auth.get(node) { // (a) our own delegated device. @@ -5841,15 +5201,12 @@ fn authorize_fetch(s: &Shared, node: &[u8; 32], chunk_id: &[u8; 32]) -> bool { }; } - // W8/§7.4: a blob we hold AS A REPLICA for another owner. Serve it only to (a) - // that vault owner's delegated devices, or (b) a current replica-set member (for - // repair) - never to an arbitrary dialer, which the old residual `return true` - // let through. + // W8/§7.4: a REPLICA-held blob. Serve only to (a) the owner's delegated devices or (b) a + // current replica-set member, never an arbitrary dialer. if let Some(vid) = s.replica_chunks.get(chunk_id).copied() { let owner = s.replica_owner.get(&vid).copied(); - // (a) the owner's delegated device: either the owner's own node that is our - // friend (Friend), or one of the owner's other devices proven by the card it - // presented (ReplicaDevice). + // (a) the owner's device: its friend node (Friend), or another device proven by the + // card it presented (ReplicaDevice). let owner_device = owner.is_some_and(|o| match s.blob_auth.get(node) { Some(BlobAuth::Friend(u)) | Some(BlobAuth::ReplicaDevice(u)) => *u == o, _ => false, @@ -5862,19 +5219,15 @@ fn authorize_fetch(s: &Shared, node: &[u8; 32], chunk_id: &[u8; 32]) -> bool { return owner_device || member; } - // F1 (design §3.5) DEFAULT-DENY: a blob in neither the owner-gated set - // (`owned_chunks`, incl. each vault's manifest-envelope digest) nor the - // replica-held set (`replica_chunks`) is served to NO ONE. With a durable blob - // store, the old residual `return true` would re-open every owned blob to any - // dialer after a reboot; default-deny turns any future gate-map omission from a - // silent public leak into a visible availability failure instead. + // F1 DEFAULT-DENY: a blob in neither the owned nor replica-held set is served to no one. + // With a durable store a residual `return true` would re-open every owned blob to any + // dialer after a reboot; default-deny turns any gate-map omission into a visible failure. false } -/// Resolve `node` to the *user* pubkey that delegates it: our own user (if one of -/// our cards delegates it) or an established friend whose newest card does (§4). The -/// owner-user a replica records for a placement so it can later admit that owner's -/// delegated devices (W8). +/// Resolve `node` to the *user* pubkey that delegates it: our own user or an established +/// friend whose newest card does (§4). Recorded at placement so a replica can later admit +/// that owner's devices (W8). fn owner_user_of_node( s: &Shared, self_user: &[u8; 32], @@ -5893,13 +5246,9 @@ fn owner_user_of_node( .map(|(user, _)| *user) } -/// W8/§7.4 a: if the self-consistent `card` a dialer presented belongs to a vault -/// OWNER this daemon holds replicas for and delegates the dialer's `remote` node, -/// the owner-user it authenticates as. Lets an owner's device this replica does not -/// otherwise know (not enumerated in the stored friend card) fetch that owner's -/// replica-held chunks. ponytail (like the self-device gate): this trusts the -/// presented card's delegation, so a device the owner has since revoked could still -/// present an old card until a rollback-guarded owner-card store lands. +/// W8/§7.4 a: if the self-consistent `card` a dialer presented belongs to a vault OWNER this +/// daemon replicates for and delegates `remote`, the owner-user it authenticates as. Lets an +/// owner's device this replica does not otherwise know fetch that owner's replica-held chunks. fn replica_owner_device( s: &Shared, card: &ContactCard, @@ -5916,17 +5265,9 @@ fn replica_owner_device( card_delegates_node(card, remote, now).then_some(owner) } -/// Choose which vaults to reconstruct from a pulled document batch, applying the -/// two §6 MUSTs: (C1) the announce/grant signer node must be delegated by the -/// vault-owning user in that user's newest verified `ContactCard`, and (W2) the -/// announce epoch must exceed the highest ever seen from that signer for the vid. -/// -/// Phase 1 is same-user two-device sync, so the vault owner is bound to *our own* -/// user key: an announce signed by a node not delegated by our user is refused. -/// Whether a manifest-supplied relative path is safe to delete under an out dir: -/// no absolute root, no `..` escape, no backslash. Mirrors `carapace_vault`'s -/// `safe_join` guard for the tombstone-deletion path (a manifest may be hostile; -/// Phase 1 manifests are same-user-trusted, so this matches that crate's stance). +/// Whether a manifest-supplied relative path is safe to delete under an out dir (no absolute +/// root, no `..` escape, no backslash). Mirrors `carapace_vault`'s `safe_join` guard for the +/// tombstone-deletion path, since a manifest may be hostile. fn manifest_rel_is_safe(rel: &str) -> bool { if rel.is_empty() { return false; @@ -5934,6 +5275,9 @@ fn manifest_rel_is_safe(rel: &str) -> bool { rel.split('/').all(|p| p != ".." && !p.contains('\\')) } +/// Choose which vaults to reconstruct from a pulled document batch, applying the two §6 MUSTs: +/// (C1) the announce signer node must be delegated by the vault-owning user's newest verified +/// card, and (W2) the announce epoch must exceed the highest ever seen from that signer. fn select_targets( docs: &mut DocStore, self_user: &[u8; 32], @@ -5941,16 +5285,10 @@ fn select_targets( announces: &[VaultAnnounce], now: u64, ) -> Vec<([u8; 32], VaultAnnounce)> { - // The set of node ids our user delegates. The `DocStore` keeps only ONE card per - // user, so with 3+ same-user devices that stored card alone names a single - // sibling and every other sibling's announce would be refused - silently - // dropping that device's edits (a §11 multi-device propagation gap). So also - // honor the delegation carried by each card presented in THIS batch: every - // announcing device presents its own user-signed card, and a forged card cannot - // fake a self_user delegation (it fails `card.verify()`), so trusting any node - // our own user validly delegates is safe. This matches the self-branch stance in - // `classify_dialer` (own-device revocation remains a separate documented TODO). - // Built fully before the rollback offer below borrows `docs` mutably. + // Node ids our user delegates. The `DocStore` keeps only ONE card per user, so with 3+ + // same-user devices also honor the delegation in each card presented in THIS batch (a + // forged card cannot fake a self_user delegation - it fails `card.verify()`), else every + // other sibling's announce would be refused. Built before the rollback offer borrows `docs`. let mut delegated: HashSet<[u8; 32]> = HashSet::new(); if let Some(card) = docs.card(self_user) { for n in &card.nodes { @@ -5974,21 +5312,15 @@ fn select_targets( for ann in announces { // C1: the announce signer must be a delegated node of the vault owner. if !delegated.contains(&ann.by) { - // W7 store-and-forward (§6): an announce for a vault we do not own is not - // a reconstruction target, but store the signed doc (rollback-guarded per - // (signer, vid)) so this node re-serves it to its own friends — an owner's - // announce reaches a trustee through any mutual friend. A bad signature or - // a stale epoch is simply not stored; it never aborts the batch. + // W7 store-and-forward (§6): store an announce for a vault we do not own + // (rollback-guarded per (signer, vid)) so this node re-serves it to its friends. + // A bad sig or stale epoch is simply not stored; it never aborts the batch. let _ = docs.offer_announce(ann); continue; } - // Option B (§4): a delegated-signer announce is a full reconstruction target - // on its own - `reconstruct_one` re-derives per-chunk keys from the manifest - // `pt_hash` (holder of `K_root`), so no matching FileGrant is required. This is - // what lets a `K_root`-recovering claimant reconstruct off a replica that - // serves only the announce + owner card, never a grant. - // W2: persistent rollback — accept only an epoch strictly newer than the - // highest ever seen from this signer for this vid (also re-verifies sig). + // Option B (§4): a delegated-signer announce is a full reconstruction target on its + // own (keys re-derived from the manifest pt_hash), no FileGrant required. W2: accept + // only an epoch strictly newer than the highest ever seen from this signer. if matches!(docs.offer_announce(ann), Ok(true)) { out.push((ann.vid, ann.clone())); } @@ -6024,24 +5356,13 @@ fn card_delegates_node(card: &ContactCard, node_id: &[u8; 32], now: u64) -> bool false } -/// W5/W2 gate for a document pull: authorize the connection's authenticated remote -/// node id. The presented `card` must be validly self-signed and name this -/// daemon's own user or an established friend. Delegation is then checked as -/// follows: -/// -/// - Self branch (`card.user == self_user`, W7 `6-newest-card-delegations`): once a -/// strictly-newer self-card is known (`newest_self`, the rollback-guarded newest -/// card this user has signed, learned via anti-entropy into the [`DocStore`]), it is -/// authoritative — a node absent from it (a revoked own device presenting an old -/// self-card) is refused, per §6 "MUST NOT honor node delegations absent from the -/// signer's newest card." Until a newer self-card exists (the same-version -/// sibling-card case this build's one-node-per-device cards produce during normal -/// multi-device sync), the presented self-card's own delegation is trusted, so a -/// first-seen sibling device still authorizes. -/// - Friend branch (W2): authorization uses the STORED newest friend card -/// (`s.friends`), never the delegations in the card the dialer presents. Once a -/// friend publishes a newer card dropping a device, a dialer presenting an old -/// card that still delegates that device is refused. +/// W5/W2 gate for a document pull: authorize the authenticated remote node id. The presented +/// `card` must be validly self-signed and name our own user or an established friend. +/// - Self branch: a strictly-newer known self-card (`newest_self`) is authoritative, so a +/// node absent from it (a revoked own device presenting an old card) is refused; until one +/// exists the presented self-card's delegation is trusted (first-seen sibling authorizes). +/// - Friend branch (W2): authorization uses the STORED newest friend card, never the +/// presented card, so a dropped device presenting an old card is refused. fn classify_dialer( s: &Shared, self_user: &[u8; 32], @@ -6054,9 +5375,8 @@ fn classify_dialer( return None; } if card.user == *self_user { - // A strictly-newer known self-card supersedes the presented one: honor only - // the nodes it still delegates (revocation takes effect). Otherwise fall back - // to the presented card, preserving same-version multi-device authorization. + // A strictly-newer known self-card supersedes the presented one (revocation takes + // effect); else fall back to the presented card, preserving multi-device auth. return match newest_self { Some(newest) if newest.version > card.version => { card_delegates_node(newest, remote, now).then_some(BlobAuth::OwnDevice) @@ -6072,10 +5392,8 @@ fn classify_dialer( } } -/// S3: whether a `FriendAccept` genuinely comes from the issuer of the ticket the -/// requester redeemed. The accept's embedded card must name `ticket_user`, and the -/// completed friendship must bind that same party. Signature validity is proven -/// separately by `verify_friend_accept`; this binds identity to the ticket. +/// S3: whether a `FriendAccept` comes from the ticket issuer. The accept's card must name +/// `ticket_user` and the friendship must bind that party (signature is checked separately). fn accept_binds_ticket(accept: &FriendAccept, ticket_user: &[u8; 32], fr: &Friendship) -> bool { accept.card.user == *ticket_user && (fr.a == *ticket_user || fr.b == *ticket_user) } @@ -6095,14 +5413,10 @@ fn node_is_authorized(s: &Shared, self_user: &[u8; 32], node: &[u8; 32], now: u6 .any(|c| card_delegates_node(c, node, now)) } -/// C1: friend-gate for the embedded relay (§6/§14). Admits only this node itself, -/// its own delegated devices, and nodes delegated by an established friend's -/// newest card - never arbitrary internet peers. Reads the live friend set on -/// every connection, so a peer befriended after the relay started is admitted -/// and an unfriended one stops being admitted, with no relay restart. -/// -/// The endpoint id is authenticated by the relay handshake before this runs -/// (iroh-relay), so a non-friend cannot forge a friend's id to pass the gate. +/// C1: friend-gate for the embedded relay (§6/§14). Admits only this node, its own devices, +/// and nodes delegated by an established friend's newest card - never arbitrary peers. Reads +/// the live friend set per connection. The endpoint id is relay-handshake-authenticated before +/// this runs, so a non-friend cannot forge a friend's id. struct FriendRelayGate { shared: Arc>, self_user: [u8; 32], @@ -6120,8 +5434,7 @@ impl std::fmt::Debug for FriendRelayGate { impl RelayAccessPolicy for FriendRelayGate { fn allows(&self, endpoint_id: &EndpointId, auth_token: Option<&str>) -> bool { let node = *endpoint_id.as_bytes(); - // Always admit ourselves: we register on our own relay as home relay, - // independent of when our own card lands in `shared`. + // Always admit ourselves: we register on our own relay as home relay. if node == self.self_node { return true; } @@ -6130,10 +5443,8 @@ impl RelayAccessPolicy for FriendRelayGate { if node_is_authorized(&s, &self.self_user, &node, now) { return true; } - // Invite bootstrap (§6): a not-yet-friend that presents a live invite - // ticket we issued (as its relay auth token) is admitted so it can reach - // us to complete the friendship handshake. Without this, a friend-only - // gate would make the very first, ticketed contact impossible over relay. + // Invite bootstrap (§6): a not-yet-friend presenting a live invite ticket we issued + // (as its relay auth token) is admitted so it can complete the friendship handshake. auth_token .and_then(parse_ticket_auth_token) .is_some_and(|tok| s.tickets.admits(&tok, now)) @@ -6189,11 +5500,9 @@ struct UnfriendTeardown { } /// §9.3 (local half of steps 1-3): drop `ex_user` from the friend graph, delete every -/// replica/share/grant we HOLD of them, queue the vaults they replicated for us for -/// immediate re-placement (`unfriended_nodes`), and record a pending re-split for every -/// recovery set they were a trustee of (`pending_resplits`). Pure state mutation - no -/// network, no `k_root` - so both the initiating [`Daemon::unfriend`] and the inbound -/// `FriendshipEnd` handler share it. Returns the initiator's follow-through inputs. +/// replica/share/grant we HOLD of them, queue their replicas of our vaults for re-placement, +/// and record a pending re-split per recovery set they were a trustee of. Pure state mutation +/// (no network, no `k_root`), shared by [`Daemon::unfriend`] and the `FriendshipEnd` handler. fn teardown_unfriended_state(s: &mut Shared, ex_user: [u8; 32]) -> UnfriendTeardown { // Their delegated nodes + last-known addresses, captured before we drop the card. let ex_nodes: Vec<[u8; 32]> = s @@ -6206,8 +5515,8 @@ fn teardown_unfriended_state(s: &mut Shared, ex_user: [u8; 32]) -> UnfriendTeard .filter_map(|n| s.peer_addrs.get(n).cloned()) .collect(); - // What we PLACED on them (DeleteRequest inputs): (a) our vaults they hold a replica - // of (their node is a member), and (b) whether they hold a share of ours. + // What we PLACED on them (DeleteRequest inputs): our vaults they replicate, and whether + // they hold a share of ours. let replica_vids: Vec<[u8; 32]> = s .members .iter() @@ -6277,9 +5586,8 @@ fn teardown_unfriended_state(s: &mut Shared, ex_user: [u8; 32]) -> UnfriendTeard } } -/// Drop all bookkeeping for a vault we hold as a replica (§9.3 delete side + unfriend -/// teardown). The blobs themselves are left to store GC (a separate resource concern, -/// like the W2-gc note); this closes the §7.4/W8 read gate for that vault immediately. +/// Drop all bookkeeping for a vault we hold as a replica, closing the §7.4/W8 read gate for +/// it immediately. The blobs are left to store GC. fn drop_replica_vid(s: &mut Shared, vid: &[u8; 32]) { s.held.remove(vid); s.replica_owner.remove(vid); @@ -6300,13 +5608,12 @@ fn drop_held_share_of(s: &mut Shared, owner: &[u8; 32]) { } } -/// Apply a verified [`DeleteRequest`] from `owner_user` (§9.3 step 1): delete what that -/// owner placed on us, per scope. Bookkeeping deletion - the bytes were ciphertext and -/// any share is neutralized by the re-split, so this is compliance, not proof. +/// Apply a verified [`DeleteRequest`] from `owner_user` (§9.3 step 1): delete what that owner +/// placed on us, per scope. Bookkeeping deletion (compliance, not proof). fn apply_delete_request(s: &mut Shared, req: &DeleteRequest, owner_user: &[u8; 32]) { match req.scope { SCOPE_REPLICAS => { - // Delete only a replica we actually hold FOR this owner. + // Delete only a replica we hold FOR this owner. if let Some(vid) = req.vid { if s.replica_owner.get(&vid) == Some(owner_user) { drop_replica_vid(s, &vid); @@ -6330,28 +5637,13 @@ fn apply_delete_request(s: &mut Shared, req: &DeleteRequest, owner_user: &[u8; 3 } } -/// Stand up the §9.3 step-3 re-split for `old_rsid`, whose trustee `ex_user` was just -/// unfriended, into an [`OpenResplit`]. Re-splits the SAME secret (a fresh -/// recovery-set id) among `new_trustee_users`, keeping the old threshold `M`; the -/// ex-friend is excluded from the OLD honest set that is told to destroy, so once the new -/// set is live and the old honest shares are destroyed the ex-friend's retained old share -/// is stranded below `M`. -/// -/// `new_trustee_users` is the chosen new set (§9.3.4: the user's choice, defaulting to the -/// old honest set). Each must be a still-known trustee or an established friend so its -/// roster identity + dial node resolve; a stranger fails loudly. `N_new = len` must be -/// `>= M`, and `M >= 2` (a 1-of-N trustee is a full key holder no re-split can -/// neutralize); slack is 1 when there is room else 0. It errors otherwise - surfaced for a -/// manual re-split rather than a silently broken set. -/// -/// §8: each new grant carries the co-trustee roster (pubkeys + node hints, the recipient -/// excluded) and the latest announce refs, mirroring the original split's grants -/// ([`Daemon::recovery_split_grant`]). The destroy of the old set is gated by -/// [`Resplit::share_destroy`] regardless. -/// -/// ponytail: re-split only supports Root-scoped sets (the inner circle) - [`Resplit::begin`] -/// splits `k_root`. A Vault-scoped old set errors here rather than silently splitting the -/// wrong secret; wire `K_vaultroot` through `begin` to lift this. +/// Stand up the §9.3 step-3 re-split for `old_rsid` (whose trustee `ex_user` was unfriended) +/// into an [`OpenResplit`]. Re-splits the SAME secret at a fresh recovery-set id among +/// `new_trustee_users`, keeping threshold `M`; the ex-friend is excluded from the OLD honest +/// set told to destroy, so once the new set is live the ex-friend's retained share is stranded +/// below `M`. Each new trustee must be a known trustee or established friend (a stranger fails +/// loudly); `N_new >= M`, `M >= 2`. Root-scoped only ([`Resplit::begin`] splits `k_root`); a +/// Vault-scoped set errors rather than split the wrong secret. fn build_resplit( node_key: &SigningKey, k_root: &[u8; 32], @@ -6376,9 +5668,8 @@ fn build_resplit( "cannot neutralize a {m}-of-N trustee by re-split: one share already recovers" ); - // Root-only: the re-split splits `k_root`. Refuse a Vault-scoped set rather than split - // the wrong secret. Absent split-state (e.g. a re-split-of-a-re-split before extend is - // wired) defaults to Root, which is what `begin` produces anyway. + // Root-only: refuse a Vault-scoped set rather than split the wrong secret. Absent + // split-state defaults to Root, which is what `begin` produces anyway. let scope = s .split_states .get(&old_rsid) @@ -6389,15 +5680,13 @@ fn build_resplit( "re-split of a vault-scoped recovery set is not supported; recruit new trustees and split manually" ); - // §9.3: the unfriended ex-trustee must NEVER be a member of the new set - re-granting - // them a fresh valid share would defeat the re-split's whole point (strand them below M). - // The default/suggested set already excludes them; this guards an operator-supplied set. + // §9.3: the unfriended ex-trustee must NEVER be in the new set - re-granting them a valid + // share would defeat the re-split. Guards an operator-supplied set (the default excludes them). ensure!( !new_trustee_users.contains(&ex_user), "the unfriended trustee cannot be a member of the new re-split set" ); - // Reject duplicate new-trustee pubkeys: duplicates inflate issuance and desync the - // attestation tracker's N from the deduped roster (n <= 32, so the scan is cheap). + // Reject duplicate new-trustee pubkeys: they inflate issuance and desync the tracker's N. for (i, u) in new_trustee_users.iter().enumerate() { ensure!( !new_trustee_users[i + 1..].contains(u), @@ -6406,8 +5695,7 @@ fn build_resplit( ); } - // The OLD honest trustees (told to destroy) are always the old set minus the ex-friend, - // independent of the chosen NEW set. + // The OLD honest trustees (told to destroy): the old set minus the ex-friend. let old_honest: Vec<[u8; 32]> = g .trustees .iter() @@ -6415,9 +5703,8 @@ fn build_resplit( .map(|t| t.node) .collect(); - // Resolve each chosen new trustee's roster identity (user + node + relay). Prefer the - // old grant record (still knows the node), else the current friend card; a user that is - // neither fails loudly - we cannot build a roster entry or deliver to it. + // Resolve each chosen new trustee's roster identity (user + node + relay): prefer the old + // grant record, else the current friend card; a user that is neither fails loudly. let mut new_roster: Vec = Vec::with_capacity(new_trustee_users.len()); for user in new_trustee_users { let ct = if let Some(t) = g.trustees.iter().find(|t| t.user == *user) { @@ -6468,9 +5755,8 @@ fn build_resplit( .map_err(|e| anyhow::anyhow!("re-split begin failed: {e:?}"))?; let new_rsid = rs.new_rsid(); - // §8: mint each new grant with the co-trustee roster (recipient excluded) + latest - // announce refs, using the fresh shares `begin` produced. The empty-roster grants - // `begin` also returned are discarded - `begin` has no roster context, this does. + // §8: mint each new grant with the co-trustee roster (recipient excluded) + latest announce + // refs, using `begin`'s fresh shares. `begin`'s empty-roster grants are discarded. let refs = current_announce_refs(s); let mut new_peers = Vec::with_capacity(new_nodes.len()); let mut new_records = Vec::with_capacity(new_nodes.len()); @@ -6567,7 +5853,7 @@ fn render_paper_cards(s: &Shared, rsid: u64) -> Result { fn register_completed_resplit(s: &mut Shared, old_rsid: u64) { // Snapshot the registration data and mark it done, dropping the `&mut o` borrow before - // mutating the sibling maps (`granted`/`share_sets`/`split_states`). + // mutating the sibling maps. let snap = { let Some(o) = s.resplits.get_mut(&old_rsid) else { return; @@ -6614,10 +5900,8 @@ fn register_completed_resplit(s: &mut Shared, old_rsid: u64) { s.granted.remove(&old_rsid); s.share_sets.remove(&old_rsid); s.split_states.remove(&old_rsid); - // Retire the completed OpenResplit itself: its `new_records` hold a duplicate in-memory - // copy of the new shares (already in `granted[new_rsid]`) that would otherwise accumulate - // unbounded across re-splits and never drop; removing it also stops drive_resplit from - // no-op-driving a finished re-split every maintenance tick. + // Retire the completed OpenResplit: it holds a duplicate copy of the new shares that would + // accumulate unbounded, and removing it stops drive_resplit no-op-driving it every tick. s.resplits.remove(&old_rsid); } @@ -6669,9 +5953,7 @@ fn resplit_status_of(s: &Shared, o: &OpenResplit) -> ResplitStatus { } } -/// The wall clock the ceremony delay gate reads: the injected `test_now` when set -/// (bounded tests advance it past the 72 h delay instantly), else real time. Only the -/// network-triggered ceremony paths (track `first_seen`, `can_release`, status) use it. +/// The wall clock the ceremony delay gate reads: injected `test_now` when set, else real time. fn ceremony_now(s: &Shared) -> u64 { if s.test_now == 0 { unix_now() @@ -6680,16 +5962,11 @@ fn ceremony_now(s: &Shared) -> u64 { } } -/// Track an inbound `RecoveryOpen` against a held `ShareGrant` (§8.5): verify the -/// grant + open, bind the open to the grant's subject/rsid, derive the FULL trustee -/// roster as `{this trustee} ∪ the grant's co-trustees`, and build the delay-anchored -/// [`CeremonyState`] (`first_seen = now`). -/// -/// The roster is reconstructed here rather than via the ceremony crate's -/// `open_from_grant`, because a W3 owner-minted grant is signed by the OWNER (not the -/// holding trustee) and lists only the OTHER co-trustees - so `grant.by` is the owner, -/// not a roster member. The holder is this device (`self_user`), which is exactly the -/// missing roster entry. +/// Track an inbound `RecoveryOpen` against a held `ShareGrant` (§8.5): verify grant + open, +/// bind the open to the grant's subject/rsid, derive the roster as `{this trustee} ∪ the +/// grant's co-trustees`, and build the delay-anchored [`CeremonyState`] (`first_seen = now`). +/// The roster is reconstructed here because a W3 grant is OWNER-signed and lists only the +/// OTHER co-trustees, so the holder (`self_user`) is the missing entry. fn track_from_grant( self_user: &[u8; 32], open: &RecoveryOpen, @@ -6710,10 +5987,9 @@ fn track_from_grant( CeremonyState::open(open, roster, share.threshold, grant.recovery_delay, now) } -/// Resolve dialable addresses of the recovery participants for `subject` we can reach -/// (§8.5 step 2 fan-out audience): the co-trustees named in our held grant (node -/// hints), the subject's own devices (from the subject's friend card), and our -/// friends' devices - deduped, excluding this node, best-effort address resolution. +/// Resolve reachable recovery participants for `subject` (§8.5 step 2 fan-out audience): the +/// co-trustees in our held grant, the subject's own devices, and our friends' devices - +/// deduped, excluding this node. fn resolve_ceremony_peers( s: &Shared, self_node: [u8; 32], @@ -6763,16 +6039,11 @@ pub fn max_epoch_refs(refs: &[AnnounceRef]) -> Vec { out } -/// A key-less recovery claimant (§8.4/§8.5 step 6): a fresh device with NO user key -/// and NO `K_root` (that is what it is recovering), holding only a fresh ceremony HPKE -/// keypair and a fresh node key. It hands its `ceremony_enc` pubkey to a sponsoring -/// trustee (which builds the `RecoveryOpen`), then collects `M` HPKE-sealed -/// `CeremonyShare`s from the approving trustees, recovers `K_root` locally, and -/// re-derives its identity so the recovered device is usable (existing friendships and -/// cards stay valid). -/// -/// It cannot be a full [`Daemon`] (that needs `K_root` to build its card), so it binds -/// a bare endpoint from its node key just long enough to collect shares. +/// A key-less recovery claimant (§8.4/§8.5 step 6): a fresh device with no user key and no +/// `K_root` (what it is recovering), holding only a fresh ceremony HPKE keypair and node key. +/// It hands its `ceremony_enc` pubkey to a sponsoring trustee, collects `M` HPKE-sealed +/// `CeremonyShare`s, recovers `K_root`, and re-derives its identity. Not a full [`Daemon`] +/// (that needs `K_root`); it binds a bare endpoint just long enough to collect shares. pub struct ClaimantDevice { node_key: SigningKey, ceremony_sk: HpkePrivateKey, @@ -6828,22 +6099,17 @@ impl ClaimantDevice { self.node_key.verifying_key().to_bytes() } - /// The node signing seed for this device (§8.4): after [`recover`](Self::recover) - /// yields `K_root`, `State::from_seeds(claimant.node_seed(), *recovered.k_root)` - /// stands the recovered device up as a full [`Daemon`] on the exact node identity - /// this claimant delegated in [`Recovered::node_deleg`], so it can then fetch and - /// reconstruct its vaults from a surviving replica. + /// The node signing seed for this device (§8.4): after recover yields `K_root`, + /// `State::from_seeds(claimant.node_seed(), *recovered.k_root)` stands the recovered device + /// up as a full [`Daemon`] on the node identity delegated in [`Recovered::node_deleg`]. #[must_use] pub fn node_seed(&self) -> [u8; 32] { self.node_key.to_bytes() } - /// Collect the raw sealed `CeremonyShare`s the approving trustees release (§8.5 - /// step 5). Dials each trustee with the signed `open`; a trustee whose gate is not - /// open (delay not elapsed, sub-`M` approvals, aborted, or it did not approve) - /// replies with nothing. The returned shares are still HPKE-sealed to the ceremony - /// key - no trustee saw another's, and nothing crosses the wire in the clear. - /// Bounded by the connect timeout per trustee. + /// Collect the raw sealed `CeremonyShare`s the approving trustees release (§8.5 step 5). + /// Dials each trustee with the signed `open`; a trustee whose gate is not open replies with + /// nothing. The returned shares are still HPKE-sealed to the ceremony key. Bounded dial. pub async fn collect_raw( &self, open: &RecoveryOpen, @@ -6863,14 +6129,10 @@ impl ClaimantDevice { Ok(out) } - /// Open `M` collected shares with the ceremony key and recover `K_root` (§8.5 step - /// 6, §8.4). Each share is authenticated against `roster` (a sender not in the - /// trustee roster, or a tampered signature, is refused) before decryption; Chela's - /// integrity tag + CRC then guarantee recovery never silently yields a wrong secret. - /// The recovered user key re-signs a delegation for this new device. - /// - /// `roster` is the trustee user pubkeys (the sponsor provides it out of band, from - /// its grant). Errors if fewer than `M` valid shares open (recovery needs a quorum). + /// Open `M` collected shares with the ceremony key and recover `K_root` (§8.5 step 6/§8.4). + /// Each share is authenticated against `roster` before decryption; Chela's integrity tag + + /// CRC guarantee recovery never silently yields a wrong secret. The recovered user key + /// re-signs a delegation for this new device. Errors if fewer than `M` valid shares open. pub fn recover_from(&self, shares: &[CeremonyShare], roster: &[[u8; 32]]) -> Result { let mut parsed: Vec = Vec::new(); for cs in shares { @@ -6887,8 +6149,8 @@ impl ClaimantDevice { let k_root = recover_key_from_shares(&parsed).map_err(|e| { anyhow::anyhow!("recover K_root failed (need >= M valid shares): {e:?}") })?; - // Re-derive the identity and re-delegate this new device (§8.4). Identical user - // key to the original owner's, so existing friendships and cards stay valid. + // Re-derive the identity and re-delegate this new device (§8.4): same user key as the + // original owner, so existing friendships and cards stay valid. let user_key = carapace_crypto::identity::user_key_from_seed(&kdf::k_userid(&*k_root)); let user_id = user_key.verifying_key().to_bytes(); let new_node = self.new_node(); @@ -7019,15 +6281,9 @@ async fn read_blob(recv: &mut RecvStream) -> Result> { Ok(buf) } -/// Build a dialable [`EndpointAddr`] from a node id and zero or more socket-address -/// strings (e.g. `"127.0.0.1:52345"`). An empty `addrs` yields an id-only address -/// (usable only with a discovery service). A malformed node id or socket string is a -/// hard error rather than a silently-dropped address. -/// Resolve a peer node id to a dialable [`EndpointAddr`] for the maintenance loop: -/// the last-known address if this node recorded one (from a prior befriend/placement -/// dial), else a node-id-only addr that iroh resolves through injected hints and relay -/// fallback (§6 "addresses are hints, not identities"). Returns `None` only if the -/// node id is not a valid endpoint key. +/// Resolve a peer node id to a dialable [`EndpointAddr`]: the last-known address if recorded, +/// else a node-id-only addr iroh resolves through injected hints and relay fallback (§6 +/// "addresses are hints"). `None` only if the node id is not a valid endpoint key. fn resolve_peer( peer_addrs: &HashMap<[u8; 32], EndpointAddr>, node: &[u8; 32], @@ -7038,6 +6294,8 @@ fn resolve_peer( EndpointId::from_bytes(node).ok().map(EndpointAddr::new) } +/// Build a dialable [`EndpointAddr`] from a node id and socket-address strings. Empty `addrs` +/// yields an id-only address; a malformed node id or socket string is a hard error. fn endpoint_addr(node: [u8; 32], addrs: &[String]) -> Result { let id = EndpointId::from_bytes(&node) .map_err(|e| anyhow::anyhow!("bad node id {}: {e}", hex32(&node)))?; @@ -7051,39 +6309,26 @@ fn endpoint_addr(node: [u8; 32], addrs: &[String]) -> Result { Ok(ea) } -/// Feed a friend's ContactCard addressing hints into the live endpoint (§6): for -/// each node entry, inject an `EndpointAddr` (id + direct addrs + relay url) so it -/// can be dialed by node id, and add its relay to our usable relay set. A -/// malformed entry is skipped rather than failing the whole learn. +/// Feed a friend's ContactCard addressing hints into the live endpoint (§6): inject each node +/// entry's `EndpointAddr` so it can be dialed by node id, and add its relay to our usable set. async fn learn_card_hints(hints: &PeerHints, card: &ContactCard) { let now = unix_now(); for n in &card.nodes { - // W1: only inject a hint for a node the card's user actually delegates, - // with a delegation that has not expired. `card.verify()` at the call - // sites covers only the card's self-signature, not the per-node - // user->node delegations, so without this gate a card could inject an - // address hint for a node_id it never delegated. - // - // ponytail (known ceiling): this enforces the card's own trust model but - // does not fully stop cross-friend hint poisoning - delegations are - // user-signed only, so a malicious friend can self-delegate an arbitrary - // node_id (including a third friend's) with attacker-chosen addrs. That - // residual is bounded (no impersonation; QUIC is node-id-authenticated; - // hints merge, not replace) and closing it needs source-keyed hints, out - // of scope here. + // W1: only inject a hint for a node the card's user actually delegates (unexpired). + // `card.verify()` covers only the self-signature, not the per-node delegations, so + // without this gate a card could inject an address hint for a node it never delegated. + // Residual cross-friend hint poisoning is bounded (QUIC is node-id-authenticated, + // hints merge not replace) and needs source-keyed hints to fully close. if card_delegates_node(card, &n.node_id, now) { - // No relay auth token: an established friend's relay admits us via the - // friend branch of its gate, not via an invite ticket. + // No relay auth token: a friend's relay admits us via the friend branch of its gate. inject_hint(hints, n.node_id, &n.addrs, n.relay_url.as_deref(), None).await; } } } -/// Like [`learn_card_hints`] but from an [`InviteTicket`] (issuer node id + direct -/// addrs + advertised relay URLs). "Your usable relay set = relays advertised by -/// your friends" (§6). The ticket's token is attached to the issuer's relays as -/// the relay auth token so the issuer's friend-gated relay admits us for the -/// (not-yet-friend) bootstrap handshake. +/// Like [`learn_card_hints`] but from an [`InviteTicket`]. The ticket's token is attached to +/// the issuer's relays as the auth token so its friend-gated relay admits us for the +/// not-yet-friend bootstrap handshake (§6). async fn learn_ticket_hints(hints: &PeerHints, ticket: &InviteTicket) { let auth = ticket_auth_token(&ticket.token); inject_hint( @@ -7102,10 +6347,9 @@ async fn learn_ticket_hints(hints: &PeerHints, ticket: &InviteTicket) { } } -/// Inject one peer's `{node_id, direct addrs, relay}` hint into the endpoint. -/// Unparseable node ids, socket strings, or relay URLs are dropped (best effort: -/// addresses are hints, §6). When `auth_token` is set, the relay is added with -/// that client auth token (the invite bootstrap, §6); otherwise it is added plain. +/// Inject one peer's `{node_id, direct addrs, relay}` hint into the endpoint (unparseable +/// parts are dropped, best-effort). `auth_token` adds the relay with that client token (the +/// invite bootstrap); else it is added plain. async fn inject_hint( hints: &PeerHints, node: [u8; 32], @@ -7170,9 +6414,8 @@ fn unix_now() -> u64 { .unwrap_or(0) } -/// Build a `GrantBody` carrying every non-deleted chunk's secret. Errors (rather -/// than panicking — S3) if a manifest chunk id is absent from `keys`; both come -/// from the same ingest, so this is an owner-local invariant, but fail loudly. +/// Build a `GrantBody` carrying every non-deleted chunk's secret. Errors (not panics, S3) if a +/// manifest chunk id is absent from `keys` - an owner-local invariant, but fail loudly. fn grant_body(manifest: &Manifest, keys: &ChunkKeys) -> Result { let mut files = Vec::new(); for f in &manifest.files { @@ -7290,8 +6533,7 @@ mod tests { a } - // C1: an announce is honored only if its signer node is delegated by the - // vault-owning user's newest card; a rogue/undelegated node is refused. + // C1: an announce is honored only if its signer node is delegated by the owner's newest card. #[test] fn c1_only_delegated_signer_is_accepted() { let user = kp(1); @@ -7316,10 +6558,8 @@ mod tests { "undelegated signer must be refused (C1)" ); - // 3+ device propagation: a second sibling node our SAME user delegates - - // proven by the card that sibling presents in this batch - is accepted even - // though the stored card names only `node`. This is what lets a third - // device's edits reach us instead of being silently dropped. + // 3+ device propagation: a sibling our SAME user delegates (proven by the card in + // this batch) is accepted even though the stored card names only `node`. let node2 = kp(0x77); let card2 = build_card(&user, &node2, &[9; 32], None); let mut docs = DocStore::new(); @@ -7337,8 +6577,7 @@ mod tests { "a sibling our own user delegates (card in batch) must be accepted" ); - // A card signed by a DIFFERENT user cannot smuggle a delegation into our set - // (its user != self_user), so a rogue presenting one stays refused. + // A card signed by a DIFFERENT user cannot smuggle a delegation into our set. let rogue_user = kp(0x99); let rogue_card = build_card(&rogue_user, &rogue, &[9; 32], None); let mut docs = DocStore::new(); @@ -7356,8 +6595,7 @@ mod tests { ); } - // C1: a valid announce survives even when a poison undelegated announce is in - // the same batch (this is also the selection half of W3's isolation). + // C1: a valid announce survives a poison undelegated announce in the same batch. #[test] fn c1_poison_announce_does_not_starve_valid_vault() { let user = kp(1); @@ -7427,10 +6665,8 @@ mod tests { card } - // W2: friend-device revocation takes effect. A friend delegates node N in card - // v1, then publishes v2 dropping N. Once v2 is the stored newest card, a dialer - // presenting the old v1 card for node N is refused - authorization uses the - // stored card, not the presented one. + // W2: friend-device revocation takes effect. Authorization uses the stored newest card, + // so a dialer presenting an old card for a dropped node N is refused. #[test] fn w2_friend_revocation_refused_after_newer_card() { let friend_user = kp(0x50); @@ -7456,13 +6692,9 @@ mod tests { ); } - // W7 (6-newest-card-delegations): own-device revocation takes effect once a newer - // self-card is known. This user's device X is delegated by self-card v1; the user - // then publishes v2 (a newer self-card) that drops X. Once v2 is the newest known - // self-card, X presenting its old v1 self-card is refused — §6 "MUST NOT honor - // node delegations absent from the signer's newest card." A device still present - // in v2 authorizes, and before any newer card exists the presented card is - // trusted (preserving same-version multi-device sync). + // W7: own-device revocation takes effect once a newer self-card is known. X (dropped in + // v2) presenting its old v1 self-card is refused; a device still in v2 authorizes, and + // before any newer card exists the presented card is trusted. #[test] fn w7_own_device_revocation_refused_after_newer_self_card() { let self_user = kp(0x01); @@ -7476,15 +6708,13 @@ mod tests { let v2 = card_with(&self_user, &device_y, 2); // newer self-card: drops X, adds Y let s = Shared::default(); - // No newer self-card known yet: X presenting its own valid self-card is trusted - // (the same-version multi-device path this build relies on). + // No newer self-card known yet: X presenting its own valid self-card is trusted. assert!( classify_dialer(&s, &self_uid, &v1, &x_id, NOW, None).is_some(), "own device authorizes on its own self-card before any newer card exists" ); - // Once v2 is the newest known self-card, X (absent from v2) is refused even - // though its old v1 card still delegates it. + // Once v2 is newest, X (absent from v2) is refused despite its old v1 delegation. assert!( classify_dialer(&s, &self_uid, &v1, &x_id, NOW, Some(&v2)).is_none(), "a revoked own device presenting an old self-card must NOT authorize (W7)" @@ -7496,11 +6726,8 @@ mod tests { ); } - // W1: `fetch_disclosed` authenticates the discloser (its `grant.by`) via - // `node_is_authorized` before reconstructing. A device of our own user or of an - // established friend passes; an unknown node (the `grant.by` of an unsolicited - // grant sealed to us by a stranger) is refused, so only established friends can - // push us disclosed content. + // W1: `fetch_disclosed` authenticates the discloser via `node_is_authorized`; a self or + // friend device passes, an unknown node (stranger-sealed grant) is refused. #[test] fn w1_discloser_must_be_self_or_friend() { let self_user = kp(0x01); @@ -7532,10 +6759,8 @@ mod tests { ); } - // W2: a superseded-epoch chunk keeps its §7.4 owner gate. Once a republish drops - // the old chunk from `vault_blobs`, `owned_chunks` still holds it, so an - // unauthenticated dialer and a non-audience friend are both refused, while the - // grant's audience is still served - the chunk never regresses to the residual. + // W2: a superseded-epoch chunk keeps its §7.4 owner gate via `owned_chunks` - unauthed + // dialer and non-audience friend refused, grant audience still served. #[test] fn w2_superseded_chunk_stays_owner_gated() { let vid = [0x55; 32]; @@ -7544,8 +6769,7 @@ mod tests { let friend_node = [0xCC; 32]; let mut s = Shared::default(); - // Published under an old epoch, then superseded: gone from vault_blobs but - // retained in owned_chunks. + // Superseded: gone from vault_blobs but retained in owned_chunks. s.owned_chunks.insert(old_chunk, vid); // Record a grant that disclosed this old chunk to `audience_user`. @@ -7575,8 +6799,7 @@ mod tests { }; s.disclosure.record(&fg, &body); - // Unauthenticated dialer: refused (pre-fix this fell through to the residual - // `return true` because the chunk was no longer in any current vault_blobs). + // Unauthenticated dialer: refused. assert!( !authorize_fetch(&s, &[0x99; 32], &old_chunk), "unauthenticated dialer refused a superseded owned chunk (W2)" @@ -7595,18 +6818,15 @@ mod tests { authorize_fetch(&s, &friend_node, &old_chunk), "the audience of a grant covering the chunk is still served" ); - // F1 (design §3.5) default-deny: a chunk in neither the owned nor replica set - // is served to NO ONE. With a durable blob store the old residual `return true` - // was a post-reboot public leak of every owned blob; now it is a hard refusal. + // F1 default-deny: a chunk in neither the owned nor replica set is served to no one. assert!( !authorize_fetch(&s, &[0x99; 32], &[0xAB; 32]), "an unknown chunk is refused under default-deny (F1)" ); } - // W8/§7.4: a chunk held AS A REPLICA is served only to the vault owner's - // delegated devices or a current replica-set member - never to an arbitrary - // dialer, which the old residual `return true` let through. + // W8/§7.4: a REPLICA-held chunk is served only to the owner's devices or a current + // replica-set member, never an arbitrary dialer. #[test] fn w8_replica_held_chunk_is_gated() { let vid = [0x77; 32]; @@ -7634,23 +6854,20 @@ mod tests { build_card(&owner_user, &owner_dev1, &[0x50; 32], None), ); - // Unauthorized dialer (never authenticated, not a member): refused. This is - // exactly the leak the pre-W8 residual `return true` allowed. + // Unauthorized dialer (never authenticated, not a member): refused. assert!( !authorize_fetch(&s, &stranger, &chunk), "an arbitrary dialer is refused a replica-held chunk (W8)" ); - // (a) the owner's known device, classified Friend(owner) via the control - // stream: served. + // (a) the owner's known device, classified Friend(owner): served. s.blob_auth.insert(dev1, BlobAuth::Friend(owner_uid)); assert!( authorize_fetch(&s, &dev1, &chunk), "the owner's delegated device is served (§7.4 a)" ); - // (a) the owner's OTHER device, authenticated by the card it presented - // (replica_owner_device -> ReplicaDevice): served. + // (a) the owner's OTHER device, authenticated by its presented card (ReplicaDevice): served. let dev2_card = build_card(&owner_user, &owner_dev2, &[0x50; 32], None); assert_eq!( replica_owner_device(&s, &dev2_card, &dev2, NOW), @@ -7663,8 +6880,7 @@ mod tests { "the owner's other delegated device is served (§7.4 a)" ); - // (b) a current replica-set member, by TLS-authenticated node id: served for - // repair, no control-stream handshake required. + // (b) a current replica-set member, by node id: served for repair, no handshake. assert!( authorize_fetch(&s, &member, &chunk), "a current replica-set member is served for repair (§7.4 b)" @@ -7697,8 +6913,7 @@ mod tests { ); } - // S3: a friend accept is bound to the ticket's issuer - both the accept's card - // user and the friendship's parties must match the ticket user. + // S3: a friend accept is bound to the ticket issuer (card user + friendship parties match). #[test] fn s3_accept_must_bind_ticket_issuer() { let issuer_key = kp(0x60); @@ -7732,8 +6947,7 @@ mod tests { assert!(!accept_binds_ticket(&accept, &issuer, &mismatched)); } - // W2: the highest-seen epoch persists across sync calls (shared DocStore), so - // a genuinely-signed but older/equal announce is refused on a later sync. + // W2: the highest-seen epoch persists across syncs, so a signed older/equal announce is refused. #[test] fn w2_rollback_persists_across_syncs() { let user = kp(1); @@ -7759,10 +6973,8 @@ mod tests { assert!(t.is_empty(), "equal epoch is refused"); } - // C1: the embedded relay's friend-gate admits only this node itself and nodes - // delegated by an established friend's newest card - never arbitrary peers - - // and it tracks the live friend set (a peer befriended after start is - // admitted with no relay restart). + // C1: the relay friend-gate admits only this node and nodes delegated by a friend's + // newest card, tracking the live friend set (befriend-after-start is admitted). #[test] fn c1_relay_gate_admits_only_self_and_friends() { let self_user_key = kp(1); @@ -7786,14 +6998,12 @@ mod tests { // Self is always admitted (it registers on its own relay as home relay). assert!(gate.allows(&eid(&self_node_key), None)); - // Before the friendship exists, the friend's node and any stranger are - // denied - the relay is not an open forwarder. + // Before the friendship exists, the friend's node and any stranger are denied. assert!(!gate.allows(&eid(&friend_node), None)); assert!(!gate.allows(&eid(&kp(99)), None)); - // Invite bootstrap: a stranger presenting a live invite-ticket token we - // issued (as its relay auth token) is admitted so it can reach us to - // complete the handshake; a bogus/unknown token is not. + // Invite bootstrap: a stranger presenting a live issued ticket token is admitted; a + // bogus/unknown token is not. let ticket = build_ticket(&self_user_key, self_node, vec![], vec![], NOW + 3600).unwrap(); let good = ticket_auth_token(&ticket.token); shared.write().unwrap().tickets.issue(&ticket); @@ -7824,10 +7034,8 @@ mod tests { assert!(!gate.allows(&eid(&kp(99)), None)); } - // W1: a card injects an addressing hint only for a node it validly delegates. - // `card.verify()` covers the card self-signature but not the per-node - // user->node delegations, so an entry carrying an invalid delegation must not - // be injected even when the card itself is validly signed. + // W1: a card injects an addressing hint only for a node it validly delegates; a bogus + // delegation entry is rejected even when the card's self-signature is valid. #[test] fn w1_hint_gate_rejects_undelegated_node() { let user = kp(5); @@ -7839,9 +7047,8 @@ mod tests { NOW )); - // Append a third party's node_id with a bogus (all-zero) delegation, then - // re-sign the card so its self-signature is valid (a malicious friend - // controls their own card). The bogus entry must be rejected by the gate. + // Append a third party's node_id with a bogus delegation, re-sign the card (a friend + // controls their own card): the bogus entry must be rejected. let victim = kp(7); card.nodes.push(NodeEntry { node_id: victim.verifying_key().to_bytes(), @@ -7858,8 +7065,7 @@ mod tests { ); } - // W4: distinct relay networks are counted by host, so a diversity warning - // (set < 2 networks) reflects real redundancy, not just relay-URL count. + // W4: distinct relay networks are counted by host, not raw relay-URL count. #[test] fn w4_distinct_relay_networks_dedup_by_host() { // Same host, different ports = one network. @@ -7915,8 +7121,7 @@ mod tests { ) } - // W15 (§8, §10.2): the paper-card backstop renders one printable page per retained - // share of an owned set, and refuses an unknown set. + // W15: the paper-card backstop renders one page per retained share, and refuses an unknown set. #[test] fn w15_paper_cards_render_one_card_per_share() { let a = kp(0x71); @@ -7948,10 +7153,9 @@ mod tests { assert!(err.to_string().contains("no owned recovery set"), "{err}"); } - // §9.3 steps 1-3 (local half): the teardown drops the ex-friend from the graph, - // deletes everything we HOLD of them, queues their replicas of our vault for - // re-placement, and records a pending re-split (they were a trustee), while - // reporting what we PLACED on them for the outbound DeleteRequests. + // §9.3 steps 1-3 (local half): the teardown drops the ex-friend, deletes what we HOLD of + // them, queues their replicas for re-placement, records a pending re-split, and reports + // what we PLACED on them for the outbound DeleteRequests. #[test] fn w5_teardown_removes_all_ex_friend_state() { let ex_user_key = kp(0x50); @@ -8029,8 +7233,8 @@ mod tests { assert!(!s.held_grants.contains_key(&ex_user)); assert!(!s.held_shares.contains_key(&their_rsid)); - // Their node is queued for replica re-placement; a re-split is pending (NOT - // started - §9.3.4 prompt), with the suggested new set = the old honest trustees. + // Their node is queued for re-placement; a re-split is pending (not started), with + // the suggested new set = the old honest trustees. assert!(s.unfriended_nodes.contains(&ex_node)); let pend = s .pending_resplits @@ -8057,10 +7261,9 @@ mod tests { assert_eq!(out.ex_addrs.len(), 1); } - // §9.3 step 3 (the critical invariant): the re-split stands up a new set, REFUSES to - // destroy the old shares until the new set is proven live (>= M + slack), then - once - // live - destroys them, stranding the ex-friend's retained old share below M. The - // destroy is only ever produced through the guard. + // §9.3 step 3 (critical invariant): the re-split REFUSES to destroy old shares until the + // new set is proven live (>= M + slack), then destroys them, stranding the ex-friend's + // old share below M. The destroy is only ever produced through the guard. #[test] fn w5_resplit_guards_destroy_until_new_set_live() { let owner_node = kp(0x30); // signs grants + challenges (the daemon node key) @@ -8090,8 +7293,7 @@ mod tests { ) .expect("re-split stands up among the remaining trustees"); assert_eq!(open.new_peers.len(), 2, "new set is B + C (A excluded)"); - // §8 gap 3: each new grant carries the co-trustee roster (the OTHER new trustee) - // AND the latest announce refs - mirroring the original split's grants. + // §8: each new grant carries the co-trustee roster + the latest announce refs. for p in &open.new_peers { let g = p.grant.as_ref().unwrap(); assert_eq!(g.cotrustees.len(), 1, "roster excludes the recipient"); @@ -8107,8 +7309,7 @@ mod tests { Err(carapace_friend::FriendError::NewSetNotLive) )); - // Collect attestations from the new set until it goes live. Each trustee's fresh - // share rides in its grant; answer with the trustee's own node key. + // Collect attestations until the new set goes live (each trustee's share rides in its grant). let node_key_for = |node: &[u8; 32]| -> SigningKey { for k in [&b, &c] { if k.verifying_key().to_bytes() == *node { @@ -8144,9 +7345,8 @@ mod tests { assert_eq!(open.rs.phase(), ResplitPhase::Complete); } - // A set with too few remaining honest trustees cannot form a new working set: a - // 2-of-2 with one trustee unfriended leaves a single node, below M - it surfaces as - // an error rather than a silently broken re-split. + // Too few remaining honest trustees to form a new set (2-of-2 minus one = 1 < M) surfaces + // as an error, not a silently broken re-split. #[test] fn w5_resplit_refuses_impossible_sets() { let owner_node = kp(0x30); @@ -8171,9 +7371,8 @@ mod tests { .is_err()); } - // §9.3.4 liveness window: a peer counts as online iff it answered within - // RESPLIT_ONLINE_WINDOW_SECS. The boundary is inclusive; a bare None (never seen) is - // never online. + // §9.3.4 liveness window: online iff answered within RESPLIT_ONLINE_WINDOW_SECS (inclusive + // boundary); None (never seen) is never online. #[test] fn w5_online_within_window_boundary() { let now = 1_800_000_000u64; @@ -8192,11 +7391,9 @@ mod tests { ); } - // §9.3 step 4: registering a COMPLETED re-split makes the new set the active one - - // its id takes over grant-refresh (`granted`), attestation cadence (`share_sets`), and - // extend bookkeeping (`split_states`) - and retires the old set. It is a one-shot - // (guarded by `OpenResplit::registered`) and refuses to fire before the re-split is - // Complete (old shares destroyed). + // §9.3 step 4: registering a COMPLETED re-split makes the new set active (granted, + // share_sets, split_states) and retires the old set. One-shot (guarded by `registered`), + // refuses to fire before the re-split is Complete. #[test] fn w5_register_completed_resplit_activates_new_set() { let owner_node = kp(0x30); @@ -8213,8 +7410,7 @@ mod tests { s.announces.push(announce(&owner_node, vid, 1)); let suggested = vec![b.verifying_key().to_bytes(), c.verifying_key().to_bytes()]; - // (1) An OPEN but not-yet-Complete re-split is not registered: the old set stays - // active (nothing else has a working set yet). + // (1) An OPEN but not-yet-Complete re-split is not registered: the old set stays active. let fresh = build_resplit( &owner_node, &k_root, @@ -8311,9 +7507,8 @@ mod tests { assert!(!s.granted.contains_key(&old_rsid)); } - // §9.3 (audit follow-up): build_resplit must refuse an operator-supplied new set that - // includes the unfriended ex-trustee (re-granting them a live share would defeat the - // re-split) or contains duplicate pubkeys; the clean suggested set still builds. + // build_resplit refuses a new set that includes the ex-trustee or has duplicate pubkeys; + // the clean suggested set still builds. #[test] fn w5_build_resplit_rejects_ex_trustee_and_dupes() { let owner_node = kp(0x30); @@ -8412,12 +7607,9 @@ mod tests { .clone() } - /// W6/§6: a node running the embedded relay elects it only after a liveness - /// check (never unconditionally at startup), withdraws it from its card on a - /// health loss and re-advertises on recovery, and BUMPS the monotonic card - /// version on every such re-issue so peers accept the new card over the one they - /// hold (rollback rule). The W4 diversity count tracks the current advertise - /// state throughout. + // W6/§6: the embedded relay is elected only after a liveness check, withdrawn on health + // loss and re-advertised on recovery, BUMPING the monotonic card version on every re-issue. + // The W4 diversity count tracks the current advertise state. #[tokio::test] async fn w6_relay_advertise_withdraw_reissues_card_monotonically() -> Result<()> { let loopback = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 0)); @@ -8432,10 +7624,8 @@ mod tests { ) .await?; - // Startup: the relay-less card is built at a wall-clock version floor - // (unix seconds, for rollback survival across restarts, W6); the initial - // health check then elected the live relay, re-issuing WITH the URL at the - // next version. We assert monotonicity and a sane floor, not exact values. + // Startup builds a relay-less card at a wall-clock version floor, then the health + // check elected the live relay, re-issuing WITH the URL. Assert monotonicity, not values. let (v_adv, url_adv) = own_card_relay(&daemon); assert!( v_adv >= 2, @@ -8455,10 +7645,8 @@ mod tests { daemon.drive_relay_health(true); assert_eq!(own_card_relay(&daemon).0, v_adv, "no bump without a change"); - // Withdraw on loss requires N consecutive failed probes (W6 hysteresis): - // the first two failures are tentative and do NOT re-issue the card; only - // the third withdraws the relay URL, bumps the version, and drops it from - // the diversity count. + // Withdraw requires 3 consecutive failed probes (hysteresis): the first two are + // tentative, the third withdraws the URL, bumps the version, and drops the diversity count. daemon.drive_relay_health(false); assert_eq!( own_card_relay(&daemon).0, @@ -8501,8 +7689,8 @@ mod tests { // Strictly monotonic across the whole advertise/withdraw/re-advertise cycle. assert!(v_adv < v_wd && v_wd < v_re, "versions strictly increase"); - // §6 rollback rule end-to-end: a friend's DocStore accepts each successive - // re-issue as newer, and rejects a replay of an earlier one as a rollback. + // §6 rollback rule end-to-end: a friend's DocStore accepts each re-issue as newer and + // rejects a replay of an earlier one. let mut store = DocStore::new(); assert!(store.offer_card(&card_adv).is_ok()); assert!( @@ -8522,10 +7710,8 @@ mod tests { Ok(()) } - /// W6 probe hysteresis: a transient probe failure must not flap a healthy - /// relay. Two consecutive failed probes leave the advertised card untouched; - /// the third withdraws (one version bump). A success anywhere in a streak - /// resets the counter, so a later pair of failures is again tentative. + // W6 probe hysteresis: two failed probes leave the card untouched; the third withdraws + // (one bump). A success anywhere resets the counter. #[tokio::test] async fn w6_relay_probe_hysteresis() -> Result<()> { let loopback = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 0)); diff --git a/crates/carapaced/src/persist.rs b/crates/carapaced/src/persist.rs index dc526c6..c67ecea 100644 --- a/crates/carapaced/src/persist.rs +++ b/crates/carapaced/src/persist.rs @@ -1,20 +1,9 @@ -//! Durable runtime-state persistence (design §3.2-§3.5). -//! -//! `state.redb` is the source of truth on disk. In-RAM `Shared` stays the hot read -//! path; every mutation boundary funnels the WHOLE `Shared` + `DocStore` back to disk -//! in one redb transaction via [`persist_all`], then commits. The state is KB-MB, so -//! re-persisting all of it per mutation is cheap and CORRECT by construction: a field -//! cannot be silently forgotten because [`persist_all`] destructures `Shared` with no -//! `..` glob, and adding a `Shared` field fails to compile until it is categorized -//! (SEAL / PLAIN / EPH / DERIVE per design §3.3). -//! -//! ponytail: whole-state re-persist per mutation. Optimize to per-table incremental -//! writes only if profiling shows the funnel is hot (KB-MB state makes it a non-issue -//! for a personal-scale daemon). -//! -//! Secret categories (Shamir shares, Chela split polynomials, share grants) are AEAD -//! -sealed under `HKDF(K_root,"carapace/v1/state-seal")` (`carapace_crypto::state_seal`) -//! BEFORE the bytes touch redb. Everything else is signed/public metadata stored plain. +//! Durable runtime-state persistence. `state.redb` is the on-disk source of truth; +//! every mutation funnels the whole `Shared` + `DocStore` back in one redb txn via +//! [`persist_all`]. State is KB-MB, so re-persisting all of it per mutation is cheap; +//! `persist_all` destructures `Shared` with no `..` glob, so a new field fails to +//! compile until categorized (SEAL / PLAIN / EPH / DERIVE). Secret categories are +//! AEAD-sealed under `HKDF(K_root,"carapace/v1/state-seal")` before touching redb. use anyhow::{anyhow, bail, Context, Result}; use redb::{Database, ReadableDatabase, TableDefinition}; @@ -22,8 +11,6 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use zeroize::Zeroizing; -// Types + serialization helpers reached through the crate root (child modules see the -// parent's private items and its `use` imports). use super::{ AlarmRecord, EndpointAddr, EndpointId, GrantedTrustee, OpenResplit, OwnerGrants, PendingResplit, Placement, RecoveryScope, RecoverySet, ResplitPeer, Shared, TrackedCeremony, @@ -41,17 +28,13 @@ use carapace_wire::{ AnnounceRef, CeremonyAbort, ContactCard, Friendship, ShareGrant, VaultAnnounce, }; -/// The single redb table: category name -> serialized (and, for SEAL categories, -/// state-sealed) blob. One row per persisted category; re-persist-all overwrites -/// every row each mutation. +/// The single redb table: category name -> serialized (SEAL categories additionally +/// state-sealed) blob. One row per category. const STATE: TableDefinition<&str, &[u8]> = TableDefinition::new("state"); -// ------------------------------------------------------------------------- -// Length-prefixed binary codec (fuzz-safe reader with explicit bounds checks). -// ------------------------------------------------------------------------- +// Length-prefixed binary codec: variable-width `bytes` fields carry a big-endian u32 +// length prefix; the reader bounds-checks every read. -/// A little append-only encoder. Fixed-width fields are written raw; variable-width -/// fields (`bytes`) are length-prefixed with a big-endian `u32`. pub(crate) struct W { buf: Vec, } @@ -69,16 +52,13 @@ impl W { pub(crate) fn u64(&mut self, x: u64) { self.buf.extend_from_slice(&x.to_be_bytes()); } - /// A `usize` count/length, capped into a `u32` (state is KB-MB; no legitimate - /// count exceeds `u32`). pub(crate) fn len(&mut self, x: usize) { self.u32(u32::try_from(x).expect("persist count fits u32")); } - /// Raw fixed-width bytes, no length prefix (the reader must know the width). + /// Raw fixed-width bytes, no length prefix (reader must know the width). pub(crate) fn fixed(&mut self, b: &[u8]) { self.buf.extend_from_slice(b); } - /// Length-prefixed variable bytes. pub(crate) fn bytes(&mut self, b: &[u8]) { self.len(b.len()); self.buf.extend_from_slice(b); @@ -91,8 +71,7 @@ impl W { } } -/// A bounds-checked decoder. Every read is guarded; malformed/truncated input is a -/// loud error, never a panic or a partial read. +/// A bounds-checked decoder: malformed/truncated input is a loud error, never a panic. pub(crate) struct R<'a> { b: &'a [u8], pos: usize, @@ -102,12 +81,9 @@ impl<'a> R<'a> { pub(crate) fn new(b: &'a [u8]) -> Self { Self { b, pos: 0 } } - /// A safe pre-allocation size for `n` upcoming elements: capped by the bytes still - /// unconsumed (audit #11). Every element consumes at least one byte, so a legitimate - /// count can never exceed the remaining length; a hostile length prefix - /// (e.g. `0xFFFFFFFF` on an unauthenticated PLAIN row) is clamped to the real input - /// size instead of triggering a multi-GB eager `with_capacity` OOM before the loop - /// errors on truncation. + /// Safe pre-alloc size for `n` upcoming elements, capped by unconsumed bytes: every + /// element eats >=1 byte, so this clamps a hostile length prefix to real input size + /// instead of a multi-GB eager `with_capacity` OOM before the loop errors. fn cap(&self, n: usize) -> usize { n.min(self.b.len().saturating_sub(self.pos)) } @@ -133,7 +109,6 @@ impl<'a> R<'a> { self.take(8)?.try_into().expect("8 bytes"), )) } - /// A length/count field, returned as `usize`. pub(crate) fn len(&mut self) -> Result { Ok(self.u32()? as usize) } @@ -146,7 +121,6 @@ impl<'a> R<'a> { pub(crate) fn arr64(&mut self) -> Result<[u8; 64]> { Ok(self.take(64)?.try_into().expect("64 bytes")) } - /// Length-prefixed variable bytes. pub(crate) fn bytes(&mut self) -> Result<&'a [u8]> { let n = self.len()?; self.take(n) @@ -154,31 +128,22 @@ impl<'a> R<'a> { pub(crate) fn bool(&mut self) -> Result { Ok(self.u8()? != 0) } - /// True once every byte has been consumed (a trailing-garbage guard for callers - /// that expect an exact-fit blob). + /// True once every byte has been consumed (trailing-garbage guard). #[cfg(test)] pub(crate) fn done(&self) -> bool { self.pos == self.b.len() } } -// ------------------------------------------------------------------------- -// Database open (0600). -// ------------------------------------------------------------------------- - /// Open (creating if absent) `state.redb` at `path`, restricting it to `0600` on unix. -/// On a non-unix host the same caveat as `state.rs::write_secret` applies (set -/// `CARAPACE_PASSPHRASE` so secrets are additionally sealed under `K_root`). pub(crate) fn open_db(path: &Path) -> Result { let db = Database::create(path).with_context(|| format!("open state db {path:?}"))?; - // Create-then-chmod leaves a brief default-perm window (mirrors the identity-file - // caveat in state.rs); acceptable for the demo posture. restrict_perms(path)?; Ok(db) } -/// Whether `state.redb` already exists (design §3.5 tripwire: blobs/keys present but no -/// state.redb => a wiped/mismatched state dir, fail/warn loudly rather than start fresh). +/// Whether `state.redb` already exists (startup tripwire: blobs/keys present but no +/// state.redb => a wiped/mismatched state dir, fail loudly rather than start fresh). pub(crate) fn db_exists(path: &Path) -> bool { path.exists() } @@ -195,10 +160,6 @@ fn restrict_perms(_path: &Path) -> Result<()> { Ok(()) } -// ------------------------------------------------------------------------- -// Raw category read/write (used by persist_all / load_all). -// ------------------------------------------------------------------------- - /// Read one category blob from a read transaction; `None` if the row is absent. pub(crate) fn read_row(db: &Database, key: &str) -> Result>> { let txn = db.begin_read().context("begin read txn")?; @@ -214,10 +175,7 @@ pub(crate) fn read_row(db: &Database, key: &str) -> Result>> { .map(|v| v.value().to_vec())) } -// ------------------------------------------------------------------------- -// Category row keys (design §3.3). One redb row per category. -// ------------------------------------------------------------------------- - +// Category row keys: one redb row per category. mod cat { // PLAIN (signed/public/metadata; no secret). pub const CARDS: &str = "cards"; @@ -259,14 +217,11 @@ mod cat { pub const RESPLITS: &str = "resplits"; } -/// The redb table name used as the `state_seal` aad `table` component for every SEAL -/// row (the per-row `key` is the category name), binding a sealed blob to its exact -/// slot so a cross-category relocation fails to open (design §3.4). +/// The `state_seal` aad `table` component for every SEAL row (per-row `key` is the +/// category name): binds a sealed blob to its slot so a cross-category move fails to open. const SEAL_TABLE: &[u8] = b"state"; -// ------------------------------------------------------------------------- // Leaf encoders/decoders shared across categories. -// ------------------------------------------------------------------------- fn enc_set32(w: &mut W, set: &HashSet<[u8; 32]>) { w.len(set.len()); @@ -356,8 +311,6 @@ fn dec_frame_list(r: &mut R) -> Result> { Ok(out) } -/// A `HashMap<[u8;32], M>` where `M` is a framed message (the key is stored explicitly -/// even when it equals the message signer, so load never has to re-derive it). fn enc_map32_frame(w: &mut W, m: &HashMap<[u8; 32], M>) { w.len(m.len()); for (k, v) in m { @@ -442,8 +395,7 @@ fn dec_scope(r: &mut R) -> Result { } } -/// One owner-held trustee record (embeds a secret `Share` via its canonical JSON). -/// Only ever written inside a SEAL row. +/// One owner-held trustee record (embeds a secret `Share`; only written in a SEAL row). fn enc_granted_trustee(w: &mut W, t: &GrantedTrustee) { w.fixed(&t.user); w.fixed(&t.node); @@ -487,17 +439,12 @@ fn dec_resplit_peer(r: &mut R) -> Result { Ok(ResplitPeer { node, grant }) } -// ------------------------------------------------------------------------- -// The funnel (design §3.2.1): persist the WHOLE Shared + DocStore in one txn. -// ------------------------------------------------------------------------- +// The funnel: persist the whole Shared + DocStore in one txn. /// Persist every durable category of `Shared` + `docs` into `txn`, sealing secret -/// categories under `k_root` first. The caller commits `txn` (design §3.2.3: -/// commit BEFORE any externally visible effect) and crashes on commit failure. -/// -/// EXHAUSTIVE + COMPILE-ENFORCED (design §3.2.1): `Shared` is destructured with NO -/// `..` glob, so every field is either persisted or explicitly discarded here. Adding -/// a `Shared` field fails to compile until it is categorized. +/// categories under `k_root` first. Caller commits `txn` BEFORE any externally visible +/// effect and crashes on commit failure. `Shared` is destructured with no `..` glob, so +/// every field is persisted or explicitly discarded and a new field fails to compile. pub(crate) fn persist_all( txn: &redb::WriteTransaction, s: &Shared, @@ -541,9 +488,9 @@ pub(crate) fn persist_all( // --- DERIVE --- vault_blobs, needs_refetch, - // --- PLAIN-rebuild: reconstructed from `granted` at load (§3.3) --- + // --- PLAIN-rebuild: reconstructed from `granted` at load --- share_sets, - // --- EPH: rebuilt on reconnect / never persisted (§3.3) --- + // --- EPH: rebuilt on reconnect / never persisted --- tickets, peer_addrs, peer_last_seen, @@ -554,9 +501,8 @@ pub(crate) fn persist_all( test_now, } = s; - // EPH: address/liveness caches, rate limiter, per-session auth, test clock, - // outstanding invite tickets (die on reboot -> TicketUnknown, acceptable), and - // vault chunk keys (NEVER persist: an accidental write-through is a key dump). + // EPH: caches, rate limiter, per-session auth, test clock, invite tickets, and vault + // chunk keys (NEVER persist: a write-through is a key dump). let _ = ( tickets, peer_addrs, @@ -567,9 +513,7 @@ pub(crate) fn persist_all( blob_auth, test_now, ); - // PLAIN-rebuild: `share_sets` (AttestTracker) is reconstructed from `granted` at - // load (rebuild_share_sets); its only lost state is recent attestation timestamps, - // self-healed by the next §10.2 challenge round. + // PLAIN-rebuild: `share_sets` reconstructed from `granted` at load. let _ = share_sets; let mut t = txn.open_table(STATE).context("open state table (write)")?; @@ -663,17 +607,16 @@ pub(crate) fn persist_all( enc(|w| enc_set32(w, unfriended_nodes)), )?; - // ---- DERIVE: vault_blobs -> only {vid -> digest, chunk_ids} (never the manifest), - // UNIONed with the needs-refetch sources (vaults whose manifest failed to - // re-derive at startup) so a failed re-derive never erases a vault's - // blob-source record from disk. `vault_blobs` wins on a vid in both. + // DERIVE: vault_blobs -> only {vid -> digest, chunk_ids} (never the manifest), + // unioned with needs-refetch sources so a failed re-derive never erases a vault's + // blob-source record. `vault_blobs` wins on a vid in both. put( &mut t, cat::VAULT_BLOBS, enc_vault_blobs(vault_blobs, needs_refetch), )?; - // ---- F3: monotonic own-card version floor = max own card.version ---- + // F3: monotonic own-card version floor = max own card.version. let card_version = cards.iter().map(|c| c.version).max().unwrap_or(0); put( &mut t, @@ -681,11 +624,11 @@ pub(crate) fn persist_all( card_version.to_be_bytes().to_vec(), )?; - // ---- DocStore (§6 rollback high-water marks) ---- + // DocStore rollback high-water marks. put_frame_list(&mut t, cat::DOC_CARDS, docs.cards().cloned())?; put_frame_list(&mut t, cat::DOC_ANNOUNCES, docs.announces().cloned())?; - // ---- SEAL rows (sealed before touching redb) ---- + // SEAL rows (sealed before touching redb). put_sealed( &mut t, cat::HELD_SHARES, @@ -710,18 +653,13 @@ pub(crate) fn persist_all( Ok(()) } -/// Persist the whole state in one txn and commit it, fail-loud (design §3.2.5): a -/// commit failure CRASHES rather than continuing with RAM ahead of disk. The caller -/// holds the `shared` (and, for the `_with` path, `docs`) lock across this call so the -/// RAM mutation and the durable write share one critical section, and calls it BEFORE -/// any externally visible effect (§3.2.3-4). +/// Persist the whole state in one txn and commit, fail-loud: a commit failure CRASHES +/// rather than continuing with RAM ahead of disk. Caller holds the `shared` (and, on the +/// `_with` path, `docs`) lock across this call and calls it before any visible effect. pub(crate) fn commit_all(db: &Database, s: &Shared, docs: &DocStore, k_root: &[u8; 32]) { if let Err(e) = try_commit_all(db, s, docs, k_root) { - // §3.2.5: the daemon DIES on a commit failure - never continue with RAM ahead of - // disk. `abort()` (not a panic): a panic here fires while the caller holds the - // `shared` write lock, poisoning it and wedging the daemon half-alive on every - // later `.expect("shared lock")`. `abort()` takes the whole process down at once, - // no unwinding, no poisoned lock. + // abort(), not panic: a panic here fires under the caller's `shared` write lock, + // poisoning it and wedging the daemon half-alive; abort takes the process down clean. eprintln!( "carapace: FATAL redb state commit failed ({e:#}); aborting the daemon \ (design §3.2.5: never continue with RAM ahead of disk)." @@ -730,8 +668,6 @@ pub(crate) fn commit_all(db: &Database, s: &Shared, docs: &DocStore, k_root: &[u } } -/// One durable state commit: open a write txn, persist the whole state, and commit. -/// Any failure is returned so [`commit_all`] can abort the process loudly. fn try_commit_all(db: &Database, s: &Shared, docs: &DocStore, k_root: &[u8; 32]) -> Result<()> { let txn = db.begin_write().context("begin redb write txn")?; persist_all(&txn, s, docs, k_root)?; @@ -795,10 +731,7 @@ fn put_sealed( k_root: &[u8; 32], plaintext: Vec, ) -> Result<()> { - // The category plaintext (share JSON, polynomial bytes, share-grant bodies) is - // secret-equivalent. Wrap the caller's buffer (moved in, no copy) so it is WIPED after - // sealing rather than left in freed heap - the seal-side edge of audit #10. Covers - // every SEAL category, since all of them route through here. + // Secret-equivalent plaintext: wrap so it is wiped after sealing, not left in freed heap. let plaintext = Zeroizing::new(plaintext); let sealed = state_seal::seal(k_root, SEAL_TABLE, key.as_bytes(), &plaintext) .map_err(|e| anyhow!("seal {key}: {e}"))?; @@ -849,10 +782,9 @@ fn enc_ceremonies(m: &HashMap<[u8; 16], TrackedCeremony>) -> Vec { }) } -/// C1: a party we have standing to trust - an owner whose share we hold -/// (`held_grants` subject), an established friend, or ourselves (`docs.card`). Shared by -/// the abort and alarm bounds: an unauthenticated dispatch from a stranger must never be -/// able to write a durable row (durable disk-fill DoS otherwise). +/// A party we have standing to trust - an owner whose share we hold, an established +/// friend, or ourselves. Gates the abort/alarm durable bounds so a stranger's +/// unauthenticated dispatch cannot write a durable row (disk-fill DoS otherwise). fn signer_qualifies( signer: &[u8; 32], held_grants: &HashMap<[u8; 32], ShareGrant>, @@ -862,13 +794,9 @@ fn signer_qualifies( held_grants.contains_key(signer) || friends.contains_key(signer) || docs.card(signer).is_some() } -/// C1: persist only alarms whose SPONSOR (the `RecoveryOpen` signer) is a qualifying -/// party (see [`signer_qualifies`]). A stranger can self-sign a `RecoveryOpen` for any -/// subject and dial us unauthenticated (`serve_recovery_open`); without this bound each -/// such open would append an attacker-chosen ~1MiB alarm to disk unbounded. A stranger's -/// alarm stays RAM-only (still visible to `/api/status` for the session, just not -/// durable). The subject is deliberately NOT a qualifier: it is public, so an attacker -/// would just set `subject = our pubkey` to bypass the bound. +/// Persist only alarms whose sponsor (the `RecoveryOpen` signer) qualifies: a stranger's +/// alarm stays RAM-only so an unauthenticated open cannot append to disk unbounded. The +/// subject is deliberately not a qualifier (it is public: an attacker would set it to us). fn enc_alarms( m: &HashMap<[u8; 16], AlarmRecord>, held_grants: &HashMap<[u8; 32], ShareGrant>, @@ -894,8 +822,7 @@ fn enc_alarms( }) } -/// C1: persist only aborts whose signer is a qualifying party (see [`signer_qualifies`]). -/// A stranger's abort stays RAM-only so an unauthenticated dispatch cannot fill disk. +/// Persist only aborts whose signer qualifies; a stranger's abort stays RAM-only. fn enc_aborted( m: &HashMap<[u8; 16], Vec>, held_grants: &HashMap<[u8; 32], ShareGrant>, @@ -947,8 +874,7 @@ fn enc_pending_delete_sends(v: &[(Vec, Placement)]) -> Vec { enc(|w| { w.len(v.len()); for (addrs, placement) in v { - // Persist node ids only; direct addresses are hints, rebuilt via relay - // fallback on reconnect (§6 "addresses are hints, not identities"). + // Node ids only; direct addresses are hints, rebuilt via relay on reconnect. w.len(addrs.len()); for a in addrs { w.fixed(a.id.as_bytes()); @@ -966,8 +892,7 @@ fn enc_vault_blobs( m: &HashMap<[u8; 32], VaultBlobs>, needs_refetch: &HashMap<[u8; 32], BlobSource>, ) -> Vec { - // Retained-but-underivable sources ride in the same row; a vid present in - // `m` (re-derived or republished) supersedes its needs-refetch entry. + // Retained-but-underivable sources ride in the same row; a vid in `m` supersedes them. let extra: Vec<_> = needs_refetch .iter() .filter(|(vid, _)| !m.contains_key(*vid)) @@ -997,7 +922,7 @@ fn enc_held_shares(m: &HashMap) -> Vec { enc(|w| { w.len(m.len()); for (rsid, (share, _monitor)) in m { - // ShareMonitor is EPH (CRC self-validation cadence, rebuilt on load). + // ShareMonitor is EPH (rebuilt on load). w.u64(*rsid); w.bytes(share_to_json(share).as_bytes()); } @@ -1075,9 +1000,7 @@ fn enc_resplits(m: &HashMap) -> Vec { }) } -// ------------------------------------------------------------------------- -// Startup load (design §3.5). -// ------------------------------------------------------------------------- +// Startup load. /// A DERIVE vault-blob source row: `(vid, manifest digest, chunk ids)`. The decrypted /// `Manifest` is re-derived from the FsStore envelope at startup (never persisted). @@ -1089,26 +1012,21 @@ pub(crate) type BlobSource = ([u8; 32], Vec<[u8; 32]>); /// Everything reloaded from `state.redb` at startup. pub(crate) struct Loaded { - /// `Shared` with every persisted category filled and EPH fields left at their - /// defaults (the daemon rebuilds `rate`/`relay_health`/… on start). `vault_blobs` - /// is EMPTY here: its decrypted manifests are re-derived asynchronously from - /// `vault_blob_sources` against FsStore + `K_manifest`. + /// `Shared` with every persisted category filled and EPH fields defaulted. + /// `vault_blobs` is EMPTY: its manifests are re-derived async from `vault_blob_sources`. pub shared: Shared, - /// The rollback high-water-mark store (§6). pub docs: DocStore, - /// DERIVE (A1): `{vid -> (digest, chunk_ids)}` to re-derive `vault_blobs` manifests - /// from FsStore at startup. The decoded `Manifest` is NEVER persisted in clear. + /// DERIVE `{vid -> (digest, chunk_ids)}` to re-derive `vault_blobs` manifests from + /// FsStore at startup. The decoded `Manifest` is never persisted in clear. pub vault_blob_sources: Vec, /// F3: persisted monotonic own-card version floor. The fresh own card is minted at - /// `max(unix_now(), card_version + 1)` so its version strictly increases across a - /// restart even under rapid relay flapping. + /// `max(unix_now(), card_version + 1)` so its version strictly increases across restart. pub card_version: u64, } -/// Load and decode all persisted state (design §3.5). SEAL rows are opened under -/// `k_root`; a sealed row that fails to open ABORTS startup (fail loud, never -/// skip-and-continue — that silently loses a share). GC/router must not start until -/// after this returns and reconciliation completes. +/// Load and decode all persisted state. SEAL rows are opened under `k_root`; a sealed +/// row that fails to open ABORTS startup (fail loud, never skip-and-continue — that +/// silently loses a share). pub(crate) fn load_all(db: &Database, k_root: &[u8; 32]) -> Result { let mut s = Shared::default(); @@ -1212,7 +1130,7 @@ pub(crate) fn load_all(db: &Database, k_root: &[u8; 32]) -> Result { s.resplits = dec_resplits(&mut R::new(&b))?; } - // ---- PLAIN-rebuild: share_sets from granted (§3.3) ---- + // ---- PLAIN-rebuild: share_sets from granted ---- s.share_sets = rebuild_share_sets(&s.granted); // ---- DERIVE: vault_blob sources (manifests re-derived by the caller) ---- @@ -1221,7 +1139,6 @@ pub(crate) fn load_all(db: &Database, k_root: &[u8; 32]) -> Result { None => Vec::new(), }; - // ---- F3 own-card version floor ---- let card_version = match read_row(db, cat::CARD_VERSION)? { Some(b) => u64::from_be_bytes( b.as_slice() @@ -1231,7 +1148,7 @@ pub(crate) fn load_all(db: &Database, k_root: &[u8; 32]) -> Result { None => 0, }; - // ---- DocStore (§6 high-water marks) ---- + // ---- DocStore high-water marks ---- let mut docs = DocStore::new(); if let Some(b) = read_row(db, cat::DOC_CARDS)? { for c in dec_frame_list::(&mut R::new(&b))? { @@ -1255,14 +1172,13 @@ pub(crate) fn load_all(db: &Database, k_root: &[u8; 32]) -> Result { } /// Open a SEAL row under `k_root`. Absent -> `None`; present-but-unopenable -> loud -/// error (design §3.4/§3.5: never skip a share that will not decrypt). +/// error (never skip a share that will not decrypt). fn read_sealed(db: &Database, key: &str, k_root: &[u8; 32]) -> Result>>> { match read_row(db, key)? { None => Ok(None), Some(sealed) => { - // Return the `Zeroizing` buffer state_seal::open produced (do NOT `to_vec()` it - // into a plain Vec that drops unwiped): the decoder borrows it and it is wiped - // when the caller's binding drops - the read-side edge of audit #10. + // Keep the `Zeroizing` buffer open produced (no `to_vec()`): the decoder + // borrows it and it is wiped when the caller's binding drops. let opened = state_seal::open(k_root, SEAL_TABLE, key.as_bytes(), &sealed).map_err(|e| { anyhow!( @@ -1429,7 +1345,6 @@ fn dec_pending_delete_sends(r: &mut R) -> Result, Placeme let mut addrs = Vec::with_capacity(r.cap(acount)); for _ in 0..acount { let node = r.arr32()?; - // Reconstruct a bare-id EndpointAddr; direct addrs resolve via relay/hole-punch. if let Ok(id) = EndpointId::from_bytes(&node) { addrs.push(EndpointAddr::new(id)); } @@ -1473,7 +1388,7 @@ fn dec_held_shares(r: &mut R) -> Result> { for _ in 0..n { let rsid = r.u64()?; let share = share_from_json(&dec_str(r)?).map_err(|e| anyhow!("decode held share: {e}"))?; - // ShareMonitor is EPH: a fresh default monitor (re-runs its CRC self-check cadence). + // ShareMonitor is EPH: fresh default monitor. m.insert(rsid, (share, ShareMonitor::new())); } Ok(m) @@ -1640,8 +1555,7 @@ mod tests { } // Full funnel roundtrip across PLAIN + SEAL + DERIVE categories, plus fail-loud on a - // wrong K_root. Exercises the compile-enforced `persist_all` destructure against real - // shares/split-state (the hard SEAL path) and the share_sets rebuild-from-granted. + // wrong K_root. #[test] fn persist_load_roundtrips_all_categories() { use carapace_wire::Signed; diff --git a/crates/carapaced/src/state.rs b/crates/carapaced/src/state.rs index e91bded..2d471e5 100644 --- a/crates/carapaced/src/state.rs +++ b/crates/carapaced/src/state.rs @@ -1,22 +1,13 @@ -//! Daemon persistent state: a state directory holding this device's node key and -//! (for the demo) the user master key `k_root`. Both are load-or-generate. +//! Daemon persistent state: a directory holding this device's node key and the user +//! master key `k_root`, both load-or-generate. //! //! `node.key` — 32-byte Ed25519 node secret seed (unique per device). -//! `root.key` — 32-byte user master key `k_root` (SHARED across a user's -//! devices; the source of `K_userid`, `K_manifest`, `K_content`, -//! and `K_disclose`). +//! `root.key` — 32-byte user master key `k_root` (shared across a user's devices). //! -//! At-rest protection (W4): if `CARAPACE_PASSPHRASE` is set, both key files are -//! sealed with `carapace-crypto::atrest` (Argon2id -> XChaCha20-Poly1305) so a -//! stolen disk/backup/snapshot yields only ciphertext. Without a passphrase the -//! seeds are written as plaintext (0600 on unix); this is the documented demo -//! fallback and does NOT protect `k_root` — whose compromise is total vault and -//! identity compromise — against anything that can read the file. On non-unix the -//! plaintext fallback additionally has no permission restriction; set a -//! passphrase there. -//! -//! ponytail: no config file; the state dir *is* the config (listen = localhost, -//! discovery = none / direct addr). Add a config when a knob actually varies. +//! At-rest protection: if `CARAPACE_PASSPHRASE` is set, both key files are sealed with +//! `carapace-crypto::atrest` (Argon2id -> XChaCha20-Poly1305). Without a passphrase the +//! seeds are plaintext (0600 on unix, no restriction on non-unix); the documented demo +//! fallback that does NOT protect `k_root` against anything that can read the file. use anyhow::{bail, Context, Result}; use carapace_crypto::atrest::{self, AtRestBlob}; @@ -26,8 +17,7 @@ use ed25519_dalek::SigningKey; use std::path::{Path, PathBuf}; use zeroize::Zeroizing; -/// Environment variable holding the at-rest passphrase (W4). When present, key -/// files are Argon2id-sealed; when absent, seeds are stored as plaintext. +/// Env var holding the at-rest passphrase; present -> key files Argon2id-sealed. const PASSPHRASE_ENV: &str = "CARAPACE_PASSPHRASE"; /// Magic prefix marking a key file as an at-rest-sealed blob (vs. a raw seed). @@ -39,16 +29,12 @@ pub struct State { pub node_key: SigningKey, /// The user master key, shared across a user's devices. pub k_root: Zeroizing<[u8; 32]>, - /// The state directory holding `node.key`/`root.key` and (design §3) the durable - /// `blobs/` store and `state.redb`. `None` for a seed-only [`State::from_seeds`]: - /// the daemon then uses a process-unique ephemeral directory (cleaned up on drop), - /// so a from-seeds test daemon persists nowhere permanent. A reboot test uses - /// [`State::load_or_generate`] twice against the same dir. + /// State directory holding the key files, durable `blobs/`, and `state.redb`. `None` + /// for seed-only [`State::from_seeds`]: the daemon uses a process-unique ephemeral dir. pub dir: Option, - /// True iff this run FRESHLY generated the identity (neither `node.key` nor - /// `root.key` existed before). The daemon's §3.5 startup tripwire uses this to tell a - /// genuine first start (an empty `state.redb` is expected) from a WIPED `state.redb` - /// beside a surviving identity - the worst variant, where firing loudly matters. + /// True iff this run freshly generated the identity (neither key file existed before). + /// The startup tripwire uses this to tell a genuine first start from a wiped + /// `state.redb` beside a surviving identity. pub keys_freshly_generated: bool, } @@ -62,9 +48,7 @@ impl State { let pass = passphrase.as_ref().map(|p| p.as_bytes()); let node_path = dir.join("node.key"); let root_path = dir.join("root.key"); - // Fresh identity iff NEITHER key existed before this call (a genuine first run). - // Captured before `load_or_generate_seed` writes them, so the §3.5 tripwire can - // distinguish a first start from a wiped `state.redb` beside a surviving identity. + // Fresh iff neither key existed before this call; captured before the seeds are written. let keys_freshly_generated = !node_path.exists() && !root_path.exists(); let node_seed = load_or_generate_seed(&node_path, pass)?; let root = load_or_generate_seed(&root_path, pass)?; @@ -76,45 +60,38 @@ impl State { }) } - /// Build state directly from raw seeds (used in tests and for scripted - /// two-device setups that share a `k_root`). No state directory: the daemon - /// persists to a process-unique ephemeral dir it cleans up on drop. + /// Build state directly from raw seeds (tests, scripted two-device setups sharing a + /// `k_root`). No state directory: the daemon persists to an ephemeral dir. pub fn from_seeds(node_seed: [u8; 32], k_root: [u8; 32]) -> Self { Self { node_key: SigningKey::from_bytes(&node_seed), k_root: Zeroizing::new(k_root), dir: None, - // Seed-only: this constructor never writes key files, so the §3.5 tripwire for - // it keys on the durable `blobs/` presence, not a fresh-identity flag. + // Never writes key files; the tripwire keys on durable `blobs/` presence. keys_freshly_generated: false, } } - /// Like [`State::from_seeds`] but pinned to a specific state directory, so a test - /// can drop the daemon and reboot a fresh one from the SAME seeds AND the same - /// durable `blobs/`/`state.redb` (design §6 reboot-survival tests). + /// Like [`State::from_seeds`] but pinned to a state directory, so a test can reboot a + /// fresh daemon from the same seeds AND durable `blobs/`/`state.redb`. pub fn from_seeds_in(dir: &Path, node_seed: [u8; 32], k_root: [u8; 32]) -> Self { Self { node_key: SigningKey::from_bytes(&node_seed), k_root: Zeroizing::new(k_root), dir: Some(dir.to_path_buf()), - // Seed-only (does not write key files): the reboot tests using this rely on - // the durable `blobs/` presence for the tripwire, not a fresh-identity flag. keys_freshly_generated: false, } } /// The user signing key: `Ed25519(seed = HKDF(k_root, "…user-identity"))`. - /// Identical across a user's devices because `k_root` is shared. pub fn user_key(&self) -> SigningKey { user_key_from_seed(&k_userid(&*self.k_root)) } } -/// Read a 32-byte seed file, or generate + persist one. When `passphrase` is -/// `Some`, the seed is Argon2id-sealed at rest; otherwise it is stored as a raw -/// plaintext seed (0600 on unix). A sealed file loaded without a passphrase (or -/// vice versa) is an explicit error rather than a silent wrong result. +/// Read a 32-byte seed file, or generate + persist one. `Some` passphrase -> the seed is +/// Argon2id-sealed at rest, else raw plaintext (0600 on unix). A passphrase/plaintext +/// mismatch either way is an explicit error, never a silent wrong result. fn load_or_generate_seed(path: &Path, passphrase: Option<&[u8]>) -> Result<[u8; 32]> { if path.exists() { let bytes = std::fs::read(path).with_context(|| format!("read {path:?}"))?; @@ -220,9 +197,8 @@ fn write_secret(path: &Path, bytes: &[u8]) -> Result<()> { mod tests { use super::*; - // W4: with a passphrase, key files are sealed (magic + ciphertext, never the - // raw seed) and re-open to the identical seed; a wrong/absent passphrase - // fails to open rather than returning garbage. + // With a passphrase: sealed (magic + ciphertext, never the raw seed), re-opens to the + // same seed, and a wrong/absent passphrase fails to open rather than returning garbage. #[test] fn sealed_at_rest_roundtrips_and_hides_seed() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/carapaced/tests/attestation_drift.rs b/crates/carapaced/tests/attestation_drift.rs index c7225f7..aba6774 100644 --- a/crates/carapaced/tests/attestation_drift.rs +++ b/crates/carapaced/tests/attestation_drift.rs @@ -1,9 +1,6 @@ -//! W4 owner attestation cadence (§10.2): a trustee that stops attesting drops the -//! attested-live count below `M + slack`, and the maintenance round surfaces an -//! `extend` recommendation on the status surface. -//! -//! BOUNDED (§11 lesson): the attestation cadence + freshness window run against an -//! injected clock (tiny intervals, an advancing `now`), never a real daily cadence. +//! W4 owner attestation cadence (§10.2): a trustee that stops attesting drops attested-live +//! below `M + slack`, and the maintenance round surfaces an `extend` recommendation. Bounded: +//! the cadence + freshness window run against an injected clock, never a real daily cadence. use std::collections::HashMap; @@ -28,9 +25,8 @@ async fn a_befriends(a: &Daemon, peer: &Daemon) -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 6)] async fn stalled_trustee_drops_live_below_target_and_surfaces_extend() -> Result<()> { - // Owner A and three trustees. M = 2, slack = 1 => live target 3, one share of - // headroom under the §8.3 soft cap (3*2 - 1 = 5), so a single drop recommends - // EXTEND (not re-split). + // M = 2, slack = 1 => live target 3, with headroom under the §8.3 soft cap, so a single + // drop recommends EXTEND (not re-split). let a = Daemon::start(seeds(0x01, 0xA0)).await?; let b = Daemon::start(seeds(0x11, 0xB0)).await?; let c = Daemon::start(seeds(0x21, 0xC0)).await?; diff --git a/crates/carapaced/tests/ceremony.rs b/crates/carapaced/tests/ceremony.rs index 32c26c6..379f080 100644 --- a/crates/carapaced/tests/ceremony.rs +++ b/crates/carapaced/tests/ceremony.rs @@ -1,18 +1,7 @@ -//! W2 recovery ceremony wired end-to-end over the daemon control stream (§8.5 + §8.4). -//! -//! The acceptance test drives a full ceremony from a KEY-LESS claimant to a recovered -//! `K_root` that EQUALS the original: an owner splits `M`-of-`N` to trustees (W3 grants -//! delivered); a sponsor trustee opens a ceremony for the subject with a fresh claimant -//! device; the open fans out to the co-trustees (each raises the alarm); `M` trustees -//! approve; before the delay NO share releases; after advancing the INJECTED clock past -//! `first_seen + recovery_delay` the `M` approving trustees release HPKE-sealed shares; -//! the claimant collects `M`, recovers `K_root`, and it matches. Plus: a subject-key -//! `CeremonyAbort` cancels permanently (takeover flagged, no release); a non-trustee -//! cannot open; sub-`M` never releases; and a share never crosses the wire unsealed. -//! -//! Every test is BOUNDED (§11 lesson): the 72 h abort delay is exercised with a fast -//! INJECTED clock (`set_test_clock`) - never a real sleep - all dials are bounded by the -//! daemon connect timeout, and every daemon is torn down at the end. +//! Recovery ceremony end-to-end over the daemon control stream (§8.5 + §8.4): a key-less +//! claimant recovers a `K_root` equal to the original, plus subject-abort cancels, non-trustee +//! cannot open, sub-`M` never releases, and shares never cross the wire unsealed. Bounded: the +//! 72 h delay uses an injected clock (`set_test_clock`), never a real sleep. use anyhow::{Context, Result}; use carapace_wire::AnnounceRef; @@ -273,12 +262,10 @@ async fn subject_abort_cancels_and_flags_takeover() -> Result<()> { Ok(()) } -/// §8.5 step 3, message-reordering vector: a subject-signed `CeremonyAbort` that reaches -/// a trustee BEFORE that trustee's `RecoveryOpen` (fan-out is best-effort per-peer with no -/// ordering) MUST still cancel the ceremony permanently. Without the durable -/// `aborted_ceremonies` record the early abort is dropped, the later open re-tracks a -/// fresh non-aborted ceremony, and an approving trustee releases its share at delay-expiry -/// despite an authoritative abort - the exact silent takeover step 3 exists to stop. +/// §8.5 step 3 reordering vector: a subject-signed abort that reaches a trustee BEFORE its +/// `RecoveryOpen` (unordered fan-out) must still cancel permanently. Without the durable +/// `aborted_ceremonies` record the later open would re-track a fresh non-aborted ceremony +/// that releases at delay-expiry - the silent takeover step 3 exists to stop. #[tokio::test(flavor = "multi_thread", worker_threads = 8)] async fn abort_before_open_still_cancels() -> Result<()> { let (a, b, c, d, _k_root) = setup().await?; diff --git a/crates/carapaced/tests/default_deny_after_reboot.rs b/crates/carapaced/tests/default_deny_after_reboot.rs index 485f307..ddebfdc 100644 --- a/crates/carapaced/tests/default_deny_after_reboot.rs +++ b/crates/carapaced/tests/default_deny_after_reboot.rs @@ -1,21 +1,10 @@ -//! Audit #4 regression (design §3.5 / §11 default-deny after reboot). -//! -//! A device reaches gate state through a §11 merge (`publish_merged`): a receiver with its -//! own concurrent edit pulls a peer's concurrent edit, reconciles, and RE-PUBLISHES the -//! merged vault - inserting `owned_chunks` (incl. the merged manifest-envelope digest), -//! `announces`, `grants`, and `vault_blobs`. The pre-fix `publish_merged` returned WITHOUT -//! committing, so after a reboot the device default-denied its OWN merged blobs (owned_chunks -//! empty) and rolled back its own announce. -//! -//! The merge is the LAST persisted op before the reboot (any later `persist_locked` - e.g. a -//! disclose - would re-commit the whole state and mask the missing commit), so the reload -//! exercises `publish_merged`'s own persistence. After reboot the fetch gate must decide from -//! the reloaded state: -//! -//! - an owner device (same `k_root`) is SERVED the merge-unique envelope chunk (owned_chunks -//! survived - the #4 catch); -//! - an unauthenticated stranger is REFUSED it (F1 default-deny); -//! - a disclosed audience friend is SERVED a disclosed chunk (the audience arm survived). +//! §3.5/§11 default-deny after reboot: a device reaches gate state through a §11 merge +//! (`publish_merged` inserts owned_chunks incl. the merged envelope digest, announces, grants, +//! vault_blobs). The pre-fix `publish_merged` returned WITHOUT committing, so a reboot +//! default-denied the device's OWN merged blobs. The merge is the LAST persisted op before the +//! reboot (a later persist would mask the missing commit). After reboot the fetch gate must +//! serve an owner device the merge-unique chunk, refuse an unauthenticated stranger, and serve +//! a disclosed audience friend a disclosed chunk. use anyhow::{Context, Result}; use carapaced::{Daemon, State}; @@ -62,9 +51,8 @@ async fn default_deny_survives_reboot_after_merge() -> Result<()> { assert_eq!(a.publish_vault(src_a.path(), vid).await?, 1); assert_eq!(b.publish_vault(src_b.path(), vid).await?, 1); - // Friendship + disclosure of B's OWN published chunk to F, BEFORE the merge. This - // persists (whole-state), so it must precede the merge - otherwise its commit would - // mask publish_merged's missing one. The disclosed chunk is B's notes.txt chunk, + // Friendship + disclosure BEFORE the merge (its whole-state persist would otherwise + // mask publish_merged's missing commit). The disclosed chunk is B's notes.txt chunk, // owned via publish_vault and retained across the merge (owned_chunks is additive). befriend(&b, &f).await?; let grant = b.disclose_files(vid, &["notes.txt"], &[f.user_id()])?; @@ -73,9 +61,8 @@ async fn default_deny_survives_reboot_after_merge() -> Result<()> { .first() .context("disclosure grant covers a chunk")?; - // A single directed pull: B sees A's concurrent edit, merges, and runs - // `publish_merged` - the LAST persisted op before the reboot. It inserts the merged - // manifest-envelope digest into owned_chunks. + // A single directed pull: B merges A's concurrent edit and runs `publish_merged` (the + // LAST persisted op before the reboot), inserting the merged envelope digest into owned_chunks. let out = tempfile::tempdir()?; let recon = b.sync_from(a.addr()?, out.path()).await?; assert!( @@ -102,8 +89,7 @@ async fn default_deny_survives_reboot_after_merge() -> Result<()> { // ---- reboot B from the SAME dir: gate state must be reloaded from disk ---- let b2 = Daemon::start(State::from_seeds_in(b_dir.path(), b_seed, K_ROOT)).await?; - // Robust non-network signal for #4: the merge-unique owned chunk survived. Pre-fix, the - // merged owned_chunks/announce were never committed, so this is absent after reload. + // The merge-unique owned chunk survived (pre-fix it was never committed, absent after reload). assert!( b2.owns_chunk(&merged_digest), "audit #4: publish_merged's owned_chunks (merged envelope digest) must survive the reboot" @@ -132,9 +118,8 @@ async fn default_deny_survives_reboot_after_merge() -> Result<()> { "an unauthenticated stranger is refused (F1 default-deny)" ); - // (3) disclosed audience friend: authenticate (classifies F as a friend), then fetch the - // disclosed chunk. Served only because BOTH owned_chunks and the disclosure audience - // survived the reboot. + // (3) disclosed audience friend: authenticate, then fetch the disclosed chunk. Served only + // because BOTH owned_chunks and the disclosure audience survived the reboot. f.pull_doc_counts(b2_addr.clone()) .await .context("friend authenticates to rebooted B")?; diff --git a/crates/carapaced/tests/friend_replica.rs b/crates/carapaced/tests/friend_replica.rs index 0231e8c..acb68a9 100644 --- a/crates/carapaced/tests/friend_replica.rs +++ b/crates/carapaced/tests/friend_replica.rs @@ -1,19 +1,9 @@ -//! Phase-1-close acceptance: friendship handshake, friendship-gated control -//! stream (W5), replica placement, repair, and reconstruction from a surviving -//! replica - all over in-process localhost iroh endpoints. -//! -//! Topology: owner `A` with a second delegated device `A2` (shared `k_root`); -//! three independent friends `B`, `C`, `E`; and a stranger `D`. -//! -//! 1. A issues single-use tickets; B, C, and E each drive `befriend` to a -//! dual-signed `Friendship`, persisted on both sides. -//! 2. A publishes a vault and places replicas on B and C (r = 2). -//! 3. W5: the stranger D pulls A's control stream and receives no documents, -//! while a friend (B) does - proving the gate keys on the friend graph. -//! 4. B is declared unreachable past grace; A repairs onto the spare friend E -//! and re-announces the new set {C, E}. -//! 5. A2 (a delegated device of A) reconstructs the vault: documents from A, -//! ciphertext blobs from the surviving replica C. Bytes match A's source. +//! Phase-1-close acceptance over in-process localhost iroh endpoints: friendship handshake, +//! W5-gated control stream, replica placement, repair, and reconstruction from a surviving +//! replica. Owner `A` + second delegated device `A2` (shared `k_root`); friends `B`, `C`, `E`; +//! stranger `D`. A befriends B/C/E, publishes + places replicas on B and C, proves the W5 gate +//! (D gets nothing, B does), repairs B (lost past grace) onto E, then A2 reconstructs the vault +//! (docs from A, blobs from surviving replica C) byte-for-byte. use std::collections::{BTreeMap, HashMap}; @@ -146,11 +136,9 @@ async fn friend_gate_replica_placement_repair_and_recovery() -> Result<()> { } // ---- W8 regression: A2 must NOT re-serve the reconstructed ciphertext ---- - // A2 fetched C's ciphertext to rebuild the vault. That must land in a throwaway - // store, never A2's router-served store; otherwise the replica fetch gate is void - // on any device that reconstructs. Learn one vault ChunkID via a disclosure to - // friend B (convergent, so the identical ChunkID A2 fetched), then confirm the - // stranger D is refused it off A2. + // A2's fetch of C's ciphertext must land in a throwaway store, not its router-served + // store, else the replica gate is void on any reconstructing device. Learn one ChunkID via + // a disclosure to B, then confirm stranger D is refused it off A2. let probe_grant = a.disclose_files(vid, &["readme.txt"], &[b.user_id()])?; let cid = *b .granted_chunk_ids(&probe_grant)? @@ -167,17 +155,15 @@ async fn friend_gate_replica_placement_repair_and_recovery() -> Result<()> { Ok(()) } -/// §11 regression: a routine local edit must push the new epoch to the CURRENT -/// enrolled replica set, not just re-announce. Owner A places a replica on friend C, -/// then edits the tree and republishes (epoch bumps). The enrolled replica C must end -/// up holding the NEW epoch's manifest + chunks, so a fresh delegated device can -/// reconstruct the edited vault with its ciphertext served entirely off C. +/// §11 regression: a routine local edit must push the new epoch to the CURRENT enrolled +/// replica set, not just re-announce. A places a replica on C, edits + republishes (epoch +/// bumps); C must hold the NEW epoch so a fresh delegated device reconstructs entirely off C. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn routine_edit_pushes_new_epoch_to_enrolled_replica() -> Result<()> { const ROOT_A: u8 = 0xA0; let a = Daemon::start(daemon_seeds(0x03, ROOT_A)).await?; - // A fresh delegated device of A that has never held this vault, so every blob it - // reconstructs must be fetched from the replica (nothing in its own store). + // A fresh delegated device of A that never held this vault, so every reconstructed blob + // must be fetched from the replica. let a2 = Daemon::start(daemon_seeds(0x04, ROOT_A)).await?; let c = Daemon::start(daemon_seeds(0x22, 0xC0)).await?; @@ -195,17 +181,15 @@ async fn routine_edit_pushes_new_epoch_to_enrolled_replica() -> Result<()> { assert_eq!(placed, vec![c.node_id()], "C accepted the placement"); assert!(c.holds_replica(&vid), "C stored the epoch-1 blobs"); - // Routine local edit: add a new file, then republish. The epoch must bump AND the - // new manifest + chunk must be pushed to the enrolled replica C. + // Routine local edit: add a file, republish. The epoch bumps AND the new manifest + chunk + // must reach the enrolled replica C. let added = b"a routine edit that must reach the replica".to_vec(); std::fs::write(src.path().join("added.txt"), &added)?; let epoch2 = a.publish_vault(src.path(), vid).await?; assert!(epoch2 > epoch1, "a real edit bumps the epoch"); - // Reconstruct on the fresh delegated device: documents (the epoch-2 announce + - // grant) from A, but ALL ciphertext strictly from the replica C. If the epoch push - // failed, C lacks the epoch-2 manifest envelope + new chunk and this errors out / - // omits the vault. + // Reconstruct on the fresh device: docs from A, ALL ciphertext from replica C. A failed + // epoch push leaves C without the epoch-2 envelope + new chunk and this errors/omits the vault. let out = tempfile::tempdir()?; let reconstructed = a2 .reconstruct_from_replica(a.addr()?, c.addr()?, out.path()) diff --git a/crates/carapaced/tests/kill_durability.rs b/crates/carapaced/tests/kill_durability.rs index 8d2b0b2..73950fb 100644 --- a/crates/carapaced/tests/kill_durability.rs +++ b/crates/carapaced/tests/kill_durability.rs @@ -1,24 +1,14 @@ -//! Durability under NON-graceful loss (kill -9, power cut, OS reboot without the -//! signal handler running): a published vault's blobs must be ON DISK by the time -//! `publish_vault` returns, not "within ~1 s if the process survives". +//! Durability under NON-graceful loss (kill -9, power cut): a published vault's blobs must +//! be on disk by the time `publish_vault` returns. The FsStore acks each add from an open +//! redb write batch that commits up to ~1 s later, so without the `sync()` barrier a prompt +//! kill loses the envelope + chunks that state.redb already names. //! -//! The iroh-blobs FsStore acks each add from inside an open redb write batch that -//! commits up to ~1 s later, so without an explicit durability barrier a prompt -//! kill loses the manifest envelope + chunks that state.redb already names — the -//! vault is gone after the very reboot the durable store exists to survive. +//! Kill simulation: copy the whole state dir while the daemon still runs (exactly the disk +//! image an abrupt kill leaves), probe its FsStore, then boot a full daemon from it. //! -//! Kill simulation: publish, then COPY the whole state dir while the daemon is -//! still running. The copy is exactly the disk image an abrupt kill leaves (no -//! Drop, no flush, no graceful actor drain — only what was already committed). -//! The copy's FsStore is probed directly, then a full daemon boots from it. -//! -//! Unix-only: the technique copies redb's live `blobs.db`/`state.redb` out from -//! under the running daemon. On Windows redb holds a mandatory byte-range lock, so -//! copying a live database file fails with os error 33 — the simulation can't run -//! there. The guarantee it proves (blobs committed to disk before `publish_vault` -//! returns, via the FsStore `sync()` barrier) is redb-level and platform-independent, -//! and the drop-then-reopen `reboot_survival` tests exercise the committed-state- -//! survives-restart path on every platform, so nothing is left uncovered on Windows. +//! Unix-only: copying redb's live db files needs no mandatory byte-range lock (Windows fails +//! with os error 33). The guarantee is redb-level and platform-independent, and the +//! `reboot_survival` tests cover the committed-state-survives-restart path everywhere. #![cfg(unix)] use anyhow::{Context, Result}; @@ -46,8 +36,8 @@ fn copy_tree(from: &Path, to: &Path) -> Result<()> { async fn publish_survives_immediate_kill() -> Result<()> { let state_a = tempfile::tempdir()?; let src = tempfile::tempdir()?; - // One tiny file (envelope + chunk inline in the store's redb) and one large - // file (file-backed blob data) so BOTH iroh-blobs storage paths are covered. + // One tiny file (inline in redb) + one large file (file-backed blob) to cover both + // iroh-blobs storage paths. std::fs::write(src.path().join("small.txt"), b"must survive kill -9")?; std::fs::create_dir_all(src.path().join("nested"))?; let big: Vec = (0..200_000u32) @@ -66,11 +56,9 @@ async fn publish_survives_immediate_kill() -> Result<()> { .expect("published vault has a blob source"); assert!(!chunks.is_empty(), "published vault has chunks"); - // "Kill -9": snapshot the on-disk state RIGHT NOW, while the daemon is still - // running — nothing that only a graceful drop/flush would write makes it in. + // "Kill -9": snapshot the on-disk state now, while the daemon still runs. let state_b = tempfile::tempdir()?; copy_tree(state_a.path(), state_b.path())?; - // Only now let the original daemon go; its shutdown cannot affect the copy. d.shutdown().await; // The kill image's FsStore must already hold every published blob. diff --git a/crates/carapaced/tests/maintenance.rs b/crates/carapaced/tests/maintenance.rs index 5f0884e..071650f 100644 --- a/crates/carapaced/tests/maintenance.rs +++ b/crates/carapaced/tests/maintenance.rs @@ -1,14 +1,7 @@ -//! W4 background maintenance loop (§10.1): a maintenance round detects a dropped -//! replica and triggers repair onto a spare. -//! -//! Two shapes, both BOUNDED (§11 lesson: never wait a real cadence): -//! - `maintenance_round_detects_loss_and_repairs` drives `Daemon::maintenance_round` -//! directly with an injected fast clock, so the PoR schedule + fail-streak logic is -//! deterministic (no wall-clock waiting). -//! - `maintenance_loop_repairs_and_tears_down` runs the REAL spawned loop -//! (`run_maintenance`) with a tiny tick + PoR interval, polls under a hard timeout -//! until the repair lands, then tears the loop down and reclaims the daemon — proving -//! the loop actually ticks and shuts down cleanly. +//! W4 background maintenance loop (§10.1): a round detects a dropped replica and repairs onto +//! a spare. Bounded: one test drives `maintenance_round` with an injected clock; the other runs +//! the REAL spawned loop with a tiny tick, polls under a hard timeout until the repair lands, +//! then tears the loop down and reclaims the daemon (proving clean tick + shutdown). use std::sync::Arc; use std::time::Duration; @@ -60,9 +53,8 @@ async fn maintenance_round_detects_loss_and_repairs() -> Result<()> { a.inject_lost_member_for_test(vid, b.node_id()); assert!(a.replica_members(&vid).contains(&b.node_id())); - // Drive maintenance rounds with a fast clock: B is reachable (a friend) but serves - // no chunk, so each round scores a retention failure; after the fail limit it is - // confirmed lost and repaired onto C (the only non-member friend candidate). + // Drive rounds with a fast clock: B is reachable but serves no chunk, so each round scores + // a retention failure; past the fail limit it is confirmed lost and repaired onto C. let mut now = 1_000_000u64; let mut repaired = false; for _ in 0..(DEFAULT_POR_FAIL_LIMIT as u64 + 2) { diff --git a/crates/carapaced/tests/por_reboot_replay.rs b/crates/carapaced/tests/por_reboot_replay.rs index 50fa105..d4073be 100644 --- a/crates/carapaced/tests/por_reboot_replay.rs +++ b/crates/carapaced/tests/por_reboot_replay.rs @@ -1,15 +1,8 @@ -//! Audit #1 + #6 regression: the PoR round counter (the challenge-unpredictability -//! nonce, §10.1) must be advanced + persisted at ISSUE time and must survive a reboot, -//! so a restarted daemon resumes at the NEXT round and never re-issues an already-observed -//! (hence predictable) challenge to the same replica. -//! -//! - #6: `por_audit_round` advances + commits the round BEFORE probing the replica, so -//! even a round that turns out unreachable advances the counter (the pre-fix code left -//! the round untouched on an unreachable probe, so a crash after a revealed challenge -//! re-issued it). -//! - #1: `run_maintenance` stamps its interval with `AuditTracker::restamp`, which KEEPS -//! the per-(replica,vid) round map (the pre-fix `AuditTracker::new` wiped it, resetting -//! every replica to round 0 at boot). +//! PoR round-counter reboot regression: the round counter (§10.1 challenge nonce) advances and +//! persists at ISSUE time, so a restarted daemon resumes at the next round and never re-issues a +//! predictable challenge. `por_audit_round` commits the advance before probing (an unreachable +//! round still advances); `run_maintenance` restamps cadence while keeping the per-(replica,vid) +//! round map. use std::collections::HashMap; use std::sync::Arc; @@ -51,10 +44,8 @@ async fn por_round_never_replays_across_reboot() -> Result<()> { let (vid, _n) = a.new_vid(); a.publish_vault(src.path(), vid).await?; - // Each round is due (the tracker starts unscheduled / the schedule elapses), and - // each ADVANCES the round counter at issue time even though the probe fails - // unreachable - this is the #6 property. With the pre-fix code the counter would - // stay 0 across all three unreachable rounds. + // Each round is due and ADVANCES the round counter at issue time even though the probe + // fails unreachable (pre-fix, the counter stayed 0 across all three). let mut now = 1_000_000u64; for i in 0..3u64 { let round = a @@ -94,11 +85,9 @@ async fn por_round_never_replays_across_reboot() -> Result<()> { "the PoR round counter survived the reboot (never rewound to a spent round)" ); - // #1: starting the maintenance loop stamps the cadence via `restamp`, which must KEEP - // the round map. The pre-fix `s.por = AuditTracker::new(..)` wiped it back to 0. + // Starting the maintenance loop restamps the cadence, which must KEEP the round map. let cfg = MaintenanceConfig { - // A long tick + interval so the background loop does not run a real audit round - // (and there are no placed replica members for it to audit anyway) before we read. + // A long tick + interval so the loop does not run a real audit round before we read. tick: Duration::from_secs(3600), por_interval: Duration::from_secs(6 * 3600), }; diff --git a/crates/carapaced/tests/por_unreachable.rs b/crates/carapaced/tests/por_unreachable.rs index c39fb03..b18d99d 100644 --- a/crates/carapaced/tests/por_unreachable.rs +++ b/crates/carapaced/tests/por_unreachable.rs @@ -1,8 +1,6 @@ -//! C1 regression: a transiently-unreachable replica is NOT evicted by the wired -//! PoR loop. Transport failure (the peer cannot be dialed) must never advance the -//! retention loss streak - only a peer that answered with missing/wrong bytes does. -//! This exercises the real network adapter (`por_audit_round` -> `fetch_audit_samples`) -//! that introduces the unreachable=loss collapse the audit flagged. +//! C1 regression: a transiently-unreachable replica is NOT evicted by the PoR loop. Transport +//! failure must never advance the retention loss streak - only a peer that answered with +//! missing/wrong bytes does. Exercises the real network adapter (`por_audit_round`). use std::collections::HashMap; diff --git a/crates/carapaced/tests/reboot_survival.rs b/crates/carapaced/tests/reboot_survival.rs index 2961a4b..7340000 100644 --- a/crates/carapaced/tests/reboot_survival.rs +++ b/crates/carapaced/tests/reboot_survival.rs @@ -1,10 +1,6 @@ -//! Design §6 reboot-survival + at-rest sealing acceptance tests. -//! -//! A daemon is started against a FIXED state dir (`State::from_seeds_in`), mutated -//! across the persisted categories, dropped, then RE-STARTED from the same dir. The -//! durable state must survive: the published vault (epochs + re-derived manifest), the -//! sealed owner split-state, the default-deny fetch gate's owned-chunk set, and the F3 -//! own-card version floor (strictly increasing across the restart). +//! §6 reboot-survival + at-rest sealing tests: a daemon is started against a fixed state dir, +//! mutated, dropped, and re-started. The published vault, sealed owner split-state, fetch-gate +//! owned-chunk set, and F3 own-card version floor must all survive. use anyhow::Result; use carapaced::{Daemon, MaintenanceConfig, RecoveryScope, State}; @@ -43,10 +39,9 @@ async fn reboot_preserves_vault_split_and_card_version() -> Result<()> { let (jsons, _warn) = d.recovery_split(7, RecoveryScope::Root, 2, 3, false)?; assert_eq!(d.split_state_count(), 1); - // Pull a distinctive BIP39-style share word out of the JSON to later assert it - // never appears in plaintext in state.redb (the split-state is SEALed under - // K_root). Exclude the JSON's own structural/label words so the needle is real - // share material, not schema text that legitimately appears in a card. + // Pull a distinctive BIP39 share word to later assert it never appears in plaintext + // in state.redb (the split-state is SEALed). Exclude structural/label words so the + // needle is real share material, not schema text. let stop = [ "carapace", "device", @@ -78,8 +73,7 @@ async fn reboot_preserves_vault_split_and_card_version() -> Result<()> { d.shutdown().await; (vid, v1, word) }; - // Give the router's accept tasks a moment to finish so every `Arc` clone - // drops and redb releases the single-open lock before we re-open the same file. + // Let the router's accept tasks finish so redb releases the single-open lock before reopen. tokio::time::sleep(std::time::Duration::from_millis(200)).await; // ---- at-rest sealing (§5.1): no share plaintext in state.redb ---- @@ -110,11 +104,9 @@ async fn reboot_preserves_vault_split_and_card_version() -> Result<()> { d2.own_card_version() ); - // The published blobs are genuinely PRESENT in the reopened FsStore — asserted - // directly, blob by blob. The no-op republish below proves the manifest - // re-derived, but on its own it cannot prove chunk survival: the no-op guard - // compares manifest FILE entries only, so it would pass identically with every - // chunk blob lost. + // Assert the published blobs are present in the reopened FsStore, blob by blob: the no-op + // republish below proves the manifest re-derived but compares file entries only, so it + // would pass identically with every chunk lost. let (digest, chunks) = d2 .vault_blob_ids(&vid) .expect("vault_blobs re-derived from the FsStore envelope after reboot"); @@ -129,9 +121,8 @@ async fn reboot_preserves_vault_split_and_card_version() -> Result<()> { ); } - // epochs + vault_blobs survived: re-publishing the identical tree is a no-op that - // returns the SAME epoch (the no-op guard compares the re-derived manifest's files, - // proving the manifest was rebuilt from the FsStore envelope + K_manifest). + // epochs + vault_blobs survived: re-publishing the identical tree is a no-op returning the + // same epoch (the guard compares the re-derived manifest's files). let epoch2 = d2.publish_vault(src.path(), vid).await?; assert_eq!( epoch2, 1, @@ -142,11 +133,9 @@ async fn reboot_preserves_vault_split_and_card_version() -> Result<()> { Ok(()) } -/// The BINARY boot path (`carapace_api::serve` shape): the daemon lives in an `Arc` -/// with the background maintenance loop running — whose rounds persist state — and -/// is shut down via `&self` while other `Arc` clones may still exist. A published -/// vault must survive that full lifecycle plus a reboot. The other tests here call -/// `Daemon` directly and never start maintenance, so this path was untested. +/// The binary boot path: the daemon lives in an `Arc` with maintenance running (whose rounds +/// persist state) and is shut down via `&self` while other `Arc` clones exist. A published +/// vault must survive that lifecycle plus a reboot. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn binary_boot_path_maintenance_rounds_preserve_vault() -> Result<()> { let state_dir = tempfile::tempdir()?; @@ -192,12 +181,10 @@ async fn binary_boot_path_maintenance_rounds_preserve_vault() -> Result<()> { Ok(()) } -/// §3.5: a vault whose manifest cannot be re-derived at startup (FsStore damage — -/// here the whole blobs/ dir deleted between boots) must KEEP its persisted -/// blob-source record as the durable needs-refetch set, across further reboots, -/// until a republish repairs it. The original bug: the failed re-derive dropped the -/// source from RAM and the next persist rewrote the VAULT_BLOBS row without it, so -/// the vault silently vanished from every later boot with zero warnings. +/// §3.5: a vault whose manifest cannot be re-derived at startup (here blobs/ deleted between +/// boots) must KEEP its persisted blob-source record as needs-refetch across further reboots +/// until a republish repairs it. The bug: the failed re-derive dropped the source and the next +/// persist rewrote the VAULT_BLOBS row without it, silently vanishing the vault. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn rederive_failure_keeps_blob_source_until_republished() -> Result<()> { let state_dir = tempfile::tempdir()?; @@ -216,13 +203,11 @@ async fn rederive_failure_keeps_blob_source_until_republished() -> Result<()> { }; tokio::time::sleep(Duration::from_millis(200)).await; - // Damage: the served blob store is gone (models the loss a pre-durability - // binary left behind, a botched restore, or future GC gone wrong). + // Damage: the served blob store is gone (a botched restore, or future GC gone wrong). std::fs::remove_dir_all(state_dir.path().join("blobs"))?; - // Boot 2: re-derive fails; the vault is not servable — but its blob source - // must be retained as needs-refetch. This boot's own startup persists are the - // clobber vector the bug rode in on. + // Boot 2: re-derive fails; the vault is not servable but its blob source must be retained + // as needs-refetch. This boot's own startup persists are the clobber vector. { let d = Daemon::start(State::from_seeds_in(state_dir.path(), node_seed, k_root)).await?; assert!( @@ -238,8 +223,8 @@ async fn rederive_failure_keeps_blob_source_until_republished() -> Result<()> { } tokio::time::sleep(Duration::from_millis(200)).await; - // Boot 3: the record SURVIVED boot 2's persists (the regression). Republish - // from the working tree repairs: new epoch, listed again, record cleared. + // Boot 3: the record survived boot 2's persists. Republish repairs: new epoch, listed + // again, record cleared. { let d = Daemon::start(State::from_seeds_in(state_dir.path(), node_seed, k_root)).await?; assert_eq!( @@ -267,22 +252,16 @@ async fn rederive_failure_keeps_blob_source_until_republished() -> Result<()> { Ok(()) } -/// The REAL binary key path: `State::load_or_generate` reads/writes `node.key` + -/// `root.key` on disk, so a genuine process reboot re-derives `k_root` from the file -/// rather than a fixed in-memory seed. Every other reboot test here uses -/// `from_seeds_in` (a fixed `k_root` that trivially matches on boot 2), so none of them -/// exercise the axis that would break if the key files did not round-trip: a mismatched -/// boot-2 `k_root` yields a different `K_manifest`, `open_envelope` fails, and the vault -/// is silently routed to needs-refetch (empty `published_vaults`). This asserts the -/// derive chain survives a real load-or-generate cycle, for BOTH the plaintext-seed and -/// the `CARAPACE_PASSPHRASE`-sealed (Argon2id) key files. +/// The real binary key path: `State::load_or_generate` reads/writes the key files on disk, so +/// a genuine reboot re-derives `k_root` from the file. The other reboot tests use +/// `from_seeds_in` (a fixed `k_root`), so none exercise a key-file round-trip: a mismatched +/// boot-2 `k_root` would fail `open_envelope` and route the vault to needs-refetch. Covers both +/// plaintext-seed and `CARAPACE_PASSPHRASE`-sealed (Argon2id) key files. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn load_or_generate_key_path_survives_reboot() -> Result<()> { - // No passphrase: plaintext seed files. reboot_via_load_or_generate(None).await?; - // With passphrase: Argon2id-sealed key files. The env var is process-global, so - // set it only for this segment and clear it immediately after. No other test in - // this binary reads it (they all use `from_seeds_in`, which ignores it). + // Argon2id-sealed key files. The env var is process-global: set only for this segment and + // clear immediately after (no other test in this binary reads it). std::env::set_var("CARAPACE_PASSPHRASE", "correct horse battery staple"); let sealed = reboot_via_load_or_generate(Some("root.key")).await; std::env::remove_var("CARAPACE_PASSPHRASE"); @@ -290,11 +269,9 @@ async fn load_or_generate_key_path_survives_reboot() -> Result<()> { Ok(()) } -/// Boot a daemon from a real on-disk identity (`State::load_or_generate`), publish a -/// vault, shut down, then reboot from the SAME dir via `load_or_generate` again and -/// assert the vault is still listed (its manifest re-derived, so `k_root` round-tripped). -/// When `sealed_key` is `Some(name)`, also assert that key file is at-rest sealed on -/// disk (magic-prefixed, not the raw seed) so the passphrase branch is really exercised. +/// Boot from a real on-disk identity, publish, shut down, reboot from the same dir via +/// `load_or_generate`, and assert the vault is still listed (manifest re-derived, so `k_root` +/// round-tripped). `sealed_key` also asserts that key file is at-rest sealed on disk. async fn reboot_via_load_or_generate(sealed_key: Option<&str>) -> Result<()> { let state_dir = tempfile::tempdir()?; let (src, _expected) = make_tree(); @@ -325,8 +302,8 @@ async fn reboot_via_load_or_generate(sealed_key: Option<&str>) -> Result<()> { // Let redb release its single-open lock before re-opening the same file. tokio::time::sleep(Duration::from_millis(200)).await; - // Reboot from the SAME dir: re-reads the key files. If `k_root` did not round-trip, - // `rederive_manifest` would fail and this vault would be absent from published_vaults. + // Reboot from the same dir (re-reads the key files); a `k_root` that did not round-trip + // would fail `rederive_manifest` and drop the vault from published_vaults. let d2 = Daemon::start(State::load_or_generate(state_dir.path())?).await?; assert_eq!( d2.node_id(), diff --git a/crates/carapaced/tests/recovery_reconstruct.rs b/crates/carapaced/tests/recovery_reconstruct.rs index ce83ac8..ed4874e 100644 --- a/crates/carapaced/tests/recovery_reconstruct.rs +++ b/crates/carapaced/tests/recovery_reconstruct.rs @@ -1,25 +1,10 @@ -//! §8.4 end-to-end DATA recovery: the ceremony -> replica -> reconstruct path the -//! project was missing. `full_ceremony_recovers_k_root` (tests/ceremony.rs) proves a -//! key-less claimant recovers `K_root`; `friend_gate_replica_placement...` -//! (tests/friend_replica.rs) proves a *delegated* device reconstructs off a replica -//! while the owner is live. This test joins them for the real recovery scenario: the -//! owner is GONE, and a FRESH claimant device that recovered only `K_root` must fetch -//! and decrypt the actual file content off a surviving friend's replica. -//! -//! Flow: owner A publishes a multi-file vault and places a replica on friend B (the -//! placement ships the owner-signed announce; Option B §4 means NO FileGrant is -//! pushed or retained); A splits `K_root` 2-of-3 to trustees B, C, D. A then "loses -//! every device". A fresh claimant runs the full ceremony (collect M shares -> -//! recover `K_root`, re-derive identity), stands itself up as a `Daemon` on the -//! recovered key, and reconstructs the vault from B - authenticating as an -//! owner-delegated device (`ReplicaDevice`) with a card its re-derived user key -//! signed. B serves it the retained announce + owner card (never a grant); the -//! claimant re-derives every per-chunk key from the manifest's `pt_hash`. The -//! assertion is CONTENT: every file byte-matches the source, not merely that -//! `K_root` came back. -//! -//! Bounded (§11 lesson): the 72 h abort delay is driven by an INJECTED clock, all -//! dials are connect-timeout bounded, and every daemon is torn down. +//! §8.4 end-to-end DATA recovery: owner GONE, a fresh claimant that recovered only `K_root` +//! fetches and decrypts the actual file content off a surviving friend's replica. Owner A +//! publishes a multi-file vault, places a replica on friend B, and splits `K_root` 2-of-3 to +//! B, C, D; A loses every device; a fresh claimant runs the full ceremony, stands up as a +//! Daemon on the recovered key, and reconstructs from B as an owner-delegated `ReplicaDevice` +//! (retained announce + owner card, no grant; keys re-derived from the manifest pt_hash). The +//! assertion is CONTENT: every file byte-matches. Bounded: injected clock, no real sleeps. use std::collections::BTreeMap; @@ -82,9 +67,8 @@ async fn ceremony_then_reconstruct_recovers_file_content() -> Result<()> { a_befriends(&a, t).await?; } - // A publishes a real vault and places a replica on friend B. The placement pushes - // A's owner-signed VaultAnnounce, which B retains (§8.4). Option B (§4): no - // FileGrant is pushed - recovery re-derives keys from the manifest pt_hash. + // A publishes a vault and places a replica on B, which retains A's owner-signed announce + // (§8.4). Option B: no FileGrant is pushed; recovery re-derives keys from the pt_hash. let (src, expected) = make_tree(); let (vid, _nonce) = a.new_vid(); a.publish_vault(src.path(), vid).await?; @@ -147,13 +131,10 @@ async fn ceremony_then_reconstruct_recovers_file_content() -> Result<()> { "re-derived user key equals the original owner's" ); - // ---- §8.4 data recovery: stand the claimant up as a full Daemon on the recovered - // K_root + its own node identity, then reconstruct the vault off the surviving - // replica B. The claimant is a FRESH device (new node key) presenting a card its - // re-derived owner-user key signed; B admits it as an owner-delegated ReplicaDevice - // and serves the retained announce + owner card + blobs - NEVER a grant (Option B, - // §4): the claimant re-derives every per-chunk key from the manifest pt_hash. B has - // no grant to serve - the `replica_grants` retention path was removed entirely. ---- + // §8.4 data recovery: stand the claimant up as a Daemon on the recovered K_root + its own + // node identity, then reconstruct off replica B. The fresh device presents a card its + // re-derived user key signed; B admits it as an owner-delegated ReplicaDevice and serves + // the retained announce + owner card + blobs, never a grant (keys from the pt_hash). let recovered_daemon = Daemon::start(State::from_seeds(claimant.node_seed(), *recovered.k_root)).await?; assert_eq!( diff --git a/crates/carapaced/tests/relay_friendship.rs b/crates/carapaced/tests/relay_friendship.rs index 984761d..9cd75e1 100644 --- a/crates/carapaced/tests/relay_friendship.rs +++ b/crates/carapaced/tests/relay_friendship.rs @@ -1,18 +1,8 @@ -//! §6 acceptance: NAT-blind connectivity over self-hosted relays only. -//! -//! Two daemons `A` and `B`, each running its own embedded self-hosted relay and -//! bound loopback. Neither is ever told the other's direct socket address: A's -//! relay URL reaches B via A's issued ticket, and B's relay URL reaches A via the -//! ContactCard B presents during the handshake. From only those relay hints they -//! complete a friendship and A places a small vault replica on B - the entire -//! bootstrap traverses the relay path. -//! -//! Structural relay proof (same caveat as `carapace-net`'s relay test): both peers -//! run on loopback, so iroh *may* background-upgrade to a direct loopback path -//! after the relay bootstraps the connection. The relay-only guarantee is -//! structural: neither peer is given a direct address and there is no discovery -//! service, so the embedded relays are the only thing that can bootstrap the -//! connection at all. +//! §6 acceptance: NAT-blind connectivity over self-hosted relays only. Two daemons, each +//! running its own embedded relay bound loopback, neither told the other's direct address: +//! relay URLs travel via A's ticket and B's card. From only those hints they befriend and A +//! places a replica on B. The relay-only guarantee is structural (no direct address, no +//! discovery), though iroh may background-upgrade to a direct loopback path afterward. use std::collections::BTreeMap; use std::net::{Ipv4Addr, SocketAddr}; @@ -81,8 +71,7 @@ async fn relay_only_friendship_and_replica() -> Result<()> { } // ---- friendship, NAT-blind: B dials A by node id only ---- - // The ticket A issues carries A's relay URL (and no direct address); B injects - // that hint and dials A's bare node id over the relay. + // A's ticket carries A's relay URL (no direct address); B injects it and dials A's node id. let ticket = a.issue_ticket()?; assert!( ticket.relay_urls.contains(&a_relay), @@ -101,8 +90,7 @@ async fn relay_only_friendship_and_replica() -> Result<()> { assert!(a.is_friend(&b.user_id()) && b.is_friend(&a.user_id())); // ---- small vault replica, NAT-blind: A places on B by node id only ---- - // A learned B's relay from the ContactCard B presented during the handshake, - // so A can reach B's bare node id over the relay with no direct address. + // A learned B's relay from B's handshake card, so it reaches B's node id over the relay. let (src, expected) = make_tree(); let (vid, _nonce) = a.new_vid(); a.publish_vault(src.path(), vid).await?; diff --git a/crates/carapaced/tests/replica_hardening.rs b/crates/carapaced/tests/replica_hardening.rs index bac3636..cd979e8 100644 --- a/crates/carapaced/tests/replica_hardening.rs +++ b/crates/carapaced/tests/replica_hardening.rs @@ -1,12 +1,8 @@ -//! Phase-3 hardening acceptance for the replica-store receive path: -//! -//! - S4: owner-side placement only invites established friends that are not on the -//! owner deny-list; a stranger or a denied friend is skipped. -//! - W1: a friend's per-friend agreed storage grant is enforced as its replica -//! quota (a placement over that friend's grant is declined and the store does -//! not grow), two friends with different agreed grants are enforced -//! independently, and a peer over its push rate limit is throttled - while an -//! honest within-grant placement still succeeds. +//! Phase-3 hardening for the replica-store receive path: +//! - S4: placement invites only established friends not on the owner deny-list. +//! - W1: a friend's agreed storage grant is enforced as its replica quota (over-grant declined, +//! per-friend grants enforced independently, rate limit throttles), while an honest +//! within-grant placement succeeds. use anyhow::Result; use carapaced::{Daemon, ReplicaLimits, State}; @@ -139,11 +135,9 @@ async fn w1_quota_and_rate_limit_cut_off_pushes() -> Result<()> { Ok(()) } -// W1 per-friend: one storage node grants two friends different limits and -// enforces them independently by WHO is placing. `store` grants `small` only 10 -// bytes but `big` the 1 GiB default; a real placement is refused from `small` -// yet accepted from `big`, proving the quota is sourced per-friend (looked up by -// the placing friend's user pubkey), not from a single global default. +// W1 per-friend: `store` grants `small` 10 bytes but `big` the default; the same-size +// placement is refused from `small` yet accepted from `big`, proving the quota is per-friend +// (by placing user pubkey), not a single global default. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn w1_per_friend_grants_enforced_independently() -> Result<()> { let store = Daemon::start(seeds(0x03, 0xA2)).await?; // the storage node diff --git a/crates/carapaced/tests/selective_disclosure.rs b/crates/carapaced/tests/selective_disclosure.rs index 50d2182..24d79c7 100644 --- a/crates/carapaced/tests/selective_disclosure.rs +++ b/crates/carapaced/tests/selective_disclosure.rs @@ -1,18 +1,11 @@ -//! §7.4 acceptance: selective disclosure + fetch authorization (adversarial D3). -//! -//! Topology: owner `A` publishes a vault of three files F1, F2, F3. Friends `B` -//! (the audience) and `C` (a friend NOT in the audience) both befriend `A`. -//! -//! 1. A discloses exactly F1, F2 to B; B opens the grant, fetches, and -//! reconstructs byte-identical F1, F2 — and only those. F3's keys never appear -//! in B's grant, so B cannot derive them. -//! 2. D3: C authenticates as a friend and holds a LEAKED copy of B's grant, yet is -//! refused the granted chunk — the blob gate enforces audience membership, so a -//! leaked grant document alone authorizes nothing. B (the real audience) fetches -//! the same chunk successfully. -//! 3. Snapshot: A edits F1 and republishes (epoch 2); a fresh disclosure of F1 -//! carries new chunk keys disjoint from the epoch-1 grant, proving a grant never -//! extends to future content. +//! §7.4 acceptance: selective disclosure + fetch authorization (adversarial D3). Owner `A` +//! publishes F1/F2/F3; `B` is the audience, `C` a friend NOT in it. +//! 1. A discloses F1, F2 to B; B reconstructs exactly those byte-identical (F3's keys are +//! absent from the grant). +//! 2. D3: C, authenticated as a friend and holding a LEAKED grant, is still refused the chunk +//! (the gate enforces audience membership); B fetches it fine. +//! 3. Snapshot: A edits F1, republishes (epoch 2); a fresh grant's keys are disjoint from the +//! epoch-1 grant, so a grant never extends to future content. use std::collections::HashSet; @@ -93,9 +86,8 @@ async fn selective_disclosure_and_fetch_authorization() -> Result<()> { // cannot derive them. let b_ids: HashSet<[u8; 32]> = b.granted_chunk_ids(&grant)?.into_iter().collect(); assert!(!b_ids.is_empty(), "B's grant discloses F1, F2 chunk ids"); - // F1 and F2 are distinct single-chunk files, so the grant discloses two ids: one - // drives the fetch-gate probes below, the other the W8 re-serve regression (it must - // stay untouched by `try_fetch_chunk`, which would otherwise populate B's store). + // F1, F2 are distinct single-chunk files, so the grant discloses two ids: one drives the + // fetch-gate probes, the other the W8 re-serve regression (kept untouched by try_fetch_chunk). let mut b_id_list: Vec<[u8; 32]> = b_ids.iter().copied().collect(); b_id_list.sort_unstable(); assert_eq!( @@ -112,8 +104,7 @@ async fn selective_disclosure_and_fetch_authorization() -> Result<()> { ); // C authenticates as a friend of A (populates A's blob-read allow-set for C). let _ = c.pull_doc_counts(a.addr()?).await?; - // Even authenticated AND knowing a granted ChunkID (leaked out of band here via - // the test), C is refused the chunk: the gate enforces audience membership. + // Even authenticated AND knowing a granted ChunkID, C is refused: the gate enforces audience. let a_granted = b_id_list[0]; assert!( c.try_fetch_chunk(a.addr()?, a_granted).await.is_err(), @@ -126,12 +117,9 @@ async fn selective_disclosure_and_fetch_authorization() -> Result<()> { ); // ---- W8 regression: B must NOT re-serve the disclosed ciphertext ---- - // B fetched F1/F2's ciphertext during fetch_disclosed above. That ciphertext must - // land in a throwaway store, never B's router-served blob store: otherwise any - // dialer knowing the ChunkID could pull the ciphertext straight off B, voiding the - // disclosure gate and revocation. Probe a granted chunk B fetched ONLY via - // fetch_disclosed (b_id_list[1] - not the one the try_fetch_chunk probe above pulled - // into B's store). C dials B raw and must be refused. + // B's fetch_disclosed ciphertext must land in a throwaway store, not B's served store, else + // any dialer knowing the ChunkID could pull it off B. Probe a chunk B fetched ONLY via + // fetch_disclosed (b_id_list[1], not the one try_fetch_chunk pulled into B's store). let disclosed_only = b_id_list[1]; assert!( c.try_fetch_chunk(b.addr()?, disclosed_only).await.is_err(), @@ -153,10 +141,8 @@ async fn selective_disclosure_and_fetch_authorization() -> Result<()> { assert_eq!(grant2.epoch, 2); let ids2: HashSet<[u8; 32]> = b.granted_chunk_ids(&grant2)?.into_iter().collect(); - // The epoch-2 F1 chunk ids (`ids2`) share nothing with the epoch-1 grant's ids - // (`b_ids`, which are epoch-1 F1 + F2): the edit gave F1 new plaintext, hence new - // convergent keys and new ChunkIDs, and F2's stable ids are for a different file. - // A grant thus never extends to future content (snapshot by construction). + // The epoch-2 F1 ids share nothing with the epoch-1 grant's ids: the edit gave F1 new + // plaintext, hence new convergent keys. A grant never extends to future content. assert!( ids2.is_disjoint(&b_ids), "epoch-2 F1 chunks are disjoint from the epoch-1 grant (snapshot)" diff --git a/crates/carapaced/tests/share_grant.rs b/crates/carapaced/tests/share_grant.rs index 54dcce3..f85eec9 100644 --- a/crates/carapaced/tests/share_grant.rs +++ b/crates/carapaced/tests/share_grant.rs @@ -1,14 +1,7 @@ -//! W3 ShareGrant minting / delivery / ref-refresh (§8, §7.3, §10.2). -//! -//! The owner splits a secret to a trustee set and mints one signed `ShareGrant` per -//! trustee, delivered over the `carapace/1` control stream. Each trustee VERIFIES the -//! grant (signature + embedded-share CRC + owner delegation) and stores the FULL grant -//! (roster + recovery_delay + announce refs), not a bare share — so a ceremony can -//! later locate co-trustees and the latest manifest without a live owner. -//! -//! Every test is BOUNDED (§11 lesson): no real cadence is ever waited on. The refresh -//! runs on an injected `maintenance_round(now)` with a fast clock; the delivery dials -//! are bounded by the daemon's connect timeout; daemons are torn down at the end. +//! W3 ShareGrant minting / delivery / ref-refresh (§8, §7.3, §10.2): the owner splits a secret +//! and delivers one signed `ShareGrant` per trustee. Each trustee verifies (signature + +//! embedded-share CRC + owner delegation) and stores the FULL grant (roster + recovery_delay + +//! announce refs). Bounded: the refresh runs on an injected `maintenance_round(now)`. use std::collections::HashSet; @@ -200,10 +193,8 @@ async fn grant_with_bad_signature_is_rejected_on_receipt() -> Result<()> { let b = Daemon::start(seeds(0x13, 0xB2)).await?; a_befriends(&a, &b).await?; - // Build a grant, sign it, then tamper a signed field so the signature no longer - // covers the content. The subject is irrelevant: `verify_share_grant` (signature + - // embedded share) runs before any delegation check, so this is rejected purely on - // the bad signature. + // Build a grant, sign it, then tamper a signed field. `verify_share_grant` runs before any + // delegation check, so this is rejected purely on the bad signature. let signer = SigningKey::from_bytes(&[0x77; 32]); let share = { let (shares, _state, _warn) = carapace_recovery::split_root(&[0x5e; 32], 2, Some(3), false) diff --git a/crates/carapaced/tests/sync_conflict.rs b/crates/carapaced/tests/sync_conflict.rs index 9bb1611..3861c51 100644 --- a/crates/carapaced/tests/sync_conflict.rs +++ b/crates/carapaced/tests/sync_conflict.rs @@ -1,20 +1,8 @@ -//! §11 multi-device conflict reconciliation between two owner daemons that share -//! one `k_root` but hold distinct, user-delegated node keys. -//! -//! Both devices publish a *concurrent* change to the SAME path in the SAME vault -//! (neither version vector dominates the other), then reconcile by pulling from -//! each other over a BOUNDED number of rounds. The tests prove: -//! -//! - **No data loss on concurrent edit-vs-edit:** both devices end up holding -//! BOTH edits - the `(mtime, deviceId)` winner at the original path and the -//! loser at `path.sync-conflict--.`. -//! - **Edit wins delete-vs-edit:** a concurrent delete on one device does not -//! erase a live edit on the other; the edit survives on both. -//! - **Convergence / termination:** reconciliation reaches a fixed point (no -//! device re-publishes once both agree), so the round loop quiesces well within -//! the hard cap. Every `sync_from` is additionally wrapped in a wall-clock -//! timeout, so a regression that reintroduces the historical ping-pong or a -//! dial-each-other deadlock FAILS the test fast instead of hanging. +//! §11 multi-device conflict reconciliation between two owner daemons sharing one `k_root` +//! but distinct node keys. Both publish a concurrent change to the same path (neither VV +//! dominates), then reconcile over bounded rounds. Proves: no loss on edit-vs-edit (winner at +//! the path, loser at `path.sync-conflict-*.ext`), edit wins delete-vs-edit, and convergence +//! (a fixed point well within the cap; a per-call timeout fails a ping-pong regression fast). use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; @@ -74,10 +62,8 @@ async fn reconcile( a_dir = Some(r.out_dir.clone()); } - // A round that pulls nothing newer for this vault in EITHER direction is - // the fixed point: the idempotent merge stopped producing republishes, so - // the per-signer epoch line stops advancing and every further pull is a - // no-op. That is convergence + termination in one observable. + // A round that pulls nothing newer in EITHER direction is the fixed point: the + // idempotent merge stopped republishing, so every further pull is a no-op. let touched = a_got.iter().any(|r| r.vid == vid) || b_got.iter().any(|r| r.vid == vid); if !touched { converged = true; @@ -109,11 +95,9 @@ fn dir_files(dir: &Path) -> BTreeMap> { out } -/// Concurrent edit-vs-edit on the same path: both devices publish a different -/// body for `notes.txt` with no shared ancestry, so neither version vector -/// dominates. After a bounded reconcile BOTH devices must hold BOTH bodies - the -/// winner at `notes.txt`, the loser at `notes.sync-conflict-*.txt` - with the -/// extension preserved. Nothing is lost, and the loop converges. +/// Concurrent edit-vs-edit on the same path (no shared ancestry, neither VV dominates): after +/// a bounded reconcile BOTH devices hold BOTH bodies - winner at `notes.txt`, loser at +/// `notes.sync-conflict-*.txt` with the extension preserved. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_edit_same_path_keeps_both_no_loss() -> Result<()> { const ALPHA: &[u8] = b"device A wrote these notes first"; @@ -180,11 +164,8 @@ async fn concurrent_edit_same_path_keeps_both_no_loss() -> Result<()> { Ok(()) } -/// Delete-vs-edit: device A deletes `data.txt` while device B concurrently edits -/// it, with the two changes concurrent (neither VV dominates). §11 resolves this -/// to the edit surviving - a concurrent delete must not erase a live edit. After -/// a bounded reconcile BOTH devices hold the edited file and no tombstone or -/// conflict artifact. +/// Delete-vs-edit: A deletes `data.txt` while B concurrently edits it. §11 resolves to the +/// edit surviving; after a bounded reconcile BOTH devices hold the edit, no tombstone/conflict. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn delete_vs_edit_edit_survives_on_both() -> Result<()> { const ORIG: &[u8] = b"the original body on device A"; @@ -195,8 +176,7 @@ async fn delete_vs_edit_edit_survives_on_both() -> Result<()> { let (vid, _n) = daemon_a.new_vid(); - // Device A: publish the file, then delete it and republish -> a tombstone - // that carries A's bumped version-vector component. + // Device A: publish, then delete and republish -> a tombstone carrying A's bumped VV. let src_a = tempfile::tempdir()?; std::fs::write(src_a.path().join("data.txt"), ORIG)?; assert_eq!(daemon_a.publish_vault(src_a.path(), vid).await?, 1); @@ -207,8 +187,7 @@ async fn delete_vs_edit_edit_survives_on_both() -> Result<()> { "the delete republishes a tombstone at a bumped epoch" ); - // Device B: concurrently publish an independent edit of the same path. With - // no shared ancestry, B's {b:1} is concurrent with A's delete tombstone. + // Device B: concurrently publish an independent edit; B's {b:1} is concurrent with A's tombstone. let src_b = tempfile::tempdir()?; std::fs::write(src_b.path().join("data.txt"), EDIT)?; assert_eq!(daemon_b.publish_vault(src_b.path(), vid).await?, 1); diff --git a/crates/carapaced/tests/three_device_convergence.rs b/crates/carapaced/tests/three_device_convergence.rs index a56a978..71f4078 100644 --- a/crates/carapaced/tests/three_device_convergence.rs +++ b/crates/carapaced/tests/three_device_convergence.rs @@ -1,18 +1,9 @@ -//! MAJOR 3 regression: 3+ device convergence on a concurrent edit must be -//! order-independent. -//! -//! Three owner daemons share one `k_root` and each publishes a DIFFERENT body for -//! the same path in the same vault, with no shared ancestry, so all three edits are -//! mutually concurrent. The bug: the winner tie-break and the `sync-conflict-` -//! filename were derived from the POST-MERGE joined version vector, so different -//! pairwise fold orders on different devices produced different winners / conflict -//! names - the devices ended up with DIFFERENT file sets (permanent divergence). -//! -//! The fix derives both the winner and the conflict name from order-independent, -//! content-intrinsic data (mtime + file_hash). This test reconciles the three -//! devices to a fixed point and asserts they converge on an IDENTICAL file set - -//! same winner path, same two conflict-copy names - with all three bodies present -//! (nothing dropped). Bounded and self-terminating. +//! 3+ device convergence on a concurrent edit must be order-independent. Three owner daemons +//! sharing one `k_root` each publish a different body for the same path (no shared ancestry, so +//! all three are mutually concurrent). The bug: the winner + conflict filename came from the +//! post-merge joined VV, so different fold orders produced different file sets (permanent +//! divergence). The fix derives both from content-intrinsic data (mtime + file_hash). This +//! reconciles to a fixed point and asserts an IDENTICAL file set with all three bodies present. use std::collections::BTreeSet; use std::path::Path; diff --git a/crates/carapaced/tests/two_device_sync.rs b/crates/carapaced/tests/two_device_sync.rs index 1105401..a9239b5 100644 --- a/crates/carapaced/tests/two_device_sync.rs +++ b/crates/carapaced/tests/two_device_sync.rs @@ -1,10 +1,6 @@ -//! Phase 1 acceptance: two in-process daemons on localhost sharing the SAME user -//! master key (`k_root`) but holding DIFFERENT, user-delegated node keys. -//! -//! Device A ingests a source tree into a vault and publishes a signed -//! `VaultAnnounce` + `FileGrant`; device B runs anti-entropy, fetches the -//! manifest envelope + every chunk by ChunkID, opens the grant, and reconstructs -//! the tree. The test asserts B's reconstructed files byte-match A's source. +//! Phase 1 acceptance: two localhost daemons sharing one `k_root` but distinct node keys. +//! Device A publishes a vault; device B runs anti-entropy, fetches the envelope + chunks, and +//! reconstructs the tree byte-for-byte against A's source. use std::collections::BTreeMap; @@ -130,10 +126,8 @@ async fn republish_bumps_epoch_and_syncs_update() -> Result<()> { Ok(()) } -/// W3: a poison announce (correctly node-signed, so it passes delegation, but -/// pointing at a manifest digest no blob backs) must not starve the legitimate -/// vaults in the same sync. B reconstructs the real vault and simply skips the -/// unfetchable one instead of aborting the whole sync. +/// W3: a poison announce (node-signed so it passes delegation, but pointing at an unbacked +/// digest) must not starve the legitimate vaults; B reconstructs the real vault and skips it. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn poison_announce_does_not_abort_sync() -> Result<()> { let daemon_a = Daemon::start(State::from_seeds([0x31; 32], K_ROOT)).await?; diff --git a/crates/carapaced/tests/unfriend.rs b/crates/carapaced/tests/unfriend.rs index 798a0d6..aec2906 100644 --- a/crates/carapaced/tests/unfriend.rs +++ b/crates/carapaced/tests/unfriend.rs @@ -1,18 +1,7 @@ -//! §9.3 unfriend / re-split inbound-handler acceptance (W5), exercised over real -//! `carapace/1` control streams between live daemons - NOT by calling the pure state -//! functions directly. This is where the authorization lives (`serve_friendship_end`, -//! `serve_delete_request`, `serve_share_destroy`), so it is where the tests must bind. -//! -//! Covered: -//! 1. Positive: `Daemon::unfriend` drives the FriendshipEnd + DeleteRequest over the -//! wire and the peer actually tears down ITS side (drops the friendship, deletes the -//! replica it held for us). -//! 2. Negative: a current friend cannot (a) force us to unfriend by sending a -//! FriendshipEnd that names a THIRD PARTY, nor (b) destroy a share we hold for an -//! UNRELATED owner by sending a ShareDestroy naming that owner's subject/rsid. -//! -//! Every test is BOUNDED: dials are capped by the daemon's connect timeout; no cadence -//! is ever waited on; daemons are torn down at the end. +//! §9.3 unfriend / re-split inbound-handler acceptance (W5) over real `carapace/1` control +//! streams (where the authorization lives). Positive: `unfriend` makes the peer tear down its +//! side. Negative: a friend cannot force us to unfriend a third party via a FriendshipEnd, nor +//! destroy a share we hold for an unrelated owner via a ShareDestroy. Bounded. use anyhow::Result; use carapace_wire::{FriendshipEnd, ShareDestroy, Signed}; @@ -46,9 +35,8 @@ async fn befriend(dialer: &Daemon, issuer: &Daemon) -> Result<()> { Ok(()) } -/// §9.3 steps 1-2 over the wire: `unfriend` must make the EX-FRIEND tear down its own -/// side - drop the friendship (FriendshipEnd) and delete the replica it stored for us -/// (DeleteRequest) - not merely mutate the initiator's local state. +/// §9.3 steps 1-2 over the wire: `unfriend` must make the EX-FRIEND drop the friendship +/// (FriendshipEnd) and delete the replica it held (DeleteRequest), not just mutate local state. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn unfriend_tears_down_the_peer_over_the_wire() -> Result<()> { let a = Daemon::start(seeds(0x01, 0xA0)).await?; @@ -91,9 +79,8 @@ async fn unfriend_tears_down_the_peer_over_the_wire() -> Result<()> { Ok(()) } -/// The authorization negatives that the two W5 blockers were about. A current friend B -/// of the victim V must not be able to (a) forge V into unfriending B by naming a third -/// party in a FriendshipEnd, nor (b) destroy the share V holds for an unrelated owner A. +/// Authorization negatives: a friend B of victim V cannot (a) forge V into unfriending B by +/// naming a third party in a FriendshipEnd, nor (b) destroy the share V holds for owner A. #[tokio::test(flavor = "multi_thread", worker_threads = 6)] async fn forged_friendship_end_and_share_destroy_are_rejected() -> Result<()> { let a = Daemon::start(seeds(0x01, 0xA1)).await?; // owner of the split secret @@ -105,8 +92,7 @@ async fn forged_friendship_end_and_share_destroy_are_rejected() -> Result<()> { for t in [&b, &c, &v] { befriend(&a, t).await?; } - // B is an established friend of V: exactly the insider whose node V will resolve as - // an owner (owner B), and who is authorized on V's control stream. + // B is an established friend of V: the insider V resolves as owner B, authorized on V's stream. befriend(&b, &v).await?; assert!(v.is_friend(&b.user_id())); @@ -156,8 +142,8 @@ async fn forged_friendship_end_and_share_destroy_are_rejected() -> Result<()> { ); // --- (b2) ShareDestroy naming B as subject but A's rsid must be rejected --- - // Here owner_user_of_node(B) == subject(B) passes, but the rsid belongs to A, so the - // rsid->subject binding must refuse it. + // owner_user_of_node(B) == subject(B) passes, but the rsid belongs to A, so the + // rsid->subject binding refuses it. let mut ds_wrong_rsid = ShareDestroy { subject: b.user_id(), rsid: RSID, @@ -178,10 +164,9 @@ async fn forged_friendship_end_and_share_destroy_are_rejected() -> Result<()> { Ok(()) } -/// §9.3.4 W5 (gap 1): unfriending a TRUSTEE does not auto-start the re-split. It records a -/// PENDING one (surfaced with the suggested new set, the ex-trustee excluded) and delivers -/// NO new grants until the user starts it via `start_pending_resplit`. This is the §9.3.4 -/// prompt flow: the user, not the daemon, decides to re-split. +/// §9.3.4 W5: unfriending a TRUSTEE does not auto-start the re-split; it records a PENDING one +/// (suggested new set, ex-trustee excluded) and delivers no new grants until the user starts +/// it via `start_pending_resplit`. #[tokio::test(flavor = "multi_thread", worker_threads = 6)] async fn unfriending_a_trustee_leaves_a_pending_resplit_until_started() -> Result<()> { let a = Daemon::start(seeds(0x01, 0xA2)).await?; // owner of the split secret @@ -263,13 +248,10 @@ async fn unfriending_a_trustee_leaves_a_pending_resplit_until_started() -> Resul Ok(()) } -/// §9.3.1 W5 (gap 2): a daemon that RECEIVES a FriendshipEnd from a peer it placed data on -/// must send ITS OWN DeleteRequest(s) for what it placed - deferred to the maintenance loop -/// (the control handler has no endpoint to dial out). Here B placed a replica on A; when B -/// receives A's FriendshipEnd, B tears down and, after a maintenance round, asks A to delete -/// B's replica. A keeps its side of the friendship so it still honors the request and the -/// deletion is observable end to end. A DeleteRequest never triggers a FriendshipEnd, so -/// this cannot loop. +/// §9.3.1 W5: a daemon that RECEIVES a FriendshipEnd must send its own DeleteRequest(s) for +/// what it placed, deferred to the maintenance loop. B placed a replica on A; on receiving A's +/// FriendshipEnd, B tears down and (after a round) asks A to delete it. A keeps its side of the +/// friendship so it still honors the request, making the deletion observable. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn receiving_friendship_end_sends_reciprocal_delete_requests() -> Result<()> { let a = Daemon::start(seeds(0x01, 0xA3)).await?; @@ -286,10 +268,8 @@ async fn receiving_friendship_end_sends_reciprocal_delete_requests() -> Result<( assert_eq!(placed, vec![a.node_id()], "A accepted B's replica"); assert!(a.holds_replica(&vid), "A stores B's replica before the end"); - // A sends B a validly signed FriendshipEnd naming B, but keeps its OWN friendship with - // B (so it still authorizes B's reciprocal DeleteRequest - we can then observe A delete - // B's data). This isolates the RECEIVE-path reciprocal-send; in a full mutual unfriend A - // would already have dropped B's data in its own teardown. + // A sends B a signed FriendshipEnd naming B but keeps its OWN friendship with B (so it + // still authorizes B's reciprocal DeleteRequest), isolating the RECEIVE-path reciprocal-send. let mut end = FriendshipEnd { user: b.user_id(), ts: 1_700_000_000, diff --git a/crates/carapaced/tests/watch_reingest.rs b/crates/carapaced/tests/watch_reingest.rs index 3307465..81889e6 100644 --- a/crates/carapaced/tests/watch_reingest.rs +++ b/crates/carapaced/tests/watch_reingest.rs @@ -1,10 +1,6 @@ -//! §11 / W12 filesystem watcher: a local change under a published vault's source -//! directory re-ingests the vault (epoch++), so replicas and other owner devices -//! pick it up "like Dropbox". -//! -//! Bounded and non-flaky: the watcher runs against a REAL `notify` backend, but -//! every wait is capped by `tokio::time::timeout`, so a regression that stops the -//! watcher from firing FAILS fast instead of hanging. +//! §11 filesystem watcher: a local change under a published vault's source dir re-ingests it +//! (epoch++), Dropbox-style. Runs against a real `notify` backend but every wait is capped by +//! a timeout, so a watcher that stops firing fails fast instead of hanging. use std::path::Path; use std::sync::Arc; @@ -13,10 +9,8 @@ use std::time::Duration; use anyhow::{bail, Result}; use carapaced::{Daemon, State}; -/// Hard ceiling on how long we wait for the watcher to observe a change and -/// re-publish. Generous (fs event latency + debounce + a full re-ingest) yet -/// finite: exceeding it means the watcher never fired, which is the failure we -/// are guarding against. +/// Ceiling on waiting for the watcher to observe a change and re-publish (fs latency + +/// debounce + re-ingest); exceeding it means the watcher never fired. const REINGEST_DEADLINE: Duration = Duration::from_secs(15); /// Current epoch this daemon has published for `vid`, or 0 if none. @@ -80,9 +74,8 @@ async fn w12_local_change_triggers_reingest() -> Result<()> { Ok(()) } -/// Give the freshly-armed watcher a beat to settle before writing, so the write -/// lands as a distinct, observed event (avoids racing setup on slow CI), then -/// write atomically-ish via a single `fs::write`. +/// Give the freshly-armed watcher a beat to settle so the write lands as a distinct observed +/// event (avoids racing setup on slow CI). async fn modify_after_settle(path: &Path, contents: &[u8]) -> Result<()> { tokio::time::sleep(Duration::from_millis(50)).await; std::fs::write(path, contents)?; diff --git a/crates/carapaced/tests/watch_sync_interaction.rs b/crates/carapaced/tests/watch_sync_interaction.rs index 42e52e7..d57882f 100644 --- a/crates/carapaced/tests/watch_sync_interaction.rs +++ b/crates/carapaced/tests/watch_sync_interaction.rs @@ -1,19 +1,11 @@ -//! BLOCKER 1 regression: a filesystem watcher on a vault's working directory must -//! not tombstone files that a sync merged INTO that directory. -//! -//! Two owner daemons share one `k_root`. Device A watches its working dir. The two -//! reconcile a vault whose merge yields (a) a file that exists only on B (a pure -//! union, synced INTO A's working dir) and (b) a `sync-conflict-*` copy from a -//! concurrent edit. A watcher tick then fires (a genuine local change). The bug -//! was: the watcher re-ingested a directory that the sync had written to a -//! DIFFERENT location, saw the synced-in file "absent from disk", and minted a -//! dominating tombstone that deleted it on every device (silent data loss). -//! -//! With the unified working-directory model the sync reconstructs into the watched -//! tree, so the re-ingest sees the full merged set and mints NO tombstone. The test -//! proves it by advancing past a watcher re-ingest and then confirming both the -//! synced-in file AND the conflict copy still round-trip to a peer - i.e. neither -//! was tombstoned. Every wait is hard-bounded, so a regression fails fast. +//! Regression: a filesystem watcher on a vault's working dir must not tombstone files a sync +//! merged INTO that dir. Two owner daemons share one `k_root`; A watches its working dir. They +//! reconcile a vault whose merge yields a B-only file (synced into A's dir) and a +//! `sync-conflict-*` copy. The bug: the watcher re-ingested a dir the sync had written +//! elsewhere, saw the synced-in file "absent", and minted a tombstone that deleted it +//! everywhere. With the unified working-dir model the re-ingest sees the full merged set and +//! mints no tombstone; the test forces a watcher re-ingest and confirms both files survive. +//! Hard-bounded waits. use std::collections::BTreeSet; use std::path::Path; @@ -121,9 +113,8 @@ async fn watcher_does_not_tombstone_synced_in_files() -> Result<()> { daemon_a.publish_vault(src_a.path(), vid).await?; daemon_b.publish_vault(src_b.path(), vid).await?; - // Arm the watcher on A's working dir BEFORE reconciling, so the sync's writes - // into it (the synced-in b_only.txt and the conflict copy) actually fire the - // watcher - the exact condition that used to trigger the tombstone bug. + // Arm the watcher BEFORE reconciling, so the sync's writes into A's dir fire it - the + // condition that used to trigger the tombstone bug. let watcher = Arc::clone(&daemon_a).watch_vault(vid, src_a.path().to_path_buf())?; let out_a = tempfile::tempdir()?; @@ -147,10 +138,8 @@ async fn watcher_does_not_tombstone_synced_in_files() -> Result<()> { "A's watched working dir must contain the synced-in file + conflict copy" ); - // Force an observable watcher re-ingest with a genuine local change. If the bug - // were present, this re-ingest of A's working dir would tombstone b_only.txt and - // the conflict copy (seen as "absent" because the sync had written them - // elsewhere), and the tombstone would then propagate and delete them on B. + // Force an observable watcher re-ingest with a local change. With the bug, this re-ingest + // would tombstone b_only.txt + the conflict copy and propagate the deletion to B. let e0 = epoch_of(&daemon_a, vid); tokio::time::sleep(Duration::from_millis(100)).await; // let the watcher settle std::fs::write(src_a.path().join("trigger.txt"), b"local change")?; From cb54e7f23812535ec4c03a7d479489467d0ec0b1 Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:53:55 -0400 Subject: [PATCH 04/28] Harden security, recovery, and release assurance --- .gitattributes | 1 + .github/workflows/ci.yml | 179 +- .github/workflows/release.yml | 263 +- .nvmrc | 1 + Cargo.lock | 626 ++ Cargo.toml | 1 + cbor_vectors.py | 82 +- chela-revision.txt | 1 + crates/carapace-api/Cargo.toml | 8 + crates/carapace-api/src/auth.rs | 19 +- crates/carapace-api/src/bin/carapaced.rs | 214 +- .../src/bin/carapaced/state_ops.rs | 579 ++ crates/carapace-api/src/claimant.rs | 625 ++ crates/carapace-api/src/handlers.rs | 895 ++- crates/carapace-api/src/lib.rs | 136 +- crates/carapace-api/src/ops.rs | 34 + .../assets/{0.BIBOqY7u.css => 0.BNChGHTQ.css} | 2 +- .../_app/immutable/assets/2.BO-zofLV.css | 1 + .../_app/immutable/assets/2.Ccp3aMT0.css | 1 - .../chunks/{Cfkx4BKF.js => BdOmOXD3.js} | 2 +- .../{app.BJNxxL8q.js => app.Cz953gq6.js} | 4 +- .../_app/immutable/entry/start.2-caBSwK.js | 1 + .../_app/immutable/entry/start.CbdnOVWP.js | 1 - .../nodes/{0.DSqSDKgT.js => 0.CCSq_TiJ.js} | 0 .../nodes/{1.X1iBfBii.js => 1.y4-4BiaA.js} | 2 +- .../static/_app/immutable/nodes/2.BZ_ZwSYn.js | 26 + .../static/_app/immutable/nodes/2.CaWSwQlO.js | 25 - crates/carapace-api/static/_app/version.json | 2 +- crates/carapace-api/static/claimant.css | 14 + crates/carapace-api/static/claimant.html | 80 + crates/carapace-api/static/claimant.js | 138 + crates/carapace-api/static/index.html | 16 +- crates/carapace-api/tests/integration.rs | 191 +- .../tests/terminal_passphrase_process.rs | 70 + crates/carapace-crypto/Cargo.toml | 1 + crates/carapace-disclose/Cargo.toml | 2 + crates/carapace-disclose/src/lib.rs | 105 +- crates/carapace-friend/Cargo.toml | 1 + crates/carapace-net/Cargo.toml | 1 + crates/carapace-net/src/blobs.rs | 300 +- crates/carapace-net/tests/integration.rs | 91 + crates/carapace-recovery/Cargo.toml | 1 + crates/carapace-recovery/src/ceremony.rs | 22 + crates/carapace-replica/Cargo.toml | 1 + crates/carapace-replica/src/lib.rs | 7 +- crates/carapace-replica/src/por.rs | 125 +- crates/carapace-replica/tests/por.rs | 31 + crates/carapace-restore/Cargo.toml | 20 + crates/carapace-restore/src/lib.rs | 1059 +++ crates/carapace-share/Cargo.toml | 1 + crates/carapace-vault/Cargo.toml | 2 + crates/carapace-vault/src/lib.rs | 129 +- crates/carapace-wire/Cargo.toml | 1 + crates/carapace/Cargo.toml | 1 + crates/carapaced/Cargo.toml | 7 + crates/carapaced/src/lib.rs | 2075 +++++- crates/carapaced/src/ops.rs | 55 + crates/carapaced/src/persist.rs | 936 ++- crates/carapaced/src/state.rs | 669 +- crates/carapaced/tests/ceremony.rs | 167 +- crates/carapaced/tests/fixtures/MANIFEST.tsv | 2 + crates/carapaced/tests/fixtures/README.md | 49 + .../tests/fixtures/legacy-rich-v1.redb.gz.b64 | 80 + crates/carapaced/tests/legacy_fixture.rs | 68 + crates/carapaced/tests/reboot_survival.rs | 32 +- .../carapaced/tests/recovery_reconstruct.rs | 70 +- crates/carapaced/tests/state_inspection.rs | 30 + crates/carapaced/tests/two_device_sync.rs | 66 + deny.toml | 30 + docs/blob-garbage-collection.md | 41 + docs/carapace-spec-summary.md | 161 + docs/cbor-vector-oracle.md | 25 + docs/claimant-recovery-boundary.md | 69 + docs/duplicate-crypto-dependencies.md | 25 + docs/key-storage.md | 96 + docs/recovery-ceremony-lifecycle.md | 29 + docs/release-dependencies.md | 58 + docs/spec-errata.md | 74 +- docs/supported-platforms.md | 39 + docs/test-inventory.md | 86 + fuzz/Cargo.lock | 6079 +++++++++++++++++ fuzz/Cargo.toml | 56 + fuzz/README.md | 42 + .../04700c4651ce9aa5b350fc0bf226e5b5a800bf7f | Bin 0 -> 16 bytes .../070288e0bb92275eb0d0f0d60f6246950b2113ed | Bin 0 -> 19 bytes .../0b753eb363d09f8447475f78b301842d131ae5af | 1 + .../0e1e41a392fa5af0ec192352db6152bed90c62ad | 1 + .../0e863dd9c0f90ef59575e71592794e8ece20f3c4 | 1 + .../117663cc2af9d0cfa642088cdac159c25768fa3b | 1 + .../128c328e12f6f9fbb4c05a8359a875027434b75b | Bin 0 -> 28 bytes .../14490ca81d6de5e755e605b2bde825e2f0db5da4 | Bin 0 -> 6 bytes .../15a4183889ea65809e47b2baba9ee78e7f7ce7ab | 1 + .../1875397fb4500fb7edd13e0d00aa537945650ccb | 1 + .../1bfbe8bcd532b7ee2f61fbd32812a74956d4fffd | 1 + .../1e128a9afee9d68d93520197de3400e8f8e44c66 | Bin 0 -> 22 bytes .../26776823358c3568acc99da47761b1bfa93a3d9b | 1 + .../2e58bfc6917246cb615f9657792cc7ab4337cbc5 | Bin 0 -> 7 bytes .../32c9211bd6dfd03385ed0ab72356ddfba5191a72 | Bin 0 -> 24 bytes .../3472d7b236d31127ee867e89ff7426575523dfbc | 1 + .../3b3f11ccacfed602011f64a913f541ef69b8cffc | Bin 0 -> 9 bytes .../3dd8da25bbfc27fcc5321599e2eb2b898aa649cd | Bin 0 -> 23 bytes .../4320aa4bf6289c6f7ca6479e93d220991e7a534d | Bin 0 -> 12 bytes .../437760bda35f2aca2c59976882df4519209a70d9 | Bin 0 -> 21 bytes .../4f695b66ae1cad9eb338322dd9d3da2b40ed77d6 | Bin 0 -> 13 bytes .../4fb8cfeaaac80a1c829b22a43089ef470bcfe5b8 | 1 + .../52a719f9d01e6a1882f97bc011e52c80f807e955 | 1 + .../550f067a125ad25f3e3743be39783cc869f26ae2 | Bin 0 -> 26 bytes .../55a77979ceb119c91ddb4a5bc942191bfe7981ea | 1 + .../56c299516d0c79aa0c93b87c90da3eac7dccdbc5 | 1 + .../59f52c524518c5411343e1d4e640bc581a686f36 | 1 + .../5a18eac50c71553681bad458d52333d9f45ede85 | Bin 0 -> 6 bytes .../5c10b5b2cd673a0616d529aa5234b12ee7153808 | 1 + .../6017e2d699f6042c53b3ab5df98448bde30d046f | Bin 0 -> 12 bytes .../60c79e75f9c2ea5f5aaf21ec2ad7d5b13d61f864 | 1 + .../616e72f7280d7b175936da0506a09586f292e840 | Bin 0 -> 5 bytes .../61f72362fd53c82f524b84e947a21e42f63ca425 | Bin 0 -> 10 bytes .../665e723f1d4c3177f116645e24c5fd0e8726690b | 1 + .../684886f194921f1237d4289216d094bf6f479ecd | Bin 0 -> 15 bytes .../68d0ad967895a4f9e902c3a3ec79df4fcd942a00 | Bin 0 -> 16 bytes .../6a17b513f9fe8087cc3bcee24810c17315c09727 | Bin 0 -> 19 bytes .../6c6d61ec3e8ae0a68ccc81c9dc1a3684d688bf7f | Bin 0 -> 8 bytes .../6dcfe81af1b0409137797e5429de9637c9bff4d3 | Bin 0 -> 8 bytes .../75ce08c3fd2653dab05205243c2cdd1e3e8b65fb | 1 + .../7728b61c6235bdea3258c2e5c0762114ba38fce0 | 1 + .../7ac390710287bab3b0d119314d9422950246d300 | Bin 0 -> 21 bytes .../7d9f4961c889d77a3e57eb68f4cd0cda4aa21b48 | Bin 0 -> 8 bytes .../7f93a7d52ffdff88508f719510b824fadc7778e6 | 1 + .../8bdfa924c0f9ae6cfc3d96a581e50e87b74ad840 | Bin 0 -> 22 bytes .../8dbb30c9cba5edf1027b67309722bd734d81a231 | Bin 0 -> 21 bytes .../8dc00598417d4eb788a77ac6ccef3cb484905d8b | 1 + .../9403fc87dd83c1cffc4c2c173de878094e4c0bdc | Bin 0 -> 7 bytes .../94c92052e049d18cd223df9f0256125bb847f49f | 1 + .../953efc01deceedba7832f24db5d1aacfea7e4836 | Bin 0 -> 14 bytes .../9a78211436f6d425ec38f5c4e02270801f3524f8 | 1 + .../a6790ec83acd251e8a2a37c7ca72314159c026e5 | Bin 0 -> 32 bytes .../a6978896e1bad8f5705d8db20e521ad4906850a2 | Bin 0 -> 23 bytes .../a6b1c3d994cf885e8b69282b29f2c2f015efccf3 | 1 + .../a91ee86ddda4cd00b985f4883f62e89dbcbe4545 | Bin 0 -> 7 bytes .../a979fc300e6039695bad522bce5e4ba543a6bbfe | Bin 0 -> 22 bytes .../a9f0de4e41eda5cdaf0ccd924d135f6784b45e00 | Bin 0 -> 7 bytes .../aa359f022b223e5269b5a1e291b8602f8a6b8277 | Bin 0 -> 32 bytes .../adad2ca7ab313add6e955f704719e03d5229e4d0 | 1 + .../b586e402a09005dee351fd00e88fc59acb88ccbd | Bin 0 -> 27 bytes .../b5aadfc855a5c39069694d7923e139ef6ed5a24f | 1 + .../bc0eac03b7f5e8bd89dcf4665f33e742297cf409 | 1 + .../ca7ab576c54f8a5ab79c833fb23cb268cf1defde | Bin 0 -> 22 bytes .../ca9fa8618b8fb892050dc1e0cd894736a3489dee | 1 + .../cb75f868886f7f370bf64d206118d1c5fbb29e28 | Bin 0 -> 15 bytes .../cc0bf81c2043fca1c9bc8e414939418fd80b376c | 1 + .../d24cbbc2ddd4bf1bce3fea4ac8aa0dbe2d5d0ed5 | Bin 0 -> 32 bytes .../d305d7866d56c57e617dc35d74a70a027b8702d5 | Bin 0 -> 25 bytes .../d383cf9061c6f2f5cc96a43e4a630b3f6fac8eda | Bin 0 -> 22 bytes .../d3cc4f310527339261fe66dbd3c3fc679de7f800 | Bin 0 -> 10 bytes .../d401cadd8a129a7830fbc348b12b10d70c2fcae1 | Bin 0 -> 10 bytes .../d52aacc35eb16470fcf18c07371331d47886570c | Bin 0 -> 5 bytes .../d54072ab41a300921e70aeadd4b6800eb4ef88b7 | 1 + .../deffad809b7a2ebe600b9025ad723694f298a170 | Bin 0 -> 10 bytes .../e9c5d7db93a1c17d45c5820daf458224bfa7a725 | 1 + .../eae13156f99b78292ee151b3bf2f9854d6fd3097 | 1 + .../ebb3d39e7a1cea60e0311ccc206a5affdc406f9f | Bin 0 -> 7 bytes .../ebdc2288a14298f5f7adf08e069b39fc42cbd909 | 1 + .../efbb36287bdff8bac6b82dc17f5bbc63a8451ebf | Bin 0 -> 7 bytes .../f6ce3f52b6e1370de181a5238f5cd9565732771a | 1 + .../f70a9b8378e448c3c1394e49b04b2957972af683 | Bin 0 -> 11 bytes .../fae1f6d68ceb776525c61b432c3a2fa72e99124f | Bin 0 -> 13 bytes .../fb9cec37b0cb4659036c9b715daff486c1064cf1 | 1 + .../0393ff30aaa9deb28be0a2e134dc7d4f27712cb4 | Bin 0 -> 20 bytes .../04757e726a7004b504517ea3b65d6d5ebaa3e12b | Bin 0 -> 13 bytes .../062db096c728515e033cf8c48a1c1f0b9a79384b | Bin 0 -> 2 bytes .../07fc65143fbb6237452c86103d705256c4596773 | Bin 0 -> 11 bytes .../0d9b86faf155101f0bc6f9b5e6546a5d7f2f00ff | 1 + .../0dcf890ad229cfb44d2a4b0caaf27d8429398ff3 | Bin 0 -> 12 bytes .../0dd534833ff16a4f58727a87b959105616ef5bc0 | Bin 0 -> 22 bytes .../0e85812dc1a466d2c1c4a68d582d8e413adbcbd0 | Bin 0 -> 17 bytes .../11d52cab70564cbadaccec9e47fbf773cca5e367 | 1 + .../11f6ad8ec52a2984abaafd7c3b516503785c2072 | 1 + .../11fcc64ccb891cef2c95149dcf87d5b74632df89 | Bin 0 -> 24 bytes .../13cba177bcfad90e7b3de70616b2e54ba4bb107f | 1 + .../1a98ddfe22b9c4705091539697b1f2ac60c3fa0f | Bin 0 -> 22 bytes .../1e4872e7bde008d6cffd8951d8ba60710ed6c481 | Bin 0 -> 19 bytes .../1eb8f1318f36456032e6aa8dfb565ff76a652b58 | 1 + .../200577df89f7bc011f3d62b0cb16e3a82d1a96a6 | Bin 0 -> 18 bytes .../227ae80dc68618d68d598aae3aec75ec0f328f63 | Bin 0 -> 25 bytes .../2630d26866a14aa5426e66392e930aa7637444cd | Bin 0 -> 18 bytes .../2e7941b976cbb4f7a12a3132b9d2db2cfbb64f6b | 1 + .../31fa92f47f979983be15ab4140b9d31ad036e3cb | Bin 0 -> 16 bytes .../321a501ff9f3bb9c65355ca158c22bf5df0b55f1 | Bin 0 -> 21 bytes .../36aea971ac9911d392123ef85bef842e69ee2b49 | 1 + .../36e7fba73c1161ecee70557ecd3f88312abf38bf | 1 + .../40dca6fd62f63ef537091021fe253300526df291 | Bin 0 -> 25 bytes .../48ac6d1a63b6ab9a4ea319becf939693f5b486d0 | 1 + .../48f2b8ef50125ef05b7378291c1784eeba242e2b | Bin 0 -> 22 bytes .../4e3ae87eabb28c18cffcaa71ca02f01e41649511 | Bin 0 -> 15 bytes .../4f057c40547dda405439af625bcd672396856733 | Bin 0 -> 10 bytes .../4ff447b8ef42ca51fa6fb287bed8d40f49be58f1 | 1 + .../511720dd7ce63ae6e6bace13fa2e6d671491b358 | 1 + .../5385a2e42890729addc03f16b5356d87a8d746fe | Bin 0 -> 17 bytes .../57d1dd312f1de4f91a09212aabd3a7a935919338 | Bin 0 -> 6 bytes .../582a8447c179d693806d180b4af05b178436a609 | Bin 0 -> 16 bytes .../5c5354ca6370528da0bc4465e37b5b1c45e32bde | Bin 0 -> 10 bytes .../5cad51770caba3e2b5a69d7e2e705c9b4b1a23ae | Bin 0 -> 11 bytes .../600386e70fad9968432325012e421fd57c5b08ba | Bin 0 -> 16 bytes .../60a23c73404c27b075d24d0e3934875aed3fe6cd | Bin 0 -> 25 bytes .../6431545d884b8685a05d055a7115b08ef9bf4e10 | Bin 0 -> 18 bytes .../680597aed4c14ebd39e7873db3a4b5b9f8f26bda | Bin 0 -> 20 bytes .../69ee7f0679cc30ae9524c00e8d175cc42de1f287 | Bin 0 -> 10 bytes .../6ac019df35369adecb01cf8c32647f925437ef47 | Bin 0 -> 12 bytes .../6b0a573c5d09b3ae83cbb8ff7b3318087f324fcc | Bin 0 -> 8 bytes .../6bc6c9168872d44922f99cdafb48012126cd5e8f | 1 + .../6c7c0741e93ca3597e8d33ae11660a9d29826d56 | Bin 0 -> 14 bytes .../6d5d8fb3cd9fc1fb88e616fb2760a751b901b46d | Bin 0 -> 17 bytes .../6e73461eba24d5ebfaccc5ea0de8b13a11a140f4 | 1 + .../70467f08eea02fe3c7d922036190996e22a8324b | Bin 0 -> 17 bytes .../846c511e157a79ce40c98c9ca73711bd6914f9fd | Bin 0 -> 11 bytes .../85e53271e14006f0265921d02d4d736cdc580b0b | 1 + .../879bcd9a0f536cffe93f55fbb392a8bcca30fb25 | Bin 0 -> 12 bytes .../8d7121a4f62d2cf030ab07432ed96a8abdc3c7ae | Bin 0 -> 23 bytes .../9034aaf45143996a2b14465c352ab0c6fa26b221 | 1 + .../9069ca78e7450a285173431b3e52c5c25299e473 | Bin 0 -> 4 bytes .../9120f1b9c42fde9689ced4d90d7cd91f94d18544 | Bin 0 -> 19 bytes .../91c5967a4f7349e8d118c76f83fecc2ab29b5d92 | 1 + .../95df4d0a7530a091751fa52f590a3a98e0e4f178 | Bin 0 -> 21 bytes .../98536b7c45d1291354a083b478919e6191d17d1f | Bin 0 -> 18 bytes .../986b212420e3b977068244e6bd916575bb0c15e5 | 1 + .../9b58cd6c932740257864845f243a850631c119c8 | Bin 0 -> 25 bytes .../a17f491cec7dcb15b9bc059d0662d8b25e4a6ab3 | Bin 0 -> 12 bytes .../a6d969a9126da348ca87d92060183e9e1e1c9163 | Bin 0 -> 25 bytes .../a71839ac78d944daeb1be89a2ec08c735a7140a7 | Bin 0 -> 16 bytes .../a9c46930ccb67a80e303f6e56e4ceddc533a12a9 | Bin 0 -> 16 bytes .../ab21de136b914670387ea060b0bea4338501ab06 | Bin 0 -> 18 bytes .../ad9acc9b8309aedf133630f34b7060bd1ae833d4 | Bin 0 -> 10 bytes .../b01c6825d39820f719176471d94e4dc59b087745 | Bin 0 -> 17 bytes .../b058b26d5bc8bf476e8784292c4f81791f2882ed | Bin 0 -> 15 bytes .../b0ad7e48247e8510be43d413c7fb2820d6bb8326 | Bin 0 -> 12 bytes .../b1a9168f48ad3f6ee8ce04b27ed4b9f8101291a4 | Bin 0 -> 15 bytes .../b44eacac29b1d23f45b5a7d49cde102e2385b613 | Bin 0 -> 10 bytes .../b8426e6923502c7b45f5824a5bce6c08a4f700d5 | 1 + .../c4ea21bb365bbeeaf5f2c654883e56d11e43c44e | 1 + .../c54fed1cb131a4293193dda493acffdbc88a5fa8 | Bin 0 -> 13 bytes .../d1917105f2335a77fed9835d2e04a32137cfcf9f | Bin 0 -> 11 bytes .../d61c44e2d4bdf400b0b6a9e11f7d5ebed99f839c | Bin 0 -> 11 bytes .../d6b759f23ba20cae581cae997beeab3fb94374de | Bin 0 -> 15 bytes .../d6cf30f6bf39a35518c484ceee19dd4cf7304c76 | Bin 0 -> 9 bytes .../d97c72e3699ec99fd2e685e13d05984af114b340 | Bin 0 -> 3 bytes .../e18e09cb06ccf9b3ed85bdd3d9c39bc6cce1946b | Bin 0 -> 22 bytes .../e48c7a66fcb462f19ec800be07b2e27d8c4d3b0a | Bin 0 -> 14 bytes .../ebe39cb0f5742b0d4712fdcb9815ea8089aeb8c7 | Bin 0 -> 12 bytes .../ed4f15928535bf9ac7b0e8afdc601f8bc1769976 | 1 + .../edc034e438e53380ca3613b9c9407a3c11cbf410 | Bin 0 -> 13 bytes .../f0101df27fe0ed4f9fcd0926d09113fbf6c653bb | Bin 0 -> 20 bytes .../f11a7ab39cf0ee38afd8710fb0e928f4af1ec66b | Bin 0 -> 18 bytes .../f327b946142369b1e029ff6b140252d12706b125 | Bin 0 -> 10 bytes .../f562fc51fd99ba8547259ba71638ffb6e7ddace9 | Bin 0 -> 12 bytes .../f778fafe063ff7cbcaf026e5b00771d3c767fc8e | Bin 0 -> 16 bytes .../f77a1eed83c950aae7923ab1688caefaf09fd25f | Bin 0 -> 13 bytes .../f804881bcc87668f5bb2750843442ce31f5789d5 | 1 + .../f8407e180bd92589b728af21c5626c18770cf26b | 1 + .../f8d493eaf1475a279dad0a7aee8fa23d6a3c2626 | 1 + .../f997594a393e15e675958dd4b9cd1b3a3aeec6ae | 1 + .../ffaae201ec3182fb24e869f92badace73b6ec12e | Bin 0 -> 25 bytes .../ffc54ca808e7666f250133ad0ae2185ad688a826 | 1 + .../011783b9471d573b335616c3f88b0a8e963b9b1b | 1 + .../02be2925afebf662739a1425d372e9725455907c | 1 + .../03ee9b799119744aa78d078583db62dd150c5bec | Bin 0 -> 11 bytes .../04a0b4a47ce31c0818a34aed2decdc96fb0152ff | 1 + .../06249eea6b4a5db9c4a25984ec0e384048ee196d | Bin 0 -> 9 bytes .../099600a10a944114aac406d136b625fb416dd779 | 1 + .../0b5b2d3c04c3b4b9a1e3934309fccdd1de02bbfe | 1 + .../12c79d6605ebf4faaf5ac517969de16d57e22c4a | Bin 0 -> 18 bytes .../17b7866c027dcafa35f8773281081c5f21d8e815 | Bin 0 -> 8 bytes .../17d82429676a942f5db1d61ab0e4082918e3721e | Bin 0 -> 6 bytes .../24a847eca1c10ab1ac91d0f070e4471fb3eadae7 | 1 + .../25441876386c77cae86763fa1b04fff91b99bbe1 | Bin 0 -> 18 bytes .../2596a2765137f1951576bd0ff693c611a7a4d635 | 1 + .../2664deb315e9a73afb9b47b3621e73c993802b9b | Bin 0 -> 7 bytes .../268be7cc174657dbcff5cc36dc1ab473bcd5d23c | Bin 0 -> 9 bytes .../287c41521a76b58002bc3ad8d78c82692d17fe54 | Bin 0 -> 18 bytes .../2a9089662d4313c1b7a2d40c7981af53b8b37bcf | Bin 0 -> 9 bytes .../2af3fef91972015d739a0e05ab77e6b97e11c369 | 1 + .../2b22b5029cca5461b600a62ba9a0345328f1cab1 | Bin 0 -> 8 bytes .../2fd64b18bf3f26afc941aec4b9d9fa5838174b34 | Bin 0 -> 14 bytes .../33149933efaecb206a8e13951b5b8793f3a39eba | Bin 0 -> 5 bytes .../380ed71d267b522661a9e5953c0c884958c19589 | Bin 0 -> 12 bytes .../3a52ce780950d4d969792a2559cd519d7ee8c727 | 1 + .../42099b4af021e53fd8fd4e056c2568d7c2e3ffa8 | 1 + .../4a7860eda0f65591a4c429c05ef5f35e54e3dfa0 | 1 + .../4a9341b8d4e4ee54c82f57ee198827b9a3a23ece | 1 + .../4af51fbf2f5def11b9f9fed6f4aa0a0e722f76f9 | Bin 0 -> 19 bytes .../506195952c960e2c406e464d4ee17538363c646f | Bin 0 -> 9 bytes .../53d09471390f5133adca2aa4e8c91f2964fce945 | Bin 0 -> 5 bytes .../572476edbefd80e1b577f9ae192bc5a50222398c | Bin 0 -> 8 bytes .../59fbdf614036a8825488648bb59e125e9301989e | Bin 0 -> 3 bytes .../5c1eee5a922c0e289ee9a987cc3efa9d9a334d34 | Bin 0 -> 5 bytes .../5c8fd4cd9ed7fc0fd1ef2f5bafa73907a8ec5634 | 1 + .../615e7ea83a420060133b831eb81e03fee7c39f5b | Bin 0 -> 16 bytes .../618f9665bb465c9877a234dadd31d65fc1003ef4 | Bin 0 -> 27 bytes .../655bff010fbe54f96d3ee1640b8b37427dd295d6 | Bin 0 -> 13 bytes .../6a9fa8d13ac92ba1bf0fd1ada52ba58cc64d466d | 1 + .../6af70ec99d7247eb410007b3384cedb028b5506f | Bin 0 -> 6 bytes .../6b3b106b555a37498986c3ef6aa12bef233e8b7f | Bin 0 -> 8 bytes .../6cd4f0f88384103872b630888783d7e52867d62a | Bin 0 -> 16 bytes .../6e231709cbbbced3431ca7cfb4cae1d153658a60 | 1 + .../71136726208953013b46b3ce0ebbd7b36570ab7a | Bin 0 -> 18 bytes .../746b049ba256830089d73c32d52dca16e6ef0870 | 1 + .../75740759c07e2564ee55eff115bf0f02d7d59cdd | 1 + .../7bf1ab1b8f7331ab5dc410e01f959d958bfd210e | 1 + .../7f9530275bc0f0e274fb7995d040f8b8ab3390e5 | Bin 0 -> 10 bytes .../85e53271e14006f0265921d02d4d736cdc580b0b | 1 + .../8e34273e82d4774330b715a72aec6533c6e19bca | Bin 0 -> 19 bytes .../915aabd6f783d9cdb573ed3735c0bfdebc3964d0 | 1 + .../91f8386ab1cfa63c8d31115fac86b1faaf9d04cb | Bin 0 -> 16 bytes .../92eaac4bc361e76746cece3d5d88071826649976 | Bin 0 -> 19 bytes .../9936b910d850aa58c9750daaa62324f434ec16c2 | Bin 0 -> 6 bytes .../9d4ca4c339ed6a61f6cdfbedadd9b165b6bc9437 | Bin 0 -> 27 bytes .../9d74b1fb042d523def9ad5eb5ced686bf9fea277 | Bin 0 -> 11 bytes .../9d891e731f75deae56884d79e9816736b7488080 | 1 + .../9fe67c062130a05e1bd5d41e6216a759d4a43f31 | Bin 0 -> 25 bytes .../a09435df490e0af28e5091a8b7f0dc5ff6c2561f | 1 + .../a0f1490a20d0211c997b44bc357e1972deab8ae3 | 1 + .../b54d36965f49908e650eba0cd779e1e41a848b00 | Bin 0 -> 5 bytes .../b858cb282617fb0956d960215c8e84d1ccf909c6 | 1 + .../bf5bd9b217b295d7235fa75359096dba9ee166b0 | 1 + .../c2898cc7518548bce2784db984e8aef7f9ae5aa1 | Bin 0 -> 18 bytes .../c603fb9bfa9749948edd0b37956b25f096bb6e27 | 1 + .../ca7a035c1f9d20ad2cd7351878f9b50a9102e975 | Bin 0 -> 10 bytes .../ce04e2f36cfece6a5a9d08f297f18fe8f1629033 | Bin 0 -> 8 bytes .../cf3b2d944580bc6bb5865fc30678e5888e1881bc | Bin 0 -> 7 bytes .../d07a5d7e36bb448ae8e9048a5c1e578eb3791333 | 1 + .../d5d7f48fcd5d253f7c2e3484d430b16b4c148e17 | Bin 0 -> 12 bytes .../d85ff1caedb708d1d2ed7fb5239d79dc45902149 | Bin 0 -> 19 bytes .../daf1816d9de51b28cea2563292f6e393e85d7a4f | Bin 0 -> 3 bytes .../eab40185d4accd51a44697959f2f20ceb4bd7cb5 | 1 + .../f8cedb407d2d4245f96b92852ad0a4be7fd96311 | Bin 0 -> 18 bytes .../febe89f69009dbe8e54b4ae3388a2321a69c07ab | Bin 0 -> 10 bytes .../48bc65e7dd509933d9c4a49cd7285875f701a243 | 1 + .../60c79e75f9c2ea5f5aaf21ec2ad7d5b13d61f864 | 1 + .../f195c020a28dfc5f2fb6af256b524ddcd93756ed | 1 + .../00c30f85342bf56088b09dc0b0d798b183ba9f48 | 1 + .../020e864bf97fd2ba945d0a589bdb0873beb1a010 | 1 + .../037683ca6ae5d80ac4ed995dc9c254d04b6c0e25 | 1 + .../067d5096f219c64b53bb1c7d5e3754285b565a47 | 1 + .../0a0773d8a72f6d44f6ec57c52499aeeb42cb0ed0 | Bin 0 -> 8 bytes .../0bc086e53561b449755cc100db146096bcfedc31 | Bin 0 -> 6 bytes .../132ccf0bbeffce4af8e88c1c38cb67d38432976f | 1 + .../1482de4a6a2eacd09930bc26436f1a12e8b92f3d | 3 + .../197f90bf0c7041c67318577a7abdc9829408a0ea | Bin 0 -> 9 bytes .../1bdc93b89c0b57b04ef3927b54ae354ad35e9a24 | 2 + .../1d30a9312108cfa1d80689a68b7b2067d2a020ab | 1 + .../1e5c2f367f02e47a8c160cda1cd9d91decbac441 | 1 + .../2649e306ab716af7dc98c98ed126d220edf85d2e | 2 + .../2995830f13175d3ae9e0030c39595d74ba5d0ba5 | 3 + .../2ff56e871fdfc40daaa9ec72ae8f0445e35b815e | 1 + .../395df8f7c51f007019cb30201c49e884b46b92fa | 1 + .../3b5c39709f491f88115c71bbead0a42c3303266b | 1 + .../3d7c5b7ca1ba330e268c47add8e4190a1e4154fe | Bin 0 -> 16 bytes .../3ebc22303e15b0985ef68e124d179ba1ebf0d75a | 1 + .../3f786850e387550fdab836ed7e6dc881de23001b | 1 + .../42c2b7f27a41309c0851ca670074e3bf4387ad6a | 2 + .../43e9a982b2d7bc56a0f01f9368b959465797b33f | 1 + .../4a901c7fa28418383b11e9c3cdf7a6a3786c3e53 | Bin 0 -> 10 bytes .../4dc1da46a99ae5b09614ebce07ad167d83bdf817 | Bin 0 -> 17 bytes .../4dc7c9ec434ed06502767136789763ec11d2c4b7 | 1 + .../4f653eb10e4c29f6aec92c6755ea3029d72e2e80 | 2 + .../51d1530dc743b98ea259c2cc49e32c38e906a5fe | 1 + .../5226033afdd9c14555a197323f7e367b12a1a6cf | 1 + .../54006e25e0dab2fcccb139e06cb8be11025f7264 | Bin 0 -> 18 bytes .../58668e7669fd564d99db5d581fcdb6a5618440b5 | 1 + .../5eeef35c8c075d820a8551c18db724487c955f35 | 1 + .../5f66b16e822cccdc83bb0a9fe214bd2aa37dc1a3 | Bin 0 -> 8 bytes .../62119883f7e243a655f3f62960125c0b76170187 | Bin 0 -> 14 bytes .../634593c85d97213c17be88a4766d14e957b04bf4 | Bin 0 -> 9 bytes .../6c5e0c496f24d0729b72352c8caefac04ec296b9 | 2 + .../6c801f9afaa817120bedd6f47da244b5cd74e044 | 4 + .../6dcd4ce23d88e2ee9568ba546c007c63d9131c1b | 1 + .../7079196d1ca8f4566bbde59c0d9c5b465a2e407a | Bin 0 -> 18 bytes .../70abeee3ca8d3c510de34f44f014219096a30f37 | 2 + .../77ed8a7ebc0e1a16a2de5852a62284fbc43b2d83 | 2 + .../7e15bb5c01e7dd56499e37c634cf791d3a519aee | 1 + .../847b9d6298714127609675a69ce9a7e62f5a04a0 | 1 + .../8768a53e1d4c182907306300f9ca90cfd8018383 | 1 + .../87c0094e9b2ef36db51265ef05be6afa78eb957f | 1 + .../885c115cc6f482537370e3b5dbc517b562e3abd3 | Bin 0 -> 5 bytes .../8a766fa20acdc7f3f2b1079da58e71d9f8bb39a3 | 7 + .../8ee6ea96168da8c61fc4ff14c46167fc8311d06e | 5 + .../9069ca78e7450a285173431b3e52c5c25299e473 | Bin 0 -> 4 bytes .../9913b492101a1f8e6fe8bc746dd64d22cee50861 | 1 + .../99593fe2abcd06f4a572111c5d5b10278be230db | 1 + .../9a0dccca06fef7402c48260e6578739fc3f731d4 | Bin 0 -> 8 bytes .../9a1651d4678ac3c445cc15fd2485ff4c87808789 | 1 + .../9a78211436f6d425ec38f5c4e02270801f3524f8 | 1 + .../9b16668f4e16c0e9932661855b7bcb5bad8b0f72 | 1 + .../9be116001345022790691e18add300aa73c6ed8b | 1 + .../9ceb17804cf223dde10dff2a988a032cf7e2ba84 | Bin 0 -> 8 bytes .../a2dfa9429bf2a04d8f23fe980209bd5315f80523 | 1 + .../a3f294235fe5422005ae9bc3a0d1bffe12cfe353 | 1 + .../a662d0b3fa8adb7faf1aba661729e284e06b6ab6 | 1 + .../a7c13e6fe60eee08b9aac00a095a9301ea1a9824 | 1 + .../aa598eabfd75ac378ee3b93ae7d04c4d8de623e1 | 1 + .../ab461f6b8a6842a473257a2561c1fbdf91bdfe77 | 1 + .../b0354f952ee3e3bd402cf980621be873bb307cf9 | Bin 0 -> 8 bytes .../b07d6562836e7ed57b5cd153ebdb37b41ee4b308 | 1 + .../b0cb15763a89859ba3a9c3c58836db53b56cb2e4 | 1 + .../b1c8b3a437181bcbd509fd1b46775ab88a10cd1f | 2 + .../b383ced91e6271bad3391fcf3cda98cb246d1db8 | Bin 0 -> 8 bytes .../b48f491783e98de10682f2d4455dfce5bdc3c233 | 1 + .../baddccd5c3660b2db4bfb27639955cbf36927c63 | Bin 0 -> 3 bytes .../bb1a737d7e1f5238a3c824696e5b208ad6e6ed0e | 1 + .../bd4f574e56df3d166ed90c2f9207ad40a3410035 | Bin 0 -> 6 bytes .../c0aef7b7aa3c11b095c47c71c77b477756e43e65 | 2 + .../c0cab0ca79f14fe03f7720a600ae0205385d93d9 | 2 + .../c151b760696d665265187501c51f38cd84503634 | 1 + .../c164c4e7bb925fb852b7225913f328b0069206ec | 1 + .../c1f78812cbc050a987a39691d44b1905975a54e5 | 2 + .../c2eb90f931ed904ca0ddba3f2256750c8eab5ce2 | Bin 0 -> 7 bytes .../c66be7210915f39e91456fc2eac9441012a0a3ea | 1 + .../c78ebd3c85a39a596d9f5cfd2b8d240bc1b9c125 | 1 + .../c7da1ff95a25c353f1319604703e8bfd287ee1a1 | 1 + .../ca9e98652c624fdb190508b2b5c10286f0b11cf4 | 1 + .../cdc1619d00d76f372b0fd70cd34fdd839660c6c4 | Bin 0 -> 6 bytes .../d07e4bc786c88b8d2304f84c7db2098666f822c0 | 1 + .../d3fe83b8d87ccda2bbca5e81ce3ab1a1400bfbe8 | 1 + .../d50591ff745cc83091f4ee12b2ee702cb24b0b45 | 1 + .../d8c6ec31147b80639d643c67ebbee7ad6a212d8c | Bin 0 -> 19 bytes .../d96ae4aff333975de4c3312f55f99984358b5556 | 2 + .../d9a4594f293a19f965d3dc0ed35e0d4210747e44 | Bin 0 -> 7 bytes .../d9cae02ee737b9083b8dc74a1e0d85d3ad6127de | 2 + .../da4b9237bacccdf19c0760cab7aec4a8359010b0 | 1 + .../db9303dc319a97589ad8e403cf4db412ef557fed | 1 + .../df9cbe189c6dee2a2c3f4f64102957349c16fd09 | 1 + .../e144f0608b1a43ec8a6ea4ed41f198c92697c471 | 1 + .../e28990f4733a2aafe939f6f8e99538b80fce57d2 | Bin 0 -> 7 bytes .../e56e8e82a23bfa5a6910d142e9462aab1453bfeb | 1 + .../e980fa35889e0ea716112b1c14543462ab5cd6da | Bin 0 -> 12 bytes .../e9c5d7db93a1c17d45c5820daf458224bfa7a725 | 1 + .../ea4bc1d0d3567a531170d2323cef68c8ad54f652 | 1 + .../ed635506c5f71b804a61de3be15ac77aaa26bc47 | Bin 0 -> 4 bytes .../f165cd1e3058ea8ef726fdd6bf588d838b26355f | 1 + .../f316ada8f55ffa5ded94d98572e67ead30dc150e | Bin 0 -> 3 bytes .../f353cdb0bf449476fda6c24d81678ddfd00e9ded | 1 + .../f63cfee862c79d90463b5cb240774990410d751e | 1 + .../f824183b844d32f2096963754a0702795cf89429 | 2 + .../f83f3ab528b96ded1c8b1525f22861542194ba96 | Bin 0 -> 8 bytes fuzz/fuzz_targets/manifests_grants.rs | 11 + fuzz/fuzz_targets/recovery_messages.rs | 15 + fuzz/fuzz_targets/restore_paths.rs | 25 + fuzz/fuzz_targets/state_database.rs | 15 + fuzz/fuzz_targets/wire.rs | 8 + gui/README.md | 58 +- gui/package-lock.json | 2750 +++++++- gui/package.json | 28 +- gui/playwright.config.ts | 17 + gui/src/app.css | 8 +- gui/src/lib/api.ts | 36 + gui/src/lib/components/FriendsView.svelte | 24 +- gui/src/lib/components/OverviewView.svelte | 5 +- gui/src/lib/components/RecoveryView.svelte | 372 +- gui/src/lib/components/SharedView.svelte | 48 +- gui/src/lib/components/VaultsView.svelte | 120 +- gui/src/lib/format.ts | 3 + gui/src/lib/notes.ts | 53 - gui/src/lib/statusStore.ts | 23 +- gui/src/lib/types.ts | 65 +- gui/src/routes/+page.svelte | 35 +- gui/static/claimant.css | 14 + gui/static/claimant.html | 80 + gui/static/claimant.js | 138 + gui/tests/browser/ui.spec.ts | 119 + gui/tests/error-banner.component.test.ts | 14 + gui/tests/fixtures/status-contract.json | 17 + gui/tests/runtime.component.test.ts | 50 + gui/tests/setup.ts | 5 + gui/tests/ui-contracts.test.mjs | 155 + gui/vitest.config.ts | 13 + requirements-cbor-vectors.txt | 3 + rust-toolchain.toml | 4 + scripts/check-fuzz-targets.sh | 49 + scripts/check-operational-logs.sh | 23 + scripts/check-release-workflow.sh | 81 + scripts/check-restore-layout.sh | 11 + scripts/check-supply-chain-config.sh | 25 + scripts/check-test-inventory.sh | 79 + scripts/check-tracked-secrets.sh | 19 + scripts/state-fixtures.sh | 59 + 483 files changed, 21283 insertions(+), 950 deletions(-) create mode 100644 .gitattributes create mode 100644 .nvmrc create mode 100644 chela-revision.txt create mode 100644 crates/carapace-api/src/bin/carapaced/state_ops.rs create mode 100644 crates/carapace-api/src/claimant.rs create mode 100644 crates/carapace-api/src/ops.rs rename crates/carapace-api/static/_app/immutable/assets/{0.BIBOqY7u.css => 0.BNChGHTQ.css} (98%) create mode 100644 crates/carapace-api/static/_app/immutable/assets/2.BO-zofLV.css delete mode 100644 crates/carapace-api/static/_app/immutable/assets/2.Ccp3aMT0.css rename crates/carapace-api/static/_app/immutable/chunks/{Cfkx4BKF.js => BdOmOXD3.js} (99%) rename crates/carapace-api/static/_app/immutable/entry/{app.BJNxxL8q.js => app.Cz953gq6.js} (87%) create mode 100644 crates/carapace-api/static/_app/immutable/entry/start.2-caBSwK.js delete mode 100644 crates/carapace-api/static/_app/immutable/entry/start.CbdnOVWP.js rename crates/carapace-api/static/_app/immutable/nodes/{0.DSqSDKgT.js => 0.CCSq_TiJ.js} (100%) rename crates/carapace-api/static/_app/immutable/nodes/{1.X1iBfBii.js => 1.y4-4BiaA.js} (89%) create mode 100644 crates/carapace-api/static/_app/immutable/nodes/2.BZ_ZwSYn.js delete mode 100644 crates/carapace-api/static/_app/immutable/nodes/2.CaWSwQlO.js create mode 100644 crates/carapace-api/static/claimant.css create mode 100644 crates/carapace-api/static/claimant.html create mode 100644 crates/carapace-api/static/claimant.js create mode 100644 crates/carapace-api/tests/terminal_passphrase_process.rs create mode 100644 crates/carapace-restore/Cargo.toml create mode 100644 crates/carapace-restore/src/lib.rs create mode 100644 crates/carapaced/src/ops.rs create mode 100644 crates/carapaced/tests/fixtures/MANIFEST.tsv create mode 100644 crates/carapaced/tests/fixtures/README.md create mode 100644 crates/carapaced/tests/fixtures/legacy-rich-v1.redb.gz.b64 create mode 100644 crates/carapaced/tests/legacy_fixture.rs create mode 100644 crates/carapaced/tests/state_inspection.rs create mode 100644 deny.toml create mode 100644 docs/blob-garbage-collection.md create mode 100644 docs/carapace-spec-summary.md create mode 100644 docs/cbor-vector-oracle.md create mode 100644 docs/claimant-recovery-boundary.md create mode 100644 docs/duplicate-crypto-dependencies.md create mode 100644 docs/key-storage.md create mode 100644 docs/recovery-ceremony-lifecycle.md create mode 100644 docs/release-dependencies.md create mode 100644 docs/supported-platforms.md create mode 100644 docs/test-inventory.md create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/README.md create mode 100644 fuzz/corpus/manifests_grants/04700c4651ce9aa5b350fc0bf226e5b5a800bf7f create mode 100644 fuzz/corpus/manifests_grants/070288e0bb92275eb0d0f0d60f6246950b2113ed create mode 100644 fuzz/corpus/manifests_grants/0b753eb363d09f8447475f78b301842d131ae5af create mode 100644 fuzz/corpus/manifests_grants/0e1e41a392fa5af0ec192352db6152bed90c62ad create mode 100644 fuzz/corpus/manifests_grants/0e863dd9c0f90ef59575e71592794e8ece20f3c4 create mode 100644 fuzz/corpus/manifests_grants/117663cc2af9d0cfa642088cdac159c25768fa3b create mode 100644 fuzz/corpus/manifests_grants/128c328e12f6f9fbb4c05a8359a875027434b75b create mode 100644 fuzz/corpus/manifests_grants/14490ca81d6de5e755e605b2bde825e2f0db5da4 create mode 100644 fuzz/corpus/manifests_grants/15a4183889ea65809e47b2baba9ee78e7f7ce7ab create mode 100644 fuzz/corpus/manifests_grants/1875397fb4500fb7edd13e0d00aa537945650ccb create mode 100644 fuzz/corpus/manifests_grants/1bfbe8bcd532b7ee2f61fbd32812a74956d4fffd create mode 100644 fuzz/corpus/manifests_grants/1e128a9afee9d68d93520197de3400e8f8e44c66 create mode 100644 fuzz/corpus/manifests_grants/26776823358c3568acc99da47761b1bfa93a3d9b create mode 100644 fuzz/corpus/manifests_grants/2e58bfc6917246cb615f9657792cc7ab4337cbc5 create mode 100644 fuzz/corpus/manifests_grants/32c9211bd6dfd03385ed0ab72356ddfba5191a72 create mode 100644 fuzz/corpus/manifests_grants/3472d7b236d31127ee867e89ff7426575523dfbc create mode 100644 fuzz/corpus/manifests_grants/3b3f11ccacfed602011f64a913f541ef69b8cffc create mode 100644 fuzz/corpus/manifests_grants/3dd8da25bbfc27fcc5321599e2eb2b898aa649cd create mode 100644 fuzz/corpus/manifests_grants/4320aa4bf6289c6f7ca6479e93d220991e7a534d create mode 100644 fuzz/corpus/manifests_grants/437760bda35f2aca2c59976882df4519209a70d9 create mode 100644 fuzz/corpus/manifests_grants/4f695b66ae1cad9eb338322dd9d3da2b40ed77d6 create mode 100644 fuzz/corpus/manifests_grants/4fb8cfeaaac80a1c829b22a43089ef470bcfe5b8 create mode 100644 fuzz/corpus/manifests_grants/52a719f9d01e6a1882f97bc011e52c80f807e955 create mode 100644 fuzz/corpus/manifests_grants/550f067a125ad25f3e3743be39783cc869f26ae2 create mode 100644 fuzz/corpus/manifests_grants/55a77979ceb119c91ddb4a5bc942191bfe7981ea create mode 100644 fuzz/corpus/manifests_grants/56c299516d0c79aa0c93b87c90da3eac7dccdbc5 create mode 100644 fuzz/corpus/manifests_grants/59f52c524518c5411343e1d4e640bc581a686f36 create mode 100644 fuzz/corpus/manifests_grants/5a18eac50c71553681bad458d52333d9f45ede85 create mode 100644 fuzz/corpus/manifests_grants/5c10b5b2cd673a0616d529aa5234b12ee7153808 create mode 100644 fuzz/corpus/manifests_grants/6017e2d699f6042c53b3ab5df98448bde30d046f create mode 100644 fuzz/corpus/manifests_grants/60c79e75f9c2ea5f5aaf21ec2ad7d5b13d61f864 create mode 100644 fuzz/corpus/manifests_grants/616e72f7280d7b175936da0506a09586f292e840 create mode 100644 fuzz/corpus/manifests_grants/61f72362fd53c82f524b84e947a21e42f63ca425 create mode 100644 fuzz/corpus/manifests_grants/665e723f1d4c3177f116645e24c5fd0e8726690b create mode 100644 fuzz/corpus/manifests_grants/684886f194921f1237d4289216d094bf6f479ecd create mode 100644 fuzz/corpus/manifests_grants/68d0ad967895a4f9e902c3a3ec79df4fcd942a00 create mode 100644 fuzz/corpus/manifests_grants/6a17b513f9fe8087cc3bcee24810c17315c09727 create mode 100644 fuzz/corpus/manifests_grants/6c6d61ec3e8ae0a68ccc81c9dc1a3684d688bf7f create mode 100644 fuzz/corpus/manifests_grants/6dcfe81af1b0409137797e5429de9637c9bff4d3 create mode 100644 fuzz/corpus/manifests_grants/75ce08c3fd2653dab05205243c2cdd1e3e8b65fb create mode 100644 fuzz/corpus/manifests_grants/7728b61c6235bdea3258c2e5c0762114ba38fce0 create mode 100644 fuzz/corpus/manifests_grants/7ac390710287bab3b0d119314d9422950246d300 create mode 100644 fuzz/corpus/manifests_grants/7d9f4961c889d77a3e57eb68f4cd0cda4aa21b48 create mode 100644 fuzz/corpus/manifests_grants/7f93a7d52ffdff88508f719510b824fadc7778e6 create mode 100644 fuzz/corpus/manifests_grants/8bdfa924c0f9ae6cfc3d96a581e50e87b74ad840 create mode 100644 fuzz/corpus/manifests_grants/8dbb30c9cba5edf1027b67309722bd734d81a231 create mode 100644 fuzz/corpus/manifests_grants/8dc00598417d4eb788a77ac6ccef3cb484905d8b create mode 100644 fuzz/corpus/manifests_grants/9403fc87dd83c1cffc4c2c173de878094e4c0bdc create mode 100644 fuzz/corpus/manifests_grants/94c92052e049d18cd223df9f0256125bb847f49f create mode 100644 fuzz/corpus/manifests_grants/953efc01deceedba7832f24db5d1aacfea7e4836 create mode 100644 fuzz/corpus/manifests_grants/9a78211436f6d425ec38f5c4e02270801f3524f8 create mode 100644 fuzz/corpus/manifests_grants/a6790ec83acd251e8a2a37c7ca72314159c026e5 create mode 100644 fuzz/corpus/manifests_grants/a6978896e1bad8f5705d8db20e521ad4906850a2 create mode 100644 fuzz/corpus/manifests_grants/a6b1c3d994cf885e8b69282b29f2c2f015efccf3 create mode 100644 fuzz/corpus/manifests_grants/a91ee86ddda4cd00b985f4883f62e89dbcbe4545 create mode 100644 fuzz/corpus/manifests_grants/a979fc300e6039695bad522bce5e4ba543a6bbfe create mode 100644 fuzz/corpus/manifests_grants/a9f0de4e41eda5cdaf0ccd924d135f6784b45e00 create mode 100644 fuzz/corpus/manifests_grants/aa359f022b223e5269b5a1e291b8602f8a6b8277 create mode 100644 fuzz/corpus/manifests_grants/adad2ca7ab313add6e955f704719e03d5229e4d0 create mode 100644 fuzz/corpus/manifests_grants/b586e402a09005dee351fd00e88fc59acb88ccbd create mode 100644 fuzz/corpus/manifests_grants/b5aadfc855a5c39069694d7923e139ef6ed5a24f create mode 100644 fuzz/corpus/manifests_grants/bc0eac03b7f5e8bd89dcf4665f33e742297cf409 create mode 100644 fuzz/corpus/manifests_grants/ca7ab576c54f8a5ab79c833fb23cb268cf1defde create mode 100644 fuzz/corpus/manifests_grants/ca9fa8618b8fb892050dc1e0cd894736a3489dee create mode 100644 fuzz/corpus/manifests_grants/cb75f868886f7f370bf64d206118d1c5fbb29e28 create mode 100644 fuzz/corpus/manifests_grants/cc0bf81c2043fca1c9bc8e414939418fd80b376c create mode 100644 fuzz/corpus/manifests_grants/d24cbbc2ddd4bf1bce3fea4ac8aa0dbe2d5d0ed5 create mode 100644 fuzz/corpus/manifests_grants/d305d7866d56c57e617dc35d74a70a027b8702d5 create mode 100644 fuzz/corpus/manifests_grants/d383cf9061c6f2f5cc96a43e4a630b3f6fac8eda create mode 100644 fuzz/corpus/manifests_grants/d3cc4f310527339261fe66dbd3c3fc679de7f800 create mode 100644 fuzz/corpus/manifests_grants/d401cadd8a129a7830fbc348b12b10d70c2fcae1 create mode 100644 fuzz/corpus/manifests_grants/d52aacc35eb16470fcf18c07371331d47886570c create mode 100644 fuzz/corpus/manifests_grants/d54072ab41a300921e70aeadd4b6800eb4ef88b7 create mode 100644 fuzz/corpus/manifests_grants/deffad809b7a2ebe600b9025ad723694f298a170 create mode 100644 fuzz/corpus/manifests_grants/e9c5d7db93a1c17d45c5820daf458224bfa7a725 create mode 100644 fuzz/corpus/manifests_grants/eae13156f99b78292ee151b3bf2f9854d6fd3097 create mode 100644 fuzz/corpus/manifests_grants/ebb3d39e7a1cea60e0311ccc206a5affdc406f9f create mode 100644 fuzz/corpus/manifests_grants/ebdc2288a14298f5f7adf08e069b39fc42cbd909 create mode 100644 fuzz/corpus/manifests_grants/efbb36287bdff8bac6b82dc17f5bbc63a8451ebf create mode 100644 fuzz/corpus/manifests_grants/f6ce3f52b6e1370de181a5238f5cd9565732771a create mode 100644 fuzz/corpus/manifests_grants/f70a9b8378e448c3c1394e49b04b2957972af683 create mode 100644 fuzz/corpus/manifests_grants/fae1f6d68ceb776525c61b432c3a2fa72e99124f create mode 100644 fuzz/corpus/manifests_grants/fb9cec37b0cb4659036c9b715daff486c1064cf1 create mode 100644 fuzz/corpus/recovery_messages/0393ff30aaa9deb28be0a2e134dc7d4f27712cb4 create mode 100644 fuzz/corpus/recovery_messages/04757e726a7004b504517ea3b65d6d5ebaa3e12b create mode 100644 fuzz/corpus/recovery_messages/062db096c728515e033cf8c48a1c1f0b9a79384b create mode 100644 fuzz/corpus/recovery_messages/07fc65143fbb6237452c86103d705256c4596773 create mode 100644 fuzz/corpus/recovery_messages/0d9b86faf155101f0bc6f9b5e6546a5d7f2f00ff create mode 100644 fuzz/corpus/recovery_messages/0dcf890ad229cfb44d2a4b0caaf27d8429398ff3 create mode 100644 fuzz/corpus/recovery_messages/0dd534833ff16a4f58727a87b959105616ef5bc0 create mode 100644 fuzz/corpus/recovery_messages/0e85812dc1a466d2c1c4a68d582d8e413adbcbd0 create mode 100644 fuzz/corpus/recovery_messages/11d52cab70564cbadaccec9e47fbf773cca5e367 create mode 100644 fuzz/corpus/recovery_messages/11f6ad8ec52a2984abaafd7c3b516503785c2072 create mode 100644 fuzz/corpus/recovery_messages/11fcc64ccb891cef2c95149dcf87d5b74632df89 create mode 100644 fuzz/corpus/recovery_messages/13cba177bcfad90e7b3de70616b2e54ba4bb107f create mode 100644 fuzz/corpus/recovery_messages/1a98ddfe22b9c4705091539697b1f2ac60c3fa0f create mode 100644 fuzz/corpus/recovery_messages/1e4872e7bde008d6cffd8951d8ba60710ed6c481 create mode 100644 fuzz/corpus/recovery_messages/1eb8f1318f36456032e6aa8dfb565ff76a652b58 create mode 100644 fuzz/corpus/recovery_messages/200577df89f7bc011f3d62b0cb16e3a82d1a96a6 create mode 100644 fuzz/corpus/recovery_messages/227ae80dc68618d68d598aae3aec75ec0f328f63 create mode 100644 fuzz/corpus/recovery_messages/2630d26866a14aa5426e66392e930aa7637444cd create mode 100644 fuzz/corpus/recovery_messages/2e7941b976cbb4f7a12a3132b9d2db2cfbb64f6b create mode 100644 fuzz/corpus/recovery_messages/31fa92f47f979983be15ab4140b9d31ad036e3cb create mode 100644 fuzz/corpus/recovery_messages/321a501ff9f3bb9c65355ca158c22bf5df0b55f1 create mode 100644 fuzz/corpus/recovery_messages/36aea971ac9911d392123ef85bef842e69ee2b49 create mode 100644 fuzz/corpus/recovery_messages/36e7fba73c1161ecee70557ecd3f88312abf38bf create mode 100644 fuzz/corpus/recovery_messages/40dca6fd62f63ef537091021fe253300526df291 create mode 100644 fuzz/corpus/recovery_messages/48ac6d1a63b6ab9a4ea319becf939693f5b486d0 create mode 100644 fuzz/corpus/recovery_messages/48f2b8ef50125ef05b7378291c1784eeba242e2b create mode 100644 fuzz/corpus/recovery_messages/4e3ae87eabb28c18cffcaa71ca02f01e41649511 create mode 100644 fuzz/corpus/recovery_messages/4f057c40547dda405439af625bcd672396856733 create mode 100644 fuzz/corpus/recovery_messages/4ff447b8ef42ca51fa6fb287bed8d40f49be58f1 create mode 100644 fuzz/corpus/recovery_messages/511720dd7ce63ae6e6bace13fa2e6d671491b358 create mode 100644 fuzz/corpus/recovery_messages/5385a2e42890729addc03f16b5356d87a8d746fe create mode 100644 fuzz/corpus/recovery_messages/57d1dd312f1de4f91a09212aabd3a7a935919338 create mode 100644 fuzz/corpus/recovery_messages/582a8447c179d693806d180b4af05b178436a609 create mode 100644 fuzz/corpus/recovery_messages/5c5354ca6370528da0bc4465e37b5b1c45e32bde create mode 100644 fuzz/corpus/recovery_messages/5cad51770caba3e2b5a69d7e2e705c9b4b1a23ae create mode 100644 fuzz/corpus/recovery_messages/600386e70fad9968432325012e421fd57c5b08ba create mode 100644 fuzz/corpus/recovery_messages/60a23c73404c27b075d24d0e3934875aed3fe6cd create mode 100644 fuzz/corpus/recovery_messages/6431545d884b8685a05d055a7115b08ef9bf4e10 create mode 100644 fuzz/corpus/recovery_messages/680597aed4c14ebd39e7873db3a4b5b9f8f26bda create mode 100644 fuzz/corpus/recovery_messages/69ee7f0679cc30ae9524c00e8d175cc42de1f287 create mode 100644 fuzz/corpus/recovery_messages/6ac019df35369adecb01cf8c32647f925437ef47 create mode 100644 fuzz/corpus/recovery_messages/6b0a573c5d09b3ae83cbb8ff7b3318087f324fcc create mode 100644 fuzz/corpus/recovery_messages/6bc6c9168872d44922f99cdafb48012126cd5e8f create mode 100644 fuzz/corpus/recovery_messages/6c7c0741e93ca3597e8d33ae11660a9d29826d56 create mode 100644 fuzz/corpus/recovery_messages/6d5d8fb3cd9fc1fb88e616fb2760a751b901b46d create mode 100644 fuzz/corpus/recovery_messages/6e73461eba24d5ebfaccc5ea0de8b13a11a140f4 create mode 100644 fuzz/corpus/recovery_messages/70467f08eea02fe3c7d922036190996e22a8324b create mode 100644 fuzz/corpus/recovery_messages/846c511e157a79ce40c98c9ca73711bd6914f9fd create mode 100644 fuzz/corpus/recovery_messages/85e53271e14006f0265921d02d4d736cdc580b0b create mode 100644 fuzz/corpus/recovery_messages/879bcd9a0f536cffe93f55fbb392a8bcca30fb25 create mode 100644 fuzz/corpus/recovery_messages/8d7121a4f62d2cf030ab07432ed96a8abdc3c7ae create mode 100644 fuzz/corpus/recovery_messages/9034aaf45143996a2b14465c352ab0c6fa26b221 create mode 100644 fuzz/corpus/recovery_messages/9069ca78e7450a285173431b3e52c5c25299e473 create mode 100644 fuzz/corpus/recovery_messages/9120f1b9c42fde9689ced4d90d7cd91f94d18544 create mode 100644 fuzz/corpus/recovery_messages/91c5967a4f7349e8d118c76f83fecc2ab29b5d92 create mode 100644 fuzz/corpus/recovery_messages/95df4d0a7530a091751fa52f590a3a98e0e4f178 create mode 100644 fuzz/corpus/recovery_messages/98536b7c45d1291354a083b478919e6191d17d1f create mode 100644 fuzz/corpus/recovery_messages/986b212420e3b977068244e6bd916575bb0c15e5 create mode 100644 fuzz/corpus/recovery_messages/9b58cd6c932740257864845f243a850631c119c8 create mode 100644 fuzz/corpus/recovery_messages/a17f491cec7dcb15b9bc059d0662d8b25e4a6ab3 create mode 100644 fuzz/corpus/recovery_messages/a6d969a9126da348ca87d92060183e9e1e1c9163 create mode 100644 fuzz/corpus/recovery_messages/a71839ac78d944daeb1be89a2ec08c735a7140a7 create mode 100644 fuzz/corpus/recovery_messages/a9c46930ccb67a80e303f6e56e4ceddc533a12a9 create mode 100644 fuzz/corpus/recovery_messages/ab21de136b914670387ea060b0bea4338501ab06 create mode 100644 fuzz/corpus/recovery_messages/ad9acc9b8309aedf133630f34b7060bd1ae833d4 create mode 100644 fuzz/corpus/recovery_messages/b01c6825d39820f719176471d94e4dc59b087745 create mode 100644 fuzz/corpus/recovery_messages/b058b26d5bc8bf476e8784292c4f81791f2882ed create mode 100644 fuzz/corpus/recovery_messages/b0ad7e48247e8510be43d413c7fb2820d6bb8326 create mode 100644 fuzz/corpus/recovery_messages/b1a9168f48ad3f6ee8ce04b27ed4b9f8101291a4 create mode 100644 fuzz/corpus/recovery_messages/b44eacac29b1d23f45b5a7d49cde102e2385b613 create mode 100644 fuzz/corpus/recovery_messages/b8426e6923502c7b45f5824a5bce6c08a4f700d5 create mode 100644 fuzz/corpus/recovery_messages/c4ea21bb365bbeeaf5f2c654883e56d11e43c44e create mode 100644 fuzz/corpus/recovery_messages/c54fed1cb131a4293193dda493acffdbc88a5fa8 create mode 100644 fuzz/corpus/recovery_messages/d1917105f2335a77fed9835d2e04a32137cfcf9f create mode 100644 fuzz/corpus/recovery_messages/d61c44e2d4bdf400b0b6a9e11f7d5ebed99f839c create mode 100644 fuzz/corpus/recovery_messages/d6b759f23ba20cae581cae997beeab3fb94374de create mode 100644 fuzz/corpus/recovery_messages/d6cf30f6bf39a35518c484ceee19dd4cf7304c76 create mode 100644 fuzz/corpus/recovery_messages/d97c72e3699ec99fd2e685e13d05984af114b340 create mode 100644 fuzz/corpus/recovery_messages/e18e09cb06ccf9b3ed85bdd3d9c39bc6cce1946b create mode 100644 fuzz/corpus/recovery_messages/e48c7a66fcb462f19ec800be07b2e27d8c4d3b0a create mode 100644 fuzz/corpus/recovery_messages/ebe39cb0f5742b0d4712fdcb9815ea8089aeb8c7 create mode 100644 fuzz/corpus/recovery_messages/ed4f15928535bf9ac7b0e8afdc601f8bc1769976 create mode 100644 fuzz/corpus/recovery_messages/edc034e438e53380ca3613b9c9407a3c11cbf410 create mode 100644 fuzz/corpus/recovery_messages/f0101df27fe0ed4f9fcd0926d09113fbf6c653bb create mode 100644 fuzz/corpus/recovery_messages/f11a7ab39cf0ee38afd8710fb0e928f4af1ec66b create mode 100644 fuzz/corpus/recovery_messages/f327b946142369b1e029ff6b140252d12706b125 create mode 100644 fuzz/corpus/recovery_messages/f562fc51fd99ba8547259ba71638ffb6e7ddace9 create mode 100644 fuzz/corpus/recovery_messages/f778fafe063ff7cbcaf026e5b00771d3c767fc8e create mode 100644 fuzz/corpus/recovery_messages/f77a1eed83c950aae7923ab1688caefaf09fd25f create mode 100644 fuzz/corpus/recovery_messages/f804881bcc87668f5bb2750843442ce31f5789d5 create mode 100644 fuzz/corpus/recovery_messages/f8407e180bd92589b728af21c5626c18770cf26b create mode 100644 fuzz/corpus/recovery_messages/f8d493eaf1475a279dad0a7aee8fa23d6a3c2626 create mode 100644 fuzz/corpus/recovery_messages/f997594a393e15e675958dd4b9cd1b3a3aeec6ae create mode 100644 fuzz/corpus/recovery_messages/ffaae201ec3182fb24e869f92badace73b6ec12e create mode 100644 fuzz/corpus/recovery_messages/ffc54ca808e7666f250133ad0ae2185ad688a826 create mode 100644 fuzz/corpus/restore_paths/011783b9471d573b335616c3f88b0a8e963b9b1b create mode 100644 fuzz/corpus/restore_paths/02be2925afebf662739a1425d372e9725455907c create mode 100644 fuzz/corpus/restore_paths/03ee9b799119744aa78d078583db62dd150c5bec create mode 100644 fuzz/corpus/restore_paths/04a0b4a47ce31c0818a34aed2decdc96fb0152ff create mode 100644 fuzz/corpus/restore_paths/06249eea6b4a5db9c4a25984ec0e384048ee196d create mode 100644 fuzz/corpus/restore_paths/099600a10a944114aac406d136b625fb416dd779 create mode 100644 fuzz/corpus/restore_paths/0b5b2d3c04c3b4b9a1e3934309fccdd1de02bbfe create mode 100644 fuzz/corpus/restore_paths/12c79d6605ebf4faaf5ac517969de16d57e22c4a create mode 100644 fuzz/corpus/restore_paths/17b7866c027dcafa35f8773281081c5f21d8e815 create mode 100644 fuzz/corpus/restore_paths/17d82429676a942f5db1d61ab0e4082918e3721e create mode 100644 fuzz/corpus/restore_paths/24a847eca1c10ab1ac91d0f070e4471fb3eadae7 create mode 100644 fuzz/corpus/restore_paths/25441876386c77cae86763fa1b04fff91b99bbe1 create mode 100644 fuzz/corpus/restore_paths/2596a2765137f1951576bd0ff693c611a7a4d635 create mode 100644 fuzz/corpus/restore_paths/2664deb315e9a73afb9b47b3621e73c993802b9b create mode 100644 fuzz/corpus/restore_paths/268be7cc174657dbcff5cc36dc1ab473bcd5d23c create mode 100644 fuzz/corpus/restore_paths/287c41521a76b58002bc3ad8d78c82692d17fe54 create mode 100644 fuzz/corpus/restore_paths/2a9089662d4313c1b7a2d40c7981af53b8b37bcf create mode 100644 fuzz/corpus/restore_paths/2af3fef91972015d739a0e05ab77e6b97e11c369 create mode 100644 fuzz/corpus/restore_paths/2b22b5029cca5461b600a62ba9a0345328f1cab1 create mode 100644 fuzz/corpus/restore_paths/2fd64b18bf3f26afc941aec4b9d9fa5838174b34 create mode 100644 fuzz/corpus/restore_paths/33149933efaecb206a8e13951b5b8793f3a39eba create mode 100644 fuzz/corpus/restore_paths/380ed71d267b522661a9e5953c0c884958c19589 create mode 100644 fuzz/corpus/restore_paths/3a52ce780950d4d969792a2559cd519d7ee8c727 create mode 100644 fuzz/corpus/restore_paths/42099b4af021e53fd8fd4e056c2568d7c2e3ffa8 create mode 100644 fuzz/corpus/restore_paths/4a7860eda0f65591a4c429c05ef5f35e54e3dfa0 create mode 100644 fuzz/corpus/restore_paths/4a9341b8d4e4ee54c82f57ee198827b9a3a23ece create mode 100644 fuzz/corpus/restore_paths/4af51fbf2f5def11b9f9fed6f4aa0a0e722f76f9 create mode 100644 fuzz/corpus/restore_paths/506195952c960e2c406e464d4ee17538363c646f create mode 100644 fuzz/corpus/restore_paths/53d09471390f5133adca2aa4e8c91f2964fce945 create mode 100644 fuzz/corpus/restore_paths/572476edbefd80e1b577f9ae192bc5a50222398c create mode 100644 fuzz/corpus/restore_paths/59fbdf614036a8825488648bb59e125e9301989e create mode 100644 fuzz/corpus/restore_paths/5c1eee5a922c0e289ee9a987cc3efa9d9a334d34 create mode 100644 fuzz/corpus/restore_paths/5c8fd4cd9ed7fc0fd1ef2f5bafa73907a8ec5634 create mode 100644 fuzz/corpus/restore_paths/615e7ea83a420060133b831eb81e03fee7c39f5b create mode 100644 fuzz/corpus/restore_paths/618f9665bb465c9877a234dadd31d65fc1003ef4 create mode 100644 fuzz/corpus/restore_paths/655bff010fbe54f96d3ee1640b8b37427dd295d6 create mode 100644 fuzz/corpus/restore_paths/6a9fa8d13ac92ba1bf0fd1ada52ba58cc64d466d create mode 100644 fuzz/corpus/restore_paths/6af70ec99d7247eb410007b3384cedb028b5506f create mode 100644 fuzz/corpus/restore_paths/6b3b106b555a37498986c3ef6aa12bef233e8b7f create mode 100644 fuzz/corpus/restore_paths/6cd4f0f88384103872b630888783d7e52867d62a create mode 100644 fuzz/corpus/restore_paths/6e231709cbbbced3431ca7cfb4cae1d153658a60 create mode 100644 fuzz/corpus/restore_paths/71136726208953013b46b3ce0ebbd7b36570ab7a create mode 100644 fuzz/corpus/restore_paths/746b049ba256830089d73c32d52dca16e6ef0870 create mode 100644 fuzz/corpus/restore_paths/75740759c07e2564ee55eff115bf0f02d7d59cdd create mode 100644 fuzz/corpus/restore_paths/7bf1ab1b8f7331ab5dc410e01f959d958bfd210e create mode 100644 fuzz/corpus/restore_paths/7f9530275bc0f0e274fb7995d040f8b8ab3390e5 create mode 100644 fuzz/corpus/restore_paths/85e53271e14006f0265921d02d4d736cdc580b0b create mode 100644 fuzz/corpus/restore_paths/8e34273e82d4774330b715a72aec6533c6e19bca create mode 100644 fuzz/corpus/restore_paths/915aabd6f783d9cdb573ed3735c0bfdebc3964d0 create mode 100644 fuzz/corpus/restore_paths/91f8386ab1cfa63c8d31115fac86b1faaf9d04cb create mode 100644 fuzz/corpus/restore_paths/92eaac4bc361e76746cece3d5d88071826649976 create mode 100644 fuzz/corpus/restore_paths/9936b910d850aa58c9750daaa62324f434ec16c2 create mode 100644 fuzz/corpus/restore_paths/9d4ca4c339ed6a61f6cdfbedadd9b165b6bc9437 create mode 100644 fuzz/corpus/restore_paths/9d74b1fb042d523def9ad5eb5ced686bf9fea277 create mode 100644 fuzz/corpus/restore_paths/9d891e731f75deae56884d79e9816736b7488080 create mode 100644 fuzz/corpus/restore_paths/9fe67c062130a05e1bd5d41e6216a759d4a43f31 create mode 100644 fuzz/corpus/restore_paths/a09435df490e0af28e5091a8b7f0dc5ff6c2561f create mode 100644 fuzz/corpus/restore_paths/a0f1490a20d0211c997b44bc357e1972deab8ae3 create mode 100644 fuzz/corpus/restore_paths/b54d36965f49908e650eba0cd779e1e41a848b00 create mode 100644 fuzz/corpus/restore_paths/b858cb282617fb0956d960215c8e84d1ccf909c6 create mode 100644 fuzz/corpus/restore_paths/bf5bd9b217b295d7235fa75359096dba9ee166b0 create mode 100644 fuzz/corpus/restore_paths/c2898cc7518548bce2784db984e8aef7f9ae5aa1 create mode 100644 fuzz/corpus/restore_paths/c603fb9bfa9749948edd0b37956b25f096bb6e27 create mode 100644 fuzz/corpus/restore_paths/ca7a035c1f9d20ad2cd7351878f9b50a9102e975 create mode 100644 fuzz/corpus/restore_paths/ce04e2f36cfece6a5a9d08f297f18fe8f1629033 create mode 100644 fuzz/corpus/restore_paths/cf3b2d944580bc6bb5865fc30678e5888e1881bc create mode 100644 fuzz/corpus/restore_paths/d07a5d7e36bb448ae8e9048a5c1e578eb3791333 create mode 100644 fuzz/corpus/restore_paths/d5d7f48fcd5d253f7c2e3484d430b16b4c148e17 create mode 100644 fuzz/corpus/restore_paths/d85ff1caedb708d1d2ed7fb5239d79dc45902149 create mode 100644 fuzz/corpus/restore_paths/daf1816d9de51b28cea2563292f6e393e85d7a4f create mode 100644 fuzz/corpus/restore_paths/eab40185d4accd51a44697959f2f20ceb4bd7cb5 create mode 100644 fuzz/corpus/restore_paths/f8cedb407d2d4245f96b92852ad0a4be7fd96311 create mode 100644 fuzz/corpus/restore_paths/febe89f69009dbe8e54b4ae3388a2321a69c07ab create mode 100644 fuzz/corpus/state_database/48bc65e7dd509933d9c4a49cd7285875f701a243 create mode 100644 fuzz/corpus/state_database/60c79e75f9c2ea5f5aaf21ec2ad7d5b13d61f864 create mode 100644 fuzz/corpus/state_database/f195c020a28dfc5f2fb6af256b524ddcd93756ed create mode 100644 fuzz/corpus/wire/00c30f85342bf56088b09dc0b0d798b183ba9f48 create mode 100644 fuzz/corpus/wire/020e864bf97fd2ba945d0a589bdb0873beb1a010 create mode 100644 fuzz/corpus/wire/037683ca6ae5d80ac4ed995dc9c254d04b6c0e25 create mode 100644 fuzz/corpus/wire/067d5096f219c64b53bb1c7d5e3754285b565a47 create mode 100644 fuzz/corpus/wire/0a0773d8a72f6d44f6ec57c52499aeeb42cb0ed0 create mode 100644 fuzz/corpus/wire/0bc086e53561b449755cc100db146096bcfedc31 create mode 100644 fuzz/corpus/wire/132ccf0bbeffce4af8e88c1c38cb67d38432976f create mode 100644 fuzz/corpus/wire/1482de4a6a2eacd09930bc26436f1a12e8b92f3d create mode 100644 fuzz/corpus/wire/197f90bf0c7041c67318577a7abdc9829408a0ea create mode 100644 fuzz/corpus/wire/1bdc93b89c0b57b04ef3927b54ae354ad35e9a24 create mode 100644 fuzz/corpus/wire/1d30a9312108cfa1d80689a68b7b2067d2a020ab create mode 100644 fuzz/corpus/wire/1e5c2f367f02e47a8c160cda1cd9d91decbac441 create mode 100644 fuzz/corpus/wire/2649e306ab716af7dc98c98ed126d220edf85d2e create mode 100644 fuzz/corpus/wire/2995830f13175d3ae9e0030c39595d74ba5d0ba5 create mode 100644 fuzz/corpus/wire/2ff56e871fdfc40daaa9ec72ae8f0445e35b815e create mode 100644 fuzz/corpus/wire/395df8f7c51f007019cb30201c49e884b46b92fa create mode 100644 fuzz/corpus/wire/3b5c39709f491f88115c71bbead0a42c3303266b create mode 100644 fuzz/corpus/wire/3d7c5b7ca1ba330e268c47add8e4190a1e4154fe create mode 100644 fuzz/corpus/wire/3ebc22303e15b0985ef68e124d179ba1ebf0d75a create mode 100644 fuzz/corpus/wire/3f786850e387550fdab836ed7e6dc881de23001b create mode 100644 fuzz/corpus/wire/42c2b7f27a41309c0851ca670074e3bf4387ad6a create mode 100644 fuzz/corpus/wire/43e9a982b2d7bc56a0f01f9368b959465797b33f create mode 100644 fuzz/corpus/wire/4a901c7fa28418383b11e9c3cdf7a6a3786c3e53 create mode 100644 fuzz/corpus/wire/4dc1da46a99ae5b09614ebce07ad167d83bdf817 create mode 100644 fuzz/corpus/wire/4dc7c9ec434ed06502767136789763ec11d2c4b7 create mode 100644 fuzz/corpus/wire/4f653eb10e4c29f6aec92c6755ea3029d72e2e80 create mode 100644 fuzz/corpus/wire/51d1530dc743b98ea259c2cc49e32c38e906a5fe create mode 100644 fuzz/corpus/wire/5226033afdd9c14555a197323f7e367b12a1a6cf create mode 100644 fuzz/corpus/wire/54006e25e0dab2fcccb139e06cb8be11025f7264 create mode 100644 fuzz/corpus/wire/58668e7669fd564d99db5d581fcdb6a5618440b5 create mode 100644 fuzz/corpus/wire/5eeef35c8c075d820a8551c18db724487c955f35 create mode 100644 fuzz/corpus/wire/5f66b16e822cccdc83bb0a9fe214bd2aa37dc1a3 create mode 100644 fuzz/corpus/wire/62119883f7e243a655f3f62960125c0b76170187 create mode 100644 fuzz/corpus/wire/634593c85d97213c17be88a4766d14e957b04bf4 create mode 100644 fuzz/corpus/wire/6c5e0c496f24d0729b72352c8caefac04ec296b9 create mode 100644 fuzz/corpus/wire/6c801f9afaa817120bedd6f47da244b5cd74e044 create mode 100644 fuzz/corpus/wire/6dcd4ce23d88e2ee9568ba546c007c63d9131c1b create mode 100644 fuzz/corpus/wire/7079196d1ca8f4566bbde59c0d9c5b465a2e407a create mode 100644 fuzz/corpus/wire/70abeee3ca8d3c510de34f44f014219096a30f37 create mode 100644 fuzz/corpus/wire/77ed8a7ebc0e1a16a2de5852a62284fbc43b2d83 create mode 100644 fuzz/corpus/wire/7e15bb5c01e7dd56499e37c634cf791d3a519aee create mode 100644 fuzz/corpus/wire/847b9d6298714127609675a69ce9a7e62f5a04a0 create mode 100644 fuzz/corpus/wire/8768a53e1d4c182907306300f9ca90cfd8018383 create mode 100644 fuzz/corpus/wire/87c0094e9b2ef36db51265ef05be6afa78eb957f create mode 100644 fuzz/corpus/wire/885c115cc6f482537370e3b5dbc517b562e3abd3 create mode 100644 fuzz/corpus/wire/8a766fa20acdc7f3f2b1079da58e71d9f8bb39a3 create mode 100644 fuzz/corpus/wire/8ee6ea96168da8c61fc4ff14c46167fc8311d06e create mode 100644 fuzz/corpus/wire/9069ca78e7450a285173431b3e52c5c25299e473 create mode 100644 fuzz/corpus/wire/9913b492101a1f8e6fe8bc746dd64d22cee50861 create mode 100644 fuzz/corpus/wire/99593fe2abcd06f4a572111c5d5b10278be230db create mode 100644 fuzz/corpus/wire/9a0dccca06fef7402c48260e6578739fc3f731d4 create mode 100644 fuzz/corpus/wire/9a1651d4678ac3c445cc15fd2485ff4c87808789 create mode 100644 fuzz/corpus/wire/9a78211436f6d425ec38f5c4e02270801f3524f8 create mode 100644 fuzz/corpus/wire/9b16668f4e16c0e9932661855b7bcb5bad8b0f72 create mode 100644 fuzz/corpus/wire/9be116001345022790691e18add300aa73c6ed8b create mode 100644 fuzz/corpus/wire/9ceb17804cf223dde10dff2a988a032cf7e2ba84 create mode 100644 fuzz/corpus/wire/a2dfa9429bf2a04d8f23fe980209bd5315f80523 create mode 100644 fuzz/corpus/wire/a3f294235fe5422005ae9bc3a0d1bffe12cfe353 create mode 100644 fuzz/corpus/wire/a662d0b3fa8adb7faf1aba661729e284e06b6ab6 create mode 100644 fuzz/corpus/wire/a7c13e6fe60eee08b9aac00a095a9301ea1a9824 create mode 100644 fuzz/corpus/wire/aa598eabfd75ac378ee3b93ae7d04c4d8de623e1 create mode 100644 fuzz/corpus/wire/ab461f6b8a6842a473257a2561c1fbdf91bdfe77 create mode 100644 fuzz/corpus/wire/b0354f952ee3e3bd402cf980621be873bb307cf9 create mode 100644 fuzz/corpus/wire/b07d6562836e7ed57b5cd153ebdb37b41ee4b308 create mode 100644 fuzz/corpus/wire/b0cb15763a89859ba3a9c3c58836db53b56cb2e4 create mode 100644 fuzz/corpus/wire/b1c8b3a437181bcbd509fd1b46775ab88a10cd1f create mode 100644 fuzz/corpus/wire/b383ced91e6271bad3391fcf3cda98cb246d1db8 create mode 100644 fuzz/corpus/wire/b48f491783e98de10682f2d4455dfce5bdc3c233 create mode 100644 fuzz/corpus/wire/baddccd5c3660b2db4bfb27639955cbf36927c63 create mode 100644 fuzz/corpus/wire/bb1a737d7e1f5238a3c824696e5b208ad6e6ed0e create mode 100644 fuzz/corpus/wire/bd4f574e56df3d166ed90c2f9207ad40a3410035 create mode 100644 fuzz/corpus/wire/c0aef7b7aa3c11b095c47c71c77b477756e43e65 create mode 100644 fuzz/corpus/wire/c0cab0ca79f14fe03f7720a600ae0205385d93d9 create mode 100644 fuzz/corpus/wire/c151b760696d665265187501c51f38cd84503634 create mode 100644 fuzz/corpus/wire/c164c4e7bb925fb852b7225913f328b0069206ec create mode 100644 fuzz/corpus/wire/c1f78812cbc050a987a39691d44b1905975a54e5 create mode 100644 fuzz/corpus/wire/c2eb90f931ed904ca0ddba3f2256750c8eab5ce2 create mode 100644 fuzz/corpus/wire/c66be7210915f39e91456fc2eac9441012a0a3ea create mode 100644 fuzz/corpus/wire/c78ebd3c85a39a596d9f5cfd2b8d240bc1b9c125 create mode 100644 fuzz/corpus/wire/c7da1ff95a25c353f1319604703e8bfd287ee1a1 create mode 100644 fuzz/corpus/wire/ca9e98652c624fdb190508b2b5c10286f0b11cf4 create mode 100644 fuzz/corpus/wire/cdc1619d00d76f372b0fd70cd34fdd839660c6c4 create mode 100644 fuzz/corpus/wire/d07e4bc786c88b8d2304f84c7db2098666f822c0 create mode 100644 fuzz/corpus/wire/d3fe83b8d87ccda2bbca5e81ce3ab1a1400bfbe8 create mode 100644 fuzz/corpus/wire/d50591ff745cc83091f4ee12b2ee702cb24b0b45 create mode 100644 fuzz/corpus/wire/d8c6ec31147b80639d643c67ebbee7ad6a212d8c create mode 100644 fuzz/corpus/wire/d96ae4aff333975de4c3312f55f99984358b5556 create mode 100644 fuzz/corpus/wire/d9a4594f293a19f965d3dc0ed35e0d4210747e44 create mode 100644 fuzz/corpus/wire/d9cae02ee737b9083b8dc74a1e0d85d3ad6127de create mode 100644 fuzz/corpus/wire/da4b9237bacccdf19c0760cab7aec4a8359010b0 create mode 100644 fuzz/corpus/wire/db9303dc319a97589ad8e403cf4db412ef557fed create mode 100644 fuzz/corpus/wire/df9cbe189c6dee2a2c3f4f64102957349c16fd09 create mode 100644 fuzz/corpus/wire/e144f0608b1a43ec8a6ea4ed41f198c92697c471 create mode 100644 fuzz/corpus/wire/e28990f4733a2aafe939f6f8e99538b80fce57d2 create mode 100644 fuzz/corpus/wire/e56e8e82a23bfa5a6910d142e9462aab1453bfeb create mode 100644 fuzz/corpus/wire/e980fa35889e0ea716112b1c14543462ab5cd6da create mode 100644 fuzz/corpus/wire/e9c5d7db93a1c17d45c5820daf458224bfa7a725 create mode 100644 fuzz/corpus/wire/ea4bc1d0d3567a531170d2323cef68c8ad54f652 create mode 100644 fuzz/corpus/wire/ed635506c5f71b804a61de3be15ac77aaa26bc47 create mode 100644 fuzz/corpus/wire/f165cd1e3058ea8ef726fdd6bf588d838b26355f create mode 100644 fuzz/corpus/wire/f316ada8f55ffa5ded94d98572e67ead30dc150e create mode 100644 fuzz/corpus/wire/f353cdb0bf449476fda6c24d81678ddfd00e9ded create mode 100644 fuzz/corpus/wire/f63cfee862c79d90463b5cb240774990410d751e create mode 100644 fuzz/corpus/wire/f824183b844d32f2096963754a0702795cf89429 create mode 100644 fuzz/corpus/wire/f83f3ab528b96ded1c8b1525f22861542194ba96 create mode 100644 fuzz/fuzz_targets/manifests_grants.rs create mode 100644 fuzz/fuzz_targets/recovery_messages.rs create mode 100644 fuzz/fuzz_targets/restore_paths.rs create mode 100644 fuzz/fuzz_targets/state_database.rs create mode 100644 fuzz/fuzz_targets/wire.rs create mode 100644 gui/playwright.config.ts delete mode 100644 gui/src/lib/notes.ts create mode 100644 gui/static/claimant.css create mode 100644 gui/static/claimant.html create mode 100644 gui/static/claimant.js create mode 100644 gui/tests/browser/ui.spec.ts create mode 100644 gui/tests/error-banner.component.test.ts create mode 100644 gui/tests/fixtures/status-contract.json create mode 100644 gui/tests/runtime.component.test.ts create mode 100644 gui/tests/setup.ts create mode 100644 gui/tests/ui-contracts.test.mjs create mode 100644 gui/vitest.config.ts create mode 100644 requirements-cbor-vectors.txt create mode 100644 rust-toolchain.toml create mode 100755 scripts/check-fuzz-targets.sh create mode 100755 scripts/check-operational-logs.sh create mode 100755 scripts/check-release-workflow.sh create mode 100755 scripts/check-restore-layout.sh create mode 100755 scripts/check-supply-chain-config.sh create mode 100755 scripts/check-test-inventory.sh create mode 100755 scripts/check-tracked-secrets.sh create mode 100755 scripts/state-fixtures.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8dc2a5d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +fuzz/corpus/** binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dea70d6..3378a6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,126 @@ permissions: contents: read jobs: + cbor-oracle: + runs-on: ubuntu-latest + steps: + - name: Checkout Carapace + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: Carapace + + - name: Install Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: Carapace/requirements-cbor-vectors.txt + + - name: Install pinned oracle dependencies + run: python -m pip install --requirement Carapace/requirements-cbor-vectors.txt + + - name: Check independent CBOR vectors + working-directory: Carapace + run: python cbor_vectors.py --check + + gui: + runs-on: ubuntu-latest + steps: + - name: Checkout Carapace + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: Carapace + + - name: Install pinned Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.18.0 + cache: npm + cache-dependency-path: Carapace/gui/package-lock.json + + - name: Install pinned npm + run: npm install --global npm@11.16.0 + + - name: Verify Node.js tools + run: | + test "$(node --version)" = "v24.18.0" + test "$(npm --version)" = "11.16.0" + + - name: Install locked GUI dependencies + working-directory: Carapace/gui + run: npm ci + + - name: Check GUI source + working-directory: Carapace/gui + run: npm run check + + - name: Test GUI contracts + working-directory: Carapace/gui + run: npm test + + - name: Install pinned browser runtime + working-directory: Carapace/gui + run: npx playwright install --with-deps chromium + + - name: Test GUI in a real browser + working-directory: Carapace/gui + run: npm run test:browser + + - name: Build embedded GUI + working-directory: Carapace/gui + run: npm run build + + - name: Verify embedded GUI is current + working-directory: Carapace + run: git diff --exit-code -- crates/carapace-api/static + + supply-chain: + runs-on: ubuntu-latest + steps: + - name: Checkout Carapace + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: Carapace + + - name: Read pinned Chela revision + id: chela-pin + shell: bash + run: | + revision="$(tr -d '\r\n' < Carapace/chela-revision.txt)" + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + + - name: Checkout chela + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + repository: SecretSplitKit/Chela + ref: ${{ steps.chela-pin.outputs.revision }} + token: ${{ secrets.CHELA_CHECKOUT_TOKEN || github.token }} + path: chela + + - name: Check tracked files for secrets + working-directory: Carapace + run: scripts/check-tracked-secrets.sh + + - name: Check supply-chain configuration + working-directory: Carapace + run: scripts/check-supply-chain-config.sh + + - name: Check dependency licenses, sources, bans, and advisories + uses: EmbarkStudios/cargo-deny-action@d755fbddac377c2d538f556dd0f9c7728c7f73e4 # v2.0.14 + with: + rust-version: 1.95.0 + manifest-path: Carapace/Cargo.toml + arguments: --all-features --config Carapace/deny.toml + + - name: Prepare lock file for cargo audit + run: cp Carapace/Cargo.lock Cargo.lock + + - name: Audit Rust dependencies + uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + test: strategy: fail-fast: false @@ -30,20 +150,73 @@ jobs: with: path: Carapace + - name: Read pinned Chela revision + id: chela-pin + shell: bash + run: | + revision="$(tr -d '\r\n' < Carapace/chela-revision.txt)" + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + echo "CHELA_REV=$revision" >> "$GITHUB_ENV" + + - name: Check release workflow + shell: bash + working-directory: Carapace + run: scripts/check-release-workflow.sh + + - name: Check shared restore layout + shell: bash + working-directory: Carapace + run: scripts/check-restore-layout.sh + + - name: Check requirement-to-test inventory + shell: bash + working-directory: Carapace + run: scripts/check-test-inventory.sh + + - name: Check operational log sources + shell: bash + working-directory: Carapace + run: scripts/check-operational-logs.sh + + - name: Check state fixture coverage and integrity + shell: bash + working-directory: Carapace + run: scripts/state-fixtures.sh check + + - name: Install fuzz toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: nightly-2026-06-01 + + - name: Run all fuzz targets with bounded budgets + shell: bash + working-directory: Carapace + run: | + cargo install cargo-fuzz --version 0.13.1 --locked + scripts/check-fuzz-targets.sh --smoke + - name: Checkout chela uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: repository: SecretSplitKit/Chela - ref: main + ref: ${{ steps.chela-pin.outputs.revision }} # The default GITHUB_TOKEN is scoped to THIS repo only and cannot read the # separate private Chela repo. Provide a PAT/App token as the # CHELA_CHECKOUT_TOKEN secret; the fallback works only if Chela is public. token: ${{ secrets.CHELA_CHECKOUT_TOKEN || github.token }} path: chela - - name: Install stable Rust + - name: Verify Chela revision + shell: bash + run: | + actual_revision="$(git -C chela rev-parse HEAD)" + test "$actual_revision" = "$CHELA_REV" + + - name: Install pinned Rust uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: + toolchain: 1.95.0 components: clippy, rustfmt - name: Cache cargo @@ -67,4 +240,4 @@ jobs: - name: cargo test working-directory: Carapace - run: cargo test --workspace + run: cargo test --workspace --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1052c57..1bd6431 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,17 +6,140 @@ on: env: CARGO_TERM_COLOR: always - BIN_NAME: carapaced -# Least privilege by default; the build job elevates to contents: write only for -# the release-asset upload. +# Build jobs are read-only. Only the publication gate can write release assets. permissions: + actions: read contents: read jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Checkout Carapace + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: Carapace + - name: Read pinned Chela revision + id: chela-pin + shell: bash + run: | + revision="$(tr -d '\r\n' < Carapace/chela-revision.txt)" + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + - name: Checkout chela + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + repository: SecretSplitKit/Chela + ref: ${{ steps.chela-pin.outputs.revision }} + token: ${{ secrets.CHELA_CHECKOUT_TOKEN || github.token }} + path: chela + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: 1.95.0 + components: clippy, rustfmt + - name: Check Rust formatting and lints + working-directory: Carapace + run: | + cargo fmt --all -- --check + cargo clippy --workspace --all-targets -- -D warnings + - name: Check secrets and supply-chain policy + working-directory: Carapace + run: | + scripts/check-tracked-secrets.sh + scripts/check-supply-chain-config.sh + - name: Check dependency policy + uses: EmbarkStudios/cargo-deny-action@d755fbddac377c2d538f556dd0f9c7728c7f73e4 # v2.0.14 + with: + rust-version: 1.95.0 + manifest-path: Carapace/Cargo.toml + arguments: --all-features --config Carapace/deny.toml + - name: Install Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Check schema and independent vectors + working-directory: Carapace + run: | + python -m pip install --requirement requirements-cbor-vectors.txt + python cbor_vectors.py --check + cargo test --workspace --locked + - name: Check source layout, logs, inventory, and fixtures + working-directory: Carapace + run: | + scripts/check-release-workflow.sh + scripts/check-restore-layout.sh + scripts/check-test-inventory.sh + scripts/check-operational-logs.sh + scripts/state-fixtures.sh check + - name: Install pinned fuzz toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: nightly-2026-06-01 + - name: Run all fuzz targets with bounded budgets + working-directory: Carapace + run: | + cargo install cargo-fuzz --version 0.13.1 --locked + scripts/check-fuzz-targets.sh --smoke + - name: Install pinned Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.18.0 + cache: npm + cache-dependency-path: Carapace/gui/package-lock.json + - name: Check GUI and browser behavior + working-directory: Carapace/gui + run: | + npm install --global npm@11.16.0 + npm ci + npm run check + npm test + npx playwright install --with-deps chromium + npm run test:browser + npm run build + - name: Verify embedded GUI is current + working-directory: Carapace + run: git diff --exit-code -- crates/carapace-api/static + + native-tests: + needs: quality + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Carapace + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: Carapace + - name: Read pinned Chela revision + id: chela-pin + shell: bash + run: | + revision="$(tr -d '\r\n' < Carapace/chela-revision.txt)" + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + - name: Checkout chela + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + repository: SecretSplitKit/Chela + ref: ${{ steps.chela-pin.outputs.revision }} + token: ${{ secrets.CHELA_CHECKOUT_TOKEN || github.token }} + path: chela + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: 1.95.0 + - name: Run native workspace tests + working-directory: Carapace + run: cargo test --workspace --locked + build: - permissions: - contents: write # softprops/action-gh-release attaches assets to the release + needs: [quality, native-tests] + env: + MACOSX_DEPLOYMENT_TARGET: "11.0" strategy: fail-fast: false matrix: @@ -25,21 +148,11 @@ jobs: target: x86_64-unknown-linux-gnu - os: ubuntu-latest target: aarch64-unknown-linux-gnu - cross: true - os: macos-latest target: x86_64-apple-darwin - os: macos-latest target: aarch64-apple-darwin - - os: windows-latest - target: x86_64-pc-windows-msvc - - os: windows-latest - target: aarch64-pc-windows-msvc - cross: true runs-on: ${{ matrix.os }} - # aarch64 cross targets rely on toolchain components (linux: apt - # cross-gcc, windows: MSVC ARM64 tools) that aren't guaranteed on every - # runner image; don't let a cross-target hiccup block the native builds. - continue-on-error: ${{ matrix.cross == true }} steps: # Path dependency on chela-engine/chela-bip39/chela-share # (`../../../chela/...`) requires both repos as siblings. @@ -48,20 +161,36 @@ jobs: with: path: Carapace + - name: Read pinned Chela revision + id: chela-pin + shell: bash + run: | + revision="$(tr -d '\r\n' < Carapace/chela-revision.txt)" + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + echo "CHELA_REV=$revision" >> "$GITHUB_ENV" + - name: Checkout chela uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: repository: SecretSplitKit/Chela - ref: main + ref: ${{ steps.chela-pin.outputs.revision }} # The default GITHUB_TOKEN cannot read the separate private Chela repo; # provide a PAT/App token as CHELA_CHECKOUT_TOKEN. The fallback works only # if Chela is public. token: ${{ secrets.CHELA_CHECKOUT_TOKEN || github.token }} path: chela + - name: Verify Chela revision + shell: bash + run: | + actual_revision="$(git -C chela rev-parse HEAD)" + test "$actual_revision" = "$CHELA_REV" + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: + toolchain: 1.95.0 targets: ${{ matrix.target }} - name: Install aarch64 cross-compiler (Linux) @@ -74,16 +203,61 @@ jobs: - name: Build working-directory: Carapace - run: cargo build --release --locked --target ${{ matrix.target }} -p carapaced + run: cargo build --release --locked --target ${{ matrix.target }} -p carapace-api --bin carapaced -p carapace --bin carapace + + - name: Verify expected binaries (Unix) + if: runner.os != 'Windows' + shell: bash + working-directory: Carapace + run: | + test -s "target/${{ matrix.target }}/release/carapaced" + test -x "target/${{ matrix.target }}/release/carapaced" + test -s "target/${{ matrix.target }}/release/carapace" + test -x "target/${{ matrix.target }}/release/carapace" + + - name: Verify expected binaries (Windows) + if: runner.os == 'Windows' + shell: pwsh + working-directory: Carapace + run: | + $daemon = Get-Item "target/${{ matrix.target }}/release/carapaced.exe" + $client = Get-Item "target/${{ matrix.target }}/release/carapace.exe" + if ($daemon.Length -eq 0 -or $client.Length -eq 0) { + throw "A release executable is empty." + } + + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 + with: + path: Carapace + format: cyclonedx-json + output-file: Carapace/sbom-${{ matrix.target }}.cdx.json + upload-artifact: false - name: Stage artifact (Unix) if: runner.os != 'Windows' shell: bash working-directory: Carapace run: | - staging="${BIN_NAME}-${{ matrix.target }}" + staging="carapace-${{ matrix.target }}" mkdir -p "$staging" - cp "target/${{ matrix.target }}/release/${BIN_NAME}" "$staging/" + cp "target/${{ matrix.target }}/release/carapaced" "$staging/" + cp "target/${{ matrix.target }}/release/carapace" "$staging/" + cp "sbom-${{ matrix.target }}.cdx.json" "$staging/SBOM.cdx.json" + echo "chela_revision=${CHELA_REV}" > "$staging/BUILD-METADATA.txt" + if [[ "${{ matrix.target }}" == *-linux-gnu ]]; then + carapaced_glibc_minimum="$(strings "target/${{ matrix.target }}/release/carapaced" | sed -n 's/.*GLIBC_\([0-9][0-9.]*\).*/\1/p' | sort -V | tail -1)" + carapace_glibc_minimum="$(strings "target/${{ matrix.target }}/release/carapace" | sed -n 's/.*GLIBC_\([0-9][0-9.]*\).*/\1/p' | sort -V | tail -1)" + test -n "$carapaced_glibc_minimum" + test -n "$carapace_glibc_minimum" + echo "carapaced_glibc_minimum=${carapaced_glibc_minimum}" >> "$staging/BUILD-METADATA.txt" + echo "carapace_glibc_minimum=${carapace_glibc_minimum}" >> "$staging/BUILD-METADATA.txt" + else + vtool -show-build "target/${{ matrix.target }}/release/carapaced" | grep -F 'minos 11.0' + vtool -show-build "target/${{ matrix.target }}/release/carapace" | grep -F 'minos 11.0' + echo "carapaced_macos_minimum=11.0" >> "$staging/BUILD-METADATA.txt" + echo "carapace_macos_minimum=11.0" >> "$staging/BUILD-METADATA.txt" + fi tar czf "${staging}.tar.gz" "$staging" echo "ASSET=Carapace/${staging}.tar.gz" >> "$GITHUB_ENV" @@ -92,19 +266,60 @@ jobs: shell: pwsh working-directory: Carapace run: | - $staging = "${env:BIN_NAME}-${{ matrix.target }}" + $staging = "carapace-${{ matrix.target }}" New-Item -ItemType Directory -Path $staging | Out-Null - Copy-Item "target/${{ matrix.target }}/release/${env:BIN_NAME}.exe" "$staging/" + Copy-Item "target/${{ matrix.target }}/release/carapaced.exe" "$staging/" + Copy-Item "target/${{ matrix.target }}/release/carapace.exe" "$staging/" + Copy-Item "sbom-${{ matrix.target }}.cdx.json" "$staging/SBOM.cdx.json" + Set-Content -Path "$staging/BUILD-METADATA.txt" -Value "chela_revision=${env:CHELA_REV}" Compress-Archive -Path $staging -DestinationPath "${staging}.zip" echo "ASSET=Carapace/${staging}.zip" >> $env:GITHUB_ENV - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ${{ env.BIN_NAME }}-${{ matrix.target }} + name: carapace-${{ matrix.target }} path: ${{ env.ASSET }} - - name: Attach to GitHub release + publish: + needs: [quality, native-tests, build] + runs-on: ubuntu-latest + permissions: + actions: read + attestations: write + contents: write + id-token: write + steps: + - name: Download all release artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + path: release-assets + merge-multiple: true + + - name: Create SHA-256 checksums + working-directory: release-assets + run: sha256sum carapace-* > SHA256SUMS + + - name: Create approved detached signature + working-directory: release-assets + env: + RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} + RELEASE_SIGNING_FINGERPRINT: ${{ secrets.RELEASE_SIGNING_FINGERPRINT }} + run: | + test -n "$RELEASE_SIGNING_KEY" + test -n "$RELEASE_SIGNING_FINGERPRINT" + printf '%s' "$RELEASE_SIGNING_KEY" | gpg --batch --import + actual="$(gpg --batch --with-colons --fingerprint "$RELEASE_SIGNING_FINGERPRINT" | awk -F: '$1 == "fpr" { print $10; exit }')" + test "$actual" = "$RELEASE_SIGNING_FINGERPRINT" + gpg --batch --local-user "$RELEASE_SIGNING_FINGERPRINT" --detach-sign --armor --output SHA256SUMS.asc SHA256SUMS + + - name: Attest release artifact provenance + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: release-assets/carapace-* + + - name: Attach complete artifact set to GitHub release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: - files: ${{ env.ASSET }} + fail_on_unmatched_files: true + files: release-assets/* diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..ca5c350 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.18.0 diff --git a/Cargo.lock b/Cargo.lock index d53cebd..b9ed7b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -117,6 +123,17 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -189,6 +206,126 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -415,6 +552,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -424,6 +570,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -466,14 +625,19 @@ dependencies = [ "axum", "carapace-wire", "carapaced", + "ed25519-dalek 2.2.0", "getrandom 0.2.17", "hex", + "rpassword", "rust-embed", + "rustix", "serde", "serde_json", "subtle", "tempfile", "tokio", + "tower", + "zeroize", ] [[package]] @@ -499,6 +663,7 @@ version = "0.1.0" dependencies = [ "blake3", "carapace-crypto", + "carapace-restore", "carapace-wire", "ed25519-dalek 2.2.0", "tempfile", @@ -564,6 +729,17 @@ dependencies = [ "hex", ] +[[package]] +name = "carapace-restore" +version = "0.1.0" +dependencies = [ + "blake3", + "getrandom 0.2.17", + "rustix", + "tempfile", + "unicode-normalization", +] + [[package]] name = "carapace-share" version = "0.1.0" @@ -579,6 +755,7 @@ version = "0.1.0" dependencies = [ "blake3", "carapace-crypto", + "carapace-restore", "carapace-wire", "chacha20poly1305", "ed25519-dalek 2.2.0", @@ -601,6 +778,7 @@ name = "carapaced" version = "0.1.0" dependencies = [ "anyhow", + "base64", "blake3", "carapace-crypto", "carapace-disclose", @@ -613,16 +791,28 @@ dependencies = [ "carapace-wire", "chela-share", "ed25519-dalek 2.2.0", + "flate2", "getrandom 0.2.17", "iroh", "iroh-blobs", + "keyring", "notify", "redb", + "rustix", "tempfile", "tokio", "zeroize", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.67" @@ -824,6 +1014,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -934,6 +1133,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -1388,6 +1596,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-assoc" version = "1.3.0" @@ -1399,6 +1613,27 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1415,6 +1650,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastbloom" version = "0.17.0" @@ -1467,6 +1722,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1805,6 +2070,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2236,6 +2507,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -2670,6 +2942,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0298a59b384c540e408a600c8b375a09b49c3f97debc080e2c30675d79a6368a" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -2796,6 +3089,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2818,6 +3120,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -3140,6 +3452,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3150,6 +3476,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3165,6 +3500,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3332,6 +3688,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "p256" version = "0.13.2" @@ -3459,6 +3825,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -3492,6 +3869,20 @@ dependencies = [ "time", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -3853,6 +4244,18 @@ dependencies = [ "windows", ] +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.15" @@ -3946,6 +4349,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rust-embed" version = "8.12.0" @@ -4183,6 +4607,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2 0.10.9", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4298,6 +4741,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -4419,6 +4873,12 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -4588,6 +5048,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn-mid" version = "0.5.4" @@ -5079,6 +5550,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicase" version = "2.9.0" @@ -5091,6 +5573,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -5186,6 +5677,7 @@ checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -5488,6 +5980,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -5545,6 +6050,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -5889,6 +6403,78 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -5988,3 +6574,43 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 12ef416..af51742 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = ["crates/*"] [workspace.package] edition = "2021" +rust-version = "1.95" license = "Apache-2.0 OR MIT" repository = "https://github.com/SecretSplitKit/Carapace" diff --git a/cbor_vectors.py b/cbor_vectors.py index 8a0ac34..b034364 100644 --- a/cbor_vectors.py +++ b/cbor_vectors.py @@ -10,9 +10,20 @@ sorted bytewise-lexicographically on their encoded form - bool/null as simple values; floats PROHIBITED """ +import argparse import hashlib +import re +from pathlib import Path from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +parser = argparse.ArgumentParser(description="Generate or check Carapace CBOR vectors") +parser.add_argument( + "--check", + action="store_true", + help="compare all generated vectors with the Rust golden source without writing files", +) +args = parser.parse_args() + # ---------------- deterministic CBOR encoder (reference) ---------------- def enc_uint(n, major=0): @@ -332,30 +343,32 @@ def hexwrap(b, indent=""): lines.append(hexwrap(e)) lines.append("```") lines.append("") -with open("appendix_b8_fragment.md", "w") as fh: - fh.write("\n".join(lines)) +if not args.check: + with open("appendix_b8_fragment.md", "w") as fh: + fh.write("\n".join(lines)) -print(f"\nPART 2: {len(part2)} frames + {len(docs)} documents generated, all signatures verified OK") -print("fragment written to appendix_b8_fragment.md") + print(f"\nPART 2: {len(part2)} frames + {len(docs)} documents generated, all signatures verified OK") + print("fragment written to appendix_b8_fragment.md") # ---------------- output ---------------- -print("== test keys ==") -for label, kp in [("USER_A", USER_A), ("USER_B", USER_B), - ("NODE_A1", NODE_A1), ("NODE_B1", NODE_B1)]: - print(f"{label}: seed={kp.private_bytes_raw().hex()}") - print(f"{label}: pub ={kp.public_key().public_bytes_raw().hex()}") -print(f"T0={T0}") -print() -for name, mt, body, f, note in vectors: - print(f"== {name} (type {mt}) == {note}") - print(f"frame ({len(f)} bytes):") - h = f.hex() - for i in range(0, len(h), 64): - print(" " + h[i:i+64]) +if not args.check: + print("== test keys ==") + for label, kp in [("USER_A", USER_A), ("USER_B", USER_B), + ("NODE_A1", NODE_A1), ("NODE_B1", NODE_B1)]: + print(f"{label}: seed={kp.private_bytes_raw().hex()}") + print(f"{label}: pub ={kp.public_key().public_bytes_raw().hex()}") + print(f"T0={T0}") print() -print("== InviteTicket URI ==") -print(ticket_uri) + for name, mt, body, f, note in vectors: + print(f"== {name} (type {mt}) == {note}") + print(f"frame ({len(f)} bytes):") + h = f.hex() + for i in range(0, len(h), 64): + print(" " + h[i:i+64]) + print() + print("== InviteTicket URI ==") + print(ticket_uri) # sanity: verify every signature verifies from cryptography.exceptions import InvalidSignature @@ -367,4 +380,33 @@ def hexwrap(b, indent=""): signer = pubmap[body.get(22, body[0])].public_key() body_wo = {k2: v for k2, v in body.items() if k2 != 23} signer.verify(body[23], DOMAIN + enc([mt, body_wo])) -print("\nall signatures verified OK") +def check_vectors(generated_vectors, generated_docs): + """Compare all oracle values with the committed Rust golden source.""" + rust_source = Path(__file__).resolve().parent / "crates/carapace-wire/tests/vectors.rs" + text = rust_source.read_text(encoding="utf-8") + rust_frames = re.findall(r'assert_frame\(\s*"([0-9a-f]+)"', text) + rust_documents = re.findall( + r'assert_eq!\(\s*"([0-9a-f]+)",\s*hex::encode\([^)]*\.to_bytes\(\)\)', + text, + ) + generated = [(name, data.hex()) for name, _, _, data, _ in generated_vectors] + generated.extend((name, data.hex()) for name, _, data, _ in generated_docs) + rust = rust_frames + rust_documents + if len(rust) != len(generated): + raise SystemExit( + f"vector count mismatch: oracle generated {len(generated)}, Rust has {len(rust)}" + ) + mismatches = [ + f"{index + 1} {name}" + for index, ((name, actual), expected) in enumerate(zip(generated, rust)) + if actual != expected + ] + if mismatches: + raise SystemExit("CBOR vector mismatch: " + ", ".join(mismatches)) + print(f"checked {len(generated)} independent CBOR vectors against {rust_source}") + + +if args.check: + check_vectors(vectors + part2, docs) +else: + print("\nall signatures verified OK") diff --git a/chela-revision.txt b/chela-revision.txt new file mode 100644 index 0000000..bd731fc --- /dev/null +++ b/chela-revision.txt @@ -0,0 +1 @@ +2f56b8333bd8b3882d47ce55c4fbba65542a70ce diff --git a/crates/carapace-api/Cargo.toml b/crates/carapace-api/Cargo.toml index f679afe..03dd644 100644 --- a/crates/carapace-api/Cargo.toml +++ b/crates/carapace-api/Cargo.toml @@ -2,6 +2,7 @@ name = "carapace-api" version = "0.1.0" edition.workspace = true +rust-version.workspace = true license.workspace = true [lints] @@ -19,7 +20,14 @@ rust-embed = { version = "8", features = ["mime-guess"] } hex = "0.4" getrandom = "0.2" anyhow = "1" +rpassword = "7" +zeroize = "1" + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1.4", features = ["fs"] } [dev-dependencies] tempfile = "3" hex = "0.4" +ed25519-dalek = "2" +tower = "0.5" diff --git a/crates/carapace-api/src/auth.rs b/crates/carapace-api/src/auth.rs index 33daf5f..f19969a 100644 --- a/crates/carapace-api/src/auth.rs +++ b/crates/carapace-api/src/auth.rs @@ -86,7 +86,7 @@ pub fn is_loopback_origin(origin: &str) -> bool { /// DNS-rebinding + CSRF guard applied to EVERY request (including the health check /// and the static GUI). Rejects a non-loopback `Host`, and a present-but-non-loopback /// `Origin`. -pub async fn guard_host_origin(req: Request, next: Next) -> Result { +pub async fn guard_host_origin(req: Request, next: Next) -> Result { let headers = req.headers(); let host_ok = headers .get(header::HOST) @@ -94,11 +94,17 @@ pub async fn guard_host_origin(req: Request, next: Next) -> Result>, req: Request, next: Next, -) -> Result { +) -> Result { let presented = bearer(req.headers().get(header::AUTHORIZATION)); match presented { Some(t) if ct_eq_str(t, &token) => Ok(next.run(req).await), - _ => Err(StatusCode::UNAUTHORIZED), + _ => Err(crate::handlers::error_response( + StatusCode::UNAUTHORIZED, + "missing or invalid token", + )), } } diff --git a/crates/carapace-api/src/bin/carapaced.rs b/crates/carapace-api/src/bin/carapaced.rs index 3c2392e..6d63d6a 100644 --- a/crates/carapace-api/src/bin/carapaced.rs +++ b/crates/carapace-api/src/bin/carapaced.rs @@ -2,6 +2,7 @@ //! //! Usage: //! carapaced run --state-dir [--publish [--watch] --vid <64-hex>] [--api-port ] +//! carapaced claimant --state-dir [--api-port ] //! //! `run` loads/generates the device state, starts the daemon (serving the blob //! store + `carapace/1` control protocol), optionally publishes a vault, starts the @@ -14,20 +15,145 @@ use std::sync::Arc; use anyhow::{bail, Context, Result}; use carapaced::{Daemon, NetConfig, State}; +use zeroize::Zeroizing; + +#[path = "carapaced/state_ops.rs"] +mod state_ops; /// Default bind for the embedded relay when `--relay` is given no explicit socket. const DEFAULT_RELAY_BIND: &str = "0.0.0.0:9991"; +trait PassphrasePrompt { + fn read(&self) -> Result>; +} + +struct ControllingTerminalPrompt; + +impl PassphrasePrompt for ControllingTerminalPrompt { + fn read(&self) -> Result> { + #[cfg(unix)] + let _terminal = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .context("terminal-passphrase mode requires a controlling terminal")?; + let passphrase = Zeroizing::new( + rpassword::prompt_password("Carapace identity passphrase: ") + .context("read passphrase from the controlling terminal")?, + ); + ensure_nonempty_passphrase(passphrase) + } +} + +fn ensure_nonempty_passphrase(passphrase: Zeroizing) -> Result> { + if passphrase.is_empty() { + bail!("the terminal passphrase is empty"); + } + Ok(passphrase) +} + +fn read_operator_passphrase(prompt: &dyn PassphrasePrompt) -> Result> { + ensure_nonempty_passphrase(prompt.read()?) +} + #[tokio::main] async fn main() -> Result<()> { let mut args = std::env::args().skip(1); match args.next().as_deref() { Some("run") => run(args.collect()).await, - Some(other) => bail!("unknown command {other:?}; try: carapaced run --state-dir "), - None => bail!( - "usage: carapaced run --state-dir [--publish --vid <64-hex>] [--api-port ]" - ), + Some("claimant") => claimant(args.collect()).await, + Some("inspect-state") => state_ops::inspect_state(args.collect()), + Some("initialize-empty") => state_ops::initialize_empty(args.collect()), + Some("restore-backup") => state_ops::restore_backup(args.collect()), + Some("reset-security-state") => state_ops::reset_security_state(args.collect()), + Some("migrate-legacy-state") => state_ops::migrate_legacy_state(args.collect()), + Some("migrate-keys") => migrate_keys(args.collect()), + Some(other) => { + bail!("unknown command {other:?}; try: carapaced run, claimant, or a state operator command") + } + None => bail!("usage: carapaced --state-dir "), + } +} + +#[derive(Debug, PartialEq, Eq)] +struct ClaimantArgs { + state_dir: PathBuf, + api_port: u16, +} + +fn parse_claimant_args(rest: Vec) -> Result { + let mut state_dir = None; + let mut api_port = 0; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => { + state_dir = Some(it.next().context("--state-dir needs a value")?.into()) + } + "--api-port" => { + api_port = it + .next() + .context("--api-port needs a value")? + .parse() + .context("--api-port must be a u16 port")? + } + other => bail!("unknown claimant flag {other:?}"), + } + } + Ok(ClaimantArgs { + state_dir: state_dir.context("--state-dir is required")?, + api_port, + }) +} + +async fn claimant(rest: Vec) -> Result<()> { + let args = parse_claimant_args(rest)?; + let api = carapace_api::serve_claimant(&args.state_dir, args.api_port).await?; + println!("claimant API: {}", api.url()); + println!( + "claimant API token: {} (bearer, 0600)", + args.state_dir.join("claimant-api-token").display() + ); + println!("claimant recovery is active; press Ctrl-C to stop"); + wait_for_stop().await?; + api.shutdown(); + Ok(()) +} + +fn migrate_keys(rest: Vec) -> Result<()> { + let mut state_dir: Option = None; + let mut confirmed: Option = None; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => { + state_dir = Some(it.next().context("--state-dir needs a value")?.into()) + } + "--confirm-state-dir" => { + confirmed = Some( + it.next() + .context("--confirm-state-dir needs a value")? + .into(), + ) + } + other => bail!("unknown migrate-keys flag {other:?}"), + } } + let state_dir = state_dir.context("--state-dir is required")?; + let confirmed = confirmed.context("--confirm-state-dir is required")?; + let canonical = std::fs::canonicalize(&state_dir) + .with_context(|| format!("resolve state directory {state_dir:?}"))?; + let confirmed_canonical = std::fs::canonicalize(&confirmed) + .with_context(|| format!("resolve confirmed state directory {confirmed:?}"))?; + if canonical != confirmed_canonical { + bail!("confirmed state directory does not match --state-dir"); + } + let backup = State::migrate_legacy_keys(&canonical)?; + println!( + "key migration complete; protected backup: {}", + backup.display() + ); + Ok(()) } async fn run(rest: Vec) -> Result<()> { @@ -40,6 +166,8 @@ async fn run(rest: Vec) -> Result<()> { let mut relays: Vec = Vec::new(); let mut run_relay: Option = None; let mut relay_host: Option = None; + let mut insecure_plaintext_keys = false; + let mut terminal_passphrase = false; let mut it = rest.into_iter().peekable(); while let Some(flag) = it.next() { @@ -80,6 +208,8 @@ async fn run(rest: Vec) -> Result<()> { "--relay-host" => { relay_host = Some(it.next().context("--relay-host needs a host or ip")?) } + "--insecure-plaintext-keys" => insecure_plaintext_keys = true, + "--terminal-passphrase" => terminal_passphrase = true, // A friend's self-hosted relay URL to consume (repeatable). These form // this node's usable relay set for relay fallback (§6). "--relay-url" => relays.push( @@ -93,8 +223,27 @@ async fn run(rest: Vec) -> Result<()> { } let state_dir = state_dir.context("--state-dir is required")?; - let state = State::load_or_generate(&state_dir)?; let networked = bind.is_some() || run_relay.is_some() || !relays.is_empty(); + if insecure_plaintext_keys && networked { + bail!("--insecure-plaintext-keys cannot be used with non-loopback or relay operation"); + } + if insecure_plaintext_keys && terminal_passphrase { + bail!("--terminal-passphrase cannot be combined with --insecure-plaintext-keys"); + } + let state = if insecure_plaintext_keys { + eprintln!( + "carapace: WARNING insecure development mode stores identity secrets in local files" + ); + State::load_or_generate_insecure(&state_dir)? + } else if terminal_passphrase { + eprintln!( + "carapace: WARNING terminal-passphrase mode stays locked after unattended restart; recovery takeover delay and alarms cannot run while locked" + ); + let passphrase = read_operator_passphrase(&ControllingTerminalPrompt)?; + State::load_protected_local(&state_dir, passphrase.as_bytes())? + } else { + State::load_or_generate(&state_dir)? + }; let daemon = Arc::new(if networked { let cfg = NetConfig { bind, @@ -195,3 +344,58 @@ fn hex(b: &[u8]) -> String { } s } + +#[cfg(test)] +mod tests { + use super::*; + + fn values(items: &[&str]) -> Vec { + items.iter().map(|item| (*item).to_string()).collect() + } + + #[test] + fn claimant_arguments_default_to_an_ephemeral_port() { + let parsed = parse_claimant_args(values(&["--state-dir", "/tmp/claimant"])).unwrap(); + assert_eq!(parsed.state_dir, PathBuf::from("/tmp/claimant")); + assert_eq!(parsed.api_port, 0); + } + + #[test] + fn claimant_arguments_accept_an_api_port() { + let parsed = parse_claimant_args(values(&[ + "--state-dir", + "/tmp/claimant", + "--api-port", + "4711", + ])) + .unwrap(); + assert_eq!(parsed.api_port, 4711); + } + + #[test] + fn claimant_arguments_reject_missing_and_extra_values() { + let missing = parse_claimant_args(Vec::new()).unwrap_err(); + assert!(missing.to_string().contains("--state-dir is required")); + let extra = + parse_claimant_args(values(&["--state-dir", "/tmp/c", "--bind", "x"])).unwrap_err(); + assert!(extra.to_string().contains("unknown claimant flag")); + } + + struct FakePrompt(&'static str); + impl PassphrasePrompt for FakePrompt { + fn read(&self) -> Result> { + Ok(Zeroizing::new(self.0.to_string())) + } + } + + #[test] + fn terminal_passphrase_prompt_rejects_empty_input() { + assert!(read_operator_passphrase(&FakePrompt("")).is_err()); + assert_eq!( + read_operator_passphrase(&FakePrompt("operator secret")) + .unwrap() + .as_str(), + "operator secret" + ); + } +} diff --git a/crates/carapace-api/src/bin/carapaced/state_ops.rs b/crates/carapace-api/src/bin/carapaced/state_ops.rs new file mode 100644 index 0000000..3c76755 --- /dev/null +++ b/crates/carapace-api/src/bin/carapaced/state_ops.rs @@ -0,0 +1,579 @@ +//! Read-only state operator commands for the `carapaced` binary. + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use carapaced::{ + initialize_empty_state, inspect_existing_state, inspect_state_database, + migrate_legacy_state as migrate_legacy_database, State, +}; + +const AUDIT_LOG: &str = "operator-audit.log"; +const RESET_CONFIRMATION: &str = "RESET SECURITY STATE"; + +pub(crate) fn inspect_state(rest: Vec) -> Result<()> { + let (state_dir, insecure) = parse_inspect_args(rest)?; + let state = load_existing_identity(&state_dir, insecure)?; + let report = inspect_existing_state(&state)?; + + println!("state directory: {}", state_dir.display()); + println!("identity: valid"); + println!("database: valid"); + println!("owned vaults: {}", report.owned_vaults); + println!("held replicas: {}", report.held_replicas); + println!("held shares: {}", report.held_shares); + println!("recovery sets: {}", report.recovery_sets); + println!("ceremonies: {}", report.ceremonies); + Ok(()) +} + +pub(crate) fn migrate_legacy_state(rest: Vec) -> Result<()> { + let (state_dir, insecure) = parse_confirmed_existing_args(rest)?; + let state = load_existing_identity(&state_dir, insecure)?; + let backup = migrate_legacy_database(&state)?; + append_audit( + &state_dir, + "migrate-legacy-state", + "validated and migrated legacy state to schema 2; preserved backup", + )?; + println!( + "legacy state migration complete; protected backup: {}", + backup.display() + ); + Ok(()) +} + +fn parse_confirmed_existing_args(rest: Vec) -> Result<(PathBuf, bool)> { + let mut state_dir = None; + let mut confirmed = None; + let mut insecure = false; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => state_dir = Some(next_path(&mut it, "--state-dir")?), + "--confirm-state-dir" => confirmed = Some(next_path(&mut it, "--confirm-state-dir")?), + "--insecure-plaintext-keys" => insecure = true, + other => bail!("unknown legacy migration flag {other:?}"), + } + } + Ok((confirmed_dir(state_dir, confirmed)?, insecure)) +} + +pub(crate) fn initialize_empty(rest: Vec) -> Result<()> { + let (state_dir, insecure) = parse_initialize_args(rest)?; + let state = load_or_create_identity(&state_dir, insecure)?; + append_audit( + &state_dir, + "initialize-empty-start", + "validated explicit state directory", + )?; + initialize_empty_state(&state)?; + append_audit( + &state_dir, + "initialize-empty", + "created identity-bound empty state", + )?; + println!("initialized empty state: {}", state_dir.display()); + Ok(()) +} + +pub(crate) fn restore_backup(rest: Vec) -> Result<()> { + let mut state_dir = None; + let mut backup = None; + let mut confirmed = None; + let mut insecure = false; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => state_dir = Some(next_path(&mut it, "--state-dir")?), + "--backup" => backup = Some(next_path(&mut it, "--backup")?), + "--confirm-state-dir" => confirmed = Some(next_path(&mut it, "--confirm-state-dir")?), + "--insecure-plaintext-keys" => insecure = true, + other => bail!("unknown restore-backup flag {other:?}"), + } + } + let state_dir = confirmed_dir(state_dir, confirmed)?; + let backup = backup.context("--backup is required")?; + let state = load_existing_identity(&state_dir, insecure)?; + inspect_state_database(&state, &backup).context("validate backup before restore")?; + append_audit( + &state_dir, + "restore-backup-start", + &format!("validated backup {}", backup.display()), + )?; + + let active = state_dir.join("state.redb"); + let prior = state_dir.join("state.redb.before-restore"); + let staged = state_dir.join("state.redb.restore-staged"); + if prior.exists() || staged.exists() { + bail!("a prior restore artifact exists; inspect it before retrying"); + } + copy_durable(&backup, &staged)?; + inspect_state_database(&state, &staged).context("validate staged backup")?; + if active.exists() { + std::fs::rename(&active, &prior).context("preserve current state database")?; + } + if let Err(error) = std::fs::rename(&staged, &active) { + if prior.exists() { + let _ = std::fs::rename(&prior, &active); + } + return Err(error).context("activate restored state database"); + } + if let Err(error) = inspect_existing_state(&state) { + let failed = state_dir.join("state.redb.failed-restore"); + let _ = std::fs::rename(&active, &failed); + if prior.exists() { + std::fs::rename(&prior, &active).context("roll back failed restore")?; + } + sync_dir(&state_dir)?; + return Err(error).context("validate activated state database; prior state restored"); + } + sync_dir(&state_dir)?; + append_audit( + &state_dir, + "restore-backup", + &format!("restored {}; prior database preserved", backup.display()), + )?; + println!("restored state database: {}", active.display()); + if prior.exists() { + println!("prior database: {}", prior.display()); + } + Ok(()) +} + +pub(crate) fn reset_security_state(rest: Vec) -> Result<()> { + let (state_dir, insecure) = parse_reset_args(rest)?; + let state = load_existing_identity(&state_dir, insecure)?; + inspect_existing_state(&state).context("validate current state before reset")?; + + eprintln!("WARNING: reset-security-state clears rollback and replay history, fetch authorization, friendships, replica placement, and recovery ceremony state."); + eprintln!("The current database and encrypted blobs will move to a recoverable backup."); + eprintln!("Confirmed state directory: {}", state_dir.display()); + append_audit( + &state_dir, + "reset-security-state-start", + "validated current state and explicit destructive confirmation", + )?; + + let backup_dir = state_dir.join("security-reset-backup"); + if backup_dir.exists() { + bail!("reset backup already exists at {backup_dir:?}"); + } + create_private_dir(&backup_dir)?; + let active = state_dir.join("state.redb"); + let backup_db = backup_dir.join("state.redb"); + std::fs::rename(&active, &backup_db).context("preserve state database for reset")?; + let blobs = state_dir.join("blobs"); + let backup_blobs = backup_dir.join("blobs"); + if blobs.exists() { + if let Err(error) = std::fs::rename(&blobs, &backup_blobs) { + let _ = std::fs::rename(&backup_db, &active); + return Err(error).context("preserve blob store for reset"); + } + } + if let Err(error) = initialize_empty_state(&state) { + let _ = std::fs::remove_file(&active); + let _ = std::fs::rename(&backup_db, &active); + if backup_blobs.exists() { + let _ = std::fs::rename(&backup_blobs, &blobs); + } + return Err(error).context("initialize replacement security state"); + } + sync_dir(&state_dir)?; + append_audit( + &state_dir, + "reset-security-state", + "reset rollback, authorization, friendship, replica, and recovery state; preserved prior database and blobs", + )?; + println!( + "security state reset; recoverable backup: {}", + backup_dir.display() + ); + Ok(()) +} + +fn parse_inspect_args(rest: Vec) -> Result<(PathBuf, bool)> { + let mut state_dir = None; + let mut insecure = false; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => { + state_dir = Some(it.next().context("--state-dir needs a value")?.into()) + } + "--insecure-plaintext-keys" => insecure = true, + other => bail!("unknown inspect-state flag {other:?}"), + } + } + Ok((state_dir.context("--state-dir is required")?, insecure)) +} + +fn parse_initialize_args(rest: Vec) -> Result<(PathBuf, bool)> { + let mut state_dir = None; + let mut confirmed = None; + let mut insecure = false; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => state_dir = Some(next_path(&mut it, "--state-dir")?), + "--confirm-state-dir" => confirmed = Some(next_path(&mut it, "--confirm-state-dir")?), + "--insecure-plaintext-keys" => insecure = true, + other => bail!("unknown initialize-empty flag {other:?}"), + } + } + Ok((confirmed_dir(state_dir, confirmed)?, insecure)) +} + +fn parse_reset_args(rest: Vec) -> Result<(PathBuf, bool)> { + let mut state_dir = None; + let mut confirmed = None; + let mut phrase = None; + let mut insecure = false; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => state_dir = Some(next_path(&mut it, "--state-dir")?), + "--confirm-state-dir" => confirmed = Some(next_path(&mut it, "--confirm-state-dir")?), + "--confirm-reset" => phrase = Some(it.next().context("--confirm-reset needs a value")?), + "--insecure-plaintext-keys" => insecure = true, + other => bail!("unknown reset-security-state flag {other:?}"), + } + } + if phrase.as_deref() != Some(RESET_CONFIRMATION) { + bail!("--confirm-reset must be exactly {RESET_CONFIRMATION:?}"); + } + Ok((confirmed_dir(state_dir, confirmed)?, insecure)) +} + +fn confirmed_dir(state_dir: Option, confirmed: Option) -> Result { + let state_dir = state_dir.context("--state-dir is required")?; + let confirmed = confirmed.context("--confirm-state-dir is required")?; + let state_dir = std::fs::canonicalize(&state_dir) + .with_context(|| format!("resolve state directory {state_dir:?}"))?; + let confirmed = std::fs::canonicalize(&confirmed) + .with_context(|| format!("resolve confirmed state directory {confirmed:?}"))?; + if state_dir != confirmed { + bail!("confirmed state directory does not match --state-dir"); + } + Ok(state_dir) +} + +fn next_path(it: &mut impl Iterator, flag: &str) -> Result { + Ok(it + .next() + .with_context(|| format!("{flag} needs a value"))? + .into()) +} + +fn load_or_create_identity(state_dir: &Path, insecure: bool) -> Result { + if insecure { + State::load_or_generate_insecure(state_dir) + } else { + State::load_or_generate(state_dir) + } +} + +fn load_existing_identity(state_dir: &Path, insecure: bool) -> Result { + let credential = state_dir.join("credential.id"); + let node = state_dir.join("node.key"); + let root = state_dir.join("root.key"); + + if insecure { + if !node.is_file() || !root.is_file() { + bail!( + "insecure state inspection requires existing root.key and node.key files in {state_dir:?}" + ); + } + return State::load_or_generate_insecure(state_dir); + } + + if !credential.is_file() { + bail!( + "secure state inspection requires an existing credential.id in {state_dir:?}; use --insecure-plaintext-keys only for an existing development state" + ); + } + if node.exists() || root.exists() { + bail!("legacy key files exist beside credential.id in {state_dir:?}"); + } + State::load_or_generate(state_dir) +} + +fn copy_durable(source: &Path, destination: &Path) -> Result<()> { + #[cfg(unix)] + let mut input = { + use rustix::fs::{openat, Mode, OFlags, CWD}; + let descriptor = openat( + CWD, + source, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(std::io::Error::from) + .with_context(|| format!("open backup without following links {source:?}"))?; + std::fs::File::from(descriptor) + }; + #[cfg(not(unix))] + let mut input = + std::fs::File::open(source).with_context(|| format!("open backup {source:?}"))?; + let mut output = create_private_file(destination)?; + std::io::copy(&mut input, &mut output) + .with_context(|| format!("copy backup to {destination:?}"))?; + output.sync_all().context("sync staged state database")?; + sync_dir( + destination + .parent() + .context("staged database has no parent")?, + ) +} + +fn append_audit(state_dir: &Path, action: &str, detail: &str) -> Result<()> { + use std::io::Write; + let path = state_dir.join(AUDIT_LOG); + let mut file = open_private_append(&path)?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock is before Unix epoch")? + .as_secs(); + let action = action.replace(['\t', '\r', '\n'], " "); + let detail = detail.replace(['\t', '\r', '\n'], " "); + writeln!(file, "{now}\t{action}\t{detail}").context("write operator audit record")?; + file.sync_all().context("sync operator audit record")?; + sync_dir(state_dir) +} + +#[cfg(unix)] +fn create_private_file(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .with_context(|| format!("create private file {path:?}")) +} + +#[cfg(not(unix))] +fn create_private_file(path: &Path) -> Result { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("create private file {path:?}")) +} + +#[cfg(unix)] +fn open_private_append(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .append(true) + .create(true) + .mode(0o600) + .open(path) + .with_context(|| format!("open private audit log {path:?}")) +} + +#[cfg(not(unix))] +fn open_private_append(path: &Path) -> Result { + std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(path) + .with_context(|| format!("open audit log {path:?}")) +} + +#[cfg(unix)] +fn create_private_dir(path: &Path) -> Result<()> { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .mode(0o700) + .create(path) + .with_context(|| format!("create private backup directory {path:?}")) +} + +#[cfg(not(unix))] +fn create_private_dir(path: &Path) -> Result<()> { + std::fs::create_dir(path).with_context(|| format!("create backup directory {path:?}")) +} + +fn sync_dir(path: &Path) -> Result<()> { + #[cfg(unix)] + { + std::fs::File::open(path) + .with_context(|| format!("open directory {path:?} for sync"))? + .sync_all() + .with_context(|| format!("sync directory {path:?}"))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inspect_requires_an_explicit_state_directory() { + let error = parse_inspect_args(Vec::new()).unwrap_err(); + assert!(error.to_string().contains("--state-dir is required")); + } + + #[test] + fn legacy_migration_requires_the_matching_state_directory() { + let root = tempfile::tempdir().unwrap(); + let state = root.path().join("state"); + let other = root.path().join("other"); + std::fs::create_dir(&state).unwrap(); + std::fs::create_dir(&other).unwrap(); + let missing = parse_confirmed_existing_args(vec![ + "--state-dir".to_string(), + state.display().to_string(), + ]) + .unwrap_err(); + assert!(missing + .to_string() + .contains("--confirm-state-dir is required")); + + let mismatch = parse_confirmed_existing_args(vec![ + "--state-dir".to_string(), + state.display().to_string(), + "--confirm-state-dir".to_string(), + other.display().to_string(), + ]) + .unwrap_err(); + assert!(mismatch + .to_string() + .contains("confirmed state directory does not match")); + } + + #[test] + fn inspect_refuses_to_create_an_identity() { + let dir = tempfile::tempdir().unwrap(); + let error = match load_existing_identity(dir.path(), false) { + Err(error) => error, + Ok(_) => panic!("inspection unexpectedly created an identity"), + }; + + assert!(error.to_string().contains("credential.id")); + assert!(!dir.path().join("credential.id").exists()); + assert!(!dir.path().join("root.key").exists()); + assert!(!dir.path().join("node.key").exists()); + } + + #[test] + fn insecure_inspect_requires_a_complete_existing_pair() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("root.key"), [0u8; 32]).unwrap(); + + let error = match load_existing_identity(dir.path(), true) { + Err(error) => error, + Ok(_) => panic!("inspection unexpectedly accepted an incomplete identity"), + }; + assert!(error.to_string().contains("root.key and node.key")); + assert!(!dir.path().join("node.key").exists()); + } + + fn insecure_args(command: &str, dir: &Path) -> Vec { + let mut args = vec![ + "--state-dir".to_string(), + dir.display().to_string(), + "--insecure-plaintext-keys".to_string(), + ]; + if matches!(command, "initialize" | "restore" | "reset") { + args.extend(["--confirm-state-dir".to_string(), dir.display().to_string()]); + } + if command == "reset" { + args.extend([ + "--confirm-reset".to_string(), + RESET_CONFIRMATION.to_string(), + ]); + } + args + } + + #[test] + fn initialize_creates_valid_state_and_an_audit_record() { + let dir = tempfile::tempdir().unwrap(); + initialize_empty(insecure_args("initialize", dir.path())).unwrap(); + + assert!(dir.path().join("state.redb").is_file()); + assert!(dir.path().join(AUDIT_LOG).is_file()); + inspect_state(insecure_args("inspect", dir.path())).unwrap(); + assert!(initialize_empty(insecure_args("initialize", dir.path())).is_err()); + } + + #[test] + fn reset_preserves_database_and_blob_data() { + let dir = tempfile::tempdir().unwrap(); + initialize_empty(insecure_args("initialize", dir.path())).unwrap(); + std::fs::create_dir(dir.path().join("blobs")).unwrap(); + std::fs::write(dir.path().join("blobs/retained"), b"ciphertext").unwrap(); + + reset_security_state(insecure_args("reset", dir.path())).unwrap(); + + let backup = dir.path().join("security-reset-backup"); + assert!(backup.join("state.redb").is_file()); + assert_eq!( + std::fs::read(backup.join("blobs/retained")).unwrap(), + b"ciphertext" + ); + assert!(dir.path().join("state.redb").is_file()); + inspect_state(insecure_args("inspect", dir.path())).unwrap(); + let audit = std::fs::read_to_string(dir.path().join(AUDIT_LOG)).unwrap(); + assert!(audit.contains("reset-security-state-start")); + assert!(audit.contains("\treset-security-state\t")); + } + + #[test] + fn reset_requires_the_exact_confirmation_phrase() { + let dir = tempfile::tempdir().unwrap(); + let mut args = insecure_args("restore", dir.path()); + args.extend(["--confirm-reset".to_string(), "yes".to_string()]); + + let error = parse_reset_args(args).unwrap_err(); + assert!(error.to_string().contains(RESET_CONFIRMATION)); + } + + #[test] + fn restore_validates_before_it_replaces_current_state() { + let dir = tempfile::tempdir().unwrap(); + initialize_empty(insecure_args("initialize", dir.path())).unwrap(); + let backup = dir.path().join("external-backup.redb"); + std::fs::copy(dir.path().join("state.redb"), &backup).unwrap(); + let mut args = insecure_args("restore", dir.path()); + args.extend(["--backup".to_string(), backup.display().to_string()]); + + restore_backup(args).unwrap(); + + assert!(dir.path().join("state.redb.before-restore").is_file()); + inspect_state(insecure_args("inspect", dir.path())).unwrap(); + assert!(std::fs::read_to_string(dir.path().join(AUDIT_LOG)) + .unwrap() + .contains("\trestore-backup\t")); + } + + #[test] + fn restore_and_reset_interruption_leave_start_without_completion() { + let restore_dir = tempfile::tempdir().unwrap(); + initialize_empty(insecure_args("initialize", restore_dir.path())).unwrap(); + let backup = restore_dir.path().join("external-backup.redb"); + std::fs::copy(restore_dir.path().join("state.redb"), &backup).unwrap(); + std::fs::write( + restore_dir.path().join("state.redb.restore-staged"), + b"stop", + ) + .unwrap(); + let mut restore_args = insecure_args("restore", restore_dir.path()); + restore_args.extend(["--backup".to_string(), backup.display().to_string()]); + assert!(restore_backup(restore_args).is_err()); + let restore_audit = std::fs::read_to_string(restore_dir.path().join(AUDIT_LOG)).unwrap(); + assert!(restore_audit.contains("restore-backup-start")); + assert!(!restore_audit.contains("\trestore-backup\t")); + + let reset_dir = tempfile::tempdir().unwrap(); + initialize_empty(insecure_args("initialize", reset_dir.path())).unwrap(); + std::fs::create_dir(reset_dir.path().join("security-reset-backup")).unwrap(); + assert!(reset_security_state(insecure_args("reset", reset_dir.path())).is_err()); + let reset_audit = std::fs::read_to_string(reset_dir.path().join(AUDIT_LOG)).unwrap(); + assert!(reset_audit.contains("reset-security-state-start")); + assert!(!reset_audit.contains("\treset-security-state\t")); + } +} diff --git a/crates/carapace-api/src/claimant.rs b/crates/carapace-api/src/claimant.rs new file mode 100644 index 0000000..c985639 --- /dev/null +++ b/crates/carapace-api/src/claimant.rs @@ -0,0 +1,625 @@ +//! A separate loopback API for a device that does not yet have an identity. +//! +//! This router cannot call normal daemon handlers because its state has no `Daemon`. +//! The process owns the claimant ceremony key and node seed. The browser receives only +//! their public values. On completion, the process reconstructs `K_root` in memory and +//! sends it directly to the operating-system credential-store activation boundary. + +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + sync::Arc, +}; + +use anyhow::{Context, Result}; +use axum::{ + extract::State, + http::{header, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use carapace_wire::{messages::Message as _, messages::Signed as _, RecoveryOpen}; +use carapaced::{activate_recovered_identity, ClaimantDevice}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::sync::Mutex; + +#[derive(rust_embed::RustEmbed)] +#[folder = "static/"] +struct ClaimantAssets; + +/// State for the claimant-only server. It deliberately has no normal daemon. +#[derive(Clone)] +pub struct ClaimantState { + pub(crate) claimant: Arc>>, + pub(crate) state_dir: Arc, + pub(crate) token: Arc, +} + +impl ClaimantState { + pub(crate) fn new(state_dir: PathBuf, token: Arc) -> Result { + Ok(Self { + claimant: Arc::new(Mutex::new(Some(ClaimantDevice::new()?))), + state_dir: Arc::new(state_dir), + token, + }) + } +} + +#[derive(Clone, Deserialize, Serialize)] +pub(crate) struct TrusteeAddress { + node: String, + #[serde(default)] + addrs: Vec, +} + +#[derive(Clone, Deserialize, Serialize)] +pub(crate) struct RecoveryAnnounceRef { + vid: String, + epoch: u64, + digest: String, +} + +#[derive(Deserialize)] +pub(crate) struct CompleteRequest { + open_hex: String, + confirmed_subject: String, + roster: Vec, + trustees: Vec, + #[serde(default)] + announce_refs: Vec, +} + +#[derive(Deserialize)] +pub(crate) struct PreviewRequest { + open_hex: String, +} + +fn bad(message: impl Into) -> ClaimantError { + ClaimantError(StatusCode::BAD_REQUEST, message.into()) +} + +#[derive(Debug)] +pub(crate) struct ClaimantError(StatusCode, String); + +impl IntoResponse for ClaimantError { + fn into_response(self) -> Response { + crate::handlers::error_response(self.0, self.1) + } +} + +/// Serve the claimant-only shell. The token is a fixed-length hexadecimal value. +pub(crate) async fn shell(State(state): State) -> Response { + let Some(asset) = ClaimantAssets::get("claimant.html") else { + return StatusCode::NOT_FOUND.into_response(); + }; + let html = + String::from_utf8_lossy(&asset.data).replace("__CARAPACE_CLAIMANT_TOKEN__", &state.token); + ( + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + (header::CACHE_CONTROL, "no-store"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ( + header::CONTENT_SECURITY_POLICY, + "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + ), + ], + html, + ) + .into_response() +} + +/// Serve one fixed claimant asset. No normal GUI asset fallback is available here. +pub(crate) async fn asset(path: &'static str, content_type: &'static str) -> Response { + let Some(asset) = ClaimantAssets::get(path) else { + return StatusCode::NOT_FOUND.into_response(); + }; + ( + [ + (header::CONTENT_TYPE, content_type), + (header::CACHE_CONTROL, "no-store"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + asset.data.into_owned(), + ) + .into_response() +} + +pub(crate) async fn script() -> Response { + asset("claimant.js", "text/javascript; charset=utf-8").await +} + +pub(crate) async fn stylesheet() -> Response { + asset("claimant.css", "text/css; charset=utf-8").await +} + +/// Return the public inputs that a sponsor must put in `RecoveryOpen`. +pub(crate) async fn status(State(state): State) -> Json { + let guard = state.claimant.lock().await; + match guard.as_ref() { + Some(claimant) => { + let ceremony_enc = hex::encode(claimant.ceremony_enc()); + let new_node = hex::encode(claimant.new_node()); + let handoff = json!({ + "type": "carapace.claimant-handoff", + "version": 1, + "ceremony_enc": ceremony_enc.clone(), + "new_node": new_node.clone(), + }) + .to_string(); + Json(json!({ + "phase": "waiting_for_open", + "handoff": handoff, + "ceremony_enc": ceremony_enc, + "new_node": new_node, + })) + } + None => Json(json!({ + "phase": "activation_complete", + "restart_required": true, + })), + } +} + +/// Cancel the current attempt, drop its secret keys, and create a fresh retry session. +pub(crate) async fn cancel( + State(state): State, +) -> Result, ClaimantError> { + let fresh = ClaimantDevice::new().map_err(|_| { + ClaimantError( + StatusCode::INTERNAL_SERVER_ERROR, + "could not create a fresh claimant session".into(), + ) + })?; + let old = state.claimant.lock().await.replace(fresh); + drop(old); + crate::ops::log("claimant.cancelled", None); + Ok(Json(json!({ + "phase": "waiting_for_open", + "cancelled": true, + "retry_ready": true, + }))) +} + +fn decode_open(open_hex: &str) -> Result { + let open_bytes = + hex::decode(open_hex).map_err(|error| bad(format!("invalid open hex: {error}")))?; + let open = RecoveryOpen::decode_frame(&open_bytes) + .map_err(|error| bad(format!("invalid recovery open: {error}")))?; + open.verify() + .map_err(|_| bad("the recovery open signature is invalid"))?; + Ok(open) +} + +/// Verify a signed open and show its subject before any share collection or activation. +pub(crate) async fn preview( + State(state): State, + Json(request): Json, +) -> Result, ClaimantError> { + let open = decode_open(&request.open_hex)?; + let guard = state.claimant.lock().await; + let claimant = guard.as_ref().ok_or_else(|| { + ClaimantError( + StatusCode::CONFLICT, + "claimant activation is complete".into(), + ) + })?; + if open.ceremony_enc != claimant.ceremony_enc() || open.new_node != claimant.new_node() { + return Err(bad("the recovery open does not name this claimant session")); + } + Ok(Json(json!({ + "subject": hex::encode(open.subject), + "sponsor": hex::encode(open.by), + "claimant_display": open.claimant_display, + "reason": open.reason, + "session_bound": true, + }))) +} + +/// Collect sealed shares, reconstruct in memory, and activate secure local state. +pub(crate) async fn complete( + State(state): State, + Json(request): Json, +) -> Result, ClaimantError> { + let open = decode_open(&request.open_hex)?; + let confirmed_subject = decode_32("confirmed subject", &request.confirmed_subject)?; + if confirmed_subject != open.subject { + return Err(bad( + "the confirmed subject does not match the signed recovery open", + )); + } + let roster = request + .roster + .iter() + .map(|value| decode_32("roster user", value)) + .collect::, _>>()?; + let trustees = request + .trustees + .iter() + .map(|trustee| { + Ok(( + decode_32("trustee node", &trustee.node)?, + trustee.addrs.clone(), + )) + }) + .collect::, ClaimantError>>()?; + if roster.is_empty() { + return Err(bad("the trustee roster is empty")); + } + if !roster.contains(&open.by) { + return Err(bad("the recovery-open signer is not in the trustee roster")); + } + if trustees.is_empty() { + return Err(bad("the trustee address list is empty")); + } + + // Take the claimant out during the network operation. This prevents two requests + // from collecting and activating the same ceremony at the same time. + let claimant = state.claimant.lock().await.take().ok_or_else(|| { + ClaimantError( + StatusCode::CONFLICT, + "claimant activation is already complete or active".into(), + ) + })?; + + if open.ceremony_enc != claimant.ceremony_enc() || open.new_node != claimant.new_node() { + *state.claimant.lock().await = Some(claimant); + return Err(bad("the recovery open does not name this claimant session")); + } + + let recovered = match claimant.recover_at(&open, &roster, &trustees).await { + Ok(recovered) => recovered, + Err(error) => { + let _ = error; + crate::ops::log("claimant.recovery_failed", None); + *state.claimant.lock().await = Some(claimant); + return Err(ClaimantError( + StatusCode::BAD_GATEWAY, + "recovery did not complete".into(), + )); + } + }; + if recovered.user_id != open.subject || recovered.new_node != open.new_node { + *state.claimant.lock().await = Some(claimant); + return Err(bad( + "the recovered identity does not match the recovery open", + )); + } + + let user_id = recovered.user_id; + let new_node = recovered.new_node; + let node_seed = claimant.node_seed(); + if persist_restart_handoff(&state.state_dir, &request.trustees, &request.announce_refs).is_err() + { + *state.claimant.lock().await = Some(claimant); + return Err(ClaimantError( + StatusCode::INTERNAL_SERVER_ERROR, + "the restart recovery handoff could not be saved".into(), + )); + } + let activated = + match activate_recovered_identity(&state.state_dir, node_seed, *recovered.k_root) + .context("activate recovered identity") + { + Ok(activated) => activated, + Err(error) => { + let _ = error; + let _ = fs::remove_file(state.state_dir.join("recovery-restart-handoff.json")); + crate::ops::log("claimant.activation_failed", None); + *state.claimant.lock().await = Some(claimant); + return Err(ClaimantError( + StatusCode::INTERNAL_SERVER_ERROR, + "recovered identity activation failed".into(), + )); + } + }; + drop(activated); + drop(recovered); + drop(claimant); + + Ok(Json(json!({ + "phase": "activation_complete", + "user_id": hex::encode(user_id), + "new_node": hex::encode(new_node), + "restart_required": true, + }))) +} + +#[derive(Serialize)] +struct RestartHandoff<'a> { + r#type: &'static str, + version: u8, + trustees: &'a [TrusteeAddress], + announce_refs: &'a [RecoveryAnnounceRef], +} + +fn persist_restart_handoff( + state_dir: &Path, + trustees: &[TrusteeAddress], + announce_refs: &[RecoveryAnnounceRef], +) -> Result<()> { + fs::create_dir_all(state_dir)?; + let path = state_dir.join("recovery-restart-handoff.json"); + let temporary = state_dir.join(".recovery-restart-handoff.tmp"); + let bytes = serde_json::to_vec(&RestartHandoff { + r#type: "carapace.recovery-restart-handoff", + version: 1, + trustees, + announce_refs, + })?; + let mut options = fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temporary)?; + file.write_all(&bytes)?; + file.sync_all()?; + fs::rename(temporary, path)?; + Ok(()) +} + +fn decode_32(label: &str, value: &str) -> Result<[u8; 32], ClaimantError> { + let bytes = hex::decode(value).map_err(|error| bad(format!("invalid {label} hex: {error}")))?; + bytes + .try_into() + .map_err(|_| bad(format!("{label} must contain 32 bytes"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::to_bytes, http::Request}; + use ed25519_dalek::SigningKey; + use tower::ServiceExt; + + fn test_state(dir: PathBuf) -> ClaimantState { + ClaimantState::new(dir, Arc::from("test-token")).unwrap() + } + + #[tokio::test] + async fn status_exposes_only_public_claimant_values() { + let dir = tempfile::tempdir().unwrap(); + let Json(value) = status(State(test_state(dir.path().to_path_buf()))).await; + let object = value.as_object().unwrap(); + assert_eq!(object.get("phase").unwrap(), "waiting_for_open"); + assert_eq!( + object.get("ceremony_enc").unwrap().as_str().unwrap().len(), + 64 + ); + assert_eq!(object.get("new_node").unwrap().as_str().unwrap().len(), 64); + let handoff: Value = serde_json::from_str(object["handoff"].as_str().unwrap()).unwrap(); + assert_eq!(handoff["type"], "carapace.claimant-handoff"); + assert_eq!(handoff["version"], 1); + assert_eq!(handoff["ceremony_enc"], object["ceremony_enc"]); + assert_eq!(handoff["new_node"], object["new_node"]); + for secret_name in ["k_root", "node_seed", "ceremony_private", "shares"] { + assert!( + !object.contains_key(secret_name), + "status exposed {secret_name}" + ); + } + } + + #[tokio::test] + async fn complete_rejects_an_open_for_another_claimant_before_network_access() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path().to_path_buf()); + let sponsor = SigningKey::from_bytes(&[5; 32]); + let mut open = RecoveryOpen { + ceremony_id: [1; 16], + subject: [2; 32], + rsid: 1, + claimant_display: "Test claimant".into(), + ceremony_enc: [3; 32], + new_node: [4; 32], + reason: "Test recovery".into(), + opened_at: 1, + by: [0; 32], + sig: [0; 64], + }; + open.sign(&sponsor); + let request = CompleteRequest { + open_hex: hex::encode(open.encode_frame()), + confirmed_subject: hex::encode(open.subject), + roster: vec![hex::encode(sponsor.verifying_key().to_bytes())], + trustees: vec![TrusteeAddress { + node: hex::encode([6; 32]), + addrs: Vec::new(), + }], + announce_refs: Vec::new(), + }; + let error = complete(State(state.clone()), Json(request)) + .await + .unwrap_err(); + assert_eq!(error.0, StatusCode::BAD_REQUEST); + assert!(error.1.contains("does not name this claimant session")); + assert!( + state.claimant.lock().await.is_some(), + "claimant must remain usable" + ); + } + + #[tokio::test] + async fn complete_rejects_an_invalid_open_signature_before_network_access() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path().to_path_buf()); + let (ceremony_enc, new_node) = { + let guard = state.claimant.lock().await; + let claimant = guard.as_ref().unwrap(); + (claimant.ceremony_enc(), claimant.new_node()) + }; + let sponsor = SigningKey::from_bytes(&[7; 32]); + let open = RecoveryOpen { + ceremony_id: [1; 16], + subject: [2; 32], + rsid: 1, + claimant_display: "Test claimant".into(), + ceremony_enc, + new_node, + reason: "Test recovery".into(), + opened_at: 1, + by: sponsor.verifying_key().to_bytes(), + sig: [0; 64], + }; + let request = CompleteRequest { + open_hex: hex::encode(open.encode_frame()), + confirmed_subject: hex::encode(open.subject), + roster: vec![hex::encode(sponsor.verifying_key().to_bytes())], + trustees: vec![TrusteeAddress { + node: hex::encode([6; 32]), + addrs: Vec::new(), + }], + announce_refs: Vec::new(), + }; + let error = complete(State(state.clone()), Json(request)) + .await + .unwrap_err(); + assert_eq!(error.0, StatusCode::BAD_REQUEST); + assert_eq!(error.1, "the recovery open signature is invalid"); + assert!(state.claimant.lock().await.is_some()); + } + + #[tokio::test] + async fn preview_shows_the_signed_subject_and_complete_requires_that_confirmation() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path().to_path_buf()); + let (ceremony_enc, new_node) = { + let guard = state.claimant.lock().await; + let claimant = guard.as_ref().unwrap(); + (claimant.ceremony_enc(), claimant.new_node()) + }; + let sponsor = SigningKey::from_bytes(&[8; 32]); + let mut open = RecoveryOpen { + ceremony_id: [1; 16], + subject: [9; 32], + rsid: 1, + claimant_display: "Test claimant".into(), + ceremony_enc, + new_node, + reason: "Test recovery".into(), + opened_at: 1, + by: [0; 32], + sig: [0; 64], + }; + open.sign(&sponsor); + let open_hex = hex::encode(open.encode_frame()); + let Json(value) = preview( + State(state.clone()), + Json(PreviewRequest { + open_hex: open_hex.clone(), + }), + ) + .await + .unwrap(); + assert_eq!(value["subject"], hex::encode(open.subject)); + assert_eq!(value["session_bound"], true); + + let error = complete( + State(state.clone()), + Json(CompleteRequest { + open_hex, + confirmed_subject: hex::encode([7; 32]), + roster: vec![hex::encode(sponsor.verifying_key().to_bytes())], + trustees: vec![TrusteeAddress { + node: hex::encode([6; 32]), + addrs: Vec::new(), + }], + announce_refs: Vec::new(), + }), + ) + .await + .unwrap_err(); + assert_eq!(error.0, StatusCode::BAD_REQUEST); + assert!(error.1.contains("confirmed subject does not match")); + assert!(state.claimant.lock().await.is_some()); + } + + #[tokio::test] + async fn claimant_router_serves_only_the_claimant_shell_and_api() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path().to_path_buf()); + let app = crate::claimant_app(state); + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/") + .header(header::HOST, "127.0.0.1") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + let body = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("Claimant mode")); + assert!(html.contains("carapace-claimant-token")); + for forbidden in ["K_root", "node_seed", "ceremony_private", "share_json"] { + assert!( + !html.contains(forbidden), + "claimant shell exposed {forbidden}" + ); + } + + let response = app + .oneshot( + Request::builder() + .uri("/api/status") + .header(header::HOST, "127.0.0.1") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn cancellation_replaces_keys_and_leaves_a_safe_retry_session() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path().to_path_buf()); + let before = state.claimant.lock().await.as_ref().unwrap().ceremony_enc(); + let Json(result) = cancel(State(state.clone())).await.unwrap(); + let after = state.claimant.lock().await.as_ref().unwrap().ceremony_enc(); + assert_ne!(before, after); + assert_eq!(result["cancelled"], true); + assert_eq!(result["retry_ready"], true); + } + + #[test] + fn restart_handoff_is_public_versioned_and_private_on_unix() { + let dir = tempfile::tempdir().unwrap(); + let trustees = vec![TrusteeAddress { + node: hex::encode([3; 32]), + addrs: vec!["127.0.0.1:9000".into()], + }]; + let refs = vec![RecoveryAnnounceRef { + vid: hex::encode([4; 32]), + epoch: 7, + digest: hex::encode([5; 32]), + }]; + persist_restart_handoff(dir.path(), &trustees, &refs).unwrap(); + let path = dir.path().join("recovery-restart-handoff.json"); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("carapace.recovery-restart-handoff")); + assert!(text.contains("\"epoch\":7")); + for secret in ["k_root", "node_seed", "share_json", "ceremony_private"] { + assert!(!text.contains(secret)); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } +} diff --git a/crates/carapace-api/src/handlers.rs b/crates/carapace-api/src/handlers.rs index 5f58cd6..10e3c8e 100644 --- a/crates/carapace-api/src/handlers.rs +++ b/crates/carapace-api/src/handlers.rs @@ -8,17 +8,18 @@ use std::time::Duration; use axum::{ extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, - Path, Query, State, + Path, State, }, - http::{header, StatusCode, Uri}, + http::{header, HeaderMap, StatusCode, Uri}, response::{IntoResponse, Response}, Json, }; use carapace_wire::messages::Message as _; -use carapace_wire::{FileGrant, InviteTicket}; +use carapace_wire::{AnnounceRef, FileGrant, InviteTicket}; use carapaced::{Daemon, PendingResplitStatus, RecoveryScope, ResplitStatus}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::sync::atomic::{AtomicU64, Ordering}; use crate::{auth, AppState}; @@ -40,7 +41,7 @@ pub async fn static_asset(State(st): State, uri: Uri) -> Response { // An unmatched `/api/*` path is a missing endpoint, not a client route: 404 JSON, // never the token-injected shell. Only non-`/api` paths get the SPA fallback. if path == "api" || path.starts_with("api/") { - return (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(); + return error_response(StatusCode::NOT_FOUND, "not found"); } // `index.html` must always route through injection, never be served raw. if path.is_empty() || path == "index.html" { @@ -120,12 +121,14 @@ fn serve_index(token: &str) -> Response { base-uri 'none'; \ frame-ancestors 'none'" ); + let cookie = format!("carapace_session={token}; HttpOnly; SameSite=Strict; Path=/api/events"); ( [ (header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CONTENT_SECURITY_POLICY, csp.as_str()), (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), (header::CACHE_CONTROL, "no-store"), + (header::SET_COOKIE, cookie.as_str()), ], rendered, ) @@ -134,24 +137,92 @@ fn serve_index(token: &str) -> Response { // ---- error type -------------------------------------------------------- +/// Sequence for references that connect a safe client error to the server log. +static ERROR_REFERENCE: AtomicU64 = AtomicU64::new(1); + /// A handler error rendered as a JSON body with an HTTP status. -pub struct ApiError(StatusCode, String); +pub struct ApiError { + status: StatusCode, + client_message: String, + internal: Option, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ApiErrorCode { + BadRequest, + Unauthorized, + Forbidden, + NotFound, + Conflict, + UpstreamFailure, + Internal, +} + +#[derive(Serialize)] +pub(crate) struct ApiErrorBody { + pub(crate) code: ApiErrorCode, + pub(crate) error: String, +} + +pub(crate) fn error_response(status: StatusCode, message: impl Into) -> Response { + let code = match status { + StatusCode::BAD_REQUEST => ApiErrorCode::BadRequest, + StatusCode::UNAUTHORIZED => ApiErrorCode::Unauthorized, + StatusCode::FORBIDDEN => ApiErrorCode::Forbidden, + StatusCode::NOT_FOUND => ApiErrorCode::NotFound, + StatusCode::CONFLICT => ApiErrorCode::Conflict, + StatusCode::BAD_GATEWAY | StatusCode::SERVICE_UNAVAILABLE => ApiErrorCode::UpstreamFailure, + _ => ApiErrorCode::Internal, + }; + ( + status, + Json(ApiErrorBody { + code, + error: message.into(), + }), + ) + .into_response() +} + +impl ApiError { + fn client(status: StatusCode, message: impl Into) -> Self { + Self { + status, + client_message: message.into(), + internal: None, + } + } +} impl IntoResponse for ApiError { fn into_response(self) -> Response { - (self.0, Json(json!({ "error": self.1 }))).into_response() + let message = match self.internal { + Some(detail) => { + let reference = ERROR_REFERENCE.fetch_add(1, Ordering::Relaxed); + let _ = detail; + crate::ops::log("api.internal_error", Some(reference)); + format!("internal server error; reference {reference}") + } + None => self.client_message, + }; + error_response(self.status, message) } } impl From for ApiError { fn from(e: anyhow::Error) -> Self { - ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")) + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + client_message: "internal server error".to_string(), + internal: Some(format!("{e:#}")), + } } } /// A 400 from a bad-input message. fn bad(msg: impl Into) -> ApiError { - ApiError(StatusCode::BAD_REQUEST, msg.into()) + ApiError::client(StatusCode::BAD_REQUEST, msg) } fn hexs(b: &[u8]) -> String { @@ -184,47 +255,256 @@ pub async fn health() -> Json { Json(json!({ "ok": true })) } +#[derive(Clone, Debug, Serialize)] +pub struct PeerOptionResponse { + user: String, + display: String, + node: String, + addrs: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PublishedVaultResponse { + vid: String, + epoch: u64, + name: String, +} + +#[derive(Clone, Debug, Serialize)] +struct FriendGrantResponse { + user: String, + grant_bytes: u64, +} +#[derive(Clone, Debug, Serialize)] +struct FriendsResponse { + count: usize, + list: Vec, + grants: Vec, +} +#[derive(Clone, Debug, Serialize)] +struct VaultsResponse { + published: Vec, + held_replicas: Vec, +} +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum RecoveryScopeResponse { + Root, + Vault { vid: String }, +} +#[derive(Clone, Debug, Serialize)] +struct TrusteeDeliveryResponse { + user: String, + delivered: bool, +} +#[derive(Clone, Debug, Serialize)] +struct RecoverySetResponse { + rsid: u64, + scope: RecoveryScopeResponse, + threshold: usize, + issued: usize, + trustees: Vec, + warnings: Vec, +} +#[derive(Clone, Debug, Serialize)] +struct RecoveryHealthResponse { + rsid: u64, + live: usize, + target: usize, + recommendation: String, + needed: usize, +} +#[derive(Clone, Debug, Serialize)] +struct ShareHealthResponse { + recovery_sets_owned: usize, + shares_held: usize, + sets: Vec, + recovery: Vec, +} +#[derive(Clone, Debug, Serialize)] +struct AnnounceRefResponse { + vid: String, + epoch: u64, +} +#[derive(Clone, Debug, Serialize)] +struct MintedGrantResponse { + rsid: u64, + subject: String, + trustees: Vec, + refs: Vec, +} +#[derive(Clone, Debug, Serialize)] +struct RecoveryGrantsResponse { + minted: Vec, + held: Vec, +} +#[derive(Clone, Debug, Serialize)] +struct CeremonyResponse { + ceremony_id: String, + subject: String, + sponsor: String, + claimant_display: String, + reason: String, + phase: String, + approvals: usize, + threshold: usize, + is_self_subject: bool, + takeover: bool, + trustee: bool, + approved: bool, + alarm: bool, +} +#[derive(Clone, Debug, Serialize)] +struct ResplitFriendResponse { + node: String, + role: String, + online: bool, + done: bool, + status: &'static str, +} +#[derive(Clone, Debug, Serialize)] +pub(crate) struct ResplitResponse { + old_rsid: u64, + new_rsid: u64, + ex_trustee: String, + phase: String, + new_attested: usize, + new_total: usize, + new_set_live: bool, + old_destroyed: usize, + old_total: usize, + remaining: Vec, +} +#[derive(Clone, Debug, Serialize)] +struct PendingTrusteeResponse { + user: String, + node: Option, + online: bool, +} +#[derive(Clone, Debug, Serialize)] +struct PendingResplitResponse { + old_rsid: u64, + ex_trustee: String, + suggested: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct StatusSnapshot { + node_id: String, + addr: Vec, + relay_url: Option, + friends: FriendsResponse, + peers: Vec, + vaults: VaultsResponse, + share_health: ShareHealthResponse, + recovery_grants: RecoveryGrantsResponse, + ceremonies: Vec, + resplits: Vec, + pending_resplits: Vec, + reachability: &'static str, + relay_networks: usize, + relay_diversity_warning: bool, + por_latency_anomaly_count: usize, +} + /// Build the status snapshot pushed over WS and returned by `GET /api/status`. -fn status_snapshot(d: &Daemon) -> Value { +fn status_snapshot(d: &Daemon) -> StatusSnapshot { let friends: Vec = d.friend_ids().iter().map(|f| hexs(f)).collect(); - let vaults: Vec = d - .published_vaults() + let friend_grants: Vec = d + .friend_grants() .iter() - .map(|(v, e)| json!({ "vid": hexs(v), "epoch": e })) + .map(|report| FriendGrantResponse { + user: hexs(&report.user), + grant_bytes: report.grant_bytes, + }) + .collect(); + let vaults: Vec = d + .vault_options() + .iter() + .map(|vault| PublishedVaultResponse { + vid: hexs(&vault.vid), + epoch: vault.epoch, + name: vault.name.clone(), + }) + .collect(); + let peers = d + .peer_options() + .iter() + .map(|peer| PeerOptionResponse { + user: hexs(&peer.user), + display: peer.display.clone(), + node: hexs(&peer.node), + addrs: peer.addrs.clone(), + }) .collect(); let held: Vec = d.held_replica_vids().iter().map(|v| hexs(v)).collect(); let (sets, shares) = d.share_health_counts(); + let recovery_sets: Vec = d + .recovery_sets() + .iter() + .map(|set| { + let scope = match set.scope { + RecoveryScope::Root => RecoveryScopeResponse::Root, + RecoveryScope::Vault(vid) => RecoveryScopeResponse::Vault { vid: hexs(&vid) }, + }; + RecoverySetResponse { + rsid: set.rsid, + scope, + threshold: set.threshold, + issued: set.issued, + trustees: set + .trustees + .iter() + .map(|(user, delivered)| TrusteeDeliveryResponse { + user: hexs(user), + delivered: *delivered, + }) + .collect(), + warnings: set + .warnings + .iter() + .map(|warning| format!("{warning:?}")) + .collect(), + } + }) + .collect(); // W4 (§10.2): per owned recovery set, the attested-live count, target, and drift // recommendation (healthy / extend / resplit) the maintenance loop keeps current. - let recovery: Vec = d + let recovery: Vec = d .recovery_health() .iter() - .map(|r| { - json!({ - "rsid": r.rsid, - "live": r.live, - "target": r.target, - "recommendation": r.recommendation, - "needed": r.needed, - }) + .map(|r| RecoveryHealthResponse { + rsid: r.rsid, + live: r.live, + target: r.target, + recommendation: r.recommendation.to_owned(), + needed: r.needed, }) .collect(); // W3 (§8, §7.3): per owned recovery set, which trustees hold a minted ShareGrant // and the announce-ref freshness (vid + epoch) the maintenance loop keeps current. - let grants: Vec = d + let grants: Vec = d .recovery_grants() .iter() - .map(|g| { - json!({ - "rsid": g.rsid, - "subject": hexs(&g.subject), - "trustees": g.trustees.iter() - .map(|(u, delivered)| json!({ "user": hexs(u), "delivered": delivered })) - .collect::>(), - "refs": g.refs.iter() - .map(|(vid, epoch)| json!({ "vid": hexs(vid), "epoch": epoch })) - .collect::>(), - }) + .map(|g| MintedGrantResponse { + rsid: g.rsid, + subject: hexs(&g.subject), + trustees: g + .trustees + .iter() + .map(|(u, delivered)| TrusteeDeliveryResponse { + user: hexs(u), + delivered: *delivered, + }) + .collect(), + refs: g + .refs + .iter() + .map(|(vid, epoch)| AnnounceRefResponse { + vid: hexs(vid), + epoch: *epoch, + }) + .collect(), }) .collect(); // W3 trustee side: subject users whose grants this daemon holds for others. @@ -233,71 +513,254 @@ fn status_snapshot(d: &Daemon) -> Value { // W4 (§6 MUST): warn when the usable relay set spans fewer than 2 distinct // networks - a single relay is a single point of failure and metadata choke. let relay_networks = d.relay_network_count(); - json!({ - "node_id": hexs(&d.node_id()), - "addr": d.dialable_addr_strings(), - "relay_url": relay_url, - "friends": { "count": friends.len(), "list": friends }, - "vaults": { "published": vaults, "held_replicas": held }, - "share_health": { "recovery_sets_owned": sets, "shares_held": shares, "recovery": recovery }, - "recovery_grants": { "minted": grants, "held": held_grants }, - // W2 (§8.5): live recovery ceremonies + the anti-silent-takeover alarm. - "ceremonies": ceremony_rows(d), - // W5 (§9.3 step 4): open trustee re-splits after an unfriend, with the live - // reachability of the remaining friends who get the new share / destroy step. - "resplits": d.resplit_statuses().iter().map(resplit_json).collect::>(), - // §9.3.4: re-splits detected on unfriend but awaiting the user's prompt to start. - "pending_resplits": d.pending_resplit_statuses().iter().map(pending_resplit_json).collect::>(), - "reachability": if relay_url.is_some() { "relay" } else { "direct" }, - "relay_networks": relay_networks, - "relay_diversity_warning": relay_networks < 2, - }) + StatusSnapshot { + node_id: hexs(&d.node_id()), + addr: d.dialable_addr_strings(), + relay_url: relay_url.clone(), + friends: FriendsResponse { + count: friends.len(), + list: friends, + grants: friend_grants, + }, + peers, + vaults: VaultsResponse { + published: vaults, + held_replicas: held, + }, + share_health: ShareHealthResponse { + recovery_sets_owned: sets, + shares_held: shares, + sets: recovery_sets, + recovery, + }, + recovery_grants: RecoveryGrantsResponse { + minted: grants, + held: held_grants, + }, + ceremonies: ceremony_rows(d), + resplits: d.resplit_statuses().iter().map(resplit_json).collect(), + pending_resplits: d + .pending_resplit_statuses() + .iter() + .map(pending_resplit_json) + .collect(), + reachability: if relay_url.is_some() { + "relay" + } else { + "direct" + }, + relay_networks, + relay_diversity_warning: relay_networks < 2, + por_latency_anomaly_count: d.por_latency_anomaly_count(), + } } /// One re-split's §9.3 step-4 prompt surface as JSON: phase, new-set liveness gate, /// old-set destroy progress, and each remaining friend's online/queued status. -fn resplit_json(rs: &ResplitStatus) -> Value { - json!({ - "old_rsid": rs.old_rsid, - "new_rsid": rs.new_rsid, - "ex_trustee": hexs(&rs.ex_trustee), - "phase": rs.phase, - // New set going live is the destroy gate (>= M + slack attested). - "new_attested": rs.new_attested, - "new_total": rs.new_total, - "new_set_live": rs.new_live, - "old_destroyed": rs.old_destroyed, - "old_total": rs.old_total, - "remaining": rs.remaining.iter().map(|f| json!({ - "node": hexs(&f.node), - "role": f.role, - "online": f.online, - "done": f.done, - "status": if f.done { "done" } else if f.online { "online" } else { "will_queue" }, - })).collect::>(), - }) +fn resplit_json(rs: &ResplitStatus) -> ResplitResponse { + ResplitResponse { + old_rsid: rs.old_rsid, + new_rsid: rs.new_rsid, + ex_trustee: hexs(&rs.ex_trustee), + phase: rs.phase.to_owned(), + new_attested: rs.new_attested, + new_total: rs.new_total, + new_set_live: rs.new_live, + old_destroyed: rs.old_destroyed, + old_total: rs.old_total, + remaining: rs + .remaining + .iter() + .map(|f| ResplitFriendResponse { + node: hexs(&f.node), + role: f.role.to_owned(), + online: f.online, + done: f.done, + status: if f.done { + "done" + } else if f.online { + "online" + } else { + "will_queue" + }, + }) + .collect(), + } } /// One PENDING re-split's §9.3.4 prompt surface as JSON: the ex-trustee and the suggested /// new trustee set with each member's live reachability, so the GUI can render the prompt /// and pre-fill `POST /api/recovery/{rsid}/resplit-start`. -fn pending_resplit_json(p: &PendingResplitStatus) -> Value { - json!({ - "old_rsid": p.old_rsid, - "ex_trustee": hexs(&p.ex_trustee), - "suggested": p.suggested.iter().map(|t| json!({ - "user": hexs(&t.user), - "node": t.node.map(|n| hexs(&n)), - "online": t.online, - })).collect::>(), - }) +fn pending_resplit_json(p: &PendingResplitStatus) -> PendingResplitResponse { + PendingResplitResponse { + old_rsid: p.old_rsid, + ex_trustee: hexs(&p.ex_trustee), + suggested: p + .suggested + .iter() + .map(|t| PendingTrusteeResponse { + user: hexs(&t.user), + node: t.node.map(|n| hexs(&n)), + online: t.online, + }) + .collect(), + } } /// `GET /api/status`. -pub async fn status(State(st): State) -> Json { +pub async fn status(State(st): State) -> Json { Json(status_snapshot(&st.daemon)) } +const METRIC_COUNT_LIMIT: usize = 1_000_000; + +#[derive(Serialize)] +struct MetricLimits { + count_ceiling: usize, +} +#[derive(Serialize)] +struct StorageMetrics { + owned_vaults: u64, + held_replica_vaults: u64, + refetch_needed: u64, +} +#[derive(Serialize)] +struct ReplicaMetrics { + peer_capacity_count: u64, + held_count: u64, + assignments: u64, + granted_capacity_bytes: u64, +} +#[derive(Serialize)] +struct MaintenanceMetrics { + running: bool, + recovery_sets_checked: u64, + relay_networks: u64, + last_completed_at: u64, + last_failure_at: u64, + last_failure_count: u64, + consecutive_failed_rounds: u64, +} +#[derive(Serialize)] +struct GarbageCollectionMetrics { + last_succeeded: bool, + live_set_capacity: usize, +} +#[derive(Serialize)] +struct CeremonyMetrics { + active_or_retained: u64, + active_tracked: u64, + capacity_ceiling: u64, + per_subject_capacity: u64, + fanout_capacity: u64, + tombstones: u64, + tombstone_capacity: u64, + subject_rate_keys: u64, + sponsor_rate_keys: u64, + at_capacity: bool, + tombstones_at_capacity: bool, +} +#[derive(Serialize)] +struct MigrationMetrics { + required: bool, + state_schema_ready: bool, + legacy_migration_is_explicit: bool, +} +#[derive(Serialize)] +struct RecoveryMetrics { + owned_sets: u64, + held_shares: u64, + open_resplits: u64, + pending_resplits: u64, +} +#[derive(Serialize)] +pub struct MetricsSnapshot { + schema: u8, + limits: MetricLimits, + storage: StorageMetrics, + replicas: ReplicaMetrics, + maintenance: MaintenanceMetrics, + garbage_collection: GarbageCollectionMetrics, + ceremonies: CeremonyMetrics, + migrations: MigrationMetrics, + recovery: RecoveryMetrics, +} + +fn bounded_count(value: usize) -> u64 { + value.min(METRIC_COUNT_LIMIT) as u64 +} + +/// `GET /api/metrics`: authenticated, identity-free operational health and capacity. +pub async fn metrics(State(st): State) -> Json { + let published = st.daemon.published_vaults().len(); + let held_replicas = st.daemon.held_replica_vids().len(); + let friends = st.daemon.friend_ids().len(); + let (recovery_sets, held_shares) = st.daemon.share_health_counts(); + let recovery = st.daemon.recovery_health(); + let ceremonies = ceremony_rows(&st.daemon); + let resplits = st.daemon.resplit_statuses(); + let pending_resplits = st.daemon.pending_resplit_statuses(); + let capacity = st.daemon.operational_capacity(); + let history = st.daemon.operational_history(); + Json(MetricsSnapshot { + schema: 1, + limits: MetricLimits { + count_ceiling: METRIC_COUNT_LIMIT, + }, + storage: StorageMetrics { + owned_vaults: bounded_count(published), + held_replica_vaults: bounded_count(held_replicas), + refetch_needed: bounded_count(capacity.storage_refetch_needed), + }, + replicas: ReplicaMetrics { + peer_capacity_count: bounded_count(friends), + held_count: bounded_count(held_replicas), + assignments: bounded_count(capacity.replica_assignments), + granted_capacity_bytes: capacity.replica_grant_bytes, + }, + maintenance: MaintenanceMetrics { + running: true, + recovery_sets_checked: bounded_count(recovery.len()), + relay_networks: bounded_count(st.daemon.relay_network_count()), + last_completed_at: history.last_maintenance_at, + last_failure_at: history.last_failure_at, + last_failure_count: bounded_count(history.last_failure_count), + consecutive_failed_rounds: history + .consecutive_failed_rounds + .min(METRIC_COUNT_LIMIT as u64), + }, + garbage_collection: GarbageCollectionMetrics { + last_succeeded: history.last_gc_succeeded, + live_set_capacity: 1_000_000, + }, + ceremonies: CeremonyMetrics { + active_or_retained: bounded_count(ceremonies.len()), + active_tracked: bounded_count(capacity.ceremony_active), + capacity_ceiling: bounded_count(capacity.ceremony_capacity), + per_subject_capacity: bounded_count(capacity.ceremony_per_subject_capacity), + fanout_capacity: bounded_count(capacity.ceremony_fanout_capacity), + tombstones: bounded_count(capacity.ceremony_tombstones), + tombstone_capacity: bounded_count(capacity.ceremony_tombstone_capacity), + subject_rate_keys: bounded_count(capacity.ceremony_subject_rate_keys), + sponsor_rate_keys: bounded_count(capacity.ceremony_sponsor_rate_keys), + at_capacity: capacity.ceremony_active >= capacity.ceremony_capacity, + tombstones_at_capacity: capacity.ceremony_tombstones + >= capacity.ceremony_tombstone_capacity, + }, + migrations: MigrationMetrics { + required: false, + state_schema_ready: true, + legacy_migration_is_explicit: true, + }, + recovery: RecoveryMetrics { + owned_sets: bounded_count(recovery_sets), + held_shares: bounded_count(held_shares), + open_resplits: bounded_count(resplits.len()), + pending_resplits: bounded_count(pending_resplits.len()), + }, + }) +} + // ---- vaults ------------------------------------------------------------ #[derive(Deserialize)] @@ -407,10 +870,10 @@ pub async fn unfriend( pub async fn resplit_status( Path(rsid): Path, State(st): State, -) -> Result, ApiError> { +) -> Result, ApiError> { match st.daemon.resplit_status(rsid) { Some(rs) => Ok(Json(resplit_json(&rs))), - None => Err(ApiError( + None => Err(ApiError::client( StatusCode::NOT_FOUND, format!("no open re-split for recovery set {rsid}"), )), @@ -433,7 +896,7 @@ pub async fn recovery_paper( let html = st .daemon .paper_cards(rsid) - .map_err(|e| ApiError(StatusCode::NOT_FOUND, format!("{e:#}")))?; + .map_err(|e| ApiError::client(StatusCode::NOT_FOUND, format!("{e:#}")))?; Ok(( [ (header::CONTENT_TYPE, "text/html; charset=utf-8"), @@ -461,7 +924,7 @@ pub async fn resplit_start( Path(rsid): Path, State(st): State, body: Option>, -) -> Result, ApiError> { +) -> Result, ApiError> { let override_set = match body.and_then(|Json(b)| b.trustees) { Some(hexes) => { let mut users = Vec::with_capacity(hexes.len()); @@ -480,7 +943,7 @@ pub async fn resplit_start( .iter() .any(|p| p.old_rsid == rsid); if !tracked { - return Err(ApiError( + return Err(ApiError::client( StatusCode::NOT_FOUND, format!("no pending or open re-split for recovery set {rsid}"), )); @@ -501,6 +964,98 @@ pub struct PeerReq { addrs: Vec, } +#[derive(Deserialize)] +pub struct SyncReq { + peer: PeerReq, + out_dir: String, +} + +#[derive(Deserialize)] +struct RestartHandoff { + r#type: String, + version: u8, + trustees: Vec, + announce_refs: Vec, +} + +#[derive(Deserialize)] +struct RestartRef { + vid: String, + epoch: u64, + digest: String, +} + +#[derive(Deserialize)] +pub struct RestartRestoreReq { + out_dir: String, +} + +/// Restore retained vaults after claimant activation. The durable public handoff +/// limits results to the maximum signed-grant epoch for each vault. +pub async fn restart_restore( + State(st): State, + Json(req): Json, +) -> Result, ApiError> { + let path = st.state_dir.join("recovery-restart-handoff.json"); + let bytes = std::fs::read(&path).map_err(|_| { + ApiError::client( + StatusCode::NOT_FOUND, + "no claimant restart handoff is available", + ) + })?; + let handoff: RestartHandoff = + serde_json::from_slice(&bytes).map_err(|_| bad("invalid claimant restart handoff"))?; + if handoff.r#type != "carapace.recovery-restart-handoff" || handoff.version != 1 { + return Err(bad("unsupported claimant restart handoff")); + } + let mut refs = Vec::new(); + for reference in handoff.announce_refs { + let vid = parse_hex32(&reference.vid)?; + let digest = parse_hex32(&reference.digest)?; + refs.push(AnnounceRef { + vid, + epoch: reference.epoch, + digest, + }); + } + let mut trustees = Vec::new(); + for trustee in handoff.trustees { + trustees.push((parse_hex32(&trustee.node)?, trustee.addrs)); + } + let maximum_refs = carapaced::max_epoch_refs(&refs).len(); + let restored = st + .daemon + .recover_retained_at(&trustees, &refs, std::path::Path::new(&req.out_dir)) + .await?; + Ok(Json(json!({ + "restored": restored.iter().map(|vault| json!({ + "vid": hexs(&vault.vid), + "epoch": vault.epoch, + "out_dir": vault.out_dir.display().to_string(), + })).collect::>(), + "maximum_epoch_refs": maximum_refs, + }))) +} + +/// `POST /api/sync`: pull owned-vault documents and blobs from another authorized device. +pub async fn sync_owned( + State(st): State, + Json(req): Json, +) -> Result, ApiError> { + let node = parse_hex32(&req.peer.node)?; + let restored = st + .daemon + .sync_from_at(node, &req.peer.addrs, std::path::Path::new(&req.out_dir)) + .await?; + Ok(Json(json!({ + "restored": restored.iter().map(|v| json!({ + "vid": hexs(&v.vid), + "epoch": v.epoch, + "out_dir": v.out_dir.display().to_string(), + })).collect::>(), + }))) +} + #[derive(Deserialize)] pub struct PlaceReq { peers: Vec, @@ -766,10 +1321,38 @@ pub async fn ceremony_open( unix_now(), )?; let reached = st.daemon.ceremony_fanout(&open).await.unwrap_or(0); + let trustee_hints = st.daemon.claimant_trustee_hints(&subject)?; + let announce_refs = st.daemon.claimant_announce_refs(&subject)?; + let roster: Vec = trustee_hints + .iter() + .map(|trustee| hexs(&trustee.user)) + .collect(); + let trustees: Vec = trustee_hints + .iter() + .map(|trustee| { + json!({ + "node": hexs(&trustee.node), + "addrs": trustee.addrs, + }) + }) + .collect(); + let sponsor_package = json!({ + "type": "carapace.sponsor-ceremony", + "version": 1, + "open_hex": hexs(&open.encode_frame()), + "roster": roster, + "trustees": trustees, + "announce_refs": announce_refs.iter().map(|reference| json!({ + "vid": hexs(&reference.vid), + "epoch": reference.epoch, + "digest": hexs(&reference.digest), + })).collect::>(), + }); Ok(Json(json!({ "ceremony_id": hexs(&id), "open_hex": hexs(&open.encode_frame()), "fanout_reached": reached, + "sponsor_package": sponsor_package.to_string(), }))) } @@ -815,26 +1398,23 @@ pub async fn ceremony_abort( /// The recovery-ceremony status rows (§8.5 step 2/6) for the status surface: each /// ceremony this device has seen, its phase, approvals, and the alarm flags. -fn ceremony_rows(d: &Daemon) -> Vec { +fn ceremony_rows(d: &Daemon) -> Vec { d.ceremony_statuses() .iter() - .map(|c| { - json!({ - "ceremony_id": hexs(&c.ceremony_id), - "subject": hexs(&c.subject), - "sponsor": hexs(&c.sponsor), - "claimant_display": c.claimant_display, - "reason": c.reason, - "phase": c.phase, - "approvals": c.approvals, - "threshold": c.threshold, - "is_self_subject": c.is_self_subject, - "takeover": c.takeover, - "trustee": c.trustee, - "approved": c.approved, - // The anti-silent-takeover banner: a live ceremony against OUR account. - "alarm": c.is_self_subject && !c.takeover, - }) + .map(|c| CeremonyResponse { + ceremony_id: hexs(&c.ceremony_id), + subject: hexs(&c.subject), + sponsor: hexs(&c.sponsor), + claimant_display: c.claimant_display.clone(), + reason: c.reason.clone(), + phase: c.phase.to_owned(), + approvals: c.approvals, + threshold: c.threshold, + is_self_subject: c.is_self_subject, + takeover: c.takeover, + trustee: c.trustee, + approved: c.approved, + alarm: c.is_self_subject && !c.takeover, }) .collect() } @@ -846,22 +1426,25 @@ pub async fn ceremony_status(State(st): State) -> Json { // ---- events (WebSocket) ------------------------------------------------ -#[derive(Deserialize)] -pub struct TokenQuery { - token: Option, -} - -/// `GET /api/events` (WebSocket). Browsers cannot set an `Authorization` header on a -/// WS handshake, so the token is passed as the `token` query parameter and validated -/// in CONSTANT TIME before the upgrade. A missing/wrong token => 401, no upgrade. +/// `GET /api/events` (WebSocket). The browser sends the short-lived, HTTP-only session +/// cookie that the loopback shell sets. This keeps the bearer value out of URLs and logs. pub async fn events( ws: WebSocketUpgrade, - Query(q): Query, + headers: HeaderMap, State(st): State, ) -> Response { - let presented = q.token.unwrap_or_default(); - if !auth::ct_eq_str(&presented, &st.token) { - return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response(); + let presented = headers + .get(header::COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(|cookies| { + cookies.split(';').find_map(|cookie| { + let (name, value) = cookie.trim().split_once('=')?; + (name == "carapace_session").then_some(value) + }) + }) + .unwrap_or_default(); + if !auth::ct_eq_str(presented, &st.token) { + return error_response(StatusCode::UNAUTHORIZED, "missing or invalid token"); } ws.on_upgrade(move |socket| push_status(socket, st)) } @@ -870,11 +1453,81 @@ pub async fn events( /// closes. A periodic full snapshot is the v1 live-status feed (§ design doc). async fn push_status(mut socket: WebSocket, st: AppState) { let mut ticker = tokio::time::interval(Duration::from_secs(5)); - loop { - let snap = status_snapshot(&st.daemon).to_string(); + while let Ok(snap) = serde_json::to_string(&status_snapshot(&st.daemon)) { if socket.send(Message::Text(snap.into())).await.is_err() { break; } ticker.tick().await; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gui_status_contract_fixture_matches_typed_server_shape() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../gui/tests/fixtures/status-contract.json" + )) + .unwrap(); + let expected = serde_json::to_value(StatusSnapshot { + node_id: String::new(), + addr: Vec::new(), + relay_url: None, + friends: FriendsResponse { + count: 0, + list: Vec::new(), + grants: Vec::new(), + }, + peers: Vec::new(), + vaults: VaultsResponse { + published: Vec::new(), + held_replicas: Vec::new(), + }, + share_health: ShareHealthResponse { + recovery_sets_owned: 0, + shares_held: 0, + sets: Vec::new(), + recovery: Vec::new(), + }, + recovery_grants: RecoveryGrantsResponse { + minted: Vec::new(), + held: Vec::new(), + }, + ceremonies: Vec::new(), + resplits: Vec::new(), + pending_resplits: Vec::new(), + reachability: "direct", + relay_networks: 0, + relay_diversity_warning: true, + por_latency_anomaly_count: 0, + }) + .unwrap(); + assert_eq!(fixture, expected); + } + + #[test] + fn internal_error_detail_is_not_the_client_message() { + let error = ApiError::from(anyhow::anyhow!( + "secret path /private/state/root.key could not be read" + )); + + assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(error.client_message, "internal server error"); + assert!(error + .internal + .as_deref() + .is_some_and(|detail| detail.contains("/private/state/root.key"))); + assert!(!error.client_message.contains("root.key")); + } + + #[test] + fn expected_client_error_has_no_internal_detail() { + let error = bad("invalid recovery set"); + + assert_eq!(error.status, StatusCode::BAD_REQUEST); + assert_eq!(error.client_message, "invalid recovery set"); + assert!(error.internal.is_none()); + } +} diff --git a/crates/carapace-api/src/lib.rs b/crates/carapace-api/src/lib.rs index 21fee19..516f494 100644 --- a/crates/carapace-api/src/lib.rs +++ b/crates/carapace-api/src/lib.rs @@ -6,19 +6,20 @@ //! `Host` check (DNS-rebinding defense), and a loopback `Origin` check (CSRF //! defense). See [`auth`] for the guards and [`handlers`] for the endpoints. //! -//! Public routes (no token): `GET /api/health`, the WebSocket `GET /api/events` -//! (which validates the token from a query parameter instead, since browsers cannot -//! set an `Authorization` header on a WS handshake), and the embedded static GUI. +//! Public routes (no bearer header): `GET /api/health`, the WebSocket `GET /api/events` +//! (which validates an HTTP-only, same-site session cookie), and the embedded static GUI. //! //! The GUI is embedded with `rust-embed` from `static/` (the SvelteKit build). The //! served `index.html` gets the session token injected as `window.__CARAPACE_TOKEN__` //! under a strict per-response CSP nonce; see [`handlers::static_asset`]. mod auth; +mod claimant; mod handlers; +mod ops; use std::net::{Ipv4Addr, SocketAddr}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{ensure, Context, Result}; @@ -36,6 +37,8 @@ pub struct AppState { pub daemon: Arc, /// The per-session bearer token (hex of 32 CSPRNG bytes). pub token: Arc, + /// Local state directory for public restart-handoff metadata. + pub state_dir: Arc, } /// A running control API. Dropping or [`ApiServer::shutdown`] stops it. @@ -49,6 +52,28 @@ pub struct ApiServer { _maintenance: MaintenanceHandle, } +/// A running claimant-only API. It never owns a normal daemon. +pub struct ClaimantApiServer { + /// The per-session bearer token, also written to `/claimant-api-token`. + pub token: String, + /// The actual bound loopback address. + pub local_addr: SocketAddr, + handle: tokio::task::JoinHandle<()>, +} + +impl ClaimantApiServer { + /// The base URL for the claimant client. + #[must_use] + pub fn url(&self) -> String { + format!("http://{}", self.local_addr) + } + + /// Stop the claimant server. A completed activation needs a normal daemon restart. + pub fn shutdown(self) { + self.handle.abort(); + } +} + impl ApiServer { /// The base URL the GUI/clients hit. #[must_use] @@ -68,6 +93,12 @@ impl ApiServer { pub fn app(state: AppState) -> Router { let protected = Router::new() .route("/api/status", get(handlers::status)) + .route("/api/metrics", get(handlers::metrics)) + .route("/api/sync", post(handlers::sync_owned)) + .route( + "/api/recovery/restart-restore", + post(handlers::restart_restore), + ) .route( "/api/vaults", get(handlers::list_vaults).post(handlers::publish_vault), @@ -128,6 +159,28 @@ pub fn app(state: AppState) -> Router { .with_state(state) } +/// Assemble the claimant-only router. Its type cannot route to normal daemon handlers. +fn claimant_app(state: claimant::ClaimantState) -> Router { + let protected = Router::new() + .route("/api/claimant/status", get(claimant::status)) + .route("/api/claimant/preview", post(claimant::preview)) + .route("/api/claimant/complete", post(claimant::complete)) + .route("/api/claimant/cancel", post(claimant::cancel)) + .layer(middleware::from_fn_with_state( + state.token.clone(), + auth::require_token, + )); + let shell = Router::new() + .route("/", get(claimant::shell)) + .route("/claimant.js", get(claimant::script)) + .route("/claimant.css", get(claimant::stylesheet)); + Router::new() + .merge(protected) + .merge(shell) + .layer(middleware::from_fn(auth::guard_host_origin)) + .with_state(state) +} + /// Generate a 32-byte CSPRNG token, hex-encode it, and write it to /// `/api-token` with `0600` permissions on unix. fn mint_and_write_token(state_dir: &Path) -> Result { @@ -169,11 +222,15 @@ fn write_token_file(path: &Path, token: &str) -> Result<()> { .with_context(|| format!("write {path:?}")) } -#[cfg(not(unix))] +#[cfg(windows)] +fn write_token_file(_path: &Path, _token: &str) -> Result<()> { + anyhow::bail!( + "control API startup on Windows is disabled until Carapace enforces a private ACL on API token files" + ) +} + +#[cfg(all(not(unix), not(windows)))] fn write_token_file(path: &Path, token: &str) -> Result<()> { - // ponytail: no OS-ACL restriction here (needs a Windows-specific crate). The - // token still gates every request; tighten the file ACL on non-unix if the - // state dir is shared. std::fs::write(path, token).with_context(|| format!("write {path:?}")) } @@ -217,12 +274,14 @@ pub async fn serve(daemon: Arc, state_dir: &Path, port: u16) -> Result, state_dir: &Path, port: u16) -> Result Result { + std::fs::create_dir_all(state_dir) + .with_context(|| format!("create claimant state directory {state_dir:?}"))?; + let token = { + let mut raw = [0u8; 32]; + getrandom::getrandom(&mut raw) + .map_err(|error| anyhow::anyhow!("generate claimant API token: {error}"))?; + let token = hex::encode(raw); + write_token_file(&state_dir.join("claimant-api-token"), &token)?; + token + }; + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + ensure!( + addr.ip().is_loopback(), + "refusing non-loopback claimant bind {addr}" + ); + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("bind claimant API on {addr}"))?; + let local_addr = listener.local_addr().context("read claimant API address")?; + let url_path = state_dir.join("claimant-api-url"); + std::fs::write(&url_path, format!("http://{local_addr}")) + .with_context(|| format!("write {url_path:?}"))?; + + let state = claimant::ClaimantState::new(state_dir.to_path_buf(), Arc::from(token.as_str()))?; + let app = claimant_app(state); + let handle = tokio::spawn(async move { + if let Err(error) = axum::serve(listener, app).await { + let _ = error; + ops::log("server.claimant_exit_error", None); + } + }); + Ok(ClaimantApiServer { + token, + local_addr, + handle, + }) +} + #[cfg(all(test, unix))] mod tests { use super::write_token_file; @@ -274,3 +378,17 @@ mod tests { assert_eq!(mode & 0o777, 0o600, "rewritten token must be 0600"); } } + +#[cfg(all(test, windows))] +mod windows_tests { + use super::write_token_file; + + #[test] + fn token_creation_fails_closed_without_private_acls() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("api-token"); + let error = write_token_file(&path, "secret").unwrap_err(); + assert!(error.to_string().contains("private ACL")); + assert!(!path.exists()); + } +} diff --git a/crates/carapace-api/src/ops.rs b/crates/carapace-api/src/ops.rs new file mode 100644 index 0000000..ab73349 --- /dev/null +++ b/crates/carapace-api/src/ops.rs @@ -0,0 +1,34 @@ +//! Secret-redacted operational events. + +use serde_json::{json, Value}; + +/// Build one stable, structured operational event. +/// +/// Callers must supply only fixed event identifiers and bounded numeric fields. This +/// function does not accept free-form error text, paths, or identity values. +pub(crate) fn event(event_id: &'static str, reference: Option) -> Value { + json!({ + "component": "carapace-api", + "event_id": event_id, + "reference": reference, + }) +} + +/// Write one event as a single JSON line to the local process log. +pub(crate) fn log(event_id: &'static str, reference: Option) { + eprintln!("{}", event(event_id, reference)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_contract_has_only_redacted_stable_fields() { + let value = event("api.internal_error", Some(7)); + assert_eq!(value["component"], "carapace-api"); + assert_eq!(value["event_id"], "api.internal_error"); + assert_eq!(value["reference"], 7); + assert_eq!(value.as_object().unwrap().len(), 3); + } +} diff --git a/crates/carapace-api/static/_app/immutable/assets/0.BIBOqY7u.css b/crates/carapace-api/static/_app/immutable/assets/0.BNChGHTQ.css similarity index 98% rename from crates/carapace-api/static/_app/immutable/assets/0.BIBOqY7u.css rename to crates/carapace-api/static/_app/immutable/assets/0.BNChGHTQ.css index d2b68ce..a900e14 100644 --- a/crates/carapace-api/static/_app/immutable/assets/0.BIBOqY7u.css +++ b/crates/carapace-api/static/_app/immutable/assets/0.BNChGHTQ.css @@ -1 +1 @@ -@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-cyrillic-ext-400-normal.xuaO2J-f.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-ext-400-normal.DMdlQ8Kv.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-cyrillic-400-normal.BSMlKf0J.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-400-normal.CEL4l2ZJ.woff)format("woff");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-vietnamese-400-normal.BulugwFq.woff2)format("woff2"),url(./ibm-plex-mono-vietnamese-400-normal.DDuiU_S-.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-latin-ext-400-normal.BmRBH3aV.woff2)format("woff2"),url(./ibm-plex-mono-latin-ext-400-normal.D3D2R8hC.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-latin-400-normal.DMJ8VG8y.woff2)format("woff2"),url(./ibm-plex-mono-latin-400-normal.CvHOgSBP.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-cyrillic-ext-500-normal.BqneJy0T.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-ext-500-normal.BIfNGwUT.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-cyrillic-500-normal.Bq9vWWag.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-500-normal.Ael50iVv.woff)format("woff");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-vietnamese-500-normal.DZ4AoWbu.woff2)format("woff2"),url(./ibm-plex-mono-vietnamese-500-normal.C8zxqsMH.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-latin-ext-500-normal.CAhNIIs5.woff2)format("woff2"),url(./ibm-plex-mono-latin-ext-500-normal.CZ70TYgx.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-latin-500-normal.DSY6xOcd.woff2)format("woff2"),url(./ibm-plex-mono-latin-500-normal.CB9ihrfo.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-cyrillic-ext-600-normal.V-xxqcpd.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-ext-600-normal.9HEixskS.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-cyrillic-600-normal.CTOM6hUh.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-600-normal.fLZuRloM.woff)format("woff");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-vietnamese-600-normal.D2EvbN8M.woff2)format("woff2"),url(./ibm-plex-mono-vietnamese-600-normal.iLQfcSjf.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-latin-ext-600-normal.D38SheWl.woff2)format("woff2"),url(./ibm-plex-mono-latin-ext-600-normal.DmB0ttJJ.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-latin-600-normal.BgSNZQsw.woff2)format("woff2"),url(./ibm-plex-mono-latin-600-normal.DWFSQ4vo.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-cyrillic-ext-400-normal.BLTEyOai.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-400-normal.C910xUUL.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-vietnamese-400-normal.BLrFBAHj.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-400-normal.C-iWyKLC.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-latin-ext-400-normal.DR7lHpW4.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-400-normal.DI-aIsWt.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-latin-400-normal.BG6hkEXj.woff2)format("woff2"),url(./hanken-grotesk-latin-400-normal.CjyVwvJV.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-cyrillic-ext-500-normal.DJxU5DEV.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-500-normal.klSdyF8A.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-vietnamese-500-normal.DxwlvJEc.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-500-normal.DkDHNoXI.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-latin-ext-500-normal.DLb9JDK8.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-500-normal.BNvrJ0Ju.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-latin-500-normal.DrDcrrxK.woff2)format("woff2"),url(./hanken-grotesk-latin-500-normal.Bo-NxEKf.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-cyrillic-ext-600-normal.gC6IfhiA.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-600-normal.CZKgvMB-.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-vietnamese-600-normal.Cp8QQjQf.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-600-normal.DHaFH8q1.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-latin-ext-600-normal.FY8kSObK.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-600-normal.DHIm05DD.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-latin-600-normal.CIXX6EOa.woff2)format("woff2"),url(./hanken-grotesk-latin-600-normal.NEn2C4Q3.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-cyrillic-ext-700-normal.t5HJuhUd.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-700-normal.CANZoffZ.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-vietnamese-700-normal.C4RNfQp6.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-700-normal.CslVg6dq.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-latin-ext-700-normal.CK2OfQqO.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-700-normal.BXHnjv6S.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-latin-700-normal.CeQ8H3UY.woff2)format("woff2"),url(./hanken-grotesk-latin-700-normal.6IGCzoPh.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--ink:#14100c;--plate:#1f1913;--plate-raised:#241d16;--hairline:#2e251b;--bronze:#c8843c;--bronze-strong:#e0a05c;--verdigris:#4e8d7c;--coral:#e0674a;--bone:#eae0d2;--muted:#9a8f7e;--font-sans:"Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;--font-mono:"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;--step--1:.833rem;--step-0:1rem;--step-1:1.2rem;--step-2:1.44rem;--step-3:1.728rem;--step-4:2.074rem;--step-5:2.488rem;--radius:10px;--transition:.16s ease;background:var(--ink);color:var(--bone)}:root[data-theme=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--ink:#f4ede0;--plate:#e7dac2;--plate-raised:#f0e6d3;--hairline:#cdb994;--bronze:#9c5f22;--bronze-strong:#7a4a1a;--verdigris:#2f6e5c;--coral:#b83f27;--bone:#241d16;--muted:#6b5f4d}@media (prefers-color-scheme:light){:root:not([data-theme=dark]){--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--ink:#f4ede0;--plate:#e7dac2;--plate-raised:#f0e6d3;--hairline:#cdb994;--bronze:#9c5f22;--bronze-strong:#7a4a1a;--verdigris:#2f6e5c;--coral:#b83f27;--bone:#241d16;--muted:#6b5f4d}}*{box-sizing:border-box}html,body{min-height:100%;margin:0;padding:0}body{background:var(--ink);color:var(--bone);font-family:var(--font-sans);font-size:var(--step-0);transition:background var(--transition), color var(--transition);line-height:1.5}h1,h2,h3{font-family:var(--font-sans);margin:0 0 .5em;font-weight:700;line-height:1.2}h1{font-size:var(--step-4)}h2{font-size:var(--step-2)}h3{font-size:var(--step-1)}code,.mono{font-family:var(--font-mono);font-variant-ligatures:none}a{color:var(--bronze-strong)}button,input,select,textarea{font-family:inherit;font-size:inherit;color:inherit}button{background:var(--plate-raised);border:1px solid var(--hairline);color:var(--bone);border-radius:var(--radius);cursor:pointer;transition:background var(--transition), border-color var(--transition);padding:.55em 1em}button:hover{border-color:var(--bronze)}button.primary{background:var(--bronze);border-color:var(--bronze);color:var(--ink);font-weight:600}button.primary:hover{background:var(--bronze-strong);border-color:var(--bronze-strong)}button:disabled{opacity:.5;cursor:not-allowed}input,select,textarea{background:var(--plate);border:1px solid var(--hairline);border-radius:calc(var(--radius) * .6);padding:.5em .7em}:focus-visible{outline:2px solid var(--bronze);outline-offset:2px}.card{background:var(--plate);border:1px solid var(--hairline);border-radius:var(--radius);padding:1.25rem}.muted{color:var(--muted)}.healthy{color:var(--verdigris)}.at-risk{color:var(--coral)}.visually-hidden{clip:rect(0 0 0 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.001ms!important;animation-duration:.001ms!important;animation-iteration-count:1!important}} +@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-cyrillic-ext-400-normal.xuaO2J-f.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-ext-400-normal.DMdlQ8Kv.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-cyrillic-400-normal.BSMlKf0J.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-400-normal.CEL4l2ZJ.woff)format("woff");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-vietnamese-400-normal.BulugwFq.woff2)format("woff2"),url(./ibm-plex-mono-vietnamese-400-normal.DDuiU_S-.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-latin-ext-400-normal.BmRBH3aV.woff2)format("woff2"),url(./ibm-plex-mono-latin-ext-400-normal.D3D2R8hC.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-latin-400-normal.DMJ8VG8y.woff2)format("woff2"),url(./ibm-plex-mono-latin-400-normal.CvHOgSBP.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-cyrillic-ext-500-normal.BqneJy0T.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-ext-500-normal.BIfNGwUT.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-cyrillic-500-normal.Bq9vWWag.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-500-normal.Ael50iVv.woff)format("woff");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-vietnamese-500-normal.DZ4AoWbu.woff2)format("woff2"),url(./ibm-plex-mono-vietnamese-500-normal.C8zxqsMH.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-latin-ext-500-normal.CAhNIIs5.woff2)format("woff2"),url(./ibm-plex-mono-latin-ext-500-normal.CZ70TYgx.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-latin-500-normal.DSY6xOcd.woff2)format("woff2"),url(./ibm-plex-mono-latin-500-normal.CB9ihrfo.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-cyrillic-ext-600-normal.V-xxqcpd.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-ext-600-normal.9HEixskS.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-cyrillic-600-normal.CTOM6hUh.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-600-normal.fLZuRloM.woff)format("woff");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-vietnamese-600-normal.D2EvbN8M.woff2)format("woff2"),url(./ibm-plex-mono-vietnamese-600-normal.iLQfcSjf.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-latin-ext-600-normal.D38SheWl.woff2)format("woff2"),url(./ibm-plex-mono-latin-ext-600-normal.DmB0ttJJ.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-latin-600-normal.BgSNZQsw.woff2)format("woff2"),url(./ibm-plex-mono-latin-600-normal.DWFSQ4vo.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-cyrillic-ext-400-normal.BLTEyOai.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-400-normal.C910xUUL.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-vietnamese-400-normal.BLrFBAHj.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-400-normal.C-iWyKLC.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-latin-ext-400-normal.DR7lHpW4.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-400-normal.DI-aIsWt.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-latin-400-normal.BG6hkEXj.woff2)format("woff2"),url(./hanken-grotesk-latin-400-normal.CjyVwvJV.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-cyrillic-ext-500-normal.DJxU5DEV.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-500-normal.klSdyF8A.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-vietnamese-500-normal.DxwlvJEc.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-500-normal.DkDHNoXI.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-latin-ext-500-normal.DLb9JDK8.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-500-normal.BNvrJ0Ju.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-latin-500-normal.DrDcrrxK.woff2)format("woff2"),url(./hanken-grotesk-latin-500-normal.Bo-NxEKf.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-cyrillic-ext-600-normal.gC6IfhiA.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-600-normal.CZKgvMB-.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-vietnamese-600-normal.Cp8QQjQf.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-600-normal.DHaFH8q1.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-latin-ext-600-normal.FY8kSObK.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-600-normal.DHIm05DD.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-latin-600-normal.CIXX6EOa.woff2)format("woff2"),url(./hanken-grotesk-latin-600-normal.NEn2C4Q3.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-cyrillic-ext-700-normal.t5HJuhUd.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-700-normal.CANZoffZ.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-vietnamese-700-normal.C4RNfQp6.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-700-normal.CslVg6dq.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-latin-ext-700-normal.CK2OfQqO.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-700-normal.BXHnjv6S.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-latin-700-normal.CeQ8H3UY.woff2)format("woff2"),url(./hanken-grotesk-latin-700-normal.6IGCzoPh.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--ink:#14100c;--plate:#1f1913;--plate-raised:#241d16;--hairline:#2e251b;--bronze:#c8843c;--bronze-strong:#e0a05c;--verdigris:#4e8d7c;--coral:#e0674a;--bone:#eae0d2;--muted:#9a8f7e;--font-sans:"Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;--font-mono:"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;--step--1:.833rem;--step-0:1rem;--step-1:1.2rem;--step-2:1.44rem;--step-3:1.728rem;--step-4:2.074rem;--step-5:2.488rem;--radius:10px;--transition:.16s ease;background:var(--ink);color:var(--bone)}:root[data-theme=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--ink:#f4ede0;--plate:#e7dac2;--plate-raised:#f0e6d3;--hairline:#cdb994;--bronze:#754515;--bronze-strong:#60370f;--verdigris:#2f6e5c;--coral:#b83f27;--bone:#241d16;--muted:#6b5f4d}@media (prefers-color-scheme:light){:root:not([data-theme=dark]){--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--ink:#f4ede0;--plate:#e7dac2;--plate-raised:#f0e6d3;--hairline:#cdb994;--bronze:#754515;--bronze-strong:#60370f;--verdigris:#2f6e5c;--coral:#b83f27;--bone:#241d16;--muted:#6b5f4d}}*{box-sizing:border-box}html,body{min-height:100%;margin:0;padding:0}body{background:var(--ink);color:var(--bone);font-family:var(--font-sans);font-size:var(--step-0);transition:background var(--transition), color var(--transition);line-height:1.5}h1,h2,h3{font-family:var(--font-sans);margin:0 0 .5em;font-weight:700;line-height:1.2}h1{font-size:var(--step-4)}h2{font-size:var(--step-2)}h3{font-size:var(--step-1)}code,.mono{font-family:var(--font-mono);font-variant-ligatures:none}a{color:var(--bronze-strong)}button,input,select,textarea{font-family:inherit;font-size:inherit;color:inherit}button{background:var(--plate-raised);border:1px solid var(--hairline);color:var(--bone);border-radius:var(--radius);cursor:pointer;transition:background var(--transition), border-color var(--transition);padding:.55em 1em}button:hover{border-color:var(--bronze)}button.primary{background:var(--bronze);border-color:var(--bronze);color:var(--ink);font-weight:600}button.primary:hover{background:var(--bronze-strong);border-color:var(--bronze-strong)}button:disabled{opacity:.5;cursor:not-allowed}input,select,textarea{background:var(--plate);border:1px solid var(--hairline);border-radius:calc(var(--radius) * .6);padding:.5em .7em}:focus-visible{outline:2px solid var(--bronze);outline-offset:2px}.card{background:var(--plate);border:1px solid var(--hairline);border-radius:var(--radius);padding:1.25rem}.muted{color:var(--muted)}.healthy{color:var(--verdigris)}.at-risk{color:var(--coral)}.visually-hidden{clip:rect(0 0 0 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.001ms!important;animation-duration:.001ms!important;animation-iteration-count:1!important}} diff --git a/crates/carapace-api/static/_app/immutable/assets/2.BO-zofLV.css b/crates/carapace-api/static/_app/immutable/assets/2.BO-zofLV.css new file mode 100644 index 0000000..b8ede71 --- /dev/null +++ b/crates/carapace-api/static/_app/immutable/assets/2.BO-zofLV.css @@ -0,0 +1 @@ +.banner.svelte-vde8u4{background:var(--plate);border:1px solid var(--coral);color:var(--coral);border-radius:var(--radius);justify-content:space-between;align-items:center;gap:1rem;margin-bottom:1rem;padding:.75rem 1rem;display:flex}.banner.svelte-vde8u4 button:where(.svelte-vde8u4){border-color:var(--coral);color:var(--coral);background:0 0;flex-shrink:0}.shell.svelte-v2pnom{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1.25rem;display:grid}.plate-group.svelte-v2pnom{background:var(--plate);border:1px solid var(--hairline);border-radius:var(--radius);animation:.42s backwards svelte-v2pnom-reveal;animation-delay:calc(var(--i) * 80ms);padding:1.25rem}@keyframes svelte-v2pnom-reveal{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.segments.svelte-v2pnom{margin-bottom:.9rem;display:flex}.segment.svelte-v2pnom{border:2px solid var(--hairline);clip-path:polygon(14% 0,100% 0,86% 100%,0 100%);background:0 0;flex:1;height:34px;margin-left:-10px}.segment.svelte-v2pnom:first-child{margin-left:0}.state-healthy.svelte-v2pnom .segment.filled:where(.svelte-v2pnom){background:var(--verdigris);border-color:var(--verdigris)}.state-at-risk.svelte-v2pnom .segment.filled:where(.svelte-v2pnom){background:var(--coral);border-color:var(--coral)}.state-at-risk.svelte-v2pnom .segment:where(.svelte-v2pnom):not(.filled){border-color:var(--coral);border-style:dashed}.state-empty.svelte-v2pnom .segment:where(.svelte-v2pnom){border-style:dashed}.label.svelte-v2pnom{font-size:var(--step--1);text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}.value.svelte-v2pnom{font-size:var(--step-3);margin:.15em 0;font-weight:600}.state-healthy.svelte-v2pnom .value:where(.svelte-v2pnom){color:var(--verdigris)}.state-at-risk.svelte-v2pnom .value:where(.svelte-v2pnom){color:var(--coral)}.note.svelte-v2pnom{font-size:var(--step--1)}.hex.svelte-3sdcoe{cursor:pointer;color:inherit;background:0 0;border:none;align-items:baseline;gap:.5em;padding:0;display:inline-flex}.hex.svelte-3sdcoe:hover .hint:where(.svelte-3sdcoe),.hex.svelte-3sdcoe:focus-visible .hint:where(.svelte-3sdcoe){color:var(--bronze)}.hint.svelte-3sdcoe{font-size:var(--step--1);font-family:var(--font-sans)}.node.svelte-156cahi{flex-wrap:wrap;gap:2rem;margin:1.5rem 0;display:flex}.label.svelte-156cahi{font-size:var(--step--1);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.3em}.actions.svelte-156cahi{flex-wrap:wrap;gap:.75rem;display:flex}.button-link.svelte-156cahi{background:var(--plate-raised);border:1px solid var(--hairline);color:var(--bone);border-radius:var(--radius);transition:border-color var(--transition);padding:.55em 1em;text-decoration:none}.button-link.svelte-156cahi:hover,.button-link.svelte-156cahi:focus-visible{border-color:var(--bronze)}.publish-form.svelte-1t2bjk0 label:where(.svelte-1t2bjk0){font-size:var(--step--1);color:var(--muted);margin-bottom:.4rem;display:block}.publish-form.svelte-1t2bjk0+.publish-form:where(.svelte-1t2bjk0){margin-top:1rem}.publish-form.svelte-1t2bjk0>input:where(.svelte-1t2bjk0){width:100%;margin-bottom:.6rem}.publish-form.svelte-1t2bjk0>button:where(.svelte-1t2bjk0){margin-top:.4rem}.row.svelte-1t2bjk0{flex-wrap:wrap;align-items:center;gap:.6rem;display:flex}.row.svelte-1t2bjk0 input:where(.svelte-1t2bjk0){flex:1;min-width:180px}.list.svelte-1t2bjk0{flex-direction:column;gap:.75rem;margin-top:1.5rem;display:flex}.vault-row.svelte-1t2bjk0{grid-template-columns:1fr 2fr auto;align-items:start;gap:1.5rem;display:grid}@media (width<=640px){.vault-row.svelte-1t2bjk0{grid-template-columns:1fr;gap:.9rem}.row.svelte-1t2bjk0>input:where(.svelte-1t2bjk0),.peer-row.svelte-1t2bjk0>input:where(.svelte-1t2bjk0){width:100%;min-width:0}}.member-list.svelte-1t2bjk0{flex-direction:column;gap:.2rem;margin:.3rem 0 0;padding:0;list-style:none;display:flex}.label.svelte-1t2bjk0{font-size:var(--step--1);text-transform:uppercase;letter-spacing:.06em}.peer-row.svelte-1t2bjk0{margin-bottom:.5rem}.grid.svelte-1owrzkc{grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;margin:1rem 0;display:grid}form.svelte-1owrzkc label:where(.svelte-1owrzkc),.ticket.svelte-1owrzkc label:where(.svelte-1owrzkc){font-size:var(--step--1);margin:.6rem 0 .3rem;display:block}form.svelte-1owrzkc input:where(.svelte-1owrzkc){width:100%}form.svelte-1owrzkc button:where(.svelte-1owrzkc){margin-top:1rem}.row.svelte-1owrzkc{gap:.5rem;display:flex}.row.svelte-1owrzkc input:where(.svelte-1owrzkc){flex:1}.list.svelte-1owrzkc{flex-direction:column;gap:.6rem;display:flex}.friend-row.svelte-1owrzkc{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.5rem;display:flex}.confirm.svelte-1owrzkc{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}button.danger.svelte-1owrzkc{border-color:var(--coral);color:var(--coral)}button.danger.svelte-1owrzkc:hover{background:var(--coral);color:var(--ink)}.row.svelte-imw02{flex-wrap:wrap;align-items:center;gap:1rem;margin-bottom:.75rem;display:flex}.row.svelte-imw02 label:where(.svelte-imw02){font-size:var(--step--1);color:var(--muted);align-items:center;gap:.4rem;display:flex}fieldset.svelte-imw02{border:1px solid var(--hairline);border-radius:var(--radius);margin:0 0 .75rem;padding:.75rem}fieldset.svelte-imw02>label:where(.svelte-imw02),.trustee-list.svelte-imw02 label:where(.svelte-imw02){align-items:center;gap:.5rem;margin:.35rem 0;display:flex}legend.svelte-imw02{font-weight:600}.trustee-list.svelte-imw02{grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:0 1rem;display:grid}.list.svelte-imw02{flex-direction:column;gap:.5rem;display:flex}.set-row.svelte-imw02{gap:1.5rem;display:flex}.share-row.svelte-imw02{align-items:center;gap:.6rem;margin-bottom:.5rem;display:flex}.share-text.svelte-imw02{word-break:break-all;background:var(--plate-raised);border-radius:6px;flex:1;padding:.4em .6em}.grid.svelte-imw02{grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1rem;display:grid}.grid.svelte-imw02 label:where(.svelte-imw02){font-size:var(--step--1);margin:.5rem 0 .3rem;display:block}.grid.svelte-imw02 input:where(.svelte-imw02),.grid.svelte-imw02 select:where(.svelte-imw02){width:100%;margin-bottom:.5rem}.ceremony.alarm.svelte-imw02{border-color:var(--coral)}.alarm-text.svelte-imw02{color:var(--coral);font-weight:600}dl.svelte-imw02{gap:.5rem;display:grid}dl.svelte-imw02 div:where(.svelte-imw02){grid-template-columns:minmax(6rem,.25fr) 1fr;gap:.75rem;display:grid}dt.svelte-imw02{color:var(--muted)}dd.svelte-imw02{overflow-wrap:anywhere;min-width:0;margin:0}button.danger.svelte-imw02{border-color:var(--coral);color:var(--coral)}.confirm-action.svelte-imw02{align-items:center;gap:.5rem;display:flex!important}.confirm-action.svelte-imw02 input:where(.svelte-imw02){width:auto;margin:0}@media (width<=640px){.share-row.svelte-imw02,.set-row.svelte-imw02{flex-direction:column;align-items:stretch}dl.svelte-imw02 div:where(.svelte-imw02){grid-template-columns:1fr;gap:.1rem}}.resplit-head.svelte-imw02{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:1rem;display:flex}.phase.svelte-imw02{font-size:var(--step--1);border:1px solid var(--hairline);border-radius:999px;padding:.2em .6em}.phase.complete.svelte-imw02{color:var(--verdigris);border-color:var(--verdigris)}.phase.ready_to_destroy.svelte-imw02{color:var(--bronze-strong);border-color:var(--bronze)}.phase.required.svelte-imw02{color:var(--coral);border-color:var(--coral)}.gauges.svelte-imw02{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin-top:.75rem;display:grid}.reach.svelte-imw02{flex-direction:column;gap:.4rem;margin-top:.4rem;display:flex}.reach-row.svelte-imw02{flex-wrap:wrap;align-items:center;gap:.6rem;display:flex}.dot.svelte-imw02{background:var(--muted);border-radius:50%;flex:none;width:.6rem;height:.6rem}.dot.done.svelte-imw02{background:var(--verdigris)}.dot.online.svelte-imw02{background:var(--bronze)}.dot.will_queue.svelte-imw02,.dot.offline.svelte-imw02{background:var(--muted)}.role.svelte-imw02{font-size:var(--step--1);color:var(--muted)}form.svelte-d3lnpc label:where(.svelte-d3lnpc){font-size:var(--step--1);margin:.6rem 0 .3rem;display:block}form.svelte-d3lnpc input:where(.svelte-d3lnpc),form.svelte-d3lnpc select:where(.svelte-d3lnpc),form.svelte-d3lnpc textarea:where(.svelte-d3lnpc){width:100%}form.svelte-d3lnpc button:where(.svelte-d3lnpc){margin-top:1rem}.row.svelte-d3lnpc{align-items:center;gap:.5rem;display:flex}.share-text.svelte-d3lnpc{word-break:break-all;background:var(--plate-raised);border-radius:6px;flex:1;padding:.4em .6em}.confirm-action.svelte-d3lnpc{align-items:center;gap:.5rem;display:flex}.confirm-action.svelte-d3lnpc input:where(.svelte-d3lnpc){width:auto}@media (width<=640px){.row.svelte-d3lnpc{flex-direction:column;align-items:stretch}}.app.svelte-1uha8ag{max-width:960px;margin:0 auto;padding:1.5rem}header.svelte-1uha8ag{border-bottom:1px solid var(--hairline);flex-wrap:wrap;align-items:center;gap:1.5rem;margin-bottom:2rem;padding-bottom:1rem;display:flex}.brand.svelte-1uha8ag{font-weight:700;font-size:var(--step-2);align-items:center;gap:.5rem;display:flex}.mark.svelte-1uha8ag{color:var(--bronze)}nav.svelte-1uha8ag{flex-wrap:wrap;flex:1;gap:.25rem;display:flex}nav.svelte-1uha8ag a:where(.svelte-1uha8ag){color:var(--muted);border-radius:var(--radius);transition:color var(--transition), background var(--transition);padding:.4em .8em;text-decoration:none}nav.svelte-1uha8ag a:where(.svelte-1uha8ag):hover,nav.svelte-1uha8ag a:where(.svelte-1uha8ag):focus-visible{color:var(--bone);background:var(--plate)}nav.svelte-1uha8ag a.active:where(.svelte-1uha8ag){color:var(--bronze);background:var(--plate);font-weight:600}.status-and-theme.svelte-1uha8ag{align-items:center;gap:.75rem;display:flex}.live-dot.svelte-1uha8ag{background:var(--coral);border-radius:50%;width:10px;height:10px;display:inline-block}.live-dot.live.svelte-1uha8ag{background:var(--verdigris)}.connection-text.svelte-1uha8ag{font-size:var(--step--1);color:var(--muted)}@media (width<=640px){.app.svelte-1uha8ag{padding:1rem}header.svelte-1uha8ag{gap:.75rem}nav.svelte-1uha8ag{flex-basis:100%;order:3}nav.svelte-1uha8ag a:where(.svelte-1uha8ag){padding:.55em .65em}.status-and-theme.svelte-1uha8ag{margin-left:auto}} diff --git a/crates/carapace-api/static/_app/immutable/assets/2.Ccp3aMT0.css b/crates/carapace-api/static/_app/immutable/assets/2.Ccp3aMT0.css deleted file mode 100644 index cada163..0000000 --- a/crates/carapace-api/static/_app/immutable/assets/2.Ccp3aMT0.css +++ /dev/null @@ -1 +0,0 @@ -.banner.svelte-vde8u4{background:var(--plate);border:1px solid var(--coral);color:var(--coral);border-radius:var(--radius);justify-content:space-between;align-items:center;gap:1rem;margin-bottom:1rem;padding:.75rem 1rem;display:flex}.banner.svelte-vde8u4 button:where(.svelte-vde8u4){border-color:var(--coral);color:var(--coral);background:0 0;flex-shrink:0}.shell.svelte-v2pnom{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1.25rem;display:grid}.plate-group.svelte-v2pnom{background:var(--plate);border:1px solid var(--hairline);border-radius:var(--radius);animation:.42s backwards svelte-v2pnom-reveal;animation-delay:calc(var(--i) * 80ms);padding:1.25rem}@keyframes svelte-v2pnom-reveal{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.segments.svelte-v2pnom{margin-bottom:.9rem;display:flex}.segment.svelte-v2pnom{border:2px solid var(--hairline);clip-path:polygon(14% 0,100% 0,86% 100%,0 100%);background:0 0;flex:1;height:34px;margin-left:-10px}.segment.svelte-v2pnom:first-child{margin-left:0}.state-healthy.svelte-v2pnom .segment.filled:where(.svelte-v2pnom){background:var(--verdigris);border-color:var(--verdigris)}.state-at-risk.svelte-v2pnom .segment.filled:where(.svelte-v2pnom){background:var(--coral);border-color:var(--coral)}.state-at-risk.svelte-v2pnom .segment:where(.svelte-v2pnom):not(.filled){border-color:var(--coral);border-style:dashed}.state-empty.svelte-v2pnom .segment:where(.svelte-v2pnom){border-style:dashed}.label.svelte-v2pnom{font-size:var(--step--1);text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}.value.svelte-v2pnom{font-size:var(--step-3);margin:.15em 0;font-weight:600}.state-healthy.svelte-v2pnom .value:where(.svelte-v2pnom){color:var(--verdigris)}.state-at-risk.svelte-v2pnom .value:where(.svelte-v2pnom){color:var(--coral)}.note.svelte-v2pnom{font-size:var(--step--1)}.hex.svelte-3sdcoe{cursor:pointer;color:inherit;background:0 0;border:none;align-items:baseline;gap:.5em;padding:0;display:inline-flex}.hex.svelte-3sdcoe:hover .hint:where(.svelte-3sdcoe),.hex.svelte-3sdcoe:focus-visible .hint:where(.svelte-3sdcoe){color:var(--bronze)}.hint.svelte-3sdcoe{font-size:var(--step--1);font-family:var(--font-sans)}.node.svelte-156cahi{flex-wrap:wrap;gap:2rem;margin:1.5rem 0;display:flex}.label.svelte-156cahi{font-size:var(--step--1);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.3em}.actions.svelte-156cahi{flex-wrap:wrap;gap:.75rem;display:flex}.button-link.svelte-156cahi{background:var(--plate-raised);border:1px solid var(--hairline);color:var(--bone);border-radius:var(--radius);transition:border-color var(--transition);padding:.55em 1em;text-decoration:none}.button-link.svelte-156cahi:hover,.button-link.svelte-156cahi:focus-visible{border-color:var(--bronze)}.publish-form.svelte-1t2bjk0 label:where(.svelte-1t2bjk0){font-size:var(--step--1);color:var(--muted);margin-bottom:.4rem;display:block}.row.svelte-1t2bjk0{flex-wrap:wrap;align-items:center;gap:.6rem;display:flex}.row.svelte-1t2bjk0 input:where(.svelte-1t2bjk0){flex:1;min-width:180px}.list.svelte-1t2bjk0{flex-direction:column;gap:.75rem;margin-top:1.5rem;display:flex}.vault-row.svelte-1t2bjk0{grid-template-columns:1fr 2fr auto;align-items:start;gap:1.5rem;display:grid}.member-list.svelte-1t2bjk0{flex-direction:column;gap:.2rem;margin:.3rem 0 0;padding:0;list-style:none;display:flex}.label.svelte-1t2bjk0{font-size:var(--step--1);text-transform:uppercase;letter-spacing:.06em}.peer-row.svelte-1t2bjk0{margin-bottom:.5rem}.grid.svelte-1owrzkc{grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;margin:1rem 0;display:grid}form.svelte-1owrzkc label:where(.svelte-1owrzkc),.ticket.svelte-1owrzkc label:where(.svelte-1owrzkc){font-size:var(--step--1);margin:.6rem 0 .3rem;display:block}form.svelte-1owrzkc input:where(.svelte-1owrzkc){width:100%}form.svelte-1owrzkc button:where(.svelte-1owrzkc){margin-top:1rem}.row.svelte-1owrzkc{gap:.5rem;display:flex}.row.svelte-1owrzkc input:where(.svelte-1owrzkc){flex:1}.list.svelte-1owrzkc{flex-direction:column;gap:.6rem;display:flex}.friend-row.svelte-1owrzkc{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.5rem;display:flex}.confirm.svelte-1owrzkc{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}button.danger.svelte-1owrzkc{border-color:var(--coral);color:var(--coral)}button.danger.svelte-1owrzkc:hover{background:var(--coral);color:var(--ink)}.row.svelte-imw02{flex-wrap:wrap;align-items:center;gap:1rem;margin-bottom:.75rem;display:flex}.row.svelte-imw02 label:where(.svelte-imw02){font-size:var(--step--1);color:var(--muted);align-items:center;gap:.4rem;display:flex}.list.svelte-imw02{flex-direction:column;gap:.5rem;display:flex}.set-row.svelte-imw02{gap:1.5rem;display:flex}.share-row.svelte-imw02{align-items:center;gap:.6rem;margin-bottom:.5rem;display:flex}.share-text.svelte-imw02{word-break:break-all;background:var(--plate-raised);border-radius:6px;flex:1;padding:.4em .6em}.grid.svelte-imw02{grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1rem;display:grid}.grid.svelte-imw02 label:where(.svelte-imw02){font-size:var(--step--1);margin:.5rem 0 .3rem;display:block}.grid.svelte-imw02 input:where(.svelte-imw02){width:100%;margin-bottom:.5rem}.resplit-head.svelte-imw02{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:1rem;display:flex}.phase.svelte-imw02{font-size:var(--step--1);border:1px solid var(--hairline);border-radius:999px;padding:.2em .6em}.phase.complete.svelte-imw02{color:var(--verdigris);border-color:var(--verdigris)}.phase.ready_to_destroy.svelte-imw02{color:var(--bronze-strong);border-color:var(--bronze)}.phase.required.svelte-imw02{color:var(--coral);border-color:var(--coral)}.gauges.svelte-imw02{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin-top:.75rem;display:grid}.reach.svelte-imw02{flex-direction:column;gap:.4rem;margin-top:.4rem;display:flex}.reach-row.svelte-imw02{flex-wrap:wrap;align-items:center;gap:.6rem;display:flex}.dot.svelte-imw02{background:var(--muted);border-radius:50%;flex:none;width:.6rem;height:.6rem}.dot.done.svelte-imw02{background:var(--verdigris)}.dot.online.svelte-imw02{background:var(--bronze)}.dot.will_queue.svelte-imw02,.dot.offline.svelte-imw02{background:var(--muted)}.role.svelte-imw02{font-size:var(--step--1);color:var(--muted)}form.svelte-d3lnpc label:where(.svelte-d3lnpc){font-size:var(--step--1);margin:.6rem 0 .3rem;display:block}form.svelte-d3lnpc input:where(.svelte-d3lnpc),form.svelte-d3lnpc select:where(.svelte-d3lnpc),form.svelte-d3lnpc textarea:where(.svelte-d3lnpc){width:100%}form.svelte-d3lnpc button:where(.svelte-d3lnpc){margin-top:1rem}.row.svelte-d3lnpc{align-items:center;gap:.5rem;display:flex}.share-text.svelte-d3lnpc{word-break:break-all;background:var(--plate-raised);border-radius:6px;flex:1;padding:.4em .6em}.app.svelte-1uha8ag{max-width:960px;margin:0 auto;padding:1.5rem}header.svelte-1uha8ag{border-bottom:1px solid var(--hairline);flex-wrap:wrap;align-items:center;gap:1.5rem;margin-bottom:2rem;padding-bottom:1rem;display:flex}.brand.svelte-1uha8ag{font-weight:700;font-size:var(--step-2);align-items:center;gap:.5rem;display:flex}.mark.svelte-1uha8ag{color:var(--bronze)}nav.svelte-1uha8ag{flex-wrap:wrap;flex:1;gap:.25rem;display:flex}nav.svelte-1uha8ag a:where(.svelte-1uha8ag){color:var(--muted);border-radius:var(--radius);transition:color var(--transition), background var(--transition);padding:.4em .8em;text-decoration:none}nav.svelte-1uha8ag a:where(.svelte-1uha8ag):hover,nav.svelte-1uha8ag a:where(.svelte-1uha8ag):focus-visible{color:var(--bone);background:var(--plate)}nav.svelte-1uha8ag a.active:where(.svelte-1uha8ag){color:var(--bronze);background:var(--plate);font-weight:600}.status-and-theme.svelte-1uha8ag{align-items:center;gap:.75rem;display:flex}.live-dot.svelte-1uha8ag{background:var(--coral);border-radius:50%;width:10px;height:10px;display:inline-block}.live-dot.live.svelte-1uha8ag{background:var(--verdigris)} diff --git a/crates/carapace-api/static/_app/immutable/chunks/Cfkx4BKF.js b/crates/carapace-api/static/_app/immutable/chunks/BdOmOXD3.js similarity index 99% rename from crates/carapace-api/static/_app/immutable/chunks/Cfkx4BKF.js rename to crates/carapace-api/static/_app/immutable/chunks/BdOmOXD3.js index e1b2d58..be7985d 100644 --- a/crates/carapace-api/static/_app/immutable/chunks/Cfkx4BKF.js +++ b/crates/carapace-api/static/_app/immutable/chunks/BdOmOXD3.js @@ -1 +1 @@ -import{G as e,K as t,M as n,N as r,X as i,j as a,r as o,t as s}from"./D7CCBGlz.js";import"./CCeg2KC3.js";var c=class{constructor(e,t){this.status=e,typeof t==`string`?this.body={message:t}:t?this.body=t:this.body={message:`Error: ${e}`}}toString(){return JSON.stringify(this.body)}},l=class{constructor(e,t){try{new Headers({location:t})}catch{throw Error(`Invalid redirect location ${JSON.stringify(t)}: this string contains characters that cannot be used in HTTP headers`)}this.status=e,this.location=t}},u=class extends Error{constructor(e,t,n){super(n),this.status=e,this.text=t}};new URL(`sveltekit-internal://`);function d(e,t){return e===`/`||t===`ignore`?e:t===`never`?e.endsWith(`/`)?e.slice(0,-1):e:t===`always`&&!e.endsWith(`/`)?e+`/`:e}function f(e){return e.split(`%25`).map(decodeURI).join(`%25`)}function p(e){for(let t in e)e[t]=decodeURIComponent(e[t]);return e}function m({href:e}){return e.split(`#`)[0]}function h(){}function g(...e){let t=5381;for(let n of e)if(typeof n==`string`){let e=n.length;for(;e;)t=t*33^n.charCodeAt(--e)}else if(ArrayBuffer.isView(n)){let e=new Uint8Array(n.buffer,n.byteOffset,n.byteLength),r=e.length;for(;r;)t=t*33^e[--r]}else throw TypeError(`value must be a string or TypedArray`);return(t>>>0).toString(36)}new TextEncoder;function _(e){let t=atob(e),n=new Uint8Array(t.length);for(let e=0;e((e instanceof Request?e.method:t?.method||`GET`)!==`GET`&&y.delete(x(e)),v(e,t));var y=new Map;function ee(e,t){let n=x(e,t),r=document.querySelector(n);if(r?.textContent){r.remove();let{body:e,...t}=JSON.parse(r.textContent);r.getAttribute(`data-b64`)!==null&&(e=_(e));let i=r.getAttribute(`data-ttl`);return i&&y.set(n,{body:e,init:t,ttl:1e3*Number(i)}),Promise.resolve(new Response(e,t))}return window.fetch(e,t)}function b(e,t,n){if(y.size>0){let t=x(e,n),r=y.get(t);if(r){if(performance.now(){let n=/^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(e);if(n)return t.push({name:n[1],matcher:n[2],optional:!1,rest:!0,chained:!0}),`(?:/([^]*))?`;let r=/^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(e);if(r)return t.push({name:r[1],matcher:r[2],optional:!0,rest:!1,chained:!0}),`(?:/([^/]+))?`;if(!e)return;let i=e.split(/\[(.+?)\](?!\])/);return`/`+i.map((e,n)=>{if(n%2){if(e.startsWith(`x+`))return se(String.fromCharCode(parseInt(e.slice(2),16)));if(e.startsWith(`u+`))return se(String.fromCharCode(...e.slice(2).split(`-`).map(e=>parseInt(e,16))));let[,r,a,o,s]=te.exec(e);return t.push({name:o,matcher:s,optional:!!r,rest:!!a,chained:a?n===1&&i[0]===``:!1}),a?`([^]*?)`:r?`([^/]*)?`:`([^/]+?)`}return se(e)}).join(``)}).join(``)}/?$`),params:t}}function ie(e){return e!==``&&!/^\([^)]+\)$/.test(e)}function ae(e){return e.slice(1).split(`/`).filter(ie)}function oe(e,t,n){let r={},i=e.slice(1),a=i.filter(e=>e!==void 0),o=0;for(let e=0;ee).join(`/`),o=0),c===void 0)if(s.rest)c=``;else continue;if(!s.matcher||n[s.matcher](c)){r[s.name]=c;let n=t[e+1],l=i[e+1];n&&!n.rest&&n.optional&&l&&s.chained&&(o=0),!n&&!l&&Object.keys(r).length===a.length&&(o=0);continue}if(s.optional&&s.chained){o++;continue}return}if(!o)return r}function se(e){return e.normalize().replace(/[[\]]/g,`\\$&`).replace(/%/g,`%25`).replace(/\//g,`%2[Ff]`).replace(/\?/g,`%3[Ff]`).replace(/#/g,`%23`).replace(/[.*+?^${}()|\\]/g,`\\$&`)}function ce({nodes:e,server_loads:t,dictionary:n,matchers:r}){let i=new Set(t);return Object.entries(n).map(([t,[n,i,s]])=>{let{pattern:c,params:l}=re(t),u={id:t,exec:e=>{let t=c.exec(e);if(t)return oe(t,l,r)},errors:[1,...s||[]].map(t=>e[t]),layouts:[0,...i||[]].map(o),leaf:a(n)};return u.errors.length=u.layouts.length=Math.max(u.errors.length,u.layouts.length),u});function a(t){let n=t<0;return n&&(t=~t),[n,e[t]]}function o(t){return t===void 0?t:[i.has(t),e[t]]}}function le(e,t=JSON.parse){try{return t(sessionStorage[e])}catch{}}function ue(e,t,n=JSON.stringify){let r=n(t);try{sessionStorage[e]=r}catch{}}var S=globalThis.__sveltekit_18z83oy?.base??``,de=globalThis.__sveltekit_18z83oy?.assets??S??``,fe=`1784071871063`,pe=`sveltekit:snapshot`,me=`sveltekit:scroll`,he=`sveltekit:states`,C=`sveltekit:history`,w=`sveltekit:navigation`,T={tap:1,hover:2,viewport:3,eager:4,off:-1,false:-1},ge=location.origin;function _e(e){if(e instanceof URL)return e;let t=document.baseURI;if(!t){let e=document.getElementsByTagName(`base`);t=e.length?e[0].href:document.URL}return new URL(e,t)}function E(){return{x:pageXOffset,y:pageYOffset}}function D(e,t){return e.getAttribute(`data-sveltekit-${t}`)}var ve={...T,"":T.hover};function ye(e){let t=e.assignedSlot??e.parentNode;return t?.nodeType===11&&(t=t.host),t}function be(e,t){for(;e&&e!==t;){if(e.nodeName.toUpperCase()===`A`&&e.hasAttribute(`href`))return e;e=ye(e)}}function xe(e,t,n){let r;try{if(r=new URL(e instanceof SVGAElement?e.href.baseVal:e.href,document.baseURI),n&&r.hash.match(/^#[^/]/)){let e=location.hash.split(`#`)[1]||`/`;r.hash=`#${e}${r.hash}`}}catch{}let i=e instanceof SVGAElement?e.target.baseVal:e.target,a=!r||!!i||k(r,t,n)||(e.getAttribute(`rel`)||``).split(/\s+/).includes(`external`),o=r?.origin===ge&&e.hasAttribute(`download`);return{url:r,external:a,target:i,download:o}}function O(e){let t=null,n=null,r=null,i=null,a=null,o=null,s=e;for(;s&&s!==document.documentElement;)r===null&&(r=D(s,`preload-code`)),i===null&&(i=D(s,`preload-data`)),t===null&&(t=D(s,`keepfocus`)),n===null&&(n=D(s,`noscroll`)),a===null&&(a=D(s,`reload`)),o===null&&(o=D(s,`replacestate`)),s=ye(s);function c(e){switch(e){case``:case`true`:return!0;case`off`:case`false`:return!1;default:return}}return{preload_code:ve[r??`off`],preload_data:ve[i??`off`],keepfocus:c(t),noscroll:c(n),reload:c(a),replace_state:c(o)}}function Se(e){let t=i(e),n=!0;function r(){n=!0,t.update(e=>e)}function a(e){n=!1,t.set(e)}function o(e){let r;return t.subscribe(t=>{(r===void 0||n&&t!==r)&&e(r=t)})}return{notify:r,set:a,subscribe:o}}var Ce={v:h};function we(){let{set:e,subscribe:t}=i(!1);async function n(){clearTimeout(void 0);try{let t=await fetch(`${de}/_app/version.json`,{headers:{pragma:`no-cache`,"cache-control":`no-cache`}});if(!t.ok)return!1;let n=(await t.json()).version!==fe;return n&&(e(!0),Ce.v(),clearTimeout(void 0)),n}catch{return!1}}return{subscribe:t,check:n}}function k(e,t,n){return e.origin!==ge||!e.pathname.startsWith(t)?!0:n?e.pathname!==location.pathname:!1}function Te(e){}var Ee=new Set([`load`,`prerender`,`csr`,`ssr`,`trailingSlash`,`config`]);[...Ee],[...new Set([...Ee])];function De(e){return e.filter(e=>e!=null)}function A(e,t){return e+`/`+t}function Oe(e){return e instanceof c||e instanceof u?e.status:500}function ke(e){return e instanceof u?e.text:`Internal Error`}var j,M,N,Ae=o.toString().includes(`$$`)||/function \w+\(\) \{\}/.test(o.toString()),je=`a:`;Ae?(j={data:{},form:null,error:null,params:{},route:{id:null},state:{},status:-1,url:new URL(je)},M={current:null},N={current:!1}):(j=new class{#e=t({});get data(){return a(this.#e)}set data(t){e(this.#e,t)}#t=t(null);get form(){return a(this.#t)}set form(t){e(this.#t,t)}#n=t(null);get error(){return a(this.#n)}set error(t){e(this.#n,t)}#r=t({});get params(){return a(this.#r)}set params(t){e(this.#r,t)}#i=t({id:null});get route(){return a(this.#i)}set route(t){e(this.#i,t)}#a=t({});get state(){return a(this.#a)}set state(t){e(this.#a,t)}#o=t(-1);get status(){return a(this.#o)}set status(t){e(this.#o,t)}#s=t(new URL(je));get url(){return a(this.#s)}set url(t){e(this.#s,t)}},M=new class{#e=t(null);get current(){return a(this.#e)}set current(t){e(this.#e,t)}},N=new class{#e=t(!1);get current(){return a(this.#e)}set current(t){e(this.#e,t)}},Ce.v=()=>N.current=!0);function Me(e){Object.assign(j,e)}var{onMount:Ne,tick:Pe}=s,Fe=new Set([`icon`,`shortcut icon`,`apple-touch-icon`]),P=null,F=le(`sveltekit:scroll`)??{},I=le(`sveltekit:snapshot`)??{},L={url:Se({}),page:Se({}),navigating:i(null),updated:we()};function Ie(e){F[e]=E()}function Le(e,t){let n=e+1;for(;F[n];)delete F[n],n+=1;for(n=t+1;I[n];)delete I[n],n+=1}function R(e,t=!1){return t?location.replace(e.href):location.href=e.href,new Promise(h)}async function Re(){if(`serviceWorker`in navigator){let e=await navigator.serviceWorker.getRegistration(S||`/`);e&&await e.update()}}var ze,Be,z,B,Ve,V,He={},Ue={},H=[],We=[],U=null;function Ge(){U?.fork?.then(e=>e?.discard()),U=null,Q={element:void 0,href:void 0}}var Ke=new Map,qe=new Set,Je=new Set,W=new Set,G={branch:[],error:null,url:null,nav:null},Ye=!1,Xe=!1,Ze=!0,K=!1,q=!1,Qe=!1,$e=!1,et,J,Y,X,tt=new Set,nt=new Map,rt=new Map;async function it(e,t,n){if(globalThis.__sveltekit_18z83oy.data){let{q:e={},p:t={},l:n={},f:r={}}=globalThis.__sveltekit_18z83oy.data;for(let t in e)He[t]=e[t];for(let e in n)He[e]=n[e];for(let e in r)He[e]=r[e];for(let e in t)Ue[e]=t[e]}document.URL!==location.href&&(location.href=location.href),V=e,await e.hooks.init?.(),ze=ce(e),B=document.documentElement,Ve=t,Be=e.nodes[0],z=e.nodes[1],Be(),z(),J=history.state?.[C],Y=history.state?.[w],J||(J=Y=Date.now(),history.replaceState({...history.state,[C]:J,[w]:Y},``));let r=F[J];function i(){r&&(history.scrollRestoration=`manual`,scrollTo(r.x,r.y))}n?(i(),await Mt(Ve,n)):(await Z({type:`enter`,url:_e(V.hash?Rt(new URL(location.href)):location.href),replace_state:!0}),i()),jt()}function at(){H.length=0,$e=!1}function ot(e){We.some(e=>e?.snapshot)&&(I[e]=We.map(e=>e?.snapshot?.capture()))}function st(e){I[e]?.forEach((e,t)=>{We[t]?.snapshot?.restore(e)})}function ct(){Ie(J),ue(me,F),ot(Y),ue(pe,I)}async function lt(e,t,n,i){let a,o;t.invalidateAll&&Ge(),await Z({type:`goto`,url:_e(e),keepfocus:t.keepFocus,noscroll:t.noScroll,replace_state:t.replaceState,state:t.state,redirect_count:n,nav_token:i,accept:()=>{if(t.invalidateAll){$e=!0,a=new Set;for(let[e,t]of nt)for(let[n,r]of t)r.resource?.reset(),a.add(A(e,n));o=new Set;for(let[e,t]of rt)for(let n of t.keys())o.add(A(e,n))}t.invalidate&&t.invalidate.forEach(At)}}),t.invalidateAll&&r().then(r).then(()=>{for(let[e,t]of nt)for(let[n,{resource:r}]of t)a?.has(A(e,n))&&r.start();for(let[e,t]of rt)for(let[n,{resource:r}]of t)o?.has(A(e,n))&&r.reconnect()})}async function ut(e){if(e.id!==U?.id){Ge();let t={};tt.add(t),U={id:e.id,token:t,promise:bt({...e,preload:t}).then(e=>(tt.delete(t),e.type===`loaded`&&e.state.error&&Ge(),e)),fork:null}}return U.promise}async function dt(e){let t=(await wt(e,!1))?.route;t&&await Promise.all([...t.layouts,t.leaf].filter(Boolean).map(e=>e[1]()))}async function ft(e,t,n){let r={params:G.params,route:{id:G.route?.id??null},url:new URL(location.href)};if(G={...e.state,nav:r},Me(e.props.page),et=new V.root({target:t,props:{...e.props,stores:L,components:We},hydrate:n,sync:!1,transformError:void 0}),await Promise.resolve(),n){let e={from:null,to:{...r,scroll:F[J]??E()},willUnload:!1,type:`enter`,complete:Promise.resolve()};W.forEach(t=>t(e))}st(Y),Xe=!0}async function pt({url:e,params:t,branch:n,errors:r,status:i,error:a,route:o,form:s}){let c=`never`;if(S&&(e.pathname===S||e.pathname===S+`/`))c=`always`;else for(let e of n)e?.slash!==void 0&&(c=e.slash);e.pathname=d(e.pathname,c),e.search=e.search;let l={type:`loaded`,state:{url:e,params:t,branch:n,error:a,route:o},props:{constructors:De(n).map(e=>e.node.component),page:Lt(j)}};s!==void 0&&(l.props.form=s);let u={},f=!j,p=0;for(let e=0;et(new URL(e))))return!0;return!1}function _t(e,t){return e?.type===`data`?e:e?.type===`skip`?t??null:null}function vt(e,t){if(!e)return new Set(t.searchParams.keys());let n=new Set([...e.searchParams.keys(),...t.searchParams.keys()]);for(let r of n){let i=e.searchParams.getAll(r),a=t.searchParams.getAll(r);i.every(e=>a.includes(e))&&a.every(e=>i.includes(e))&&n.delete(r)}return n}function yt({error:e,url:t,route:n,params:r}){return{type:`loaded`,state:{error:e,url:t,route:n,params:r,branch:[]},props:{page:Lt(j),constructors:[]}}}async function bt({id:e,invalidating:t,url:n,params:r,route:i,preload:a}){if(U?.id===e)return tt.delete(U.token),U.promise;let{errors:o,layouts:s,leaf:u}=i,d=[...s,u];o.forEach(e=>e?.().catch(h)),d.forEach(e=>e?.[1]().catch(h));let f=G.url?e!==Et(G.url):!1,p=G.route?i.id!==G.route.id:!1,m=vt(G.url,n),g=!1,_=d.map(async(e,t)=>{if(!e)return;let a=G.branch[t];return e[1]===a?.loader&&!gt(g,p,f,m,a.universal?.uses,r)?a:(g=!0,mt({loader:e[1],url:n,params:r,route:i,parent:async()=>{let e={};for(let n=0;nPromise.resolve({}),server_data_node:_t(null)}),{node:await z(),loader:z,universal:null,server:null,data:null}],status:e,error:t,errors:[],route:null})}catch(t){if(t instanceof l){await lt(new URL(t.location,location.href),{},0);return}let a=await V.get_error_template(),o=await $(t,{url:n,params:i,route:r}),s=a({status:e,message:String(o?.message??``).replace(/&/g,`&`).replace(//g,`>`)}),c=new DOMParser().parseFromString(s,`text/html`);throw document.documentElement.replaceChild(document.adoptNode(c.head),document.head),document.documentElement.replaceChild(document.adoptNode(c.body),document.body),t}}async function Ct(e){let t=e.href;if(Ke.has(t))return Ke.get(t);let n;try{let r=(async()=>{let t=await V.hooks.reroute({url:new URL(e),fetch:async(t,n)=>ht(t,n,e).promise})??e;if(typeof t==`string`){let n=new URL(e);V.hash?n.hash=t:n.pathname=t,t=n}return t})();Ke.set(t,r),n=await r}catch{Ke.delete(t);return}return n}async function wt(e,t){if(e&&!k(e,S,V.hash)){let n=await Ct(e);if(!n)return;let r=Tt(n);for(let n of ze){let i=n.exec(r);if(i)return{id:Et(e),invalidating:t,route:n,params:p(i),url:e}}}}function Tt(e){return f(V.hash?e.hash.replace(/^#/,``).replace(/[?#].+/,``):e.pathname.slice(S.length))||`/`}function Et(e){return(V.hash?e.hash.replace(/^#/,``):e.pathname)+e.search}function Dt({url:e,type:t,intent:n,delta:r,event:i,scroll:a}){let o=!1,s=It(G,n,e,t,a??null);r!==void 0&&(s.navigation.delta=r),i!==void 0&&(s.navigation.event=i);let c={...s.navigation,cancel:()=>{o=!0,s.reject(Error(`navigation cancelled`))}};return K||qe.forEach(e=>e(c)),o?null:s}async function Z({type:e,url:t,popped:i,keepfocus:a,noscroll:o,replace_state:s,state:c={},redirect_count:l=0,nav_token:d={},accept:f=h,block:p=h,event:m}){let g=X;X=d;let _=await wt(t,!1),v=e===`enter`?It(G,_,t,e):Dt({url:t,type:e,delta:i?.delta,intent:_,scroll:i?.scroll,event:m});if(!v){p(),X===d&&(X=g);return}let y=J,ee=Y;f(),K=!0,Xe&&v.navigation.type!==`enter`&&L.navigating.set(M.current=v.navigation);let b=_&&await bt(_);if(!b){if(k(t,S,V.hash))return await R(t,s);b=await Ot(t,{id:null},await $(new u(404,`Not Found`,`Not found: ${t.pathname}`),{url:t,params:{},route:{id:null}}),404,s)}if(t=_?.url||t,X!==d){v.reject(Error(`navigation aborted`));return}if(!b)return;if(b.type===`redirect`){if(l<20){await Z({type:e,url:new URL(b.location,t),popped:i,keepfocus:a,noscroll:o,replace_state:s,state:c,redirect_count:l+1,nav_token:d}),v.fulfil(void 0);return}if(b=await St({status:500,error:await $(Error(`Redirect loop`),{url:t,params:{},route:{id:null}}),url:t,route:{id:null}}),!b)return}else if(b.props.page.status>=400&&await L.updated.check())return await Re(),await R(t,s);if(at(),Ie(y),ot(ee),b.props.page.url.pathname!==t.pathname&&(t.pathname=b.props.page.url.pathname),c=i?i.state:c,!i){let e=+!s,n={[C]:J+=e,[w]:Y+=e,[he]:c};(s?history.replaceState:history.pushState).call(history,n,``,t),s||Le(J,Y)}let x=_&&U?.id===_.id?U.fork:null;U?.fork&&!x?Ge():(U=null,Q={element:void 0,href:void 0}),b.props.page.state=c;let te;if(Xe){let e=(await Promise.all(Array.from(Je,e=>e(v.navigation)))).filter(e=>typeof e==`function`);if(e.length>0){function t(){e.forEach(e=>{W.delete(e)})}e.push(t),e.forEach(e=>{W.add(e)})}let r=v.navigation.to;G={...b.state,nav:{params:r.params,route:r.route,url:r.url}},b.props.page&&(b.props.page.url=t),!a&&document.activeElement instanceof HTMLElement&&document.activeElement!==document.body&&document.activeElement.blur();let i=x&&await x;i?te=i.commit():(P=null,et.$set(b.props),P&&Object.assign(b.props.page,P),Me(b.props.page),te=n?.()),Qe=!0}else await ft(b,Ve,!1);let{activeElement:ne}=document;if(await te,await r(),await r(),X!==d){v.reject(Error(`navigation aborted`));return}b.props.page&&P&&Object.assign(b.props.page,P);let re=null;if(Ze){let e=i?i.scroll:o?E():null;e?scrollTo(e.x,e.y):(re=t.hash&&document.getElementById(zt(t)))?re.scrollIntoView():scrollTo(0,0)}let ie=document.activeElement!==ne&&document.activeElement!==document.body;!a&&!ie&&Ft(t,!re),Ze=!0,K=!1,v.fulfil(void 0),v.navigation.to&&(v.navigation.to.scroll=E()),W.forEach(e=>e(v.navigation)),e===`popstate`&&st(Y),L.navigating.set(M.current=null)}async function Ot(e,t,n,r,i){return e.origin===ge&&e.pathname===location.pathname&&!Ye?await St({status:r,error:n,url:e,route:t}):await R(e,i)}var Q={element:void 0,href:void 0};function kt(){let e,t;B.addEventListener(`mousemove`,t=>{let n=t.target;clearTimeout(e),e=setTimeout(()=>{i(n,T.hover)},20)});function n(e){e.defaultPrevented||i(e.composedPath()[0],T.tap)}B.addEventListener(`mousedown`,n),B.addEventListener(`touchstart`,n,{passive:!0});let r=new IntersectionObserver(e=>{for(let t of e)t.isIntersecting&&(dt(new URL(t.target.href)),r.unobserve(t.target))},{threshold:0});async function i(e,n){let r=be(e,B),i=r===Q.element&&r?.href===Q.href&&n>=t;if(!r||i)return;let{url:a,external:o,download:s}=xe(r,S,V.hash);if(o||s)return;let c=O(r),l=a&&Et(G.url)===Et(a);if(!(c.reload||l))if(n<=c.preload_data){Q={element:r,href:r.href},t=T.tap;let e=await wt(a,!1);if(!e)return;ut(e)}else n<=c.preload_code&&(Q={element:r,href:r.href},t=n,dt(a))}function a(){r.disconnect();for(let e of B.querySelectorAll(`a`)){let{url:t,external:n,download:i}=xe(e,S,V.hash);if(n||i)continue;let a=O(e);a.reload||(a.preload_code===T.viewport&&r.observe(e),a.preload_code===T.eager&&dt(t))}}W.add(a),a()}function $(e,t){if(e instanceof c)return e.body;let n=Oe(e),r=ke(e);return V.hooks.handleError({error:e,event:t,status:n,message:r})??{message:r}}function At(e){if(typeof e==`function`)H.push(e);else{let{href:t}=new URL(e,location.href);H.push(e=>e.href===t)}}function jt(){history.scrollRestoration=`manual`,addEventListener(`beforeunload`,e=>{let t=!1;if(ct(),!K){let e=It(G,void 0,null,`leave`),n={...e.navigation,cancel:()=>{t=!0,e.reject(Error(`navigation cancelled`))}};qe.forEach(e=>e(n))}t?(e.preventDefault(),e.returnValue=``):history.scrollRestoration=`auto`}),addEventListener(`visibilitychange`,()=>{document.visibilityState===`hidden`&&ct()}),navigator.connection?.saveData||kt(),B.addEventListener(`click`,async t=>{if(t.button||t.which!==1||t.metaKey||t.ctrlKey||t.shiftKey||t.altKey||t.defaultPrevented)return;let n=be(t.composedPath()[0],B);if(!n)return;let{url:r,external:i,target:a,download:o}=xe(n,S,V.hash);if(!r)return;if(a===`_parent`||a===`_top`){if(window.parent!==window)return}else if(a&&a!==`_self`)return;let s=O(n);if(!(n instanceof SVGAElement)&&r.protocol!==location.protocol&&!(r.protocol===`https:`||r.protocol===`http:`)||o)return;let[c,l]=(V.hash?r.hash.replace(/^#/,``):r.href).split(`#`),u=c===m(location);if(i||s.reload&&(!u||!l)){Dt({url:r,type:`link`,event:t})?K=!0:t.preventDefault();return}if(l!==void 0&&u){let[,i]=G.url.href.split(`#`);if(i===l){if(t.preventDefault(),l===``||l===`top`&&n.ownerDocument.getElementById(`top`)===null)scrollTo({top:0});else{let e=n.ownerDocument.getElementById(decodeURIComponent(l));e&&(e.scrollIntoView(),e.focus())}return}if(q=!0,Ie(J),e(r),!s.replace_state)return;q=!1}t.preventDefault(),await new Promise(e=>{requestAnimationFrame(()=>{setTimeout(e,0)}),setTimeout(e,100)}),await Z({type:`link`,url:r,keepfocus:s.keepfocus,noscroll:s.noscroll,replace_state:s.replace_state??r.href===location.href,event:t})}),B.addEventListener(`submit`,e=>{if(e.defaultPrevented)return;let t=HTMLFormElement.prototype.cloneNode.call(e.target),n=e.submitter;if((n?.formTarget||t.target)===`_blank`||(n?.formMethod||t.method)!==`get`)return;let r=new URL(n?.hasAttribute(`formaction`)&&n?.formAction||t.action);if(k(r,S,!1))return;let i=e.target,a=O(i);if(a.reload)return;e.preventDefault(),e.stopPropagation();let o=new FormData(i,n);r.search=new URLSearchParams(o).toString(),Z({type:`form`,url:r,keepfocus:a.keepfocus,noscroll:a.noscroll,replace_state:a.replace_state??r.href===location.href,event:e})}),addEventListener(`popstate`,async t=>{if(!Pt)if(t.state?.[`sveltekit:history`]){let n=t.state[C];if(X={},n===J)return;let r=F[n],i=t.state[`sveltekit:states`]??{},a=new URL(t.state[`sveltekit:pageurl`]??location.href),o=t.state[w],s=G.url?m(location)===m(G.url):!1;if(o===Y&&(Qe||s)){i!==j.state&&(j.state=i),e(a),F[J]=E(),r&&scrollTo(r.x,r.y),J=n;return}let c=n-J;await Z({type:`popstate`,url:a,popped:{state:i,scroll:r,delta:c},accept:()=>{J=n,Y=o},block:()=>{history.go(-c)},nav_token:X,event:t})}else q||(e(new URL(location.href)),V.hash&&location.reload())}),addEventListener(`hashchange`,()=>{q&&(q=!1,history.replaceState({...history.state,[C]:++J,[w]:Y},``,location.href))});for(let e of document.querySelectorAll(`link`))Fe.has(e.rel)&&(e.href=e.href);addEventListener(`pageshow`,e=>{e.persisted&&L.navigating.set(M.current=null)});function e(e){G.url=j.url=e,L.page.set(Lt(j)),L.page.notify()}}async function Mt(e,{status:t=200,error:n,node_ids:r,params:i,route:a,server_route:o,data:s,form:c}){Ye=!0;let u=new URL(location.href),d;({params:i={},route:a={id:null}}=await wt(u,!1)||{}),d=ze.find(({id:e})=>e===a.id);let f,p=!0;try{let e=r.map(async(t,n)=>{let r=s[n];return r?.uses&&(r.uses=Nt(r.uses)),mt({loader:V.nodes[t],url:u,params:i,route:a,parent:async()=>{let t={};for(let r=0;r{let a=history.state;Pt=!0,location.replace(new URL(`#${n}`,location.href)),history.replaceState(a,``,e),t&&scrollTo(r,i),Pt=!1})}else{let e=document.body,t=e.getAttribute(`tabindex`);e.tabIndex=-1,e.focus({preventScroll:!0,focusVisible:!1}),t===null?e.removeAttribute(`tabindex`):e.setAttribute(`tabindex`,t)}let r=getSelection();if(r&&r.type!==`None`){let e=[];for(let t=0;t{if(r.rangeCount===e.length){for(let t=0;t{a=e,o=t});return s.catch(h),{navigation:{from:{params:e.params,route:{id:e.route?.id??null},url:e.url,scroll:E()},to:n&&{params:t?.params??null,route:{id:t?.route?.id??null},url:n,scroll:i},willUnload:!t,type:r,complete:s},fulfil:a,reject:o}}function Lt(e){return{data:e.data,error:e.error,form:e.form,params:e.params,route:e.route,state:e.state,status:e.status,url:e.url}}function Rt(e){let t=new URL(e);return t.hash=decodeURIComponent(e.hash),t}function zt(e){let t;if(V.hash){let[,,n]=e.hash.split(`#`,3);t=n??``}else t=e.hash.slice(1);return decodeURIComponent(t)}export{N as a,j as i,L as n,Te as o,M as r,it as t}; \ No newline at end of file +import{G as e,K as t,M as n,N as r,X as i,j as a,r as o,t as s}from"./D7CCBGlz.js";import"./CCeg2KC3.js";var c=class{constructor(e,t){this.status=e,typeof t==`string`?this.body={message:t}:t?this.body=t:this.body={message:`Error: ${e}`}}toString(){return JSON.stringify(this.body)}},l=class{constructor(e,t){try{new Headers({location:t})}catch{throw Error(`Invalid redirect location ${JSON.stringify(t)}: this string contains characters that cannot be used in HTTP headers`)}this.status=e,this.location=t}},u=class extends Error{constructor(e,t,n){super(n),this.status=e,this.text=t}};new URL(`sveltekit-internal://`);function d(e,t){return e===`/`||t===`ignore`?e:t===`never`?e.endsWith(`/`)?e.slice(0,-1):e:t===`always`&&!e.endsWith(`/`)?e+`/`:e}function f(e){return e.split(`%25`).map(decodeURI).join(`%25`)}function p(e){for(let t in e)e[t]=decodeURIComponent(e[t]);return e}function m({href:e}){return e.split(`#`)[0]}function h(){}function g(...e){let t=5381;for(let n of e)if(typeof n==`string`){let e=n.length;for(;e;)t=t*33^n.charCodeAt(--e)}else if(ArrayBuffer.isView(n)){let e=new Uint8Array(n.buffer,n.byteOffset,n.byteLength),r=e.length;for(;r;)t=t*33^e[--r]}else throw TypeError(`value must be a string or TypedArray`);return(t>>>0).toString(36)}new TextEncoder;function _(e){let t=atob(e),n=new Uint8Array(t.length);for(let e=0;e((e instanceof Request?e.method:t?.method||`GET`)!==`GET`&&y.delete(x(e)),v(e,t));var y=new Map;function ee(e,t){let n=x(e,t),r=document.querySelector(n);if(r?.textContent){r.remove();let{body:e,...t}=JSON.parse(r.textContent);r.getAttribute(`data-b64`)!==null&&(e=_(e));let i=r.getAttribute(`data-ttl`);return i&&y.set(n,{body:e,init:t,ttl:1e3*Number(i)}),Promise.resolve(new Response(e,t))}return window.fetch(e,t)}function b(e,t,n){if(y.size>0){let t=x(e,n),r=y.get(t);if(r){if(performance.now(){let n=/^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(e);if(n)return t.push({name:n[1],matcher:n[2],optional:!1,rest:!0,chained:!0}),`(?:/([^]*))?`;let r=/^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(e);if(r)return t.push({name:r[1],matcher:r[2],optional:!0,rest:!1,chained:!0}),`(?:/([^/]+))?`;if(!e)return;let i=e.split(/\[(.+?)\](?!\])/);return`/`+i.map((e,n)=>{if(n%2){if(e.startsWith(`x+`))return se(String.fromCharCode(parseInt(e.slice(2),16)));if(e.startsWith(`u+`))return se(String.fromCharCode(...e.slice(2).split(`-`).map(e=>parseInt(e,16))));let[,r,a,o,s]=te.exec(e);return t.push({name:o,matcher:s,optional:!!r,rest:!!a,chained:a?n===1&&i[0]===``:!1}),a?`([^]*?)`:r?`([^/]*)?`:`([^/]+?)`}return se(e)}).join(``)}).join(``)}/?$`),params:t}}function ie(e){return e!==``&&!/^\([^)]+\)$/.test(e)}function ae(e){return e.slice(1).split(`/`).filter(ie)}function oe(e,t,n){let r={},i=e.slice(1),a=i.filter(e=>e!==void 0),o=0;for(let e=0;ee).join(`/`),o=0),c===void 0)if(s.rest)c=``;else continue;if(!s.matcher||n[s.matcher](c)){r[s.name]=c;let n=t[e+1],l=i[e+1];n&&!n.rest&&n.optional&&l&&s.chained&&(o=0),!n&&!l&&Object.keys(r).length===a.length&&(o=0);continue}if(s.optional&&s.chained){o++;continue}return}if(!o)return r}function se(e){return e.normalize().replace(/[[\]]/g,`\\$&`).replace(/%/g,`%25`).replace(/\//g,`%2[Ff]`).replace(/\?/g,`%3[Ff]`).replace(/#/g,`%23`).replace(/[.*+?^${}()|\\]/g,`\\$&`)}function ce({nodes:e,server_loads:t,dictionary:n,matchers:r}){let i=new Set(t);return Object.entries(n).map(([t,[n,i,s]])=>{let{pattern:c,params:l}=re(t),u={id:t,exec:e=>{let t=c.exec(e);if(t)return oe(t,l,r)},errors:[1,...s||[]].map(t=>e[t]),layouts:[0,...i||[]].map(o),leaf:a(n)};return u.errors.length=u.layouts.length=Math.max(u.errors.length,u.layouts.length),u});function a(t){let n=t<0;return n&&(t=~t),[n,e[t]]}function o(t){return t===void 0?t:[i.has(t),e[t]]}}function le(e,t=JSON.parse){try{return t(sessionStorage[e])}catch{}}function ue(e,t,n=JSON.stringify){let r=n(t);try{sessionStorage[e]=r}catch{}}var S=globalThis.__sveltekit_1dnp3xv?.base??``,de=globalThis.__sveltekit_1dnp3xv?.assets??S??``,fe=`1785631688168`,pe=`sveltekit:snapshot`,me=`sveltekit:scroll`,he=`sveltekit:states`,C=`sveltekit:history`,w=`sveltekit:navigation`,T={tap:1,hover:2,viewport:3,eager:4,off:-1,false:-1},ge=location.origin;function _e(e){if(e instanceof URL)return e;let t=document.baseURI;if(!t){let e=document.getElementsByTagName(`base`);t=e.length?e[0].href:document.URL}return new URL(e,t)}function E(){return{x:pageXOffset,y:pageYOffset}}function D(e,t){return e.getAttribute(`data-sveltekit-${t}`)}var ve={...T,"":T.hover};function ye(e){let t=e.assignedSlot??e.parentNode;return t?.nodeType===11&&(t=t.host),t}function be(e,t){for(;e&&e!==t;){if(e.nodeName.toUpperCase()===`A`&&e.hasAttribute(`href`))return e;e=ye(e)}}function xe(e,t,n){let r;try{if(r=new URL(e instanceof SVGAElement?e.href.baseVal:e.href,document.baseURI),n&&r.hash.match(/^#[^/]/)){let e=location.hash.split(`#`)[1]||`/`;r.hash=`#${e}${r.hash}`}}catch{}let i=e instanceof SVGAElement?e.target.baseVal:e.target,a=!r||!!i||k(r,t,n)||(e.getAttribute(`rel`)||``).split(/\s+/).includes(`external`),o=r?.origin===ge&&e.hasAttribute(`download`);return{url:r,external:a,target:i,download:o}}function O(e){let t=null,n=null,r=null,i=null,a=null,o=null,s=e;for(;s&&s!==document.documentElement;)r===null&&(r=D(s,`preload-code`)),i===null&&(i=D(s,`preload-data`)),t===null&&(t=D(s,`keepfocus`)),n===null&&(n=D(s,`noscroll`)),a===null&&(a=D(s,`reload`)),o===null&&(o=D(s,`replacestate`)),s=ye(s);function c(e){switch(e){case``:case`true`:return!0;case`off`:case`false`:return!1;default:return}}return{preload_code:ve[r??`off`],preload_data:ve[i??`off`],keepfocus:c(t),noscroll:c(n),reload:c(a),replace_state:c(o)}}function Se(e){let t=i(e),n=!0;function r(){n=!0,t.update(e=>e)}function a(e){n=!1,t.set(e)}function o(e){let r;return t.subscribe(t=>{(r===void 0||n&&t!==r)&&e(r=t)})}return{notify:r,set:a,subscribe:o}}var Ce={v:h};function we(){let{set:e,subscribe:t}=i(!1);async function n(){clearTimeout(void 0);try{let t=await fetch(`${de}/_app/version.json`,{headers:{pragma:`no-cache`,"cache-control":`no-cache`}});if(!t.ok)return!1;let n=(await t.json()).version!==fe;return n&&(e(!0),Ce.v(),clearTimeout(void 0)),n}catch{return!1}}return{subscribe:t,check:n}}function k(e,t,n){return e.origin!==ge||!e.pathname.startsWith(t)?!0:n?e.pathname!==location.pathname:!1}function Te(e){}var Ee=new Set([`load`,`prerender`,`csr`,`ssr`,`trailingSlash`,`config`]);[...Ee],[...new Set([...Ee])];function De(e){return e.filter(e=>e!=null)}function A(e,t){return e+`/`+t}function Oe(e){return e instanceof c||e instanceof u?e.status:500}function ke(e){return e instanceof u?e.text:`Internal Error`}var j,M,N,Ae=o.toString().includes(`$$`)||/function \w+\(\) \{\}/.test(o.toString()),je=`a:`;Ae?(j={data:{},form:null,error:null,params:{},route:{id:null},state:{},status:-1,url:new URL(je)},M={current:null},N={current:!1}):(j=new class{#e=t({});get data(){return a(this.#e)}set data(t){e(this.#e,t)}#t=t(null);get form(){return a(this.#t)}set form(t){e(this.#t,t)}#n=t(null);get error(){return a(this.#n)}set error(t){e(this.#n,t)}#r=t({});get params(){return a(this.#r)}set params(t){e(this.#r,t)}#i=t({id:null});get route(){return a(this.#i)}set route(t){e(this.#i,t)}#a=t({});get state(){return a(this.#a)}set state(t){e(this.#a,t)}#o=t(-1);get status(){return a(this.#o)}set status(t){e(this.#o,t)}#s=t(new URL(je));get url(){return a(this.#s)}set url(t){e(this.#s,t)}},M=new class{#e=t(null);get current(){return a(this.#e)}set current(t){e(this.#e,t)}},N=new class{#e=t(!1);get current(){return a(this.#e)}set current(t){e(this.#e,t)}},Ce.v=()=>N.current=!0);function Me(e){Object.assign(j,e)}var{onMount:Ne,tick:Pe}=s,Fe=new Set([`icon`,`shortcut icon`,`apple-touch-icon`]),P=null,F=le(`sveltekit:scroll`)??{},I=le(`sveltekit:snapshot`)??{},L={url:Se({}),page:Se({}),navigating:i(null),updated:we()};function Ie(e){F[e]=E()}function Le(e,t){let n=e+1;for(;F[n];)delete F[n],n+=1;for(n=t+1;I[n];)delete I[n],n+=1}function R(e,t=!1){return t?location.replace(e.href):location.href=e.href,new Promise(h)}async function Re(){if(`serviceWorker`in navigator){let e=await navigator.serviceWorker.getRegistration(S||`/`);e&&await e.update()}}var ze,Be,z,B,Ve,V,He={},Ue={},H=[],We=[],U=null;function Ge(){U?.fork?.then(e=>e?.discard()),U=null,Q={element:void 0,href:void 0}}var Ke=new Map,qe=new Set,Je=new Set,W=new Set,G={branch:[],error:null,url:null,nav:null},Ye=!1,Xe=!1,Ze=!0,K=!1,q=!1,Qe=!1,$e=!1,et,J,Y,X,tt=new Set,nt=new Map,rt=new Map;async function it(e,t,n){if(globalThis.__sveltekit_1dnp3xv.data){let{q:e={},p:t={},l:n={},f:r={}}=globalThis.__sveltekit_1dnp3xv.data;for(let t in e)He[t]=e[t];for(let e in n)He[e]=n[e];for(let e in r)He[e]=r[e];for(let e in t)Ue[e]=t[e]}document.URL!==location.href&&(location.href=location.href),V=e,await e.hooks.init?.(),ze=ce(e),B=document.documentElement,Ve=t,Be=e.nodes[0],z=e.nodes[1],Be(),z(),J=history.state?.[C],Y=history.state?.[w],J||(J=Y=Date.now(),history.replaceState({...history.state,[C]:J,[w]:Y},``));let r=F[J];function i(){r&&(history.scrollRestoration=`manual`,scrollTo(r.x,r.y))}n?(i(),await Mt(Ve,n)):(await Z({type:`enter`,url:_e(V.hash?Rt(new URL(location.href)):location.href),replace_state:!0}),i()),jt()}function at(){H.length=0,$e=!1}function ot(e){We.some(e=>e?.snapshot)&&(I[e]=We.map(e=>e?.snapshot?.capture()))}function st(e){I[e]?.forEach((e,t)=>{We[t]?.snapshot?.restore(e)})}function ct(){Ie(J),ue(me,F),ot(Y),ue(pe,I)}async function lt(e,t,n,i){let a,o;t.invalidateAll&&Ge(),await Z({type:`goto`,url:_e(e),keepfocus:t.keepFocus,noscroll:t.noScroll,replace_state:t.replaceState,state:t.state,redirect_count:n,nav_token:i,accept:()=>{if(t.invalidateAll){$e=!0,a=new Set;for(let[e,t]of nt)for(let[n,r]of t)r.resource?.reset(),a.add(A(e,n));o=new Set;for(let[e,t]of rt)for(let n of t.keys())o.add(A(e,n))}t.invalidate&&t.invalidate.forEach(At)}}),t.invalidateAll&&r().then(r).then(()=>{for(let[e,t]of nt)for(let[n,{resource:r}]of t)a?.has(A(e,n))&&r.start();for(let[e,t]of rt)for(let[n,{resource:r}]of t)o?.has(A(e,n))&&r.reconnect()})}async function ut(e){if(e.id!==U?.id){Ge();let t={};tt.add(t),U={id:e.id,token:t,promise:bt({...e,preload:t}).then(e=>(tt.delete(t),e.type===`loaded`&&e.state.error&&Ge(),e)),fork:null}}return U.promise}async function dt(e){let t=(await wt(e,!1))?.route;t&&await Promise.all([...t.layouts,t.leaf].filter(Boolean).map(e=>e[1]()))}async function ft(e,t,n){let r={params:G.params,route:{id:G.route?.id??null},url:new URL(location.href)};if(G={...e.state,nav:r},Me(e.props.page),et=new V.root({target:t,props:{...e.props,stores:L,components:We},hydrate:n,sync:!1,transformError:void 0}),await Promise.resolve(),n){let e={from:null,to:{...r,scroll:F[J]??E()},willUnload:!1,type:`enter`,complete:Promise.resolve()};W.forEach(t=>t(e))}st(Y),Xe=!0}async function pt({url:e,params:t,branch:n,errors:r,status:i,error:a,route:o,form:s}){let c=`never`;if(S&&(e.pathname===S||e.pathname===S+`/`))c=`always`;else for(let e of n)e?.slash!==void 0&&(c=e.slash);e.pathname=d(e.pathname,c),e.search=e.search;let l={type:`loaded`,state:{url:e,params:t,branch:n,error:a,route:o},props:{constructors:De(n).map(e=>e.node.component),page:Lt(j)}};s!==void 0&&(l.props.form=s);let u={},f=!j,p=0;for(let e=0;et(new URL(e))))return!0;return!1}function _t(e,t){return e?.type===`data`?e:e?.type===`skip`?t??null:null}function vt(e,t){if(!e)return new Set(t.searchParams.keys());let n=new Set([...e.searchParams.keys(),...t.searchParams.keys()]);for(let r of n){let i=e.searchParams.getAll(r),a=t.searchParams.getAll(r);i.every(e=>a.includes(e))&&a.every(e=>i.includes(e))&&n.delete(r)}return n}function yt({error:e,url:t,route:n,params:r}){return{type:`loaded`,state:{error:e,url:t,route:n,params:r,branch:[]},props:{page:Lt(j),constructors:[]}}}async function bt({id:e,invalidating:t,url:n,params:r,route:i,preload:a}){if(U?.id===e)return tt.delete(U.token),U.promise;let{errors:o,layouts:s,leaf:u}=i,d=[...s,u];o.forEach(e=>e?.().catch(h)),d.forEach(e=>e?.[1]().catch(h));let f=G.url?e!==Et(G.url):!1,p=G.route?i.id!==G.route.id:!1,m=vt(G.url,n),g=!1,_=d.map(async(e,t)=>{if(!e)return;let a=G.branch[t];return e[1]===a?.loader&&!gt(g,p,f,m,a.universal?.uses,r)?a:(g=!0,mt({loader:e[1],url:n,params:r,route:i,parent:async()=>{let e={};for(let n=0;nPromise.resolve({}),server_data_node:_t(null)}),{node:await z(),loader:z,universal:null,server:null,data:null}],status:e,error:t,errors:[],route:null})}catch(t){if(t instanceof l){await lt(new URL(t.location,location.href),{},0);return}let a=await V.get_error_template(),o=await $(t,{url:n,params:i,route:r}),s=a({status:e,message:String(o?.message??``).replace(/&/g,`&`).replace(//g,`>`)}),c=new DOMParser().parseFromString(s,`text/html`);throw document.documentElement.replaceChild(document.adoptNode(c.head),document.head),document.documentElement.replaceChild(document.adoptNode(c.body),document.body),t}}async function Ct(e){let t=e.href;if(Ke.has(t))return Ke.get(t);let n;try{let r=(async()=>{let t=await V.hooks.reroute({url:new URL(e),fetch:async(t,n)=>ht(t,n,e).promise})??e;if(typeof t==`string`){let n=new URL(e);V.hash?n.hash=t:n.pathname=t,t=n}return t})();Ke.set(t,r),n=await r}catch{Ke.delete(t);return}return n}async function wt(e,t){if(e&&!k(e,S,V.hash)){let n=await Ct(e);if(!n)return;let r=Tt(n);for(let n of ze){let i=n.exec(r);if(i)return{id:Et(e),invalidating:t,route:n,params:p(i),url:e}}}}function Tt(e){return f(V.hash?e.hash.replace(/^#/,``).replace(/[?#].+/,``):e.pathname.slice(S.length))||`/`}function Et(e){return(V.hash?e.hash.replace(/^#/,``):e.pathname)+e.search}function Dt({url:e,type:t,intent:n,delta:r,event:i,scroll:a}){let o=!1,s=It(G,n,e,t,a??null);r!==void 0&&(s.navigation.delta=r),i!==void 0&&(s.navigation.event=i);let c={...s.navigation,cancel:()=>{o=!0,s.reject(Error(`navigation cancelled`))}};return K||qe.forEach(e=>e(c)),o?null:s}async function Z({type:e,url:t,popped:i,keepfocus:a,noscroll:o,replace_state:s,state:c={},redirect_count:l=0,nav_token:d={},accept:f=h,block:p=h,event:m}){let g=X;X=d;let _=await wt(t,!1),v=e===`enter`?It(G,_,t,e):Dt({url:t,type:e,delta:i?.delta,intent:_,scroll:i?.scroll,event:m});if(!v){p(),X===d&&(X=g);return}let y=J,ee=Y;f(),K=!0,Xe&&v.navigation.type!==`enter`&&L.navigating.set(M.current=v.navigation);let b=_&&await bt(_);if(!b){if(k(t,S,V.hash))return await R(t,s);b=await Ot(t,{id:null},await $(new u(404,`Not Found`,`Not found: ${t.pathname}`),{url:t,params:{},route:{id:null}}),404,s)}if(t=_?.url||t,X!==d){v.reject(Error(`navigation aborted`));return}if(!b)return;if(b.type===`redirect`){if(l<20){await Z({type:e,url:new URL(b.location,t),popped:i,keepfocus:a,noscroll:o,replace_state:s,state:c,redirect_count:l+1,nav_token:d}),v.fulfil(void 0);return}if(b=await St({status:500,error:await $(Error(`Redirect loop`),{url:t,params:{},route:{id:null}}),url:t,route:{id:null}}),!b)return}else if(b.props.page.status>=400&&await L.updated.check())return await Re(),await R(t,s);if(at(),Ie(y),ot(ee),b.props.page.url.pathname!==t.pathname&&(t.pathname=b.props.page.url.pathname),c=i?i.state:c,!i){let e=+!s,n={[C]:J+=e,[w]:Y+=e,[he]:c};(s?history.replaceState:history.pushState).call(history,n,``,t),s||Le(J,Y)}let x=_&&U?.id===_.id?U.fork:null;U?.fork&&!x?Ge():(U=null,Q={element:void 0,href:void 0}),b.props.page.state=c;let te;if(Xe){let e=(await Promise.all(Array.from(Je,e=>e(v.navigation)))).filter(e=>typeof e==`function`);if(e.length>0){function t(){e.forEach(e=>{W.delete(e)})}e.push(t),e.forEach(e=>{W.add(e)})}let r=v.navigation.to;G={...b.state,nav:{params:r.params,route:r.route,url:r.url}},b.props.page&&(b.props.page.url=t),!a&&document.activeElement instanceof HTMLElement&&document.activeElement!==document.body&&document.activeElement.blur();let i=x&&await x;i?te=i.commit():(P=null,et.$set(b.props),P&&Object.assign(b.props.page,P),Me(b.props.page),te=n?.()),Qe=!0}else await ft(b,Ve,!1);let{activeElement:ne}=document;if(await te,await r(),await r(),X!==d){v.reject(Error(`navigation aborted`));return}b.props.page&&P&&Object.assign(b.props.page,P);let re=null;if(Ze){let e=i?i.scroll:o?E():null;e?scrollTo(e.x,e.y):(re=t.hash&&document.getElementById(zt(t)))?re.scrollIntoView():scrollTo(0,0)}let ie=document.activeElement!==ne&&document.activeElement!==document.body;!a&&!ie&&Ft(t,!re),Ze=!0,K=!1,v.fulfil(void 0),v.navigation.to&&(v.navigation.to.scroll=E()),W.forEach(e=>e(v.navigation)),e===`popstate`&&st(Y),L.navigating.set(M.current=null)}async function Ot(e,t,n,r,i){return e.origin===ge&&e.pathname===location.pathname&&!Ye?await St({status:r,error:n,url:e,route:t}):await R(e,i)}var Q={element:void 0,href:void 0};function kt(){let e,t;B.addEventListener(`mousemove`,t=>{let n=t.target;clearTimeout(e),e=setTimeout(()=>{i(n,T.hover)},20)});function n(e){e.defaultPrevented||i(e.composedPath()[0],T.tap)}B.addEventListener(`mousedown`,n),B.addEventListener(`touchstart`,n,{passive:!0});let r=new IntersectionObserver(e=>{for(let t of e)t.isIntersecting&&(dt(new URL(t.target.href)),r.unobserve(t.target))},{threshold:0});async function i(e,n){let r=be(e,B),i=r===Q.element&&r?.href===Q.href&&n>=t;if(!r||i)return;let{url:a,external:o,download:s}=xe(r,S,V.hash);if(o||s)return;let c=O(r),l=a&&Et(G.url)===Et(a);if(!(c.reload||l))if(n<=c.preload_data){Q={element:r,href:r.href},t=T.tap;let e=await wt(a,!1);if(!e)return;ut(e)}else n<=c.preload_code&&(Q={element:r,href:r.href},t=n,dt(a))}function a(){r.disconnect();for(let e of B.querySelectorAll(`a`)){let{url:t,external:n,download:i}=xe(e,S,V.hash);if(n||i)continue;let a=O(e);a.reload||(a.preload_code===T.viewport&&r.observe(e),a.preload_code===T.eager&&dt(t))}}W.add(a),a()}function $(e,t){if(e instanceof c)return e.body;let n=Oe(e),r=ke(e);return V.hooks.handleError({error:e,event:t,status:n,message:r})??{message:r}}function At(e){if(typeof e==`function`)H.push(e);else{let{href:t}=new URL(e,location.href);H.push(e=>e.href===t)}}function jt(){history.scrollRestoration=`manual`,addEventListener(`beforeunload`,e=>{let t=!1;if(ct(),!K){let e=It(G,void 0,null,`leave`),n={...e.navigation,cancel:()=>{t=!0,e.reject(Error(`navigation cancelled`))}};qe.forEach(e=>e(n))}t?(e.preventDefault(),e.returnValue=``):history.scrollRestoration=`auto`}),addEventListener(`visibilitychange`,()=>{document.visibilityState===`hidden`&&ct()}),navigator.connection?.saveData||kt(),B.addEventListener(`click`,async t=>{if(t.button||t.which!==1||t.metaKey||t.ctrlKey||t.shiftKey||t.altKey||t.defaultPrevented)return;let n=be(t.composedPath()[0],B);if(!n)return;let{url:r,external:i,target:a,download:o}=xe(n,S,V.hash);if(!r)return;if(a===`_parent`||a===`_top`){if(window.parent!==window)return}else if(a&&a!==`_self`)return;let s=O(n);if(!(n instanceof SVGAElement)&&r.protocol!==location.protocol&&!(r.protocol===`https:`||r.protocol===`http:`)||o)return;let[c,l]=(V.hash?r.hash.replace(/^#/,``):r.href).split(`#`),u=c===m(location);if(i||s.reload&&(!u||!l)){Dt({url:r,type:`link`,event:t})?K=!0:t.preventDefault();return}if(l!==void 0&&u){let[,i]=G.url.href.split(`#`);if(i===l){if(t.preventDefault(),l===``||l===`top`&&n.ownerDocument.getElementById(`top`)===null)scrollTo({top:0});else{let e=n.ownerDocument.getElementById(decodeURIComponent(l));e&&(e.scrollIntoView(),e.focus())}return}if(q=!0,Ie(J),e(r),!s.replace_state)return;q=!1}t.preventDefault(),await new Promise(e=>{requestAnimationFrame(()=>{setTimeout(e,0)}),setTimeout(e,100)}),await Z({type:`link`,url:r,keepfocus:s.keepfocus,noscroll:s.noscroll,replace_state:s.replace_state??r.href===location.href,event:t})}),B.addEventListener(`submit`,e=>{if(e.defaultPrevented)return;let t=HTMLFormElement.prototype.cloneNode.call(e.target),n=e.submitter;if((n?.formTarget||t.target)===`_blank`||(n?.formMethod||t.method)!==`get`)return;let r=new URL(n?.hasAttribute(`formaction`)&&n?.formAction||t.action);if(k(r,S,!1))return;let i=e.target,a=O(i);if(a.reload)return;e.preventDefault(),e.stopPropagation();let o=new FormData(i,n);r.search=new URLSearchParams(o).toString(),Z({type:`form`,url:r,keepfocus:a.keepfocus,noscroll:a.noscroll,replace_state:a.replace_state??r.href===location.href,event:e})}),addEventListener(`popstate`,async t=>{if(!Pt)if(t.state?.[`sveltekit:history`]){let n=t.state[C];if(X={},n===J)return;let r=F[n],i=t.state[`sveltekit:states`]??{},a=new URL(t.state[`sveltekit:pageurl`]??location.href),o=t.state[w],s=G.url?m(location)===m(G.url):!1;if(o===Y&&(Qe||s)){i!==j.state&&(j.state=i),e(a),F[J]=E(),r&&scrollTo(r.x,r.y),J=n;return}let c=n-J;await Z({type:`popstate`,url:a,popped:{state:i,scroll:r,delta:c},accept:()=>{J=n,Y=o},block:()=>{history.go(-c)},nav_token:X,event:t})}else q||(e(new URL(location.href)),V.hash&&location.reload())}),addEventListener(`hashchange`,()=>{q&&(q=!1,history.replaceState({...history.state,[C]:++J,[w]:Y},``,location.href))});for(let e of document.querySelectorAll(`link`))Fe.has(e.rel)&&(e.href=e.href);addEventListener(`pageshow`,e=>{e.persisted&&L.navigating.set(M.current=null)});function e(e){G.url=j.url=e,L.page.set(Lt(j)),L.page.notify()}}async function Mt(e,{status:t=200,error:n,node_ids:r,params:i,route:a,server_route:o,data:s,form:c}){Ye=!0;let u=new URL(location.href),d;({params:i={},route:a={id:null}}=await wt(u,!1)||{}),d=ze.find(({id:e})=>e===a.id);let f,p=!0;try{let e=r.map(async(t,n)=>{let r=s[n];return r?.uses&&(r.uses=Nt(r.uses)),mt({loader:V.nodes[t],url:u,params:i,route:a,parent:async()=>{let t={};for(let r=0;r{let a=history.state;Pt=!0,location.replace(new URL(`#${n}`,location.href)),history.replaceState(a,``,e),t&&scrollTo(r,i),Pt=!1})}else{let e=document.body,t=e.getAttribute(`tabindex`);e.tabIndex=-1,e.focus({preventScroll:!0,focusVisible:!1}),t===null?e.removeAttribute(`tabindex`):e.setAttribute(`tabindex`,t)}let r=getSelection();if(r&&r.type!==`None`){let e=[];for(let t=0;t{if(r.rangeCount===e.length){for(let t=0;t{a=e,o=t});return s.catch(h),{navigation:{from:{params:e.params,route:{id:e.route?.id??null},url:e.url,scroll:E()},to:n&&{params:t?.params??null,route:{id:t?.route?.id??null},url:n,scroll:i},willUnload:!t,type:r,complete:s},fulfil:a,reject:o}}function Lt(e){return{data:e.data,error:e.error,form:e.form,params:e.params,route:e.route,state:e.state,status:e.status,url:e.url}}function Rt(e){let t=new URL(e);return t.hash=decodeURIComponent(e.hash),t}function zt(e){let t;if(V.hash){let[,,n]=e.hash.split(`#`,3);t=n??``}else t=e.hash.slice(1);return decodeURIComponent(t)}export{N as a,j as i,L as n,Te as o,M as r,it as t}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/entry/app.BJNxxL8q.js b/crates/carapace-api/static/_app/immutable/entry/app.Cz953gq6.js similarity index 87% rename from crates/carapace-api/static/_app/immutable/entry/app.BJNxxL8q.js rename to crates/carapace-api/static/_app/immutable/entry/app.Cz953gq6.js index 144b1b8..6919f33 100644 --- a/crates/carapace-api/static/_app/immutable/entry/app.BJNxxL8q.js +++ b/crates/carapace-api/static/_app/immutable/entry/app.Cz953gq6.js @@ -1,2 +1,2 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["../nodes/0.DSqSDKgT.js","../chunks/D7CCBGlz.js","../chunks/xihTtKlq.js","../assets/0.BIBOqY7u.css","../nodes/1.X1iBfBii.js","../chunks/Cfkx4BKF.js","../chunks/CCeg2KC3.js","../nodes/2.CaWSwQlO.js","../assets/2.Ccp3aMT0.css"])))=>i.map(i=>d[i]); -import{$ as e,C as t,D as n,E as r,G as i,H as a,I as o,K as s,L as c,N as l,Q as u,R as d,S as f,T as p,U as m,V as h,a as g,i as _,j as v,nt as y,o as b,q as x,r as S,v as C,w}from"../chunks/D7CCBGlz.js";import"../chunks/xihTtKlq.js";var T=`modulepreload`,E=function(e,t){return new URL(e,t).href},D={},O=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=E(t,n),t=s(t),t in D)return;D[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:T,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},k={},A=r(`
`),j=r(` `,1);function M(r,_){e(_,!0);let T=g(_,`components`,23,()=>[]),E=g(_,`data_0`,3,null),D=g(_,`data_1`,3,null);d(()=>_.stores.page.set(_.page)),c(()=>{_.stores,_.page,_.constructors,T(),_.form,E(),D(),_.stores.page.notify()});let O=s(!1),k=s(!1),M=s(null);S(()=>{let e=_.stores.page.subscribe(()=>{v(O)&&(i(k,!0),l().then(()=>{i(M,document.title||`untitled page`,!0)}))});return i(O,!0),e});let N=x(()=>_.constructors[1]);var P=j(),F=a(P),I=e=>{let t=x(()=>_.constructors[0]);var n=p();C(a(n),()=>v(t),(e,t)=>{b(t(e,{get data(){return E()},get form(){return _.form},get params(){return _.page.params},children:(e,t)=>{var n=p();C(a(n),()=>v(N),(e,t)=>{b(t(e,{get data(){return D()},get form(){return _.form},get params(){return _.page.params}}),e=>T()[1]=e,()=>T()?.[1])}),w(e,n)},$$slots:{default:!0}}),e=>T()[0]=e,()=>T()?.[0])}),w(e,n)},L=e=>{let t=x(()=>_.constructors[0]);var n=p();C(a(n),()=>v(t),(e,t)=>{b(t(e,{get data(){return E()},get form(){return _.form},get params(){return _.page.params}}),e=>T()[0]=e,()=>T()?.[0])}),w(e,n)};f(F,e=>{_.constructors[1]?e(I):e(L,-1)});var R=m(F,2),z=e=>{var r=A(),i=h(r),a=e=>{var r=n();o(()=>t(r,v(M))),w(e,r)};f(i,e=>{v(k)&&e(a)}),y(r),w(e,r)};f(R,e=>{v(O)&&e(z)}),w(r,P),u()}var N=_(M),P=[()=>O(()=>import(`../nodes/0.DSqSDKgT.js`),__vite__mapDeps([0,1,2,3]),import.meta.url),()=>O(()=>import(`../nodes/1.X1iBfBii.js`),__vite__mapDeps([4,1,5,6,2]),import.meta.url),()=>O(()=>import(`../nodes/2.CaWSwQlO.js`),__vite__mapDeps([7,1,6,2,8]),import.meta.url)],F=[],I={"/":[2]},L={handleError:(({error:e})=>{console.error(e)}),reroute:(()=>{}),transport:{}},R=Object.fromEntries(Object.entries(L.transport).map(([e,t])=>[e,t.decode])),z=Object.fromEntries(Object.entries(L.transport).map(([e,t])=>[e,t.encode])),B=!1,V=(e,t)=>R[e](t),H=()=>O(()=>import(`../chunks/Bjy-W4x2.js`).then(e=>e.default),[],import.meta.url);export{V as decode,R as decoders,I as dictionary,z as encoders,H as get_error_template,B as hash,L as hooks,k as matchers,P as nodes,N as root,F as server_loads}; \ No newline at end of file +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["../nodes/0.CCSq_TiJ.js","../chunks/D7CCBGlz.js","../chunks/xihTtKlq.js","../assets/0.BNChGHTQ.css","../nodes/1.y4-4BiaA.js","../chunks/BdOmOXD3.js","../chunks/CCeg2KC3.js","../nodes/2.BZ_ZwSYn.js","../assets/2.BO-zofLV.css"])))=>i.map(i=>d[i]); +import{$ as e,C as t,D as n,E as r,G as i,H as a,I as o,K as s,L as c,N as l,Q as u,R as d,S as f,T as p,U as m,V as h,a as g,i as _,j as v,nt as y,o as b,q as x,r as S,v as C,w}from"../chunks/D7CCBGlz.js";import"../chunks/xihTtKlq.js";var T=`modulepreload`,E=function(e,t){return new URL(e,t).href},D={},O=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=E(t,n),t=s(t),t in D)return;D[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:T,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},k={},A=r(`
`),j=r(` `,1);function M(r,_){e(_,!0);let T=g(_,`components`,23,()=>[]),E=g(_,`data_0`,3,null),D=g(_,`data_1`,3,null);d(()=>_.stores.page.set(_.page)),c(()=>{_.stores,_.page,_.constructors,T(),_.form,E(),D(),_.stores.page.notify()});let O=s(!1),k=s(!1),M=s(null);S(()=>{let e=_.stores.page.subscribe(()=>{v(O)&&(i(k,!0),l().then(()=>{i(M,document.title||`untitled page`,!0)}))});return i(O,!0),e});let N=x(()=>_.constructors[1]);var P=j(),F=a(P),I=e=>{let t=x(()=>_.constructors[0]);var n=p();C(a(n),()=>v(t),(e,t)=>{b(t(e,{get data(){return E()},get form(){return _.form},get params(){return _.page.params},children:(e,t)=>{var n=p();C(a(n),()=>v(N),(e,t)=>{b(t(e,{get data(){return D()},get form(){return _.form},get params(){return _.page.params}}),e=>T()[1]=e,()=>T()?.[1])}),w(e,n)},$$slots:{default:!0}}),e=>T()[0]=e,()=>T()?.[0])}),w(e,n)},L=e=>{let t=x(()=>_.constructors[0]);var n=p();C(a(n),()=>v(t),(e,t)=>{b(t(e,{get data(){return E()},get form(){return _.form},get params(){return _.page.params}}),e=>T()[0]=e,()=>T()?.[0])}),w(e,n)};f(F,e=>{_.constructors[1]?e(I):e(L,-1)});var R=m(F,2),z=e=>{var r=A(),i=h(r),a=e=>{var r=n();o(()=>t(r,v(M))),w(e,r)};f(i,e=>{v(k)&&e(a)}),y(r),w(e,r)};f(R,e=>{v(O)&&e(z)}),w(r,P),u()}var N=_(M),P=[()=>O(()=>import(`../nodes/0.CCSq_TiJ.js`),__vite__mapDeps([0,1,2,3]),import.meta.url),()=>O(()=>import(`../nodes/1.y4-4BiaA.js`),__vite__mapDeps([4,1,5,6,2]),import.meta.url),()=>O(()=>import(`../nodes/2.BZ_ZwSYn.js`),__vite__mapDeps([7,1,6,2,8]),import.meta.url)],F=[],I={"/":[2]},L={handleError:(({error:e})=>{console.error(e)}),reroute:(()=>{}),transport:{}},R=Object.fromEntries(Object.entries(L.transport).map(([e,t])=>[e,t.decode])),z=Object.fromEntries(Object.entries(L.transport).map(([e,t])=>[e,t.encode])),B=!1,V=(e,t)=>R[e](t),H=()=>O(()=>import(`../chunks/Bjy-W4x2.js`).then(e=>e.default),[],import.meta.url);export{V as decode,R as decoders,I as dictionary,z as encoders,H as get_error_template,B as hash,L as hooks,k as matchers,P as nodes,N as root,F as server_loads}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/entry/start.2-caBSwK.js b/crates/carapace-api/static/_app/immutable/entry/start.2-caBSwK.js new file mode 100644 index 0000000..dc3b7b3 --- /dev/null +++ b/crates/carapace-api/static/_app/immutable/entry/start.2-caBSwK.js @@ -0,0 +1 @@ +import{o as e,t}from"../chunks/BdOmOXD3.js";export{e as load_css,t as start}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/entry/start.CbdnOVWP.js b/crates/carapace-api/static/_app/immutable/entry/start.CbdnOVWP.js deleted file mode 100644 index d197d45..0000000 --- a/crates/carapace-api/static/_app/immutable/entry/start.CbdnOVWP.js +++ /dev/null @@ -1 +0,0 @@ -import{o as e,t}from"../chunks/Cfkx4BKF.js";export{e as load_css,t as start}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/nodes/0.DSqSDKgT.js b/crates/carapace-api/static/_app/immutable/nodes/0.CCSq_TiJ.js similarity index 100% rename from crates/carapace-api/static/_app/immutable/nodes/0.DSqSDKgT.js rename to crates/carapace-api/static/_app/immutable/nodes/0.CCSq_TiJ.js diff --git a/crates/carapace-api/static/_app/immutable/nodes/1.X1iBfBii.js b/crates/carapace-api/static/_app/immutable/nodes/1.y4-4BiaA.js similarity index 89% rename from crates/carapace-api/static/_app/immutable/nodes/1.X1iBfBii.js rename to crates/carapace-api/static/_app/immutable/nodes/1.y4-4BiaA.js index f81df96..4e6c7d6 100644 --- a/crates/carapace-api/static/_app/immutable/nodes/1.X1iBfBii.js +++ b/crates/carapace-api/static/_app/immutable/nodes/1.y4-4BiaA.js @@ -1 +1 @@ -import{$ as e,C as t,E as n,H as r,I as i,Q as a,U as o,V as s,nt as c,w as l}from"../chunks/D7CCBGlz.js";import{i as u,n as d}from"../chunks/Cfkx4BKF.js";import"../chunks/xihTtKlq.js";var f={get data(){return u.data},get error(){return u.error},get form(){return u.form},get params(){return u.params},get route(){return u.route},get state(){return u.state},get status(){return u.status},get url(){return u.url}};d.updated.check;var p=f,m=n(`

`,1);function h(n,u){e(u,!0);var d=m(),f=r(d),h=s(f,!0);c(f);var g=o(f,2),_=s(g,!0);c(g),i(()=>{t(h,p.status),t(_,p.error?.message)}),l(n,d),a()}export{h as component}; \ No newline at end of file +import{$ as e,C as t,E as n,H as r,I as i,Q as a,U as o,V as s,nt as c,w as l}from"../chunks/D7CCBGlz.js";import{i as u,n as d}from"../chunks/BdOmOXD3.js";import"../chunks/xihTtKlq.js";var f={get data(){return u.data},get error(){return u.error},get form(){return u.form},get params(){return u.params},get route(){return u.route},get state(){return u.state},get status(){return u.status},get url(){return u.url}};d.updated.check;var p=f,m=n(`

`,1);function h(n,u){e(u,!0);var d=m(),f=r(d),h=s(f,!0);c(f);var g=o(f,2),_=s(g,!0);c(g),i(()=>{t(h,p.status),t(_,p.error?.message)}),l(n,d),a()}export{h as component}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/nodes/2.BZ_ZwSYn.js b/crates/carapace-api/static/_app/immutable/nodes/2.BZ_ZwSYn.js new file mode 100644 index 0000000..81adb53 --- /dev/null +++ b/crates/carapace-api/static/_app/immutable/nodes/2.BZ_ZwSYn.js @@ -0,0 +1,26 @@ +import{$ as e,A as t,B as n,C as r,D as i,E as a,F as o,G as s,H as c,I as l,J as u,K as d,L as f,O as p,Q as m,S as h,T as g,U as _,V as v,W as y,X as b,Y as ee,_ as x,a as S,b as C,c as w,d as T,f as E,g as D,h as O,j as k,k as A,l as j,m as M,n as N,nt as P,p as te,q as ne,r as F,rt as I,s as L,tt as R,u as z,w as B,x as V,z as re}from"../chunks/D7CCBGlz.js";import"../chunks/CCeg2KC3.js";import"../chunks/xihTtKlq.js";function H(){return typeof window<`u`&&window.__CARAPACE_TOKEN__?window.__CARAPACE_TOKEN__:``}var U=b(null);function W(e){U.set(e)}function G(){U.set(null)}var ie=``,K=class extends Error{status;constructor(e,t){super(e),this.status=t}};async function ae(e,t){let n;try{n=await fetch(ie+e,{...t,headers:{...t?.body?{"Content-Type":`application/json`}:{},Authorization:`Bearer ${H()}`,...t?.headers}})}catch(t){let n=`Could not reach the daemon (${e}). Is it running? (${t.message})`;throw W(n),new K(n)}if(n.status===401||n.status===403){let e=n.status===401?`The daemon rejected this session (bad or missing token). Reload the page.`:`The daemon refused this request (Host/Origin guard). Reload the page.`;throw W(e),new K(e,n.status)}if(!n.ok){let t=n.statusText;try{let e=await n.json();typeof e?.error==`string`&&(t=e.error)}catch{}throw W(`${e} failed: ${t}`),new K(t,n.status)}return await n.json()}async function q(e,t){let n;try{n=await fetch(ie+e,{...t,headers:{Authorization:`Bearer ${H()}`,...t?.headers}})}catch(t){let n=`Could not reach the daemon (${e}). Is it running? (${t.message})`;throw W(n),new K(n)}if(n.status===401||n.status===403){let e=n.status===401?`The daemon rejected this session (bad or missing token). Reload the page.`:`The daemon refused this request (Host/Origin guard). Reload the page.`;throw W(e),new K(e,n.status)}if(!n.ok){let t=n.statusText;try{let e=await n.json();typeof e?.error==`string`&&(t=e.error)}catch{}throw W(`${e} failed: ${t}`),new K(t,n.status)}return n.text()}var J=e=>ae(e),Y=(e,t)=>ae(e,{method:`POST`,body:t===void 0?void 0:JSON.stringify(t)}),X={health:()=>J(`/api/health`),status:()=>J(`/api/status`),syncOwned:(e,t)=>Y(`/api/sync`,{peer:e,out_dir:t}),listVaults:()=>J(`/api/vaults`),publishVault:(e,t)=>Y(`/api/vaults`,{dir:e,vid:t}),listReplicas:e=>J(`/api/vaults/${e}/replicas`),placeReplicas:(e,t,n)=>Y(`/api/vaults/${e}/replicas`,{peers:t,r:n}),discloseFiles:(e,t,n)=>Y(`/api/vaults/${e}/grants`,{paths:t,audience:n}),fetchGrant:(e,t,n)=>Y(`/api/grants/fetch`,{grant_hex:e,owner:t,out_dir:n}),listFriends:()=>J(`/api/friends`),issueTicket:()=>Y(`/api/friends/ticket`),addFriend:(e,t,n)=>Y(`/api/friends`,{ticket_hex:e,addrs:t,grant_bytes:n}),unfriend:e=>Y(`/api/friends/${e}/unfriend`),resplitStatus:e=>J(`/api/recovery/${e}/resplit-status`),ceremonyStatus:()=>J(`/api/recovery/ceremony`),paperCards:e=>q(`/api/recovery/${e}/paper`),resplitStart:(e,t)=>Y(`/api/recovery/${e}/resplit-start`,t?{trustees:t}:void 0),recoverySplit:(e,t,n,r,i)=>Y(`/api/recovery/split`,{rsid:e,scope:t,m:n,n:r,allow_over_cap:i}),recoverySplitToTrustees:(e,t,n,r,i)=>Y(`/api/recovery/split`,{rsid:e,scope:t,m:n,trustees:r,allow_over_cap:i}),recoveryResplit:(e,t,n,r,i)=>Y(`/api/recovery/resplit`,{rsid:e,scope:t,m:n,n:r,allow_over_cap:i}),recoveryResplitToTrustees:(e,t,n,r,i)=>Y(`/api/recovery/resplit`,{rsid:e,scope:t,m:n,trustees:r,allow_over_cap:i}),recoveryExtend:(e,t,n)=>Y(`/api/recovery/extend`,{rsid:e,count:t,allow_over_cap:n}),restartRestore:e=>Y(`/api/recovery/restart-restore`,{out_dir:e}),ceremonyOpen:e=>Y(`/api/recovery/ceremony/open`,e),ceremonyApprove:e=>Y(`/api/recovery/ceremony/approve`,{ceremony_id:e}),ceremonyAbort:e=>Y(`/api/recovery/ceremony/abort`,{ceremony_id:e})},oe=b(null),se=b(!1),Z=null,ce=1e3,Q=null,le=!0;function ue(){if(typeof window>`u`||le)return;let e=`${location.protocol===`https:`?`wss:`:`ws:`}//${location.host}/api/events`;Z=new WebSocket(e),Z.onopen=()=>{se.set(!0),ce=1e3},Z.onmessage=e=>{try{oe.set(JSON.parse(e.data))}catch{}},Z.onclose=()=>{se.set(!1),!le&&(Q=setTimeout(ue,ce),ce=Math.min(ce*2,15e3))},Z.onerror=()=>{Z?.close()}}function de(){fe(),le=!1,X.status().then(e=>oe.set(e)).catch(()=>{}),ue()}function fe(){le=!0,Q!==null&&(clearTimeout(Q),Q=null);let e=Z;Z=null,e?.close(),se.set(!1)}var pe=a(``);function me(e){let t=()=>ee(U,`$lastError`,n),[n,i]=u();var a=g(),o=c(a),s=e=>{var n=pe(),i=v(n),a=v(i,!0);P(i);var o=_(i,2);P(n),l(()=>r(a,t())),A(`click`,o,function(...e){G?.apply(this,e)}),B(e,n)};h(o,e=>{t()&&e(s)}),B(e,a),i()}p([`click`]);var he=a(``),ge=a(`
`),_e=a(`
`);function $(e,t){function n(e){let t=Math.max(e.target,e.achieved,1);return Array.from({length:t},(t,n)=>({filled:nt.plates,e=>e.key,(e,t,i)=>{var a=ge(),o=v(a);C(o,21,()=>n(k(t)),V,(e,t)=>{var n=he();let r;l(()=>r=O(n,1,`segment svelte-v2pnom`,null,r,{filled:k(t).filled})),B(e,n)}),P(o);var s=_(o,2),c=v(s,!0);P(s);var u=_(s,2),d=v(u,!0);P(u);var f=_(u,2),p=v(f,!0);P(f),P(a),l(()=>{O(a,1,`plate-group state-${k(t).state??``}`,`svelte-v2pnom`),M(a,`--i: ${k(i)??``}`),r(c,k(t).label),r(d,k(t).valueLabel),r(p,k(t).note)}),B(e,a)}),P(i),B(e,i)}function ve(e,t=8,n=6){return e.length<=t+n+1?e:`${e.slice(0,t)}…${e.slice(-n)}`}async function ye(e){try{return await navigator.clipboard.writeText(e),!0}catch{return W(`Could not copy to the clipboard. Select and copy the value manually.`),!1}}var be=[`B`,`KiB`,`MiB`,`GiB`,`TiB`];function xe(e){if(!Number.isFinite(e)||e<0)return`—`;if(e===0)return`0 B`;let t=Math.min(Math.floor(Math.log2(e)/10),be.length-1),n=e/2**(10*t);return`${n>=10||t===0?Math.round(n):n.toFixed(1)} ${be[t]}`}var Se=a(``);function Ce(t,n){e(n,!0);let i=S(n,`head`,3,8),a=S(n,`tail`,3,6),o=d(!1);async function c(){await ye(n.value)&&(s(o,!0),setTimeout(()=>s(o,!1),1200))}var u=Se(),f=v(u),p=_(f),h=v(p,!0);P(p),P(u),l(e=>{T(u,`title`,n.value),T(u,`aria-label`,`Copy ${n.value??``}`),r(f,`${e??``} `),r(h,k(o)?`copied`:`copy`)},[()=>ve(n.value,i(),a())]),A(`click`,u,c),B(t,u),m()}p([`click`]);var we=a(`
This node
Friends storing your vaults
Vaults published
`,1),Te=a(`

Waiting for the daemon…

`),Ee=a(`

Shell integrity

`);function De(t,n){e(n,!0);let i=()=>ee(oe,`$status`,a),[a,o]=u(),p=d(null);f(()=>{let e=i()?.vaults.published??[];if(e.length===0){s(p,null);return}Promise.all(e.map(e=>X.listReplicas(e.vid).catch(()=>({members:[]})))).then(e=>{s(p,Math.min(...e.map(e=>e.members.length)),!0)}).catch(()=>{s(p,null)})});let g=ne(()=>{let e=i();if(!e)return[];let t=e.vaults.published.length,n=k(p)??0,r={key:`replicas`,label:`Replicas`,achieved:t===0?0:Math.min(n,3),target:3,valueLabel:t===0?`—`:`${n}/3`,state:t===0?`empty`:n>=3?`healthy`:`at-risk`,note:t===0?`No vaults published yet`:`Weakest vault held by ${n} friend${n===1?``:`s`}`},a=e.share_health.recovery_sets_owned,o=e.share_health.sets.find(e=>e.scope.kind===`root`),s={key:`shares`,label:`Recovery shares`,achieved:+(a>0),target:1,valueLabel:o?`${o.threshold}-of-${o.issued}`:a>0?`split`:`—`,state:a>0?`healthy`:`empty`,note:a>0?`${e.share_health.shares_held} share${e.share_health.shares_held===1?``:`s`} held here in trust for others`:`Your key has no trustees yet - nobody could rebuild it`},c=e.addr.length,l=e.relay_networks,u=e.relay_diversity_warning||l<2;return[r,s,{key:`relays`,label:`Reachability`,achieved:Math.min(l,2),target:2,valueLabel:`${l}`,state:c===0?`empty`:u?`at-risk`:`healthy`,note:u?`Only ${l} relay network${l===1?``:`s`} - add a friend's relay so you can still be reached if one drops`:`${e.reachability} · ${l} relay networks, ${c} dialable address${c===1?``:`es`}`}]});var y=Ee(),b=_(v(y),2),x=e=>{var t=we(),n=c(t);$(n,{get plates(){return k(g)}});var a=_(n,2),o=v(a);Ce(_(v(o),2),{get value(){return i().node_id},head:12,tail:8}),P(o);var s=_(o,2),u=_(v(s),2),d=v(u,!0);P(u),P(s);var f=_(s,2),p=_(v(f),2),m=v(p,!0);P(p),P(f),P(a),R(2),l(()=>{r(d,i().friends.count),r(m,i().vaults.published.length)}),B(e,t)},S=e=>{B(e,Te())};h(b,e=>{i()?e(x):e(S,-1)}),P(y),B(t,y),m(),o()}var Oe=a(``),ke=a(``),Ae=a(`

Sync is in progress.

`),je=a(`

The sync completed. There were no new vault versions to restore.

`),Me=a(`
  • `),Ne=a(`

      `,1),Pe=a(`

      Loading vaults…

      `),Fe=a(`

      No vaults published yet. Publish a directory above to start protecting it.

      `),Ie=a(`
    • `),Le=a(`
        `),Re=a(`no replicas placed - vault exists only here`),ze=a(`

        Replica members
        `),Be=a(`
        `),Ve=a(``),He=a(``),Ue=a(`
        `),We=a(`

        `),Ge=a(`

        Place replicas for

        Peer-dependent operation · safe to retry if a peer is offline

        Select verified friends
        Advanced manual peers
        `),Ke=a(`

        Vaults

        A vault is a directory you've published for friends to hold replicas of. Publishing ingests + and encrypts it locally; placing replicas is what actually copies it out.

        Local operation

        Sync from your other device

        This peer-dependent action pulls authorized vault updates and restores them below the selected directory.

        Advanced manual peer
        `);function qe(n,i){e(i,!0);let a=()=>ee(oe,`$status`,o),[o,p]=u(),b=[],x=d(y([])),S=d(y({})),E=d(!0),D=d(``),O=d(!1),M=d(``),N=d(``),ne=d(``),F=d(``),I=d(!1),L=d(null),R=d(``),re=d(null),H=d(3),U=d(y([{node:``,addrs:``}])),W=d(!1),G=d(null),ie=d(y([]));async function K(){s(E,!0);let e=await X.listVaults().catch(()=>({published:[]}));s(x,e.published,!0);let t=await Promise.all(k(x).map(async e=>[e.vid,(await X.listReplicas(e.vid).catch(()=>({members:[]}))).members]));s(S,Object.fromEntries(t),!0),s(E,!1)}K(),f(()=>{a()?.vaults.published.length,K()});async function ae(){if(k(D).trim()){s(O,!0),s(R,``);try{await X.publishVault(k(D).trim()),s(D,``),await K()}catch(e){s(R,e.message,!0)}finally{s(O,!1)}}}async function q(){let e=a()?.peers.find(e=>e.node===k(ne));if(e&&(s(M,e.node,!0),s(N,e.addrs.join(`,`),!0)),!(!k(M).trim()||!k(F).trim())){s(I,!0),s(L,null),s(R,``);try{let e=k(N).split(`,`).map(e=>e.trim()).filter(Boolean),t=await X.syncOwned({node:k(M).trim(),addrs:e},k(F).trim());s(L,t.restored,!0),await K()}catch(e){s(R,e.message,!0)}finally{s(I,!1)}}}function J(e){s(re,e,!0),s(G,null),s(U,[{node:``,addrs:``}],!0),s(ie,[],!0)}function Y(){s(U,[...k(U),{node:``,addrs:``}],!0)}function se(e){s(U,k(U).filter((t,n)=>n!==e),!0)}async function Z(){if(k(re)){s(W,!0);try{let e=(a()?.peers??[]).filter(e=>k(ie).includes(e.node)),t=k(U).filter(e=>e.node.trim()).map(e=>({node:e.node.trim(),addrs:e.addrs.split(`,`).map(e=>e.trim()).filter(Boolean)})),n=[...e.map(e=>({node:e.node,addrs:e.addrs})),...t],r=await X.placeReplicas(k(re),n,k(H));s(G,r.placed,!0),await K()}catch(e){s(R,e.message,!0)}finally{s(W,!1)}}}var ce=Ke(),Q=_(v(ce),4),le=e=>{var t=Oe(),n=v(t,!0);P(t),l(()=>r(n,k(R))),B(e,t)};h(Q,e=>{k(R)&&e(le)});var ue=_(Q,2),de=_(v(ue),4),fe=v(de);z(fe);var pe=_(fe,2),me=v(pe,!0);P(pe),P(de),P(ue);var he=_(ue,2),ge=_(v(he),6),_e=v(ge);_e.value=_e.__value=``,C(_(_e),1,()=>a()?.peers??[],e=>e.node,(e,t)=>{var n=ke(),i=v(n);P(n);var a={};l(e=>{r(i,`${(k(t).display||`Friend`)??``} · ${e??``}…`),a!==(a=k(t).node)&&(n.value=(n.__value=k(t).node)??``)},[()=>k(t).node.slice(0,10)]),B(e,n)}),P(ge);var $=_(ge,2),ve=_(v($),3);z(ve);var ye=_(ve,3);z(ye),P($);var be=_($,4);z(be);var xe=_(be,2),Se=_(xe,2),we=v(Se),Te=e=>{B(e,Ae())},Ee=e=>{var t=g(),n=c(t),i=e=>{B(e,je())},a=e=>{var t=Ne(),n=c(t),i=v(n);P(n);var a=_(n,2);C(a,21,()=>k(L),e=>e.vid,(e,t)=>{var n=Me(),i=v(n),a=v(i);P(i);var o=_(i),s=_(o),c=v(s,!0);P(s),P(n),l(e=>{r(a,`${e??``}…`),r(o,`, epoch ${k(t).epoch??``}, in `),r(c,k(t).out_dir)},[()=>k(t).vid.slice(0,12)]),B(e,n)}),P(a),l(()=>r(i,`Restored ${k(L).length??``} vault(s).`)),B(e,t)};h(n,e=>{k(L).length===0?e(i):e(a,-1)}),B(e,t)};h(we,e=>{k(I)?e(Te):k(L)&&e(Ee,1)}),P(Se),P(he);var De=_(he,2),qe=e=>{B(e,Pe())},Je=e=>{B(e,Fe())},Ye=e=>{var t=Be();C(t,21,()=>k(x),e=>e.vid,(e,t)=>{var n=ze(),i=v(n),a=v(i),o=v(a,!0);P(a);var s=_(a,2);Ce(s,{get value(){return k(t).vid}});var c=_(s,2),u=v(c);P(c),P(i);var d=_(i,2),f=_(v(d),2),p=e=>{var n=Le();C(n,20,()=>k(S)[k(t).vid],e=>e,(e,t)=>{var n=Ie();Ce(v(n),{get value(){return t}}),P(n),B(e,n)}),P(n),B(e,n)},m=e=>{B(e,Re())};h(f,e=>{k(S)[k(t).vid]?.length?e(p):e(m,-1)}),P(d);var g=_(d,2);P(n),l(()=>{r(o,k(t).name),r(u,`epoch ${k(t).epoch??``}`)}),A(`click`,g,()=>J(k(t).vid)),B(e,n)}),P(t),B(e,t)};h(De,e=>{k(E)?e(qe):k(x).length===0?e(Je,1):e(Ye,-1)});var Xe=_(De,2),Ze=e=>{var t=Ge(),n=v(t),i=_(v(n)),o=v(i);P(i),P(n);var c=_(n,4);C(_(v(c),2),1,()=>a()?.peers??[],e=>e.node,(e,t)=>{var n=Ve(),i=v(n);z(i);var a,o=_(i);P(n),l(e=>{a!==(a=k(t).node)&&(i.value=(i.__value=k(t).node)??``),r(o,` ${(k(t).display||`Friend`)??``} · ${e??``}…`)},[()=>k(t).node.slice(0,10)]),w(b,[],i,()=>(k(t).node,k(ie)),e=>s(ie,e)),B(e,n)}),P(c);var u=_(c,2),d=_(v(u));C(d,17,()=>k(U),V,(e,t,n)=>{var r=Ue(),i=v(r);z(i);var a=_(i,2);z(a);var o=_(a,2),s=e=>{var t=He();A(`click`,t,()=>se(n)),B(e,t)};h(o,e=>{k(U).length>1&&e(s)}),P(r),j(i,()=>k(t).node,e=>k(t).node=e),j(a,()=>k(t).addrs,e=>k(t).addrs=e),B(e,r)});var f=_(d);P(u);var p=_(u,2),m=_(v(p),2);z(m);var g=_(m,2),y=v(g,!0);P(g);var ee=_(g,2);P(p);var x=_(p,2),S=e=>{var t=We(),n=v(t);P(t),l(()=>r(n,`Placed on ${k(G).length??``} peer${k(G).length===1?``:`s`}.`)),B(e,t)};h(x,e=>{k(G)&&e(S)}),P(t),l(e=>{r(o,`${e??``}…`),g.disabled=k(W),r(y,k(W)?`Placing…`:`Place`)},[()=>k(re).slice(0,12)]),A(`click`,f,Y),j(m,()=>k(H),e=>s(H,e)),A(`click`,g,Z),A(`click`,ee,()=>s(re,null)),B(e,t)};h(Xe,e=>{k(re)&&e(Ze)}),P(ce),l((e,t)=>{pe.disabled=e,r(me,k(O)?`Publishing…`:`Publish vault`),T(he,`aria-busy`,k(I)),xe.disabled=t},[()=>k(O)||!k(D).trim(),()=>k(I)||!k(ne)&&!k(M).trim()||!k(F).trim()]),t(`submit`,ue,e=>(e.preventDefault(),ae())),j(fe,()=>k(D),e=>s(D,e)),t(`submit`,he,e=>(e.preventDefault(),q())),te(ge,()=>k(ne),e=>s(ne,e)),j(ve,()=>k(M),e=>s(M,e)),j(ye,()=>k(N),e=>s(N,e)),j(be,()=>k(F),e=>s(F,e)),B(n,ce),m(),p()}p([`click`]);var Je=a(`
        `),Ye=a(`

        `),Xe=a(`

        Re-split required

        Recovery & trustees.

        `,1),Ze=a(`

        `),Qe=a(`
        `),$e=a(`

        Loading…

        `),et=a(`

        No friends yet. Issue an invite ticket to add your first one.

        `),tt=a(`Remove this friend? `),nt=a(``),rt=a(`
        `),it=a(`
        `),at=a(`

        Friends

        Friends hold encrypted replicas of your vaults and, if you make them trustees, pieces of your + recovery key.

        Invite a friend

        Local operation · the ticket becomes peer-dependent when your friend accepts it

        Add a friend from a ticket

        Peer-dependent operation · an unused ticket is safe to retry

        Your friends

        Storage grants above come from durable daemon state. Trustee and relay roles appear in their + own recovery and replica status views.

        `);function ot(n,a){e(a,!0);let o=()=>ee(oe,`$status`,p),[p,g]=u(),b=d(y([])),x=d(!0),S=d(null),w=d(!1),T=d(``),D=d(``),M=d(1),N=d(!1),te=d(null),F=d(!1),I=d(null),L=d(null),V=d(null);function re(e){return o()?.friends.grants.find(t=>t.user===e)?.grant_bytes??null}async function H(){s(x,!0);let e=await X.listFriends().catch(()=>({count:0,list:[]}));s(b,e.list,!0),s(x,!1)}H(),f(()=>{o()?.friends.count,H()});async function U(){s(w,!0);try{let e=await X.issueTicket();s(S,e,!0)}finally{s(w,!1)}}async function W(){k(S)&&await ye(k(S).uri)&&(s(F,!0),setTimeout(()=>s(F,!1),1200))}async function G(){if(k(T).trim()){s(N,!0),s(te,null);try{let e=k(D).split(`,`).map(e=>e.trim()).filter(Boolean),t=Math.round(k(M)*1024**3),n=await X.addFriend(k(T).trim(),e.length?e:void 0,t);s(te,`Friend added (${n.friend.slice(0,12)}…).`),s(T,``),s(D,``),await H()}finally{s(N,!1)}}}async function ie(e){s(L,e,!0);try{let t=await X.unfriend(e);s(I,null),t.was_friend&&s(V,{friend:e,resplit:t.resplit_triggered,rsids:t.recovery_set_ids},!0),await H()}finally{s(L,null)}}var K=at(),ae=_(v(K),4),q=v(ae),J=_(v(q),4),Y=v(J,!0);P(J);var se=_(J,2),Z=e=>{var t=Je(),n=_(v(t),2),i=v(n);z(i);var a=_(i,2),o=v(a,!0);P(a),P(n),P(t),l(()=>{E(i,k(S).uri),r(o,k(F)?`Copied`:`Copy`)}),A(`click`,a,W),B(e,t)};h(se,e=>{k(S)&&e(Z)}),P(q);var ce=_(q,2),Q=_(v(ce),4),le=_(v(Q),2);z(le);var ue=_(le,4);z(ue);var de=_(ue,4);z(de);var fe=_(de,2),pe=v(fe,!0);P(fe),P(Q);var me=_(Q,2),he=e=>{var t=Ye(),n=v(t,!0);P(t),l(()=>r(n,k(te))),B(e,t)};h(me,e=>{k(te)&&e(he)}),P(ce),P(ae);var ge=_(ae,2),_e=e=>{var t=Qe(),n=v(t),i=e=>{var t=Xe(),n=_(c(t),2),i=v(n);R(2),P(n),l((e,t)=>r(i,`${e??``}… was a trustee. A trustee re-split is now running for + recovery set${k(V).rsids.length>1?`s`:``} + ${t??``}. Both the old and new sets stay usable until the new set is + live and the old shares are destroyed - track it under `),[()=>k(V).friend.slice(0,12),()=>k(V).rsids.join(`, `)]),B(e,t)},a=e=>{var t=Ze(),n=v(t);P(t),l(e=>r(n,`${e??``}… removed. They held no recovery shares, so no re-split + was needed.`),[()=>k(V).friend.slice(0,12)]),B(e,t)};h(n,e=>{k(V).resplit?e(i):e(a,-1)});var o=_(n,2);P(t),l(()=>O(t,1,`card ${k(V).resplit?`at-risk`:`healthy`}`)),A(`click`,o,()=>s(V,null)),B(e,t)};h(ge,e=>{k(V)&&e(_e)});var $=_(ge,4),ve=e=>{B(e,$e())},be=e=>{B(e,et())},Se=e=>{var t=it();C(t,20,()=>k(b),e=>e,(e,t)=>{var n=rt(),a=v(n);Ce(a,{get value(){return t}});var o=_(a,2),c=v(o),u=e=>{var n=i();l(e=>r(n,`${e??``} storage grant`),[()=>xe(re(t))]),B(e,n)},d=ne(()=>re(t)!==null),f=e=>{B(e,i(`storage grant unavailable`))};h(c,e=>{k(d)?e(u):e(f,-1)}),P(o);var p=_(o,2),m=e=>{var n=tt(),i=_(v(n),2),a=v(i,!0);P(i);var o=_(i,2);P(n),l(()=>{i.disabled=k(L)===t,r(a,k(L)===t?`Removing…`:`Confirm unfriend`),o.disabled=k(L)===t}),A(`click`,i,()=>ie(t)),A(`click`,o,()=>s(I,null)),B(e,n)},g=e=>{var n=nt();A(`click`,n,()=>s(I,t,!0)),B(e,n)};h(p,e=>{k(I)===t?e(m):e(g,-1)}),P(n),B(e,n)}),P(t),B(e,t)};h($,e=>{k(x)?e(ve):k(b).length===0?e(be,1):e(Se,-1)}),R(2),P(K),l(e=>{J.disabled=k(w),r(Y,k(w)?`Issuing…`:`Create invite ticket`),fe.disabled=e,r(pe,k(N)?`Adding…`:`Add friend`)},[()=>k(N)||!k(T).trim()]),A(`click`,J,U),t(`submit`,Q,e=>(e.preventDefault(),G())),j(le,()=>k(T),e=>s(T,e)),j(ue,()=>k(D),e=>s(D,e)),j(de,()=>k(M),e=>s(M,e)),B(n,K),m(),g()}p([`click`]);var st=a(`
        Live on this node

        `),ct=a(`

        Restore is in progress.

        `),lt=a(`

        `),ut=a(``),dt=a(``),ft=a(``),pt=a(`
        Reason
        Approvals
        Sponsor
        `),mt=a(`

        Active recovery ceremonies

        Review the claimant and reason out of band before you approve a ceremony. Abort any ceremony + against your identity that you did not start.

        `,1),ht=a(`
        `),gt=a(`

        Paper cards (offline backstop)

        Print a paper card for each recovery set (§8, §10.2). A card recovers from its words + alone - offline, with no Carapace software - so it is the backstop that never goes + offline. The card shows a share's secret words; print it, then keep or destroy the copy.

        `,1),_t=a(`
        `),vt=a(`
        re-split required

        was a trustee of this recovery set and + was unfriended. Their retained share must be neutralized by re-splitting to a fresh set.

        Suggested new trustee set - live reachability

        `),yt=a(`

        Re-split required

        An unfriended trustee still held a share of the recovery set below (§9.3.4). Start the + re-split to hand a fresh share to a new trustee set - the old shares are only destroyed + once that new set is live.

        `,1),bt=a(`· live`),xt=a(`· not live yet`),St=a(`· destroy refused until new set is live`),Ct=a(`
        `),wt=a(`

        New set attested (destroy gate: M + slack)

        Old shares destroyed (ack)

        Remaining friends - live reachability
        `),Tt=a(`

        Trustee re-splits in progress

        An unfriended trustee's share is being neutralized (§9.3 step 4). Both the old and new + recovery sets stay usable until the new set is live and the old shares are destroyed - + neither door closes early.

        `,1),Et=a(` `),Dt=a(`
        `),Ot=a(`

        Authoritative recovery sets

        `,1),kt=a(``),At=a(`

        Advanced recovery-set id
        `,1),jt=a(``),Mt=a(` `,1),Nt=a(``),Pt=a(`

        Add friends before you create recovery protection.

        `),Ft=a(``),It=a(`
        `),Lt=a(`
        `),Rt=a(` `,1),zt=a(` `),Bt=a(`
      • `),Vt=a(`

          `,1),Ht=a(`

          All selected trustees received their shares.

          `),Ut=a(`

          Verified share delivery

          `),Wt=a(`

          `),Gt=a(`
          `),Kt=a(``),qt=a(`

          Shares - send one to each trustee

          `),Jt=a(``),Yt=a(`
          `),Xt=a(`

          This device does not hold a recovery grant. It cannot sponsor a ceremony.

          `),Zt=a(``),Qt=a(`

          Verified sponsor ceremony package - hand to the claimant
          `,1),$t=a(`

          `,1),en=a(`

          Send this abort to your trustees.

          `,1),tn=a(`

          Recovery & trustees

          Split your key into pieces so a group of trustees can rebuild it if you lose access. + The normal flow sends a signed recovery grant to each selected friend and tracks delivery. + The advanced manual flow shows bearer shares that you must protect and deliver yourself.

          Restore retained vaults after identity recovery

          Discovery-dependent operation · it is safe to retry after a network failure

          Use the public restart handoff saved by claimant activation. Carapace contacts the verified trustee hints and accepts only the maximum announced epoch for each vault.

          Split or re-split

          Share delivery

          Add a trustee (extend)

          Peer-dependent operation · offline delivery stays queued and is safe to retry

          Recovery ceremony

          A ceremony is opened by a signed request a trustee receives out of band (from the person + recovering, or the daemon that observed them). The recovery delay and required approvals are + enforced by the daemon per the grant that authorized the ceremony; paste the pieces below as + they arrive.

          Open (sponsor)

          Use the recovery request from the claimant. Confirm their identity and new device through + a separate trusted channel before you open the ceremony.

          Advanced manual values

          Approve

          Abort

          `);function nn(n,a){e(a,!0);let o=()=>ee(oe,`$status`,f),[f,p]=u(),g=[],b=[],x=[],S=[],E=d(`split`),M=d(1),N=d(!1),F=d(`root`),I=d(``),H=d(2),U=d(3),W=d(`friends`),G=d(y([])),ie=d(!1),K=d(!1),ae=d(null),q=d(y([])),J=d(null),Y=ne(()=>Math.max(0,...o()?.share_health.sets.map(e=>e.rsid)??[])+1),se=d(1),Z=d(1),ce=d(!1),Q=d(!1),le=d(null),ue=d(``),de=d(``),fe=d(``),pe=d(``),me=d(``),he=d(``),ge=d(``),_e=d(!1),$=d(null);function ve(){s(pe,``);try{let e=JSON.parse(k(fe));if(e.type!==`carapace.claimant-handoff`||e.version!==1||typeof e.ceremony_enc!=`string`||typeof e.new_node!=`string`||!/^[0-9a-f]{64}$/i.test(e.ceremony_enc)||!/^[0-9a-f]{64}$/i.test(e.new_node))throw Error(`Package type, version, or public values are invalid.`);s(me,e.ceremony_enc,!0),s(he,e.new_node,!0)}catch(e){s(pe,e instanceof Error?e.message:`The handoff package is invalid.`,!0)}}let be=d(``),xe=d(!1),Se=d(null),Ce=d(``),we=d(!1),Te=d(!1),Ee=d(null),De=d(``),Oe=d(!1),ke=d(null);async function Ae(){s(Oe,!0);try{let e=await X.restartRestore(k(De).trim());s(ke,{restored:e.restored.length,refs:e.maximum_epoch_refs},!0)}finally{s(Oe,!1)}}function je(e){switch(e){case`awaiting_new_set`:return`Standing up new set`;case`ready_to_destroy`:return`New set live - destroying old shares`;case`complete`:return`Complete`;default:return e}}function Me(e){return o()?.peers.find(t=>t.user===e)?.display||`Friend ${e.slice(0,10)}…`}let Ne=d(null);function Pe(e){return e.filter(e=>e.online).length}async function Fe(e){s(Ne,e,!0);try{await X.resplitStart(e)}finally{s(Ne,null)}}let Ie=d(null);async function Le(e){s(Ie,e,!0);try{let t=await X.paperCards(e),n=URL.createObjectURL(new Blob([t],{type:`text/html`}));window.open(n,`_blank`,`noopener`),setTimeout(()=>URL.revokeObjectURL(n),6e4)}finally{s(Ie,null)}}async function Re(e,t){await ye(e)&&(t(!0),setTimeout(()=>t(!1),1200))}async function ze(){s(K,!0),s(ae,null),s(q,[],!0),s(J,null);try{let e=k(F)===`root`?{kind:`root`}:{kind:`vault`,vid:k(I).trim()};if(k(W)===`friends`){let t=await(k(E)===`split`?X.recoverySplitToTrustees:X.recoveryResplitToTrustees)(k(E)===`split`&&!k(N)?k(Y):k(M),e,k(H),k(G),k(ie));s(J,{delivered:t.delivered,undelivered:t.undelivered},!0),s(q,t.warnings,!0)}else{let t=await(k(E)===`split`?X.recoverySplit:X.recoveryResplit)(k(E)===`split`&&!k(N)?k(Y):k(M),e,k(H),k(U),k(ie));s(ae,t.shares,!0),s(q,t.warnings,!0)}}finally{s(K,!1)}}async function Be(){s(Q,!0),s(le,null);try{let e=await X.recoveryExtend(k(se),k(Z),k(ce));s(le,e.shares,!0)}finally{s(Q,!1)}}async function Ve(){s(_e,!0),s($,null);try{s($,await X.ceremonyOpen({subject:k(ue).trim(),claimant_display:k(de).trim(),ceremony_enc:k(me).trim(),new_node:k(he).trim(),reason:k(ge).trim()}),!0)}finally{s(_e,!1)}}async function He(){s(xe,!0),s(Se,null);try{s(Se,await X.ceremonyApprove(k(be).trim()),!0)}finally{s(xe,!1)}}async function Ue(){s(Te,!0),s(Ee,null);try{s(Ee,(await X.ceremonyAbort(k(Ce).trim())).abort_hex,!0)}finally{s(Te,!1)}}let We=d(y({})),Ge=d(!1),Ke=d(!1);function qe(e){s(be,e,!0),document.getElementById(`approve-id`)?.focus()}function Je(e){s(Ce,e,!0),s(we,!1),document.getElementById(`abort-id`)?.focus()}var Ye=tn(),Xe=_(v(Ye),4),Ze=e=>{var t=st(),n=_(v(t),2),i=v(n);P(n),P(t),l(()=>r(i,`${o().share_health.recovery_sets_owned??``} recovery set(s) split · + ${o().share_health.shares_held??``} share(s) held here in trust for others`)),B(e,t)};h(Xe,e=>{o()&&e(Ze)});var Qe=_(Xe,2),$e=_(v(Qe),6),et=_(v($e),2);z(et);var tt=_(et,2),nt=v(tt,!0);P(tt),P($e);var rt=_($e,2),it=v(rt),at=e=>{B(e,ct())},ot=e=>{var t=lt(),n=v(t);P(t),l(()=>r(n,`Restored ${k(ke).restored??``} vault(s) from ${k(ke).refs??``} maximum-epoch reference(s).`)),B(e,t)};h(it,e=>{k(Oe)?e(at):k(ke)&&e(ot,1)}),P(rt),P(Qe);var nn=_(Qe,2),rn=e=>{var t=mt(),n=_(c(t),4);C(n,5,()=>o().ceremonies,e=>e.ceremony_id,(e,t)=>{var n=pt();let i;var a=v(n),o=v(a),s=v(o),c=v(s,!0);P(s);var u=_(s,2),d=v(u,!0);P(u),P(o);var f=_(o,2),p=v(f,!0);P(f),P(a);var m=_(a,2),g=e=>{B(e,ut())};h(m,e=>{k(t).alarm&&e(g)});var y=_(m,2),b=v(y),ee=_(v(b)),x=v(ee,!0);P(ee),P(b);var S=_(b,2),C=_(v(S)),w=v(C);P(C),P(S);var T=_(S,2),E=_(v(T)),D=v(E,!0);P(E),P(T),P(y);var j=_(y,2),M=v(j),N=e=>{var n=dt();A(`click`,n,()=>qe(k(t).ceremony_id)),B(e,n)};h(M,e=>{k(t).trustee&&!k(t).approved&&e(N)});var te=_(M,2),ne=e=>{var n=ft();A(`click`,n,()=>Je(k(t).ceremony_id)),B(e,n)};h(te,e=>{k(t).is_self_subject&&!k(t).takeover&&e(ne)}),P(j),P(n),l(()=>{i=O(n,1,`card ceremony svelte-imw02`,null,i,{alarm:k(t).alarm}),r(c,k(t).claimant_display||`Unnamed claimant`),r(d,k(t).ceremony_id),r(p,k(t).phase),r(x,k(t).reason||`No reason supplied`),r(w,`${k(t).approvals??``} / ${k(t).threshold??``}`),r(D,k(t).sponsor)}),B(e,n)}),P(n),B(e,t)};h(nn,e=>{o()?.ceremonies?.length&&e(rn)});var an=_(nn,2),on=e=>{var t=gt(),n=_(c(t),4);C(n,5,()=>o().recovery_grants.minted,e=>e.rsid,(e,t)=>{var n=ht(),i=v(n),a=v(i);P(i);var o=_(i,2),s=v(o);P(o);var c=_(o,2),u=v(c,!0);P(c),P(n),l(()=>{r(a,`rsid ${k(t).rsid??``}`),r(s,`${k(t).trustees.length??``} share(s)`),c.disabled=k(Ie)===k(t).rsid,r(u,k(Ie)===k(t).rsid?`Opening…`:`Print / export paper cards`)}),A(`click`,c,()=>Le(k(t).rsid)),B(e,n)}),P(n),B(e,t)};h(an,e=>{o()?.recovery_grants?.minted?.length&&e(on)});var sn=_(an,2),cn=e=>{var t=yt();C(_(c(t),4),1,()=>o().pending_resplits,e=>e.old_rsid,(e,t)=>{var n=vt(),a=v(n),o=v(a),s=v(o);P(o),R(2),P(a);var c=_(a,2),u=v(c),d=v(u);P(u),R(),P(c);var f=_(c,4);C(f,21,()=>k(t).suggested,e=>e.user,(e,t)=>{var n=_t(),i=v(n),a=_(i,2),o=v(a);P(a);var s=_(a,2),c=v(s,!0);P(s),P(n),l(e=>{O(i,1,`dot ${k(t).online?`online`:`offline`}`,`svelte-imw02`),T(i,`title`,k(t).online?`online`:`offline`),r(o,`${e??``}…`),r(c,k(t).online?`online`:`offline`)},[()=>k(t).user.slice(0,12)]),B(e,n)}),P(f);var p=_(f,2),m=v(p),g=_(m),y=e=>{B(e,i(`will complete immediately once started.`))},b=ne(()=>Pe(k(t).suggested)===k(t).suggested.length&&k(t).suggested.length>0),ee=e=>{B(e,i(`will complete progressively as offline trustees come online.`))};h(g,e=>{k(b)?e(y):e(ee,-1)}),P(p);var x=_(p,2),S=v(x,!0);P(x),P(n),l((e,n,i)=>{r(s,`rsid ${k(t).old_rsid??``}`),r(d,`${e??``}…`),O(p,1,n),r(m,`${i??``} / ${k(t).suggested.length??``} suggested trustee(s) online - `),x.disabled=k(Ne)===k(t).old_rsid,r(S,k(Ne)===k(t).old_rsid?`Starting…`:`Start re-split (use suggested set)`)},[()=>k(t).ex_trustee.slice(0,12),()=>D(Pe(k(t).suggested)===k(t).suggested.length&&k(t).suggested.length>0?`healthy`:`muted`),()=>Pe(k(t).suggested)]),A(`click`,x,()=>Fe(k(t).old_rsid)),B(e,n)}),B(e,t)};h(sn,e=>{o()?.pending_resplits?.length&&e(cn)});var ln=_(sn,2),un=e=>{var t=Tt();C(_(c(t),4),1,()=>o().resplits,e=>e.old_rsid,(e,t)=>{var n=wt(),a=v(n),o=v(a),s=v(o);P(o);var c=_(o,2),u=v(c,!0);P(c),P(a);var d=_(a,2),f=v(d);P(d);var p=_(d,2),m=v(p),g=_(v(m),2),y=v(g),b=_(y),ee=e=>{B(e,bt())},x=e=>{B(e,xt())};h(b,e=>{k(t).new_set_live?e(ee):e(x,-1)}),P(g),P(m);var S=_(m,2),w=_(v(S),2),E=v(w),D=_(E),A=e=>{B(e,St())};h(D,e=>{k(t).new_set_live||e(A)}),P(w),P(S),P(p);var j=_(p,4);C(j,21,()=>k(t).remaining,e=>e.node,(e,t)=>{var n=Ct(),a=v(n),o=_(a,2),s=v(o);P(o);var c=_(o,2),u=v(c,!0);P(c);var d=_(c,2),f=v(d),p=e=>{B(e,i(`done`))},m=e=>{var n=i();l(()=>r(n,`online - ${k(t).role===`new`?`sending share`:`sending destroy`}`)),B(e,n)},g=e=>{B(e,i(`offline - queued`))};h(f,e=>{k(t).done?e(p):k(t).online?e(m,1):e(g,-1)}),P(d),P(n),l(e=>{O(a,1,`dot ${k(t).status??``}`,`svelte-imw02`),T(a,`title`,k(t).status),r(s,`${e??``}…`),O(c,1,`role ${k(t).role??``}`,`svelte-imw02`),r(u,k(t).role===`new`?`gets new share`:`gets destroy step`)},[()=>k(t).node.slice(0,12)]),B(e,n)}),P(j),P(n),l((e,n)=>{r(s,`rsid ${k(t).old_rsid??``} → ${k(t).new_rsid??``}`),O(c,1,`phase ${k(t).phase??``}`,`svelte-imw02`),r(u,e),r(f,`ex-trustee ${n??``}…`),r(y,`${k(t).new_attested??``} / ${k(t).new_total??``} `),r(E,`${k(t).old_destroyed??``} / ${k(t).old_total??``} `)},[()=>je(k(t).phase),()=>k(t).ex_trustee.slice(0,12)]),B(e,n)}),B(e,t)};h(ln,e=>{o()?.resplits?.length&&e(un)});var dn=_(ln,2),fn=e=>{var t=Ot(),n=_(c(t),2);C(n,5,()=>o().share_health.sets,e=>e.rsid,(e,t)=>{var n=Dt(),i=v(n),a=v(i);P(i);var o=_(i,2),s=v(o,!0);P(o);var c=_(o,2),u=v(c);P(c);var d=_(c,2),f=v(d);P(d);var p=_(d,2),m=e=>{var n=Et(),i=v(n,!0);P(n),l(e=>r(i,e),[()=>k(t).warnings.join(`, `)]),B(e,n)};h(p,e=>{k(t).warnings.length&&e(m)}),P(n),l((e,n)=>{r(a,`rsid ${k(t).rsid??``}`),r(s,e),r(u,`${k(t).threshold??``}-of-${k(t).issued??``}`),r(f,`${n??``}/${k(t).trustees.length??``} trustee grants delivered`)},[()=>k(t).scope.kind===`root`?`your root key`:`vault ${k(t).scope.vid.slice(0,10)}…`,()=>k(t).trustees.filter(e=>e.delivered).length]),B(e,n)}),P(n),B(e,t)};h(dn,e=>{o()?.share_health.sets.length&&e(fn)});var pn=_(dn,4),mn=v(pn),hn=v(mn),gn=v(hn);z(gn),gn.value=gn.__value=`split`,R(),P(hn);var _n=_(hn,2),vn=v(_n);z(vn),vn.value=vn.__value=`resplit`,R(),P(_n),P(mn);var yn=_(mn,2),bn=e=>{var t=At(),n=c(t),i=v(n);P(n);var a=_(n,2),o=_(v(a)),u=v(o);z(u),R(),P(o);var d=_(o),f=e=>{var t=kt();z(t),j(t,()=>k(M),e=>s(M,e)),B(e,t)};h(d,e=>{k(N)&&e(f)}),P(a),l(()=>r(i,`Carapace will create recovery set ${k(Y)??``}.`)),L(u,()=>k(N),e=>s(N,e)),B(e,t)},xn=e=>{var t=Mt(),n=_(c(t),2);C(n,5,()=>o()?.share_health.sets??[],e=>e.rsid,(e,t)=>{var n=jt(),i=v(n);P(n);var a={};l(()=>{r(i,`${k(t).scope.kind===`root`?`Identity recovery`:`Vault recovery`} · ${k(t).threshold??``}-of-${k(t).issued??``}`),a!==(a=k(t).rsid)&&(n.value=(n.__value=k(t).rsid)??``)}),B(e,n)}),P(n),te(n,()=>k(M),e=>s(M,e)),B(e,t)};h(yn,e=>{k(E)===`split`?e(bn):e(xn,-1)});var Sn=_(yn,2),Cn=v(Sn),wn=v(Cn);z(wn),wn.value=wn.__value=`root`,R(),P(Cn);var Tn=_(Cn,2),En=v(Tn);z(En),En.value=En.__value=`vault`,R(),P(Tn);var Dn=_(Tn,2),On=e=>{var t=Nt(),n=v(t);n.value=n.__value=``,C(_(n),1,()=>o()?.vaults.published??[],e=>e.vid,(e,t)=>{var n=jt(),i=v(n,!0);P(n);var a={};l(()=>{r(i,k(t).name),a!==(a=k(t).vid)&&(n.value=(n.__value=k(t).vid)??``)}),B(e,n)}),P(t),te(t,()=>k(I),e=>s(I,e)),B(e,t)};h(Dn,e=>{k(F)===`vault`&&e(On)}),P(Sn);var kn=_(Sn,2),An=_(v(kn),2),jn=v(An);z(jn),jn.value=jn.__value=`friends`,R(),P(An);var Mn=_(An,2),Nn=v(Mn);z(Nn),Nn.value=Nn.__value=`manual`,R(),P(Mn),P(kn);var Pn=_(kn,2),Fn=e=>{var t=Lt(),n=v(t),i=v(n);P(n);var a=_(n,2),c=e=>{B(e,Pt())},u=e=>{var t=It();C(t,5,()=>o().friends.list,e=>e,(e,t)=>{var n=Ft(),i=v(n);z(i);var a,o=_(i,2),c=v(o,!0);P(o),P(n),l(e=>{T(n,`title`,k(t)),a!==(a=k(t))&&(i.value=(i.__value=k(t))??``),r(c,e)},[()=>Me(k(t))]),w(S,[],i,()=>(k(t),k(G)),e=>s(G,e)),B(e,n)}),P(t),B(e,t)};h(a,e=>{o()?.friends.list.length?e(u,-1):e(c)}),P(t),l(()=>r(i,`Select trustees (${k(G).length??``} selected)`)),B(e,t)};h(Pn,e=>{k(W)===`friends`&&e(Fn)});var In=_(Pn,2),Ln=_(v(In),2);z(Ln);var Rn=_(Ln,2),zn=e=>{var t=Rt(),n=_(c(t),2);z(n),j(n,()=>k(U),e=>s(U,e)),B(e,t)},Bn=e=>{var t=zt(),n=v(t);P(t),l(()=>r(n,`of ${k(G).length??``} selected`)),B(e,t)};h(Rn,e=>{k(W)===`manual`?e(zn):e(Bn,-1)}),P(In);var Vn=_(In,2),Hn=v(Vn),Un=v(Hn);z(Un),R(),P(Hn),P(Vn);var Wn=_(Vn,2),Gn=v(Wn,!0);P(Wn),P(pn);var Kn=_(pn,2),qn=e=>{var t=Ut();let n;var i=_(v(t),2),a=v(i);P(i);var o=_(i,2),s=e=>{var t=Vt(),n=c(t),i=v(n);P(n);var a=_(n,2);C(a,20,()=>k(J).undelivered,e=>e,(e,t)=>{var n=Bt(),i=v(n,!0);P(n),l(()=>r(i,t)),B(e,n)}),P(a),l(()=>r(i,`Could not reach ${k(J).undelivered.length??``} trustee(s). Carapace will retry delivery during maintenance.`)),B(e,t)},u=e=>{B(e,Ht())};h(o,e=>{k(J).undelivered.length?e(s):e(u,-1)}),P(t),l(()=>{n=O(t,1,`card`,null,n,{"at-risk":k(J).undelivered.length>0}),r(a,`${k(J).delivered.length??``} trustee(s) confirmed that they stored a signed recovery grant.`)}),B(e,t)};h(Kn,e=>{k(J)&&e(qn)});var Jn=_(Kn,2),Yn=e=>{var t=Gt();C(t,20,()=>k(q),e=>e,(e,t)=>{var n=Wt(),i=v(n,!0);P(n),l(()=>r(i,t)),B(e,n)}),P(t),B(e,t)};h(Jn,e=>{k(q).length&&e(Yn)});var Xn=_(Jn,2),Zn=e=>{var t=qt();C(_(v(t),2),17,()=>k(ae),V,(e,t,n)=>{var i=Kt(),a=v(i),o=v(a,!0);P(a);var c=_(a,2),u=v(c,!0);P(c),P(i),l(()=>{r(o,k(t)),r(u,k(We)[n]?`Copied`:`Copy`)}),A(`click`,c,()=>Re(k(t),e=>s(We,{...k(We),[n]:e},!0))),B(e,i)}),P(t),B(e,t)};h(Xn,e=>{k(ae)&&e(Zn)});var Qn=_(Xn,6),$n=v(Qn),er=_(v($n),2);C(er,5,()=>o()?.share_health.sets??[],e=>e.rsid,(e,t)=>{var n=jt(),i=v(n);P(n);var a={};l(()=>{r(i,`${k(t).scope.kind===`root`?`Identity recovery`:`Vault recovery`} · ${k(t).threshold??``}-of-${k(t).issued??``}`),a!==(a=k(t).rsid)&&(n.value=(n.__value=k(t).rsid)??``)}),B(e,n)}),P(er);var tr=_(er,4);z(tr);var nr=_(tr,2),rr=v(nr);z(rr),R(),P(nr),P($n);var ir=_($n,2),ar=v(ir,!0);P(ir),P(Qn);var or=_(Qn,2),sr=e=>{var t=Yt();C(t,21,()=>k(le),V,(e,t)=>{var n=Jt(),i=v(n),a=v(i,!0);P(i),P(n),l(()=>r(a,k(t))),B(e,n)}),P(t),B(e,t)};h(or,e=>{k(le)&&e(sr)});var cr=_(or,6),lr=v(cr),ur=_(v(lr),6),dr=v(ur);dr.value=dr.__value=``,C(_(dr),1,()=>o()?.recovery_grants.held??[],e=>e,(e,t)=>{var n=jt(),i=v(n,!0);P(n);var a={};l(e=>{r(i,e),a!==(a=k(t))&&(n.value=(n.__value=k(t))??``)},[()=>Me(k(t))]),B(e,n)}),P(ur);var fr=_(ur,2),pr=e=>{B(e,Xt())};h(fr,e=>{o()?.recovery_grants.held.length||e(pr)});var mr=_(fr,4);z(mr);var hr=_(mr,4);re(hr);var gr=_(hr,2),_r=_(gr,2),vr=e=>{var t=Zt(),n=v(t,!0);P(t),l(()=>r(n,k(pe))),B(e,t)};h(_r,e=>{k(pe)&&e(vr)});var yr=_(_r,2),br=_(v(yr),4);z(br);var xr=_(br,4);z(xr),P(yr);var Sr=_(yr,4);z(Sr);var Cr=_(Sr,2),wr=v(Cr,!0);P(Cr);var Tr=_(Cr,2),Er=e=>{var t=Qt(),n=c(t),i=v(n);P(n);var a=_(n,4),o=v(a),u=v(o,!0);P(o);var d=_(o,2),f=v(d,!0);P(d),P(a),l(e=>{r(i,`id ${e??``}… · fanned out to ${k($).fanout_reached??``} peer(s)`),r(u,k($).sponsor_package),r(f,k(Ge)?`Copied`:`Copy`)},[()=>k($).ceremony_id.slice(0,12)]),A(`click`,d,()=>Re(k($).sponsor_package,e=>s(Ge,e,!0))),B(e,t)};h(Tr,e=>{k($)&&e(Er)}),P(lr);var Dr=_(lr,2),Or=_(v(Dr),4);z(Or);var kr=_(Or,2),Ar=v(kr,!0);P(kr);var jr=_(kr,2),Mr=e=>{var t=$t(),n=c(t),i=v(n);P(n);var a=_(n,2),o=v(a),u=v(o,!0);P(o);var d=_(o,2),f=v(d,!0);P(d),P(a),l(()=>{r(i,`Approval broadcast to ${k(Se).broadcast_reached??``} co-trustee(s).`),r(u,k(Se).approve_hex),r(f,k(Ke)?`Copied`:`Copy`)}),A(`click`,d,()=>Re(k(Se).approve_hex,e=>s(Ke,e,!0))),B(e,t)};h(jr,e=>{k(Se)&&e(Mr)}),P(Dr);var Nr=_(Dr,2),Pr=_(v(Nr),4);z(Pr);var Fr=_(Pr,2),Ir=v(Fr);z(Ir),R(),P(Fr);var Lr=_(Fr,2),Rr=v(Lr,!0);P(Lr);var zr=_(Lr,2),Br=e=>{var t=en(),n=c(t),i=v(n,!0);P(n),R(2),l(()=>r(i,k(Ee))),B(e,t)};h(zr,e=>{k(Ee)&&e(Br)}),P(Nr),P(cr),P(Ye),l((e,t,n)=>{tt.disabled=e,r(nt,k(Oe)?`Restoring…`:`Discover and restore retained vaults`),Wn.disabled=k(K)||k(W)===`friends`&&(k(G).length===0||k(H)>k(G).length),r(Gn,k(K)?`Splitting…`:k(E)===`split`?`Split key`:`Re-split key`),ir.disabled=k(Q),r(ar,k(Q)?`Issuing…`:`Issue new share(s)`),Cr.disabled=t,r(wr,k(_e)?`Opening…`:`Open ceremony`),kr.disabled=k(xe),r(Ar,k(xe)?`Recording…`:`Record approval`),Lr.disabled=n,r(Rr,k(Te)?`Signing…`:`Abort as subject`)},[()=>k(Oe)||!k(De).trim(),()=>k(_e)||!k(ue)||!k(de).trim()||!k(me).trim()||!k(he).trim()||!k(ge).trim(),()=>k(Te)||!k(Ce).trim()||!k(we)]),t(`submit`,$e,e=>(e.preventDefault(),Ae())),j(et,()=>k(De),e=>s(De,e)),t(`submit`,pn,e=>(e.preventDefault(),ze())),w(g,[],gn,()=>k(E),e=>s(E,e)),w(g,[],vn,()=>k(E),e=>s(E,e)),w(b,[],wn,()=>k(F),e=>s(F,e)),w(b,[],En,()=>k(F),e=>s(F,e)),w(x,[],jn,()=>k(W),e=>s(W,e)),w(x,[],Nn,()=>k(W),e=>s(W,e)),j(Ln,()=>k(H),e=>s(H,e)),L(Un,()=>k(ie),e=>s(ie,e)),t(`submit`,Qn,e=>(e.preventDefault(),Be())),te(er,()=>k(se),e=>s(se,e)),j(tr,()=>k(Z),e=>s(Z,e)),L(rr,()=>k(ce),e=>s(ce,e)),t(`submit`,lr,e=>(e.preventDefault(),Ve())),te(ur,()=>k(ue),e=>s(ue,e)),j(mr,()=>k(de),e=>s(de,e)),j(hr,()=>k(fe),e=>s(fe,e)),A(`click`,gr,ve),j(br,()=>k(me),e=>s(me,e)),j(xr,()=>k(he),e=>s(he,e)),j(Sr,()=>k(ge),e=>s(ge,e)),t(`submit`,Dr,e=>(e.preventDefault(),He())),j(Or,()=>k(be),e=>s(be,e)),t(`submit`,Nr,e=>(e.preventDefault(),Ue())),j(Pr,()=>k(Ce),e=>s(Ce,e)),L(Ir,()=>k(we),e=>s(we,e)),B(n,Ye),m(),p()}p([`click`]);var rn=a(``),an=a(``),on=a(`

          Send this to each person in the audience.

          `,1),sn=a(`
        • `),cn=a(`

            `,1),ln=a(`

            Shared files

            Share files from a vault

            Local operation · creates a non-recallable encrypted snapshot

            A share is a snapshot of these files at the vault's current epoch. It cannot + be recalled once handed over - editing the files afterward only affects future shares, not + this one.

            Recipients
            Advanced manual recipient identities

            Fetch a file someone shared with you

            Peer-dependent operation · no partial output is activated, so a failed fetch is safe to retry

            Advanced manual peer
            `);function un(n,i){e(i,!0);let a=()=>ee(oe,`$status`,o),[o,f]=u(),p=[],g=d(y([]));X.listVaults().then(e=>s(g,e.published,!0)).catch(()=>{});let b=d(``),x=d(``),S=d(``),T=d(y([])),E=d(!1),D=d(null),O=d(!1),M=d(!1);async function N(){if(!(!k(b)||!k(x).trim()||k(T).length===0&&!k(S).trim())){s(E,!0),s(D,null);try{let e=k(x).split(` +`).map(e=>e.trim()).filter(Boolean),t=k(S).split(`,`).map(e=>e.trim()).filter(Boolean),n=[...new Set([...k(T),...t])],r=await X.discloseFiles(k(b),e,n);s(D,r.grant_hex,!0)}finally{s(E,!1)}}}async function ne(){k(D)&&await ye(k(D))&&(s(O,!0),setTimeout(()=>s(O,!1),1200))}let F=d(``),I=d(``),V=d(``),H=d(``),U=d(``),W=d(!1),G=d(null);async function ie(){let e=a()?.peers.find(e=>e.node===k(H));if(e&&(s(I,e.node,!0),s(V,e.addrs.join(`,`),!0)),!(!k(F).trim()||!k(I).trim()||!k(U).trim())){s(W,!0),s(G,null);try{let e=k(V).split(`,`).map(e=>e.trim()).filter(Boolean),t=await X.fetchGrant(k(F).trim(),{node:k(I).trim(),addrs:e},k(U).trim());s(G,t.written,!0)}finally{s(W,!1)}}}var K=ln(),ae=_(v(K),2),q=_(v(ae),6),J=_(v(q),2),Y=v(J);Y.value=Y.__value=``,C(_(Y),17,()=>k(g),e=>e.vid,(e,t)=>{var n=rn(),i=v(n);P(n);var a={};l(()=>{r(i,`${k(t).name??``} (epoch ${k(t).epoch??``})`),a!==(a=k(t).vid)&&(n.value=(n.__value=k(t).vid)??``)}),B(e,n)}),P(J);var se=_(J,4);re(se);var Z=_(se,2);C(_(v(Z)),1,()=>a()?.peers??[],e=>e.node,(e,t)=>{var n=an(),i=v(n);z(i);var a,o=_(i);P(n),l(e=>{a!==(a=k(t).user)&&(i.value=(i.__value=k(t).user)??``),r(o,` ${e??``}`)},[()=>k(t).display||`Friend ${k(t).user.slice(0,10)}…`]),w(p,[],i,()=>(k(t).user,k(T)),e=>s(T,e)),B(e,n)}),P(Z);var ce=_(Z,2),Q=_(v(ce),2);z(Q),P(ce);var le=_(ce,2),ue=v(le);z(ue),R(),P(le);var de=_(le,2),fe=v(de,!0);P(de),P(q);var pe=_(q,2),me=e=>{var t=on(),n=c(t),i=v(n),a=v(i,!0);P(i);var o=_(i,2),s=v(o,!0);P(o),P(n),R(2),l(()=>{r(a,k(D)),r(s,k(O)?`Copied`:`Copy`)}),A(`click`,o,ne),B(e,t)};h(pe,e=>{k(D)&&e(me)}),P(ae);var he=_(ae,2),ge=_(v(he),4),_e=_(v(ge),2);z(_e);var $=_(_e,3),ve=v($);ve.value=ve.__value=``,C(_(ve),1,()=>a()?.peers??[],e=>e.node,(e,t)=>{var n=rn(),i=v(n,!0);P(n);var a={};l(()=>{r(i,k(t).display||`Friend`),a!==(a=k(t).node)&&(n.value=(n.__value=k(t).node)??``)}),B(e,n)}),P($);var be=_($,2),xe=_(v(be),2);z(xe);var Se=_(xe,2);z(Se),P(be);var Ce=_(be,4);z(Ce);var we=_(Ce,2),Te=v(we,!0);P(we),P(ge);var Ee=_(ge,2),De=e=>{var t=cn(),n=c(t),i=v(n);P(n);var a=_(n,2);C(a,20,()=>k(G),e=>e,(e,t)=>{var n=sn(),i=v(n,!0);P(n),l(()=>r(i,t)),B(e,n)}),P(a),l(()=>r(i,`Wrote ${k(G).length??``} file(s):`)),B(e,t)};h(Ee,e=>{k(G)&&e(De)}),P(he),P(K),l(e=>{de.disabled=e,r(fe,k(E)?`Sharing…`:`Share files`),we.disabled=k(W),r(Te,k(W)?`Fetching…`:`Fetch files`)},[()=>k(E)||!k(b)||!k(x).trim()||k(T).length===0&&!k(S).trim()||!k(M)]),t(`submit`,q,e=>(e.preventDefault(),N())),te(J,()=>k(b),e=>s(b,e)),j(se,()=>k(x),e=>s(x,e)),j(Q,()=>k(S),e=>s(S,e)),L(ue,()=>k(M),e=>s(M,e)),t(`submit`,ge,e=>(e.preventDefault(),ie())),j(_e,()=>k(F),e=>s(F,e)),te($,()=>k(H),e=>s(H,e)),j(xe,()=>k(I),e=>s(I,e)),j(Se,()=>k(V),e=>s(V,e)),j(Ce,()=>k(U),e=>s(U,e)),B(n,K),m(),f()}p([`click`]);var dn=a(` `),fn=a(`
            Carapace
            `);function pn(t,i){e(i,!0);let a=()=>ee(se,`$live`,c),[c,f]=u(),p={"":`Overview`,vaults:`Vaults`,friends:`Friends`,recovery:`Recovery`,shared:`Shared files`};function g(){let e=typeof location<`u`?location.hash.replace(/^#\/?/,``):``;return e in p?e:``}let b=d(y(g()));function S(){s(b,g(),!0)}let w=d(null);function E(e){e?document.documentElement.setAttribute(`data-theme`,e):document.documentElement.removeAttribute(`data-theme`)}function D(){let e=typeof matchMedia<`u`&&matchMedia(`(prefers-color-scheme: light)`).matches,t=k(w)?k(w)===`light`:e;s(w,t?`dark`:`light`,!0),localStorage.setItem(`carapace-theme`,k(w)),E(k(w))}F(()=>{window.addEventListener(`hashchange`,S);let e=localStorage.getItem(`carapace-theme`);return(e===`dark`||e===`light`)&&(s(w,e,!0),E(k(w))),de(),()=>window.removeEventListener(`hashchange`,S)}),N(()=>{fe()});var j=fn();x(`1uha8ag`,e=>{o(()=>{n.title=`Carapace`})});var M=v(j),te=_(v(M),2);C(te,21,()=>Object.entries(p),([e,t])=>e,(e,t)=>{var n=ne(()=>I(k(t),2));let i=()=>k(n)[0],a=()=>k(n)[1];var o=dn();let s;var c=v(o,!0);P(o),l(()=>{T(o,`href`,i()?`#/${i()}`:`#/`),s=O(o,1,`svelte-1uha8ag`,null,s,{active:k(b)===i()}),r(c,a())}),B(e,o)}),P(te);var L=_(te,2),R=v(L);let z;var V=_(R,2),re=v(V,!0);P(V);var H=_(V,2),U=v(H,!0);P(H),P(L),P(M);var W=_(M,2),G=v(W);me(G,{});var ie=_(G,2),K=e=>{qe(e,{})},ae=e=>{ot(e,{})},q=e=>{nn(e,{})},J=e=>{un(e,{})},Y=e=>{De(e,{})};h(ie,e=>{k(b)===`vaults`?e(K):k(b)===`friends`?e(ae,1):k(b)===`recovery`?e(q,2):k(b)===`shared`?e(J,3):e(Y,-1)}),P(W),P(j),l(()=>{z=O(R,1,`live-dot svelte-1uha8ag`,null,z,{live:a()}),T(R,`title`,a()?`Live updates connected`:`Reconnecting…`),r(re,a()?`Live`:`Reconnecting…`),r(U,k(w)===`light`?`Molt (light)`:`Dark`)}),A(`click`,H,D),B(t,j),m(),f()}p([`click`]);export{pn as component}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/nodes/2.CaWSwQlO.js b/crates/carapace-api/static/_app/immutable/nodes/2.CaWSwQlO.js deleted file mode 100644 index 10fcd26..0000000 --- a/crates/carapace-api/static/_app/immutable/nodes/2.CaWSwQlO.js +++ /dev/null @@ -1,25 +0,0 @@ -import{$ as e,A as t,B as n,C as r,D as i,E as a,F as o,G as s,H as c,I as l,J as u,K as d,L as f,O as p,Q as m,S as h,T as g,U as _,V as v,W as y,X as b,Y as ee,_ as x,a as S,b as C,c as w,d as T,f as E,g as D,h as O,j as k,k as A,l as j,m as M,n as te,nt as N,p as P,q as ne,r as F,rt as I,s as L,tt as R,u as z,w as B,x as V,z as re}from"../chunks/D7CCBGlz.js";import"../chunks/CCeg2KC3.js";import"../chunks/xihTtKlq.js";function H(){return typeof window<`u`&&window.__CARAPACE_TOKEN__?window.__CARAPACE_TOKEN__:``}var U=b(null);function W(e){U.set(e)}function ie(){U.set(null)}var ae=``,G=class extends Error{status;constructor(e,t){super(e),this.status=t}};async function K(e,t){let n;try{n=await fetch(ae+e,{...t,headers:{...t?.body?{"Content-Type":`application/json`}:{},Authorization:`Bearer ${H()}`,...t?.headers}})}catch(t){let n=`Could not reach the daemon (${e}). Is it running? (${t.message})`;throw W(n),new G(n)}if(n.status===401||n.status===403){let e=n.status===401?`The daemon rejected this session (bad or missing token). Reload the page.`:`The daemon refused this request (Host/Origin guard). Reload the page.`;throw W(e),new G(e,n.status)}if(!n.ok){let t=n.statusText;try{let e=await n.json();typeof e?.error==`string`&&(t=e.error)}catch{}throw W(`${e} failed: ${t}`),new G(t,n.status)}return await n.json()}async function q(e,t){let n;try{n=await fetch(ae+e,{...t,headers:{Authorization:`Bearer ${H()}`,...t?.headers}})}catch(t){let n=`Could not reach the daemon (${e}). Is it running? (${t.message})`;throw W(n),new G(n)}if(n.status===401||n.status===403){let e=n.status===401?`The daemon rejected this session (bad or missing token). Reload the page.`:`The daemon refused this request (Host/Origin guard). Reload the page.`;throw W(e),new G(e,n.status)}if(!n.ok){let t=n.statusText;try{let e=await n.json();typeof e?.error==`string`&&(t=e.error)}catch{}throw W(`${e} failed: ${t}`),new G(t,n.status)}return n.text()}var J=e=>K(e),Y=(e,t)=>K(e,{method:`POST`,body:t===void 0?void 0:JSON.stringify(t)}),X={health:()=>J(`/api/health`),status:()=>J(`/api/status`),listVaults:()=>J(`/api/vaults`),publishVault:(e,t)=>Y(`/api/vaults`,{dir:e,vid:t}),listReplicas:e=>J(`/api/vaults/${e}/replicas`),placeReplicas:(e,t,n)=>Y(`/api/vaults/${e}/replicas`,{peers:t,r:n}),discloseFiles:(e,t,n)=>Y(`/api/vaults/${e}/grants`,{paths:t,audience:n}),fetchGrant:(e,t,n)=>Y(`/api/grants/fetch`,{grant_hex:e,owner:t,out_dir:n}),listFriends:()=>J(`/api/friends`),issueTicket:()=>Y(`/api/friends/ticket`),addFriend:(e,t,n)=>Y(`/api/friends`,{ticket_hex:e,addrs:t,grant_bytes:n}),unfriend:e=>Y(`/api/friends/${e}/unfriend`),resplitStatus:e=>J(`/api/recovery/${e}/resplit-status`),paperCards:e=>q(`/api/recovery/${e}/paper`),resplitStart:(e,t)=>Y(`/api/recovery/${e}/resplit-start`,t?{trustees:t}:void 0),recoverySplit:(e,t,n,r,i)=>Y(`/api/recovery/split`,{rsid:e,scope:t,m:n,n:r,allow_over_cap:i}),recoveryResplit:(e,t,n,r,i)=>Y(`/api/recovery/resplit`,{rsid:e,scope:t,m:n,n:r,allow_over_cap:i}),recoveryExtend:(e,t,n)=>Y(`/api/recovery/extend`,{rsid:e,count:t,allow_over_cap:n}),ceremonyOpen:e=>Y(`/api/recovery/ceremony/open`,e),ceremonyApprove:e=>Y(`/api/recovery/ceremony/approve`,{ceremony_id:e}),ceremonyAbort:e=>Y(`/api/recovery/ceremony/abort`,{ceremony_id:e})},oe=b(null),se=b(!1),Z=null,Q=1e3;function $(){if(typeof window>`u`)return;let e=`${location.protocol===`https:`?`wss:`:`ws:`}//${location.host}/api/events?token=${encodeURIComponent(H())}`;Z=new WebSocket(e),Z.onopen=()=>{se.set(!0),Q=1e3},Z.onmessage=e=>{try{oe.set(JSON.parse(e.data))}catch{}},Z.onclose=()=>{se.set(!1),setTimeout($,Q),Q=Math.min(Q*2,15e3)},Z.onerror=()=>{Z?.close()}}function ce(){X.status().then(e=>oe.set(e)).catch(()=>{}),$()}function le(){Z?.close(),Z=null}var ue=a(``);function de(e){let t=()=>ee(U,`$lastError`,n),[n,i]=u();var a=g(),o=c(a),s=e=>{var n=ue(),i=v(n),a=v(i,!0);N(i);var o=_(i,2);N(n),l(()=>r(a,t())),A(`click`,o,function(...e){ie?.apply(this,e)}),B(e,n)};h(o,e=>{t()&&e(s)}),B(e,a),i()}p([`click`]);var fe=`carapace-gui-notes-v1`;function pe(){if(typeof localStorage>`u`)return{friendStorageGrants:{},recoverySets:{}};try{let e=localStorage.getItem(fe);return e?JSON.parse(e):{friendStorageGrants:{},recoverySets:{}}}catch{return{friendStorageGrants:{},recoverySets:{}}}}var me=b(pe());me.subscribe(e=>{typeof localStorage>`u`||localStorage.setItem(fe,JSON.stringify(e))});function he(e,t){me.update(n=>({...n,friendStorageGrants:{...n.friendStorageGrants,[e]:t}}))}function ge(e){me.update(t=>({...t,recoverySets:{...t.recoverySets,[String(e.rsid)]:e}}))}var _e=a(``),ve=a(`
            `),ye=a(`
            `);function be(e,t){function n(e){let t=Math.max(e.target,e.achieved,1);return Array.from({length:t},(t,n)=>({filled:nt.plates,e=>e.key,(e,t,i)=>{var a=ve(),o=v(a);C(o,21,()=>n(k(t)),V,(e,t)=>{var n=_e();let r;l(()=>r=O(n,1,`segment svelte-v2pnom`,null,r,{filled:k(t).filled})),B(e,n)}),N(o);var s=_(o,2),c=v(s,!0);N(s);var u=_(s,2),d=v(u,!0);N(u);var f=_(u,2),p=v(f,!0);N(f),N(a),l(()=>{O(a,1,`plate-group state-${k(t).state??``}`,`svelte-v2pnom`),M(a,`--i: ${k(i)??``}`),r(c,k(t).label),r(d,k(t).valueLabel),r(p,k(t).note)}),B(e,a)}),N(i),B(e,i)}function xe(e,t=8,n=6){return e.length<=t+n+1?e:`${e.slice(0,t)}…${e.slice(-n)}`}async function Se(e){try{return await navigator.clipboard.writeText(e),!0}catch{return!1}}var Ce=[`B`,`KiB`,`MiB`,`GiB`,`TiB`];function we(e){if(!Number.isFinite(e)||e<0)return`—`;if(e===0)return`0 B`;let t=Math.min(Math.floor(Math.log2(e)/10),Ce.length-1),n=e/2**(10*t);return`${n>=10||t===0?Math.round(n):n.toFixed(1)} ${Ce[t]}`}var Te=a(``);function Ee(t,n){e(n,!0);let i=S(n,`head`,3,8),a=S(n,`tail`,3,6),o=d(!1);async function c(){await Se(n.value)&&(s(o,!0),setTimeout(()=>s(o,!1),1200))}var u=Te(),f=v(u),p=_(f),h=v(p,!0);N(p),N(u),l(e=>{T(u,`title`,n.value),T(u,`aria-label`,`Copy ${n.value??``}`),r(f,`${e??``} `),r(h,k(o)?`copied`:`copy`)},[()=>xe(n.value,i(),a())]),A(`click`,u,c),B(t,u),m()}p([`click`]);var De=a(`
            This node
            Friends storing your vaults
            Vaults published
            `,1),Oe=a(`

            Waiting for the daemon…

            `),ke=a(`

            Shell integrity

            `);function Ae(t,n){e(n,!0);let i=()=>ee(oe,`$status`,o),a=()=>ee(me,`$notes`,o),[o,p]=u(),g=d(null);f(()=>{let e=i()?.vaults.published??[];if(e.length===0){s(g,null);return}Promise.all(e.map(e=>X.listReplicas(e.vid).catch(()=>({members:[]})))).then(e=>{s(g,Math.min(...e.map(e=>e.members.length)),!0)}).catch(()=>{s(g,null)})});let y=ne(()=>{let e=i();if(!e)return[];let t=e.vaults.published.length,n=k(g)??0,r={key:`replicas`,label:`Replicas`,achieved:t===0?0:Math.min(n,3),target:3,valueLabel:t===0?`—`:`${n}/3`,state:t===0?`empty`:n>=3?`healthy`:`at-risk`,note:t===0?`No vaults published yet`:`Weakest vault held by ${n} friend${n===1?``:`s`}`},o=e.share_health.recovery_sets_owned,s=Object.values(a().recoverySets).find(e=>e.scope.kind===`root`),c={key:`shares`,label:`Recovery shares`,achieved:+(o>0),target:1,valueLabel:s?`${s.m}-of-${s.n}`:o>0?`split`:`—`,state:o>0?`healthy`:`empty`,note:o>0?`${e.share_health.shares_held} share${e.share_health.shares_held===1?``:`s`} held here in trust for others`:`Your key has no trustees yet - nobody could rebuild it`},l=e.addr.length,u=e.relay_networks,d=e.relay_diversity_warning||u<2;return[r,c,{key:`relays`,label:`Reachability`,achieved:Math.min(u,2),target:2,valueLabel:`${u}`,state:l===0?`empty`:d?`at-risk`:`healthy`,note:d?`Only ${u} relay network${u===1?``:`s`} - add a friend's relay so you can still be reached if one drops`:`${e.reachability} · ${u} relay networks, ${l} dialable address${l===1?``:`es`}`}]});var b=ke(),x=_(v(b),2),S=e=>{var t=De(),n=c(t);be(n,{get plates(){return k(y)}});var a=_(n,2),o=v(a);Ee(_(v(o),2),{get value(){return i().node_id},head:12,tail:8}),N(o);var s=_(o,2),u=_(v(s),2),d=v(u,!0);N(u),N(s);var f=_(s,2),p=_(v(f),2),m=v(p,!0);N(p),N(f),N(a),R(2),l(()=>{r(d,i().friends.count),r(m,i().vaults.published.length)}),B(e,t)},C=e=>{B(e,Oe())};h(x,e=>{i()?e(S):e(C,-1)}),N(b),B(t,b),m(),p()}var je=a(`

            Loading vaults…

            `),Me=a(`

            No vaults published yet. Publish a directory above to start protecting it.

            `),Ne=a(`
          • `),Pe=a(`
              `),Fe=a(`no replicas placed - vault exists only here`),Ie=a(`
              Replica members
              `),Le=a(`
              `),Re=a(``),ze=a(`
              `),Be=a(`

              `),Ve=a(`

              Place replicas for

              The daemon doesn't remember a friend's network address for you - list each peer's node - id and dialable address(es) again here.

              `),He=a(`

              Vaults

              A vault is a directory you've published for friends to hold replicas of. Publishing ingests - and encrypts it locally; placing replicas is what actually copies it out.

              `);function Ue(n,i){e(i,!0);let a=()=>ee(oe,`$status`,o),[o,c]=u(),p=d(y([])),g=d(y({})),b=d(!0),x=d(``),S=d(!1),w=d(null),T=d(3),E=d(y([{node:``,addrs:``}])),D=d(!1),O=d(null);async function M(){s(b,!0);let e=await X.listVaults().catch(()=>({published:[]}));s(p,e.published,!0);let t=await Promise.all(k(p).map(async e=>[e.vid,(await X.listReplicas(e.vid).catch(()=>({members:[]}))).members]));s(g,Object.fromEntries(t),!0),s(b,!1)}M(),f(()=>{a()?.vaults.published.length,M()});async function te(){if(k(x).trim()){s(S,!0);try{await X.publishVault(k(x).trim()),s(x,``),await M()}finally{s(S,!1)}}}function P(e){s(w,e,!0),s(O,null),s(E,[{node:``,addrs:``}],!0)}function ne(){s(E,[...k(E),{node:``,addrs:``}],!0)}function F(e){s(E,k(E).filter((t,n)=>n!==e),!0)}async function I(){if(k(w)){s(D,!0);try{let e=k(E).filter(e=>e.node.trim()).map(e=>({node:e.node.trim(),addrs:e.addrs.split(`,`).map(e=>e.trim()).filter(Boolean)})),t=await X.placeReplicas(k(w),e,k(T));s(O,t.placed,!0),await M()}finally{s(D,!1)}}}var L=He(),R=_(v(L),4),re=_(v(R),2),H=v(re);z(H);var U=_(H,2),W=v(U,!0);N(U),N(re),N(R);var ie=_(R,2),ae=e=>{B(e,je())},G=e=>{B(e,Me())},K=e=>{var t=Le();C(t,21,()=>k(p),e=>e.vid,(e,t)=>{var n=Ie(),i=v(n),a=v(i);Ee(a,{get value(){return k(t).vid}});var o=_(a,2),s=v(o);N(o),N(i);var c=_(i,2),u=_(v(c),2),d=e=>{var n=Pe();C(n,20,()=>k(g)[k(t).vid],e=>e,(e,t)=>{var n=Ne();Ee(v(n),{get value(){return t}}),N(n),B(e,n)}),N(n),B(e,n)},f=e=>{B(e,Fe())};h(u,e=>{k(g)[k(t).vid]?.length?e(d):e(f,-1)}),N(c);var p=_(c,2);N(n),l(()=>r(s,`epoch ${k(t).epoch??``}`)),A(`click`,p,()=>P(k(t).vid)),B(e,n)}),N(t),B(e,t)};h(ie,e=>{k(b)?e(ae):k(p).length===0?e(G,1):e(K,-1)});var q=_(ie,2),J=e=>{var t=Ve(),n=v(t),i=_(v(n)),a=v(i);N(i),N(n);var o=_(n,4);C(o,17,()=>k(E),V,(e,t,n)=>{var r=ze(),i=v(r);z(i);var a=_(i,2);z(a);var o=_(a,2),s=e=>{var t=Re();A(`click`,t,()=>F(n)),B(e,t)};h(o,e=>{k(E).length>1&&e(s)}),N(r),j(i,()=>k(t).node,e=>k(t).node=e),j(a,()=>k(t).addrs,e=>k(t).addrs=e),B(e,r)});var c=_(o,2),u=_(c,2),d=_(v(u),2);z(d);var f=_(d,2),p=v(f,!0);N(f);var m=_(f,2);N(u);var g=_(u,2),y=e=>{var t=Be(),n=v(t);N(t),l(()=>r(n,`Placed on ${k(O).length??``} peer${k(O).length===1?``:`s`}.`)),B(e,t)};h(g,e=>{k(O)&&e(y)}),N(t),l(e=>{r(a,`${e??``}…`),f.disabled=k(D),r(p,k(D)?`Placing…`:`Place`)},[()=>k(w).slice(0,12)]),A(`click`,c,ne),j(d,()=>k(T),e=>s(T,e)),A(`click`,f,I),A(`click`,m,()=>s(w,null)),B(e,t)};h(q,e=>{k(w)&&e(J)}),N(L),l(e=>{U.disabled=e,r(W,k(S)?`Publishing…`:`Publish vault`)},[()=>k(S)||!k(x).trim()]),t(`submit`,R,e=>(e.preventDefault(),te())),j(H,()=>k(x),e=>s(x,e)),B(n,L),m(),c()}p([`click`]);var We=a(`
              `),Ge=a(`

              `),Ke=a(`

              Re-split required

              Recovery & trustees.

              `,1),qe=a(`

              `),Je=a(`
              `),Ye=a(`

              Loading…

              `),Xe=a(`

              No friends yet. Issue an invite ticket to add your first one.

              `),Ze=a(`Remove this friend? `),Qe=a(``),$e=a(`
              `),et=a(`
              `),tt=a(`

              Friends

              Friends hold encrypted replicas of your vaults and, if you make them trustees, pieces of your - recovery key.

              Invite a friend

              Add a friend from a ticket

              Your friends

              The daemon doesn't yet report a friend's storage/trustee/relay role or agreed limit back to the - GUI - the figures above are only what this browser set when adding the friend.

              `);function nt(n,a){e(a,!0);let o=()=>ee(oe,`$status`,g),p=()=>ee(me,`$notes`,g),[g,b]=u(),x=d(y([])),S=d(!0),w=d(null),T=d(!1),D=d(``),M=d(``),te=d(1),P=d(!1),ne=d(null),F=d(!1),I=d(null),L=d(null),V=d(null);async function re(){s(S,!0);let e=await X.listFriends().catch(()=>({count:0,list:[]}));s(x,e.list,!0),s(S,!1)}re(),f(()=>{o()?.friends.count,re()});async function H(){s(T,!0);try{let e=await X.issueTicket();s(w,e,!0)}finally{s(T,!1)}}async function U(){k(w)&&await Se(k(w).uri)&&(s(F,!0),setTimeout(()=>s(F,!1),1200))}async function W(){if(k(D).trim()){s(P,!0),s(ne,null);try{let e=k(M).split(`,`).map(e=>e.trim()).filter(Boolean),t=Math.round(k(te)*1024**3),n=await X.addFriend(k(D).trim(),e.length?e:void 0,t);he(n.friend,t),s(ne,`Friend added (${n.friend.slice(0,12)}…).`),s(D,``),s(M,``),await re()}finally{s(P,!1)}}}async function ie(e){s(L,e,!0);try{let t=await X.unfriend(e);s(I,null),t.was_friend&&s(V,{friend:e,resplit:t.resplit_triggered,rsids:t.recovery_set_ids},!0),await re()}finally{s(L,null)}}var ae=tt(),G=_(v(ae),4),K=v(G),q=_(v(K),2),J=v(q,!0);N(q);var Y=_(q,2),se=e=>{var t=We(),n=_(v(t),2),i=v(n);z(i);var a=_(i,2),o=v(a,!0);N(a),N(n),N(t),l(()=>{E(i,k(w).uri),r(o,k(F)?`Copied`:`Copy`)}),A(`click`,a,U),B(e,t)};h(Y,e=>{k(w)&&e(se)}),N(K);var Z=_(K,2),Q=_(v(Z),2),$=_(v(Q),2);z($);var ce=_($,4);z(ce);var le=_(ce,4);z(le);var ue=_(le,2),de=v(ue,!0);N(ue),N(Q);var fe=_(Q,2),pe=e=>{var t=Ge(),n=v(t,!0);N(t),l(()=>r(n,k(ne))),B(e,t)};h(fe,e=>{k(ne)&&e(pe)}),N(Z),N(G);var ge=_(G,2),_e=e=>{var t=Je(),n=v(t),i=e=>{var t=Ke(),n=_(c(t),2),i=v(n);R(2),N(n),l((e,t)=>r(i,`${e??``}… was a trustee. A trustee re-split is now running for - recovery set${k(V).rsids.length>1?`s`:``} - ${t??``}. Both the old and new sets stay usable until the new set is - live and the old shares are destroyed - track it under `),[()=>k(V).friend.slice(0,12),()=>k(V).rsids.join(`, `)]),B(e,t)},a=e=>{var t=qe(),n=v(t);N(t),l(e=>r(n,`${e??``}… removed. They held no recovery shares, so no re-split - was needed.`),[()=>k(V).friend.slice(0,12)]),B(e,t)};h(n,e=>{k(V).resplit?e(i):e(a,-1)});var o=_(n,2);N(t),l(()=>O(t,1,`card ${k(V).resplit?`at-risk`:`healthy`}`)),A(`click`,o,()=>s(V,null)),B(e,t)};h(ge,e=>{k(V)&&e(_e)});var ve=_(ge,4),ye=e=>{B(e,Ye())},be=e=>{B(e,Xe())},xe=e=>{var t=et();C(t,20,()=>k(x),e=>e,(e,t)=>{var n=$e(),a=v(n);Ee(a,{get value(){return t}});var o=_(a,2),c=v(o),u=e=>{var n=i();l(e=>r(n,`≈${e??``} agreed (recorded in this browser)`),[()=>we(p().friendStorageGrants[t])]),B(e,n)},d=e=>{B(e,i(`storage limit not recorded here`))};h(c,e=>{p().friendStorageGrants[t]===void 0?e(d,-1):e(u)}),N(o);var f=_(o,2),m=e=>{var n=Ze(),i=_(v(n),2),a=v(i,!0);N(i);var o=_(i,2);N(n),l(()=>{i.disabled=k(L)===t,r(a,k(L)===t?`Removing…`:`Confirm unfriend`),o.disabled=k(L)===t}),A(`click`,i,()=>ie(t)),A(`click`,o,()=>s(I,null)),B(e,n)},g=e=>{var n=Qe();A(`click`,n,()=>s(I,t,!0)),B(e,n)};h(f,e=>{k(I)===t?e(m):e(g,-1)}),N(n),B(e,n)}),N(t),B(e,t)};h(ve,e=>{k(S)?e(ye):k(x).length===0?e(be,1):e(xe,-1)}),R(2),N(ae),l(e=>{q.disabled=k(T),r(J,k(T)?`Issuing…`:`Create invite ticket`),ue.disabled=e,r(de,k(P)?`Adding…`:`Add friend`)},[()=>k(P)||!k(D).trim()]),A(`click`,q,H),t(`submit`,Q,e=>(e.preventDefault(),W())),j($,()=>k(D),e=>s(D,e)),j(ce,()=>k(M),e=>s(M,e)),j(le,()=>k(te),e=>s(te,e)),B(n,ae),m(),b()}p([`click`]);var rt=a(`
              Live on this node

              `),it=a(`
              `),at=a(`

              Paper cards (offline backstop)

              Print a paper card for each recovery set (§8, §10.2). A card recovers from its words - alone - offline, with no Carapace software - so it is the backstop that never goes - offline. The card shows a share's secret words; print it, then keep or destroy the copy.

              `,1),ot=a(`
              `),st=a(`
              re-split required

              was a trustee of this recovery set and - was unfriended. Their retained share must be neutralized by re-splitting to a fresh set.

              Suggested new trustee set - live reachability

              `),ct=a(`

              Re-split required

              An unfriended trustee still held a share of the recovery set below (§9.3.4). Start the - re-split to hand a fresh share to a new trustee set - the old shares are only destroyed - once that new set is live.

              `,1),lt=a(`· live`),ut=a(`· not live yet`),dt=a(`· destroy refused until new set is live`),ft=a(`
              `),pt=a(`

              New set attested (destroy gate: M + slack)

              Old shares destroyed (ack)

              Remaining friends - live reachability
              `),mt=a(`

              Trustee re-splits in progress

              An unfriended trustee's share is being neutralized (§9.3 step 4). Both the old and new - recovery sets stay usable until the new set is live and the old shares are destroyed - - neither door closes early.

              `,1),ht=a(`
              `),gt=a(`

              Recovery sets split from this browser

              `,1),_t=a(``),vt=a(`

              `),yt=a(`
              `),bt=a(``),xt=a(`

              Shares - send one to each trustee

              `),St=a(``),Ct=a(`
              `),wt=a(`

              Signed open - hand to the claimant
              `,1),Tt=a(`

              `,1),Et=a(`

              Send this abort to your trustees.

              `,1),Dt=a(`

              Recovery & trustees

              Split your key into pieces so a group of trustees can rebuild it if you lose access. - Each share below is a bearer secret: send one to each trustee yourself - the daemon - doesn't track who you gave it to.

              Split or re-split

              Add a trustee (extend)

              Recovery ceremony

              A ceremony is opened by a signed request a trustee receives out of band (from the person - recovering, or the daemon that observed them). The recovery delay and required approvals are - enforced by the daemon per the grant that authorized the ceremony; paste the pieces below as - they arrive.

              Open (sponsor)

              Approve

              Abort

              `);function Ot(n,a){e(a,!0);let o=()=>ee(me,`$notes`,p),f=()=>ee(oe,`$status`,p),[p,g]=u(),b=[],x=[],S=d(`split`),E=d(1),M=d(`root`),te=d(``),P=d(2),F=d(3),I=d(!1),re=d(!1),H=d(null),U=d(y([])),W=d(1),ie=d(1),ae=d(!1),G=d(!1),K=d(null),q=d(``),J=d(``),Y=d(``),se=d(``),Z=d(``),Q=d(!1),$=d(null),ce=d(``),le=d(!1),ue=d(null),de=d(``),fe=d(!1),pe=d(null);function he(e){switch(e){case`awaiting_new_set`:return`Standing up new set`;case`ready_to_destroy`:return`New set live - destroying old shares`;case`complete`:return`Complete`;default:return e}}let _e=d(null);function ve(e){return e.filter(e=>e.online).length}async function ye(e){s(_e,e,!0);try{await X.resplitStart(e)}finally{s(_e,null)}}let be=d(null);async function xe(e){s(be,e,!0);try{let t=await X.paperCards(e),n=URL.createObjectURL(new Blob([t],{type:`text/html`}));window.open(n,`_blank`,`noopener`),setTimeout(()=>URL.revokeObjectURL(n),6e4)}finally{s(be,null)}}async function Ce(e,t){await Se(e)&&(t(!0),setTimeout(()=>t(!1),1200))}async function we(){s(re,!0),s(H,null),s(U,[],!0);try{let e=k(M)===`root`?{kind:`root`}:{kind:`vault`,vid:k(te).trim()},t=await(k(S)===`split`?X.recoverySplit:X.recoveryResplit)(k(E),e,k(P),k(F),k(I));s(H,t.shares,!0),s(U,t.warnings,!0),ge({rsid:k(E),scope:e,m:k(P),n:k(F),createdAt:Date.now()})}finally{s(re,!1)}}async function Te(){s(G,!0),s(K,null);try{let e=await X.recoveryExtend(k(W),k(ie),k(ae));s(K,e.shares,!0);let t=o().recoverySets[String(k(W))];t&&ge({...t,n:t.n+k(ie)})}finally{s(G,!1)}}async function Ee(){s(Q,!0),s($,null);try{s($,await X.ceremonyOpen({subject:k(q).trim(),claimant_display:k(J).trim(),ceremony_enc:k(Y).trim(),new_node:k(se).trim(),reason:k(Z).trim()}),!0)}finally{s(Q,!1)}}async function De(){s(le,!0),s(ue,null);try{s(ue,await X.ceremonyApprove(k(ce).trim()),!0)}finally{s(le,!1)}}async function Oe(){s(fe,!0),s(pe,null);try{s(pe,(await X.ceremonyAbort(k(de).trim())).abort_hex,!0)}finally{s(fe,!1)}}let ke=d(y({})),Ae=d(!1),je=d(!1);var Me=Dt(),Ne=_(v(Me),4),Pe=e=>{var t=rt(),n=_(v(t),2),i=v(n);N(n),N(t),l(()=>r(i,`${f().share_health.recovery_sets_owned??``} recovery set(s) split · - ${f().share_health.shares_held??``} share(s) held here in trust for others`)),B(e,t)};h(Ne,e=>{f()&&e(Pe)});var Fe=_(Ne,2),Ie=e=>{var t=at(),n=_(c(t),4);C(n,5,()=>f().recovery_grants.minted,e=>e.rsid,(e,t)=>{var n=it(),i=v(n),a=v(i);N(i);var o=_(i,2),s=v(o);N(o);var c=_(o,2),u=v(c,!0);N(c),N(n),l(()=>{r(a,`rsid ${k(t).rsid??``}`),r(s,`${k(t).trustees.length??``} share(s)`),c.disabled=k(be)===k(t).rsid,r(u,k(be)===k(t).rsid?`Opening…`:`Print / export paper cards`)}),A(`click`,c,()=>xe(k(t).rsid)),B(e,n)}),N(n),B(e,t)};h(Fe,e=>{f()?.recovery_grants?.minted?.length&&e(Ie)});var Le=_(Fe,2),Re=e=>{var t=ct();C(_(c(t),4),1,()=>f().pending_resplits,e=>e.old_rsid,(e,t)=>{var n=st(),a=v(n),o=v(a),s=v(o);N(o),R(2),N(a);var c=_(a,2),u=v(c),d=v(u);N(u),R(),N(c);var f=_(c,4);C(f,21,()=>k(t).suggested,e=>e.user,(e,t)=>{var n=ot(),i=v(n),a=_(i,2),o=v(a);N(a);var s=_(a,2),c=v(s,!0);N(s),N(n),l(e=>{O(i,1,`dot ${k(t).online?`online`:`offline`}`,`svelte-imw02`),T(i,`title`,k(t).online?`online`:`offline`),r(o,`${e??``}…`),r(c,k(t).online?`online`:`offline`)},[()=>k(t).user.slice(0,12)]),B(e,n)}),N(f);var p=_(f,2),m=v(p),g=_(m),y=e=>{B(e,i(`will complete immediately once started.`))},b=ne(()=>ve(k(t).suggested)===k(t).suggested.length&&k(t).suggested.length>0),ee=e=>{B(e,i(`will complete progressively as offline trustees come online.`))};h(g,e=>{k(b)?e(y):e(ee,-1)}),N(p);var x=_(p,2),S=v(x,!0);N(x),N(n),l((e,n,i)=>{r(s,`rsid ${k(t).old_rsid??``}`),r(d,`${e??``}…`),O(p,1,n),r(m,`${i??``} / ${k(t).suggested.length??``} suggested trustee(s) online - `),x.disabled=k(_e)===k(t).old_rsid,r(S,k(_e)===k(t).old_rsid?`Starting…`:`Start re-split (use suggested set)`)},[()=>k(t).ex_trustee.slice(0,12),()=>D(ve(k(t).suggested)===k(t).suggested.length&&k(t).suggested.length>0?`healthy`:`muted`),()=>ve(k(t).suggested)]),A(`click`,x,()=>ye(k(t).old_rsid)),B(e,n)}),B(e,t)};h(Le,e=>{f()?.pending_resplits?.length&&e(Re)});var ze=_(Le,2),Be=e=>{var t=mt();C(_(c(t),4),1,()=>f().resplits,e=>e.old_rsid,(e,t)=>{var n=pt(),a=v(n),o=v(a),s=v(o);N(o);var c=_(o,2),u=v(c,!0);N(c),N(a);var d=_(a,2),f=v(d);N(d);var p=_(d,2),m=v(p),g=_(v(m),2),y=v(g),b=_(y),ee=e=>{B(e,lt())},x=e=>{B(e,ut())};h(b,e=>{k(t).new_set_live?e(ee):e(x,-1)}),N(g),N(m);var S=_(m,2),w=_(v(S),2),E=v(w),D=_(E),A=e=>{B(e,dt())};h(D,e=>{k(t).new_set_live||e(A)}),N(w),N(S),N(p);var j=_(p,4);C(j,21,()=>k(t).remaining,e=>e.node,(e,t)=>{var n=ft(),a=v(n),o=_(a,2),s=v(o);N(o);var c=_(o,2),u=v(c,!0);N(c);var d=_(c,2),f=v(d),p=e=>{B(e,i(`done`))},m=e=>{var n=i();l(()=>r(n,`online - ${k(t).role===`new`?`sending share`:`sending destroy`}`)),B(e,n)},g=e=>{B(e,i(`offline - queued`))};h(f,e=>{k(t).done?e(p):k(t).online?e(m,1):e(g,-1)}),N(d),N(n),l(e=>{O(a,1,`dot ${k(t).status??``}`,`svelte-imw02`),T(a,`title`,k(t).status),r(s,`${e??``}…`),O(c,1,`role ${k(t).role??``}`,`svelte-imw02`),r(u,k(t).role===`new`?`gets new share`:`gets destroy step`)},[()=>k(t).node.slice(0,12)]),B(e,n)}),N(j),N(n),l((e,n)=>{r(s,`rsid ${k(t).old_rsid??``} → ${k(t).new_rsid??``}`),O(c,1,`phase ${k(t).phase??``}`,`svelte-imw02`),r(u,e),r(f,`ex-trustee ${n??``}…`),r(y,`${k(t).new_attested??``} / ${k(t).new_total??``} `),r(E,`${k(t).old_destroyed??``} / ${k(t).old_total??``} `)},[()=>he(k(t).phase),()=>k(t).ex_trustee.slice(0,12)]),B(e,n)}),B(e,t)};h(ze,e=>{f()?.resplits?.length&&e(Be)});var Ve=_(ze,2),He=e=>{var t=gt(),n=_(c(t),2);C(n,5,()=>Object.values(o().recoverySets),e=>e.rsid,(e,t)=>{var n=ht(),i=v(n),a=v(i);N(i);var o=_(i,2),s=v(o,!0);N(o);var c=_(o,2),u=v(c);N(c),N(n),l(e=>{r(a,`rsid ${k(t).rsid??``}`),r(s,e),r(u,`${k(t).m??``}-of-${k(t).n??``}`)},[()=>k(t).scope.kind===`root`?`your root key`:`vault ${k(t).scope.vid.slice(0,10)}…`]),B(e,n)}),N(n),B(e,t)},Ue=ne(()=>Object.keys(o().recoverySets).length>0);h(Ve,e=>{k(Ue)&&e(He)});var We=_(Ve,4),Ge=v(We),Ke=v(Ge),qe=v(Ke);z(qe),qe.value=qe.__value=`split`,R(),N(Ke);var Je=_(Ke,2),Ye=v(Je);z(Ye),Ye.value=Ye.__value=`resplit`,R(),N(Je),N(Ge);var Xe=_(Ge,2),Ze=_(v(Xe),2);z(Ze),N(Xe);var Qe=_(Xe,2),$e=v(Qe),et=v($e);z(et),et.value=et.__value=`root`,R(),N($e);var tt=_($e,2),nt=v(tt);z(nt),nt.value=nt.__value=`vault`,R(),N(tt);var Ot=_(tt,2),kt=e=>{var t=_t();z(t),j(t,()=>k(te),e=>s(te,e)),B(e,t)};h(Ot,e=>{k(M)===`vault`&&e(kt)}),N(Qe);var At=_(Qe,2),jt=_(v(At),2);z(jt);var Mt=_(jt,4);z(Mt),N(At);var Nt=_(At,2),Pt=v(Nt),Ft=v(Pt);z(Ft),R(),N(Pt),N(Nt);var It=_(Nt,2),Lt=v(It,!0);N(It),N(We);var Rt=_(We,2),zt=e=>{var t=yt();C(t,20,()=>k(U),e=>e,(e,t)=>{var n=vt(),i=v(n,!0);N(n),l(()=>r(i,t)),B(e,n)}),N(t),B(e,t)};h(Rt,e=>{k(U).length&&e(zt)});var Bt=_(Rt,2),Vt=e=>{var t=xt();C(_(v(t),2),17,()=>k(H),V,(e,t,n)=>{var i=bt(),a=v(i),o=v(a,!0);N(a);var c=_(a,2),u=v(c,!0);N(c),N(i),l(()=>{r(o,k(t)),r(u,k(ke)[n]?`Copied`:`Copy`)}),A(`click`,c,()=>Ce(k(t),e=>s(ke,{...k(ke),[n]:e},!0))),B(e,i)}),N(t),B(e,t)};h(Bt,e=>{k(H)&&e(Vt)});var Ht=_(Bt,4),Ut=v(Ht),Wt=_(v(Ut),2);z(Wt);var Gt=_(Wt,4);z(Gt);var Kt=_(Gt,2),qt=v(Kt);z(qt),R(),N(Kt),N(Ut);var Jt=_(Ut,2),Yt=v(Jt,!0);N(Jt),N(Ht);var Xt=_(Ht,2),Zt=e=>{var t=Ct();C(t,21,()=>k(K),V,(e,t)=>{var n=St(),i=v(n),a=v(i,!0);N(i),N(n),l(()=>r(a,k(t))),B(e,n)}),N(t),B(e,t)};h(Xt,e=>{k(K)&&e(Zt)});var Qt=_(Xt,6),$t=v(Qt),en=_(v($t),4);z(en);var tn=_(en,4);z(tn);var nn=_(tn,4);z(nn);var rn=_(nn,4);z(rn);var an=_(rn,4);z(an);var on=_(an,2),sn=v(on,!0);N(on);var cn=_(on,2),ln=e=>{var t=wt(),n=c(t),i=v(n);N(n);var a=_(n,4),o=v(a),u=v(o,!0);N(o);var d=_(o,2),f=v(d,!0);N(d),N(a),l(e=>{r(i,`id ${e??``}… · fanned out to ${k($).fanout_reached??``} peer(s)`),r(u,k($).open_hex),r(f,k(Ae)?`Copied`:`Copy`)},[()=>k($).ceremony_id.slice(0,12)]),A(`click`,d,()=>Ce(k($).open_hex,e=>s(Ae,e,!0))),B(e,t)};h(cn,e=>{k($)&&e(ln)}),N($t);var un=_($t,2),dn=_(v(un),4);z(dn);var fn=_(dn,2),pn=v(fn,!0);N(fn);var mn=_(fn,2),hn=e=>{var t=Tt(),n=c(t),i=v(n);N(n);var a=_(n,2),o=v(a),u=v(o,!0);N(o);var d=_(o,2),f=v(d,!0);N(d),N(a),l(()=>{r(i,`Approval broadcast to ${k(ue).broadcast_reached??``} co-trustee(s).`),r(u,k(ue).approve_hex),r(f,k(je)?`Copied`:`Copy`)}),A(`click`,d,()=>Ce(k(ue).approve_hex,e=>s(je,e,!0))),B(e,t)};h(mn,e=>{k(ue)&&e(hn)}),N(un);var gn=_(un,2),_n=_(v(gn),4);z(_n);var vn=_(_n,2),yn=v(vn,!0);N(vn);var bn=_(vn,2),xn=e=>{var t=Et(),n=c(t),i=v(n,!0);N(n),R(2),l(()=>r(i,k(pe))),B(e,t)};h(bn,e=>{k(pe)&&e(xn)}),N(gn),N(Qt),N(Me),l(()=>{It.disabled=k(re),r(Lt,k(re)?`Splitting…`:k(S)===`split`?`Split key`:`Re-split key`),Jt.disabled=k(G),r(Yt,k(G)?`Issuing…`:`Issue new share(s)`),on.disabled=k(Q),r(sn,k(Q)?`Opening…`:`Open ceremony`),fn.disabled=k(le),r(pn,k(le)?`Recording…`:`Record approval`),vn.disabled=k(fe),r(yn,k(fe)?`Signing…`:`Abort as subject`)}),t(`submit`,We,e=>(e.preventDefault(),we())),w(b,[],qe,()=>k(S),e=>s(S,e)),w(b,[],Ye,()=>k(S),e=>s(S,e)),j(Ze,()=>k(E),e=>s(E,e)),w(x,[],et,()=>k(M),e=>s(M,e)),w(x,[],nt,()=>k(M),e=>s(M,e)),j(jt,()=>k(P),e=>s(P,e)),j(Mt,()=>k(F),e=>s(F,e)),L(Ft,()=>k(I),e=>s(I,e)),t(`submit`,Ht,e=>(e.preventDefault(),Te())),j(Wt,()=>k(W),e=>s(W,e)),j(Gt,()=>k(ie),e=>s(ie,e)),L(qt,()=>k(ae),e=>s(ae,e)),t(`submit`,$t,e=>(e.preventDefault(),Ee())),j(en,()=>k(q),e=>s(q,e)),j(tn,()=>k(J),e=>s(J,e)),j(nn,()=>k(Y),e=>s(Y,e)),j(rn,()=>k(se),e=>s(se,e)),j(an,()=>k(Z),e=>s(Z,e)),t(`submit`,un,e=>(e.preventDefault(),De())),j(dn,()=>k(ce),e=>s(ce,e)),t(`submit`,gn,e=>(e.preventDefault(),Oe())),j(_n,()=>k(de),e=>s(de,e)),B(n,Me),m(),g()}p([`click`]);var kt=a(``),At=a(`

              Send this to each person in the audience.

              `,1),jt=a(`
            • `),Mt=a(`

                `,1),Nt=a(`

                Shared files

                Share files from a vault

                A share is a snapshot of these files at the vault's current epoch. It cannot - be recalled once handed over - editing the files afterward only affects future shares, not - this one.

                Fetch a file someone shared with you

                `);function Pt(n,i){e(i,!0);let a=d(y([]));X.listVaults().then(e=>s(a,e.published,!0)).catch(()=>{});let o=d(``),u=d(``),f=d(``),p=d(!1),g=d(null),b=d(!1);async function ee(){if(!(!k(o)||!k(u).trim()||!k(f).trim())){s(p,!0),s(g,null);try{let e=k(u).split(` -`).map(e=>e.trim()).filter(Boolean),t=k(f).split(`,`).map(e=>e.trim()).filter(Boolean),n=await X.discloseFiles(k(o),e,t);s(g,n.grant_hex,!0)}finally{s(p,!1)}}}async function x(){k(g)&&await Se(k(g))&&(s(b,!0),setTimeout(()=>s(b,!1),1200))}let S=d(``),w=d(``),T=d(``),E=d(``),D=d(!1),O=d(null);async function M(){if(!(!k(S).trim()||!k(w).trim()||!k(E).trim())){s(D,!0),s(O,null);try{let e=k(T).split(`,`).map(e=>e.trim()).filter(Boolean),t=await X.fetchGrant(k(S).trim(),{node:k(w).trim(),addrs:e},k(E).trim());s(O,t.written,!0)}finally{s(D,!1)}}}var te=Nt(),ne=_(v(te),2),F=_(v(ne),4),I=_(v(F),2),L=v(I);L.value=L.__value=``,C(_(L),17,()=>k(a),e=>e.vid,(e,t)=>{var n=kt(),i=v(n);N(n);var a={};l(e=>{r(i,`${e??``}… (epoch ${k(t).epoch??``})`),a!==(a=k(t).vid)&&(n.value=(n.__value=k(t).vid)??``)},[()=>k(t).vid.slice(0,16)]),B(e,n)}),N(I);var V=_(I,4);re(V);var H=_(V,4);z(H);var U=_(H,2),W=v(U,!0);N(U),N(F);var ie=_(F,2),ae=e=>{var t=At(),n=c(t),i=v(n),a=v(i,!0);N(i);var o=_(i,2),s=v(o,!0);N(o),N(n),R(2),l(()=>{r(a,k(g)),r(s,k(b)?`Copied`:`Copy`)}),A(`click`,o,x),B(e,t)};h(ie,e=>{k(g)&&e(ae)}),N(ne);var G=_(ne,2),K=_(v(G),2),q=_(v(K),2);z(q);var J=_(q,4);z(J);var Y=_(J,4);z(Y);var oe=_(Y,4);z(oe);var se=_(oe,2),Z=v(se,!0);N(se),N(K);var Q=_(K,2),$=e=>{var t=Mt(),n=c(t),i=v(n);N(n);var a=_(n,2);C(a,20,()=>k(O),e=>e,(e,t)=>{var n=jt(),i=v(n,!0);N(n),l(()=>r(i,t)),B(e,n)}),N(a),l(()=>r(i,`Wrote ${k(O).length??``} file(s):`)),B(e,t)};h(Q,e=>{k(O)&&e($)}),N(G),N(te),l(()=>{U.disabled=k(p),r(W,k(p)?`Sharing…`:`Share files`),se.disabled=k(D),r(Z,k(D)?`Fetching…`:`Fetch files`)}),t(`submit`,F,e=>(e.preventDefault(),ee())),P(I,()=>k(o),e=>s(o,e)),j(V,()=>k(u),e=>s(u,e)),j(H,()=>k(f),e=>s(f,e)),t(`submit`,K,e=>(e.preventDefault(),M())),j(q,()=>k(S),e=>s(S,e)),j(J,()=>k(w),e=>s(w,e)),j(Y,()=>k(T),e=>s(T,e)),j(oe,()=>k(E),e=>s(E,e)),B(n,te),m()}p([`click`]);var Ft=a(` `),It=a(`
                Carapace
                `);function Lt(t,i){e(i,!0);let a=()=>ee(se,`$live`,c),[c,f]=u(),p={"":`Overview`,vaults:`Vaults`,friends:`Friends`,recovery:`Recovery`,shared:`Shared files`};function g(){let e=typeof location<`u`?location.hash.replace(/^#\/?/,``):``;return e in p?e:``}let b=d(y(g()));function S(){s(b,g(),!0)}let w=d(null);function E(e){e?document.documentElement.setAttribute(`data-theme`,e):document.documentElement.removeAttribute(`data-theme`)}function D(){let e=typeof matchMedia<`u`&&matchMedia(`(prefers-color-scheme: light)`).matches,t=k(w)?k(w)===`light`:e;s(w,t?`dark`:`light`,!0),localStorage.setItem(`carapace-theme`,k(w)),E(k(w))}F(()=>{window.addEventListener(`hashchange`,S);let e=localStorage.getItem(`carapace-theme`);return(e===`dark`||e===`light`)&&(s(w,e,!0),E(k(w))),ce(),()=>window.removeEventListener(`hashchange`,S)}),te(()=>{le()});var j=It();x(`1uha8ag`,e=>{o(()=>{n.title=`Carapace`})});var M=v(j),P=_(v(M),2);C(P,21,()=>Object.entries(p),([e,t])=>e,(e,t)=>{var n=ne(()=>I(k(t),2));let i=()=>k(n)[0],a=()=>k(n)[1];var o=Ft();let s;var c=v(o,!0);N(o),l(()=>{T(o,`href`,i()?`#/${i()}`:`#/`),s=O(o,1,`svelte-1uha8ag`,null,s,{active:k(b)===i()}),r(c,a())}),B(e,o)}),N(P);var L=_(P,2),R=v(L);let z;var V=_(R,2),re=v(V,!0);N(V),N(L),N(M);var H=_(M,2),U=v(H);de(U,{});var W=_(U,2),ie=e=>{Ue(e,{})},ae=e=>{nt(e,{})},G=e=>{Ot(e,{})},K=e=>{Pt(e,{})},q=e=>{Ae(e,{})};h(W,e=>{k(b)===`vaults`?e(ie):k(b)===`friends`?e(ae,1):k(b)===`recovery`?e(G,2):k(b)===`shared`?e(K,3):e(q,-1)}),N(H),N(j),l(()=>{z=O(R,1,`live-dot svelte-1uha8ag`,null,z,{live:a()}),T(R,`title`,a()?`Live updates connected`:`Reconnecting…`),r(re,k(w)===`light`?`Molt (light)`:`Dark`)}),A(`click`,V,D),B(t,j),m(),f()}p([`click`]);export{Lt as component}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/version.json b/crates/carapace-api/static/_app/version.json index 9b24b99..a0e0c28 100644 --- a/crates/carapace-api/static/_app/version.json +++ b/crates/carapace-api/static/_app/version.json @@ -1 +1 @@ -{"version":"1784071871063"} \ No newline at end of file +{"version":"1785631688168"} \ No newline at end of file diff --git a/crates/carapace-api/static/claimant.css b/crates/carapace-api/static/claimant.css new file mode 100644 index 0000000..c7b9a2c --- /dev/null +++ b/crates/carapace-api/static/claimant.css @@ -0,0 +1,14 @@ +:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #151719; color: #f3eee5; } +body { margin: 0; } +main { max-width: 52rem; margin: auto; padding: 1.25rem; } +header, section { border: 1px solid #53504a; border-radius: .75rem; padding: 1.25rem; margin-bottom: 1rem; background: #202326; } +.eyebrow { color: #d9a86c; text-transform: uppercase; letter-spacing: .08em; } +label { display: block; margin: 1rem 0 .4rem; } +textarea { box-sizing: border-box; width: 100%; margin-top: .4rem; padding: .7rem; color: inherit; background: #111315; border: 1px solid #77736b; border-radius: .35rem; font: .9rem ui-monospace, monospace; } +button { margin-top: .35rem; padding: .65rem .9rem; color: #151719; background: #d9a86c; border: 0; border-radius: .35rem; font-weight: 700; cursor: pointer; } +button:focus-visible, textarea:focus-visible { outline: 3px solid #6fc7bd; outline-offset: 2px; } +button:disabled { opacity: .55; cursor: wait; } +#error { margin: 1rem 0; padding: 1rem; border: 2px solid #e68172; } +#notice, #progress { min-height: 1.4em; } +@media (max-width: 40rem) { main { padding: .65rem; } header, section { padding: .9rem; } } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; } } diff --git a/crates/carapace-api/static/claimant.html b/crates/carapace-api/static/claimant.html new file mode 100644 index 0000000..f5fdc8a --- /dev/null +++ b/crates/carapace-api/static/claimant.html @@ -0,0 +1,80 @@ + + + + + + + Carapace identity recovery + + + + +
                +
                +

                Claimant mode

                +

                Recover this Carapace identity

                +

                This isolated server cannot open vaults or use normal daemon actions.

                +
                + +
                + + +
                +

                1. Give the public session values to a sponsor

                +

                Verify the sponsor through a separate trusted channel. This package contains public values only.

                + + +
                + Advanced manual values + + +
                +
                + +
                +

                2. Import the signed open and trustee endpoints

                +

                The signed open must contain the exact public values above. Add one trustee user per line.

                +
                + + + +
                + Advanced manual values + + + +
                + + +
                +

                +
                + + +
                + + diff --git a/crates/carapace-api/static/claimant.js b/crates/carapace-api/static/claimant.js new file mode 100644 index 0000000..6fee8b1 --- /dev/null +++ b/crates/carapace-api/static/claimant.js @@ -0,0 +1,138 @@ +const token = document.querySelector('meta[name="carapace-claimant-token"]')?.content ?? ''; +const errorBox = document.querySelector('#error'); +const notice = document.querySelector('#notice'); +const progress = document.querySelector('#progress'); + +function showError(message) { + errorBox.textContent = message; + errorBox.hidden = false; +} + +async function request(path, init) { + const response = await fetch(path, { + ...init, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...init?.headers } + }); + const body = await response.json(); + if (!response.ok) throw new Error(typeof body.error === 'string' ? body.error : 'The claimant request failed.'); + return body; +} + +async function copyPublicValue(id) { + try { + await navigator.clipboard.writeText(document.querySelector(`#${id}`).value); + notice.textContent = 'Copied.'; + } catch { + showError('Could not copy. Select and copy the public value manually.'); + } +} + +function lines(id) { + return document.querySelector(`#${id}`).value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean); +} + +function trusteeRows() { + return lines('trustees').map((line) => { + const [node, rawAddresses = ''] = line.split('|', 2); + return { node: node.trim(), addrs: rawAddresses.split(',').map((value) => value.trim()).filter(Boolean) }; + }); +} + +let announceRefs = []; + +function importSponsorPackage() { + const value = JSON.parse(document.querySelector('#sponsor-package').value); + if (value.type !== 'carapace.sponsor-ceremony' || value.version !== 1 || + typeof value.open_hex !== 'string' || !Array.isArray(value.roster) || !Array.isArray(value.trustees)) { + throw new Error('The sponsor ceremony package type, version, or fields are invalid.'); + } + document.querySelector('#open-hex').value = value.open_hex; + document.querySelector('#roster').value = value.roster.join('\n'); + document.querySelector('#trustees').value = value.trustees.map((trustee) => + `${trustee.node}|${Array.isArray(trustee.addrs) ? trustee.addrs.join(',') : ''}`).join('\n'); + announceRefs = Array.isArray(value.announce_refs) ? value.announce_refs : []; +} + +let confirmedSubject = ''; + +async function previewOpen() { + errorBox.hidden = true; + importSponsorPackage(); + const preview = await request('/api/claimant/preview', { + method: 'POST', body: JSON.stringify({ open_hex: document.querySelector('#open-hex').value.trim() }) + }); + if (!preview.session_bound) throw new Error('The signed open is not bound to this session.'); + confirmedSubject = preview.subject; + document.querySelector('#subject-id').textContent = preview.subject; + document.querySelector('#subject-confirmation').hidden = false; + document.querySelector('#confirm-subject').checked = false; + document.querySelector('#recover').disabled = true; +} + +async function loadSession() { + const session = await request('/api/claimant/status'); + if (session.phase === 'activation_complete') { + document.querySelector('#restart').hidden = false; + document.querySelector('#complete-form').hidden = true; + return; + } + document.querySelector('#ceremony-enc').value = session.ceremony_enc; + document.querySelector('#new-node').value = session.new_node; + document.querySelector('#handoff').value = session.handoff; +} + +document.querySelectorAll('[data-copy]').forEach((button) => { + button.addEventListener('click', () => copyPublicValue(button.dataset.copy)); +}); + +document.querySelector('#preview-open').addEventListener('click', () => { + previewOpen().catch((error) => showError(error instanceof Error ? error.message : 'Could not verify the signed open.')); +}); + +document.querySelector('#sponsor-package').addEventListener('input', () => { + confirmedSubject = ''; + announceRefs = []; + document.querySelector('#subject-confirmation').hidden = true; + document.querySelector('#recover').disabled = true; +}); + +document.querySelector('#cancel').addEventListener('click', async () => { + errorBox.hidden = true; + await request('/api/claimant/cancel', { method: 'POST', body: '{}' }); + for (const id of ['sponsor-package', 'open-hex', 'roster', 'trustees']) document.querySelector(`#${id}`).value = ''; + confirmedSubject = ''; + announceRefs = []; + document.querySelector('#subject-confirmation').hidden = true; + document.querySelector('#recover').disabled = true; + progress.textContent = 'The old session keys were cleared. A fresh retry session is ready.'; + await loadSession(); +}); + +document.querySelector('#confirm-subject').addEventListener('change', (event) => { + document.querySelector('#recover').disabled = !(event.target.checked && confirmedSubject); +}); + +document.querySelector('#complete-form').addEventListener('submit', async (event) => { + event.preventDefault(); + errorBox.hidden = true; + const button = document.querySelector('#recover'); + button.disabled = true; + progress.textContent = 'Contacting trustees. Keep this window open.'; + try { + const result = await request('/api/claimant/complete', { + method: 'POST', + body: JSON.stringify({ open_hex: document.querySelector('#open-hex').value.trim(), confirmed_subject: confirmedSubject, roster: lines('roster'), trustees: trusteeRows(), announce_refs: announceRefs }) + }); + if (!result.restart_required) throw new Error('Activation did not request a safe restart.'); + progress.textContent = 'Recovered identity activation is complete.'; + document.querySelector('#restart').hidden = false; + document.querySelector('#complete-form').hidden = true; + document.querySelector('#restart').focus?.(); + } catch (error) { + showError(error instanceof Error ? error.message : 'Recovery did not complete.'); + progress.textContent = 'Recovery stopped without activation.'; + button.disabled = false; + } +}); + +loadSession().catch(() => showError('Could not load the claimant session. Reload this local page.')); diff --git a/crates/carapace-api/static/index.html b/crates/carapace-api/static/index.html index c2e4e0d..67e9ff8 100644 --- a/crates/carapace-api/static/index.html +++ b/crates/carapace-api/static/index.html @@ -4,29 +4,29 @@ - - + + - + - + - +

                Recovery & trustees

                Split your key into pieces so a group of trustees can rebuild it if you lose access. - Each share below is a bearer secret: send one to each trustee yourself - the daemon - doesn't track who you gave it to. + The normal flow sends a signed recovery grant to each selected friend and tracks delivery. + The advanced manual flow shows bearer shares that you must protect and deliver yourself.

                {#if $status} @@ -179,6 +240,55 @@
                {/if} +
                + Restore retained vaults after identity recovery +

                Discovery-dependent operation · it is safe to retry after a network failure

                +

                Use the public restart handoff saved by claimant activation. Carapace contacts the verified trustee hints and accepts only the maximum announced epoch for each vault.

                +
                (event.preventDefault(), runRestartRestore())}> + + + +
                +
                {#if restartRestoring}

                Restore is in progress.

                {:else if restartResult}

                Restored {restartResult.restored} vault(s) from {restartResult.refs} maximum-epoch reference(s).

                {/if}
                +
                + + {#if $status?.ceremonies?.length} +

                Active recovery ceremonies

                +

                + Review the claimant and reason out of band before you approve a ceremony. Abort any ceremony + against your identity that you did not start. +

                +
                + {#each $status.ceremonies as ceremony (ceremony.ceremony_id)} +
                +
                +
                + {ceremony.claimant_display || 'Unnamed claimant'} +
                {ceremony.ceremony_id}
                +
                + {ceremony.phase} +
                + {#if ceremony.alarm} + + {/if} +
                +
                Reason
                {ceremony.reason || 'No reason supplied'}
                +
                Approvals
                {ceremony.approvals} / {ceremony.threshold}
                +
                Sponsor
                {ceremony.sponsor}
                +
                +
                + {#if ceremony.trustee && !ceremony.approved} + + {/if} + {#if ceremony.is_self_subject && !ceremony.takeover} + + {/if} +
                +
                + {/each} +
                + {/if} + {#if $status?.recovery_grants?.minted?.length}

                Paper cards (offline backstop)

                @@ -313,14 +423,16 @@ {/each} {/if} - {#if Object.keys($notes.recoverySets).length > 0} -

                Recovery sets split from this browser

                + {#if $status?.share_health.sets.length} +

                Authoritative recovery sets

                - {#each Object.values($notes.recoverySets) as rs (rs.rsid)} + {#each $status.share_health.sets as rs (rs.rsid)}
                rsid {rs.rsid} {rs.scope.kind === 'root' ? 'your root key' : `vault ${rs.scope.vid.slice(0, 10)}…`} - {rs.m}-of-{rs.n} + {rs.threshold}-of-{rs.issued} + {rs.trustees.filter((trustee) => trustee.delivered).length}/{rs.trustees.length} trustee grants delivered + {#if rs.warnings.length}{rs.warnings.join(', ')}{/if}
                {/each}
                @@ -336,10 +448,15 @@ Re-split (raise M or replace trustees) -
                - - -
                + {#if mode === 'split'} +

                Carapace will create recovery set {nextRsid}.

                +
                Advanced recovery-set id{#if manualSetId}{/if}
                + {:else} + + + {/if}
                {#if scopeKind === 'vault'} - + {/if} -
                -
                - - - - +
                +
                + Share delivery + + +
                + {#if deliveryMode === 'friends'} +
                + Select trustees ({selectedTrustees.length} selected) + {#if !$status?.friends.list.length} +

                Add friends before you create recovery protection.

                + {:else} +
                + {#each $status.friends.list as friend (friend)} + + {/each} +
                + {/if} +
                + {/if} +
                + + + {#if deliveryMode === 'manual'} + + + {:else} + of {selectedTrustees.length} selected + {/if}
                - - + - {#if splitWarnings.length} + {#if deliveryResult} +
                0} style="margin-top: 1rem"> +

                Verified share delivery

                +

                {deliveryResult.delivered.length} trustee(s) confirmed that they stored a signed recovery grant.

                + {#if deliveryResult.undelivered.length} +

                Could not reach {deliveryResult.undelivered.length} trustee(s). Carapace will retry delivery during maintenance.

                +
                  {#each deliveryResult.undelivered as trustee (trustee)}
                • {trustee}
                • {/each}
                + {:else} +

                All selected trustees received their shares.

                + {/if} +
                + {/if} + + {#if splitWarnings.length}
                {#each splitWarnings as w (w)}

                {w}

                {/each}
                @@ -386,10 +549,11 @@ {/if}

                Add a trustee (extend)

                +

                Peer-dependent operation · offline delivery stays queued and is safe to retry

                (e.preventDefault(), runExtend())}>
                - - + + @@ -418,23 +582,50 @@
                (e.preventDefault(), runOpen())}>

                Open (sponsor)

                - - +

                + Use the recovery request from the claimant. Confirm their identity and new device through + a separate trusted channel before you open the ceremony. +

                + + + {#if !$status?.recovery_grants.held.length} +

                + This device does not hold a recovery grant. It cannot sponsor a ceremony. +

                + {/if} - - - - + + + + {#if handoffError}{/if} +
                + Advanced manual values + + + + +
                - + {#if openResult}

                id {openResult.ceremony_id.slice(0, 12)}… · fanned out to {openResult.fanout_reached} peer(s)

                -
                Signed open - hand to the claimant
                +
                Verified sponsor ceremony package - hand to the claimant
                @@ -457,11 +648,15 @@ {/if} -
                (e.preventDefault(), runAbort())}> + (e.preventDefault(), runAbort())}>

                Abort

                - - + + + {#if abortHex}

                Send this abort to your trustees.

                @@ -487,6 +682,31 @@ color: var(--muted); } + fieldset { + border: 1px solid var(--hairline); + border-radius: var(--radius); + margin: 0 0 0.75rem; + padding: 0.75rem; + } + + fieldset > label, + .trustee-list label { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.35rem 0; + } + + legend { + font-weight: 600; + } + + .trustee-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0 1rem; + } + .list { display: flex; flex-direction: column; @@ -530,6 +750,70 @@ margin-bottom: 0.5rem; } + .grid select { + width: 100%; + margin-bottom: 0.5rem; + } + + .ceremony.alarm { + border-color: var(--coral); + } + + .alarm-text { + color: var(--coral); + font-weight: 600; + } + + dl { + display: grid; + gap: 0.5rem; + } + + dl div { + display: grid; + grid-template-columns: minmax(6rem, 0.25fr) 1fr; + gap: 0.75rem; + } + + dt { + color: var(--muted); + } + + dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; + } + + button.danger { + border-color: var(--coral); + color: var(--coral); + } + + .confirm-action { + display: flex !important; + align-items: center; + gap: 0.5rem; + } + + .confirm-action input { + width: auto; + margin: 0; + } + + @media (max-width: 640px) { + .share-row, + .set-row { + align-items: stretch; + flex-direction: column; + } + + dl div { + grid-template-columns: 1fr; + gap: 0.1rem; + } + } + .resplit-head { display: flex; justify-content: space-between; diff --git a/gui/src/lib/components/SharedView.svelte b/gui/src/lib/components/SharedView.svelte index 1203ac5..c3bb9e1 100644 --- a/gui/src/lib/components/SharedView.svelte +++ b/gui/src/lib/components/SharedView.svelte @@ -1,6 +1,7 @@ + + +
                +
                +

                Claimant mode

                +

                Recover this Carapace identity

                +

                This isolated server cannot open vaults or use normal daemon actions.

                +
                + +
                + + +
                +

                1. Give the public session values to a sponsor

                +

                Verify the sponsor through a separate trusted channel. This package contains public values only.

                + + +
                + Advanced manual values + + +
                +
                + +
                +

                2. Import the signed open and trustee endpoints

                +

                The signed open must contain the exact public values above. Add one trustee user per line.

                + + + + +
                + Advanced manual values + + + +
                + + + +

                +
                + + +
                + + diff --git a/gui/static/claimant.js b/gui/static/claimant.js new file mode 100644 index 0000000..6fee8b1 --- /dev/null +++ b/gui/static/claimant.js @@ -0,0 +1,138 @@ +const token = document.querySelector('meta[name="carapace-claimant-token"]')?.content ?? ''; +const errorBox = document.querySelector('#error'); +const notice = document.querySelector('#notice'); +const progress = document.querySelector('#progress'); + +function showError(message) { + errorBox.textContent = message; + errorBox.hidden = false; +} + +async function request(path, init) { + const response = await fetch(path, { + ...init, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...init?.headers } + }); + const body = await response.json(); + if (!response.ok) throw new Error(typeof body.error === 'string' ? body.error : 'The claimant request failed.'); + return body; +} + +async function copyPublicValue(id) { + try { + await navigator.clipboard.writeText(document.querySelector(`#${id}`).value); + notice.textContent = 'Copied.'; + } catch { + showError('Could not copy. Select and copy the public value manually.'); + } +} + +function lines(id) { + return document.querySelector(`#${id}`).value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean); +} + +function trusteeRows() { + return lines('trustees').map((line) => { + const [node, rawAddresses = ''] = line.split('|', 2); + return { node: node.trim(), addrs: rawAddresses.split(',').map((value) => value.trim()).filter(Boolean) }; + }); +} + +let announceRefs = []; + +function importSponsorPackage() { + const value = JSON.parse(document.querySelector('#sponsor-package').value); + if (value.type !== 'carapace.sponsor-ceremony' || value.version !== 1 || + typeof value.open_hex !== 'string' || !Array.isArray(value.roster) || !Array.isArray(value.trustees)) { + throw new Error('The sponsor ceremony package type, version, or fields are invalid.'); + } + document.querySelector('#open-hex').value = value.open_hex; + document.querySelector('#roster').value = value.roster.join('\n'); + document.querySelector('#trustees').value = value.trustees.map((trustee) => + `${trustee.node}|${Array.isArray(trustee.addrs) ? trustee.addrs.join(',') : ''}`).join('\n'); + announceRefs = Array.isArray(value.announce_refs) ? value.announce_refs : []; +} + +let confirmedSubject = ''; + +async function previewOpen() { + errorBox.hidden = true; + importSponsorPackage(); + const preview = await request('/api/claimant/preview', { + method: 'POST', body: JSON.stringify({ open_hex: document.querySelector('#open-hex').value.trim() }) + }); + if (!preview.session_bound) throw new Error('The signed open is not bound to this session.'); + confirmedSubject = preview.subject; + document.querySelector('#subject-id').textContent = preview.subject; + document.querySelector('#subject-confirmation').hidden = false; + document.querySelector('#confirm-subject').checked = false; + document.querySelector('#recover').disabled = true; +} + +async function loadSession() { + const session = await request('/api/claimant/status'); + if (session.phase === 'activation_complete') { + document.querySelector('#restart').hidden = false; + document.querySelector('#complete-form').hidden = true; + return; + } + document.querySelector('#ceremony-enc').value = session.ceremony_enc; + document.querySelector('#new-node').value = session.new_node; + document.querySelector('#handoff').value = session.handoff; +} + +document.querySelectorAll('[data-copy]').forEach((button) => { + button.addEventListener('click', () => copyPublicValue(button.dataset.copy)); +}); + +document.querySelector('#preview-open').addEventListener('click', () => { + previewOpen().catch((error) => showError(error instanceof Error ? error.message : 'Could not verify the signed open.')); +}); + +document.querySelector('#sponsor-package').addEventListener('input', () => { + confirmedSubject = ''; + announceRefs = []; + document.querySelector('#subject-confirmation').hidden = true; + document.querySelector('#recover').disabled = true; +}); + +document.querySelector('#cancel').addEventListener('click', async () => { + errorBox.hidden = true; + await request('/api/claimant/cancel', { method: 'POST', body: '{}' }); + for (const id of ['sponsor-package', 'open-hex', 'roster', 'trustees']) document.querySelector(`#${id}`).value = ''; + confirmedSubject = ''; + announceRefs = []; + document.querySelector('#subject-confirmation').hidden = true; + document.querySelector('#recover').disabled = true; + progress.textContent = 'The old session keys were cleared. A fresh retry session is ready.'; + await loadSession(); +}); + +document.querySelector('#confirm-subject').addEventListener('change', (event) => { + document.querySelector('#recover').disabled = !(event.target.checked && confirmedSubject); +}); + +document.querySelector('#complete-form').addEventListener('submit', async (event) => { + event.preventDefault(); + errorBox.hidden = true; + const button = document.querySelector('#recover'); + button.disabled = true; + progress.textContent = 'Contacting trustees. Keep this window open.'; + try { + const result = await request('/api/claimant/complete', { + method: 'POST', + body: JSON.stringify({ open_hex: document.querySelector('#open-hex').value.trim(), confirmed_subject: confirmedSubject, roster: lines('roster'), trustees: trusteeRows(), announce_refs: announceRefs }) + }); + if (!result.restart_required) throw new Error('Activation did not request a safe restart.'); + progress.textContent = 'Recovered identity activation is complete.'; + document.querySelector('#restart').hidden = false; + document.querySelector('#complete-form').hidden = true; + document.querySelector('#restart').focus?.(); + } catch (error) { + showError(error instanceof Error ? error.message : 'Recovery did not complete.'); + progress.textContent = 'Recovery stopped without activation.'; + button.disabled = false; + } +}); + +loadSession().catch(() => showError('Could not load the claimant session. Reload this local page.')); diff --git a/gui/tests/browser/ui.spec.ts b/gui/tests/browser/ui.spec.ts new file mode 100644 index 0000000..b418e00 --- /dev/null +++ b/gui/tests/browser/ui.spec.ts @@ -0,0 +1,119 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test, type Page } from '@playwright/test'; + +const user = 'a'.repeat(64); +const status = { + node_id: 'b'.repeat(64), + addr: ['127.0.0.1:9999'], + friends: { count: 1, list: [user], grants: [{ user, grant_bytes: 1073741824 }] }, + peers: [{ user, display: 'Alex', node: 'b'.repeat(64), addrs: ['127.0.0.1:9999'] }], + vaults: { published: [], held_replicas: [] }, + share_health: { recovery_sets_owned: 0, shares_held: 0, sets: [], recovery: [] }, + recovery_grants: { minted: [], held: [] }, + ceremonies: [], + resplits: [], + pending_resplits: [], + reachability: 'local', + relay_networks: 0, + relay_diversity_warning: false +}; + +async function mockDaemon(page: Page) { + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/status') return route.fulfill({ json: status }); + if (path === '/api/friends' && route.request().method() === 'GET') { + return route.fulfill({ json: { count: 1, list: [user] } }); + } + if (path === `/api/friends/${user}/unfriend`) { + return route.fulfill({ json: { was_friend: true, resplit_triggered: false, recovery_set_ids: [] } }); + } + return route.fulfill({ status: 404, json: { code: 'not_found', error: 'not found' } }); + }); +} + +test.beforeEach(async ({ page }) => mockDaemon(page)); + +test('keyboard navigation, destructive confirmation, and accessibility work', async ({ page }) => { + await page.goto('/#/friends'); + await expect(page.getByRole('heading', { name: 'Friends', exact: true })).toBeVisible(); + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toBeVisible(); + await page.getByRole('button', { name: 'Unfriend' }).click(); + await expect(page.getByRole('button', { name: 'Confirm unfriend' })).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByRole('button', { name: 'Confirm unfriend' })).toHaveCount(0); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); +}); + +test('the narrow viewport has no horizontal overflow and keeps focus visible', async ({ page }) => { + await page.goto('/#/recovery'); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + const focusTarget = page.getByRole('link', { name: 'Recovery' }); + await focusTarget.focus(); + await expect(focusTarget).toBeFocused(); + const focusBox = await focusTarget.boundingBox(); + expect(focusBox).not.toBeNull(); + expect(focusBox!.x).toBeGreaterThanOrEqual(0); + expect(focusBox!.x + focusBox!.width).toBeLessThanOrEqual(await page.evaluate(() => innerWidth)); +}); + +test('changing operation status is announced in a live region', async ({ page }) => { + await page.route('**/api/sync', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 150)); + await route.fulfill({ json: { restored: [] } }); + }); + await page.goto('/#/vaults'); + await page.locator('#sync-peer').selectOption('b'.repeat(64)); + await page.locator('#sync-out').fill('/tmp/restore'); + await page.getByRole('button', { name: 'Sync and restore' }).click(); + const live = page.locator('[aria-live="polite"]').filter({ hasText: 'Sync' }); + await expect(live).toContainText('Sync is in progress.'); + await expect(live).toContainText('There were no new vault versions to restore.'); +}); + +test('claimant workflow verifies the subject and moves focus after activation', async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'mobile-chromium', 'The separate responsive test covers the mobile viewport.'); + await page.route('**/api/claimant/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/status')) return route.fulfill({ json: { phase: 'ready', ceremony_enc: 'enc', new_node: 'node', handoff: 'handoff' } }); + if (path.endsWith('/preview')) return route.fulfill({ json: { session_bound: true, subject: user } }); + return route.fulfill({ json: { restart_required: true } }); + }); + await page.goto('/claimant.html'); + await page.locator('#sponsor-package').fill(JSON.stringify({ + type: 'carapace.sponsor-ceremony', version: 1, open_hex: '00', roster: [user], + trustees: [{ node: 'b'.repeat(64), addrs: ['127.0.0.1:1'] }] + })); + await page.getByRole('button', { name: 'Import package and show subject' }).click(); + await expect(page.locator('#subject-id')).toHaveText(user); + await page.locator('#confirm-subject').check(); + await page.getByRole('button', { name: 'Collect approvals and activate' }).click(); + await expect(page.locator('#restart')).toBeVisible(); + await expect(page.locator('#restart')).toBeFocused(); +}); + +test('claimant cancellation clears inputs and starts a fresh retry session', async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'mobile-chromium', 'The responsive test covers the mobile viewport.'); + await page.route('**/api/claimant/status', (route) => route.fulfill({ json: { phase: 'ready', ceremony_enc: 'enc', new_node: 'node', handoff: 'handoff' } })); + await page.route('**/api/claimant/cancel', (route) => route.fulfill({ json: { cancelled: true, retry_ready: true } })); + await page.goto('/claimant.html'); + await page.locator('#sponsor-package').fill('sensitive attempt data'); + await page.getByRole('button', { name: 'Cancel and clear this attempt' }).click(); + await expect(page.locator('#sponsor-package')).toHaveValue(''); + await expect(page.locator('#progress')).toContainText('fresh retry session is ready'); +}); + +test('claimant clipboard success and failure give truthful feedback', async ({ page, context }, testInfo) => { + test.skip(testInfo.project.name === 'mobile-chromium', 'Clipboard permission emulation runs in desktop Chromium.'); + await page.route('**/api/claimant/status', (route) => route.fulfill({ json: { phase: 'ready', ceremony_enc: 'enc', new_node: 'node', handoff: 'handoff' } })); + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.goto('/claimant.html'); + await page.getByRole('button', { name: 'Copy handoff package' }).click(); + await expect(page.locator('#notice')).toHaveText('Copied.'); + await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: () => Promise.reject(new Error('denied')) } })); + await page.getByRole('button', { name: 'Copy handoff package' }).click(); + await expect(page.getByRole('alert')).toContainText('Select and copy'); +}); diff --git a/gui/tests/error-banner.component.test.ts b/gui/tests/error-banner.component.test.ts new file mode 100644 index 0000000..02f2653 --- /dev/null +++ b/gui/tests/error-banner.component.test.ts @@ -0,0 +1,14 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; +import ErrorBanner from '$lib/components/ErrorBanner.svelte'; +import { reportError } from '$lib/errors'; + +describe('ErrorBanner', () => { + it('renders an accessible alert and dismisses it', async () => { + reportError('The request failed safely.'); + render(ErrorBanner); + expect(screen.getByRole('alert')).toHaveTextContent('The request failed safely.'); + await fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/gui/tests/fixtures/status-contract.json b/gui/tests/fixtures/status-contract.json new file mode 100644 index 0000000..a082b5b --- /dev/null +++ b/gui/tests/fixtures/status-contract.json @@ -0,0 +1,17 @@ +{ + "node_id": "", + "addr": [], + "relay_url": null, + "friends": { "count": 0, "list": [], "grants": [] }, + "peers": [], + "vaults": { "published": [], "held_replicas": [] }, + "share_health": { "recovery_sets_owned": 0, "shares_held": 0, "sets": [], "recovery": [] }, + "recovery_grants": { "minted": [], "held": [] }, + "ceremonies": [], + "resplits": [], + "pending_resplits": [], + "reachability": "direct", + "relay_networks": 0, + "relay_diversity_warning": true, + "por_latency_anomaly_count": 0 +} diff --git a/gui/tests/runtime.component.test.ts b/gui/tests/runtime.component.test.ts new file mode 100644 index 0000000..4d8f84f --- /dev/null +++ b/gui/tests/runtime.component.test.ts @@ -0,0 +1,50 @@ +import { get } from 'svelte/store'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { lastError } from '$lib/errors'; +import { copyToClipboard } from '$lib/format'; + +describe('clipboard feedback', () => { + beforeEach(() => lastError.set(null)); + + it('reports success only after the clipboard accepts the value', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + expect(await copyToClipboard('public-value')).toBe(true); + expect(writeText).toHaveBeenCalledWith('public-value'); + expect(get(lastError)).toBeNull(); + }); + + it('shows safe manual-copy guidance after clipboard rejection', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) } }); + expect(await copyToClipboard('public-value')).toBe(false); + expect(get(lastError)).toMatch(/Select and copy the value manually/); + }); +}); + +describe('status WebSocket lifecycle', () => { + beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); }); + afterEach(() => vi.useRealTimers()); + + it('retries an unexpected close and does not retry a deliberate stop', async () => { + const sockets: FakeSocket[] = []; + class FakeSocket { + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + constructor(public url: string) { sockets.push(this); } + close() { this.onclose?.(); } + } + vi.stubGlobal('WebSocket', FakeSocket); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + const feed = await import('$lib/statusStore'); + feed.startStatusFeed(); + expect(sockets).toHaveLength(1); + sockets[0].onclose?.(); + await vi.advanceTimersByTimeAsync(1_000); + expect(sockets).toHaveLength(2); + feed.stopStatusFeed(); + await vi.advanceTimersByTimeAsync(30_000); + expect(sockets).toHaveLength(2); + }); +}); diff --git a/gui/tests/setup.ts b/gui/tests/setup.ts new file mode 100644 index 0000000..ba23909 --- /dev/null +++ b/gui/tests/setup.ts @@ -0,0 +1,5 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/svelte'; +import { afterEach } from 'vitest'; + +afterEach(cleanup); diff --git a/gui/tests/ui-contracts.test.mjs b/gui/tests/ui-contracts.test.mjs new file mode 100644 index 0000000..ea66794 --- /dev/null +++ b/gui/tests/ui-contracts.test.mjs @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const source = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); + +test('API calls use the authenticated same-origin request boundary', () => { + const api = source('src/lib/api.ts'); + assert.match(api, /const BASE = ''/); + assert.match(api, /Authorization: `Bearer \$\{apiToken\(\)\}`/); + for (const endpoint of [ + "'/api/status'", + "'/api/sync'", + "'/api/vaults'", + "'/api/friends'", + "'/api/recovery/split'", + "'/api/recovery/ceremony/open'", + "'/api/recovery/ceremony/approve'", + "'/api/recovery/ceremony/abort'" + ]) { + assert.ok(api.includes(endpoint), `missing API contract ${endpoint}`); + } + assert.match(api, /res\.status === 401 \|\| res\.status === 403/); +}); + +test('the shell and alerts expose keyboard and accessibility semantics', () => { + const page = source('src/routes/+page.svelte'); + const errorBanner = source('src/lib/components/ErrorBanner.svelte'); + const shellHero = source('src/lib/components/ShellHero.svelte'); + assert.match(page, /