Files
buzz/crates/buzz-pair-relay/tests/desktop_code.rs
klopez4212 5173fad660 feat(pairing): enter desktop codes on mobile (#8085)
## Summary
Enter a desktop-only, random six-digit code on mobile to pair without a
desktop confirmation click. Verification uses the encrypted session,
limits guesses to five, and never treats the QR-derived transcript as
approval. Older clients retain desktop confirmation.

Adds digit-only error shakes and native haptics, a separate biometrics
choice, and a direct community-icon arrival animation. Fixes dropped
early offers and resets completed pairing so reopening or signing out
cannot get stuck.

### Related issue
Related: #8060; no duplicate code-entry PR found.
<img width="780" height="1688" alt="01-enter-code"
src="https://github.com/user-attachments/assets/a99ced3e-4305-4ac6-a6cc-d598ced423e7"
/>
<img width="780" height="1688" alt="02-incorrect-code"
src="https://github.com/user-attachments/assets/19cdb109-1f10-40a9-a661-1d7ba89c81c1"
/>
<img width="780" height="1688" alt="03-biometrics"
src="https://github.com/user-attachments/assets/af1b2924-9ab6-4d1a-b4b1-fccfdf446404"
/>
<img width="780" height="1688" alt="04-community-loading"
src="https://github.com/user-attachments/assets/84145d82-843a-4c25-aef7-6492fc241d4b"
/>
<img width="897" height="388" alt="05-desktop-code"
src="https://github.com/user-attachments/assets/d8a2e469-7e42-466a-988f-bab6b1df85fc"
/>

### Release order
Deploy the updated pairing relay first, then desktop, then mobile. The
relay now allows eight events per connection (seven for a successful
fifth guess plus room for cancellation), with a corresponding bounded
delivery budget. Existing clients remain supported; older client
combinations keep desktop confirmation.

### Testing
- 103 mobile pairing tests, 270 core tests, 51 relay integration tests,
and the new real-relay fifth-guess flow passed.
- The real-relay regression verifies proof, decrypted identity import,
and completion after four wrong guesses. Restoring the old six-event cap
makes it fail.
- A paused-clock desktop regression confirms slow readiness cannot
extend the visible QR beyond protocol expiry. Mobile regression covers
typing before negotiation completes.
- Core/pair-relay Clippy and full native desktop Clippy/workspace tests
passed. Mobile analysis passed; broad mobile runs each hit an unrelated
failure (a gateway-script timeout and a native-menu missing-plugin
error), and both affected test files passed separately.
- Repository-wide Rust testing exhausted local disk space while
compiling unrelated packages. The final push omitted that
resource-blocked hook and avoided repeating the desktop/mobile runs
above; hosted CI remains required.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-05 13:42:39 -04:00

164 lines
6.3 KiB
Rust

//! Full desktop-code wire exchange through the production relay and source state machine.
use std::{sync::Arc, time::Duration};
use buzz_core::pairing::{crypto, PairingSession, PayloadType, SessionState};
use buzz_pair_relay::{run_server, Relay};
use futures_util::{SinkExt, StreamExt};
use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, PublicKey, Tag, ToBech32};
use serde_json::{json, Value};
use tokio::net::TcpListener;
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use zeroize::Zeroizing;
type Socket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
async fn receive(socket: &mut Socket) -> Value {
let frame = tokio::time::timeout(Duration::from_secs(2), socket.next())
.await
.expect("relay response deadline")
.expect("connection open")
.expect("frame");
serde_json::from_str(frame.to_text().expect("text frame")).expect("relay JSON")
}
async fn subscribe(socket: &mut Socket, pubkey: PublicKey) {
socket
.send(Message::Text(
json!(["REQ", "pair", {
"kinds": [24134], "#p": [pubkey.to_hex()]
}])
.to_string()
.into(),
))
.await
.unwrap();
assert_eq!(receive(socket).await[0], "EOSE");
}
async fn forward(sender: &mut Socket, receiver: &mut Socket, event: Event) -> Event {
sender
.send(Message::Text(json!(["EVENT", event]).to_string().into()))
.await
.unwrap();
let ack = receive(sender).await;
assert_eq!(ack[0], "OK");
assert_eq!(ack[2], true, "relay must accept each protocol event: {ack}");
let delivery = receive(receiver).await;
assert_eq!(delivery[0], "EVENT");
let received: Event = serde_json::from_value(delivery[2].clone()).unwrap();
received.verify().unwrap();
received
}
fn target_message(keys: &Keys, source: PublicKey, message: Value) -> Event {
let encrypted = nip44::encrypt(
keys.secret_key(),
&source,
message.to_string(),
nip44::Version::V2,
)
.unwrap();
EventBuilder::new(Kind::Custom(24134), encrypted)
.tags([Tag::public_key(source)])
.sign_with_keys(keys)
.unwrap()
}
fn decrypt(keys: &Keys, event: &Event) -> Value {
serde_json::from_str(&nip44::decrypt(keys.secret_key(), &event.pubkey, &event.content).unwrap())
.unwrap()
}
#[tokio::test]
async fn fifth_correct_guess_delivers_identity_and_completion() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}", listener.local_addr().unwrap());
let server = tokio::spawn(run_server(listener, Arc::new(Relay::new())));
let (mut source, qr) = PairingSession::new_source(url.clone());
let target = Keys::generate();
let (mut source_socket, _) = connect_async(&url).await.unwrap();
let (mut target_socket, _) = connect_async(&url).await.unwrap();
subscribe(&mut source_socket, source.pubkey()).await;
subscribe(&mut target_socket, target.public_key()).await;
let session_id = crypto::derive_session_id(&qr.session_secret);
let hex = |bytes: &[u8]| bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
let offer = target_message(
&target,
source.pubkey(),
json!({
"type": "offer", "session_id": hex(&session_id), "version": 1,
"confirmation": "desktop-code-v1"
}),
);
let offer = forward(&mut target_socket, &mut source_socket, offer).await;
assert!(source.handle_offer_with_confirmation(&offer).unwrap().1);
let (code, challenge) = source.start_desktop_code().unwrap();
let challenge = forward(&mut source_socket, &mut target_socket, challenge).await;
assert_eq!(
decrypt(&target, &challenge),
json!({"type": "desktop-code"})
);
let wrong = if code == "000000" { "000001" } else { "000000" };
for attempt in 1..=5 {
let submission = target_message(
&target,
source.pubkey(),
json!({
"type": "code-submit", "code": if attempt == 5 { &code } else { wrong },
"request_id": attempt.to_string()
}),
);
let submission = forward(&mut target_socket, &mut source_socket, submission).await;
let (response, accepted) = source.handle_target_code(&submission).unwrap();
assert_eq!(accepted, attempt == 5);
let response = forward(&mut source_socket, &mut target_socket, response).await;
let message = decrypt(&target, &response);
if attempt < 5 {
assert_eq!(
message,
json!({"type":"code-rejected", "request_id":attempt.to_string(), "remaining_attempts":5-attempt})
);
} else {
let ecdh =
nostr::util::generate_shared_key(target.secret_key(), &source.pubkey()).unwrap();
let (_, sas_input) = crypto::derive_sas(&ecdh, &qr.session_secret);
let proof = crypto::derive_transcript_hash(
&session_id,
&source.pubkey().to_bytes(),
&target.public_key().to_bytes(),
&sas_input,
&qr.session_secret,
);
assert_eq!(
message,
json!({"type":"sas-confirm", "transcript_hash":hex(&proof)})
);
}
}
// Source event seven must reach the target and decrypt to the identity.
let identity = Keys::generate();
let secret = identity.secret_key().to_bech32().unwrap();
let payload = source
.send_payload(PayloadType::Nsec, Zeroizing::new(secret.clone()))
.unwrap();
let payload = forward(&mut source_socket, &mut target_socket, payload).await;
let imported = decrypt(&target, &payload);
assert_eq!(imported["payload"], secret);
assert_eq!(
Keys::parse(imported["payload"].as_str().unwrap())
.unwrap()
.public_key(),
identity.public_key()
);
// Target event seven must reach source and finish the session.
let complete = target_message(
&target,
source.pubkey(),
json!({"type":"complete", "success":true}),
);
let complete = forward(&mut target_socket, &mut source_socket, complete).await;
source.handle_complete(&complete).unwrap();
assert_eq!(source.state(), SessionState::Completed);
server.abort();
}