lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 2a982d02f4b251f59edf5c5ec54c53daac25fdcc
parent ad1985b1aa4d79a98fcacee4c8b8c96d663b8634
Author: triesap <tyson@radroots.org>
Date:   Sat,  8 Aug 2026 21:35:30 +0000

blossom: expose passive endpoint evidence

Add a non-mutating bounded endpoint probe, fingerprint-guarded operation evidence, and focused mobile/UniFFI records for configuration, probe, upload, retrieval, and failure states.

Diffstat:
Mcrates/mobile_core/src/runtime/builder.rs | 10++++++++++
Mcrates/mobile_core/src/runtime/sdk.rs | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/mobile_ffi/src/dto.rs | 43+++++++++++++++++++++++++++++++++++++++++--
Mcrates/mobile_ffi/src/runtime.rs | 35++++++++++++++++++++++++++++-------
Mcrates/mobile_ffi/tests/local_mvp_real_io.rs | 19+++++++++++++++++++
Mcrates/mobile_ffi/tests/runtime_delegation.rs | 11+++++++++++
Mcrates/sdk/src/adapters/blossom.rs | 315++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/src/transport.rs | 420+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/tests/public_api.rs | 5++++-
9 files changed, 952 insertions(+), 21 deletions(-)

diff --git a/crates/mobile_core/src/runtime/builder.rs b/crates/mobile_core/src/runtime/builder.rs @@ -181,6 +181,16 @@ mod tests { .host_kind, "simulator" ); + let evidence = runtime + .sdk_blossom_evidence() + .expect("Blossom evidence") + .expect("configured evidence"); + assert_eq!(evidence.schema_version, 1); + assert_eq!(evidence.state, "configured_unobserved"); + assert_eq!(evidence.last_successful_state, "configured_unobserved"); + assert_eq!(evidence.transport_security, "development_cleartext"); + assert!(evidence.observed_at_unix_ms.is_none()); + assert!(evidence.error_code.is_none()); assert!( runtime .configure_blossom( diff --git a/crates/mobile_core/src/runtime/sdk.rs b/crates/mobile_core/src/runtime/sdk.rs @@ -51,6 +51,23 @@ pub struct SdkBlossomConfigurationRecord { } #[derive(Clone, Debug, Eq, PartialEq)] +pub struct SdkBlossomEvidenceRecord { + pub schema_version: u16, + pub origin: String, + pub config_fingerprint: String, + pub state: String, + pub last_successful_state: String, + pub transport_security: String, + pub observed_at_unix_ms: Option<u64>, + pub http_status: Option<u16>, + pub error_code: Option<String>, + pub error_phase: Option<String>, + pub retryable: bool, + pub possible_orphan: bool, + pub attempts: u8, +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub struct SdkShutdownRecord { pub state: String, pub already_closed: bool, @@ -199,6 +216,34 @@ impl RadrootsRuntime { ) } + /// Returns the latest passive Blossom evidence without network I/O. + #[cfg(feature = "mobile-social")] + pub fn sdk_blossom_evidence( + &self, + ) -> Result<Option<SdkBlossomEvidenceRecord>, RadrootsAppError> { + let evidence = self + .client + .blossom() + .map_err(RadrootsAppError::from_sdk)? + .and_then(radroots_sdk::transport::BlossomSlot::evidence); + Ok(evidence.map(sdk_blossom_evidence_record)) + } + + /// Explicitly probes the primary Blossom origin without mutation or authorization. + #[cfg(feature = "mobile-social")] + pub async fn probe_blossom(&self) -> Result<SdkBlossomEvidenceRecord, RadrootsAppError> { + let blossom = self + .client + .blossom() + .map_err(RadrootsAppError::from_sdk)? + .ok_or_else(|| RadrootsAppError::runtime("blossom_endpoint_not_configured"))?; + blossom + .probe(radroots_sdk::transport::BlossomCancellation::default()) + .await + .map(sdk_blossom_evidence_record) + .map_err(|error| RadrootsAppError::runtime(error.code())) + } + /// Returns passive relay evidence without DNS, socket, or probe work. #[cfg(feature = "mobile-social")] pub fn sdk_relay_status(&self) -> Result<Option<SdkRelayStatusReportRecord>, RadrootsAppError> { @@ -236,6 +281,76 @@ impl RadrootsRuntime { } #[cfg(feature = "mobile-social")] +fn sdk_blossom_evidence_record( + value: radroots_sdk::transport::BlossomEndpointEvidence, +) -> SdkBlossomEvidenceRecord { + SdkBlossomEvidenceRecord { + schema_version: value.schema_version(), + origin: value.origin().to_owned(), + config_fingerprint: value.config_fingerprint().to_hex(), + state: blossom_evidence_label(value.state()).to_owned(), + last_successful_state: blossom_evidence_label(value.last_successful_state()).to_owned(), + transport_security: blossom_transport_security_label(value.transport_security()).to_owned(), + observed_at_unix_ms: value.observed_at_unix_ms(), + http_status: value.http_status(), + error_code: value.error_code().map(str::to_owned), + error_phase: value + .error_phase() + .map(blossom_phase_label) + .map(str::to_owned), + retryable: value.retryable(), + possible_orphan: value.possible_orphan(), + attempts: value.attempts(), + } +} + +#[cfg(feature = "mobile-social")] +const fn blossom_evidence_label( + value: radroots_sdk::transport::BlossomEvidenceState, +) -> &'static str { + match value { + radroots_sdk::transport::BlossomEvidenceState::ConfiguredUnobserved => { + "configured_unobserved" + } + radroots_sdk::transport::BlossomEvidenceState::DnsPolicyValidated => "dns_policy_validated", + radroots_sdk::transport::BlossomEvidenceState::TlsHttpObserved => "tls_http_observed", + radroots_sdk::transport::BlossomEvidenceState::UploadVerified => "upload_verified", + radroots_sdk::transport::BlossomEvidenceState::RetrievalVerified => "retrieval_verified", + radroots_sdk::transport::BlossomEvidenceState::RetryableFailure => "retryable_failure", + radroots_sdk::transport::BlossomEvidenceState::TerminalFailure => "terminal_failure", + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn blossom_transport_security_label( + value: radroots_sdk::transport::BlossomTransportSecurity, +) -> &'static str { + match value { + radroots_sdk::transport::BlossomTransportSecurity::PublicWebPki => "public_webpki", + radroots_sdk::transport::BlossomTransportSecurity::DevelopmentTls => "development_tls", + radroots_sdk::transport::BlossomTransportSecurity::DevelopmentCleartext => { + "development_cleartext" + } + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn blossom_phase_label(value: radroots_sdk::transport::BlossomPhase) -> &'static str { + match value { + radroots_sdk::transport::BlossomPhase::Configuration => "configuration", + radroots_sdk::transport::BlossomPhase::Probe => "probe", + radroots_sdk::transport::BlossomPhase::Authorization => "authorization", + radroots_sdk::transport::BlossomPhase::Upload => "upload", + radroots_sdk::transport::BlossomPhase::Descriptor => "descriptor", + radroots_sdk::transport::BlossomPhase::Retrieval => "retrieval", + radroots_sdk::transport::BlossomPhase::Verification => "verification", + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] const fn blossom_host_kind_label(value: radroots_sdk::transport::BlossomHostKind) -> &'static str { match value { radroots_sdk::transport::BlossomHostKind::Native => "native", diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_ffi/src/dto.rs @@ -28,8 +28,9 @@ use radroots_mobile_core::runtime::{ TodayRefreshReceipt, TodayRelaySyncState, TodaySyncReceipt, }, sdk::{ - SdkBlossomConfigurationRecord, SdkCapabilityRecord, SdkRelayStatusRecord, - SdkRelayStatusReportRecord, SdkShutdownRecord, SdkStorageStatusRecord, + SdkBlossomConfigurationRecord, SdkBlossomEvidenceRecord, SdkCapabilityRecord, + SdkRelayStatusRecord, SdkRelayStatusReportRecord, SdkShutdownRecord, + SdkStorageStatusRecord, }, }; @@ -1862,6 +1863,23 @@ pub struct FfiBlossomConfigurationRecord { pub config_fingerprint: String, } +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiBlossomEvidenceRecord { + pub schema_version: u16, + pub origin: String, + pub config_fingerprint: String, + pub state: String, + pub last_successful_state: String, + pub transport_security: String, + pub observed_at_unix_ms: Option<u64>, + pub http_status: Option<u16>, + pub error_code: Option<String>, + pub error_phase: Option<String>, + pub retryable: bool, + pub possible_orphan: bool, + pub attempts: u8, +} + #[cfg_attr(coverage_nightly, coverage(off))] impl From<SdkBlossomConfigurationRecord> for FfiBlossomConfigurationRecord { fn from(value: SdkBlossomConfigurationRecord) -> Self { @@ -1877,6 +1895,27 @@ impl From<SdkBlossomConfigurationRecord> for FfiBlossomConfigurationRecord { } #[cfg_attr(coverage_nightly, coverage(off))] +impl From<SdkBlossomEvidenceRecord> for FfiBlossomEvidenceRecord { + fn from(value: SdkBlossomEvidenceRecord) -> Self { + Self { + schema_version: value.schema_version, + origin: value.origin, + config_fingerprint: value.config_fingerprint, + state: value.state, + last_successful_state: value.last_successful_state, + transport_security: value.transport_security, + observed_at_unix_ms: value.observed_at_unix_ms, + http_status: value.http_status, + error_code: value.error_code, + error_phase: value.error_phase, + retryable: value.retryable, + possible_orphan: value.possible_orphan, + attempts: value.attempts, + } + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] impl From<SdkRelayStatusReportRecord> for FfiRelayStatusReportRecord { fn from(value: SdkRelayStatusReportRecord) -> Self { Self { diff --git a/crates/mobile_ffi/src/runtime.rs b/crates/mobile_ffi/src/runtime.rs @@ -8,13 +8,14 @@ use crate::signer::HostSignerAdapter; use crate::subscription::SubscriptionHub; use crate::{ FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomConfigurationRecord, - FfiBlossomEndpointAuthority, FfiBlossomHostKind, FfiBlossomUploadInput, FfiCapabilityRecord, - FfiCardAddParityRecord, FfiDraftStatusRecord, FfiIdentityStatusRecord, FfiLocalNetworkRecord, - FfiMeRecord, FfiQueuePolicyRecord, FfiRelayStatusReportRecord, FfiRetractionDraftInput, - FfiRuntimeChangeKind, FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord, - FfiStorageStatusRecord, FfiSubscriptionHandle, FfiTodayPageRecord, FfiTodayProjectionUpdate, - FfiTodayRefreshRecord, FfiTodaySyncRecord, RadrootsAppError, RadrootsHostSigner, - RadrootsRuntimeObserver, add_schemas, decode_id, + FfiBlossomEndpointAuthority, FfiBlossomEvidenceRecord, FfiBlossomHostKind, + FfiBlossomUploadInput, FfiCapabilityRecord, FfiCardAddParityRecord, FfiDraftStatusRecord, + FfiIdentityStatusRecord, FfiLocalNetworkRecord, FfiMeRecord, FfiQueuePolicyRecord, + FfiRelayStatusReportRecord, FfiRetractionDraftInput, FfiRuntimeChangeKind, + FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord, FfiStorageStatusRecord, + FfiSubscriptionHandle, FfiTodayPageRecord, FfiTodayProjectionUpdate, FfiTodayRefreshRecord, + FfiTodaySyncRecord, RadrootsAppError, RadrootsHostSigner, RadrootsRuntimeObserver, add_schemas, + decode_id, }; #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] @@ -174,6 +175,26 @@ impl RadrootsRuntime { .map_err(Into::into) } + pub fn sdk_blossom_evidence( + &self, + ) -> Result<Option<FfiBlossomEvidenceRecord>, RadrootsAppError> { + self.inner + .sdk_blossom_evidence() + .map(|value| value.map(Into::into)) + .map_err(Into::into) + } + + pub async fn probe_blossom(&self) -> Result<FfiBlossomEvidenceRecord, RadrootsAppError> { + let evidence = self + .inner + .probe_blossom() + .await + .map(Into::into) + .map_err(RadrootsAppError::from)?; + self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); + Ok(evidence) + } + pub fn subscribe_changes( &self, observer: Box<dyn RadrootsRuntimeObserver>, diff --git a/crates/mobile_ffi/tests/local_mvp_real_io.rs b/crates/mobile_ffi/tests/local_mvp_real_io.rs @@ -233,6 +233,13 @@ async fn public_runtime_completes_the_local_mvp_against_real_protocol_services() .await .expect("upload and re-fetch exact media"); assert_eq!(uploaded.media[0].stage, FfiMediaStage::Verified); + let evidence = publisher + .sdk_blossom_evidence() + .expect("Blossom evidence") + .expect("configured evidence"); + assert_eq!(evidence.state, "retrieval_verified"); + assert_eq!(evidence.last_successful_state, "retrieval_verified"); + assert!(evidence.error_code.is_none()); uploaded } else { saved @@ -812,6 +819,18 @@ async fn prove_corrupted_media_fails( .expect("durable corrupt-media status"); assert_eq!(failed.media[0].stage, FfiMediaStage::Failed); assert!(failed.media[0].possible_orphan); + let evidence = runtime + .sdk_blossom_evidence() + .expect("Blossom evidence") + .expect("configured evidence"); + assert_eq!(evidence.state, "terminal_failure"); + assert_eq!(evidence.last_successful_state, "upload_verified"); + assert_eq!( + evidence.error_code.as_deref(), + Some("blossom_retrieved_bytes_mismatch") + ); + assert_eq!(evidence.error_phase.as_deref(), Some("verification")); + assert!(evidence.possible_orphan); corrupt.finish().await; } diff --git a/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_ffi/tests/runtime_delegation.rs @@ -119,6 +119,17 @@ async fn native_boundary_delegates_the_complete_core_surface() { assert_eq!(blossom.primary_origin, "https://media.example"); assert_eq!(blossom.fallback_origins, ["https://fallback.example"]); assert_eq!(blossom.config_fingerprint.len(), 64); + let evidence = runtime + .sdk_blossom_evidence() + .expect("Blossom evidence") + .expect("configured evidence"); + assert_eq!(evidence.schema_version, 1); + assert_eq!(evidence.origin, "https://media.example"); + assert_eq!(evidence.config_fingerprint, blossom.config_fingerprint); + assert_eq!(evidence.state, "configured_unobserved"); + assert_eq!(evidence.transport_security, "public_webpki"); + assert!(evidence.observed_at_unix_ms.is_none()); + assert!(evidence.error_code.is_none()); runtime .configure_blossom( FfiBlossomHostKind::Simulator, diff --git a/crates/sdk/src/adapters/blossom.rs b/crates/sdk/src/adapters/blossom.rs @@ -14,6 +14,192 @@ use crate::transport::{ const MAX_RESOLVED_ADDRESSES: usize = 32; const X_SHA_256: &str = "x-sha-256"; +const BLOSSOM_PROBE_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +pub(crate) struct BlossomProbeObservation { + pub(crate) http_status: u16, +} + +pub(crate) struct BlossomProbeFailure { + pub(crate) error: BlossomError, + pub(crate) dns_policy_validated: bool, +} + +/// Performs one BUD-01-shaped GET for an impossible sentinel digest. +/// +/// The request carries no authorization and cannot upload, delete, or mutate a +/// server. Any terminal HTTP response proves only DNS-policy, transport, and +/// HTTP reachability for the exact configured origin. +#[cfg_attr(coverage_nightly, coverage(off))] +pub(crate) async fn probe( + config: BlossomConfig, + endpoint: BlossomEndpoint, + cancellation: BlossomCancellation, +) -> Result<BlossomProbeObservation, BlossomProbeFailure> { + let mut url = BlobUrl::parse(format!("{}/{BLOSSOM_PROBE_HASH}", endpoint.origin()).as_str()) + .map_err(|_| BlossomProbeFailure { + error: failure( + BlossomErrorKind::InvalidEndpoint, + BlossomPhase::Probe, + false, + false, + 0, + ), + dns_policy_validated: false, + })?; + let mut dns_policy_validated = false; + for redirects in 0..=config.max_redirects() { + let current = + config + .profile() + .endpoint_for_blob(&url) + .ok_or_else(|| BlossomProbeFailure { + error: failure( + BlossomErrorKind::UnsafeRedirect, + BlossomPhase::Probe, + false, + false, + 1, + ), + dns_policy_validated, + })?; + let addresses = resolve( + current, + config.connect_timeout(), + &cancellation, + BlossomPhase::Probe, + 1, + false, + ) + .await + .map_err(|error| BlossomProbeFailure { + error, + dns_policy_validated, + })?; + dns_policy_validated = true; + let client = hardened_client_with_addresses( + &config, + current, + addresses.as_slice(), + BlossomPhase::Probe, + 1, + false, + ) + .map_err(|error| BlossomProbeFailure { + error, + dns_policy_validated, + })?; + let pending = client + .get(url.as_str()) + .header(ACCEPT, "*/*") + .header(ACCEPT_ENCODING, "identity") + .send(); + let response = tokio::select! { + biased; + _ = cancellation.cancelled() => { + return Err(BlossomProbeFailure { + error: failure( + BlossomErrorKind::Cancelled, + BlossomPhase::Probe, + true, + false, + 1, + ), + dns_policy_validated, + }); + } + response = pending => response.map_err(|error| BlossomProbeFailure { + error: request_error(error, BlossomPhase::Probe, false, 1), + dns_policy_validated, + })?, + }; + if response.status().is_redirection() { + if redirects == config.max_redirects() { + return Err(BlossomProbeFailure { + error: failure( + BlossomErrorKind::RedirectLimit, + BlossomPhase::Probe, + false, + false, + 1, + ), + dns_policy_validated, + }); + } + let location = response + .headers() + .get(LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| BlossomProbeFailure { + error: failure( + BlossomErrorKind::UnsafeRedirect, + BlossomPhase::Probe, + false, + false, + 1, + ), + dns_policy_validated, + })?; + let base = reqwest::Url::parse(url.as_str()).map_err(|_| BlossomProbeFailure { + error: failure( + BlossomErrorKind::UnsafeRedirect, + BlossomPhase::Probe, + false, + false, + 1, + ), + dns_policy_validated, + })?; + let next = base + .join(location) + .ok() + .and_then(|value| BlobUrl::parse(value.as_str()).ok()) + .filter(|value| { + value.hash_path().hash() == url.hash_path().hash() + && config.profile().endpoint_for_blob(value).is_some() + }) + .ok_or_else(|| BlossomProbeFailure { + error: failure( + BlossomErrorKind::UnsafeRedirect, + BlossomPhase::Probe, + false, + false, + 1, + ), + dns_policy_validated, + })?; + url = next; + continue; + } + let status = response.status().as_u16(); + read_bounded( + response, + config.max_descriptor_bytes(), + &cancellation, + BlossomPhase::Probe, + false, + 1, + ) + .await + .map_err(|error| BlossomProbeFailure { + error, + dns_policy_validated, + })?; + return Ok(BlossomProbeObservation { + http_status: status, + }); + } + Err(BlossomProbeFailure { + error: failure( + BlossomErrorKind::RedirectLimit, + BlossomPhase::Probe, + false, + false, + 1, + ), + dns_policy_validated, + }) +} pub(crate) async fn upload( transaction: BlossomUploadTransaction, @@ -476,13 +662,40 @@ async fn hardened_client( possible_orphan, ) .await?; + hardened_client_with_addresses( + config, + endpoint, + addresses.as_slice(), + phase, + attempts, + possible_orphan, + ) +} + +fn hardened_client_with_addresses( + config: &BlossomConfig, + endpoint: &BlossomEndpoint, + addresses: &[SocketAddr], + phase: BlossomPhase, + attempts: u8, + possible_orphan: bool, +) -> Result<reqwest::Client, BlossomError> { let mut builder = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .connect_timeout(config.connect_timeout()) .timeout(config.request_timeout()) .pool_max_idle_per_host(0); if endpoint.host().parse::<std::net::IpAddr>().is_err() { - builder = builder.resolve(endpoint.host(), addresses[0]); + let address = addresses.first().ok_or_else(|| { + failure( + BlossomErrorKind::ResolutionFailed, + phase, + true, + possible_orphan, + attempts, + ) + })?; + builder = builder.resolve(endpoint.host(), *address); } builder.build().map_err(|_| { failure( @@ -694,6 +907,7 @@ fn http_status_error( possible_orphan, attempts, ) + .with_http_status(status.as_u16()) } fn with_operation(error: BlossomError, possible_orphan: bool, attempts: u8) -> BlossomError { @@ -1475,6 +1689,105 @@ mod tests { } #[tokio::test] + async fn non_mutating_probe_records_only_dns_transport_and_http_evidence() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0_u8; 2_048]; + let count = stream.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..count]); + assert!(request.starts_with(&format!("GET /{BLOSSOM_PROBE_HASH} HTTP/1.1"))); + assert!(!request.to_ascii_lowercase().contains("authorization:")); + stream + .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + }); + let slot = crate::transport::BlossomSlot::new(); + slot.configure(config(format!("http://{address}").as_str())) + .unwrap(); + let initial = slot.evidence().unwrap(); + assert_eq!( + initial.state(), + crate::transport::BlossomEvidenceState::ConfiguredUnobserved + ); + assert!(initial.observed_at_unix_ms().is_none()); + + let observed = slot.probe(BlossomCancellation::default()).await.unwrap(); + assert_eq!( + observed.state(), + crate::transport::BlossomEvidenceState::TlsHttpObserved + ); + assert_eq!(observed.http_status(), Some(404)); + assert!(observed.error_code().is_none()); + assert!(observed.observed_at_unix_ms().is_some()); + server.await.unwrap(); + } + + #[tokio::test] + async fn cancelled_probe_is_retryable_redacted_and_never_claims_dns() { + let slot = crate::transport::BlossomSlot::new(); + slot.configure(config("http://127.0.0.1:9")).unwrap(); + let cancellation = BlossomCancellation::default(); + cancellation.cancel(); + let error = slot.probe(cancellation).await.unwrap_err(); + assert_eq!(error.kind(), BlossomErrorKind::Cancelled); + let evidence = slot.evidence().unwrap(); + assert_eq!( + evidence.state(), + crate::transport::BlossomEvidenceState::RetryableFailure + ); + assert_eq!( + evidence.last_successful_state(), + crate::transport::BlossomEvidenceState::ConfiguredUnobserved + ); + assert_eq!(evidence.error_code(), Some("blossom_cancelled")); + assert_eq!(evidence.error_phase(), Some(BlossomPhase::Probe)); + assert!(!evidence.possible_orphan()); + } + + #[tokio::test] + async fn reconfiguration_during_probe_cannot_promote_stale_evidence() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0_u8; 2_048]; + let _ = stream.read(&mut request).await.unwrap(); + accepted_tx.send(()).unwrap(); + release_rx.await.unwrap(); + stream + .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + }); + let slot = crate::transport::BlossomSlot::new(); + slot.configure(config(format!("http://{address}").as_str())) + .unwrap(); + let probing = { + let slot = slot.clone(); + tokio::spawn(async move { slot.probe(BlossomCancellation::default()).await }) + }; + accepted_rx.await.unwrap(); + slot.configure(config("http://127.0.0.1:9")).unwrap(); + release_tx.send(()).unwrap(); + let error = probing.await.unwrap().unwrap_err(); + assert_eq!(error.kind(), BlossomErrorKind::ConfigurationChanged); + assert_eq!(error.phase(), BlossomPhase::Probe); + assert!(!error.possible_orphan()); + let evidence = slot.evidence().unwrap(); + assert_eq!(evidence.origin(), "http://127.0.0.1:9"); + assert_eq!( + evidence.state(), + crate::transport::BlossomEvidenceState::ConfiguredUnobserved + ); + server.await.unwrap(); + } + + #[tokio::test] async fn loopback_upload_preserves_exact_bytes_and_verifies_retrieval() { let bytes = png(2, 3); let (origin, server) = spawn_server(bytes.clone(), RetrievalResponse::Exact, false).await; diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs @@ -17,7 +17,7 @@ use std::{ collections::BTreeSet, net::{IpAddr, Ipv4Addr, Ipv6Addr}, sync::atomic::{AtomicBool, Ordering}, - time::Duration, + time::{Duration, SystemTime, UNIX_EPOCH}, }; #[cfg(feature = "blossom")] @@ -444,6 +444,16 @@ fn append_fingerprint_field(material: &mut Vec<u8>, value: &[u8]) { material.extend_from_slice(value); } +#[cfg(feature = "blossom")] +fn blossom_now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + .unwrap_or(u64::MAX) + .max(1) +} + /// Nonzero dimensions verified from the final image bytes. #[cfg(feature = "blossom")] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -548,6 +558,175 @@ pub struct BlossomUploadTransaction { request: BlossomUploadRequest, } +/// Security property observed for the configured primary transport. +#[cfg(feature = "blossom")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum BlossomTransportSecurity { + /// Public HTTPS authenticated by the bundled platform WebPKI roots. + PublicWebPki, + /// Development HTTPS without a public-origin availability claim. + DevelopmentTls, + /// Simulator-only cleartext loopback HTTP. + DevelopmentCleartext, +} + +/// Latest redacted evidence state for the configured primary Blossom origin. +#[cfg(feature = "blossom")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum BlossomEvidenceState { + ConfiguredUnobserved, + DnsPolicyValidated, + TlsHttpObserved, + UploadVerified, + RetrievalVerified, + RetryableFailure, + TerminalFailure, +} + +/// Versioned, passive, secret-safe evidence for one exact configuration. +#[cfg(feature = "blossom")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BlossomEndpointEvidence { + origin: String, + config_fingerprint: BlossomConfigFingerprint, + state: BlossomEvidenceState, + last_successful_state: BlossomEvidenceState, + transport_security: BlossomTransportSecurity, + observed_at_unix_ms: Option<u64>, + http_status: Option<u16>, + error_code: Option<&'static str>, + error_phase: Option<BlossomPhase>, + retryable: bool, + possible_orphan: bool, + attempts: u8, +} + +#[cfg(feature = "blossom")] +impl BlossomEndpointEvidence { + const SCHEMA_VERSION: u16 = 1; + + fn configured(config: &BlossomConfig) -> Self { + let primary = config.profile().primary(); + Self { + origin: primary.origin().to_owned(), + config_fingerprint: config.fingerprint(), + state: BlossomEvidenceState::ConfiguredUnobserved, + last_successful_state: BlossomEvidenceState::ConfiguredUnobserved, + transport_security: match ( + primary.origin().starts_with("https://"), + primary.authority(), + ) { + (true, BlossomEndpointAuthority::PublicWebPki) => { + BlossomTransportSecurity::PublicWebPki + } + (true, _) => BlossomTransportSecurity::DevelopmentTls, + (false, _) => BlossomTransportSecurity::DevelopmentCleartext, + }, + observed_at_unix_ms: None, + http_status: None, + error_code: None, + error_phase: None, + retryable: false, + possible_orphan: false, + attempts: 0, + } + } + + #[must_use] + pub const fn schema_version(&self) -> u16 { + Self::SCHEMA_VERSION + } + + #[must_use] + pub fn origin(&self) -> &str { + self.origin.as_str() + } + + #[must_use] + pub const fn config_fingerprint(&self) -> BlossomConfigFingerprint { + self.config_fingerprint + } + + #[must_use] + pub const fn state(&self) -> BlossomEvidenceState { + self.state + } + + #[must_use] + pub const fn last_successful_state(&self) -> BlossomEvidenceState { + self.last_successful_state + } + + #[must_use] + pub const fn transport_security(&self) -> BlossomTransportSecurity { + self.transport_security + } + + #[must_use] + pub const fn observed_at_unix_ms(&self) -> Option<u64> { + self.observed_at_unix_ms + } + + #[must_use] + pub const fn http_status(&self) -> Option<u16> { + self.http_status + } + + #[must_use] + pub const fn error_code(&self) -> Option<&'static str> { + self.error_code + } + + #[must_use] + pub const fn error_phase(&self) -> Option<BlossomPhase> { + self.error_phase + } + + #[must_use] + pub const fn retryable(&self) -> bool { + self.retryable + } + + #[must_use] + pub const fn possible_orphan(&self) -> bool { + self.possible_orphan + } + + #[must_use] + pub const fn attempts(&self) -> u8 { + self.attempts + } + + fn record_success(&mut self, state: BlossomEvidenceState, http_status: Option<u16>) { + self.state = state; + self.last_successful_state = state; + self.observed_at_unix_ms = Some(blossom_now_unix_ms()); + self.http_status = http_status; + self.error_code = None; + self.error_phase = None; + self.retryable = false; + self.possible_orphan = false; + self.attempts = 0; + } + + fn record_failure(&mut self, error: &BlossomError) { + self.state = if error.retryable() { + BlossomEvidenceState::RetryableFailure + } else { + BlossomEvidenceState::TerminalFailure + }; + self.observed_at_unix_ms = Some(blossom_now_unix_ms()); + self.http_status = error.http_status(); + self.error_code = Some(error.code()); + self.error_phase = Some(error.phase()); + self.retryable = error.retryable(); + self.possible_orphan = error.possible_orphan(); + self.attempts = error.attempts(); + } +} + #[cfg(feature = "blossom")] impl BlossomUploadTransaction { #[must_use] @@ -648,6 +827,7 @@ impl BlossomCancellation { #[non_exhaustive] pub enum BlossomPhase { Configuration, + Probe, Authorization, Upload, Descriptor, @@ -697,6 +877,7 @@ pub struct BlossomError { retryable: bool, possible_orphan: bool, attempts: u8, + http_status: Option<u16>, } #[cfg(feature = "blossom")] @@ -714,6 +895,7 @@ impl BlossomError { retryable, possible_orphan, attempts, + http_status: None, } } @@ -747,6 +929,11 @@ impl BlossomError { } #[must_use] + pub const fn http_status(&self) -> Option<u16> { + self.http_status + } + + #[must_use] pub const fn code(&self) -> &'static str { match self.kind { BlossomErrorKind::InvalidEndpoint => "blossom_invalid_endpoint", @@ -783,6 +970,11 @@ impl BlossomError { self.attempts = attempts; self } + + pub(crate) const fn with_http_status(mut self, status: u16) -> Self { + self.http_status = Some(status); + self + } } #[cfg(feature = "blossom")] @@ -802,6 +994,7 @@ impl std::fmt::Debug for BlossomError { .field("retryable", &self.retryable) .field("possible_orphan", &self.possible_orphan) .field("attempts", &self.attempts) + .field("http_status", &self.http_status) .finish() } } @@ -865,7 +1058,14 @@ impl BlossomUploadReceipt { #[cfg(feature = "blossom")] #[derive(Clone, Default)] pub struct BlossomSlot { - config: Arc<RwLock<Option<BlossomConfig>>>, + state: Arc<RwLock<BlossomSlotState>>, +} + +#[cfg(feature = "blossom")] +#[derive(Default)] +struct BlossomSlotState { + config: Option<BlossomConfig>, + evidence: Option<BlossomEndpointEvidence>, } #[cfg(feature = "blossom")] @@ -877,17 +1077,20 @@ impl BlossomSlot { /// Atomically installs completely validated inert configuration. pub fn configure(&self, config: BlossomConfig) -> Result<(), BlossomError> { - let mut current = self - .config + let evidence = BlossomEndpointEvidence::configured(&config); + let mut state = self + .state .write() .map_err(|_| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?; - *current = Some(config); + state.config = Some(config); + state.evidence = Some(evidence); Ok(()) } pub fn clear(&self) { - if let Ok(mut current) = self.config.write() { - *current = None; + if let Ok(mut state) = self.state.write() { + state.config = None; + state.evidence = None; } } @@ -921,6 +1124,51 @@ impl BlossomSlot { }) } + /// Returns the latest passive evidence without performing network I/O. + #[must_use] + pub fn evidence(&self) -> Option<BlossomEndpointEvidence> { + self.state + .read() + .ok() + .and_then(|state| state.evidence.clone()) + } + + /// Performs a bounded non-mutating primary-origin probe. + pub async fn probe( + &self, + cancellation: BlossomCancellation, + ) -> Result<BlossomEndpointEvidence, BlossomError> { + let config = self + .snapshot() + .ok_or_else(|| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?; + let fingerprint = config.fingerprint(); + let result = crate::adapters::blossom::probe( + config.clone(), + config.profile().primary().clone(), + cancellation, + ) + .await; + match result { + Ok(observation) => { + self.record_evidence(fingerprint, BlossomPhase::Probe, false, |evidence| { + evidence.record_success( + BlossomEvidenceState::TlsHttpObserved, + Some(observation.http_status), + ); + }) + } + Err(failure) => { + self.record_evidence(fingerprint, BlossomPhase::Probe, false, |evidence| { + if failure.dns_policy_validated { + evidence.last_successful_state = BlossomEvidenceState::DnsPolicyValidated; + } + evidence.record_failure(&failure.error); + })?; + Err(failure.error) + } + } + } + /// Binds verified bytes to the configured primary origin without network I/O. pub fn prepare_upload( &self, @@ -980,7 +1228,31 @@ impl BlossomSlot { cancellation: BlossomCancellation, ) -> Result<BlossomUploadReceipt, BlossomError> { self.validate_transaction(&transaction)?; - crate::adapters::blossom::upload(transaction, authorization, cancellation).await + let fingerprint = transaction.config_fingerprint(); + let result = + crate::adapters::blossom::upload(transaction, authorization, cancellation).await; + match result { + Ok(receipt) => { + self.record_evidence(fingerprint, BlossomPhase::Verification, true, |evidence| { + evidence.record_success(BlossomEvidenceState::RetrievalVerified, None); + })?; + Ok(receipt) + } + Err(error) => { + self.record_evidence(fingerprint, BlossomPhase::Verification, true, |evidence| { + if error.possible_orphan() + && matches!( + error.phase(), + BlossomPhase::Retrieval | BlossomPhase::Verification + ) + { + evidence.last_successful_state = BlossomEvidenceState::UploadVerified; + } + evidence.record_failure(&error); + })?; + Err(error) + } + } } fn validate_transaction( @@ -999,7 +1271,42 @@ impl BlossomSlot { } fn snapshot(&self) -> Option<BlossomConfig> { - self.config.read().ok().and_then(|config| config.clone()) + self.state + .read() + .ok() + .and_then(|state| state.config.clone()) + } + + fn record_evidence( + &self, + fingerprint: BlossomConfigFingerprint, + drift_phase: BlossomPhase, + drift_possible_orphan: bool, + update: impl FnOnce(&mut BlossomEndpointEvidence), + ) -> Result<BlossomEndpointEvidence, BlossomError> { + let mut state = self + .state + .write() + .map_err(|_| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?; + let current = state + .config + .as_ref() + .ok_or_else(|| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?; + if current.fingerprint() != fingerprint { + return Err(BlossomError::new( + BlossomErrorKind::ConfigurationChanged, + drift_phase, + false, + drift_possible_orphan, + 0, + )); + } + let evidence = state + .evidence + .as_mut() + .ok_or_else(|| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?; + update(evidence); + Ok(evidence.clone()) } } @@ -1817,6 +2124,99 @@ mod tests { } #[cfg(feature = "blossom")] + #[test] + fn blossom_evidence_is_versioned_passive_and_preserves_last_success() { + let slot = BlossomSlot::new(); + assert!(slot.profile().is_none()); + assert!(slot.configuration().is_none()); + assert!(slot.evidence().is_none()); + + let public_config = BlossomConfig::from_profile( + public_blossom_profile("https://media.example").expect("public profile"), + ); + let public_fingerprint = public_config.fingerprint(); + slot.configure(public_config).expect("public config"); + let initial = slot.evidence().expect("initial evidence"); + assert_eq!(initial.schema_version(), 1); + assert_eq!(initial.origin(), "https://media.example"); + assert_eq!(initial.config_fingerprint(), public_fingerprint); + assert_eq!(initial.state(), BlossomEvidenceState::ConfiguredUnobserved); + assert_eq!( + initial.last_successful_state(), + BlossomEvidenceState::ConfiguredUnobserved + ); + assert_eq!( + initial.transport_security(), + BlossomTransportSecurity::PublicWebPki + ); + assert_eq!(initial.observed_at_unix_ms(), None); + assert_eq!(initial.http_status(), None); + assert_eq!(initial.error_code(), None); + assert_eq!(initial.error_phase(), None); + assert!(!initial.retryable()); + assert!(!initial.possible_orphan()); + assert_eq!(initial.attempts(), 0); + assert_eq!(public_fingerprint.to_hex(), public_fingerprint.to_string()); + assert_eq!( + slot.profile().expect("profile").primary().origin(), + initial.origin() + ); + assert_eq!( + slot.configuration().expect("configuration").1, + public_fingerprint + ); + + let private_tls = BlossomConfig::from_profile( + device_blossom_profile("https://10.0.0.10:8443").expect("device profile"), + ); + let mut evidence = BlossomEndpointEvidence::configured(&private_tls); + assert_eq!( + evidence.transport_security(), + BlossomTransportSecurity::DevelopmentTls + ); + evidence.record_success(BlossomEvidenceState::UploadVerified, Some(201)); + assert_eq!(evidence.state(), BlossomEvidenceState::UploadVerified); + assert_eq!( + evidence.last_successful_state(), + BlossomEvidenceState::UploadVerified + ); + assert!(evidence.observed_at_unix_ms().is_some()); + assert_eq!(evidence.http_status(), Some(201)); + + let failure = BlossomError::new( + BlossomErrorKind::HttpStatus, + BlossomPhase::Retrieval, + false, + true, + 2, + ) + .with_http_status(403); + evidence.record_failure(&failure); + assert_eq!(evidence.state(), BlossomEvidenceState::TerminalFailure); + assert_eq!( + evidence.last_successful_state(), + BlossomEvidenceState::UploadVerified + ); + assert_eq!(evidence.http_status(), Some(403)); + assert_eq!(evidence.error_code(), Some("blossom_http_status")); + assert_eq!(evidence.error_phase(), Some(BlossomPhase::Retrieval)); + assert!(!evidence.retryable()); + assert!(evidence.possible_orphan()); + assert_eq!(evidence.attempts(), 2); + + let cleartext = BlossomEndpointEvidence::configured(&BlossomConfig::from_profile( + simulator_blossom_profile("http://127.0.0.1:3000").expect("simulator profile"), + )); + assert_eq!( + cleartext.transport_security(), + BlossomTransportSecurity::DevelopmentCleartext + ); + + slot.clear(); + assert!(slot.evidence().is_none()); + } + + #[cfg(feature = "blossom")] fn blossom_png(width: u32, height: u32) -> Vec<u8> { let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); bytes.extend_from_slice(&width.to_be_bytes()); @@ -2447,7 +2847,7 @@ mod tests { assert_eq!(receipt.into_descriptor().size(), request.byte_size()); let poisoned = BlossomSlot::new(); - let state = Arc::clone(&poisoned.config); + let state = Arc::clone(&poisoned.state); let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let _guard = state.write().expect("write lock"); panic!("poison Blossom slot"); diff --git a/crates/sdk/tests/public_api.rs b/crates/sdk/tests/public_api.rs @@ -79,8 +79,10 @@ fn public_native_type_snapshot_uses_contextual_names() { type_name::<radroots_sdk::transport::BlossomConfigFingerprint>(), type_name::<radroots_sdk::transport::BlossomEndpoint>(), type_name::<radroots_sdk::transport::BlossomEndpointAuthority>(), + type_name::<radroots_sdk::transport::BlossomEndpointEvidence>(), type_name::<radroots_sdk::transport::BlossomError>(), type_name::<radroots_sdk::transport::BlossomErrorKind>(), + type_name::<radroots_sdk::transport::BlossomEvidenceState>(), type_name::<radroots_sdk::transport::BlossomHostKind>(), type_name::<radroots_sdk::transport::BlossomImageDimensions>(), type_name::<radroots_sdk::transport::BlossomPhase>(), @@ -89,6 +91,7 @@ fn public_native_type_snapshot_uses_contextual_names() { type_name::<radroots_sdk::transport::BlossomUploadReceipt>(), type_name::<radroots_sdk::transport::BlossomUploadRequest>(), type_name::<radroots_sdk::transport::BlossomUploadTransaction>(), + type_name::<radroots_sdk::transport::BlossomTransportSecurity>(), ]) .collect::<BTreeSet<_>>(); #[cfg(feature = "radrootsd")] @@ -168,7 +171,7 @@ const fn expected_public_type_count() -> usize { #[cfg(feature = "nostr")] let count = count + 1; #[cfg(feature = "blossom")] - let count = count + 17; + let count = count + 20; #[cfg(feature = "radrootsd")] let count = count + 5; count