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"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 47
|
versionCode = 48
|
||||||
versionName = "0.5.27"
|
versionName = "0.5.28"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@@ -54,6 +54,15 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="archipelago" android:host="pair" />
|
<data android:scheme="archipelago" android:host="pair" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Remote-signer pairing deep link (NIP-46, companion 0.5.28):
|
||||||
|
nostrconnect://<client-pubkey>?relay=...&secret=... — the
|
||||||
|
node's login QR, hand-off from any QR scanner app. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="nostrconnect" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
|
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.archipelago.app
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JNI binding to the companion's non-mesh native surface (same
|
||||||
|
* libarchy_fips_core.so as FipsNative — backup + nostr signer crypto, built
|
||||||
|
* from Android/rust/archy-fips-core).
|
||||||
|
*
|
||||||
|
* Same contract as FipsNative: JSON over strings, failures come back as
|
||||||
|
* {"error": "…"} rather than exceptions, and [available] is false on ABIs
|
||||||
|
* the .so isn't built for so every caller can degrade gracefully.
|
||||||
|
*/
|
||||||
|
object NativeCore {
|
||||||
|
val available: Boolean = try {
|
||||||
|
System.loadLibrary("archy_fips_core")
|
||||||
|
true
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backup (#128): the node's ADR-005 envelope ──────────────────────────
|
||||||
|
|
||||||
|
/** Encrypt a JSON payload into an ADR-005 envelope (ChaCha20-Poly1305). */
|
||||||
|
external fun backupEncrypt(payload: String, passphrase: String): String
|
||||||
|
|
||||||
|
/** Decrypt an ADR-005 envelope back to its payload JSON. */
|
||||||
|
external fun backupDecrypt(envelope: String, passphrase: String): String
|
||||||
|
|
||||||
|
// ── NIP-46 remote signer (#139) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Generate a fresh nostr key: {"secret","pubkey","npub","nsec"}. */
|
||||||
|
external fun nostrGenerateSecret(): String
|
||||||
|
|
||||||
|
/** Import a key from hex or nsec…: {"secret","pubkey","npub","nsec"}. */
|
||||||
|
external fun nostrSecretFromAny(secret: String): String
|
||||||
|
|
||||||
|
/** Parse nostrconnect://…: {"clientPubkey","relays":[…],"secret","perms","name","url","image"}. */
|
||||||
|
external fun nostrParseConnectUri(uri: String): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign `{kind, content, tags, created_at}` with the signer key: returns
|
||||||
|
* the full signed event JSON. Approval happens BEFORE this call — the
|
||||||
|
* native side never signs unasked.
|
||||||
|
*/
|
||||||
|
external fun nostrSignEvent(secretHex: String, eventJson: String): String
|
||||||
|
|
||||||
|
/** NIP-44 v2 encrypt/decrypt; result JSON: {"result": payload} or {"error": …}. */
|
||||||
|
external fun nostrNip44Encrypt(secretHex: String, peerPub: String, plaintext: String): String
|
||||||
|
external fun nostrNip44Decrypt(secretHex: String, peerPub: String, payload: String): String
|
||||||
|
|
||||||
|
/** NIP-04 fallback (deprecated but still spoken by real clients). */
|
||||||
|
external fun nostrNip04Encrypt(secretHex: String, peerPub: String, plaintext: String): String
|
||||||
|
external fun nostrNip04Decrypt(secretHex: String, peerPub: String, payload: String): String
|
||||||
|
|
||||||
|
/** True when a native reply is an error envelope. */
|
||||||
|
fun isErr(json: String): Boolean = try {
|
||||||
|
JSONObject(json).has("error")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error text from a native reply, or a generic message if malformed. */
|
||||||
|
fun errMsg(json: String): String = try {
|
||||||
|
JSONObject(json).optString("error", "native call failed")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
"native call failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package com.archipelago.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.archipelago.app.NativeCore
|
||||||
|
import com.archipelago.app.fips.FipsPreferences
|
||||||
|
import com.archipelago.app.nostr.NostrSignerPreferences
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Companion backup & restore (#128) — the phone side of "losing your phone,
|
||||||
|
* or wiping it to cross a border".
|
||||||
|
*
|
||||||
|
* The payload (servers + FIPS identity/peers + signer key + flags) is
|
||||||
|
* serialized to JSON and sealed into the node's ADR-005 envelope (Argon2id +
|
||||||
|
* ChaCha20-Poly1305) by the native core — the SAME envelope the node uses,
|
||||||
|
* not a second format. The passphrase never leaves the encrypt call.
|
||||||
|
*
|
||||||
|
* Transport is deliberately boring: a plain .json file the user saves via
|
||||||
|
* the system file picker (SAF) — on GrapheneOS there is no cloud backup and
|
||||||
|
* there should be none here either; the file goes wherever the user puts it
|
||||||
|
* (USB drive, computer, a folder synced their way).
|
||||||
|
*/
|
||||||
|
class BackupManager(private val context: Context) {
|
||||||
|
|
||||||
|
private val servers = ServerPreferences(context)
|
||||||
|
private val fips = FipsPreferences(context)
|
||||||
|
private val signer = NostrSignerPreferences(context)
|
||||||
|
|
||||||
|
/** Everything the backup captures, for the restore preview UI. */
|
||||||
|
data class PayloadSummary(
|
||||||
|
val serverCount: Int,
|
||||||
|
val hasFipsIdentity: Boolean,
|
||||||
|
val hasSignerKey: Boolean,
|
||||||
|
val appVersion: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What a restore actually did, for the result UI. */
|
||||||
|
data class RestoreResult(
|
||||||
|
val serversRestored: Int,
|
||||||
|
val activeSet: Boolean,
|
||||||
|
val fipsIdentityRestored: Boolean,
|
||||||
|
val signerKeyRestored: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun appVersion(): String = try {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: ""
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the encrypted backup envelope. Runs on IO: DataStore reads
|
||||||
|
* plus the Argon2id KDF (tens of ms) + AEAD.
|
||||||
|
*/
|
||||||
|
suspend fun createBackup(passphrase: String): String = withContext(Dispatchers.IO) {
|
||||||
|
require(passphrase.isNotEmpty()) { "passphrase required" }
|
||||||
|
|
||||||
|
val active = servers.activeServer.first()
|
||||||
|
val saved = servers.savedServers.first()
|
||||||
|
val fipsId = fips.identity()
|
||||||
|
val peers = fips.peersJson()
|
||||||
|
val partyPeers = fips.partyPeers()
|
||||||
|
val partyName = fips.partyName()
|
||||||
|
val partyListen = fips.partyListen()
|
||||||
|
val signerSecret = signer.secret()
|
||||||
|
|
||||||
|
val payload = JSONObject().apply {
|
||||||
|
put("app", "archipelago-companion")
|
||||||
|
put("payloadVersion", 1)
|
||||||
|
put("appVersion", appVersion())
|
||||||
|
put("createdAt", System.currentTimeMillis() / 1000)
|
||||||
|
put("servers", JSONArray(saved.map { it.serialize() }))
|
||||||
|
put("active", active?.serialize() ?: JSONObject.NULL)
|
||||||
|
if (fipsId != null) {
|
||||||
|
put("fips", JSONObject().apply {
|
||||||
|
put("secret", fipsId.secret)
|
||||||
|
put("npub", fipsId.npub)
|
||||||
|
put("address", fipsId.address)
|
||||||
|
put("peers", JSONArray(peers))
|
||||||
|
put("partyPeers", JSONArray().apply { partyPeers.forEach { put(JSONObject().apply {
|
||||||
|
put("npub", it.npub); put("ula", it.ula); put("name", it.name)
|
||||||
|
put("ip", it.ip); put("port", it.port)
|
||||||
|
}) } })
|
||||||
|
put("partyName", partyName)
|
||||||
|
put("partyListen", partyListen)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (signerSecret != null) {
|
||||||
|
put("signer", JSONObject().apply { put("secret", signerSecret) })
|
||||||
|
}
|
||||||
|
put("flags", JSONObject().apply {
|
||||||
|
put("introSeen", servers.introSeen.first())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
val envelope = NativeCore.backupEncrypt(payload.toString(), passphrase)
|
||||||
|
if (NativeCore.isErr(envelope)) throw BackupException(NativeCore.errMsg(envelope))
|
||||||
|
envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peek at a decrypted backup (passphrase already checked) to preview what
|
||||||
|
* a restore would do. Does NOT touch any stored state.
|
||||||
|
*/
|
||||||
|
suspend fun readBackup(envelope: String, passphrase: String): Pair<PayloadSummary, JSONObject> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val payload = NativeCore.backupDecrypt(envelope, passphrase)
|
||||||
|
if (NativeCore.isErr(payload)) throw BackupException(NativeCore.errMsg(payload))
|
||||||
|
val obj = JSONObject(payload)
|
||||||
|
if (obj.optString("app") != "archipelago-companion") {
|
||||||
|
throw BackupException("Not a companion backup (this may be a node backup — restore it on the node)")
|
||||||
|
}
|
||||||
|
val summary = PayloadSummary(
|
||||||
|
serverCount = obj.optJSONArray("servers")?.length() ?: 0,
|
||||||
|
hasFipsIdentity = obj.has("fips"),
|
||||||
|
hasSignerKey = obj.has("signer"),
|
||||||
|
appVersion = obj.optString("appVersion", ""),
|
||||||
|
)
|
||||||
|
summary to obj
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a decrypted backup to this install. Merge semantics — a restore
|
||||||
|
* never silently destroys what's already here:
|
||||||
|
*
|
||||||
|
* - Servers upsert (npub-first, [ServerPreferences.upsertServer]) — same
|
||||||
|
* identity merges, never duplicates.
|
||||||
|
* - The backup's active server is set active only when none is.
|
||||||
|
* - FIPS identity/peers restore only when this phone has none (a phone
|
||||||
|
* that already paired has a live identity the node peers with; swapping
|
||||||
|
* it from a backup would strand the current pairing). Peers merge by
|
||||||
|
* npub otherwise.
|
||||||
|
* - Signer key restores only when none exists locally.
|
||||||
|
*/
|
||||||
|
suspend fun restoreBackup(payload: JSONObject): RestoreResult = withContext(Dispatchers.IO) {
|
||||||
|
val serverArray = payload.optJSONArray("servers") ?: JSONArray()
|
||||||
|
var restored = 0
|
||||||
|
for (i in 0 until serverArray.length()) {
|
||||||
|
val raw = serverArray.optString(i)
|
||||||
|
val entry = ServerEntry.deserialize(raw) ?: continue
|
||||||
|
servers.upsertServer(entry)
|
||||||
|
restored++
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeSet = false
|
||||||
|
val activeStr = if (payload.isNull("active")) null else payload.optString("active", "")
|
||||||
|
val activeEntry = activeStr?.takeIf { it.isNotBlank() }?.let { ServerEntry.deserialize(it) }
|
||||||
|
if (activeEntry != null && servers.activeServer.first() == null) {
|
||||||
|
servers.setActiveServer(activeEntry)
|
||||||
|
activeSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIPS identity: only adopt when this phone has none.
|
||||||
|
var fipsRestored = false
|
||||||
|
val fipsObj = payload.optJSONObject("fips")
|
||||||
|
if (fipsObj != null && fips.identity() == null) {
|
||||||
|
val secret = fipsObj.optString("secret")
|
||||||
|
if (secret.isNotBlank()) {
|
||||||
|
fips.saveIdentity(
|
||||||
|
com.archipelago.app.fips.FipsNative.Identity(
|
||||||
|
secret = secret,
|
||||||
|
npub = fipsObj.optString("npub"),
|
||||||
|
address = fipsObj.optString("address"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fipsRestored = true
|
||||||
|
}
|
||||||
|
// Peers: union by npub with whatever is already here (an empty
|
||||||
|
// store takes the backup's list wholesale).
|
||||||
|
val backupPeers = fipsObj.optJSONArray("peers")?.let { arr ->
|
||||||
|
(0 until arr.length()).joinToString(",", "[", "]") { arr.optString(it) }
|
||||||
|
} ?: "[]"
|
||||||
|
fips.mergePeersJson(backupPeers)
|
||||||
|
|
||||||
|
val partyArr = fipsObj.optJSONArray("partyPeers")
|
||||||
|
if (partyArr != null) {
|
||||||
|
for (i in 0 until partyArr.length()) {
|
||||||
|
val p = partyArr.optJSONObject(i) ?: continue
|
||||||
|
val npub = p.optString("npub")
|
||||||
|
val ula = p.optString("ula")
|
||||||
|
if (npub.isNotBlank() && ula.isNotBlank()) {
|
||||||
|
fips.upsertPartyPeer(
|
||||||
|
com.archipelago.app.fips.PartyPeer(
|
||||||
|
npub = npub, ula = ula,
|
||||||
|
name = p.optString("name").ifBlank { "Phone" },
|
||||||
|
ip = p.optString("ip"), port = p.optInt("port"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fipsObj.optString("partyName").isNotBlank()) {
|
||||||
|
fips.setPartyName(fipsObj.optString("partyName"))
|
||||||
|
}
|
||||||
|
fips.setPartyListen(fipsObj.optBoolean("partyListen", false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signer key: only adopt when none exists locally.
|
||||||
|
var signerRestored = false
|
||||||
|
val signerObj = payload.optJSONObject("signer")
|
||||||
|
if (signerObj != null && signer.secret() == null) {
|
||||||
|
val secret = signerObj.optString("secret")
|
||||||
|
if (secret.isNotBlank()) {
|
||||||
|
signer.saveSecret(secret)
|
||||||
|
signerRestored = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flags: a user who completed the intro on the old phone shouldn't
|
||||||
|
// see it again on the new one.
|
||||||
|
val flags = payload.optJSONObject("flags")
|
||||||
|
if (flags?.optBoolean("introSeen", false) == true) {
|
||||||
|
servers.markIntroSeen()
|
||||||
|
}
|
||||||
|
|
||||||
|
RestoreResult(
|
||||||
|
serversRestored = restored,
|
||||||
|
activeSet = activeSet,
|
||||||
|
fipsIdentityRestored = fipsRestored,
|
||||||
|
signerKeyRestored = signerRestored,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackupException(message: String) : Exception(message)
|
||||||
|
}
|
||||||
@@ -89,6 +89,38 @@ class FipsPreferences(private val context: Context) {
|
|||||||
|
|
||||||
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
|
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union the stored node peers with a backup's peer list, matched by
|
||||||
|
* npub — the backup's copy wins for the same npub (its addresses are what
|
||||||
|
* the restored identity pairs against). Used by companion restore (#128)
|
||||||
|
* after [saveIdentity] adopted the backup's mesh identity.
|
||||||
|
*/
|
||||||
|
suspend fun mergePeersJson(incomingJson: String) {
|
||||||
|
context.fipsDataStore.edit { prefs ->
|
||||||
|
val current = JSONArray(prefs[peersKey] ?: "[]")
|
||||||
|
val incoming = try {
|
||||||
|
JSONArray(incomingJson)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
JSONArray()
|
||||||
|
}
|
||||||
|
val incomingNpubs = mutableSetOf<String>()
|
||||||
|
val merged = JSONArray()
|
||||||
|
for (i in 0 until incoming.length()) {
|
||||||
|
val peer = incoming.optJSONObject(i) ?: continue
|
||||||
|
val npub = peer.optString("npub")
|
||||||
|
if (npub.isNotBlank()) {
|
||||||
|
incomingNpubs.add(npub)
|
||||||
|
merged.put(peer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i in 0 until current.length()) {
|
||||||
|
val peer = current.optJSONObject(i) ?: continue
|
||||||
|
if (peer.optString("npub") !in incomingNpubs) merged.put(peer)
|
||||||
|
}
|
||||||
|
prefs[peersKey] = merged.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
|
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
|
||||||
|
|
||||||
suspend fun partyListen(): Boolean =
|
suspend fun partyListen(): Boolean =
|
||||||
|
|||||||
@@ -0,0 +1,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.Groups
|
||||||
import androidx.compose.material.icons.filled.Keyboard
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
import androidx.compose.material.icons.filled.RestartAlt
|
import androidx.compose.material.icons.filled.RestartAlt
|
||||||
|
import androidx.compose.material.icons.filled.SettingsBackupRestore
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
import androidx.compose.material.icons.filled.SportsEsports
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -106,6 +108,8 @@ fun NESMenu(
|
|||||||
onKeyboard: () -> Unit,
|
onKeyboard: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)? = null,
|
onBackToWebView: (() -> Unit)? = null,
|
||||||
onMeshParty: (() -> Unit)? = null,
|
onMeshParty: (() -> Unit)? = null,
|
||||||
|
onBackupRestore: (() -> Unit)? = null,
|
||||||
|
onSigner: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||||
// Contained hub overlay: a centred glass panel (not full-screen) that
|
// Contained hub overlay: a centred glass panel (not full-screen) that
|
||||||
@@ -118,7 +122,7 @@ fun NESMenu(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||||
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
|
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty, onBackupRestore, onSigner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +142,8 @@ private fun MenuPanel(
|
|||||||
onKeyboard: () -> Unit,
|
onKeyboard: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)?,
|
onBackToWebView: (() -> Unit)?,
|
||||||
onMeshParty: (() -> Unit)?,
|
onMeshParty: (() -> Unit)?,
|
||||||
|
onBackupRestore: (() -> Unit)?,
|
||||||
|
onSigner: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
var showAdd by remember { mutableStateOf(false) }
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
// The saved server being edited, or null when adding a new one.
|
// The saved server being edited, or null when adding a new one.
|
||||||
@@ -233,6 +239,16 @@ private fun MenuPanel(
|
|||||||
if (onMeshParty != null) {
|
if (onMeshParty != null) {
|
||||||
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
|
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
|
||||||
}
|
}
|
||||||
|
// Backup & Restore (#128): the phone side of losing your phone
|
||||||
|
// or wiping it to cross a border — encrypted export file, no cloud.
|
||||||
|
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
|
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||||
// the settings button — not here.
|
// 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.ServerPreferences
|
||||||
import com.archipelago.app.data.ServerQrParser
|
import com.archipelago.app.data.ServerQrParser
|
||||||
import com.archipelago.app.fips.FipsManager
|
import com.archipelago.app.fips.FipsManager
|
||||||
|
import com.archipelago.app.ui.screens.BackupRestoreScreen
|
||||||
import com.archipelago.app.ui.screens.FlareScreen
|
import com.archipelago.app.ui.screens.FlareScreen
|
||||||
import com.archipelago.app.ui.screens.IntroScreen
|
import com.archipelago.app.ui.screens.IntroScreen
|
||||||
import com.archipelago.app.ui.screens.NodePickerScreen
|
import com.archipelago.app.ui.screens.NodePickerScreen
|
||||||
import com.archipelago.app.ui.screens.PartyScreen
|
import com.archipelago.app.ui.screens.PartyScreen
|
||||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||||
|
import com.archipelago.app.ui.screens.SignerScreen
|
||||||
import com.archipelago.app.ui.screens.WebViewScreen
|
import com.archipelago.app.ui.screens.WebViewScreen
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -41,6 +43,8 @@ object Routes {
|
|||||||
const val REMOTE_INPUT = "remote_input"
|
const val REMOTE_INPUT = "remote_input"
|
||||||
const val MESH_PARTY = "mesh_party"
|
const val MESH_PARTY = "mesh_party"
|
||||||
const val FLARE = "flare"
|
const val FLARE = "flare"
|
||||||
|
const val BACKUP_RESTORE = "backup_restore"
|
||||||
|
const val SIGNER = "signer"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,27 +137,35 @@ fun AppNavHost(
|
|||||||
LaunchedEffect(pairUri) {
|
LaunchedEffect(pairUri) {
|
||||||
val raw = pairUri ?: return@LaunchedEffect
|
val raw = pairUri ?: return@LaunchedEffect
|
||||||
onPairUriConsumed()
|
onPairUriConsumed()
|
||||||
when (val result = ServerQrParser.parse(raw)) {
|
when {
|
||||||
is PairResult.Success -> {
|
// Remote-signer pairing deep link (NIP-46): nostrconnect://…
|
||||||
// Pairing implies the app is installed and in use — skip the intro.
|
// from the node's login QR — any QR scanner app can hand it over.
|
||||||
|
raw.startsWith("nostrconnect://") -> {
|
||||||
prefs.markIntroSeen()
|
prefs.markIntroSeen()
|
||||||
val merged = prefs.upsertServer(result.server)
|
navController.navigate("${Routes.SIGNER}?uri=${android.net.Uri.encode(raw)}")
|
||||||
FipsManager.registerNode(context, result.fips, merged.displayName())
|
}
|
||||||
if (merged.password.isNotBlank()) {
|
else -> when (val result = ServerQrParser.parse(raw)) {
|
||||||
// Demo flow: password came with the link — connect in one step.
|
is PairResult.Success -> {
|
||||||
prefs.setActiveServer(merged)
|
// Pairing implies the app is installed and in use — skip the intro.
|
||||||
navController.navigate(Routes.WEB_VIEW) {
|
prefs.markIntroSeen()
|
||||||
popUpTo(0) { inclusive = true }
|
val merged = prefs.upsertServer(result.server)
|
||||||
}
|
FipsManager.registerNode(context, result.fips, merged.displayName())
|
||||||
} else {
|
if (merged.password.isNotBlank()) {
|
||||||
pairPrefill = merged
|
// Demo flow: password came with the link — connect in one step.
|
||||||
navController.navigate(Routes.SERVER_CONNECT) {
|
prefs.setActiveServer(merged)
|
||||||
popUpTo(0) { inclusive = true }
|
navController.navigate(Routes.WEB_VIEW) {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pairPrefill = merged
|
||||||
|
navController.navigate(Routes.SERVER_CONNECT) {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
else -> {
|
||||||
else -> {
|
// Invalid or too-new pairing link — ignore; normal startup continues.
|
||||||
// Invalid or too-new pairing link — ignore; normal startup continues.
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,6 +271,12 @@ fun AppNavHost(
|
|||||||
onMeshParty = {
|
onMeshParty = {
|
||||||
navController.navigate(Routes.MESH_PARTY)
|
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() },
|
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 = {},
|
onRemoteKeyboard: () -> Unit = {},
|
||||||
// Opens the phone-to-phone Mesh Party screen; null hides its hub card.
|
// Opens the phone-to-phone Mesh Party screen; null hides its hub card.
|
||||||
onMeshParty: (() -> Unit)? = null,
|
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
|
// 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
|
// non-blank, the login page is auto-filled and submitted — the one-step
|
||||||
// demo flow from docs/companion-pairing-qr.md.
|
// demo flow from docs/companion-pairing-qr.md.
|
||||||
@@ -1427,6 +1431,8 @@ fun WebViewScreen(
|
|||||||
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
|
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
|
||||||
onBackToWebView = { showHubMenu = false },
|
onBackToWebView = { showHubMenu = false },
|
||||||
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
|
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
|
||||||
|
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
|
// 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