Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,19 +592,50 @@ 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
from a fresh key; nothing pings the board because of a lock message (the gone-quiet alert uses
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` is deliberately thin: `InviteReplyBuilder.tags(invite)` is the reply event's
exactly-two tags (`h`/`expiration`), pure Kotlin, nothing cryptographic -- production
(`signer/UnlockRelay.kt`'s `inviteReplyEvent`, below) and `EnrolInviteVectorTest` both call this
one function rather than keeping two copies of the shape that could drift apart. All the actual
cryptography for the reply -- the fresh throwaway key, NIP-44 v2 encrypting
`EnrolmentCode.encode()` to the invite's `inv` key, signing -- runs entirely through rust-nostr in
production, the same as `deliveryEvent` below; nothing hand-rolled reaches `app/src/main`.

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) still has to be checked byte
for byte on the host JVM, where rust-nostr's native code cannot load at all (see
`pairing/BunkerUri.kt`'s class doc) and its Kotlin bindings expose no way to pin the NIP-44 nonce
regardless. `EnrolInviteVectorTest` holds it to `InviteReplyBuilder.tags` for the tag values, and
to a **test-only** reference NIP-44 v2 (`src/test/kotlin/.../Nip44.kt`) and just enough secp256k1
(`Secp256k1.kt`, naive affine double-and-add, no attempt at constant time or speed) for the
content: decrypting the vector's content to its plaintext, and, given its fixed throwaway secret
and nonce, reproducing its exact ciphertext. Both files live under `app/src/test`, are never
reachable from `app/src/main`, and exist purely to check the wire format independently of
rust-nostr -- production never uses them.
- `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), building and signing an enrol-invite reply
(`inviteReplyEvent`: `Keys.generate()`, `nip44Encrypt` with a random nonce, `InviteReplyBuilder.
tags` turned into real `Tag`s, `signWithKeys` -- 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

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/kotlin/dev/forgesworn/cambium/signer/UnlockRelay.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package dev.forgesworn.cambium.signer

import android.util.Log
import dev.forgesworn.cambium.toHex
import dev.forgesworn.cambium.unlock.InviteReplyBuilder
import dev.forgesworn.cambium.unlock.InviteUri
import dev.forgesworn.cambium.unlock.PhoneUnlock
import dev.forgesworn.cambium.unlock.RawAnnouncement
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -64,6 +67,22 @@ object UnlockNostr {
.tags(listOf(Tag.publicKey(board)))
.signWithKeys(throwaway)
}

/**
* Builds and signs the enrol-invite reply to [invite] (spec v1): a fresh throwaway key,
* NIP-44 v2 content (a random nonce, entirely rust-nostr's own) sealing [enrolmentCode] to
* the invite's `inv` key, and exactly the two tags [InviteReplyBuilder.tags] says
* (`h`/`expiration`) -- the same pure shape `EnrolInviteVectorTest` checks against the shared
* Sapwood/Cambium vector. Everything cryptographic here is rust-nostr, never the hand-rolled,
* test-only NIP-44/secp256k1 that vector test uses to check the wire format independently.
*/
fun inviteReplyEvent(invite: InviteUri, enrolmentCode: String): Event {
val throwaway = Keys.generate()
val inviter = PublicKey.parse(invite.inviterPubkeyHex)
val content = nip44Encrypt(throwaway.secretKey(), inviter, enrolmentCode, Nip44Version.V2)
val tags = InviteReplyBuilder.tags(invite).map { (name, value) -> Tag.parse(listOf(name, value)) }
return EventBuilder(Kind(PhoneUnlock.HANDOFF_KIND.toUShort()), content).tags(tags).signWithKeys(throwaway)
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 21 additions & 0 deletions app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteReply.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package dev.forgesworn.cambium.unlock

/**
* The shaping of an enrol-invite reply event (spec v1) that has nothing to do with cryptography:
* exactly two tags, `h` (the invite's own rendezvous `ri`) and `expiration` (NIP-40, the invite's
* own `x`), and nothing else. Pure Kotlin, no rust-nostr, shared between the production builder
* (`signer/UnlockRelay.kt`'s `UnlockNostr.inviteReplyEvent`, which turns these into real `Tag`s,
* NIP-44-encrypts the plaintext, and signs, all through rust-nostr) and `EnrolInviteVectorTest`
* (which checks the same tag values against the shared Sapwood/Cambium vector), so both are held
* to one copy of this shape rather than two that could quietly drift apart.
*
* The actual encryption is deliberately not here: rust-nostr's Kotlin bindings expose no way to
* pin the NIP-44 nonce and cannot load on the host JVM at all (native code per ABI -- see
* `pairing/BunkerUri.kt`'s class doc), so production and the vector test cannot share that step.
* The vector test instead holds a test-only reference NIP-44 v2 implementation
* (`src/test/kotlin/.../Nip44.kt`, `Secp256k1.kt`) to the vector directly.
*/
object InviteReplyBuilder {
fun tags(invite: InviteUri): List<Pair<String, String>> =
listOf("h" to invite.rendezvous, "expiration" to invite.expiresAtSecs.toString())
}
58 changes: 58 additions & 0 deletions app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteUri.kt
Original file line number Diff line number Diff line change
@@ -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=<inv pubkey hex64>&r=<ri hex32>&x=<unix>&relay=<wss url>[&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<String>,
) {
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() }
Loading
Loading