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 21:44:39 +01:00
parent b88609e0ff
commit 4b91765cc7
25 changed files with 1119 additions and 33 deletions

4
Android/.gitignore vendored
View File

@ -19,3 +19,7 @@ local.properties
# updates then install over the top without an uninstall. Debug keys are not # updates then install over the top without an uninstall. Debug keys are not
# secret (well-known password "android"); never commit a real release keystore. # secret (well-known password "android"); never commit a real release keystore.
!/app/debug.keystore !/app/debug.keystore
# Rust build outputs (archy-fips-core → jniLibs via buildRustArm64)
/rust/archy-fips-core/target
/app/src/main/jniLibs

View File

@ -11,12 +11,17 @@ android {
applicationId = "com.archipelago.app" applicationId = "com.archipelago.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 19 versionCode = 20
versionName = "0.4.15" versionName = "0.5.0"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
} }
// The embedded FIPS mesh (libarchy_fips_core.so) is built arm64-only,
// matching real handsets. FipsNative.available gates every call, so
// the app still runs as a plain companion elsewhere (e.g. x86 emu).
ndk { abiFilters += "arm64-v8a" }
} }
signingConfigs { signingConfigs {
@ -80,6 +85,44 @@ android {
} }
} }
// ---------------------------------------------------------------------------
// Embedded FIPS mesh: cross-compile Android/rust/archy-fips-core via cargo-ndk
// into jniLibs before the native-libs merge, so a plain `gradlew assembleDebug`
// builds the Rust too. Requires rustup target aarch64-linux-android, cargo-ndk,
// and an NDK (ANDROID_NDK_HOME or the SDK's ndk/ dir).
// ---------------------------------------------------------------------------
val rustCrateDir = layout.projectDirectory.dir("../rust/archy-fips-core")
val jniLibsDir = layout.projectDirectory.dir("src/main/jniLibs")
tasks.register<Exec>("buildRustArm64") {
workingDir = rustCrateDir.asFile
inputs.dir(rustCrateDir.dir("src"))
inputs.file(rustCrateDir.file("Cargo.toml"))
outputs.dir(jniLibsDir)
// cargo/cargo-ndk live in ~/.cargo/bin, which Gradle's env may not have.
val home = System.getProperty("user.home")
environment("PATH", "$home/.cargo/bin:${System.getenv("PATH")}")
if (System.getenv("ANDROID_NDK_HOME") == null) {
val sdkNdk = file("$home/Library/Android/sdk/ndk")
.listFiles()?.maxByOrNull { it.name }
if (sdkNdk != null) environment("ANDROID_NDK_HOME", sdkNdk.absolutePath)
}
commandLine(
"cargo", "ndk",
"-t", "arm64-v8a",
"--platform", "26",
"-o", jniLibsDir.asFile.absolutePath,
"build", "--release",
)
}
tasks.matching {
it.name in listOf(
"mergeDebugNativeLibs", "mergeReleaseNativeLibs",
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders",
)
}.configureEach { dependsOn("buildRustArm64") }
dependencies { dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.05.00") val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
implementation(composeBom) implementation(composeBom)

View File

@ -7,6 +7,10 @@
<!-- Pairing-QR scanner. Camera is optional: manual entry still works without one. --> <!-- Pairing-QR scanner. Camera is optional: manual entry still works without one. -->
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" /> <uses-feature android:name="android.hardware.camera.any" android:required="false" />
<!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".ArchipelagoApp" android:name=".ArchipelagoApp"
@ -39,6 +43,21 @@
<data android:scheme="archipelago" android:host="pair" /> <data android:scheme="archipelago" android:host="pair" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
configured entirely by scanning the node's pairing QR. -->
<service
android:name=".fips.ArchyVpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="mesh-vpn-tunnel" />
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>

View File

@ -19,25 +19,35 @@ data class ServerEntry(
val port: String = "", val port: String = "",
val password: String = "", val password: String = "",
val name: String = "", val name: String = "",
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
val meshIp: String = "",
) { ) {
/** Label to show in lists — the user-given name, or the address if unnamed. */ /** Label to show in lists — the user-given name, or the address if unnamed. */
fun displayName(): String = name.ifBlank { address } fun displayName(): String = name.ifBlank { address }
/** Bracket bare IPv6 literals (the mesh ULA) so they form valid URLs. */
private fun urlHost(host: String): String =
if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
fun toUrl(): String { fun toUrl(): String {
val scheme = if (useHttps) "https" else "http" val scheme = if (useHttps) "https" else "http"
val portSuffix = if (port.isNotBlank()) ":$port" else "" val portSuffix = if (port.isNotBlank()) ":$port" else ""
return "$scheme://$address$portSuffix" return "$scheme://${urlHost(address)}$portSuffix"
} }
fun toWsUrl(): String { fun toWsUrl(): String {
val scheme = if (useHttps) "wss" else "ws" val scheme = if (useHttps) "wss" else "ws"
val portSuffix = if (port.isNotBlank()) ":$port" else "" val portSuffix = if (port.isNotBlank()) ":$port" else ""
return "$scheme://$address$portSuffix" return "$scheme://${urlHost(address)}$portSuffix"
} }
// name is the trailing field so entries saved before naming existed /** Mesh-address UI URL, or null when the node never advertised one. */
// (4 fields) still deserialize, with name defaulting to "". fun toMeshUrl(): String? =
fun serialize(): String = "$address|$useHttps|$port|$password|$name" meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
// name/meshIp are trailing fields so entries saved before they existed
// (4/5 fields) still deserialize, defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp"
companion object { companion object {
fun deserialize(raw: String): ServerEntry? { fun deserialize(raw: String): ServerEntry? {
@ -49,6 +59,7 @@ data class ServerEntry(
port = parts.getOrElse(2) { "" }, port = parts.getOrElse(2) { "" },
password = parts.getOrElse(3) { "" }, password = parts.getOrElse(3) { "" },
name = parts.getOrElse(4) { "" }, name = parts.getOrElse(4) { "" },
meshIp = parts.getOrElse(5) { "" },
) )
} }
} }
@ -61,6 +72,7 @@ class ServerPreferences(private val context: Context) {
private val activePortKey = stringPreferencesKey("active_port") private val activePortKey = stringPreferencesKey("active_port")
private val activePasswordKey = stringPreferencesKey("active_password") private val activePasswordKey = stringPreferencesKey("active_password")
private val activeNameKey = stringPreferencesKey("active_name") private val activeNameKey = stringPreferencesKey("active_name")
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
private val savedServersKey = stringSetPreferencesKey("saved_servers") private val savedServersKey = stringSetPreferencesKey("saved_servers")
private val introSeenKey = booleanPreferencesKey("intro_seen") private val introSeenKey = booleanPreferencesKey("intro_seen")
@ -72,6 +84,7 @@ class ServerPreferences(private val context: Context) {
port = prefs[activePortKey] ?: "", port = prefs[activePortKey] ?: "",
password = prefs[activePasswordKey] ?: "", password = prefs[activePasswordKey] ?: "",
name = prefs[activeNameKey] ?: "", name = prefs[activeNameKey] ?: "",
meshIp = prefs[activeMeshIpKey] ?: "",
) )
} }
@ -91,6 +104,7 @@ class ServerPreferences(private val context: Context) {
prefs[activePortKey] = server.port prefs[activePortKey] = server.port
prefs[activePasswordKey] = server.password prefs[activePasswordKey] = server.password
prefs[activeNameKey] = server.name prefs[activeNameKey] = server.name
prefs[activeMeshIpKey] = server.meshIp
} }
addSavedServer(server) addSavedServer(server)
} }
@ -102,6 +116,7 @@ class ServerPreferences(private val context: Context) {
prefs.remove(activePortKey) prefs.remove(activePortKey)
prefs.remove(activePasswordKey) prefs.remove(activePasswordKey)
prefs.remove(activeNameKey) prefs.remove(activeNameKey)
prefs.remove(activeMeshIpKey)
} }
} }
@ -139,6 +154,7 @@ class ServerPreferences(private val context: Context) {
prefs[activePortKey] = updated.port prefs[activePortKey] = updated.port
prefs[activePasswordKey] = updated.password prefs[activePasswordKey] = updated.password
prefs[activeNameKey] = updated.name prefs[activeNameKey] = updated.name
prefs[activeMeshIpKey] = updated.meshIp
} }
} }
} }
@ -162,6 +178,7 @@ class ServerPreferences(private val context: Context) {
merged = server.copy( merged = server.copy(
password = server.password.ifBlank { existing.password }, password = server.password.ifBlank { existing.password },
name = server.name.ifBlank { existing.name }, name = server.name.ifBlank { existing.name },
meshIp = server.meshIp.ifBlank { existing.meshIp },
) )
} }
val filtered = current.filterNot { raw -> val filtered = current.filterNot { raw ->

View File

@ -1,6 +1,8 @@
package com.archipelago.app.data package com.archipelago.app.data
import android.net.Uri import android.net.Uri
import com.archipelago.app.fips.AnchorPeer
import com.archipelago.app.fips.FipsPairInfo
/** /**
* Result of parsing a pairing QR / deep link. * Result of parsing a pairing QR / deep link.
@ -10,7 +12,11 @@ import android.net.Uri
* user to update the app rather than call the code invalid. * user to update the app rather than call the code invalid.
*/ */
sealed class PairResult { sealed class PairResult {
data class Success(val server: ServerEntry) : PairResult() data class Success(
val server: ServerEntry,
/** Mesh info when the node advertises FIPS (fnpub present). */
val fips: FipsPairInfo? = null,
) : PairResult()
object UnsupportedVersion : PairResult() object UnsupportedVersion : PairResult()
object Invalid : PairResult() object Invalid : PairResult()
} }
@ -19,13 +25,18 @@ sealed class PairResult {
* Parser for the companion pairing QR / OS deep link. Contract: * Parser for the companion pairing QR / OS deep link. Contract:
* docs/companion-pairing-qr.md (repo root). * docs/companion-pairing-qr.md (repo root).
* *
* archipelago://pair?v=1&url=<percent-encoded origin>[&pw=<password>] * archipelago://pair?v=1&url=<origin>&name=…[&tok=…][&pw=…][&fnpub=…&fip=…&fhost=…&fudp=…&ftcp=…]
* *
* - `url` is a full origin including scheme (http for LAN/mDNS nodes, https * - `url` is a full origin including scheme (http for LAN/mDNS nodes, https
* for the public demo); trailing slashes are normalized away. * for the public demo); trailing slashes are normalized away.
* - `pw` is only ever present for the public demo. * - `tok` is a device token minted by the node; it goes through the password
* - Unknown extra query params are tolerated (forward compat under v=1 * field on purpose the whole password auto-login path (WebSocket auth +
* `name` is already honored if present). * WebView form injection) then works unchanged, and the backend accepts
* device tokens wherever it accepts the password. `pw` (demo only) wins if
* both are ever present.
* - `fnpub`/`fip`/`fhost`/`fudp`/`ftcp` describe the node's FIPS mesh; their
* absence just means LAN-only pairing (older node, or FIPS not provisioned).
* - Unknown extra query params are tolerated (forward compat under v=1).
*/ */
object ServerQrParser { object ServerQrParser {
private const val SUPPORTED_MAJOR = 1 private const val SUPPORTED_MAJOR = 1
@ -54,14 +65,51 @@ object ServerQrParser {
val host = server.host val host = server.host
if (host.isNullOrBlank()) return PairResult.Invalid if (host.isNullOrBlank()) return PairResult.Invalid
val fips = parseFips(uri)
val credential = uri.getQueryParameter("pw")?.takeIf { it.isNotBlank() }
?: uri.getQueryParameter("tok")
?: ""
return PairResult.Success( return PairResult.Success(
ServerEntry( server = ServerEntry(
address = host, address = host,
useHttps = scheme == "https", useHttps = scheme == "https",
port = if (server.port != -1) server.port.toString() else "", port = if (server.port != -1) server.port.toString() else "",
password = uri.getQueryParameter("pw") ?: "", password = credential,
name = uri.getQueryParameter("name") ?: "", name = uri.getQueryParameter("name") ?: "",
) meshIp = fips?.ula ?: "",
),
fips = fips,
) )
} }
private fun parseFips(uri: Uri): FipsPairInfo? {
val npub = uri.getQueryParameter("fnpub")?.trim()
val host = uri.getQueryParameter("fhost")?.trim()
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
return FipsPairInfo(
npub = npub,
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
host = host,
udpPort = uri.getQueryParameter("fudp")?.toIntOrNull() ?: 2121,
tcpPort = uri.getQueryParameter("ftcp")?.toIntOrNull() ?: 8443,
anchors = parseAnchors(uri.getQueryParameter("fanchors")),
)
}
/** `fanchors` = comma-joined `npub@host:port/transport`; bad items skipped. */
private fun parseAnchors(raw: String?): List<AnchorPeer> {
if (raw.isNullOrBlank()) return emptyList()
return raw.split(",").mapNotNull { item ->
val at = item.indexOf('@')
val slash = item.lastIndexOf('/')
if (at <= 0 || slash <= at) return@mapNotNull null
val npub = item.substring(0, at).trim()
val addr = item.substring(at + 1, slash).trim()
val transport = item.substring(slash + 1).trim().lowercase()
if (npub.isBlank() || !addr.contains(":")) return@mapNotNull null
if (transport != "udp" && transport != "tcp") return@mapNotNull null
AnchorPeer(npub = npub, addr = addr, transport = transport)
}
}
} }

View File

@ -0,0 +1,129 @@
package com.archipelago.app.fips
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.util.Log
import com.archipelago.app.MainActivity
import com.archipelago.app.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* VpnService hosting the embedded FIPS mesh node.
*
* Split tunnel: only fd00::/8 (the FIPS ULA space) routes into the TUN, so
* normal phone traffic is untouched this is mesh reachability, not a
* default-route VPN. The established fd is detached and handed to the Rust
* node (Node::start_with_tun_fd); the node owns it until stop.
*/
class ArchyVpnService : VpnService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
shutdown()
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, buildNotification())
scope.launch { startMesh() }
return START_STICKY
}
private suspend fun startMesh() {
if (!FipsNative.available) {
shutdown()
return
}
val prefs = FipsPreferences(this)
val identity = FipsManager.ensureIdentity(prefs)
val peersJson = prefs.peersJson()
if (identity == null || peersJson == "[]") {
Log.w(TAG, "mesh not configured — stopping")
shutdown()
return
}
// Re-establishing while running would strand the old fd; restart clean.
if (FipsNative.isRunning()) FipsNative.stop()
val pfd = try {
Builder()
.setSession("Archipelago Mesh")
.setMtu(1280)
.addAddress(identity.address, 128)
.addRoute("fd00::", 8)
.establish()
} catch (e: Exception) {
Log.e(TAG, "VPN establish failed", e)
null
}
if (pfd == null) {
shutdown()
return
}
val fd = pfd.detachFd()
val result = FipsNative.start(identity.secret, peersJson, fd)
Log.i(TAG, "mesh start: $result")
if (result.contains("\"error\"")) shutdown()
}
private fun shutdown() {
FipsNative.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
FipsNative.stop()
scope.cancel()
super.onDestroy()
}
override fun onRevoke() {
// User pulled VPN permission from system settings.
shutdown()
}
private fun buildNotification(): Notification {
val manager = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Mesh connection",
NotificationManager.IMPORTANCE_MIN,
).apply { description = "Keeps the node reachable from anywhere" }
)
}
val tapIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL_ID)
.setContentTitle("Connected to your Archipelago")
.setContentText("Secure mesh link active")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(tapIntent)
.setOngoing(true)
.build()
}
companion object {
const val ACTION_STOP = "com.archipelago.app.fips.STOP"
private const val CHANNEL_ID = "archy_mesh"
private const val NOTIFICATION_ID = 4841
private const val TAG = "ArchyVpnService"
}
}

View File

@ -0,0 +1,72 @@
package com.archipelago.app.fips
import android.content.Context
import android.content.Intent
import android.net.VpnService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Glue between pairing and the mesh: persists the node peer from a scanned
* QR and asks the UI to bring the tunnel up. There is deliberately no
* settings surface scanning a node's QR is the entire configuration.
*
* The one unavoidable interaction is Android's VPN consent dialog
* (VpnService.prepare), which only an Activity can launch; [consentNeeded]
* signals AppNavHost to run it, once, on first pairing.
*/
object FipsManager {
/** Set when a pairing registered mesh info and the VPN needs starting. */
private val _consentNeeded = MutableStateFlow(false)
val consentNeeded: StateFlow<Boolean> = _consentNeeded
fun consentHandled() {
_consentNeeded.value = false
}
/**
* Persist mesh info from a pairing scan and request tunnel start.
* No-op on devices without the native lib (non-arm64).
*/
suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) {
if (info == null || !FipsNative.available) return
val prefs = FipsPreferences(context)
ensureIdentity(prefs)
prefs.upsertNodePeer(info, alias)
_consentNeeded.value = true
}
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
suspend fun ensureIdentity(prefs: FipsPreferences): FipsNative.Identity? {
prefs.identity()?.let { return it }
val generated = FipsNative.parseIdentity(FipsNative.generateIdentity()) ?: return null
prefs.saveIdentity(generated)
return generated
}
/**
* Start the mesh service if this device is paired and the user has already
* consented to the VPN (prepare() == null). Called on app start so the
* tunnel comes back without any interaction; first-time consent goes
* through AppNavHost instead.
*/
suspend fun autoStartIfReady(context: Context) {
if (!FipsNative.available) return
val prefs = FipsPreferences(context)
if (prefs.identity() == null || !prefs.hasPeers()) return
if (VpnService.prepare(context) != null) return // consent missing — don't prompt here
startService(context)
}
fun startService(context: Context) {
val intent = Intent(context, ArchyVpnService::class.java)
context.startForegroundService(intent)
}
fun stopService(context: Context) {
val intent = Intent(context, ArchyVpnService::class.java)
.setAction(ArchyVpnService.ACTION_STOP)
context.startService(intent)
}
}

View File

@ -0,0 +1,43 @@
package com.archipelago.app.fips
import org.json.JSONObject
/**
* JNI binding to the embedded FIPS mesh node (Android/rust/archy-fips-core,
* built into libarchy_fips_core.so by the buildRustArm64 gradle task).
*
* All calls return JSON strings; failures come back as {"error": ""} rather
* than exceptions. [available] is false on ABIs the .so isn't built for
* (anything but arm64) every caller must gate on it so the app still runs
* as a plain companion there.
*/
object FipsNative {
val available: Boolean = try {
System.loadLibrary("archy_fips_core")
true
} catch (_: Throwable) {
false
}
external fun generateIdentity(): String
external fun deriveIdentity(secret: String): String
external fun start(secret: String, peersJson: String, tunFd: Int): String
external fun stop()
external fun isRunning(): Boolean
external fun statusJson(): String
data class Identity(val secret: String, val npub: String, val address: String)
/** Parse an identity JSON reply; null on {"error": …} or malformed. */
fun parseIdentity(json: String): Identity? = try {
val obj = JSONObject(json)
if (obj.has("error")) null
else Identity(
secret = obj.getString("secret"),
npub = obj.getString("npub"),
address = obj.getString("address"),
)
} catch (_: Exception) {
null
}
}

View File

@ -0,0 +1,29 @@
package com.archipelago.app.fips
/**
* Node mesh parameters carried by the pairing QR (fnpub/fip/fhost/fudp/ftcp
* docs/companion-pairing-qr.md). The phone's embedded FIPS node dials
* host:udpPort / host:tcpPort claiming nothing; the mesh accepts inbound peers
* without registration, so possession of these params is all pairing takes.
*/
data class FipsPairInfo(
val npub: String,
/** Node's fips0 ULA — where its UI stays reachable once meshed. May be empty. */
val ula: String,
val host: String,
val udpPort: Int,
val tcpPort: Int,
/**
* Public rendezvous anchors (the node's seed-anchor list). The phone
* peers with these too, so it can route to the node via the mesh when
* the node's LAN endpoint isn't directly dialable.
*/
val anchors: List<AnchorPeer> = emptyList(),
)
/** One rendezvous anchor from the QR's `fanchors` param (npub@addr/transport). */
data class AnchorPeer(
val npub: String,
val addr: String,
val transport: String,
)

View File

@ -0,0 +1,106 @@
package com.archipelago.app.fips
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
import org.json.JSONArray
import org.json.JSONObject
import androidx.datastore.preferences.preferencesDataStore
private val Context.fipsDataStore: DataStore<Preferences> by preferencesDataStore(name = "fips_prefs")
/**
* Mesh identity + known node peers. Follows the same plaintext-DataStore
* storage model as ServerPreferences (the server password lives there the
* same way); the mesh secret only grants mesh membership, not node login.
*/
class FipsPreferences(private val context: Context) {
private val secretKey = stringPreferencesKey("fips_secret")
private val npubKey = stringPreferencesKey("fips_npub")
private val addressKey = stringPreferencesKey("fips_address")
/** JSON array of node peers in fips PeerConfig shape (see NodePeer). */
private val peersKey = stringPreferencesKey("fips_node_peers")
suspend fun identity(): FipsNative.Identity? {
val prefs = context.fipsDataStore.data.first()
val secret = prefs[secretKey] ?: return null
return FipsNative.Identity(
secret = secret,
npub = prefs[npubKey] ?: "",
address = prefs[addressKey] ?: "",
)
}
suspend fun saveIdentity(identity: FipsNative.Identity) {
context.fipsDataStore.edit { prefs ->
prefs[secretKey] = identity.secret
prefs[npubKey] = identity.npub
prefs[addressKey] = identity.address
}
}
suspend fun peersJson(): String {
val prefs = context.fipsDataStore.data.first()
return prefs[peersKey] ?: "[]"
}
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
/**
* Add or update the node peer plus its rendezvous anchors (each matched
* by npub, so re-pairing updates addresses instead of duplicating).
* Stored directly in the fips PeerConfig JSON shape the Rust side
* deserializes. The node's direct addresses get the best priorities;
* anchors trail so the mesh prefers the direct path when it works.
*/
suspend fun upsertNodePeer(info: FipsPairInfo, alias: String) {
val incoming = mutableListOf<JSONObject>()
incoming += JSONObject().apply {
put("npub", info.npub)
put("alias", alias.ifBlank { "Archipelago" })
val addresses = JSONArray()
if (info.udpPort > 0) {
addresses.put(JSONObject().apply {
put("transport", "udp")
put("addr", "${info.host}:${info.udpPort}")
put("priority", 10)
})
}
if (info.tcpPort > 0) {
addresses.put(JSONObject().apply {
put("transport", "tcp")
put("addr", "${info.host}:${info.tcpPort}")
put("priority", 20)
})
}
put("addresses", addresses)
}
for (anchor in info.anchors) {
if (anchor.npub == info.npub) continue
incoming += JSONObject().apply {
put("npub", anchor.npub)
put("alias", "Mesh anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", anchor.transport)
put("addr", anchor.addr)
put("priority", 30)
}))
}
}
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
context.fipsDataStore.edit { prefs ->
val current = JSONArray(prefs[peersKey] ?: "[]")
val merged = JSONArray()
for (i in 0 until current.length()) {
val existing = current.optJSONObject(i) ?: continue
if (existing.optString("npub") !in incomingNpubs) merged.put(existing)
}
incoming.forEach { merged.put(it) }
prefs[peersKey] = merged.toString()
}
}
}

View File

@ -56,7 +56,6 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.archipelago.app.R import com.archipelago.app.R
import com.archipelago.app.data.PairResult import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerQrParser import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.ui.screens.GlassButton import com.archipelago.app.ui.screens.GlassButton
import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.BitcoinOrange
@ -82,7 +81,7 @@ import java.util.concurrent.Executors
fun QrScannerOverlay( fun QrScannerOverlay(
visible: Boolean, visible: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onServerScanned: (ServerEntry) -> Unit, onServerScanned: (PairResult.Success) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var hasPermission by remember { var hasPermission by remember {
@ -131,7 +130,7 @@ fun QrScannerOverlay(
when (val result = ServerQrParser.parse(text)) { when (val result = ServerQrParser.parse(text)) {
is PairResult.Success -> { is PairResult.Success -> {
handled = true handled = true
onServerScanned(result.server) onServerScanned(result)
} }
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr

View File

@ -1,5 +1,8 @@
package com.archipelago.app.ui.navigation package com.archipelago.app.ui.navigation
import android.net.VpnService
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -16,6 +19,7 @@ import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.data.ServerQrParser import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.screens.IntroScreen import com.archipelago.app.ui.screens.IntroScreen
import com.archipelago.app.ui.screens.RemoteInputScreen import com.archipelago.app.ui.screens.RemoteInputScreen
import com.archipelago.app.ui.screens.ServerConnectScreen import com.archipelago.app.ui.screens.ServerConnectScreen
@ -46,6 +50,34 @@ fun AppNavHost(
// connect form so the user lands on the password prompt for that server. // connect form so the user lands on the password prompt for that server.
var pairPrefill by remember { mutableStateOf<ServerEntry?>(null) } var pairPrefill by remember { mutableStateOf<ServerEntry?>(null) }
// Mesh tunnel: Android's VPN consent dialog is the single unavoidable
// interaction — it can only be launched from an Activity, so pairing
// paths raise FipsManager.consentNeeded and it is handled here, once.
val consentNeeded by FipsManager.consentNeeded.collectAsState()
val vpnConsentLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
FipsManager.consentHandled()
if (result.resultCode == android.app.Activity.RESULT_OK) {
FipsManager.startService(context)
}
}
LaunchedEffect(consentNeeded) {
if (!consentNeeded) return@LaunchedEffect
val consentIntent = VpnService.prepare(context)
if (consentIntent == null) {
FipsManager.consentHandled()
FipsManager.startService(context)
} else {
vpnConsentLauncher.launch(consentIntent)
}
}
// Paired + previously consented → the mesh comes back silently on launch.
LaunchedEffect(Unit) {
FipsManager.autoStartIfReady(context)
}
if (introSeen == null) return if (introSeen == null) return
// Declared after the introSeen gate so it can't fire before the NavHost // Declared after the introSeen gate so it can't fire before the NavHost
@ -58,6 +90,7 @@ fun AppNavHost(
// Pairing implies the app is installed and in use — skip the intro. // Pairing implies the app is installed and in use — skip the intro.
prefs.markIntroSeen() prefs.markIntroSeen()
val merged = prefs.upsertServer(result.server) val merged = prefs.upsertServer(result.server)
FipsManager.registerNode(context, result.fips, merged.displayName())
if (merged.password.isNotBlank()) { if (merged.password.isNotBlank()) {
// Demo flow: password came with the link — connect in one step. // Demo flow: password came with the link — connect in one step.
prefs.setActiveServer(merged) prefs.setActiveServer(merged)
@ -125,6 +158,7 @@ fun AppNavHost(
WebViewScreen( WebViewScreen(
serverUrl = server.toUrl(), serverUrl = server.toUrl(),
serverPassword = server.password, serverPassword = server.password,
meshFallbackUrl = server.toMeshUrl(),
onDisconnect = { onDisconnect = {
scope.launch { scope.launch {
prefs.clearActiveServer() prefs.clearActiveServer()

View File

@ -37,6 +37,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.archipelago.app.R import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.network.ConnectionState import com.archipelago.app.network.ConnectionState
import com.archipelago.app.network.InputWebSocket import com.archipelago.app.network.InputWebSocket
import com.archipelago.app.ui.components.NESController import com.archipelago.app.ui.components.NESController
@ -256,10 +257,11 @@ fun RemoteInputScreen(onBack: () -> Unit) {
QrScannerOverlay( QrScannerOverlay(
visible = showQrScanner, visible = showQrScanner,
onDismiss = { showQrScanner = false }, onDismiss = { showQrScanner = false },
onServerScanned = { server -> onServerScanned = { scan ->
showQrScanner = false showQrScanner = false
scope.launch { scope.launch {
val merged = prefs.upsertServer(server) val merged = prefs.upsertServer(scan.server)
FipsManager.registerNode(context, scan.fips, merged.displayName())
if (activeServer == null) prefs.setActiveServer(merged) if (activeServer == null) prefs.setActiveServer(merged)
} }
}, },

View File

@ -71,8 +71,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.archipelago.app.R import com.archipelago.app.R
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.QrScannerOverlay import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed import com.archipelago.app.ui.theme.ErrorRed
@ -190,12 +192,14 @@ fun ServerConnectScreen(
} }
// Pairing QR scanned: dedupe against saved servers, then either auto-connect // Pairing QR scanned: dedupe against saved servers, then either auto-connect
// (payload carried a password — the demo flow) or land on the password // (payload carried a credential — demo password or a real node's device
// prompt with everything else filled in (real nodes never embed one). // token) or land on the password prompt with everything else filled in.
fun onQrScanned(scanned: ServerEntry) { // Mesh info (when present) is registered so the FIPS tunnel comes up too.
fun onQrScanned(scan: PairResult.Success) {
showScanner = false showScanner = false
scope.launch { scope.launch {
val merged = prefs.upsertServer(scanned) val merged = prefs.upsertServer(scan.server)
FipsManager.registerNode(context, scan.fips, merged.displayName())
prefill(merged) prefill(merged)
if (merged.password.isNotBlank()) { if (merged.password.isNotBlank()) {
connect(merged) connect(merged)

View File

@ -146,9 +146,14 @@ fun WebViewScreen(
// non-blank, the login page is auto-filled and submitted — the one-step // non-blank, the login page is auto-filled and submitted — the one-step
// demo flow from docs/companion-pairing-qr.md. // demo flow from docs/companion-pairing-qr.md.
serverPassword: String = "", serverPassword: String = "",
// Node's FIPS mesh URL (http://[fd…]). When the primary address fails on
// a main-frame load — typically the phone left the LAN — retry there
// before surfacing the error page: the mesh tunnel works from anywhere.
meshFallbackUrl: String? = null,
) { ) {
var isLoading by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) }
var loadProgress by remember { mutableIntStateOf(0) } var loadProgress by remember { mutableIntStateOf(0) }
var triedMeshFallback by remember { mutableStateOf(false) }
var hasError by remember { mutableStateOf(false) } var hasError by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) } var webView by remember { mutableStateOf<WebView?>(null) }
@ -336,8 +341,13 @@ fun WebViewScreen(
) )
// Auto-login with the stored password (QR pairing / // Auto-login with the stored password (QR pairing /
// saved server) — only on our own server's pages. // saved server) — only on our own server's pages
if (serverPassword.isNotBlank() && url != null && url.startsWith(serverUrl)) { // (LAN origin or the mesh address).
val onOwnServer = url != null && (
url.startsWith(serverUrl) ||
(meshFallbackUrl != null && url.startsWith(meshFallbackUrl))
)
if (serverPassword.isNotBlank() && onOwnServer) {
view.evaluateJavascript(buildAutoLoginScript(serverPassword), null) view.evaluateJavascript(buildAutoLoginScript(serverPassword), null)
} }
} }
@ -348,6 +358,15 @@ fun WebViewScreen(
error: WebResourceError?, error: WebResourceError?,
) { ) {
if (request?.isForMainFrame == true) { if (request?.isForMainFrame == true) {
val mesh = meshFallbackUrl
if (mesh != null && !triedMeshFallback &&
request.url?.toString()?.startsWith(mesh) != true
) {
triedMeshFallback = true
isLoading = true
view?.loadUrl(mesh)
return
}
hasError = true hasError = true
isLoading = false isLoading = false
} }
@ -380,6 +399,8 @@ fun WebViewScreen(
val url = request?.url?.toString() ?: return false val url = request?.url?.toString() ?: return false
// Keep kiosk navigation (same origin incl. port) in place // Keep kiosk navigation (same origin incl. port) in place
if (url.startsWith(serverUrl)) return false if (url.startsWith(serverUrl)) return false
// Mesh address is the same server too
if (meshFallbackUrl != null && url.startsWith(meshFallbackUrl)) return false
// Same node (other port) → in-app; external → browser // Same node (other port) → in-app; external → browser
routeOutbound(url) routeOutbound(url)
return true return true

View File

@ -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]

View File

@ -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())
}

View File

@ -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;

View File

@ -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());
}
}

View File

@ -49,6 +49,15 @@ impl RpcHandler {
let valid = self.auth_manager.verify_password(password).await?; let valid = self.auth_manager.verify_password(password).await?;
if !valid { 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"); tracing::warn!("[onboarding] login failed — wrong password");
return Err(anyhow::anyhow!("Password Incorrect")); return Err(anyhow::anyhow!("Password Incorrect"));
} }

View File

@ -27,11 +27,27 @@ impl RpcHandler {
anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first") anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first")
})?; })?;
let ula = fips::iface::fips0_ula().map(|ip| ip.to_string()); 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!({ Ok(serde_json::json!({
"npub": npub, "npub": npub,
"ula": ula, "ula": ula,
"udp_port": fips::PUBLISHED_UDP_PORT, "udp_port": fips::PUBLISHED_UDP_PORT,
"tcp_port": fips::DEFAULT_TCP_PORT, "tcp_port": fips::DEFAULT_TCP_PORT,
"anchors": anchors,
})) }))
} }

View File

@ -537,11 +537,24 @@ impl RpcHandler {
// the token was minted from an already-authenticated session, and there // the token was minted from an already-authenticated session, and there
// is no password with which to decrypt the TOTP secret anyway. // is no password with which to decrypt the TOTP secret anyway.
if method == "auth.login" && rpc_resp.error.is_none() { 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 let is_token_login = login_params
.as_ref() .as_ref()
.and_then(|p| p.get("token")) .and_then(|p| p.get("token"))
.and_then(|v| v.as_str()) .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 let totp_enabled = !is_token_login
&& self.auth_manager.is_totp_enabled().await.unwrap_or(false); && self.auth_manager.is_totp_enabled().await.unwrap_or(false);
if totp_enabled { if totp_enabled {

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 /// The default anchor set carried implicitly by `load()` on nodes that have
/// never edited their anchor list, so every node dials them without operator /// never edited their anchor list, so every node dials them without operator
/// action. Multiple anchors so one unreachable rendezvous host can't strand a /// 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 /// network can reach wins. The Archipelago-operated anchor is listed first
/// because it is reachable from the widest set of networks. /// because it is reachable from the widest set of networks.
pub fn default_public_anchors() -> Vec<SeedAnchor> { 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 /// One seed-anchor entry. `address` must be directly dialable (IP or

View File

@ -38,6 +38,7 @@ Query parameters:
| `fip` | no | Node's `fips0` ULA (IPv6). Once the phone is meshed, the node's UI stays reachable at `http://[<fip>]` from anywhere — this is the remote-access address (replaces the old WireGuard 10.44.0.1 flow). | | `fip` | no | Node's `fips0` ULA (IPv6). Once the phone is meshed, the node's UI stays reachable at `http://[<fip>]` from anywhere — this is the remote-access address (replaces the old WireGuard 10.44.0.1 flow). |
| `fhost` | no | Host the phone's embedded FIPS dials (same host `url` resolved to). | | `fhost` | no | Host the phone's embedded FIPS dials (same host `url` resolved to). |
| `fudp` / `ftcp` | no | Mesh transport ports on `fhost` (currently 2121/udp and 8443/tcp). | | `fudp` / `ftcp` | no | Mesh transport ports on `fhost` (currently 2121/udp and 8443/tcp). |
| `fanchors` | no | Comma-joined `npub@host:port/transport` rendezvous anchors (the node's seed-anchor list, capped at 4). The phone peers with these too so it can route to the node via the public mesh when the direct endpoint is unreachable (away from home / NAT). |
Examples the web UI actually emits: Examples the web UI actually emits:
@ -71,12 +72,20 @@ Examples the web UI actually emits:
the parse on scheme. the parse on scheme.
- Bad/foreign QR → clear error, stay on the scan screen. - Bad/foreign QR → clear error, stay on the scan screen.
## Notes / future extensions (not in v1) ## Landed extensions (2026-07-22)
- A real-node pairing **token** instead of a password (backend mints a one-time - **Device token** (`tok`) — real nodes now pair instantly; see the param table.
token, QR carries it, app exchanges it for a session) — needs a backend RPC; Minting replaces the previous `companion` token, so merely re-opening the pair
the `v` param exists so we can bump when this lands. screen invalidates a previously issued token (the phone's session/remember
- Optional `name` param (node display name) so the app labels the entry nicely. cookies keep working; a re-scan re-pairs).
- **`name`** — the app labels the entry with the node's server name
("My Archipelago" when unset).
- **FIPS mesh params** (`fnpub`/`fip`/`fhost`/`fudp`/`ftcp`/`fanchors`) — the
companion app embeds a leaf-only FIPS node (Android/rust/archy-fips-core)
behind a split-tunnel VpnService and dials the node + rendezvous anchors on
scan. This replaces the WireGuard install/tunnel onboarding screens entirely;
remote access = the node's fips0 ULA (`fip`), which the WebView falls back to
automatically when the LAN address stops answering.
## Testing checklist (app side) ## Testing checklist (app side)

View File

@ -317,6 +317,7 @@ async function buildPairingUrl(): Promise<string> {
ula?: string | null ula?: string | null
udp_port?: number udp_port?: number
tcp_port?: number tcp_port?: number
anchors?: { npub: string; addr: string; transport: string }[]
}>({ method: 'fips.pair-info' }) }>({ method: 'fips.pair-info' })
if (info?.npub) { if (info?.npub) {
params.set('fnpub', info.npub) params.set('fnpub', info.npub)
@ -328,6 +329,16 @@ async function buildPairingUrl(): Promise<string> {
} }
if (info.udp_port) params.set('fudp', String(info.udp_port)) if (info.udp_port) params.set('fudp', String(info.udp_port))
if (info.tcp_port) params.set('ftcp', String(info.tcp_port)) if (info.tcp_port) params.set('ftcp', String(info.tcp_port))
// Rendezvous anchors (compact: npub@addr/transport, comma-joined).
// Cap keeps the QR at a camera-friendly density; the node lists its
// most reachable anchors first.
const anchors = (info.anchors || []).slice(0, 4)
if (anchors.length) {
params.set(
'fanchors',
anchors.map((a) => `${a.npub}@${a.addr}/${a.transport}`).join(','),
)
}
} }
} catch { } catch {
// FIPS not provisioned yet LAN pairing still works; the app can mesh // FIPS not provisioned yet LAN pairing still works; the app can mesh