Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
913743923c | ||
|
|
241e8cfca4 | ||
|
|
017505c431 | ||
|
|
7a39d8fbd1 | ||
|
|
e3275353b9 | ||
|
|
9f1a289d1a | ||
|
|
dd07da53f9 | ||
|
|
7d09418a09 | ||
|
|
b927461f8e | ||
|
|
8cf45377a2 | ||
|
|
4efac99e97 | ||
|
|
981296e8b0 | ||
|
|
22f8129b52 | ||
|
|
57e31eb192 | ||
|
|
12c853da45 | ||
|
|
d259f3cbb9 |
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 47
|
versionCode = 48
|
||||||
versionName = "0.5.27"
|
versionName = "0.5.28"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@@ -54,6 +54,15 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="archipelago" android:host="pair" />
|
<data android:scheme="archipelago" android:host="pair" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
|
<!-- 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
|
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) ────────────────────────────────────────────
|
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
|
||||||
|
|
||||||
suspend fun partyListen(): Boolean =
|
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.Groups
|
||||||
import androidx.compose.material.icons.filled.Keyboard
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
import androidx.compose.material.icons.filled.RestartAlt
|
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.material.icons.filled.SportsEsports
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -106,22 +108,62 @@ fun NESMenu(
|
|||||||
onKeyboard: () -> Unit,
|
onKeyboard: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)? = null,
|
onBackToWebView: (() -> Unit)? = null,
|
||||||
onMeshParty: (() -> 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()) {
|
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||||
// Contained hub overlay: a centred glass panel (not full-screen) that
|
// Contained hub overlay: a centred glass panel (not full-screen) that
|
||||||
// holds the card page and its sub-pages (Nodes, FIPS) and scrolls
|
// holds the card page and its sub-pages (Nodes, FIPS, Backup, Signer)
|
||||||
// inside its own bounds when content is tall. Tapping the dimmed
|
// and scrolls inside its own bounds when content is tall. Tapping the
|
||||||
// backdrop dismisses.
|
// dimmed backdrop dismisses.
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
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
|
@Composable
|
||||||
@@ -138,6 +180,9 @@ private fun MenuPanel(
|
|||||||
onKeyboard: () -> Unit,
|
onKeyboard: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)?,
|
onBackToWebView: (() -> Unit)?,
|
||||||
onMeshParty: (() -> Unit)?,
|
onMeshParty: (() -> Unit)?,
|
||||||
|
signerPairUri: String?,
|
||||||
|
onSignerScan: () -> Unit,
|
||||||
|
onSignerPairHandled: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var showAdd by remember { mutableStateOf(false) }
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
// The saved server being edited, or null when adding a new one.
|
// The saved server being edited, or null when adding a new one.
|
||||||
@@ -176,9 +221,10 @@ private fun MenuPanel(
|
|||||||
.widthIn(max = 420.dp)
|
.widthIn(max = 420.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 20.dp)
|
.padding(horizontal = 20.dp)
|
||||||
// Cap height just short of the full screen; the panel wraps short
|
// Cap height at 70% of the screen — a ~15% breathing margin top
|
||||||
// content and only scrolls in the rare case it outgrows this.
|
// and bottom — the panel wraps short content and scrolls inside
|
||||||
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
|
// its own bounds when a sub-page outgrows this.
|
||||||
|
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.70f).dp)
|
||||||
.clip(RoundedCornerShape(PANEL_R))
|
.clip(RoundedCornerShape(PANEL_R))
|
||||||
.background(PanelBg.copy(alpha = 0.86f))
|
.background(PanelBg.copy(alpha = 0.86f))
|
||||||
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||||
@@ -201,7 +247,13 @@ private fun MenuPanel(
|
|||||||
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
|
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Text(
|
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,
|
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -233,6 +285,12 @@ private fun MenuPanel(
|
|||||||
if (onMeshParty != null) {
|
if (onMeshParty != null) {
|
||||||
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
|
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
|
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||||
// the settings button — not here.
|
// the settings button — not here.
|
||||||
|
|
||||||
@@ -272,6 +330,11 @@ private fun MenuPanel(
|
|||||||
val active = server.serialize() == activeServer?.serialize()
|
val active = server.serialize() == activeServer?.serialize()
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label = server.displayName(),
|
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,
|
selected = active,
|
||||||
onClick = { onSelectServer(server) },
|
onClick = { onSelectServer(server) },
|
||||||
onEdit = { startEdit(server) },
|
onEdit = { startEdit(server) },
|
||||||
@@ -391,11 +454,23 @@ private fun MenuPanel(
|
|||||||
HubPage.FIPS -> {
|
HubPage.FIPS -> {
|
||||||
FipsSection(embedded = true)
|
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. */
|
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -582,26 +657,52 @@ private fun MenuItem(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onEdit: (() -> Unit)? = null,
|
onEdit: (() -> Unit)? = null,
|
||||||
onRemove: (() -> Unit)? = null,
|
onRemove: (() -> Unit)? = null,
|
||||||
|
/** Optional second line (the node's mesh ULA); tapping it copies. */
|
||||||
|
subtitle: String? = null,
|
||||||
) {
|
) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.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))
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
.background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg)
|
.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))
|
.border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R))
|
||||||
.clickable { onClick() }
|
.clickable { onClick() }
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp)
|
||||||
|
.then(if (subtitle == null) Modifier else Modifier.padding(vertical = 8.dp)),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text(
|
Column(Modifier.weight(1f)) {
|
||||||
label,
|
Text(
|
||||||
color = if (selected) BitcoinOrange else labelColor,
|
label,
|
||||||
fontSize = 16.sp,
|
color = if (selected) BitcoinOrange else labelColor,
|
||||||
fontWeight = FontWeight.Medium,
|
fontSize = 16.sp,
|
||||||
modifier = Modifier.weight(1f),
|
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) {
|
if (onEdit != null) {
|
||||||
Text(
|
Text(
|
||||||
"✎",
|
"✎",
|
||||||
@@ -623,7 +724,7 @@ private fun MenuItem(
|
|||||||
|
|
||||||
/** Glass text field with centered input text. */
|
/** Glass text field with centered input text. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun GlassField(
|
internal fun GlassField(
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
placeholder: String,
|
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.ServerPreferences
|
||||||
import com.archipelago.app.data.ServerQrParser
|
import com.archipelago.app.data.ServerQrParser
|
||||||
import com.archipelago.app.fips.FipsManager
|
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.FlareScreen
|
||||||
import com.archipelago.app.ui.screens.IntroScreen
|
import com.archipelago.app.ui.screens.IntroScreen
|
||||||
import com.archipelago.app.ui.screens.NodePickerScreen
|
import com.archipelago.app.ui.screens.NodePickerScreen
|
||||||
@@ -133,27 +134,41 @@ fun AppNavHost(
|
|||||||
LaunchedEffect(pairUri) {
|
LaunchedEffect(pairUri) {
|
||||||
val raw = pairUri ?: return@LaunchedEffect
|
val raw = pairUri ?: return@LaunchedEffect
|
||||||
onPairUriConsumed()
|
onPairUriConsumed()
|
||||||
when (val result = ServerQrParser.parse(raw)) {
|
when {
|
||||||
is PairResult.Success -> {
|
// Remote-signer pairing deep link (NIP-46): nostrconnect://…
|
||||||
// Pairing implies the app is installed and in use — skip the intro.
|
// 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()
|
prefs.markIntroSeen()
|
||||||
val merged = prefs.upsertServer(result.server)
|
SignerLaunch.pendingUri.value = raw
|
||||||
FipsManager.registerNode(context, result.fips, merged.displayName())
|
navController.navigate(Routes.WEB_VIEW) {
|
||||||
if (merged.password.isNotBlank()) {
|
popUpTo(0) { inclusive = true }
|
||||||
// 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 -> {
|
else -> when (val result = ServerQrParser.parse(raw)) {
|
||||||
// Invalid or too-new pairing link — ignore; normal startup continues.
|
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.GestureHintOverlay
|
||||||
import com.archipelago.app.ui.components.MeshLoadingScreen
|
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||||
import com.archipelago.app.ui.components.NESMenu
|
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.QrScannerOverlay
|
||||||
import com.archipelago.app.ui.components.SlidingLoader
|
import com.archipelago.app.ui.components.SlidingLoader
|
||||||
import com.archipelago.app.ui.components.WalletQrScannerModal
|
import com.archipelago.app.ui.components.WalletQrScannerModal
|
||||||
@@ -1373,6 +1374,16 @@ fun WebViewScreen(
|
|||||||
// Hub menu overlay — opened by the three-finger hold, drawn above
|
// Hub menu overlay — opened by the three-finger hold, drawn above
|
||||||
// everything (also reachable from the error screen, where switching
|
// everything (also reachable from the error screen, where switching
|
||||||
// servers is exactly what's needed).
|
// 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(
|
NESMenu(
|
||||||
visible = showHubMenu,
|
visible = showHubMenu,
|
||||||
servers = savedServers,
|
servers = savedServers,
|
||||||
@@ -1427,6 +1438,8 @@ fun WebViewScreen(
|
|||||||
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
|
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
|
||||||
onBackToWebView = { showHubMenu = false },
|
onBackToWebView = { showHubMenu = false },
|
||||||
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
|
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
|
// Pairing-QR scan launched from the menu's Nodes page; the menu stays
|
||||||
|
|||||||
Generated
+369
-1
@@ -12,6 +12,17 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -81,17 +92,41 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
|||||||
name = "archy-fips-core"
|
name = "archy-fips-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"argon2",
|
||||||
|
"base64",
|
||||||
|
"bech32",
|
||||||
|
"cbc",
|
||||||
|
"chacha20 0.9.1",
|
||||||
|
"chacha20poly1305",
|
||||||
"fips",
|
"fips",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"hex",
|
"hex",
|
||||||
|
"hkdf",
|
||||||
|
"hmac",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"paranoid-android",
|
"paranoid-android",
|
||||||
|
"secp256k1 0.29.1",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"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]]
|
[[package]]
|
||||||
@@ -124,6 +159,18 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
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]]
|
[[package]]
|
||||||
name = "bech32"
|
name = "bech32"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -172,6 +219,15 @@ version = "2.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -181,6 +237,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "blocking"
|
name = "blocking"
|
||||||
version = "1.6.2"
|
version = "1.6.2"
|
||||||
@@ -200,6 +265,15 @@ version = "1.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbc"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -406,6 +480,17 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.16.0"
|
||||||
@@ -476,7 +561,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"rtnetlink",
|
"rtnetlink",
|
||||||
"secp256k1",
|
"secp256k1 0.30.0",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
@@ -491,6 +576,15 @@ dependencies = [
|
|||||||
"tun",
|
"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]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.33"
|
version = "0.3.33"
|
||||||
@@ -676,6 +770,110 @@ dependencies = [
|
|||||||
"digest",
|
"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]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -692,6 +890,7 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"block-padding",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -788,6 +987,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litemap"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.33"
|
version = "0.4.33"
|
||||||
@@ -954,12 +1159,29 @@ version = "2.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
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]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -988,6 +1210,15 @@ dependencies = [
|
|||||||
"universal-hash",
|
"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]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -1129,6 +1360,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"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]]
|
[[package]]
|
||||||
name = "secp256k1"
|
name = "secp256k1"
|
||||||
version = "0.30.0"
|
version = "0.30.0"
|
||||||
@@ -1272,6 +1512,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1306,6 +1552,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -1355,6 +1612,16 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.1"
|
version = "1.53.1"
|
||||||
@@ -1519,6 +1786,24 @@ version = "0.2.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
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]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -1657,6 +1942,35 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.55"
|
version = "0.8.55"
|
||||||
@@ -1677,12 +1991,66 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
@@ -37,6 +37,35 @@ tracing = "0.1"
|
|||||||
# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start).
|
# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start).
|
||||||
libc = "0.2"
|
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
|
# The JNI surface only exists on Android; host builds skip it and drive the
|
||||||
# mesh module directly (tests).
|
# mesh module directly (tests).
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[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,
|
//! JNI surface for `com.archipelago.app.fips.FipsNative` and
|
||||||
//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as
|
//! `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.
|
//! `{"error": "…"}` so Kotlin never sees a raw exception from native code.
|
||||||
|
|
||||||
use std::sync::Once;
|
use std::sync::Once;
|
||||||
@@ -127,3 +128,177 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson(
|
|||||||
) -> jstring {
|
) -> jstring {
|
||||||
out(&env, mesh::status_json())
|
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:
|
//! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern:
|
||||||
//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`.
|
//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`.
|
||||||
|
|
||||||
|
pub mod backup;
|
||||||
pub mod mesh;
|
pub mod mesh;
|
||||||
|
pub mod nostr;
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod jni_glue;
|
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()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# HANDOFF — deploy companion 0.5.28 (vc48) to the live surfaces
|
||||||
|
|
||||||
|
**For: the agent on archi-dev-box.** Companion 0.5.28 shipped to `main`
|
||||||
|
today (PR #149, merge `9f1a289d` — backup & restore #128, NIP-46 remote
|
||||||
|
signer #139, companion-gated install pitch #61 residual, hub sub-pages).
|
||||||
|
The dev box verified everything it can reach; three live surfaces remain,
|
||||||
|
same shape as the 2026-07-23 deploy handoff
|
||||||
|
([`HANDOFF-2026-07-23-companion-apk-deploy.md`](HANDOFF-2026-07-23-companion-apk-deploy.md)).
|
||||||
|
|
||||||
|
## Already done and verified (do not redo)
|
||||||
|
|
||||||
|
- `neode-ui/public/packages/archipelago-companion.apk` on `main` is
|
||||||
|
**0.5.28 / versionCode 48**, clean build via `Android/ship-companion.sh`,
|
||||||
|
**v1+v2+v3 signatures verified**, meta json refreshed beside it.
|
||||||
|
- Gitea raw-on-main serves it byte-identical:
|
||||||
|
`shasum -a 256` = `fc786b46c704c5752f04fe603371365524c749734f17bd8858cf02fa2dbc34ca`
|
||||||
|
(2 bytes: 28,206,999… file size ≈ 28.2 MB).
|
||||||
|
- The foundation server's **raw-proxy** path already serves 0.5.28 (verified
|
||||||
|
via `https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.json`).
|
||||||
|
- Demo CI (`demo-images.yml`) fired on the push and redeploys the stack via
|
||||||
|
the Portainer webhook — should flip on its own; confirm only.
|
||||||
|
- Signing key unchanged (cert SHA-256 `d622e07e…ec2664d`), so phones update
|
||||||
|
**in place** over any 0.5.27 install.
|
||||||
|
|
||||||
|
## 1. Foundation server static `/packages/` mirror — the real-node QR URL
|
||||||
|
|
||||||
|
`https://source.archipelago-foundation.org/packages/archipelago-companion.apk`
|
||||||
|
is a **static dir** on the release server (openresty; still 0.5.27,
|
||||||
|
last-modified 2026-08-17). This is the exact URL real nodes' companion QR
|
||||||
|
downloads (`DEFAULT_DOWNLOAD_URL` in `CompanionIntroOverlay.vue`) — it must
|
||||||
|
flip before the release is done.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find the webroot once:
|
||||||
|
grep -rl "packages" /etc/openresty /etc/nginx 2>/dev/null
|
||||||
|
find / -name archipelago-companion.apk -not -path '/proc/*' 2>/dev/null
|
||||||
|
|
||||||
|
# Mirror the exact bytes from Gitea raw-on-main (no rebuild, no re-sign):
|
||||||
|
cd <that webroot>
|
||||||
|
curl -fsS -o archipelago-companion.apk http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk
|
||||||
|
curl -fsS -o archipelago-companion.json http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.json
|
||||||
|
shasum -a 256 archipelago-companion.apk
|
||||||
|
# MUST print: fc786b46c704c5752f04fe603371365524c749734f17bd8858cf02fa2dbc34ca
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Node web-bundle redeploys
|
||||||
|
|
||||||
|
Same as 2026-07-23: redeploy the web-ui bundle from current `main` to the
|
||||||
|
active nodes — web root `/opt/archipelago/web-ui/` (NOT a `neode-ui/`
|
||||||
|
subfolder), at minimum every node the user pairs against. The APK rides in
|
||||||
|
the bundle's `packages/` dir, so this is also what makes each node's own
|
||||||
|
served QR download 0.5.28.
|
||||||
|
|
||||||
|
## 3. Confirm the demo flipped
|
||||||
|
|
||||||
|
`curl -s http://146.59.87.168:2100/packages/archipelago-companion.json`
|
||||||
|
should read 0.5.28/48 once CI's Portainer webhook redeploy lands; trigger a
|
||||||
|
stack redeploy if it lags.
|
||||||
|
|
||||||
|
## Final verify (all three must show 0.5.28 / 48)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aapt2 dump badging <downloaded apk> | head -1 # versionCode='48' versionName='0.5.28-debug'
|
||||||
|
apksigner verify -v --min-sdk-version 21 <downloaded apk> | grep scheme # v1/v2/v3 true
|
||||||
|
curl -s https://source.archipelago-foundation.org/packages/archipelago-companion.json
|
||||||
|
curl -s http://146.59.87.168:2100/packages/archipelago-companion.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the user's on-device end-to-end: scan the node's companion QR →
|
||||||
|
installs vc48 in place → hub → Backup & Restore / Remote Signer.
|
||||||
|
Testing notes for the new features live in the closed tracker issues
|
||||||
|
(#61/#128/#139) and `docs/companion-backup-restore.md` /
|
||||||
|
`docs/companion-nip46-remote-signer.md` (the signer's e2e harness:
|
||||||
|
`Android/tools/nip46-test-client.py`).
|
||||||
@@ -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.
|
||||||
@@ -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
|
## Integration sketch
|
||||||
|
|
||||||
> ⚠️ Coordinates and API surface below are from memory and were **not**
|
> Verified 2026-08-31 against Maven Central and the wrapper source
|
||||||
> verified against Maven Central — the machine this was written on had no
|
> (`wrappers/android/zxingcpp/src/main/java/zxingcpp/BarcodeReader.kt` at
|
||||||
> network. Confirm the current artifact version and wrapper API on the first
|
> `io.github.zxing-cpp:android:3.1.1`, the current release). Coordinates and
|
||||||
> online Gradle sync before trusting the snippet.
|
> API below are what the published artifact actually ships.
|
||||||
|
|
||||||
`Android/app/build.gradle.kts`:
|
`Android/app/build.gradle.kts`:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// Replaces com.google.zxing:core for the live-camera path.
|
// 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:
|
`QrCodeAnalyzer` collapses to roughly:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
private val reader = BarcodeReader().apply {
|
private val reader = BarcodeReader(
|
||||||
options = BarcodeReader.Options(
|
options = BarcodeReader.Options(
|
||||||
formats = setOf(BarcodeFormat.QR_CODE),
|
formats = setOf(BarcodeReader.Format.QR_CODE),
|
||||||
tryHarder = true,
|
tryHarder = true,
|
||||||
tryRotate = true,
|
tryRotate = true,
|
||||||
tryInvert = true,
|
tryInvert = true,
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
|
|
||||||
override fun analyze(image: ImageProxy) {
|
override fun analyze(image: ImageProxy) {
|
||||||
try {
|
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
|
Keep `com.google.zxing:core` for now regardless: the still-image path
|
||||||
(`decodeQrFromUri` in `WalletQrScannerModal.kt`, used by "Upload image") and
|
(`decodeQrFromUri` in `WalletQrScannerModal.kt`, used by "Upload image") and
|
||||||
`prewarmQrScanner` both use it, and neither is on the hot path.
|
`prewarmQrScanner` both use it, and neither is on the hot path.
|
||||||
|
|||||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.5.27",
|
"versionName": "0.5.28",
|
||||||
"versionCode": 47
|
"versionCode": 48
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|||||||
import * as QRCode from 'qrcode'
|
import * as QRCode from 'qrcode'
|
||||||
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
|
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
|
||||||
import { companionIntroRequested } from '@/composables/useCompanionIntro'
|
import { companionIntroRequested } from '@/composables/useCompanionIntro'
|
||||||
|
import { isCompanionApp } from '@/utils/openExternal'
|
||||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
@@ -205,10 +206,12 @@ const POST_INTRO_GRACE_MS = 2000
|
|||||||
|
|
||||||
let calmTicker: ReturnType<typeof setInterval> | null = null
|
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
|
// 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.
|
// 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(() => {
|
onMounted(() => {
|
||||||
if (IN_COMPANION_APP) return
|
if (IN_COMPANION_APP) return
|
||||||
@@ -247,9 +250,13 @@ function maybeShow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
|
// 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) => {
|
watch(companionIntroRequested, (requested) => {
|
||||||
if (!requested) return
|
if (!requested) return
|
||||||
companionIntroRequested.value = false
|
companionIntroRequested.value = false
|
||||||
|
if (IN_COMPANION_APP) return
|
||||||
if (calmTicker) {
|
if (calmTicker) {
|
||||||
clearInterval(calmTicker)
|
clearInterval(calmTicker)
|
||||||
calmTicker = null
|
calmTicker = null
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { isCompanionApp } from '@/utils/openExternal'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-view trigger for the Remote Companion intro/pairing modal
|
* Cross-view trigger for the Remote Companion intro/pairing modal
|
||||||
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
|
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
|
||||||
* App Store banner call openCompanionIntro() to pop it on demand — this
|
* App Store banner call openCompanionIntro() to pop it on demand — this
|
||||||
* bypasses the once-per-browser auto-show gate.
|
* 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 const companionIntroRequested = ref(false)
|
||||||
|
|
||||||
export function openCompanionIntro(): void {
|
export function openCompanionIntro(): void {
|
||||||
|
if (isCompanionApp()) return
|
||||||
companionIntroRequested.value = true
|
companionIntroRequested.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Companion app banner — same format as the featured app banner, with a
|
<!-- Companion app banner — same format as the featured app banner, with a
|
||||||
phone mockup rising out of the right edge. Clicking anywhere (or the
|
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
|
<div
|
||||||
|
v-if="showPitch"
|
||||||
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||||
@click="openCompanionIntro()"
|
@click="openCompanionIntro()"
|
||||||
>
|
>
|
||||||
@@ -41,7 +44,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { isCompanionApp } from '@/utils/openExternal'
|
||||||
import { openCompanionIntro } from '@/composables/useCompanionIntro'
|
import { openCompanionIntro } from '@/composables/useCompanionIntro'
|
||||||
|
|
||||||
|
const showPitch = computed(() => !isCompanionApp())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -362,42 +362,6 @@ init()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
<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 -->
|
<!-- v1.8.5-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
@@ -550,7 +514,6 @@ init()
|
|||||||
<p>Known gaps, unchanged from the last release: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.</p>
|
<p>Known gaps, unchanged from the last release: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- v1.7.125-alpha -->
|
<!-- v1.7.125-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
@@ -598,6 +561,7 @@ init()
|
|||||||
<p>Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release.</p>
|
<p>Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- v1.7.122-alpha -->
|
<!-- v1.7.122-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
@@ -862,6 +826,18 @@ init()
|
|||||||
<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.</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.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 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.106-alpha -->
|
<!-- v1.7.106-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
@@ -1581,6 +1557,30 @@ init()
|
|||||||
<p>Grafana lifecycle actions repair missing rootless host listeners on port 3000, and Debian 13 install paths pull security updates from trixie-security during image/install creation.</p>
|
<p>Grafana lifecycle actions repair missing rootless host listeners on port 3000, and Debian 13 install paths pull security updates from trixie-security during image/install creation.</p>
|
||||||
</div>
|
</div>
|
||||||
</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.7.49-alpha -->
|
<!-- v1.7.49-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Validate releases/manifest.json:
|
# Validate the live or a pending release manifest:
|
||||||
# - version matches core/archipelago/Cargo.toml
|
# - version matches core/archipelago/Cargo.toml
|
||||||
# - changelog contains curated release notes, not raw git log output
|
# - changelog contains curated release notes, not raw git log output
|
||||||
# - every component's download_url exists on disk and matches sha256/size
|
# - every component's download_url exists on disk and matches sha256/size
|
||||||
#
|
#
|
||||||
# Run on every push from CI, and also locally before publishing a release:
|
# Run on every push from CI, and also locally before publishing a release:
|
||||||
# scripts/check-release-manifest.sh
|
# scripts/check-release-manifest.sh [path/to/manifest.json]
|
||||||
#
|
#
|
||||||
# Exits non-zero on any mismatch so the release process fails loud.
|
# Exits non-zero on any mismatch so the release process fails loud.
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
MANIFEST="$REPO_ROOT/releases/manifest.json"
|
MANIFEST="${1:-$REPO_ROOT/releases/manifest.json}"
|
||||||
|
[[ "$MANIFEST" = /* ]] || MANIFEST="$REPO_ROOT/$MANIFEST"
|
||||||
|
|
||||||
if [ ! -f "$MANIFEST" ]; then
|
if [ ! -f "$MANIFEST" ]; then
|
||||||
echo "❌ releases/manifest.json missing"
|
echo "❌ manifest missing: $MANIFEST"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -25,6 +26,18 @@ ok() { echo "✅ $*"; }
|
|||||||
MANIFEST_VERSION=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
|
MANIFEST_VERSION=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
|
||||||
CARGO_VERSION=$(grep '^version' "$REPO_ROOT/core/archipelago/Cargo.toml" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
|
CARGO_VERSION=$(grep '^version' "$REPO_ROOT/core/archipelago/Cargo.toml" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
|
||||||
|
|
||||||
|
# A prepared release deliberately leaves the live manifest on the previous
|
||||||
|
# version. Ordinary pushes are therefore harmless: only the publisher promotes
|
||||||
|
# the pending manifest after its assets have been uploaded and downloaded back.
|
||||||
|
if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ] && [ "$MANIFEST" = "$REPO_ROOT/releases/manifest.json" ]; then
|
||||||
|
PENDING="$REPO_ROOT/releases/pending/v${CARGO_VERSION}/manifest.json"
|
||||||
|
if [ -f "$PENDING" ]; then
|
||||||
|
ok "live manifest remains v${MANIFEST_VERSION} while v${CARGO_VERSION} is pending"
|
||||||
|
MANIFEST="$PENDING"
|
||||||
|
MANIFEST_VERSION="$CARGO_VERSION"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then
|
if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then
|
||||||
fail "manifest version ($MANIFEST_VERSION) ≠ Cargo.toml ($CARGO_VERSION)"
|
fail "manifest version ($MANIFEST_VERSION) ≠ Cargo.toml ($CARGO_VERSION)"
|
||||||
fi
|
fi
|
||||||
@@ -105,4 +118,4 @@ for i in $(seq 0 $((COMPONENT_COUNT - 1))); do
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo
|
echo
|
||||||
ok "releases/manifest.json passes all checks — safe to publish v${MANIFEST_VERSION}"
|
ok "$MANIFEST passes all checks — safe to publish v${MANIFEST_VERSION}"
|
||||||
|
|||||||
@@ -261,15 +261,19 @@ content = open('$CHANGELOG_FILE').read()
|
|||||||
pattern = r'## .*?${VERSION}.*?\n(.*?)(?=\n## |\Z)'
|
pattern = r'## .*?${VERSION}.*?\n(.*?)(?=\n## |\Z)'
|
||||||
m = re.search(pattern, content, re.DOTALL)
|
m = re.search(pattern, content, re.DOTALL)
|
||||||
if m:
|
if m:
|
||||||
for line in m.group(1).strip().split('\n')[:10]:
|
for line in m.group(1).splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line:
|
if not line.startswith('- '):
|
||||||
print(line)
|
continue
|
||||||
|
text = line[2:].strip()
|
||||||
|
if text.lower().startswith('validation '):
|
||||||
|
continue
|
||||||
|
print(text)
|
||||||
" 2>/dev/null || echo "")
|
" 2>/dev/null || echo "")
|
||||||
if [ -n "$ENTRIES" ]; then
|
if [ -n "$ENTRIES" ]; then
|
||||||
CHANGELOG=$(echo "$ENTRIES" | python3 -c "
|
CHANGELOG=$(echo "$ENTRIES" | python3 -c "
|
||||||
import sys, json
|
import sys, json
|
||||||
lines = [l.strip().lstrip('- ') for l in sys.stdin if l.strip()]
|
lines = [l.strip() for l in sys.stdin if l.strip()]
|
||||||
print(json.dumps(lines))
|
print(json.dumps(lines))
|
||||||
")
|
")
|
||||||
fi
|
fi
|
||||||
@@ -298,7 +302,7 @@ echo ""
|
|||||||
cat "$OUTPUT_FILE"
|
cat "$OUTPUT_FILE"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next steps:"
|
echo "Next steps:"
|
||||||
echo " 1. Review the manifest above"
|
echo " 1. Review and sign the manifest above"
|
||||||
echo " 2. Upload artifacts to Gitea release v$VERSION"
|
echo " 2. Keep it under releases/pending/v$VERSION/ — do NOT replace the live manifest"
|
||||||
echo " 3. Commit manifest.json to releases/manifest.json on main"
|
echo " 3. Run scripts/publish-release-assets.sh $VERSION gitea-vps2"
|
||||||
echo " 4. Tag the release: git tag v$VERSION && git push --tags"
|
echo " (it uploads + verifies assets before atomically promoting the manifest)"
|
||||||
|
|||||||
+39
-49
@@ -2,7 +2,8 @@
|
|||||||
# create-release.sh — Full release automation for Archipelago
|
# create-release.sh — Full release automation for Archipelago
|
||||||
#
|
#
|
||||||
# Bumps version in Cargo.toml and package.json, generates changelog from git log,
|
# Bumps version in Cargo.toml and package.json, generates changelog from git log,
|
||||||
# creates release manifest, and creates git tag.
|
# creates a pending release manifest, and creates git tag. The live manifest is
|
||||||
|
# promoted only by publish-release-assets.sh after the assets are verified.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/create-release.sh 1.0.0 # Release v1.0.0
|
# ./scripts/create-release.sh 1.0.0 # Release v1.0.0
|
||||||
@@ -30,9 +31,9 @@ for arg in "$@"; do
|
|||||||
echo " 2. Bump version in Cargo.toml and package.json"
|
echo " 2. Bump version in Cargo.toml and package.json"
|
||||||
echo " 3. Build backend"
|
echo " 3. Build backend"
|
||||||
echo " 4. Build frontend"
|
echo " 4. Build frontend"
|
||||||
echo " 5. Generate changelog from git log"
|
echo " 5. Validate the curated changelog"
|
||||||
echo " 6. Create release manifest"
|
echo " 6. Create pending release manifest"
|
||||||
echo " 7. Commit version bump"
|
echo " 7. Commit release preparation"
|
||||||
echo " 8. Create git tag v{VERSION}"
|
echo " 8. Create git tag v{VERSION}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
@@ -121,14 +122,13 @@ if $DRY_RUN; then
|
|||||||
echo " 2. Update neode-ui/package.json version to $VERSION"
|
echo " 2. Update neode-ui/package.json version to $VERSION"
|
||||||
echo " 3. Build backend (cargo build --release -p archipelago)"
|
echo " 3. Build backend (cargo build --release -p archipelago)"
|
||||||
echo " 4. Build frontend (npm run build)"
|
echo " 4. Build frontend (npm run build)"
|
||||||
echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
|
echo " 5. Validate the curated changelog"
|
||||||
echo " 6. Create release manifest"
|
echo " 6. Create pending release manifest (the live manifest stays unchanged)"
|
||||||
echo " 7. Commit: 'chore: release v${VERSION}'"
|
echo " 7. Commit: 'chore: prepare release v${VERSION}'"
|
||||||
echo " 8. Tag: v${VERSION}"
|
echo " 8. Tag: v${VERSION}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "After this script, you would:"
|
echo "After this script, publish only with:"
|
||||||
echo " - Push: git push && git push --tags"
|
echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2"
|
||||||
echo " - Build ISOs on server: ssh archipelago@192.0.2.10"
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -214,9 +214,13 @@ if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE"
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[6/8] Creating release manifest..."
|
echo "[6/8] Creating pending release manifest..."
|
||||||
mkdir -p "$PROJECT_ROOT/releases"
|
# Never write the fleet-visible path here. A normal `git push main` must not be
|
||||||
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
|
# capable of advertising assets which have not been uploaded yet.
|
||||||
|
PENDING_DIR="$PROJECT_ROOT/releases/pending/v${VERSION}"
|
||||||
|
PENDING_MANIFEST="$PENDING_DIR/manifest.json"
|
||||||
|
mkdir -p "$PENDING_DIR"
|
||||||
|
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PENDING_MANIFEST" 2>&1 | grep -v "^$"
|
||||||
|
|
||||||
# §A supply-chain: the OTA manifest must carry the release-root signature.
|
# §A supply-chain: the OTA manifest must carry the release-root signature.
|
||||||
# Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh
|
# Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh
|
||||||
@@ -239,60 +243,45 @@ if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then
|
|||||||
echo " Enter by itself will NOT submit; pasting twice concatenates"
|
echo " Enter by itself will NOT submit; pasting twice concatenates"
|
||||||
echo " the phrases and fails on word count."
|
echo " the phrases and fails on word count."
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
"$SIGNER" ceremony sign "$PROJECT_ROOT/releases/manifest.json"
|
"$SIGNER" ceremony sign "$PENDING_MANIFEST"
|
||||||
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json"
|
"$SIGNER" ceremony verify "$PENDING_MANIFEST"
|
||||||
else
|
else
|
||||||
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — manifest left UNSIGNED."
|
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — pending manifest left UNSIGNED."
|
||||||
echo " This run will ABORT before committing (step 7 refuses an unsigned"
|
echo " This run will ABORT before committing (step 7 refuses an unsigned manifest)."
|
||||||
echo " manifest), because nodes read releases/manifest.json from branch main"
|
echo " Sign it, then re-run: bash scripts/sign-manifest.sh $PENDING_MANIFEST"
|
||||||
echo " and would refuse to auto-apply it."
|
|
||||||
echo " Sign it, then re-run: bash scripts/sign-manifest.sh"
|
|
||||||
fi
|
fi
|
||||||
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
|
|
||||||
|
|
||||||
echo "[6c/8] Staging release artifacts for validation..."
|
echo "[6c/8] Staging release artifacts for validation..."
|
||||||
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
||||||
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
|
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
|
||||||
mkdir -p "$VERSION_DIR"
|
mkdir -p "$VERSION_DIR"
|
||||||
install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago"
|
install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago"
|
||||||
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
||||||
"$SCRIPT_DIR/check-release-manifest.sh"
|
"$SCRIPT_DIR/check-release-manifest.sh" "$PENDING_MANIFEST"
|
||||||
|
|
||||||
# §A supply-chain gate, mirroring publish-release-assets.sh — but EARLIER,
|
# §A supply-chain gate, mirroring publish-release-assets.sh. The pending path
|
||||||
# because publishing is not the first way an unsigned manifest reaches the
|
# prevents an ordinary main push from exposing the release, but an unsigned
|
||||||
# fleet. Nodes fetch releases/manifest.json straight from branch `main`
|
# manifest is still unpublishable and must never be tagged as ready.
|
||||||
# (see the verification URLs printed below), so the COMMIT is what exposes
|
|
||||||
# it, not the publish. publish-release-assets.sh refusing to ship is a
|
|
||||||
# backstop that arrives one step too late: by then the unsigned manifest is
|
|
||||||
# already on main and the fleet is already refusing to auto-apply.
|
|
||||||
#
|
|
||||||
# This is why every cycle needed a manual catch. The signing block above is
|
|
||||||
# conditional — no TTY and no RELEASE_MASTER_MNEMONIC means it prints a
|
|
||||||
# warning and falls through — and the commit then happened anyway. A release
|
|
||||||
# commit carrying a manifest no node will accept has no valid use, so refuse
|
|
||||||
# to create one rather than leave a tag that has to be re-cut.
|
|
||||||
# Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed
|
# Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed
|
||||||
# with the old root (z6Mkkid…q7ur) — it is the release that installed this
|
# with the old root (z6Mkkid…q7ur) — it is the release that installed this
|
||||||
# pin on every node. From v1.7.123 onward the new root signs, and nodes
|
# pin on every node. From v1.7.123 onward the new root signs, and nodes
|
||||||
# running .122+ reject anything signed with the old key.
|
# running .122+ reject anything signed with the old key.
|
||||||
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
|
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
|
||||||
if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|
if ! grep -q '"signature":' "$PENDING_MANIFEST" \
|
||||||
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then
|
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PENDING_MANIFEST"; then
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "Error: releases/manifest.json is NOT signed by the release root." >&2
|
echo "Error: the pending manifest is NOT signed by the release root." >&2
|
||||||
echo " Refusing to commit — nodes read this file from branch main and will" >&2
|
echo " Refusing to commit an unpublishable release." >&2
|
||||||
echo " refuse to auto-apply it, so the release would be dead on arrival." >&2
|
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo " Sign it, then re-run this script:" >&2
|
echo " Sign it, then re-run this script:" >&2
|
||||||
echo " bash scripts/sign-manifest.sh" >&2
|
echo " bash scripts/sign-manifest.sh $PENDING_MANIFEST" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo " (Signing needs a TTY for the mnemonic prompt, or RELEASE_MASTER_MNEMONIC set.)" >&2
|
echo " (Signing needs a TTY for the mnemonic prompt, or RELEASE_MASTER_MNEMONIC set.)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
|
"$SIGNER" ceremony verify "$PENDING_MANIFEST" \
|
||||||
|| { echo "Error: manifest signature failed cryptographic verification — refusing to commit" >&2; exit 1; }
|
|| { echo "Error: manifest signature failed cryptographic verification — refusing to commit" >&2; exit 1; }
|
||||||
|
|
||||||
echo "[7/8] Committing version bump..."
|
echo "[7/8] Committing release preparation..."
|
||||||
git -C "$PROJECT_ROOT" add \
|
git -C "$PROJECT_ROOT" add \
|
||||||
core/archipelago/Cargo.toml \
|
core/archipelago/Cargo.toml \
|
||||||
core/Cargo.lock \
|
core/Cargo.lock \
|
||||||
@@ -300,15 +289,16 @@ git -C "$PROJECT_ROOT" add \
|
|||||||
neode-ui/package-lock.json \
|
neode-ui/package-lock.json \
|
||||||
neode-ui/public/catalog.json \
|
neode-ui/public/catalog.json \
|
||||||
CHANGELOG.md \
|
CHANGELOG.md \
|
||||||
releases/manifest.json \
|
|
||||||
release-manifest.json \
|
|
||||||
2>/dev/null || true
|
2>/dev/null || true
|
||||||
|
# releases/** is ignored because binaries live in Gitea attachments; force-add
|
||||||
|
# only this small signed pending manifest.
|
||||||
|
git -C "$PROJECT_ROOT" add -f "releases/pending/v${VERSION}/manifest.json"
|
||||||
# Cargo.lock (rewritten by the release build after the version bump) and
|
# Cargo.lock (rewritten by the release build after the version bump) and
|
||||||
# neode-ui/public/catalog.json (regenerated by the frontend build) belong in
|
# neode-ui/public/catalog.json (regenerated by the frontend build) belong in
|
||||||
# THIS commit: leaving them dirty failed build-iso-release.sh's clean-tree
|
# THIS commit: leaving them dirty failed build-iso-release.sh's clean-tree
|
||||||
# preflight on three consecutive releases (.127-.129, 2026-08-09/10).
|
# preflight on three consecutive releases (.127-.129, 2026-08-09/10).
|
||||||
|
|
||||||
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
|
git -C "$PROJECT_ROOT" commit -m "chore: prepare release v${VERSION}"
|
||||||
|
|
||||||
echo "[8/8] Creating git tag..."
|
echo "[8/8] Creating git tag..."
|
||||||
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
|
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
|
||||||
@@ -319,8 +309,8 @@ echo ""
|
|||||||
echo "Artifacts:"
|
echo "Artifacts:"
|
||||||
echo " - Version bumped in Cargo.toml and package.json"
|
echo " - Version bumped in Cargo.toml and package.json"
|
||||||
echo " - Changelog updated in CHANGELOG.md"
|
echo " - Changelog updated in CHANGELOG.md"
|
||||||
echo " - Release manifest: releases/manifest.json"
|
echo " - Pending manifest: releases/pending/v${VERSION}/manifest.json"
|
||||||
echo " - Release manifest copy: release-manifest.json"
|
echo " - Live manifest: unchanged until assets pass publication verification"
|
||||||
echo " - Staged artifacts: releases/v${VERSION}/"
|
echo " - Staged artifacts: releases/v${VERSION}/"
|
||||||
echo " - Git tag: v${VERSION}"
|
echo " - Git tag: v${VERSION}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -16,14 +16,26 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|||||||
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
||||||
BACKEND="$VERSION_DIR/archipelago"
|
BACKEND="$VERSION_DIR/archipelago"
|
||||||
FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
||||||
|
PENDING_MANIFEST="$PROJECT_ROOT/releases/pending/v${VERSION}/manifest.json"
|
||||||
|
LIVE_MANIFEST="$PROJECT_ROOT/releases/manifest.json"
|
||||||
|
if [ -f "$PENDING_MANIFEST" ]; then
|
||||||
|
MANIFEST="$PENDING_MANIFEST"
|
||||||
|
PROMOTE_MANIFEST=1
|
||||||
|
else
|
||||||
|
# Backward compatibility for releases prepared before pending manifests.
|
||||||
|
MANIFEST="$LIVE_MANIFEST"
|
||||||
|
PROMOTE_MANIFEST=0
|
||||||
|
fi
|
||||||
|
|
||||||
fail() { echo "Error: $*" >&2; exit 1; }
|
fail() { echo "Error: $*" >&2; exit 1; }
|
||||||
|
|
||||||
[ -f "$PROJECT_ROOT/releases/manifest.json" ] || fail "releases/manifest.json missing"
|
[ -f "$MANIFEST" ] || fail "release manifest missing: $MANIFEST"
|
||||||
|
MANIFEST_VERSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST")
|
||||||
|
[ "$MANIFEST_VERSION" = "$VERSION" ] || fail "requested v$VERSION but $MANIFEST describes v$MANIFEST_VERSION"
|
||||||
[ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND"
|
[ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND"
|
||||||
[ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND"
|
[ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND"
|
||||||
|
|
||||||
"$SCRIPT_DIR/check-release-manifest.sh"
|
"$SCRIPT_DIR/check-release-manifest.sh" "$MANIFEST"
|
||||||
|
|
||||||
# §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes
|
# §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes
|
||||||
# with the pinned release-root anchor refuse to auto-apply unsigned manifests,
|
# with the pinned release-root anchor refuse to auto-apply unsigned manifests,
|
||||||
@@ -32,11 +44,11 @@ fail() { echo "Error: $*" >&2; exit 1; }
|
|||||||
# Release root ROTATED 2026-08-05; see create-release.sh. New root from
|
# Release root ROTATED 2026-08-05; see create-release.sh. New root from
|
||||||
# v1.7.123 onward.
|
# v1.7.123 onward.
|
||||||
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
|
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
|
||||||
grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|
grep -q '"signature":' "$MANIFEST" \
|
||||||
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \
|
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$MANIFEST" \
|
||||||
|| fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh"
|
|| fail "$MANIFEST is not signed by the release root — run: bash scripts/sign-manifest.sh $MANIFEST"
|
||||||
if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
|
if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
|
||||||
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
|
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$MANIFEST" \
|
||||||
|| fail "manifest signature failed cryptographic verification"
|
|| fail "manifest signature failed cryptographic verification"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -130,12 +142,36 @@ echo "Verifying public download URLs (full GET + size + sha256)..."
|
|||||||
# hand during recovery. It fails hard on the first bad asset — the previous
|
# hand during recovery. It fails hard on the first bad asset — the previous
|
||||||
# inline `while read` ran in a pipe subshell, where a `fail` (exit) killed only
|
# inline `while read` ran in a pipe subshell, where a `fail` (exit) killed only
|
||||||
# the subshell and let this script march on to "published and verified".
|
# the subshell and let this script march on to "published and verified".
|
||||||
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$PROJECT_ROOT/releases/manifest.json" \
|
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$MANIFEST" \
|
||||||
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
|
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
|
||||||
|
|
||||||
# Assets are proven fetchable — only now does the manifest become live.
|
# Assets are proven fetchable — only now may the manifest become live. First
|
||||||
echo "Assets verified. Pushing main to $REMOTE (this makes v${VERSION} live)..."
|
# incorporate concurrent work, then promote in a dedicated commit. Until the
|
||||||
git -C "$PROJECT_ROOT" push "$REMOTE" main
|
# final push succeeds the remote still serves the previous manifest.
|
||||||
|
echo "Assets verified. Synchronizing main before manifest promotion..."
|
||||||
|
git -C "$PROJECT_ROOT" fetch "$REMOTE" main
|
||||||
|
git -C "$PROJECT_ROOT" merge --no-edit "$REMOTE/main"
|
||||||
|
|
||||||
|
if [ "$PROMOTE_MANIFEST" = "1" ]; then
|
||||||
|
cp "$MANIFEST" "$LIVE_MANIFEST"
|
||||||
|
cp "$MANIFEST" "$PROJECT_ROOT/release-manifest.json"
|
||||||
|
git -C "$PROJECT_ROOT" add releases/manifest.json release-manifest.json
|
||||||
|
git -C "$PROJECT_ROOT" rm -f -- "releases/pending/v${VERSION}/manifest.json"
|
||||||
|
git -C "$PROJECT_ROOT" commit -m "chore: publish release v${VERSION}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Publishing verified manifest to main (this makes v${VERSION} live)..."
|
||||||
|
# A concurrent push can race the fetch above. Merge and retry without ever
|
||||||
|
# force-pushing; the remote remains on its old, working manifest meanwhile.
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if git -C "$PROJECT_ROOT" push "$REMOTE" HEAD:main; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
[ "$attempt" -lt 3 ] || fail "main advanced repeatedly; assets are safe but manifest was not promoted"
|
||||||
|
echo "main advanced during publication; merging and retrying..."
|
||||||
|
git -C "$PROJECT_ROOT" fetch "$REMOTE" main
|
||||||
|
git -C "$PROJECT_ROOT" merge --no-edit "$REMOTE/main"
|
||||||
|
done
|
||||||
|
|
||||||
echo "Release v${VERSION} published and verified on $REMOTE."
|
echo "Release v${VERSION} published and verified on $REMOTE."
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# One-step OTA-manifest signer (counterpart to sign-catalog.sh).
|
# One-step OTA-manifest signer (counterpart to sign-catalog.sh).
|
||||||
#
|
#
|
||||||
# Run: bash scripts/sign-manifest.sh
|
# Run: bash scripts/sign-manifest.sh [path/to/manifest.json]
|
||||||
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
|
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
|
||||||
#
|
#
|
||||||
# Signs releases/manifest.json in place and cryptographically verifies the
|
# Signs the requested manifest (live by default) and cryptographically verifies the
|
||||||
# result against the pinned release-root anchor. The mnemonic is read from the
|
# result against the pinned release-root anchor. The mnemonic is read from the
|
||||||
# terminal only (never stored, never in shell history, never passed to Claude).
|
# terminal only (never stored, never in shell history, never passed to Claude).
|
||||||
#
|
#
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
MANIFEST="$REPO/releases/manifest.json"
|
MANIFEST="${1:-$REPO/releases/manifest.json}"
|
||||||
|
[[ "$MANIFEST" = /* ]] || MANIFEST="$REPO/$MANIFEST"
|
||||||
|
[ -f "$MANIFEST" ] || { echo "Manifest not found: $MANIFEST" >&2; exit 1; }
|
||||||
|
|
||||||
# Use ONLY a prebuilt signer — never compile here (compiling caused hangs in
|
# Use ONLY a prebuilt signer — never compile here (compiling caused hangs in
|
||||||
# the earlier catalog ceremony). Prefer the repo's release build.
|
# the earlier catalog ceremony). Prefer the repo's release build.
|
||||||
@@ -41,9 +43,10 @@ echo "════════════════════════
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
if "$BIN" ceremony verify "$MANIFEST"; then
|
if "$BIN" ceremony verify "$MANIFEST"; then
|
||||||
echo "✅ SUCCESS — manifest signed by the pinned release root."
|
echo "✅ SUCCESS — manifest signed by the pinned release root: $MANIFEST"
|
||||||
echo " Commit + push releases/manifest.json (and release-manifest.json if present)."
|
if [ "$MANIFEST" = "$REPO/releases/manifest.json" ]; then
|
||||||
cp "$MANIFEST" "$REPO/release-manifest.json" 2>/dev/null || true
|
cp "$MANIFEST" "$REPO/release-manifest.json"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "❌ Signature did NOT verify against the pinned release-root anchor."
|
echo "❌ Signature did NOT verify against the pinned release-root anchor."
|
||||||
echo " Do NOT commit. Check the mnemonic and re-run."
|
echo " Do NOT commit. Check the mnemonic and re-run."
|
||||||
|
|||||||
+74
-22
@@ -67,9 +67,53 @@ def undated_versions():
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
def existing_versions():
|
def ordered_versions():
|
||||||
text = MODAL.read_text()
|
"""Return modal versions in display order (top to bottom)."""
|
||||||
return set(re.findall(r"<!-- (v\d+\.\d+\.\d+\S*) -->", text))
|
return re.findall(r"<!-- (v\d+\.\d+\.\d+\S*) -->", MODAL.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def version_key(version):
|
||||||
|
match = re.match(r"v(\d+)\.(\d+)\.(\d+)", version)
|
||||||
|
return tuple(map(int, match.groups()))
|
||||||
|
|
||||||
|
|
||||||
|
def sort_modal_blocks():
|
||||||
|
"""Sort complete release-note blocks newest-first, preserving gaps."""
|
||||||
|
lines = MODAL.read_text().splitlines(keepends=True)
|
||||||
|
marker = re.compile(r"^\s*<!-- (v\d+\.\d+\.\d+\S*) -->\s*$")
|
||||||
|
blocks = []
|
||||||
|
|
||||||
|
for start, line in enumerate(lines):
|
||||||
|
match = marker.match(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
depth = 0
|
||||||
|
opened = False
|
||||||
|
for index in range(start + 1, len(lines)):
|
||||||
|
for tag in re.findall(r"</?div\b[^>]*>", lines[index]):
|
||||||
|
if tag.startswith("</"):
|
||||||
|
depth -= 1
|
||||||
|
else:
|
||||||
|
depth += 1
|
||||||
|
opened = True
|
||||||
|
if opened and depth == 0:
|
||||||
|
blocks.append((start, index + 1, match.group(1), lines[start:index + 1]))
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"unclosed What's New block for {match.group(1)}")
|
||||||
|
|
||||||
|
sorted_segments = [b[3] for b in sorted(blocks, key=lambda b: version_key(b[2]), reverse=True)]
|
||||||
|
output = []
|
||||||
|
cursor = 0
|
||||||
|
for (start, end, _version, _segment), replacement in zip(blocks, sorted_segments):
|
||||||
|
output.extend(lines[cursor:start])
|
||||||
|
output.extend(replacement)
|
||||||
|
cursor = end
|
||||||
|
output.extend(lines[cursor:])
|
||||||
|
changed = output != lines
|
||||||
|
if changed:
|
||||||
|
MODAL.write_text("".join(output))
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def to_html(text):
|
def to_html(text):
|
||||||
@@ -113,36 +157,44 @@ def main():
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
entries = parse_changelog()
|
entries = parse_changelog()
|
||||||
have = existing_versions()
|
displayed = ordered_versions()
|
||||||
|
have = set(displayed)
|
||||||
missing = [e for e in entries if e["ver"] not in have]
|
missing = [e for e in entries if e["ver"] not in have]
|
||||||
|
expected_order = sorted(displayed, key=version_key, reverse=True)
|
||||||
|
out_of_order = displayed != expected_order
|
||||||
|
|
||||||
if not missing:
|
if not missing and not out_of_order:
|
||||||
print("What's New modal is in sync with CHANGELOG.md "
|
print("What's New modal is in sync with CHANGELOG.md "
|
||||||
f"({len(entries)} changelog versions, all present).")
|
f"({len(entries)} changelog versions, all present and newest-first).")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
names = ", ".join(e["ver"] for e in missing)
|
names = ", ".join(e["ver"] for e in missing)
|
||||||
if check:
|
if check:
|
||||||
print("FAIL: these CHANGELOG versions have no block in the Settings "
|
if missing:
|
||||||
f"What's New modal: {names}", file=sys.stderr)
|
print("FAIL: these CHANGELOG versions have no block in the Settings "
|
||||||
|
f"What's New modal: {names}", file=sys.stderr)
|
||||||
|
if out_of_order:
|
||||||
|
print("FAIL: What's New entries are not newest-first; the modal currently "
|
||||||
|
f"opens at {displayed[0]} instead of {expected_order[0]}", file=sys.stderr)
|
||||||
print("Run: python3 scripts/sync-whats-new.py", file=sys.stderr)
|
print("Run: python3 scripts/sync-whats-new.py", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Insert missing blocks newest-first, immediately before the newest existing
|
if missing:
|
||||||
# block marker (the first "<!-- v... -->" line in the file).
|
# Insert before the first block; the full sort below makes this safe even
|
||||||
lines = MODAL.read_text().splitlines(keepends=True)
|
# when a historical hand-written block was accidentally left at the top.
|
||||||
marker = re.compile(r"^\s*<!-- v\d+\.\d+\.\d+\S* -->\s*$")
|
lines = MODAL.read_text().splitlines(keepends=True)
|
||||||
idx = next((i for i, ln in enumerate(lines) if marker.match(ln)), None)
|
marker = re.compile(r"^\s*<!-- v\d+\.\d+\.\d+\S* -->\s*$")
|
||||||
if idx is None:
|
idx = next((i for i, ln in enumerate(lines) if marker.match(ln)), None)
|
||||||
print("ERROR: could not find an existing version block marker in the modal.",
|
if idx is None:
|
||||||
file=sys.stderr)
|
print("ERROR: could not find an existing version block marker in the modal.",
|
||||||
return 2
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
lines.insert(idx, "".join(render_block(e) for e in missing))
|
||||||
|
MODAL.write_text("".join(lines))
|
||||||
|
print(f"Inserted {len(missing)} block(s): {names}")
|
||||||
|
|
||||||
# newest-first: sort missing by their order in `entries` (already newest-first)
|
if sort_modal_blocks():
|
||||||
block_text = "".join(render_block(e) for e in missing)
|
print("Sorted What's New blocks newest-first.")
|
||||||
lines.insert(idx, block_text)
|
|
||||||
MODAL.write_text("".join(lines))
|
|
||||||
print(f"Inserted {len(missing)} block(s): {names}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user