Android: sealed sender, so the relay stops learning who talks to whom
Key transparency stopped the relay substituting keys. It did nothing about the relay watching who writes to whom, which is the metadata most people actually leak. §14 moves the sender's identity inside the ciphertext: the relay learns who a message is for and nothing about who it is from. Verified against relay1 rather than asserted. A sealed submission is accepted unauthenticated, the stored row comes back with `from_user = 0` — the relay saying it does not know — and the recipient still reads the message with the sender recovered from inside the seal. Cross-engine verified in BOTH directions against the normative web engine, and that mattered: the seal is asymmetric in practice, since one side derives from an ephemeral it generated and the other from one it was handed, so a byte-order or AAD mistake can easily break only one direction and a single-direction test picks the half that works. The interop harness gained `seal` and `open_sealed` ops for it; 43 assertions, none failing. 🔴 **The seal is confidentiality, never authentication.** The recipient's `IK_dh` is published to the world, so anyone can produce a well-formed seal claiming to be from anybody. The claimed sender picks a session and nothing else; the ratchet opening it is what makes it a fact, and the identity pin is what refuses an impostor. There is a test that forges a sender deliberately, so that if somebody later renames these fields into something confident the reason they are claims is still written down and still executing. ⚠️ **The first message to a new contact cannot be sealed.** Sealing needs the recipient's delivery key and that key travels inside an E2E message, so a prior exchange is required. `Sent.sealed` reports which happened rather than leaving the UI free to imply anonymity that was not achieved — a silent fall back to an unsealed send is exactly what §14.5 warns about. ⚠️ **Rotation is blunt, and the copy says so.** There is no sender to block, so the only lever is replacing the delivery key, which cuts off every contact until the next time they are written to. Reachable from Account, asserted by `CrudSurfaceTest`, and live-tested: a submission presenting a retired key is refused by the relay. The relay stores only `SHA-256(key)`. The key itself never passes through the relay from its owner, which is the only reason an unauthenticated sealed submission can be authorised without naming who is submitting. Tested: 108 engine, 67 client, 43 interop, none failing, live suites enabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cfeefa0616
commit
a213ad8d5d
@@ -49,6 +49,7 @@ 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 DELIVERY_KEY = "delivery-key"
|
||||
|
||||
/** The two kinds of thread, named once so they cannot drift apart. */
|
||||
fun threadForUser(userId: Long) = "user:$userId"
|
||||
@@ -210,6 +211,61 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
|
||||
fun forgetTrust(userId: Long) = storage.delete(trustName(userId))
|
||||
|
||||
// -- Sealed sender (§14.4) -----------------------------------------------
|
||||
|
||||
/**
|
||||
* This account's delivery key, and whether the relay has its digest yet.
|
||||
*
|
||||
* 🔴 **The key itself never goes to the relay — only `SHA-256(key)` does.**
|
||||
* It reaches contacts inside E2E messages, so the relay never sees it from
|
||||
* its owner, and that is the only reason an unauthenticated sealed
|
||||
* submission can be authorised without naming who is submitting. Sending
|
||||
* the raw key to the server would make the whole mechanism decorative.
|
||||
*
|
||||
* `published` tracks the digest upload separately from the key, because a
|
||||
* key generated and never published is one every contact would try to seal
|
||||
* to and every seal would be rejected.
|
||||
*/
|
||||
data class Delivery(
|
||||
val key: ByteArray,
|
||||
val published: Boolean,
|
||||
/** Peer user id -> their delivery key, so we can reach them sealed. */
|
||||
val peers: Map<Long, ByteArray> = emptyMap(),
|
||||
/** Peers we have already handed OUR key to, so we send it once. */
|
||||
val toldPeers: Set<Long> = emptySet(),
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is Delivery && key.contentEquals(other.key) && published == other.published &&
|
||||
peers.keys == other.peers.keys && toldPeers == other.toldPeers
|
||||
|
||||
override fun hashCode(): Int = key.contentHashCode() * 31 + published.hashCode()
|
||||
}
|
||||
|
||||
fun loadDelivery(): Delivery? = readJson(DELIVERY_KEY)?.let { o ->
|
||||
val peers = o.optJSONObject("peers") ?: JSONObject()
|
||||
val told = o.optJSONArray("told") ?: org.json.JSONArray()
|
||||
Delivery(
|
||||
key = java.util.Base64.getDecoder().decode(o.getString("key")),
|
||||
published = o.optBoolean("published", false),
|
||||
peers = peers.keys().asSequence().associate { k ->
|
||||
k.toLong() to java.util.Base64.getDecoder().decode(peers.getString(k))
|
||||
},
|
||||
toldPeers = (0 until told.length()).map { told.getLong(it) }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
fun saveDelivery(delivery: Delivery) {
|
||||
val peers = JSONObject()
|
||||
delivery.peers.forEach { (id, k) ->
|
||||
peers.put(id.toString(), java.util.Base64.getEncoder().encodeToString(k))
|
||||
}
|
||||
writeJson(DELIVERY_KEY, JSONObject()
|
||||
.put("key", java.util.Base64.getEncoder().encodeToString(delivery.key))
|
||||
.put("published", delivery.published)
|
||||
.put("peers", peers)
|
||||
.put("told", org.json.JSONArray(delivery.toldPeers.toList())))
|
||||
}
|
||||
|
||||
// -- Key-log pin (§19.3) -------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user