signer.rs (15963B)
1 //! Opaque, secret-free host signing bridge. 2 3 use std::sync::Arc; 4 5 use radroots_event::{SignedEvent, wire::v1::Nip01EventWire}; 6 use radroots_signing::{ 7 AuthoredSignEvidence, Error, SignReceipt, SignRequest, Signer, SignerStatus, 8 capability::{CancellationSupport, SignerCapability, SignerKind}, 9 error::Kind, 10 recovery::ReplayCapability, 11 signer::BoxFuture, 12 status::SignerAvailability, 13 }; 14 15 use crate::MOBILE_FFI_SCHEMA_VERSION; 16 17 const SIGNED_EVENT_MAX_BYTES: usize = 1_048_576; 18 19 #[cfg(test)] 20 mod evidence_tests; 21 22 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 23 pub enum SignerAvailabilityRecord { 24 Ready, 25 Busy, 26 Locked, 27 Unavailable, 28 } 29 30 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 31 pub struct SignerStatusRecord { 32 pub schema_version: u16, 33 pub availability: SignerAvailabilityRecord, 34 } 35 36 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 37 pub enum HostSigningPurpose { 38 NostrEvent, 39 BlossomUpload, 40 } 41 42 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 43 pub struct HostSigningRequest { 44 pub schema_version: u16, 45 pub operation_kind: String, 46 pub operation_id: String, 47 pub artifact_id: String, 48 pub signer_request_id: String, 49 pub public_key: String, 50 pub purpose: HostSigningPurpose, 51 pub deadline_unix_ms: u64, 52 pub event_id_digest: Vec<u8>, 53 pub expected_event_id: String, 54 pub created_at_unix_s: u64, 55 pub kind: u32, 56 pub tags: Vec<Vec<String>>, 57 pub content: String, 58 } 59 60 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 61 pub enum HostSigningOutcome { 62 Signed, 63 Locked, 64 Cancelled, 65 Rejected, 66 TimedOut, 67 Unavailable, 68 Invalidated, 69 Failed, 70 } 71 72 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 73 pub struct HostSigningResult { 74 pub schema_version: u16, 75 pub outcome: HostSigningOutcome, 76 pub operation_id: String, 77 pub signer_request_id: String, 78 pub public_key: String, 79 pub purpose: HostSigningPurpose, 80 pub signature_hex: Option<String>, 81 /// Host completion time, or zero when unavailable. Authored signed evidence 82 /// may be verified with a separate local observation; strict receipts require time. 83 pub completed_at_unix_ms: u64, 84 } 85 86 #[uniffi::export(callback_interface)] 87 #[async_trait::async_trait] 88 pub trait TeraHostSigner: Send + Sync { 89 async fn signer_status(&self) -> SignerStatusRecord; 90 async fn sign(&self, request: HostSigningRequest) -> HostSigningResult; 91 } 92 93 pub(crate) struct HostSignerAdapter { 94 host: Arc<dyn TeraHostSigner>, 95 } 96 97 impl HostSignerAdapter { 98 pub(crate) fn new(host: Box<dyn TeraHostSigner>) -> Self { 99 Self { 100 host: Arc::from(host), 101 } 102 } 103 } 104 105 impl Signer for HostSignerAdapter { 106 fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> { 107 Box::pin(async move { 108 let status = self.host.signer_status().await; 109 if status.schema_version != MOBILE_FFI_SCHEMA_VERSION { 110 return Err(Error::new(Kind::SignerOutputInvalid)); 111 } 112 let availability = match status.availability { 113 SignerAvailabilityRecord::Ready => SignerAvailability::Ready, 114 SignerAvailabilityRecord::Busy => SignerAvailability::Busy, 115 SignerAvailabilityRecord::Locked => SignerAvailability::AwaitingAuthentication, 116 SignerAvailabilityRecord::Unavailable => SignerAvailability::Unavailable, 117 }; 118 Ok(SignerStatus::new( 119 availability, 120 vec![SignerCapability::new( 121 SignerKind::HostMediated, 122 ReplayCapability::ExactReplayByRequestId, 123 CancellationSupport::BeforeAndAfterPublication, 124 false, 125 true, 126 )], 127 None, 128 )) 129 }) 130 } 131 132 fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { 133 Box::pin(async move { 134 request.ensure_active(now_unix_ms())?; 135 let ffi_request = HostSigningRequest::from_request(&request)?; 136 let result = self.host.sign(ffi_request.clone()).await; 137 request.ensure_active(now_unix_ms())?; 138 request.ensure_active(result.completed_at_unix_ms)?; 139 validate_result_binding(&ffi_request, &result)?; 140 match result.outcome { 141 HostSigningOutcome::Signed => signed_receipt(&request, result), 142 HostSigningOutcome::Locked | HostSigningOutcome::Unavailable => { 143 Err(Error::new(Kind::SignerUnavailable)) 144 } 145 HostSigningOutcome::Cancelled => Err(Error::new(Kind::SignerCancelled)), 146 HostSigningOutcome::Rejected => Err(Error::new(Kind::SignerRejected)), 147 HostSigningOutcome::TimedOut => Err(Error::new(Kind::SignerTimeout)), 148 HostSigningOutcome::Invalidated => Err(Error::new(Kind::SignerOutputInvalid)), 149 HostSigningOutcome::Failed => Err(Error::new(Kind::InternalError)), 150 } 151 }) 152 } 153 154 fn sign_authored_evidence( 155 &self, 156 request: SignRequest, 157 ) -> BoxFuture<'_, Result<AuthoredSignEvidence, Error>> { 158 Box::pin(async move { 159 if request.authored_plan().is_none() { 160 return Err(Error::new(Kind::InvalidArgument)); 161 } 162 request.ensure_active(now_unix_ms())?; 163 let ffi_request = HostSigningRequest::from_request(&request)?; 164 let result = self.host.sign(ffi_request.clone()).await; 165 validate_result_identity(&ffi_request, &result)?; 166 match result.outcome { 167 HostSigningOutcome::Signed => AuthoredSignEvidence::from_signed_event( 168 &request, 169 signed_event(&request, result)?, 170 now_unix_ms(), 171 ), 172 HostSigningOutcome::Locked | HostSigningOutcome::Unavailable => { 173 Err(Error::new(Kind::SignerUnavailable)) 174 } 175 HostSigningOutcome::Cancelled => Err(Error::new(Kind::SignerCancelled)), 176 HostSigningOutcome::Rejected => Err(Error::new(Kind::SignerRejected)), 177 HostSigningOutcome::TimedOut => Err(Error::new(Kind::SignerTimeout)), 178 HostSigningOutcome::Invalidated => Err(Error::new(Kind::SignerOutputInvalid)), 179 HostSigningOutcome::Failed => Err(Error::new(Kind::InternalError)), 180 } 181 }) 182 } 183 } 184 185 impl HostSigningRequest { 186 fn from_request(request: &SignRequest) -> Result<Self, Error> { 187 let purpose = match request.purpose() { 188 radroots_signing::SigningPurpose::AuthoredEvent => HostSigningPurpose::NostrEvent, 189 radroots_signing::SigningPurpose::BlossomUploadAuthorization => { 190 HostSigningPurpose::BlossomUpload 191 } 192 _ => return Err(Error::new(Kind::SignerOutputInvalid)), 193 }; 194 Ok(Self { 195 schema_version: MOBILE_FFI_SCHEMA_VERSION, 196 operation_kind: request.operation_kind().as_str().to_owned(), 197 operation_id: uuid_string(request.intent_id().operation_id().as_bytes()), 198 artifact_id: hex::encode(request.intent_id().artifact_id().as_bytes()), 199 signer_request_id: request.signer_request_id().to_hex(), 200 public_key: request.expected_author().to_hex(), 201 purpose, 202 deadline_unix_ms: request.policy().deadline_unix_ms(), 203 event_id_digest: request.expected_event_id().as_bytes().to_vec(), 204 expected_event_id: request.expected_event_id().to_hex(), 205 created_at_unix_s: request.created_at(), 206 kind: request.kind(), 207 tags: request.tags().to_vec(), 208 content: request.content().to_owned(), 209 }) 210 } 211 } 212 213 fn validate_result_binding( 214 request: &HostSigningRequest, 215 result: &HostSigningResult, 216 ) -> Result<(), Error> { 217 validate_result_identity(request, result)?; 218 if result.completed_at_unix_ms == 0 { 219 return Err(Error::new(Kind::SignerOutputInvalid)); 220 } 221 Ok(()) 222 } 223 224 fn validate_result_identity( 225 request: &HostSigningRequest, 226 result: &HostSigningResult, 227 ) -> Result<(), Error> { 228 if result.schema_version != MOBILE_FFI_SCHEMA_VERSION 229 || result.operation_id != request.operation_id 230 || result.signer_request_id != request.signer_request_id 231 || result.public_key != request.public_key 232 || result.purpose != request.purpose 233 || (result.outcome == HostSigningOutcome::Signed) != result.signature_hex.is_some() 234 { 235 return Err(Error::new(Kind::SignerOutputInvalid)); 236 } 237 Ok(()) 238 } 239 240 fn signed_receipt(request: &SignRequest, result: HostSigningResult) -> Result<SignReceipt, Error> { 241 let completed_at_unix_ms = result.completed_at_unix_ms; 242 SignReceipt::from_signed_event( 243 request, 244 signed_event(request, result)?, 245 completed_at_unix_ms, 246 ) 247 } 248 249 fn signed_event(request: &SignRequest, result: HostSigningResult) -> Result<SignedEvent, Error> { 250 let signature = result 251 .signature_hex 252 .filter(|value| { 253 value.len() == 128 254 && value 255 .bytes() 256 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 257 }) 258 .ok_or_else(|| Error::new(Kind::SignerOutputInvalid))?; 259 let wire = Nip01EventWire { 260 id: request.expected_event_id().to_hex(), 261 pubkey: request.expected_author().to_hex(), 262 created_at: request.created_at(), 263 kind: request.kind(), 264 tags: request.tags().to_vec(), 265 content: request.content().to_owned(), 266 sig: signature, 267 extra: Default::default(), 268 }; 269 let raw_json = serde_json::to_string(&wire).map_err(|_| Error::new(Kind::InternalError))?; 270 if raw_json.len() > SIGNED_EVENT_MAX_BYTES { 271 return Err(Error::new(Kind::SignerOutputInvalid)); 272 } 273 SignedEvent::from_wire_verified_id(wire, raw_json) 274 .map_err(|_| Error::new(Kind::SignerOutputInvalid)) 275 } 276 277 fn uuid_string(bytes: &[u8; 16]) -> String { 278 let hex = hex::encode(bytes); 279 format!( 280 "{}-{}-{}-{}-{}", 281 &hex[0..8], 282 &hex[8..12], 283 &hex[12..16], 284 &hex[16..20], 285 &hex[20..32] 286 ) 287 } 288 289 fn now_unix_ms() -> u64 { 290 std::time::SystemTime::now() 291 .duration_since(std::time::UNIX_EPOCH) 292 .map_or(0, |duration| { 293 duration.as_millis().try_into().unwrap_or(u64::MAX) 294 }) 295 } 296 297 #[cfg(test)] 298 mod tests { 299 use super::*; 300 301 struct StatusHost { 302 schema_version: u16, 303 availability: SignerAvailabilityRecord, 304 } 305 306 #[async_trait::async_trait] 307 impl TeraHostSigner for StatusHost { 308 async fn signer_status(&self) -> SignerStatusRecord { 309 SignerStatusRecord { 310 schema_version: self.schema_version, 311 availability: self.availability, 312 } 313 } 314 315 async fn sign(&self, request: HostSigningRequest) -> HostSigningResult { 316 HostSigningResult { 317 schema_version: MOBILE_FFI_SCHEMA_VERSION, 318 outcome: HostSigningOutcome::Failed, 319 operation_id: request.operation_id, 320 signer_request_id: request.signer_request_id, 321 public_key: request.public_key, 322 purpose: request.purpose, 323 signature_hex: None, 324 completed_at_unix_ms: 1, 325 } 326 } 327 } 328 329 #[test] 330 fn opaque_operation_identity_has_canonical_uuid_shape() { 331 assert_eq!( 332 uuid_string(&[0xabu8; 16]), 333 "abababab-abab-abab-abab-abababababab" 334 ); 335 } 336 337 #[test] 338 fn result_binding_rejects_signature_on_failure() { 339 let request = HostSigningRequest { 340 schema_version: 1, 341 operation_kind: "sync.push".to_owned(), 342 operation_id: "11111111-1111-1111-1111-111111111111".to_owned(), 343 artifact_id: "22".repeat(16), 344 signer_request_id: "33".repeat(32), 345 public_key: "44".repeat(32), 346 purpose: HostSigningPurpose::NostrEvent, 347 deadline_unix_ms: 10, 348 event_id_digest: vec![5; 32], 349 expected_event_id: "55".repeat(32), 350 created_at_unix_s: 1, 351 kind: 1, 352 tags: Vec::new(), 353 content: "test".to_owned(), 354 }; 355 let result = HostSigningResult { 356 schema_version: 1, 357 outcome: HostSigningOutcome::Failed, 358 operation_id: request.operation_id.clone(), 359 signer_request_id: request.signer_request_id.clone(), 360 public_key: request.public_key.clone(), 361 purpose: request.purpose, 362 signature_hex: Some("66".repeat(64)), 363 completed_at_unix_ms: 2, 364 }; 365 assert_eq!( 366 validate_result_binding(&request, &result) 367 .expect_err("failure cannot carry signature") 368 .kind(), 369 Kind::SignerOutputInvalid 370 ); 371 372 let valid = HostSigningResult { 373 signature_hex: None, 374 ..result.clone() 375 }; 376 assert!(validate_result_binding(&request, &valid).is_ok()); 377 let invalid_results = [ 378 HostSigningResult { 379 schema_version: 2, 380 ..valid.clone() 381 }, 382 HostSigningResult { 383 operation_id: "different".to_owned(), 384 ..valid.clone() 385 }, 386 HostSigningResult { 387 signer_request_id: "different".to_owned(), 388 ..valid.clone() 389 }, 390 HostSigningResult { 391 public_key: "different".to_owned(), 392 ..valid.clone() 393 }, 394 HostSigningResult { 395 purpose: HostSigningPurpose::BlossomUpload, 396 ..valid.clone() 397 }, 398 HostSigningResult { 399 completed_at_unix_ms: 0, 400 ..valid.clone() 401 }, 402 HostSigningResult { 403 outcome: HostSigningOutcome::Signed, 404 signature_hex: None, 405 ..valid 406 }, 407 ]; 408 for invalid in invalid_results { 409 assert_eq!( 410 validate_result_binding(&request, &invalid) 411 .expect_err("binding mismatch") 412 .kind(), 413 Kind::SignerOutputInvalid 414 ); 415 } 416 } 417 418 #[tokio::test] 419 async fn host_status_maps_every_availability_and_rejects_schema_drift() { 420 for (ffi, expected) in [ 421 (SignerAvailabilityRecord::Ready, SignerAvailability::Ready), 422 (SignerAvailabilityRecord::Busy, SignerAvailability::Busy), 423 ( 424 SignerAvailabilityRecord::Locked, 425 SignerAvailability::AwaitingAuthentication, 426 ), 427 ( 428 SignerAvailabilityRecord::Unavailable, 429 SignerAvailability::Unavailable, 430 ), 431 ] { 432 let adapter = HostSignerAdapter::new(Box::new(StatusHost { 433 schema_version: MOBILE_FFI_SCHEMA_VERSION, 434 availability: ffi, 435 })); 436 assert_eq!( 437 Signer::status(&adapter) 438 .await 439 .expect("mapped host status") 440 .availability(), 441 expected 442 ); 443 } 444 445 let adapter = HostSignerAdapter::new(Box::new(StatusHost { 446 schema_version: MOBILE_FFI_SCHEMA_VERSION + 1, 447 availability: SignerAvailabilityRecord::Ready, 448 })); 449 assert_eq!( 450 Signer::status(&adapter) 451 .await 452 .expect_err("schema drift") 453 .kind(), 454 Kind::SignerOutputInvalid 455 ); 456 } 457 }