app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 77c2e8c3129498f0a8688ffdc515c5fabd29ac15
parent 23ddedd8f4988c202d3746b0eeeec7b70c2d98f0
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 17:59:49 +0000

identity: make signer binding access capability-safe

- expose local-keyring access as an optional signer capability
- require local capability explicitly for sessions and credential operations
- fail closed when persistence or recovery sees a nonlocal binding
- preserve current LocalKeyring behavior with focused model and runtime tests

Diffstat:
Mcore/crates/harvestcircle_application/src/actor.rs | 8+++++---
Mcore/crates/harvestcircle_application/src/app_core.rs | 10++++++++--
Mcore/crates/harvestcircle_application/src/identities.rs | 48++++++++++++++++++++++++++++++++++--------------
Mcore/crates/harvestcircle_application/src/recovery.rs | 23++++++++++++++++++++---
Mcore/crates/harvestcircle_domain/src/identity.rs | 22+++++++++++++++++-----
Mcore/crates/harvestcircle_runtime/src/persistence.rs | 10++++++++--
Mcore/crates/harvestcircle_storage/src/identities.rs | 20+++++++++++++-------
7 files changed, 105 insertions(+), 36 deletions(-)

diff --git a/core/crates/harvestcircle_application/src/actor.rs b/core/crates/harvestcircle_application/src/actor.rs @@ -57,9 +57,10 @@ impl ActiveSessionBinding { generation: SessionGeneration, ) -> Result<Self, SafeError> { let signer_binding = signer_binding.into(); - if identity.public_key() != signer_binding.identity() - || signer_binding.availability() != SignerAvailability::Available - { + let local_keyring = signer_binding + .as_local_keyring() + .filter(|binding| binding.availability() == SignerAvailability::Available); + if local_keyring.map(|binding| binding.identity()) != Some(identity.public_key()) { return Err(invalid_foreground_session()); } Ok(Self { @@ -602,6 +603,7 @@ mod tests { .expect("session"); assert_eq!(session.identity(), &identity); assert_eq!(session.signer_binding().identity(), public_key); + assert!(session.signer_binding().as_local_keyring().is_some()); assert_eq!(session.generation(), generation); let correlation = super::TaskCorrelation::new( diff --git a/core/crates/harvestcircle_application/src/app_core.rs b/core/crates/harvestcircle_application/src/app_core.rs @@ -171,8 +171,14 @@ impl AppCore { else { return Err(identity_not_found()); }; - let deletes_local_credential = identity.signer_binding().availability() - != harvestcircle_domain::SignerAvailability::CredentialMissing; + let deletes_local_credential = + identity + .signer_binding() + .as_local_keyring() + .is_some_and(|binding| { + binding.availability() + != harvestcircle_domain::SignerAvailability::CredentialMissing + }); let id = state.next_removal_token; state.next_removal_token = id.checked_add(1).ok_or_else(invalid_application_state)?; let revision = state.state_machine.snapshot().revision(); diff --git a/core/crates/harvestcircle_application/src/identities.rs b/core/crates/harvestcircle_application/src/identities.rs @@ -158,7 +158,8 @@ impl AppCore { let (public_key, npub, secret) = imported.into_parts(); let previous = identities.find_identity(public_key)?; if let Some(existing) = &previous - && (existing.signer_binding().availability() != SignerAvailability::CredentialMissing + && (local_keyring_binding(existing)?.availability() + != SignerAvailability::CredentialMissing || secrets.contains(public_key)?) { return Err(identity_exists()); @@ -167,7 +168,9 @@ impl AppCore { return Err(identity_exists()); } let identity = if let Some(existing) = &previous { - existing.with_binding_availability(SignerAvailability::Available) + existing + .with_local_keyring_availability(SignerAvailability::Available) + .ok_or_else(recovery_required)? } else { NostrIdentity::new( NostrIdentityReference::verify(public_key, npub.as_str().to_owned())?, @@ -220,10 +223,12 @@ impl AppCore { operations: &(impl DurableOperationRepository + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { - let prior = OperationPriorState::new( - app_state.load_selected_identity()?, - previous.map(|identity| identity.signer_binding().availability()), - ); + let prior_availability = previous + .map(local_keyring_binding) + .transpose()? + .map(LocalKeyringBinding::availability); + let prior = + OperationPriorState::new(app_state.load_selected_identity()?, prior_availability); match operations.begin_durable_operation( request_id, kind, @@ -340,12 +345,12 @@ impl AppCore { self.sign_out()?; } let identity = &registry[index]; + let local_keyring = local_keyring_binding(identity)?; match secrets.delete(public_key) { Ok(()) => {} Err(error) if error.code() == SafeErrorCode::CredentialMissing - && identity.signer_binding().availability() - == SignerAvailability::CredentialMissing => {} + && local_keyring.availability() == SignerAvailability::CredentialMissing => {} Err(error) => return Err(error), } journal.update_operation( @@ -402,12 +407,13 @@ impl AppCore { self.snapshot().selected_identity() }; let identity = &registry[index]; + let local_keyring = local_keyring_binding(identity)?; match operations.begin_durable_operation( request_id, DurableOperationKind::Remove, public_key, Some(expected_revision), - OperationPriorState::new(selected, Some(identity.signer_binding().availability())), + OperationPriorState::new(selected, Some(local_keyring.availability())), clock.now(), )? { DurableOperationStart::Started(_) => {} @@ -433,8 +439,7 @@ impl AppCore { Ok(()) => {} Err(error) if error.code() == SafeErrorCode::CredentialMissing - && identity.signer_binding().availability() - == SignerAvailability::CredentialMissing => {} + && local_keyring.availability() == SignerAvailability::CredentialMissing => {} Err(error) => return Err(error), } operations.advance_durable_operation( @@ -554,12 +559,15 @@ impl AppCore { let imported = self.key_material().import(input)?; let (public_key, npub, secret) = imported.into_parts(); if let Some(existing) = identities.find_identity(public_key)? { - if existing.signer_binding().availability() != SignerAvailability::CredentialMissing + if local_keyring_binding(&existing)?.availability() + != SignerAvailability::CredentialMissing || secrets.contains(public_key)? { return Err(identity_exists()); } - let repaired = existing.with_binding_availability(SignerAvailability::Available); + let repaired = existing + .with_local_keyring_availability(SignerAvailability::Available) + .ok_or_else(recovery_required)?; Self::persist_identity_transaction( IdentityOperationKind::Import, &repaired, @@ -905,6 +913,13 @@ const fn identity_not_found() -> SafeError { ) } +fn local_keyring_binding(identity: &NostrIdentity) -> Result<LocalKeyringBinding, SafeError> { + identity + .signer_binding() + .as_local_keyring() + .ok_or_else(recovery_required) +} + const fn recovery_required() -> SafeError { SafeError::new( SafeErrorCode::PendingOperationRecoveryRequired, @@ -1265,7 +1280,12 @@ mod tests { ) .expect("repair"); assert_eq!( - receipt.identity().signer_binding().availability(), + receipt + .identity() + .signer_binding() + .as_local_keyring() + .expect("local keyring") + .availability(), SignerAvailability::Available ); assert!(secrets.contains(public_key).expect("credential")); diff --git a/core/crates/harvestcircle_application/src/recovery.rs b/core/crates/harvestcircle_application/src/recovery.rs @@ -153,8 +153,13 @@ fn recover_durable_addition( DurableOperationPhase::CredentialWritten => { let metadata = identities.find_identity(identity)?; let committed = metadata.as_ref().is_some_and(|saved| { - saved.signer_binding().availability() - == harvestcircle_domain::SignerAvailability::Available + saved + .signer_binding() + .as_local_keyring() + .is_some_and(|binding| { + binding.availability() + == harvestcircle_domain::SignerAvailability::Available + }) }); if committed { operations.advance_durable_operation( @@ -246,7 +251,10 @@ fn compensate_durable_addition( } if let Some(availability) = operation.prior().binding_availability() { if let Some(previous) = identities.find_identity(operation.identity())? { - identities.update_identity(&previous.with_binding_availability(availability))?; + let restored = previous + .with_local_keyring_availability(availability) + .ok_or_else(recovery_required)?; + identities.update_identity(&restored)?; } } else if identities.find_identity(operation.identity())?.is_some() { identities.remove_identity(operation.identity())?; @@ -269,6 +277,15 @@ fn compensate_durable_addition( Ok(()) } +const fn recovery_required() -> SafeError { + SafeError::new( + harvestcircle_domain::SafeErrorCode::PendingOperationRecoveryRequired, + harvestcircle_domain::SafeMessage::new( + "Identity recovery is required before this operation can continue.", + ), + ) +} + fn recover_removal( operation: &crate::PendingIdentityOperation, identities: &(impl IdentityRepository + ?Sized), diff --git a/core/crates/harvestcircle_domain/src/identity.rs b/core/crates/harvestcircle_domain/src/identity.rs @@ -75,9 +75,9 @@ impl SignerBinding { } #[must_use] - pub const fn local_keyring(self) -> LocalKeyringBinding { + pub const fn as_local_keyring(self) -> Option<LocalKeyringBinding> { match self { - Self::LocalKeyring(binding) => binding, + Self::LocalKeyring(binding) => Some(binding), } } } @@ -296,8 +296,12 @@ impl NostrIdentity { } #[must_use] - pub fn with_binding_availability(&self, availability: SignerAvailability) -> Self { - Self { + pub fn with_local_keyring_availability( + &self, + availability: SignerAvailability, + ) -> Option<Self> { + self.signer_binding.as_local_keyring()?; + Some(Self { identity: self.identity.clone(), signer_binding: SignerBinding::LocalKeyring(LocalKeyringBinding::new( self.public_key(), @@ -306,7 +310,7 @@ impl NostrIdentity { label: self.label.clone(), created_at: self.created_at, last_used_at: self.last_used_at, - } + }) } #[must_use] @@ -435,6 +439,14 @@ mod tests { } #[test] + fn local_keyring_capability_is_explicit_and_preserves_the_binding() { + let binding = LocalKeyringBinding::new(public_key(), SignerAvailability::Available); + let signer_binding = SignerBinding::LocalKeyring(binding); + + assert_eq!(signer_binding.as_local_keyring(), Some(binding)); + } + + #[test] fn signer_binding_rejects_an_identity_mismatch() { let identity = NostrIdentityReference::derive(public_key()).expect("identity"); let other = PublicKey::from_bytes([8_u8; 32]).expect("other public key"); diff --git a/core/crates/harvestcircle_runtime/src/persistence.rs b/core/crates/harvestcircle_runtime/src/persistence.rs @@ -495,7 +495,9 @@ mod tests { fn durable_recovery_preserves_repair_metadata_and_deletes_orphan_credentials() { let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); let secrets = InMemorySecretStore::default(); - let missing = identity().with_binding_availability(SignerAvailability::CredentialMissing); + let missing = identity() + .with_local_keyring_availability(SignerAvailability::CredentialMissing) + .expect("local keyring"); adapter .database() .insert_identity(&missing) @@ -547,7 +549,11 @@ mod tests { .expect("lookup") .expect("preserved identity"); assert_eq!( - repaired.signer_binding().availability(), + repaired + .signer_binding() + .as_local_keyring() + .expect("local keyring") + .availability(), SignerAvailability::CredentialMissing ); assert!(!secrets.contains(missing.public_key()).expect("credential")); diff --git a/core/crates/harvestcircle_storage/src/identities.rs b/core/crates/harvestcircle_storage/src/identities.rs @@ -44,7 +44,7 @@ impl IdentityRepository for Database { } fn insert_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { - let encoded = EncodedIdentity::from(identity); + let encoded = EncodedIdentity::try_from(identity)?; let mut connection = self.connection(); let transaction = connection.transaction().map_err(|_| storage_error())?; let result = transaction.execute( @@ -82,7 +82,7 @@ impl IdentityRepository for Database { } fn update_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { - let encoded = EncodedIdentity::from(identity); + let encoded = EncodedIdentity::try_from(identity)?; let mut connection = self.connection(); let transaction = connection.transaction().map_err(|_| storage_error())?; let identity_rows = transaction @@ -188,17 +188,23 @@ struct EncodedIdentity { last_used_at: Option<i64>, } -impl From<&NostrIdentity> for EncodedIdentity { - fn from(identity: &NostrIdentity) -> Self { - Self { +impl TryFrom<&NostrIdentity> for EncodedIdentity { + type Error = SafeError; + + fn try_from(identity: &NostrIdentity) -> Result<Self, Self::Error> { + let binding = identity + .signer_binding() + .as_local_keyring() + .ok_or_else(storage_error)?; + Ok(Self { public_key: identity.public_key().to_hex(), npub: identity.npub().as_str().to_owned(), signer_kind: "local_secret", - key_availability: encode_key_availability(identity.signer_binding().availability()), + key_availability: encode_key_availability(binding.availability()), label: identity.label().map(|label| label.as_str().to_owned()), created_at: identity.created_at().timestamp().as_seconds(), last_used_at: identity.last_used_at().map(UnixTimestamp::as_seconds), - } + }) } }