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