From 34be35a038450cc57408f91e0c3256d858f9ce45 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Fri, 25 Sep 2026 23:59:57 +0100 Subject: [PATCH 1/3] feat: scan Sapwood's invite to add a phone for unlock Reverses the enrolment QR direction (enrol-invite spec v1): "Add a phone" in Sapwood shows a one-off invite, and Cambium's "Scan Sapwood's code" reads it, seals its usual enrolment code to the invite's throwaway key with NIP-44, and publishes the reply once to the invite's relays, with a Retry on failure. From there the flow is unchanged: the board's hand-off, the five request words, then the check code. The old phone-shows-a-QR flow stays as a secondary "Show a code instead" option. NIP-44 v2 and just enough secp256k1 are implemented in pure Kotlin so the reply's ciphertext can be held to the shared Sapwood/Cambium test vector, which rust-nostr's native bindings cannot do on the host JVM. --- AGENTS.md | 39 +++- CHANGELOG.md | 10 + .../forgesworn/cambium/signer/UnlockRelay.kt | 17 ++ .../cambium/unlock/EnrolInviteScan.kt | 52 +++++ .../forgesworn/cambium/unlock/InviteReply.kt | 49 +++++ .../forgesworn/cambium/unlock/InviteUri.kt | 58 +++++ .../dev/forgesworn/cambium/unlock/Nip44.kt | 105 +++++++++ .../forgesworn/cambium/unlock/Secp256k1.kt | 97 +++++++++ .../cambium/unlock/UnlockEnrolActivity.kt | 205 +++++++++++++++--- .../main/res/layout/activity_unlock_enrol.xml | 59 ++++- app/src/main/res/values/strings.xml | 10 +- .../cambium/unlock/EnrolInviteScanTest.kt | 49 +++++ .../cambium/unlock/EnrolInviteVectorTest.kt | 61 ++++++ .../cambium/unlock/InviteReplyTest.kt | 45 ++++ .../cambium/unlock/InviteUriTest.kt | 62 ++++++ .../forgesworn/cambium/unlock/Nip44Test.kt | 69 ++++++ .../cambium/unlock/Secp256k1Test.kt | 41 ++++ app/src/test/resources/enrol-invite-v1.json | 16 ++ 18 files changed, 1000 insertions(+), 44 deletions(-) create mode 100644 app/src/main/kotlin/dev/forgesworn/cambium/unlock/EnrolInviteScan.kt create mode 100644 app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteReply.kt create mode 100644 app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteUri.kt create mode 100644 app/src/main/kotlin/dev/forgesworn/cambium/unlock/Nip44.kt create mode 100644 app/src/main/kotlin/dev/forgesworn/cambium/unlock/Secp256k1.kt create mode 100644 app/src/test/kotlin/dev/forgesworn/cambium/unlock/EnrolInviteScanTest.kt create mode 100644 app/src/test/kotlin/dev/forgesworn/cambium/unlock/EnrolInviteVectorTest.kt create mode 100644 app/src/test/kotlin/dev/forgesworn/cambium/unlock/InviteReplyTest.kt create mode 100644 app/src/test/kotlin/dev/forgesworn/cambium/unlock/InviteUriTest.kt create mode 100644 app/src/test/kotlin/dev/forgesworn/cambium/unlock/Nip44Test.kt create mode 100644 app/src/test/kotlin/dev/forgesworn/cambium/unlock/Secp256k1Test.kt create mode 100644 app/src/test/resources/enrol-invite-v1.json diff --git a/AGENTS.md b/AGENTS.md index 3d2355a..c3b8a1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -592,7 +592,7 @@ Android apps Websites relay and would only slow down every other delivery too), `UnlockNotifications.kt`, `UnlockActivity.kt` (always acts on the board's *current* request, not the notification's) and `UnlockEnrolActivity.kt` (enrolment key in memory only; `configChanges` - so a rotation cannot lose it). + so a rotation cannot lose it; see the enrol-invite paragraph below for its primary scan flow). Metadata rules the code must keep (design section 6): the lock subscription has no filter but the kind; the relay client has no signer (no NIP-42 answer with a stable key); every delivery is @@ -600,11 +600,40 @@ Android apps Websites only the keep-alive's scheduled pings); a relay Cambium has never spoken to before does not see its first connection from this phone land at the same moment the board's broadcast changed (`RelayGate`'s jitter). + + Enrol-invite (spec v1): Sapwood shows an invite QR, this phone scans it, reversing the earlier + phone-shows-a-QR direction (kept as `UnlockEnrolActivity`'s secondary "Show a code instead"). + `InviteUri.kt` parses `heartwood-unlock:invite?v=1&k=...&r=...&x=...&relay=...` as strictly as + `EnrolmentCode.parse` (single-valued fields refuse a repeat, `x` must be in the future and no + more than an hour ahead, every relay is `wss://` or `ws://localhost` for a test bench, + deduplicated). `EnrolInviteScan.kt` is `pairing/QrPairingScan.kt`'s reverse-direction sibling: a + `bunker://`/`nostrconnect://` link or another phone's own `heartwood-unlock:enrol?...` code both + get a distinct wrong-direction message, since both are the opposite direction from an invite. + `InviteReply.kt` builds the reply's content -- `NIP-44 v2 encrypt(EnrolmentCode.encode())` to the + invite's `inv` key, from a fresh throwaway key -- and needs its own NIP-44 v2 (`Nip44.kt`) and + secp256k1 (`Secp256k1.kt`, naive affine double-and-add, no attempt at constant time or speed: + called once per scan or per test) rather than `signer/UnlockRelay.kt`'s rust-nostr calls, for two + reasons: rust-nostr's Kotlin bindings expose no way to pin the NIP-44 nonce, and native code + cannot load on the host JVM at all (see `pairing/BunkerUri.kt`'s class doc), so neither can be + held to the shared Sapwood/Cambium test vector + (`test/fixtures/enrol-invite-v1.json` in Sapwood, copied byte-for-byte into + `src/test/resources/enrol-invite-v1.json` here) the way this file's own tests are. All three + files are pure Kotlin and JVM-tested, including against that vector (`EnrolInviteVectorTest`): + decrypting its content to its plaintext, and, given its fixed throwaway secret and nonce, + reproducing its exact ciphertext. `signer/UnlockRelay.kt`'s `inviteReplyEvent` then does the one + step that does need rust-nostr -- `Keys.parse` on the throwaway secret hex and + `EventBuilder.signWithKeys` -- tagged `["h", rendezvous]` and `["expiration", x]` only; the + caller `fill(0)`s `InviteReply.throwawaySecret` once that returns. `UnlockEnrolActivity` publishes + the signed event once, to the invite's relays, over the same `RelayWatch` instance already + listening for the board's hand-off (on the paired identity's own relays plus the invite's), so a + Retry after a failed publish reuses the same connection and the same signed event rather than + re-signing. - `signer/UnlockRelay.kt` -- the rust-nostr half of phone unlock: throwaway-key delivery events, - opening the enrolment hand-off (NIP-44), and `RelayWatch`, one signer-less `Client` per purpose - (the unfiltered 24135 listener; the enrolment screen's rendezvous subscription). Native calls - follow `RustNostrHeartwoodClient`'s rule: `NonCancellable` on IO, and the notification loop is - ended by `shutdown()`, never by cancelling the coroutine inside it. + opening the enrolment hand-off (NIP-44), signing an enrol-invite reply (`inviteReplyEvent`, see + above), and `RelayWatch`, one signer-less `Client` per purpose (the unfiltered 24135 listener; the + enrolment screen's rendezvous subscription, now also used to publish the invite reply). Native + calls follow `RustNostrHeartwoodClient`'s rule: `NonCancellable` on IO, and the notification loop + is ended by `shutdown()`, never by cancelling the coroutine inside it. ## Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index f64048b..a4d3315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- Adding a phone now scans Sapwood's own invite QR instead of the other way round: "Add a phone" + in Sapwood shows a one-off invite, and this phone's "Scan Sapwood's code" reads it, builds its + usual enrolment, seals it with NIP-44 to a fresh throwaway key, and publishes the reply once to + the invite's relays, with a Retry if no relay accepts it. From there it is exactly as before: + waiting for the board's hand-off, then the five request words, then the check code. Scanning a + bunker link or another phone's own enrol code here gets a wrong-direction message; "Show a code + instead" keeps the original phone-shows-a-QR flow for a Sapwood with no camera-visible screen. + ## 0.6.0 (2026-09-25) - Enrolling this phone for unlock over the relay. A Heartwood on 0.18.0-beta.19 or later shows a diff --git a/app/src/main/kotlin/dev/forgesworn/cambium/signer/UnlockRelay.kt b/app/src/main/kotlin/dev/forgesworn/cambium/signer/UnlockRelay.kt index a5f61a2..1f2ffee 100644 --- a/app/src/main/kotlin/dev/forgesworn/cambium/signer/UnlockRelay.kt +++ b/app/src/main/kotlin/dev/forgesworn/cambium/signer/UnlockRelay.kt @@ -1,6 +1,8 @@ package dev.forgesworn.cambium.signer import android.util.Log +import dev.forgesworn.cambium.toHex +import dev.forgesworn.cambium.unlock.InviteReply import dev.forgesworn.cambium.unlock.PhoneUnlock import dev.forgesworn.cambium.unlock.RawAnnouncement import kotlinx.coroutines.CoroutineScope @@ -64,6 +66,21 @@ object UnlockNostr { .tags(listOf(Tag.publicKey(board))) .signWithKeys(throwaway) } + + /** + * Signs [reply] (an [InviteReply]'s already-encrypted content, from Sapwood's invite) into a + * kind-[PhoneUnlock.HANDOFF_KIND] event tagged `["h", rendezvous]` and `["expiration", x]`, + * nothing else, per the enrol-invite spec. The caller must `fill(0)` [reply]'s + * [InviteReply.throwawaySecret] once this returns -- the key has no other purpose. + */ + fun inviteReplyEvent(reply: InviteReply): Event { + val keys = Keys.parse(reply.throwawaySecret.toHex()) + val tags = listOf( + Tag.parse(listOf("h", reply.rendezvous)), + Tag.expiration(Timestamp.fromSecs(reply.expiresAtSecs.toULong())), + ) + return EventBuilder(Kind(PhoneUnlock.HANDOFF_KIND.toUShort()), reply.content).tags(tags).signWithKeys(keys) + } } /** diff --git a/app/src/main/kotlin/dev/forgesworn/cambium/unlock/EnrolInviteScan.kt b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/EnrolInviteScan.kt new file mode 100644 index 0000000..d76b967 --- /dev/null +++ b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/EnrolInviteScan.kt @@ -0,0 +1,52 @@ +package dev.forgesworn.cambium.unlock + +/** + * Turns a raw QR scan result into an invite decision, the reverse-direction sibling of + * [dev.forgesworn.cambium.pairing.QrPairingScan]: pure Kotlin, no Android or zxing, so the zxing + * call itself stays in [UnlockEnrolActivity]. + */ +sealed interface EnrolInviteScanResult { + data class Accepted(val invite: InviteUri) : EnrolInviteScanResult + data class Rejected(val message: String) : EnrolInviteScanResult + /** The user backed out of the scanner: not an error, nothing should be shown. */ + data object Cancelled : EnrolInviteScanResult +} + +object EnrolInviteScan { + + const val NOT_AN_INVITE = "That QR is not a Sapwood invite." + const val WRONG_DIRECTION_BUNKER = + "That link is for pairing an app with Sapwood, not an unlock invite: go to Sapwood's \"Add a phone\" step and scan that code instead." + const val WRONG_DIRECTION_ENROL_CODE = + "That is this phone's own unlock code, not Sapwood's invite: on Sapwood, click \"Add a phone\" and scan the code it shows instead." + + private const val BUNKER_SCHEME = "bunker://" + private const val NOSTRCONNECT_SCHEME = "nostrconnect://" + private const val ENROL_PREFIX = "${EnrolmentCode.SCHEME}:enrol?" + + /** + * [rawContents] is the scanning library's result content: `null` means the user cancelled the + * scan, an empty/blank string means a QR was read but encoded nothing usable. + */ + fun evaluate(rawContents: String?, nowSecs: Long = System.currentTimeMillis() / 1000): EnrolInviteScanResult { + if (rawContents == null) return EnrolInviteScanResult.Cancelled + + val trimmed = rawContents.trim() + if (trimmed.isEmpty()) return EnrolInviteScanResult.Rejected(NOT_AN_INVITE) + + if (trimmed.startsWith(BUNKER_SCHEME, ignoreCase = true) || trimmed.startsWith(NOSTRCONNECT_SCHEME, ignoreCase = true)) { + // Sapwood's own "Connect an app" bunker link (or its client-initiated sibling): the + // pairing direction, not the unlock-invite direction this screen expects. + return EnrolInviteScanResult.Rejected(WRONG_DIRECTION_BUNKER) + } + + if (trimmed.startsWith(ENROL_PREFIX, ignoreCase = true)) { + // This phone's own "heartwood-unlock:enrol?..." code, or another phone's: the + // opposite direction from Sapwood's invite. + return EnrolInviteScanResult.Rejected(WRONG_DIRECTION_ENROL_CODE) + } + + val invite = InviteUri.parse(trimmed, nowSecs) ?: return EnrolInviteScanResult.Rejected(NOT_AN_INVITE) + return EnrolInviteScanResult.Accepted(invite) + } +} diff --git a/app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteReply.kt b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteReply.kt new file mode 100644 index 0000000..6638f2c --- /dev/null +++ b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteReply.kt @@ -0,0 +1,49 @@ +package dev.forgesworn.cambium.unlock + +import dev.forgesworn.cambium.toHex +import java.security.SecureRandom + +/** + * Cambium's answer to a Sapwood invite ([InviteUri]): everything needed to build and sign the + * kind-24137 reply event, except the actual signing (rust-nostr, native, see + * `signer/UnlockRelay.kt`'s `inviteReplyEvent`) -- this class stays pure Kotlin so it can be held + * to the shared Sapwood/Cambium test vector on the host JVM. + * + * [throwawaySecret] is the reply's author: a fresh key with no other purpose, never persisted. + * The caller is responsible for `fill(0)`-ing it once the event is signed -- this class only + * builds the plaintext-to-ciphertext transform, so it cannot itself know when signing is done. + * [content] is `NIP-44 v2 encrypt(EnrolmentCode.encode())` to the invite's `inv` key; [rendezvous] + * and [expiresAtSecs] are exactly the invite's `ri`/`x`, carried through as the reply event's only + * tags (`h`, `expiration`). + */ +class InviteReply( + val throwawaySecret: ByteArray, + val throwawayPubkeyHex: String, + val content: String, + val rendezvous: String, + val expiresAtSecs: Long, +) + +object InviteReplyBuilder { + + /** + * Builds the reply to [invite], sealing [enrolmentCode] (an [EnrolmentCode.encode] string) so + * only whoever holds `inv`'s secret half can read it. [throwawaySecret] and [nonce] are + * injectable so a test can pin them to the shared vector; production callers take the + * defaults, a fresh 32 bytes of each from [SecureRandom]. Null only if the invite's `k` is not + * a usable public key (already checked by [InviteUri.parse]'s hex64 rule, so this should not + * happen for a value that parsed). + */ + fun build( + invite: InviteUri, + enrolmentCode: String, + throwawaySecret: ByteArray = randomBytes(32), + nonce: ByteArray = randomBytes(32), + ): InviteReply? { + val pubkeyHex = Secp256k1.publicKeyXOnly(throwawaySecret).toHex() + val content = Nip44.encrypt(throwawaySecret, invite.inviterPubkeyHex, enrolmentCode, nonce) ?: return null + return InviteReply(throwawaySecret, pubkeyHex, content, invite.rendezvous, invite.expiresAtSecs) + } + + private fun randomBytes(size: Int): ByteArray = ByteArray(size).also { SecureRandom().nextBytes(it) } +} diff --git a/app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteUri.kt b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteUri.kt new file mode 100644 index 0000000..595e252 --- /dev/null +++ b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteUri.kt @@ -0,0 +1,58 @@ +package dev.forgesworn.cambium.unlock + +import java.net.URLDecoder + +/** + * Sapwood's invite (enrol-invite spec v1), reversed from [EnrolmentCode]'s own QR: Sapwood shows + * this, Cambium scans it. + * + * ``` + * heartwood-unlock:invite?v=1&k=&r=&x=&relay=[&relay=...] + * ``` + * + * [inviterPubkeyHex] (`inv`) is Sapwood's one-off key, kept only in page memory; [rendezvous] (`ri`) + * is the one-off `h` tag Cambium's reply is addressed to; [expiresAtSecs] (`x`) is when Sapwood + * stops listening; [relays] are where it is listening. Parsing is deliberately as strict as + * [EnrolmentCode.parse]: a repeated single-valued parameter, or a relay that is not `wss://` (or + * `ws://localhost` for a test bench) refuses the whole code, rather than guessing which value was + * meant. + */ +data class InviteUri( + val inviterPubkeyHex: String, + val rendezvous: String, + val expiresAtSecs: Long, + val relays: List, +) { + companion object { + const val SCHEME = "heartwood-unlock" + private val HEX64 = Regex("^[0-9a-f]{64}$") + private val HEX32 = Regex("^[0-9a-f]{32}$") + /** Sapwood generates a fresh invite every 10 minutes; an hour is a generous clock-skew margin. */ + const val MAX_FUTURE_SECS = 3600L + + /** + * Parses [text] as an invite, or null for anything malformed, expired, or too far in the + * future. [nowSecs] is injectable so a test does not need to race a wall clock. + */ + fun parse(text: String, nowSecs: Long = System.currentTimeMillis() / 1000): InviteUri? { + val prefix = "$SCHEME:invite?" + if (!text.startsWith(prefix)) return null + val params = text.removePrefix(prefix).split('&').mapNotNull { pair -> + val eq = pair.indexOf('=') + if (eq <= 0) null else pair.substring(0, eq) to runCatching { URLDecoder.decode(pair.substring(eq + 1), "UTF-8") }.getOrNull() + } + fun one(name: String) = params.singleOrNull { it.first == name }?.second + if (one("v") != "1") return null + val k = one("k")?.takeIf { HEX64.matches(it) } ?: return null + val r = one("r")?.takeIf { HEX32.matches(it) } ?: return null + val x = one("x")?.toLongOrNull() ?: return null + if (x <= nowSecs || x > nowSecs + MAX_FUTURE_SECS) return null + val relays = params.filter { it.first == "relay" }.map { it.second ?: return null }.distinct() + if (relays.isEmpty() || relays.any { !isInviteRelayUrl(it) }) return null + return InviteUri(k, r, x, relays) + } + } +} + +private fun isInviteRelayUrl(url: String): Boolean = + (url.startsWith("wss://") || url.startsWith("ws://localhost")) && url.length in 7..256 && url.none { it.isWhitespace() } diff --git a/app/src/main/kotlin/dev/forgesworn/cambium/unlock/Nip44.kt b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/Nip44.kt new file mode 100644 index 0000000..505ae3e --- /dev/null +++ b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/Nip44.kt @@ -0,0 +1,105 @@ +package dev.forgesworn.cambium.unlock + +import java.security.MessageDigest +import java.util.Base64 +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * NIP-44 v2, pure Kotlin ([Secp256k1] for the ECDH, [ChaCha20] for the stream cipher already used + * for phone unlock's own sealed messages). Only used to build and check the invite reply's content + * (`InviteReply.kt`): rust-nostr's `nip44Encrypt` (see `signer/UnlockRelay.kt`) exposes no way to + * pin the nonce, and cannot run on the host JVM at all (native code per ABI), so it cannot be held + * to the shared Sapwood/Cambium test vector directly. [encrypt] takes the nonce explicitly so a + * test can fix it; production code draws 32 random bytes. + */ +internal object Nip44 { + private const val VERSION: Byte = 2 + private const val NONCE_LEN = 32 + private const val MAC_LEN = 32 + private val SALT = "nip44-v2".toByteArray(Charsets.UTF_8) + + /** `secp256k1_ecdh(secretKey, pubkeyXOnlyHex)` run through `hkdf_extract(salt="nip44-v2", ikm)`. */ + private fun conversationKey(secretKey: ByteArray, pubkeyXOnlyHex: String): ByteArray? { + val pubkey = pubkeyXOnlyHex.hexToBytesOrNull()?.takeIf { it.size == 32 } ?: return null + val sharedX = Secp256k1.ecdhXOnly(secretKey, pubkey) ?: return null + return hmacSha256(SALT, sharedX) + } + + private fun messageKeys(conversationKey: ByteArray, nonce: ByteArray): Triple { + val keys = hkdfExpand(conversationKey, nonce, 76) + return Triple(keys.copyOfRange(0, 32), keys.copyOfRange(32, 44), keys.copyOfRange(44, 76)) + } + + /** + * Encrypts [plaintext] to [pubkeyXOnlyHex] from [secretKey], with an explicit 32-byte [nonce] + * (never reused for two different messages in real use). Null only if [pubkeyXOnlyHex] is not + * a usable public key. + */ + fun encrypt(secretKey: ByteArray, pubkeyXOnlyHex: String, plaintext: String, nonce: ByteArray): String? { + require(nonce.size == NONCE_LEN) { "nonce must be $NONCE_LEN bytes" } + val convKey = conversationKey(secretKey, pubkeyXOnlyHex) ?: return null + val (chachaKey, chachaNonce, hmacKey) = messageKeys(convKey, nonce) + val ciphertext = ChaCha20.xor(chachaKey, chachaNonce, pad(plaintext.toByteArray(Charsets.UTF_8))) + val mac = hmacSha256(hmacKey, nonce + ciphertext) + return Base64.getEncoder().encodeToString(byteArrayOf(VERSION) + nonce + ciphertext + mac) + } + + /** Decrypts a NIP-44 v2 [payload] from [pubkeyXOnlyHex] with [secretKey]. Null on any failure. */ + fun decrypt(secretKey: ByteArray, pubkeyXOnlyHex: String, payload: String): String? { + val blob = runCatching { Base64.getDecoder().decode(payload) }.getOrNull() ?: return null + if (blob.size < 1 + NONCE_LEN + MAC_LEN || blob[0] != VERSION) return null + val nonce = blob.copyOfRange(1, 1 + NONCE_LEN) + val ciphertext = blob.copyOfRange(1 + NONCE_LEN, blob.size - MAC_LEN) + val mac = blob.copyOfRange(blob.size - MAC_LEN, blob.size) + val convKey = conversationKey(secretKey, pubkeyXOnlyHex) ?: return null + val (chachaKey, chachaNonce, hmacKey) = messageKeys(convKey, nonce) + if (!MessageDigest.isEqual(hmacSha256(hmacKey, nonce + ciphertext), mac)) return null + val padded = ChaCha20.xor(chachaKey, chachaNonce, ciphertext) + return unpad(padded)?.toString(Charsets.UTF_8) + } + + private fun pad(plaintext: ByteArray): ByteArray { + val len = plaintext.size + require(len in 1..0xFFFF) { "plaintext must be 1..65535 bytes" } + val prefix = byteArrayOf((len ushr 8).toByte(), len.toByte()) + return prefix + plaintext + ByteArray(paddedLen(len) - len) + } + + private fun unpad(padded: ByteArray): ByteArray? { + if (padded.size < 2) return null + val len = ((padded[0].toInt() and 0xFF) shl 8) or (padded[1].toInt() and 0xFF) + if (len == 0 || padded.size < 2 + len) return null + if (padded.size != 2 + paddedLen(len)) return null + return padded.copyOfRange(2, 2 + len) + } + + /** NIP-44's padding scheme: rounds up to a power-of-two-ish chunk so lengths cluster. */ + private fun paddedLen(len: Int): Int { + if (len <= 32) return 32 + val nextPower = Integer.highestOneBit(len - 1) shl 1 + val chunk = if (nextPower <= 256) 32 else nextPower / 8 + return chunk * ((len - 1) / chunk + 1) + } + + private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray { + val out = ByteArray(length) + var previous = ByteArray(0) + var written = 0 + var counter = 1 + while (written < length) { + previous = hmacSha256(prk, previous + info + byteArrayOf(counter.toByte())) + val n = minOf(previous.size, length - written) + previous.copyInto(out, written, 0, n) + written += n + counter++ + } + return out + } + + private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray = + Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(key, "HmacSHA256")) + doFinal(data) + } +} diff --git a/app/src/main/kotlin/dev/forgesworn/cambium/unlock/Secp256k1.kt b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/Secp256k1.kt new file mode 100644 index 0000000..4281483 --- /dev/null +++ b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/Secp256k1.kt @@ -0,0 +1,97 @@ +package dev.forgesworn.cambium.unlock + +import java.math.BigInteger + +/** + * Just enough secp256k1 (affine point arithmetic over [P], double-and-add scalar multiplication) + * to compute a BIP-340 x-only public key and an x-only ECDH shared point for [Nip44]. Pure Kotlin, + * `java.math.BigInteger` only: rust-nostr already does this natively, but only on a real device + * (see `signer/HeartwoodClient.kt`'s class doc -- native code per ABI cannot load on the host + * JVM), and the invite reply's content must be byte-identical to Sapwood's vector under a fixed + * throwaway secret and nonce, which needs its own implementation held to that vector rather than + * a black box. Only ever called once per invite scan (building the reply) or once per test, so + * naive affine arithmetic (a modular inverse per addition) is not worth optimising away. + */ +internal object Secp256k1 { + val P: BigInteger = BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F", 16) + private val N: BigInteger = BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16) + private val GX: BigInteger = BigInteger("79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798", 16) + private val GY: BigInteger = BigInteger("483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8", 16) + private val G = Point(GX, GY) + + data class Point(val x: BigInteger, val y: BigInteger) + + /** The x-only public key (32 bytes big-endian) for a raw 32-byte secret key scalar. */ + fun publicKeyXOnly(secretKey: ByteArray): ByteArray { + val d = BigInteger(1, secretKey).mod(N) + val point = multiply(d, G) ?: throw IllegalArgumentException("secret key produced the point at infinity") + return to32Bytes(point.x) + } + + /** + * The x coordinate (32 bytes big-endian) of `secretKey * liftX(pubkeyXOnly)`, i.e. NIP-44's + * `secp256k1_ecdh`: the recipient's x-only key is lifted with the even-y point BIP-340 always + * uses, then multiplied by the sender's raw scalar. Null if [pubkeyXOnly] is not a valid + * x-coordinate on the curve. + */ + fun ecdhXOnly(secretKey: ByteArray, pubkeyXOnly: ByteArray): ByteArray? { + val recipient = liftX(pubkeyXOnly) ?: return null + val d = BigInteger(1, secretKey).mod(N) + val shared = multiply(d, recipient) ?: return null + return to32Bytes(shared.x) + } + + /** BIP-340 `lift_x`: the point on the curve with this x-coordinate and an even y, or null. */ + private fun liftX(xBytes: ByteArray): Point? { + val x = BigInteger(1, xBytes) + if (x >= P) return null + val ySquared = x.modPow(BigInteger.valueOf(3), P).add(BigInteger.valueOf(7)).mod(P) + var y = ySquared.modPow(P.add(BigInteger.ONE).shiftRight(2), P) + if (y.modPow(BigInteger.valueOf(2), P) != ySquared) return null // x is not a valid coordinate + if (y.testBit(0)) y = P.subtract(y) + return Point(x, y) + } + + private fun multiply(scalar: BigInteger, point: Point): Point? { + var result: Point? = null + var addend = point + var k = scalar + while (k.signum() > 0) { + if (k.testBit(0)) result = add(result, addend) + addend = double(addend) + k = k.shiftRight(1) + } + return result + } + + private fun add(p1: Point?, p2: Point): Point { + if (p1 == null) return p2 + if (p1.x == p2.x) { + return if (p1.y.add(p2.y).mod(P) == BigInteger.ZERO) { + throw IllegalArgumentException("point at infinity is not representable here") + } else { + double(p1) + } + } + val lambda = p2.y.subtract(p1.y).mod(P).multiply(p2.x.subtract(p1.x).mod(P).modInverse(P)).mod(P) + val x3 = lambda.modPow(BigInteger.valueOf(2), P).subtract(p1.x).subtract(p2.x).mod(P) + val y3 = lambda.multiply(p1.x.subtract(x3)).subtract(p1.y).mod(P) + return Point(x3, y3) + } + + private fun double(p: Point): Point { + val lambda = p.x.modPow(BigInteger.valueOf(2), P).multiply(BigInteger.valueOf(3)) + .multiply(p.y.shiftLeft(1).mod(P).modInverse(P)).mod(P) + val x3 = lambda.modPow(BigInteger.valueOf(2), P).subtract(p.x.shiftLeft(1)).mod(P) + val y3 = lambda.multiply(p.x.subtract(x3)).subtract(p.y).mod(P) + return Point(x3, y3) + } + + private fun to32Bytes(value: BigInteger): ByteArray { + val raw = value.toByteArray() + val out = ByteArray(32) + val copyLen = minOf(raw.size, 32) + raw.copyInto(out, 32 - copyLen, raw.size - copyLen, raw.size) + return out + } +} diff --git a/app/src/main/kotlin/dev/forgesworn/cambium/unlock/UnlockEnrolActivity.kt b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/UnlockEnrolActivity.kt index bf404df..9e512ee 100644 --- a/app/src/main/kotlin/dev/forgesworn/cambium/unlock/UnlockEnrolActivity.kt +++ b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/UnlockEnrolActivity.kt @@ -20,6 +20,9 @@ import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.google.zxing.BarcodeFormat import com.journeyapps.barcodescanner.BarcodeEncoder +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanIntentResult +import com.journeyapps.barcodescanner.ScanOptions import dev.forgesworn.cambium.R import dev.forgesworn.cambium.databinding.ActivityUnlockEnrolBinding import dev.forgesworn.cambium.pairing.Pairing @@ -33,34 +36,47 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import rust.nostr.sdk.Event import java.security.SecureRandom import javax.crypto.Cipher /** * Makes this phone an unlock phone for the board behind one paired identity. * - * 1. Shows an [EnrolmentCode]: a one-off enrolment pubkey, a one-off rendezvous tag and the - * relays to meet on. Its secret half stays in this activity's memory and nowhere else. - * 2. Sapwood (or the bench script) enrols the pubkey on the board, which needs a press there, - * and publishes the board's sealed answer tagged with the rendezvous tag. - * 3. Cambium opens it, and the owner's screen lock (PIN or strong biometric) seals the slot secret under a new Keystore key - * ([SlotSecretVault]). Only then is the enrolment stored, the listener started, and the - * keep-alive switched on, so the phone hears the board after a power cut. + * The primary path (enrol-invite spec v1) reverses the optical step: Sapwood shows an invite QR, + * this phone scans it. + * + * 1. "Scan Sapwood's code" scans and strictly parses an [InviteUri]. A bunker link or another + * phone's own enrolment code gets a helpful wrong-direction message ([EnrolInviteScan]). + * 2. Cambium then builds its [EnrolmentCode] exactly as before (a one-off enrolment pubkey, a + * one-off rendezvous tag, this phone's relays), seals it to the invite's `inv` key + * ([InviteReplyBuilder]) and publishes the reply once to the invite's relays. Sending/sent/ + * failed shows on screen, with a Retry that republishes the exact same signed event. + * 3. From there it is exactly the old flow: waits for the board's hand-off (now on its own relays + * plus the invite's relays), shows the five request words, then the check code, then the + * fingerprint that seals the slot secret under a new Keystore key ([SlotSecretVault]). Only + * then is the enrolment stored, the listener started, and the keep-alive switched on, so the + * phone hears the board after a power cut. + * + * "Show a code instead" keeps the original direction (this phone shows a QR, Sapwood scans it) as + * a secondary option, for a Sapwood with no camera-visible screen or an older build. * * Nothing secret is ever on screen or passes through Sapwood. If the owner leaves before the * fingerprint, or waits more than [PENDING_TIMEOUT_MILLIS], the secret is dropped and the screen * says which board record to revoke. * - * The hand-off is not signed by anything the phone already trusts, so anyone who saw the code - * could race in an answer of their own. Two guards: the screen shows the board record number for - * the owner to compare with what Sapwood (or the bench script) reports, and a second, different - * answer arriving before the fingerprint blocks the enrolment outright. + * The hand-off is not signed by anything the phone already trusts, so anyone who saw the code (or + * the invite) could race in an answer of their own. Three guards: the owner compares the five + * request words between this phone and Sapwood (or the board's own card) before confirming, the + * board shows the same five words before its button press, and a second, different answer arriving + * before the fingerprint blocks the enrolment outright. * Declared with `configChanges` in the manifest so a rotation does not lose the enrolment key. */ class UnlockEnrolActivity : AppCompatActivity() { private lateinit var binding: ActivityUnlockEnrolBinding private lateinit var pairing: Pairing + private lateinit var relays: List private var enrolSecretHex: String? = null private var watch: RelayWatch? = null private var pending: HandOff? = null @@ -71,7 +87,15 @@ class UnlockEnrolActivity : AppCompatActivity() { private var conflicted = false @Volatile private var destroyed = false + /** Held for Retry: the exact signed event, and the invite's relays it targets. */ + private var pendingInviteReply: Event? = null + private var pendingInviteRelays: List = emptyList() + private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { } + private val scanLauncher = registerForActivityResult(ScanContract()) { result -> onInviteScanResult(result) } + private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) launchScanner() else binding.enrolStatus.text = getString(R.string.enrol_camera_denied) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -80,6 +104,9 @@ class UnlockEnrolActivity : AppCompatActivity() { binding.enrolDoneButton.setOnClickListener { finish() } binding.enrolConfirmButton.setOnClickListener { promptSeal() } binding.enrolBatteryButton.setOnClickListener { requestBatteryExemption() } + binding.enrolScanButton.setOnClickListener { onScanClicked() } + binding.enrolShowCodeButton.setOnClickListener { onShowCodeClicked() } + binding.enrolRetryButton.setOnClickListener { publishInviteReply() } val signer = intent.getStringExtra(EXTRA_SIGNER_PUBKEY) pairing = PairingStore(this).pairings().firstOrNull { it.signerPubkeyHex == signer } ?: return finish() @@ -89,38 +116,17 @@ class UnlockEnrolActivity : AppCompatActivity() { showOnly(getString(R.string.enrol_needs_biometric)) return } - val relays = pairing.relays.map { it.trimEnd('/') }.filter(::isRelayUrl).distinct() + relays = pairing.relays.map { it.trimEnd('/') }.filter(::isRelayUrl).distinct() if (relays.isEmpty()) { showOnly(getString(R.string.enrol_no_relays)) return } - val (secretHex, pubkeyHex) = UnlockNostr.newEnrolmentKey() - enrolSecretHex = secretHex - val rendezvous = ByteArray(16).also { SecureRandom().nextBytes(it) }.toHex() - val code = EnrolmentCode(pubkeyHex, rendezvous, EnrolmentCode.fitLabel(Build.MODEL), relays).encode() - binding.enrolCode.text = code - binding.enrolWords.text = requestWords(pubkeyHex).orEmpty() - binding.enrolQr.setImageBitmap(BarcodeEncoder().encodeBitmap(code, BarcodeFormat.QR_CODE, 720, 720)) - binding.enrolCopyButton.setOnClickListener { - getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("Heartwood unlock enrolment", code)) - } - binding.enrolStatus.text = getString(R.string.enrol_waiting) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } - - // Not lifecycleScope: a start cancelled half way would leave a relay client nobody can - // stop. The watch is recorded, or stopped at once if the screen closed meanwhile. - CoroutineScope(Dispatchers.IO).launch { - val started = RelayWatch.handOff(relays, rendezvous) { raw -> runOnUiThread { onHandOff(raw) } } - runOnUiThread { - if (destroyed) CoroutineScope(Dispatchers.IO).launch { started.stop() } else watch = started - } - } } override fun onDestroy() { @@ -137,6 +143,135 @@ class UnlockEnrolActivity : AppCompatActivity() { super.onDestroy() } + // -- Secondary path: this phone shows a QR, Sapwood scans it (unchanged from before this spec). -- + + private fun onShowCodeClicked() { + binding.enrolScanButton.isVisible = false + binding.enrolShowCodeButton.isVisible = false + binding.enrolBody.isVisible = false + binding.enrolShowCodeBody.isVisible = true + + val code = buildOwnEnrolment() + val text = code.encode() + binding.enrolCode.text = text + binding.enrolCode.isVisible = true + binding.enrolWords.text = requestWords(code.enrolPubkeyHex).orEmpty() + binding.enrolWords.isVisible = true + binding.enrolWordsHint.isVisible = true + binding.enrolQr.setImageBitmap(BarcodeEncoder().encodeBitmap(text, BarcodeFormat.QR_CODE, 720, 720)) + binding.enrolQr.isVisible = true + binding.enrolCopyButton.setOnClickListener { + getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("Heartwood unlock enrolment", text)) + } + binding.enrolCopyButton.isVisible = true + binding.enrolStatus.text = getString(R.string.enrol_waiting) + + startHandOffWatch(relays, code.rendezvous) + } + + // -- Primary path: Sapwood shows an invite, this phone scans it (enrol-invite spec v1). -- + + private fun onScanClicked() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + launchScanner() + } else { + cameraPermission.launch(Manifest.permission.CAMERA) + } + } + + private fun launchScanner() { + val options = ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt(getString(R.string.qr_scan_prompt)) + setBeepEnabled(false) + setOrientationLocked(true) + } + scanLauncher.launch(options) + } + + private fun onInviteScanResult(result: ScanIntentResult) { + when (val evaluated = EnrolInviteScan.evaluate(result.contents)) { + is EnrolInviteScanResult.Accepted -> onInviteAccepted(evaluated.invite) + is EnrolInviteScanResult.Rejected -> binding.enrolStatus.text = evaluated.message + EnrolInviteScanResult.Cancelled -> Unit + } + } + + private fun onInviteAccepted(invite: InviteUri) { + binding.enrolScanButton.isVisible = false + binding.enrolShowCodeButton.isVisible = false + binding.enrolBody.isVisible = false + + val code = buildOwnEnrolment() + binding.enrolWords.text = requestWords(code.enrolPubkeyHex).orEmpty() + binding.enrolWords.isVisible = true + binding.enrolWordsHint.isVisible = true + + val reply = InviteReplyBuilder.build(invite, code.encode()) + if (reply == null) { + binding.enrolStatus.text = getString(R.string.enrol_send_failed) + return + } + val event = UnlockNostr.inviteReplyEvent(reply) + reply.throwawaySecret.fill(0) // signed; the throwaway key has no further purpose + pendingInviteReply = event + pendingInviteRelays = invite.relays + binding.enrolStatus.text = getString(R.string.enrol_sending) + + val combined = (relays + invite.relays).distinct() + // Not lifecycleScope: a start cancelled half way would leave a relay client nobody can + // stop. The watch is recorded, or stopped at once if the screen closed meanwhile. The + // publish itself waits for this same connect, so the reply is never lost to the race + // between "connected" and "about to publish". + CoroutineScope(Dispatchers.IO).launch { + val started = RelayWatch.handOff(combined, code.rendezvous) { raw -> runOnUiThread { onHandOff(raw) } } + if (destroyed) { + started.stop() + return@launch + } + watch = started + publishInviteReply() + } + } + + /** Publishes [pendingInviteReply] to [pendingInviteRelays]. Retry calls this again, unchanged. */ + private fun publishInviteReply() { + val event = pendingInviteReply ?: return + val current = watch ?: return + binding.enrolRetryButton.isVisible = false + binding.enrolStatus.text = getString(R.string.enrol_sending) + CoroutineScope(Dispatchers.IO).launch { + val ok = current.publish(event, pendingInviteRelays) + runOnUiThread { + if (destroyed) return@runOnUiThread + if (ok) { + binding.enrolStatus.text = getString(R.string.enrol_sent) + } else { + binding.enrolStatus.text = getString(R.string.enrol_send_failed) + binding.enrolRetryButton.isVisible = true + } + } + } + } + + // -- Shared by both paths. -- + + private fun buildOwnEnrolment(): EnrolmentCode { + val (secretHex, pubkeyHex) = UnlockNostr.newEnrolmentKey() + enrolSecretHex = secretHex + val rendezvous = ByteArray(16).also { SecureRandom().nextBytes(it) }.toHex() + return EnrolmentCode(pubkeyHex, rendezvous, EnrolmentCode.fitLabel(Build.MODEL), relays) + } + + private fun startHandOffWatch(onRelays: List, rendezvous: String) { + CoroutineScope(Dispatchers.IO).launch { + val started = RelayWatch.handOff(onRelays, rendezvous) { raw -> runOnUiThread { onHandOff(raw) } } + runOnUiThread { + if (destroyed) CoroutineScope(Dispatchers.IO).launch { started.stop() } else watch = started + } + } + } + private fun onHandOff(raw: RawAnnouncement) { if (conflicted || isFinishing) return val secret = enrolSecretHex ?: return @@ -181,6 +316,7 @@ class UnlockEnrolActivity : AppCompatActivity() { binding.enrolWords.isVisible = false binding.enrolWordsHint.isVisible = false binding.enrolCopyButton.isVisible = false + binding.enrolRetryButton.isVisible = false binding.enrolStatus.text = getString(R.string.enrol_received) binding.enrolCheckCode.text = checkCode(envelope.ephemeralPubkeyHex).orEmpty() binding.enrolCheckCode.isVisible = true @@ -278,6 +414,9 @@ class UnlockEnrolActivity : AppCompatActivity() { private fun showOnly(message: String) { binding.enrolBody.isVisible = false + binding.enrolScanButton.isVisible = false + binding.enrolShowCodeButton.isVisible = false + binding.enrolShowCodeBody.isVisible = false binding.enrolQr.isVisible = false binding.enrolCode.isVisible = false binding.enrolWords.isVisible = false diff --git a/app/src/main/res/layout/activity_unlock_enrol.xml b/app/src/main/res/layout/activity_unlock_enrol.xml index 9d0531c..6c20b18 100644 --- a/app/src/main/res/layout/activity_unlock_enrol.xml +++ b/app/src/main/res/layout/activity_unlock_enrol.xml @@ -30,6 +30,34 @@ android:text="@string/enrol_body" android:textColor="@color/on_background_muted" /> +