Expose strict code-entry pairing and live authorization bindings (#9061)

Native clients using Rust pairing can now receive the existing
`desktop-code-v1` flow: accept its challenge, submit a user-entered
six-digit code, handle correlated rejections, and require successful
code submission before accepting the source proof. A failed durable
import can explicitly acknowledge failure.

Adds a public, live-session channel binding (session ID, ordered
ephemeral peers and transcript commitment) so applications can authorize
the exact exchange using each device's independently authenticated
account session. The binding does not itself authorize secret export.
Legacy APIs and the wire format remain compatible.

Validation: 79 pairing unit tests and seven public API journeys pass,
including wrong-code retries, five-attempt lockout, replay/peer
rejection, failed import, and identical live bindings on both devices.
Focused `cargo clippy -p buzz-core --all-targets -- -D warnings` and
formatting pass. Repository formatting/mobile checks and desktop, web,
and Flutter build/test lanes pass after installing the pinned Flutter
dependencies. Three initial normal push-hook attempts stopped at
different unrelated ACP deadlines. The final normal push passed all
applicable hooks, including desktop Tauri Clippy/tests and the full Rust
unit lane, with existing hook lanes serialized and four Rust test
threads. No checks were disabled and no ACP source was changed.
Independent review found no blocker in this API delta. App and human
acceptance remain outstanding; this PR is a draft.

---------

Signed-off-by: Matt Dickenson <mdickenson@squareup.com>
This commit is contained in:
Matt Dickenson
2026-11-05 15:18:14 -05:00
committed by GitHub
parent 6793cbd25b
commit 120d75d976
5 changed files with 473 additions and 4 deletions

View File

@@ -25,7 +25,7 @@ pub mod session;
pub mod types; pub mod types;
pub use qr::QrPayload; pub use qr::QrPayload;
pub use session::{PairingSession, Role, SessionState}; pub use session::{PairingBinding, PairingSession, Role, SessionState};
pub use types::{AbortReason, PairingMessage, PayloadType}; pub use types::{AbortReason, PairingMessage, PayloadType};
use thiserror::Error; use thiserror::Error;

View File

@@ -62,6 +62,20 @@ pub enum Role {
Target, 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. /// Protocol state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionState { pub enum SessionState {
@@ -115,6 +129,8 @@ pub struct PairingSession {
desktop_code_requested: bool, desktop_code_requested: bool,
desktop_code: Option<Zeroizing<String>>, desktop_code: Option<Zeroizing<String>>,
code_attempts: u8, code_attempts: u8,
/// Strict target-side code-entry negotiation and request correlation.
target_desktop_code: Option<desktop_code::TargetDesktopCode>,
} }
impl PairingSession { impl PairingSession {
@@ -150,6 +166,7 @@ impl PairingSession {
desktop_code_requested: false, desktop_code_requested: false,
desktop_code: None, desktop_code: None,
code_attempts: 0, code_attempts: 0,
target_desktop_code: None,
}; };
(session, qr) (session, qr)
@@ -357,6 +374,13 @@ impl PairingSession {
/// ///
/// Returns the session and the `offer` event to publish. /// Returns the session and the `offer` event to publish.
pub fn new_target(qr: &QrPayload) -> Result<(Self, Event), PairingError> { 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 keys = Keys::generate();
let session_id = derive_session_id(&qr.session_secret); let session_id = derive_session_id(&qr.session_secret);
@@ -383,13 +407,14 @@ impl PairingSession {
desktop_code_requested: false, desktop_code_requested: false,
desktop_code: None, desktop_code: None,
code_attempts: 0, code_attempts: 0,
target_desktop_code: desktop_code.then(desktop_code::TargetDesktopCode::default),
}; };
// Build and return the offer event. // Build and return the offer event.
let msg = PairingMessage::Offer { let msg = PairingMessage::Offer {
session_id: hex::encode(session_id), session_id: hex::encode(session_id),
version: 1, version: 1,
confirmation: None, confirmation: desktop_code.then(|| "desktop-code-v1".into()),
}; };
let event = session.build_event(&msg)?; let event = session.build_event(&msg)?;
session.state = SessionState::Confirming; session.state = SessionState::Confirming;
@@ -408,6 +433,14 @@ impl PairingSession {
self.expect_state(SessionState::Confirming)?; self.expect_state(SessionState::Confirming)?;
self.expect_role(Role::Target)?; self.expect_role(Role::Target)?;
self.validate_event_from_peer(event)?; 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 msg = self.decrypt_message(event)?;
let received_hash = match &msg { let received_hash = match &msg {
@@ -443,6 +476,9 @@ impl PairingSession {
} }
self.state = SessionState::AwaitingConfirmation; self.state = SessionState::AwaitingConfirmation;
if let Some(code) = &mut self.target_desktop_code {
code.pending_request_id = None;
}
self.record_event(event); self.record_event(event);
let code = self.sas_code.ok_or(PairingError::SasMismatch)?; let code = self.sas_code.ok_or(PairingError::SasMismatch)?;
Ok(format_sas(code)) Ok(format_sas(code))
@@ -487,13 +523,23 @@ impl PairingSession {
/// (Target) Build the `complete` event to publish. /// (Target) Build the `complete` event to publish.
pub fn send_complete(&mut self) -> Result<Event, PairingError> { pub fn send_complete(&mut self) -> Result<Event, PairingError> {
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<Event, PairingError> {
self.check_expired()?; self.check_expired()?;
self.expect_state(SessionState::PayloadExchanged)?; self.expect_state(SessionState::PayloadExchanged)?;
self.expect_role(Role::Target)?; self.expect_role(Role::Target)?;
let msg = PairingMessage::Complete { success: true }; let msg = PairingMessage::Complete { success };
let event = self.build_event(&msg)?; let event = self.build_event(&msg)?;
self.state = SessionState::Completed; self.state = if success {
SessionState::Completed
} else {
SessionState::Aborted
};
Ok(event) Ok(event)
} }
} }
@@ -608,6 +654,45 @@ impl PairingSession {
relays: self.relay_urls.clone(), 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<PairingBinding, PairingError> {
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)] #[cfg(test)]

View File

@@ -194,3 +194,29 @@ fn readiness_delay_and_late_scan_share_the_original_deadline() {
Err(PairingError::SessionExpired) 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());
}
}

View File

@@ -55,3 +55,117 @@ impl PairingSession {
Ok((rejection, false)) 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<String>,
remaining_attempts: Option<u8>,
}
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<String>) -> Result<Event, PairingError> {
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<u8, PairingError> {
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)
}
}

View File

@@ -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<String> {
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());
}