field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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 }