Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -534,3 +534,34 @@ tuple. The preserved server candidate accepts one proof per unique target; that
alone cannot recheck a second provider which has since remapped elsewhere. Retained
domain labels are necessary, but enforcing that condition remains unresolved before
any future activation.

### Retained migration outcome observations (Rust source only)

`BackupReadSession::reconcile_migration` compares an opaque retained proposal with
fresh status reads through both original app and LDK clients, including when their
original physical tuple was shared. It checks each original principal/store and the
exact UUID, all-tuples digest, archive presence/hash, initial encrypted-head hash and
complete component set. Component wire order is irrelevant. It compares the immutable
initial receipt, never a later mutable head or a substitute wallet export.

Only two matching committed receipts return `ObservedCommitted`; two expected legacy
receipts return `ObservedUncommitted`. Neither is an atomic snapshot or write/replay
permit. Read failures, malformed/unsupported responses, remapped providers, mismatched
receipts, mixed outcomes, superseding reads and owner revocation remain errors.
Each read obtains a new token with its captured original signer, even when the ordinary
provider holds a valid cached token. Fresh authentication failure cannot fall back to
that token. The ten-second deadline includes authentication; the VSS storage POST has
no automatic retry or redirect. The inherited LNURL GETs retain their own redirect
policy and uncapped response parsing within that deadline. Cancellation
abandons pending reads; revocation discards their results. Another session, even with
the same seed, cannot adopt the handle. Starting reconciliation invalidates preparation
access and does not restore it. The retained immutable content is used only for comparison.

This is in-memory native source, without new UniFFI exports or durable restart adoption.
Sequential provider reads do not repair the coalesced-provider atomic enforcement gap
above. Server deployment, app lifecycle admission, legacy-reader exclusion and complete
coherent capture/restore remain separate prerequisites; no migration is dispatched.

The candidate server may initialize an empty legacy policy row during a status read.
This client invokes only status and authentication routes; it does not assert that
server implementation performs zero database writes.
28 changes: 18 additions & 10 deletions src/backup_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ use tokio::sync::watch;

mod preparation;
pub use preparation::{
MigrationHeadInput, MigrationPreparation, MigrationPreparationError, PreparedMigration,
PreparedMigrationContent, PreparedMigrationHead, PreparedMigrationStore,
MigrationHeadInput, MigrationPreparation, MigrationPreparationError, MigrationReconciliation,
MigrationReconciliationError, PreparedMigration, PreparedMigrationContent,
PreparedMigrationHead, PreparedMigrationStore,
};

/// One immutable storage/auth configuration for both native derivation domains.
Expand Down Expand Up @@ -175,18 +176,25 @@ impl BackupReadSession {
&self,
domain: BackupDomain,
) -> Result<MigrationObservation, BackupReadError> {
let mut cancellation = self.revoked.subscribe();
self.ensure_active()?;
// A pending, failed or abandoned refresh must not leave earlier policy
// observations admissible for a new proposal.
let revision = Arc::new(());
self.migration_revisions[domain.index()].send_replace(Arc::clone(&revision));
let read = async {
self.migration_observation(domain, async {
match domain {
BackupDomain::App => self.app.read_migration_state().await,
BackupDomain::Ldk => self.ldk.read_migration_state().await,
}
};
})
.await
}

async fn migration_observation(
&self,
domain: BackupDomain,
read: impl std::future::Future<Output = Result<MigrationState, VssError>>,
) -> Result<MigrationObservation, BackupReadError> {
let mut cancellation = self.revoked.subscribe();
self.ensure_active()?;
// Pending, failed and abandoned reads invalidate earlier preparation.
let revision = Arc::new(());
self.migration_revisions[domain.index()].send_replace(Arc::clone(&revision));
let state = tokio::select! {
biased;
_ = cancellation.changed() => return Err(BackupReadError::Revoked),
Expand Down
2 changes: 2 additions & 0 deletions src/backup_session/preparation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ use bitcoin::hashes::{sha256, Hash};
use prost::Message;

mod canonical;
mod reconciliation;
pub use reconciliation::{MigrationReconciliation, MigrationReconciliationError};

const MAX_HEAD_BYTES: usize = 10 * 1024 * 1024;

Expand Down
167 changes: 167 additions & 0 deletions src/backup_session/preparation/reconciliation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//! Read-only comparison of a retained attempt with fresh original-provider receipts.

use super::*;
use crate::CommittedMigration;

/// Observations from two sequential reads, never an atomic snapshot or permission
/// to activate/replay a migration. Even `ObservedUncommitted` may already be stale.
#[derive(Debug, PartialEq, Eq)]
pub enum MigrationReconciliation {
ObservedCommitted,
ObservedUncommitted,
}

#[derive(Debug, thiserror::Error)]
pub enum MigrationReconciliationError {
#[error(transparent)]
Ownership(#[from] BackupReadError),
#[error("Migration status could not be read through the original {domain:?} provider")]
Read {
domain: BackupDomain,
#[source]
source: BackupReadError,
},
#[error("The original {0:?} provider now identifies a different participant")]
ParticipantChanged(BackupDomain),
#[error("The {0:?} receipt does not match the retained migration attempt")]
ReceiptMismatch(BackupDomain),
#[error("The original participants report mixed migration outcomes")]
MixedOutcome,
#[error("A newer status read superseded reconciliation")]
SupersededObservation,
}

impl BackupReadSession {
/// Reads each original credential domain once, including coalesced domains.
/// Each read obtains fresh credentials with its captured original signer and
/// includes authentication in the ten-second deadline. No storage retry,
/// redirect, upload, deletion, activation or current-head read occurs here.
/// The inherited LNURL authentication GETs retain their redirect/body behavior.
///
/// The opaque attempt must belong to this still-active in-memory owner. Its
/// preparation may already be stale: this method only compares immutable
/// content, and never restores preparation access. It supplies no persisted
/// UUID adoption or restart authority. Revocation discards pending results;
/// dropping this future abandons its reads without scheduling continuation.
pub async fn reconcile_migration(
&self,
prepared: &PreparedMigration,
) -> Result<MigrationReconciliation, MigrationReconciliationError> {
if !Arc::ptr_eq(&self.revoked, &prepared.owner) {
return Err(BackupReadError::DifferentOwner.into());
}
self.ensure_active()?;
let app = self.reconciliation_observation(BackupDomain::App).await?;
let ldk = self.reconciliation_observation(BackupDomain::Ldk).await?;
let app_committed = matches_receipt(&prepared.content, &app)?;
let ldk_committed = matches_receipt(&prepared.content, &ldk)?;
self.ensure_reconciliation_current(&[app, ldk])?;
match (app_committed, ldk_committed) {
(true, true) => Ok(MigrationReconciliation::ObservedCommitted),
(false, false) => Ok(MigrationReconciliation::ObservedUncommitted),
_ => Err(MigrationReconciliationError::MixedOutcome),
}
}

async fn reconciliation_observation(
&self,
domain: BackupDomain,
) -> Result<MigrationObservation, MigrationReconciliationError> {
self.migration_observation(domain, async {
match domain {
BackupDomain::App => self.app.read_fresh_migration_state().await,
BackupDomain::Ldk => self.ldk.read_fresh_migration_state().await,
}
})
.await
.map_err(|source| {
if matches!(source, BackupReadError::Revoked) {
MigrationReconciliationError::Ownership(source)
} else {
MigrationReconciliationError::Read { domain, source }
}
})
}

fn ensure_reconciliation_current(
&self,
observations: &[MigrationObservation; 2],
) -> Result<(), MigrationReconciliationError> {
self.ensure_active()?;
for observation in observations {
if !Arc::ptr_eq(
&self.migration_revisions[observation.domain.index()].borrow(),
&observation.revision,
) {
return Err(MigrationReconciliationError::SupersededObservation);
}
}
Ok(())
}
}

fn matches_receipt(
prepared: &PreparedMigrationContent,
observation: &MigrationObservation,
) -> Result<bool, MigrationReconciliationError> {
let domain = observation.domain;
let expected = &prepared.stores[store_index(&prepared.stores, domain)];
let actual = &observation.state;
if actual.effective_principal != expected.effective_principal
|| actual.store_id != expected.store_id
{
return Err(MigrationReconciliationError::ParticipantChanged(domain));
}
match &actual.policy {
MigrationPolicy::Legacy => Ok(false),
MigrationPolicy::Committed(receipt) if receipt_matches(prepared, expected, receipt) => {
Ok(true)
}
MigrationPolicy::Committed(_) => Err(MigrationReconciliationError::ReceiptMismatch(domain)),
}
}

fn receipt_matches(
prepared: &PreparedMigrationContent,
expected: &PreparedMigrationStore,
actual: &CommittedMigration,
) -> bool {
actual.migration_id == prepared.migration_id
&& actual.proposal_sha256 == prepared.proposal_sha256
&& archives_match(expected.archive.as_ref(), actual.archive.as_ref())
&& heads_match(expected.initial_head.as_ref(), actual.initial_head.as_ref())
&& objects_match(&expected.objects, &actual.objects)
}

fn archives_match(expected: Option<&MigrationArchive>, actual: Option<&MigrationArchive>) -> bool {
match (expected, actual) {
(None, None) => true,
(Some(expected), Some(actual)) => {
expected.key == actual.key && expected.ciphertext_sha256 == actual.ciphertext_sha256
}
_ => false,
}
}

fn heads_match(expected: Option<&PreparedMigrationHead>, actual: Option<&MigrationObject>) -> bool {
match (expected, actual) {
(None, None) => true,
(Some(expected), Some(actual)) => {
expected.key == actual.key
&& sha256::Hash::hash(&expected.encrypted_value).to_byte_array()
== actual.ciphertext_sha256
}
_ => false,
}
}

fn objects_match(expected: &[MigrationObject], actual: &[MigrationObject]) -> bool {
// Both sets were validated as unique. Wire order is not part of the receipt.
expected.len() == actual.len()
&& expected.iter().all(|object| {
actual.iter().any(|candidate| {
object.key == candidate.key
&& object.ciphertext_sha256 == candidate.ciphertext_sha256
})
})
}
Loading
Loading