lib

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

signing.rs (17963B)


      1 //! Generic signer composition without protocol or relay ownership.
      2 
      3 use std::{
      4     sync::Arc,
      5     time::{SystemTime, UNIX_EPOCH},
      6 };
      7 
      8 #[cfg(feature = "blossom")]
      9 use radroots_signing::SigningPurpose;
     10 use radroots_signing::{
     11     Actor, SignReceipt, SignRequest, Signer, SignerStatus, SigningIntentId, request::SignPolicy,
     12 };
     13 
     14 pub use radroots_event_codec::authoring::BlossomAuthorizationPlan;
     15 #[cfg(feature = "blossom")]
     16 pub use radroots_nostr::blossom::AuthorizationHeader;
     17 
     18 /// Host-visible signer composition mode.
     19 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     20 #[non_exhaustive]
     21 pub enum Mode {
     22     /// A concrete local Nostr adapter owns opaque key material.
     23     Local,
     24     /// A host-provided signer drives NIP-46 protocol and relay execution.
     25     Nip46,
     26     /// Another host-provided implementation of the generic signer SPI.
     27     Host,
     28 }
     29 
     30 /// Cloneable SDK composition wrapper around the canonical signer SPI.
     31 #[derive(Clone)]
     32 pub struct Provider {
     33     mode: Mode,
     34     signer: Arc<dyn Signer>,
     35 }
     36 
     37 /// Borrowed high-level signing operations over one configured opaque signer.
     38 #[derive(Clone, Copy)]
     39 pub struct Operations<'a> {
     40     signer: &'a dyn Signer,
     41 }
     42 
     43 impl<'a> Operations<'a> {
     44     pub(crate) const fn new(signer: &'a dyn Signer) -> Self {
     45         Self { signer }
     46     }
     47 
     48     /// Delegates an already authorized exact request to the opaque signer.
     49     pub async fn sign(&self, request: SignRequest) -> Result<SignReceipt, radroots_signing::Error> {
     50         sign_checked(self.signer, request).await
     51     }
     52 
     53     /// Signs one HTTP-only BUD-11 upload plan and returns its canonical header.
     54     #[cfg(feature = "blossom")]
     55     pub async fn authorize_blossom_upload(
     56         &self,
     57         request: SignRequest,
     58     ) -> Result<radroots_nostr::blossom::AuthorizationHeader, BlossomSigningError> {
     59         if request.purpose() != SigningPurpose::BlossomUploadAuthorization {
     60             return Err(BlossomSigningError::WrongPurpose);
     61         }
     62         let receipt = self
     63             .sign(request)
     64             .await
     65             .map_err(BlossomSigningError::Signing)?;
     66         radroots_nostr::blossom::encode_signed_event_authorization_header(receipt.signed_event())
     67             .map_err(BlossomSigningError::Encoding)
     68     }
     69 }
     70 
     71 impl std::fmt::Debug for Operations<'_> {
     72     fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
     73         formatter
     74             .debug_struct("Operations")
     75             .field("signer", &"<borrowed opaque signer>")
     76             .finish()
     77     }
     78 }
     79 
     80 /// Creates one domain-separated BUD-11 request from an exact HTTP-only plan.
     81 pub fn blossom_upload_request(
     82     operation_kind: radroots_protocol::runtime::v1::OperationId,
     83     intent_id: SigningIntentId,
     84     actor: Actor,
     85     plan: BlossomAuthorizationPlan,
     86     policy: SignPolicy,
     87 ) -> Result<SignRequest, radroots_signing::Error> {
     88     SignRequest::blossom_upload(operation_kind, intent_id, actor, plan, policy)
     89 }
     90 
     91 /// Failure while producing a BUD-11 HTTP authorization header.
     92 #[cfg(feature = "blossom")]
     93 #[derive(Debug)]
     94 #[non_exhaustive]
     95 pub enum BlossomSigningError {
     96     /// The caller supplied a relay-authoring request to the HTTP-only method.
     97     WrongPurpose,
     98     /// The opaque signer rejected or failed the exact request.
     99     Signing(radroots_signing::Error),
    100     /// The verified signer result could not be encoded as BUD-11.
    101     Encoding(radroots_nostr::blossom::AuthorizationError),
    102 }
    103 
    104 #[cfg(feature = "blossom")]
    105 impl std::fmt::Display for BlossomSigningError {
    106     fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    107         match self {
    108             Self::WrongPurpose => {
    109                 formatter.write_str("signing request is not BUD-11 HTTP authorization")
    110             }
    111             Self::Signing(error) => error.fmt(formatter),
    112             Self::Encoding(error) => error.fmt(formatter),
    113         }
    114     }
    115 }
    116 
    117 #[cfg(feature = "blossom")]
    118 impl std::error::Error for BlossomSigningError {
    119     fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
    120         match self {
    121             Self::WrongPurpose => None,
    122             Self::Signing(error) => Some(error),
    123             Self::Encoding(error) => Some(error),
    124         }
    125     }
    126 }
    127 
    128 impl Provider {
    129     /// Wraps any host-provided canonical signer implementation.
    130     #[must_use]
    131     pub fn host(signer: Arc<dyn Signer>) -> Self {
    132         Self {
    133             mode: Mode::Host,
    134             signer,
    135         }
    136     }
    137 
    138     /// Wraps the concrete local Nostr adapter without exposing its key.
    139     #[cfg(feature = "local-signing")]
    140     #[must_use]
    141     pub fn local(signer: radroots_nostr::signing::LocalSigner) -> Self {
    142         Self {
    143             mode: Mode::Local,
    144             signer: Arc::new(signer),
    145         }
    146     }
    147 
    148     /// Marks a host-provided canonical signer as a NIP-46 composition.
    149     ///
    150     /// `radroots_nostr_connect` owns protocol state and its transport SPI. The
    151     /// injected implementation owns that client plus explicit relay execution;
    152     /// this wrapper does not contact a relay, persist a session, or start work.
    153     #[cfg(feature = "nip46")]
    154     #[must_use]
    155     pub fn nip46(signer: Arc<dyn Signer>) -> Self {
    156         Self {
    157             mode: Mode::Nip46,
    158             signer,
    159         }
    160     }
    161 
    162     /// Returns the explicit composition mode.
    163     #[must_use]
    164     pub const fn mode(&self) -> Mode {
    165         self.mode
    166     }
    167 
    168     /// Borrows the canonical signer SPI.
    169     #[must_use]
    170     pub fn as_signer(&self) -> &dyn Signer {
    171         self.signer.as_ref()
    172     }
    173 
    174     /// Reports canonical status without initiating a signing request.
    175     pub async fn status(&self) -> Result<SignerStatus, radroots_signing::Error> {
    176         self.signer.status().await
    177     }
    178 
    179     /// Delegates one already-authorized request to the canonical SPI.
    180     pub async fn sign(&self, request: SignRequest) -> Result<SignReceipt, radroots_signing::Error> {
    181         sign_checked(self.signer.as_ref(), request).await
    182     }
    183 
    184     pub(crate) fn into_signer(self) -> Arc<dyn Signer> {
    185         self.signer
    186     }
    187 }
    188 
    189 async fn sign_checked(
    190     signer: &dyn Signer,
    191     request: SignRequest,
    192 ) -> Result<SignReceipt, radroots_signing::Error> {
    193     let expected = request.clone();
    194     let returned = signer.sign(request).await?;
    195     SignReceipt::from_signed_event(
    196         &expected,
    197         returned.signed_event().clone(),
    198         system_time_unix_ms()?,
    199     )
    200 }
    201 
    202 fn system_time_unix_ms() -> Result<u64, radroots_signing::Error> {
    203     SystemTime::now()
    204         .duration_since(UNIX_EPOCH)
    205         .map_err(|_| radroots_signing::Error::new(radroots_signing::error::Kind::InternalError))
    206         .and_then(|duration| {
    207             u64::try_from(duration.as_millis()).map_err(|_| {
    208                 radroots_signing::Error::new(radroots_signing::error::Kind::InternalError)
    209             })
    210         })
    211 }
    212 
    213 impl std::fmt::Debug for Provider {
    214     fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    215         formatter
    216             .debug_struct("Provider")
    217             .field("mode", &self.mode)
    218             .field("signer", &"<opaque>")
    219             .finish()
    220     }
    221 }
    222 
    223 #[cfg(test)]
    224 mod tests {
    225     use std::sync::{
    226         Arc,
    227         atomic::{AtomicUsize, Ordering},
    228     };
    229 
    230     use radroots_event::{GenericEventDraft, contract::AuthorRole};
    231     use radroots_event_codec::authoring::AuthoredEventPlan;
    232     use radroots_identity::PublicKey;
    233     use radroots_protocol::runtime::v1::OperationId;
    234     use radroots_signing::{
    235         Actor, AuthoredArtifactId, Error, SignReceipt, SignRequest, SignerStatus, SigningIntentId,
    236         SigningOperationId,
    237         actor::ActorSource,
    238         error::Kind,
    239         request::{CancellationPolicy, SignPolicy},
    240         signer::BoxFuture,
    241     };
    242     #[cfg(any(feature = "local-signing", feature = "nip46"))]
    243     use radroots_signing::{capability::SignerKind, status::SignerAvailability};
    244     #[cfg(feature = "nip46")]
    245     use radroots_signing::{
    246         capability::{CancellationSupport, SignerCapability},
    247         recovery::ReplayCapability,
    248         status::{AuthChallenge, SignProgress},
    249     };
    250 
    251     use super::*;
    252 
    253     const PUBLIC_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    254     #[cfg(feature = "local-signing")]
    255     const SECRET_KEY: &str = "7e0112ad58b2d2d13fb80532625195dc169b86d72b0e1db48347837a785cae90";
    256 
    257     struct ScriptedSigner {
    258         status: SignerStatus,
    259         result: Kind,
    260         polls: Arc<AtomicUsize>,
    261     }
    262 
    263     impl Signer for ScriptedSigner {
    264         fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> {
    265             let status = self.status.clone();
    266             Box::pin(async move { Ok(status) })
    267         }
    268 
    269         fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> {
    270             let result = self.result;
    271             let polls = Arc::clone(&self.polls);
    272             Box::pin(async move {
    273                 polls.fetch_add(1, Ordering::Relaxed);
    274                 Err(Error::new(result))
    275             })
    276         }
    277     }
    278 
    279     fn request_for(public_key: PublicKey, artifact: u8, deadline_unix_ms: u64) -> SignRequest {
    280         let actor = Actor::new(
    281             public_key,
    282             ActorSource::ExplicitPublicKey,
    283             [AuthorRole::Any],
    284         )
    285         .expect("actor");
    286         let plan = AuthoredEventPlan::from_generic(
    287             GenericEventDraft::new(
    288                 "radroots.social.geochat.v1",
    289                 20_000,
    290                 1_700_000_000,
    291                 Vec::new(),
    292                 "frozen-content",
    293                 public_key.to_hex(),
    294             )
    295             .expect("draft"),
    296         )
    297         .expect("authored plan");
    298         SignRequest::new(
    299             OperationId::SyncPush,
    300             SigningIntentId::new(
    301                 SigningOperationId::new([1; 16]).expect("operation id"),
    302                 AuthoredArtifactId::new([artifact; 16]).expect("artifact id"),
    303             ),
    304             actor,
    305             plan,
    306             SignPolicy::new(
    307                 deadline_unix_ms,
    308                 CancellationPolicy::PreservePublishedRequest,
    309             )
    310             .expect("policy"),
    311         )
    312         .expect("request")
    313     }
    314 
    315     fn request() -> SignRequest {
    316         request_for(
    317             PublicKey::from_hex(PUBLIC_KEY).expect("public key"),
    318             2,
    319             1_700_000_100,
    320         )
    321     }
    322 
    323     #[cfg(feature = "local-signing")]
    324     fn local_signer() -> radroots_nostr::signing::LocalSigner {
    325         radroots_nostr::signing::LocalSigner::new(
    326             radroots_nostr::key::SecretKey::parse(SECRET_KEY).expect("secret fixture"),
    327         )
    328         .expect("local signer")
    329     }
    330 
    331     #[cfg(all(feature = "local-signing", feature = "blossom"))]
    332     fn blossom_request_for(public_key: PublicKey) -> SignRequest {
    333         use radroots_blossom::{
    334             Sha256,
    335             authorization::{AuthoredUploadClaim, AuthorizationContent, ServerDomain},
    336         };
    337 
    338         let claim = AuthoredUploadClaim::new(
    339             AuthorizationContent::parse("Upload exact SDK image").expect("content"),
    340             ServerDomain::parse("media.example").expect("server"),
    341             Sha256::digest(b"exact-sdk-image"),
    342             1_700_000_000,
    343             60,
    344         )
    345         .expect("claim");
    346         let actor = Actor::new(
    347             public_key,
    348             ActorSource::ExplicitPublicKey,
    349             [AuthorRole::Any],
    350         )
    351         .expect("actor");
    352         blossom_upload_request(
    353             OperationId::SyncPush,
    354             SigningIntentId::new(
    355                 SigningOperationId::new([9; 16]).expect("operation id"),
    356                 AuthoredArtifactId::new([10; 16]).expect("artifact id"),
    357             ),
    358             actor,
    359             BlossomAuthorizationPlan::for_upload(&claim, public_key).expect("plan"),
    360             SignPolicy::new(u64::MAX, CancellationPolicy::LocalCooperative).expect("policy"),
    361         )
    362         .expect("Blossom request")
    363     }
    364 
    365     #[cfg(feature = "nip46")]
    366     fn remote_status(progress: Option<SignProgress>) -> SignerStatus {
    367         SignerStatus::new(
    368             SignerAvailability::AwaitingAuthentication,
    369             vec![SignerCapability::new(
    370                 SignerKind::Remote,
    371                 ReplayCapability::ExactReplayByRequestId,
    372                 CancellationSupport::BeforeAndAfterPublication,
    373                 true,
    374                 true,
    375             )],
    376             progress,
    377         )
    378     }
    379 
    380     #[cfg(feature = "local-signing")]
    381     #[tokio::test]
    382     async fn local_provider_uses_the_concrete_lower_adapter() {
    383         let local = radroots_nostr::signing::LocalSigner::generate().expect("local signer");
    384         let provider = Provider::local(local);
    385         let status = provider.status().await.expect("status");
    386         assert_eq!(provider.mode(), Mode::Local);
    387         assert_eq!(status.availability(), SignerAvailability::Ready);
    388         assert_eq!(status.capabilities()[0].kind(), SignerKind::Local);
    389     }
    390 
    391     #[cfg(all(feature = "local-signing", feature = "blossom"))]
    392     #[tokio::test]
    393     async fn focused_operations_sign_exact_events_and_bud11_without_secret_surface() {
    394         use radroots_blossom::{
    395             Sha256,
    396             authorization::{AuthorizationTarget, AuthorizationValidation, ServerDomain},
    397         };
    398 
    399         let signer = local_signer();
    400         let public_key = signer.public_key();
    401         let provider = Provider::local(signer);
    402         let operations = Operations::new(provider.as_signer());
    403         assert!(format!("{provider:?}").contains("signer: \"<opaque>\""));
    404         assert!(format!("{operations:?}").contains("borrowed opaque signer"));
    405 
    406         let receipt = operations
    407             .sign(request_for(public_key, 3, u64::MAX))
    408             .await
    409             .expect("focused sign");
    410         assert_eq!(receipt.signed_event().pubkey(), &public_key);
    411         let provider_receipt = provider
    412             .sign(request_for(public_key, 4, u64::MAX))
    413             .await
    414             .expect("provider sign");
    415         assert_eq!(provider_receipt.signed_event().pubkey(), &public_key);
    416 
    417         let wrong_purpose = operations
    418             .authorize_blossom_upload(request_for(public_key, 5, u64::MAX))
    419             .await
    420             .expect_err("relay event cannot become an HTTP credential");
    421         assert!(matches!(wrong_purpose, BlossomSigningError::WrongPurpose));
    422 
    423         let header = operations
    424             .authorize_blossom_upload(blossom_request_for(public_key))
    425             .await
    426             .expect("BUD-11 authorization");
    427         let hash = Sha256::digest(b"exact-sdk-image");
    428         let verified = radroots_nostr::blossom::decode_verify_authorization_header(
    429             header.as_str(),
    430             &AuthorizationValidation::bud11(
    431                 AuthorizationTarget::Upload(hash),
    432                 ServerDomain::parse("media.example").expect("server"),
    433                 1_700_000_001,
    434             ),
    435         )
    436         .expect("verify BUD-11 header");
    437         assert_eq!(verified.author().to_hex(), public_key.to_hex());
    438     }
    439 
    440     #[cfg(feature = "blossom")]
    441     #[test]
    442     fn blossom_errors_are_typed_and_preserve_only_explicit_sources() {
    443         use std::error::Error as _;
    444 
    445         let wrong = BlossomSigningError::WrongPurpose;
    446         assert!(wrong.source().is_none());
    447         assert!(!wrong.to_string().is_empty());
    448 
    449         let signing = BlossomSigningError::Signing(Error::new(Kind::SignerRejected));
    450         assert!(signing.source().is_some());
    451         assert_eq!(signing.to_string(), "signer rejected the request");
    452 
    453         let encoding = BlossomSigningError::Encoding(
    454             radroots_nostr::blossom::AuthorizationError::InvalidEventSignature,
    455         );
    456         assert!(encoding.source().is_some());
    457         assert_eq!(
    458             encoding.to_string(),
    459             "invalid Blossom authorization event signature"
    460         );
    461     }
    462 
    463     #[cfg(feature = "nip46")]
    464     #[tokio::test]
    465     async fn nip46_provider_preserves_auth_challenge_and_capabilities() {
    466         let challenge = AuthChallenge::new(
    467             "https://signer.example/approve",
    468             1_700_000_000,
    469             Some(1_700_000_100),
    470         )
    471         .expect("challenge");
    472         let signer = ScriptedSigner {
    473             status: remote_status(Some(SignProgress::authentication(challenge))),
    474             result: Kind::SignerRejected,
    475             polls: Arc::new(AtomicUsize::new(0)),
    476         };
    477         let provider = Provider::nip46(Arc::new(signer));
    478         let status = provider.status().await.expect("status");
    479         assert_eq!(provider.mode(), Mode::Nip46);
    480         assert_eq!(
    481             status.availability(),
    482             SignerAvailability::AwaitingAuthentication
    483         );
    484         assert!(status.progress().expect("progress").challenge().is_some());
    485         assert!(status.capabilities()[0].may_require_authentication());
    486     }
    487 
    488     #[tokio::test]
    489     async fn canonical_errors_preserve_timeout_drift_and_cancellation() {
    490         for expected in [
    491             Kind::SignerTimeout,
    492             Kind::SignerOutputInvalid,
    493             Kind::SignerCancelled,
    494         ] {
    495             let provider = Provider::host(Arc::new(ScriptedSigner {
    496                 status: SignerStatus::unavailable(),
    497                 result: expected,
    498                 polls: Arc::new(AtomicUsize::new(0)),
    499             }));
    500             assert_eq!(
    501                 provider.sign(request()).await.expect_err("failure").kind(),
    502                 expected
    503             );
    504             assert_eq!(
    505                 provider.as_signer().status().await.expect("status"),
    506                 SignerStatus::unavailable()
    507             );
    508         }
    509     }
    510 
    511     #[test]
    512     fn dropping_unpolled_signing_future_has_no_effect() {
    513         let polls = Arc::new(AtomicUsize::new(0));
    514         let provider = Provider::host(Arc::new(ScriptedSigner {
    515             status: SignerStatus::unavailable(),
    516             result: Kind::SignerCancelled,
    517             polls: Arc::clone(&polls),
    518         }));
    519         drop(provider.sign(request()));
    520         assert_eq!(polls.load(Ordering::Relaxed), 0);
    521     }
    522 }