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
39 changes: 34 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,25 +552,54 @@ Android apps Websites
JVM-tested: `PhoneUnlock.kt` (K = HKDF(S), the per-boot hint, opening the sealed lock message with
`ChaCha20.kt` + HMAC-SHA256, the delivery plaintext, and `judge`, the prompt rule) held to the
firmware's own vectors (`src/test/resources/phone-unlock-v1.json`, copied from heartwood-esp32
`common/tests/fixtures/`; regenerate only with a deliberate format bump there);
`common/tests/fixtures/`; regenerate only with a deliberate format bump there). Kind 24135 also
carries a relay-update variant -- identical construction to a lock announcement, only the sealed
`t` differs (`"relays"` instead of `"locked"`), sent whenever the board's own relay list changes
outside of a restart; `phone-unlock-v1-relays.json` (also copied from heartwood-esp32) is its
vector. `judge` returns `NOT_LOCKED` for it unconditionally (checked before staleness/replay/
duplicate), so it never raises a prompt; `LockMatcher.withRelaysFrom` follows its relay list the
same as a lock announcement's, regardless of verdict.
`Enrolment.kt` (the `heartwood-unlock:enrol?...` code Cambium shows, and the kind-24137 hand-off,
whose content is the board's enrolment answer passed through); `LockMatcher.kt` (matching one
announcement against every enrolled board, relay-list following, and `Reachability`, the
"gone quiet" rule). Android-side: `SlotSecretVault.kt` (per-board Keystore AES key, per-use
authentication by strong biometric or, on API 30+, the device credential (the owner's
"gone quiet" rule); `RelayGate.kt` (`RelayJitter`, a uniform 30 s-10 min draw from a
`SecureRandom` by default, and `RelayGate`, which withholds a relay a message just taught Cambium
about until that jitter elapses -- a relay Cambium already knows, from pairing/enrolment/a prior
session, is trusted immediately with no delay; both take their randomness/jitter as constructor
parameters so `RelayGateTest`/`RelayJitterTest` run entirely on `kotlinx.coroutines.test`'s
virtual time rather than real minutes). Android-side: `SlotSecretVault.kt` (per-board Keystore AES
key, per-use authentication by strong biometric or, on API 30+, the device credential (the owner's
GrapheneOS phone has no biometrics, deliberately), invalidated on biometric enrolment change,
StrongBox when present),
`UnlockStore.kt` (enrolments and ping records in their own EncryptedSharedPreferences, `commit()`
writes), `UnlockCoordinator.kt` (process-wide listener owner: current requests as a `StateFlow`,
sent deliveries, "still locked" detection from a same-boot repeat 25 s after answering),
sent deliveries, "still locked" detection from a same-boot repeat 25 s after answering; owns the
`RelayGate` -- a board's relays are trusted the first time its id is seen this process, a relay a
running board later teaches it about while passively listening goes through `RelayGate.learn`,
and `forget` prunes a forgotten board's relays out of the ready set, unless another remaining
board still needs one of them. `deliver` -- an owner-tapped, genuine unlock -- trusts its own lock
message's relays outright (`relayGate.trust`) before calling `sync`, rather than routing them
through `learn`'s jitter: the owner's tap already exposes the timing, so there is nothing left to
protect by delaying, and this is exactly the case the relay-update message exists for (the board
restarted locked on a relay it only announced less than a jitter ago; without this, the delivery
would go to the board's *old* relays, where it is no longer listening, until the jitter finally
elapsed). Guarded on `t == "locked"` even though `judge` already guarantees a relay-update can
never produce a `PROMPT`. `sync` itself blocks until `RelayWatch.locks` has tried to connect
(`waitForConnection`, ~10 s), so by the time `deliver` reaches `RelayWatch.publish` the newly
trusted relay is normally already in its `added` set; `publish`'s fallback to whatever is
already connected only matters if that connection attempt itself failed, and is left as-is
deliberately -- retrying or waiting longer there has no better chance of reaching an unreachable
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).

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

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.security.SecureRandom
import java.time.Duration
import java.util.Random
import java.util.concurrent.ConcurrentHashMap

/**
* How long Cambium waits before connecting to a relay a message just taught it about, so that
* relay -- one it has never spoken to before -- cannot tie this phone's first connection to the
* exact moment the board's broadcast reached it. A relay Cambium already knows (from pairing,
* enrolment, or a previous session) is connected immediately; see [RelayGate].
*/
object RelayJitter {
val MIN: Duration = Duration.ofSeconds(30)
val MAX: Duration = Duration.ofMinutes(10)

/** Uniform in [[min], [max]), drawn from [random] -- a [SecureRandom] by default. */
fun next(random: Random = SecureRandom(), min: Duration = MIN, max: Duration = MAX): Duration {
require(!max.isNegative && max >= min) { "max must be at least min" }
val spanMillis = max.toMillis() - min.toMillis()
val offsetMillis = if (spanMillis <= 0L) 0L else (random.nextDouble() * spanMillis).toLong()
return min.plusMillis(offsetMillis)
}
}

/**
* Which relays [UnlockCoordinator] may connect to right now. A relay already known -- from
* pairing, enrolment, or a previous session -- is ready immediately via [trust]; a relay a message
* just taught Cambium about is withheld until a random jitter elapses ([learn]), so it cannot
* correlate its first connection from this phone to the moment the board's broadcast changed.
*
* Pure Kotlin bar the coroutine delay itself, so tests can run [learn] against virtual time
* (`kotlinx.coroutines.test`) instead of real minutes, and supply their own [jitter].
*/
class RelayGate(private val jitter: () -> Duration = { RelayJitter.next() }) {
private val readyRelays = ConcurrentHashMap.newKeySet<String>()
private val pending = ConcurrentHashMap.newKeySet<String>()

/** The subset of [relays] safe to connect to right now. */
fun ready(relays: Collection<String>): List<String> = relays.filter { it in readyRelays }

/** Marks [relays] ready with no delay: already known, not something a message just taught us. */
fun trust(relays: Collection<String>) {
readyRelays += relays
}

/**
* Drops [relays] from the ready set -- a board being forgotten. Cheap best-effort cleanup, not
* required for correctness: a relay already scheduled in [learn] (`pending`) is left alone and
* simply becomes ready, harmlessly, whenever its jitter elapses.
*/
fun untrust(relays: Collection<String>) {
readyRelays -= relays.toSet()
}

/**
* Schedules each of [relays] not already ready or already scheduled to become ready after a
* jitter, on [scope], calling [onReady] once each one does. Returns immediately -- the wait
* happens in the launched coroutines, never on the caller, so this never delays the caller's
* own work (in particular, an unlock delivery must stay fast; see [UnlockCoordinator.deliver]).
*/
fun learn(scope: CoroutineScope, relays: Collection<String>, onReady: () -> Unit) {
for (relay in relays) {
if (relay in readyRelays || !pending.add(relay)) continue
val wait = jitter()
scope.launch {
delay(wait.toMillis())
readyRelays += relay
pending -= relay
onReady()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ object UnlockCoordinator {
private var watchedRelays: List<String> = emptyList()
@Volatile private var boards: List<UnlockEnrolment> = emptyList()

/**
* Gates which relays are actually connected to: a board's relays at the point it is first seen
* this process (pairing, enrolment, restart) are trusted immediately, but a relay a lock or
* relay-update message teaches Cambium about while a board is already known waits out a jitter
* first -- see [RelayGate] and [onAnnouncement].
*/
private val relayGate = RelayGate()
private val knownBoardIds = ConcurrentHashMap.newKeySet<Long>()

/**
* Starts, restarts or stops the listener so it watches exactly the enrolled boards' relays.
* Runs to completion even if the caller is cancelled (an activity closed mid-start): a relay
Expand All @@ -62,7 +71,14 @@ object UnlockCoordinator {
mutex.withLock {
val app = context.applicationContext
boards = UnlockStore(app).enrolments()
val relays = LockMatcher.relayUnion(boards)
// A board seen for the first time this process (fresh pairing, enrolment, or the
// process's first sync after a restart) has its relays trusted at once: they came from
// pairing/enrolment, not from a stray relay message. A board already known keeps
// whatever relayGate already granted it -- a newly learned relay for it only becomes
// ready once onAnnouncement's jitter elapses.
val freshBoards = boards.filter { knownBoardIds.add(it.id) }
if (freshBoards.isNotEmpty()) relayGate.trust(freshBoards.flatMap { it.relays })
val relays = relayGate.ready(LockMatcher.relayUnion(boards))
if (relays == watchedRelays && watch != null) return@withLock
watch?.stop()
watch = null
Expand Down Expand Up @@ -90,6 +106,15 @@ object UnlockCoordinator {
_requests.update { it - enrolmentId }
sent.remove(enrolmentId)
warnedStillLocked.remove(enrolmentId)
knownBoardIds.remove(enrolmentId)
// The caller removes the enrolment from the store before calling this, but boards (this
// process's cache) has not been reloaded yet, so the forgotten board's relays are still
// here to prune -- unless another remaining board still needs one of them.
val forgotten = boards.firstOrNull { it.id == enrolmentId }
if (forgotten != null) {
val stillNeeded = boards.asSequence().filter { it.id != enrolmentId }.flatMap { it.relays }.toSet()
relayGate.untrust(forgotten.relays.filterNot { it in stillNeeded })
}
}

private fun onAnnouncement(app: Context, raw: RawAnnouncement) {
Expand All @@ -99,10 +124,15 @@ object UnlockCoordinator {
val store = UnlockStore(app)

// Any authentic message carries the board's relay list: follow it, whatever the verdict.
// The relay list itself is trusted and persisted at once; connecting to any brand-new
// relay in it is delayed by a jitter (relayGate.learn), so a relay Cambium has never spoken
// to before cannot correlate its first connection to the moment this message arrived.
val followed = LockMatcher.withRelaysFrom(match.enrolment, match.context)
if (followed !== match.enrolment) {
val newRelays = followed.relays.filterNot { it in match.enrolment.relays }
store.modify(id) { it.copy(relays = followed.relays) }
scope.launch { sync(app) }
boards = boards.map { if (it.id == id) it.copy(relays = followed.relays) else it }
relayGate.learn(scope, newRelays) { scope.launch { sync(app) } }
}

when (match.verdict) {
Expand Down Expand Up @@ -138,6 +168,15 @@ object UnlockCoordinator {
* wipes [slotSecret] afterwards and has already checked [request] is still the board's
* current one. The delivery goes only to the relays the board itself listed, not to every
* relay this phone listens on. True once at least one relay accepted it.
*
* The board may have moved to a brand-new relay less than a jitter ago (that is exactly what
* the relay-update message is for): if we waited out [relayGate]'s jitter here too, a delivery
* could sit unconnected to the board's new relay for up to 10 minutes. The owner's tap already
* exposes the timing -- there is nothing left to protect by delaying it -- so a genuine lock
* prompt's own relays are trusted immediately, before [sync] rebuilds the watch and waits for
* the connection. [match]'s verdict is only ever `PROMPT` for `t == "locked"` (see
* [PhoneUnlock.judge]), so a relay-update can never reach this method, but the check is kept
* explicit rather than relied on implicitly.
*/
suspend fun deliver(context: Context, request: Request, slotSecret: ByteArray): Boolean {
val app = context.applicationContext
Expand All @@ -146,6 +185,9 @@ object UnlockCoordinator {
match.announcement.authorHex,
PhoneUnlock.deliveryJson(match.enrolment.id, slotSecret),
)
if (match.context.t == PhoneUnlock.TYPE_LOCKED) {
relayGate.trust(match.context.relays.map { it.trimEnd('/') }.filter(::isRelayUrl))
}
sync(app)
val targets = match.context.relays.map { it.trimEnd('/') }.filter(::isRelayUrl)
.ifEmpty { match.enrolment.relays }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,16 @@ class LockMatcherTest {
last = last,
)

private fun announcement(forEnrolment: UnlockEnrolment, id: Long = forEnrolment.id, boot: Long = 5, createdAt: Long = now): RawAnnouncement {
private fun announcement(
forEnrolment: UnlockEnrolment,
id: Long = forEnrolment.id,
boot: Long = 5,
createdAt: Long = now,
t: String = "locked",
relays: List<String> = listOf("wss://new.example"),
): RawAnnouncement {
val k = forEnrolment.phoneKeyHex.hexToBytesOrNull()!!
val context = LockContext(1, "locked", id, boot, "power-on", "home", "", "0.18.0-beta.17", listOf("wss://new.example"))
val context = LockContext(1, t, id, boot, "power-on", "home", "", "0.18.0-beta.17", relays)
val content = PhoneUnlock.sealContext(k, author, Json.encodeToString(LockContext.serializer(), context), ByteArray(12))
return RawAnnouncement("e".repeat(64), authorHex, createdAt, content, PhoneUnlock.hint(k, author))
}
Expand Down Expand Up @@ -69,6 +76,37 @@ class LockMatcherTest {
assertEquals(Verdict.STALE, LockMatcher.match(announcement(desk, boot = 6, createdAt = now - 600), listOf(desk), now)?.verdict)
}

@Test
fun `a relay-update message is matched and opened but never prompts`() {
val desk = enrolment(1, 1)
val match = LockMatcher.match(announcement(desk, t = "relays"), listOf(desk), now)
assertNotNull(match)
assertEquals("relays", match.context.t)
assertEquals(Verdict.NOT_LOCKED, match.verdict)
// withRelaysFrom follows it regardless of verdict, exactly like a lock announcement would.
val followed = LockMatcher.withRelaysFrom(match.enrolment, match.context)
assertEquals(listOf("wss://relay.example", "wss://new.example"), followed.relays)
}

@Test
fun `a duplicate or stale relay-update never prompts either`() {
val desk = enrolment(1, 1, last = LastPrompt(5, authorHex))
// Same boot and author as the recorded last prompt: a lock announcement here would be
// Verdict.DUPLICATE; a relay-update is NOT_LOCKED regardless.
assertEquals(Verdict.NOT_LOCKED, LockMatcher.match(announcement(desk, t = "relays"), listOf(desk), now)?.verdict)
// An older boot: a lock announcement here would be Verdict.REPLAY; still NOT_LOCKED.
assertEquals(
Verdict.NOT_LOCKED,
LockMatcher.match(announcement(desk, t = "relays", boot = 4), listOf(desk), now)?.verdict,
)
// Outside the announce-age window: a lock announcement here would be Verdict.STALE; still
// NOT_LOCKED.
assertEquals(
Verdict.NOT_LOCKED,
LockMatcher.match(announcement(desk, t = "relays", boot = 6, createdAt = now - 600), listOf(desk), now)?.verdict,
)
}

@Test
fun `relay lists are merged, never shrunk`() {
val desk = enrolment(1, 1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,33 @@ class PhoneUnlockTest {
assertEquals(field("delivery"), PhoneUnlock.deliveryJson(context.id, s))
}

/**
* The firmware's relay-update vector (heartwood-esp32
* `common/tests/fixtures/phone-unlock-v1-relays.json`): identical construction to a lock
* announcement, only `t` differs. A phone opens it exactly the same way and never prompts.
*/
@Test
fun `the firmware relay-update vector opens and is never a prompt`() {
val text = javaClass.classLoader!!.getResource("phone-unlock-v1-relays.json")!!.readText()
val fixture = Json.parseToJsonElement(text) as JsonObject
fun field(name: String) = fixture[name]!!.jsonPrimitive.content
val s = field("slot_secret").hexToBytesOrNull()!!
val k = PhoneUnlock.phoneKey(s)
assertEquals(field("phone_key"), k.toHex())
val author = field("author").hexToBytesOrNull()!!
assertEquals(field("hint"), PhoneUnlock.hint(k, author))
val context = Json.decodeFromJsonElement(LockContext.serializer(), fixture["context"]!!)
assertEquals(ctx(PhoneUnlock.TYPE_RELAYS), context)
assertEquals(PhoneUnlock.TYPE_RELAYS, context.t)
val sealed = PhoneUnlock.sealContext(k, author, json(context), field("nonce").hexToBytesOrNull()!!)
assertEquals(field("content"), sealed)
assertEquals(context, PhoneUnlock.openContext(k, author, field("content")))
assertEquals(
Verdict.NOT_LOCKED,
PhoneUnlock.judge(context, author.toHex(), 1_800_000_000L, 1_800_000_000L, null),
)
}

@Test
fun `a phone recognises and opens its own announcement only`() {
val k = PhoneUnlock.phoneKey(bytes(1))
Expand Down
Loading
Loading