feat(companion): embedded FIPS mesh replaces WireGuard for remote access

- Android/rust/archy-fips-core: leaf-only fips node as a JNI cdylib (fips
  pinned to the fips-native fork rev with VpnService fd support), built by
  gradle via cargo-ndk (arm64), tested on host
- ArchyVpnService: split-tunnel VpnService routing only fd00::/8 (MTU 1280,
  foreground specialUse); FipsManager handles the one-time VPN consent and
  silent auto-start — no settings surface at all
- pairing QR now fully configures the mesh: fnpub/fip/fhost/fudp/ftcp plus
  fanchors (the node's seed-anchor list, npub@addr/transport) so the phone
  can rendezvous through public anchors when the LAN endpoint is unreachable
- default seed anchors gain the two dual-transport join.fips.network test
  anchors (23.182.128.74:443/tcp, 217.77.8.91:443/tcp); anchor adverts are
  Nostr kind-37195 events
- device token rides the password field end-to-end: backend accepts tokens
  wherever it accepts the password, so scan = instant login (WebSocket auth
  + WebView form injection unchanged); token logins skip TOTP
- ServerEntry.meshIp + IPv6-bracketed URLs; WebView retries the mesh address
  on main-frame errors, auto-login and origin checks honor it
- companion v0.5.0 (versionCode 20), arm64 abiFilter; FipsNative.available
  gates everything so non-arm64 still runs as a plain companion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-22 22:06:32 +01:00
co-authored by Claude Fable 5
parent b88609e0ff
commit 4b91765cc7
25 changed files with 1119 additions and 33 deletions
+9
View File
@@ -49,6 +49,15 @@ impl RpcHandler {
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
// The companion app sends its device token through the password
// field (it reuses the whole password auto-login path, including
// the WebView form). Accept a valid token here so that path works.
if let Some(device) =
crate::device_tokens::verify(&self.config.data_dir, password).await
{
tracing::info!("[onboarding] device-token login via password field ({device})");
return Ok(serde_json::Value::Null);
}
tracing::warn!("[onboarding] login failed — wrong password");
return Err(anyhow::anyhow!("Password Incorrect"));
}
+16
View File
@@ -27,11 +27,27 @@ impl RpcHandler {
anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first")
})?;
let ula = fips::iface::fips0_ula().map(|ip| ip.to_string());
// The node's seed anchors ride along so the phone can rendezvous
// through the same public mesh points when the node's LAN endpoint
// isn't directly dialable (phone away from home, node behind NAT).
let anchors = fips::anchors::load(&self.config.data_dir)
.await
.unwrap_or_default()
.into_iter()
.map(|a| {
serde_json::json!({
"npub": a.npub,
"addr": a.address,
"transport": a.transport,
})
})
.collect::<Vec<_>>();
Ok(serde_json::json!({
"npub": npub,
"ula": ula,
"udp_port": fips::PUBLISHED_UDP_PORT,
"tcp_port": fips::DEFAULT_TCP_PORT,
"anchors": anchors,
}))
}
+14 -1
View File
@@ -537,11 +537,24 @@ impl RpcHandler {
// 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 password = login_params
.as_ref()
.and_then(|p| p.get("password"))
.and_then(|v| v.as_str());
let is_token_login = login_params
.as_ref()
.and_then(|p| p.get("token"))
.and_then(|v| v.as_str())
.is_some();
.is_some()
|| match password {
// Companion device tokens also arrive through the password
// field (see handle_auth_login) — those logins get a full
// session too; there's no password to decrypt TOTP with.
Some(pw) => crate::device_tokens::verify(&self.config.data_dir, pw)
.await
.is_some(),
None => false,
};
let totp_enabled = !is_token_login
&& self.auth_manager.is_totp_enabled().await.unwrap_or(false);
if totp_enabled {
+28 -1
View File
@@ -81,6 +81,31 @@ pub fn archy_anchor() -> SeedAnchor {
}
}
/// Public FIPS-network anchors from join.fips.network (adverts published as
/// Nostr kind-37195 `fips-overlay-v1` events, refreshed hourly). Of the eight
/// live test anchors (2026-07-22), these two are the dual-transport ones —
/// they answer on TCP as well as UDP, and TCP is what traverses restrictive
/// networks. The remaining six are UDP-only; add them per-node via
/// `fips.add-seed-anchor` if more rendezvous diversity is wanted.
pub fn fips_network_anchors() -> Vec<SeedAnchor> {
vec![
SeedAnchor {
npub: "npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u"
.to_string(),
address: "23.182.128.74:443".to_string(),
transport: "tcp".to_string(),
label: "FIPS network anchor (join.fips.network)".to_string(),
},
SeedAnchor {
npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98"
.to_string(),
address: "217.77.8.91:443".to_string(),
transport: "tcp".to_string(),
label: "FIPS network anchor (join.fips.network)".to_string(),
},
]
}
/// The default anchor set carried implicitly by `load()` on nodes that have
/// never edited their anchor list, so every node dials them without operator
/// action. Multiple anchors so one unreachable rendezvous host can't strand a
@@ -88,7 +113,9 @@ pub fn archy_anchor() -> SeedAnchor {
/// network can reach wins. The Archipelago-operated anchor is listed first
/// because it is reachable from the widest set of networks.
pub fn default_public_anchors() -> Vec<SeedAnchor> {
vec![archy_anchor(), default_public_anchor()]
let mut anchors = vec![archy_anchor(), default_public_anchor()];
anchors.extend(fips_network_anchors());
anchors
}
/// One seed-anchor entry. `address` must be directly dialable (IP or