Skip to main content

softchat/
external_signer.rs

1//! Capability-based boundary for host, hardware, extension, and remote signers.
2
3use crate::{
4    EventDraft, MAX_NIP44_WRITER_PLAINTEXT_BYTES, Nip44EncryptedMessage, Nip44Payload,
5    NostrEventDraft, NostrEventId, NostrEventSignature, NostrPublicKey, NostrRumor, NostrTag,
6    SignedEvent, SignedNostrEvent, SoftchatError,
7};
8
9/// Capabilities advertised by one external identity provider.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
12pub struct ExternalSignerCapabilities {
13    /// The provider can create BIP-340 signatures for explicit event IDs.
14    pub sign_events: bool,
15    /// The provider can perform complete NIP-44 encryption without exporting a key.
16    pub nip44_encrypt: bool,
17    /// The provider can perform complete NIP-44 decryption without exporting a key.
18    pub nip44_decrypt: bool,
19}
20
21/// Redacted failure returned by an external provider.
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum ExternalSignerFailure {
24    /// The user or host cancelled the operation.
25    Cancelled,
26    /// The provider refused the request.
27    Rejected,
28    /// The requested capability is unavailable.
29    CapabilityUnavailable,
30}
31
32impl From<ExternalSignerFailure> for SoftchatError {
33    fn from(failure: ExternalSignerFailure) -> Self {
34        match failure {
35            ExternalSignerFailure::Cancelled => Self::ExternalSignerCancelled,
36            ExternalSignerFailure::Rejected => Self::ExternalSignerRejected,
37            ExternalSignerFailure::CapabilityUnavailable => {
38                Self::ExternalSignerCapabilityUnavailable
39            }
40        }
41    }
42}
43
44/// Complete, immutable request that an external provider signs.
45///
46/// The provider signs the 32-byte event ID represented by `event_id`. The
47/// remaining fields allow the SDK to recompute that ID before and after the
48/// provider call.
49#[derive(Clone, Debug, Eq, PartialEq)]
50#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
51pub struct ExternalEventSigningRequest {
52    /// Canonical signing public key.
53    pub public_key: String,
54    /// Canonical event ID to sign.
55    pub event_id: String,
56    /// Exact explicit event draft.
57    pub draft: EventDraft,
58}
59
60/// A keyless external signing and NIP-44 provider.
61///
62/// Providers expose only high-level operations. There is deliberately no
63/// private-key export method and no local-identity erasure semantic. Provider
64/// lifecycle, user prompts, cancellation, and transport remain host-owned.
65pub trait ExternalSigner: Send + Sync {
66    /// Advertise the provider's independently available capabilities.
67    fn capabilities(&self) -> ExternalSignerCapabilities;
68
69    /// Return the canonical identity public key.
70    fn public_key(&self) -> Result<NostrPublicKey, ExternalSignerFailure>;
71
72    /// Sign the explicit event ID in `request` and return a Schnorr signature.
73    fn sign_event(
74        &self,
75        request: &ExternalEventSigningRequest,
76    ) -> Result<String, ExternalSignerFailure>;
77
78    /// Encrypt UTF-8 through the provider's NIP-44 capability.
79    fn nip44_encrypt(
80        &self,
81        _recipient: &NostrPublicKey,
82        _plaintext: &str,
83    ) -> Result<String, ExternalSignerFailure> {
84        Err(ExternalSignerFailure::CapabilityUnavailable)
85    }
86
87    /// Authenticate and decrypt UTF-8 through the provider's NIP-44 capability.
88    fn nip44_decrypt(
89        &self,
90        _sender: &NostrPublicKey,
91        _payload: &Nip44Payload,
92    ) -> Result<String, ExternalSignerFailure> {
93        Err(ExternalSignerFailure::CapabilityUnavailable)
94    }
95}
96
97/// Prepare one externally signed event without invoking a provider.
98///
99/// # Errors
100///
101/// Returns a stable public-key, draft, timestamp, tag, or size failure.
102#[cfg_attr(feature = "native-bindings", uniffi::export)]
103pub fn prepare_external_event_signing(
104    public_key: String,
105    draft: EventDraft,
106) -> Result<ExternalEventSigningRequest, SoftchatError> {
107    let public_key = NostrPublicKey::from_hex(&public_key)?;
108    let typed_draft = NostrEventDraft::try_from(draft.clone())?;
109    let event_id = NostrRumor::new(public_key.clone(), typed_draft)?.id();
110    Ok(ExternalEventSigningRequest {
111        public_key: public_key.to_hex(),
112        event_id: event_id.to_hex(),
113        draft,
114    })
115}
116
117/// Attach and verify a provider signature.
118///
119/// The request's ID is recomputed and the resulting BIP-340 signature is
120/// verified before a signed event is returned.
121///
122/// # Errors
123///
124/// Any malformed, mismatched, or unauthenticated provider signature maps to
125/// [`SoftchatError::InvalidExternalSignature`].
126#[cfg_attr(feature = "native-bindings", uniffi::export)]
127pub fn complete_external_event_signing(
128    request: ExternalEventSigningRequest,
129    signature: String,
130) -> Result<SignedEvent, SoftchatError> {
131    complete_typed_external_event_signing(request, signature).map(|event| SignedEvent::from(&event))
132}
133
134/// Invoke a native Rust provider and verify its result.
135///
136/// # Errors
137///
138/// Returns stable provider lifecycle errors or a verified-signature failure.
139pub fn sign_event_with_external(
140    signer: &dyn ExternalSigner,
141    draft: NostrEventDraft,
142) -> Result<SignedNostrEvent, SoftchatError> {
143    if !signer.capabilities().sign_events {
144        return Err(SoftchatError::ExternalSignerCapabilityUnavailable);
145    }
146    let public_key = signer.public_key().map_err(SoftchatError::from)?;
147    let request = prepare_external_event_signing(public_key.to_hex(), EventDraft::from(&draft))?;
148    let signature = signer.sign_event(&request).map_err(SoftchatError::from)?;
149    complete_typed_external_event_signing(request, signature)
150}
151
152/// Encrypt UTF-8 through an external provider and validate the result's bounds.
153///
154/// # Errors
155///
156/// Returns stable capability/provider errors or a redacted invalid payload.
157pub fn encrypt_with_external(
158    signer: &dyn ExternalSigner,
159    recipient: &NostrPublicKey,
160    plaintext: &str,
161) -> Result<Nip44EncryptedMessage, SoftchatError> {
162    if !signer.capabilities().nip44_encrypt {
163        return Err(SoftchatError::ExternalSignerCapabilityUnavailable);
164    }
165    if plaintext.is_empty() {
166        return Err(SoftchatError::EmptyPlaintext);
167    }
168    if plaintext.len() > MAX_NIP44_WRITER_PLAINTEXT_BYTES {
169        return Err(SoftchatError::PlaintextTooLarge);
170    }
171    let public_key = signer.public_key().map_err(SoftchatError::from)?;
172    let payload = signer
173        .nip44_encrypt(recipient, plaintext)
174        .map_err(SoftchatError::from)
175        .and_then(Nip44Payload::from_encoded)?;
176    Ok(Nip44EncryptedMessage::new(public_key, payload))
177}
178
179/// Decrypt UTF-8 through an external provider.
180///
181/// # Errors
182///
183/// Returns stable capability/provider errors or a redacted invalid plaintext.
184pub fn decrypt_with_external(
185    signer: &dyn ExternalSigner,
186    message: &Nip44EncryptedMessage,
187) -> Result<String, SoftchatError> {
188    if !signer.capabilities().nip44_decrypt {
189        return Err(SoftchatError::ExternalSignerCapabilityUnavailable);
190    }
191    let plaintext = signer
192        .nip44_decrypt(message.sender_public_key(), message.payload())
193        .map_err(SoftchatError::from)?;
194    if plaintext.is_empty() || plaintext.len() > MAX_NIP44_WRITER_PLAINTEXT_BYTES {
195        return Err(SoftchatError::DecryptionFailed);
196    }
197    Ok(plaintext)
198}
199
200fn complete_typed_external_event_signing(
201    request: ExternalEventSigningRequest,
202    signature: String,
203) -> Result<SignedNostrEvent, SoftchatError> {
204    let public_key = NostrPublicKey::from_hex(&request.public_key)
205        .map_err(|_| SoftchatError::InvalidExternalSignature)?;
206    let expected_id = NostrEventId::from_hex(&request.event_id)
207        .map_err(|_| SoftchatError::InvalidExternalSignature)?;
208    let draft = NostrEventDraft::try_from(request.draft)
209        .map_err(|_| SoftchatError::InvalidExternalSignature)?;
210    let recomputed = NostrRumor::new(public_key.clone(), draft.clone())
211        .map_err(|_| SoftchatError::InvalidExternalSignature)?;
212    if recomputed.id() != expected_id {
213        return Err(SoftchatError::InvalidExternalSignature);
214    }
215    let tags = draft
216        .tags()
217        .map(<[String]>::to_vec)
218        .map(NostrTag::new)
219        .collect::<Result<Vec<_>, _>>()
220        .map_err(|_| SoftchatError::InvalidExternalSignature)?;
221    let signature = NostrEventSignature::from_hex(&signature)
222        .map_err(|_| SoftchatError::InvalidExternalSignature)?;
223    SignedNostrEvent::new(
224        expected_id,
225        public_key,
226        draft.created_at(),
227        draft.kind(),
228        tags,
229        draft.content().to_owned(),
230        signature,
231    )
232    .map_err(|_| SoftchatError::InvalidExternalSignature)
233}
234
235#[cfg(test)]
236mod tests {
237    use std::sync::Mutex;
238
239    use super::*;
240    use crate::{LocalIdentity, NostrEventKind};
241
242    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
243
244    struct TestSigner {
245        identity: LocalIdentity,
246        failure: Mutex<Option<ExternalSignerFailure>>,
247    }
248
249    impl ExternalSigner for TestSigner {
250        fn capabilities(&self) -> ExternalSignerCapabilities {
251            ExternalSignerCapabilities {
252                sign_events: true,
253                nip44_encrypt: false,
254                nip44_decrypt: false,
255            }
256        }
257
258        fn public_key(&self) -> Result<NostrPublicKey, ExternalSignerFailure> {
259            Ok(self.identity.public_key())
260        }
261
262        fn sign_event(
263            &self,
264            request: &ExternalEventSigningRequest,
265        ) -> Result<String, ExternalSignerFailure> {
266            if let Some(failure) = *self
267                .failure
268                .lock()
269                .map_err(|_| ExternalSignerFailure::Rejected)?
270            {
271                return Err(failure);
272            }
273            let draft = NostrEventDraft::try_from(request.draft.clone())
274                .map_err(|_| ExternalSignerFailure::Rejected)?;
275            self.identity
276                .sign_event(draft)
277                .map(|event| event.signature().to_hex())
278                .map_err(|_| ExternalSignerFailure::Rejected)
279        }
280    }
281
282    fn draft() -> Result<NostrEventDraft, SoftchatError> {
283        NostrEventDraft::new(
284            1_700_000_000,
285            NostrEventKind::SHORT_TEXT_NOTE,
286            Vec::new(),
287            "external",
288        )
289    }
290
291    #[test]
292    fn signs_without_exporting_private_material() -> Result<(), SoftchatError> {
293        let signer = TestSigner {
294            identity: LocalIdentity::from_secret_hex(ALICE_SECRET)?,
295            failure: Mutex::new(None),
296        };
297        let event = sign_event_with_external(&signer, draft()?)?;
298        assert_eq!(event.content(), "external");
299        assert_eq!(event.public_key(), signer.identity.public_key());
300        Ok(())
301    }
302
303    #[test]
304    fn maps_cancel_reject_and_invalid_signature_to_stable_errors() -> Result<(), SoftchatError> {
305        let signer = TestSigner {
306            identity: LocalIdentity::from_secret_hex(ALICE_SECRET)?,
307            failure: Mutex::new(Some(ExternalSignerFailure::Cancelled)),
308        };
309        assert!(matches!(
310            sign_event_with_external(&signer, draft()?),
311            Err(SoftchatError::ExternalSignerCancelled)
312        ));
313
314        let request = prepare_external_event_signing(
315            signer.identity.public_key().to_hex(),
316            EventDraft::from(&draft()?),
317        )?;
318        assert!(matches!(
319            complete_external_event_signing(request, "00".repeat(64)),
320            Err(SoftchatError::InvalidExternalSignature)
321        ));
322        Ok(())
323    }
324}