mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
## 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>
164 lines
6.3 KiB
Rust
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();
|
|
}
|