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:
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();