lib

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

commit 400d923acbeaedc48646f88bb0c1dd5e27a9cc04
parent 2219a79cc9ea05afd53a424d683cf895c66a1732
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Jul 2026 11:57:27 +0000

signing: move concrete local Nostr signing to the Nostr adapter

- implement the generic signer over private Nostr key material
- enforce deadlines progress and exact request-bound receipts
- normalize adapter failures and redact secret-bearing diagnostics
- remove legacy authority key ownership and its Nostr dependency

Diffstat:
MCargo.lock | 3++-
Mcrates/authority/Cargo.toml | 7-------
Mcrates/authority/src/lib.rs | 4----
Dcrates/authority/src/local_signer.rs | 92-------------------------------------------------------------------------------
Mcrates/nostr/Cargo.toml | 5+++++
Mcrates/nostr/src/lib.rs | 3+++
Acrates/nostr/src/signing.rs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 258 insertions(+), 104 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4500,7 +4500,6 @@ version = "0.1.0-alpha" dependencies = [ "radroots_event", "radroots_identity", - "radroots_nostr", "serde_json", ] @@ -4699,6 +4698,8 @@ dependencies = [ "radroots_event", "radroots_event_codec", "radroots_identity", + "radroots_protocol", + "radroots_signing", "radroots_test_fixtures", "reqwest", "serde", diff --git a/crates/authority/Cargo.toml b/crates/authority/Cargo.toml @@ -13,17 +13,10 @@ homepage.workspace = true [features] default = ["std"] std = ["radroots_event/std", "radroots_identity/std"] -local_signer = [ - "std", - "dep:radroots_nostr", - "radroots_nostr/events", - "radroots_nostr/std", -] [dependencies] radroots_event = { workspace = true, default-features = false } radroots_identity = { workspace = true, default-features = false } -radroots_nostr = { workspace = true, optional = true, default-features = false } [dev-dependencies] serde_json = { workspace = true, default-features = false, features = [ diff --git a/crates/authority/src/lib.rs b/crates/authority/src/lib.rs @@ -8,8 +8,6 @@ extern crate alloc; pub mod actor; pub mod authorization; pub mod error; -#[cfg(feature = "local_signer")] -pub mod local_signer; pub mod signer; pub use actor::{ @@ -21,6 +19,4 @@ pub use authorization::{ sign_authorized_draft, validate_signed_event_matches_draft, }; pub use error::{RadrootsAuthorityError, RadrootsSignerError}; -#[cfg(feature = "local_signer")] -pub use local_signer::RadrootsLocalEventSigner; pub use signer::{RadrootsEventSigner, RadrootsSignerIdentity}; diff --git a/crates/authority/src/local_signer.rs b/crates/authority/src/local_signer.rs @@ -1,92 +0,0 @@ -#![forbid(unsafe_code)] - -use crate::{RadrootsAuthorityError, RadrootsEventSigner, RadrootsSignerError}; -use radroots_event::draft::{EventDraft, SignedEvent}; -use radroots_identity::PublicKey; -use radroots_nostr::prelude::{RadrootsNostrKeys, radroots_nostr_sign_frozen_draft}; - -pub struct RadrootsLocalEventSigner { - keys: RadrootsNostrKeys, - pubkey: PublicKey, -} - -impl RadrootsLocalEventSigner { - pub fn new(keys: RadrootsNostrKeys) -> Result<Self, RadrootsAuthorityError> { - let pubkey = PublicKey::from_hex(&keys.public_key().to_hex()) - .map_err(|_| RadrootsAuthorityError::InvalidSignerPubkey)?; - Ok(Self { keys, pubkey }) - } -} - -impl RadrootsEventSigner for RadrootsLocalEventSigner { - fn pubkey(&self) -> &PublicKey { - &self.pubkey - } - - fn sign_frozen_draft(&self, draft: &EventDraft) -> Result<SignedEvent, RadrootsSignerError> { - radroots_nostr_sign_frozen_draft(&self.keys, draft).map_err(|error| { - RadrootsSignerError::SigningFailed { - message: error.to_string(), - } - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use radroots_event::envelope::EventEnvelope; - use radroots_event::envelope::kind::KIND_GEOCHAT; - use radroots_nostr::prelude::{ - RadrootsNostrEventVerification, RadrootsNostrSecretKey, radroots_nostr_verify_event, - }; - - const FIXTURE_ALICE_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; - const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - - fn fixture_keys() -> RadrootsNostrKeys { - let secret_key = - RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key"); - RadrootsNostrKeys::new(secret_key) - } - - fn generic_draft() -> EventDraft { - EventDraft::new( - "radroots.social.geochat.v1", - KIND_GEOCHAT, - 1_700_000_000, - vec![vec!["t".to_owned(), "soil".to_owned()]], - "hello", - FIXTURE_ALICE_PUBLIC_KEY_HEX, - ) - .expect("draft") - } - - fn verification_event(signed: &SignedEvent) -> EventEnvelope { - signed.envelope().clone() - } - - #[test] - fn local_signer_reports_public_key() { - let signer = RadrootsLocalEventSigner::new(fixture_keys()).expect("signer"); - - assert_eq!(signer.pubkey().to_hex(), FIXTURE_ALICE_PUBLIC_KEY_HEX); - } - - #[test] - fn local_signer_signs_and_verifies_frozen_drafts() { - let signer = RadrootsLocalEventSigner::new(fixture_keys()).expect("signer"); - let draft = generic_draft(); - - let signed = signer.sign_frozen_draft(&draft).expect("signed"); - - assert_eq!(signed.id_str(), draft.expected_event_id_hex()); - assert_eq!(signed.pubkey().to_hex(), draft.expected_pubkey().to_hex()); - assert_eq!( - radroots_nostr_verify_event(&verification_event(&signed)), - RadrootsNostrEventVerification::Verified - ); - } -} diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml @@ -34,6 +34,7 @@ events = [ ] http = ["dep:reqwest"] nip17 = ["std", "codec", "nostr/nip44", "nostr/nip59"] +signing = ["events", "dep:radroots_signing", "radroots_signing/std"] [dependencies] base64 = { workspace = true, optional = true } @@ -43,6 +44,7 @@ radroots_event_codec = { workspace = true, optional = true, default-features = f radroots_identity = { workspace = true, optional = true, default-features = false, features = [ "std", ] } +radroots_signing = { workspace = true, optional = true, default-features = false } nostr = { workspace = true, features = ["nip04"] } nostr-sdk = { workspace = true, optional = true } reqwest = { workspace = true, optional = true, default-features = false, features = [ @@ -57,6 +59,9 @@ thiserror = { workspace = true } radroots_blossom = { workspace = true, default-features = false, features = [ "std", ] } +radroots_protocol = { workspace = true, default-features = false, features = [ + "std", +] } radroots_test_fixtures = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs @@ -30,6 +30,9 @@ pub mod job_adapter; #[cfg(feature = "nip17")] pub mod nip17; +#[cfg(feature = "signing")] +pub mod signing; + #[cfg(feature = "http")] pub mod nip11; diff --git a/crates/nostr/src/signing.rs b/crates/nostr/src/signing.rs @@ -0,0 +1,248 @@ +//! Concrete local Nostr implementation of the generic signing SPI. + +use core::fmt; +use std::{ + time::{SystemTime, UNIX_EPOCH}, + vec, +}; + +use radroots_signing::{ + Error, SignReceipt, SignRequest, Signer, SignerStatus, + capability::{CancellationSupport, SignerCapability, SignerKind}, + error::Kind, + signer::BoxFuture, + status::{SignProgress, SignProgressStage, SignerAvailability}, +}; + +use crate::{ + draft_signing::radroots_nostr_sign_frozen_draft, error::RadrootsNostrError, + types::RadrootsNostrKeys, +}; + +type Clock = fn() -> Result<u64, Error>; + +/// A local Nostr key-backed signer adapter. +/// +/// Key material remains private to this adapter. Debug output reports only the +/// public key and never delegates to the upstream key container. +pub struct LocalSigner { + keys: RadrootsNostrKeys, + clock: Clock, +} + +impl LocalSigner { + /// Creates a local signer over one Nostr keypair. + #[must_use] + pub const fn new(keys: RadrootsNostrKeys) -> Self { + Self { + keys, + clock: system_time_unix, + } + } + + #[cfg(test)] + const fn with_clock(keys: RadrootsNostrKeys, clock: Clock) -> Self { + Self { keys, clock } + } +} + +impl fmt::Debug for LocalSigner { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LocalSigner") + .field("public_key", &self.keys.public_key().to_hex()) + .field("secret_key", &"[redacted]") + .finish() + } +} + +impl Signer for LocalSigner { + fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> { + Box::pin(async { + Ok(SignerStatus::new( + SignerAvailability::Ready, + vec![SignerCapability::new( + SignerKind::Local, + CancellationSupport::BeforePublication, + true, + false, + )], + None, + )) + }) + } + + fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { + Box::pin(async move { + let started_at_unix = (self.clock)()?; + if started_at_unix >= request.policy().deadline_unix() { + return Err(Error::new(Kind::DeadlineExceeded)); + } + request.report_progress( + &SignProgress::stage(SignProgressStage::Validating) + .expect("validating progress never requires a challenge"), + ); + let signed_event = radroots_nostr_sign_frozen_draft(&self.keys, request.draft()) + .map_err(normalize_nostr_error)?; + request.report_progress( + &SignProgress::stage(SignProgressStage::VerifyingOutput) + .expect("verification progress never requires a challenge"), + ); + let completed_at_unix = (self.clock)()?; + if completed_at_unix >= request.policy().deadline_unix() { + return Err(Error::new(Kind::DeadlineExceeded)); + } + let receipt = + SignReceipt::from_signed_event(&request, signed_event, completed_at_unix)?; + request.report_progress( + &SignProgress::stage(SignProgressStage::Complete) + .expect("completion progress never requires a challenge"), + ); + Ok(receipt) + }) + } +} + +fn system_time_unix() -> Result<u64, Error> { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|source| Error::with_source(Kind::InternalError, source)) +} + +fn normalize_nostr_error(source: RadrootsNostrError) -> Error { + let kind = match &source { + RadrootsNostrError::FrozenDraftPubkeyMismatch { .. } => Kind::AuthorizationDenied, + RadrootsNostrError::FrozenDraftEventIdMismatch { .. } => Kind::SignerOutputInvalid, + _ => Kind::InternalError, + }; + Error::with_source(kind, source) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_event::{EventDraft, contract::AuthorRole, envelope::kind::KIND_GEOCHAT}; + use radroots_protocol::runtime::v1::OperationId; + use radroots_signing::{ + Actor, + actor::ActorSource, + request::{CancellationPolicy, ProgressObserver, SignPolicy}, + }; + use std::sync::{Arc, Mutex}; + + use crate::{ + test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB}, + types::RadrootsNostrSecretKey, + }; + + const DEADLINE: u64 = 1_700_000_100; + + struct RecordingObserver(Mutex<Vec<SignProgressStage>>); + + impl ProgressObserver for RecordingObserver { + fn on_progress(&self, progress: &SignProgress) { + self.0 + .lock() + .expect("progress lock") + .push(progress.stage_value()); + } + } + + fn before_deadline() -> Result<u64, Error> { + Ok(1_700_000_050) + } + + fn at_deadline() -> Result<u64, Error> { + Ok(DEADLINE) + } + + fn fixture_keys(secret_key_hex: &str) -> RadrootsNostrKeys { + let secret_key = + RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key fixture"); + RadrootsNostrKeys::new(secret_key) + } + + fn request() -> SignRequest { + let actor = Actor::from_public_key_hex( + FIXTURE_ALICE.public_key_hex, + ActorSource::ExplicitPublicKey, + [AuthorRole::Any], + ) + .expect("actor"); + let draft = EventDraft::new( + "radroots.social.geochat.v1", + KIND_GEOCHAT, + 1_700_000_000, + Vec::new(), + "private-fixture-content", + FIXTURE_ALICE.public_key_hex, + ) + .expect("draft"); + SignRequest::new( + OperationId::SyncPush, + actor, + draft, + SignPolicy::new(DEADLINE, CancellationPolicy::LocalCooperative).expect("policy"), + ) + .expect("request") + } + + #[tokio::test] + async fn local_adapter_reports_capability_and_signs_the_exact_draft() { + let signer = + LocalSigner::with_clock(fixture_keys(FIXTURE_ALICE.secret_key_hex), before_deadline); + let status = signer.status().await.expect("status"); + assert_eq!(status.availability(), SignerAvailability::Ready); + assert_eq!(status.capabilities().len(), 1); + assert_eq!(status.capabilities()[0].kind(), SignerKind::Local); + + let observer = Arc::new(RecordingObserver(Mutex::new(Vec::new()))); + let request = request().with_progress_observer(observer.clone()); + let expected_id = request.draft().expected_event_id_hex(); + let receipt = signer.sign(request).await.expect("receipt"); + + assert_eq!(receipt.operation_id(), OperationId::SyncPush); + assert_eq!(receipt.completed_at_unix(), 1_700_000_050); + assert_eq!(receipt.signed_event().id_str(), expected_id); + assert_eq!( + receipt.signed_event().pubkey().to_hex(), + FIXTURE_ALICE.public_key_hex + ); + assert_eq!( + observer.0.lock().expect("progress lock").as_slice(), + &[ + SignProgressStage::Validating, + SignProgressStage::VerifyingOutput, + SignProgressStage::Complete, + ] + ); + } + + #[tokio::test] + async fn wrong_local_key_is_normalized_without_leaking_secret_material() { + let signer = + LocalSigner::with_clock(fixture_keys(FIXTURE_BOB.secret_key_hex), before_deadline); + let error = signer + .sign(request()) + .await + .expect_err("wrong key must fail"); + + assert_eq!(error.kind(), Kind::AuthorizationDenied); + assert!(!error.to_string().contains(FIXTURE_BOB.secret_key_hex)); + assert!(!format!("{error:?}").contains(FIXTURE_BOB.secret_key_hex)); + assert!(!format!("{signer:?}").contains(FIXTURE_BOB.secret_key_hex)); + } + + #[tokio::test] + async fn expired_deadline_fails_before_local_signing() { + let signer = + LocalSigner::with_clock(fixture_keys(FIXTURE_ALICE.secret_key_hex), at_deadline); + let error = signer + .sign(request()) + .await + .expect_err("deadline must fail"); + + assert_eq!(error.kind(), Kind::DeadlineExceeded); + } +}