field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit ccd23e24e8d3fdf12f6cf289c602e37eac12ec7f
parent 1da8765f528ee17ad6c67a65f216058685419efb
Author: triesap <tyson@radroots.org>
Date:   Fri,  7 Aug 2026 15:30:36 +0000

feat(mobile): enforce opaque host signing

- replace local secret slots and duplicate social authoring with focused host signer operations
- domain-separate BUD-11 HTTP authorization from durable relay event plans
- revalidate signer output against exact request deadline cancellation and signature before commit
- refresh public APIs docs and tests across consolidated mobile surfaces

Diffstat:
Mcore/crates/tera_core/Cargo.toml | 4+++-
Mcore/crates/tera_core/src/runtime/builder.rs | 23+++++++++++++++++++++--
Dcore/crates/tera_core/src/runtime/key_management.rs | 222-------------------------------------------------------------------------------
Mcore/crates/tera_core/src/runtime/mod.rs | 41++++++++++++++++++-----------------------
Dcore/crates/tera_core/src/runtime/nostr.rs | 240-------------------------------------------------------------------------------
Mcore/crates/tera_core/src/runtime/product_surface/outbox.rs | 170++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcore/crates/tera_core/tests/durable_runtime.rs | 18------------------
Mcore/crates/tera_core/tests/package_boundary.rs | 27+++++++++++++++++++++++----
8 files changed, 230 insertions(+), 515 deletions(-)

diff --git a/core/crates/tera_core/Cargo.toml b/core/crates/tera_core/Cargo.toml @@ -21,7 +21,7 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [features] default = [] mobile-social = [ - "radroots_sdk/local-signing", + "radroots_sdk/blossom", "radroots_sdk/nostr", "radroots_sdk/sync", ] @@ -35,6 +35,7 @@ radroots_sdk = { workspace = true, features = ["sqlite"] } radroots_event = { workspace = true, default-features = false, features = ["std"] } radroots_event_codec = { workspace = true, default-features = false, features = ["json", "std"] } radroots_identity = { workspace = true, default-features = false, features = ["std"] } +radroots_protocol = { workspace = true, default-features = false, features = ["std"] } radroots_signing = { workspace = true, default-features = false, features = ["std"] } radroots_storage = { workspace = true, default-features = false } radroots_sync = { workspace = true, default-features = false } @@ -48,6 +49,7 @@ thiserror = { workspace = true } [dev-dependencies] nostr = { workspace = true, features = ["std"] } +radroots_nostr = { workspace = true, features = ["blossom", "signing"] } radroots_sdk = { workspace = true, features = ["memory", "sqlite"] } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/core/crates/tera_core/src/runtime/builder.rs b/core/crates/tera_core/src/runtime/builder.rs @@ -4,12 +4,26 @@ use crate::{RadrootsAppError, RadrootsRuntime}; /// Host-owned construction boundary for the shared SDK-backed runtime. pub struct RuntimeBuilder { store: MobileUserStoreConfig, + #[cfg(feature = "mobile-social")] + signer: Option<std::sync::Arc<dyn radroots_signing::Signer>>, } impl RuntimeBuilder { #[must_use] pub const fn new(store: MobileUserStoreConfig) -> Self { - Self { store } + Self { + store, + #[cfg(feature = "mobile-social")] + signer: None, + } + } + + /// Installs one opaque host signer without transferring secret material. + #[cfg(feature = "mobile-social")] + #[must_use] + pub fn signer(mut self, signer: std::sync::Arc<dyn radroots_signing::Signer>) -> Self { + self.signer = Some(signer); + self } /// Opens the exact authenticated user's durable SQLite store. @@ -22,7 +36,12 @@ impl RuntimeBuilder { let builder = radroots_sdk::ClientBuilder::sqlite(options) .await .map_err(RadrootsAppError::from_sdk)?; - RadrootsRuntime::from_client_builder(builder, Some(self.store.public_key())) + RadrootsRuntime::from_client_builder( + builder, + Some(self.store.public_key()), + #[cfg(feature = "mobile-social")] + self.signer, + ) } } diff --git a/core/crates/tera_core/src/runtime/key_management.rs b/core/crates/tera_core/src/runtime/key_management.rs @@ -1,222 +0,0 @@ -//! Host-custodied mobile identity presentation over the SDK signer slot. - -use super::RadrootsRuntime; -use crate::RadrootsAppError; - -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct NostrIdentityRecord { - pub id: String, - pub public_key_hex: String, - pub public_key_npub: String, - pub label: Option<String>, - pub is_selected: bool, -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct NostrIdentitySnapshot { - pub has_selected_signing_identity: bool, - pub selected_identity_id: Option<String>, - pub selected_npub: Option<String>, - pub identities: Vec<NostrIdentityRecord>, -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct NostrHostCustodyIdentity { - pub id: String, - pub public_key_hex: String, - pub public_key_npub: String, -} - -fn host_identity(identity: &radroots_sdk::signing::LocalIdentity) -> NostrHostCustodyIdentity { - let public_key_hex = identity.public_key_hex(); - NostrHostCustodyIdentity { - id: public_key_hex.clone(), - public_key_hex, - public_key_npub: identity.npub().to_owned(), - } -} - -fn identity_record( - identity: &radroots_sdk::signing::LocalIdentity, - label: Option<String>, -) -> NostrIdentityRecord { - let identity = host_identity(identity); - NostrIdentityRecord { - id: identity.id, - public_key_hex: identity.public_key_hex, - public_key_npub: identity.public_key_npub, - label, - is_selected: true, - } -} - -impl RadrootsRuntime { - pub fn nostr_identity_has_selected_signing_identity(&self) -> bool { - self.signing_slot.identity().is_some() - } - - pub fn nostr_identity_selected_npub(&self) -> Option<String> { - self.signing_slot - .identity() - .map(|identity| identity.npub().to_owned()) - } - - pub fn nostr_identity_list(&self) -> Result<Vec<NostrIdentityRecord>, RadrootsAppError> { - let Some(identity) = self.signing_slot.identity() else { - return Ok(Vec::new()); - }; - Ok(vec![identity_record(&identity, self.identity_label())]) - } - - pub fn nostr_identity_list_ids(&self) -> Result<Vec<String>, RadrootsAppError> { - Ok(self - .nostr_identity_list()? - .into_iter() - .map(|identity| identity.id) - .collect()) - } - - pub fn nostr_identity_snapshot(&self) -> Result<NostrIdentitySnapshot, RadrootsAppError> { - let identities = self.nostr_identity_list()?; - let selected = identities.first(); - Ok(NostrIdentitySnapshot { - has_selected_signing_identity: selected.is_some(), - selected_identity_id: selected.map(|identity| identity.id.clone()), - selected_npub: selected.map(|identity| identity.public_key_npub.clone()), - identities, - }) - } - - pub fn nostr_identity_validate_host_custody_secret( - &self, - secret_key: String, - ) -> Result<NostrHostCustodyIdentity, RadrootsAppError> { - let slot = radroots_sdk::signing::Slot::new(); - let identity = slot - .install(secret_key.as_str()) - .map_err(|_| RadrootsAppError::runtime("identity secret is invalid"))?; - slot.clear(); - Ok(host_identity(&identity)) - } - - pub fn nostr_identity_restore_host_custody_secret( - &self, - secret_key: String, - label: Option<String>, - make_selected: bool, - ) -> Result<NostrIdentityRecord, RadrootsAppError> { - if !make_selected { - let identity = self.nostr_identity_validate_host_custody_secret(secret_key)?; - return Ok(NostrIdentityRecord { - id: identity.id, - public_key_hex: identity.public_key_hex, - public_key_npub: identity.public_key_npub, - label, - is_selected: false, - }); - } - let identity = self - .signing_slot - .install(secret_key.as_str()) - .map_err(|_| RadrootsAppError::runtime("identity secret is invalid"))?; - if self - .store_public_key - .is_some_and(|expected| expected.to_hex() != identity.public_key_hex()) - { - self.signing_slot.clear(); - return Err(RadrootsAppError::runtime( - "identity does not match the authenticated user store", - )); - } - self.set_identity_label(label.clone())?; - Ok(identity_record(&identity, label)) - } - - pub fn nostr_identity_select(&self, identity_id: String) -> Result<(), RadrootsAppError> { - let current = self - .signing_slot - .identity() - .ok_or_else(|| RadrootsAppError::runtime("identity is not installed"))?; - if current.public_key_hex() != identity_id { - return Err(RadrootsAppError::runtime("identity is not installed")); - } - Ok(()) - } - - pub fn nostr_identity_remove(&self, identity_id: String) -> Result<(), RadrootsAppError> { - if self - .signing_slot - .identity() - .is_some_and(|identity| identity.public_key_hex() == identity_id) - { - self.signing_slot.clear(); - self.set_identity_label(None)?; - } - Ok(()) - } - - pub fn nostr_identity_lock_host_custody_runtime(&self) -> Result<(), RadrootsAppError> { - self.signing_slot.clear(); - self.set_identity_label(None) - } - - pub fn nostr_identity_reset_host_custody_runtime(&self) -> Result<(), RadrootsAppError> { - self.nostr_identity_lock_host_custody_runtime() - } - - fn identity_label(&self) -> Option<String> { - self.identity_label - .read() - .ok() - .and_then(|label| label.clone()) - } - - fn set_identity_label(&self, label: Option<String>) -> Result<(), RadrootsAppError> { - let mut current = self - .identity_label - .write() - .map_err(|_| RadrootsAppError::runtime("identity label state is unavailable"))?; - *current = label; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; - - #[test] - fn validation_does_not_select_and_restore_is_single_slot() { - let runtime = RadrootsRuntime::test_memory().expect("runtime"); - let validated = runtime - .nostr_identity_validate_host_custody_secret(SECRET.to_owned()) - .expect("valid secret"); - assert!(!runtime.nostr_identity_has_selected_signing_identity()); - - let staged = runtime - .nostr_identity_restore_host_custody_secret( - SECRET.to_owned(), - Some("staged".to_owned()), - false, - ) - .expect("staged"); - assert_eq!(staged.id, validated.id); - assert!(!staged.is_selected); - - let selected = runtime - .nostr_identity_restore_host_custody_secret( - SECRET.to_owned(), - Some("selected".to_owned()), - true, - ) - .expect("selected"); - assert!(selected.is_selected); - assert_eq!(runtime.nostr_identity_list().expect("list"), vec![selected]); - runtime - .nostr_identity_lock_host_custody_runtime() - .expect("lock"); - assert!(runtime.nostr_identity_list().expect("list").is_empty()); - } -} diff --git a/core/crates/tera_core/src/runtime/mod.rs b/core/crates/tera_core/src/runtime/mod.rs @@ -1,10 +1,6 @@ pub mod app_info; pub mod builder; pub mod info; -#[cfg(feature = "mobile-social")] -pub mod key_management; -#[cfg(feature = "mobile-social")] -pub mod nostr; pub mod product_surface; pub mod sdk; pub mod store; @@ -25,12 +21,6 @@ use crate::RadrootsAppError; pub struct RadrootsRuntime { pub(crate) client: Client, - #[cfg(feature = "mobile-social")] - pub(crate) signing_slot: radroots_sdk::signing::Slot, - #[cfg(feature = "mobile-social")] - pub(crate) nostr_slot: radroots_sdk::transport::NostrSlot, - #[cfg(feature = "mobile-social")] - pub(crate) identity_label: RwLock<Option<String>>, pub(crate) started_unix_ms: i64, pub(crate) shutting_down: AtomicBool, pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>, @@ -41,28 +31,28 @@ impl RadrootsRuntime { pub(crate) fn from_client_builder( builder: ClientBuilder, store_public_key: Option<PublicKey>, + #[cfg(feature = "mobile-social")] signer: Option< + std::sync::Arc<dyn radroots_signing::Signer>, + >, ) -> Result<Self, RadrootsAppError> { #[cfg(feature = "mobile-social")] - let signing_slot = radroots_sdk::signing::Slot::new(); - #[cfg(feature = "mobile-social")] let nostr_slot = radroots_sdk::transport::NostrSlot::new( radroots_sdk::transport::RelayUrlPolicy::Public, ); #[cfg(feature = "mobile-social")] - let builder = builder - .signing(radroots_sdk::signing::Provider::slot(signing_slot.clone())) - .nostr(nostr_slot.clone()) - .host_sync(radroots_sdk::sync::HostPolicy::standard()); + let builder = { + let builder = builder + .nostr(nostr_slot.clone()) + .host_sync(radroots_sdk::sync::HostPolicy::standard()); + match signer { + Some(signer) => builder.signing(radroots_sdk::signing::Provider::host(signer)), + None => builder, + } + }; let client = builder.build().map_err(RadrootsAppError::from_sdk)?; Ok(Self { client, - #[cfg(feature = "mobile-social")] - signing_slot, - #[cfg(feature = "mobile-social")] - nostr_slot, - #[cfg(feature = "mobile-social")] - identity_label: RwLock::new(None), started_unix_ms: Utc::now().timestamp_millis(), shutting_down: AtomicBool::new(false), platform_app: RwLock::new(None), @@ -72,7 +62,12 @@ impl RadrootsRuntime { #[cfg(test)] pub(crate) fn test_memory() -> Result<Self, RadrootsAppError> { - Self::from_client_builder(ClientBuilder::memory_default(), None) + Self::from_client_builder( + ClientBuilder::memory_default(), + None, + #[cfg(feature = "mobile-social")] + None, + ) } /// Closes SDK resources asynchronously across every runtime reference. diff --git a/core/crates/tera_core/src/runtime/nostr.rs b/core/crates/tera_core/src/runtime/nostr.rs @@ -1,240 +0,0 @@ -//! Bounded mobile Nostr presentation over shared SDK operations. - -use super::RadrootsRuntime; -use crate::RadrootsAppError; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NostrLight { - Red, - Yellow, - Green, -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct NostrConnectionStatus { - pub light: NostrLight, - pub configured: bool, - pub source_available: bool, - pub sink_available: bool, - pub last_error: Option<String>, -} - -#[derive(Debug, Clone, Default, Eq, PartialEq)] -pub struct NostrProfile { - pub name: Option<String>, - pub display_name: Option<String>, - pub nip05: Option<String>, - pub about: Option<String>, - pub website: Option<String>, - pub picture: Option<String>, - pub banner: Option<String>, - pub lud06: Option<String>, - pub lud16: Option<String>, - pub bot: Option<String>, -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct NostrProfileEventMetadata { - pub id: String, - pub author: String, - pub published_at: u64, - pub profile: NostrProfile, -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct NostrPost { - pub content: String, -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct NostrPostEventMetadata { - pub id: String, - pub author: String, - pub published_at: u64, - pub post: NostrPost, -} - -fn map_profile(event: radroots_sdk::client::ProfileEvent) -> NostrProfileEventMetadata { - NostrProfileEventMetadata { - id: event.event_id().to_owned(), - author: event.author().to_owned(), - published_at: event.created_at(), - profile: NostrProfile { - name: event.name().map(str::to_owned), - display_name: event.display_name().map(str::to_owned), - nip05: event.nip05().map(str::to_owned), - about: event.about().map(str::to_owned), - website: None, - picture: event.picture().map(str::to_owned), - banner: event.banner().map(str::to_owned), - lud06: None, - lud16: None, - bot: event.bot().map(|value| value.to_string()), - }, - } -} - -fn map_post(event: radroots_sdk::client::PostEvent) -> NostrPostEventMetadata { - NostrPostEventMetadata { - id: event.event_id().to_owned(), - author: event.author().to_owned(), - published_at: event.created_at(), - post: NostrPost { - content: event.content().to_owned(), - }, - } -} - -impl RadrootsRuntime { - pub fn nostr_set_default_relays(&self, relays: Vec<String>) -> Result<(), RadrootsAppError> { - self.nostr_slot - .configure(relays) - .map_err(RadrootsAppError::from_sdk) - } - - /// Validates readiness; relay connections remain operation-scoped. - pub fn nostr_connect_if_key_present(&self) -> Result<(), RadrootsAppError> { - if self.signing_slot.identity().is_none() { - return Err(RadrootsAppError::runtime("identity is not installed")); - } - if self.nostr_slot.targets().is_none() { - return Err(RadrootsAppError::runtime( - "relay selection is not configured", - )); - } - Ok(()) - } - - pub async fn nostr_connection_status(&self) -> Result<NostrConnectionStatus, RadrootsAppError> { - let social = self.client.social().map_err(RadrootsAppError::from_sdk)?; - let health = social - .transport_health() - .await - .map_err(RadrootsAppError::from_sdk)?; - let light = if health.is_source_available() && health.is_sink_available() { - NostrLight::Green - } else if health.is_configured() { - NostrLight::Yellow - } else { - NostrLight::Red - }; - Ok(NostrConnectionStatus { - light, - configured: health.is_configured(), - source_available: health.is_source_available(), - sink_available: health.is_sink_available(), - last_error: None, - }) - } - - pub async fn nostr_profile_for_self( - &self, - ) -> Result<Option<NostrProfileEventMetadata>, RadrootsAppError> { - self.client - .social() - .map_err(RadrootsAppError::from_sdk)? - .fetch_profile_for_signer() - .await - .map(|profile| profile.map(map_profile)) - .map_err(RadrootsAppError::from_sdk) - } - - pub async fn nostr_post_profile( - &self, - name: Option<String>, - display_name: Option<String>, - nip05: Option<String>, - about: Option<String>, - ) -> Result<String, RadrootsAppError> { - let name = name - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| RadrootsAppError::runtime("profile name is required"))?; - let mut draft = radroots_sdk::client::ProfileDraft::new(name); - if let Some(value) = display_name.filter(|value| !value.is_empty()) { - draft = draft.with_display_name(value); - } - if let Some(value) = nip05.filter(|value| !value.is_empty()) { - draft = draft.with_nip05(value); - } - if let Some(value) = about.filter(|value| !value.is_empty()) { - draft = draft.with_about(value); - } - self.client - .social() - .map_err(RadrootsAppError::from_sdk)? - .publish_profile(draft) - .await - .map(|receipt| receipt.event_id().to_owned()) - .map_err(RadrootsAppError::from_sdk) - } - - pub async fn nostr_post_text_note(&self, content: String) -> Result<String, RadrootsAppError> { - self.client - .social() - .map_err(RadrootsAppError::from_sdk)? - .publish_text(content) - .await - .map(|receipt| receipt.event_id().to_owned()) - .map_err(RadrootsAppError::from_sdk) - } - - pub async fn nostr_fetch_text_notes( - &self, - limit: u16, - since_unix: Option<u64>, - ) -> Result<Vec<NostrPostEventMetadata>, RadrootsAppError> { - self.client - .social() - .map_err(RadrootsAppError::from_sdk)? - .fetch_posts(limit, since_unix) - .await - .map(|events| events.into_iter().map(map_post).collect()) - .map_err(RadrootsAppError::from_sdk) - } - - pub async fn nostr_post_reply( - &self, - parent_event_id_hex: String, - parent_author_hex: String, - content: String, - root_event_id_hex: Option<String>, - ) -> Result<String, RadrootsAppError> { - if root_event_id_hex - .as_deref() - .is_some_and(|root| root != parent_event_id_hex.as_str()) - { - return Err(RadrootsAppError::unsupported( - "nested reply author context is required", - )); - } - self.client - .social() - .map_err(RadrootsAppError::from_sdk)? - .publish_reply( - content, - parent_event_id_hex.as_str(), - parent_author_hex.as_str(), - None, - ) - .await - .map(|receipt| receipt.event_id().to_owned()) - .map_err(RadrootsAppError::from_sdk) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn relay_configuration_is_explicit_and_status_is_categorical() { - let runtime = RadrootsRuntime::test_memory().expect("runtime"); - let initial = runtime - .nostr_connection_status() - .await - .expect("initial status"); - assert_eq!(initial.light, NostrLight::Red); - assert!(!initial.configured); - assert!(runtime.nostr_set_default_relays(Vec::new()).is_err()); - } -} diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox.rs b/core/crates/tera_core/src/runtime/product_surface/outbox.rs @@ -1,10 +1,14 @@ use std::collections::BTreeSet; -use radroots_blossom::{BlobUrl, MediaType}; +use radroots_blossom::{BlobUrl, MediaType, authorization::AuthoredUploadClaim}; use radroots_event::contract::AuthorRole; use radroots_event_codec::authoring::PlanWireV1; use radroots_identity::PublicKey; -use radroots_signing::{Actor, actor::ActorSource, request::CancellationPolicy}; +use radroots_signing::{ + Actor, AuthoredArtifactId, SigningIntentId, SigningOperationId, + actor::ActorSource, + request::{CancellationPolicy, SignPolicy}, +}; use radroots_storage::{ authored::{AdmissionState, SigningState}, authored_delivery::{AuthoredDeliveryState, DeliveryAttemptOutcome}, @@ -140,6 +144,15 @@ pub enum Phase1CancellationPolicy { LocalCooperative, } +impl Phase1CancellationPolicy { + const fn signing(self) -> CancellationPolicy { + match self { + Self::PreservePublishedRequest => CancellationPolicy::PreservePublishedRequest, + Self::LocalCooperative => CancellationPolicy::LocalCooperative, + } + } +} + /// Exact relay and deadline intent frozen before an operation is prepared. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] @@ -614,6 +627,79 @@ impl RadrootsRuntime { } } + /// Invokes the configured opaque host signer for one durably queued draft. + /// + /// The canonical sync engine verifies author, event ID, exact fields, + /// signature, deadline, cancellation, and operation binding before the + /// signed artifact can be persisted. Delivery remains a separate phase. + pub async fn phase1_sign_queued_draft( + &self, + draft_id: [u8; 16], + expected_revision: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let expected = AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let head = self + .storage()? + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + if head.revision() != expected || head.stage() != AuthoredDraftStage::Queued { + return Err(Phase1DraftError::RevisionConflict); + } + self.sync()? + .sign_prepared(push_request(&head)?) + .await + .map_err(|_| Phase1DraftError::Operation)?; + self.draft_status_from(head).await + } + + /// Signs one short-lived BUD-11 upload credential for HTTP use only. + /// + /// The returned value is not persisted and its distinct plan type cannot + /// enter the relay push pipeline. + #[allow(clippy::too_many_arguments)] + pub async fn phase1_authorize_blossom_upload( + &self, + operation_id: [u8; 16], + artifact_id: [u8; 16], + claim: AuthoredUploadClaim, + deadline_unix_ms: u64, + cancellation: Phase1CancellationPolicy, + ) -> Result<radroots_sdk::signing::AuthorizationHeader, Phase1DraftError> { + let public_key = self + .store_public_key + .ok_or(Phase1DraftError::IdentityUnavailable)?; + let actor = Actor::new(public_key, ActorSource::ExplicitPublicKey, AuthorRole::ALL) + .map_err(|_| Phase1DraftError::IdentityUnavailable)?; + let plan = radroots_sdk::signing::BlossomAuthorizationPlan::for_upload(&claim, public_key) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + let operation_id = + SigningOperationId::new(operation_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let artifact_id = + AuthoredArtifactId::new(artifact_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let policy = SignPolicy::new(deadline_unix_ms, cancellation.signing()) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let request = radroots_sdk::signing::blossom_upload_request( + radroots_protocol::runtime::v1::OperationId::SyncPush, + SigningIntentId::new(operation_id, artifact_id), + actor, + plan, + policy, + ) + .map_err(|_| Phase1DraftError::Operation)?; + self.client + .signing() + .map_err(|_| Phase1DraftError::OperationUnavailable)? + .ok_or(Phase1DraftError::OperationUnavailable)? + .authorize_blossom_upload(request) + .await + .map_err(|_| Phase1DraftError::Operation) + } + /// Returns durable draft state composed with canonical authored-operation state. pub async fn phase1_draft_status( &self, @@ -1085,12 +1171,27 @@ mod tests { }; use radroots_sdk::ClientBuilder; - const AUTHOR: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const AUTHOR: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; fn runtime() -> RadrootsRuntime { RadrootsRuntime::from_client_builder( ClientBuilder::memory_default(), Some(PublicKey::from_hex(AUTHOR).unwrap()), + None, + ) + .unwrap() + } + + fn signing_runtime() -> RadrootsRuntime { + let signer = radroots_nostr::signing::LocalSigner::new( + radroots_nostr::key::SecretKey::parse(SECRET).unwrap(), + ) + .unwrap(); + RadrootsRuntime::from_client_builder( + ClientBuilder::memory_default(), + Some(PublicKey::from_hex(AUTHOR).unwrap()), + Some(std::sync::Arc::new(signer)), ) .unwrap() } @@ -1184,8 +1285,8 @@ mod tests { } #[tokio::test] - async fn all_five_add_flows_queue_without_network_access() { - let runtime = runtime(); + async fn all_five_add_flows_queue_and_sign_without_network_access() { + let runtime = signing_runtime(); let (photo, media) = photo_command(); let commands = [ ( @@ -1238,10 +1339,69 @@ mod tests { .unwrap(); assert_eq!(queued.state(), Phase1OutboxState::Queued); assert_eq!(queued.command_type(), CANONICAL_ADD_COMMAND_TYPES[index]); + let signed = runtime + .phase1_sign_queued_draft(id, queued.draft().revision().get()) + .await + .unwrap(); + assert_eq!(signed.state(), Phase1OutboxState::Signed); + assert_eq!( + signed + .push() + .and_then(|push| push.artifact().signed()) + .expect("signed artifact") + .event() + .kind(), + match index { + 0..=2 => 1, + 3 => 31_922, + 4 => 30_402, + _ => unreachable!(), + } + ); } } #[tokio::test] + async fn blossom_authorization_uses_the_same_opaque_signer_but_never_the_outbox() { + use radroots_blossom::authorization::{ + AuthorizationContent, AuthorizationTarget, AuthorizationValidation, ServerDomain, + }; + + let runtime = signing_runtime(); + let hash = BlossomSha256::digest(b"exact upload bytes"); + let server = ServerDomain::parse("media.example").unwrap(); + let claim = AuthoredUploadClaim::new( + AuthorizationContent::parse("Upload exact Radroots image").unwrap(), + server.clone(), + hash, + 1_900_000_000, + 60, + ) + .unwrap(); + let header = runtime + .phase1_authorize_blossom_upload( + [71; 16], + [72; 16], + claim, + u64::MAX, + Phase1CancellationPolicy::LocalCooperative, + ) + .await + .unwrap(); + let verified = radroots_nostr::blossom::decode_verify_authorization_header( + header.as_str(), + &AuthorizationValidation::bud11( + AuthorizationTarget::Upload(hash), + server, + 1_900_000_001, + ), + ) + .unwrap(); + assert_eq!(verified.claim().hashes(), &[hash]); + assert!(runtime.phase1_draft_heads(10).await.unwrap().is_empty()); + } + + #[tokio::test] async fn cancellation_is_terminal_and_preserves_operation_evidence() { let runtime = runtime(); let id = [8; 16]; diff --git a/core/crates/tera_core/tests/durable_runtime.rs b/core/crates/tera_core/tests/durable_runtime.rs @@ -109,21 +109,3 @@ async fn unrecognized_sqlite_bytes_are_corruption_classified() { assert_eq!(report.code, "storage_integrity_failed"); assert!(!report.retryable); } - -#[cfg(feature = "mobile-social")] -#[tokio::test] -async fn signer_selection_cannot_cross_the_authenticated_store_identity() { - const OTHER_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000002"; - - let root = tempfile::tempdir().expect("tempdir"); - let runtime = RuntimeBuilder::new(support::store(root.path())) - .build() - .await - .expect("runtime"); - let error = runtime - .nostr_identity_restore_host_custody_secret(OTHER_SECRET.to_owned(), None, true) - .expect_err("different identity must not select this user store"); - assert!(matches!(error, RadrootsAppError::Runtime(_))); - assert!(!runtime.nostr_identity_has_selected_signing_identity()); - runtime.shutdown().await.expect("shutdown"); -} diff --git a/core/crates/tera_core/tests/package_boundary.rs b/core/crates/tera_core/tests/package_boundary.rs @@ -4,13 +4,13 @@ const ERROR: &str = include_str!("../src/error.rs"); const RUNTIME: &str = include_str!("../src/runtime/mod.rs"); const APP_INFO: &str = include_str!("../src/runtime/app_info.rs"); const INFO: &str = include_str!("../src/runtime/info.rs"); -const KEY_MANAGEMENT: &str = include_str!("../src/runtime/key_management.rs"); -const NOSTR: &str = include_str!("../src/runtime/nostr.rs"); const PRODUCT_SURFACE: &str = include_str!("../src/runtime/product_surface.rs"); +const PRODUCT_AUTHORING: &str = include_str!("../src/runtime/product_surface/authoring.rs"); const PRODUCT_CONTEXT: &str = include_str!("../src/runtime/product_surface/context.rs"); const PRODUCT_CURSOR: &str = include_str!("../src/runtime/product_surface/cursor.rs"); const PRODUCT_IDENTITY: &str = include_str!("../src/runtime/product_surface/identity.rs"); const PRODUCT_MODEL: &str = include_str!("../src/runtime/product_surface/model.rs"); +const PRODUCT_OUTBOX: &str = include_str!("../src/runtime/product_surface/outbox.rs"); const PRODUCT_PROJECTION: &str = include_str!("../src/runtime/product_surface/projection.rs"); const PRODUCT_RANKING: &str = include_str!("../src/runtime/product_surface/ranking.rs"); const SDK: &str = include_str!("../src/runtime/sdk.rs"); @@ -26,13 +26,16 @@ fn core_owns_no_uniffi_or_process_global_logging_policy() { ("src/runtime/mod.rs", RUNTIME), ("src/runtime/app_info.rs", APP_INFO), ("src/runtime/info.rs", INFO), - ("src/runtime/key_management.rs", KEY_MANAGEMENT), - ("src/runtime/nostr.rs", NOSTR), ("src/runtime/product_surface.rs", PRODUCT_SURFACE), + ( + "src/runtime/product_surface/authoring.rs", + PRODUCT_AUTHORING, + ), ("src/runtime/product_surface/context.rs", PRODUCT_CONTEXT), ("src/runtime/product_surface/cursor.rs", PRODUCT_CURSOR), ("src/runtime/product_surface/identity.rs", PRODUCT_IDENTITY), ("src/runtime/product_surface/model.rs", PRODUCT_MODEL), + ("src/runtime/product_surface/outbox.rs", PRODUCT_OUTBOX), ( "src/runtime/product_surface/projection.rs", PRODUCT_PROJECTION, @@ -52,6 +55,22 @@ fn core_owns_no_uniffi_or_process_global_logging_policy() { } #[test] +fn mobile_runtime_has_no_secret_taking_or_local_signer_slot_surface() { + for source in [ + MANIFEST, + RUNTIME, + BUILDER, + PRODUCT_AUTHORING, + PRODUCT_OUTBOX, + ] { + assert!(!source.contains("signing::Slot")); + assert!(!source.contains("secret_key: String")); + assert!(!source.contains("Provider::slot")); + } + assert!(!MANIFEST.contains("radroots_sdk/local-signing")); +} + +#[test] fn production_runtime_requires_validated_sqlite_and_memory_is_test_only() { assert!(MANIFEST.contains("radroots_sdk = { workspace = true, features = [\"sqlite\"] }")); assert_eq!(BUILDER.matches("ClientBuilder::sqlite").count(), 1);