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:
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);