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()
+131
View File
@@ -0,0 +1,131 @@
//! Companion device tokens — long-lived bearer credentials minted from an
//! authenticated session, so the pairing QR can log a phone in without
//! carrying the admin password (which the browser never has anyway).
//!
//! Only the SHA-256 of each token is persisted (`device-tokens.json` in the
//! data dir); the plaintext is returned exactly once at mint time and rides
//! the QR as the `tok` param. Verification goes through `auth.login`'s
//! `token` param and is covered by the same login rate limiter as passwords.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use tokio::fs;
const TOKENS_FILE: &str = "device-tokens.json";
/// Cap on stored tokens; re-pairing the same device name replaces its entry,
/// so this only limits the number of *distinct* device names.
const MAX_TOKENS: usize = 32;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceToken {
pub name: String,
/// Hex SHA-256 of the plaintext token.
pub hash: String,
/// Unix seconds at mint time.
pub created: u64,
}
fn tokens_path(data_dir: &Path) -> PathBuf {
data_dir.join(TOKENS_FILE)
}
async fn load(data_dir: &Path) -> Vec<DeviceToken> {
match fs::read(tokens_path(data_dir)).await {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => Vec::new(),
}
}
async fn save(data_dir: &Path, tokens: &[DeviceToken]) -> Result<()> {
let bytes = serde_json::to_vec_pretty(tokens)?;
fs::write(tokens_path(data_dir), bytes)
.await
.context("write device-tokens.json")
}
fn hash_hex(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// Mint a new token for `name`. An existing token with the same name is
/// replaced, so re-showing the pairing QR never piles up stale entries.
/// Returns the plaintext token — the only time it ever exists outside the QR.
pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
let token_bytes: [u8; 32] = rand::random();
let token = hex::encode(token_bytes);
let mut tokens = load(data_dir).await;
tokens.retain(|t| t.name != name);
if tokens.len() >= MAX_TOKENS {
tokens.remove(0);
}
tokens.push(DeviceToken {
name: name.to_string(),
hash: hash_hex(&token),
created: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
});
save(data_dir, &tokens).await?;
Ok(token)
}
/// Verify a candidate token. Returns the device name it was minted for.
pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> {
let candidate_hash = hash_hex(candidate);
load(data_dir)
.await
.iter()
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()))
.map(|t| t.name.clone())
}
/// List stored tokens (hashes only — plaintexts are unrecoverable).
pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
load(data_dir).await
}
/// Remove the token minted for `name`. Returns whether one existed.
pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
let mut tokens = load(data_dir).await;
let before = tokens.len();
tokens.retain(|t| t.name != name);
let removed = tokens.len() != before;
if removed {
save(data_dir, &tokens).await?;
}
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn mint_verify_replace_remove() {
let dir = tempfile::tempdir().unwrap();
let token = create(dir.path(), "phone").await.unwrap();
assert_eq!(verify(dir.path(), &token).await.as_deref(), Some("phone"));
assert!(verify(dir.path(), "not-a-token").await.is_none());
// Re-minting the same name invalidates the old token.
let token2 = create(dir.path(), "phone").await.unwrap();
assert!(verify(dir.path(), &token).await.is_none());
assert_eq!(verify(dir.path(), &token2).await.as_deref(), Some("phone"));
assert_eq!(list(dir.path()).await.len(), 1);
assert!(remove(dir.path(), "phone").await.unwrap());
assert!(verify(dir.path(), &token2).await.is_none());
}
}
+4 -4
View File
@@ -15,7 +15,7 @@ use std::path::Path;
use tokio::process::Command;
use super::{
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT,
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, PUBLISHED_UDP_PORT,
};
/// Header prepended to the generated YAML. serde doesn't emit comments, so
@@ -137,7 +137,7 @@ impl Default for FipsConfig {
},
transports: TransportsSection {
udp: TransportBind {
bind_addr: format!("0.0.0.0:{DEFAULT_UDP_PORT}"),
bind_addr: format!("0.0.0.0:{PUBLISHED_UDP_PORT}"),
},
tcp: TransportBind {
bind_addr: format!("0.0.0.0:{DEFAULT_TCP_PORT}"),
@@ -293,7 +293,7 @@ mod tests {
fn test_rendered_yaml_matches_upstream_schema() {
let yaml = render_config_yaml();
assert!(yaml.contains("persistent: true"));
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_UDP_PORT)));
assert!(yaml.contains(&format!("0.0.0.0:{}", PUBLISHED_UDP_PORT)));
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_TCP_PORT)));
assert!(yaml.contains("udp:"));
assert!(yaml.contains("tcp:"));
@@ -329,7 +329,7 @@ dns:
bind_addr: 127.0.0.1
transports:
udp:
bind_addr: 0.0.0.0:8668
bind_addr: 0.0.0.0:2121
tcp:
bind_addr: 0.0.0.0:8443
peers: []
+9
View File
@@ -119,6 +119,15 @@ pub const UPSTREAM_REPO: &str = "jmcorgan/fips";
/// Default UDP port the daemon listens on.
pub const DEFAULT_UDP_PORT: u16 = 8668;
/// UDP port archipelago actually publishes/binds for the daemon. The
/// container publishes `2121:2121/udp` (see `package/config.rs`) and every
/// peer roster in the fleet dials `<host>:2121`, but the rendered daemon
/// config used to bind upstream's 8668 — leaving inbound UDP dead on
/// bridged-network installs and everything silently riding TCP 8443. The
/// bind now uses this port so the published mapping, the fleet rosters,
/// and the pairing QR all agree.
pub const PUBLISHED_UDP_PORT: u16 = 2121;
/// Default TCP port the daemon listens on. Used as a fallback when a
/// peer can't be reached over UDP — common on networks that block UDP
/// (corporate/guest wifi) and the path the public fips.v0l.io anchor
+1
View File
@@ -45,6 +45,7 @@ mod content_server;
mod crash_recovery;
mod credentials;
mod data_model;
mod device_tokens;
mod disk_monitor;
mod electrs_status;
mod federation;