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 }