Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd07da53f9 | ||
|
|
7d09418a09 | ||
|
|
eef35d65b7 | ||
|
|
3b3500a7dd | ||
|
|
2f0f7fd388 | ||
|
|
b300a720db | ||
|
|
5b6d278c46 | ||
|
|
b927461f8e | ||
|
|
699669a5f7 | ||
|
|
8cf45377a2 | ||
|
|
4efac99e97 | ||
|
|
981296e8b0 | ||
|
|
54431fc856 | ||
|
|
22f8129b52 | ||
|
|
57e31eb192 | ||
|
|
12c853da45 | ||
|
|
3409db569e | ||
|
|
d259f3cbb9 | ||
|
|
cb71c25ea0 | ||
|
|
c5eeb31055 | ||
|
|
966db4810a | ||
|
|
51a5473e22 | ||
|
|
1872fc20ee | ||
|
|
cbd463e980 | ||
|
|
9df580bf2b | ||
|
|
aee7ecaac1 | ||
|
|
e51ceaa250 | ||
|
|
7c9559aa57 | ||
|
|
7b88ba59b2 | ||
|
|
b12d1d3826 | ||
|
|
698e915df2 | ||
|
|
a179df66d8 | ||
|
|
771ff0d28b | ||
|
|
92111385b7 | ||
|
|
a9e52fa310 | ||
|
|
b4714f1773 | ||
|
|
d79ca54019 | ||
|
|
758332d63d | ||
|
|
ee5123af68 | ||
|
|
a624d11b6a | ||
|
|
2c984fbd49 | ||
|
|
c188d9de78 | ||
|
|
37a82fd2f9 | ||
|
|
a9a30406df | ||
|
|
f1b5d2d267 | ||
|
|
d6b48ce095 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 47
|
||||
versionName = "0.5.27"
|
||||
versionCode = 48
|
||||
versionName = "0.5.28"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -54,6 +54,15 @@
|
||||
<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),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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,6 +89,38 @@ 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 =
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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,6 +31,8 @@ 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
|
||||
@@ -106,22 +108,62 @@ 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) 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, Backup, Signer)
|
||||
// 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)
|
||||
MenuPanel(
|
||||
servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr,
|
||||
onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty,
|
||||
signerPairUri = pendingSignerPair,
|
||||
onSignerScan = { signerScan = true },
|
||||
onSignerPairHandled = {
|
||||
pendingSignerPair = null
|
||||
onSignerPairHandled()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -138,6 +180,9 @@ 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.
|
||||
@@ -176,9 +221,10 @@ private fun MenuPanel(
|
||||
.widthIn(max = 420.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.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)
|
||||
// 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)
|
||||
.clip(RoundedCornerShape(PANEL_R))
|
||||
.background(PanelBg.copy(alpha = 0.86f))
|
||||
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||
@@ -201,7 +247,13 @@ private fun MenuPanel(
|
||||
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
|
||||
when (page) {
|
||||
HubPage.NODES -> "Nodes"
|
||||
HubPage.FIPS -> "FIPS Mesh"
|
||||
HubPage.BACKUP -> "Backup & Restore"
|
||||
HubPage.SIGNER -> "Remote Signer"
|
||||
HubPage.HUB -> "Menu"
|
||||
},
|
||||
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
||||
)
|
||||
}
|
||||
@@ -233,6 +285,12 @@ 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.
|
||||
|
||||
@@ -272,6 +330,11 @@ 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) },
|
||||
@@ -391,11 +454,23 @@ 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 }
|
||||
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER }
|
||||
|
||||
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
||||
@Composable
|
||||
@@ -582,26 +657,52 @@ 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()
|
||||
.height(ROW_H)
|
||||
// Rows with a second line grow to fit it.
|
||||
.then(if (subtitle == null) Modifier.height(ROW_H) else Modifier.heightIn(min = 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),
|
||||
.padding(horizontal = 16.dp)
|
||||
.then(if (subtitle == null) Modifier else Modifier.padding(vertical = 8.dp)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) BitcoinOrange else labelColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onEdit != null) {
|
||||
Text(
|
||||
"✎",
|
||||
@@ -623,7 +724,7 @@ private fun MenuItem(
|
||||
|
||||
/** Glass text field with centered input text. */
|
||||
@Composable
|
||||
private fun GlassField(
|
||||
internal fun GlassField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
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,6 +22,7 @@ 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
|
||||
@@ -133,27 +134,41 @@ fun AppNavHost(
|
||||
LaunchedEffect(pairUri) {
|
||||
val raw = pairUri ?: return@LaunchedEffect
|
||||
onPairUriConsumed()
|
||||
when (val result = ServerQrParser.parse(raw)) {
|
||||
is PairResult.Success -> {
|
||||
// Pairing implies the app is installed and in use — skip the intro.
|
||||
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://") -> {
|
||||
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 }
|
||||
}
|
||||
SignerLaunch.pendingUri.value = raw
|
||||
navController.navigate(Routes.WEB_VIEW) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Invalid or too-new pairing link — ignore; normal startup continues.
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Invalid or too-new pairing link — ignore; normal startup continues.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ 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
|
||||
@@ -1373,6 +1374,16 @@ 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,
|
||||
@@ -1427,6 +1438,8 @@ 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
|
||||
|
||||
Generated
+369
-1
@@ -12,6 +12,17 @@ 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"
|
||||
@@ -81,17 +92,41 @@ 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]]
|
||||
@@ -124,6 +159,18 @@ 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"
|
||||
@@ -172,6 +219,15 @@ 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"
|
||||
@@ -181,6 +237,15 @@ 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"
|
||||
@@ -200,6 +265,15 @@ 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"
|
||||
@@ -406,6 +480,17 @@ 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"
|
||||
@@ -476,7 +561,7 @@ dependencies = [
|
||||
"libc",
|
||||
"rand 0.10.2",
|
||||
"rtnetlink",
|
||||
"secp256k1",
|
||||
"secp256k1 0.30.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -491,6 +576,15 @@ 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"
|
||||
@@ -676,6 +770,110 @@ 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"
|
||||
@@ -692,6 +890,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -788,6 +987,12 @@ 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"
|
||||
@@ -954,12 +1159,29 @@ 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"
|
||||
@@ -988,6 +1210,15 @@ 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"
|
||||
@@ -1129,6 +1360,15 @@ 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"
|
||||
@@ -1272,6 +1512,12 @@ 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"
|
||||
@@ -1306,6 +1552,17 @@ 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"
|
||||
@@ -1355,6 +1612,16 @@ 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"
|
||||
@@ -1519,6 +1786,24 @@ 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"
|
||||
@@ -1657,6 +1942,35 @@ 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"
|
||||
@@ -1677,12 +1991,66 @@ 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"
|
||||
|
||||
@@ -37,6 +37,35 @@ 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]
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! JNI surface for `com.archipelago.app.fips.FipsNative` — JSON over strings,
|
||||
//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as
|
||||
//! 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
|
||||
//! `{"error": "…"}` so Kotlin never sees a raw exception from native code.
|
||||
|
||||
use std::sync::Once;
|
||||
@@ -127,3 +128,177 @@ 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
|
||||
);
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
//! 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;
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/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}" "$@"
|
||||
@@ -0,0 +1,384 @@
|
||||
#!/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()
|
||||
+45
-5
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.5-alpha (2026-08-30)
|
||||
|
||||
- **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.
|
||||
@@ -291,6 +309,12 @@
|
||||
- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.
|
||||
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.
|
||||
|
||||
## v1.7.107-alpha (2026-07-20)
|
||||
|
||||
- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.
|
||||
- Your node rejoins the mesh faster after an update. Applying this update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried. It now notices the restart and reconnects within seconds.
|
||||
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle — two separate faults that had been failing the build.
|
||||
|
||||
## v1.7.106-alpha (2026-07-20)
|
||||
|
||||
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
|
||||
@@ -670,11 +694,13 @@
|
||||
|
||||
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
|
||||
- Live diagnostics on a fleet node confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
|
||||
- The gap this closes: apps launched through the orchestrator previously skipped the legacy start-time repair path entirely, so the same stale metadata the old flow cleaned up silently broke the new one. Both paths now converge on the same repairs.
|
||||
|
||||
## v1.7.64-alpha (2026-05-18)
|
||||
|
||||
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
|
||||
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
|
||||
- For operators mid-incident this changes the recovery loop: a failed apply can now be retried immediately from the System Update page instead of waiting out a throttle window while a node sits half-updated.
|
||||
|
||||
## v1.7.63-alpha (2026-05-18)
|
||||
|
||||
@@ -784,6 +810,18 @@
|
||||
- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.
|
||||
- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`.
|
||||
|
||||
## v1.7.51-alpha (2026-04-30)
|
||||
|
||||
- Stack installs now adopt containers that already exist instead of failing on them — a repair or reinstall over leftover containers completes, and the adopted container's readiness is waited on like any fresh start.
|
||||
- Failed installs come with evidence: the install path waits for its containers, and when one doesn't become healthy it captures that container's logs, so the error on screen names the real culprit instead of a bare timeout.
|
||||
- Bitcoin RPC bindings are ensured as part of install, and the startup self-heal path gained additional ground for already-deployed nodes.
|
||||
|
||||
## v1.7.50-alpha (2026-04-30)
|
||||
|
||||
- The OTA bridge older nodes needed: deployed binaries only knew how to apply two artifacts (the backend binary and the frontend archive), so the scripts, app specs and docker assets newer releases carry never reached them. This release packs those payloads inside the frontend tarball — the one channel old binaries do apply — and the new backend promotes them into /opt once it starts.
|
||||
- Runtime payloads are staged into timestamped directories and promoted atomically; a failed extraction cleans up its staging area instead of leaving half-written state for the next update to trip over.
|
||||
- This is the release that un-sticks the fleet's update pipeline: from here on, an OTA can carry more than the two artifacts, and app installs on updated nodes use the specs that match their backend.
|
||||
|
||||
## v1.7.49-alpha (2026-04-30)
|
||||
|
||||
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.
|
||||
@@ -795,12 +833,15 @@
|
||||
|
||||
## v1.7.48-alpha (2026-04-29)
|
||||
|
||||
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in.
|
||||
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where that runtime directory wasn't pre-created — the failure surfaced in systemd's mount-namespace setup before the service itself ever ran.
|
||||
- ExecStartPre now creates /run/containers before the service starts, so the node's service manager finds the directory it needs on every boot; ISO installs from this version forward have the fix baked in.
|
||||
- Existing nodes pick the fix up with a one-time `systemctl edit archipelago` adding the mkdir — after which the boot failure does not recur.
|
||||
|
||||
## v1.7.47-alpha (2026-04-29)
|
||||
|
||||
- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed.
|
||||
- ElectrumX initial indexing is faster too. Its CPU cap is removed, container memory is 4GB, and its internal cache is now 3GB (default was 1.2GB).
|
||||
- The result: a fresh node's first hours are measurably shorter — initial block download and ElectrumX indexing were the two longest post-install waits, and both now run at the hardware's limit.
|
||||
|
||||
## v1.7.46-alpha (2026-04-29)
|
||||
|
||||
@@ -823,10 +864,9 @@
|
||||
|
||||
## v1.7.44-alpha (2026-04-28)
|
||||
|
||||
43de3b73 feat(orchestrator): complete container migration and release hardening
|
||||
ce39430b feat(self-update): sync and rebuild UI containers on OTA
|
||||
72dec5aa fix(lnd-ui): align container port across all specs
|
||||
83aacdf2 chore(release): archive ISO build recipes, tarball-only releases
|
||||
- Container orchestration migration completed, with release hardening across the app lifecycle — installs, updates and removals now run through one orchestrator path instead of the split legacy/Podman flows.
|
||||
- OTA updates now rebuild and sync the app UI containers they carry, so an updated app serves the UI image that matches its backend instead of whatever happened to be on disk.
|
||||
- LND UI port handling is aligned across all runtime specs, and release packaging moved to tarball-only payloads with the ISO build recipes archived — update payloads now carry only the files existing nodes need.
|
||||
|
||||
|
||||
All notable changes to Archipelago will be documented in this file.
|
||||
|
||||
@@ -52,13 +52,13 @@
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.3",
|
||||
"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.2",
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
@@ -378,7 +378,7 @@
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"repoUrl": "https://github.com/rhasspy/wyoming"
|
||||
},
|
||||
{
|
||||
@@ -464,7 +464,7 @@
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -571,6 +571,18 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@ 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 +1,7 @@
|
||||
app:
|
||||
id: btcpay-server
|
||||
name: BTCPay Server
|
||||
version: 2.4.2
|
||||
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.
|
||||
@@ -11,7 +11,7 @@ app:
|
||||
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
|
||||
|
||||
container:
|
||||
image: docker.io/btcpayserver/btcpayserver:2.4.2
|
||||
image: docker.io/btcpayserver/btcpayserver:2.4.3
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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
|
||||
@@ -2,6 +2,12 @@ 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
|
||||
|
||||
@@ -2,6 +2,12 @@ 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
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ 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:
|
||||
|
||||
@@ -18,7 +18,7 @@ app:
|
||||
container_name: netbird
|
||||
|
||||
container:
|
||||
image: docker.io/library/nginx:1.31.3-alpine
|
||||
image: docker.io/library/nginx:1.31.4-alpine
|
||||
pull_policy: if-not-present
|
||||
network: netbird-net
|
||||
# Self-signed TLS cert materialised before create — the dashboard needs a
|
||||
|
||||
@@ -6,6 +6,14 @@ 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
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ app:
|
||||
container_name: pine
|
||||
|
||||
container:
|
||||
image: docker.io/library/nginx:1.31.3-alpine
|
||||
image: docker.io/library/nginx:1.31.4-alpine
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
network_aliases: [pine]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: strfry
|
||||
name: Strfry Nostr Relay
|
||||
version: 1.1.1
|
||||
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.
|
||||
@@ -11,7 +11,7 @@ app:
|
||||
description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage.
|
||||
|
||||
container:
|
||||
image: dockurr/strfry:1.1.1
|
||||
image: dockurr/strfry:1.1.2
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.4-alpha"
|
||||
version = "1.8.5-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.4-alpha"
|
||||
version = "1.8.5-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -405,9 +405,17 @@ 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;
|
||||
&& bytes.len() <= RETICULUM_RESOURCE_MAX
|
||||
&& svc.has_radio_route(contact_id).await;
|
||||
|
||||
if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer {
|
||||
anyhow::bail!(
|
||||
@@ -492,15 +500,58 @@ impl RpcHandler {
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
svc.send_typed_wire(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
// 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?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -590,6 +641,16 @@ 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 {
|
||||
@@ -598,7 +659,7 @@ impl RpcHandler {
|
||||
} else {
|
||||
("auto-mesh", "No Tor path — sending inline over mesh")
|
||||
}
|
||||
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX {
|
||||
} else if has_radio_route && size <= RETICULUM_RESOURCE_MAX {
|
||||
(
|
||||
"resource-mesh",
|
||||
"Sending directly over LoRa via a Reticulum resource transfer",
|
||||
|
||||
@@ -365,8 +365,18 @@ impl RpcHandler {
|
||||
// after uninstall. The reconciler owns a manifest map independent of
|
||||
// podman state, so a raw `podman rm` alone is not enough.
|
||||
if let Some(orchestrator) = &self.orchestrator {
|
||||
let mut teardown_errors = Vec::new();
|
||||
for app_id in orchestrator_uninstall_app_ids(package_id) {
|
||||
let _ = orchestrator.remove(&app_id, preserve_data).await;
|
||||
if let Err(err) = orchestrator.remove(&app_id, preserve_data).await {
|
||||
teardown_errors.push(format!("{app_id}: {err:#}"));
|
||||
}
|
||||
}
|
||||
if !teardown_errors.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Uninstall {} aborted: failed to remove declarative app unit(s): {}",
|
||||
package_id,
|
||||
teardown_errors.join("; ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2182,6 +2192,11 @@ mod tests {
|
||||
assert!(!is_missing_container_error("Error: OCI runtime error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_app_uninstall_targets_its_declarative_unit() {
|
||||
assert_eq!(orchestrator_uninstall_app_ids("cuprate"), vec!["cuprate"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_host_ports_are_manifest_derived_for_public_apps() {
|
||||
assert_eq!(runtime_host_ports("photoprism"), vec![2342]);
|
||||
|
||||
@@ -168,7 +168,7 @@ pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
|
||||
/// Read disk usage via `df` for a given path.
|
||||
pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["--block-size=1", "--output=used,size", path])
|
||||
.args(["--block-size=1", "--output=used,size,avail", path])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run df")?;
|
||||
@@ -189,11 +189,22 @@ pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing used"))?
|
||||
.parse()
|
||||
.context("parse df used")?;
|
||||
let total: u64 = parts
|
||||
// Raw `size` includes the filesystem's root-reserved blocks (5% by default
|
||||
// on ext4 — 92 GiB of this node's 1.8 TiB), which nothing can allocate.
|
||||
// Reporting it as capacity told the dashboard there were 251 GiB free when
|
||||
// only 159 GiB were writable. Callers derive free as total - used, so total
|
||||
// must mean "what can actually be used".
|
||||
let _size: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing size"))?
|
||||
.parse()
|
||||
.context("parse df total")?;
|
||||
.context("parse df size")?;
|
||||
let avail: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing avail"))?
|
||||
.parse()
|
||||
.context("parse df avail")?;
|
||||
let total = used.saturating_add(avail);
|
||||
|
||||
Ok((used, total))
|
||||
}
|
||||
|
||||
@@ -4,9 +4,19 @@
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Parse df output into (used_bytes, total_bytes, used_percent).
|
||||
/// Expects output from `df --block-size=1 --output=used,size /` which has a header line
|
||||
/// followed by a data line with two whitespace-separated numbers.
|
||||
/// Parse df output into (used_bytes, usable_total_bytes, used_percent).
|
||||
/// Expects `df --block-size=1 --output=used,size,avail <path>`: a header line
|
||||
/// followed by used, size and avail.
|
||||
///
|
||||
/// `size` is deliberately NOT the denominator. ext4 reserves 5% of the
|
||||
/// filesystem for root — 92 GiB on archi-dev-box's 1.8 TiB disk — which `size`
|
||||
/// counts but no ordinary process can ever allocate. Dividing by `size`
|
||||
/// under-reports usage by about five points: on 2026-08-22 that disk was
|
||||
/// genuinely 90.8% full (159 GiB usable left) while this returned 86.2%, so the
|
||||
/// 90% auto-cleanup below had never once fired and ~72 GB of dangling images
|
||||
/// had accumulated. It also meant the dashboard advertised 251 GiB free when
|
||||
/// only 159 GiB could actually be written. used/(used+avail) is what `df`
|
||||
/// itself prints and what the operator can actually spend.
|
||||
fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
|
||||
let data_line = stdout
|
||||
.lines()
|
||||
@@ -18,11 +28,19 @@ fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing used"))?
|
||||
.parse()
|
||||
.context("parse df used")?;
|
||||
let total: u64 = parts
|
||||
// Parsed to keep the column contract explicit, then intentionally unused —
|
||||
// see the note above on why raw size is the wrong denominator.
|
||||
let _size: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing size"))?
|
||||
.parse()
|
||||
.context("parse df total")?;
|
||||
.context("parse df size")?;
|
||||
let avail: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing avail"))?
|
||||
.parse()
|
||||
.context("parse df avail")?;
|
||||
let total = used.saturating_add(avail);
|
||||
|
||||
let percent = if total > 0 {
|
||||
(used as f64 / total as f64) * 100.0
|
||||
@@ -44,7 +62,7 @@ pub async fn check_disk_usage() -> Result<(u64, u64, f64)> {
|
||||
"/"
|
||||
};
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["--block-size=1", "--output=used,size", data_path])
|
||||
.args(["--block-size=1", "--output=used,size,avail", data_path])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run df")?;
|
||||
@@ -257,8 +275,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_normal() {
|
||||
// Simulates typical df --block-size=1 --output=used,size / output
|
||||
let output = " Used Size\n 500000000000 1000000000000\n";
|
||||
// df --block-size=1 --output=used,size,avail : used, size, avail
|
||||
let output = " Used Size Avail\n 500000000000 1000000000000 500000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 500_000_000_000);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
@@ -267,16 +285,35 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_high_usage() {
|
||||
let output = " Used Size\n 900000000000 1000000000000\n";
|
||||
let output = " Used Size Avail\n 900000000000 1000000000000 100000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 900_000_000_000);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
assert!((percent - 90.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// The bug this function existed to hide: reserved blocks are counted by
|
||||
/// `size` but are not available to anyone. Real numbers from archi-dev-box,
|
||||
/// 2026-08-22 — 1.8 TiB disk, ext4 5% reserve, genuinely 90.8% full. The old
|
||||
/// used/size math returned 86.2%, so the 90% auto-cleanup never triggered.
|
||||
#[test]
|
||||
fn reserved_blocks_are_not_counted_as_free() {
|
||||
let output = "Used Size Avail\n1681459122176 1951249276928 170581372928\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 1_681_459_122_176);
|
||||
// Total is what can actually be written, not the raw device size.
|
||||
assert_eq!(total, 1_852_040_495_104);
|
||||
assert!(
|
||||
total < 1_951_249_276_928,
|
||||
"raw size must not be the denominator"
|
||||
);
|
||||
assert!((percent - 90.8).abs() < 0.1, "got {percent}");
|
||||
assert!(percent >= 90.0, "must cross the auto-cleanup threshold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_almost_full() {
|
||||
let output = "Used Size\n999 1000\n";
|
||||
let output = "Used Size Avail\n999 1000 1\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 999);
|
||||
assert_eq!(total, 1000);
|
||||
@@ -285,7 +322,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_empty_disk() {
|
||||
let output = "Used Size\n0 1000000000000\n";
|
||||
let output = "Used Size Avail\n0 1000000000000 1000000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 0);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
@@ -295,7 +332,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_parse_df_output_zero_total() {
|
||||
// Edge case: total is 0 (should not happen but should not panic/divide-by-zero)
|
||||
let output = "Used Size\n0 0\n";
|
||||
let output = "Used Size Avail\n0 0 0\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 0);
|
||||
assert_eq!(total, 0);
|
||||
@@ -338,21 +375,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_extra_whitespace() {
|
||||
let output = " Used Size \n 123456 7890000 \n";
|
||||
let output = " Used Size Avail \n 123456 7890000 7766544 \n";
|
||||
let (used, total, _) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 123456);
|
||||
assert_eq!(total, 7890000);
|
||||
assert_eq!(total, 7_890_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_real_world_format() {
|
||||
// Closer to real df output with header padding
|
||||
let output = " Used Size\n 328000000000 1800000000000\n";
|
||||
// Real df output carries a reserved-block gap: size here is 1.8 TB but
|
||||
// only 1.382 TB is available, so usable total is used + avail.
|
||||
let output = " Used Size Avail\n 328000000000 1800000000000 1382000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 328_000_000_000);
|
||||
assert_eq!(total, 1_800_000_000_000);
|
||||
// ~18.2%
|
||||
assert!(percent > 18.0 && percent < 19.0);
|
||||
assert_eq!(total, 1_710_000_000_000);
|
||||
// ~19.2% against usable space, not 18.2% against the raw device.
|
||||
assert!(percent > 19.0 && percent < 20.0, "got {percent}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
//! Host-level fixups: OS packages, kernel parameters and system services the
|
||||
//! node needs, delivered by the same signed-binary OTA that ships everything
|
||||
//! else (docs/system-level-ota-design.md).
|
||||
//!
|
||||
//! Scope and posture — read before adding anything here:
|
||||
//!
|
||||
//! * **Idempotent + non-fatal.** Every step is a no-op when the host already
|
||||
//! has the desired state, and a failure (offline box, locked dpkg, missing
|
||||
//! package in the release's Debian suite) logs a warning and moves on. A
|
||||
//! host fixup must never be able to stop the node from starting.
|
||||
//! * **Curated, pinned intent — not dist-upgrade automation.** We deliver the
|
||||
//! specific packages and settings a release deliberately adds (crash
|
||||
//! capture, hardware-error logging, later: unattended-upgrades posture, host
|
||||
//! firewall). Regular Debian upgrades stay with the operator; this channel
|
||||
//! never silently swaps a kernel or a libc.
|
||||
//! * **Fresh installs converge too.** The ISO bakes the same end state in
|
||||
//! (Dockerfile.rootfs, auto-install.sh cmdline), so the fixup is a no-op on
|
||||
//! new machines and only does real work on already-deployed nodes.
|
||||
//! * **Kernel cmdline can't move at runtime.** `crashkernel=` reserves memory
|
||||
//! at boot; the fixup writes GRUB and update-grub so the change lands on the
|
||||
//! next reboot, and says so in the log. Everything else (packages, sysctls,
|
||||
//! services) applies immediately.
|
||||
//!
|
||||
//! First payload (#144, docs/kdump-rasdaemon-design.md): kdump + rasdaemon —
|
||||
//! post-mortem and hardware-error capture:
|
||||
//! * kdump-tools/kexec-tools/rasdaemon installed
|
||||
//! * /etc/default/kdump-tools: USE_KDUMP=1, dumps to /var/crash, compressed
|
||||
//! core collector
|
||||
//! * /etc/sysctl.d/99-archipelago-kdump.conf: a wedged node dumps and
|
||||
//! reboots rather than sitting dead until power-cycled
|
||||
//! * crashkernel=256M appended to the installed GRUB cmdline (next reboot)
|
||||
//! * /var/crash pruned to the two newest dumps
|
||||
//!
|
||||
//! The module is skipped on dev boxes (same guard bootstrap::run uses) and on
|
||||
//! hosts without dpkg.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::update::host_sudo;
|
||||
|
||||
/// Packages the node's host must have. Keep this list short and justified —
|
||||
/// every entry is state we now own on the fleet's OS images.
|
||||
const HOST_PACKAGES: &[&str] = &["kdump-tools", "kexec-tools", "makedumpfile", "rasdaemon"];
|
||||
|
||||
/// Crash-kernel reservation. 256M covers the capture kernel plus makedumpfile
|
||||
/// on the fleet's 16–64GB amd64 machines (~1–2% of RAM, permanently reserved).
|
||||
/// The arm image (RPi) is out of scope for phase 1 — see the design doc.
|
||||
const CRASHKERNEL_PARAM: &str = "crashkernel=256M";
|
||||
|
||||
const KDUMP_SYSDROPIN_PATH: &str = "/etc/sysctl.d/99-archipelago-kdump.conf";
|
||||
const KDUMP_SYSDROPIN: &str = "\
|
||||
# Archipelago kdump policy (#144). A wedged kiosk is useless until someone
|
||||
# power-cycles it — capture the evidence, then reboot by itself. Dumps land in
|
||||
# /var/crash (see docs/kdump-rasdaemon-design.md); keep-2 pruning is done by
|
||||
# the host fixup pass, not a timer.
|
||||
kernel.panic = 10
|
||||
kernel.panic_on_oops = 1
|
||||
kernel.hung_task_panic = 1
|
||||
kernel.hardlockup_panic = 1
|
||||
";
|
||||
|
||||
/// How many dumps to keep in /var/crash. Two ≈ 4 GiB worst case on the 30 GiB
|
||||
/// unencrypted root — the partition usage itself is tracked by disk_monitor.
|
||||
const KEEP_DUMPS: usize = 2;
|
||||
|
||||
/// Entry point, spawned from main.rs at startup like the other ensure_* heals.
|
||||
pub async fn ensure_host_fixups() {
|
||||
// Dev-box guard (same rationale as bootstrap::run): on contributor
|
||||
// machines /home/archipelago/archy is a symlink into a git checkout and
|
||||
// the host is the contributor's own OS — never touch it.
|
||||
let home_archy = std::path::Path::new("/home/archipelago/archy");
|
||||
if tokio::fs::symlink_metadata(home_archy)
|
||||
.await
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
debug!("/home/archipelago/archy is a symlink — skipping host fixups (dev box)");
|
||||
return;
|
||||
}
|
||||
// Non-Debian hosts: nothing we manage here applies.
|
||||
if tokio::fs::symlink_metadata("/usr/bin/dpkg").await.is_err() {
|
||||
debug!("no dpkg on this host — skipping host fixups");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = run_host_fixups().await {
|
||||
warn!("host fixups failed (non-fatal): {:#}", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_host_fixups() -> Result<()> {
|
||||
// 1. Packages — install only what's missing; a locked/offline apt must
|
||||
// never block anything downstream (steps below degrade to no-ops).
|
||||
match ensure_packages().await {
|
||||
Ok(true) => info!("host fixups: installed missing packages"),
|
||||
Ok(false) => debug!("host fixups: all packages present"),
|
||||
Err(e) => warn!("host fixups: package install failed (non-fatal): {:#}", e),
|
||||
}
|
||||
|
||||
// 2. kdump config + sysctl drop-in + GRUB cmdline + services. One helper
|
||||
// per concern so a failure in one logs and leaves the others running.
|
||||
if let Err(e) = ensure_kdump_sysdropin().await {
|
||||
warn!(
|
||||
"host fixups: kdump sysctl drop-in failed (non-fatal): {:#}",
|
||||
e
|
||||
);
|
||||
}
|
||||
if let Err(e) = ensure_kdump_defaults().await {
|
||||
warn!(
|
||||
"host fixups: kdump-tools config failed (non-fatal): {:#}",
|
||||
e
|
||||
);
|
||||
}
|
||||
match ensure_crashkernel_cmdline().await? {
|
||||
true => {
|
||||
warn!("host fixups: crashkernel= written to GRUB — takes effect on the NEXT reboot")
|
||||
}
|
||||
false => debug!("host fixups: crashkernel already in GRUB cmdline"),
|
||||
}
|
||||
if let Err(e) = ensure_rasdaemon_enabled().await {
|
||||
warn!("host fixups: rasdaemon enable failed (non-fatal): {:#}", e);
|
||||
}
|
||||
if let Err(e) = prune_crash_dumps().await {
|
||||
debug!("host fixups: /var/crash prune skipped: {:#}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True if any package was installed. Mirrors the polkit repair's apt posture:
|
||||
/// install without `apt-get update` first; only if that fails (fresh suite,
|
||||
/// stale index), update once and retry. Both under timeout, both non-fatal.
|
||||
async fn ensure_packages() -> Result<bool> {
|
||||
// Package names are a fixed internal allowlist. Do not embed shell quote
|
||||
// characters in WANTED: quotes produced by variable expansion are data,
|
||||
// so dpkg-query would look for a package literally named 'kdump-tools'.
|
||||
let wanted = HOST_PACKAGES.join(" ");
|
||||
let script = format!(
|
||||
r#"
|
||||
set -u
|
||||
WANTED="{wanted}"
|
||||
MISSING=""
|
||||
for p in $WANTED; do
|
||||
dpkg-query -W -f='${{Status}}' "$p" 2>/dev/null | grep -q 'install ok installed' || MISSING="$MISSING $p"
|
||||
done
|
||||
[ -z "$MISSING" ] && exit 0
|
||||
timeout 240 apt-get install -y --no-install-recommends $MISSING >/dev/null 2>&1 \
|
||||
|| timeout 240 sh -c 'apt-get update >/dev/null 2>&1 && apt-get install -y --no-install-recommends $MISSING >/dev/null 2>&1' \
|
||||
|| exit 3
|
||||
exit 2
|
||||
"#
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("install host packages")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => Ok(true),
|
||||
code => anyhow::bail!("host package install exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the sysctl drop-in and apply it live (these four keys are all
|
||||
/// runtime-settable, so the hang/panic policy takes effect without a reboot).
|
||||
async fn ensure_kdump_sysdropin() -> Result<()> {
|
||||
let script = format!(
|
||||
r#"
|
||||
set -u
|
||||
PATH_FILE='{KDUMP_SYSDROPIN_PATH}'
|
||||
CONTENT_FILE=/tmp/archy-kdump-sysctl.$$.tmp
|
||||
cat > "$CONTENT_FILE" <<'SYSEOF'
|
||||
{KDUMP_SYSDROPIN}SYSEOF
|
||||
if [ -f "$PATH_FILE" ] && cmp -s "$CONTENT_FILE" "$PATH_FILE"; then
|
||||
rm -f "$CONTENT_FILE"
|
||||
exit 0
|
||||
fi
|
||||
mv "$CONTENT_FILE" "$PATH_FILE"
|
||||
chmod 644 "$PATH_FILE"
|
||||
sysctl --system >/dev/null 2>&1 || true
|
||||
exit 2
|
||||
"#
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("write kdump sysctl drop-in")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(()),
|
||||
Some(2) => {
|
||||
info!("host fixups: installed {KDUMP_SYSDROPIN_PATH} (hang/panic policy)");
|
||||
Ok(())
|
||||
}
|
||||
code => anyhow::bail!("kdump sysctl drop-in exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Point kdump-tools at /var/crash with a compressed core collector. Works on
|
||||
/// the package's shipped defaults file (USE_KDUMP=0, commented KDUMP_COREDIR)
|
||||
/// and on any state we already wrote — pure line surgery, idempotent.
|
||||
fn kdump_defaults_script(conf: &str) -> String {
|
||||
r#"
|
||||
set -u
|
||||
CONF='@@CONF@@'
|
||||
[ -f "$CONF" ] || exit 3
|
||||
CHANGED=0
|
||||
# Remove the one malformed line emitted by the old systemd-run environment
|
||||
# expansion bug before it was disabled. It makes every kdump-config invocation
|
||||
# print an error while sourcing this file.
|
||||
if grep -Fqx '=""' "$CONF"; then
|
||||
sed -i '/^=""$/d' "$CONF"
|
||||
CHANGED=1
|
||||
fi
|
||||
set_kv() {
|
||||
# Canonicalise KEY to one double-quoted assignment. Older fixup versions
|
||||
# could append duplicates because their exact-value check did not accept
|
||||
# double quotes; collapsing them also makes future passes idempotent.
|
||||
KEY="$1"; VAL="$2"
|
||||
EXPECTED="${KEY}=\"${VAL}\""
|
||||
COUNT=$(grep -c "^${KEY}=" "$CONF" 2>/dev/null || true)
|
||||
if [ "$COUNT" -eq 1 ] && grep -Fqx "$EXPECTED" "$CONF"; then
|
||||
return
|
||||
fi
|
||||
sed -i "/^${KEY}=/d" "$CONF"
|
||||
printf '\n%s\n' "$EXPECTED" >> "$CONF"
|
||||
CHANGED=1
|
||||
}
|
||||
set_kv USE_KDUMP 1
|
||||
set_kv KDUMP_COREDIR /var/crash
|
||||
set_kv CORE_COLLECTOR 'makedumpfile -l --message-level 1 -d 31'
|
||||
[ "$CHANGED" -eq 1 ] || exit 0
|
||||
systemctl enable kdump-tools >/dev/null 2>&1 || true
|
||||
exit 2
|
||||
"#
|
||||
.replace("@@CONF@@", conf)
|
||||
}
|
||||
|
||||
async fn ensure_kdump_defaults() -> Result<()> {
|
||||
let script = kdump_defaults_script("/etc/default/kdump-tools");
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("configure kdump-tools")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(()),
|
||||
Some(2) => {
|
||||
info!("host fixups: kdump-tools configured (USE_KDUMP=1, /var/crash)");
|
||||
Ok(())
|
||||
}
|
||||
code => anyhow::bail!("kdump-tools config exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the installed GRUB cmdline to one fixed `crashkernel=` reservation and
|
||||
/// run update-grub. Debian's kdump-tools package installs a grub.d snippet that
|
||||
/// otherwise appends its own range-based reservation after ours; on amd64 that
|
||||
/// silently wins and reserves only 192M instead of the intended 256M.
|
||||
/// The reservation itself only exists after the next reboot — memory cannot
|
||||
/// be set aside at runtime — so the caller must log the reboot caveat.
|
||||
/// Returns true if the generated cmdline changed.
|
||||
async fn ensure_crashkernel_cmdline() -> Result<bool> {
|
||||
let script = format!(
|
||||
r#"
|
||||
set -u
|
||||
GRUB=/etc/default/grub
|
||||
KDUMP_GRUB=/etc/default/grub.d/kdump-tools.cfg
|
||||
PARAM='{CRASHKERNEL_PARAM}'
|
||||
[ -f "$GRUB" ] || exit 3
|
||||
CHANGED=0
|
||||
# kdump-tools sources this after /etc/default/grub and unconditionally appends
|
||||
# crashkernel=512M-:192M. Neutralize that package default: Archipelago owns the
|
||||
# explicit fixed reservation in GRUB_CMDLINE_LINUX_DEFAULT below.
|
||||
if [ -f "$KDUMP_GRUB" ] && grep -qE '^[^#]*crashkernel=' "$KDUMP_GRUB"; then
|
||||
printf '%s\n' '# Archipelago owns crashkernel sizing in /etc/default/grub.' > "$KDUMP_GRUB"
|
||||
CHANGED=1
|
||||
fi
|
||||
LINE=$(grep -E '^GRUB_CMDLINE_LINUX_DEFAULT=' "$GRUB" | head -1)
|
||||
[ -n "$LINE" ] || exit 3
|
||||
# Remove any prior value before appending ours, so repeated fixups can never
|
||||
# create conflicting parameters whose kernel precedence is easy to misread.
|
||||
NEWLINE=$(printf '%s' "$LINE" | sed -E "s/[[:space:]]+crashkernel=[^ \"']+//g; s/\"$/ $PARAM\"/")
|
||||
if [ "$NEWLINE" != "$LINE" ]; then
|
||||
sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|$NEWLINE|" "$GRUB"
|
||||
CHANGED=1
|
||||
fi
|
||||
[ "$CHANGED" -eq 1 ] || exit 0
|
||||
timeout 120 update-grub >/dev/null 2>&1 || true
|
||||
exit 2
|
||||
"#
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("set crashkernel= in GRUB")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => Ok(true),
|
||||
code => anyhow::bail!("crashkernel cmdline fixup exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_rasdaemon_enabled() -> Result<()> {
|
||||
let status = host_sudo(&["systemctl", "enable", "--now", "rasdaemon"])
|
||||
.await
|
||||
.context("enable rasdaemon")?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("systemctl enable --now rasdaemon exited with {status}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only the newest [`KEEP_DUMPS`] dumps in /var/crash. Called on every
|
||||
/// fixup pass rather than by a timer: the pass runs at every startup, which is
|
||||
/// exactly the cadence at which new dumps appear (a dump ends in a reboot).
|
||||
fn crash_dump_prune_script() -> String {
|
||||
format!(
|
||||
r#"
|
||||
set -u
|
||||
DIR=${{ARCHIPELAGO_CRASH_DIR:-/var/crash}}
|
||||
[ -d "$DIR" ] || exit 0
|
||||
KEEP={KEEP_DUMPS}
|
||||
# kdump-tools keeps its lock and kexec command files beside timestamped dump
|
||||
# directories. Count and prune directories only: treating those bookkeeping
|
||||
# files as dumps can delete the sole freshly captured vmcore on startup.
|
||||
COUNT=$(find "$DIR" -mindepth 1 -maxdepth 1 -type d -printf . | wc -c)
|
||||
[ "$COUNT" -gt "$KEEP" ] || exit 0
|
||||
find "$DIR" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\0' \
|
||||
| sort -zrn \
|
||||
| tail -z -n +"$((KEEP + 1))" \
|
||||
| cut -z -d ' ' -f 2- \
|
||||
| xargs -0r rm -rf --
|
||||
exit 2
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
async fn prune_crash_dumps() -> Result<()> {
|
||||
let script = crash_dump_prune_script();
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("prune /var/crash")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(()),
|
||||
Some(2) => {
|
||||
info!("host fixups: pruned old dumps in /var/crash (keep {KEEP_DUMPS})");
|
||||
Ok(())
|
||||
}
|
||||
code => anyhow::bail!("/var/crash prune exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sysctl_dropin_carries_the_full_hang_capture_policy() {
|
||||
for key in [
|
||||
"kernel.panic = 10",
|
||||
"kernel.panic_on_oops = 1",
|
||||
"kernel.hung_task_panic = 1",
|
||||
"kernel.hardlockup_panic = 1",
|
||||
] {
|
||||
assert!(KDUMP_SYSDROPIN.contains(key), "drop-in missing {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_list_is_exactly_the_kdump_rasdaemon_set() {
|
||||
assert_eq!(
|
||||
HOST_PACKAGES,
|
||||
&["kdump-tools", "kexec-tools", "makedumpfile", "rasdaemon"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashkernel_param_is_sized_and_unprefixed() {
|
||||
assert_eq!(CRASHKERNEL_PARAM, "crashkernel=256M");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_dumps_is_two() {
|
||||
assert_eq!(KEEP_DUMPS, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kdump_defaults_repairs_old_malformed_line_and_is_idempotent() {
|
||||
use std::{fs, process::Command};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let conf = root.path().join("kdump-tools");
|
||||
let bin = root.path().join("bin");
|
||||
fs::create_dir(&bin).unwrap();
|
||||
fs::write(bin.join("systemctl"), "#!/bin/sh\nexit 0\n").unwrap();
|
||||
assert!(Command::new("chmod")
|
||||
.args(["+x"])
|
||||
.arg(bin.join("systemctl"))
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
fs::write(
|
||||
&conf,
|
||||
"# package defaults\n=\"\"\nUSE_KDUMP=0\nUSE_KDUMP=\"1\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let script = kdump_defaults_script(conf.to_str().unwrap());
|
||||
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap());
|
||||
let first = Command::new("sh")
|
||||
.args(["-lc", &script])
|
||||
.env("PATH", &path)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert_eq!(first.code(), Some(2));
|
||||
let repaired = fs::read_to_string(&conf).unwrap();
|
||||
assert!(!repaired.lines().any(|line| line == "=\"\""));
|
||||
assert_eq!(repaired.matches("USE_KDUMP=").count(), 1);
|
||||
assert!(repaired.contains("USE_KDUMP=\"1\""));
|
||||
assert!(repaired.contains("KDUMP_COREDIR=\"/var/crash\""));
|
||||
assert!(repaired.contains("CORE_COLLECTOR=\"makedumpfile -l --message-level 1 -d 31\""));
|
||||
|
||||
let second = Command::new("sh")
|
||||
.args(["-lc", &script])
|
||||
.env("PATH", path)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(second.success());
|
||||
assert_eq!(fs::read_to_string(conf).unwrap(), repaired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_pruning_ignores_kdump_bookkeeping_files() {
|
||||
use std::{fs, process::Command};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let crash = root.path();
|
||||
fs::write(crash.join("kdump_lock"), []).unwrap();
|
||||
fs::write(crash.join("kexec_cmd"), "kexec -p").unwrap();
|
||||
|
||||
for (name, epoch) in [("old dump", "100"), ("middle", "200"), ("newest", "300")] {
|
||||
let path = crash.join(name);
|
||||
fs::create_dir(&path).unwrap();
|
||||
fs::write(path.join("vmcore"), name).unwrap();
|
||||
assert!(Command::new("touch")
|
||||
.args(["-d", &format!("@{epoch}")])
|
||||
.arg(&path)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
}
|
||||
|
||||
let status = Command::new("sh")
|
||||
.args(["-lc", &crash_dump_prune_script()])
|
||||
.env("ARCHIPELAGO_CRASH_DIR", crash)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert_eq!(status.code(), Some(2));
|
||||
assert!(!crash.join("old dump").exists());
|
||||
assert!(crash.join("middle").join("vmcore").exists());
|
||||
assert!(crash.join("newest").join("vmcore").exists());
|
||||
assert!(crash.join("kdump_lock").exists());
|
||||
assert!(crash.join("kexec_cmd").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_pruning_does_nothing_when_only_bookkeeping_files_exist() {
|
||||
use std::{fs, process::Command};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for name in ["kdump_lock", "kexec_cmd", "another-marker"] {
|
||||
fs::write(root.path().join(name), []).unwrap();
|
||||
}
|
||||
let status = Command::new("sh")
|
||||
.args(["-lc", &crash_dump_prune_script()])
|
||||
.env("ARCHIPELAGO_CRASH_DIR", root.path())
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 3);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ mod entropy;
|
||||
mod federation;
|
||||
mod fips;
|
||||
mod health_monitor;
|
||||
mod host_fixups;
|
||||
mod host_ip;
|
||||
mod identity;
|
||||
mod identity_manager;
|
||||
@@ -435,6 +436,12 @@ async fn main() -> Result<()> {
|
||||
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
|
||||
tokio::spawn(bootstrap::ensure_gamepad_keys());
|
||||
|
||||
// Host-level fixups (#144 + docs/system-level-ota-design.md): kdump +
|
||||
// rasdaemon — crash/hardware-error capture delivered to already-deployed
|
||||
// nodes over the signed binary OTA. Idempotent, non-fatal, background;
|
||||
// the crashkernel= GRUB edit lands on the next reboot.
|
||||
tokio::spawn(host_fixups::ensure_host_fixups());
|
||||
|
||||
// Mesh access: mirror IPv4-published app ports onto [::] so direct-port
|
||||
// app URLs (http://[<fips0 ULA>]:<port>) work from the companion.
|
||||
tokio::spawn(mesh_ports::run_mesh_port_mirror());
|
||||
|
||||
@@ -1222,6 +1222,19 @@ impl MeshService {
|
||||
Ok(dest_prefix)
|
||||
}
|
||||
|
||||
/// True if `contact_id` is reachable over the mesh radio right now — the
|
||||
/// same peer/twin resolution `peer_dest_prefix` performs, exposed as a
|
||||
/// cheap bool so RPC handlers can gate radio-only transports (LXMF
|
||||
/// native image, Reticulum resource transfer) without duplicating the
|
||||
/// twin-resolution logic. A federation-only contact_id with no matching
|
||||
/// radio twin returns false here — offering "resource-mesh" or native
|
||||
/// image to such a peer sends it straight into `peer_dest_prefix`'s
|
||||
/// "federation-only (no radio twin)" error (picture-send from a
|
||||
/// federation-only contact, 2026-08-07).
|
||||
pub async fn has_radio_route(&self, contact_id: u32) -> bool {
|
||||
self.peer_dest_prefix(contact_id).await.is_ok()
|
||||
}
|
||||
|
||||
/// Split an oversized wire payload into MC-framed base64 chunks and send
|
||||
/// each via the mesh device. Matches the receive-side reassembly in
|
||||
/// `mesh/listener/decode.rs::handle_chunked_frame` (header `MCIIXXTT`,
|
||||
|
||||
@@ -1487,6 +1487,11 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"--pipe",
|
||||
// Shell snippets passed as one argument must reach the child intact.
|
||||
// systemd-run otherwise expands $VAR/${VAR} against the manager's
|
||||
// environment before `sh -lc` can see them (and usually replaces them
|
||||
// with empty strings).
|
||||
"--expand-environment=no",
|
||||
"--",
|
||||
];
|
||||
full.extend_from_slice(args);
|
||||
@@ -1506,6 +1511,7 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"--pipe",
|
||||
"--expand-environment=no",
|
||||
"--",
|
||||
];
|
||||
full.extend_from_slice(args);
|
||||
|
||||
@@ -1746,6 +1746,15 @@ app:
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
|
||||
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
|
||||
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
|
||||
// upstream's own safe-for-public
|
||||
// subset that wallets connect to directly as a "remote node" over
|
||||
// plain HTTP JSON-RPC — same reasoning as electrumx's 50001).
|
||||
// cuprate's unrestricted RPC (full node control) stays loopback-only
|
||||
// (auth: local), not in this set.
|
||||
//
|
||||
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a
|
||||
// loopback-only JSON API whose own generated http password
|
||||
// authenticates every request (added with the phoenixd onboarding,
|
||||
@@ -1762,7 +1771,7 @@ app:
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
26,
|
||||
28,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# HANDOFF — companion-agent work queue (2026-08-30)
|
||||
|
||||
**For: the companion agent.** Compiled from the 2026-08-30 issue-triage
|
||||
session. The tracker now labels the companion-owned issues `companion-agent`
|
||||
(#128, #139); this document adds the pointers and one small residual that
|
||||
isn't worth its own issue until it's being fixed.
|
||||
|
||||
## Pointers
|
||||
|
||||
- **App source:** `Android/` in this repo (Kotlin/Gradle). Release notes
|
||||
live in `Android/COMPANION_RELEASE.md`.
|
||||
- **Served artifact:** `neode-ui/public/packages/archipelago-companion.apk`
|
||||
+ `archipelago-companion.json` (currently **0.5.27 / versionCode 47**).
|
||||
Shipping a companion change means refreshing both in the same commit
|
||||
(versionCode +1) plus a COMPANION_RELEASE.md entry; nodes serve the file
|
||||
from the web bundle. The deploy/verify pipeline (aapt badging, size
|
||||
checks, node redeploy) is documented in
|
||||
`docs/HANDOFF-2026-07-23-companion-apk-deploy.md`.
|
||||
- **Web bridge:** `window.ArchipelagoNative` (JS interface the WebView
|
||||
injects); `isCompanionApp()` in `neode-ui/src/utils/openExternal.ts` is
|
||||
the canonical detection helper; `appLauncher.ts` shows the gating pattern.
|
||||
|
||||
## Work queue
|
||||
|
||||
### 1. Residual of #61 — companion-gate the store banner + intro overlay (small)
|
||||
|
||||
What #61 fixed was the AUTO-popup: `CompanionIntroOverlay` skips its
|
||||
mounted auto-show when `IN_COMPANION_APP` (the `ArchipelagoNative` bridge
|
||||
is present). Two paths are still ungated, so a user already inside the
|
||||
companion WebView still gets "install the companion" pitches:
|
||||
|
||||
- `<CompanionBanner />` in `neode-ui/src/views/Discover.vue:156` renders
|
||||
unconditionally.
|
||||
- `openCompanionIntro()` (`neode-ui/src/composables/useCompanionIntro.ts`)
|
||||
is an explicit trigger that intentionally bypasses the once-per-browser
|
||||
gate — but nothing companion-checks its callers.
|
||||
|
||||
Fix: gate the banner render and the intro-trigger entry points on
|
||||
`isCompanionApp()`, same pattern as `appLauncher.ts` (lines ~236/~341).
|
||||
Verify inside the companion WebView (banner absent, no manual path can pop
|
||||
the overlay). Land it in the web UI here; the APK doesn't change.
|
||||
|
||||
### 2. #128 — GrapheneOS phone backup & restore (feature)
|
||||
|
||||
Reporter's problem: losing your phone, or wiping it to cross a border.
|
||||
Reporter's suggestion: "part of the companion app or passport prime combo".
|
||||
The companion owns the phone side: trigger a GrapheneOS backup, transport
|
||||
it, and restore it onto a wiped device — coordinated with the node's
|
||||
existing encrypted-backup envelope (ADR-005: ChaCha20-Poly1305 +
|
||||
Argon2id, `core/archipelago/src/backup.rs`). **Reuse that envelope; do not
|
||||
invent a second backup format.** Node-side storage/quota/scheduling is
|
||||
tracked separately on the roadmap — coordinate before assuming node-side
|
||||
surface beyond the existing backup RPCs.
|
||||
|
||||
### 3. #139 — Nostr Bunker: companion-side remote signer (feature)
|
||||
|
||||
"Remote signer with companion app?" — the phone side of NIP-46: a bunker
|
||||
client in the companion (pairing with a node-side bunker service via
|
||||
QR/URI, a signature approve/deny UX that makes what's being signed legible,
|
||||
and saved-remote-bunker management). Background research already exists:
|
||||
`docs/nostr-signer-login-research.md`. The node-side bunker hosting is
|
||||
roadmap-tracked separately; this issue's companion label covers the
|
||||
phone-side integration.
|
||||
|
||||
## Working rules (same as the node repo)
|
||||
|
||||
- Small commits, pushed immediately; vitest for web-side changes; the
|
||||
Kotlin app's on-device flows get verified on a real device before the
|
||||
APK ships.
|
||||
- Node-side Rust changes are out of scope for the companion queue —
|
||||
anything that needs them goes through the labeled issues on the tracker.
|
||||
- Done = artifact refreshed (APK + json meta) so a web-bundle deploy can
|
||||
serve it, plus the issue updated with what shipped.
|
||||
@@ -0,0 +1,116 @@
|
||||
# HANDOFF — SSH over the FIPS mesh (node-side toggle), 2026-08-31
|
||||
|
||||
**For: the node OS agent.** From the companion agent, mid-0.5.28 testing. The
|
||||
user wants to SSH their node from Termux over the phone's FIPS mesh instead
|
||||
of keeping Tailscale around for it — the phone side is done and verified; the
|
||||
remaining work is all node-side, and it wants to be a **first-class settings
|
||||
toggle**, not a hand-edited firewall rule.
|
||||
|
||||
## What already works (do not rebuild this)
|
||||
|
||||
- The companion's embedded mesh is a **device-wide split tunnel**
|
||||
(`ArchyVpnService` routes `fd00::/8` for the whole phone, no per-app
|
||||
filter, `allowBypass`). Termux — or any app — reaches mesh addresses with
|
||||
zero setup while the tunnel is up, on-LAN and away (anchor path).
|
||||
- The hub's Nodes page now **displays and copies each FIPS node's `fips0`
|
||||
ULA** (committed on `companion/0.5.28`).
|
||||
- Verified live today: `ssh user@<node-ULA>` from Termux answers **RST** —
|
||||
the path works end-to-end; something on the node is doing the refusing.
|
||||
|
||||
## The diagnosis (from today's field test + code read)
|
||||
|
||||
1. **`fips0` is default-deny inbound.** The hardening baseline
|
||||
(`/etc/fips/fips.nft`, provisioned out-of-band) rejects un-allowlisted
|
||||
ports with RST — the exact symptom the web-UI drop-in's comment documents
|
||||
on :80 (`core/archipelago/src/fips/config.rs` ~L237). The daemon's own
|
||||
drop-ins (`/etc/fips/fips.d/80-web-ui.nft`: 80/8443/5679,
|
||||
`85-app-ports.nft`: app launch ports) **do not include 22**.
|
||||
2. **sshd IPv6 listening is unverified.** `fips0` is IPv6-only; a sshd pinned
|
||||
to `ListenAddress 0.0.0.0` RSTs on the ULA identically. The image installs
|
||||
and enables openssh-server (`image-recipe/archipelago-scripts/install-to-disk.sh`
|
||||
L177/L210) with default config (binds `::`), but a preflight in the toggle
|
||||
should confirm rather than assume.
|
||||
|
||||
**Interim manual unblock (what the user can do today, keep valid):**
|
||||
`/etc/fips/fips.d/90-ssh.nft` containing `ip6 saddr <phone-ULA> tcp dport 22
|
||||
accept`, then `sudo nft -f /etc/fips/fips.nft`. A daemon-owned toggle must
|
||||
**own that file name/lifecycle** so a hand-added rule and the feature don't
|
||||
fight over the same slot.
|
||||
|
||||
## The ask: a "SSH over mesh" toggle
|
||||
|
||||
The user's instinct (seconded here): **a setting in the FIPS/network area of
|
||||
the node UI**, default **off**. Sketch:
|
||||
|
||||
- **UI**: a small settings card in the pattern of
|
||||
`neode-ui/src/views/settings/` (see `TransportPrefsCard.vue` for a
|
||||
segmented-pref card + vitest). Toggle + a source-scope selector +
|
||||
preflight status rows.
|
||||
- **RPC**: `fips.ssh-over-mesh.get` / `fips.ssh-over-mesh.set` (dispatch arm
|
||||
in `core/archipelago/src/api/rpc/dispatcher.rs` alongside the existing
|
||||
`fips.*` arms at ~L544; handler in `api/rpc/fips.rs`). Persisted with the
|
||||
other fips daemon-config state.
|
||||
- **Enforcement**: mirror the existing drop-in lifecycle in
|
||||
`core/archipelago/src/fips/config.rs` (~L243–320): when the toggle is on,
|
||||
write `/etc/fips/fips.d/90-ssh.nft` on every daemon config install and on
|
||||
toggle change; when off, remove it. Reload stays
|
||||
`sudo nft -f /etc/fips/fips.nft`. Never touch `80-web-ui.nft` /
|
||||
`85-app-ports.nft`.
|
||||
- **Source scope** (the design decision worth an issue thread):
|
||||
- *Paired phones only* — restricts to the phone ULAs/npubs the node has
|
||||
actually paired with. Open question: does the node durably know which
|
||||
inbound peers are "its" phones? FIPS accepts inbound peers without prior
|
||||
registration, so this may need a small persisted "trusted peers" list
|
||||
(seeded when `fips.pair-info` is issued, or on first successful dial).
|
||||
Recommended default if the data can be made reliable.
|
||||
- *Custom source list* — raw ULA list, per-rule `ip6 saddr <ula> …`
|
||||
entries. Escape hatch; fine to ship alongside.
|
||||
- *Any mesh peer* — what the user literally asked for, but flag it
|
||||
honestly in the UI: with no registration requirement, this faces port 22
|
||||
at every peer that can route to the node over the mesh. If offered at
|
||||
all, gate it behind the same "I understand" confirmation pattern as
|
||||
other danger-zone settings.
|
||||
- **Preflights, surfaced in the card**: sshd enabled + listening on IPv6
|
||||
(`[::]:22` or `*:22` via `ss -tln`), and whether
|
||||
`PasswordAuthentication` is on — if it is, show a keys-only recommendation
|
||||
(the firewall restriction is the belt; this is the suspenders).
|
||||
|
||||
## Acceptance (on-device)
|
||||
|
||||
- [ ] Toggle on, phone on LAN: `ssh user@<node-ULA>` from Termux connects.
|
||||
- [ ] Phone away from LAN (anchor path): same result.
|
||||
- [ ] Toggle off: connection refused again; `90-ssh.nft` gone.
|
||||
- [ ] Daemon config install (upgrade/restart) preserves the on-state and
|
||||
the rule; nothing duplicated.
|
||||
- [ ] Non-default source scope actually restricts (try from a second mesh
|
||||
peer, or a wrong ULA).
|
||||
- [ ] Settings UI survives a page reload; RPC has a vitest like
|
||||
`TransportPrefsCard.test.ts`.
|
||||
|
||||
## Addendum (2026-08-31, same day): the npub IS the address
|
||||
|
||||
While wiring this up we confirmed the mesh ULA is a **pure function of the
|
||||
public key** — `fd ‖ sha256(x-only pubkey)[0..15]` (`fips/src/identity/node_addr.rs`
|
||||
`from_pubkey` → `identity/address.rs` `from_node_addr`,
|
||||
`FIPS_ADDRESS_PREFIX = 0xfd`). The daemon's DNS resolver (`fips/dial.rs`) just
|
||||
answers what anyone can compute. Consequences for the node side:
|
||||
|
||||
- Docs/UI can advertise `ssh <user>@npub1…`-style addressing: Termux's
|
||||
`Android/tools/fipssh` (shipped with the companion work) derives the ULA
|
||||
from the npub with zero infrastructure, verified byte-identical against
|
||||
the fips crate (`archy-fips-core` test
|
||||
`npub_derives_the_same_mesh_ula_as_the_fips_identity`).
|
||||
- If the settings toggle from this handover ever grows a "copy command"
|
||||
affordance, `fipssh <user>@<npub>` is the natural shape (npub, not ULA —
|
||||
it is the durable identity; the ULA follows from it).
|
||||
- No node-side DNS surface is required for the SSH case; the resolver stays
|
||||
what it is today (the node's own peer dials).
|
||||
|
||||
## Working rules
|
||||
|
||||
Same as the queue handoffs: small commits, tracker issue for this feature
|
||||
(`ssh-over-mesh`), and the companion agent is downstream-only here — no
|
||||
companion changes are required (the phone already routes and displays the
|
||||
ULA). Optional nicety later, NOT part of this issue: the companion's FIPS
|
||||
hub page could one day surface the toggle state — only worth it if the
|
||||
`fips.ssh-over-mesh.get` RPC is trivial to add to the existing status call.
|
||||
@@ -54,6 +54,9 @@ step-by-step guides, and some predate the current implementation.
|
||||
- [Dual Ecash](dual-ecash-design.md)
|
||||
- [Hardware Signer](hardware-signer-design.md)
|
||||
- [Manifest Hooks](manifest-hooks-design.md)
|
||||
- [Peering & Federation Trust](peering-trust-model.md) — naming/semantics of trust levels vs discovery (#134)
|
||||
- [kdump + rasdaemon Troubleshooting](kdump-rasdaemon-design.md) — post-mortem and hardware-error capture on nodes (#144)
|
||||
- [System-Level OTA](system-level-ota-design.md) — how host-level packages/config reach already-deployed nodes
|
||||
- [Meshroller Integration](meshroller-integration-design.md)
|
||||
- [Nostr Git Source Hosting](nostr-git-source-hosting.md)
|
||||
- [Nostr Identity Import](nostr-identity-import-plan.md) · [Nostr Signer Login (research)](nostr-signer-login-research.md)
|
||||
@@ -86,4 +89,5 @@ file.
|
||||
## Roadmap & history
|
||||
|
||||
- [Roadmap](ROADMAP.md) — where the project is going
|
||||
- [TODO](TODO.md) — working backlog of unscoped forward-looking items
|
||||
- [archive/](archive/README.md) — superseded design and status documents, kept for provenance
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
# Release Notes Backlog
|
||||
|
||||
## Next Release Required Work
|
||||
## Required Work — completed 2026-08-30, before the v1.8.5-alpha cut
|
||||
|
||||
- Backfill missing or thin historical release notes before cutting the next release.
|
||||
- Audit every `CHANGELOG.md` section from `v1.7.44-alpha` through the current release.
|
||||
- Replace raw commit-hash entries with user/operator-facing bullets that explain behavior changes, operational impact, validation, and known limitations.
|
||||
- Ensure `releases/manifest.json` changelog entries come from curated `CHANGELOG.md` notes only.
|
||||
- [x] Backfill missing or thin historical release notes before cutting the next release.
|
||||
Eight sections backfilled, sourced from the Settings "What's New" blocks,
|
||||
the old-lineage release commits, and the diffs of the self-contained
|
||||
hotfix releases: **v1.7.44** (was raw commit-hash lines), **v1.7.47,
|
||||
v1.7.48, v1.7.64, v1.7.65** (were thin), and **v1.7.50, v1.7.51,
|
||||
v1.7.107** (sections were missing entirely — real releases with tags but
|
||||
no changelog section; v1.7.107 was restored verbatim from the curated
|
||||
version that existed at `35e9c624` and was later lost). The What's New
|
||||
modal blocks for the three restored versions were generated by
|
||||
`scripts/sync-whats-new.py`, which now passes with all 92 versions.
|
||||
- [x] Audit every `CHANGELOG.md` section from `v1.7.44-alpha` through the
|
||||
current release. Mechanical inventory of all 92 sections in range:
|
||||
every section carries ≥3 curated bullets, zero raw commit-hash entries.
|
||||
- [x] Replace raw commit-hash entries with user/operator-facing bullets
|
||||
that explain behavior changes, operational impact, validation, and
|
||||
known limitations. The only offender was v1.7.44 (four raw hash lines,
|
||||
now curated).
|
||||
- [x] Ensure `releases/manifest.json` changelog entries come from curated
|
||||
`CHANGELOG.md` notes only. Satisfied by construction:
|
||||
`create-release-manifest.sh` reads the changelog from `CHANGELOG.md`,
|
||||
and `check-release-manifest.sh` rejects manifests with fewer than three
|
||||
bullets or raw git-log lines before publishing.
|
||||
|
||||
## Release Note Policy
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# TODO
|
||||
|
||||
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
||||
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||
|
||||
## Dev & build process (priority)
|
||||
|
||||
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
||||
builds, PR/issue flow, branch naming, and reproducible builds.
|
||||
|
||||
## Federation & peering
|
||||
|
||||
- Peering trust model — define tiers (trusted / public / private / peered)
|
||||
on top of the existing federation DID trust levels.
|
||||
- Federation architecture built on the above peering model.
|
||||
|
||||
## Distributed git & OTA
|
||||
|
||||
- Nostr-hosted git for the alpha (see
|
||||
[`nostr-git-source-hosting.md`](nostr-git-source-hosting.md)).
|
||||
- Distributed git beyond the nostr-hosting case.
|
||||
- Distributed OTA / app delivery.
|
||||
|
||||
## Nostr integration
|
||||
|
||||
- Nostr signer integration.
|
||||
|
||||
## Platform / OS
|
||||
|
||||
- Source-availability ISO — define the build/distribution story.
|
||||
- HW/OS update pipeline.
|
||||
- Deeper OpenWRT integration.
|
||||
- GrapheneOS integration — backups, attestation, profiles.
|
||||
|
||||
## App ecosystem
|
||||
|
||||
- Full pass testing every app in the catalog; expect issues across the board.
|
||||
- App update strategy — finalize the update policy referenced in
|
||||
[`app-developer-guide.md`](app-developer-guide.md) (pinned vs. mutable
|
||||
tags, catalog-vs-disk precedence, rollout/rollback).
|
||||
- App wishlist — candidates not yet packaged: Cashu wallet, phoenixd.
|
||||
(CLN is already shipped as `apps/core-lightning`.)
|
||||
|
||||
## Access & security
|
||||
|
||||
- SSH access strategy — define the access model (keys, rotation, recovery
|
||||
path, remote-support access).
|
||||
|
||||
## Observability
|
||||
|
||||
- Capture error logs to troubleshoot customer issues.
|
||||
- Stats & visualization for traffic, blocked attacks, VPNs, routing.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Companion backup & restore — the phone side of a border crossing (#128)
|
||||
|
||||
**Status:** shipped in companion 0.5.28 (vc48). Issue: #128 ("Graphene phone
|
||||
backup/restore — part of the companion app or passport prime combo").
|
||||
|
||||
## The problem
|
||||
|
||||
The companion holds real secrets: node addresses and login passwords, the
|
||||
phone's FIPS mesh identity (which nodes peer with), and — since 0.5.28 — the
|
||||
remote-signer key. Losing the phone, or wiping it to cross a border, loses all
|
||||
of it. On GrapheneOS there is no cloud backup and there should be none here
|
||||
either: the export is a plain file the user saves wherever they choose (USB
|
||||
drive, computer, a folder synced their way), sealed with a passphrase.
|
||||
|
||||
## The envelope — the node's, not a second format
|
||||
|
||||
Backups use the node's ADR-005 encrypted-backup envelope
|
||||
(`core/archipelago/src/backup/identity.rs`), byte-for-byte:
|
||||
|
||||
- Argon2id key derivation (RustCrypto `argon2`, default params — same as the
|
||||
node's `Argon2::default()`), passphrase in, 16-byte random salt.
|
||||
- ChaCha20-Poly1305 AEAD with a 12-byte random nonce.
|
||||
- Envelope JSON:
|
||||
`{"version": 1, "kind": "companion", "encrypted": true, "blob": "<base64(salt‖nonce‖ct)>", "timestamp": "<rfc3339>"}`
|
||||
- The native code (`Android/rust/archy-fips-core/src/backup.rs`) is the same
|
||||
crate family as the node's backup code; `decrypt` ignores unknown envelope
|
||||
fields, so a **node** identity backup (which carries `did`/`pubkey`/`kid`)
|
||||
also decrypts here — one envelope, two producers.
|
||||
|
||||
The encrypted payload is the companion's own JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"app": "archipelago-companion",
|
||||
"payloadVersion": 1,
|
||||
"appVersion": "0.5.28",
|
||||
"createdAt": 1725100000,
|
||||
"servers": ["<serialized ServerEntry>", …],
|
||||
"active": "<serialized ServerEntry or null>",
|
||||
"fips": {"secret","npub","address","peers","partyPeers","partyName","partyListen"},
|
||||
"signer": {"secret": "<hex>"},
|
||||
"flags": {"introSeen": true}
|
||||
}
|
||||
```
|
||||
|
||||
## Where the code lives
|
||||
|
||||
- **Crypto:** `Android/rust/archy-fips-core/src/backup.rs` (+ JNI
|
||||
`NativeCore.backupEncrypt/Decrypt`). Host `cargo test` covers round-trip,
|
||||
wrong-passphrase, tampered-blob, node-shape envelopes, and salt/nonce
|
||||
freshness.
|
||||
- **Payload/merge:** `BackupManager` (`Android/app/src/main/java/com/archipelago/app/data/BackupManager.kt`).
|
||||
- **UI:** a hub sub-page (`ui/components/BackupSection.kt`, opened from the
|
||||
three-finger hub menu like Nodes/FIPS) — SAF file picker
|
||||
(`CreateDocument` for export, `OpenDocument` for import), passphrase
|
||||
fields, verified-backup preview, result summary. The suggested export
|
||||
name is `archy-companion-backup-YYYYMMDD-HHmmss.json`.
|
||||
|
||||
## Restore semantics — never silently destructive
|
||||
|
||||
| What | On restore |
|
||||
|---|---|
|
||||
| Servers | Upsert (`ServerPreferences.upsertServer`): same npub merges (even when every address changed), new ones append |
|
||||
| Active server | Set only when this phone has none (the fresh-wipe case) |
|
||||
| FIPS identity | Restored only when this phone has none; node peers UNION by npub (`FipsPreferences.mergePeersJson`); party peers merge by npub |
|
||||
| Signer key | Restored only when this phone has none |
|
||||
| introSeen flag | Restored (no re-onboarding after a restore) |
|
||||
|
||||
The identity rules exist because a phone that already paired has a live mesh
|
||||
identity nodes peer with; swapping it in from a backup would strand the
|
||||
current pairing.
|
||||
|
||||
## Test checklist (on-device)
|
||||
|
||||
- [ ] Export → file saved, `version: 1`, `kind: companion`, base64 blob ≥ 44 chars.
|
||||
- [ ] Wrong passphrase on import → "wrong passphrase" error, no state change.
|
||||
- [ ] Correct passphrase → preview shows the right server count; restore on a
|
||||
second install (or after clearing app data) reconnects to the node
|
||||
without re-pairing, mesh included.
|
||||
- [ ] Re-scan the node's QR after restore → no duplicate entry.
|
||||
- [ ] The old phone's password for a node restores (login works on the new phone).
|
||||
|
||||
## Roadmap notes (node-side, tracked separately)
|
||||
|
||||
Node-side storage/quota/scheduling for companion backups ("passport prime
|
||||
combo") is roadmap territory — this issue's scope was the phone side. The
|
||||
envelope is ready to be a drop-in for the node's existing backup RPCs when
|
||||
that lands.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Companion NIP-46 remote signer — the phone side of Nostr Bunker (#139)
|
||||
|
||||
**Status:** shipped in companion 0.5.28 (vc48). Issue: #139 ("Remote signer
|
||||
with companion app?"). Background research:
|
||||
[`nostr-signer-login-research.md`](nostr-signer-login-research.md) — flow B of
|
||||
that document is exactly the flow this implements, with the companion playing
|
||||
the role the research assigned to Amber.
|
||||
|
||||
## What shipped: the phone IS the bunker (remote signer)
|
||||
|
||||
The companion holds a nostr key (generate or import an `nsec`) and speaks
|
||||
NIP-46 as the **remote signer**:
|
||||
|
||||
1. A NIP-46 client — the node's login page, per the research doc's flow B,
|
||||
or any `nostrconnect://`-emitting app — shows its pairing QR.
|
||||
2. The phone scans it (hub → **Remote Signer** → *Scan pairing QR*), or any
|
||||
QR-scanner app hands the `nostrconnect://` URI over as a deep link
|
||||
(registered in the manifest).
|
||||
3. The phone connects to the client's relay(s), subscribes to kind-24133
|
||||
events p-tagged to its own key, and sends the `connect` request carrying
|
||||
the secret — the same handshake direction rust-nostr's reference bunker
|
||||
uses (`NostrConnectRemoteSigner::send_connect_ack`), which is what the
|
||||
node's eventual nostr-connect client will wait for.
|
||||
4. Requests arrive NIP-44-encrypted. Handled methods:
|
||||
- `connect` → "ack" (validates our pubkey + the pairing secret)
|
||||
- `get_public_key` → our pubkey
|
||||
- `describe` → method list
|
||||
- `ping` → "pong"
|
||||
- **`sign_event` → an approve/deny card — kind label, content, tags,
|
||||
time. Nothing signs without a thumb on Approve.** Deny replies
|
||||
`"denied"`; a second request while one is pending replies `"busy"`
|
||||
instead of replacing the visible card.
|
||||
- anything else → `"not authorized"` (nip04/nip44 encrypt/decrypt are
|
||||
deliberately NOT granted in v1).
|
||||
5. Responses go back over the same encrypted kind-24133 channel.
|
||||
|
||||
The session lives while the app does (the login handshake takes seconds);
|
||||
remembered-session auto-reconnect is the research doc's deferred flow C, and
|
||||
stays deferred. NIP-04 is accepted on receive as a fallback (deprecated but
|
||||
still spoken by real clients); all sending is NIP-44 v2.
|
||||
|
||||
## Where the code lives
|
||||
|
||||
- **Crypto:** `Android/rust/archy-fips-core/src/nostr.rs` — nsec/npub bech32
|
||||
keys, BIP-340 schnorr event signing (NIP-01 id serialization), NIP-44 v2
|
||||
payloads, NIP-04 fallback, `nostrconnect://` parsing. Host `cargo test`
|
||||
runs the official NIP-44 vectors (conversation/message keys, padded
|
||||
lengths, byte-exact encrypt vectors), the official BIP-340 sign vectors,
|
||||
and round-trip/tamper/failure cases.
|
||||
- **JNI:** `com.archipelago.app.NativeCore` (same .so as the FIPS mesh).
|
||||
- **Session:** `nostr/BunkerManager.kt` — OkHttp WebSocket relay client,
|
||||
JSON-RPC dispatch, approve/deny state.
|
||||
- **UI:** a hub sub-page (`ui/components/SignerSection.kt`, opened from the
|
||||
three-finger hub menu like Nodes/FIPS) — key setup, npub/nsec display,
|
||||
pairing scan, session status, the approve/deny card. The full-screen
|
||||
pairing scanner (`QrGlassModal`) is hosted by NESMenu so it isn't clipped
|
||||
to the panel's bounds. The `nostrconnect://` deep link routes to the
|
||||
session and pops the hub open on the signer sub-page (`SignerLaunch`).
|
||||
|
||||
## Security notes (conscious deviations, reviewed)
|
||||
|
||||
- Incoming events are **not** signature-verified before decryption — the
|
||||
same choice rust-nostr's reference bunker makes. The NIP-44 MAC is the
|
||||
actual gate: forging content that decrypts with a valid MAC requires one
|
||||
of the two conversation secrets. A future hardening pass may add event
|
||||
verification first.
|
||||
- The signer secret lives in app-private DataStore (same storage model as
|
||||
the FIPS secret and node login passwords). It can additionally be sealed
|
||||
inside an encrypted backup (see
|
||||
[`companion-backup-restore.md`](companion-backup-restore.md)).
|
||||
- `sign_event` approval is per-request and per-screen; there is no
|
||||
"remember this client" auto-approve in v1.
|
||||
|
||||
## End-to-end test harness (the node side doesn't exist yet)
|
||||
|
||||
`Android/tools/nip46-test-client.py` plays the node's role: generates the
|
||||
pairing QR in your terminal, runs the full handshake, requests
|
||||
`get_public_key` + `sign_event`, and verifies the returned signature with an
|
||||
independent pure-Python BIP-340 implementation (no code shared with the
|
||||
phone's Rust core; both are pinned to the same official test vectors).
|
||||
|
||||
```bash
|
||||
python3 -m venv /tmp/nip46env
|
||||
/tmp/nip46env/bin/pip install websockets qrcode
|
||||
/tmp/nip46env/bin/python Android/tools/nip46-test-client.py # --relay to override
|
||||
```
|
||||
|
||||
Then on the phone: hub → Remote Signer → Generate key (once) → Scan pairing
|
||||
QR → point at the terminal QR → Approve the incoming request. The harness
|
||||
prints `END-TO-END PASS` when the phone-signed event verifies.
|
||||
|
||||
## Test checklist (on-device)
|
||||
|
||||
- [ ] Generate key → npub shows, copy works; import nsec → same npub.
|
||||
- [ ] Harness handshake: pair → ack → `get_public_key` returns the phone's npub.
|
||||
- [ ] `sign_event` request shows a legible card (kind label, content, tags);
|
||||
Approve → harness verifies the schnorr signature; Deny → harness sees
|
||||
`"denied"`.
|
||||
- [ ] Deep link: open a `nostrconnect://…` URI from a QR app → SignerScreen
|
||||
with the pairing already starting.
|
||||
- [ ] Wrong/foreign QR → clear error, no state change.
|
||||
|
||||
## Roadmap (node-side, tracked separately)
|
||||
|
||||
The node-side bunker hosting/login flow (research doc flows A+B, the
|
||||
`auth.login.nostr` slot, relay topology on the node's own strfry) is roadmap
|
||||
territory via the `companion-agent`-labeled tracker issues; when it ships,
|
||||
the phone side here already speaks its language.
|
||||
@@ -88,29 +88,29 @@ proprietary and Play-Services-backed.
|
||||
|
||||
## Integration sketch
|
||||
|
||||
> ⚠️ Coordinates and API surface below are from memory and were **not**
|
||||
> verified against Maven Central — the machine this was written on had no
|
||||
> network. Confirm the current artifact version and wrapper API on the first
|
||||
> online Gradle sync before trusting the snippet.
|
||||
> Verified 2026-08-31 against Maven Central and the wrapper source
|
||||
> (`wrappers/android/zxingcpp/src/main/java/zxingcpp/BarcodeReader.kt` at
|
||||
> `io.github.zxing-cpp:android:3.1.1`, the current release). Coordinates and
|
||||
> API below are what the published artifact actually ships.
|
||||
|
||||
`Android/app/build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
// Replaces com.google.zxing:core for the live-camera path.
|
||||
implementation("io.github.zxing-cpp:android:<pin-exact-version>")
|
||||
implementation("io.github.zxing-cpp:android:3.1.1")
|
||||
```
|
||||
|
||||
`QrCodeAnalyzer` collapses to roughly:
|
||||
|
||||
```kotlin
|
||||
private val reader = BarcodeReader().apply {
|
||||
private val reader = BarcodeReader(
|
||||
options = BarcodeReader.Options(
|
||||
formats = setOf(BarcodeFormat.QR_CODE),
|
||||
formats = setOf(BarcodeReader.Format.QR_CODE),
|
||||
tryHarder = true,
|
||||
tryRotate = true,
|
||||
tryInvert = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
try {
|
||||
@@ -121,6 +121,15 @@ override fun analyze(image: ImageProxy) {
|
||||
}
|
||||
```
|
||||
|
||||
API notes from the published wrapper: `BarcodeReader.read(ImageProxy)` takes
|
||||
the CameraX `YUV_420_888` frame directly (it reads the Y plane + cropRect +
|
||||
rotation itself — the manual crop/copy machinery really can go); options are
|
||||
one constructor-argument data class; `Format.QR_CODE` is nested inside
|
||||
`BarcodeReader` (not a top-level `BarcodeFormat`); results carry `text`,
|
||||
`contentType`, `position` — and `lastReadTime` gives the per-call decode time
|
||||
in ms, useful to measure the claimed 5–10× while evaluating. Keep
|
||||
`com.google.zxing:core` for the still-image path regardless (below).
|
||||
|
||||
Keep `com.google.zxing:core` for now regardless: the still-image path
|
||||
(`decodeQrFromUri` in `WalletQrScannerModal.kt`, used by "Upload image") and
|
||||
`prewarmQrScanner` both use it, and neither is on the hot path.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# kdump + rasdaemon — post-mortem and hardware-error capture (#144)
|
||||
|
||||
Status: IMPLEMENTED (phase 1) — decisions approved 2026-08-30: hang capture ON,
|
||||
crashkernel=256M, ship the backfill with this release, phase-2 UI deferred.
|
||||
Delivery: image-recipe (Dockerfile.rootfs, auto-install.sh cmdline) +
|
||||
`core/archipelago/src/host_fixups.rs` (existing nodes, see
|
||||
docs/system-level-ota-design.md) + `tests/lifecycle/os-audit.sh` section D.
|
||||
Owner: node image (image-recipe) + lifecycle gate
|
||||
Issue: #144 — "Configure kdump and rasdaemon for troubleshooting"
|
||||
|
||||
## The problem
|
||||
|
||||
When a fleet node hard-locks or a memory stick starts failing, today we get
|
||||
nothing: a frozen kiosk is power-cycled and the evidence is gone; a DIMM
|
||||
throwing correctable ECC errors for weeks is invisible until it starts
|
||||
corrupting things. Two standard kernel mechanisms capture this evidence:
|
||||
|
||||
- **kdump** — reserves a small crash kernel at boot; on a kernel panic (or,
|
||||
configured so, a hang) the running kernel hands the machine over to the
|
||||
crash kernel, which writes a compressed dump of memory to disk and
|
||||
reboots. The node comes back by itself *and* leaves a post-mortem.
|
||||
- **rasdaemon** — a userspace daemon that records hardware error events
|
||||
(correctable/uncorrectable ECC per DIMM, PCIe AER) from EDAC/sysfs into a
|
||||
sqlite database: persistent evidence of degrading hardware with no crash
|
||||
required.
|
||||
|
||||
## Facts the design rests on
|
||||
|
||||
- Installed-disk layout (auto-install.sh): BIOS boot 1MiB · EFI 512MiB ·
|
||||
**root ext4 30GiB, unencrypted** · data (rest, LUKS).
|
||||
- The data partition is LUKS and unlocked late by the node itself — the
|
||||
crash kernel must never be asked to handle key material.
|
||||
- The installed system's kernel command line is written by
|
||||
auto-install.sh:1810 (`GRUB_CMDLINE_LINUX_DEFAULT="quiet splash …"`).
|
||||
- Packages land via `Dockerfile.rootfs` (trixie) with `systemctl enable`
|
||||
in the same RUN block (nginx/tor/avahi pattern).
|
||||
- Kernel cmdline cannot be changed by OTA — it lives in GRUB. Existing
|
||||
nodes need a backfill step (bootstrap) plus a deliberate reboot.
|
||||
|
||||
## Design
|
||||
|
||||
### kdump
|
||||
|
||||
- **Packages:** `kdump-tools kexec-tools` added to Dockerfile.rootfs.
|
||||
- **Command line:** append `crashkernel=256M` to
|
||||
`GRUB_CMDLINE_LINUX_DEFAULT` in auto-install.sh. 256M covers the capture
|
||||
kernel plus makedumpfile on the fleet's 16–64GB amd64 machines (~1–2% of
|
||||
RAM reserved, permanently). The arm image (RPi, config.txt boot) is out
|
||||
of scope for phase 1.
|
||||
- **Dump target:** `local filesystem /var/crash` — on the unencrypted 30GiB
|
||||
root, deliberately *not* the encrypted data partition. No key handling
|
||||
in the crash initramfs, no dependency on the node's own unlock logic.
|
||||
- **Core collector:** `makedumpfile -l --message-level 1 -d 31`
|
||||
(compressed, zero/free pages excluded) — a dump lands at roughly 5–15%
|
||||
of RAM, i.e. ~1–2 GiB on a 16 GiB machine.
|
||||
- **Retention:** keep the **2 newest** dumps only. A small systemd timer
|
||||
(or kdump-tools' `KDUMP_POST_SCRIPT`) prunes older vmcores; a full root
|
||||
partition is already caught by disk_monitor's usage tracking. Two dumps
|
||||
≈ 4 GiB worst case on 30 GiB root — safe.
|
||||
- **When to dump — the deliberate trade-off (decision needed):**
|
||||
- Baseline: dump on real panics (`kernel.panic` path) — no behavioral
|
||||
change to a wedged node.
|
||||
- Recommended for this fleet: also enable hang capture
|
||||
(`kernel.hung_task_panic=1`, hardlockup via NMI watchdog). A kiosk
|
||||
that hard-locks is useless until power-cycled anyway; converting the
|
||||
hang into "dump + automatic reboot" turns every freeze into evidence
|
||||
*and* self-heals the node. Cost: a genuinely-busy-but-alive machine
|
||||
that trips the watchdog reboots — the threshold is kernel-default
|
||||
conservative (40s), so this should be rare.
|
||||
|
||||
### rasdaemon
|
||||
|
||||
- **Packages:** `rasdaemon`; `systemctl enable rasdaemon` in the
|
||||
Dockerfile.rootfs enable block (same pattern as nginx).
|
||||
- **Storage:** its default sqlite DB at
|
||||
`/var/lib/rasdaemon/ras-mc_event.db` on the unencrypted root.
|
||||
- **Human access today:** `ras-mc-ctl --summary` / `--errors` over SSH.
|
||||
No UI in phase 1.
|
||||
|
||||
### Surfacing (phase 2 — separate follow-up, not in this cut)
|
||||
|
||||
A small read-only `system.diagnostics` surface: last-crash timestamp and
|
||||
vmcore sizes from `/var/crash`, plus ECC error totals per DIMM from the
|
||||
rasdaemon DB — shown in Settings → System. Deliberately deferred: capture
|
||||
first, UI once there is something to show and a node in the fleet has
|
||||
actually produced a dump.
|
||||
|
||||
### Existing nodes (phase 1.5 backfill)
|
||||
|
||||
The OTA cannot change the bootloader. Bootstrap (which already delivers
|
||||
fixes to existing nodes) appends `crashkernel=256M` (and the chosen
|
||||
panic/hang params) to `/etc/default/grub` on machines that don't have it,
|
||||
and enables `rasdaemon` via the node's package install path. **Takes
|
||||
effect on the next reboot** — the operator reboots nodes when applying the
|
||||
release; no special ceremony needed beyond that.
|
||||
|
||||
## Testing
|
||||
|
||||
- Image: the new packages appear in the ISO; QEMU boot smoke
|
||||
(build-iso-release.sh stage 5) still green.
|
||||
- Lifecycle gate additions (bats, archi-dev-box first): `kdump-config show`
|
||||
reports a loaded crash kernel reservation; `systemctl is-active
|
||||
rasdaemon`; `/etc/default/grub` carries `crashkernel=`.
|
||||
- Live drill (once, on archi-dev-box, not in the gate): trigger
|
||||
`sysrq c` → vmcore appears in `/var/crash`, node reboots itself,
|
||||
second boot is clean. Keep this manual — it reboots the box.
|
||||
|
||||
## Implementation touchpoints
|
||||
|
||||
1. `image-recipe/build/auto-installer/Dockerfile.rootfs` — packages +
|
||||
`systemctl enable rasdaemon`.
|
||||
2. `image-recipe/build/auto-installer/installer-iso/archipelago/auto-install.sh:1810`
|
||||
— append `crashkernel=256M` (+ hang params if approved) to
|
||||
`GRUB_CMDLINE_LINUX_DEFAULT`.
|
||||
3. `kdump-tools` config: `/etc/default/kdump-tools` (dump target
|
||||
`/var/crash`, core_collector line, `KDUMP_POST_SCRIPT` or timer for
|
||||
retention).
|
||||
4. Bootstrap backfill for existing nodes.
|
||||
5. `tests/lifecycle` — presence assertions (crash kernel reserved,
|
||||
rasdaemon active).
|
||||
|
||||
## Decisions needed before implementation
|
||||
|
||||
1. **Hang capture on or off?** Recommended ON (`hung_task_panic=1` +
|
||||
NMI watchdog): every hard lockup becomes a dump + self-reboot. OFF
|
||||
means dumps only on true panics; wedged nodes still need the button.
|
||||
2. **crashkernel=256M vs 320M** — 256M is the common default for
|
||||
16–64GB machines; 320M if we expect large io-heavy kernels.
|
||||
3. **Backfill now or new-installs-only?** Recommended: ship the backfill
|
||||
with the next release so the whole fleet gains capture on reboot.
|
||||
4. Phase-2 UI surfacing scope — confirm "later" so phase 1 stays small.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Peering & Federation Trust — naming and semantics
|
||||
|
||||
Status: TERMINOLOGY SET — records what the code does today (#134).
|
||||
Deferred: the "don't advertise my peers" opt-out (see §Open questions).
|
||||
|
||||
The code is the authority; this doc gives names to the four concepts that
|
||||
issue #134 showed get conflated in conversation. Where a name changed in
|
||||
user-facing discussion, the term below is the one to use everywhere
|
||||
(UI copy, docs, issues, reviews).
|
||||
|
||||
## The four concepts
|
||||
|
||||
| Term (use this) | What it is | Where it lives |
|
||||
|---|---|---|
|
||||
| **Trusted peer** | A node THIS operator invited and verified: bilateral DID challenge over an out-of-band invite code (`federation::sync`, ADR-007). The only level that grants full access. | `TrustLevel::Trusted`, set via `TrustSource::Invite` or `Manual` |
|
||||
| **Discovered peer** | A peer we learned about from a Trusted peer's advertised list — the transitive merge. Never better than **Observer**: `TRUST IS NOT TRANSITIVE` (sync.rs guard). | `TrustLevel::Observer`, `TrustSource::TransitiveMerge` |
|
||||
| **Routing hint** | What a Discovered peer actually contributes: an address that lets us route directly over FIPS without a second invite hop. Reachability, not trust. | Observer-level sync + FIPS endpoint records |
|
||||
| **Peer advertisement** | The act of a Trusted peer sharing its own peer list during sync. This is the *mechanism* #134 observed — a feature, not a leak. | sync.rs merge path |
|
||||
|
||||
## The two rules that make it sound
|
||||
|
||||
1. **Trust requires an operator decision, always traceable.** Every trust
|
||||
level carries a `TrustSource`. Only a minted invite (or an explicit
|
||||
operator change) can produce `Trusted`; uninvited joins and transitive
|
||||
merges are hard-capped at `Observer` — a peer can never expand our
|
||||
trusted set on its own authority.
|
||||
2. **Discovery is transitive; trust is not.** Seeing more nodes through a
|
||||
Trusted peer is expected and useful (routing). Granting those nodes
|
||||
anything is an operator action, never automatic.
|
||||
|
||||
## Why a Trusted peer advertising its list is by design
|
||||
|
||||
Without advertisement, every new node needs a direct invite from every node
|
||||
that wants to reach it — the invite graph becomes the routing bottleneck
|
||||
AdDR-007 set out to remove. With it, one invite makes a node *reachable* to
|
||||
the trusted set (routing hints), while *authorization* still requires each
|
||||
operator's own invite. Reachability ≠ access.
|
||||
|
||||
## Open questions (deferred, tracked in #134)
|
||||
|
||||
- **"Don't advertise my peers"** — an operator privacy toggle suppressing
|
||||
peer advertisement during sync. Small code change, real design questions:
|
||||
it hides peers who may WANT discovery, and it degrades the routing benefit
|
||||
for every node trusting you. Needs a product decision, not just code.
|
||||
- **Tier vocabulary in the UI** — whether to surface "Observer" as such or
|
||||
a friendlier term ("Connected"/"Visible") — part of the TODO.md peering
|
||||
trust-model item.
|
||||
@@ -0,0 +1,82 @@
|
||||
# System-Level OTA — host fixups
|
||||
|
||||
Status: Implemented (first payload shipped alongside this doc)
|
||||
Owner: `core/archipelago/src/host_fixups.rs`
|
||||
Related: docs/kdump-rasdaemon-design.md (first payload), CLAUDE.md invariants
|
||||
|
||||
## The problem
|
||||
|
||||
The binary OTA updates the node's own software, and the signed app catalog
|
||||
updates apps. But the **host OS** — Debian packages, kernel parameters,
|
||||
system services — previously moved only through ISO re-installs. A node
|
||||
deployed a year ago can be running today's node software on a host that
|
||||
never gained anything the image learned since. Issue #99 (missing polkit
|
||||
rule on old nodes) and the audio-stack heal were each hand-carved
|
||||
one-off bootstrap repairs; there was no general channel and no stated
|
||||
policy for touching the host from the node.
|
||||
|
||||
## The mechanism
|
||||
|
||||
`host_fixups::ensure_host_fixups()` — spawned from `main.rs` at startup
|
||||
alongside the other `ensure_*` heals, in the background, best-effort:
|
||||
|
||||
1. **Dev-box guard** — skip when `/home/archipelago/archy` is a symlink
|
||||
(contributor checkout) and when there's no dpkg (non-Debian host).
|
||||
2. **Packages** — install only what's missing, from a curated, in-code
|
||||
list (`HOST_PACKAGES`), `apt-get install` first, one `apt-get update`
|
||||
retry, both under timeout, never fatal (offline/locked-dpkg nodes
|
||||
converge on a later boot).
|
||||
3. **Configuration** — idempotent per-concern helpers writing root-owned
|
||||
config (via the existing `host_sudo` path): sysctl drop-ins, service
|
||||
defaults, GRUB cmdline, service enablement.
|
||||
4. **Reporting** — every step logs what it did; failures log warnings and
|
||||
move on. A host fixup must never be able to stop the node from starting.
|
||||
|
||||
### Why embedded-in-the-binary rather than fetched
|
||||
|
||||
Same reasoning as the tor-helper (`bootstrap.rs`): the signed binary OTA
|
||||
is the only authenticated delivery channel every node already trusts and
|
||||
pulls on schedule. Fixups compiled into the binary travel with a version,
|
||||
are reviewable in git, and can't be served to a subset of the fleet.
|
||||
|
||||
## Policy — what may travel this channel
|
||||
|
||||
| May | May not |
|
||||
|---|---|
|
||||
| Specific, pinned packages the node needs (kdump-tools, rasdaemon, …) | `dist-upgrade` or silent kernel/libc swaps — regular Debian upgrades stay with the operator |
|
||||
| Kernel *parameters* via GRUB/sysctl — with the next-reboot caveat logged loudly | Anything requiring a secret, or touching LUKS key material |
|
||||
| Service enablement + config the image also bakes in | Divergence: the ISO must converge to the SAME end state so fresh installs are a no-op |
|
||||
| Small, reviewable, per-concern Rust functions with tests | Shell-script-of-things payloads beyond a single concern |
|
||||
|
||||
The rule: **the ISO and the fixup must express the same intent twice,
|
||||
in reviewable places** — Dockerfile.rootfs/auto-install.sh for fresh
|
||||
installs, `host_fixups.rs` for the deployed fleet. A change that lands in
|
||||
one and not the other is a bug.
|
||||
|
||||
## Kernel cmdline caveat
|
||||
|
||||
`crashkernel=` (and any future `hugepages=`-style reservation) only takes
|
||||
effect at boot: the fixup writes `/etc/default/grub` + `update-grub` and
|
||||
logs `takes effect on the NEXT reboot`. Operators reboot nodes when
|
||||
applying releases; no special ceremony is required beyond that, but the
|
||||
lifecycle gate grades this state honestly (WARN for written-but-not-yet-
|
||||
rebooted, FAIL for never-written — see `tests/lifecycle/os-audit.sh`
|
||||
section D).
|
||||
|
||||
## Verification story
|
||||
|
||||
- Unit tests pin the policy constants and script shapes
|
||||
(`host_fixups` tests in `core/archipelago`).
|
||||
- `tests/lifecycle/os-audit.sh` section D asserts the end state on a real
|
||||
node (config present, crashkernel reserved or pending reboot, hang
|
||||
policy live, rasdaemon active).
|
||||
- The lifecycle gate runs on archi-dev-box per release; the QEMU ISO
|
||||
smoke covers fresh installs.
|
||||
|
||||
## Future payloads (candidates, not commitments)
|
||||
|
||||
- `unattended-upgrades` posture + a default-deny host nftables ruleset
|
||||
(the §F hardening-plan item — needs its own design first).
|
||||
- Host firewall rules for mesh/WG ports.
|
||||
- Chronic: anything the image learns post-deploy that old nodes must
|
||||
converge on (the polkit and audio precedents, formalized).
|
||||
@@ -567,6 +567,33 @@ RUN mkdir -p /etc/polkit-1/rules.d && \
|
||||
> /etc/polkit-1/rules.d/49-archipelago-networkmanager.rules && \
|
||||
chmod 644 /etc/polkit-1/rules.d/49-archipelago-networkmanager.rules
|
||||
|
||||
# kdump + rasdaemon (#144, docs/kdump-rasdaemon-design.md): crash dumps and
|
||||
# hardware-error capture on the host. Packages + config are baked in for fresh
|
||||
# installs; the binary's host_fixups module delivers the identical end state to
|
||||
# already-deployed nodes over OTA (idempotent no-op here once applied).
|
||||
RUN set -eu; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends kdump-tools kexec-tools rasdaemon; \
|
||||
apt-get clean; rm -rf /var/lib/apt/lists/*; \
|
||||
CONF=/etc/default/kdump-tools; \
|
||||
sed -i 's|^#\?USE_KDUMP=.*|USE_KDUMP="1"|' "$CONF"; \
|
||||
grep -q '^KDUMP_COREDIR=' "$CONF" \
|
||||
&& sed -i 's|^KDUMP_COREDIR=.*|KDUMP_COREDIR="/var/crash"|' "$CONF" \
|
||||
|| printf '\nKDUMP_COREDIR="/var/crash"\n' >> "$CONF"; \
|
||||
grep -q '^CORE_COLLECTOR=' "$CONF" \
|
||||
&& sed -i 's|^CORE_COLLECTOR=.*|CORE_COLLECTOR="makedumpfile -l --message-level 1 -d 31"|' "$CONF" \
|
||||
|| printf '\nCORE_COLLECTOR="makedumpfile -l --message-level 1 -d 31"\n' >> "$CONF"; \
|
||||
printf '%s\n' \
|
||||
'# Archipelago kdump policy (#144). A wedged kiosk is useless until someone' \
|
||||
'# power-cycles it — capture the evidence, then reboot by itself. Dumps land in' \
|
||||
'# /var/crash (see docs/kdump-rasdaemon-design.md); keep-2 pruning is done by' \
|
||||
'# the host fixup pass, not a timer.' \
|
||||
'kernel.panic = 10' \
|
||||
'kernel.panic_on_oops = 1' \
|
||||
'kernel.hung_task_panic = 1' \
|
||||
'kernel.hardlockup_panic = 1' \
|
||||
> /etc/sysctl.d/99-archipelago-kdump.conf
|
||||
|
||||
# Enable services
|
||||
RUN systemctl enable NetworkManager || true && \
|
||||
systemctl enable polkit || systemctl enable polkit.service || true && \
|
||||
@@ -580,7 +607,9 @@ RUN systemctl enable NetworkManager || true && \
|
||||
systemctl enable archipelago-update.timer || true && \
|
||||
systemctl enable archipelago-doctor.timer || true && \
|
||||
systemctl enable archipelago-tor-helper.path || true && \
|
||||
systemctl enable nostr-relay || true
|
||||
systemctl enable nostr-relay || true && \
|
||||
systemctl enable rasdaemon || true && \
|
||||
systemctl enable kdump-tools || true
|
||||
# archipelago-fips.service + archipelago-wg.service + archipelago-wg-address.service
|
||||
# stay installed and enabled. They all use `ConditionPathExists=` on their
|
||||
# respective seed-derived key files, so on a fresh pre-onboarding boot
|
||||
@@ -3715,8 +3744,15 @@ if [ -d "$BOOT_MEDIA/archipelago/plymouth-theme" ]; then
|
||||
ln -sf /usr/share/plymouth/themes/archipelago/archipelago.plymouth \
|
||||
/mnt/target/etc/alternatives/default.plymouth 2>/dev/null || true
|
||||
# Configure clean boot: splash, suppress kernel noise, hide cursor
|
||||
sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT=".*"/GRUB_CMDLINE_LINUX_DEFAULT="quiet splash loglevel=0 rd.systemd.show_status=false vt.global_cursor_default=0 acpi=force"/' \
|
||||
sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT=".*"/GRUB_CMDLINE_LINUX_DEFAULT="quiet splash loglevel=0 rd.systemd.show_status=false vt.global_cursor_default=0 acpi=force crashkernel=256M"/' \
|
||||
/mnt/target/etc/default/grub 2>/dev/null || true
|
||||
# kdump-tools ships a grub.d snippet that appends crashkernel=512M-:192M
|
||||
# after this line. The later value silently wins on amd64, so neutralize
|
||||
# the package default and keep Archipelago's explicit fixed reservation.
|
||||
if [ -f /mnt/target/etc/default/grub.d/kdump-tools.cfg ]; then
|
||||
printf '%s\n' '# Archipelago owns crashkernel sizing in /etc/default/grub.' \
|
||||
> /mnt/target/etc/default/grub.d/kdump-tools.cfg
|
||||
fi
|
||||
echo " Installed Archipelago Plymouth theme on target"
|
||||
fi
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.4-alpha",
|
||||
"version": "1.8.5-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.4-alpha",
|
||||
"version": "1.8.5-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.4-alpha",
|
||||
"version": "1.8.5-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
@@ -52,13 +52,13 @@
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.3",
|
||||
"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.2",
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
@@ -378,7 +378,7 @@
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"repoUrl": "https://github.com/rhasspy/wyoming"
|
||||
},
|
||||
{
|
||||
@@ -464,7 +464,7 @@
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -571,6 +571,18 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.5.27",
|
||||
"versionCode": 47
|
||||
"versionName": "0.5.28",
|
||||
"versionCode": 48
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ watch(() => appStore.isAuthenticated, (authenticated) => {
|
||||
startRemoteRelay()
|
||||
} else {
|
||||
messageToast.stopPolling()
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '', contactId: null }
|
||||
screensaverStore.clearInactivityTimer()
|
||||
screensaverStore.deactivate()
|
||||
stopRemoteRelay()
|
||||
|
||||
@@ -143,6 +143,7 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
|
||||
import { companionIntroRequested } from '@/composables/useCompanionIntro'
|
||||
import { isCompanionApp } from '@/utils/openExternal'
|
||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
@@ -205,10 +206,12 @@ const POST_INTRO_GRACE_MS = 2000
|
||||
|
||||
let calmTicker: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Running inside the companion app's own WebView (it injects this JS bridge).
|
||||
// Running inside the companion app's own WebView (it injects the JS bridge —
|
||||
// detected with the canonical helper, not a raw window check, so the gate
|
||||
// is identical everywhere the question is asked).
|
||||
// The "get the companion app" pitch is nonsense there — the user is already in
|
||||
// it. Server management for connected companions lives in the NESMenu instead.
|
||||
const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined'
|
||||
const IN_COMPANION_APP = isCompanionApp()
|
||||
|
||||
onMounted(() => {
|
||||
if (IN_COMPANION_APP) return
|
||||
@@ -247,9 +250,13 @@ function maybeShow() {
|
||||
}
|
||||
|
||||
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
|
||||
// The trigger itself is already a no-op inside the companion (useCompanionIntro),
|
||||
// and this watcher refuses to open there too, so no caller can ever pop the
|
||||
// install pitch inside the app it installs (#61).
|
||||
watch(companionIntroRequested, (requested) => {
|
||||
if (!requested) return
|
||||
companionIntroRequested.value = false
|
||||
if (IN_COMPANION_APP) return
|
||||
if (calmTicker) {
|
||||
clearInterval(calmTicker)
|
||||
calmTicker = null
|
||||
|
||||
@@ -50,6 +50,7 @@ const showRevealModal = ref(false)
|
||||
const revealPassword = ref('')
|
||||
const revealCode = ref('')
|
||||
const revealPassphrase = ref('')
|
||||
const showRevealPassphrase = ref(false)
|
||||
const revealing = ref(false)
|
||||
const revealError = ref('')
|
||||
const revealedWords = ref<string[]>([])
|
||||
@@ -60,6 +61,7 @@ function openReveal() {
|
||||
revealPassword.value = ''
|
||||
revealCode.value = ''
|
||||
revealPassphrase.value = ''
|
||||
showRevealPassphrase.value = false
|
||||
revealError.value = ''
|
||||
revealedWords.value = []
|
||||
showRevealModal.value = true
|
||||
@@ -83,7 +85,17 @@ async function submitReveal() {
|
||||
// to set up a backup that now exists.
|
||||
void loadStatus()
|
||||
} catch (e: unknown) {
|
||||
revealError.value = e instanceof Error ? e.message : 'Failed to reveal the ecash phrase'
|
||||
const message = e instanceof Error ? e.message : 'Failed to reveal the ecash phrase'
|
||||
// Most operators used their login password as the backup passphrase. Do
|
||||
// not confront everyone with an unexplained third credential up front;
|
||||
// disclose it only when the authenticated password could not decrypt the
|
||||
// node seed and a distinct setup-time passphrase may actually exist.
|
||||
if (!status.value?.active && /could not decrypt the saved seed/i.test(message)) {
|
||||
showRevealPassphrase.value = true
|
||||
revealError.value = 'Your login password did not unlock the saved seed. Enter the separate backup passphrase you chose during setup.'
|
||||
} else {
|
||||
revealError.value = message
|
||||
}
|
||||
} finally {
|
||||
revealing.value = false
|
||||
}
|
||||
@@ -95,6 +107,7 @@ function closeReveal() {
|
||||
revealPassword.value = ''
|
||||
revealCode.value = ''
|
||||
revealPassphrase.value = ''
|
||||
showRevealPassphrase.value = false
|
||||
}
|
||||
|
||||
async function copyRevealedWords() {
|
||||
@@ -376,9 +389,9 @@ async function restoreFromPhrase() {
|
||||
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
|
||||
<input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
|
||||
</div>
|
||||
<div v-if="!status?.active">
|
||||
<label class="block text-xs text-white/60 mb-1">Backup passphrase <span class="text-white/30">(only if different from password)</span></label>
|
||||
<input v-model="revealPassphrase" type="password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Leave blank to use password" />
|
||||
<div v-if="showRevealPassphrase">
|
||||
<label class="block text-xs text-white/60 mb-1">Separate backup passphrase</label>
|
||||
<input v-model="revealPassphrase" type="password" autocomplete="off" autofocus class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Passphrase chosen during setup" />
|
||||
</div>
|
||||
<p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p>
|
||||
<div class="flex gap-2 pt-1">
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed bottom-5 left-1/2 -translate-x-1/2 z-40 w-[min(92vw,420px)] glass-card px-4 py-3 flex items-start gap-3 shadow-xl"
|
||||
>
|
||||
<svg class="w-5 h-5 text-white/60 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393C6.957 3.83 17.043 3.83 22.606 9.393" />
|
||||
</svg>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white">No network connection</p>
|
||||
<p class="text-xs text-white/60 mt-0.5">This node has no cable or WiFi link yet. You can set up WiFi now — it also works without internet.</p>
|
||||
<div class="flex gap-2 mt-2.5">
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button rounded-lg text-xs font-medium"
|
||||
@click="goToWifi"
|
||||
>
|
||||
Connect to WiFi
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs text-white/50 hover:text-white transition-colors"
|
||||
@click="dismissed = true"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-white/40 hover:text-white transition-colors shrink-0" aria-label="Dismiss" @click="dismissed = true">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
/** True when at least one physical interface is up (ethernet or WiFi).
|
||||
* Exported for tests — the component only needs this one pure decision. */
|
||||
export function hasPhysicalLink(interfaces: { type: string; state: string }[]): boolean {
|
||||
return interfaces.some(
|
||||
(iface) => (iface.type === 'ethernet' || iface.type === 'wifi') && iface.state === 'up',
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
/**
|
||||
* Onboarding-only "no network at all" callout (#145).
|
||||
*
|
||||
* A fresh install without a cable can leave a user stranded: the WiFi
|
||||
* settings live in Server → Network and nothing points there. This floats
|
||||
* over the onboarding steps whenever the node has NO physical link (no
|
||||
* ethernet up, no WiFi associated) and deep-links to the WiFi picker.
|
||||
*
|
||||
* Deliberately scoped the other way too: Archipelago is offline-first, so
|
||||
* "no internet" must NEVER nag — only "no link at all" qualifies, and the
|
||||
* callout is onboarding-context only (the wrapper renders it on
|
||||
* /onboarding/* routes; logged-in users have their own places to look).
|
||||
*/
|
||||
const router = useRouter()
|
||||
|
||||
const dismissed = ref(false)
|
||||
const hasLink = ref<boolean | null>(null)
|
||||
|
||||
const visible = computed(() => !dismissed.value && hasLink.value === false)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let inFlight = false
|
||||
|
||||
async function check() {
|
||||
if (inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ interfaces: { type: string; state: string }[] }>({
|
||||
method: 'network.list-interfaces',
|
||||
dedup: true,
|
||||
maxRetries: 1,
|
||||
})
|
||||
hasLink.value = hasPhysicalLink(res?.interfaces ?? [])
|
||||
} catch {
|
||||
// Node busy or RPC not ready during early onboarding — never nag on a
|
||||
// failed probe; treat unknown as "don't show".
|
||||
hasLink.value = null
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToWifi() {
|
||||
dismissed.value = true
|
||||
// Server.vue consumes ?open=wifi by popping the WiFi picker on arrival.
|
||||
router.push('/dashboard/server?open=wifi')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
check()
|
||||
// A cable gets plugged in mid-onboarding; poll gently so the callout
|
||||
// dismisses itself the moment a link exists.
|
||||
timer = setInterval(check, 15_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<input
|
||||
:type="revealed ? 'text' : 'password'"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:autocomplete="autocomplete"
|
||||
class="w-full px-3 py-2 pr-10 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
@keyup.enter="$emit('enter')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 px-3 text-white/40 hover:text-white/80 transition-colors"
|
||||
:aria-label="revealed ? 'Hide password' : 'Show password'"
|
||||
:title="revealed ? 'Hide password' : 'Show password'"
|
||||
@click="revealed = !revealed"
|
||||
>
|
||||
<!-- eye -->
|
||||
<svg v-if="!revealed" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<!-- eye-off -->
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Password input with a reveal toggle (#145). Introduced for the WiFi SSID
|
||||
* password — a fresh-install user typing a long wifi key into a TV from
|
||||
* across the room needs to see what they typed — and written reusable so
|
||||
* other password fields can adopt it without re-deriving the eye icon.
|
||||
*/
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
autocomplete?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'enter'): void
|
||||
}>()
|
||||
|
||||
const revealed = ref(false)
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: { call: vi.fn() },
|
||||
}))
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import EcashSeedBackup from '../EcashSeedBackup.vue'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
describe('EcashSeedBackup reveal credentials (#127)', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('asks for a separate backup passphrase only after password decryption fails', async () => {
|
||||
vi.mocked(rpcClient.call)
|
||||
.mockResolvedValueOnce({
|
||||
active: false,
|
||||
source: null,
|
||||
can_activate: true,
|
||||
derivable_from_node_seed: true,
|
||||
})
|
||||
.mockRejectedValueOnce(new Error(
|
||||
'Could not decrypt the saved seed. If you set a separate backup passphrase during setup, enter that passphrase.',
|
||||
))
|
||||
|
||||
wrapper = mount(EcashSeedBackup, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(document.body.textContent).not.toContain('Separate backup passphrase')
|
||||
const password = document.body.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')!
|
||||
password.value = 'login-password'
|
||||
password.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.body.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('Separate backup passphrase')
|
||||
expect(document.body.textContent).toContain('Your login password did not unlock the saved seed')
|
||||
expect(document.body.querySelector('input[placeholder="Passphrase chosen during setup"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import OnboardingNetworkCallout, { hasPhysicalLink } from '../OnboardingNetworkCallout.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
// #145: a fresh install with no cable strands the user — the callout points
|
||||
// at the WiFi picker, and ONLY when no physical link exists. Archipelago is
|
||||
// offline-first, so "no internet" must never nag: only "no link at all".
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: { call: vi.fn() },
|
||||
}))
|
||||
|
||||
const push = vi.fn()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push }),
|
||||
}))
|
||||
|
||||
const call = vi.mocked(rpcClient.call)
|
||||
|
||||
function mountCallout() {
|
||||
return mount(OnboardingNetworkCallout)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('hasPhysicalLink (pure decision)', () => {
|
||||
it('no interfaces at all → no link', () => {
|
||||
expect(hasPhysicalLink([])).toBe(false)
|
||||
})
|
||||
|
||||
it('ethernet up → link', () => {
|
||||
expect(hasPhysicalLink([{ type: 'ethernet', state: 'up' }])).toBe(true)
|
||||
})
|
||||
|
||||
it('wifi up → link', () => {
|
||||
expect(hasPhysicalLink([{ type: 'wifi', state: 'up' }])).toBe(true)
|
||||
})
|
||||
|
||||
it('physical interface present but down → no link', () => {
|
||||
expect(
|
||||
hasPhysicalLink([
|
||||
{ type: 'ethernet', state: 'down' },
|
||||
{ type: 'wifi', state: 'down' },
|
||||
]),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('virtual interfaces that happen to be up do NOT count as a link', () => {
|
||||
expect(
|
||||
hasPhysicalLink([
|
||||
{ type: 'bridge', state: 'up' },
|
||||
{ type: 'loopback', state: 'up' },
|
||||
]),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OnboardingNetworkCallout (component)', () => {
|
||||
beforeEach(() => {
|
||||
call.mockReset()
|
||||
})
|
||||
|
||||
it('shows when the node has no physical link, and offers the WiFi picker', async () => {
|
||||
call.mockResolvedValue({
|
||||
interfaces: [
|
||||
{ type: 'ethernet', state: 'down' },
|
||||
{ type: 'wifi', state: 'down' },
|
||||
],
|
||||
})
|
||||
const wrapper = mountCallout()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('No network connection')
|
||||
expect(wrapper.text()).toContain('Connect to WiFi')
|
||||
|
||||
await wrapper.findAll('button').find(b => b.text() === 'Connect to WiFi')!.trigger('click')
|
||||
expect(push).toHaveBeenCalledWith('/dashboard/server?open=wifi')
|
||||
})
|
||||
|
||||
it('stays hidden once any physical link exists — offline-first, no nagging', async () => {
|
||||
call.mockResolvedValue({ interfaces: [{ type: 'ethernet', state: 'up' }] })
|
||||
const wrapper = mountCallout()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('div.fixed').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('never shows on a failed probe — early onboarding, RPC not ready yet', async () => {
|
||||
call.mockRejectedValue(new Error('not ready'))
|
||||
const wrapper = mountCallout()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('div.fixed').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides when dismissed, even with no link', async () => {
|
||||
call.mockResolvedValue({ interfaces: [{ type: 'wifi', state: 'down' }] })
|
||||
const wrapper = mountCallout()
|
||||
await flushPromises()
|
||||
|
||||
const dismiss = wrapper.findAll('button').find(b => b.text() === 'Dismiss')!
|
||||
expect(dismiss).toBeDefined()
|
||||
await dismiss.trigger('click')
|
||||
expect(wrapper.find('div.fixed').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PasswordRevealInput from '../PasswordRevealInput.vue'
|
||||
|
||||
// #145: the reveal toggle exists so a fresh-install user typing a WiFi key
|
||||
// from across the room can see what they typed. The contract: masked by
|
||||
// default, one tap reveals, v-model and enter behave like a plain input.
|
||||
|
||||
describe('PasswordRevealInput', () => {
|
||||
it('masks by default and reveals on toggle', async () => {
|
||||
const wrapper = mount(PasswordRevealInput, {
|
||||
props: { modelValue: 'hunter2', placeholder: 'WiFi password' },
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('type')).toBe('password')
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(input.attributes('type')).toBe('text')
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(input.attributes('type')).toBe('password')
|
||||
})
|
||||
|
||||
it('syncs v-model through update:modelValue', async () => {
|
||||
const wrapper = mount(PasswordRevealInput, { props: { modelValue: '' } })
|
||||
await wrapper.find('input').setValue('s3cret')
|
||||
const emitted = wrapper.emitted('update:modelValue') as string[][]
|
||||
expect(emitted[emitted.length - 1]).toEqual(['s3cret'])
|
||||
})
|
||||
|
||||
it('emits enter on Enter keyup — the WiFi modal submits from the keyboard', async () => {
|
||||
const wrapper = mount(PasswordRevealInput, { props: { modelValue: 'pw' } })
|
||||
await wrapper.find('input').trigger('keyup.enter')
|
||||
expect(wrapper.emitted('enter')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('passes placeholder and disabled through to the input', () => {
|
||||
const wrapper = mount(PasswordRevealInput, {
|
||||
props: { modelValue: '', placeholder: 'WiFi password', disabled: true },
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('placeholder')).toBe('WiFi password')
|
||||
expect(input.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
|
||||
import { companionIntroRequested, openCompanionIntro } from '../useCompanionIntro'
|
||||
|
||||
// #61: the manual intro trigger (App Store banner etc.) must be a no-op inside
|
||||
// the companion app's WebView — the "install the companion" pitch is nonsense
|
||||
// where the user is already running it. The auto-popup was already gated
|
||||
// (CompanionIntroOverlay.onMounted); openCompanionIntro is the second, manual
|
||||
// path and the banner render (CompanionBanner) the third.
|
||||
|
||||
type TestWindow = Window & { ArchipelagoNative?: unknown }
|
||||
const w = window as TestWindow
|
||||
|
||||
beforeEach(() => {
|
||||
companionIntroRequested.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete w.ArchipelagoNative
|
||||
})
|
||||
|
||||
describe('openCompanionIntro', () => {
|
||||
it('raises the manual intro request in a plain browser/PWA', () => {
|
||||
expect(companionIntroRequested.value).toBe(false)
|
||||
openCompanionIntro()
|
||||
expect(companionIntroRequested.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is a no-op inside the companion app (bridge with openInApp)', () => {
|
||||
w.ArchipelagoNative = { openInApp: () => {}, openExternal: () => {} }
|
||||
openCompanionIntro()
|
||||
expect(companionIntroRequested.value).toBe(false)
|
||||
})
|
||||
|
||||
it('still fires when the bridge exists but is not the companion shell', () => {
|
||||
// Partial bridge (no openInApp) is not the companion app — a future
|
||||
// embedder must still see the pitch.
|
||||
w.ArchipelagoNative = { openExternal: () => {} }
|
||||
openCompanionIntro()
|
||||
expect(companionIntroRequested.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
// Controllable doubles shared between the hoisted block and the mock
|
||||
// factories. Plain holders — each test writes to them before importing the
|
||||
// composable under a fresh module registry (the watcher keeps a module-level
|
||||
// `firedThisSession` session guard, so every case needs its own module).
|
||||
const state = vi.hoisted(() => ({
|
||||
packages: {} as Record<string, unknown>,
|
||||
goalStatus: 'in-progress',
|
||||
goalProgress: {} as Record<string, { completedSteps: string[] }>,
|
||||
toastAction: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
// Re-bound every time the useBitcoinSync factory is (re)evaluated; holds the
|
||||
// exact refs the freshly imported composable watches.
|
||||
syncRefs: null as null | { synced: { value: boolean }; loaded: { value: boolean } },
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useBitcoinSync', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const synced = ref(false)
|
||||
const loaded = ref(false)
|
||||
state.syncRefs = { synced, loaded }
|
||||
return {
|
||||
bitcoinSynced: synced,
|
||||
bitcoinSyncLoaded: loaded,
|
||||
acquireBitcoinSync: () => () => {},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/goals', () => ({
|
||||
useGoalStore: () => ({
|
||||
getGoalStatus: () => state.goalStatus,
|
||||
progress: state.goalProgress,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
get packages() {
|
||||
return state.packages
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({ action: state.toastAction }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: state.routerPush }),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Fresh module registry → fresh `firedThisSession`, then mount the composable
|
||||
* inside a real component so its watchers live in a proper effect scope.
|
||||
*/
|
||||
async function mountWatcher() {
|
||||
vi.resetModules()
|
||||
const { useIbdFinishWatcher } = await import('../useIbdFinishWatcher')
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
useIbdFinishWatcher()
|
||||
return () => null
|
||||
},
|
||||
})
|
||||
return mount(Host)
|
||||
}
|
||||
|
||||
/** Drive a real unsynced→synced transition through the mocked sync refs. */
|
||||
async function completeSync() {
|
||||
const refs = state.syncRefs!
|
||||
refs.loaded.value = true
|
||||
refs.synced.value = false // the watcher must observe unsynced at least once
|
||||
await nextTick()
|
||||
refs.synced.value = true
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('useIbdFinishWatcher', () => {
|
||||
beforeEach(() => {
|
||||
state.packages = {}
|
||||
state.goalStatus = 'in-progress'
|
||||
state.goalProgress = {}
|
||||
state.toastAction.mockClear()
|
||||
state.routerPush.mockClear()
|
||||
})
|
||||
|
||||
it('says to install LND next when Lightning is not installed yet (#143)', async () => {
|
||||
// Bitcoin synced mid-goal, but the goal's install-LND step is still
|
||||
// pending: the on-chain wallet lives in LND, so "fund your wallet" would
|
||||
// promise a flow that cannot work yet.
|
||||
state.packages = { 'bitcoin-knots': { state: 'running' } }
|
||||
const wrapper = await mountWatcher()
|
||||
await completeSync()
|
||||
|
||||
expect(state.toastAction).toHaveBeenCalledTimes(1)
|
||||
const [message, opts] = state.toastAction.mock.calls[0]!
|
||||
expect(message).toBe(
|
||||
"Bitcoin is fully synced — next, install Lightning (LND) to get your node's on-chain wallet.",
|
||||
)
|
||||
expect(opts.label).toBe('Finish setup')
|
||||
opts.onClick()
|
||||
// "Finish setup" lands on the goal wizard, whose active step is the
|
||||
// pending install-LND one — the correct next action.
|
||||
expect(state.routerPush).toHaveBeenCalledWith('/dashboard/goals/open-a-shop')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('says to fund the wallet when LND is already installed', async () => {
|
||||
state.packages = { 'bitcoin-knots': { state: 'running' }, lnd: { state: 'running' } }
|
||||
const wrapper = await mountWatcher()
|
||||
await completeSync()
|
||||
|
||||
expect(state.toastAction).toHaveBeenCalledTimes(1)
|
||||
const [message, opts] = state.toastAction.mock.calls[0]!
|
||||
expect(message).toBe(
|
||||
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
|
||||
)
|
||||
expect(opts.label).toBe('Finish setup')
|
||||
opts.onClick()
|
||||
expect(state.routerPush).toHaveBeenCalledWith('/dashboard/goals/open-a-shop')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('stays silent when no Lightning goal is in progress', async () => {
|
||||
state.goalStatus = 'not-started'
|
||||
const wrapper = await mountWatcher()
|
||||
await completeSync()
|
||||
|
||||
expect(state.toastAction).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('stays silent when the chain was already synced at page load', async () => {
|
||||
// A node that's already synced never shows unsynced this session, so the
|
||||
// toast must not fire (it only marks real IBD-completion transitions).
|
||||
const wrapper = await mountWatcher()
|
||||
const refs = state.syncRefs!
|
||||
refs.loaded.value = true
|
||||
refs.synced.value = true
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(state.toastAction).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
@@ -9,6 +10,7 @@ vi.mock('vue-router', () => ({
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
getReceivedMessages: vi.fn(),
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -21,13 +23,16 @@ describe('useMessageToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ messages: [], count: 0 })
|
||||
// Reset shared singleton state
|
||||
const toast = useMessageToast()
|
||||
toast.stopPolling()
|
||||
toast.receivedMessages.value = []
|
||||
toast.lastMessageCount.value = 0
|
||||
toast.loadingMessages.value = false
|
||||
toast.toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
toast.toastMessage.value = { show: false, text: '', fromPubkey: '', contactId: null }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -143,9 +148,43 @@ describe('useMessageToast', () => {
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('shows a radio-mesh toast and deep-links to its contact', async () => {
|
||||
const toast = useMessageToast()
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({ messages: [] })
|
||||
|
||||
// Initialize an empty node, then deliver its first Meshtastic message.
|
||||
await toast.loadReceivedMessages()
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
messages: [{
|
||||
id: 1,
|
||||
direction: 'received',
|
||||
peer_contact_id: 42,
|
||||
peer_name: 'Alice',
|
||||
plaintext: 'Over LoRa',
|
||||
timestamp: '2026-01-01',
|
||||
delivered: true,
|
||||
encrypted: true,
|
||||
transport: 'meshtastic',
|
||||
}],
|
||||
count: 1,
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value).toMatchObject({
|
||||
show: true,
|
||||
text: 'Over LoRa',
|
||||
contactId: 42,
|
||||
})
|
||||
toast.dismissToastAndOpenMessages()
|
||||
expect(mockPush).toHaveBeenCalledWith({
|
||||
path: '/dashboard/mesh',
|
||||
query: { contact: '42' },
|
||||
})
|
||||
})
|
||||
|
||||
it('dismissToastAndOpenMessages clears toast and navigates', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '' }
|
||||
toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '', contactId: null }
|
||||
toast.dismissToastAndOpenMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { isCompanionApp } from '@/utils/openExternal'
|
||||
|
||||
/**
|
||||
* Cross-view trigger for the Remote Companion intro/pairing modal
|
||||
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
|
||||
* App Store banner call openCompanionIntro() to pop it on demand — this
|
||||
* bypasses the once-per-browser auto-show gate.
|
||||
*
|
||||
* Inside the companion app's own WebView the whole pitch is nonsense — the
|
||||
* user is already running it — so the trigger is a no-op there (#61: the
|
||||
* auto-popup was gated, this manual path and CompanionBanner weren't).
|
||||
*/
|
||||
export const companionIntroRequested = ref(false)
|
||||
|
||||
export function openCompanionIntro(): void {
|
||||
if (isCompanionApp()) return
|
||||
companionIntroRequested.value = true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, watch, watchEffect, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
acquireBitcoinSync,
|
||||
@@ -20,6 +21,7 @@ let firedThisSession = false
|
||||
*/
|
||||
export function useIbdFinishWatcher() {
|
||||
const goalStore = useGoalStore()
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -68,8 +70,16 @@ export function useIbdFinishWatcher() {
|
||||
const goalId = pendingLightningGoalId.value
|
||||
if (!goalId) return
|
||||
firedThisSession = true
|
||||
// The on-chain wallet lives in LND, not Bitcoin Core — the address the
|
||||
// fund flow shows comes from `lnd.newaddress`. While the goal's
|
||||
// install-LND step is still pending, "fund your wallet" would point at
|
||||
// something that doesn't exist yet, so the toast names the actual next
|
||||
// step instead (issue #143).
|
||||
const lndInstalled = Object.keys(appStore.packages).includes('lnd')
|
||||
toast.action(
|
||||
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
|
||||
lndInstalled
|
||||
? 'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.'
|
||||
: "Bitcoin is fully synced — next, install Lightning (LND) to get your node's on-chain wallet.",
|
||||
{
|
||||
label: 'Finish setup',
|
||||
onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
|
||||
export interface ReceivedMessage {
|
||||
from_pubkey: string
|
||||
@@ -14,11 +15,19 @@ const MESSAGE_POLL_INTERVAL = 30000 // 30s
|
||||
const receivedMessages = ref<ReceivedMessage[]>([])
|
||||
const lastMessageCount = ref(0)
|
||||
const loadingMessages = ref(false)
|
||||
const toastMessage = ref<{ show: boolean; text: string; fromPubkey: string }>({ show: false, text: '', fromPubkey: '' })
|
||||
type MessageToast = {
|
||||
show: boolean
|
||||
text: string
|
||||
fromPubkey: string
|
||||
contactId: number | null
|
||||
}
|
||||
const emptyToast = (): MessageToast => ({ show: false, text: '', fromPubkey: '', contactId: null })
|
||||
const toastMessage = ref<MessageToast>(emptyToast())
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export function useMessageToast() {
|
||||
const router = useRouter()
|
||||
const mesh = useMeshStore()
|
||||
|
||||
const unreadCount = computed(() =>
|
||||
Math.max(0, receivedMessages.value.length - lastMessageCount.value)
|
||||
@@ -40,6 +49,7 @@ export function useMessageToast() {
|
||||
// Only deep-link to a specific chat when it's a single new message
|
||||
// from one sender; otherwise open the mesh list.
|
||||
fromPubkey: newCount === 1 ? (latest?.from_pubkey ?? '') : '',
|
||||
contactId: null,
|
||||
}
|
||||
lastMessageCount.value = msgs.length
|
||||
} else {
|
||||
@@ -55,6 +65,26 @@ export function useMessageToast() {
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
|
||||
// Federation messages and radio-mesh messages use separate backend
|
||||
// queues. Poll the mesh store too so Meshtastic/MeshCore/Reticulum
|
||||
// arrivals produce the same app-wide toast. fetchMessages returns only
|
||||
// the newly-unread batch computed from its durable per-contact watermark.
|
||||
const newMeshMessages = await mesh.fetchMessages()
|
||||
if (newMeshMessages.length > 0) {
|
||||
const latest = newMeshMessages[newMeshMessages.length - 1]!
|
||||
const oneConversation = newMeshMessages.every(
|
||||
msg => msg.peer_contact_id === latest.peer_contact_id
|
||||
)
|
||||
toastMessage.value = {
|
||||
show: true,
|
||||
text: newMeshMessages.length === 1
|
||||
? latest.plaintext
|
||||
: `${newMeshMessages.length} new messages`,
|
||||
fromPubkey: '',
|
||||
contactId: oneConversation ? latest.peer_contact_id : null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthenticated(): boolean {
|
||||
@@ -86,16 +116,21 @@ export function useMessageToast() {
|
||||
}
|
||||
|
||||
function dismissToastAndOpenMessages() {
|
||||
const peer = toastMessage.value.fromPubkey
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
const { fromPubkey: peer, contactId } = toastMessage.value
|
||||
toastMessage.value = emptyToast()
|
||||
markAsRead()
|
||||
// Open the specific conversation when we know the sender; else the mesh list.
|
||||
router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh')
|
||||
// Open the exact radio conversation by contact id, or the federation
|
||||
// conversation by pubkey. Multiple conversations fall back to the list.
|
||||
if (contactId !== null) {
|
||||
router.push({ path: '/dashboard/mesh', query: { contact: String(contactId) } })
|
||||
} else {
|
||||
router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh')
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss the toast without navigating (the close icon).
|
||||
function closeToast() {
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
toastMessage.value = emptyToast()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: { call: vi.fn() },
|
||||
}))
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMeshStore, type MeshMessage } from '../mesh'
|
||||
|
||||
const message = (id: number, contact = 7): MeshMessage => ({
|
||||
id,
|
||||
direction: 'received',
|
||||
peer_contact_id: contact,
|
||||
peer_name: 'Alice',
|
||||
plaintext: `message ${id}`,
|
||||
timestamp: `2026-01-${String(id).padStart(2, '0')}`,
|
||||
delivered: true,
|
||||
encrypted: true,
|
||||
transport: 'meshtastic',
|
||||
})
|
||||
|
||||
function reply(messages: MeshMessage[]) {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ messages, count: messages.length })
|
||||
}
|
||||
|
||||
describe('mesh unread persistence', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not swallow the first message after initializing with empty history', async () => {
|
||||
const store = useMeshStore()
|
||||
reply([])
|
||||
expect(await store.fetchMessages()).toEqual([])
|
||||
expect(localStorage.getItem('archipelago.mesh.last-seen.v1')).toBe('{}')
|
||||
|
||||
reply([message(1)])
|
||||
expect(await store.fetchMessages()).toEqual([message(1)])
|
||||
expect(store.unreadCounts[7]).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps read messages read across a page refresh', async () => {
|
||||
const firstPage = useMeshStore()
|
||||
reply([message(1), message(2)])
|
||||
await firstPage.fetchMessages() // migration seeds existing history as read
|
||||
firstPage.markChatRead(7)
|
||||
|
||||
setActivePinia(createPinia()) // simulate a full page/store reload
|
||||
const refreshedPage = useMeshStore()
|
||||
reply([message(1), message(2), message(3)])
|
||||
const newlyUnread = await refreshedPage.fetchMessages()
|
||||
|
||||
expect(newlyUnread.map(m => m.id)).toEqual([3])
|
||||
expect(refreshedPage.unreadCounts[7]).toBe(1)
|
||||
|
||||
refreshedPage.markChatRead(7)
|
||||
setActivePinia(createPinia())
|
||||
const readAgain = useMeshStore()
|
||||
reply([message(1), message(2), message(3)])
|
||||
expect(await readAgain.fetchMessages()).toEqual([])
|
||||
expect(readAgain.totalUnread).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -288,12 +288,27 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
// are safe watermarks: the backend allocates them monotonically and
|
||||
// restores the counter as max(persisted)+1 across restarts.
|
||||
const LAST_SEEN_KEY = 'archipelago.mesh.last-seen.v1'
|
||||
const lastSeenId = ref<Record<number, number>>(
|
||||
JSON.parse(localStorage.getItem(LAST_SEEN_KEY) || '{}') as Record<number, number>
|
||||
)
|
||||
let storedLastSeen = localStorage.getItem(LAST_SEEN_KEY)
|
||||
function parseLastSeen(raw: string | null): Record<number, number> {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<number, number>
|
||||
}
|
||||
} catch {
|
||||
// Treat corrupt browser state like a first run and safely reseed it.
|
||||
}
|
||||
storedLastSeen = null
|
||||
return {}
|
||||
}
|
||||
const lastSeenId = ref<Record<number, number>>(parseLastSeen(storedLastSeen))
|
||||
// First run after this feature ships: treat existing history as seen so
|
||||
// nobody gets a wall of phantom badges for months-old messages.
|
||||
let seedLastSeenFromHistory = localStorage.getItem(LAST_SEEN_KEY) === null
|
||||
// nobody gets a wall of phantom badges for months-old messages. Complete
|
||||
// this initialization even when history is empty; otherwise the first real
|
||||
// message to arrive on a brand-new node is mistaken for old history and its
|
||||
// notification is silently swallowed.
|
||||
let seedLastSeenFromHistory = storedLastSeen === null
|
||||
function persistLastSeen() {
|
||||
localStorage.setItem(LAST_SEEN_KEY, JSON.stringify(lastSeenId.value))
|
||||
}
|
||||
@@ -481,17 +496,18 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMessages(limit?: number) {
|
||||
async function fetchMessages(limit?: number): Promise<MeshMessage[]> {
|
||||
try {
|
||||
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
|
||||
method: 'mesh.messages',
|
||||
params: limit ? { limit } : {},
|
||||
dedup: true,
|
||||
})
|
||||
if (seedLastSeenFromHistory && res.messages.length > 0) {
|
||||
if (seedLastSeenFromHistory) {
|
||||
for (const m of res.messages) {
|
||||
if (m.direction === 'received') advanceLastSeen(m.peer_contact_id, m.id)
|
||||
}
|
||||
// Persist even an empty object as the initialization sentinel.
|
||||
persistLastSeen()
|
||||
seedLastSeenFromHistory = false
|
||||
}
|
||||
@@ -520,8 +536,15 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
messages.value = res.messages
|
||||
// Extract node positions from coordinate messages
|
||||
updateNodePositionsFromMessages(res.messages)
|
||||
// The app-wide notification poll uses this exact batch, rather than a
|
||||
// session message-count delta, so one arrival can never resurrect old
|
||||
// messages as "11 unread" after a refresh.
|
||||
return newMsgs.filter(msg => !(
|
||||
viewingChatIds.value.includes(msg.peer_contact_id) && viewingAtBottom.value
|
||||
))
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages'
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -565,7 +565,13 @@ function armMeshLive() {
|
||||
// match an entry in mesh.peers, so without this fallback the deep-link
|
||||
// silently failed and just landed on the bare mesh page every time.
|
||||
const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : ''
|
||||
if (targetPeer) {
|
||||
const targetContact = typeof route.query.contact === 'string'
|
||||
? Number(route.query.contact)
|
||||
: NaN
|
||||
if (Number.isInteger(targetContact)) {
|
||||
const match = mesh.peers.find(p => p.contact_id === targetContact)
|
||||
if (match) openChat(match)
|
||||
} else if (targetPeer) {
|
||||
const match = mesh.peers.find(
|
||||
(p) => p.pubkey_hex === targetPeer || p.did === targetPeer
|
||||
)
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
<!-- Content with 3D transitions -->
|
||||
<div class="perspective-container-wrapper">
|
||||
<div class="perspective-container">
|
||||
<!-- #145: fresh install with no cable — point at the WiFi picker. -->
|
||||
<!-- Onboarding routes only: logged-in sessions are offline-first and
|
||||
must never be nagged about connectivity. -->
|
||||
<OnboardingNetworkCallout v-if="isOnboardingRoute" />
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition :name="transitionName">
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
@@ -64,9 +68,15 @@
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { resumeAudioContext, startSynthwave } from '@/composables/useLoginSounds'
|
||||
import OnboardingNetworkCallout from '@/components/OnboardingNetworkCallout.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const currentBackground = ref('bg-intro.jpg')
|
||||
|
||||
// #145: the no-network callout follows the user across onboarding steps, but
|
||||
// this wrapper also hosts /login (and could host more later) — scope the
|
||||
// callout to the onboarding flow only.
|
||||
const isOnboardingRoute = computed(() => route.path.startsWith('/onboarding/'))
|
||||
const isGlitching = ref(false)
|
||||
const isTransitioning = ref(false)
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
@@ -970,6 +970,7 @@ onUnmounted(() => disarmVpnPoll())
|
||||
// outside a <KeepAlive> boundary (confirmed by ServerNetworkRefresh.test.ts,
|
||||
// which mounts this view bare), so a bare mount must not silently skip them.
|
||||
onMounted(() => {
|
||||
consumeOpenWifiQuery()
|
||||
checkTorStatus(); loadNetworkData(); loadInterfaces(); loadTorServices(); loadVpnPeers(); loadFipsSummary(); loadDiskStatus()
|
||||
armServerEntryEffects()
|
||||
})
|
||||
@@ -977,6 +978,20 @@ onMounted(() => {
|
||||
watch(showWifiModal, (open) => { if (open) scanWifi() })
|
||||
watch(showDnsModal, (open) => { if (open) { dnsSelectedProvider.value = networkData.value.dnsProvider || 'system'; dnsError.value = '' } })
|
||||
|
||||
// #145: onboarding's no-network callout deep-links here with ?open=wifi so a
|
||||
// fresh-install user lands straight in the WiFi picker. Read from the real
|
||||
// URL (the dashboard's SPA router keeps it in sync) rather than vue-router —
|
||||
// the KeepAlive-mounted view has no router guarantee at test-mount time —
|
||||
// and consume it (history.replaceState) so a tab-return never re-pops.
|
||||
function consumeOpenWifiQuery() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('open') !== 'wifi') return
|
||||
params.delete('open')
|
||||
const qs = params.toString()
|
||||
history.replaceState(history.state, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash)
|
||||
showWifiModal.value = true
|
||||
}
|
||||
|
||||
async function restartServices() {
|
||||
restarting.value = true; servicesRunning.value = false
|
||||
try { await rpcClient.restartServer(); logsToast.value = 'Services restarting...'; setTimeout(() => { logsToast.value = '' }, 4000) }
|
||||
|
||||
@@ -51,6 +51,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"botfights": "BotFights",
|
||||
"btcpay-server": "BTCPay Server",
|
||||
"core-lightning": "Core Lightning (CLN)",
|
||||
"cuprate": "Cuprate",
|
||||
"did-wallet": "Web5 DID Wallet",
|
||||
"electrs-ui": "Electrs UI",
|
||||
"electrumx": "ElectrumX",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<template>
|
||||
<!-- Companion app banner — same format as the featured app banner, with a
|
||||
phone mockup rising out of the right edge. Clicking anywhere (or the
|
||||
Install button) opens the Remote Companion download/pairing modal. -->
|
||||
Install button) opens the Remote Companion download/pairing modal.
|
||||
Not rendered at all inside the companion app's WebView: pitching
|
||||
"install the companion" to someone already in it is noise (#61). -->
|
||||
<div
|
||||
v-if="showPitch"
|
||||
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||
@click="openCompanionIntro()"
|
||||
>
|
||||
@@ -41,7 +44,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { isCompanionApp } from '@/utils/openExternal'
|
||||
import { openCompanionIntro } from '@/composables/useCompanionIntro'
|
||||
|
||||
const showPitch = computed(() => !isCompanionApp())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
:class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||
>{{ tierLabel }}</span>
|
||||
</h3>
|
||||
<p class="text-sm text-white/60">{{ app.version ? $ver(app.version) : 'latest' }}</p>
|
||||
<p v-if="!isMultiVersion" class="text-sm text-white/60">{{ app.version ? $ver(app.version) : 'latest' }}</p>
|
||||
<p v-else class="text-sm text-white/60">Choose version when installing</p>
|
||||
<p v-if="app.author" class="text-xs text-white/50 mt-1">by {{ app.author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,7 +176,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MarketplaceApp, InstallProgress } from './marketplaceData'
|
||||
import { MULTI_VERSION_APP_IDS, type MarketplaceApp, type InstallProgress } from './marketplaceData'
|
||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -200,6 +201,8 @@ defineEmits<{
|
||||
launch: [app: MarketplaceApp]
|
||||
}>()
|
||||
|
||||
const isMultiVersion = computed(() => MULTI_VERSION_APP_IDS.has(props.app.id))
|
||||
|
||||
const signatureLabel = computed(() => {
|
||||
switch (props.app.signature?.status) {
|
||||
case 'valid': return 'signed'
|
||||
|
||||
@@ -15,7 +15,7 @@ const app: MarketplaceApp = {
|
||||
source: 'community',
|
||||
}
|
||||
|
||||
function mountCard(installed: boolean, installBlockedReason?: string) {
|
||||
function mountCard(installed: boolean, installBlockedReason?: string, appOverride: MarketplaceApp = app) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -29,7 +29,7 @@ function mountCard(installed: boolean, installBlockedReason?: string) {
|
||||
|
||||
return mount(MarketplaceAppCard, {
|
||||
props: {
|
||||
app,
|
||||
app: appOverride,
|
||||
index: 0,
|
||||
stagger: false,
|
||||
installed,
|
||||
@@ -65,4 +65,16 @@ describe('MarketplaceAppCard', () => {
|
||||
expect(wrapper.text()).toContain('Requires a full archive Bitcoin node before install.')
|
||||
expect(wrapper.text()).toContain('Bitcoin Pruned')
|
||||
})
|
||||
|
||||
it('does not present one catalog version as definitive for multi-version apps (#129)', () => {
|
||||
const wrapper = mountCard(false, undefined, {
|
||||
...app,
|
||||
id: 'bitcoin-core',
|
||||
title: 'Bitcoin Core',
|
||||
version: '28.4.0',
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Choose version when installing')
|
||||
expect(wrapper.text()).not.toContain('28.4')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,6 +57,12 @@ export interface InstallProgress {
|
||||
attempt: number
|
||||
}
|
||||
|
||||
/** Apps that ask for their concrete version in InstallVersionModal. Their
|
||||
* store tiles deliberately omit a single catalog version: showing “v28.4”
|
||||
* there implies that is the only version immediately before asking the user
|
||||
* to choose a different one. */
|
||||
export const MULTI_VERSION_APP_IDS = new Set(['bitcoin-knots', 'bitcoin-core'])
|
||||
|
||||
/** Archipelago app registry — all app images are mirrored here */
|
||||
const REGISTRY = 'source.archipelago-foundation.org/lfg2025'
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: { call: vi.fn() },
|
||||
}))
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import OpenWrtGateway from './OpenWrtGateway.vue'
|
||||
|
||||
describe('OpenWrtGateway stale cached router recovery (#103)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('offers reconfiguration when the saved router can no longer connect', async () => {
|
||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Connection timed out'))
|
||||
const wrapper = mount(OpenWrtGateway, {
|
||||
global: { plugins: [createPinia()] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const reconfigure = wrapper.findAll('button').find(button => button.text() === 'Reconfigure router')
|
||||
expect(reconfigure).toBeDefined()
|
||||
await reconfigure!.trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('Connect to Router')
|
||||
expect(wrapper.text()).not.toContain('Connection timed out')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -260,6 +260,7 @@ function pickDetectedRouter(ip: string) {
|
||||
function disconnectRouter() {
|
||||
host.value = status.value?.host ?? host.value
|
||||
connectedParams.value = null
|
||||
error.value = ''
|
||||
detectError.value = ''
|
||||
detectedCandidates.value = []
|
||||
showConnectForm.value = true
|
||||
@@ -533,7 +534,10 @@ onMounted(() => {
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="glass-card p-6 mb-4">
|
||||
<p class="text-sm text-red-300">{{ error }}</p>
|
||||
<button class="mt-3 text-xs text-white/50 hover:text-white transition-colors underline" @click="load()">Retry</button>
|
||||
<div class="mt-3 flex items-center gap-4">
|
||||
<button class="text-xs text-white/50 hover:text-white transition-colors underline" @click="load()">Retry</button>
|
||||
<button class="text-xs text-orange-300/80 hover:text-orange-200 transition-colors underline" @click="disconnectRouter">Reconfigure router</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status panels -->
|
||||
|
||||
@@ -147,12 +147,12 @@
|
||||
<!-- WiFi password prompt -->
|
||||
<div v-if="wifiConnecting" class="mt-4 pt-4 border-t border-white/10">
|
||||
<p class="text-sm text-white/80 mb-2">Connect to <span class="font-medium text-white">{{ wifiSelectedSsid }}</span></p>
|
||||
<input
|
||||
<PasswordRevealInput
|
||||
v-model="localWifiPassword"
|
||||
type="password"
|
||||
placeholder="WiFi password"
|
||||
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30 mb-3"
|
||||
@keyup.enter="$emit('connectWifi', localWifiPassword)"
|
||||
:disabled="wifiSubmitting"
|
||||
class="mb-3"
|
||||
@enter="$emit('connectWifi', localWifiPassword)"
|
||||
/>
|
||||
<p v-if="wifiError" class="text-sm text-red-400 mb-3">{{ wifiError }}</p>
|
||||
<div class="flex gap-2">
|
||||
@@ -231,6 +231,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import PasswordRevealInput from '@/components/PasswordRevealInput.vue'
|
||||
|
||||
defineProps<{
|
||||
showAddServiceModal: boolean
|
||||
|
||||
@@ -362,6 +362,59 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.107-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.107-alpha</span>
|
||||
<span class="text-xs text-white/40">July 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.</p>
|
||||
<p>Your node rejoins the mesh faster after an update. Applying this update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried. It now notices the restart and reconnects within seconds.</p>
|
||||
<p>Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle — two separate faults that had been failing the build.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.51-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.51-alpha</span>
|
||||
<span class="text-xs text-white/40">April 30, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Stack installs now adopt containers that already exist instead of failing on them — a repair or reinstall over leftover containers completes, and the adopted container's readiness is waited on like any fresh start.</p>
|
||||
<p>Failed installs come with evidence: the install path waits for its containers, and when one doesn't become healthy it captures that container's logs, so the error on screen names the real culprit instead of a bare timeout.</p>
|
||||
<p>Bitcoin RPC bindings are ensured as part of install, and the startup self-heal path gained additional ground for already-deployed nodes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.50-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.50-alpha</span>
|
||||
<span class="text-xs text-white/40">April 30, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The OTA bridge older nodes needed: deployed binaries only knew how to apply two artifacts (the backend binary and the frontend archive), so the scripts, app specs and docker assets newer releases carry never reached them. This release packs those payloads inside the frontend tarball — the one channel old binaries do apply — and the new backend promotes them into /opt once it starts.</p>
|
||||
<p>Runtime payloads are staged into timestamped directories and promoted atomically; a failed extraction cleans up its staging area instead of leaving half-written state for the next update to trip over.</p>
|
||||
<p>This is the release that un-sticks the fleet's update pipeline: from here on, an OTA can carry more than the two artifacts, and app installs on updated nodes use the specs that match their backend.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.5-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.5-alpha</span>
|
||||
<span class="text-xs text-white/40">August 30, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>**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.</p>
|
||||
<p>**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.</p>
|
||||
<p>**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".</p>
|
||||
<p>**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.</p>
|
||||
<p>**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.</p>
|
||||
<p>**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.</p>
|
||||
<p>**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.</p>
|
||||
<p>**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.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.4-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+19
-21
@@ -1,33 +1,31 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**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."
|
||||
"**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."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.4-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago",
|
||||
"current_version": "1.8.5-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.4-alpha",
|
||||
"sha256": "c4d3a4fdecfdc2a972f808c7f98225b29dc65333418038bb016a5f0bd79d3551",
|
||||
"size_bytes": 63850680
|
||||
"new_version": "1.8.5-alpha",
|
||||
"sha256": "54e91944c6395a53c8ac87ea97f61e8a7fc5ffd133f017180931ba4fa9566239",
|
||||
"size_bytes": 63934504
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.4-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago-frontend-1.8.4-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.4-alpha.tar.gz",
|
||||
"new_version": "1.8.4-alpha",
|
||||
"sha256": "790de85816a7ad49480dc99134022279e4f40b69bd9b0983a6393373a3bdd7cc",
|
||||
"size_bytes": 97641658
|
||||
"current_version": "1.8.5-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago-frontend-1.8.5-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.5-alpha.tar.gz",
|
||||
"new_version": "1.8.5-alpha",
|
||||
"sha256": "a5d773e8225bfd8a34dfc74acf38bdc2c39bc138c62bf9f4137d0efd72cce3e9",
|
||||
"size_bytes": 97657151
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-20",
|
||||
"signature": "94ffb717166c6062ddbea97476942285903d629543bf3b9ec435697a1b8c31243dedb0f3c8f874daf3d9c0974a6342bf42b7e9d34b00680aa63a1d441dbc4805",
|
||||
"release_date": "2026-08-31",
|
||||
"signature": "f0bcec4935588ee428ebabfbb6e08e00dbd42202af92763073a7f98f4785925ca3565bf96fecbdd1695eff77fcf906783fd9053d82730b11c75441c6f5a60b05",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.4-alpha"
|
||||
"version": "1.8.5-alpha"
|
||||
}
|
||||
|
||||
+123
-13
@@ -505,6 +505,10 @@
|
||||
"network_policy": "bridge",
|
||||
"readonly_root": true
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "gitlab",
|
||||
"repo": "ark-bitcoin/bark"
|
||||
},
|
||||
"version": "0.3.0",
|
||||
"volumes": [
|
||||
{
|
||||
@@ -978,13 +982,13 @@
|
||||
"version": "1.2.11"
|
||||
},
|
||||
"btcpay": {
|
||||
"image": "docker.io/btcpayserver/btcpayserver:2.4.2",
|
||||
"image": "docker.io/btcpayserver/btcpayserver:2.4.3",
|
||||
"images": {
|
||||
"archy-btcpay-db": "source.archipelago-foundation.org/lfg2025/postgres:15.17",
|
||||
"archy-nbxplorer": "source.archipelago-foundation.org/lfg2025/nbxplorer:2.6.0",
|
||||
"btcpay-server": "docker.io/btcpayserver/btcpayserver:2.4.2"
|
||||
"btcpay-server": "docker.io/btcpayserver/btcpayserver:2.4.3"
|
||||
},
|
||||
"version": "2.4.2"
|
||||
"version": "2.4.3"
|
||||
},
|
||||
"btcpay-server": {
|
||||
"manifest": {
|
||||
@@ -1000,7 +1004,7 @@
|
||||
"template": "{{HOST_IP}}:23000"
|
||||
}
|
||||
],
|
||||
"image": "docker.io/btcpayserver/btcpayserver:2.4.2",
|
||||
"image": "docker.io/btcpayserver/btcpayserver:2.4.3",
|
||||
"network": "archy-net",
|
||||
"pull_policy": "if-not-present",
|
||||
"secret_env": [
|
||||
@@ -1097,7 +1101,7 @@
|
||||
"kind": "github",
|
||||
"repo": "btcpayserver/btcpayserver"
|
||||
},
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.3",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
@@ -1110,7 +1114,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "2.4.2"
|
||||
"version": "2.4.3"
|
||||
},
|
||||
"core-lightning": {
|
||||
"manifest": {
|
||||
@@ -1205,6 +1209,96 @@
|
||||
"image": "source.archipelago-foundation.org/lfg2025/cryptpad:2024.12.0",
|
||||
"version": "2024.12.0"
|
||||
},
|
||||
"cuprate": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
"category": "money",
|
||||
"container": {
|
||||
"custom_args": [
|
||||
"--config-file",
|
||||
"/home/cuprate/Cuprated.toml"
|
||||
],
|
||||
"data_uid": "1000:1000",
|
||||
"image": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
|
||||
"network": "archy-net",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "300Gi"
|
||||
}
|
||||
],
|
||||
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
||||
"files": [
|
||||
{
|
||||
"content": "network = \"Mainnet\"\ntarget_max_memory = 3000000000\n\n[rpc.restricted]\nenable = true\n",
|
||||
"overwrite": false,
|
||||
"path": "/var/lib/archipelago/cuprate/Cuprated.toml"
|
||||
}
|
||||
],
|
||||
"health_check": {
|
||||
"endpoint": "localhost:18090",
|
||||
"interval": "30s",
|
||||
"retries": 3,
|
||||
"start_period": "5m",
|
||||
"timeout": "5s",
|
||||
"type": "tcp"
|
||||
},
|
||||
"id": "cuprate",
|
||||
"metadata": {
|
||||
"author": "Cuprate",
|
||||
"category": "money",
|
||||
"icon": "/assets/img/app-icons/cuprate.svg",
|
||||
"repo": "https://github.com/Cuprate/cuprate",
|
||||
"tier": "optional"
|
||||
},
|
||||
"name": "Cuprate",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Monero p2p gossip. Peers are anonymous by design and speak the Monero wire protocol, not HTTP.",
|
||||
"container": 18080,
|
||||
"host": 18183,
|
||||
"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.",
|
||||
"container": 18089,
|
||||
"host": 18090,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_limit": 0,
|
||||
"disk_limit": "300Gi",
|
||||
"memory_limit": "4Gi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [],
|
||||
"network_policy": "isolated",
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": true
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "github",
|
||||
"repo": "Cuprate/cuprate"
|
||||
},
|
||||
"version": "0.1.0-preview",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/cuprate",
|
||||
"target": "/home/cuprate",
|
||||
"type": "bind"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "0.1.0-preview"
|
||||
},
|
||||
"did-wallet": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
@@ -2375,6 +2469,10 @@
|
||||
"network_policy": "isolated",
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "ghcr",
|
||||
"repo": "immich-app/postgres"
|
||||
},
|
||||
"version": "14-vectorchord0.4.3-pgvectors0.2.0",
|
||||
"volumes": [
|
||||
{
|
||||
@@ -2788,6 +2886,10 @@
|
||||
"network_policy": "isolated",
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "github",
|
||||
"repo": "minio/minio"
|
||||
},
|
||||
"version": "RELEASE.2024-11-07T00-52-20Z",
|
||||
"volumes": [
|
||||
{
|
||||
@@ -3175,6 +3277,10 @@
|
||||
"seccomp_profile": "default",
|
||||
"user": 1000
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "manual",
|
||||
"url": "no public listing for lightninglabs/lightning-stack — verify by hand"
|
||||
},
|
||||
"version": "0.12.0",
|
||||
"volumes": [
|
||||
{
|
||||
@@ -3625,7 +3731,7 @@
|
||||
"key": "/var/lib/archipelago/netbird/tls.key"
|
||||
}
|
||||
],
|
||||
"image": "docker.io/library/nginx:1.31.3-alpine",
|
||||
"image": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"network": "netbird-net",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
@@ -4315,7 +4421,7 @@
|
||||
"key": "/var/lib/archipelago/pine/tls.key"
|
||||
}
|
||||
],
|
||||
"image": "docker.io/library/nginx:1.31.3-alpine",
|
||||
"image": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"network": "archy-net",
|
||||
"network_aliases": [
|
||||
"pine"
|
||||
@@ -4701,6 +4807,10 @@
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "dockerhub",
|
||||
"repo": "rhasspy/wyoming-whisper"
|
||||
},
|
||||
"version": "3.4.2",
|
||||
"volumes": [
|
||||
{
|
||||
@@ -4998,7 +5108,7 @@
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
"image": "dockurr/strfry:1.1.1",
|
||||
"image": "dockurr/strfry:1.1.2",
|
||||
"image_signature": "cosign://...",
|
||||
"pull_policy": "verify-signature"
|
||||
},
|
||||
@@ -5055,7 +5165,7 @@
|
||||
"kind": "github",
|
||||
"repo": "hoytech/strfry"
|
||||
},
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
@@ -5076,7 +5186,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "1.1.1"
|
||||
"version": "1.1.2"
|
||||
},
|
||||
"tailscale": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/tailscale:stable",
|
||||
@@ -5256,7 +5366,7 @@
|
||||
}
|
||||
},
|
||||
"schema": 1,
|
||||
"signature": "97628de24e3ffa17f639c663e19881cf6dea8c79aab272fe9c5442a4e951b3f0d257fee21aa9ce6158e3824b56a337acb71805f6fd34245e48345b86b46ec007",
|
||||
"signature": "da5b6b183ac46c062945c27abdc06affb558e805e1ccf67ac0ee17e5e3dd85cc05a0656dd83bdacb1e1d237445145d00995f55e77209e1cbb2b6d8ce47084e0a",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"updated": "2026-08-19"
|
||||
"updated": "2026-08-30"
|
||||
}
|
||||
|
||||
+19
-21
@@ -1,33 +1,31 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**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."
|
||||
"**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."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.4-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago",
|
||||
"current_version": "1.8.5-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.4-alpha",
|
||||
"sha256": "c4d3a4fdecfdc2a972f808c7f98225b29dc65333418038bb016a5f0bd79d3551",
|
||||
"size_bytes": 63850680
|
||||
"new_version": "1.8.5-alpha",
|
||||
"sha256": "54e91944c6395a53c8ac87ea97f61e8a7fc5ffd133f017180931ba4fa9566239",
|
||||
"size_bytes": 63934504
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.4-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago-frontend-1.8.4-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.4-alpha.tar.gz",
|
||||
"new_version": "1.8.4-alpha",
|
||||
"sha256": "790de85816a7ad49480dc99134022279e4f40b69bd9b0983a6393373a3bdd7cc",
|
||||
"size_bytes": 97641658
|
||||
"current_version": "1.8.5-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago-frontend-1.8.5-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.5-alpha.tar.gz",
|
||||
"new_version": "1.8.5-alpha",
|
||||
"sha256": "a5d773e8225bfd8a34dfc74acf38bdc2c39bc138c62bf9f4137d0efd72cce3e9",
|
||||
"size_bytes": 97657151
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-20",
|
||||
"signature": "94ffb717166c6062ddbea97476942285903d629543bf3b9ec435697a1b8c31243dedb0f3c8f874daf3d9c0974a6342bf42b7e9d34b00680aa63a1d441dbc4805",
|
||||
"release_date": "2026-08-31",
|
||||
"signature": "f0bcec4935588ee428ebabfbb6e08e00dbd42202af92763073a7f98f4785925ca3565bf96fecbdd1695eff77fcf906783fd9053d82730b11c75441c6f5a60b05",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.4-alpha"
|
||||
"version": "1.8.5-alpha"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -191,7 +192,50 @@ def _highest(tags: list[str], current: str = "") -> str:
|
||||
return max(ranked)[1]
|
||||
|
||||
|
||||
FETCHERS = {"github": latest_github, "dockerhub": latest_dockerhub}
|
||||
def latest_gitlab(project: str, current: str = "") -> str:
|
||||
"""Newest release tag for a GitLab `group/project`.
|
||||
|
||||
Some projects publish releases only on GitLab with no GitHub mirror
|
||||
(bark lives at ark-bitcoin/bark and nowhere else). GitLab release tags
|
||||
sometimes carry the project name as a prefix (`bark-0.6.2`); strip it so
|
||||
version ordering can see the number.
|
||||
"""
|
||||
esc = urllib.parse.quote(project, safe="")
|
||||
releases = http_json(
|
||||
f"https://gitlab.com/api/v4/projects/{esc}/releases?per_page=100"
|
||||
)
|
||||
tags = [str(r["tag_name"]) for r in releases]
|
||||
prefix = project.rsplit("/", 1)[-1].lower() + "-"
|
||||
tags = [t[len(prefix):] if t.lower().startswith(prefix) else t for t in tags]
|
||||
return _highest(tags, current)
|
||||
|
||||
|
||||
def latest_ghcr(repo: str, current: str = "") -> str:
|
||||
"""Newest version-like tag on GitHub's container registry.
|
||||
|
||||
Some images exist only on ghcr.io (immich-app/postgres publishes there
|
||||
and nowhere else), so neither the GitHub-release nor the Docker Hub
|
||||
fetcher can see them. Anonymous pull token first, then the tag list —
|
||||
the same handshake any `docker pull ghcr.io/...` performs.
|
||||
"""
|
||||
token = http_json(
|
||||
f"https://ghcr.io/token?scope=repository:{repo}:pull&service=ghcr.io"
|
||||
)["token"]
|
||||
req = urllib.request.Request(
|
||||
f"https://ghcr.io/v2/{repo}/tags/list",
|
||||
headers={"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as res: # noqa: S310
|
||||
tags = [str(t) for t in json.loads(res.read().decode()).get("tags", [])]
|
||||
return _highest(tags, current)
|
||||
|
||||
|
||||
FETCHERS = {
|
||||
"github": latest_github,
|
||||
"dockerhub": latest_dockerhub,
|
||||
"gitlab": latest_gitlab,
|
||||
"ghcr": latest_ghcr,
|
||||
}
|
||||
|
||||
|
||||
# ── Manifest reading ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -37,7 +37,7 @@ MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.3.1"
|
||||
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
|
||||
|
||||
# BTCPay
|
||||
BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.4.2"
|
||||
BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.4.3"
|
||||
NBXPLORER_IMAGE="$ARCHY_REGISTRY/nbxplorer:2.6.0"
|
||||
POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
|
||||
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
|
||||
|
||||
@@ -84,6 +84,60 @@ else
|
||||
warn "unmkinitramfs not installed — skipping initrd live-boot check"
|
||||
fi
|
||||
|
||||
# ── Host crash-capture payload (#144) ──────────────────────────────
|
||||
# Check the built artifact, not just the ISO builder source: otherwise a stale
|
||||
# rootfs.tar can silently omit the packages/configuration while the build passes.
|
||||
ROOTFS="$MNT/archipelago/rootfs.tar"
|
||||
if [ -f "$ROOTFS" ]; then
|
||||
DPKG_STATUS="$(sudo tar -xOf "$ROOTFS" var/lib/dpkg/status 2>/dev/null || true)"
|
||||
for package in kdump-tools kexec-tools makedumpfile rasdaemon; do
|
||||
if awk -v wanted="$package" 'BEGIN { RS=""; FS="\n" }
|
||||
$0 ~ "(^|\\n)Package: " wanted "(\\n|$)" &&
|
||||
$0 ~ "(^|\\n)Status: install ok installed(\\n|$)" { found=1 }
|
||||
END { exit !found }' <<<"$DPKG_STATUS"; then
|
||||
ok "rootfs package installed: $package"
|
||||
else
|
||||
bad "rootfs package missing/not installed: $package"
|
||||
fi
|
||||
done
|
||||
|
||||
KDUMP_DEFAULTS="$(sudo tar -xOf "$ROOTFS" etc/default/kdump-tools 2>/dev/null || true)"
|
||||
if grep -qE '^USE_KDUMP=.?1' <<<"$KDUMP_DEFAULTS"; then
|
||||
ok "rootfs enables kdump"
|
||||
else
|
||||
bad "rootfs /etc/default/kdump-tools does not enable kdump"
|
||||
fi
|
||||
if grep -qE '^KDUMP_COREDIR=.?/var/crash' <<<"$KDUMP_DEFAULTS"; then
|
||||
ok "rootfs sends crash dumps to /var/crash"
|
||||
else
|
||||
bad "rootfs kdump target is not /var/crash"
|
||||
fi
|
||||
|
||||
KDUMP_SYSCTL="$(sudo tar -xOf "$ROOTFS" etc/sysctl.d/99-archipelago-kdump.conf 2>/dev/null || true)"
|
||||
for setting in 'kernel.panic = 10' 'kernel.panic_on_oops = 1' \
|
||||
'kernel.hung_task_panic = 1' 'kernel.hardlockup_panic = 1'; do
|
||||
if grep -Fqx "$setting" <<<"$KDUMP_SYSCTL"; then
|
||||
ok "rootfs sysctl: $setting"
|
||||
else
|
||||
bad "rootfs missing sysctl: $setting"
|
||||
fi
|
||||
done
|
||||
else
|
||||
bad "cannot inspect crash-capture payload: missing archipelago/rootfs.tar"
|
||||
fi
|
||||
|
||||
AUTO_INSTALL="$MNT/archipelago/auto-install.sh"
|
||||
if grep -qF 'crashkernel=256M' "$AUTO_INSTALL" 2>/dev/null; then
|
||||
ok "installer writes crashkernel=256M"
|
||||
else
|
||||
bad "installer does not write crashkernel=256M"
|
||||
fi
|
||||
if grep -qF 'Archipelago owns crashkernel sizing in /etc/default/grub' "$AUTO_INSTALL" 2>/dev/null; then
|
||||
ok "installer neutralizes the conflicting Debian kdump GRUB default"
|
||||
else
|
||||
bad "installer does not neutralize the conflicting Debian kdump GRUB default"
|
||||
fi
|
||||
|
||||
# ── Backend binary inside the ISO embeds the expected version ────────
|
||||
# (the v1.4.0-binary-in-a-v1.5-ISO incident: a stale captured binary
|
||||
# shipped and the fleet rejected its fips.yaml on Activate)
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
# C. FM-guards — the concrete failure modes that have bitten the
|
||||
# fleet: port-drift (FM8), secret-completeness (FM2),
|
||||
# orphaned container states (FM9), OTA wedge (FM12)
|
||||
# D. Host capture (#144) — kdump + rasdaemon baseline: crash dumps configured
|
||||
# and reserved, hang policy live, ECC recording running
|
||||
#
|
||||
# Everything here is READ-ONLY: no install/stop/start/uninstall, no service bounce.
|
||||
# Safe to run against a live production node. It is the per-boot building block the
|
||||
@@ -226,6 +228,50 @@ section_c() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ══ Section D — host capture (#144): kdump + rasdaemon ═══════════════════════
|
||||
section_d() {
|
||||
echo
|
||||
echo "== D. Host capture — crash + hardware-error evidence (#144) =="
|
||||
if [[ "$ARCHY_LOCAL" != "1" ]]; then
|
||||
record WARN "kdump + rasdaemon baseline" "remote node — host checks skipped"
|
||||
return
|
||||
fi
|
||||
# D1. kdump enabled in config (image bakes it in; OTA host fixups converge)
|
||||
if grep -qE '^USE_KDUMP=.?1' /etc/default/kdump-tools 2>/dev/null; then
|
||||
record PASS "kdump-tools configured" "USE_KDUMP=1, dumps to /var/crash"
|
||||
else
|
||||
record FAIL "kdump-tools configured" "/etc/default/kdump-tools missing USE_KDUMP=1 — host fixup didn't land"
|
||||
fi
|
||||
# D2. crashkernel reservation — grade the memory the kernel actually
|
||||
# reserved, not merely the first matching cmdline token. Debian's
|
||||
# kdump-tools.cfg used to append a second crashkernel= range after our 256M;
|
||||
# the audit falsely passed while /sys reported only 192M reserved.
|
||||
local crash_size expected_size=$((256 * 1024 * 1024))
|
||||
crash_size=$(cat /sys/kernel/kexec_crash_size 2>/dev/null || echo 0)
|
||||
[[ "$crash_size" =~ ^[0-9]+$ ]] || crash_size=0
|
||||
if (( crash_size >= expected_size )); then
|
||||
record PASS "crashkernel reserved" "$((crash_size / 1024 / 1024))MiB actually reserved"
|
||||
elif (( crash_size > 0 )); then
|
||||
record FAIL "crashkernel reserved" "$((crash_size / 1024 / 1024))MiB reserved; expected >=256MiB (conflicting cmdline?)"
|
||||
elif grep -q 'crashkernel=256M' /etc/default/grub 2>/dev/null; then
|
||||
record WARN "crashkernel reserved" "256M written to GRUB — applies on next reboot"
|
||||
else
|
||||
record FAIL "crashkernel reserved" "no reservation and crashkernel=256M absent from GRUB"
|
||||
fi
|
||||
# D3. hang/panic capture policy — runtime-settable, expected immediately
|
||||
if [[ "$(cat /proc/sys/kernel/hung_task_panic 2>/dev/null)" == "1" ]]; then
|
||||
record PASS "hang-capture policy live" "kernel.hung_task_panic=1"
|
||||
else
|
||||
record FAIL "hang-capture policy live" "kernel.hung_task_panic!=1 — sysctl drop-in not applied"
|
||||
fi
|
||||
# D4. rasdaemon recording hardware errors (ECC/AER events → sqlite)
|
||||
if systemctl is-active --quiet rasdaemon 2>/dev/null; then
|
||||
record PASS "rasdaemon active" "hardware-error events recorded to /var/lib/rasdaemon"
|
||||
else
|
||||
record FAIL "rasdaemon active" "service not running — package missing or host fixup failed"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── run ────────────────────────────────────────────────────────────────────────
|
||||
echo "=============================================================="
|
||||
echo " OS-wide audit — ${BASE_URL} ($(date '+%Y-%m-%d %H:%M:%S'))"
|
||||
@@ -237,6 +283,9 @@ if (( FAIL == 0 )) || [[ -n "$SESSION" ]]; then
|
||||
section_b
|
||||
section_c
|
||||
fi
|
||||
# Host-capture baseline is independent of RPC health: a wedged backend must
|
||||
# not mask that the node also stopped capturing evidence.
|
||||
section_d
|
||||
|
||||
echo
|
||||
echo "=============================================================="
|
||||
|
||||
Reference in New Issue
Block a user