Skip to content

Commit 2119a64

Browse files
committed
Add persistent PIN pairing system
- Generate persistent PIN at server startup (valid for server lifetime) - Display PIN on desktop Status tab alongside URL with copy/refresh buttons - Add /api/pair/verify-direct endpoint for session-less PIN verification - Remove redundant "Enter Server URL" button from web landing page - Update web PIN entry to use direct verification flow Fixes pairing UX issues when navigating directly to server URL.
1 parent 18d3c4a commit 2119a64

10 files changed

Lines changed: 259 additions & 28 deletions

File tree

crates/linglide-auth/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ pub mod storage;
3939

4040
pub use device::{Device, DeviceId, DeviceInfo, DeviceType};
4141
pub use pairing::{
42-
hash_token, PairingError, PairingManager, PairingResult, PairingStartResponse,
43-
PairingVerifyRequest, PairingVerifyResponse, QrCodeData, PIN_VALIDITY_SECONDS,
42+
hash_token, DirectVerifyRequest, PairingError, PairingManager, PairingResult,
43+
PairingStartResponse, PairingVerifyRequest, PairingVerifyResponse, PersistentPinResponse,
44+
QrCodeData, PIN_VALIDITY_SECONDS,
4445
};
4546
pub use storage::{DeviceStorage, StorageError, StorageResult};

crates/linglide-auth/src/pairing.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,25 @@ pub struct PairingVerifyResponse {
105105
pub token: String,
106106
}
107107

108+
/// Request to verify PIN directly (without session)
109+
#[derive(Debug, Clone, Serialize, Deserialize)]
110+
pub struct DirectVerifyRequest {
111+
/// The PIN entered by user
112+
pub pin: String,
113+
/// Device name provided by client
114+
pub device_name: String,
115+
/// Device type hint
116+
#[serde(default)]
117+
pub device_type: Option<String>,
118+
}
119+
120+
/// Response containing the persistent PIN
121+
#[derive(Debug, Clone, Serialize, Deserialize)]
122+
pub struct PersistentPinResponse {
123+
/// The 6-digit PIN
124+
pub pin: String,
125+
}
126+
108127
/// QR code data structure
109128
#[derive(Debug, Clone, Serialize, Deserialize)]
110129
pub struct QrCodeData {
@@ -132,6 +151,8 @@ pub struct PairingManager {
132151
server_url: String,
133152
/// Certificate fingerprint for QR codes
134153
cert_fingerprint: Option<String>,
154+
/// Persistent PIN for direct entry (valid for server lifetime)
155+
persistent_pin: Arc<RwLock<String>>,
135156
}
136157

137158
impl PairingManager {
@@ -142,6 +163,7 @@ impl PairingManager {
142163
storage,
143164
server_url,
144165
cert_fingerprint: None,
166+
persistent_pin: Arc::new(RwLock::new(generate_pin())),
145167
}
146168
}
147169

@@ -156,6 +178,7 @@ impl PairingManager {
156178
storage,
157179
server_url,
158180
cert_fingerprint: fingerprint,
181+
persistent_pin: Arc::new(RwLock::new(generate_pin())),
159182
}
160183
}
161184

@@ -164,6 +187,57 @@ impl PairingManager {
164187
self.cert_fingerprint = fingerprint;
165188
}
166189

190+
/// Get the persistent PIN (valid for entire server lifetime)
191+
pub async fn get_persistent_pin(&self) -> String {
192+
self.persistent_pin.read().await.clone()
193+
}
194+
195+
/// Regenerate the persistent PIN
196+
pub async fn refresh_persistent_pin(&self) -> String {
197+
let mut pin = self.persistent_pin.write().await;
198+
*pin = generate_pin();
199+
info!("Persistent PIN refreshed");
200+
pin.clone()
201+
}
202+
203+
/// Verify PIN directly without requiring a session
204+
/// This is for direct PIN entry when user navigates to the URL
205+
pub async fn verify_persistent_pin(
206+
&self,
207+
request: DirectVerifyRequest,
208+
) -> PairingResult<PairingVerifyResponse> {
209+
let persistent = self.persistent_pin.read().await;
210+
211+
if *persistent != request.pin {
212+
warn!("Invalid persistent PIN attempt");
213+
return Err(PairingError::InvalidPin);
214+
}
215+
216+
// Generate auth token
217+
let token = generate_token();
218+
let token_hash = hash_token(&token);
219+
220+
// Create device
221+
let device_type = request
222+
.device_type
223+
.as_deref()
224+
.and_then(|s| s.parse().ok())
225+
.unwrap_or(DeviceType::Unknown);
226+
227+
let device = Device::new(request.device_name, device_type, token_hash);
228+
let device_id = device.id.to_string();
229+
230+
// Save device
231+
self.storage.save_device(device).await?;
232+
233+
info!(
234+
"Device {} paired successfully via persistent PIN",
235+
device_id
236+
);
237+
238+
Ok(PairingVerifyResponse { device_id, token })
239+
}
240+
167241
/// Start a new pairing session
168242
pub async fn start_pairing(&self) -> PairingStartResponse {
169243
let session = PairingSession::new();
@@ -295,6 +369,13 @@ impl PairingManager {
295369
}
296370
}
297371

372+
/// Generate a 6-digit PIN
373+
fn generate_pin() -> String {
374+
let mut rng = rand::thread_rng();
375+
let pin: u32 = rng.gen_range(0..1_000_000);
376+
format!("{:06}", pin)
377+
}
378+
298379
/// Generate a secure random token
299380
fn generate_token() -> String {
300381
let mut rng = rand::thread_rng();

crates/linglide-desktop/src/app.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,13 @@ impl LinGlideApp {
7474
url,
7575
fingerprint,
7676
paired_devices,
77+
pin,
7778
} => {
7879
info!("Server started: {}", url);
7980
self.server_status.running = true;
8081
self.server_url = Some(url.clone());
8182
self.server_status.url = Some(url);
83+
self.server_status.pin = Some(pin);
8284
self.cert_fingerprint = Some(fingerprint);
8385
self.paired_devices = paired_devices.clone();
8486
self.server_status.paired_device_count = paired_devices.len();
@@ -89,10 +91,15 @@ impl LinGlideApp {
8991
let _ = self.bridge.command_tx.try_send(UiCommand::StartPairing);
9092
}
9193
}
94+
UiEvent::PinRefreshed { pin } => {
95+
info!("PIN refreshed");
96+
self.server_status.pin = Some(pin);
97+
}
9298
UiEvent::ServerStopped => {
9399
info!("Server stopped");
94100
self.server_status.running = false;
95101
self.server_status.url = None;
102+
self.server_status.pin = None;
96103
self.server_status.connected_devices.clear();
97104
self.pairing_state = PairingState::default();
98105
}

crates/linglide-desktop/src/bridge.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ pub enum UiEvent {
1616
url: String,
1717
fingerprint: String,
1818
paired_devices: Vec<Device>,
19+
pin: String,
1920
},
2021
/// Server stopped
2122
ServerStopped,
23+
/// Persistent PIN was refreshed
24+
PinRefreshed { pin: String },
2225
/// Server failed to start
2326
ServerError { message: String },
2427
/// New device connected
@@ -62,6 +65,8 @@ pub enum UiCommand {
6265
SetMdns { enabled: bool },
6366
/// Enable/disable USB/ADB forwarding
6467
SetUsb { enabled: bool },
68+
/// Refresh the persistent PIN
69+
RefreshPin,
6570
/// Shutdown the application
6671
Shutdown,
6772
}
@@ -71,6 +76,7 @@ pub enum UiCommand {
7176
pub struct ServerStatus {
7277
pub running: bool,
7378
pub url: Option<String>,
79+
pub pin: Option<String>,
7480
pub connected_devices: Vec<Device>,
7581
pub paired_device_count: usize,
7682
pub mdns_active: bool,

crates/linglide-desktop/src/controller.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ impl ServerController {
111111
UiCommand::SetUsb { enabled: _ } => {
112112
// Would need to restart server to change USB
113113
}
114+
UiCommand::RefreshPin => {
115+
self.refresh_pin().await;
116+
}
114117
UiCommand::Shutdown => {
115118
info!("Shutdown requested");
116119
self.stop_server().await;
@@ -192,6 +195,7 @@ impl ServerController {
192195
let ds_clone = device_storage.clone();
193196
let fp_clone = fingerprint.clone();
194197
let devices_clone = paired_devices.clone();
198+
let persistent_pin = pairing_manager.get_persistent_pin().await;
195199
tokio::spawn(async move {
196200
if let Err(e) = run_server(
197201
config,
@@ -204,6 +208,7 @@ impl ServerController {
204208
fp_clone,
205209
local_ip,
206210
devices_clone,
211+
persistent_pin,
207212
)
208213
.await
209214
{
@@ -248,6 +253,19 @@ impl ServerController {
248253
}
249254
}
250255
}
256+
257+
async fn refresh_pin(&mut self) {
258+
if let Some(ref ctx) = self.context {
259+
let ctx = ctx.read().await;
260+
let new_pin = ctx.pairing_manager.refresh_persistent_pin().await;
261+
let _ = self
262+
.bridge
263+
.event_tx
264+
.send(UiEvent::PinRefreshed { pin: new_pin });
265+
} else {
266+
warn!("Cannot refresh PIN: server not running");
267+
}
268+
}
251269
}
252270

253271
/// Get the local IP address
@@ -272,6 +290,7 @@ async fn run_server(
272290
fingerprint: String,
273291
local_ip: String,
274292
paired_devices: Vec<linglide_auth::device::Device>,
293+
persistent_pin: String,
275294
) -> Result<()> {
276295
let core_config = Config::new()
277296
.with_width(config.width)
@@ -335,6 +354,7 @@ async fn run_server(
335354
url: server_url.clone(),
336355
fingerprint: fingerprint.clone(),
337356
paired_devices,
357+
pin: persistent_pin,
338358
});
339359

340360
// Start mDNS if enabled

crates/linglide-desktop/src/windows/mod.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,38 @@ impl MainWindow {
292292
});
293293
}
294294

295+
if let Some(pin) = &status.pin {
296+
ui.add_space(8.0);
297+
ui.horizontal(|ui| {
298+
ui.label(
299+
RichText::new("PIN:")
300+
.font(typography::body())
301+
.color(colors::TEXT_SECONDARY),
302+
);
303+
ui.add_space(4.0);
304+
ui.label(
305+
RichText::new(pin)
306+
.strong()
307+
.color(colors::SUCCESS)
308+
.monospace(),
309+
);
310+
if ui
311+
.small_button("\u{1F4CB}")
312+
.on_hover_text("Copy PIN")
313+
.clicked()
314+
{
315+
ui.output_mut(|o| o.copied_text = pin.clone());
316+
}
317+
if ui
318+
.small_button("\u{1F504}")
319+
.on_hover_text("Generate new PIN")
320+
.clicked()
321+
{
322+
let _ = command_tx.try_send(UiCommand::RefreshPin);
323+
}
324+
});
325+
}
326+
295327
ui.add_space(4.0);
296328
ui.horizontal(|ui| {
297329
ui.label(

crates/linglide-server/src/http.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ use axum::{
1111
};
1212
use image::ImageFormat;
1313
use linglide_auth::{
14-
DeviceInfo, PairingStartResponse, PairingVerifyRequest, PairingVerifyResponse,
14+
DeviceInfo, DirectVerifyRequest, PairingStartResponse, PairingVerifyRequest,
15+
PairingVerifyResponse, PersistentPinResponse,
1516
};
1617
use linglide_discovery::DiscoveryInfo;
1718
use linglide_web::Assets;
@@ -37,6 +38,9 @@ pub fn create_router(state: Arc<AppState>) -> Router {
3738
// Pairing API
3839
.route("/api/pair/start", post(pair_start_handler))
3940
.route("/api/pair/verify", post(pair_verify_handler))
41+
.route("/api/pair/verify-direct", post(pair_verify_direct_handler))
42+
.route("/api/pair/pin", get(pair_pin_handler))
43+
.route("/api/pair/pin/refresh", post(pair_pin_refresh_handler))
4044
.route("/api/pair/qr", get(pair_qr_handler))
4145
.route("/api/pair/status", get(pair_status_handler))
4246
// Device management API
@@ -119,6 +123,38 @@ async fn pair_verify_handler(
119123
.map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))
120124
}
121125

126+
/// Verify PIN directly without requiring a session
127+
///
128+
/// This is for direct PIN entry when users navigate to the server URL.
129+
/// Uses the persistent PIN that is valid for the server's lifetime.
130+
async fn pair_verify_direct_handler(
131+
State(state): State<Arc<AppState>>,
132+
Json(request): Json<DirectVerifyRequest>,
133+
) -> Result<Json<PairingVerifyResponse>, (StatusCode, String)> {
134+
state
135+
.pairing_manager
136+
.verify_persistent_pin(request)
137+
.await
138+
.map(Json)
139+
.map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))
140+
}
141+
142+
/// Get the persistent PIN
143+
///
144+
/// Returns the PIN that is valid for the server's lifetime.
145+
async fn pair_pin_handler(State(state): State<Arc<AppState>>) -> Json<PersistentPinResponse> {
146+
let pin = state.pairing_manager.get_persistent_pin().await;
147+
Json(PersistentPinResponse { pin })
148+
}
149+
150+
/// Refresh (regenerate) the persistent PIN
151+
async fn pair_pin_refresh_handler(
152+
State(state): State<Arc<AppState>>,
153+
) -> Json<PersistentPinResponse> {
154+
let pin = state.pairing_manager.refresh_persistent_pin().await;
155+
Json(PersistentPinResponse { pin })
156+
}
157+
122158
/// Query parameters for QR code generation
123159
#[derive(Debug, Deserialize)]
124160
pub struct QrQuery {

crates/linglide-web/www/js/api.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,25 @@ export class ApiClient {
100100
});
101101
}
102102

103+
/**
104+
* Verify PIN directly without session (for persistent PIN)
105+
* This is used when users navigate directly to the server URL and enter the PIN.
106+
* @param {string} pin
107+
* @param {string} deviceName
108+
* @param {string} [deviceType]
109+
* @returns {Promise<PairingVerifyResponse>}
110+
*/
111+
async verifyPinDirect(pin, deviceName, deviceType = 'browser') {
112+
return this.request('/api/pair/verify-direct', {
113+
method: 'POST',
114+
body: JSON.stringify({
115+
pin,
116+
device_name: deviceName,
117+
device_type: deviceType
118+
})
119+
});
120+
}
121+
103122
/**
104123
* Get pairing session status
105124
* @param {string} sessionId

0 commit comments

Comments
 (0)