Compare commits

..
Author SHA1 Message Date
Archipelago e16b389f04 Archipelago — open-source initial import 2026-08-12 10:55:49 +00:00
321 changed files with 1107 additions and 17708 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 48
versionName = "0.5.28"
versionCode = 47
versionName = "0.5.27"
vectorDrawables {
useSupportLibrary = true
-9
View File
@@ -54,15 +54,6 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="archipelago" android:host="pair" />
</intent-filter>
<!-- Remote-signer pairing deep link (NIP-46, companion 0.5.28):
nostrconnect://<client-pubkey>?relay=...&secret=... — the
node's login QR, hand-off from any QR scanner app. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrconnect" />
</intent-filter>
</activity>
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
@@ -1,69 +0,0 @@
package com.archipelago.app
import org.json.JSONObject
/**
* JNI binding to the companion's non-mesh native surface (same
* libarchy_fips_core.so as FipsNative — backup + nostr signer crypto, built
* from Android/rust/archy-fips-core).
*
* Same contract as FipsNative: JSON over strings, failures come back as
* {"error": "…"} rather than exceptions, and [available] is false on ABIs
* the .so isn't built for so every caller can degrade gracefully.
*/
object NativeCore {
val available: Boolean = try {
System.loadLibrary("archy_fips_core")
true
} catch (_: Throwable) {
false
}
// ── Backup (#128): the node's ADR-005 envelope ──────────────────────────
/** Encrypt a JSON payload into an ADR-005 envelope (ChaCha20-Poly1305). */
external fun backupEncrypt(payload: String, passphrase: String): String
/** Decrypt an ADR-005 envelope back to its payload JSON. */
external fun backupDecrypt(envelope: String, passphrase: String): String
// ── NIP-46 remote signer (#139) ─────────────────────────────────────────
/** Generate a fresh nostr key: {"secret","pubkey","npub","nsec"}. */
external fun nostrGenerateSecret(): String
/** Import a key from hex or nsec…: {"secret","pubkey","npub","nsec"}. */
external fun nostrSecretFromAny(secret: String): String
/** Parse nostrconnect://…: {"clientPubkey","relays":[…],"secret","perms","name","url","image"}. */
external fun nostrParseConnectUri(uri: String): String
/**
* Sign `{kind, content, tags, created_at}` with the signer key: returns
* the full signed event JSON. Approval happens BEFORE this call — the
* native side never signs unasked.
*/
external fun nostrSignEvent(secretHex: String, eventJson: String): String
/** NIP-44 v2 encrypt/decrypt; result JSON: {"result": payload} or {"error": …}. */
external fun nostrNip44Encrypt(secretHex: String, peerPub: String, plaintext: String): String
external fun nostrNip44Decrypt(secretHex: String, peerPub: String, payload: String): String
/** NIP-04 fallback (deprecated but still spoken by real clients). */
external fun nostrNip04Encrypt(secretHex: String, peerPub: String, plaintext: String): String
external fun nostrNip04Decrypt(secretHex: String, peerPub: String, payload: String): String
/** True when a native reply is an error envelope. */
fun isErr(json: String): Boolean = try {
JSONObject(json).has("error")
} catch (_: Exception) {
true
}
/** Error text from a native reply, or a generic message if malformed. */
fun errMsg(json: String): String = try {
JSONObject(json).optString("error", "native call failed")
} catch (_: Exception) {
"native call failed"
}
}
@@ -1,229 +0,0 @@
package com.archipelago.app.data
import android.content.Context
import com.archipelago.app.NativeCore
import com.archipelago.app.fips.FipsPreferences
import com.archipelago.app.nostr.NostrSignerPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
/**
* Companion backup & restore (#128) — the phone side of "losing your phone,
* or wiping it to cross a border".
*
* The payload (servers + FIPS identity/peers + signer key + flags) is
* serialized to JSON and sealed into the node's ADR-005 envelope (Argon2id +
* ChaCha20-Poly1305) by the native core — the SAME envelope the node uses,
* not a second format. The passphrase never leaves the encrypt call.
*
* Transport is deliberately boring: a plain .json file the user saves via
* the system file picker (SAF) — on GrapheneOS there is no cloud backup and
* there should be none here either; the file goes wherever the user puts it
* (USB drive, computer, a folder synced their way).
*/
class BackupManager(private val context: Context) {
private val servers = ServerPreferences(context)
private val fips = FipsPreferences(context)
private val signer = NostrSignerPreferences(context)
/** Everything the backup captures, for the restore preview UI. */
data class PayloadSummary(
val serverCount: Int,
val hasFipsIdentity: Boolean,
val hasSignerKey: Boolean,
val appVersion: String,
)
/** What a restore actually did, for the result UI. */
data class RestoreResult(
val serversRestored: Int,
val activeSet: Boolean,
val fipsIdentityRestored: Boolean,
val signerKeyRestored: Boolean,
)
private fun appVersion(): String = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: ""
} catch (_: Exception) {
""
}
/**
* Assemble the encrypted backup envelope. Runs on IO: DataStore reads
* plus the Argon2id KDF (tens of ms) + AEAD.
*/
suspend fun createBackup(passphrase: String): String = withContext(Dispatchers.IO) {
require(passphrase.isNotEmpty()) { "passphrase required" }
val active = servers.activeServer.first()
val saved = servers.savedServers.first()
val fipsId = fips.identity()
val peers = fips.peersJson()
val partyPeers = fips.partyPeers()
val partyName = fips.partyName()
val partyListen = fips.partyListen()
val signerSecret = signer.secret()
val payload = JSONObject().apply {
put("app", "archipelago-companion")
put("payloadVersion", 1)
put("appVersion", appVersion())
put("createdAt", System.currentTimeMillis() / 1000)
put("servers", JSONArray(saved.map { it.serialize() }))
put("active", active?.serialize() ?: JSONObject.NULL)
if (fipsId != null) {
put("fips", JSONObject().apply {
put("secret", fipsId.secret)
put("npub", fipsId.npub)
put("address", fipsId.address)
put("peers", JSONArray(peers))
put("partyPeers", JSONArray().apply { partyPeers.forEach { put(JSONObject().apply {
put("npub", it.npub); put("ula", it.ula); put("name", it.name)
put("ip", it.ip); put("port", it.port)
}) } })
put("partyName", partyName)
put("partyListen", partyListen)
})
}
if (signerSecret != null) {
put("signer", JSONObject().apply { put("secret", signerSecret) })
}
put("flags", JSONObject().apply {
put("introSeen", servers.introSeen.first())
})
}
val envelope = NativeCore.backupEncrypt(payload.toString(), passphrase)
if (NativeCore.isErr(envelope)) throw BackupException(NativeCore.errMsg(envelope))
envelope
}
/**
* Peek at a decrypted backup (passphrase already checked) to preview what
* a restore would do. Does NOT touch any stored state.
*/
suspend fun readBackup(envelope: String, passphrase: String): Pair<PayloadSummary, JSONObject> =
withContext(Dispatchers.IO) {
val payload = NativeCore.backupDecrypt(envelope, passphrase)
if (NativeCore.isErr(payload)) throw BackupException(NativeCore.errMsg(payload))
val obj = JSONObject(payload)
if (obj.optString("app") != "archipelago-companion") {
throw BackupException("Not a companion backup (this may be a node backup — restore it on the node)")
}
val summary = PayloadSummary(
serverCount = obj.optJSONArray("servers")?.length() ?: 0,
hasFipsIdentity = obj.has("fips"),
hasSignerKey = obj.has("signer"),
appVersion = obj.optString("appVersion", ""),
)
summary to obj
}
/**
* Apply a decrypted backup to this install. Merge semantics — a restore
* never silently destroys what's already here:
*
* - Servers upsert (npub-first, [ServerPreferences.upsertServer]) — same
* identity merges, never duplicates.
* - The backup's active server is set active only when none is.
* - FIPS identity/peers restore only when this phone has none (a phone
* that already paired has a live identity the node peers with; swapping
* it from a backup would strand the current pairing). Peers merge by
* npub otherwise.
* - Signer key restores only when none exists locally.
*/
suspend fun restoreBackup(payload: JSONObject): RestoreResult = withContext(Dispatchers.IO) {
val serverArray = payload.optJSONArray("servers") ?: JSONArray()
var restored = 0
for (i in 0 until serverArray.length()) {
val raw = serverArray.optString(i)
val entry = ServerEntry.deserialize(raw) ?: continue
servers.upsertServer(entry)
restored++
}
var activeSet = false
val activeStr = if (payload.isNull("active")) null else payload.optString("active", "")
val activeEntry = activeStr?.takeIf { it.isNotBlank() }?.let { ServerEntry.deserialize(it) }
if (activeEntry != null && servers.activeServer.first() == null) {
servers.setActiveServer(activeEntry)
activeSet = true
}
// FIPS identity: only adopt when this phone has none.
var fipsRestored = false
val fipsObj = payload.optJSONObject("fips")
if (fipsObj != null && fips.identity() == null) {
val secret = fipsObj.optString("secret")
if (secret.isNotBlank()) {
fips.saveIdentity(
com.archipelago.app.fips.FipsNative.Identity(
secret = secret,
npub = fipsObj.optString("npub"),
address = fipsObj.optString("address"),
)
)
fipsRestored = true
}
// Peers: union by npub with whatever is already here (an empty
// store takes the backup's list wholesale).
val backupPeers = fipsObj.optJSONArray("peers")?.let { arr ->
(0 until arr.length()).joinToString(",", "[", "]") { arr.optString(it) }
} ?: "[]"
fips.mergePeersJson(backupPeers)
val partyArr = fipsObj.optJSONArray("partyPeers")
if (partyArr != null) {
for (i in 0 until partyArr.length()) {
val p = partyArr.optJSONObject(i) ?: continue
val npub = p.optString("npub")
val ula = p.optString("ula")
if (npub.isNotBlank() && ula.isNotBlank()) {
fips.upsertPartyPeer(
com.archipelago.app.fips.PartyPeer(
npub = npub, ula = ula,
name = p.optString("name").ifBlank { "Phone" },
ip = p.optString("ip"), port = p.optInt("port"),
)
)
}
}
}
if (fipsObj.optString("partyName").isNotBlank()) {
fips.setPartyName(fipsObj.optString("partyName"))
}
fips.setPartyListen(fipsObj.optBoolean("partyListen", false))
}
// Signer key: only adopt when none exists locally.
var signerRestored = false
val signerObj = payload.optJSONObject("signer")
if (signerObj != null && signer.secret() == null) {
val secret = signerObj.optString("secret")
if (secret.isNotBlank()) {
signer.saveSecret(secret)
signerRestored = true
}
}
// Flags: a user who completed the intro on the old phone shouldn't
// see it again on the new one.
val flags = payload.optJSONObject("flags")
if (flags?.optBoolean("introSeen", false) == true) {
servers.markIntroSeen()
}
RestoreResult(
serversRestored = restored,
activeSet = activeSet,
fipsIdentityRestored = fipsRestored,
signerKeyRestored = signerRestored,
)
}
class BackupException(message: String) : Exception(message)
}
@@ -89,38 +89,6 @@ class FipsPreferences(private val context: Context) {
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
/**
* Union the stored node peers with a backup's peer list, matched by
* npub — the backup's copy wins for the same npub (its addresses are what
* the restored identity pairs against). Used by companion restore (#128)
* after [saveIdentity] adopted the backup's mesh identity.
*/
suspend fun mergePeersJson(incomingJson: String) {
context.fipsDataStore.edit { prefs ->
val current = JSONArray(prefs[peersKey] ?: "[]")
val incoming = try {
JSONArray(incomingJson)
} catch (_: Exception) {
JSONArray()
}
val incomingNpubs = mutableSetOf<String>()
val merged = JSONArray()
for (i in 0 until incoming.length()) {
val peer = incoming.optJSONObject(i) ?: continue
val npub = peer.optString("npub")
if (npub.isNotBlank()) {
incomingNpubs.add(npub)
merged.put(peer)
}
}
for (i in 0 until current.length()) {
val peer = current.optJSONObject(i) ?: continue
if (peer.optString("npub") !in incomingNpubs) merged.put(peer)
}
prefs[peersKey] = merged.toString()
}
}
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
suspend fun partyListen(): Boolean =
@@ -1,445 +0,0 @@
package com.archipelago.app.nostr
import android.content.Context
import com.archipelago.app.NativeCore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONArray
import org.json.JSONObject
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* NIP-46 remote-signer session (#139) — the phone side, wire-faithful to
* rust-nostr's reference bunker (`signer/nostr-connect/src/signer.rs`),
* which the node's login flow will interoperate with:
*
* 1. Client (the node's login page) shows a `nostrconnect://` QR.
* 2. We scan it, connect to its relay, subscribe to kind-24133 events
* p-tagged to our signer key, and send a `connect` request carrying the
* secret (the client validates it and answers "ack").
* 3. Requests arrive as NIP-44-encrypted kind-24133 events; we respond over
* the same channel. `sign_event` is the one method that never runs
* without a human tapping Approve on this phone.
*
* The session lives while the app is around (the login handshake takes
* seconds); there is no background service in v1 and no remembered-session
* auto-reconnect (research doc flow C — deferred deliberately).
*/
object BunkerManager {
sealed class SignerState {
/** Native core unavailable (e.g. x86 emulator) — signing impossible. */
object Unavailable : SignerState()
/** Key exists, no session. */
object Idle : SignerState()
/** No signer key generated/imported yet. */
object NoKey : SignerState()
data class Connecting(val relay: String) : SignerState()
/** Connect request sent; waiting for the client to ack. */
data class AwaitingClient(val relay: String, val clientName: String) : SignerState()
/** Handshake complete — this is the state where requests are answered. */
data class Ready(val relay: String, val clientName: String) : SignerState()
data class Failed(val reason: String) : SignerState()
}
/** One signature request awaiting a human decision. */
data class PendingRequest(
val id: String,
val method: String,
val clientPubkey: String,
val clientName: String,
val kind: Long?,
val content: String?,
/** Formatted tag lines for the approval card. */
val tags: List<String>,
val createdAt: Long?,
/** The full unsigned event JSON handed to the native signer on approve. */
val unsignedEventJson: String,
)
private val _state = MutableStateFlow<SignerState>(SignerState.Idle)
val state: StateFlow<SignerState> = _state.asStateFlow()
private val _pending = MutableStateFlow<PendingRequest?>(null)
val pending: StateFlow<PendingRequest?> = _pending.asStateFlow()
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.pingInterval(25, TimeUnit.SECONDS) // relay keepalive
.build()
private data class Session(
val socket: WebSocket,
val relay: String,
/** The client's pubkey (hex) from the nostrconnect URI. */
val clientPubkey: String,
val clientName: String,
/** The pairing secret — echoed back during handshake, then kept for
* validating an incoming `connect` from the same client. */
val secret: String,
/** Our connect request id, to match the client's ack response. */
val connectRequestId: String,
/** Our signer secret (hex). */
val signerSecretHex: String,
/** Our signer pubkey (hex). */
val signerPubkeyHex: String,
/** Event ids already handled (relays may redeliver). */
val seen: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet(),
)
private val session = AtomicReference<Session?>(null)
/** Refresh Idle/NoKey state (suspend; call from a coroutine — DataStore reads hit disk). */
suspend fun refreshState(context: Context) {
if (!NativeCore.available) {
_state.value = SignerState.Unavailable
return
}
if (session.get() != null) return
val prefs = NostrSignerPreferences(context.applicationContext)
_state.value =
if (prefs.secret() == null) SignerState.NoKey else SignerState.Idle
}
/**
* Pair from a scanned or deep-linked `nostrconnect://…` URI. Returns a
* user-presentable error on failure, or null on success (state moves to
* Connecting → AwaitingClient).
*/
suspend fun pair(context: Context, uri: String): String? {
if (!NativeCore.available) return "Signing is unavailable on this device"
val appContext = context.applicationContext
return withContext(Dispatchers.IO) {
val parsed = JSONObject(NativeCore.nostrParseConnectUri(uri.trim()))
if (parsed.has("error")) return@withContext parsed.getString("error")
val prefs = NostrSignerPreferences(appContext)
val secret = prefs.secret()
?: return@withContext "No signer key yet — generate or import one first"
val info = JSONObject(NativeCore.nostrSecretFromAny(secret))
if (info.has("error")) return@withContext info.getString("error")
val clientPubkey = parsed.getString("clientPubkey")
val relays = mutableListOf<String>()
parsed.optJSONArray("relays")?.let { arr -> for (i in 0 until arr.length()) relays.add(arr.optString(i)) }
val clientName = parsed.optString("name").ifBlank { "client" }
val pairSecret = parsed.getString("secret")
if (relays.isEmpty()) return@withContext "The pairing code carries no relay to reach the client on"
teardown()
var lastError = "no relay could be reached"
for (relay in relays) {
_state.value = SignerState.Connecting(relay)
val opened = openSession(
relay, clientPubkey, clientName, pairSecret, secret, info,
)
if (opened != null) {
session.set(opened)
prefs.savePairing(
NostrSignerPreferences.Pairing(clientPubkey, relay, clientName)
)
_state.value = SignerState.AwaitingClient(relay, clientName)
return@withContext null
}
lastError = "relay $relay did not answer"
}
_state.value = SignerState.Failed(lastError)
lastError
}
}
/** Re-establish the last saved pairing without a fresh QR. */
suspend fun resume(context: Context): String? {
if (!NativeCore.available) return "Signing is unavailable on this device"
val appContext = context.applicationContext
return withContext(Dispatchers.IO) {
val prefs = NostrSignerPreferences(appContext)
val pairing = prefs.lastPairing()
?: return@withContext "Nothing to resume — no saved pairing"
val secret = prefs.secret()
?: return@withContext "No signer key"
val info = JSONObject(NativeCore.nostrSecretFromAny(secret))
if (info.has("error")) return@withContext info.getString("error")
teardown()
_state.value = SignerState.Connecting(pairing.relay)
val opened = openSession(
pairing.relay, pairing.clientPubkey, pairing.name,
secret = "", signerSecretHex = secret, info = info,
)
if (opened == null) {
_state.value = SignerState.Failed("relay ${pairing.relay} did not answer")
return@withContext "Could not reach ${pairing.relay}"
}
session.set(opened)
_state.value = SignerState.AwaitingClient(pairing.relay, pairing.name)
null
}
}
fun unpair() {
teardown()
_state.value = SignerState.Idle
}
private fun teardown() {
session.getAndSet(null)?.socket?.close(1000, "unpaired")
_pending.value = null
}
private fun randomId(): String {
val bytes = ByteArray(8)
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
private fun openSession(
relay: String,
clientPubkey: String,
clientName: String,
secret: String,
signerSecretHex: String,
info: JSONObject,
): Session? {
val signerPubkeyHex = info.getString("pubkey")
val connectRequestId = randomId()
// The listener needs the Session, the Session needs the WebSocket:
// bind through a holder set right after newWebSocket returns (OkHttp
// invokes onOpen on its own dispatcher after the network round-trip,
// i.e. always after the bind below).
val holder = AtomicReference<Session?>()
val request = Request.Builder().url(relay).build()
val socket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
val s = holder.get() ?: return
// Subscribe to requests addressed to us (p-tag filter), from
// now — no history replay of stale login attempts.
webSocket.send(
"""["REQ","${s.connectRequestId}sub",{"kinds":[24133],"#p":["${s.signerPubkeyHex}"],"since":${epochSecs() - 120}}]"""
)
// Handshake: the signer sends `connect` carrying the secret
// (rust-nostr's NostrConnectRemoteSigner.send_connect_ack —
// the exact frame the node's client waits for).
val content = JSONObject().apply {
put("id", s.connectRequestId)
put("method", "connect")
put("params", JSONArray().put(s.signerPubkeyHex).put(s.secret))
}.toString()
if (!sendEncrypted(s, content)) {
_state.value = SignerState.Failed("Could not encrypt the connect message")
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
val s = session.get() ?: return
handleRelayMessage(s, text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (session.get()?.socket === webSocket) {
_state.value = SignerState.Failed(t.message ?: "relay connection failed")
session.getAndSet(null)
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (session.get()?.socket === webSocket) {
_state.value = SignerState.Idle
session.getAndSet(null)
}
}
})
val s = Session(
socket = socket,
relay = relay,
clientPubkey = clientPubkey,
clientName = clientName,
secret = secret,
connectRequestId = connectRequestId,
signerSecretHex = signerSecretHex,
signerPubkeyHex = signerPubkeyHex,
)
holder.set(s)
return s
}
private fun epochSecs(): Long = System.currentTimeMillis() / 1000
/** Encrypt a JSON-RPC frame to the peer and publish it as kind 24133. */
private fun sendEncrypted(s: Session, json: String): Boolean {
val enc = NativeCore.nostrNip44Encrypt(s.signerSecretHex, s.clientPubkey, json)
if (NativeCore.isErr(enc)) return false
val payload = JSONObject(enc).getString("result")
val event = JSONObject().apply {
put("kind", 24133)
put("content", payload)
put("tags", JSONArray().put(JSONArray().put("p").put(s.clientPubkey)))
put("created_at", epochSecs())
}.toString()
val signed = NativeCore.nostrSignEvent(s.signerSecretHex, event)
if (NativeCore.isErr(signed)) return false
return s.socket.send("""["EVENT",$signed]""")
}
private fun handleRelayMessage(s: Session, text: String) {
val arr = try {
JSONArray(text)
} catch (_: Exception) {
return
}
if (arr.length() == 0) return
when (arr.optString(0)) {
"EVENT" -> {
val event = arr.optJSONObject(2) ?: return
if (event.optLong("kind") != 24133L) return
val id = event.optString("id")
if (id.isNotEmpty() && !s.seen.add(id)) return
val author = event.optString("pubkey")
if (author != s.clientPubkey) return // not our client
handleClientEvent(s, author, event.optString("content"))
}
// OK / CLOSED / NOTICE: nothing actionable for the bunker in v1.
}
}
private fun handleClientEvent(s: Session, author: String, content: String) {
// NIP-44 is the mandated transport; NIP-04 stays as receive fallback
// for clients that still speak the deprecated scheme.
val plain = run {
val nip44 = NativeCore.nostrNip44Decrypt(s.signerSecretHex, author, content)
if (!NativeCore.isErr(nip44)) JSONObject(nip44).getString("result") else {
val nip04 = NativeCore.nostrNip04Decrypt(s.signerSecretHex, author, content)
if (!NativeCore.isErr(nip04)) JSONObject(nip04).getString("result") else return
}
}
val msg = try {
JSONObject(plain)
} catch (_: Exception) {
return
}
val id = msg.optString("id")
val method = msg.optString("method", "")
if (method.isNotEmpty()) {
when (method) {
"connect" -> {
val params = msg.optJSONArray("params") ?: return
// Param 0 must be OUR pubkey (client is connecting to us,
// not some other bunker through this session).
val target = params.optString(0)
val givenSecret = params.optString(1)
val authorized = target == s.signerPubkeyHex &&
(s.secret.isBlank() || givenSecret == s.secret || givenSecret.isBlank())
if (authorized) {
respond(s, id, result = "ack")
_state.value = SignerState.Ready(s.relay, s.clientName)
} else {
respond(s, id, error = "unauthorized")
}
}
"get_public_key" -> respond(s, id, result = s.signerPubkeyHex)
"describe" -> respond(s, id, result = "connect get_public_key sign_event ping")
"ping" -> respond(s, id, result = "pong")
"sign_event" -> {
val params = msg.optJSONArray("params") ?: return
val eventJson = params.optString(0)
val ev = try {
JSONObject(eventJson)
} catch (_: Exception) {
respond(s, id, error = "malformed event")
return
}
// Never overwrite a pending request silently — a second
// tap on the node would otherwise cancel the visible one.
if (_pending.value == null) {
_pending.value = PendingRequest(
id = id,
method = method,
clientPubkey = author,
clientName = s.clientName,
kind = if (ev.has("kind") && !ev.isNull("kind")) ev.optLong("kind") else null,
content = if (ev.has("content") && !ev.isNull("content")) ev.optString("content") else null,
tags = formatTags(ev.optJSONArray("tags")),
createdAt = if (ev.has("created_at") && !ev.isNull("created_at")) ev.optLong("created_at") else null,
unsignedEventJson = eventJson,
)
} else {
respond(s, id, error = "busy")
}
}
else -> respond(s, id, error = "not authorized")
}
} else if (msg.has("result") || msg.has("error")) {
// A response to OUR connect request (the client's ack).
if (id == s.connectRequestId) {
if (msg.has("error")) {
_state.value = SignerState.Failed("Client rejected the connection: ${msg.optString("error")}")
} else if (msg.optString("result") == "ack") {
_state.value = SignerState.Ready(s.relay, s.clientName)
}
}
}
}
/** Approve the pending request: sign and send the result. */
suspend fun approve(): Boolean {
val s = session.get() ?: return false
val req = _pending.value ?: return false
val ok = withContext(Dispatchers.IO) {
val signed = NativeCore.nostrSignEvent(s.signerSecretHex, req.unsignedEventJson)
if (NativeCore.isErr(signed)) {
respond(s, req.id, error = "signing failed")
false
} else {
// Result is the signed event, JSON-stringified per the spec.
respond(s, req.id, result = signed)
}
}
_pending.value = null
return ok
}
/** Deny the pending request with an explicit error. */
fun deny() {
val s = session.get() ?: return
val req = _pending.value ?: return
respond(s, req.id, error = "denied")
_pending.value = null
}
/** Send a JSON-RPC response frame to the client. True when the WS send worked. */
private fun respond(s: Session, id: String, result: String? = null, error: String? = null): Boolean {
val frame = JSONObject().apply {
put("id", id)
if (error != null) put("error", error)
if (result != null) put("result", result)
}.toString()
return sendEncrypted(s, frame)
}
private fun formatTags(tags: JSONArray?): List<String> {
tags ?: return emptyList()
val out = mutableListOf<String>()
for (i in 0 until tags.length()) {
val tag = tags.optJSONArray(i) ?: continue
val parts = mutableListOf<String>()
for (j in 0 until tag.length()) parts.add(tag.optString(j))
out.add(parts.joinToString(" "))
}
return out
}
}
@@ -1,95 +0,0 @@
package com.archipelago.app.nostr
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 androidx.datastore.preferences.preferencesDataStore
import com.archipelago.app.NativeCore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
private val Context.signerDataStore: DataStore<Preferences> by preferencesDataStore(name = "nostr_signer")
/**
* Storage for the phone-side NIP-46 remote signer (#139): the signer secret
* key (hex) and the last pairing, so a re-opened app can resume a session
* without re-scanning the node's QR.
*
* Same plaintext-DataStore model as the FIPS secret (app-private storage,
* no extra OS keystore ceremony — the node login password lives the same way
* in ServerPreferences); the nsec grants the ability to sign as this identity,
* never node login.
*/
class NostrSignerPreferences(private val context: Context) {
private val secretKey = stringPreferencesKey("signer_secret")
private val clientPubkeyKey = stringPreferencesKey("pair_client_pubkey")
private val clientRelayKey = stringPreferencesKey("pair_client_relay")
private val clientNameKey = stringPreferencesKey("pair_client_name")
/** The signer secret (hex) or null when no key exists yet. */
suspend fun secret(): String? = context.signerDataStore.data.first()[secretKey]
val secretFlow: Flow<String?> = context.signerDataStore.data
.map { it[secretKey] }
.distinctUntilChanged()
suspend fun saveSecret(hex: String) {
context.signerDataStore.edit { it[secretKey] = hex.trim() }
}
/** Generate a fresh signer key (fails if the native core is missing). */
suspend fun generateSecret(): JSONObject = withContext(Dispatchers.IO) {
val json = NativeCore.nostrGenerateSecret()
val obj = JSONObject(json)
if (obj.has("error")) throw IllegalStateException(obj.getString("error"))
saveSecret(obj.getString("secret"))
obj
}
/** Import a secret from hex or nsec…; returns the parsed key info. */
suspend fun importSecret(raw: String): JSONObject = withContext(Dispatchers.IO) {
val json = NativeCore.nostrSecretFromAny(raw.trim())
val obj = JSONObject(json)
if (obj.has("error")) throw IllegalArgumentException(obj.getString("error"))
saveSecret(obj.getString("secret"))
obj
}
data class Pairing(val clientPubkey: String, val relay: String, val name: String)
suspend fun lastPairing(): Pairing? {
val prefs = context.signerDataStore.data.first()
val pubkey = prefs[clientPubkeyKey] ?: return null
val relay = prefs[clientRelayKey] ?: return null
if (pubkey.isBlank() || relay.isBlank()) return null
return Pairing(pubkey, relay, prefs[clientNameKey] ?: "")
}
suspend fun savePairing(pairing: Pairing) {
context.signerDataStore.edit {
it[clientPubkeyKey] = pairing.clientPubkey
it[clientRelayKey] = pairing.relay
it[clientNameKey] = pairing.name
}
}
suspend fun clearPairing() {
context.signerDataStore.edit {
it.remove(clientPubkeyKey)
it.remove(clientRelayKey)
it.remove(clientNameKey)
}
}
suspend fun wipeKey() {
context.signerDataStore.edit { it.remove(secretKey) }
}
}
@@ -1,294 +0,0 @@
package com.archipelago.app.ui.components
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.data.BackupManager
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Backup & Restore (#128) — the hub's BACKUP sub-page (same container as
* Nodes/FIPS), the phone side of losing your phone or wiping it to cross a
* border. See docs/companion-backup-restore.md for the envelope and merge
* semantics; this composable is the flow only.
*/
@Composable
internal fun BackupSection() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val manager = remember { BackupManager(context) }
var passphrase by remember { mutableStateOf("") }
var confirm by remember { mutableStateOf("") }
var status by remember { mutableStateOf<String?>(null) }
var statusError by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
// Decrypted backup awaiting the user's go-ahead (restore flow).
var restorePreview by remember { mutableStateOf<Pair<BackupManager.PayloadSummary, org.json.JSONObject>?>(null) }
fun say(msg: String, error: Boolean) {
status = msg
statusError = error
}
val exportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
busy = true
try {
val envelope = manager.createBackup(passphrase)
withContext(Dispatchers.IO) {
context.contentResolver.openOutputStream(uri)?.use { out ->
out.write(envelope.toByteArray())
} ?: throw BackupManager.BackupException("could not open the destination file")
}
say("Saved — keep the file and the passphrase somewhere safe.", false)
} catch (e: Exception) {
say(e.message ?: "backup failed", true)
} finally {
busy = false
}
}
}
val importLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
busy = true
try {
val envelope = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() }
?: throw BackupManager.BackupException("could not read the selected file")
}
val (summary, payload) = manager.readBackup(envelope, passphrase)
restorePreview = summary to payload
} catch (e: Exception) {
say(e.message ?: "restore failed", true)
} finally {
busy = false
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionCopy(
"An encrypted copy of everything this phone holds — nodes and their passwords, " +
"your mesh identity, the remote-signer key. Same envelope your node uses (ADR-005), " +
"one passphrase, no cloud."
)
// ── Create a backup ──────────────────────────────────────────────
SectionHeader(Icons.Default.Save, "Create a backup")
GlassField(
value = passphrase,
onValueChange = { passphrase = it },
placeholder = "Passphrase",
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
)
GlassField(
value = confirm,
onValueChange = { confirm = it },
placeholder = "Repeat passphrase",
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
)
SectionHint("The passphrase cannot be recovered — a backup nobody can open is a paperweight.")
WideAction(
text = if (busy) "Working…" else "Save backup file",
onClick = {
if (busy) return@WideAction
if (passphrase.length < 8) {
say("Use at least 8 characters — this passphrase guards every secret in the app.", true)
return@WideAction
}
if (passphrase != confirm) {
say("The two passphrases don't match.", true)
return@WideAction
}
val stamp = SimpleDateFormat("yyyyMMdd-HHmm", Locale.US).format(Date())
exportLauncher.launch("archy-companion-backup-$stamp.json")
},
)
Spacer(Modifier.height(2.dp))
// ── Restore a backup ─────────────────────────────────────────────
SectionHeader(Icons.Default.Restore, "Restore a backup")
SectionHint(
"Nothing is overwritten: nodes merge by identity, and the mesh identity and " +
"signer key only restore when this phone has none."
)
WideAction(
text = if (busy) "Working…" else "Choose backup file",
onClick = {
if (busy) return@WideAction
if (passphrase.isEmpty()) {
say("Enter the backup's passphrase first.", true)
return@WideAction
}
importLauncher.launch(arrayOf("application/json"))
},
)
restorePreview?.let { (summary, payload) ->
Spacer(Modifier.height(2.dp))
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(Color.White.copy(alpha = 0.04f))
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
"Backup verified${if (summary.appVersion.isNotBlank()) " (made by v${summary.appVersion})" else ""}",
color = SuccessGreen, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
)
SummaryRow("Nodes", summary.serverCount.toString())
if (summary.hasFipsIdentity) SummaryRow("Mesh identity", "included")
if (summary.hasSignerKey) SummaryRow("Remote-signer key", "included")
WideAction(
text = if (busy) "Restoring…" else "Restore onto this phone",
onClick = {
if (busy) return@WideAction
scope.launch {
busy = true
try {
val result = manager.restoreBackup(payload)
restorePreview = null
passphrase = ""
confirm = ""
say(
"Restored ${result.serversRestored} node(s)" +
(if (result.activeSet) ", set active" else "") +
(if (result.fipsIdentityRestored) ", mesh identity" else "") +
(if (result.signerKeyRestored) ", signer key" else "") +
". Restart the app to reconnect.",
false,
)
} catch (e: Exception) {
say(e.message ?: "restore failed", true)
} finally {
busy = false
}
}
},
)
}
}
status?.takeIf { it.isNotBlank() }?.let { msg ->
Text(
msg,
color = if (statusError) Color(0xFFFF6B6B) else SuccessGreen,
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
internal fun SectionHeader(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(icon, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(18.dp))
Text(title, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
}
}
@Composable
internal fun SectionCopy(text: String) {
Text(text, color = TextMuted, fontSize = 12.sp, lineHeight = 16.sp)
}
@Composable
internal fun SectionHint(text: String) {
Text(text, color = TextMuted.copy(alpha = 0.8f), fontSize = 10.sp, lineHeight = 13.sp)
}
/** Wide orange-outline action button in the menu's visual language. */
@Composable
internal fun WideAction(
text: String,
onClick: () -> Unit,
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
) {
Row(
Modifier
.fillMaxWidth()
.height(44.dp)
.clip(RoundedCornerShape(12.dp))
.background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { onClick() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
if (icon != null) {
Icon(icon, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(16.dp))
Spacer(Modifier.size(8.dp))
}
Text(text, color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Bold)
}
}
@Composable
internal fun SummaryRow(label: String, value: String) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, color = TextMuted, fontSize = 12.sp)
Text(value, color = TextPrimary, fontSize = 12.sp, fontWeight = FontWeight.Medium)
}
}
@@ -31,8 +31,6 @@ import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.SettingsBackupRestore
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -108,62 +106,22 @@ fun NESMenu(
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)? = null,
onMeshParty: (() -> Unit)? = null,
// Remote-signer pairing request (nostrconnect://… deep link, or a scan):
// non-null opens the hub on the signer sub-page and pairs. Consumed once
// the signer section hands it back via [onSignerPairHandled].
signerPairRequest: String? = null,
onSignerPairHandled: () -> Unit = {},
) {
// Pairing state is latched here (not passed straight through) so the
// source can clear itself while the request stays alive until consumed.
var pendingSignerPair by remember { mutableStateOf<String?>(null) }
var signerScan by remember { mutableStateOf(false) }
LaunchedEffect(signerPairRequest) {
if (signerPairRequest != null) pendingSignerPair = signerPairRequest
}
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
// Contained hub overlay: a centred glass panel (not full-screen) that
// holds the card page and its sub-pages (Nodes, FIPS, Backup, Signer)
// and scrolls inside its own bounds when content is tall. Tapping the
// dimmed backdrop dismisses.
// holds the card page and its sub-pages (Nodes, FIPS) and scrolls
// inside its own bounds when content is tall. Tapping the dimmed
// backdrop dismisses.
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(
servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr,
onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty,
signerPairUri = pendingSignerPair,
onSignerScan = { signerScan = true },
onSignerPairHandled = {
pendingSignerPair = null
onSignerPairHandled()
},
)
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
}
}
}
// Pairing-QR scanner for the signer sub-page — a full-screen glass
// modal hosted OUTSIDE the hub panel so it isn't clipped to the panel's
// bounds (same layering the pairing scanner gets from WebViewScreen).
QrGlassModal(
visible = signerScan && visible,
title = "Scan pairing QR",
status = null,
idleHint = "Point at the nostrconnect QR the node or client shows",
permissionRationale = "Camera access is needed to scan the pairing code",
onDismiss = { signerScan = false },
onDecoded = { text ->
if (text.startsWith("nostrconnect://")) {
signerScan = false
pendingSignerPair = text
}
},
)
}
@Composable
@@ -180,9 +138,6 @@ private fun MenuPanel(
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)?,
onMeshParty: (() -> Unit)?,
signerPairUri: String?,
onSignerScan: () -> Unit,
onSignerPairHandled: () -> Unit,
) {
var showAdd by remember { mutableStateOf(false) }
// The saved server being edited, or null when adding a new one.
@@ -221,10 +176,9 @@ private fun MenuPanel(
.widthIn(max = 420.dp)
.fillMaxWidth()
.padding(horizontal = 20.dp)
// Cap height at 70% of the screen — a ~15% breathing margin top
// and bottom — the panel wraps short content and scrolls inside
// its own bounds when a sub-page outgrows this.
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.70f).dp)
// Cap height just short of the full screen; the panel wraps short
// content and only scrolls in the rare case it outgrows this.
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
.clip(RoundedCornerShape(PANEL_R))
.background(PanelBg.copy(alpha = 0.86f))
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
@@ -247,13 +201,7 @@ private fun MenuPanel(
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
Spacer(Modifier.width(12.dp))
Text(
when (page) {
HubPage.NODES -> "Nodes"
HubPage.FIPS -> "FIPS Mesh"
HubPage.BACKUP -> "Backup & Restore"
HubPage.SIGNER -> "Remote Signer"
HubPage.HUB -> "Menu"
},
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
)
}
@@ -285,12 +233,6 @@ private fun MenuPanel(
if (onMeshParty != null) {
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
}
// Backup & Restore (#128): the phone side of losing your phone
// or wiping it to cross a border — encrypted export file, no cloud.
HubCard(Icons.Default.SettingsBackupRestore, "Backup & Restore", "Encrypted export for a wiped phone") { page = HubPage.BACKUP }
// Remote Signer (#139): hold a nostr key on the phone and
// approve/deny remote signature requests (NIP-46).
HubCard(Icons.Default.Key, "Remote Signer", "Approve signatures for your node") { page = HubPage.SIGNER }
// Dark/Classic style lives on the remote/keyboard screen next to
// the settings button — not here.
@@ -330,11 +272,6 @@ private fun MenuPanel(
val active = server.serialize() == activeServer?.serialize()
MenuItem(
label = server.displayName(),
// FIPS nodes carry their mesh ULA — the address Termux
// (or any other app) can reach over the split-tunnel,
// from anywhere. Tap to copy; the node's npub stays
// visible in the FIPS Mesh page.
subtitle = server.meshIp.takeIf { it.isNotBlank() },
selected = active,
onClick = { onSelectServer(server) },
onEdit = { startEdit(server) },
@@ -454,23 +391,11 @@ private fun MenuPanel(
HubPage.FIPS -> {
FipsSection(embedded = true)
}
HubPage.BACKUP -> {
BackupSection()
}
HubPage.SIGNER -> {
SignerSection(
pairUri = signerPairUri,
onScan = onSignerScan,
onPairHandled = onSignerPairHandled,
)
}
}
}
}
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER }
private enum class HubPage { HUB, NODES, FIPS }
/** Big tappable destination card for the hub page: icon + title + subtitle. */
@Composable
@@ -657,52 +582,26 @@ private fun MenuItem(
onClick: () -> Unit,
onEdit: (() -> Unit)? = null,
onRemove: (() -> Unit)? = null,
/** Optional second line (the node's mesh ULA); tapping it copies. */
subtitle: String? = null,
) {
val clipboard = LocalClipboardManager.current
Row(
Modifier
.fillMaxWidth()
// Rows with a second line grow to fit it.
.then(if (subtitle == null) Modifier.height(ROW_H) else Modifier.heightIn(min = ROW_H))
.height(ROW_H)
.clip(RoundedCornerShape(ROW_R))
.background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg)
.border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R))
.clickable { onClick() }
.padding(horizontal = 16.dp)
.then(if (subtitle == null) Modifier else Modifier.padding(vertical = 8.dp)),
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text(
label,
color = if (selected) BitcoinOrange else labelColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
if (subtitle != null) {
Row(
Modifier
.padding(top = 2.dp)
.clip(RoundedCornerShape(6.dp))
.clickable { clipboard.setText(AnnotatedString(subtitle)) }
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
subtitle,
color = TextMuted,
fontSize = 10.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
Text("⧉", color = TextMuted.copy(alpha = 0.7f), fontSize = 11.sp, modifier = Modifier.padding(start = 6.dp))
}
}
}
Text(
label,
color = if (selected) BitcoinOrange else labelColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
if (onEdit != null) {
Text(
"✎",
@@ -724,7 +623,7 @@ private fun MenuItem(
/** Glass text field with centered input text. */
@Composable
internal fun GlassField(
private fun GlassField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
@@ -1,14 +0,0 @@
package com.archipelago.app.ui.components
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Cross-layer handoff for remote-signer pairing (#139): NavGraph's
* `nostrconnect://` deep link drops the URI here and routes to the session;
* WebViewScreen collects it, opens the hub menu, and NESMenu opens the
* signer sub-page with the request. Cleared once the signer section has
* consumed it (via NESMenu's onSignerPairHandled).
*/
object SignerLaunch {
val pendingUri = MutableStateFlow<String?>(null)
}
@@ -1,381 +0,0 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.NativeCore
import com.archipelago.app.nostr.BunkerManager
import com.archipelago.app.nostr.NostrSignerPreferences
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Remote Signer (#139) — the hub's SIGNER sub-page (same container as
* Nodes/FIPS). The phone holds a nostr key; a NIP-46 client (the node's
* login QR, any nostrconnect:// app) pairs via [pairUri] or the scanner
* (hosted by NESMenu outside this panel), and every `sign_event` request
* lands as a legible approve/deny card. See
* docs/companion-nip46-remote-signer.md.
*/
@Composable
internal fun SignerSection(
pairUri: String?,
onScan: () -> Unit,
onPairHandled: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current
val prefs = remember { NostrSignerPreferences(context) }
var keyInfo by remember { mutableStateOf<JSONObject?>(null) }
var keyError by remember { mutableStateOf<String?>(null) }
var importText by remember { mutableStateOf("") }
var showNsec by remember { mutableStateOf(false) }
var notice by remember { mutableStateOf<String?>(null) }
var noticeError by remember { mutableStateOf(false) }
val bunkerState by BunkerManager.state.collectAsState()
val pending by BunkerManager.pending.collectAsState()
fun say(msg: String, error: Boolean) {
notice = msg
noticeError = error
}
suspend fun loadKey() {
val secret = prefs.secret()
keyInfo = secret?.let {
val json = NativeCore.nostrSecretFromAny(it)
if (NativeCore.isErr(json)) null else JSONObject(json)
}
}
LaunchedEffect(Unit) {
BunkerManager.refreshState(context)
loadKey()
}
// Consume a pairing request (deep link or scanner) exactly once.
LaunchedEffect(pairUri) {
val uri = pairUri?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
if (keyInfo == null) loadKey()
val err = BunkerManager.pair(context, uri)
if (err != null) say(err, true) else say("Pairing started…", false)
onPairHandled()
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionCopy(
"Hold a nostr key on this phone and sign for it remotely — pair with your " +
"node's login QR (or any NIP-46 client), then approve each signature " +
"request as it arrives. Nothing signs without you."
)
if (bunkerState is BunkerManager.SignerState.Unavailable) {
Text(
"Signing is unavailable on this device (native core missing).",
color = Color(0xFFFF6B6B), fontSize = 12.sp,
)
}
val info = keyInfo
if (info == null) {
// ── No key yet: generate or import ──────────────────────────
keyError?.let { Text(it, color = Color(0xFFFF6B6B), fontSize = 11.sp) }
WideAction(text = "Generate signer key", onClick = {
scope.launch {
try {
keyInfo = prefs.generateSecret()
keyError = null
BunkerManager.refreshState(context)
} catch (e: Exception) {
keyError = e.message ?: "could not generate a key"
}
}
})
GlassField(
value = importText,
onValueChange = { importText = it },
placeholder = "or import nsec…",
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onGo = {
if (importText.isNotBlank()) {
scope.launch {
try {
keyInfo = prefs.importSecret(importText)
importText = ""
keyError = null
BunkerManager.refreshState(context)
} catch (e: Exception) {
keyError = e.message ?: "not a valid nsec"
}
}
}
}),
)
WideAction(text = "Import", onClick = {
if (importText.isBlank()) return@WideAction
scope.launch {
try {
keyInfo = prefs.importSecret(importText)
importText = ""
keyError = null
BunkerManager.refreshState(context)
} catch (e: Exception) {
keyError = e.message ?: "not a valid nsec"
}
}
})
} else {
// ── Identity ─────────────────────────────────────────────────
SectionHeader(Icons.Default.Key, "Signer identity")
MonoValue("npub", info.optString("npub")) {
clipboard.setText(AnnotatedString(info.optString("npub")))
}
if (showNsec) {
MonoValue("nsec", info.optString("nsec"), secret = true) {
clipboard.setText(AnnotatedString(info.optString("nsec")))
}
SectionHint("Anyone with the nsec can sign as you — clear the clipboard after copying.")
} else {
Text(
"Show nsec",
color = TextMuted, fontSize = 11.sp,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable { showNsec = true }
.padding(vertical = 2.dp, horizontal = 6.dp),
)
}
// ── Session ──────────────────────────────────────────────────
Spacer(Modifier.height(2.dp))
val label = when (val s = bunkerState) {
BunkerManager.SignerState.Unavailable -> "Unavailable on this device"
BunkerManager.SignerState.NoKey -> "No signer key yet"
BunkerManager.SignerState.Idle -> "Idle — pair to start"
is BunkerManager.SignerState.Connecting -> "Connecting to ${s.relay}…"
is BunkerManager.SignerState.AwaitingClient -> "Paired with \"${s.clientName}\" — waiting for the handshake to finish"
is BunkerManager.SignerState.Ready -> "Ready for \"${s.clientName}\""
is BunkerManager.SignerState.Failed -> s.reason
}
Text("Session", color = TextMuted, fontSize = 11.sp)
Text(
label,
color = if (bunkerState is BunkerManager.SignerState.Failed) Color(0xFFFF6B6B)
else if (bunkerState is BunkerManager.SignerState.Ready) SuccessGreen
else TextPrimary,
fontSize = 13.sp,
lineHeight = 17.sp,
)
WideAction(
text = "Scan pairing QR",
onClick = {
if (bunkerState is BunkerManager.SignerState.NoKey) {
say("Generate or import a signer key first.", true)
return@WideAction
}
onScan()
},
icon = Icons.Default.QrCodeScanner,
)
if (bunkerState is BunkerManager.SignerState.Ready ||
bunkerState is BunkerManager.SignerState.AwaitingClient ||
bunkerState is BunkerManager.SignerState.Connecting
) {
Text(
"End session",
color = TextMuted, fontSize = 11.sp,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable { BunkerManager.unpair() }
.padding(vertical = 2.dp, horizontal = 6.dp),
)
}
// ── Pending signature request — the whole point ──────────────
pending?.let { req ->
Spacer(Modifier.height(2.dp))
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(Color.White.copy(alpha = 0.04f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.35f), RoundedCornerShape(14.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("Signature request", color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Bold)
SummaryRow("Client", req.clientName.ifBlank { req.clientPubkey.take(12) + "…" })
SummaryRow("Kind", kindLabel(req.kind))
req.createdAt?.let {
SummaryRow("Time", SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(it * 1000)))
}
req.content?.takeIf { it.isNotBlank() }?.let { content ->
Text(
content,
color = TextPrimary, fontSize = 10.sp, lineHeight = 14.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color.Black.copy(alpha = 0.45f))
.padding(8.dp)
.heightIn(max = 160.dp),
)
}
if (req.tags.isNotEmpty()) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color.Black.copy(alpha = 0.45f))
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
req.tags.take(6).forEach {
Text(
it,
color = TextMuted, fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (req.tags.size > 6) {
Text("+${req.tags.size - 6} more", color = TextMuted, fontSize = 9.sp)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(
Modifier
.weight(1f)
.height(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFFE5484D).copy(alpha = 0.16f))
.border(1.dp, Color(0xFFE5484D).copy(alpha = 0.5f), RoundedCornerShape(12.dp))
.clickable { BunkerManager.deny() },
contentAlignment = Alignment.Center,
) { Text("Deny", color = Color(0xFFFF8A8D), fontSize = 13.sp, fontWeight = FontWeight.Bold) }
Box(
Modifier
.weight(1f)
.height(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(BitcoinOrange.copy(alpha = 0.2f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.6f), RoundedCornerShape(12.dp))
.clickable {
scope.launch {
val ok = BunkerManager.approve()
say(if (ok) "Signed and sent." else "Could not send the signature.", !ok)
}
},
contentAlignment = Alignment.Center,
) { Text("Approve", color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Bold) }
}
}
}
}
notice?.takeIf { it.isNotBlank() }?.let { msg ->
Text(
msg,
color = if (noticeError) Color(0xFFFF6B6B) else SuccessGreen,
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
/** Kind number → legible label, so the approve/deny card reads like a sentence. */
private fun kindLabel(kind: Long?): String = when (kind) {
0L -> "Metadata (kind 0)"
1L -> "Text note (kind 1)"
3L -> "Contact list (kind 3)"
4L -> "Direct message (kind 4)"
7L -> "Reaction (kind 7)"
14L -> "Chat message (kind 14)"
22242L -> "Client authentication (kind 22242)"
30078L -> "App-stored data (kind 30078)"
null -> "Unknown kind"
else -> "Kind $kind"
}
/** Monospace value chip with a copy affordance (tap the row). */
@Composable
private fun MonoValue(label: String, value: String, secret: Boolean = false, onCopy: () -> Unit) {
Column(Modifier.fillMaxWidth()) {
Text(label, color = TextMuted, fontSize = 10.sp)
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color.Black.copy(alpha = 0.45f))
.clickable { onCopy() }
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
value,
color = if (secret) Color(0xFFFFB86B) else TextPrimary,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
}
}
}
@@ -22,7 +22,6 @@ import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.SignerLaunch
import com.archipelago.app.ui.screens.FlareScreen
import com.archipelago.app.ui.screens.IntroScreen
import com.archipelago.app.ui.screens.NodePickerScreen
@@ -134,41 +133,27 @@ fun AppNavHost(
LaunchedEffect(pairUri) {
val raw = pairUri ?: return@LaunchedEffect
onPairUriConsumed()
when {
// Remote-signer pairing deep link (NIP-46): nostrconnect://…
// from the node's login QR — any QR scanner app can hand it over.
// The signer UI lives inside the hub menu: drop the URI where
// WebViewScreen picks it up and route to the session, which opens
// the hub on its signer sub-page.
raw.startsWith("nostrconnect://") -> {
when (val result = ServerQrParser.parse(raw)) {
is PairResult.Success -> {
// Pairing implies the app is installed and in use — skip the intro.
prefs.markIntroSeen()
SignerLaunch.pendingUri.value = raw
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
}
else -> when (val result = ServerQrParser.parse(raw)) {
is PairResult.Success -> {
// Pairing implies the app is installed and in use — skip the intro.
prefs.markIntroSeen()
val merged = prefs.upsertServer(result.server)
FipsManager.registerNode(context, result.fips, merged.displayName())
if (merged.password.isNotBlank()) {
// Demo flow: password came with the link — connect in one step.
prefs.setActiveServer(merged)
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
} else {
pairPrefill = merged
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(0) { inclusive = true }
}
val merged = prefs.upsertServer(result.server)
FipsManager.registerNode(context, result.fips, merged.displayName())
if (merged.password.isNotBlank()) {
// Demo flow: password came with the link — connect in one step.
prefs.setActiveServer(merged)
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
} else {
pairPrefill = merged
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(0) { inclusive = true }
}
}
else -> {
// Invalid or too-new pairing link — ignore; normal startup continues.
}
}
else -> {
// Invalid or too-new pairing link — ignore; normal startup continues.
}
}
}
@@ -101,7 +101,6 @@ import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.GestureHintOverlay
import com.archipelago.app.ui.components.MeshLoadingScreen
import com.archipelago.app.ui.components.NESMenu
import com.archipelago.app.ui.components.SignerLaunch
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.components.SlidingLoader
import com.archipelago.app.ui.components.WalletQrScannerModal
@@ -1374,16 +1373,6 @@ fun WebViewScreen(
// Hub menu overlay — opened by the three-finger hold, drawn above
// everything (also reachable from the error screen, where switching
// servers is exactly what's needed).
// Remote-signer deep link: route to the session and pop the hub open
// on its signer sub-page (the request itself is consumed by NESMenu).
var signerPairRequest by remember { mutableStateOf<String?>(null) }
val signerLaunch by SignerLaunch.pendingUri.collectAsState()
LaunchedEffect(signerLaunch) {
val uri = signerLaunch ?: return@LaunchedEffect
signerPairRequest = uri
SignerLaunch.pendingUri.value = null
showHubMenu = true
}
NESMenu(
visible = showHubMenu,
servers = savedServers,
@@ -1438,8 +1427,6 @@ fun WebViewScreen(
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
onBackToWebView = { showHubMenu = false },
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
signerPairRequest = signerPairRequest,
onSignerPairHandled = { signerPairRequest = null },
)
// Pairing-QR scan launched from the menu's Nodes page; the menu stays
+1 -369
View File
@@ -12,17 +12,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -92,41 +81,17 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
name = "archy-fips-core"
version = "0.1.0"
dependencies = [
"aes",
"anyhow",
"argon2",
"base64",
"bech32",
"cbc",
"chacha20 0.9.1",
"chacha20poly1305",
"fips",
"getrandom 0.2.17",
"hex",
"hkdf",
"hmac",
"jni",
"libc",
"paranoid-android",
"secp256k1 0.29.1",
"serde_json",
"sha2",
"tokio",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
@@ -159,18 +124,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bech32"
version = "0.11.1"
@@ -219,15 +172,6 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -237,15 +181,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "blocking"
version = "1.6.2"
@@ -265,15 +200,6 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.3.0"
@@ -480,17 +406,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "either"
version = "1.16.0"
@@ -561,7 +476,7 @@ dependencies = [
"libc",
"rand 0.10.2",
"rtnetlink",
"secp256k1 0.30.0",
"secp256k1",
"serde",
"serde_json",
"serde_yaml",
@@ -576,15 +491,6 @@ dependencies = [
"tun",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.33"
@@ -770,110 +676,6 @@ dependencies = [
"digest",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -890,7 +692,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -987,12 +788,6 @@ dependencies = [
"libc",
]
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "log"
version = "0.4.33"
@@ -1159,29 +954,12 @@ version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1210,15 +988,6 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1360,15 +1129,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "secp256k1"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
"secp256k1-sys",
]
[[package]]
name = "secp256k1"
version = "0.30.0"
@@ -1512,12 +1272,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -1552,17 +1306,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -1612,16 +1355,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "tokio"
version = "1.53.1"
@@ -1786,24 +1519,6 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -1942,35 +1657,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.55"
@@ -1991,66 +1677,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "zmij"
version = "1.0.23"
-29
View File
@@ -37,35 +37,6 @@ tracing = "0.1"
# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start).
libc = "0.2"
# ── Companion backup (#128) ───────────────────────────────────────────────
# ADR-005 envelope: the SAME crates and blob layout as the node's backup code
# (core/archipelago/src/backup/identity.rs) — Argon2id KDF + ChaCha20-Poly1305
# AEAD — applied to the companion's own JSON payload. Do not diverge from
# those parameters: a companion backup and a node backup must decrypt with
# the same code path on either side.
argon2 = "0.5"
chacha20poly1305 = "0.10"
base64 = "0.22"
# ── NIP-46 remote signer (#139) ───────────────────────────────────────────
# BIP340 schnorr signing + secp256k1 ECDH (NIP-44/NIP-04 conversation keys).
# Audited libsecp256k1 via cc; cargo-ndk provides the NDK clang on Android.
secp256k1 = "0.29"
# NIP-44 v2: HKDF-SHA256 (conversation/message keys) + HMAC-SHA256 (MAC).
sha2 = "0.10"
hmac = "0.12"
hkdf = "0.12"
# NIP-44 v2 stream cipher (raw ChaCha20, RFC 8439 — NOT the AEAD).
chacha20 = "0.9"
# NIP-04 fallback (deprecated in the spec but still sent by real clients):
# AES-256-CBC, key = raw ECDH x-coordinate.
aes = "0.8"
cbc = { version = "0.1", features = ["alloc"] }
# npub/nsec (bech32, BIP173 variant — NOT Bech32m).
bech32 = "0.11"
# nostrconnect:// URI parsing (repeated relay params + percent-decoding).
url = "2.5"
# The JNI surface only exists on Android; host builds skip it and drive the
# mesh module directly (tests).
[target.'cfg(target_os = "android")'.dependencies]
-246
View File
@@ -1,246 +0,0 @@
//! Companion app backup — the ADR-005 encrypted-backup envelope.
//!
//! Reuses the node's backup format exactly (ADR-005:
//! `core/archipelago/src/backup/identity.rs`): Argon2id key derivation with
//! default params, ChaCha20-Poly1305 AEAD, and the same blob layout
//! `base64(salt[16] || nonce[12] || ciphertext)`. A companion backup and a
//! node backup share one crypto story — the payload differs (the companion
//! serializes its servers, FIPS identity and signer key instead of a node
//! key), the envelope does not.
//!
//! The envelope is JSON with `version`, `kind`, `encrypted`, `blob` and
//! `timestamp`; [`decrypt`] ignores any extra fields, so node envelopes
//! (which carry `did`/`pubkey`/`kid`) decrypt here too.
use anyhow::{bail, Context, Result};
use argon2::Argon2;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use serde_json::json;
/// Envelope version. Bump only when the blob layout itself changes — and
/// then only with a reader for the old layout (same policy as the node).
const BACKUP_VERSION: u32 = 1;
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;
/// Encrypt a JSON payload into an ADR-005 envelope.
///
/// The passphrase never leaves this call; the envelope carries only the
/// salt (Argon2id parameter), the AEAD nonce, and the ciphertext.
pub fn encrypt(payload: &str, passphrase: &str) -> Result<String> {
if payload.is_empty() {
bail!("backup payload is empty");
}
if passphrase.is_empty() {
bail!("backup passphrase must not be empty");
}
let mut salt = [0u8; SALT_LEN];
let mut nonce = [0u8; NONCE_LEN];
// Same CSPRNG discipline as identity generation (getrandom, see mesh.rs):
// OS RNG, never thread-local or derived-from-content randomness for key
// material or nonces.
getrandom::getrandom(&mut salt).context("OS RNG")?;
getrandom::getrandom(&mut nonce).context("OS RNG")?;
let key = derive_key(passphrase, &salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), payload.as_bytes())
.map_err(|_| anyhow::anyhow!("encryption failed"))?;
let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
blob.extend_from_slice(&salt);
blob.extend_from_slice(&nonce);
blob.extend_from_slice(&ciphertext);
Ok(json!({
"version": BACKUP_VERSION,
"kind": "companion",
"encrypted": true,
"blob": BASE64.encode(&blob),
"timestamp": chrono_like_now(),
})
.to_string())
}
/// Decrypt an ADR-005 envelope back into its JSON payload.
///
/// Accepts `version: 1` envelopes regardless of `kind` or extra fields —
/// the node's identity backups use the same blob, and being able to decrypt
/// one here is free interop (the caller decides what to do with it).
pub fn decrypt(envelope: &str, passphrase: &str) -> Result<String> {
let obj: serde_json::Value =
serde_json::from_str(envelope).context("not a JSON backup envelope")?;
if obj.get("version").and_then(|v| v.as_u64()) != Some(BACKUP_VERSION as u64) {
bail!("unsupported backup version (expected {BACKUP_VERSION})");
}
let blob_b64 = obj
.get("blob")
.and_then(|v| v.as_str())
.context("missing 'blob' in backup envelope")?;
let blob = BASE64
.decode(blob_b64)
.context("invalid base64 in backup blob")?;
if blob.len() < SALT_LEN + NONCE_LEN {
bail!("backup blob too short");
}
let salt = &blob[..SALT_LEN];
let nonce = &blob[SALT_LEN..SALT_LEN + NONCE_LEN];
let ciphertext = &blob[SALT_LEN + NONCE_LEN..];
let key = derive_key(passphrase, salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let plaintext = cipher
.decrypt(Nonce::from_slice(nonce), ciphertext)
.map_err(|_| anyhow::anyhow!("decryption failed — wrong passphrase or corrupted backup"))?;
String::from_utf8(plaintext).context("decrypted payload is not valid UTF-8")
}
fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; KEY_LEN]> {
let mut key = [0u8; KEY_LEN];
Argon2::default()
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
.map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {e}"))?;
Ok(key)
}
/// RFC 3339 UTC timestamp without pulling chrono into the .so — the node's
/// envelope field is informational (display), not part of the authenticated
/// or derived material.
fn chrono_like_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = secs / 86_400;
let rem = secs % 86_400;
let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
// Civil-from-days (Howard Hinnant's algorithm), valid for 1970-2100+.
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
const PAYLOAD: &str = r#"{"app":"archipelago-companion","servers":["192.168.1.10|false|1301||Lab Node|fd00::1|npub1abc"]}"#;
#[test]
fn round_trip() {
let envelope = encrypt(PAYLOAD, "correct horse battery staple").unwrap();
let decrypted = decrypt(&envelope, "correct horse battery staple").unwrap();
assert_eq!(decrypted, PAYLOAD);
}
#[test]
fn wrong_passphrase_fails() {
let envelope = encrypt(PAYLOAD, "right").unwrap();
let err = decrypt(&envelope, "wrong").unwrap_err();
assert!(
err.to_string().contains("wrong passphrase"),
"error should name the likely cause: {err}"
);
}
#[test]
fn envelope_shape_matches_node_format() {
let envelope = encrypt(PAYLOAD, "pw").unwrap();
let obj: serde_json::Value = serde_json::from_str(&envelope).unwrap();
assert_eq!(obj["version"], 1);
assert_eq!(obj["encrypted"], true);
assert!(obj["kind"].as_str().is_some());
assert!(obj["timestamp"].as_str().is_some());
// Blob layout is exactly the node's: base64(salt||nonce||ct) with the
// AEAD tag inside the ciphertext — at least 16+12+16+1 bytes.
let blob = BASE64
.decode(obj["blob"].as_str().unwrap())
.expect("blob is base64");
assert!(blob.len() >= SALT_LEN + NONCE_LEN + 16 + PAYLOAD.len());
}
#[test]
fn fresh_salt_and_nonce_every_time() {
let a = encrypt(PAYLOAD, "pw").unwrap();
let b = encrypt(PAYLOAD, "pw").unwrap();
let (oa, ob): (serde_json::Value, serde_json::Value) = (
serde_json::from_str(&a).unwrap(),
serde_json::from_str(&b).unwrap(),
);
assert_ne!(oa["blob"], ob["blob"], "salt/nonce must never repeat");
}
#[test]
fn tampered_blob_fails_to_decrypt() {
let envelope = encrypt(PAYLOAD, "pw").unwrap();
let mut obj: serde_json::Value = serde_json::from_str(&envelope).unwrap();
let blob = BASE64.decode(obj["blob"].as_str().unwrap()).unwrap();
let mut tampered = blob.clone();
// Flip a bit inside the ciphertext (past salt+nonce).
tampered[SALT_LEN + NONCE_LEN] ^= 0x01;
obj["blob"] = serde_json::Value::String(BASE64.encode(&tampered));
assert!(decrypt(&obj.to_string(), "pw").is_err());
}
/// Node identity backups use the same blob layout but carry their own
/// envelope fields (did/pubkey/kid). Decrypt must ignore those extras —
/// one envelope reader, two producers.
#[test]
fn node_style_envelope_with_extra_fields_decrypts() {
let envelope = encrypt(PAYLOAD, "pw").unwrap();
let mut obj: serde_json::Value = serde_json::from_str(&envelope).unwrap();
obj["kind"] = serde_json::Value::String("node-identity".into());
obj["did"] = serde_json::Value::String("did:key:z6Mktest".into());
obj["pubkey"] = serde_json::Value::String("aabbcc".into());
obj["kid"] = serde_json::Value::String("did:key:z6Mktest#key-1".into());
let decrypted = decrypt(&obj.to_string(), "pw").unwrap();
assert_eq!(decrypted, PAYLOAD);
}
#[test]
fn rejects_unknown_version_and_garbage() {
let err = decrypt("{\"version\":99,\"blob\":\"AAAA\"}", "pw").unwrap_err();
assert!(err.to_string().contains("version"));
assert!(decrypt("not json", "pw").is_err());
assert!(decrypt("{\"version\":1}", "pw").is_err());
}
#[test]
fn rejects_empty_passphrase_and_payload() {
assert!(encrypt(PAYLOAD, "").is_err());
assert!(encrypt("", "pw").is_err());
}
#[test]
fn timestamp_is_rfc3339_utc() {
let envelope = encrypt(PAYLOAD, "pw").unwrap();
let obj: serde_json::Value = serde_json::from_str(&envelope).unwrap();
let ts = obj["timestamp"].as_str().unwrap();
// 2026-08-31T12:34:56Z — 20 chars, RFC 3339 UTC.
assert_eq!(ts.len(), 20);
assert!(ts.ends_with('Z'));
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[10..11], "T");
assert!(ts.starts_with("20"));
}
}
+2 -177
View File
@@ -1,6 +1,5 @@
//! JNI surface for `com.archipelago.app.fips.FipsNative` and
//! `com.archipelago.app.NativeCore` — JSON over strings, no codegen (the
//! myco / nostr-vpn embedding pattern). Errors come back as
//! 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;
@@ -128,177 +127,3 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson(
) -> jstring {
out(&env, mesh::status_json())
}
// ─────────────────────────────────────────────────────────────────────────────
// com.archipelago.app.NativeCore — companion backup (#128) and NIP-46 remote
// signer crypto (#139). Same library, JSON-over-strings contract.
// ─────────────────────────────────────────────────────────────────────────────
/// Kotlin: `external fun backupEncrypt(payload: String, passphrase: String): String`
/// Returns the ADR-005 envelope JSON or `{"error": …}`.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_NativeCore_backupEncrypt(
mut env: JNIEnv,
_class: JClass,
payload: JString,
passphrase: JString,
) -> jstring {
init_logging();
let payload = jstr(&mut env, &payload);
let passphrase = jstr(&mut env, &passphrase);
let json = match crate::backup::encrypt(&payload, &passphrase) {
Ok(envelope) => envelope,
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun backupDecrypt(envelope: String, passphrase: String): String`
/// Returns the decrypted payload JSON or `{"error": …}`.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_NativeCore_backupDecrypt(
mut env: JNIEnv,
_class: JClass,
envelope: JString,
passphrase: JString,
) -> jstring {
init_logging();
let envelope = jstr(&mut env, &envelope);
let passphrase = jstr(&mut env, &passphrase);
let json = match crate::backup::decrypt(&envelope, &passphrase) {
Ok(payload) => payload,
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun nostrGenerateSecret(): String`
/// Returns `{"secret": hex, "pubkey": hex, "npub": …, "nsec": …}` or `{"error": …}`.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrGenerateSecret(
env: JNIEnv,
_class: JClass,
) -> jstring {
init_logging();
let json = match crate::nostr::generate_secret() {
Ok(secret) => nostr_key_info_json(&secret),
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun nostrSecretFromAny(secret: String): String`
/// Accepts hex or `nsec…`; returns key-info JSON or `{"error": …}`.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrSecretFromAny(
mut env: JNIEnv,
_class: JClass,
secret: JString,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret);
let json = match crate::nostr::secret_from_any(&secret) {
Ok(hex) => nostr_key_info_json(&hex),
Err(e) => err_json(e),
};
out(&env, json)
}
fn nostr_key_info_json(secret_hex: &str) -> String {
match (
crate::nostr::pubkey_hex(secret_hex),
crate::nostr::npub_from_pubkey(&crate::nostr::pubkey_hex(secret_hex).unwrap_or_default()),
crate::nostr::nsec_from_secret(secret_hex),
) {
(Ok(pubkey), Ok(npub), Ok(nsec)) => serde_json::json!({
"secret": secret_hex,
"pubkey": pubkey,
"npub": npub,
"nsec": nsec,
})
.to_string(),
(e, _, _) => err_json(e.unwrap_err()),
}
}
/// Kotlin: `external fun nostrParseConnectUri(uri: String): String`
/// Returns the parsed URI fields or `{"error": …}`.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrParseConnectUri(
mut env: JNIEnv,
_class: JClass,
uri: JString,
) -> jstring {
init_logging();
let uri = jstr(&mut env, &uri);
let json = match crate::nostr::parse_connect_uri(&uri) {
Ok(info) => info.to_json().to_string(),
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun nostrSignEvent(secretHex: String, eventJson: String): String`
/// Returns the signed event JSON or `{"error": …}`. The approve/deny decision
/// is made in Kotlin BEFORE this is called — native code never signs unasked.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrSignEvent(
mut env: JNIEnv,
_class: JClass,
secret_hex: JString,
event_json: JString,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret_hex);
let event = jstr(&mut env, &event_json);
let json = match crate::nostr::sign_event(&secret, &event) {
Ok(signed) => signed,
Err(e) => err_json(e),
};
out(&env, json)
}
macro_rules! nostr_cipher {
($name:ident, $doc:literal, $fn:path) => {
#[doc = $doc]
#[no_mangle]
pub extern "system" fn $name(
mut env: JNIEnv,
_class: JClass,
secret_hex: JString,
peer_pub: JString,
text: JString,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret_hex);
let peer = jstr(&mut env, &peer_pub);
let text = jstr(&mut env, &text);
let json = match $fn(&secret, &peer, &text) {
Ok(out) => serde_json::json!({ "result": out }).to_string(),
Err(e) => err_json(e),
};
out(&env, json)
}
};
}
nostr_cipher!(
Java_com_archipelago_app_NativeCore_nostrNip44Encrypt,
"Kotlin: `external fun nostrNip44Encrypt(secretHex: String, peerPub: String, plaintext: String): String` — returns `{\"result\": payload}` or `{\"error\": …}`.",
crate::nostr::nip44_encrypt
);
nostr_cipher!(
Java_com_archipelago_app_NativeCore_nostrNip44Decrypt,
"Kotlin: `external fun nostrNip44Decrypt(secretHex: String, peerPub: String, payload: String): String`",
crate::nostr::nip44_decrypt
);
nostr_cipher!(
Java_com_archipelago_app_NativeCore_nostrNip04Encrypt,
"Kotlin: `external fun nostrNip04Encrypt(secretHex: String, peerPub: String, plaintext: String): String`",
crate::nostr::nip04_encrypt
);
nostr_cipher!(
Java_com_archipelago_app_NativeCore_nostrNip04Decrypt,
"Kotlin: `external fun nostrNip04Decrypt(secretHex: String, peerPub: String, payload: String): String`",
crate::nostr::nip04_decrypt
);
-2
View File
@@ -11,9 +11,7 @@
//! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern:
//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`.
pub mod backup;
pub mod mesh;
pub mod nostr;
#[cfg(target_os = "android")]
mod jni_glue;
-824
View File
@@ -1,824 +0,0 @@
//! NIP-46 phone-side remote signer ("bunker") crypto core.
//!
//! Everything that must be constant-time correct for the companion to act as
//! a nostr remote signer: key handling (nsec/npub bech32), BIP340 schnorr
//! event signing, NIP-44 v2 payload encryption (the mandated NIP-46
//! transport), NIP-04 fallback decryption (deprecated, but real clients
//! still speak it), and `nostrconnect://` URI parsing. The protocol session
//! — relay WebSocket, JSON-RPC dispatch, approve/deny UX — lives in Kotlin;
//! this module is the crypto and nothing but.
//!
//! Verified against the official NIP-44 vectors and BIP-340 reference
//! vectors (see tests below).
use anyhow::{bail, Context, Result};
use base64::engine::general_purpose::{STANDARD as BASE64, URL_SAFE as BASE64_URL};
use base64::Engine;
use bech32::{Bech32, Hrp};
use chacha20::cipher::{KeyIvInit, StreamCipher};
use chacha20::ChaCha20;
use hmac::{Hmac, Mac};
use hkdf::Hkdf;
use secp256k1::ecdh;
use secp256k1::schnorr::Signature;
use secp256k1::{
Keypair, Message, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey,
};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
const NIP44_VERSION: u8 = 2;
const NIP44_SALT: &[u8] = b"nip44-v2";
const NIP44_MIN_PAYLOAD_LEN: usize = 99; // 1 ver + 32 nonce + 32 ct + 32 mac
const NIP44_MIN_B64_LEN: usize = 132;
// ── keys ──────────────────────────────────────────────────────────────────
/// Generate a fresh nostr secret key (hex) from the OS CSPRNG.
pub fn generate_secret() -> Result<String> {
loop {
let mut bytes = [0u8; 32];
getrandom::getrandom(&mut bytes).context("OS RNG")?;
// Reject zero and >= curve order — the valid scalar range (mirrors
// the mesh identity loop; rejection is astronomically unlikely).
if bytes.iter().all(|&b| b == 0) {
continue;
}
if SecretKey::from_slice(&bytes).is_ok() {
return Ok(hex::encode(bytes));
}
}
}
/// Parse a secret key from hex or bech32 `nsec…` form into hex.
pub fn secret_from_any(s: &str) -> Result<String> {
let s = s.trim();
if s.starts_with("nsec") {
return secret_from_nsec(s);
}
let bytes = hex::decode(s.trim()).context("secret key must be hex or nsec")?;
let sk = SecretKey::from_slice(&bytes).context("invalid nostr secret key")?;
Ok(hex::encode(sk.secret_bytes()))
}
pub fn secret_from_nsec(nsec: &str) -> Result<String> {
let (hrp, data) = bech32::decode(nsec).context("bad nsec encoding")?;
if hrp.as_str() != "nsec" {
bail!("not an nsec");
}
let sk = SecretKey::from_slice(&data).context("invalid nostr secret key")?;
Ok(hex::encode(sk.secret_bytes()))
}
pub fn nsec_from_secret(secret_hex: &str) -> Result<String> {
let bytes = hex::decode(secret_hex.trim()).context("bad secret hex")?;
let hrp = Hrp::parse("nsec").context("nsec hrp")?;
bech32::encode::<Bech32>(hrp, &bytes).context("nsec encoding")
}
/// x-only public key (hex) for a secret key.
/// NOTE: `Keypair::public_key()` in secp256k1 0.29 is the full compressed
/// (33-byte) key — nostr uses x-only pubkeys, so serialize `.x_only_public_key().0`.
pub fn pubkey_hex(secret_hex: &str) -> Result<String> {
let kp = keypair(secret_hex)?;
Ok(hex::encode(kp.public_key().x_only_public_key().0.serialize()))
}
pub fn npub_from_pubkey(pub_hex: &str) -> Result<String> {
let bytes = hex::decode(pub_hex.trim()).context("bad pubkey hex")?;
let hrp = Hrp::parse("npub").context("npub hrp")?;
bech32::encode::<Bech32>(hrp, &bytes).context("npub encoding")
}
/// Parse an x-only pubkey from hex or bech32 `npub…` form into hex.
pub fn pubkey_from_any(s: &str) -> Result<String> {
let s = s.trim();
let bytes = if s.starts_with("npub") {
let (hrp, data) = bech32::decode(s).context("bad npub encoding")?;
if hrp.as_str() != "npub" {
bail!("not an npub");
}
data
} else {
hex::decode(s).context("pubkey must be hex or npub")?
};
XOnlyPublicKey::from_slice(&bytes).context("invalid x-only pubkey")?;
Ok(hex::encode(bytes))
}
fn keypair(secret_hex: &str) -> Result<Keypair> {
let bytes = hex::decode(secret_hex.trim()).context("bad secret hex")?;
let sk = SecretKey::from_slice(&bytes).context("invalid nostr secret key")?;
Ok(Keypair::from_secret_key(&Secp256k1::new(), &sk))
}
// ── nostrconnect:// URI ───────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct ConnectUri {
/// The client's pubkey, hex.
pub client_pubkey: String,
/// Relays the client is listening on (≥1 by spec; kept in URI order).
pub relays: Vec<String>,
/// One-time pairing secret the client expects to see echoed back.
pub secret: String,
/// Comma-separated permission grants the client requests (display hint
/// only — approval always stays with the human).
pub perms: Vec<String>,
pub name: String,
pub url: String,
pub image: String,
}
impl ConnectUri {
/// JSON shape for the JNI boundary (flat strings/arrays — easy to parse
/// with org.json on the Kotlin side).
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"clientPubkey": self.client_pubkey,
"relays": self.relays,
"secret": self.secret,
"perms": self.perms,
"name": self.name,
"url": self.url,
"image": self.image,
})
}
}
/// Parse `nostrconnect://<client-pubkey>?relay=…&secret=…&perms=…&name=…`.
///
/// Query values are percent-decoded; `relay` may repeat. The pubkey in the
/// host position may be hex or (non-spec but harmless) `npub…`.
pub fn parse_connect_uri(uri: &str) -> Result<ConnectUri> {
let uri = uri.trim();
let rest = uri
.strip_prefix("nostrconnect://")
.ok_or_else(|| anyhow::anyhow!("not a nostrconnect:// URI"))?;
let (host, query) = match rest.split_once('?') {
Some((h, q)) => (h, q),
None => bail!("nostrconnect URI has no query parameters"),
};
let client_pubkey = pubkey_from_any(host).context("nostrconnect URI: bad client pubkey")?;
let mut relays = Vec::new();
let mut secret = String::new();
let mut perms: Vec<String> = Vec::new();
let mut name = String::new();
let mut url = String::new();
let mut image = String::new();
for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
let v = v.into_owned();
match k.as_ref() {
"relay" => {
if v.starts_with("ws://") || v.starts_with("wss://") {
relays.push(v);
}
}
"secret" => secret = v,
"perms" => perms = v.split(',').filter(|s| !s.is_empty()).map(String::from).collect(),
"name" => name = v,
"url" => url = v,
"image" => image = v,
_ => {} // forward-compat: ignore unknown params
}
}
if relays.is_empty() {
bail!("nostrconnect URI carries no relay");
}
if secret.is_empty() {
bail!("nostrconnect URI carries no secret");
}
Ok(ConnectUri {
client_pubkey,
relays,
secret,
perms,
name,
url,
image,
})
}
// ── events (NIP-01 id + BIP340 signature) ─────────────────────────────────
/// Compute the NIP-01 event id: sha256 over the compact serialization
/// `[0, pubkey, created_at, kind, tags, content]`.
fn event_id(pubkey: &str, created_at: u64, kind: u64, tags: &serde_json::Value, content: &str) -> [u8; 32] {
let serialized = serde_json::json!([
0,
pubkey,
created_at,
kind,
tags,
content,
]);
let mut hasher = Sha256::new();
hasher.update(serialized.to_string().as_bytes());
hasher.finalize().into()
}
/// Sign an unsigned event `{kind, content, tags, created_at}` (pubkey filled
/// from the secret key; `pubkey` in the input ignored) and return the signed
/// event JSON. This is the `sign_event` NIP-46 method's core — the approve
/// happens before this call, never inside it.
pub fn sign_event(secret_hex: &str, event_json: &str) -> Result<String> {
let ev: serde_json::Value = serde_json::from_str(event_json).context("event is not JSON")?;
let kind = ev
.get("kind")
.and_then(|v| v.as_u64())
.context("event has no kind")?;
let created_at = ev
.get("created_at")
.and_then(|v| v.as_u64())
.context("event has no created_at")?;
let tags = ev
.get("tags")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
let content = ev
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let kp = keypair(secret_hex)?;
let pubkey = hex::encode(kp.public_key().x_only_public_key().0.serialize());
let id = event_id(&pubkey, created_at, kind, &tags, &content);
let mut aux = [0u8; 32];
getrandom::getrandom(&mut aux).context("OS RNG")?;
let sig = Secp256k1::new().sign_schnorr_with_aux_rand(
&Message::from_digest(id),
&kp,
&aux,
);
Ok(serde_json::json!({
"id": hex::encode(id),
"pubkey": pubkey,
"created_at": created_at,
"kind": kind,
"tags": tags,
"content": content,
"sig": hex::encode(sig.serialize()),
})
.to_string())
}
/// Verify a signed event's id and schnorr signature (tests + defensive use).
pub fn verify_event(event_json: &str) -> Result<()> {
let ev: serde_json::Value = serde_json::from_str(event_json).context("event is not JSON")?;
let pubkey = ev.get("pubkey").and_then(|v| v.as_str()).context("no pubkey")?;
let id_hex = ev.get("id").and_then(|v| v.as_str()).context("no id")?;
let sig_hex = ev.get("sig").and_then(|v| v.as_str()).context("no sig")?;
let kind = ev.get("kind").and_then(|v| v.as_u64()).context("no kind")?;
let created_at = ev.get("created_at").and_then(|v| v.as_u64()).context("no created_at")?;
let tags = ev.get("tags").cloned().unwrap_or_else(|| serde_json::json!([]));
let content = ev.get("content").and_then(|v| v.as_str()).unwrap_or("");
let expected = event_id(pubkey, created_at, kind, &tags, content);
if hex::encode(expected) != id_hex {
bail!("event id mismatch");
}
let pk = XOnlyPublicKey::from_slice(&hex::decode(pubkey)?)
.context("bad pubkey")?;
let sig = Signature::from_slice(&hex::decode(sig_hex)?)
.context("bad signature")?;
Secp256k1::new()
.verify_schnorr(&sig, &Message::from_digest(expected), &pk)
.context("signature verification failed")?;
Ok(())
}
// ── NIP-44 v2 ──────────────────────────────────────────────────────────────
/// ECDH shared x-coordinate (unhashed, 32 bytes) between our secret key and
/// the peer's x-only public key. Lifting the x-only key with even-y parity
/// is safe here: negating a point flips only y, so the shared x — the only
/// thing NIP-44/NIP-04 consume — is unchanged.
fn shared_x(secret_hex: &str, peer_pubkey_hex: &str) -> Result<[u8; 32]> {
let sk_bytes = hex::decode(secret_hex.trim()).context("bad secret hex")?;
let sk = SecretKey::from_slice(&sk_bytes).context("invalid secret key")?;
let peer_hex = pubkey_from_any(peer_pubkey_hex)?;
let peer = XOnlyPublicKey::from_slice(&hex::decode(&peer_hex)?)
.context("invalid peer pubkey")?;
// Lift x-only key to a full public key (even-y representative).
let full = PublicKey::from_x_only_public_key(peer, secp256k1::Parity::Even);
let point = ecdh::shared_secret_point(&full, &sk); // 64 bytes: x || y
let mut x = [0u8; 32];
x.copy_from_slice(&point[..32]);
Ok(x)
}
/// NIP-44 v2 conversation key: HKDF-extract(IKM = ECDH x, salt = 'nip44-v2').
fn conversation_key(secret_hex: &str, peer_pubkey_hex: &str) -> Result<[u8; 32]> {
let x = shared_x(secret_hex, peer_pubkey_hex)?;
let mut hk = HkdfExtractSha256::new(Some(NIP44_SALT));
hk.input_ikm(&x);
let (prk, _) = hk.finalize();
let mut ck = [0u8; 32];
ck.copy_from_slice(prk.as_slice());
Ok(ck)
}
/// HKDF-SHA256 extract step, exposing the raw PRK (Hkdf::expand hashes with
/// an info suffix even when info is empty, which is NOT the extract output;
/// finalize returns (PRK, ready-to-expand Hkdf)).
type HkdfExtractSha256 = hkdf::HkdfExtract<Sha256>;
/// Per-message keys: HKDF-expand(PRK = conversation key, info = nonce, L = 76)
/// sliced into chacha_key[32] chacha_nonce[12] hmac_key[32].
fn message_keys(ck: &[u8; 32], nonce: &[u8; 32]) -> ([u8; 32], [u8; 12], [u8; 32]) {
let hk = Hkdf::<Sha256>::from_prk(ck).expect("conversation key is 32 bytes");
let mut okm = [0u8; 76];
hk.expand(nonce, &mut okm).expect("76 <= 255 * hash len");
let mut chacha_key = [0u8; 32];
let mut chacha_nonce = [0u8; 12];
let mut hmac_key = [0u8; 32];
chacha_key.copy_from_slice(&okm[..32]);
chacha_nonce.copy_from_slice(&okm[32..44]);
hmac_key.copy_from_slice(&okm[44..76]);
(chacha_key, chacha_nonce, hmac_key)
}
/// NIP-44 padding: 2-byte big-endian plaintext length (6 bytes, `0x0000` +
/// u32, when ≥ 65536), zero-padded to the next power-of-two-ish chunk.
fn calc_padded_len(unpadded: usize) -> usize {
let unpadded: u64 = unpadded as u64;
if unpadded <= 32 {
return 32;
}
let next_power = 1u64 << ((63 - (unpadded - 1).leading_zeros()) + 1);
let chunk = if next_power <= 256 { 32 } else { next_power / 8 };
(chunk * ((unpadded - 1) / chunk + 1)) as usize
}
fn pad(plaintext: &[u8]) -> Result<Vec<u8>> {
if plaintext.is_empty() || plaintext.len() > u32::MAX as usize {
bail!("invalid plaintext length");
}
let prefix: Vec<u8> = if plaintext.len() >= 65536 {
let mut p = vec![0u8, 0u8];
p.extend_from_slice(&(plaintext.len() as u32).to_be_bytes());
p
} else {
(plaintext.len() as u16).to_be_bytes().to_vec()
};
let padded_len = calc_padded_len(plaintext.len());
let mut out = Vec::with_capacity(prefix.len() + padded_len);
out.extend_from_slice(&prefix);
out.extend_from_slice(plaintext);
out.resize(prefix.len() + padded_len, 0);
Ok(out)
}
fn unpad(padded: &[u8]) -> Result<Vec<u8>> {
if padded.len() < 2 {
bail!("invalid padding");
}
let first_two = u16::from_be_bytes([padded[0], padded[1]]);
let (unpadded_len, prefix_len) = if first_two == 0 {
if padded.len() < 6 {
bail!("invalid padding");
}
(u32::from_be_bytes([padded[2], padded[3], padded[4], padded[5]]) as usize, 6)
} else {
(first_two as usize, 2)
};
if unpadded_len == 0
|| padded.len() < prefix_len + unpadded_len
|| padded.len() != prefix_len + calc_padded_len(unpadded_len)
{
bail!("invalid padding");
}
Ok(padded[prefix_len..prefix_len + unpadded_len].to_vec())
}
/// Constant-time equality (length differs → false; content comparison never
/// short-circuits on a byte).
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
/// NIP-44 v2 encrypt: returns `base64(0x02 || nonce || ciphertext || mac)`.
pub fn nip44_encrypt(secret_hex: &str, peer_pubkey_hex: &str, plaintext: &str) -> Result<String> {
let ck = conversation_key(secret_hex, peer_pubkey_hex)?;
let mut nonce = [0u8; 32];
getrandom::getrandom(&mut nonce).context("OS RNG")?;
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck, &nonce);
let mut padded = pad(plaintext.as_bytes())?;
ChaCha20::new(&chacha_key.into(), &chacha_nonce.into()).apply_keystream(&mut padded);
let mut mac = <HmacSha256 as Mac>::new_from_slice(&hmac_key).expect("hmac accepts any key len");
mac.update(&nonce);
mac.update(&padded);
let tag = mac.finalize().into_bytes();
let mut out = Vec::with_capacity(1 + 32 + padded.len() + 32);
out.push(NIP44_VERSION);
out.extend_from_slice(&nonce);
out.extend_from_slice(&padded);
out.extend_from_slice(&tag);
Ok(BASE64.encode(&out))
}
/// NIP-44 v2 decrypt of a `base64(0x02 || …)` payload.
pub fn nip44_decrypt(secret_hex: &str, peer_pubkey_hex: &str, payload: &str) -> Result<String> {
if payload.starts_with('#') {
bail!("unknown NIP-44 version (non-base64 payload)");
}
let data = BASE64
.decode(payload.trim())
.context("payload is not base64")?;
if payload.len() < NIP44_MIN_B64_LEN || data.len() < NIP44_MIN_PAYLOAD_LEN {
bail!("invalid NIP-44 payload size");
}
if data[0] != NIP44_VERSION {
bail!("unknown NIP-44 version {}", data[0]);
}
let nonce: [u8; 32] = data[1..33].try_into().expect("slice is 32");
let ciphertext = &data[33..data.len() - 32];
let mac_bytes = &data[data.len() - 32..];
let ck = conversation_key(secret_hex, peer_pubkey_hex)?;
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck, &nonce);
let mut mac = <HmacSha256 as Mac>::new_from_slice(&hmac_key).expect("hmac accepts any key len");
mac.update(&nonce);
mac.update(ciphertext);
let expected = mac.finalize().into_bytes();
if !ct_eq(&expected, mac_bytes) {
bail!("invalid NIP-44 MAC");
}
let mut buf = ciphertext.to_vec();
ChaCha20::new(&chacha_key.into(), &chacha_nonce.into()).apply_keystream(&mut buf);
let plaintext = unpad(&buf)?;
String::from_utf8(plaintext).context("decrypted payload is not UTF-8")
}
// ── NIP-04 (deprecated transport, still spoken by real clients) ────────────
/// NIP-04 encrypt: AES-256-CBC, key = raw ECDH x-coordinate (unhashed — the
/// spec's quirk), output `<base64 ct>?iv=<base64 iv>`.
pub fn nip04_encrypt(secret_hex: &str, peer_pubkey_hex: &str, plaintext: &str) -> Result<String> {
use aes::cipher::{BlockEncryptMut, KeyIvInit};
type Enc = cbc::Encryptor<aes::Aes256>;
let key = shared_x(secret_hex, peer_pubkey_hex)?;
let mut iv = [0u8; 16];
getrandom::getrandom(&mut iv).context("OS RNG")?;
let ct = Enc::new(&key.into(), &iv.into()).encrypt_padded_vec_mut::<aes::cipher::block_padding::Pkcs7>(plaintext.as_bytes());
Ok(format!("{}?iv={}", BASE64.encode(&ct), BASE64.encode(iv)))
}
/// NIP-04 decrypt of `<base64 ct>?iv=<base64 iv>`.
pub fn nip04_decrypt(secret_hex: &str, peer_pubkey_hex: &str, payload: &str) -> Result<String> {
use aes::cipher::{BlockDecryptMut, KeyIvInit};
type Dec = cbc::Decryptor<aes::Aes256>;
let (ct_b64, iv_b64) = payload
.trim()
.split_once("?iv=")
.ok_or_else(|| anyhow::anyhow!("not a NIP-04 payload (no iv)"))?;
let ct = BASE64.decode(ct_b64).context("bad NIP-04 ciphertext base64")?;
let iv: [u8; 16] = BASE64
.decode(iv_b64)
.context("bad NIP-04 iv base64")?
.try_into()
.map_err(|_| anyhow::anyhow!("NIP-04 iv must be 16 bytes"))?;
let key = shared_x(secret_hex, peer_pubkey_hex)?;
let pt = Dec::new(&key.into(), &iv.into())
.decrypt_padded_vec_mut::<aes::cipher::block_padding::Pkcs7>(&ct)
.map_err(|_| anyhow::anyhow!("NIP-04 decryption failed"))?;
String::from_utf8(pt).context("decrypted payload is not UTF-8")
}
/// URL-safe base64 for keys that cross the JNI boundary — unused by the
/// protocol but handy for the Kotlin side; keep the engine in one place.
pub fn b64_url(data: &[u8]) -> String {
BASE64_URL.encode(data)
}
#[cfg(test)]
mod tests {
use super::*;
// ── official NIP-44 vectors (paulmillr/nip44 nip44.vectors.json) ──────
#[test]
fn nip44_official_conversation_keys() {
let vectors: &[(&str, &str, &str)] = &[
("315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268", "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133", "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1"),
("98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311", "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1", "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7"),
("86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f", "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585", "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba"),
// sec1 == pub2 (ECDH with self)
("0000000000000000000000000000000000000000000000000000000000000001", "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e"),
];
for (sec1, pub2, expected) in vectors {
let ck = conversation_key(sec1, pub2).unwrap();
assert_eq!(hex::encode(ck), *expected);
}
}
#[test]
fn nip44_official_message_keys() {
let ck_bytes: [u8; 32] = hex::decode("a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54")
.unwrap()
.try_into()
.unwrap();
let vectors: &[(&str, &str, &str, &str)] = &[
("e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72", "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76", "c4ad129bb01180c0933a160c", "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"),
("ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967", "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be", "50bb859aa2dde938cc49ec7a", "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6"),
];
for (nonce_h, ck_exp, cn_exp, hk_exp) in vectors {
let nonce: [u8; 32] = hex::decode(nonce_h).unwrap().try_into().unwrap();
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck_bytes, &nonce);
assert_eq!(hex::encode(chacha_key), *ck_exp);
assert_eq!(hex::encode(chacha_nonce), *cn_exp);
assert_eq!(hex::encode(hmac_key), *hk_exp);
}
}
#[test]
fn nip44_offical_padded_len() {
let vectors: &[(usize, usize)] = &[
(16, 32), (32, 32), (33, 64), (37, 64), (45, 64), (49, 64), (64, 64),
(65, 96), (100, 128), (111, 128), (200, 224), (250, 256), (320, 320),
(383, 384), (384, 384), (400, 448), (500, 512), (512, 512), (515, 640),
(700, 768), (800, 896), (900, 1024), (1020, 1024), (65536, 65536),
];
for (unpadded, padded) in vectors {
assert_eq!(calc_padded_len(*unpadded), *padded, "unpadded {unpadded}");
}
}
#[test]
fn nip44_official_encrypt_vectors() {
// (sec1, sec2, nonce, plaintext, payload) — decrypt with the peer's
// view (sec2, pub(sec1)) so this also proves key symmetry.
let vectors: &[(&str, &str, &str, &str, &str)] = &[
("0000000000000000000000000000000000000000000000000000000000000001",
"0000000000000000000000000000000000000000000000000000000000000002",
"0000000000000000000000000000000000000000000000000000000000000001",
"a",
"AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"),
("0000000000000000000000000000000000000000000000000000000000000002",
"0000000000000000000000000000000000000000000000000000000000000001",
"f00000000000000000000000000000f00000000000000000000000000000000f",
"🍕🫃",
"AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj"),
("5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
"4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
"b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
"表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀",
"ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs="),
("eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
"dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
"2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
"( ͡° ͜ʖ ͡°)",
"AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv"),
("d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
"🙈 🙉 🙊 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
"AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg=="),
];
for (sec1, sec2, nonce_hex, plaintext, payload) in vectors {
// Encrypt from A to B with the fixed nonce must reproduce the
// official payload byte-for-byte.
let pub1 = pubkey_hex(sec1).unwrap();
let made = {
let ck = conversation_key(sec1, &pubkey_hex(sec2).unwrap()).unwrap();
let nonce: [u8; 32] = hex::decode(nonce_hex).unwrap().try_into().unwrap();
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck, &nonce);
let mut padded = pad(plaintext.as_bytes()).unwrap();
ChaCha20::new(&chacha_key.into(), &chacha_nonce.into()).apply_keystream(&mut padded);
let mut mac = <HmacSha256 as Mac>::new_from_slice(&hmac_key).unwrap();
mac.update(&nonce);
mac.update(&padded);
let tag = mac.finalize().into_bytes();
let mut out = vec![NIP44_VERSION];
out.extend_from_slice(&nonce);
out.extend_from_slice(&padded);
out.extend_from_slice(&tag);
BASE64.encode(&out)
};
assert_eq!(&made, payload, "encrypt vector for {plaintext:?}");
// Decrypt from B's view of A (key-role symmetry).
let got = nip44_decrypt(sec2, &pub1, payload).unwrap();
assert_eq!(got, *plaintext);
}
}
#[test]
fn nip44_round_trip_and_failures() {
let sk_a = generate_secret().unwrap();
let sk_b = generate_secret().unwrap();
let pub_b = pubkey_hex(&sk_b).unwrap();
let pub_a = pubkey_hex(&sk_a).unwrap();
let msg = "hello, remote signer";
let payload = nip44_encrypt(&sk_a, &pub_b, msg).unwrap();
assert_eq!(nip44_decrypt(&sk_b, &pub_a, &payload).unwrap(), msg);
// Round-trip long content across the 65536 prefix boundary.
let long = "x".repeat(70_000);
let payload = nip44_encrypt(&sk_a, &pub_b, &long).unwrap();
assert_eq!(nip44_decrypt(&sk_b, &pub_a, &payload).unwrap(), long);
// Wrong peer key must fail the MAC, not return garbage.
let stranger = generate_secret().unwrap();
assert!(nip44_decrypt(&sk_b, &pub_b, &payload).is_err());
let _ = stranger;
// Tampered payload fails.
let payload = nip44_encrypt(&sk_a, &pub_b, msg).unwrap();
let mut tampered = BASE64.decode(&payload).unwrap();
let n = tampered.len();
tampered[n - 1] ^= 0x01;
assert!(nip44_decrypt(&sk_b, &pub_a, &BASE64.encode(&tampered)).is_err());
// Truncated payload fails.
assert!(nip44_decrypt(&sk_b, &pub_a, "AAAA").is_err());
}
// ── BIP-340 official vectors (github.com/bitcoin/bips test vectors) ────
#[test]
fn bip340_reference_sign_vectors() {
// (seckey, pubkey, aux, msg, expected sig) — indices 0/1/2 of the
// official BIP-340 `bip-0340/test-vectors.csv` "should sign" set,
// transcribed from the file itself (x(3G) additionally verified
// by independent scalar-math in the review notes for this commit).
let vectors: &[(&str, &str, &str, &str, &str)] = &[
("0000000000000000000000000000000000000000000000000000000000000003",
"F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9",
"0000000000000000000000000000000000000000000000000000000000000000",
"0000000000000000000000000000000000000000000000000000000000000000",
"E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0"),
("B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF",
"DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659",
"0000000000000000000000000000000000000000000000000000000000000001",
"243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89",
"6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE33418906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A"),
("C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9",
"DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8",
"C87AA53824B4D7AE2EB035A2B5BBBCCC080E76CDC6D1692C4B0B62D798E6D906",
"7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C",
"5831AAEED7B44BB74E5EAB94BA9D4294C49BCF2A60728D8B4C200F50DD313C1BAB745879A5AD954A72C45A91C3A51D3C7ADEA98D82F8481E0E1E03674A6F3FB7"),
];
for (sk_hex, pk_hex, aux_hex, msg_hex, sig_hex) in vectors {
let sk_bytes = hex::decode(sk_hex).unwrap();
let sk = SecretKey::from_slice(&sk_bytes).unwrap();
let kp = Keypair::from_secret_key(&Secp256k1::new(), &sk);
assert_eq!(hex::encode(kp.public_key().x_only_public_key().0.serialize()).to_uppercase(), *pk_hex);
let msg: [u8; 32] = hex::decode(msg_hex).unwrap().try_into().unwrap();
let aux: [u8; 32] = hex::decode(aux_hex).unwrap().try_into().unwrap();
let sig = Secp256k1::new().sign_schnorr_with_aux_rand(
&Message::from_digest(msg),
&kp,
&aux,
);
assert_eq!(hex::encode(sig.serialize()).to_uppercase(), *sig_hex);
}
}
#[test]
fn event_signing_round_trip() {
let sk = generate_secret().unwrap();
let unsigned = r#"{"kind":22242,"content":"{\"challenge\":\"abc123\"}","tags":[["relay","ws://127.0.0.1:7777"]],"created_at":1725100000}"#;
let signed = sign_event(&sk, unsigned).unwrap();
verify_event(&signed).unwrap();
let ev: serde_json::Value = serde_json::from_str(&signed).unwrap();
assert_eq!(ev["kind"], 22242);
assert_eq!(ev["pubkey"], pubkey_hex(&sk).unwrap());
// Tampering with content breaks the id, which breaks verification.
let mut tampered = ev.clone();
tampered["content"] = serde_json::Value::String("nope".into());
assert!(verify_event(&tampered.to_string()).is_err());
}
#[test]
fn connect_uri_parsing() {
let uri = "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay1.example.com&perms=nip44_encrypt%2Csign_event%3A22242&name=My+Client&secret=0s8j2djs&relay=ws%3A%2F%2F192.168.1.20%3A7777";
let info = parse_connect_uri(uri).unwrap();
assert_eq!(info.client_pubkey, "83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5");
assert_eq!(
info.relays,
vec!["wss://relay1.example.com", "ws://192.168.1.20:7777"]
);
assert_eq!(info.secret, "0s8j2djs");
assert_eq!(info.perms, vec!["nip44_encrypt", "sign_event:22242"]);
assert_eq!(info.name, "My Client");
// npub client keys and unknown params tolerated — the npub is
// generated through our own encoder so the test carries no
// hand-transcribed bech32 string.
let sk1 = "0000000000000000000000000000000000000000000000000000000000000001";
let npub = npub_from_pubkey(&pubkey_hex(sk1).unwrap()).unwrap();
let pubkey = pubkey_from_any(&npub).unwrap();
let uri = format!("nostrconnect://{npub}?relay=wss://r&secret=s&future=1");
let info = parse_connect_uri(&uri).unwrap();
assert_eq!(info.client_pubkey, pubkey);
assert_eq!(info.relays, vec!["wss://r"]);
assert!(parse_connect_uri("bunker://abc?relay=wss://r&secret=s").is_err());
assert!(parse_connect_uri("nostrconnect://zz?relay=wss://r&secret=s").is_err());
assert!(parse_connect_uri("nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?name=x").is_err());
}
#[test]
fn nip04_round_trip_and_cross_check() {
let sk_a = generate_secret().unwrap();
let sk_b = generate_secret().unwrap();
let pub_b = pubkey_hex(&sk_b).unwrap();
let pub_a = pubkey_hex(&sk_a).unwrap();
let payload = nip04_encrypt(&sk_a, &pub_b, "old client hello").unwrap();
assert!(payload.contains("?iv="));
assert_eq!(nip04_decrypt(&sk_b, &pub_a, &payload).unwrap(), "old client hello");
// Wrong key must fail (PKCS#7 padding check) rather than return garbage.
assert!(nip04_decrypt(&sk_a, &pub_a, &payload).is_err());
assert!(nip04_decrypt(&sk_b, &pub_b, &payload).is_err());
assert!(nip04_decrypt(&sk_b, &pub_a, "not-a-payload").is_err());
}
#[test]
fn key_encoding_round_trip() {
let sk = generate_secret().unwrap();
let nsec = nsec_from_secret(&sk).unwrap();
assert!(nsec.starts_with("nsec1"));
assert_eq!(secret_from_nsec(&nsec).unwrap(), sk);
assert_eq!(secret_from_any(&nsec).unwrap(), sk);
assert_eq!(secret_from_any(&sk).unwrap(), sk);
let pk = pubkey_hex(&sk).unwrap();
let npub = npub_from_pubkey(&pk).unwrap();
assert!(npub.starts_with("npub1"));
assert_eq!(pubkey_from_any(&npub).unwrap(), pk);
assert_eq!(pubkey_from_any(&pk).unwrap(), pk);
// The famous even-y lift edge case: pubkey of sk=1 is x(G) (y is odd);
// shared_x with oneself is exactly x(G) — pins the unhashed-x ECDH and
// the even-parity lift in one assertion (x is invariant under y-negation,
// so the lift is safe for NIP-44/NIP-04 keys).
let g_x = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
assert_eq!(
pubkey_hex("0000000000000000000000000000000000000000000000000000000000000001").unwrap(),
g_x
);
assert_eq!(
hex::encode(
shared_x("0000000000000000000000000000000000000000000000000000000000000001", g_x).unwrap()
),
g_x
);
assert!(secret_from_nsec("npub1").is_err());
}
/// The mesh ULA is a PURE function of the node's public key:
/// `fd ‖ sha256(x-only pubkey)[0..15]` (fips identity/node_addr.rs →
/// identity/address.rs). That is what makes "address by npub" work —
/// Termux's fipssh helper, and any future DNS-style resolver, just
/// computes what the fips daemon's DNS answers.
#[test]
fn npub_derives_the_same_mesh_ula_as_the_fips_identity() {
for seed in [0x42u8, 0x07, 0x31] {
// 0xff… would exceed the curve order — secret keys must be valid scalars.
let secret = [seed; 32];
let id = fips::Identity::from_secret_bytes(&secret).unwrap();
let npub = id.npub();
let expected = id.address().to_ipv6().to_string();
let pubkey_hex = pubkey_from_any(&npub).unwrap();
let pk = hex::decode(&pubkey_hex).unwrap();
let mut hasher = Sha256::new();
hasher.update(&pk);
let hash = hasher.finalize();
let mut ula = [0u8; 16];
ula[0] = 0xfd;
ula[1..].copy_from_slice(&hash[..15]);
assert_eq!(std::net::Ipv6Addr::from(ula).to_string(), expected, "npub {npub}");
}
}
}
-145
View File
@@ -1,145 +0,0 @@
#!/data/data/com.termux/files/usr/bin/sh
# fipssh — SSH to an Archipelago FIPS mesh node BY NPUB.
#
# The mesh ULA is a pure function of the node's public key (verified against
# the fips crate itself — archy-fips-core's npub_derives_the_same_mesh_ula
# test, and the Android tools commit that shipped this script):
#
# ula = fd || sha256(x-only pubkey)[0..15]
#
# so the npub IS the address: no DNS server, no mesh query, works offline.
# The node's fips daemon answers the same question through its DNS resolver
# (core/archipelago/src/fips/dial.rs) — this is the phone-side equivalent.
#
# Setup (Termux): pkg install python openssh
# Usage:
# fipssh <user>@npub1… [ssh args…] connect
# fipssh npub1… connect as $FIPSSH_USER
# fipssh --resolve npub1… print the ULA and exit
#
# The companion's split tunnel carries the connection (fd00::/8 routes the
# whole device while the mesh is up) — at home on LAN, away via the anchors.
# The node still has to allow port 22 through its fips0 firewall: see
# docs/HANDOFF-2026-08-31-ssh-over-mesh.md (the interim 90-ssh.nft drop-in,
# restricted to your phone's ULA, until the node-side toggle ships).
set -eu
usage() {
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
exit 1
}
RESOLVE_ONLY=0
if [ "${1:-}" = "--resolve" ]; then
RESOLVE_ONLY=1
shift
fi
[ $# -ge 1 ] || usage
TARGET="$1"
shift 2>/dev/null || true
case "$TARGET" in
*npub1*)
case "$TARGET" in
*@npub1*) USER_PART="${TARGET%%@*}"; N_PUB="${TARGET#*@}" ;;
npub1*)
USER_PART="${FIPSSH_USER:-}"
N_PUB="$TARGET"
if [ -z "$USER_PART" ] && [ "$RESOLVE_ONLY" = 0 ]; then
echo "fipssh: no user given (use user@npub… or set FIPSSH_USER)" >&2
exit 1
fi
;;
*) echo "fipssh: expected [user@]npub1…, got '$TARGET'" >&2; exit 1 ;;
esac
;;
*) echo "fipssh: '$TARGET' is not an npub (expected [user@]npub1…)" >&2; exit 1 ;;
esac
command -v python3 >/dev/null 2>&1 || {
echo "fipssh: python3 not found — run: pkg install python" >&2
exit 1
}
ULA=$(python3 - "$N_PUB" <<'PYEOF'
import hashlib, ipaddress, sys
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def bech32_polymod(values):
gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
chk = 1
for value in values:
top = chk >> 25
chk = (chk & 0x1FFFFFF) << 5 ^ value
for i in range(5):
chk ^= gen[i] if ((top >> i) & 1) else 0
return chk
def bech32_hrp_expand(hrp):
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
def bech32_verify_checksum(hrp, data):
return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1
def bech32_decode(s):
if any(ord(c) < 33 or ord(c) > 126 for c in s):
raise ValueError("bad character")
if s.lower() != s and s.upper() != s:
raise ValueError("mixed case")
s = s.lower()
pos = s.rfind("1")
if pos < 1 or pos + 7 > len(s) or len(s) > 90:
raise ValueError("bad separator")
hrp = s[:pos]
data = [CHARSET.find(c) for c in s[pos + 1:]]
if -1 in data:
raise ValueError("bad data character")
if not bech32_verify_checksum(hrp, data):
raise ValueError("bad checksum — typo in the npub?")
return hrp, data[:-6]
def convertbits(data, frombits, tobits):
acc = 0
bits = 0
ret = bytearray()
maxv = (1 << tobits) - 1
for value in data:
if value < 0 or (value >> frombits):
raise ValueError("bad value")
acc = (acc << frombits) | value
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append((acc >> bits) & maxv)
if bits >= frombits or ((acc << (tobits - bits)) & maxv):
raise ValueError("bad padding")
return bytes(ret)
npub = sys.argv[1]
hrp, data = bech32_decode(npub)
if hrp != "npub":
raise ValueError(f"expected hrp 'npub', got '{hrp}'")
pubkey = convertbits(data, 5, 8)
if len(pubkey) != 32:
raise ValueError(f"npub data must be 32 bytes, got {len(pubkey)}")
# ula = fd || sha256(pubkey)[0..15] — mirrors fips identity/node_addr.rs +
# identity/address.rs (FIPS_ADDRESS_PREFIX = 0xfd).
ula = bytes([0xFD]) + hashlib.sha256(pubkey).digest()[:15]
print(ipaddress.IPv6Address(ula).compressed)
PYEOF
) || exit 1
if [ "$RESOLVE_ONLY" = 1 ]; then
echo "$ULA"
exit 0
fi
exec ssh "${USER_PART}@${ULA}" "$@"
-384
View File
@@ -1,384 +0,0 @@
#!/usr/bin/env python3
"""
NIP-46 test client for the Archipelago companion's Remote Signer (#139).
Plays the role the node's login flow will play (rust-nostr nostr-connect
client): generates a nostrconnect:// pairing QR, connects to a relay, waits
for the phone's bunker `connect` (secret echo), acks it, then exercises
get_public_key + sign_event and VERIFIES the returned schnorr signature with
independent pure-Python BIP-340 code (no shared code with the phone's Rust).
Run it on your computer next to the phone:
python3 -m venv /tmp/nip46env
/tmp/nip46env/bin/pip install websockets qrcode
/tmp/nip46env/bin/python Android/tools/nip46-test-client.py [--relay wss://relay.damus.io]
…then on the phone: hub menu (three-finger hold) → Remote Signer →
Generate key (once) → Scan pairing QR → point at the terminal QR → Approve.
Pure Python (no deps for the crypto; websockets + qrcode for transport/QR).
"""
import argparse
import asyncio
import base64
import hashlib
import hmac
import json
import os
import secrets
import struct
import sys
import time
import urllib.parse
import websockets # pip install websockets
# ── secp256k1 / BIP-340 (independent of the phone's Rust code) ──────────────
P = 2**256 - 2**32 - 977
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
GX = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
GY = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
G = (GX, GY)
def _add(pt1, pt2):
if pt1 is None:
return pt2
if pt2 is None:
return pt1
x1, y1 = pt1
x2, y2 = pt2
if x1 == x2 and (y1 + y2) % P == 0:
return None
if pt1 == pt2:
lam = (3 * x1 * x1) * pow(2 * y1, -1, P) % P
else:
lam = (y2 - y1) * pow(x2 - x1, -1, P) % P
x3 = (lam * lam - x1 - x2) % P
return (x3, (lam * (x1 - x3) - y1) % P)
def _mul(k, pt):
r = None
while k:
if k & 1:
r = _add(r, pt)
pt = _add(pt, pt)
k >>= 1
return r
def lift_x(x):
if x >= P:
return None
y_sq = (pow(x, 3, P) + 7) % P
y = pow(y_sq, (P + 1) // 4, P)
if y * y % P != y_sq:
return None
return (x, y if y % 2 == 0 else P - y)
def tagged(tag: bytes, data: bytes) -> bytes:
"""BIP-340 tagged hash: sha256(hash(tag) || hash(tag) || data)."""
th = hashlib.sha256(tag).digest()
return hashlib.sha256(th + th + data).digest()
def bip340_sign(msg: bytes, seckey: int, aux: bytes) -> bytes:
d = seckey if seckey <= N - 1 else seckey - N
pub = _mul(d, G)
if pub[1] % 2 != 0:
d = N - d
t = bytes(a ^ b for a, b in zip(d.to_bytes(32, "big"), tagged(b"BIP0340/aux", aux)))
rand = tagged(b"BIP0340/nonce", t + pub[0].to_bytes(32, "big") + msg)
k = int.from_bytes(rand, "big") % N
assert k > 0
R = _mul(k, G)
if R[1] % 2 != 0:
k = N - k
e = int.from_bytes(tagged(b"BIP0340/challenge", R[0].to_bytes(32, "big") + pub[0].to_bytes(32, "big") + msg), "big") % N
return R[0].to_bytes(32, "big") + ((k + e * d) % N).to_bytes(32, "big")
def bip340_verify(msg: bytes, pubkey_x: bytes, sig: bytes) -> bool:
"""Check s·G − e·P == R with even-y R and x(R) == r (BIP-340)."""
if len(sig) != 64 or len(pubkey_x) != 32:
return False
pub = lift_x(int.from_bytes(pubkey_x, "big"))
if pub is None:
return False
r = int.from_bytes(sig[:32], "big")
s = int.from_bytes(sig[32:], "big")
if r >= P or s >= N:
return False
e = int.from_bytes(tagged(b"BIP0340/challenge", sig[:32] + pubkey_x + msg), "big") % N
sg = _mul(s, G)
ep = _mul(e, pub)
neg_ep = (ep[0], (P - ep[1]) % P)
rp = _add(sg, neg_ep)
return rp is not None and rp[0] == r and rp[1] % 2 == 0
def ecdh_x(secret_hex: str, peer_x_hex: str) -> bytes:
"""Raw ECDH x-coordinate against an x-only peer key (even-y lift)."""
peer = lift_x(int(peer_x_hex, 16))
assert peer is not None, "peer pubkey not on curve"
pt = _mul(int(secret_hex, 16) % N, peer)
return pt[0].to_bytes(32, "big")
# ── NIP-44 v2 (pure python, spec-literal) ────────────────────────────────────
def hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
return hmac.new(salt, ikm, hashlib.sha256).digest()
def hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
t = b""
out = b""
i = 1
while len(out) < length:
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
out += t
i += 1
return out[:length]
def _rotl(x: int, n: int) -> int:
return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF
def _qr(s, a, b, c, d):
s[a] = (s[a] + s[b]) & 0xFFFFFFFF; s[d] ^= s[a]; s[d] = _rotl(s[d], 16)
s[c] = (s[c] + s[d]) & 0xFFFFFFFF; s[b] ^= s[c]; s[b] = _rotl(s[b], 12)
s[a] = (s[a] + s[b]) & 0xFFFFFFFF; s[d] ^= s[a]; s[d] = _rotl(s[d], 8)
s[c] = (s[c] + s[d]) & 0xFFFFFFFF; s[b] ^= s[c]; s[b] = _rotl(s[b], 7)
def chacha20_block(key: bytes, counter: int, nonce: bytes) -> bytes:
consts = [0x61707865, 0x3320646E, 0x79622D32, 0x6B206574]
state = consts + list(struct.unpack("<8I", key)) + [counter] + list(struct.unpack("<3I", nonce))
working = list(state)
for _ in range(10):
_qr(working, 0, 4, 8, 12); _qr(working, 1, 5, 9, 13)
_qr(working, 2, 6, 10, 14); _qr(working, 3, 7, 11, 15)
_qr(working, 0, 5, 10, 15); _qr(working, 1, 6, 11, 12)
_qr(working, 2, 7, 8, 13); _qr(working, 3, 4, 9, 14)
return struct.pack("<16I", *[(x + y) & 0xFFFFFFFF for x, y in zip(working, state)])
def chacha20(key: bytes, nonce: bytes, data: bytes) -> bytes:
counter = 0 # NIP-44: "ChaCha20 (RFC 8439) with starting counter set to 0"
out = bytearray()
for i in range(0, len(data), 64):
ks = chacha20_block(key, counter, nonce)
chunk = data[i:i + 64]
out += bytes(a ^ b for a, b in zip(chunk, ks))
counter += 1
return bytes(out)
def calc_padded_len(n: int) -> int:
if n <= 32:
return 32
power = 1 << ((n - 1).bit_length())
chunk = 32 if power <= 256 else power // 8
return chunk * ((n - 1) // chunk + 1)
def nip44_encrypt(secret_hex: str, peer_hex: str, plaintext: str) -> str:
ck = hkdf_extract(b"nip44-v2", ecdh_x(secret_hex, peer_hex))
nonce = secrets.token_bytes(32)
okm = hkdf_expand(ck, nonce, 76)
key, iv, mac_key = okm[:32], okm[32:44], okm[44:76]
pt = plaintext.encode()
padded = (len(pt).to_bytes(2, "big") if len(pt) < 65536 else b"\x00\x00" + len(pt).to_bytes(4, "big")) + pt
padded += b"\x00" * (calc_padded_len(len(pt)) - len(pt))
ct = chacha20(key, iv, padded)
mac = hmac.new(mac_key, nonce + ct, hashlib.sha256).digest()
return base64.b64encode(bytes([2]) + nonce + ct + mac).decode()
def nip44_decrypt(secret_hex: str, peer_hex: str, payload: str) -> str:
data = base64.b64decode(payload)
assert data[0] == 2, "only NIP-44 v2 supported"
nonce, ct, mac = data[1:33], data[33:-32], data[-32:]
ck = hkdf_extract(b"nip44-v2", ecdh_x(secret_hex, peer_hex))
okm = hkdf_expand(ck, nonce, 76)
key, iv, mac_key = okm[:32], okm[32:44], okm[44:76]
assert hmac.compare_digest(hmac.new(mac_key, nonce + ct, hashlib.sha256).digest(), mac), "bad MAC"
padded = chacha20(key, iv, ct)
ln = int.from_bytes(padded[:2], "big")
body = padded[2:2 + ln] if ln else padded[6:6 + int.from_bytes(padded[2:6], "big")]
return body.decode()
# ── nostr events ─────────────────────────────────────────────────────────────
def event_id(pubkey_hex: str, created_at: int, kind: int, tags, content: str) -> str:
serialized = json.dumps([0, pubkey_hex, created_at, kind, tags, content], separators=(",", ":"))
return hashlib.sha256(serialized.encode()).hexdigest()
def sign_event(secret_hex: str, event: dict) -> dict:
eid = event_id(event["pubkey"], event["created_at"], event["kind"], event["tags"], event["content"])
ev = dict(event)
ev["id"] = eid
ev["sig"] = bip340_sign(bytes.fromhex(eid), int(secret_hex, 16), os.urandom(32)).hex()
return ev
# ── the client session ────────────────────────────────────────────────────────
def compact(d) -> str:
return json.dumps(d, separators=(",", ":"))
async def run(relay: str):
client_secret = os.urandom(32).hex()
client_secret_int = int(client_secret, 16) % N
client_pub_hex = _mul(client_secret_int, G)[0].to_bytes(32, "big").hex()
pair_secret = secrets.token_hex(16)
nonce = secrets.token_hex(8)
uri = (
f"nostrconnect://{client_pub_hex}"
f"?relay={urllib.parse.quote(relay, safe='')}"
f"&secret={pair_secret}"
f"&name=Archipelago+Test+Client"
)
print(f"· client key : {client_pub_hex}")
print(f"· relay : {relay}")
print()
print("Scan this QR with: Companion → hub (3-finger) → Remote Signer → Scan pairing QR")
print()
try:
import qrcode
qr = qrcode.QRCode(border=1)
qr.add_data(uri)
qr.make(fit=True)
qr.print_ascii(invert=True)
except ImportError:
print(uri)
print()
print("Waiting for the phone to pair (connect, ack, get_public_key, sign_event)…")
async with websockets.connect(relay, max_size=2**22) as ws:
await ws.send(compact(["REQ", "test", {"kinds": [24133], "#p": [client_pub_hex], "since": int(time.time()) - 60}]))
signer_pub = None
acked = False
requests = []
def send_frame(content: dict):
assert signer_pub is not None
ev = {
"pubkey": client_pub_hex,
"created_at": int(time.time()),
"kind": 24133,
"tags": [["p", signer_pub]],
"content": nip44_encrypt(client_secret, signer_pub, compact(content)),
}
return asyncio.ensure_future(ws.send(compact(["EVENT", sign_event(client_secret, ev)])))
async def request(method, params, rid):
send_frame({"id": rid, "method": method, "params": params})
timeout = time.time() + 120
got_pubkey = None
signed_event = None
while time.time() < timeout:
try:
raw = await asyncio.wait_for(ws.recv(), timeout=timeout - time.time())
except (asyncio.TimeoutError, TimeoutError):
break
arr = json.loads(raw)
if not isinstance(arr, list) or len(arr) < 3 or arr[0] != "EVENT":
continue
ev = arr[2]
if ev.get("kind") != 24133 or ev.get("pubkey") == client_pub_hex:
continue
author = ev["pubkey"]
try:
msg = json.loads(nip44_decrypt(client_secret, author, ev["content"]))
except Exception:
continue
if "method" in msg and msg["method"] == "connect":
params = msg.get("params", [])
if params and params[0] == author and (len(params) < 2 or params[1] == pair_secret):
signer_pub = author
print(f"✓ phone paired — signer pubkey {author[:16]}…")
send_frame({"id": msg["id"], "result": "ack"})
acked = True
await asyncio.sleep(0.5)
await request("get_public_key", [], nonce + "-gpk")
else:
print("✗ phone sent connect but the secret didn't match")
return 1
continue
if "result" in msg or "error" in msg:
rid = msg.get("id", "")
if "error" in msg:
print(f"✗ error for {rid}: {msg['error']}")
if rid.endswith("-sign"):
return 1
continue
result = msg.get("result", "")
if rid.endswith("-gpk"):
got_pubkey = result
print(f"✓ get_public_key → {result}")
await request(
"sign_event",
[compact({
"kind": 1,
"content": "Hello from the Archipelago NIP-46 test client — approved by hand.",
"tags": [],
"created_at": int(time.time()),
})],
nonce + "-sign",
)
elif rid.endswith("-sign"):
signed_event = json.loads(result)
print(f"✓ sign_event → signed event {signed_event.get('id', '')[:16]}…")
break
if not acked:
print("✗ the phone never connected (2-minute timeout)")
return 1
if got_pubkey is None or got_pubkey != signer_pub:
print("✗ get_public_key missing or mismatched")
return 1
if signed_event is None:
return 1
ev = signed_event
expected_id = event_id(ev["pubkey"], ev["created_at"], ev["kind"], ev["tags"], ev["content"])
ok_id = expected_id == ev["id"]
ok_sig = bip340_verify(bytes.fromhex(expected_id), bytes.fromhex(ev["pubkey"]), bytes.fromhex(ev["sig"]))
print(f"· event id correct : {ok_id}")
print(f"· schnorr signature: {'VERIFIED ✓' if ok_sig else 'INVALID ✗'}")
if ok_id and ok_sig:
print()
print("END-TO-END PASS — the companion signed as the identity the phone holds,")
print("and the signature verifies under an independent BIP-340 implementation.")
return 0
return 1
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--relay", default="wss://relay.damus.io", help="any nostr relay both devices can reach")
args = ap.parse_args()
sys.exit(asyncio.run(run(args.relay)))
if __name__ == "__main__":
main()
+1 -52
View File
@@ -1,58 +1,7 @@
# Changelog
## v1.8.5-alpha (2026-08-30)
## Unreleased
- **Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.
- **A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.
- **Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so "still there" is never presented as "gone".
- **Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with "Peer is federation-only (no radio twin)" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.
- **Disk cleanup finally has honest numbers.** Space "free" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure.
- **Three small screens that were lying to you, fixed.** The "Bitcoin is synced — fund your wallet" toast no longer appears on a node where the wallet it means (LND) isn't installed — it points at installing LND instead. The seed-reveal screen hides its third prompt unless the password actually fails to decrypt (the backup passphrase only exists if you set one). And multi-version store cards stop quoting a version number you'll be asked to choose on the next screen anyway.
- **Mesh notifications survive a refresh, and a stale router no longer hides the fix.** Radio message unread counts are now remembered per contact instead of guessed from session state (the "one new message showed 11 unread" bug), cover Meshtastic, MeshCore and Reticulum alike, and deep-link to the right conversation; a single new message announces itself once. Separately, when the cached router address goes stale, the error card gains a "Reconfigure router" action instead of a Retry loop that can never succeed.
- **The app updater now knows what upstream shipped.** Every app's manifest records where it comes from — including the odd corners (GitLab-only projects, ghcr-only images) — and a checker sweeps all of them against upstream releases, so a pin that quietly rots for months is now visible instead of invisible. The first full sweep found 27 pins behind; the safe patch-level ones shipped with this release (strfry, BTCPay Server 2.4.3, the two nginx frontends), and the major jumps that may carry data migrations are deliberately held for their own careful passes.
## v1.8.4-alpha (2026-08-20)
- **Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the "app is restarting" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.
- **The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.
- **While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.
- **"Are you sure?" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.
- **A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the "Radio detected" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.
- **Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so "the radio is working" and "the internet is doing the radio's job" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show "unknown" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)
- **A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle.
## v1.8.3-alpha (2026-08-14)
- **The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.
- **The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.
- **App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files.
## v1.8.2-alpha (2026-08-13)
- **An app that can't be shown inside the dashboard now becomes a tab app by itself.** A few apps refuse to render inside another page no matter what — they break out with their own code or insist on owning the whole browser window. Opening one used to mean staring at a grey pane. Now the dashboard notices, offers the app in its own tab, and remembers: from then on that app's button opens a tab directly (with the little launch icon that tab apps carry), first click, every time. If a later update makes the app embeddable after all, the dashboard notices that too and goes back to embedding it.
- **The logo emblem got its glossy black paint finish — properly this time.** The circle behind the A on the screensaver, intro, and login now wears a deep wet-paint look: warm light blooming from the top edge, fine grain so the dark tones stay smooth instead of banding, and no more ring border. (An earlier rougher version of this experiment briefly shipped by accident and then vanished depending on which screen you were on — this is the finished, deliberate one, everywhere.)
- **New app icons now match the store's look, on every screen.** Alby Hub and phoenixd arrived with edge-to-edge logos that ignored the breathing room every other app icon has, and the app detail page skipped the icon backdrop entirely. Both icons are re-set on the standard canvas, the detail page now applies the same icon treatment as the store tiles, and app developers get a one-command tool that puts any logo onto the house canvas automatically.
## v1.8.1-alpha (2026-08-13)
- **Apps that refused to open inside the dashboard now embed like everything else.** Some apps ship browser headers that forbid being shown inside another page — correct hardening on the open web, but inside Archipelago it produced a dead grey pane when you opened them from My Apps (Alby Hub was the first to hit it). The app gate, which already checks your login on every request to an app, now removes just those framing headers on the way through; each app's own content-security rules pass through untouched. No more per-app proxy workarounds.
- **The network map no longer freezes kiosk TVs.** The animated federation map at 4K was too much for the deliberately conservative graphics settings the on-screen display used on every machine — settings chosen years back to stop audio crackle on much older hardware. Two fixes: on kiosk screens the map now opens in its flat 2D view (the 3D globe is one tap away, and remembered) and animates at half rate — invisible from the couch, half the work. And the display itself now recognizes what machine it runs on: older kiosk boxes keep the proven careful settings, modern ones finally get real GPU rendering.
- **New Settings → Display → Graphics choice for the on-screen display.** Auto (recommended) picks the right rendering mode for the machine by itself; Compatibility forces the most conservative mode if a screen ever stutters, tears, or crackles; Quality forces full GPU rendering on hardware the automatic detection doesn't recognize. Changing it restarts the on-screen display, like the size presets.
## v1.8.0-alpha (2026-08-12)
- **Archipelago is now open source.** The full source code of the node you are running — the orchestrator, the dashboard, the app platform, the mesh, the release tooling — is published for anyone to read, build and audit at source.archipelago-foundation.org/lfg2025/archy. A node that holds your money, your files and your communications should not ask to be taken on faith: from this release onward you, or anyone you trust, can see exactly what it does and follow every change we make in the open.
- **Installing an update is reliable again, and tells you what happened when it isn't.** Some nodes could download an update but never apply it — the button stayed on "Install", and no amount of retrying worked. The cause: applying the update consumed the downloaded files as it went, so if any one step hit a snag partway through, the leftover files were incomplete and every later attempt failed the safety re-check forever, needing a technician to recover. Applying no longer consumes the download — a failed apply can always be retried from the same files — and the pieces are now applied in a fixed order with the program itself last, so a hiccup can't leave a half-swapped node. When an apply does fail, the screen now shows the real reason and what to do ("download the update again"), and offers Download again instead of a dead "Install" button, rather than a generic "it failed".
- **Video on the kiosk stops tearing.** The kiosk's display had no vertical sync at all, so fast motion — IndeedHub films especially — showed horizontal tearing lines. The display driver now syncs every frame to the panel (no extra hardware needed, existing kiosks pick it up with this update), and on machines with a GPU, video decoding moves off the CPU onto the video hardware — smoother playback that also leaves more headroom for audio, not less.
- **The Back button finally does what you expect.** Pressing Back — the mouse's side button on a kiosk, a swipe on a phone, the toolbar button in any browser — used to navigate the screen underneath an open window, or leave the dashboard entirely. Back now closes the topmost open window first, one per press, exactly like a native app; closing a window yourself never leaves a phantom entry that makes you press Back twice.
- **No more bare IP addresses in your update or app-registry settings.** The update mirrors and the app registry each listed the same server twice — once by its proper name, once as a raw `http://146…` address left over from before the domain existed. The raw-address entries are retired: new nodes never see them, and existing nodes clean them out of their saved lists automatically on the next read. Everything now goes through the named, TLS-protected origin — which was always the same machine.
- **The phone companion app downloads over the proper domain.** The download QR pointed at a raw address over plain HTTP; it now points at the same file on the https domain. Scanning it gets you an encrypted download from a named server.
- **The Receive window now tells you when the money is on its way.** Previously it showed a QR code and left you to check elsewhere whether anything happened. Now, the moment the sender's transaction is broadcast, the QR gives way to a clock: the amount, the transaction ID (tap to copy), and a note that the funds arrive on their own — with a single Done button. If you keep the window open, the clock becomes a green check at the first confirmation. Verified live on a real node: payment detected within seconds of broadcast.
@@ -93,11 +93,10 @@ describe('useAI', () => {
expect(activeModel.value).toBe('echo')
})
it('lists available providers with models, Routstr first', () => {
it('lists available providers with models', () => {
const { availableProviders } = useAI()
expect(availableProviders.value.length).toBe(4)
expect(availableProviders.value.length).toBe(3)
const ids = availableProviders.value.map(p => p.id)
expect(ids[0]).toBe('routstr')
expect(ids).toContain('claude')
expect(ids).toContain('openrouter')
expect(ids).toContain('mock')
@@ -119,7 +119,7 @@
<Transition name="picker">
<div
v-if="showModelPicker"
class="fixed z-[9999] path-glass-card header-overlay-panel p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px] max-h-[70vh] overflow-y-auto"
class="fixed z-[9999] path-glass-card header-overlay-panel p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px]"
:style="modelPickerDropdownStyle"
@click.stop
>
@@ -332,7 +332,7 @@ const modelDisplayName = computed(() => {
})
function selectModel(providerId: string, modelId: string) {
setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock')
setProvider(providerId as 'claude' | 'openrouter' | 'mock')
setModel(modelId)
showModelPicker.value = false
}
+5 -118
View File
@@ -13,14 +13,12 @@ import { useCodeContext } from '@/composables/useCodeContext'
import { apiFetch } from '@/utils/api-fetch'
import { useSettingsStore } from '@/stores/settings'
type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock'
type Provider = 'claude' | 'openrouter' | 'mock'
// API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/)
const BASE = import.meta.env.BASE_URL || '/'
const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
const OPENROUTER_PATH = `${BASE}api/openrouter`
const ROUTSTR_MODELS_PATH = `${BASE}api/routstr/models`
const ROUTSTR_CHAT_PATH = `${BASE}api/routstr/chat/completions`
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
@@ -150,41 +148,8 @@ function looksLikeMissingApiKey(err: string): boolean {
)
}
// ─── Routstr model catalog (fetched from the node's session-gated proxy) ───
// The node forwards the live Routstr aggregator's /v1/models; entries carry
// sats_pricing so completions are Cashu-paid against the operator's budget.
const routstrModels = ref<{ id: string; name: string }[]>([])
let routstrModelsFetched = false
async function refreshRoutstrModels() {
if (routstrModelsFetched) return
routstrModelsFetched = true
try {
const res = await apiFetch(ROUTSTR_MODELS_PATH)
if (!res.ok) return
const data = await res.json()
if (Array.isArray(data?.data)) {
routstrModels.value = data.data
.filter((m: Record<string, unknown>) => typeof m.id === 'string')
.map((m: Record<string, unknown>) => ({
id: m.id as string,
name: (m.name as string) || (m.id as string),
}))
}
} catch {
routstrModelsFetched = false // allow a retry on the next send/open
}
}
const availableProviders = computed(() => {
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
{
id: 'routstr',
name: 'Routstr (sats)',
models: routstrModels.value.length > 0
? routstrModels.value
: [{ id: 'routstr-unavailable', name: 'No models — node offline?' }],
},
{
id: 'claude',
name: 'Claude (Max)',
@@ -416,71 +381,6 @@ async function streamOpenRouter(
}, onError, signal)
}
/**
* Routstr: one paid, NON-streaming, OpenAI-shaped completion through the
* node's session-gated `/aiui/api/routstr/` forwarder. The node quotes a
* price from the live catalog, pays with a Cashu token against the
* operator's budget (Settings → System → Routstr AI budget), redeems the
* change, and passes the provider's JSON back. The full answer is emitted
* as a single token — streaming across a paid hop is the planned follow-up.
*/
async function streamRoutstr(
messages: ChatMessage[],
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
signal?: AbortSignal,
): Promise<void> {
const wireMessages = [
{ role: 'system' as const, content: systemPrompt },
...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })),
]
const res = await apiFetch(ROUTSTR_CHAT_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: activeModel.value,
messages: wireMessages,
stream: false,
}),
signal,
})
const bodyText = await res.text().catch(() => '')
if (!res.ok) {
// The node's refusals carry a plain-language error.message (budget not
// set, budget spent, wallet can't fund) — surface it verbatim.
let msg = `Routstr error ${res.status}`
try {
const parsed = JSON.parse(bodyText)
// Node refusals use {error:{message}}; the upstream provider nests
// its own as {detail:{error:{message}}} or a plain {detail:"..."}.
const detail = parsed?.detail
msg =
parsed?.error?.message ??
detail?.error?.message ??
(typeof detail === 'string' ? detail : undefined) ??
msg
} catch { /* keep the status-only message */ }
onError(msg)
return
}
if (signal?.aborted) return
try {
const parsed = JSON.parse(bodyText)
const text = parsed?.choices?.[0]?.message?.content
if (typeof text === 'string' && text.length > 0) {
onToken(text)
} else {
onError('Routstr returned an empty response')
}
} catch {
onError('Routstr returned a malformed response')
}
}
/**
* Embedded-mode chat delegation (D-01/D-17): when AIUI is running inside
* Archy, the model call, the tool-calling loop, and the model key all live
@@ -719,11 +619,7 @@ export async function streamWithModel(
activeModel.value = model
try {
if (provider === 'routstr') {
// Explicitly chosen Routstr wins even embedded in Archy — the whole
// point of the picker entry is that it is a selection, not a fallback.
await streamRoutstr(history, onToken, onError, 'You are a helpful assistant.', signal)
} else if (useArchy().isEmbedded.value) {
if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal)
@@ -742,9 +638,8 @@ export async function streamWithModel(
export function useAI() {
const chatStore = useChatStore()
// Fetch Wavlake + Routstr catalogs on first use (non-blocking)
// Fetch Wavlake catalog on first use (non-blocking)
refreshWavlakeCatalog()
refreshRoutstrModels()
function stopGeneration() {
if (currentAbort) {
@@ -817,11 +712,7 @@ export function useAI() {
const genParams = getConversationParams(chatStore)
try {
if (provider === 'routstr') {
// Explicitly chosen Routstr wins even embedded in Archy — a
// selection, not a fallback.
await streamRoutstr(history, onToken, onError, systemPrompt, signal)
} else if (useArchy().isEmbedded.value) {
if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal)
@@ -937,11 +828,7 @@ export function useAI() {
const genParams = getConversationParams(chatStore)
try {
if (provider === 'routstr') {
// Explicitly chosen Routstr wins even embedded in Archy — a
// selection, not a fallback.
await streamRoutstr(history, onToken, onError, systemPrompt, signal)
} else if (useArchy().isEmbedded.value) {
if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal)
+13 -49
View File
@@ -52,13 +52,13 @@
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.3",
"version": "2.4.2",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.2",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
@@ -73,7 +73,7 @@
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
@@ -193,13 +193,13 @@
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.10.0",
"version": "0.8.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
@@ -223,7 +223,7 @@
"author": "Vaultwarden",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
@@ -262,7 +262,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
@@ -285,7 +285,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
@@ -325,7 +325,7 @@
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
@@ -356,7 +356,7 @@
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
@@ -378,7 +378,7 @@
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
@@ -442,7 +442,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
@@ -464,7 +464,7 @@
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
@@ -547,42 +547,6 @@
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
},
{
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"id": "cuprate",
"title": "Cuprate",
"version": "0.1.0-preview",
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
"icon": "/assets/img/app-icons/cuprate.svg",
"author": "Cuprate contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
"repoUrl": "https://github.com/Cuprate/cuprate"
}
]
}
-3
View File
@@ -2,9 +2,6 @@ app:
id: aiui
name: AI Assistant
version: 0.1.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Conversational AI interface for Archipelago. Quarantined — communicates only via context broker.
internal: true # System-managed, not shown in App Store
-81
View File
@@ -1,81 +0,0 @@
app:
id: alby-hub
name: Alby Hub
version: 1.23.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: getAlby/hub
description: Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.
category: money
container:
image: source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0
pull_policy: if-not-present
dependencies:
- storage: 1Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: bridge
ports:
- host: 8187
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/alby-hub
target: /data
options: [rw]
environment:
- WORK_DIR=/data
- PORT=8080
# LDK peers are dialed outbound-only in v1; no inbound p2p port is
# advertised, so no extra port mapping is needed for payments to work.
- LOG_LEVEL=info
health_check:
type: http
endpoint: http://localhost:8080
path: /
interval: 30s
timeout: 5s
retries: 5
interfaces:
main:
name: Web UI
description: Alby Hub wallet interface
type: ui
port: 8187
protocol: http
metadata:
icon: /assets/img/app-icons/alby-hub.svg
repo: https://github.com/getAlby/hub
tier: optional
launch:
# Embedded: the gate neutralizes Alby Hub's X-Frame-Options: DENY on
# proxied responses. Nodes older than the gate fix show a blocked
# frame — flip to true only if targeting such nodes.
open_in_new_tab: false
features:
- Self-custodial Lightning node (LDK) with a friendly wallet UI
- Connect wallets and apps via Nostr Wallet Connect (NWC)
- Per-app budgets and isolated sub-wallets
- Works with the Alby browser extension and mobile app
-6
View File
@@ -2,12 +2,6 @@ app:
id: archy-btcpay-db
name: BTCPay Postgres
version: "15.17"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/postgres
description: Postgres backend for BTCPay and NBXplorer.
container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: archy-mempool-db
name: Mempool MariaDB
version: 11.4.10
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/mariadb
description: MariaDB backend for the mempool explorer stack.
container:
+1 -7
View File
@@ -2,17 +2,11 @@ app:
id: archy-mempool-web
name: Mempool Web
version: 3.0.1
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Frontend web UI for mempool explorer.
container_name: mempool
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
pull_policy: if-not-present
network: archy-net
-6
View File
@@ -2,12 +2,6 @@ app:
id: archy-nbxplorer
name: NBXplorer
version: 2.6.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: dgarage/NBXplorer
description: BTCPay blockchain indexer service.
container:
-8
View File
@@ -2,14 +2,6 @@ app:
id: barkd
name: Ark Wallet
version: 0.3.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. bark ships on GitLab only
# (no GitHub mirror), so the gitlab fetcher is the one that can see it.
# NOTE: a version bump is code work, not a pin move — the REST shapes are
# coded in core/archipelago/src/wallet/ark_client.rs (see Dockerfile note).
upstream:
kind: gitlab
repo: ark-bitcoin/bark
description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.
container:
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: bitcoin-core
name: Bitcoin Core
version: 28.4.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: bitcoin/bitcoin
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-core
@@ -55,7 +49,7 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: bitcoin-knots
name: Bitcoin Knots
version: 28.1.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: bitcoinknots/bitcoin
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-knots
@@ -61,7 +55,7 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
-3
View File
@@ -2,9 +2,6 @@ app:
id: bitcoin-ui
name: Bitcoin UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP proxy + static site for interacting with the
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
-3
View File
@@ -2,9 +2,6 @@ app:
id: botfights
name: BotFights
version: 1.2.11
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
category: community
+3 -19
View File
@@ -1,17 +1,11 @@
app:
id: btcpay-server
name: BTCPay Server
version: 2.4.3
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: btcpayserver/btcpayserver
version: 2.4.2
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container:
image: docker.io/btcpayserver/btcpayserver:2.4.3
image: docker.io/btcpayserver/btcpayserver:2.4.2
pull_policy: if-not-present
network: archy-net
secret_env:
@@ -52,17 +46,7 @@ app:
container: 49392
protocol: tcp
bind: 127.0.0.1
# open, not gated: BTCPay has its own account system, and its public
# surfaces (checkout/invoice pages, payment buttons, webhooks) must be
# reachable by anonymous payers and machines — a dashboard login in
# front of a checkout link breaks the product. The gate still fronts
# the port; the operator can force the dashboard login back on from
# Settings → BTCPay Server → Access control.
auth: open
auth_rationale: >-
BTCPay enforces its own login for administration, and its checkout,
invoice and webhook endpoints are designed to be reached by
anonymous payers and payment processors.
auth: gated
volumes:
- type: bind
-6
View File
@@ -2,12 +2,6 @@ app:
id: core-lightning
name: Core Lightning (CLN)
version: 23.08.2
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: ElementsProject/lightning
description: Lightning Network implementation in C. Lightweight alternative to LND.
container:
-157
View File
@@ -1,157 +0,0 @@
app:
id: cuprate
name: Cuprate
# Matches the crate's own Cargo.toml version (binaries/cuprated/Cargo.toml).
# Cuprate has no stable release yet — this is explicitly work-in-progress
# software (see upstream README). The image tag below pins the exact
# commit built, since "0.1.0-preview" alone is not reproducible.
version: 0.1.0-preview
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: Cuprate/cuprate
description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.
category: money
metadata:
icon: /assets/img/app-icons/cuprate.svg
repo: https://github.com/Cuprate/cuprate
tier: optional
container:
# Built from the upstream Dockerfile at the tip of main, 18 commits past
# the cuprated-0.1.0-preview tag (commit 618ff14, 2026-08-19) — there is
# no newer tagged release as of this writing. Re-pin to a tagged release
# once upstream cuts one.
image: source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14
pull_policy: if-not-present
network: archy-net
# The image's own ENTRYPOINT is ["/usr/local/bin/cuprated"]; these are
# appended as its argv, matching the project's own systemd unit
# (cuprated.service) invocation exactly.
custom_args: ["--config-file", "/home/cuprate/Cuprated.toml"]
# The image (FROM scratch) creates uid:gid 1000:1000 for the `cuprate`
# user at build time and runs as it unconditionally (USER 1000:1000,
# no shell to switch users at runtime) — same pattern as
# apps/phoenixd, apps/electrumx, apps/nostr-rs-relay, apps/portainer,
# apps/barkd. The bind-mounted data dir must be owned by that literal
# uid or cuprated dies on a permission error the first time it writes.
data_uid: "1000:1000"
dependencies:
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
# month; cuprated's pruning support is not confirmed stable yet (the
# `pruning` crate exists in the workspace but nothing in this config
# surface toggles it), so this sizes for a full unpruned chain plus
# headroom rather than assuming pruning is available.
- storage: 300Gi
resources:
cpu_limit: 0
memory_limit: 4Gi
disk_limit: 300Gi
security:
# FROM scratch, no package manager/shell, ownership fixed at build time
# — unlike bitcoin-knots this needs no runtime chown/setuid dance, so it
# can run fully read-only with an empty capability set.
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: isolated
ports:
# P2P. Cuprate's own default listen address is already 0.0.0.0
# (p2p.clear_net.listen_on), so no config override is needed — only the
# host-side port differs from Monero's canonical 18080 because that
# number is already taken on this fleet by lnd's REST port.
- host: 18183
container: 18080
protocol: tcp
auth: none
auth_rationale: >-
Monero p2p gossip. Peers are anonymous by design and speak the Monero wire protocol, not HTTP.
# Unrestricted RPC (full node control) is deliberately NOT published.
# cuprated has no RPC authentication, and for a published port to reach
# it the service would have to bind 0.0.0.0 inside the container — at
# which point every other app can reach it directly on 18081, since
# ports[].bind only restricts the HOST side and podman bridges route to
# each other (verified live 2026-08-22: a peer container on archy-net
# got an unauthenticated get_info, from a *different* network). That is
# unlike bitcoin-knots, whose 0.0.0.0 RPC still demands the rpcuser /
# rpcpassword it writes from generated secrets. So unrestricted RPC is
# left at cuprated's own default — container loopback only, reachable by
# nothing — which is also what upstream intends by refusing a non-local
# bind without an explicit i_know_what_im_doing override.
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
# what wallets use when connecting to a "remote node". Disabled by
# cuprated's own default; enabled via files[] below. A dashboard login
# would break wallet clients connecting programmatically, same
# reasoning as electrumx's port. The daemon still uses its canonical
# container port 18089, but Penpot already owns host port 18089, so this
# maps the public host port to the free 18090 instead.
- host: 18090
container: 18089
protocol: tcp
auth: none
auth_rationale: >-
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
volumes:
- type: bind
source: /var/lib/archipelago/cuprate
target: /home/cuprate
options: [rw]
# Settings that need to differ from cuprated's own documented defaults
# (verified against `cuprated --generate-config` and `--dry-run` locally,
# 2026-08-21):
# - target_max_memory: cuprated's own default auto-detects total *host*
# RAM via sysinfo, which inside a memory-limited container would let
# it size caches far past what resources.memory_limit above actually
# grants — same class of problem bitcoin-knots' -dbcache sizing
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
# - rpc.restricted.enable: cuprated ships this off by default; flip on
# so the auth:none host port above actually serves something instead
# of refusing every connection. port stays at its documented default
# (canonical 18089), and advertise stays false — this node is not
# opting in to being listed as a public remote node over the p2p
# network, just reachable if someone points a wallet at it directly.
# - rpc.unrestricted.address + the allow-public flag: cuprated's own
# default (127.0.0.1) looks like the obviously-correct choice for a
# port meant to stay loopback-only, but verified live (2026-08-21)
# that a service bound literally to 127.0.0.1 *inside* the container
# is unreachable through the host's published port — connections
# reset regardless of how long the daemon has been up. Binding
# 0.0.0.0 inside and letting ports[].bind: 127.0.0.1 below be the
# actual restriction is the same pattern apps/bitcoin-knots already
# uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate
# restricts it externally) — not a new risk, the same one already
# reviewed and accepted for Bitcoin's RPC.
files:
- path: /var/lib/archipelago/cuprate/Cuprated.toml
content: |
network = "Mainnet"
target_max_memory = 3000000000
[rpc.restricted]
enable = true
overwrite: false
health_check:
type: tcp
# Restricted RPC — the only RPC surface published now.
endpoint: localhost:18090
interval: 30s
timeout: 5s
retries: 3
start_period: 5m
metadata:
icon: /assets/img/app-icons/cuprate.svg
category: money
tier: optional
author: Cuprate
repo: https://github.com/Cuprate/cuprate
-3
View File
@@ -2,9 +2,6 @@ app:
id: did-wallet
name: Web5 DID Wallet
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
container:
-3
View File
@@ -2,9 +2,6 @@ app:
id: electrs-ui
name: Electrs UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP frontend for electrs/electrumx status. Runs
nginx inside a container, serves static assets, and proxies
-6
View File
@@ -2,12 +2,6 @@ app:
id: electrumx
name: ElectrumX
version: 1.18.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: spesmilo/electrumx
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: fedimint-clientd
name: Fedimint Client
version: 0.8.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint-clientd
description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.
container:
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: fedimint-gateway
name: Fedimint Gateway
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
container:
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: fedimint
name: Fedimint Guardian
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
container:
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
-6
View File
@@ -2,12 +2,6 @@ app:
id: filebrowser
name: File Browser
version: 2.27.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: filebrowser/filebrowser
description: Baseline Archipelago file manager service.
container:
-3
View File
@@ -2,9 +2,6 @@ app:
id: fips-ui
name: FIPS Mesh
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
inside a container with host networking, serves a static dashboard on
+1 -16
View File
@@ -2,12 +2,6 @@ app:
id: gitea
name: Gitea
version: "1.23"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: go-gitea/gitea
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
category: development
@@ -33,16 +27,7 @@ app:
container: 3000
protocol: tcp
bind: 127.0.0.1
# open, not gated: Gitea carries a complete login of its own, and git
# clients speak HTTP basic-auth — a cookie challenge in front of
# git-over-HTTP breaks every clone/push. The gate still fronts the
# port (iframe header fixes, retry page, Tor); the operator can force
# the dashboard login back on from Settings → Gitea → Access control.
auth: open
auth_rationale: >-
Gitea enforces its own account login on every page and API route;
git clients authenticate with basic-auth/tokens and cannot complete
a browser login challenge.
auth: gated
- host: 2222
container: 22
protocol: tcp
-6
View File
@@ -2,12 +2,6 @@ app:
id: grafana
name: Grafana
version: 10.2.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: grafana/grafana
description: Analytics and monitoring platform. Visualize metrics and create dashboards.
container:
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: homeassistant
name: Home Assistant
version: 2026.7.3
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: home-assistant/core
description: Open source home automation platform. Control and monitor your smart home devices.
container:
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3
pull_policy: if-not-present
network: pasta
-6
View File
@@ -2,12 +2,6 @@ app:
id: immich-postgres
name: Immich Postgres
version: "14-vectorchord0.4.3-pgvectors0.2.0"
# Upstream is the Immich-built Postgres image, published only on ghcr.io
# (no GitHub release tags, no Docker Hub repo) — the ghcr fetcher in
# scripts/check-upstream-releases.py is the only one that can see it.
upstream:
kind: ghcr
repo: immich-app/postgres
description: Postgres (pgvecto.rs / vectorchord) backend for Immich.
# Container named immich_postgres (underscore) to match the runtime's existing
-6
View File
@@ -2,12 +2,6 @@ app:
id: immich-redis
name: Immich Redis
version: "7-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: valkey/valkey
description: Valkey (Redis-compatible) cache for Immich.
# Container named immich_redis (underscore) to match runtime per-app references
-6
View File
@@ -2,12 +2,6 @@ app:
id: immich
name: Immich
version: "2.7.4"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: immich-app/immich
description: Self-hosted photo and video backup with mobile apps and search.
# app_id "immich" = the user-facing launcher (matches the catalog entry's title
-3
View File
@@ -2,9 +2,6 @@ app:
id: indeedhub-api
name: IndeedHub API
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: IndeedHub backend API (Nostr auth, media, payments).
category: community
-3
View File
@@ -2,9 +2,6 @@ app:
id: indeedhub-ffmpeg
name: IndeedHub FFmpeg Worker
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: IndeedHub background media transcoding worker.
category: community
-6
View File
@@ -2,12 +2,6 @@ app:
id: indeedhub-minio
name: IndeedHub MinIO
version: "RELEASE.2024-11-07T00-52-20Z"
# MinIO's release tags are date-opaque (RELEASE.YYYY-MM-DD…), so the
# checker reports them as UNCOMPARABLE rather than ordering them — the
# latest tag is still shown for hand comparison, which is the point.
upstream:
kind: github
repo: minio/minio
description: MinIO S3-compatible object storage for IndeedHub media.
category: community
-6
View File
@@ -2,12 +2,6 @@ app:
id: indeedhub-postgres
name: IndeedHub Postgres
version: "16.13-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/postgres
description: Postgres database backend for IndeedHub.
category: community
-6
View File
@@ -2,12 +2,6 @@ app:
id: indeedhub-redis
name: IndeedHub Redis
version: "7.4.8-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/redis
description: Redis queue/cache backend for IndeedHub.
category: community
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: indeedhub-relay
name: IndeedHub Nostr Relay
version: "0.9.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: scsibug/nostr-rs-relay
description: nostr-rs-relay backing IndeedHub's Nostr identity + comments.
category: community
@@ -17,7 +11,7 @@ app:
container_name: indeedhub-relay
container:
image: source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.10.0
image: source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0
pull_policy: if-not-present
network: indeedhub-net
network_aliases: [relay]
-3
View File
@@ -2,9 +2,6 @@ app:
id: indeedhub
name: IndeeHub
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
category: community
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: jellyfin
name: Jellyfin
version: 10.8.13
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: jellyfin/jellyfin
description: Free media server. Stream movies, music, and photos.
container:
image: source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11
image: source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13
pull_policy: if-not-present
network: pasta
-6
View File
@@ -2,12 +2,6 @@ app:
id: lightning-stack
name: Lightning Stack
version: 0.12.0
# No public listing exists for lightninglabs/lightning-stack (checked
# docker.io, ghcr.io and github.com) — nothing can be queried automatically,
# so this one is tracked by hand.
upstream:
kind: manual
url: no public listing for lightninglabs/lightning-stack — verify by hand
description: Complete Lightning Network implementation. Includes LND, CLN, and management tools.
container:
-3
View File
@@ -2,9 +2,6 @@ app:
id: lnd-ui
name: LND UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP frontend for LND. Runs nginx inside a
container and serves static assets. LND connection info is fetched
-6
View File
@@ -2,12 +2,6 @@ app:
id: lnd
name: LND
version: 0.18.4
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: lightningnetwork/lnd
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container:
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: mempool-api
name: Mempool API
version: 3.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Backend API for mempool explorer.
container:
image: source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1
image: source.archipelago-foundation.org/lfg2025/mempool-backend:v3.0.0
pull_policy: if-not-present
network: archy-net
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: mempool
name: Mempool Explorer
version: 3.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
image_signature: cosign://...
pull_policy: if-not-present
-3
View File
@@ -2,9 +2,6 @@ app:
id: morphos-server
name: MorphOS Server
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: MorphOS server platform. Decentralized application server.
container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: netbird-dashboard
name: NetBird Dashboard
version: "2.38.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: netbirdio/dashboard
description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy.
category: networking
-6
View File
@@ -2,12 +2,6 @@ app:
id: netbird-server
name: NetBird Server
version: "0.71.2"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: netbirdio/netbird
description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN.
category: networking
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: netbird
name: NetBird
version: "2.38.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/nginx
description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.
category: networking
@@ -18,7 +12,7 @@ app:
container_name: netbird
container:
image: docker.io/library/nginx:1.31.4-alpine
image: docker.io/library/nginx:1.27-alpine
pull_policy: if-not-present
network: netbird-net
# Self-signed TLS cert materialised before create — the dashboard needs a
-6
View File
@@ -2,12 +2,6 @@ app:
id: nextcloud
name: Nextcloud
version: "29"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: nextcloud/server
description: Your own private cloud. File sync, calendars, contacts.
container:
+2 -8
View File
@@ -1,17 +1,11 @@
app:
id: nostr-rs-relay
name: Nostr Relay (Rust)
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: scsibug/nostr-rs-relay
version: 0.8.0
description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.
container:
image: scsibug/nostr-rs-relay:0.10.0
image: scsibug/nostr-rs-relay:0.8.9
image_signature: cosign://...
pull_policy: verify-signature
data_uid: "1000:1000"
-81
View File
@@ -1,81 +0,0 @@
app:
id: phoenixd
name: phoenixd
version: 0.9.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: ACINQ/phoenixd
description: Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.
category: money
container:
# Image entrypoint already runs with --agree-to-terms-of-service and
# --http-bind-ip 0.0.0.0, as user "phoenix"; no custom args needed.
image: source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0
pull_policy: if-not-present
# The image runs as user phoenix (1000:1000); the datadir bind source
# must be chowned to that identity or phoenixd dies on
# "Failed to open /data/phoenix.conf with Permission denied".
data_uid: "1000:1000"
dependencies:
- storage: 500Mi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: bridge
ports:
- host: 9740
container: 9740
protocol: tcp
bind: 127.0.0.1
auth: none
auth_rationale: >-
Loopback-only JSON API, not a web page. Every request is
authenticated by the http password phoenixd generates in its own
data directory on first run; the app gate's browser login page
would break the API clients this port exists for.
volumes:
# The wallet seed (seed.dat) and phoenix.conf live here. This directory
# must survive reinstall/migration like any other app data dir —
# losing it means losing funds.
# Target is /data (via PHOENIX_DATADIR below), NOT the image's default
# /phoenix/.phoenix: the orchestrator treats any bind path containing a
# dot as a file mount and skips creating its source directory, so a
# hidden-dir target never gets its host dir and the unit crash-loops.
- type: bind
source: /var/lib/archipelago/phoenixd
target: /data
options: [rw]
environment:
- PHOENIX_DATADIR=/data
health_check:
type: tcp
endpoint: localhost:9740
interval: 30s
timeout: 5s
retries: 5
metadata:
icon: /assets/img/app-icons/phoenixd.svg
repo: https://github.com/ACINQ/phoenixd
tier: optional
features:
- Ultra-light Lightning node — no bitcoin node required
- Automated channel and liquidity management (fees apply)
- Simple HTTP API + websockets for payments
- Backed by the team behind the Phoenix mobile wallet
-6
View File
@@ -2,12 +2,6 @@ app:
id: photoprism
name: PhotoPrism
version: "240915"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: photoprism/photoprism
description: AI-powered photo management with facial recognition.
container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: pine-openwakeword
name: Pine Wake Word (openWakeWord)
version: "2.1.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: rhasspy/wyoming-openwakeword
description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image).
category: home
+2 -8
View File
@@ -1,13 +1,7 @@
app:
id: pine-piper
name: Pine Piper (TTS)
version: "2.4.2"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: rhasspy/wyoming-piper
version: "2.2.2"
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
category: home
@@ -18,7 +12,7 @@ app:
container_name: pine-piper
container:
image: docker.io/rhasspy/wyoming-piper:2.4.2
image: docker.io/rhasspy/wyoming-piper:2.2.2
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-piper]
-8
View File
@@ -6,14 +6,6 @@ app:
# pick up the args change; the pre-release form "3.4.1-1" would compare
# LOWER than 3.4.1 under semver and never roll out.
version: "3.4.2"
# Tracks the rhasspy/wyoming-whisper image we pin (Docker Hub — the
# project's GitHub tags are not the image tags). NOTE: this manifest
# deliberately ships an args-tuned revision AHEAD of the image tag (see
# comment above) — BEHIND here means the image tag moved and the tuned
# revision needs re-basing onto it, not just a pin bump.
upstream:
kind: dockerhub
repo: rhasspy/wyoming-whisper
description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist.
category: home
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: pine
name: Pine
version: "1.3.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/nginx
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.
category: home
@@ -19,7 +13,7 @@ app:
container_name: pine
container:
image: docker.io/library/nginx:1.31.4-alpine
image: docker.io/library/nginx:1.27-alpine
pull_policy: if-not-present
network: archy-net
network_aliases: [pine]
+1 -7
View File
@@ -2,17 +2,11 @@ app:
id: portainer
name: Portainer
version: 2.19.4
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: portainer/portainer
description: Container management web UI for the local Podman socket.
category: development
container:
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.6
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.1
pull_policy: if-not-present
data_uid: "1000:1000"
-3
View File
@@ -2,9 +2,6 @@ app:
id: router
name: Mesh Router
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization.
container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: searxng
name: SearXNG
version: 1.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: searxng/searxng
description: Privacy-respecting metasearch engine. Search the web without tracking.
container:
+2 -8
View File
@@ -1,17 +1,11 @@
app:
id: strfry
name: Strfry Nostr Relay
version: 1.1.2
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: hoytech/strfry
version: 0.9.0
description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage.
container:
image: dockurr/strfry:1.1.2
image: dockurr/strfry:1.0.4
image_signature: cosign://...
pull_policy: verify-signature
-6
View File
@@ -2,12 +2,6 @@ app:
id: uptime-kuma
name: Uptime Kuma
version: 1.23.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: louislam/uptime-kuma
description: Self-hosted uptime monitoring.
container:
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: vaultwarden
name: Vaultwarden
version: 1.30.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: dani-garcia/vaultwarden
description: Self-hosted password vault with zero-knowledge encryption.
container:
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine
pull_policy: if-not-present
network: pasta
+58 -398
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.4-alpha"
version = "1.7.129-alpha"
dependencies = [
"anyhow",
"archipelago-container",
@@ -120,7 +120,6 @@ dependencies = [
"blake3",
"bs58",
"bytes",
"cashu",
"chacha20poly1305",
"chrono",
"ciborium",
@@ -187,7 +186,7 @@ dependencies = [
"futures",
"hex",
"hyper 0.14.32",
"indexmap 2.13.0",
"indexmap",
"log",
"reqwest 0.11.27",
"serde",
@@ -451,8 +450,8 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f"
dependencies = [
"bitcoin-internals",
"bitcoin_hashes",
"bitcoin-internals 0.3.0",
"bitcoin_hashes 0.14.1",
]
[[package]]
@@ -500,11 +499,11 @@ checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab"
[[package]]
name = "bip39"
version = "2.2.2"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387"
dependencies = [
"bitcoin_hashes",
"bitcoin_hashes 0.13.0",
"rand 0.8.5",
"rand_core 0.6.4",
"serde",
@@ -527,26 +526,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026"
dependencies = [
"base58ck",
"base64 0.21.7",
"bech32",
"bitcoin-internals",
"bitcoin-internals 0.3.0",
"bitcoin-io",
"bitcoin-units",
"bitcoin_hashes",
"hex-conservative",
"bitcoin_hashes 0.14.1",
"hex-conservative 0.2.2",
"hex_lit",
"secp256k1",
"serde",
]
[[package]]
name = "bitcoin-internals"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb"
[[package]]
name = "bitcoin-internals"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2"
dependencies = [
"serde",
]
[[package]]
name = "bitcoin-io"
@@ -560,8 +560,17 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2"
dependencies = [
"bitcoin-internals",
"serde",
"bitcoin-internals 0.3.0",
]
[[package]]
name = "bitcoin_hashes"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b"
dependencies = [
"bitcoin-internals 0.2.0",
"hex-conservative 0.1.2",
]
[[package]]
@@ -571,7 +580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
dependencies = [
"bitcoin-io",
"hex-conservative",
"hex-conservative 0.2.2",
"serde",
]
@@ -698,32 +707,6 @@ dependencies = [
"serde",
]
[[package]]
name = "cashu"
version = "0.17.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bd7216af2b980e203d10677076d8c6c5c30610cbdea6549b8a9020cfa0cf47b"
dependencies = [
"bitcoin",
"cbor-diag",
"ciborium",
"lightning",
"lightning-invoice",
"once_cell",
"serde",
"serde_json",
"serde_with",
"strum 0.27.2",
"strum_macros 0.27.2",
"thiserror 2.0.18",
"tracing",
"unicode-normalization",
"url",
"uuid",
"web-time",
"zeroize",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -733,25 +716,6 @@ dependencies = [
"cipher",
]
[[package]]
name = "cbor-diag"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc245b6ecd09b23901a4fbad1ad975701fd5061ceaef6afa93a2d70605a64429"
dependencies = [
"bs58",
"chrono",
"data-encoding",
"half",
"nom",
"num-bigint",
"num-rational",
"num-traits",
"separator",
"url",
"uuid",
]
[[package]]
name = "cc"
version = "1.2.54"
@@ -1128,18 +1092,8 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core 0.23.0",
"darling_macro 0.23.0",
"darling_core",
"darling_macro",
]
[[package]]
@@ -1156,37 +1110,13 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.114",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.114",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core 0.23.0",
"darling_core",
"quote",
"syn 2.0.114",
]
@@ -1217,37 +1147,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -1288,9 +1187,6 @@ name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
@@ -1318,7 +1214,7 @@ version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling 0.20.11",
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
@@ -1418,18 +1314,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "dnssec-prover"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "869bf72abc8c654b350aa8d881c5d9957b85e1e1ed6569c10cd68e9f505d5435"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
@@ -1890,7 +1774,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap 2.13.0",
"indexmap",
"slab",
"tokio",
"tokio-util",
@@ -1909,7 +1793,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.4.0",
"indexmap 2.13.0",
"indexmap",
"slab",
"tokio",
"tokio-util",
@@ -1945,12 +1829,6 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -2009,6 +1887,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20"
[[package]]
name = "hex-conservative"
version = "0.2.2"
@@ -2507,17 +2391,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.13.0"
@@ -2634,7 +2507,7 @@ dependencies = [
"rustls-pki-types",
"serde",
"smallvec",
"strum 0.28.0",
"strum",
"time",
"tokio",
"tokio-stream",
@@ -2722,7 +2595,7 @@ dependencies = [
"rand 0.10.1",
"rustls 0.23.36",
"simple-dns",
"strum 0.28.0",
"strum",
"tokio",
"tracing",
"url",
@@ -2802,7 +2675,7 @@ dependencies = [
"rustls-pki-types",
"serde",
"serde_bytes",
"strum 0.28.0",
"strum",
"tokio",
"tokio-rustls 0.26.4",
"tokio-util",
@@ -2892,59 +2765,6 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jiff"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-link",
]
[[package]]
name = "jiff-core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
dependencies = [
"defmt",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "jiff-tzdb"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
[[package]]
name = "jiff-tzdb-platform"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
dependencies = [
"jiff-tzdb",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -3091,55 +2911,6 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "lightning"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab16d2a714c0b26d7230bd388ac383a30fce231c8927c62752afc0471a36dc6"
dependencies = [
"bech32",
"bitcoin",
"dnssec-prover",
"hashbrown 0.13.2",
"libm",
"lightning-invoice",
"lightning-macros",
"lightning-types",
"possiblyrandom",
]
[[package]]
name = "lightning-invoice"
version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d83bd798e04ab9eecc8bbef1fa17d3808859bcdc0406bd16c55d51c8834444"
dependencies = [
"bech32",
"bitcoin",
"lightning-types",
"serde",
]
[[package]]
name = "lightning-macros"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4c717494cdc2c8bb85bee7113031248f5f6c64f8802b33c1c9e2d98e594aa71"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "lightning-types"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c77c676d4a34cceb2ae3756916e446b4d17f9430a24107e099981f0f9aec77e6"
dependencies = [
"bitcoin",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
@@ -3644,7 +3415,7 @@ dependencies = [
"base64 0.22.1",
"bech32",
"bip39",
"bitcoin_hashes",
"bitcoin_hashes 0.14.1",
"cbc",
"chacha20 0.9.1",
"chacha20poly1305",
@@ -3746,17 +3517,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -4136,7 +3896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"indexmap",
"quick-xml",
"serde",
"time",
@@ -4174,15 +3934,6 @@ dependencies = [
"serde",
]
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "portmapper"
version = "0.19.0"
@@ -4222,15 +3973,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "possiblyrandom"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c564dbf654befd49035528299f1208a40508f6e07efb11c163444e304e4484f"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "postcard"
version = "1.1.3"
@@ -4901,30 +4643,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -4974,7 +4692,7 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
"bitcoin_hashes",
"bitcoin_hashes 0.14.1",
"rand 0.8.5",
"secp256k1-sys",
"serde",
@@ -5040,12 +4758,6 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "separator"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5"
[[package]]
name = "serde"
version = "1.0.228"
@@ -5130,46 +4842,13 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
dependencies = [
"base64 0.22.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.13.0",
"jiff",
"schemars 0.9.0",
"schemars 1.2.2",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.13.0",
"indexmap",
"itoa",
"ryu",
"serde",
@@ -5458,31 +5137,13 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros 0.28.0",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.114",
"strum_macros",
]
[[package]]
@@ -5945,7 +5606,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.13.0",
"indexmap",
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
@@ -5959,7 +5620,7 @@ version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap 2.13.0",
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.3",
@@ -6162,9 +5823,9 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
dependencies = [
"tinyvec",
]
@@ -6242,7 +5903,6 @@ checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
dependencies = [
"getrandom 0.3.4",
"js-sys",
"serde_core",
"wasm-bindgen",
]
@@ -6420,7 +6080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap 2.13.0",
"indexmap",
"wasm-encoder",
"wasmparser",
]
@@ -6459,7 +6119,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.13.0",
"hashbrown 0.15.5",
"indexmap 2.13.0",
"indexmap",
"semver",
]
@@ -7018,7 +6678,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap 2.13.0",
"indexmap",
"prettyplease",
"syn 2.0.114",
"wasm-metadata",
@@ -7049,7 +6709,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.13.0",
"indexmap 2.13.0",
"indexmap",
"log",
"serde",
"serde_derive",
@@ -7068,7 +6728,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap 2.13.0",
"indexmap",
"log",
"semver",
"serde",
@@ -7299,7 +6959,7 @@ dependencies = [
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.13.0",
"indexmap",
"memchr",
"thiserror 2.0.18",
"zopfli",
+2 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.4-alpha"
version = "1.7.129-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
@@ -72,7 +72,7 @@ bs58 = "0.5"
chrono = "0.4"
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
bip39 = { version = "2.1", features = ["rand"] }
bip39 = { version = "=2.1.0", features = ["rand"] }
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
# Configuration
@@ -143,7 +143,6 @@ async-trait = "0.1"
iroh = { version = "1", optional = true }
iroh-blobs = { version = "0.103", optional = true }
lofty = "0.24.0"
cashu = { version = "0.17.5", default-features = false, features = ["wallet"] }
[dev-dependencies]
tokio-test = "0.4"
-429
View File
@@ -1,429 +0,0 @@
//! CDP input bridge — forwards companion remote input into the local kiosk
//! Chromium as *trusted*, browser-level input via the DevTools protocol.
//!
//! Why this exists: the web relay path (`remote-relay.ts`) synthesizes DOM
//! events in the top document, and synthetic events can never cross into a
//! cross-origin iframe — so on the kiosk, companion taps/keys/scrolls died at
//! the border of every containerized app's frame. CDP `Input.dispatch*`
//! events enter the browser's real input pipeline: they hit-test through any
//! frame, move focus, and insert text exactly like a physical device, which
//! is the only correct way to drive app iframes (tracked in the unified task
//! tracker; supersedes the earlier "no CDP" note in
//! `docs/tv-input-iframe-apps.md`, which was about gamepad *keys* only).
//!
//! The kiosk launcher opens Chromium with `--remote-debugging-port=9222`
//! bound to loopback. This keeper task discovers the page target, holds one
//! WebSocket to it, and reconnects whenever the kiosk restarts (the launcher
//! supervises Chromium in a loop, so the debugger URL changes under us).
//! When the bridge is not connected (non-kiosk installs, kiosk booting),
//! `is_active()` is false and callers fall back to the web relay unchanged.
//!
//! Security: the CDP port is loopback-only and Chromium's default origin
//! check stands (we deliberately do NOT pass `--remote-allow-origins`, so
//! browser pages can't open the debug socket; our raw client sends no
//! Origin header and is accepted).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info, warn};
const CDP_HTTP: &str = "http://127.0.0.1:9222";
/// Marker whose presence means this node drives a local kiosk display.
const KIOSK_UNIT: &str = "/etc/systemd/system/archipelago-kiosk.service";
/// One relay scroll step ≈ this many CSS pixels (matches remote-relay.ts).
const SCROLL_STEP_PX: f64 = 100.0;
/// Cloneable handle the WS handlers use to feed validated relay JSON into
/// the keeper task.
#[derive(Clone)]
pub struct CdpBridge {
tx: mpsc::Sender<String>,
connected: Arc<AtomicBool>,
}
impl CdpBridge {
/// Spawn the session keeper and return the shared handle.
pub fn spawn() -> Self {
let (tx, rx) = mpsc::channel::<String>(256);
let connected = Arc::new(AtomicBool::new(false));
tokio::spawn(run_keeper(rx, connected.clone()));
Self { tx, connected }
}
/// True only while a live CDP session to the kiosk Chromium exists.
pub fn is_active(&self) -> bool {
self.connected.load(Ordering::Relaxed)
}
/// Queue a validated relay input message (the exact JSON that goes to the
/// broadcast channel) for CDP dispatch. Best-effort: if the keeper is
/// behind or gone the message is dropped — input is transient by nature.
pub fn send(&self, relay_json: &str) {
let _ = self.tx.try_send(relay_json.to_string());
}
}
/// Virtual cursor state, server-side. The web relay keeps this in the kiosk
/// page (`cursorX`/`cursorY`); CDP needs its own copy because trusted mouse
/// events carry absolute viewport coordinates.
struct Cursor {
x: f64,
y: f64,
w: f64,
h: f64,
}
async fn run_keeper(mut rx: mpsc::Receiver<String>, connected: Arc<AtomicBool>) {
loop {
// Cheap gate: no kiosk unit on this node → nothing to drive. Keep
// draining queued input so the channel never backs up.
if tokio::fs::metadata(KIOSK_UNIT).await.is_err() {
drain_for(&mut rx, Duration::from_secs(60)).await;
continue;
}
let Some(ws_url) = discover_page_target().await else {
// Kiosk configured but Chromium not up (or CDP flag not rolled
// out yet) — retry gently.
drain_for(&mut rx, Duration::from_secs(15)).await;
continue;
};
match drive_session(&ws_url, &mut rx, &connected).await {
Ok(()) => info!("CDP kiosk input session ended cleanly"),
Err(e) => debug!(error = %e, "CDP kiosk input session dropped — will re-discover"),
}
connected.store(false, Ordering::Relaxed);
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
/// Discard queued input for `d` — used while no kiosk session exists so the
/// bounded channel can't fill with stale events.
async fn drain_for(rx: &mut mpsc::Receiver<String>, d: Duration) {
let _ = tokio::time::timeout(d, async { while rx.recv().await.is_some() {} }).await;
}
/// Find the kiosk page target's WebSocket debugger URL. Prefers the page on
/// localhost (the kiosk app) over e.g. devtools/extension targets.
async fn discover_page_target() -> Option<String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build()
.ok()?;
let list: Vec<Value> = client
.get(format!("{CDP_HTTP}/json/list"))
.send()
.await
.ok()?
.json()
.await
.ok()?;
let pages: Vec<&Value> = list
.iter()
.filter(|t| t.get("type").and_then(Value::as_str) == Some("page"))
.collect();
let preferred = pages
.iter()
.find(|t| {
t.get("url")
.and_then(Value::as_str)
.is_some_and(|u| u.contains("localhost") || u.contains("127.0.0.1"))
})
.or_else(|| pages.first());
preferred?
.get("webSocketDebuggerUrl")
.and_then(Value::as_str)
.map(str::to_string)
}
async fn drive_session(
ws_url: &str,
rx: &mut mpsc::Receiver<String>,
connected: &Arc<AtomicBool>,
) -> anyhow::Result<()> {
let (ws, _) = tokio_tungstenite::connect_async(ws_url).await?;
let (mut sink, mut stream) = ws.split();
let mut next_id: u64 = 0;
let mut id = move || {
next_id += 1;
next_id
};
// Viewport size for cursor clamping. Best-effort: fall back to 1080p if
// the metrics call fails — clamping is a nicety, not a correctness need.
sink.send(Message::Text(
json!({"id": id(), "method": "Page.getLayoutMetrics"}).to_string(),
))
.await?;
let (mut vw, mut vh) = (1920.0_f64, 1080.0_f64);
if let Ok(Some(Ok(Message::Text(txt)))) =
tokio::time::timeout(Duration::from_secs(3), stream.next()).await
{
if let Ok(v) = serde_json::from_str::<Value>(&txt) {
if let Some(vp) = v.pointer("/result/cssLayoutViewport") {
vw = vp.get("clientWidth").and_then(Value::as_f64).unwrap_or(vw);
vh = vp.get("clientHeight").and_then(Value::as_f64).unwrap_or(vh);
}
}
}
let mut cursor = Cursor {
x: vw / 2.0,
y: vh / 2.0,
w: vw,
h: vh,
};
connected.store(true, Ordering::Relaxed);
info!(viewport = %format!("{vw}x{vh}"), "CDP kiosk input bridge connected");
loop {
tokio::select! {
cmd = rx.recv() => {
let Some(cmd) = cmd else { return Ok(()) };
for frame in translate(&cmd, &mut cursor, &mut id) {
sink.send(Message::Text(frame.to_string())).await?;
}
}
msg = stream.next() => {
match msg {
// Responses/events — nothing to correlate, but a read
// error or close means Chromium restarted.
Some(Ok(_)) => {}
Some(Err(e)) => return Err(e.into()),
None => anyhow::bail!("CDP socket closed"),
}
}
}
}
}
/// Translate one validated relay input message into CDP command frames.
fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Vec<Value> {
let Ok(msg) = serde_json::from_str::<Value>(raw) else {
return vec![];
};
match msg.get("t").and_then(Value::as_str) {
Some("m") => {
let dx = msg.get("x").and_then(Value::as_i64).unwrap_or(0) as f64;
let dy = msg.get("y").and_then(Value::as_i64).unwrap_or(0) as f64;
cursor.x = (cursor.x + dx).clamp(0.0, cursor.w - 1.0);
cursor.y = (cursor.y + dy).clamp(0.0, cursor.h - 1.0);
vec![mouse_event(id(), "mouseMoved", cursor, "none", 0, 1)]
}
Some("c") => {
let b = msg
.get("b")
.and_then(Value::as_u64)
.unwrap_or(1)
.clamp(1, 3);
let (button, buttons) = match b {
2 => ("middle", 4),
3 => ("right", 2),
_ => ("left", 1),
};
vec![
// Hover first so the press lands on current hit-test state.
mouse_event(id(), "mouseMoved", cursor, "none", 0, 1),
mouse_event(id(), "mousePressed", cursor, button, buttons, 1),
mouse_event(id(), "mouseReleased", cursor, button, 0, 1),
]
}
Some("s") => {
let dy = msg.get("y").and_then(Value::as_i64).unwrap_or(0) as f64 * SCROLL_STEP_PX;
vec![json!({
"id": id(),
"method": "Input.dispatchMouseEvent",
"params": {
"type": "mouseWheel",
"x": cursor.x, "y": cursor.y,
"deltaX": 0.0, "deltaY": dy,
"pointerType": "mouse",
}
})]
}
Some("k") => {
let Some(k) = msg.get("k").and_then(Value::as_str) else {
return vec![];
};
key_events(k, id)
}
_ => vec![],
}
}
fn mouse_event(
id: u64,
kind: &str,
cursor: &Cursor,
button: &str,
buttons: u32,
clicks: u32,
) -> Value {
json!({
"id": id,
"method": "Input.dispatchMouseEvent",
"params": {
"type": kind,
"x": cursor.x, "y": cursor.y,
"button": button,
"buttons": buttons,
"clickCount": if kind == "mousePressed" || kind == "mouseReleased" { clicks } else { 0 },
"pointerType": "mouse",
}
})
}
/// xdotool named key → (DOM key, DOM code, Windows virtual-key code).
fn named_key(k: &str) -> Option<(&'static str, &'static str, i32)> {
Some(match k {
"Return" => ("Enter", "Enter", 13),
"BackSpace" => ("Backspace", "Backspace", 8),
"Escape" => ("Escape", "Escape", 27),
"Tab" => ("Tab", "Tab", 9),
"Delete" => ("Delete", "Delete", 46),
"Up" => ("ArrowUp", "ArrowUp", 38),
"Down" => ("ArrowDown", "ArrowDown", 40),
"Left" => ("ArrowLeft", "ArrowLeft", 37),
"Right" => ("ArrowRight", "ArrowRight", 39),
"Home" => ("Home", "Home", 36),
"End" => ("End", "End", 35),
"Prior" => ("PageUp", "PageUp", 33),
"Next" => ("PageDown", "PageDown", 34),
"F1" => ("F1", "F1", 112),
"F2" => ("F2", "F2", 113),
"F3" => ("F3", "F3", 114),
"F4" => ("F4", "F4", 115),
"F5" => ("F5", "F5", 116),
"F6" => ("F6", "F6", 117),
"F7" => ("F7", "F7", 118),
"F8" => ("F8", "F8", 119),
"F9" => ("F9", "F9", 120),
"F10" => ("F10", "F10", 121),
"F11" => ("F11", "F11", 122),
"F12" => ("F12", "F12", 123),
_ => return None,
})
}
/// xdotool symbol name → printable char (the relay whitelist speaks xdotool).
fn symbol_char(k: &str) -> Option<char> {
Some(match k {
"space" => ' ',
"exclam" => '!',
"at" => '@',
"numbersign" => '#',
"dollar" => '$',
"percent" => '%',
"asciicircum" => '^',
"ampersand" => '&',
"asterisk" => '*',
"parenleft" => '(',
"parenright" => ')',
"underscore" => '_',
"plus" => '+',
"braceleft" => '{',
"braceright" => '}',
"bar" => '|',
"colon" => ':',
"quotedbl" => '"',
"less" => '<',
"greater" => '>',
"question" => '?',
"asciitilde" => '~',
"minus" => '-',
"equal" => '=',
"bracketleft" => '[',
"bracketright" => ']',
"backslash" => '\\',
"semicolon" => ';',
"apostrophe" => '\'',
"grave" => '`',
"comma" => ',',
"period" => '.',
"slash" => '/',
_ => return None,
})
}
/// Build the CDP frame pair (keyDown, keyUp) for one relay key name,
/// including `modifier+base` combos. A keyDown that carries `text` both
/// fires real keydown/keypress AND inserts the character — exactly how a
/// physical keystroke behaves, so games see the key and fields get the text.
fn key_events(k: &str, id: &mut impl FnMut() -> u64) -> Vec<Value> {
let (modifiers, base) = match k.split_once('+') {
Some((m, b)) => (
match m {
"alt" => 1,
"ctrl" => 2,
"super" => 4,
"shift" => 8,
_ => 0,
},
b,
),
None => (0, k),
};
let (key, code, vk, text): (String, Option<&str>, i32, Option<String>) =
if let Some((key, code, vk)) = named_key(base) {
// Enter carries "\r" like a real keyboard so single-line inputs
// submit and textareas newline.
let text = (key == "Enter").then(|| "\r".to_string());
(key.to_string(), Some(code), vk, text)
} else {
let ch = if base.chars().count() == 1 {
base.chars().next()
} else {
symbol_char(base)
};
let Some(mut ch) = ch else {
return vec![];
};
if modifiers == 8 && ch.is_ascii_alphabetic() {
ch = ch.to_ascii_uppercase();
}
let vk = ch.to_ascii_uppercase() as i32;
// Ctrl/Alt/Super chords are shortcuts, not typing — no text.
let text = (modifiers & !8 == 0).then(|| ch.to_string());
(ch.to_string(), None, vk, text)
};
let mut down = json!({
"id": id(),
"method": "Input.dispatchKeyEvent",
"params": {
"type": "keyDown",
"key": key,
"modifiers": modifiers,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
}
});
if let Some(code) = code {
down["params"]["code"] = json!(code);
}
if let Some(t) = &text {
down["params"]["text"] = json!(t);
down["params"]["unmodifiedText"] = json!(t);
}
let mut up = json!({
"id": id(),
"method": "Input.dispatchKeyEvent",
"params": {
"type": "keyUp",
"key": key,
"modifiers": modifiers,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
}
});
if let Some(code) = code {
up["params"]["code"] = json!(code);
}
vec![down, up]
}
-18
View File
@@ -1,5 +1,4 @@
mod blob;
mod cdp;
mod content;
mod dwn;
mod model_proxy;
@@ -7,7 +6,6 @@ mod node_message;
mod proxy;
mod remote_input;
mod remote_relay;
mod routstr_proxy;
mod websocket;
use crate::api::rpc::RpcHandler;
@@ -52,10 +50,6 @@ pub struct ApiHandler {
/// to the phone's default browser. Lets "open in external browser" apps —
/// which the kiosk can't usefully open itself — launch on the controller.
external_open_tx: broadcast::Sender<String>,
/// Bridge that dispatches companion input into the local kiosk Chromium
/// as trusted CDP events (reaches inside cross-origin app iframes).
/// Inert (never connects) on nodes without a kiosk.
cdp_bridge: cdp::CdpBridge,
/// Content-addressed blob store for attachments shared over mesh/federation.
blob_store: Arc<BlobStore>,
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
@@ -84,7 +78,6 @@ impl ApiHandler {
);
let (input_relay_tx, _) = broadcast::channel(64);
let (external_open_tx, _) = broadcast::channel(16);
let cdp_bridge = cdp::CdpBridge::spawn();
// Derive a blob-store capability key from the node's Ed25519 signing
// key. SHA-256 domain-separated so rotating the identity rotates
@@ -115,7 +108,6 @@ impl ApiHandler {
session_store,
input_relay_tx,
external_open_tx,
cdp_bridge,
blob_store,
self_pubkey_hex,
})
@@ -409,7 +401,6 @@ impl ApiHandler {
req,
self.input_relay_tx.clone(),
self.external_open_tx.subscribe(),
self.cdp_bridge.clone(),
)
.await;
}
@@ -424,7 +415,6 @@ impl ApiHandler {
req,
self.input_relay_tx.subscribe(),
self.external_open_tx.clone(),
self.cdp_bridge.clone(),
)
.await;
}
@@ -459,14 +449,6 @@ impl ApiHandler {
self.handle_model_proxy(req_with_bytes, p).await
}
// AIUI Routstr proxy — the explicit, user-selected Routstr
// provider (model catalog + Cashu-paid completions), same
// session-gate discipline as the model proxy above. D-05: paid
// requests are refused unless the operator has armed a budget.
(_, p) if p.starts_with("/aiui/api/routstr/") => {
self.handle_routstr_proxy(req_with_bytes, p).await
}
// Health — unauthenticated, returns JSON with service status
(Method::GET, "/health") => {
let recovery_complete = crate::crash_recovery::is_recovery_complete();
@@ -84,14 +84,14 @@ async fn route_model_proxy(
/// call back into `ApiHandler::is_authenticated` — keeping this small and
/// dependency-free is what makes the 401 behaviour unit-testable without
/// paying for a full `ApiHandler` in every test.
pub(super) async fn is_authenticated(session_store: &SessionStore, headers: &HeaderMap) -> bool {
async fn is_authenticated(session_store: &SessionStore, headers: &HeaderMap) -> bool {
match session::extract_session_cookie(headers) {
Some(token) => session_store.validate(&token).await,
None => false,
}
}
pub(super) fn unauthorized() -> Response<Body> {
fn unauthorized() -> Response<Body> {
let body = serde_json::json!({ "error": "Unauthorized" });
Response::builder()
.status(StatusCode::UNAUTHORIZED)
@@ -119,7 +119,7 @@ fn key_not_configured() -> Response<Body> {
/// backends' egress screen never sees the forwarder path — the standalone
/// frontend posts FULL history and images straight here — so the forwarder
/// screens for itself. 400, plain-language, never naming what matched.
pub(super) fn blocked_secret_shaped() -> Response<Body> {
fn blocked_secret_shaped() -> Response<Body> {
let body = serde_json::json!({
"error": "Blocked: this request contained secret-shaped content (e.g. a seed phrase, key, or token). It was not sent anywhere."
});
@@ -134,12 +134,12 @@ pub(super) fn blocked_secret_shaped() -> Response<Body> {
/// the assistant's secret-shape rules (G-B1) with this node's own secrets
/// as the deny corpus. Returns Some(kind) — kind only, never the value —
/// when the content must not leave.
pub(super) async fn forward_screen(text: &str, data_dir: &Path) -> Option<&'static str> {
async fn forward_screen(text: &str, data_dir: &Path) -> Option<&'static str> {
let secrets = crate::assistant::egress::load_known_secrets(&data_dir.join("secrets")).await;
crate::assistant::egress::scan_secret_shapes(text, &secrets)
}
pub(super) fn bad_gateway(msg: &str) -> Response<Body> {
fn bad_gateway(msg: &str) -> Response<Body> {
let body = serde_json::json!({ "error": msg });
Response::builder()
.status(StatusCode::BAD_GATEWAY)
@@ -331,7 +331,7 @@ async fn forward(
/// same shape as `proxy.rs`'s peer-content Range streamer — so a
/// token-by-token reply doesn't wait for the full response before the first
/// byte reaches the browser.
pub(super) fn stream_response(resp: reqwest::Response) -> Result<Response<Body>> {
fn stream_response(resp: reqwest::Response) -> Result<Response<Body>> {
let status = resp.status().as_u16();
let headers = resp.headers().clone();
let mut builder = Response::builder().status(status);
@@ -212,7 +212,6 @@ impl ApiHandler {
req: Request<hyper::Body>,
relay_tx: broadcast::Sender<String>,
mut external_open_rx: broadcast::Receiver<String>,
cdp_bridge: super::cdp::CdpBridge,
) -> Result<Response<hyper::Body>> {
// Extract optional player ID from query string: /ws/remote-input?p=1
let player_id: Option<u8> = req
@@ -318,20 +317,9 @@ impl ApiHandler {
} else {
text.clone()
};
let validation = handle_input(&text).await;
let _ = relay_tx.send(relay_text.clone());
// Trusted-input path: while the kiosk CDP
// bridge is live, also dispatch validated
// input into the kiosk Chromium so it
// lands inside cross-origin app iframes
// (the web relay above can't cross that
// boundary; the kiosk subscriber mutes its
// own DOM synthesis — remote_relay.rs).
if matches!(validation, Ok(None)) && cdp_bridge.is_active() {
cdp_bridge.send(&relay_text);
}
let _ = relay_tx.send(relay_text);
match validation {
match handle_input(&text).await {
Ok(Some(reply)) => {
let _ = tx.send(Message::Text(reply)).await;
}
@@ -21,16 +21,7 @@ impl ApiHandler {
req: Request<hyper::Body>,
mut relay_rx: broadcast::Receiver<String>,
external_open_tx: broadcast::Sender<String>,
cdp_bridge: super::cdp::CdpBridge,
) -> Result<Response<hyper::Body>> {
// The kiosk browser self-identifies with ?kiosk=1 so we can suppress
// its DOM-synthesis path while the CDP bridge delivers trusted input
// (otherwise every key/click/scroll would apply twice). A remote
// browser claiming kiosk=1 only mutes its own input — harmless.
let is_kiosk = req
.uri()
.query()
.is_some_and(|q| q.split('&').any(|s| s == "kiosk=1"));
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
@@ -69,19 +60,6 @@ impl ApiHandler {
msg = relay_rx.recv() => {
match msg {
Ok(text) => {
// Kiosk + live CDP bridge: keys/clicks/
// scrolls arrive as trusted browser input
// via CDP; forward only cursor moves (the
// on-screen cursor is drawn by the page)
// so nothing applies twice.
if is_kiosk && cdp_bridge.is_active() {
let tag = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v.get("t").and_then(|t| t.as_str().map(str::to_string)));
if !matches!(tag.as_deref(), Some("m") | Some("o") | Some("p")) {
continue;
}
}
if tx.send(Message::Text(text)).await.is_err() {
break;
}
@@ -1,535 +0,0 @@
//! Session-gated forwarder for `/aiui/api/routstr/*` — the explicit,
//! user-selected Routstr path (as opposed to `assistant/backends/routstr.rs`,
//! which is the D-04 fallback leg the operator never chooses directly).
//!
//! AIUI's model picker lists Routstr as a first-class provider; selecting one
//! of its models routes chat completions through here. Same discipline as
//! `model_proxy.rs`: auth is re-derived from the request's own session cookie
//! (never trusted to nginx), inbound `authorization`/`cookie` headers are
//! never forwarded, and every outbound body is egress-screened (S3) before it
//! leaves the node.
//!
//! Payment is Cashu, D-05-gated end to end: a request is refused unless the
//! operator has set a non-zero Routstr allowance (Settings → System), the
//! quoted price fits the remaining allowance, and `auto_pay_token` (the ONE
//! budget-capped payment primitive, T-13-89) agrees to build the token. The
//! provider's change (`X-Cashu` / `X-Cashu-Refund` response headers, per
//! docs.routstr.com) is redeemed back into the node wallet and only the net
//! is recorded against the allowance.
//!
//! Upstream is the public Routstr aggregator instance routstr.com itself
//! ships against (verified live 2026-08-14: `/v1/models` serves the full
//! catalog with `sats_pricing`; the canonical `api.routstr.com` host 404s).
//! Making the instance operator-configurable — or sourcing it from the Nostr
//! provider announcements once those carry real endpoint/pricing content —
//! is the planned follow-up, not this file's job.
use super::ApiHandler;
use crate::session::SessionStore;
use anyhow::Result;
use hyper::{Body, Method, Request, Response, StatusCode};
use serde_json::{json, Value};
use std::path::Path;
use std::sync::Mutex as StdMutex;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use super::model_proxy::{
bad_gateway, blocked_secret_shaped, forward_screen, is_authenticated, unauthorized,
};
/// The live public Routstr aggregator (the same instance routstr.com's own
/// frontend queries for `/v1/providers` and `/v1/models`).
const ROUTSTR_INSTANCE: &str = "https://routstr.otrta.me";
/// Generation cap forced onto every forwarded completion — never unbounded
/// (T-13-88), and the completion half of the price quote is arithmetic over
/// exactly this figure.
const MAX_COMPLETION_TOKENS: u64 = 1024;
/// Same round-trip ceiling as `model_proxy.rs`'s Claude/Ollama forwarders.
const FORWARD_TIMEOUT_SECS: u64 = 180;
/// Models-catalog cache TTL — mirrors `backends/routstr.rs`'s provider
/// discovery TTL. The catalog prices every chat request, so it cannot be
/// fetched per-message without doubling latency.
const MODELS_CACHE_TTL: Duration = Duration::from_secs(300);
/// One model's sats-denominated pricing, parsed from the aggregator's
/// `/v1/models` entries (`sats_pricing`). Rates are sats PER TOKEN (fractional
/// floats); `request` is a flat per-request fee in sats. Untrusted input — a
/// missing/garbled field parses as 0.0 and simply prices low, which the
/// remaining-allowance ceiling still caps.
#[derive(Debug, Clone, Default, serde::Deserialize)]
struct SatsPricing {
#[serde(default)]
prompt: f64,
#[serde(default)]
completion: f64,
#[serde(default)]
request: f64,
}
/// Quote a price in whole sats for one completion call: flat request fee +
/// prompt rate × (payload chars / 4, the usual chars-per-token rule of thumb)
/// + completion rate × the forced `MAX_COMPLETION_TOKENS` cap, +20% margin,
/// rounded up, never below 1. Deliberately a pure function so the arithmetic
/// is unit-testable; deliberately conservative because the provider's change
/// comes back as a Cashu refund and is redeemed — overquoting costs nothing
/// but float, underquoting gets the request rejected upstream.
fn estimate_price_sats(pricing: &SatsPricing, prompt_chars: usize, completion_tokens: u64) -> u64 {
let prompt_tokens = (prompt_chars as f64) / 4.0;
let raw = pricing.request
+ pricing.prompt * prompt_tokens
+ pricing.completion * (completion_tokens as f64);
let with_margin = raw * 1.2;
(with_margin.ceil() as u64).max(1)
}
/// Process-lifetime cache of the aggregator's models catalog (same pattern as
/// `backends/routstr.rs`'s `PROVIDER_CACHE`).
static MODELS_CACHE: OnceLock<StdMutex<Option<(Instant, Value)>>> = OnceLock::new();
fn cached_models() -> Option<Value> {
let cache = MODELS_CACHE.get_or_init(|| StdMutex::new(None));
let guard = cache.lock().expect("routstr models cache poisoned");
guard.as_ref().and_then(|(at, models)| {
if at.elapsed() < MODELS_CACHE_TTL {
Some(models.clone())
} else {
None
}
})
}
fn set_cached_models(models: Value) {
let cache = MODELS_CACHE.get_or_init(|| StdMutex::new(None));
*cache.lock().expect("routstr models cache poisoned") = Some((Instant::now(), models));
}
/// Fetch (or serve cached) the aggregator's `/v1/models` catalog.
async fn fetch_models() -> Result<Value> {
if let Some(cached) = cached_models() {
return Ok(cached);
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()?;
let url = format!("{ROUTSTR_INSTANCE}/v1/models");
let resp = client.get(&url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("routstr models upstream returned HTTP {}", resp.status());
}
let models: Value = resp.json().await?;
set_cached_models(models.clone());
Ok(models)
}
/// Find one model's `sats_pricing` in the catalog by exact id.
fn pricing_for_model(models: &Value, model_id: &str) -> Option<SatsPricing> {
models
.get("data")?
.as_array()?
.iter()
.find(|m| m.get("id").and_then(|v| v.as_str()) == Some(model_id))
.and_then(|m| m.get("sats_pricing"))
.and_then(|sp| serde_json::from_value(sp.clone()).ok())
}
fn json_response(status: StatusCode, body: Value) -> Response<Body> {
Response::builder()
.status(status)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
.unwrap_or_else(|_| Response::new(Body::from("{}")))
}
/// Plain-language refusal naming the UI path that fixes it — never a bare
/// status (RULE: every action needs a UI path).
fn budget_refusal(msg: String) -> Response<Body> {
json_response(
StatusCode::SERVICE_UNAVAILABLE,
json!({ "error": { "message": msg } }),
)
}
impl ApiHandler {
/// Entry point wired into the `/aiui/api/routstr/` arm in `mod.rs` —
/// thin, like `handle_model_proxy`, so the routing/budget logic below is
/// testable without a full `ApiHandler`.
pub(super) async fn handle_routstr_proxy(
&self,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>> {
route_routstr_proxy(&self.session_store, &self.config.data_dir, req, path).await
}
}
async fn route_routstr_proxy(
session_store: &SessionStore,
data_dir: &Path,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>> {
if !is_authenticated(session_store, req.headers()).await {
tracing::warn!("401 routstr proxy {} — session invalid or missing", path);
return Ok(unauthorized());
}
match path.strip_prefix("/aiui/api/routstr/") {
Some("models") if req.method() == Method::GET => forward_models().await,
Some("chat/completions") if req.method() == Method::POST => {
forward_chat(req, data_dir).await
}
_ => Ok(unauthorized()),
}
}
/// GET /aiui/api/routstr/models — the full catalog, passed through so AIUI
/// can render ids/names and show sats pricing. Read-only and unpaid.
async fn forward_models() -> Result<Response<Body>> {
match fetch_models().await {
Ok(models) => Ok(json_response(StatusCode::OK, models)),
Err(e) => {
tracing::warn!("routstr proxy: models upstream failed: {}", e);
Ok(bad_gateway("Routstr model catalog is unreachable"))
}
}
}
/// POST /aiui/api/routstr/chat/completions — one paid, non-streaming,
/// OpenAI-shaped completion. Order matters: screen (S3) → budget gate (D-05,
/// offline) → price quote → pay → forward → redeem change → record net.
async fn forward_chat(req: Request<Body>, data_dir: &Path) -> Result<Response<Body>> {
let payload = hyper::body::to_bytes(req.into_body())
.await
.map_err(|e| anyhow::anyhow!("read request payload: {e}"))?;
let payload_str = String::from_utf8_lossy(&payload).to_string();
// S3: the standalone frontend posts full history straight here with no
// assistant loop (and no egress screen) behind it.
if let Some(kind) = forward_screen(&payload_str, data_dir).await {
tracing::error!(
kind,
"routstr proxy: blocked chat forward — secret-shaped content"
);
return Ok(blocked_secret_shaped());
}
let mut body: Value = match serde_json::from_str(&payload_str) {
Ok(v) => v,
Err(_) => {
return Ok(json_response(
StatusCode::BAD_REQUEST,
json!({ "error": { "message": "request body is not valid JSON" } }),
));
}
};
let Some(model_id) = body.get("model").and_then(|v| v.as_str()).map(String::from) else {
return Ok(json_response(
StatusCode::BAD_REQUEST,
json!({ "error": { "message": "request is missing a model id" } }),
));
};
// D-05 gate, checked before any network I/O: a zero allowance means
// Routstr is refused outright, with the UI path that arms it.
let mut budget = crate::assistant::AssistantBudget::load(data_dir).await;
if budget.allowance_sats == 0 {
return Ok(budget_refusal(
"Routstr is disabled on this node — set a sats budget in Settings → System → \
Routstr AI budget to enable it."
.to_string(),
));
}
let remaining = budget.remaining_sats();
if remaining == 0 {
return Ok(budget_refusal(format!(
"This period's Routstr budget is spent ({} of {} sats). Raise the allowance in \
Settings → System → Routstr AI budget to continue.",
budget.spent_sats, budget.allowance_sats
)));
}
// Price the request from the catalog. An unknown model is a caller bug
// (the dropdown only offers catalog models), not a reason to guess a
// price.
let models = match fetch_models().await {
Ok(m) => m,
Err(e) => {
tracing::warn!("routstr proxy: cannot price request, models fetch failed: {e}");
return Ok(bad_gateway(
"Routstr model catalog is unreachable — cannot price this request",
));
}
};
let Some(pricing) = pricing_for_model(&models, &model_id) else {
return Ok(json_response(
StatusCode::BAD_REQUEST,
json!({ "error": { "message": format!("unknown Routstr model: {model_id}") } }),
));
};
// Force the shape this forwarder actually supports: non-streaming, with
// an explicit, capped generation limit (T-13-88).
let max_tokens = body
.get("max_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(MAX_COMPLETION_TOKENS)
.min(MAX_COMPLETION_TOKENS);
body["stream"] = json!(false);
body["max_tokens"] = json!(max_tokens);
let price_sats = estimate_price_sats(&pricing, payload_str.len(), max_tokens);
if price_sats > remaining {
return Ok(budget_refusal(format!(
"This request quotes ~{price_sats} sats but only {remaining} sats remain in this \
period's Routstr budget (Settings → System → Routstr AI budget)."
)));
}
// Pay via the ONE budget-capped primitive (T-13-89) — same call, same
// mint list as the fallback leg in backends/routstr.rs.
let accepted_mints = crate::wallet::ecash::load_accepted_mints(data_dir)
.await
.map(|m| m.mints)
.unwrap_or_default();
let token = match crate::swarm::payment::auto_pay_token(
data_dir,
&budget.payment_policy(),
&accepted_mints,
price_sats,
)
.await?
{
Some(t) => t,
None => {
return Ok(budget_refusal(format!(
"The node wallet could not fund this request (~{price_sats} sats) — check the \
ecash balance and accepted mints in Settings → Wallet."
)));
}
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(FORWARD_TIMEOUT_SECS))
.build()?;
let url = format!("{ROUTSTR_INSTANCE}/v1/chat/completions");
let resp = match client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
// The token never reached the provider — reclaim it into our own
// wallet so the sats aren't stranded, and record nothing.
match crate::wallet::ecash::receive_token(data_dir, &token).await {
Ok(_) => tracing::info!(
"routstr proxy: upstream send failed ({e}); unsent payment token reclaimed"
),
Err(re) => tracing::warn!(
"routstr proxy: upstream send failed ({e}) AND reclaiming the unsent token \
failed ({re}) — {price_sats} sats may be stranded in the token"
),
}
return Ok(bad_gateway("Routstr provider is unreachable"));
}
};
let status = resp.status();
// Change comes back as a Cashu token header (docs.routstr.com names both
// spellings across versions); redeem it so only the net leaves the
// allowance.
let refund_token = ["x-cashu-refund", "x-cashu"]
.iter()
.find_map(|h| resp.headers().get(*h))
.and_then(|v| v.to_str().ok())
.map(String::from);
let resp_body = resp.bytes().await.unwrap_or_default();
let mut reclaimed = 0u64;
if let Some(refund) = refund_token {
match crate::wallet::ecash::receive_token(data_dir, &refund).await {
Ok(sats) => reclaimed = sats,
Err(e) => tracing::warn!("routstr proxy: redeeming the change token failed: {e}"),
}
} else if !status.is_success() {
// The provider refused the request (e.g. "mint unreachable") and
// sent no change — if it never actually redeemed our token, the
// proofs are still ours to take back. If it DID redeem and then
// failed, this reclaim fails harmlessly and the spend stands.
match crate::wallet::ecash::receive_token(data_dir, &token).await {
Ok(sats) => {
reclaimed = sats;
tracing::info!(
"routstr proxy: upstream refused (HTTP {status}); unredeemed payment token \
reclaimed ({sats} sats)"
);
}
Err(e) => tracing::warn!(
"routstr proxy: upstream refused (HTTP {status}) and the payment token could \
not be reclaimed ({e}) — treating the {price_sats} sats as spent"
),
}
}
let net_sats = price_sats.saturating_sub(reclaimed);
if net_sats > 0 {
if let Err(e) = budget.record_spend(data_dir, net_sats).await {
tracing::warn!(
error = %e,
"routstr proxy: failed to persist the budget spend (the payment itself already happened)"
);
}
}
tracing::info!(
model = %model_id,
quoted = price_sats,
reclaimed,
net = net_sats,
status = %status,
"routstr proxy: forwarded paid chat completion"
);
Ok(Response::builder()
.status(status.as_u16())
.header("Content-Type", "application/json")
.body(Body::from(resp_body))
.unwrap_or_else(|_| Response::new(Body::from("{}"))))
}
#[cfg(test)]
mod tests {
use super::*;
async fn test_store() -> SessionStore {
let path = std::env::temp_dir().join(format!(
"archy-routstr-proxy-test-sessions-{}.json",
rand::RngCore::next_u64(&mut rand::rngs::OsRng)
));
SessionStore::new_for_tests(path)
}
fn req(method: &str, path: &str, cookie: Option<&str>, body: &'static str) -> Request<Body> {
let mut builder = Request::builder().method(method).uri(path);
if let Some(c) = cookie {
builder = builder.header("cookie", format!("session={c}"));
}
builder.body(Body::from(body)).unwrap()
}
#[tokio::test]
async fn models_without_session_is_401() {
let store = test_store().await;
let data_dir = tempfile::tempdir().unwrap();
let r = req("GET", "/aiui/api/routstr/models", None, "");
let resp = route_routstr_proxy(&store, data_dir.path(), r, "/aiui/api/routstr/models")
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn chat_without_session_is_401() {
let store = test_store().await;
let data_dir = tempfile::tempdir().unwrap();
let r = req("POST", "/aiui/api/routstr/chat/completions", None, "{}");
let resp = route_routstr_proxy(
&store,
data_dir.path(),
r,
"/aiui/api/routstr/chat/completions",
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
/// D-05: a fresh node (no budget file → zero allowance) refuses the paid
/// path BEFORE any pricing/network I/O — this test runs fully offline.
#[tokio::test]
async fn chat_with_zero_allowance_is_refused_offline() {
let store = test_store().await;
let token = store.create().await;
let data_dir = tempfile::tempdir().unwrap();
let r = req(
"POST",
"/aiui/api/routstr/chat/completions",
Some(&token),
r#"{"model":"some-model","messages":[{"role":"user","content":"hi"}]}"#,
);
let resp = route_routstr_proxy(
&store,
data_dir.path(),
r,
"/aiui/api/routstr/chat/completions",
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let v: Value = serde_json::from_slice(&body).unwrap();
let msg = v["error"]["message"].as_str().unwrap();
assert!(msg.contains("Settings"), "refusal must name the UI path");
}
#[tokio::test]
async fn chat_body_carrying_bip39_is_blocked() {
let store = test_store().await;
let token = store.create().await;
let data_dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(data_dir.path().join("secrets")).unwrap();
let r = req(
"POST",
"/aiui/api/routstr/chat/completions",
Some(&token),
r#"{"model":"m","messages":[{"role":"user","content":"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"}]}"#,
);
let resp = route_routstr_proxy(
&store,
data_dir.path(),
r,
"/aiui/api/routstr/chat/completions",
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn price_estimate_is_conservative_and_never_zero() {
// A free/garbled pricing entry still quotes at least 1 sat.
assert_eq!(estimate_price_sats(&SatsPricing::default(), 100, 1024), 1);
// Live-catalog-shaped numbers (deepseek-v4-flash, 2026-08-14):
// request 0.001, prompt ~0.000178/tok, completion ~0.000267/tok.
let p = SatsPricing {
prompt: 0.000178,
completion: 0.000267,
request: 0.001,
};
let quote = estimate_price_sats(&p, 4000, 1024);
// ~0.18 + ~0.27 + flat, with margin → rounds up to 1 sat.
assert_eq!(quote, 1);
// A pricier model scales with the prompt.
let expensive = SatsPricing {
prompt: 0.05,
completion: 0.1,
request: 1.0,
};
let quote = estimate_price_sats(&expensive, 40_000, 1024);
assert!(quote >= 600, "quote {quote} should reflect real rates");
}
#[test]
fn pricing_lookup_finds_exact_model_id() {
let models = json!({ "data": [
{ "id": "a-model", "sats_pricing": { "prompt": 0.1, "completion": 0.2, "request": 1.0 } },
{ "id": "other", "sats_pricing": { "prompt": 0.3 } }
]});
let p = pricing_for_model(&models, "a-model").unwrap();
assert_eq!(p.request, 1.0);
assert!(pricing_for_model(&models, "missing").is_none());
}
}
-62
View File
@@ -42,13 +42,6 @@ impl RpcHandler {
"port": g.port,
"app_id": g.app_id,
"app_name": g.app_name,
// Is the login challenge active on this port right now
// (manifest default + operator override, resolved)?
"gate_enabled": g.auth_enabled,
// Whether an operator override is recorded, and what the
// manifest would do without it — the UI needs all three
// to render a meaningful toggle.
"override": crate::container::app_gate_config::gate_override(&g.app_id),
})
})
.collect();
@@ -63,59 +56,4 @@ impl RpcHandler {
"exempt": exempt,
}))
}
/// `security.set-app-gate` — the operator's per-app gate toggle.
///
/// Params: `{ id: "<app_id>", enabled: true | false | null }`.
/// `enabled: null` clears the override so the manifest default applies
/// again. Takes effect on the next request (the gate resolves per-request
/// policy from the live port map) — no rebind, no restart.
pub(in crate::api::rpc) async fn handle_set_app_gate(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing id"))?
.to_string();
let enabled = match params.get("enabled") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::Bool(b)) => Some(*b),
Some(other) => anyhow::bail!("enabled must be true, false or null, got {other}"),
};
// Only apps the gate actually fronts have a challenge to toggle.
// Writing an override for anything else would sit silently in the
// config doing nothing — reject instead so a typo'd id is loud.
let port_map = self.app_gate.port_map().await;
if !port_map.gated_ports().any(|g| g.app_id == app_id) {
anyhow::bail!(
"'{app_id}' has no gate-fronted ports — nothing to toggle \
(auth: none/local ports are manifest-declared, not runtime-toggled)"
);
}
crate::container::app_gate_config::write_gate_override(&app_id, enabled)
.map_err(|e| anyhow::anyhow!("Failed to persist gate override: {e}"))?;
// Rebuild the port map now so the change is live on the next request
// instead of after the next 60s sweep.
self.app_gate.refresh().await;
let effective: Vec<serde_json::Value> = self
.app_gate
.port_map()
.await
.gated_ports()
.filter(|g| g.app_id == app_id)
.map(|g| serde_json::json!({ "port": g.port, "gate_enabled": g.auth_enabled }))
.collect();
tracing::info!(
app = %app_id,
override_ = ?enabled,
"app gate override updated by operator"
);
Ok(serde_json::json!({ "id": app_id, "override": enabled, "ports": effective }))
}
}
@@ -344,7 +344,6 @@ impl RpcHandler {
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
"system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await,
"system.node-ca.generate" => self.handle_system_node_ca_generate().await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
"bitcoin.relay-update-settings" => {
@@ -133,7 +133,6 @@ impl RpcHandler {
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
@@ -266,12 +265,6 @@ impl RpcHandler {
"wallet.ecash-send" => self.handle_wallet_ecash_send(params).await,
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
// Fedimint ecash (via fedimint-clientd sidecar)
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
@@ -495,7 +488,6 @@ impl RpcHandler {
// System monitoring
"security.app-gate-status" => self.handle_app_gate_status().await,
"security.set-app-gate" => self.handle_set_app_gate(params).await,
"system.get-hostname" => self.handle_system_get_hostname().await,
"system.stats" => self.handle_system_stats().await,
"system.processes" => self.handle_system_processes().await,
@@ -511,7 +503,6 @@ impl RpcHandler {
"ai.permissions.set" => self.handle_ai_permissions_set(params).await,
"system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await,
"system.node-ca.generate" => self.handle_system_node_ca_generate().await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
@@ -696,22 +696,6 @@ impl RpcHandler {
anyhow::bail!("Refusing to peer with self");
}
// Bind the DID to the advertised pubkey. Without this the signature
// check below is self-referential (the caller signs over a pubkey it
// also supplies), so a consistent-but-unrelated keypair would pass.
// The DID-rotation handler already enforces the same invariant.
match identity::did_key_from_pubkey_hex(pubkey) {
Ok(derived) if derived == did => {}
Ok(derived) => {
tracing::warn!(peer_did = %did, derived_did = %derived, "Rejected peer-joined: DID does not match pubkey");
anyhow::bail!("DID does not match pubkey");
}
Err(e) => {
tracing::warn!(peer_did = %did, error = %e, "Rejected peer-joined: invalid pubkey");
anyhow::bail!("Invalid pubkey");
}
}
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
let signature = params.get("signature").and_then(|v| v.as_str());
match signature {
@@ -719,16 +703,10 @@ impl RpcHandler {
let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey);
match identity::NodeIdentity::verify(pubkey, sign_data.as_bytes(), sig) {
Ok(true) => {}
Ok(false) => {
_ => {
tracing::warn!(peer_did = %did, "Rejected peer-joined: invalid signature");
anyhow::bail!("Invalid signature");
}
Err(e) => {
// Malformed hex / wrong length — distinguish from a
// genuine mismatch so the log tells us which it was.
tracing::warn!(peer_did = %did, error = %e, "Rejected peer-joined: malformed signature");
anyhow::bail!("Invalid signature");
}
}
}
None => {
@@ -607,81 +607,9 @@ impl RpcHandler {
.unwrap_or("")
.to_string();
// LND returns r_hash base64-encoded; the lookup endpoint the Receive
// flow polls (`lnd.invoicestatus`) wants it hex — hand the UI the
// ready-to-use form.
let r_hash_hex = {
use base64::Engine as _;
body.get("r_hash")
.and_then(|v| v.as_str())
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
.map(hex::encode)
.unwrap_or_default()
};
Ok(serde_json::json!({
"payment_request": payment_request,
"amount_sats": amount_sats,
"r_hash_hex": r_hash_hex,
}))
}
/// lnd.invoicestatus — is this invoice settled yet? Polled by the wallet's
/// Receive flow so a Lightning payment gets the same "money has arrived"
/// success screen as on-chain (minus the broadcast step: settlement is
/// final). Params: `{ "r_hash_hex": string }`.
pub(in crate::api::rpc) async fn handle_lnd_invoicestatus(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let r_hash_hex = params
.get("r_hash_hex")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'r_hash_hex' parameter"))?;
if r_hash_hex.len() != 64 || !r_hash_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("r_hash_hex must be 64 hex characters"));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{r_hash_hex}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to query invoice")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse invoice lookup response")?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Invoice lookup failed: {}", msg));
}
let settled = body
.get("state")
.and_then(|v| v.as_str())
.map(|s| s == "SETTLED")
.unwrap_or_else(|| {
body.get("settled")
.and_then(|v| v.as_bool())
.unwrap_or(false)
});
let amt_paid_sat = body
.get("amt_paid_sat")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.or_else(|| body.get("amt_paid_sat").and_then(|v| v.as_i64()))
.unwrap_or(0);
Ok(serde_json::json!({
"settled": settled,
"amt_paid_sat": amt_paid_sat,
}))
}
@@ -405,17 +405,9 @@ impl RpcHandler {
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let device_type = svc.shared_state().status.read().await.device_type;
// Resource transfer is a native RNS transfer over LoRa — it needs an
// actual radio route to this contact, not just a Reticulum device on
// our end. A federation-only peer with no radio twin fits the size
// and device-type checks but has no dest_prefix to send to; without
// this check the send falls into send_content_resource and fails
// with "Peer is federation-only (no radio twin)" (picture-send,
// 2026-08-07) instead of falling back to the federation path below.
let use_resource_transfer = bytes.len() > INLINE_HARD_MAX
&& device_type == crate::mesh::types::DeviceType::Reticulum
&& bytes.len() <= RETICULUM_RESOURCE_MAX
&& svc.has_radio_route(contact_id).await;
&& bytes.len() <= RETICULUM_RESOURCE_MAX;
if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer {
anyhow::bail!(
@@ -500,58 +492,15 @@ impl RpcHandler {
)
.await?
} else {
// Federation-only peers have no radio twin for
// send_typed_wire's LoRa dest-prefix resolution — route over
// Tor federation instead, mirroring mesh.send-content's onion
// lookup, or the send fails with "Peer is federation-only (no
// radio twin)" (picture-send from a federation-only contact,
// 2026-08-07).
let federation_onion = {
let state = svc.shared_state();
let peers = state.peers.read().await;
peers
.get(&contact_id)
.map(|p| (p.pubkey_hex.clone(), p.did.clone()))
};
let federation_onion = match federation_onion {
Some((Some(pubkey_hex), did)) => {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
nodes
.iter()
.find(|n| n.pubkey == pubkey_hex)
.map(|n| n.onion.clone())
.or_else(|| {
did.as_ref().and_then(|d| {
nodes.iter().find(|n| &n.did == d).map(|n| n.onion.clone())
})
})
}
_ => None,
};
if let Some(onion) = federation_onion {
svc.send_typed_wire_via_federation(
contact_id,
&onion,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
} else {
svc.send_typed_wire(
contact_id,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
}
svc.send_typed_wire(
contact_id,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
}
};
@@ -641,16 +590,6 @@ impl RpcHandler {
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
// A Reticulum device on our end doesn't mean THIS peer is radio
// reachable — a federation-only contact (no radio twin) has no dest
// prefix for a resource transfer, even though it's small enough and
// our device type qualifies. Without this check the frontend was
// steered into mesh.send-content-inline's resource-transfer path,
// which fails with "Peer is federation-only (no radio twin)"
// (picture-send, 2026-08-07); the tier below now defers to the
// has_tor branches for such peers, which route via mesh.send-content
// (federation) instead.
let has_radio_route = is_reticulum && svc.has_radio_route(contact_id).await;
let (tier, reason) = if size <= MESH_AUTO_MAX {
("auto-mesh", "Small enough to send inline over mesh")
} else if size <= MESH_HARD_MAX {
@@ -659,7 +598,7 @@ impl RpcHandler {
} else {
("auto-mesh", "No Tor path — sending inline over mesh")
}
} else if has_radio_route && size <= RETICULUM_RESOURCE_MAX {
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX {
(
"resource-mesh",
"Sending directly over LoRa via a Reticulum resource transfer",
@@ -64,12 +64,6 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"must be",
"cannot",
"Password",
// OTA apply/download errors are all operator-actionable ("download it
// again", "download first") — sanitizing them to "Operation failed"
// left users stuck with no idea what to do, and hid the "already
// running" text the update UI matches on to join an in-flight apply
// instead of showing a false failure. Every such message starts "Update".
"Update",
// The federation escalation sentinel. "Password" above does NOT cover
// it — starts_with is case-sensitive and the sentinel is ALL-CAPS —
// so the frontend's isPasswordRequired() never saw it and the
@@ -172,20 +166,6 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"No pending seed generation",
"Submitted words",
"Already set up",
// Ecash backup phrase — these two ARE the feature's safety rails, and
// masking them made it dangerous rather than merely opaque. "That is
// not a valid BIP-39 recovery phrase… check for typos" is the whole
// help someone gets when a pasted phrase has a bad word; and "This
// wallet already has a backup phrase… reveal and write down the
// current phrase first, then confirm to replace it" is the warning
// that stops an operator orphaning the words their balance was minted
// under. Behind "check server logs" the first is unactionable and the
// second is invisible.
"That is not a valid BIP-39",
"This wallet already has a backup phrase",
"This wallet has no backup phrase yet",
// Restore against a mint that never implemented NUT-09.
"This mint does not support restoring",
];
for prefix in &user_facing_prefixes {
if msg.starts_with(prefix) {
@@ -209,27 +189,6 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
mod sanitize_tests {
use super::sanitize_error_message;
/// The ecash import errors are the feature's safety rails. If the
/// sanitizer eats them, a bad paste gives no hint and — worse — the
/// warning about replacing an established phrase never reaches the person
/// about to do it.
#[test]
fn ecash_backup_phrase_errors_reach_the_operator() {
for msg in [
"That is not a valid BIP-39 recovery phrase: invalid checksum. Check for typos",
"This wallet already has a backup phrase. Importing a different one means coins \
minted under the current phrase will no longer be restorable from words",
"This wallet has no backup phrase yet, so there is nothing to restore from.",
"This mint does not support restoring from a backup phrase (NUT-09).",
] {
let out = sanitize_error_message(msg);
assert_ne!(
out, "Operation failed. Check server logs for details.",
"swallowed: {msg}"
);
}
}
#[test]
fn password_required_sentinel_passes_through_verbatim() {
// The UI machine-reads this sentinel (isPasswordRequired checks

Some files were not shown because too many files have changed in this diff Show More