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:
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 = ®istry[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 = ®istry[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),
- }
+ })
}
}