Companion 0.5.28 (versionCode 48), the companion-agent queue items: #128 Backup & Restore — the phone side of losing your phone or wiping it to cross a border. Hub card → SAF export/import of an encrypted .json: everything the app holds (servers+passwords, FIPS identity/peers, signer key) sealed in the node's ADR-005 envelope (Argon2id + ChaCha20-Poly1305, native backup.rs — same blob layout as the node's, node-shaped envelopes decrypt too). Restore is merge-only: servers upsert npub-first, identity and signer key adopt only when absent, peers union by npub. No cloud, no telemetry — the file goes wherever the user saves it. #139 Remote Signer — the phone IS the NIP-46 bunker. Generate/import a nostr key, scan a nostrconnect:// QR (in-app scanner or deep link), and approve/deny each sign_event request from a legible card (kind label, content, tags, time) — nothing signs without a human. Wire-faithful to rust-nostr's reference bunker (connect-carrying-secret handshake, NIP-44 v2 transport with NIP-04 receive fallback, kind-24133 responses); get_public_key/describe/ping handled, everything else 'not authorized'. Session state in BunkerManager, UI in SignerScreen, hub card wired. Plus NativeCore (JNI object for the new native surface), FipsPreferences peers-merge for restore, nostrconnect:// intent filter, and the release docs (companion-backup-restore.md, companion-nip46-remote-signer.md). Also Android/tools/nip46-test-client.py: a pure-Python NIP-46 client that plays the node's login role (QR, handshake, get_public_key, sign_event) and verifies the phone's signature with an independent BIP-340 — the end-to-end test for the feature until node-side lands. Its crypto matches the official NIP-44 + BIP-340 vectors byte-for-byte, the same vectors the Rust core passes, so the two interop by construction. Built + smoke: assembleDebug v0.5.28/vc48, same signing cert as the served 0.5.27 (d622e07e…644d) so it updates in place.
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 47
|
||||
versionName = "0.5.27"
|
||||
versionCode = 48
|
||||
versionName = "0.5.28"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -54,6 +54,15 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="archipelago" android:host="pair" />
|
||||
</intent-filter>
|
||||
<!-- Remote-signer pairing deep link (NIP-46, companion 0.5.28):
|
||||
nostrconnect://<client-pubkey>?relay=...&secret=... — the
|
||||
node's login QR, hand-off from any QR scanner app. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostrconnect" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.archipelago.app
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* JNI binding to the companion's non-mesh native surface (same
|
||||
* libarchy_fips_core.so as FipsNative — backup + nostr signer crypto, built
|
||||
* from Android/rust/archy-fips-core).
|
||||
*
|
||||
* Same contract as FipsNative: JSON over strings, failures come back as
|
||||
* {"error": "…"} rather than exceptions, and [available] is false on ABIs
|
||||
* the .so isn't built for so every caller can degrade gracefully.
|
||||
*/
|
||||
object NativeCore {
|
||||
val available: Boolean = try {
|
||||
System.loadLibrary("archy_fips_core")
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
// ── Backup (#128): the node's ADR-005 envelope ──────────────────────────
|
||||
|
||||
/** Encrypt a JSON payload into an ADR-005 envelope (ChaCha20-Poly1305). */
|
||||
external fun backupEncrypt(payload: String, passphrase: String): String
|
||||
|
||||
/** Decrypt an ADR-005 envelope back to its payload JSON. */
|
||||
external fun backupDecrypt(envelope: String, passphrase: String): String
|
||||
|
||||
// ── NIP-46 remote signer (#139) ─────────────────────────────────────────
|
||||
|
||||
/** Generate a fresh nostr key: {"secret","pubkey","npub","nsec"}. */
|
||||
external fun nostrGenerateSecret(): String
|
||||
|
||||
/** Import a key from hex or nsec…: {"secret","pubkey","npub","nsec"}. */
|
||||
external fun nostrSecretFromAny(secret: String): String
|
||||
|
||||
/** Parse nostrconnect://…: {"clientPubkey","relays":[…],"secret","perms","name","url","image"}. */
|
||||
external fun nostrParseConnectUri(uri: String): String
|
||||
|
||||
/**
|
||||
* Sign `{kind, content, tags, created_at}` with the signer key: returns
|
||||
* the full signed event JSON. Approval happens BEFORE this call — the
|
||||
* native side never signs unasked.
|
||||
*/
|
||||
external fun nostrSignEvent(secretHex: String, eventJson: String): String
|
||||
|
||||
/** NIP-44 v2 encrypt/decrypt; result JSON: {"result": payload} or {"error": …}. */
|
||||
external fun nostrNip44Encrypt(secretHex: String, peerPub: String, plaintext: String): String
|
||||
external fun nostrNip44Decrypt(secretHex: String, peerPub: String, payload: String): String
|
||||
|
||||
/** NIP-04 fallback (deprecated but still spoken by real clients). */
|
||||
external fun nostrNip04Encrypt(secretHex: String, peerPub: String, plaintext: String): String
|
||||
external fun nostrNip04Decrypt(secretHex: String, peerPub: String, payload: String): String
|
||||
|
||||
/** True when a native reply is an error envelope. */
|
||||
fun isErr(json: String): Boolean = try {
|
||||
JSONObject(json).has("error")
|
||||
} catch (_: Exception) {
|
||||
true
|
||||
}
|
||||
|
||||
/** Error text from a native reply, or a generic message if malformed. */
|
||||
fun errMsg(json: String): String = try {
|
||||
JSONObject(json).optString("error", "native call failed")
|
||||
} catch (_: Exception) {
|
||||
"native call failed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.archipelago.app.data
|
||||
|
||||
import android.content.Context
|
||||
import com.archipelago.app.NativeCore
|
||||
import com.archipelago.app.fips.FipsPreferences
|
||||
import com.archipelago.app.nostr.NostrSignerPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Companion backup & restore (#128) — the phone side of "losing your phone,
|
||||
* or wiping it to cross a border".
|
||||
*
|
||||
* The payload (servers + FIPS identity/peers + signer key + flags) is
|
||||
* serialized to JSON and sealed into the node's ADR-005 envelope (Argon2id +
|
||||
* ChaCha20-Poly1305) by the native core — the SAME envelope the node uses,
|
||||
* not a second format. The passphrase never leaves the encrypt call.
|
||||
*
|
||||
* Transport is deliberately boring: a plain .json file the user saves via
|
||||
* the system file picker (SAF) — on GrapheneOS there is no cloud backup and
|
||||
* there should be none here either; the file goes wherever the user puts it
|
||||
* (USB drive, computer, a folder synced their way).
|
||||
*/
|
||||
class BackupManager(private val context: Context) {
|
||||
|
||||
private val servers = ServerPreferences(context)
|
||||
private val fips = FipsPreferences(context)
|
||||
private val signer = NostrSignerPreferences(context)
|
||||
|
||||
/** Everything the backup captures, for the restore preview UI. */
|
||||
data class PayloadSummary(
|
||||
val serverCount: Int,
|
||||
val hasFipsIdentity: Boolean,
|
||||
val hasSignerKey: Boolean,
|
||||
val appVersion: String,
|
||||
)
|
||||
|
||||
/** What a restore actually did, for the result UI. */
|
||||
data class RestoreResult(
|
||||
val serversRestored: Int,
|
||||
val activeSet: Boolean,
|
||||
val fipsIdentityRestored: Boolean,
|
||||
val signerKeyRestored: Boolean,
|
||||
)
|
||||
|
||||
private fun appVersion(): String = try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: ""
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the encrypted backup envelope. Runs on IO: DataStore reads
|
||||
* plus the Argon2id KDF (tens of ms) + AEAD.
|
||||
*/
|
||||
suspend fun createBackup(passphrase: String): String = withContext(Dispatchers.IO) {
|
||||
require(passphrase.isNotEmpty()) { "passphrase required" }
|
||||
|
||||
val active = servers.activeServer.first()
|
||||
val saved = servers.savedServers.first()
|
||||
val fipsId = fips.identity()
|
||||
val peers = fips.peersJson()
|
||||
val partyPeers = fips.partyPeers()
|
||||
val partyName = fips.partyName()
|
||||
val partyListen = fips.partyListen()
|
||||
val signerSecret = signer.secret()
|
||||
|
||||
val payload = JSONObject().apply {
|
||||
put("app", "archipelago-companion")
|
||||
put("payloadVersion", 1)
|
||||
put("appVersion", appVersion())
|
||||
put("createdAt", System.currentTimeMillis() / 1000)
|
||||
put("servers", JSONArray(saved.map { it.serialize() }))
|
||||
put("active", active?.serialize() ?: JSONObject.NULL)
|
||||
if (fipsId != null) {
|
||||
put("fips", JSONObject().apply {
|
||||
put("secret", fipsId.secret)
|
||||
put("npub", fipsId.npub)
|
||||
put("address", fipsId.address)
|
||||
put("peers", JSONArray(peers))
|
||||
put("partyPeers", JSONArray().apply { partyPeers.forEach { put(JSONObject().apply {
|
||||
put("npub", it.npub); put("ula", it.ula); put("name", it.name)
|
||||
put("ip", it.ip); put("port", it.port)
|
||||
}) } })
|
||||
put("partyName", partyName)
|
||||
put("partyListen", partyListen)
|
||||
})
|
||||
}
|
||||
if (signerSecret != null) {
|
||||
put("signer", JSONObject().apply { put("secret", signerSecret) })
|
||||
}
|
||||
put("flags", JSONObject().apply {
|
||||
put("introSeen", servers.introSeen.first())
|
||||
})
|
||||
}
|
||||
|
||||
val envelope = NativeCore.backupEncrypt(payload.toString(), passphrase)
|
||||
if (NativeCore.isErr(envelope)) throw BackupException(NativeCore.errMsg(envelope))
|
||||
envelope
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek at a decrypted backup (passphrase already checked) to preview what
|
||||
* a restore would do. Does NOT touch any stored state.
|
||||
*/
|
||||
suspend fun readBackup(envelope: String, passphrase: String): Pair<PayloadSummary, JSONObject> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val payload = NativeCore.backupDecrypt(envelope, passphrase)
|
||||
if (NativeCore.isErr(payload)) throw BackupException(NativeCore.errMsg(payload))
|
||||
val obj = JSONObject(payload)
|
||||
if (obj.optString("app") != "archipelago-companion") {
|
||||
throw BackupException("Not a companion backup (this may be a node backup — restore it on the node)")
|
||||
}
|
||||
val summary = PayloadSummary(
|
||||
serverCount = obj.optJSONArray("servers")?.length() ?: 0,
|
||||
hasFipsIdentity = obj.has("fips"),
|
||||
hasSignerKey = obj.has("signer"),
|
||||
appVersion = obj.optString("appVersion", ""),
|
||||
)
|
||||
summary to obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a decrypted backup to this install. Merge semantics — a restore
|
||||
* never silently destroys what's already here:
|
||||
*
|
||||
* - Servers upsert (npub-first, [ServerPreferences.upsertServer]) — same
|
||||
* identity merges, never duplicates.
|
||||
* - The backup's active server is set active only when none is.
|
||||
* - FIPS identity/peers restore only when this phone has none (a phone
|
||||
* that already paired has a live identity the node peers with; swapping
|
||||
* it from a backup would strand the current pairing). Peers merge by
|
||||
* npub otherwise.
|
||||
* - Signer key restores only when none exists locally.
|
||||
*/
|
||||
suspend fun restoreBackup(payload: JSONObject): RestoreResult = withContext(Dispatchers.IO) {
|
||||
val serverArray = payload.optJSONArray("servers") ?: JSONArray()
|
||||
var restored = 0
|
||||
for (i in 0 until serverArray.length()) {
|
||||
val raw = serverArray.optString(i)
|
||||
val entry = ServerEntry.deserialize(raw) ?: continue
|
||||
servers.upsertServer(entry)
|
||||
restored++
|
||||
}
|
||||
|
||||
var activeSet = false
|
||||
val activeStr = if (payload.isNull("active")) null else payload.optString("active", "")
|
||||
val activeEntry = activeStr?.takeIf { it.isNotBlank() }?.let { ServerEntry.deserialize(it) }
|
||||
if (activeEntry != null && servers.activeServer.first() == null) {
|
||||
servers.setActiveServer(activeEntry)
|
||||
activeSet = true
|
||||
}
|
||||
|
||||
// FIPS identity: only adopt when this phone has none.
|
||||
var fipsRestored = false
|
||||
val fipsObj = payload.optJSONObject("fips")
|
||||
if (fipsObj != null && fips.identity() == null) {
|
||||
val secret = fipsObj.optString("secret")
|
||||
if (secret.isNotBlank()) {
|
||||
fips.saveIdentity(
|
||||
com.archipelago.app.fips.FipsNative.Identity(
|
||||
secret = secret,
|
||||
npub = fipsObj.optString("npub"),
|
||||
address = fipsObj.optString("address"),
|
||||
)
|
||||
)
|
||||
fipsRestored = true
|
||||
}
|
||||
// Peers: union by npub with whatever is already here (an empty
|
||||
// store takes the backup's list wholesale).
|
||||
val backupPeers = fipsObj.optJSONArray("peers")?.let { arr ->
|
||||
(0 until arr.length()).joinToString(",", "[", "]") { arr.optString(it) }
|
||||
} ?: "[]"
|
||||
fips.mergePeersJson(backupPeers)
|
||||
|
||||
val partyArr = fipsObj.optJSONArray("partyPeers")
|
||||
if (partyArr != null) {
|
||||
for (i in 0 until partyArr.length()) {
|
||||
val p = partyArr.optJSONObject(i) ?: continue
|
||||
val npub = p.optString("npub")
|
||||
val ula = p.optString("ula")
|
||||
if (npub.isNotBlank() && ula.isNotBlank()) {
|
||||
fips.upsertPartyPeer(
|
||||
com.archipelago.app.fips.PartyPeer(
|
||||
npub = npub, ula = ula,
|
||||
name = p.optString("name").ifBlank { "Phone" },
|
||||
ip = p.optString("ip"), port = p.optInt("port"),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fipsObj.optString("partyName").isNotBlank()) {
|
||||
fips.setPartyName(fipsObj.optString("partyName"))
|
||||
}
|
||||
fips.setPartyListen(fipsObj.optBoolean("partyListen", false))
|
||||
}
|
||||
|
||||
// Signer key: only adopt when none exists locally.
|
||||
var signerRestored = false
|
||||
val signerObj = payload.optJSONObject("signer")
|
||||
if (signerObj != null && signer.secret() == null) {
|
||||
val secret = signerObj.optString("secret")
|
||||
if (secret.isNotBlank()) {
|
||||
signer.saveSecret(secret)
|
||||
signerRestored = true
|
||||
}
|
||||
}
|
||||
|
||||
// Flags: a user who completed the intro on the old phone shouldn't
|
||||
// see it again on the new one.
|
||||
val flags = payload.optJSONObject("flags")
|
||||
if (flags?.optBoolean("introSeen", false) == true) {
|
||||
servers.markIntroSeen()
|
||||
}
|
||||
|
||||
RestoreResult(
|
||||
serversRestored = restored,
|
||||
activeSet = activeSet,
|
||||
fipsIdentityRestored = fipsRestored,
|
||||
signerKeyRestored = signerRestored,
|
||||
)
|
||||
}
|
||||
|
||||
class BackupException(message: String) : Exception(message)
|
||||
}
|
||||
@@ -89,6 +89,38 @@ class FipsPreferences(private val context: Context) {
|
||||
|
||||
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
|
||||
|
||||
/**
|
||||
* Union the stored node peers with a backup's peer list, matched by
|
||||
* npub — the backup's copy wins for the same npub (its addresses are what
|
||||
* the restored identity pairs against). Used by companion restore (#128)
|
||||
* after [saveIdentity] adopted the backup's mesh identity.
|
||||
*/
|
||||
suspend fun mergePeersJson(incomingJson: String) {
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
val current = JSONArray(prefs[peersKey] ?: "[]")
|
||||
val incoming = try {
|
||||
JSONArray(incomingJson)
|
||||
} catch (_: Exception) {
|
||||
JSONArray()
|
||||
}
|
||||
val incomingNpubs = mutableSetOf<String>()
|
||||
val merged = JSONArray()
|
||||
for (i in 0 until incoming.length()) {
|
||||
val peer = incoming.optJSONObject(i) ?: continue
|
||||
val npub = peer.optString("npub")
|
||||
if (npub.isNotBlank()) {
|
||||
incomingNpubs.add(npub)
|
||||
merged.put(peer)
|
||||
}
|
||||
}
|
||||
for (i in 0 until current.length()) {
|
||||
val peer = current.optJSONObject(i) ?: continue
|
||||
if (peer.optString("npub") !in incomingNpubs) merged.put(peer)
|
||||
}
|
||||
prefs[peersKey] = merged.toString()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
|
||||
|
||||
suspend fun partyListen(): Boolean =
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
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)
|
||||
|
||||
fun refreshState(context: Context) {
|
||||
if (!NativeCore.available) {
|
||||
_state.value = SignerState.Unavailable
|
||||
return
|
||||
}
|
||||
if (session.get() != null) return
|
||||
// Called from a LaunchedEffect (IO dispatcher); DataStore read is quick
|
||||
// but still a disk access — never on Main.
|
||||
kotlinx.coroutines.runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material.icons.filled.SettingsBackupRestore
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -106,6 +108,8 @@ fun NESMenu(
|
||||
onKeyboard: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)? = null,
|
||||
onMeshParty: (() -> Unit)? = null,
|
||||
onBackupRestore: (() -> Unit)? = null,
|
||||
onSigner: (() -> Unit)? = null,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
// Contained hub overlay: a centred glass panel (not full-screen) that
|
||||
@@ -118,7 +122,7 @@ fun NESMenu(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
|
||||
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty, onBackupRestore, onSigner)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +142,8 @@ private fun MenuPanel(
|
||||
onKeyboard: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)?,
|
||||
onMeshParty: (() -> Unit)?,
|
||||
onBackupRestore: (() -> Unit)?,
|
||||
onSigner: (() -> Unit)?,
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
// The saved server being edited, or null when adding a new one.
|
||||
@@ -233,6 +239,16 @@ private fun MenuPanel(
|
||||
if (onMeshParty != null) {
|
||||
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
|
||||
}
|
||||
// Backup & Restore (#128): the phone side of losing your phone
|
||||
// or wiping it to cross a border — encrypted export file, no cloud.
|
||||
if (onBackupRestore != null) {
|
||||
HubCard(Icons.Default.SettingsBackupRestore, "Backup & Restore", "Encrypted export for a wiped phone") { onBackupRestore() }
|
||||
}
|
||||
// Remote Signer (#139): hold a nostr key on the phone and
|
||||
// approve/deny remote signature requests (NIP-46).
|
||||
if (onSigner != null) {
|
||||
HubCard(Icons.Default.Key, "Remote Signer", "Approve signatures for your node") { onSigner() }
|
||||
}
|
||||
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||
// the settings button — not here.
|
||||
|
||||
|
||||
@@ -22,12 +22,14 @@ import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.screens.BackupRestoreScreen
|
||||
import com.archipelago.app.ui.screens.FlareScreen
|
||||
import com.archipelago.app.ui.screens.IntroScreen
|
||||
import com.archipelago.app.ui.screens.NodePickerScreen
|
||||
import com.archipelago.app.ui.screens.PartyScreen
|
||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||
import com.archipelago.app.ui.screens.SignerScreen
|
||||
import com.archipelago.app.ui.screens.WebViewScreen
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -41,6 +43,8 @@ object Routes {
|
||||
const val REMOTE_INPUT = "remote_input"
|
||||
const val MESH_PARTY = "mesh_party"
|
||||
const val FLARE = "flare"
|
||||
const val BACKUP_RESTORE = "backup_restore"
|
||||
const val SIGNER = "signer"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,7 +137,14 @@ fun AppNavHost(
|
||||
LaunchedEffect(pairUri) {
|
||||
val raw = pairUri ?: return@LaunchedEffect
|
||||
onPairUriConsumed()
|
||||
when (val result = ServerQrParser.parse(raw)) {
|
||||
when {
|
||||
// Remote-signer pairing deep link (NIP-46): nostrconnect://…
|
||||
// from the node's login QR — any QR scanner app can hand it over.
|
||||
raw.startsWith("nostrconnect://") -> {
|
||||
prefs.markIntroSeen()
|
||||
navController.navigate("${Routes.SIGNER}?uri=${android.net.Uri.encode(raw)}")
|
||||
}
|
||||
else -> when (val result = ServerQrParser.parse(raw)) {
|
||||
is PairResult.Success -> {
|
||||
// Pairing implies the app is installed and in use — skip the intro.
|
||||
prefs.markIntroSeen()
|
||||
@@ -157,6 +168,7 @@ fun AppNavHost(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val startDestination = when {
|
||||
introSeen == false -> Routes.INTRO
|
||||
@@ -259,6 +271,12 @@ fun AppNavHost(
|
||||
onMeshParty = {
|
||||
navController.navigate(Routes.MESH_PARTY)
|
||||
},
|
||||
onBackupRestore = {
|
||||
navController.navigate(Routes.BACKUP_RESTORE)
|
||||
},
|
||||
onSigner = {
|
||||
navController.navigate(Routes.SIGNER)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -295,5 +313,28 @@ fun AppNavHost(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.BACKUP_RESTORE) {
|
||||
BackupRestoreScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"${Routes.SIGNER}?uri={uri}",
|
||||
arguments = listOf(
|
||||
navArgument("uri") {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
),
|
||||
) { entry ->
|
||||
SignerScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
initialPairUri = entry.arguments?.getString("uri")?.let { uri ->
|
||||
if (uri.isBlank()) null else android.net.Uri.decode(uri)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
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.SurfaceBlack
|
||||
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 phone side of "losing your phone, or wiping
|
||||
* it to cross a border".
|
||||
*
|
||||
* Export seals everything the companion holds (servers, FIPS identity and
|
||||
* peers, the remote-signer key) into the node's ADR-005 envelope and hands it
|
||||
* to the system file picker; import decrypts, previews and merges it. On
|
||||
* GrapheneOS there is no cloud anything — the .json goes wherever the user
|
||||
* saves it (USB drive, computer, a folder synced their way), and the
|
||||
* passphrase is the only way back in.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupRestoreScreen(onBack: () -> Unit) {
|
||||
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 preview 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("Backup 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
|
||||
say("", false)
|
||||
} catch (e: Exception) {
|
||||
say(e.message ?: "restore failed", true)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.bg_synthwave),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.65f),
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
Color.Black.copy(alpha = 0.85f),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 16.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
// Header
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = TextPrimary,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Backup & Restore",
|
||||
style = androidx.compose.material3.MaterialTheme.typography.headlineSmall,
|
||||
color = TextPrimary,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"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.",
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||||
color = TextMuted,
|
||||
)
|
||||
|
||||
GlassCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Save, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(20.dp))
|
||||
Text("Create a backup", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
PassphraseField(
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
placeholder = "Passphrase",
|
||||
)
|
||||
PassphraseField(
|
||||
value = confirm,
|
||||
onValueChange = { confirm = it },
|
||||
placeholder = "Repeat passphrase",
|
||||
)
|
||||
Text(
|
||||
"The passphrase cannot be recovered — a backup nobody can open is a paperweight.",
|
||||
color = TextMuted, fontSize = 11.sp,
|
||||
)
|
||||
GlassButton(
|
||||
text = if (busy) "Working…" else "Save backup file",
|
||||
onClick = {
|
||||
if (busy) return@GlassButton
|
||||
if (passphrase.length < 8) {
|
||||
say("Use at least 8 characters — this passphrase guards every secret in the app.", true)
|
||||
return@GlassButton
|
||||
}
|
||||
if (passphrase != confirm) {
|
||||
say("The two passphrases don't match.", true)
|
||||
return@GlassButton
|
||||
}
|
||||
val stamp = SimpleDateFormat("yyyyMMdd-HHmm", Locale.US).format(Date())
|
||||
exportLauncher.launch("archy-companion-backup-$stamp.json")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Restore, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(20.dp))
|
||||
Text("Restore a backup", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Text(
|
||||
"Pick a backup file and enter its passphrase. Nothing is overwritten: nodes merge by identity, and the mesh identity and signer key only restore when this phone has none.",
|
||||
color = TextMuted, fontSize = 11.sp,
|
||||
)
|
||||
PassphraseField(
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
placeholder = "Backup passphrase",
|
||||
)
|
||||
GlassButton(
|
||||
text = if (busy) "Working…" else "Choose backup file",
|
||||
onClick = {
|
||||
if (busy) return@GlassButton
|
||||
if (passphrase.isEmpty()) {
|
||||
say("Enter the backup's passphrase first.", true)
|
||||
return@GlassButton
|
||||
}
|
||||
importLauncher.launch(arrayOf("application/json"))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
restorePreview?.let { (summary, payload) ->
|
||||
GlassCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
"Backup verified${if (summary.appVersion.isNotBlank()) " (made by v${summary.appVersion})" else ""}",
|
||||
color = SuccessGreen, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
SummaryRow("Nodes", summary.serverCount.toString())
|
||||
if (summary.hasFipsIdentity) SummaryRow("Mesh identity", "included")
|
||||
if (summary.hasSignerKey) SummaryRow("Remote-signer key", "included")
|
||||
GlassButton(
|
||||
text = if (busy) "Restoring…" else "Restore onto this phone",
|
||||
onClick = {
|
||||
if (busy) return@GlassButton
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status?.takeIf { it.isNotBlank() }?.let { msg ->
|
||||
Text(
|
||||
msg,
|
||||
color = if (statusError) Color(0xFFFF6B6B) else SuccessGreen,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PassphraseField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = { Text(placeholder, color = TextMuted, fontSize = 14.sp) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(),
|
||||
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = BitcoinOrange,
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.15f),
|
||||
cursorColor = BitcoinOrange,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, color = TextMuted, fontSize = 13.sp)
|
||||
Text(value, color = TextPrimary, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
/** House glass card: translucent panel, hairline border. */
|
||||
@Composable
|
||||
fun GlassCard(content: @Composable () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.White.copy(alpha = 0.06f))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.Image
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
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.NativeCore
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.nostr.BunkerManager
|
||||
import com.archipelago.app.nostr.NostrSignerPreferences
|
||||
import com.archipelago.app.ui.components.QrGlassModal
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SuccessGreen
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
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 phone side of NIP-46.
|
||||
*
|
||||
* The phone holds a nostr key; the node's login page (or any NIP-46 client)
|
||||
* shows a `nostrconnect://` QR, this screen scans it, and every signature
|
||||
* request lands here as a legible approve/deny card — kind, content, tags —
|
||||
* before anything is signed. Nothing signs without a thumb on Approve.
|
||||
*/
|
||||
@Composable
|
||||
fun SignerScreen(
|
||||
onBack: () -> Unit,
|
||||
initialPairUri: String? = null,
|
||||
) {
|
||||
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 scanVisible by remember { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
var statusError by remember { mutableStateOf(false) }
|
||||
|
||||
val bunkerState by BunkerManager.state.collectAsState()
|
||||
val pending by BunkerManager.pending.collectAsState()
|
||||
|
||||
fun say(msg: String, error: Boolean) {
|
||||
status = msg
|
||||
statusError = 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()
|
||||
}
|
||||
|
||||
// Deep link (nostrconnect://…) may arrive with the route.
|
||||
LaunchedEffect(initialPairUri) {
|
||||
val uri = initialPairUri?.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)
|
||||
}
|
||||
|
||||
fun pairWith(uri: String) {
|
||||
scope.launch {
|
||||
val err = BunkerManager.pair(context, uri)
|
||||
if (err != null) say(err, true)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.bg_synthwave),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.65f),
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
Color.Black.copy(alpha = 0.85f),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 16.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = TextPrimary)
|
||||
}
|
||||
Text(
|
||||
"Remote Signer",
|
||||
style = androidx.compose.material3.MaterialTheme.typography.headlineSmall,
|
||||
color = TextPrimary,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Hold a nostr key on this phone and sign for it remotely — scan a pairing QR from your node's login page (or any NIP-46 client), then approve each signature request as it arrives. Nothing signs without you.",
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||||
color = TextMuted,
|
||||
)
|
||||
|
||||
if (bunkerState is BunkerManager.SignerState.Unavailable) {
|
||||
GlassCard {
|
||||
Text(
|
||||
"Signing is unavailable on this device (native core missing).",
|
||||
color = Color(0xFFFF6B6B), fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val info = keyInfo
|
||||
if (info == null) {
|
||||
// ── No key yet ─────────────────────────────────────────────
|
||||
GlassCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Create your signer key", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Generate a fresh key here, or import an existing nsec. The key never leaves this phone except inside an encrypted backup.",
|
||||
color = TextMuted, fontSize = 11.sp,
|
||||
)
|
||||
keyError?.let { Text(it, color = Color(0xFFFF6B6B), fontSize = 12.sp) }
|
||||
GlassButton(
|
||||
text = "Generate key",
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
keyInfo = prefs.generateSecret()
|
||||
keyError = null
|
||||
BunkerManager.refreshState(context)
|
||||
} catch (e: Exception) {
|
||||
keyError = e.message ?: "could not generate a key"
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = importText,
|
||||
onValueChange = { importText = it },
|
||||
placeholder = { Text("or import nsec…", color = TextMuted, fontSize = 14.sp) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(color = TextPrimary, fontSize = 13.sp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = BitcoinOrange,
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.15f),
|
||||
cursorColor = BitcoinOrange,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
GlassButton(
|
||||
text = "Import",
|
||||
onClick = {
|
||||
if (importText.isBlank()) return@GlassButton
|
||||
scope.launch {
|
||||
try {
|
||||
keyInfo = prefs.importSecret(importText)
|
||||
importText = ""
|
||||
keyError = null
|
||||
BunkerManager.refreshState(context)
|
||||
} catch (e: Exception) {
|
||||
keyError = e.message ?: "not a valid nsec"
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Key present: identity + session + pairing ──────────────
|
||||
GlassCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Key, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(18.dp))
|
||||
Text("Signer identity", color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
MonoRow("npub", info.optString("npub")) {
|
||||
clipboard.setText(AnnotatedString(info.optString("npub")))
|
||||
}
|
||||
if (showNsec) {
|
||||
MonoRow("nsec", info.optString("nsec"), secret = true) {
|
||||
clipboard.setText(AnnotatedString(info.optString("nsec")))
|
||||
}
|
||||
Text(
|
||||
"Anyone with the nsec can sign as you — clear the clipboard after copying.",
|
||||
color = Color(0xFFFF6B6B), fontSize = 10.sp,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Show nsec",
|
||||
color = TextMuted, fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { showNsec = true }
|
||||
.padding(vertical = 4.dp, horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session status
|
||||
GlassCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.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 — scan a pairing QR to start"
|
||||
is BunkerManager.SignerState.Connecting -> "Connecting to ${s.relay}…"
|
||||
is BunkerManager.SignerState.AwaitingClient -> "Paired with \"${s.clientName}\" — waiting for it to finish the handshake"
|
||||
is BunkerManager.SignerState.Ready -> "Ready for \"${s.clientName}\" via ${s.relay}"
|
||||
is BunkerManager.SignerState.Failed -> s.reason
|
||||
}
|
||||
Text("Session", color = TextMuted, fontSize = 12.sp)
|
||||
Text(
|
||||
label,
|
||||
color = if (bunkerState is BunkerManager.SignerState.Failed) Color(0xFFFF6B6B)
|
||||
else if (bunkerState is BunkerManager.SignerState.Ready) SuccessGreen
|
||||
else TextPrimary,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.height(44.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White.copy(alpha = 0.06f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(12.dp))
|
||||
.clickable { scanVisible = true },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.QrCodeScanner, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(18.dp))
|
||||
Text("Scan pairing QR", color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bunkerState is BunkerManager.SignerState.Ready ||
|
||||
bunkerState is BunkerManager.SignerState.AwaitingClient ||
|
||||
bunkerState is BunkerManager.SignerState.Connecting
|
||||
) {
|
||||
Text(
|
||||
"End session",
|
||||
color = TextMuted, fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { BunkerManager.unpair() }
|
||||
.padding(vertical = 4.dp, horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pending signature request — the whole point.
|
||||
pending?.let { req ->
|
||||
GlassCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"Signature request",
|
||||
color = BitcoinOrange, fontSize = 15.sp, fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("Client", color = TextMuted, fontSize = 12.sp)
|
||||
Text(req.clientName.ifBlank { req.clientPubkey.take(12) + "…" }, color = TextPrimary, fontSize = 12.sp)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("Kind", color = TextMuted, fontSize = 12.sp)
|
||||
Text(kindLabel(req.kind), color = TextPrimary, fontSize = 12.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
req.createdAt?.let {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("Time", color = TextMuted, fontSize = 12.sp)
|
||||
Text(
|
||||
SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(it * 1000)),
|
||||
color = TextPrimary, fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
req.content?.takeIf { it.isNotBlank() }?.let { content ->
|
||||
Text("Content", color = TextMuted, fontSize = 12.sp)
|
||||
Text(
|
||||
content,
|
||||
color = TextPrimary,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.padding(10.dp)
|
||||
.heightIn(max = 220.dp),
|
||||
)
|
||||
}
|
||||
if (req.tags.isNotEmpty()) {
|
||||
Text("Tags", color = TextMuted, fontSize = 12.sp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
req.tags.take(6).forEach {
|
||||
Text(
|
||||
it,
|
||||
color = TextMuted, fontSize = 10.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (req.tags.size > 6) {
|
||||
Text("+${req.tags.size - 6} more", color = TextMuted, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.height(46.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFFE5484D).copy(alpha = 0.18f))
|
||||
.border(1.dp, Color(0xFFE5484D).copy(alpha = 0.5f), RoundedCornerShape(12.dp))
|
||||
.clickable { BunkerManager.deny() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("Deny", color = Color(0xFFFF8A8D), fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.height(46.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 = 14.sp, fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status?.takeIf { it.isNotBlank() }?.let { msg ->
|
||||
Text(
|
||||
msg,
|
||||
color = if (statusError) Color(0xFFFF6B6B) else SuccessGreen,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pairing-QR scan — reuses the pairing scanner's glass modal, decodes
|
||||
// any text and treats nostrconnect:// (or a raw bunker:// error) here.
|
||||
QrGlassModal(
|
||||
visible = scanVisible,
|
||||
title = "Scan pairing QR",
|
||||
status = null,
|
||||
idleHint = "Point at the nostrconnect QR your node or client shows",
|
||||
permissionRationale = "Camera access is needed to scan the pairing code",
|
||||
onDismiss = { scanVisible = false },
|
||||
onDecoded = { text ->
|
||||
if (text.startsWith("nostrconnect://")) {
|
||||
scanVisible = false
|
||||
pairWith(text)
|
||||
}
|
||||
// Anything else keeps scanning with the hint showing.
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Kind number → legible label, so approve/deny cards read like sentences. */
|
||||
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"
|
||||
}
|
||||
|
||||
/** Label + monospace value + copy affordance. */
|
||||
@Composable
|
||||
private fun MonoRow(label: String, value: String, secret: Boolean = false, onCopy: () -> Unit) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(label, color = TextMuted, fontSize = 11.sp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.clickable { onCopy() }
|
||||
.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
value,
|
||||
color = if (secret) Color(0xFFFFB86B) else TextPrimary,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
Text("⧉", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -599,6 +599,10 @@ fun WebViewScreen(
|
||||
onRemoteKeyboard: () -> Unit = {},
|
||||
// Opens the phone-to-phone Mesh Party screen; null hides its hub card.
|
||||
onMeshParty: (() -> Unit)? = null,
|
||||
// Backup & Restore (companion 0.5.28, #128); null hides its hub card.
|
||||
onBackupRestore: (() -> Unit)? = null,
|
||||
// Remote Signer (companion 0.5.28, #139); null hides its hub card.
|
||||
onSigner: (() -> Unit)? = null,
|
||||
// Stored password for this server (from QR pairing or manual entry). When
|
||||
// non-blank, the login page is auto-filled and submitted — the one-step
|
||||
// demo flow from docs/companion-pairing-qr.md.
|
||||
@@ -1427,6 +1431,8 @@ fun WebViewScreen(
|
||||
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
|
||||
onBackToWebView = { showHubMenu = false },
|
||||
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
|
||||
onBackupRestore = onBackupRestore?.let { open -> { showHubMenu = false; open() } },
|
||||
onSigner = onSigner?.let { open -> { showHubMenu = false; open() } },
|
||||
)
|
||||
|
||||
// Pairing-QR scan launched from the menu's Nodes page; the menu stays
|
||||
|
||||
@@ -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,87 @@
|
||||
# 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:** hub menu (three-finger) → **Backup & Restore** → 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,105 @@
|
||||
# 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:** `ui/screens/SignerScreen.kt` — key setup, npub/nsec display,
|
||||
pairing scan, session status, the approve/deny card.
|
||||
- **Deep link:** `nostrconnect://` intent filter → SignerScreen.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user