diff --git a/crates/buzz-core/src/pairing/mod.rs b/crates/buzz-core/src/pairing/mod.rs index 4b5e03eff8..f50734f288 100644 --- a/crates/buzz-core/src/pairing/mod.rs +++ b/crates/buzz-core/src/pairing/mod.rs @@ -25,7 +25,7 @@ pub mod session; pub mod types; pub use qr::QrPayload; -pub use session::{PairingSession, Role, SessionState}; +pub use session::{PairingBinding, PairingSession, Role, SessionState}; pub use types::{AbortReason, PairingMessage, PayloadType}; use thiserror::Error; diff --git a/crates/buzz-core/src/pairing/session.rs b/crates/buzz-core/src/pairing/session.rs index 606315bead..042958af83 100644 --- a/crates/buzz-core/src/pairing/session.rs +++ b/crates/buzz-core/src/pairing/session.rs @@ -62,6 +62,20 @@ pub enum Role { Target, } +/// Public channel binding for independently authenticated application approval. +/// This identifies the locked peers and transcript, not their account identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairingBinding { + /// Identifier derived from this QR session's random secret. + pub session_id: [u8; 32], + /// The initiating device's ephemeral public key. + pub source_pubkey: PublicKey, + /// The receiving device's ephemeral public key. + pub target_pubkey: PublicKey, + /// The commitment verified by `sas-confirm`. + pub transcript_hash: [u8; 32], +} + /// Protocol state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionState { @@ -115,6 +129,8 @@ pub struct PairingSession { desktop_code_requested: bool, desktop_code: Option>, code_attempts: u8, + /// Strict target-side code-entry negotiation and request correlation. + target_desktop_code: Option, } impl PairingSession { @@ -150,6 +166,7 @@ impl PairingSession { desktop_code_requested: false, desktop_code: None, code_attempts: 0, + target_desktop_code: None, }; (session, qr) @@ -357,6 +374,13 @@ impl PairingSession { /// /// Returns the session and the `offer` event to publish. pub fn new_target(qr: &QrPayload) -> Result<(Self, Event), PairingError> { + Self::new_target_with_confirmation(qr, false) + } + + fn new_target_with_confirmation( + qr: &QrPayload, + desktop_code: bool, + ) -> Result<(Self, Event), PairingError> { let keys = Keys::generate(); let session_id = derive_session_id(&qr.session_secret); @@ -383,13 +407,14 @@ impl PairingSession { desktop_code_requested: false, desktop_code: None, code_attempts: 0, + target_desktop_code: desktop_code.then(desktop_code::TargetDesktopCode::default), }; // Build and return the offer event. let msg = PairingMessage::Offer { session_id: hex::encode(session_id), version: 1, - confirmation: None, + confirmation: desktop_code.then(|| "desktop-code-v1".into()), }; let event = session.build_event(&msg)?; session.state = SessionState::Confirming; @@ -408,6 +433,14 @@ impl PairingSession { self.expect_state(SessionState::Confirming)?; self.expect_role(Role::Target)?; self.validate_event_from_peer(event)?; + if let Some(code) = &self.target_desktop_code { + if !code.challenged || code.pending_request_id.is_none() { + return Err(PairingError::UnexpectedMessage { + expected: "desktop code challenge and submission".into(), + got: "sas-confirm".into(), + }); + } + } let msg = self.decrypt_message(event)?; let received_hash = match &msg { @@ -443,6 +476,9 @@ impl PairingSession { } self.state = SessionState::AwaitingConfirmation; + if let Some(code) = &mut self.target_desktop_code { + code.pending_request_id = None; + } self.record_event(event); let code = self.sas_code.ok_or(PairingError::SasMismatch)?; Ok(format_sas(code)) @@ -487,13 +523,23 @@ impl PairingSession { /// (Target) Build the `complete` event to publish. pub fn send_complete(&mut self) -> Result { + self.send_complete_with_result(true) + } + + /// (Target) Acknowledge whether the received payload was durably imported. + /// Call only after the application has completed its secure-storage attempt. + pub fn send_complete_with_result(&mut self, success: bool) -> Result { self.check_expired()?; self.expect_state(SessionState::PayloadExchanged)?; self.expect_role(Role::Target)?; - let msg = PairingMessage::Complete { success: true }; + let msg = PairingMessage::Complete { success }; let event = self.build_event(&msg)?; - self.state = SessionState::Completed; + self.state = if success { + SessionState::Completed + } else { + SessionState::Aborted + }; Ok(event) } } @@ -608,6 +654,45 @@ impl PairingSession { relays: self.relay_urls.clone(), })) } + + /// Capture the live exchange's public channel binding, in source/target + /// order on both devices. Source must first accept an offer. Applications + /// submit this independently through each user's authenticated session; + /// no application credential needs to travel over the pairing protocol. + /// This method does not authorize exporting identity material. + pub fn binding(&self) -> Result { + self.check_expired()?; + if !matches!( + self.state, + SessionState::Confirming + | SessionState::AwaitingConfirmation + | SessionState::Transferring + ) { + return Err(PairingError::UnexpectedMessage { + expected: "live pairing with a locked peer".into(), + got: "unbound or terminal session".into(), + }); + } + let peer = self.peer_pubkey.ok_or(PairingError::SasMismatch)?; + let sas_input = self.sas_input.ok_or(PairingError::SasMismatch)?; + let own = self.keys.public_key(); + let (source_pubkey, target_pubkey) = match self.role { + Role::Source => (own, peer), + Role::Target => (peer, own), + }; + Ok(PairingBinding { + session_id: self.session_id, + source_pubkey, + target_pubkey, + transcript_hash: derive_transcript_hash( + &self.session_id, + &source_pubkey.to_bytes(), + &target_pubkey.to_bytes(), + &sas_input, + &self.session_secret, + ), + }) + } } #[cfg(test)] diff --git a/crates/buzz-core/src/pairing/session_code_entry_tests.rs b/crates/buzz-core/src/pairing/session_code_entry_tests.rs index 09d902cea5..a1e7239f29 100644 --- a/crates/buzz-core/src/pairing/session_code_entry_tests.rs +++ b/crates/buzz-core/src/pairing/session_code_entry_tests.rs @@ -194,3 +194,29 @@ fn readiness_delay_and_late_scan_share_the_original_deadline() { Err(PairingError::SessionExpired) )); } + +#[test] +fn strict_target_expiry_and_cancellation_block_every_entry_point() { + for cancel in [false, true] { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer) = PairingSession::new_target_with_desktop_code(&qr).unwrap(); + source.handle_offer_with_confirmation(&offer).unwrap(); + let (code, challenge) = source.start_desktop_code().unwrap(); + target.handle_desktop_code(&challenge).unwrap(); + let submission = target.submit_desktop_code(Zeroizing::new(code)).unwrap(); + let (proof, _) = source.handle_target_code(&submission).unwrap(); + if cancel { + target.abort(AbortReason::UserDenied).unwrap(); + } else { + target.created_at = Instant::now() - target.timeout - Duration::from_secs(1); + } + assert!(target.handle_desktop_code(&challenge).is_err()); + assert!(target + .submit_desktop_code(Zeroizing::new("123456".into())) + .is_err()); + assert!(target.handle_code_rejected(&proof).is_err()); + assert!(target.handle_sas_confirm(&proof).is_err()); + assert!(target.confirm_target_sas().is_err()); + assert!(target.send_complete_with_result(true).is_err()); + } +} diff --git a/crates/buzz-core/src/pairing/session_desktop_code.rs b/crates/buzz-core/src/pairing/session_desktop_code.rs index 0ffd1c60d8..cc06bb711d 100644 --- a/crates/buzz-core/src/pairing/session_desktop_code.rs +++ b/crates/buzz-core/src/pairing/session_desktop_code.rs @@ -55,3 +55,117 @@ impl PairingSession { Ok((rejection, false)) } } + +/// Target-only state. The source never learns the pending code locally. +#[derive(Default)] +pub(super) struct TargetDesktopCode { + pub(super) challenged: bool, + pub(super) pending_request_id: Option, + remaining_attempts: Option, +} + +impl PairingSession { + /// Create a target that requires `desktop-code-v1`, with no SAS fallback. + /// + /// The caller must handle the source challenge, submit the user-entered code, + /// verify the source proof, and call `confirm_target_sas` before importing. + pub fn new_target_with_desktop_code(qr: &QrPayload) -> Result<(Self, Event), PairingError> { + Self::new_target_with_confirmation(qr, true) + } + + /// Validate the locked source's code-entry challenge. It contains no code. + /// Duplicate or replacement challenges never reset an outstanding attempt. + pub fn handle_desktop_code(&mut self, event: &Event) -> Result<(), PairingError> { + self.check_expired()?; + self.expect_role(Role::Target)?; + self.expect_state(SessionState::Confirming)?; + self.validate_event_from_peer(event)?; + let code = self + .target_desktop_code + .as_ref() + .ok_or(PairingError::SasMismatch)?; + if code.challenged { + return Err(PairingError::SasMismatch); + } + match self.decrypt_message(event)? { + PairingMessage::DesktopCode {} => {} + other => return Err(unexpected("desktop-code", &other)), + } + if let Some(code) = &mut self.target_desktop_code { + code.challenged = true; + } + self.record_event(event); + Ok(()) + } + + /// Encrypt one six-ASCII-digit user submission with a fresh request ID. + /// + /// Only one request may be outstanding. A correlated rejection allows the + /// next submission; callers must not retry a pending guess with a new ID. + pub fn submit_desktop_code(&mut self, code: Zeroizing) -> Result { + self.check_expired()?; + self.expect_role(Role::Target)?; + self.expect_state(SessionState::Confirming)?; + let target = self + .target_desktop_code + .as_ref() + .ok_or(PairingError::SasMismatch)?; + if !target.challenged + || target.pending_request_id.is_some() + || code.len() != 6 + || !code.bytes().all(|b| b.is_ascii_digit()) + { + return Err(PairingError::SasMismatch); + } + let request_id = hex::encode(rand::random::<[u8; 16]>()); + let mut message = PairingMessage::CodeSubmit { + code: (*code).clone(), + request_id: request_id.clone(), + }; + let result = self.build_event(&message); + if let PairingMessage::CodeSubmit { code, .. } = &mut message { + code.zeroize(); + } + let event = result?; + if let Some(target) = &mut self.target_desktop_code { + target.pending_request_id = Some(request_id); + } + Ok(event) + } + + /// Validate a rejection for the pending request and return guesses remaining. + /// + /// Unrelated, stale, duplicate and non-decreasing budgets leave the pending + /// request unchanged. Zero remaining guesses permanently aborts the session. + pub fn handle_code_rejected(&mut self, event: &Event) -> Result { + self.check_expired()?; + self.expect_role(Role::Target)?; + self.expect_state(SessionState::Confirming)?; + self.validate_event_from_peer(event)?; + let (request_id, remaining_attempts) = match self.decrypt_message(event)? { + PairingMessage::CodeRejected { + request_id, + remaining_attempts, + } => (request_id, remaining_attempts), + other => return Err(unexpected("code-rejected", &other)), + }; + let target = self + .target_desktop_code + .as_ref() + .ok_or(PairingError::SasMismatch)?; + if target.pending_request_id.as_deref() != Some(request_id.as_str()) + || remaining_attempts >= target.remaining_attempts.unwrap_or(5) + { + return Err(PairingError::SasMismatch); + } + if let Some(target) = &mut self.target_desktop_code { + target.pending_request_id = None; + target.remaining_attempts = Some(remaining_attempts); + } + self.record_event(event); + if remaining_attempts == 0 { + self.state = SessionState::Aborted; + } + Ok(remaining_attempts) + } +} diff --git a/crates/buzz-core/tests/pairing_code_entry.rs b/crates/buzz-core/tests/pairing_code_entry.rs new file mode 100644 index 0000000000..aafa4af71b --- /dev/null +++ b/crates/buzz-core/tests/pairing_code_entry.rs @@ -0,0 +1,244 @@ +//! Public-API interoperability journeys for strict NIP-AB code entry. +use buzz_core::pairing::{PairingError, PairingSession, PayloadType, SessionState}; +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag, Timestamp}; +use zeroize::Zeroizing; + +fn setup() -> (PairingSession, PairingSession) { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (target, offer) = PairingSession::new_target_with_desktop_code(&qr).unwrap(); + assert!(source.handle_offer_with_confirmation(&offer).unwrap().1); + (source, target) +} + +fn input(code: &str) -> Zeroizing { + Zeroizing::new(code.into()) +} + +#[test] +fn both_devices_bind_authorization_to_the_same_live_exchange() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + assert!(source.binding().is_err()); + let (mut target, offer) = PairingSession::new_target_with_desktop_code(&qr).unwrap(); + source.handle_offer_with_confirmation(&offer).unwrap(); + let expected = source.binding().unwrap(); + assert_eq!(expected, target.binding().unwrap()); + assert_eq!(expected.source_pubkey, source.pubkey()); + assert_eq!(expected.target_pubkey, target.pubkey()); + let (other, _) = PairingSession::new_target_with_desktop_code(&qr).unwrap(); + assert_ne!( + expected.transcript_hash, + other.binding().unwrap().transcript_hash + ); + let (code, challenge) = source.start_desktop_code().unwrap(); + target.handle_desktop_code(&challenge).unwrap(); + let submission = target.submit_desktop_code(input(&code)).unwrap(); + let (proof, _) = source.handle_target_code(&submission).unwrap(); + target.handle_sas_confirm(&proof).unwrap(); + assert_eq!(expected, source.binding().unwrap()); + assert_eq!(expected, target.binding().unwrap()); + target.confirm_target_sas().unwrap(); + let payload = source + .send_payload(PayloadType::Custom, input("test")) + .unwrap(); + assert!(source.binding().is_err()); + target.handle_payload(&payload).unwrap(); + assert!(target.binding().is_err()); + source + .handle_complete(&target.send_complete().unwrap()) + .unwrap(); + assert!(source.binding().is_err()); + assert!(target.binding().is_err()); +} + +#[test] +fn cancelled_exchange_cannot_produce_an_authorization_binding() { + let (mut source, mut target) = setup(); + let abort = source + .abort(buzz_core::pairing::AbortReason::UserDenied) + .unwrap() + .unwrap(); + target.handle_abort(&abort).unwrap(); + assert!(source.binding().is_err()); + assert!(target.binding().is_err()); +} + +fn wrong(code: &str) -> &'static str { + if code == "000000" { + "000001" + } else { + "000000" + } +} + +fn resign(source: &PairingSession, event: &Event) -> Event { + source + .sign_event( + EventBuilder::new(event.kind, &event.content) + .tags(event.tags.iter().cloned()) + .custom_created_at(Timestamp::from(event.created_at.as_secs() + 1)), + ) + .unwrap() +} + +#[test] +fn code_entry_retries_then_import_requires_explicit_intent_and_durable_ack() { + let (mut source, mut target) = setup(); + let (code, challenge) = source.start_desktop_code().unwrap(); + assert!(target.submit_desktop_code(input(&code)).is_err()); + target.handle_desktop_code(&challenge).unwrap(); + assert!(target.handle_desktop_code(&challenge).is_err()); + for invalid in ["", "12345", "1234567", "abcdef", "123456"] { + assert!(target.submit_desktop_code(input(invalid)).is_err()); + } + let submission = target.submit_desktop_code(input(wrong(&code))).unwrap(); + assert!(target.submit_desktop_code(input(&code)).is_err()); + let (rejection, accepted) = source.handle_target_code(&submission).unwrap(); + assert!(!accepted); + assert_eq!(target.handle_code_rejected(&rejection).unwrap(), 4); + let correct = target.submit_desktop_code(input(&code)).unwrap(); + // Even re-signed stale content must not resolve the new pending request. + assert!(target + .handle_code_rejected(&resign(&source, &rejection)) + .is_err()); + assert!(target.submit_desktop_code(input(&code)).is_err()); + let (proof, accepted) = source.handle_target_code(&correct).unwrap(); + assert!(accepted); + target.handle_sas_confirm(&proof).unwrap(); + let payload = source + .send_payload(PayloadType::Nsec, input("identity-secret")) + .unwrap(); + assert!( + target.handle_payload(&payload).is_err(), + "local import approval is mandatory" + ); + assert!( + target.send_complete().is_err(), + "cannot acknowledge an unreceived secret" + ); + target.confirm_target_sas().unwrap(); + let (kind, received) = target.handle_payload(&payload).unwrap(); + assert_eq!(kind, PayloadType::Nsec); + assert_eq!(&*received, "identity-secret"); + assert!( + target.handle_payload(&payload).is_err(), + "import is single use" + ); + let complete = target.send_complete_with_result(true).unwrap(); + source.handle_complete(&complete).unwrap(); + assert_eq!(source.state(), SessionState::Completed); + assert_eq!(target.state(), SessionState::Completed); +} + +#[test] +fn strict_target_rejects_legacy_proof_before_challenge_or_code_submission() { + for challenge_first in [false, true] { + let (mut source, mut target) = setup(); + if challenge_first { + let (_, challenge) = source.start_desktop_code().unwrap(); + target.handle_desktop_code(&challenge).unwrap(); + } + let legacy_proof = source.confirm_sas().unwrap(); + assert!(target.handle_sas_confirm(&legacy_proof).is_err()); + assert_eq!(target.state(), SessionState::Confirming); + assert!(target.confirm_target_sas().is_err()); + } + // The legacy constructor still negotiates the existing explicit SAS flow. + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer) = PairingSession::new_target(&qr).unwrap(); + assert!(!source.handle_offer_with_confirmation(&offer).unwrap().1); + assert!(target.submit_desktop_code(input("123456")).is_err()); + target + .handle_sas_confirm(&source.confirm_sas().unwrap()) + .unwrap(); + target.confirm_target_sas().unwrap(); +} + +#[test] +fn five_rejections_end_both_peers_and_replays_cannot_reset_the_budget() { + let (mut source, mut target) = setup(); + let (code, challenge) = source.start_desktop_code().unwrap(); + target.handle_desktop_code(&challenge).unwrap(); + for remaining in (0..5).rev() { + let submission = target.submit_desktop_code(input(wrong(&code))).unwrap(); + let (rejection, accepted) = source.handle_target_code(&submission).unwrap(); + assert!(!accepted); + assert!(source.handle_target_code(&submission).is_err()); + assert_eq!(target.handle_code_rejected(&rejection).unwrap(), remaining); + assert!(target.handle_code_rejected(&rejection).is_err()); + assert!(target + .handle_desktop_code(&resign(&source, &challenge)) + .is_err()); + assert!(source.start_desktop_code().is_err()); + } + assert_eq!(source.state(), SessionState::Aborted); + assert_eq!(target.state(), SessionState::Aborted); + assert!(target.submit_desktop_code(input(&code)).is_err()); + assert!(target.confirm_target_sas().is_err()); +} + +#[test] +fn authenticated_peer_and_recipient_checks_preserve_a_valid_challenge() { + let (mut source, mut target) = setup(); + let (code, challenge) = source.start_desktop_code().unwrap(); + let attacker = Keys::generate(); + let ciphertext = nip44::encrypt( + attacker.secret_key(), + &target.pubkey(), + "{\"type\":\"desktop-code\"}", + nip44::Version::V2, + ) + .unwrap(); + let wrong_peer = EventBuilder::new(Kind::Custom(24134), ciphertext) + .tags([Tag::public_key(target.pubkey())]) + .sign_with_keys(&attacker) + .unwrap(); + let wrong_recipient = source + .sign_event( + EventBuilder::new(challenge.kind, &challenge.content) + .tags([Tag::public_key(attacker.public_key())]), + ) + .unwrap(); + let wrong_kind = source + .sign_event( + EventBuilder::new(Kind::TextNote, &challenge.content) + .tags([Tag::public_key(target.pubkey())]), + ) + .unwrap(); + let mut tampered = challenge.clone(); + tampered.content.push('x'); + for candidate in [wrong_peer.clone(), wrong_recipient, wrong_kind, tampered] { + assert!(target.handle_desktop_code(&candidate).is_err()); + } + target.handle_desktop_code(&challenge).unwrap(); + let submission = target.submit_desktop_code(input(&code)).unwrap(); + assert!(target.handle_code_rejected(&wrong_peer).is_err()); + let (proof, accepted) = source.handle_target_code(&submission).unwrap(); + assert!(accepted); + target.handle_sas_confirm(&proof).unwrap(); +} + +#[test] +fn failed_secure_import_is_reported_without_success_or_retry() { + let (mut source, mut target) = setup(); + let (code, challenge) = source.start_desktop_code().unwrap(); + target.handle_desktop_code(&challenge).unwrap(); + let submission = target.submit_desktop_code(input(&code)).unwrap(); + let (proof, _) = source.handle_target_code(&submission).unwrap(); + target.handle_sas_confirm(&proof).unwrap(); + target.confirm_target_sas().unwrap(); + let payload = source + .send_payload(PayloadType::Nsec, input("identity-secret")) + .unwrap(); + target.handle_payload(&payload).unwrap(); + let failed = target.send_complete_with_result(false).unwrap(); + assert!(matches!( + source.handle_complete(&failed), + Err(PairingError::UnexpectedMessage { .. }) + )); + assert_eq!(source.state(), SessionState::Aborted); + assert_eq!(target.state(), SessionState::Aborted); + assert!(target.send_complete().is_err()); + assert!(source + .send_payload(PayloadType::Nsec, input("identity-secret")) + .is_err()); +}