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:
@@ -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,
|
||||
|
||||
@@ -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)?)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user