feat: instant companion pairing — device tokens, named QR, FIPS pair-info

- auth.createDeviceToken / listDeviceTokens / revokeDeviceToken RPCs; only
  SHA-256 hashes persist in data_dir/device-tokens.json
- auth.login accepts {token} (same rate limiter, skips TOTP like remember-me)
- pairing QR now carries name (server name, fallback "My Archipelago"),
  tok (instant login), and FIPS mesh params from new fips.pair-info RPC
  (npub, fips0 ULA, transport ports)
- companion onboarding drops the WireGuard install/tunnel screens — remote
  access moves to the FIPS mesh embedded in the companion app
- fix: fips daemon UDP bind now 2121, matching the published container port
  and fleet rosters (was upstream's 8668 — inbound UDP was dead on bridged
  installs, mesh silently rode TCP 8443)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-22 22:04:59 +01:00
co-authored by Claude Fable 5
parent 8b744d377c
commit b88609e0ff
10 changed files with 328 additions and 312 deletions
+59
View File
@@ -9,6 +9,23 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
// Companion device-token login: minted via auth.createDeviceToken and
// carried by the pairing QR. Verified here so it shares the login rate
// limiter with password attempts.
if let Some(token) = params.get("token").and_then(|v| v.as_str()) {
return match crate::device_tokens::verify(&self.config.data_dir, token).await {
Some(device) => {
tracing::info!("[onboarding] device-token login ({device})");
Ok(serde_json::Value::Null)
}
None => {
tracing::warn!("[onboarding] device-token login failed");
Err(anyhow::anyhow!("Invalid device token"))
}
};
}
let password = params
.get("password")
.and_then(|v| v.as_str())
@@ -73,6 +90,48 @@ impl RpcHandler {
Ok(serde_json::Value::Null)
}
/// Mint a device token for the companion pairing QR. Session-gated by the
/// dispatcher (not in UNAUTHENTICATED_METHODS), so only a logged-in web UI
/// can mint one. The plaintext token is returned exactly once.
pub(super) async fn handle_auth_create_device_token(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let name = params
.as_ref()
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("companion")
.trim()
.to_string();
if name.is_empty() || name.len() > 64 {
return Err(anyhow::anyhow!("Device name must be 1-64 characters"));
}
let token = crate::device_tokens::create(&self.config.data_dir, &name).await?;
Ok(serde_json::json!({ "name": name, "token": token }))
}
pub(super) async fn handle_auth_list_device_tokens(&self) -> Result<serde_json::Value> {
let tokens = crate::device_tokens::list(&self.config.data_dir).await;
Ok(serde_json::json!(tokens
.iter()
.map(|t| serde_json::json!({ "name": t.name, "created": t.created }))
.collect::<Vec<_>>()))
}
pub(super) async fn handle_auth_revoke_device_token(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let name = params
.as_ref()
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
let removed = crate::device_tokens::remove(&self.config.data_dir, name).await?;
Ok(serde_json::json!({ "removed": removed }))
}
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
tracing::info!("[onboarding] logout");
Ok(serde_json::Value::Null)
@@ -26,6 +26,9 @@ impl RpcHandler {
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
"auth.createDeviceToken" => self.handle_auth_create_device_token(params).await,
"auth.listDeviceTokens" => self.handle_auth_list_device_tokens().await,
"auth.revokeDeviceToken" => self.handle_auth_revoke_device_token(params).await,
// Seed management (BIP-39 mnemonic)
"seed.generate" => self.handle_seed_generate().await,
@@ -487,6 +490,7 @@ impl RpcHandler {
// FIPS mesh transport
"fips.status" => self.handle_fips_status().await,
"fips.pair-info" => self.handle_fips_pair_info().await,
"fips.check-update" => self.handle_fips_check_update().await,
"fips.apply-update" => self.handle_fips_apply_update().await,
"fips.install" => self.handle_fips_install().await,
+19
View File
@@ -16,6 +16,25 @@ impl RpcHandler {
Ok(serde_json::to_value(status)?)
}
/// Everything the companion app needs to join this node's mesh, embedded
/// in the pairing QR by the web UI: the daemon's npub (identity to dial),
/// the fips0 ULA (where the UI is reachable once the phone is meshed),
/// and the transport ports on this host. The QR builder supplies the
/// host/IP itself — it knows which origin the browser reached the node on.
pub(super) async fn handle_fips_pair_info(&self) -> Result<serde_json::Value> {
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
let npub = crate::identity::fips_npub(&identity_dir).await?.ok_or_else(|| {
anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first")
})?;
let ula = fips::iface::fips0_ula().map(|ip| ip.to_string());
Ok(serde_json::json!({
"npub": npub,
"ula": ula,
"udp_port": fips::PUBLISHED_UDP_PORT,
"tcp_port": fips::DEFAULT_TCP_PORT,
}))
}
pub(super) async fn handle_fips_check_update(&self) -> Result<serde_json::Value> {
let check = fips::update::check().await?;
Ok(serde_json::to_value(check)?)
+11 -2
View File
@@ -532,9 +532,18 @@ impl RpcHandler {
self.login_rate_limiter.record_failure(client_ip).await;
}
// On successful login, check if 2FA is required
// On successful login, check if 2FA is required. Device-token logins
// (companion pairing QR) skip the TOTP challenge like remember-me does:
// the token was minted from an already-authenticated session, and there
// is no password with which to decrypt the TOTP secret anyway.
if method == "auth.login" && rpc_resp.error.is_none() {
let totp_enabled = self.auth_manager.is_totp_enabled().await.unwrap_or(false);
let is_token_login = login_params
.as_ref()
.and_then(|p| p.get("token"))
.and_then(|v| v.as_str())
.is_some();
let totp_enabled = !is_token_login
&& self.auth_manager.is_totp_enabled().await.unwrap_or(false);
if totp_enabled {
let password = login_params
.as_ref()