lib

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

commit 1f2844104850f2b4438dd2be7e1841150f4d5a7a
parent 3978d830ac861e0bbec4e7fa05b4ff8ec60fc847
Author: triesap <tyson@radroots.org>
Date:   Fri, 31 Jul 2026 10:57:41 +0000

nostr: implement the local Nostr signer adapter

- Consume opaque local secrets without exposing upstream key containers.
- Report canonical public identity while keeping debug output fully redacted.
- Enforce deadline, capability, and exact frozen-draft signing contracts.
- Cover generation, wrong-key, drift, and public API boundary regressions.

Diffstat:
Mcrates/nostr/src/key.rs | 16++++++++++++++--
Mcrates/nostr/src/signing.rs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/nostr/tests/package_boundary.rs | 27+++++++++++++++++++++++++++
3 files changed, 150 insertions(+), 39 deletions(-)

diff --git a/crates/nostr/src/key.rs b/crates/nostr/src/key.rs @@ -1,7 +1,7 @@ //! Nostr key encoding and NIP-19 conversion for Radroots identities. //! -//! Step 125 moves the existing conversion behavior to this durable public -//! module without returning Nostr representation policy to identity. +//! This durable adapter keeps Nostr representation policy out of the identity +//! crate and keeps local secret material opaque. use alloc::string::String; @@ -67,6 +67,14 @@ pub struct SecretKey { #[cfg(feature = "signing")] impl SecretKey { + /// Generates a fresh local secret without exposing its representation. + #[must_use] + pub fn generate() -> Self { + Self { + inner: nostr::SecretKey::generate(), + } + } + /// Parses exact hexadecimal or NIP-19 `nsec` text. /// /// Errors never retain or render the supplied secret material. @@ -81,6 +89,10 @@ impl SecretKey { let public_key = nostr::Keys::new(self.inner.clone()).public_key(); public_key_from_nostr(public_key) } + + pub(crate) fn into_keys(self) -> nostr::Keys { + nostr::Keys::new(self.inner) + } } #[cfg(feature = "signing")] diff --git a/crates/nostr/src/signing.rs b/crates/nostr/src/signing.rs @@ -7,7 +7,7 @@ use std::{ }; use radroots_signing::{ - Error, SignReceipt, SignRequest, Signer, SignerStatus, + Error as SigningError, SignReceipt, SignRequest, Signer, SignerStatus, capability::{CancellationSupport, SignerCapability, SignerKind}, error::Kind, signer::BoxFuture, @@ -15,34 +15,46 @@ use radroots_signing::{ }; use crate::{ - draft_signing::radroots_nostr_sign_frozen_draft, error::RadrootsNostrError, - types::RadrootsNostrKeys, + draft_signing::radroots_nostr_sign_frozen_draft, error::RadrootsNostrError, key::SecretKey, }; +use radroots_identity::PublicKey; -type Clock = fn() -> Result<u64, Error>; +type Clock = fn() -> Result<u64, SigningError>; /// 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, + keys: nostr::Keys, + public_key: PublicKey, clock: Clock, } impl LocalSigner { - /// Creates a local signer over one Nostr keypair. + /// Consumes one opaque local secret and creates its signer adapter. + pub fn new(secret_key: SecretKey) -> Result<Self, crate::Error> { + Self::with_clock(secret_key, system_time_unix) + } + + /// Generates a fresh opaque secret and creates its signer adapter. + pub fn generate() -> Result<Self, crate::Error> { + Self::new(SecretKey::generate()) + } + + /// Returns the canonical public identity controlled by this signer. #[must_use] - pub const fn new(keys: RadrootsNostrKeys) -> Self { - Self { - keys, - clock: system_time_unix, - } + pub const fn public_key(&self) -> PublicKey { + self.public_key } - #[cfg(test)] - const fn with_clock(keys: RadrootsNostrKeys, clock: Clock) -> Self { - Self { keys, clock } + fn with_clock(secret_key: SecretKey, clock: Clock) -> Result<Self, crate::Error> { + let public_key = secret_key.public_key()?; + Ok(Self { + keys: secret_key.into_keys(), + public_key, + clock, + }) } } @@ -50,14 +62,14 @@ 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("public_key", &self.public_key) .field("secret_key", &"[redacted]") .finish() } } impl Signer for LocalSigner { - fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> { + fn status(&self) -> BoxFuture<'_, Result<SignerStatus, SigningError>> { Box::pin(async { Ok(SignerStatus::new( SignerAvailability::Ready, @@ -72,11 +84,11 @@ impl Signer for LocalSigner { }) } - fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { + fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, SigningError>> { Box::pin(async move { let started_at_unix = (self.clock)()?; if started_at_unix >= request.policy().deadline_unix() { - return Err(Error::new(Kind::DeadlineExceeded)); + return Err(SigningError::new(Kind::DeadlineExceeded)); } request.report_progress( &SignProgress::stage(SignProgressStage::Validating) @@ -90,7 +102,7 @@ impl Signer for LocalSigner { ); let completed_at_unix = (self.clock)()?; if completed_at_unix >= request.policy().deadline_unix() { - return Err(Error::new(Kind::DeadlineExceeded)); + return Err(SigningError::new(Kind::DeadlineExceeded)); } let receipt = SignReceipt::from_signed_event(&request, signed_event, completed_at_unix)?; @@ -103,20 +115,20 @@ impl Signer for LocalSigner { } } -fn system_time_unix() -> Result<u64, Error> { +fn system_time_unix() -> Result<u64, SigningError> { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_secs()) - .map_err(|source| Error::with_source(Kind::InternalError, source)) + .map_err(|source| SigningError::with_source(Kind::InternalError, source)) } -fn normalize_nostr_error(source: RadrootsNostrError) -> Error { +fn normalize_nostr_error(source: RadrootsNostrError) -> SigningError { let kind = match &source { RadrootsNostrError::FrozenDraftPubkeyMismatch { .. } => Kind::AuthorizationDenied, RadrootsNostrError::FrozenDraftEventIdMismatch { .. } => Kind::SignerOutputInvalid, _ => Kind::InternalError, }; - Error::with_source(kind, source) + SigningError::with_source(kind, source) } #[cfg(test)] @@ -129,14 +141,18 @@ mod tests { actor::ActorSource, request::{CancellationPolicy, ProgressObserver, SignPolicy}, }; - use std::sync::{Arc, Mutex}; + use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }; use crate::{ + key::parse_secret_key, test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB}, - types::RadrootsNostrSecretKey, }; const DEADLINE: u64 = 1_700_000_100; + static CROSSING_DEADLINE_CALLS: AtomicUsize = AtomicUsize::new(0); struct RecordingObserver(Mutex<Vec<SignProgressStage>>); @@ -149,18 +165,24 @@ mod tests { } } - fn before_deadline() -> Result<u64, Error> { + fn before_deadline() -> Result<u64, SigningError> { Ok(1_700_000_050) } - fn at_deadline() -> Result<u64, Error> { + fn at_deadline() -> Result<u64, SigningError> { 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 crossing_deadline() -> Result<u64, SigningError> { + if CROSSING_DEADLINE_CALLS.fetch_add(1, Ordering::SeqCst) == 0 { + before_deadline() + } else { + at_deadline() + } + } + + fn fixture_secret(secret_key_hex: &str) -> SecretKey { + parse_secret_key(secret_key_hex).expect("secret key fixture") } fn request() -> SignRequest { @@ -190,12 +212,23 @@ mod tests { #[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 signer = LocalSigner::with_clock( + fixture_secret(FIXTURE_ALICE.secret_key_hex), + before_deadline, + ) + .expect("local signer"); 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 capability = status.capabilities()[0]; + assert_eq!(capability.kind(), SignerKind::Local); + assert_eq!( + capability.cancellation(), + CancellationSupport::BeforePublication + ); + assert!(capability.reports_progress()); + assert!(!capability.may_require_authentication()); + assert_eq!(signer.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); let observer = Arc::new(RecordingObserver(Mutex::new(Vec::new()))); let request = request().with_progress_observer(observer.clone()); @@ -222,7 +255,8 @@ mod tests { #[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); + LocalSigner::with_clock(fixture_secret(FIXTURE_BOB.secret_key_hex), before_deadline) + .expect("local signer"); let error = signer .sign(request()) .await @@ -232,17 +266,55 @@ mod tests { 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)); + assert!(!format!("{signer:?}").contains(FIXTURE_BOB.nsec)); + } + + #[test] + fn generated_local_signer_exposes_only_its_public_identity() { + let signer = LocalSigner::generate().expect("generated local signer"); + let rendered = format!("{signer:?}"); + + assert_eq!(signer.public_key().to_hex().len(), 64); + assert!(rendered.contains("[redacted]")); + assert!(rendered.contains(&signer.public_key().to_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); + LocalSigner::with_clock(fixture_secret(FIXTURE_ALICE.secret_key_hex), at_deadline) + .expect("local signer"); + let observer = Arc::new(RecordingObserver(Mutex::new(Vec::new()))); let error = signer - .sign(request()) + .sign(request().with_progress_observer(observer.clone())) .await .expect_err("deadline must fail"); assert_eq!(error.kind(), Kind::DeadlineExceeded); + assert!(observer.0.lock().expect("progress lock").is_empty()); + } + + #[tokio::test] + async fn deadline_crossing_discards_output_before_receipt_completion() { + CROSSING_DEADLINE_CALLS.store(0, Ordering::SeqCst); + let signer = LocalSigner::with_clock( + fixture_secret(FIXTURE_ALICE.secret_key_hex), + crossing_deadline, + ) + .expect("local signer"); + let observer = Arc::new(RecordingObserver(Mutex::new(Vec::new()))); + let error = signer + .sign(request().with_progress_observer(observer.clone())) + .await + .expect_err("crossed deadline must fail"); + + assert_eq!(error.kind(), Kind::DeadlineExceeded); + assert_eq!( + observer.0.lock().expect("progress lock").as_slice(), + &[ + SignProgressStage::Validating, + SignProgressStage::VerifyingOutput, + ] + ); } } diff --git a/crates/nostr/tests/package_boundary.rs b/crates/nostr/tests/package_boundary.rs @@ -16,6 +16,7 @@ const ROOT: &str = include_str!("../src/lib.rs"); const EVENT_MODULE: &str = include_str!("../src/event.rs"); const FILTER_MODULE: &str = include_str!("../src/filter.rs"); const KEY_MODULE: &str = include_str!("../src/key.rs"); +const SIGNING_MODULE: &str = include_str!("../src/signing.rs"); const TAG_MODULE: &str = include_str!("../src/tag.rs"); const TYPES_MODULE: &str = include_str!("../src/types.rs"); const IDENTITY_MANIFEST: &str = include_str!("../../identity/Cargo.toml"); @@ -284,6 +285,32 @@ fn nostr_key_conversion_is_explicit_and_identity_remains_public_only() { } } +#[test] +fn local_signer_consumes_only_the_opaque_secret_boundary() { + for required in [ + "pub fn new(secret_key: SecretKey)", + "pub fn generate()", + "pub const fn public_key(&self) -> PublicKey", + "key::SecretKey", + ] { + assert!( + SIGNING_MODULE.contains(required), + "local signer boundary is missing `{required}`" + ); + } + for forbidden in [ + "pub const fn new(keys: RadrootsNostrKeys)", + "pub fn new(keys: RadrootsNostrKeys)", + "pub fn keys(", + "pub fn secret_key(", + ] { + assert!( + !SIGNING_MODULE.contains(forbidden), + "local signer leaks an upstream representation: `{forbidden}`" + ); + } +} + fn rust_sources(root: &Path) -> Vec<PathBuf> { let mut pending = vec![root.to_path_buf()]; let mut sources = Vec::new();