Issue, spend and revoke delivery capabilities on Android (§21.4)

The last client. A recipient mints a batch of random tokens per contact,
uploads only their hashes, and hands the tokens over inside the ratchet;
a sender spends one per message; revoking one contact is dropping one
batch, and nobody else notices.

The batch rides the existing `delivery_key` envelope rather than a new
control kind, because a receiver recognises control traffic by KIND
first and falls back to the `ctl` flag only for kinds it has never heard
of — and this engine reads that flag with `optBoolean`, which does not
coerce the number §6.10 calls for. A `delivery_tokens` kind would have
arrived on the phones already out there as a bubble full of raw JSON.

Replenishment is counted rather than requested, for the same reason: a
"running low" message would have been another new kind. The issuer can
count on its own, because every message it receives from a contact spent
one of their capabilities, and it mints afresh at half a batch.

A refused capability means the batch was revoked, so the rest is
discarded and the same envelopes are re-sent on the shared key — they
are addressed to the device, not to the authorisation. That is not a way
around revocation, and the code says so: revoking moves a sender back
onto §14.4's key, it does not silence them.

One bug the tests found. `consumeControl` read the peer's delivery key
first and bailed with `loadDelivery() ?: return`, so a device that had
not published a key of ITS own yet — every device before
`ensureDeliveryKey` runs — silently dropped everything in the envelope,
capabilities included. Capabilities have their own store and no such
dependency, so they are handled before that early return.

Verified against a locally-run relay carrying the §21 work as well as by
the unit suite: 126 capability hashes on the relay after the live run,
so the path is exercised rather than mocked past.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 19:25:22 -04:00
co-authored by Claude Opus 4.8
parent 356c89e1cd
commit 79c2734f86
6 changed files with 601 additions and 13 deletions
@@ -189,6 +189,27 @@ object SealedSender {
fun deliveryKeyDigest(key: ByteArray): ByteArray = Bytes.sha256(key)
/** The control message that hands a contact this key (§6.6, §14.4). */
fun deliveryKeyEnvelope(key: ByteArray): String =
"""{"fnc":1,"kind":"${Content.Kind.DELIVERY_KEY}","key":"${Bytes.b64(key)}"}"""
/**
* The control message handing a contact our delivery key (§14.4) and, since
* §21.4, a batch of single-use capabilities alongside it.
*
* 🔴 **One envelope, not two, and that is a compatibility decision rather
* than tidiness.** A receiver recognises control traffic by KIND first and
* falls back to the `ctl` flag only for kinds it has never heard of — and
* the build in the field reads that flag with `optBoolean`, which does not
* coerce the number §6.10 specifies. A new `delivery_tokens` kind would
* therefore have arrived on those phones as a bubble full of raw JSON,
* which is the bug this engine fixed in the other direction on 2026-08-30.
*
* ⚠️ Hand-assembled so `fnc` stays the first key: the marker is a prefix
* test, and a reordered top level decodes as a plain text message — raw
* JSON in somebody's conversation.
*/
@JvmOverloads
fun deliveryKeyEnvelope(key: ByteArray, tokens: List<String> = emptyList()): String {
val head = """{"fnc":1,"kind":"${Content.Kind.DELIVERY_KEY}","key":"${Bytes.b64(key)}""""
if (tokens.isEmpty()) return "$head}"
return head + ""","tokens":[""" +
tokens.joinToString(",") { "\"" + it + "\"" } + "]}"
}
}
@@ -55,6 +55,9 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val HISTORY_PREFIX = "history/"
private const val TRUST_PREFIX = "trust/"
private const val KEYLOG_PIN = "keylog-pin"
private const val ISSUED_TOKENS = "issued-tokens-"
private const val PEER_TOKENS = "peer-tokens-"
private const val RECEIVED_SINCE = "tokens-received-"
private const val SEEN_ENVELOPES = "seen-envelopes"
private const val EXTRA_HOMES = "extra-homes"
private const val MAX_SEEN_ENVELOPES = 2000
@@ -586,6 +589,72 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
get() = readJson(WATERMARK)?.optLong("id", 0) ?: 0
set(value) = writeJson(WATERMARK, JSONObject().put("id", value))
// -- §21.4 single-use delivery capabilities -------------------------------
/**
* Capabilities we ISSUED, by contact.
*
* 🔴 **This map is the whole revocation mechanism**, and it lives here
* rather than on the relay because the relay must not be able to tell
* senders apart — it holds hashes and cannot say whose they are. Losing it
* is losing the ability to cut one contact off.
*/
fun issuedTokens(userId: Long): List<String> = tokenList(ISSUED_TOKENS, userId)
fun saveIssuedTokens(userId: Long, tokens: List<String>) =
saveTokenList(ISSUED_TOKENS, userId, tokens)
/** Capabilities a contact issued US. Spent one per sealed message. */
fun peerTokens(userId: Long): List<String> = tokenList(PEER_TOKENS, userId)
fun savePeerTokens(userId: Long, tokens: List<String>) =
saveTokenList(PEER_TOKENS, userId, tokens)
/**
* Take the next capability for a contact, removing it.
*
* ⚠️ Removed as it is taken, before the send is attempted. One reused after
* a failed send may already have been spent at the relay, and presenting it
* again is refused — so a lost send costs one capability rather than a
* conversation that quietly stops working.
*/
fun takePeerToken(userId: Long): String? {
val held = peerTokens(userId).toMutableList()
if (held.isEmpty()) return null
val next = held.removeAt(0)
savePeerTokens(userId, held)
return next
}
/**
* Messages taken from a contact since they were issued a batch.
*
* 🔴 **This is how replenishment works without anybody asking.** The holder
* cannot ask the relay how many it has left — the relay does not know whose
* are whose — but the issuer can count, because every message it receives
* from somebody spent one of theirs.
*/
fun receivedSinceIssue(userId: Long): Int =
readJson(RECEIVED_SINCE + userId)?.optInt("n", 0) ?: 0
fun noteReceivedSinceIssue(userId: Long) =
writeJson(RECEIVED_SINCE + userId,
JSONObject().put("n", receivedSinceIssue(userId) + 1))
fun resetReceivedSinceIssue(userId: Long) =
writeJson(RECEIVED_SINCE + userId, JSONObject().put("n", 0))
private fun tokenList(prefix: String, userId: Long): List<String> =
readJson(prefix + userId)?.optJSONArray("t")?.let { a ->
(0 until a.length()).map { a.getString(it) }
} ?: emptyList()
private fun saveTokenList(prefix: String, userId: Long, tokens: List<String>) {
if (tokens.isEmpty()) storage.delete(prefix + userId)
else writeJson(prefix + userId,
JSONObject().put("t", org.json.JSONArray(tokens)))
}
// -- Extra mailboxes (§20.4) ---------------------------------------------
/**