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:
co-authored by
Claude Opus 4.8
parent
356c89e1cd
commit
79c2734f86
@@ -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) ---------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user