Files
flamenet-messenger-android/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt
T
Paul HittandClaude Opus 5.5 88906aacb4 Keep a blocked contact out of the inbox on Android
The relay refuses a blocked sender only on an identified send. A sealed
send is authorised by the recipient's delivery key and does not say who
sent it, so a blocked contact who already holds that key keeps getting
through. Revoking delivery tokens does not close it: a refused token
falls back to the delivery key by design. The recipient learns the sender
when it unseals, so the recipient has to refuse.

Found on the iPhone-to-Android pair: after the iPhone blocked the
emulator, the emulator's next message went sealed, the relay accepted it
(blocks row 7 -> 6 present) and the iPhone showed it. This app had no
such check either.

The blocked list is kept in the vault, written on every block and unblock
and whenever the relay's list is fetched, and absorb() drops messages and
call signals from anyone on it. ABlockedSenderStaysOutTest: 2 red before,
4 green after; engine Vault tests 75 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:02:26 -04:00

2096 lines
77 KiB
Kotlin

package io.flamenet.e2e
import org.json.JSONArray
import org.json.JSONObject
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Everything the app must remember, encrypted at rest.
*/
class Vault(
private val storage: Storage,
private val key: SecretKey,
) {
/** Wherever bytes live. A directory on a phone, a map in a test. */
interface Storage {
fun read(name: String): ByteArray?
fun write(
name: String,
bytes: ByteArray,
)
fun delete(name: String)
fun names(): List<String>
}
class Corrupt(
name: String,
cause: Throwable?,
) : Exception("the vault record '$name' did not open", cause)
companion object {
private const val NONCE_LEN = 12
private const val TAG_BITS = 128
private const val IDENTITY = "identity"
private const val WATERMARK = "watermark"
private const val CREDENTIAL = "credential"
private const val SESSION_PREFIX = "session/"
// Must NOT start with SESSION_PREFIX, or `knownPeers()` would list
// every retired chain as a peer and `sessionName` collisions would put
// two sessions in one record.
private const val RETIRED_PREFIX = "retired-session/"
private const val HISTORY_PREFIX = "history/"
private const val TRUST_PREFIX = "trust/"
private const val KEYLOG_PIN = "keylog-pin"
private const val PQ_PIN_PREFIX = "pq-identity-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
private const val HELD_ENVELOPES = "held-envelopes"
/**
* The most one sender may have waiting behind an unaccepted identity.
*
* Not about disk. An unbounded hold is a way for somebody who will
* never be accepted to keep writing, and a queue that grew for a year
* is not one anybody wants replayed at the moment they tap accept.
*/
const val MAX_HELD_PER_SENDER = 100
/** And how long one may wait, for the same reason. */
const val HELD_ENVELOPE_MAX_AGE_MS = 30L * 24L * 60L * 60L * 1000L
private const val REMOTE_ADDRESS_PREFIX = "remote-addr-"
private const val REMOTE_PIN_PREFIX = "remote-pin-"
private const val REMOTE_ADDRESSES = "remote-addresses"
/**
* A ceiling on remembered addresses.
*/
private const val MAX_REMOTE_ADDRESSES = 500
private const val KEYLOG_SERVED = "keylog-served"
private const val DELIVERY_KEY = "delivery-key"
private const val STORIES = "stories"
private const val AVATARS = "avatars"
private const val MY_AVATAR = "my-avatar"
private const val OUTBOX = "outbox"
private const val CALL_LOG = "call-log"
private const val STORY_AUDIENCE = "story-audience"
private const val STORY_MUTES = "story-mutes"
private const val STORY_ARCHIVE = "story-archive"
private const val STORY_REACTIONS = "story-reactions"
private const val PINNED_PREFIX = "pinned-"
private const val UNREAD_THREADS = "unread-threads"
private const val DISAPPEAR = "disappear"
private const val BLOCKED_SENDERS = "blocked-senders"
private const val OFF_RECORD = "offrecord"
private const val POLLS = "polls"
/** Bridge lines contacts have offered, pending review. See [bridgeOffers]. */
private const val BRIDGE_OFFERS = "bridge-offers"
private const val SNOOZE = "snooze"
private const val REPLY_REMINDER = "reply-reminder"
private const val ROSTER = "roster"
private const val GROUP_NAMES = "group-names"
private const val DRAFTS = "drafts"
private const val GROUP_SECRETS = "group-secrets"
private const val DEVICE_OWNER_PREFIX = "devowner/"
private const val KT_ISSUER_PIN = "kt-issuer-pin"
/** Kept because the whole log is re-sealed on every write. */
private const val CALL_LOG_MAX = 500
/**
* Matches the bound the relay's old column had, so a name that used to
* be storable still is, and caps what a peer can push into our vault.
*/
private const val MAX_GROUP_NAME = 80
/** The two kinds of thread, named once so they cannot drift apart. */
/**
* Who a group story will reach, and who among them this account has
* excluded from its stories (§6.24).
*/
fun groupStoryReach(
members: List<Long>,
me: Long,
/**
* Who this account's story audience would have allowed, already
* resolved.
*/
allowedByAudience: Set<Long>,
contacts: List<Long>,
): GroupStoryReach {
val recipients = members.filter { it != me }
val contactSet = contacts.toSet()
return GroupStoryReach(
recipients = recipients,
excludedByAudience =
recipients.filter { it in contactSet && it !in allowedByAudience },
notContacts = recipients.filter { it !in contactSet },
)
}
/**
* Collapse a set to the expiry every item shares (§6.23).
*/
fun setExpiry(items: List<Story>): Double = items.mapNotNull { it.ref?.expires?.takeIf { e -> e > 0 } }.minOrNull() ?: 0.0
/**
* Group one author's stories into sets, in position order (§6.23).
*/
fun groupedStories(stories: List<Story>): List<List<Story>> {
val order = mutableListOf<String>()
val sets = LinkedHashMap<String, MutableList<Story>>()
stories.forEach { story ->
// A story with no set is its own group of one, keyed so it can
// never collide with a real set id.
val key = story.set?.let { "set:${it.id}" } ?: "one:${story.id}"
if (sets[key] == null) {
order += key
sets[key] = mutableListOf()
}
sets.getValue(key) += story
}
return order.map { key -> sets.getValue(key).sortedBy { it.set?.i ?: 0 } }
}
/**
* Fold newly-expired stories into the archive (ios#54).
*/
fun archive(
existing: List<Story>,
adding: List<Story>,
): List<Story> {
val seen = existing.map { it.id }.toMutableSet()
val result = existing.toMutableList()
adding.forEach { story ->
if (story.mine && seen.add(story.id)) result += story
}
return result.sortedByDescending { it.postedAt }
}
/**
* How long a message actually lives, given its own timer and its conversation's
* (§6.17).
*/
fun effectiveLifetime(
message: Int,
conversation: Int,
): Int? {
val own = message.takeIf { it > 0 }
val thread = conversation.takeIf { it > 0 }
return when {
own != null && thread != null -> minOf(own, thread)
else -> own ?: thread
}
}
/**
* Whether this story may be written out of the app (ios#34).
*/
fun isSaveable(
mine: Boolean,
hasBlob: Boolean,
): Boolean = mine && hasBlob
fun threadForUser(userId: Long) = "user:$userId"
fun threadForGroup(groupId: String) = "group:$groupId"
/**
* A conversation with somebody on another relay.
*/
fun threadForAddress(address: String) = "addr:$address"
private val random = SecureRandom()
}
// -- Bytes ---------------------------------------------------------------.
private fun seal(
name: String,
plaintext: ByteArray,
): ByteArray {
val nonce = ByteArray(NONCE_LEN).also(random::nextBytes)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_BITS, nonce))
cipher.updateAAD(name.toByteArray(Charsets.UTF_8))
return nonce + cipher.doFinal(plaintext)
}
private fun open(
name: String,
box: ByteArray,
): ByteArray {
if (box.size <= NONCE_LEN) throw Corrupt(name, null)
return try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
key,
GCMParameterSpec(TAG_BITS, box.copyOfRange(0, NONCE_LEN)),
)
cipher.updateAAD(name.toByteArray(Charsets.UTF_8))
cipher.doFinal(box, NONCE_LEN, box.size - NONCE_LEN)
} catch (e: Exception) {
// Deliberately one exception for every cause.
throw Corrupt(name, e)
}
}
private fun readJson(name: String): JSONObject? = storage.read(name)?.let { JSONObject(String(open(name, it), Charsets.UTF_8)) }
private fun writeJson(
name: String,
json: JSONObject,
) = storage.write(name, seal(name, json.toString().toByteArray(Charsets.UTF_8)))
// -- Identity ------------------------------------------------------------.
/**
* Returning null here means "this is a new install", and the caller will
* mint a fresh device, which every contact sees as "security keys changed",
* the warning that is supposed to mean an attack.
*/
fun loadIdentity(): DeviceIdentity? = readJson(IDENTITY)?.let(DeviceIdentity::fromArchive)
fun saveIdentity(identity: DeviceIdentity) = writeJson(IDENTITY, identity.archive())
fun hasIdentity(): Boolean = storage.read(IDENTITY) != null
// -- The account credential ----------------------------------------------.
/**
* Who we are signed in as, so a restart does not mean signing in again.
*/
data class Credential(
val relayBaseUrl: String,
val username: String,
val appPassword: String,
val userId: Long,
/**
* This account's global address.
*/
val fnid: String? = null,
)
fun loadCredential(): Credential? =
readJson(CREDENTIAL)?.let {
Credential(
relayBaseUrl = it.getString("relay"),
username = it.getString("username"),
appPassword = it.getString("appPassword"),
userId = it.getLong("userId"),
// Optional read: a credential written before addresses existed
// is still a valid credential, and must not fail to load.
fnid = it.optString("fnid").ifEmpty { null },
)
}
fun saveCredential(credential: Credential) =
writeJson(
CREDENTIAL,
JSONObject()
.put("relay", credential.relayBaseUrl)
.put("username", credential.username)
.put("appPassword", credential.appPassword)
.put("userId", credential.userId)
.put("fnid", credential.fnid ?: ""),
)
// -- Sessions ------------------------------------------------------------.
private fun sessionName(peerDeviceId: String) = SESSION_PREFIX + peerDeviceId
fun loadSession(peerDeviceId: String): E2ESession? = readJson(sessionName(peerDeviceId))?.let(E2ESession::fromArchive)
fun saveSession(
peerDeviceId: String,
session: E2ESession,
) = writeJson(sessionName(peerDeviceId), session.archive())
/**
* The session a re-establishment retired, kept so its in-flight messages
* can still be opened.
*/
fun loadRetiredSession(peerDeviceId: String): E2ESession? = readJson(RETIRED_PREFIX + peerDeviceId)?.let(E2ESession::fromArchive)
fun saveRetiredSession(
peerDeviceId: String,
session: E2ESession,
) = writeJson(RETIRED_PREFIX + peerDeviceId, session.archive())
fun knownPeers(): List<String> = storage.names().filter { it.startsWith(SESSION_PREFIX) }.map { it.removePrefix(SESSION_PREFIX) }
// -- Identity trust (§19) ------------------------------------------------
// Encrypted at rest like everything else here rather than a nicety.
private fun trustName(userId: Long) = TRUST_PREFIX + userId
/**
* What we have seen this person's devices use, and whether a human checked.
*/
data class Trust(
val identities: List<String>,
val verified: Boolean,
/**
* The global address this contact was first seen under, if any.
*/
val fnid: String? = null,
/**
* A key change was detected and has NOT been accepted yet.
*/
val changed: Boolean = false,
)
fun loadTrust(userId: Long): Trust? =
readJson(trustName(userId))?.let { o ->
val a = o.optJSONArray("identities") ?: org.json.JSONArray()
Trust(
identities = (0 until a.length()).map { a.getString(it) },
verified = o.optBoolean("verified", false),
// `"null"` is rejected, and that is not paranoia, records
// written that way are already on phones.
fnid = o.optString("fnid").takeIf { it.isNotEmpty() && it != "null" },
changed = o.optBoolean("changed", false),
)
}
/**
* The key is OMITTED when there is no address, never written as an explicit
* JSON null.
*/
fun saveTrust(
userId: Long,
trust: Trust,
) = writeJson(
trustName(userId),
JSONObject()
.put("identities", org.json.JSONArray(trust.identities))
.put("verified", trust.verified)
.also { if (trust.fnid != null) it.put("fnid", trust.fnid) }
.put("changed", trust.changed),
)
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.
*/
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(),
/**
* §8.9c. The token issued to each contact so they can read this
* account's presence, base64 of 32 bytes. Only its SHA-256 ever
* reaches the relay.
*/
val presenceIssued: Map<Long, String> = emptyMap(),
/** §8.9c. The token each contact issued to US, for reading theirs. */
val presenceHeld: Map<Long, String> = emptyMap(),
) {
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(),
presenceIssued = tokenMap(o.optJSONObject("pissued")),
presenceHeld = tokenMap(o.optJSONObject("pheld")),
)
}
/** A `{ "userId": "token" }` object as a map, or empty when absent. */
private fun tokenMap(o: JSONObject?): Map<Long, String> {
if (o == null) return emptyMap()
return o
.keys()
.asSequence()
.mapNotNull { k -> k.toLongOrNull()?.let { it to o.getString(k) } }
.toMap()
}
/** The inverse of [tokenMap]. */
private fun tokenJson(tokens: Map<Long, String>): JSONObject {
val out = JSONObject()
tokens.forEach { (id, token) -> out.put(id.toString(), token) }
return out
}
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()))
.put("pissued", tokenJson(delivery.presenceIssued))
.put("pheld", tokenJson(delivery.presenceHeld)),
)
}
// -- Stories (§6.6) ------------------------------------------------------.
/**
* One story, as this device holds it.
*/
data class GroupStoryReach(
val recipients: List<Long>,
/** Members this account excluded from its stories, who get it anyway. */
val excludedByAudience: List<Long>,
/** Members who are not contacts at all. */
val notContacts: List<Long>,
)
data class Story(
val id: String,
val authorId: Long,
val mine: Boolean,
/**
* The picture or video, when there is one.
*/
val ref: Attachment.Ref?,
val caption: String,
val postedAt: Long,
val seen: Boolean = false,
/** When it stops showing, in unix seconds. 0 means it does not. */
val expiresAt: Double? = null,
/** Which sequence this story belongs to (§6.23), if any. */
val set: Content.StorySet? = null,
/**
* The group this story was posted to (§6.24), if any.
*/
val gid: String? = null,
/**
* A live stream this story embeds, if it is one (§6.13).
*/
val streamUrl: String? = null,
) {
/** Where the expiry actually lives, on the story, or on its picture. */
val expires: Double get() = expiresAt ?: ref?.expires ?: 0.0
/** Words with no picture behind them. */
val textOnly: Boolean get() = ref == null
fun isActive(nowSeconds: Double): Boolean = expires <= 0 || expires > nowSeconds
/** The host a viewer would connect to, for the disclosure. */
val streamHost: String?
get() = streamUrl?.let { runCatching { java.net.URI(it).host }.getOrNull() }
}
// -- Avatars -------------------------------------------------------------
// Encrypted at rest like everything else here.
/** The profile picture we last accepted from this contact, if any. */
fun avatarFor(userId: Long): Attachment.Ref? {
val json = readJson(AVATARS)?.optJSONObject(userId.toString()) ?: return null
return runCatching { Attachment.Ref.fromJson(Attachment.IMAGE, json, 0.0) }.getOrNull()
}
// -- Sealed group secrets (§8.9b)
// -----------------------------------------.
/**
* What this device holds for one sealed group.
*/
data class GroupSecrets(
/** The opaque token that fetches this group from the relay. */
val tag: String,
/** Opens the roster blob. */
val key: ByteArray,
/**
* Ed25519 private key iff we own this group, only the owner may.
*/
val ownerPrivate: ByteArray? = null,
/**
* Last roster version seen, so a rewrite increments rather than.
*/
val version: Int = 1,
) {
override fun equals(other: Any?): Boolean =
other is GroupSecrets && tag == other.tag && key.contentEquals(other.key) &&
version == other.version &&
(
ownerPrivate?.contentEquals(other.ownerPrivate ?: ByteArray(0))
?: (other.ownerPrivate == null)
)
override fun hashCode(): Int = tag.hashCode() * 31 + key.contentHashCode()
}
fun groupSecrets(groupId: String): GroupSecrets? =
readJson(GROUP_SECRETS)?.optJSONObject(groupId)?.let { o ->
GroupSecrets(
tag = o.optString("tag"),
key = Bytes.unb64(o.optString("key")),
ownerPrivate = o.optString("owner").takeIf { it.isNotEmpty() }?.let(Bytes::unb64),
version = o.optInt("version", 1),
)
}
fun saveGroupSecrets(
groupId: String,
secrets: GroupSecrets,
) {
val json = readJson(GROUP_SECRETS) ?: JSONObject()
json.put(
groupId,
JSONObject()
.put("tag", secrets.tag)
.put("key", Bytes.b64(secrets.key))
.put("version", secrets.version)
.also { o -> secrets.ownerPrivate?.let { o.put("owner", Bytes.b64(it)) } },
)
writeJson(GROUP_SECRETS, json)
}
/** Every group this device can open, the only such list that exists. */
fun allGroupSecrets(): Map<String, GroupSecrets> {
val json = readJson(GROUP_SECRETS) ?: return emptyMap()
return json
.keys()
.asSequence()
.mapNotNull { gid ->
groupSecrets(gid)?.let { gid to it }
}.toMap()
}
/** Forget a group on leaving. The tag is surrendered separately. */
fun clearGroupSecrets(groupId: String) {
val json = readJson(GROUP_SECRETS) ?: return
json.remove(groupId)
writeJson(GROUP_SECRETS, json)
}
// -- Group names ---------------------------------------------------------.
/**
* What each group is called.
*/
fun groupName(groupId: String): String? = readJson(GROUP_NAMES)?.optString(groupId)?.takeIf { it.isNotEmpty() }
fun saveGroupName(
groupId: String,
name: String,
) {
val json = readJson(GROUP_NAMES) ?: JSONObject()
json.put(groupId, name.take(MAX_GROUP_NAME))
writeJson(GROUP_NAMES, json)
}
/**
* Forget a group's name on leaving it.
*/
fun clearGroupName(groupId: String) {
val json = readJson(GROUP_NAMES) ?: return
json.remove(groupId)
writeJson(GROUP_NAMES, json)
}
/**
* A half-written message, kept across a relaunch.
*/
fun draft(threadId: String): String? = readJson(DRAFTS)?.optString(threadId)?.takeIf { it.isNotEmpty() }
/**
* Blank is not a draft, and neither is anything typed off the record.
*
* A draft is the message before it is sent, under a key that names the
* person it is for. Writing one for an off-the-record thread put a
* half-finished message on disk for a conversation §6.28 says is gone when
* the app closes. An existing draft is removed rather than left behind,
* since the mode can be turned on after one was typed.
*
* The filter is on the write for the same reason as [saveOutbox] and
* [saveUnreadThreads]: a later caller cannot forget it.
*/
fun saveDraft(
threadId: String,
text: String,
) {
val json = readJson(DRAFTS) ?: JSONObject()
if (text.isBlank() || offTheRecord(threadId)) json.remove(threadId) else json.put(threadId, text)
writeJson(DRAFTS, json)
}
/**
* Record a contact's picture.
*/
fun saveAvatar(
userId: Long,
ref: Attachment.Ref?,
) {
val json = readJson(AVATARS) ?: JSONObject()
if (ref == null) {
json.remove(userId.toString())
} else {
json.put(userId.toString(), ref.toJson())
}
writeJson(AVATARS, json)
}
/**
* Which version of our picture a contact has already been told about.
*/
fun avatarSharedWith(userId: Long): String? = readJson(AVATARS)?.optString("shared:$userId")?.takeIf { it.isNotEmpty() }
fun markAvatarShared(
userId: Long,
blobId: String,
) {
val json = readJson(AVATARS) ?: JSONObject()
json.put("shared:$userId", blobId)
writeJson(AVATARS, json)
}
/** Our own, so it survives a relaunch and can be re-sent to new contacts. */
fun myAvatar(): Attachment.Ref? {
val json = readJson(MY_AVATAR) ?: return null
return runCatching { Attachment.Ref.fromJson(Attachment.IMAGE, json, 0.0) }.getOrNull()
}
fun saveMyAvatar(ref: Attachment.Ref?) {
// `storage.delete`, not `forget`, that one is session-scoped and would
// delete a record called `session/my-avatar` that does not exist,
// leaving the picture in place while reporting success.
if (ref == null) storage.delete(MY_AVATAR) else writeJson(MY_AVATAR, ref.toJson())
}
fun loadStories(): List<Story> = storiesFrom(readJson(STORIES))
/** Shared by the live set and the archive (ios#54). */
private fun storiesFrom(record: JSONObject?): List<Story> {
val array = record?.optJSONArray("items") ?: return emptyList()
return (0 until array.length()).mapNotNull { i ->
val o = array.getJSONObject(i)
// `optJSONObject`, not `getJSONObject`.
val refJson = o.optJSONObject("ref")
val ref =
refJson?.let {
Attachment.Ref.fromJson(Attachment.STORY, it, it.optDouble("exp", 0.0))
}
// A row with a ref that would not parse is broken; one with no ref
// at all is a text story.
if (refJson != null && ref == null) return@mapNotNull null
Story(
id = o.getString("id"),
authorId = o.getLong("author"),
mine = o.optBoolean("mine", false),
ref = ref,
caption = o.optString("caption"),
postedAt = o.optLong("at"),
seen = o.optBoolean("seen", false),
expiresAt = if (o.has("expires")) o.optDouble("expires") else null,
set = o.optJSONObject("set")?.let { Content.StorySet.fromJson(it) },
gid = o.optString("gid").takeIf { it.isNotEmpty() },
)
}
}
/**
* Replace the stored set.
*/
fun saveStories(
stories: List<Story>,
nowSeconds: Double,
) {
writeJson(
STORIES,
JSONObject()
.put("items", storiesToJson(stories.filter { it.isActive(nowSeconds) })),
)
}
/** Shared by the live set and the archive (ios#54). */
private fun storiesToJson(stories: List<Story>): org.json.JSONArray {
val array = org.json.JSONArray()
stories.forEach { story ->
val row =
JSONObject()
.put("id", story.id)
.put("author", story.authorId)
.put("mine", story.mine)
.put("caption", story.caption)
.put("at", story.postedAt)
.put("seen", story.seen)
.put("expires", story.expires)
// Only when there is one, `put(key, null)` REMOVES a key here,
// which is what we want, but being explicit is clearer than relying
// on it.
story.ref?.let { row.put("ref", it.toJson()) }
story.set?.let { row.put("set", it.toJson()) }
story.gid?.let { row.put("gid", it) }
array.put(row)
}
return array
}
/**
* Stories this account posted, kept past their expiry (ios#54).
*/
fun storyArchive(): List<Story> = storiesFrom(readJson(STORY_ARCHIVE))
fun saveStoryArchive(stories: List<Story>) = writeJson(STORY_ARCHIVE, JSONObject().put("items", storiesToJson(stories)))
// -- Key-log pin (§19.3) -------------------------------------------------.
/**
* The head this device last accepted, and the reason a rewritten log is
* detectable at all.
*/
data class KeyLogPin(
val size: Int,
val root: String,
)
/**
* Per relay, like [keyLogServed].
*/
fun loadKeyLogPin(relay: String? = null): KeyLogPin? {
if (relay != null) {
readJson(KEYLOG_PIN + "-" + relay)?.let {
return KeyLogPin(it.getInt("size"), it.getString("root"))
}
}
return readJson(KEYLOG_PIN)?.let { KeyLogPin(it.getInt("size"), it.getString("root")) }
}
fun saveKeyLogPin(
pin: KeyLogPin,
relay: String? = null,
) = writeJson(
if (relay == null) KEYLOG_PIN else KEYLOG_PIN + "-" + relay,
JSONObject().put("size", pin.size).put("root", pin.root),
)
/**
* Whether this relay has ever answered the head route.
*/
/**
* Per relay.
*/
fun keyLogServed(relay: String): Boolean = readJson(KEYLOG_SERVED + "-" + relay) != null
fun rememberKeyLogServed(relay: String) = writeJson(KEYLOG_SERVED + "-" + relay, JSONObject().put("seen", true))
// -- Inbox watermark -----------------------------------------------------.
/**
* The highest inbox id already decrypted and stored.
*/
var inboxWatermark: Long
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.
*/
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.
*/
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.
*/
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) ---------------------------------------------.
/**
* The inbox cursor for a relay other than the one this app signed in to.
*/
fun watermarkFor(relay: String): Long = readJson(WATERMARK + "-" + relay)?.optLong("id", 0) ?: 0
fun setWatermarkFor(
relay: String,
value: Long,
) = writeJson(WATERMARK + "-" + relay, JSONObject().put("id", value))
/**
* Whether this message has already been read from another mailbox.
*/
fun hasSeenEnvelope(id: String): Boolean = id in seenEnvelopeIds()
fun rememberEnvelope(id: String) {
val seen = seenEnvelopeIds().toMutableList()
if (id in seen) return
seen += id
while (seen.size > MAX_SEEN_ENVELOPES) seen.removeAt(0)
writeJson(SEEN_ENVELOPES, JSONObject().put("ids", org.json.JSONArray(seen)))
}
private fun seenEnvelopeIds(): List<String> =
readJson(SEEN_ENVELOPES)?.optJSONArray("ids")?.let { a ->
(0 until a.length()).map { a.getString(it) }
} ?: emptyList()
// ── Envelopes held for an identity that is not accepted yet ──────────────
/**
* One envelope the identity gate refused, kept as it arrived.
*
* The gate is right: a session must not be built on an identity key the
* person holding the phone has not accepted. Throwing the envelope away
* was the part that was wrong, and it was silent on both ends. The poll
* advances its watermark whatever happens, so the relay marks the message
* delivered and forgets it; the sender sees a delivered message and the
* recipient sees an empty thread under a security banner that brings
* nothing back.
*
* What is kept is ciphertext. It cannot be read without a session and
* there is no session until the identity is accepted, which is the point:
* holding costs no secrecy and buys back the messages.
*/
data class HeldEnvelope(
val id: Long,
val fromUser: Long,
val fromDevice: String,
val payload: String,
val createdAt: Long,
val envelopeId: String,
/** When this device set it aside, for the age bound. */
val heldAt: Long,
) {
fun toJson(): JSONObject =
JSONObject()
.put("id", id)
.put("from_user", fromUser)
.put("from_device", fromDevice)
.put("payload", payload)
.put("created_at", createdAt)
.put("envelope_id", envelopeId)
.put("held_at", heldAt)
companion object {
fun fromJson(o: JSONObject) =
HeldEnvelope(
id = o.optLong("id"),
fromUser = o.optLong("from_user"),
fromDevice = o.optString("from_device"),
payload = o.optString("payload"),
createdAt = o.optLong("created_at"),
envelopeId = o.optString("envelope_id"),
heldAt = o.optLong("held_at"),
)
}
}
/** Everything waiting on one sender, oldest first. */
fun heldEnvelopes(userId: Long): List<Vault.HeldEnvelope> = heldAll()[userId].orEmpty().sortedBy { it.heldAt }
/** How many are waiting on each sender, for a screen that wants to say so. */
fun heldEnvelopeCounts(): Map<Long, Int> = heldAll().mapValues { it.value.size }.filterValues { it > 0 }
/**
* Set one aside. Returns whether it was actually kept.
*
* The same envelope arriving twice is kept once: the relay can hand a
* message over again after a fetch that did not complete, and two copies
* would mean the thread showing it twice on acceptance.
*/
fun holdEnvelope(
envelope: Vault.HeldEnvelope,
now: Long = System.currentTimeMillis(),
): Boolean {
val all = expireHeld(heldAll(), now).toMutableMap()
val queue = all[envelope.fromUser].orEmpty().toMutableList()
if (envelope.envelopeId.isNotEmpty() && queue.any { it.envelopeId == envelope.envelopeId }) {
saveHeld(all)
return false
}
if (queue.any { it.id == envelope.id }) {
saveHeld(all)
return false
}
queue += envelope
while (queue.size > MAX_HELD_PER_SENDER) queue.removeAt(0)
all[envelope.fromUser] = queue
saveHeld(all)
return true
}
/** Take everything waiting on one sender, oldest first, and forget it. */
fun takeHeldEnvelopes(userId: Long): List<Vault.HeldEnvelope> {
val all = heldAll().toMutableMap()
val queue = all.remove(userId).orEmpty()
saveHeld(all)
return queue.sortedBy { it.heldAt }
}
/**
* Forget one sender's held envelopes without replaying them.
*
* For a contact blocked or removed while messages are waiting on them: the
* hold exists so an acceptance can undo a refusal, and somebody who has
* just been blocked is not going to be accepted.
*/
fun forgetHeldEnvelopes(userId: Long) {
val all = heldAll().toMutableMap()
if (all.remove(userId) == null) return
saveHeld(all)
}
private fun heldAll(): Map<Long, List<Vault.HeldEnvelope>> {
val stored = readJson(HELD_ENVELOPES) ?: return emptyMap()
val out = mutableMapOf<Long, List<Vault.HeldEnvelope>>()
for (key in stored.keys()) {
val userId = key.toLongOrNull() ?: continue
val arr = stored.optJSONArray(key) ?: continue
out[userId] = (0 until arr.length()).map { Vault.HeldEnvelope.fromJson(arr.getJSONObject(it)) }
}
return out
}
private fun expireHeld(
all: Map<Long, List<Vault.HeldEnvelope>>,
now: Long,
): Map<Long, List<Vault.HeldEnvelope>> =
all
.mapValues { (_, queue) ->
queue.filter { now - it.heldAt < HELD_ENVELOPE_MAX_AGE_MS }
}.filterValues { it.isNotEmpty() }
private fun saveHeld(all: Map<Long, List<Vault.HeldEnvelope>>) {
val kept = all.filterValues { it.isNotEmpty() }
if (kept.isEmpty()) {
storage.delete(HELD_ENVELOPES)
return
}
val o = JSONObject()
for ((userId, queue) in kept) {
o.put(userId.toString(), org.json.JSONArray(queue.map { it.toJson() }))
}
writeJson(HELD_ENVELOPES, o)
}
/**
* A relay this account keeps a mailbox at, and how to sign in there.
*/
data class ExtraHome(
/**
* The hostname, as the signed home record names it.
*/
val relay: String,
val username: String,
val appPassword: String,
/**
* How THIS device reaches that relay, which is a separate question.
*/
val baseUrl: String = "https://$relay",
)
fun extraHomes(): List<ExtraHome> =
readJson(EXTRA_HOMES)?.optJSONArray("homes")?.let { a ->
(0 until a.length()).map {
val o = a.getJSONObject(it)
val relay = o.getString("relay")
ExtraHome(
relay,
o.getString("user"),
o.getString("pw"),
o.optString("url").ifEmpty { "https://$relay" },
)
}
} ?: emptyList()
fun saveExtraHomes(homes: List<ExtraHome>) {
val array = org.json.JSONArray()
homes.forEach {
array.put(
JSONObject()
.put("relay", it.relay)
.put("user", it.username)
.put("pw", it.appPassword)
.put("url", it.baseUrl),
)
}
writeJson(EXTRA_HOMES, JSONObject().put("homes", array))
}
fun forgetExtraHome(relay: String) {
saveExtraHomes(extraHomes().filterNot { it.relay == relay })
storage.delete(WATERMARK + "-" + relay)
}
// -- Message history -----------------------------------------------------.
/**
* Messages, encrypted at rest, keyed by the person rather than the device.
*/
/**
* A thread, with anything past its disappearing-message timer already gone.
*/
fun history(
threadId: String,
now: Long = System.currentTimeMillis(),
): org.json.JSONArray = pruneExpired(threadId, now)
fun append(
threadId: String,
message: JSONObject,
) {
val existing = history(threadId)
existing.put(message)
writeJson(historyName(threadId), JSONObject().put("messages", existing))
}
// -- Attention management: snooze and reply reminders
// ---------------------.
/**
* When this conversation stops being snoozed, epoch millis. 0 = not
* snoozed.
*/
fun snoozedUntil(threadId: String): Long = readJson(SNOOZE)?.optLong(threadId, 0L) ?: 0L
fun saveSnoozedUntil(
threadId: String,
until: Long,
) {
val all = readJson(SNOOZE) ?: JSONObject()
if (until <= 0L) all.remove(threadId) else all.put(threadId, until)
writeJson(SNOOZE, all)
}
/** Thread ids whose snooze has not yet run out at [now]. */
fun snoozedThreads(now: Long): Set<String> {
val all = readJson(SNOOZE) ?: return emptySet()
return all
.keys()
.asSequence()
.filter { all.optLong(it, 0L) > now }
.toSet()
}
/**
* When to remind about an unanswered message, epoch millis. 0 = no
* reminder.
*/
fun replyReminder(threadId: String): Long = readJson(REPLY_REMINDER)?.optLong(threadId, 0L) ?: 0L
fun saveReplyReminder(
threadId: String,
at: Long,
) {
val all = readJson(REPLY_REMINDER) ?: JSONObject()
if (at <= 0L) all.remove(threadId) else all.put(threadId, at)
writeJson(REPLY_REMINDER, all)
}
/** Reminders that are due at [now], as thread id -> when it was set for. */
fun dueReplyReminders(now: Long): Map<String, Long> {
val all = readJson(REPLY_REMINDER) ?: return emptyMap()
return all
.keys()
.asSequence()
.mapNotNull { id -> all.optLong(id, 0L).takeIf { it in 1..now }?.let { id to it } }
.toMap()
}
// -- Blocked senders -----------------------------------------------------.
/**
* Who this account has blocked, kept here so a message from them can be
* refused on arrival. The relay can only enforce a block on an
* identified send: a sealed one does not say who sent it, so this device,
* which learns the sender on unsealing, is the only place that can.
*/
fun blockedSenders(): Set<Long> {
val list = readJson(BLOCKED_SENDERS)?.optJSONArray("ids") ?: return emptySet()
return (0 until list.length()).map { list.optLong(it) }.toSet()
}
fun saveBlockedSenders(ids: Set<Long>) =
writeJson(BLOCKED_SENDERS, JSONObject().put("ids", org.json.JSONArray(ids.sorted())))
// -- Disappearing messages (§6.11) ---------------------------------------.
/**
* This conversation's timer in seconds, 0 when off.
*/
fun disappearSeconds(threadId: String): Int = readJson(DISAPPEAR)?.optInt(threadId, 0) ?: 0
/** Whether this conversation is off the record (§6.28). */
/**
* Polls in flight (§6.29).
*
* Kept at rest, or a poll could never finish: it waits for every member to
* vote and then to publish, and the pieces already gathered would be gone
* the first time the app was closed.
*/
fun loadPolls(): JSONObject? = readJson(POLLS)
fun savePolls(polls: JSONObject) = writeJson(POLLS, polls)
fun offTheRecord(threadId: String): Boolean = readJson(OFF_RECORD)?.optBoolean(threadId, false) ?: false
/**
* Remember that a conversation is off the record. The setting is kept, and
* only the setting: the messages themselves never reach disk at all.
*/
fun saveOffTheRecord(
threadId: String,
on: Boolean,
) {
val all = readJson(OFF_RECORD) ?: JSONObject()
if (on) all.put(threadId, true) else all.remove(threadId)
writeJson(OFF_RECORD, all)
}
/**
* A bridge line a contact offered, and who offered it (§6.19).
*/
fun bridgeOffers(): List<BridgeOffer> {
val all = readJson(BRIDGE_OFFERS) ?: return emptyList()
return all
.keys()
.asSequence()
.mapNotNull { key ->
val o = all.optJSONObject(key) ?: return@mapNotNull null
val line = o.optString("line").takeIf { it.isNotBlank() } ?: return@mapNotNull null
BridgeOffer(
fromUser = key.toLongOrNull() ?: return@mapNotNull null,
line = line,
at = o.optLong("at", 0L),
)
}.sortedByDescending { it.at }
.toList()
}
fun saveBridgeOffer(
fromUser: Long,
line: String,
at: Long,
) {
val all = readJson(BRIDGE_OFFERS) ?: JSONObject()
all.put(fromUser.toString(), JSONObject().put("line", line).put("at", at))
writeJson(BRIDGE_OFFERS, all)
}
fun removeBridgeOffer(fromUser: Long) {
val all = readJson(BRIDGE_OFFERS) ?: return
all.remove(fromUser.toString())
writeJson(BRIDGE_OFFERS, all)
}
data class BridgeOffer(
val fromUser: Long,
val line: String,
val at: Long,
)
fun saveDisappearSeconds(
threadId: String,
seconds: Int,
) {
val all = readJson(DISAPPEAR) ?: JSONObject()
if (seconds <= 0) all.remove(threadId) else all.put(threadId, seconds)
writeJson(DISAPPEAR, all)
}
/**
* Drop everything in this thread older than its timer, and persist that.
*/
fun pruneExpired(
threadId: String,
now: Long,
): org.json.JSONArray {
val conversation = disappearSeconds(threadId)
val stored =
readJson(historyName(threadId))?.optJSONArray("messages")
?: org.json.JSONArray()
if (stored.length() == 0) return stored
val kept = org.json.JSONArray()
for (i in 0 until stored.length()) {
val m = stored.getJSONObject(i)
// Per MESSAGE, not per thread.
val life = effectiveLifetime(m.optInt("secs", 0), conversation)
// A message with no timestamp predates the field.
val at = m.optLong("at", 0L)
if (life == null || at == 0L || at >= now - life * 1000L) kept.put(m)
}
if (kept.length() != stored.length()) {
writeJson(historyName(threadId), JSONObject().put("messages", kept))
}
return kept
}
/**
* Messages typed but not yet delivered to anybody.
*/
fun outbox(): JSONArray = readJson(OUTBOX)?.optJSONArray("pending") ?: JSONArray()
/**
* Write the queue, minus anything for an off-the-record conversation.
*
* An entry carries `to` and `text`, so this is the message and not a note
* about it. Every enqueue writes here directly, so an off-the-record send
* queued for later reached disk in plaintext the moment it was typed, for
* a conversation §6.28 says is gone when the app closes.
*
* The filter is here rather than at the four call sites for the same
* reason it is on [saveUnreadThreads]: a later caller cannot forget it.
* A group entry carries `thread` and no user-thread setting, so it passes
* through.
*
* Dropping a queued send is the promise, not the cost of it. The thread
* it belongs to is not kept either, so a message that survived would
* arrive into a conversation the app no longer has.
*/
fun saveOutbox(pending: JSONArray) {
val kept = JSONArray()
for (i in 0 until pending.length()) {
val entry = pending.optJSONObject(i) ?: continue
val to = entry.optLong("to", 0L)
if (to != 0L && offTheRecord(threadForUser(to))) continue
kept.put(entry)
}
writeJson(OUTBOX, JSONObject().put("pending", kept))
}
/**
* Calls placed and received, newest last.
*/
fun callLog(): JSONArray = readJson(CALL_LOG)?.optJSONArray("calls") ?: JSONArray()
fun saveCallLog(calls: JSONArray) {
val trimmed =
if (calls.length() <= CALL_LOG_MAX) {
calls
} else {
JSONArray().also { out ->
for (i in (calls.length() - CALL_LOG_MAX) until calls.length()) out.put(calls.get(i))
}
}
writeJson(CALL_LOG, JSONObject().put("calls", trimmed))
}
/**
* Who may see this account's stories.
*/
fun storyAudience(): JSONObject? = readJson(STORY_AUDIENCE)
fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience)
/**
* Whose stories this device does not show.
*/
fun storyMutes(): Set<Long> {
val array = readJson(STORY_MUTES)?.optJSONArray("ids") ?: return emptySet()
return (0 until array.length()).map { array.optLong(it) }.toSet()
}
fun saveStoryMutes(ids: Set<Long>) {
val array = org.json.JSONArray()
ids.sorted().forEach { array.put(it) }
writeJson(STORY_MUTES, JSONObject().put("ids", array))
}
/**
* Who reacted to which story, and with what, keyed by story then reactor.
*
* Kept, because it was not: a reaction arrived, went into a map in memory,
* and was gone at the next launch. The author watched a tally empty itself
* overnight and the reactor watched their own reaction disappear, neither
* of which is anything the other person did.
*/
fun storyReactions(): Map<String, Map<Long, String>> {
val root = readJson(STORY_REACTIONS) ?: return emptyMap()
val out = mutableMapOf<String, Map<Long, String>>()
for (storyKey in root.keys()) {
val byUser = root.optJSONObject(storyKey) ?: continue
val one = mutableMapOf<Long, String>()
for (who in byUser.keys()) {
val id = who.toLongOrNull() ?: continue
byUser.optString(who).takeIf { it.isNotEmpty() }?.let { one[id] = it }
}
if (one.isNotEmpty()) out[storyKey] = one
}
return out
}
/**
* Which threads have something unread, by thread id.
*
* The unread markers lived in the view model's UI state and nowhere else,
* so killing the app with unread conversations cleared every one of them.
* Kept here for the same reason the story mutes are: a mark somebody has
* not yet dealt with is theirs until they deal with it, not until the
* process ends.
*/
fun unreadThreads(): Set<String> {
val array = readJson(UNREAD_THREADS)?.optJSONArray("ids") ?: return emptySet()
return (0 until array.length()).map { array.optString(it) }.filter { it.isNotEmpty() }.toSet()
}
/**
* Write the unread marks, minus any off-the-record thread.
*
* The filter is here rather than at the call site for the reason the call
* site gives for being a chokepoint: a later caller cannot forget it. A
* thread id for a user conversation is `user:<id>`, so an unfiltered save
* put the peer's id on disk for a conversation §6.28 says is gone when the
* app closes, and [saveOffTheRecord] says keeps the setting and nothing
* else.
*
* Nothing is lost by dropping them. An off-the-record thread holds no
* lines at rest, so a restored mark would badge a conversation with
* nothing in it.
*/
fun saveUnreadThreads(ids: Set<String>) {
val array = org.json.JSONArray()
ids.filterNot { offTheRecord(it) }.sorted().forEach { array.put(it) }
writeJson(UNREAD_THREADS, JSONObject().put("ids", array))
}
fun saveStoryReactions(reactions: Map<String, Map<Long, String>>) {
val root = JSONObject()
for ((storyKey, byUser) in reactions) {
if (byUser.isEmpty()) continue
val one = JSONObject()
byUser.forEach { (who, emoji) -> one.put(who.toString(), emoji) }
root.put(storyKey, one)
}
writeJson(STORY_REACTIONS, root)
}
// -- Key-log issuer pin (audit M1) ---------------------------------------.
/**
* The relay's log-signing key, pinned on first sight.
*/
fun keyLogIssuerPin(): ByteArray? =
readJson(KT_ISSUER_PIN)
?.optString("key")
?.takeIf { it.isNotEmpty() }
?.let { runCatching { Bytes.unb64(it) }.getOrNull() }
fun saveKeyLogIssuerPin(key: ByteArray) = writeJson(KT_ISSUER_PIN, JSONObject().put("key", Bytes.b64(key)))
// -- Device → owner binding (audit C1/C2) --------------------------------.
/**
* The user a device is bound to, first-use-wins.
*/
fun bindDeviceOwner(
userId: Long,
deviceId: String,
): Long {
deviceOwner(deviceId)?.let { return it }
if (userId <= 0L) return userId
writeJson(DEVICE_OWNER_PREFIX + deviceId, JSONObject().put("user", userId))
return userId
}
fun deviceOwner(deviceId: String): Long? = readJson(DEVICE_OWNER_PREFIX + deviceId)?.optLong("user")?.takeIf { it > 0L }
/** Forget a binding, used when an identity change is accepted. */
fun clearDeviceOwner(deviceId: String) = storage.delete(DEVICE_OWNER_PREFIX + deviceId)
// -- §20.5 post-quantum identity pins ------------------------------------
// One record per device, holding the SHA-256 of the ik_pq last accepted.
// The fingerprint and not a boolean, because a relay that substituted its
// own ML-DSA key would satisfy a boolean pin.
fun pqIdentityPin(deviceId: String): String? =
readJson(PQ_PIN_PREFIX + deviceId)?.optString("fp")?.ifEmpty { null }
fun savePQIdentityPin(
deviceId: String,
fingerprint: String,
) = writeJson(PQ_PIN_PREFIX + deviceId, JSONObject().put("fp", fingerprint))
/**
* Forget a pin. Used when an identity change is accepted by the user, the
* same moment [clearDeviceOwner] applies: the peer has been re-verified out
* of band, so the old pin is no longer the thing to compare against.
*/
fun clearPQIdentityPin(deviceId: String) = storage.delete(PQ_PIN_PREFIX + deviceId)
// -- The address keyspace (§20.5) ----------------------------------------
// A federated peer has no user id here.
/**
* Bind a device to the address it first claimed, first use wins.
*/
fun bindRemoteAddress(
address: String,
deviceId: String,
): String {
remoteAddressFor(deviceId)?.let { return it }
if (address.isEmpty()) return address
writeJson(REMOTE_ADDRESS_PREFIX + deviceId, JSONObject().put("addr", address))
return address
}
fun remoteAddressFor(deviceId: String): String? = readJson(REMOTE_ADDRESS_PREFIX + deviceId)?.optString("addr")?.ifEmpty { null }
/**
* The identity keys pinned for an address, trust on first use.
*/
fun pinnedAddressIdentities(address: String): List<ByteArray> {
val stored = readJson(REMOTE_PIN_PREFIX + address) ?: return emptyList()
val keys = stored.optJSONArray("keys") ?: return emptyList()
return (0 until keys.length()).mapNotNull {
runCatching { Bytes.unb64(keys.getString(it)) }.getOrNull()
}
}
fun pinAddressIdentities(
address: String,
keys: List<ByteArray>,
) {
val array = org.json.JSONArray()
keys.forEach { array.put(Bytes.b64(it)) }
writeJson(REMOTE_PIN_PREFIX + address, JSONObject().put("keys", array))
}
/** Addresses this device has exchanged messages with, most recent last. */
fun remoteAddresses(): List<String> =
readJson(REMOTE_ADDRESSES)?.optJSONArray("addrs")?.let { a ->
(0 until a.length()).map { a.getString(it) }
} ?: emptyList()
fun rememberRemoteAddress(address: String) {
val current = remoteAddresses().filter { it != address } + address
writeJson(
REMOTE_ADDRESSES,
JSONObject().put("addrs", org.json.JSONArray(current.takeLast(MAX_REMOTE_ADDRESSES))),
)
}
// -- Contact list (P1) ---------------------------------------------------.
/**
* The contact list, once it lives here rather than on the relay.
*/
fun loadRoster(): Pair<org.json.JSONArray, Boolean> {
val json = readJson(ROSTER) ?: return org.json.JSONArray() to false
return (json.optJSONArray("items") ?: org.json.JSONArray()) to
json.optBoolean("offServer", false)
}
fun saveRoster(
items: org.json.JSONArray,
offServer: Boolean,
) = writeJson(ROSTER, JSONObject().put("items", items).put("offServer", offServer))
/** Rewrite a thread wholesale, for restating a message's delivery state. */
fun replaceHistory(
threadId: String,
messages: JSONArray,
) = writeJson(historyName(threadId), JSONObject().put("messages", messages))
// -- History backup (D4) -------------------------------------------------.
/**
* Everything a history backup carries, as one JSON value.
*/
fun exportPayload(): JSONObject {
val threadsObj = JSONObject()
threads().forEach { tid -> threadsObj.put(tid, history(tid)) }
return JSONObject()
.put("threads", threadsObj)
.put("callLog", callLog())
.also { p -> readJson(STORIES)?.let { p.put("stories", it) } }
.also { p -> readJson(ROSTER)?.let { p.put("roster", it) } }
// The relay does not hold group names any more, so this device's
// copy is the only copy.
.also { p -> readJson(GROUP_NAMES)?.let { p.put("groupNames", it) } }
// Without these a restored device loses its sealed groups for good.
.also { p -> readJson(GROUP_SECRETS)?.let { p.put("groupSecrets", it) } }
// Who reacted to which story. An export that leaves these behind
// restores an author whose stories have quietly lost their tallies.
.also { p -> readJson(STORY_REACTIONS)?.let { p.put("storyReactions", it) } }
// Everything below is state somebody set on purpose (§16.3). An
// export that leaves it behind restores an account that has
// forgotten the person's choices without ever saying so.
.also { p -> readJson(STORY_AUDIENCE)?.let { p.put("storyAudience", it) } }
.also { p -> readJson(STORY_MUTES)?.let { p.put("storyMutes", it) } }
.also { p -> readJson(STORY_ARCHIVE)?.let { p.put("storyArchive", it) } }
.also { p -> readJson(DRAFTS)?.let { p.put("drafts", it) } }
// A poll waits for every member, so pieces already gathered would
// be lost the first time the app was closed on a new phone.
.also { p -> readJson(POLLS)?.let { p.put("polls", it) } }
.also { p -> pins().takeIf { it.length() > 0 }?.let { p.put("pins", it) } }
// §8.9c, per-contact and carried: a new phone without them loses
// every contact's presence and issues fresh tokens nobody holds.
// Only these two maps, never the delivery record around them, which
// holds this account's OWN key and §16.1 excludes.
.also { p ->
loadDelivery()?.let { d ->
if (d.presenceIssued.isNotEmpty()) {
p.put("presenceTokensIssued", longKeyed(d.presenceIssued))
}
if (d.presenceHeld.isNotEmpty()) {
p.put("presenceTokensHeld", longKeyed(d.presenceHeld))
}
}
}
}
/** JSON object keys cannot be numbers, so a contact id travels as text. */
private fun longKeyed(map: Map<Long, String>): JSONObject {
val out = JSONObject()
map.forEach { (id, value) -> out.put(id.toString(), value) }
return out
}
data class ImportCounts(
val messages: Int,
val stories: Int,
)
/**
* Merge a backup into what this device already has.
*/
fun mergeImportedPayload(
payload: JSONObject,
nowSeconds: Double,
): ImportCounts {
var newMessages = 0
val threadsObj = payload.optJSONObject("threads") ?: JSONObject()
for (tid in threadsObj.keys()) {
val incoming = threadsObj.optJSONArray(tid) ?: continue
val current = history(tid)
val seen = HashSet<String>()
for (i in 0 until current.length()) seen.add(lineKey(current.getJSONObject(i)))
val fresh = ArrayList<JSONObject>()
for (i in 0 until incoming.length()) {
val m = incoming.optJSONObject(i) ?: continue
if (seen.add(lineKey(m))) fresh.add(m)
}
if (fresh.isEmpty()) continue
newMessages += fresh.size
val merged = ArrayList<JSONObject>(current.length() + fresh.size)
for (i in 0 until current.length()) merged.add(current.getJSONObject(i))
merged.addAll(fresh)
merged.sortBy { it.optLong("at") }
replaceHistory(tid, JSONArray().also { arr -> merged.forEach(arr::put) })
}
var newStories = 0
val importedStories = payload.optJSONObject("stories")?.optJSONArray("items")
if (importedStories != null && importedStories.length() > 0) {
val current = readJson(STORIES)?.optJSONArray("items") ?: JSONArray()
val known = HashSet<String>()
for (i in 0 until current.length()) known.add(current.getJSONObject(i).optString("id"))
for (i in 0 until importedStories.length()) {
val s = importedStories.optJSONObject(i) ?: continue
// An expired story must not sneak back in through a backup, the
// expiry is a promise about the bytes leaving the device.
val exp = s.optJSONObject("ref")?.optDouble("exp", 0.0) ?: 0.0
val active = exp == 0.0 || nowSeconds < exp
if (active && s.optString("id").isNotEmpty() && known.add(s.optString("id"))) {
current.put(s)
newStories++
}
}
writeJson(STORIES, JSONObject().put("items", current))
}
val importedCalls = payload.optJSONArray("callLog")
if (importedCalls != null && importedCalls.length() > 0) {
val current = callLog()
val known = HashSet<String>()
for (i in 0 until current.length()) {
val c = current.getJSONObject(i)
known.add("${c.optLong("peer")}|${c.optLong("at")}")
}
var changed = false
for (i in 0 until importedCalls.length()) {
val c = importedCalls.optJSONObject(i) ?: continue
if (known.add("${c.optLong("peer")}|${c.optLong("at")}")) {
current.put(c)
changed = true
}
}
if (changed) {
val sorted = ArrayList<JSONObject>(current.length())
for (i in 0 until current.length()) sorted.add(current.getJSONObject(i))
sorted.sortBy { it.optLong("at") }
saveCallLog(JSONArray().also { arr -> sorted.forEach(arr::put) })
}
}
// The contact list rides the backup too (P1): union by id, and the off-
// server mode is adopted if the backup carries it.
payload.optJSONObject("roster")?.let { imported ->
val (current, offServer) = loadRoster()
val known = HashSet<Long>()
for (i in 0 until current.length()) known.add(current.getJSONObject(i).optLong("id"))
val items = imported.optJSONArray("items") ?: org.json.JSONArray()
for (i in 0 until items.length()) {
val e = items.optJSONObject(i) ?: continue
if (known.add(e.optLong("id"))) current.put(e)
}
saveRoster(current, offServer || imported.optBoolean("offServer", false))
}
// Group names ride the backup, because nothing else can supply them
// now.
payload.optJSONObject("groupNames")?.let { imported ->
for (gid in imported.keys()) {
if (groupName(gid) != null) continue
val name = imported.optString(gid)
if (name.isNotEmpty()) saveGroupName(gid, name)
}
}
// §8.9b, gaps only, like the names.
payload.optJSONObject("groupSecrets")?.let { imported ->
val existing = readJson(GROUP_SECRETS) ?: JSONObject()
var changed = false
for (gid in imported.keys()) {
if (existing.has(gid)) continue
imported.optJSONObject(gid)?.let {
existing.put(gid, it)
changed = true
}
}
if (changed) writeJson(GROUP_SECRETS, existing)
}
// Reactions merge per reactor, not per story: two devices can each
// hold a different half of the same story's tally, and taking one
// story wholesale would drop the other half.
payload.optJSONObject("storyReactions")?.let { imported ->
val existing = readJson(STORY_REACTIONS) ?: JSONObject()
var changed = false
for (storyKey in imported.keys()) {
val incoming = imported.optJSONObject(storyKey) ?: continue
val current = existing.optJSONObject(storyKey) ?: JSONObject()
for (who in incoming.keys()) {
if (current.has(who)) continue
val emoji = incoming.optString(who)
if (emoji.isEmpty()) continue
current.put(who, emoji)
changed = true
}
if (current.length() > 0) existing.put(storyKey, current)
}
if (changed) writeJson(STORY_REACTIONS, existing)
}
// Gaps only, like the group names: a choice this device has already
// made outranks the one in a file that may be months old.
payload.optJSONObject("storyAudience")?.let {
if (readJson(STORY_AUDIENCE) == null) writeJson(STORY_AUDIENCE, it)
}
payload.optJSONObject("storyMutes")?.optJSONArray("ids")?.let { incoming ->
val merged = storyMutes().toMutableSet()
for (i in 0 until incoming.length()) merged += incoming.optLong(i)
if (merged.isNotEmpty()) saveStoryMutes(merged)
}
payload.optJSONObject("storyArchive")?.let {
if (readJson(STORY_ARCHIVE) == null) writeJson(STORY_ARCHIVE, it)
}
payload.optJSONObject("drafts")?.let { imported ->
val existing = readJson(DRAFTS) ?: JSONObject()
var changed = false
for (thread in imported.keys()) {
// A draft typed on THIS phone is the newer thought, and losing
// it to a restore is losing something never sent anywhere.
if (existing.optString(thread).isNotEmpty()) continue
val text = imported.optString(thread)
if (text.isEmpty()) continue
existing.put(thread, text)
changed = true
}
if (changed) writeJson(DRAFTS, existing)
}
payload.optJSONObject("polls")?.let {
if (readJson(POLLS) == null) writeJson(POLLS, it)
}
// Gaps only: a pin set on THIS phone is the current one.
payload.optJSONObject("pins")?.let { imported ->
val fresh = JSONObject()
for (thread in imported.keys()) {
if (pinnedRecord(thread) != null) continue
imported.optJSONObject(thread)?.let { fresh.put(thread, it) }
}
if (fresh.length() > 0) savePins(fresh)
}
mergePresenceTokens(payload)
return ImportCounts(newMessages, newStories)
}
/**
* §8.9c tokens from a backup, gaps only.
*
* A token this device already holds is the live one, the relay knows its
* digest, and replacing it with an older copy would make this account
* invisible to that contact until they issued another.
*/
private fun mergePresenceTokens(payload: JSONObject) {
val current = loadDelivery() ?: return
val issued = current.presenceIssued.toMutableMap()
val held = current.presenceHeld.toMutableMap()
var changed = false
payload.optJSONObject("presenceTokensIssued")?.let { imported ->
for (who in imported.keys()) {
val id = who.toLongOrNull() ?: continue
if (issued.containsKey(id)) continue
imported.optString(who).takeIf { it.isNotEmpty() }?.let {
issued[id] = it
changed = true
}
}
}
payload.optJSONObject("presenceTokensHeld")?.let { imported ->
for (who in imported.keys()) {
val id = who.toLongOrNull() ?: continue
if (held.containsKey(id)) continue
imported.optString(who).takeIf { it.isNotEmpty() }?.let {
held[id] = it
changed = true
}
}
}
if (changed) {
saveDelivery(current.copy(presenceIssued = issued, presenceHeld = held))
}
}
/** What makes a line *the same message* across two devices. */
private fun lineKey(m: JSONObject): String =
"${m.optBoolean("mine")}|${m.optLong("at")}|${m.optString("text")}|" +
(m.optJSONObject("att")?.optString("id") ?: "")
/**
* Every attachment id this device still has a reason to hold bytes for.
*/
fun attachmentIdsInUse(): Set<String> {
val found = mutableSetOf<String>()
for (name in storage.names()) {
val record = readJson(name) ?: continue
collectAttachmentIds(record, found)
}
return found
}
/**
* Depth-bounded.
*/
private fun collectAttachmentIds(
node: Any?,
into: MutableSet<String>,
depth: Int = 0,
) {
if (depth > 24) return
when (node) {
is JSONObject -> {
val id = node.optString("id")
// The three together are what makes it a reference to bytes.
if (id.isNotEmpty() &&
node.optString("key").isNotEmpty() &&
node.optString("digest").isNotEmpty()
) {
into += id
}
for (key in node.keys()) collectAttachmentIds(node.opt(key), into, depth + 1)
}
is JSONArray -> {
for (i in 0 until node.length()) collectAttachmentIds(node.opt(i), into, depth + 1)
}
}
}
/** Every thread this device holds, group or person. */
fun threads(): List<String> =
storage
.names()
.filter { it.startsWith(HISTORY_PREFIX) }
.map { it.removePrefix(HISTORY_PREFIX) }
private fun historyName(threadId: String) = HISTORY_PREFIX + threadId
// -- Forgetting ----------------------------------------------------------.
/** Forget one device's session. The identity and every other session survive. */
fun forget(peerDeviceId: String) = storage.delete(sessionName(peerDeviceId))
/**
* Delete a thread's history.
*/
fun forgetHistory(threadId: String) {
storage.delete(historyName(threadId))
// The pin goes with the transcript.
storage.delete(pinnedName(threadId))
}
// -- Wallet -------------------------------------------------------------.
private val walletName = "wallet-record"
/**
* The wallet's ENTROPY, sealed at rest, or null if there is no wallet.
*/
fun walletEntropy(): ByteArray? =
readJson(walletName)
?.optString("entropy")
?.takeIf { it.isNotEmpty() }
?.let { runCatching { Bytes.unb64(it) }.getOrNull() }
fun saveWalletEntropy(entropy: ByteArray) {
val existing = readJson(walletName)
writeJson(
walletName,
JSONObject()
.put("entropy", Bytes.b64(entropy))
// The counter is carried across a rewrite rather than reset.
.put("next", existing?.optInt("next", 0) ?: 0),
)
}
/** How many receive addresses have been handed out. */
fun walletNextIndex(): Int = readJson(walletName)?.optInt("next", 0) ?: 0
/**
* Advance the receive counter.
*/
fun saveWalletNextIndex(next: Int) {
val record = readJson(walletName) ?: return
if (next <= record.optInt("next", 0)) return
writeJson(walletName, record.put("next", next))
}
// -- The Files identity --------------------------------------------------.
private val filesIdentityName = "files-identity"
/**
* The passphrase-protected Files identity.
*/
fun filesIdentityRecord(): FilesIdentity.Record? =
readJson(filesIdentityName)?.let {
runCatching { FilesIdentity.Record.parse(it.toString()) }.getOrNull()
}
fun saveFilesIdentityRecord(record: FilesIdentity.Record) = writeJson(filesIdentityName, JSONObject(record.json()))
// -- Home-record rollback floor (§20.4) ----------------------------------.
private fun homeSerialName(fnid: String) =
"home-serial-${Bytes.b64(Bytes.sha256(fnid.toByteArray(Charsets.UTF_8))).take(22).replace('/', '_')}"
/**
* The highest home-record serial seen for an address, or null.
*/
fun homeSerial(fnid: String): Int? = readJson(homeSerialName(fnid))?.optInt("serial", -1)?.takeIf { it >= 0 }
/** Remember a serial, never going backwards. */
fun saveHomeSerial(
fnid: String,
serial: Int,
) {
val seen = homeSerial(fnid) ?: -1
if (serial <= seen) return
writeJson(homeSerialName(fnid), JSONObject().put("serial", serial))
}
// -- Pinned message (§6.19) ----------------------------------------------.
private fun pinnedName(threadId: String) = PINNED_PREFIX + threadId
/**
* The message pinned in a thread, or null.
*/
fun pinnedMessage(threadId: String): String? = readJson(pinnedName(threadId))?.optString("mid")?.takeIf { it.isNotEmpty() }
/**
* The whole pin record, `mid` plus whatever else travelled with it.
*
* §16's pin carries when it was pinned and by whom, and this app shows
* neither. It still keeps them, because dropping a field an engine cannot
* use turns a backup into a lossy hop: restore an iPhone through an
* Android phone and the pins come back stripped.
*/
fun pinnedRecord(threadId: String): JSONObject? = readJson(pinnedName(threadId))
/** Pin a message, or clear the pin with null. */
fun savePinnedMessage(
threadId: String,
messageId: String?,
) {
if (messageId.isNullOrEmpty()) {
storage.delete(pinnedName(threadId))
} else {
// Whatever else the record carried stays: see `pinnedRecord`.
val record = readJson(pinnedName(threadId)) ?: JSONObject()
writeJson(pinnedName(threadId), record.put("mid", messageId))
}
}
/** Every pinned thread, as the export carries them. */
fun pins(): JSONObject {
val out = JSONObject()
for (name in storage.names()) {
if (!name.startsWith(PINNED_PREFIX)) continue
val record = readJson(name) ?: continue
if (record.optString("mid").isEmpty()) continue
out.put(name.removePrefix(PINNED_PREFIX), record)
}
return out
}
fun savePins(pins: JSONObject) {
for (thread in pins.keys()) {
val record = pins.optJSONObject(thread) ?: continue
if (record.optString("mid").isEmpty()) continue
writeJson(pinnedName(thread), record)
}
}
/**
* Delete one line from a thread's history (delete for me).
*/
fun deleteLine(
threadId: String,
at: Long,
mine: Boolean,
text: String,
) {
val existing = history(threadId)
val kept = org.json.JSONArray()
for (i in 0 until existing.length()) {
val m = existing.getJSONObject(i)
if (m.optLong("at") == at && m.optBoolean("mine") == mine &&
m.optString("text") == text
) {
continue
}
kept.put(m)
}
replaceHistory(threadId, kept)
}
/**
* Forget everything, identity included.
*/
fun forgetEverything() = storage.names().forEach(storage::delete)
/** Forget conversations but keep this device's identity. */
/**
* Retired sessions too, they hold chain keys, and a sign-out that.
*/
fun forgetAllSessions() =
storage
.names()
.filter { it.startsWith(SESSION_PREFIX) || it.startsWith(RETIRED_PREFIX) }
.forEach(storage::delete)
}