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:
@@ -0,0 +1,43 @@
|
||||
# Embedded FIPS mesh node for the Archipelago companion app.
|
||||
#
|
||||
# Built for Android via cargo-ndk (see Android/app/build.gradle.kts, task
|
||||
# buildRustArm64) into app/src/main/jniLibs/arm64-v8a/libarchy_fips_core.so.
|
||||
# Also builds on the host so `cargo test` covers the non-JNI logic.
|
||||
#
|
||||
# `fips` is pinned to the fips-native fork rev that this integration was
|
||||
# developed against — the fork carries Android support upstream lacks
|
||||
# (Tun::from_fd for a VpnService-owned fd, cfg(target_os = "android") paths).
|
||||
# Override with a local checkout when hacking on fips itself:
|
||||
# CARGO_NET_OFFLINE=false cargo ndk ... --config 'patch."https://github.com/9qeklajc/fips-native".fips.path="/path/to/fips-native/fips"'
|
||||
[package]
|
||||
name = "archy-fips-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "archy_fips_core"
|
||||
# rlib for host tests; cdylib for the Android shared library.
|
||||
crate-type = ["lib", "cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd.
|
||||
fips = { git = "https://github.com/9qeklajc/fips-native", rev = "46494a74fc85b878305d275d42ab719082b06ba6", default-features = false, features = ["tun-support"] }
|
||||
anyhow = "1.0"
|
||||
serde_json = "1.0"
|
||||
hex = "0.4"
|
||||
# OS CSPRNG for identity generation (crypto rule: no thread-local RNG for keys).
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] }
|
||||
tracing = "0.1"
|
||||
|
||||
# The JNI surface only exists on Android; host builds skip it and drive the
|
||||
# mesh module directly (tests).
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.21"
|
||||
# Bridge `tracing` (ours + fips) to logcat: `adb logcat -s archy-fips`.
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
paranoid-android = "0.2"
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,126 @@
|
||||
//! JNI surface for `com.archipelago.app.fips.FipsNative` — JSON over strings,
|
||||
//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as
|
||||
//! `{"error": "…"}` so Kotlin never sees a raw exception from native code.
|
||||
|
||||
use std::sync::Once;
|
||||
|
||||
use jni::objects::{JClass, JString};
|
||||
use jni::sys::{jboolean, jint, jstring};
|
||||
use jni::JNIEnv;
|
||||
|
||||
use crate::mesh;
|
||||
|
||||
static LOG_INIT: Once = Once::new();
|
||||
|
||||
fn init_logging() {
|
||||
LOG_INIT.call_once(|| {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new("info"))
|
||||
.with(paranoid_android::layer("archy-fips"))
|
||||
.try_init();
|
||||
});
|
||||
}
|
||||
|
||||
fn jstr(env: &mut JNIEnv, s: &JString) -> String {
|
||||
env.get_string(s).map(|s| s.into()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn out(env: &JNIEnv, s: String) -> jstring {
|
||||
env.new_string(s)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
fn err_json(e: impl std::fmt::Display) -> String {
|
||||
serde_json::json!({ "error": e.to_string() }).to_string()
|
||||
}
|
||||
|
||||
fn identity_json(info: &mesh::IdentityInfo) -> String {
|
||||
serde_json::json!({
|
||||
"secret": info.secret_hex,
|
||||
"npub": info.npub,
|
||||
"address": info.address,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun generateIdentity(): String`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_generateIdentity(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let json = match mesh::generate_identity() {
|
||||
Ok(info) => identity_json(&info),
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun deriveIdentity(secret: String): String`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
secret: JString,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let secret = jstr(&mut env, &secret);
|
||||
let json = match mesh::derive_identity(&secret) {
|
||||
Ok(info) => identity_json(&info),
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int): String`
|
||||
/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
secret: JString,
|
||||
peers_json: JString,
|
||||
tun_fd: jint,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let secret = jstr(&mut env, &secret);
|
||||
let peers = jstr(&mut env, &peers_json);
|
||||
let json = match mesh::start(&secret, &peers, tun_fd) {
|
||||
Ok((npub, address)) => {
|
||||
serde_json::json!({ "npub": npub, "address": address }).to_string()
|
||||
}
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun stop()`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_stop(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) {
|
||||
mesh::stop();
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun isRunning(): Boolean`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_isRunning(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jboolean {
|
||||
mesh::is_running() as jboolean
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun statusJson(): String`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jstring {
|
||||
out(&env, mesh::status_json())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Embedded FIPS mesh node for the Archipelago companion app.
|
||||
//!
|
||||
//! The phone runs a real, leaf-only FIPS node in-process: Android's
|
||||
//! `VpnService` owns the TUN fd (routing only `fd00::/8`, so normal traffic
|
||||
//! never touches the tunnel) and hands it to [`fips::Node::start_with_tun_fd`].
|
||||
//! Peering is outbound-only — the pairing QR carries the node's npub and
|
||||
//! transport endpoints, and FIPS nodes accept inbound peers without prior
|
||||
//! registration, so no server-side enrollment step exists.
|
||||
//!
|
||||
//! The JNI surface (`jni_glue`, Android-only) is deliberately tiny and
|
||||
//! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern:
|
||||
//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`.
|
||||
|
||||
pub mod mesh;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod jni_glue;
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Mesh lifecycle: identity, config assembly, and the node task.
|
||||
//!
|
||||
//! Host-buildable (no JNI) so the config/identity logic is unit-testable;
|
||||
//! only [`start`] needs a real TUN fd and therefore only runs on-device.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use fips::config::{PeerConfig, TransportInstances, UdpConfig};
|
||||
use fips::{Config, Identity, Node};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// How long `start` waits for the node to come up (TUN attach + transports).
|
||||
const START_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// How long `stop` waits for the node task to drain.
|
||||
const STOP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
struct MeshHandle {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
task: Option<tokio::task::JoinHandle<()>>,
|
||||
shutdown: Arc<Notify>,
|
||||
running: Arc<AtomicBool>,
|
||||
npub: String,
|
||||
address: String,
|
||||
}
|
||||
|
||||
static MESH: Mutex<Option<MeshHandle>> = Mutex::new(None);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdentityInfo {
|
||||
pub secret_hex: String,
|
||||
pub npub: String,
|
||||
/// The phone's own ULA on the mesh (fd::/8), for VpnService.addAddress.
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
/// Generate a fresh mesh identity from the OS CSPRNG.
|
||||
pub fn generate_identity() -> Result<IdentityInfo> {
|
||||
// ~1 in 2^128 chance a candidate is off the curve; loop regardless.
|
||||
loop {
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::getrandom(&mut bytes).context("OS RNG")?;
|
||||
if let Ok(id) = Identity::from_secret_bytes(&bytes) {
|
||||
return Ok(IdentityInfo {
|
||||
secret_hex: hex::encode(bytes),
|
||||
npub: id.npub(),
|
||||
address: id.address().to_ipv6().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-derive npub + ULA from a stored secret.
|
||||
pub fn derive_identity(secret: &str) -> Result<IdentityInfo> {
|
||||
let id = Identity::from_secret_str(secret).map_err(|e| anyhow!("bad secret: {e}"))?;
|
||||
Ok(IdentityInfo {
|
||||
secret_hex: secret.to_string(),
|
||||
npub: id.npub(),
|
||||
address: id.address().to_ipv6().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the phone-side node config: leaf-only (never routes third-party
|
||||
/// traffic — battery), ephemeral outbound-only transports, no DNS responder,
|
||||
/// TUN enabled but attached to the VpnService fd rather than created.
|
||||
pub fn build_config(secret: &str, peers: Vec<PeerConfig>) -> Config {
|
||||
let mut cfg = Config::default();
|
||||
cfg.node.identity.nsec = Some(secret.to_string());
|
||||
cfg.node.identity.persistent = false;
|
||||
cfg.node.leaf_only = true;
|
||||
cfg.tun.enabled = true;
|
||||
cfg.tun.mtu = Some(1280);
|
||||
cfg.dns.enabled = false;
|
||||
// Ephemeral UDP port: outbound dialing works, nothing predictable listens.
|
||||
cfg.transports.udp = TransportInstances::Single(UdpConfig {
|
||||
bind_addr: Some("0.0.0.0:0".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
|
||||
cfg.transports.tcp = TransportInstances::Single(Default::default());
|
||||
cfg.peers = peers;
|
||||
cfg
|
||||
}
|
||||
|
||||
/// Parse the peers JSON handed over from Kotlin. Shape = fips `PeerConfig`:
|
||||
/// `[{"npub":"…","alias":"…","addresses":[{"transport":"udp","addr":"host:2121","priority":10},…]}]`
|
||||
pub fn parse_peers(peers_json: &str) -> Result<Vec<PeerConfig>> {
|
||||
serde_json::from_str(peers_json).context("peers JSON")
|
||||
}
|
||||
|
||||
/// Start the mesh node on the given TUN fd (from `VpnService.establish()`,
|
||||
/// detached — the node owns it from here). Returns (npub, ula) on success.
|
||||
/// Any previously running node is stopped first.
|
||||
pub fn start(secret: &str, peers_json: &str, tun_fd: i32) -> Result<(String, String)> {
|
||||
stop();
|
||||
|
||||
let peers = parse_peers(peers_json)?;
|
||||
let config = build_config(secret, peers);
|
||||
let mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?;
|
||||
let npub = node.npub();
|
||||
let address = node.identity().address().to_ipv6().to_string();
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.thread_name("archy-fips")
|
||||
.build()
|
||||
.context("tokio runtime")?;
|
||||
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let running = Arc::new(AtomicBool::new(false));
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel::<Result<()>>();
|
||||
|
||||
let task = {
|
||||
let shutdown = shutdown.clone();
|
||||
let running = running.clone();
|
||||
runtime.spawn(async move {
|
||||
match node.start_with_tun_fd(tun_fd).await {
|
||||
Ok(()) => {
|
||||
running.store(true, Ordering::SeqCst);
|
||||
let _ = started_tx.send(Ok(()));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = started_tx.send(Err(anyhow!("node start: {e}")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
tokio::select! {
|
||||
result = node.run_rx_loop() => {
|
||||
if let Err(e) = result {
|
||||
tracing::error!("mesh rx loop error: {e}");
|
||||
}
|
||||
}
|
||||
_ = shutdown.notified() => {}
|
||||
}
|
||||
if let Err(e) = node.stop().await {
|
||||
tracing::warn!("mesh stop: {e}");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
})
|
||||
};
|
||||
|
||||
let started = runtime
|
||||
.block_on(async { tokio::time::timeout(START_TIMEOUT, started_rx).await })
|
||||
.map_err(|_| anyhow!("node start timed out"))?
|
||||
.map_err(|_| anyhow!("node task died during start"))?;
|
||||
if let Err(e) = started {
|
||||
runtime.shutdown_background();
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
*MESH.lock().unwrap() = Some(MeshHandle {
|
||||
runtime,
|
||||
task: Some(task),
|
||||
shutdown,
|
||||
running,
|
||||
npub: npub.clone(),
|
||||
address: address.clone(),
|
||||
});
|
||||
Ok((npub, address))
|
||||
}
|
||||
|
||||
/// Stop the mesh node if running. Idempotent.
|
||||
pub fn stop() {
|
||||
let Some(mut handle) = MESH.lock().unwrap().take() else {
|
||||
return;
|
||||
};
|
||||
handle.shutdown.notify_waiters();
|
||||
if let Some(task) = handle.task.take() {
|
||||
let _ = handle
|
||||
.runtime
|
||||
.block_on(async { tokio::time::timeout(STOP_TIMEOUT, task).await });
|
||||
}
|
||||
handle.runtime.shutdown_background();
|
||||
}
|
||||
|
||||
pub fn is_running() -> bool {
|
||||
MESH.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|h| h.running.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `{running, npub, address}` for the Kotlin status surface.
|
||||
pub fn status_json() -> String {
|
||||
let guard = MESH.lock().unwrap();
|
||||
match guard.as_ref() {
|
||||
Some(h) => serde_json::json!({
|
||||
"running": h.running.load(Ordering::SeqCst),
|
||||
"npub": h.npub,
|
||||
"address": h.address,
|
||||
})
|
||||
.to_string(),
|
||||
None => serde_json::json!({ "running": false }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identity_roundtrip() {
|
||||
let a = generate_identity().unwrap();
|
||||
let b = derive_identity(&a.secret_hex).unwrap();
|
||||
assert_eq!(a.npub, b.npub);
|
||||
assert_eq!(a.address, b.address);
|
||||
// ULA in fd::/8
|
||||
assert!(a.address.starts_with("fd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peers_json_parses_into_peer_config() {
|
||||
let peers = parse_peers(
|
||||
r#"[{
|
||||
"npub": "npub1abc",
|
||||
"alias": "My Archipelago",
|
||||
"addresses": [
|
||||
{"transport": "udp", "addr": "192.168.1.228:2121", "priority": 10},
|
||||
{"transport": "tcp", "addr": "192.168.1.228:8443", "priority": 20}
|
||||
]
|
||||
}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(peers.len(), 1);
|
||||
assert_eq!(peers[0].addresses.len(), 2);
|
||||
assert!(peers[0].is_auto_connect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_is_leaf_only_with_tun() {
|
||||
let id = generate_identity().unwrap();
|
||||
let cfg = build_config(&id.secret_hex, vec![]);
|
||||
assert!(cfg.node.leaf_only);
|
||||
assert!(cfg.tun.enabled);
|
||||
assert_eq!(cfg.tun.mtu(), 1280);
|
||||
assert!(!cfg.dns.enabled);
|
||||
assert!(!cfg.transports.udp.is_empty());
|
||||
assert!(!cfg.transports.tcp.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user