API-style comments and ktlint formatting

Every comment is reduced to its first sentence plus @param, @return
and @throws tags. Comment lines: 20040 to 8526. ktlint 1.8 applied
with its default (official Kotlin) style across all modules; 262 files
reformatted. The 264 remaining ktlint findings are naming and filename
rules, which are code changes, and are left alone.

Comments and formatting only. 1208 tests pass, counted from the XML.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-08 20:02:07 -04:00
co-authored by Claude Fable 5.1
parent 8a7f40fb13
commit 2fa430f21c
262 changed files with 20797 additions and 23969 deletions
@@ -9,30 +9,19 @@ import kotlin.test.assertTrue
/**
* The Kotlin account KDF against the vectors pinned from the enclave client.
*
* Not against the JavaScript or Swift engines: those were pinned from the same
* source, and an implementation checked only against our other implementations
* inherits whatever we all misread. The cases include a non-ASCII passphrase
* and an empty one, which is where a `CharArray` API and a UTF-8 wire format
* quietly disagree.
*
* Passing here says nothing about a phone. The provider on a device is
* Conscrypt, not the JDK's, and this engine already has one case. X25519,
* that passed twenty-one JVM tests and threw on the first tap on real hardware.
* The on-device suite in `:engine-android` is what settles Android.
*/
class AccountKdfTest {
private val vectors: JSONObject by lazy {
val candidates = listOfNotNull(
System.getenv("FLAMENET_ACCOUNT_VECTORS"),
"../../engine/test/vectors/account-kdf-v1.json",
"../../../flamenet-e2e/test/vectors/account-kdf-v1.json",
"../../flamenet-e2e/test/vectors/account-kdf-v1.json",
).map { File(it) }
val candidates =
listOfNotNull(
System.getenv("FLAMENET_ACCOUNT_VECTORS"),
"../../engine/test/vectors/account-kdf-v1.json",
"../../../flamenet-e2e/test/vectors/account-kdf-v1.json",
"../../flamenet-e2e/test/vectors/account-kdf-v1.json",
).map { File(it) }
val file = candidates.firstOrNull { it.exists() }
// Fails rather than skips: a vector suite that quietly stops checking is
// worth less than none, because it still reports green.
// Fails rather than skips: a vector suite that quietly stops checking
// is worth less than none, because it still reports green.
assertTrue(
file != null,
"account KDF vectors not found. Looked in: " +
@@ -42,8 +31,8 @@ class AccountKdfTest {
}
private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) }
private fun unhex(s: String) =
ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
private fun unhex(s: String) = ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
@Test
fun `derives exactly the published bytes for every case`() {
@@ -57,12 +46,13 @@ class AccountKdfTest {
for (i in 0 until cases.length()) {
val c = cases.getJSONObject(i)
val out = AccountKdf.derive(
passphrase = c.getString("passphrase"),
salt = unhex(c.getString("saltHex")),
context = c.getString("context"),
params = params,
)
val out =
AccountKdf.derive(
passphrase = c.getString("passphrase"),
salt = unhex(c.getString("saltHex")),
context = c.getString("context"),
params = params,
)
assertEquals(c.getString("expectedHex"), hex(out), "case: ${c.getString("name")}")
assertEquals(c.getString("expectedB64"), Bytes.b64(out), "case: ${c.getString("name")}")
}
@@ -78,8 +68,6 @@ class AccountKdfTest {
val account = AccountKdf.accountKeySeed(pass, salt)
// Same passphrase, same salt, same stretch, three unrelated secrets.
// If any pair matched, a compromise of one product's key would hand over
// another's, which is the entire reason the contexts exist.
assertFalse(auth.contentEquals(identity), "auth and identity keys collided")
assertFalse(auth.contentEquals(account), "auth and account keys collided")
assertFalse(identity.contentEquals(account), "identity and account keys collided")
@@ -89,7 +77,5 @@ class AccountKdfTest {
// The tie between this KDF and FNID.encode, that a correctly derived seed
// also produces the same ADDRESS on every client, belongs here too, and
// cannot be written yet: the Kotlin FNID lives on the multi-homing branch
// and is not on main. Add it when that lands, because a seed that derives
// correctly and encodes differently still gives one account a different
// name on every platform.
// and is not on main.
}
@@ -9,16 +9,9 @@ import kotlin.test.assertTrue
/**
* AES-256-GCM against NIST's published vectors, plus the layout the protocol
* depends on.
*
* The vectors are from NIST CAVP `gcmEncryptExtIV256`, not from our other
* engines, same reasoning as [HkdfTest]. The layout assertions ARE about our
* protocol, and they are the ones that would otherwise be discovered by two
* engines failing to talk to each other.
*/
class AeadTest {
private fun hex(s: String): ByteArray =
if (s.isEmpty()) ByteArray(0) else s.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun hex(s: String): ByteArray = if (s.isEmpty()) ByteArray(0) else s.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
@@ -55,9 +48,7 @@ class AeadTest {
}
/**
* The layout the other two engines assume. CryptoKit's `combined` emits
* nonce first; a box assembled any other way decrypts to garbage on iOS
* rather than failing, which is the worst kind of interop bug.
* The layout the other two engines assume.
*/
@Test
fun `box is nonce then ciphertext then tag`() {
@@ -74,8 +65,10 @@ class AeadTest {
val key = ByteArray(32) { 4 }
val nonce = ByteArray(12) { 5 }
val box = Aead.seal(key, nonce, "hello".toByteArray(), aad = "context".toByteArray())
assertContentEquals("hello".toByteArray(),
Aead.open(key, box, aad = "context".toByteArray()))
assertContentEquals(
"hello".toByteArray(),
Aead.open(key, box, aad = "context".toByteArray()),
)
assertFailsWith<Aead.DecryptionFailed> {
Aead.open(key, box, aad = "different".toByteArray())
}
@@ -127,10 +120,12 @@ class AeadTest {
val key = ByteArray(32) { 12 }
val box = Aead.seal(key, ByteArray(12) { 13 }, "x".toByteArray())
val badKey = runCatching { Aead.open(ByteArray(32) { 14 }, box) }.exceptionOrNull()
val badTag = runCatching {
val broken = box.copyOf(); broken[broken.size - 1] = 0
Aead.open(key, broken)
}.exceptionOrNull()
val badTag =
runCatching {
val broken = box.copyOf()
broken[broken.size - 1] = 0
Aead.open(key, broken)
}.exceptionOrNull()
assertTrue(badKey is Aead.DecryptionFailed && badTag is Aead.DecryptionFailed)
assertEquals(badKey.message, badTag.message, "the failures are distinguishable")
}
@@ -11,84 +11,107 @@ import kotlin.test.assertTrue
/**
* Which attachment ids the device still has a reason to hold bytes for.
*
* This set is the input to a DELETE, so the dangerous error is the one
* that omits something. A missing id means a cleanup removes a file that is
* still in use, a worse failure than the leak it was written to fix, because
* the leak only wastes space and this loses somebody's photo.
*
* So the cases below are mostly about the stores an enumeration would forget.
*/
class AttachmentsInUseTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun ref(id: String) = Attachment.Ref(
kind = Attachment.IMAGE, id = id,
key = ByteArray(32) { 7 }, digest = ByteArray(32) { 9 },
bytes = 100, mime = "image/jpeg", width = 2, height = 2,
private fun ref(id: String) =
Attachment.Ref(
kind = Attachment.IMAGE,
id = id,
key = ByteArray(32) { 7 },
digest = ByteArray(32) { 9 },
bytes = 100,
mime = "image/jpeg",
width = 2,
height = 2,
)
private fun story(
id: String,
attId: String,
expiresAt: Double,
) = Vault.Story(
id = id,
authorId = 1L,
mine = true,
ref = ref(attId),
caption = "",
postedAt = 0L,
seen = false,
expiresAt = expiresAt,
)
private fun story(id: String, attId: String, expiresAt: Double) = Vault.Story(
id = id, authorId = 1L, mine = true, ref = ref(attId),
caption = "", postedAt = 0L, seen = false, expiresAt = expiresAt,
)
// ---- The stores an enumeration forgets --------------------------------
// ---- The stores an enumeration forgets --------------------------------.
@Test
fun `a message's attachment counts as in use`() {
val storage = MapStorage(); val k = key()
val storage = MapStorage()
val k = key()
val v = Vault(storage, k)
v.append("user:1", JSONObject().put("text", "").put("at", 1L)
.put("att", ref("in-a-message").toJson()))
v.append(
"user:1",
JSONObject()
.put("text", "")
.put("at", 1L)
.put("att", ref("in-a-message").toJson()),
)
assertTrue("in-a-message" in Vault(storage, k).attachmentIdsInUse())
}
@Test
fun `a live story's attachment counts`() {
val storage = MapStorage(); val k = key()
val storage = MapStorage()
val k = key()
Vault(storage, k).saveStories(listOf(story("s1", "in-a-story", 4_000_000_000.0)), 1.0)
assertTrue("in-a-story" in Vault(storage, k).attachmentIdsInUse())
}
/**
* The archive is the case that makes a naive sweep destructive.
* An expired story is dropped from the live set AND folded into the private
* archive, which keeps its ref. Purging on expiry without looking here
* would leave somebody's own archive full of blank pictures, the exact
* thing the archive exists to prevent.
*/
@Test
fun `an ARCHIVED story's attachment still counts, long after it expired`() {
val storage = MapStorage(); val k = key()
val storage = MapStorage()
val k = key()
val v = Vault(storage, k)
// Expired: gone from the live set, kept in the archive.
v.saveStories(listOf(story("s1", "expired-blob", 1.0)), 1_000_000.0)
v.saveStoryArchive(listOf(story("s1", "expired-blob", 1.0)))
val inUse = Vault(storage, k).attachmentIdsInUse()
assertTrue("expired-blob" in inUse,
"an archived story's attachment was reported unused, the archive would go blank")
assertTrue(
"expired-blob" in inUse,
"an archived story's attachment was reported unused, the archive would go blank",
)
}
/**
* Avatars are attachments too. They are fetched and cached by the
* same code path, and they live in a store nobody thinks of when listing
* "places messages are". A sweep that enumerated history and stories would
* have deleted every avatar on the device the first time it ran.
* Avatars are attachments too.
*/
@Test
fun `avatars count, both a contact's and this account's own`() {
val storage = MapStorage(); val k = key()
val storage = MapStorage()
val k = key()
val v = Vault(storage, k)
v.saveAvatar(42L, ref("their-face"))
v.saveMyAvatar(ref("my-face"))
@@ -98,21 +121,25 @@ class AttachmentsInUseTest {
assertTrue("my-face" in inUse, "this account's own avatar was reported unused")
}
// ---- Things that look like refs and are not ---------------------------
// ---- Things that look like refs and are not ---------------------------.
/**
* A sticker carries `pack` + `id`, and a story SET carries `id` + `i` +
* `n` where the id names the SEQUENCE, not a blob. Matching on `id` alone
* would pull both in, harmless for a set, but it would keep files alive
* forever under ids that name nothing.
* A sticker carries `pack` + `id`, and a story SET carries `id` + `i` + `n`
* where the id names the SEQUENCE, not a blob.
*/
@Test
fun `ids that are not attachment references are not collected`() {
val storage = MapStorage(); val k = key()
val storage = MapStorage()
val k = key()
val v = Vault(storage, k)
v.append("user:1", JSONObject().put("text", "").put("at", 1L)
.put("sticker", JSONObject().put("pack", "p").put("id", "sticker-id"))
.put("set", JSONObject().put("id", "sequence-id").put("i", 0).put("n", 3)))
v.append(
"user:1",
JSONObject()
.put("text", "")
.put("at", 1L)
.put("sticker", JSONObject().put("pack", "p").put("id", "sticker-id"))
.put("set", JSONObject().put("id", "sequence-id").put("i", 0).put("n", 3)),
)
val inUse = Vault(storage, k).attachmentIdsInUse()
assertFalse("sticker-id" in inUse, "a sticker id was mistaken for an attachment")
@@ -122,28 +149,65 @@ class AttachmentsInUseTest {
/** The discriminator is all three fields, so two of them is not enough. */
@Test
fun `an object with an id and a key but no digest is not a reference`() {
val storage = MapStorage(); val k = key()
Vault(storage, k).append("user:1", JSONObject().put("at", 1L)
.put("odd", JSONObject().put("id", "two-of-three").put("key", "x")))
val storage = MapStorage()
val k = key()
Vault(storage, k).append(
"user:1",
JSONObject()
.put("at", 1L)
.put("odd", JSONObject().put("id", "two-of-three").put("key", "x")),
)
assertFalse("two-of-three" in Vault(storage, k).attachmentIdsInUse())
}
// ---- Failing safe -----------------------------------------------------
// ---- Failing safe -----------------------------------------------------.
/**
* An unreadable record must throw, not be skipped. Skipping it
* returns a set that is missing whatever it held, and a caller that deletes
* everything outside that set would then delete live attachments. The
* cleanup has to refuse to run rather than run on a partial view.
* An unreadable record must throw, not be skipped.
*/
@Test
fun `a record that cannot be opened stops the whole answer`() {
val storage = MapStorage(); val k = key()
Vault(storage, k).append("user:1", JSONObject().put("at", 1L)
.put("att", ref("real").toJson()))
storage.records["history/user:2"] = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8,
9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30)
val storage = MapStorage()
val k = key()
Vault(storage, k).append(
"user:1",
JSONObject()
.put("at", 1L)
.put("att", ref("real").toJson()),
)
storage.records["history/user:2"] =
byteArrayOf(
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
)
assertFailsWith<Vault.Corrupt> { Vault(storage, k).attachmentIdsInUse() }
}
@@ -8,29 +8,32 @@ import kotlin.test.assertTrue
/**
* Snooze and reply reminders, at the vault.
*
* Both live here and nowhere else. "I am ignoring this person until
* Tuesday" and "remind me if they have not answered" are statements about a
* relationship, and a server has no business holding either. The honest cost is
* that neither follows you to a new device, the alternative is telling the
* relay who you are avoiding.
*/
class AttentionStoreTest {
private class MapStorage : Vault.Storage {
private val records = mutableMapOf<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun vault() = Vault(
MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
private fun vault() = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
private val now = 1_700_000_000_000L
// -- Snooze --------------------------------------------------------------
// -- Snooze --------------------------------------------------------------.
@Test
fun `a thread is not snoozed by default`() {
@@ -43,9 +46,7 @@ class AttentionStoreTest {
val v = vault()
v.saveSnoozedUntil("t", now + 60_000)
assertTrue("t" in v.snoozedThreads(now))
// Expiry is read at query time, not swept. A snooze that outlived
// its own deadline because nothing ran is a conversation the user
// silently stopped being told about.
// Expiry is read at query time, not swept.
assertFalse("t" in v.snoozedThreads(now + 120_000))
}
@@ -66,7 +67,7 @@ class AttentionStoreTest {
assertEquals(0L, v.snoozedUntil("loud"))
}
// -- Reply reminders -----------------------------------------------------
// -- Reply reminders -----------------------------------------------------.
@Test
fun `a reminder is due only once its time has passed`() {
@@ -78,8 +79,6 @@ class AttentionStoreTest {
/**
* A reminder must be clearable by a reply ARRIVING, not only by firing.
* "Remind me if they have not answered" that still fires after they did is
* worse than none, it teaches people to ignore the reminder.
*/
@Test
fun `a reminder can be cancelled before it fires`() {
@@ -9,20 +9,15 @@ import kotlin.test.assertTrue
/**
* Handing a contact the bridge you use (§6.19).
*
* The security property under test is that this is an OFFER. A bridge
* line routes every relay connection through a machine somebody else chose. A
* contact, or whoever has taken their account, could not read the traffic,
* because the ratchet does not care what carries it, but they would learn who
* is online and when, and could cut them off. So nothing in the engine or the
* transport may apply one; only the person it was sent to can.
*/
class BridgeOfferTest {
private val line =
"obfs4 198.51.100.7:4444 0123456789ABCDEF0123456789ABCDEF01234567 " +
"cert=" + java.util.Base64.getEncoder()
.encodeToString(ByteArray(52) { 0xAB.toByte() }).trimEnd('=') +
"cert=" +
java.util.Base64
.getEncoder()
.encodeToString(ByteArray(52) { 0xAB.toByte() })
.trimEnd('=') +
" iat-mode=0"
@Test
@@ -33,11 +28,10 @@ class BridgeOfferTest {
}
/**
* `1`, not `true`. iOS decodes `ctl` as a number, and a boolean does
* not merely fail to set the flag, it fails the whole envelope's decode
* and falls through to the plain-text path, which is how an Android user
* once sent an iPhone a bubble of raw JSON. Asserted on the wire rather
* than through the parser, because the parser here would accept both.
* `1`, not `true`. iOS decodes `ctl` as a number, and a boolean does not
* merely fail to set the flag, it fails the whole envelope's decode and
* falls through to the plain-text path, which is how an Android user once
* sent an iPhone a bubble of raw JSON.
*/
@Test
fun `the control flag is the number one on the wire`() {
@@ -47,9 +41,7 @@ class BridgeOfferTest {
}
/**
* What an older build does with this. A build that has never heard
* of this kind must swallow it, not draw it. `ctl` is what makes that true,
* and membership of CONTROL is what routes it to a handler here.
* What an older build does with this.
*/
@Test
fun `an offer is control traffic and never renders`() {
@@ -59,9 +51,9 @@ class BridgeOfferTest {
assertTrue(Content.Kind.BRIDGE in Content.Kind.CONTROL)
}
/** The line crosses whole. Rebuilding it from parsed fields at the far
* end would lose the `cert=` that makes it usable and the fingerprint
* somebody may want to check against another source. */
/**
* The line crosses whole.
*/
@Test
fun `the line is carried verbatim`() {
val awkward = "obfs4 [2001:db8::1]:9001 ABC cert=zz+/9 iat-mode=1 sni=x.example"
@@ -76,8 +68,7 @@ class BridgeOfferTest {
}
/**
* An empty or whitespace line is not an offer. Storing one would put an
* un-actionable row on the settings screen with somebody's name against it.
* An empty or whitespace line is not an offer.
*/
@Test
fun `an empty line is not an offer`() {
@@ -85,10 +76,7 @@ class BridgeOfferTest {
}
/**
* The engine validates nothing, deliberately. Whether a line is
* usable is asked on the screen where a person decides, so a bad one is
* reported to them with a reason. Rejecting it here would drop it in
* silence, the exact failure the settings screen was built to end.
* The engine validates nothing, deliberately.
*/
@Test
fun `a malformed line still arrives, to be judged where it can be explained`() {
@@ -8,29 +8,32 @@ import kotlin.test.assertTrue
/**
* Where a bridge somebody offered is kept, and what keeping it does not do.
*
* Storing an offer must change nothing about where traffic goes. That is
* the whole reason offers exist as a separate thing from the configured bridge:
* a contact, or whoever has taken their account, must not be able to move
* another person's traffic onto a host of their choosing by sending a message.
*/
class BridgeOfferVaultTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun vault() = Vault(MapStorage(), key())
private fun line(host: String) =
"obfs4 $host:4444 0123456789ABCDEF cert=abc iat-mode=0"
private fun line(host: String) = "obfs4 $host:4444 0123456789ABCDEF cert=abc iat-mode=0"
@Test
fun `an offer survives being stored and read back`() {
@@ -44,8 +47,7 @@ class BridgeOfferVaultTest {
}
/**
* One offer per contact. An operator who rotates a bridge sends a new
* line, and a list of every line they ever had is a menu of stale hosts.
* One offer per contact.
*/
@Test
fun `a second offer from the same contact replaces the first`() {
@@ -75,11 +77,7 @@ class BridgeOfferVaultTest {
}
/**
* The load-bearing one. Offers live in their own record and touch
* nothing else. If storing an offer ever wrote to whatever the app reads
* for its configured bridge, a message would become a route, and the whole
* point of the offer/accept split would be gone while every other test here
* still passed.
* The load-bearing one.
*/
@Test
fun `storing an offer writes only the offers record`() {
@@ -88,8 +86,11 @@ class BridgeOfferVaultTest {
val before = storage.records.keys.toSet()
v.saveBridgeOffer(42, line("198.51.100.7"), 1_700_000_000)
val written = storage.records.keys.toSet() - before
assertEquals(setOf("bridge-offers"), written,
"an offer touched something other than the offers record")
assertEquals(
setOf("bridge-offers"),
written,
"an offer touched something other than the offers record",
)
}
/** Reading offers on a fresh device is empty, not a crash. */
@@ -7,20 +7,8 @@ import kotlin.test.assertTrue
/**
* Parsing an address, and refusing everything that is nearly one.
*
* This runs on input nobody authenticated. An address arrives typed, or
* pasted, or scanned from a code somebody else printed, and, worst of all,
* from inside a sealed envelope written by a stranger on another relay. So the
* refusals matter more than the successes: accepting `fnid@https://x/y` would
* let a message point this client at a path on a host of the sender's choosing.
*
* The cases here deliberately mirror the Swift suite one for one. Two
* implementations that disagree about which strings are addresses open two
* threads for one person on one side and none on the other, and nothing about
* either engine's own tests would show it.
*/
class ContactCardTest {
@Test
fun `a bare address with no scheme is accepted`() {
val card = ContactCard.decode("fn1abc@relay1.flamenet.io")
@@ -45,10 +33,7 @@ class ContactCardTest {
}
/**
* Kept only so an older card still parses. Nothing reads it and nothing
* should start to: sealing uses the recipient DEVICE's key, which the
* remote directory returns, and the federated send carries no delivery key
* at all.
* Kept only so an older card still parses.
*/
@Test
fun `an older card carrying a delivery key still parses`() {
@@ -62,9 +47,7 @@ class ContactCardTest {
}
/**
* A relay is a hostname. Accepting a URL here would let a card, or a
* message from a stranger, point this client at a path on somebody else's
* host.
* A relay is a hostname.
*/
@Test
fun `a relay that is a URL is refused`() {
@@ -83,8 +66,7 @@ class ContactCardTest {
}
/**
* A second `@` means this is not one address. Splitting on the first
* would silently address a different host than the string names.
* A second `@` means this is not one address.
*/
@Test
fun `two at signs is not one address`() {
@@ -97,12 +79,13 @@ class ContactCardTest {
*/
@Test
fun `canonical gives one spelling for one address`() {
val spellings = listOf(
"fn1abc@relay1.flamenet.io",
"flamenet:fn1abc@relay1.flamenet.io",
" fn1abc@RELAY1.Flamenet.io ",
"flamenet:fn1abc@relay1.flamenet.io?k=" + "A".repeat(44),
)
val spellings =
listOf(
"fn1abc@relay1.flamenet.io",
"flamenet:fn1abc@relay1.flamenet.io",
" fn1abc@RELAY1.Flamenet.io ",
"flamenet:fn1abc@relay1.flamenet.io?k=" + "A".repeat(44),
)
val canonical = spellings.map { ContactCard.canonical(it) }.toSet()
assertEquals(setOf("fn1abc@relay1.flamenet.io"), canonical, "got $canonical")
}
@@ -9,40 +9,37 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue
class ContentTest {
private fun ref(kind: String = Attachment.IMAGE) = Attachment.Ref(
kind = kind,
id = "blob-1",
key = ByteArray(32) { it.toByte() },
digest = ByteArray(32) { (it * 3).toByte() },
bytes = 1234,
mime = "image/jpeg",
width = 800,
height = 600,
)
private fun ref(kind: String = Attachment.IMAGE) =
Attachment.Ref(
kind = kind,
id = "blob-1",
key = ByteArray(32) { it.toByte() },
digest = ByteArray(32) { (it * 3).toByte() },
bytes = 1234,
mime = "image/jpeg",
width = 800,
height = 600,
)
/**
* The marker is a prefix test on the bytes, so `fnc` must be
* serialised first. A JSON library that sorts keys puts `att` first, every
* envelope it produces reads as plain text on the far side, and somebody's
* chat fills with raw JSON.
* The marker is a prefix test on the bytes, so `fnc` must be serialised
* first.
*/
@Test
fun `every envelope starts with the marker`() {
val envelopes = listOf(
Content.text("hi", messageId = "m1"),
Content.groupText("hi", groupId = "g1"),
Content.attachment(ref(), caption = "look"),
Content.reaction("👍", Content.Reference("m1", 7, "hi")),
Content.callSignal(Content.CallSignal(Content.CallSignal.OFFER, "c1", sdp = "v=0")),
)
val envelopes =
listOf(
Content.text("hi", messageId = "m1"),
Content.groupText("hi", groupId = "g1"),
Content.attachment(ref(), caption = "look"),
Content.reaction("👍", Content.Reference("m1", 7, "hi")),
Content.callSignal(Content.CallSignal(Content.CallSignal.OFFER, "c1", sdp = "v=0")),
)
envelopes.forEach { assertTrue(it.startsWith("{\"fnc\":1,"), "bad prefix: $it") }
}
/**
* Plain 1:1 text stays raw UTF-8. A client predating content envelopes
* shows an envelope as literal JSON, so the simplest message keeps the
* format every client has always understood.
* Plain 1:1 text stays raw UTF-8.
*/
@Test
fun `plain text is not wrapped at all`() {
@@ -89,15 +86,14 @@ class ContentTest {
assertEquals("m1", parsed.about?.messageId)
assertFalse(parsed.isRenderable)
// An empty emoji takes one back, a message, not an absence, because
// the other device has already rendered the first one.
// An empty emoji takes one back, a message, not an absence, because the
// other device has already rendered the first one.
assertEquals("", Content.parse(Content.reaction("", about)).reaction)
}
/**
* The web client sends `delivery_key`, `kt_head` and `sent` down this
* same channel. A client that renders every envelope it does not recognise
* shows those to the user as a bubble full of JSON.
* The web client sends `delivery_key`, `kt_head` and `sent` down this same
* channel.
*/
@Test
fun `control messages are consumed rather than shown`() {
@@ -113,8 +109,8 @@ class ContentTest {
@Test
fun `call signals are not messages`() {
val encoded = Content.callSignal(
Content.CallSignal(Content.CallSignal.OFFER, "call-9", sdp = "v=0\r\n"))
val encoded =
Content.callSignal(Content.CallSignal(Content.CallSignal.OFFER, "call-9", sdp = "v=0\r\n"))
val parsed = Content.parse(encoded)
assertEquals("call-9", parsed.call?.callId)
assertEquals("v=0\r\n", parsed.call?.sdp)
@@ -139,14 +135,6 @@ class ContentTest {
/**
* Forward compatibility for control traffic.
*
* Control messages ride the same channel as chat and are recognised by
* NAME, an allowlist of kinds this build knows. A control kind from a
* newer peer is in no such set, so it fell through and became a
* `[unsupported attachment]` bubble: a message the user can neither read
* nor act on, for something never meant for them to see.
*
* `ctl: 1` is what lets every kind added from here on stay quiet.
*/
@Test
fun `a flagged unknown control kind is consumed, not rendered`() {
@@ -156,9 +144,7 @@ class ContentTest {
}
/**
* The opposite has to stay true. Unknown *content* is still shown, with
* a placeholder, silently dropping a real message is the one failure a
* messenger cannot make, so the flag is what decides, not the unknown-ness.
* The opposite has to stay true.
*/
@Test
fun `an unknown kind without the flag is still shown`() {
@@ -180,11 +166,6 @@ class ContentTest {
/**
* Disappearing-message timers (§6.11).
*
* The timer travels inside the ratchet. Sent any other way the relay
* would learn that a conversation is timed and for how long, metadata this
* feature exists to avoid producing, on a product whose claim is that the
* server knows as little as possible.
*/
@Test
fun `a disappearing timer round-trips and is control traffic`() {
@@ -203,9 +184,7 @@ class ContentTest {
}
/**
* `fnc` must stay the first key. The marker is a prefix test, so a
* reordered top level makes the envelope decode as a plain text message,
* raw JSON in somebody's conversation.
* `fnc` must stay the first key.
*/
@Test
fun `the timer envelope starts with the marker`() {
@@ -219,11 +198,6 @@ class ContentTest {
/**
* Link previews (§6.12).
*
* The SENDER generates these and they ride inside the ciphertext. A card
* each recipient fetched would tell the site the reader's IP, that they
* received a link, and roughly when they opened it, once per member on a
* group message.
*/
@Test
fun `a link preview round-trips`() {
@@ -235,21 +209,20 @@ class ContentTest {
}
/**
* The host comes from the URL, never the page. A card whose title claims
* one destination while the link points at another is a phishing primitive.
* The host comes from the URL, never the page.
*/
@Test
fun `the host comes from the url not the title`() {
val preview = Content.LinkPreview.of(
"https://evil.example/login", "Sign in, yourbank.com", "")
val preview =
Content.LinkPreview.of("https://evil.example/login", "Sign in, yourbank.com", "")
assertEquals("evil.example", preview.host)
}
/** Bounded on the way in, so no renderer has to remember a cap. */
@Test
fun `title and description are bounded`() {
val preview = Content.LinkPreview.of(
"https://example.com", "A".repeat(5_000), "B".repeat(5_000))
val preview =
Content.LinkPreview.of("https://example.com", "A".repeat(5_000), "B".repeat(5_000))
assertEquals(Content.LinkPreview.MAX_TITLE, preview.title.length)
assertEquals(Content.LinkPreview.MAX_DESC, preview.desc.length)
}
@@ -257,7 +230,11 @@ class ContentTest {
/** A preview with no title is not a preview, it would render as a blank card. */
@Test
fun `a preview without a title is refused`() {
val json = org.json.JSONObject().put("url", "https://example.com").put("title", "")
val json =
org.json
.JSONObject()
.put("url", "https://example.com")
.put("title", "")
assertNull(Content.LinkPreview.fromJson(json))
}
@@ -289,7 +266,7 @@ class ContentTest {
assertEquals(awkward, Content.parse(Content.groupText(awkward, "g1")).text)
}
// -- Blobs ---------------------------------------------------------------
// -- Blobs ---------------------------------------------------------------.
@Test
fun `a blob round-trips`() {
@@ -307,18 +284,16 @@ class ContentTest {
}
/**
* The digest is checked BEFORE decrypting. AES-GCM would catch this too,
* but failing on the digest names the problem, "the relay served different
* bytes", instead of reporting it as a decryption failure indistinguishable
* from a broken session.
* The digest is checked BEFORE decrypting.
*/
@Test
fun `a substituted blob is refused by digest, not by the cipher`() {
val sealed = Attachment.seal("secret".toByteArray())
val bent = sealed.blob.copyOf().also { it[it.size / 2] = (it[it.size / 2] + 1).toByte() }
val failure = assertFailsWith<SecurityException> {
Attachment.open(bent, ref().copy(key = sealed.key, digest = sealed.digest))
}
val failure =
assertFailsWith<SecurityException> {
Attachment.open(bent, ref().copy(key = sealed.key, digest = sealed.digest))
}
assertTrue(failure.message!!.contains("digest"))
}
@@ -331,17 +306,17 @@ class ContentTest {
}
/**
* The vault persists refs through toJson and reads "exp" back out of
* that very JSON, a field toJson never wrote reloaded as 0, which both
* rendered every story as "no expiry" and (because 0 means immortal to
* isActive) meant expired story bytes were never dropped on write.
* The vault persists refs through toJson and reads "exp" back out of that
* very JSON, a field toJson never wrote reloaded as 0, which both rendered
* every story as "no expiry" and (because 0 means immortal to isActive)
* meant expired story bytes were never dropped on write.
*/
@Test
fun `a story ref keeps its expiry through the json round trip`() {
val posted = ref(kind = Attachment.STORY).copy(expires = 1_700_000_000.5)
val json = posted.toJson()
val reloaded = Attachment.Ref.fromJson(
Attachment.STORY, json, json.optDouble("exp", 0.0))!!
val reloaded =
Attachment.Ref.fromJson(Attachment.STORY, json, json.optDouble("exp", 0.0))!!
kotlin.test.assertEquals(posted.expires, reloaded.expires)
}
@@ -7,23 +7,16 @@ import kotlin.test.assertTrue
/**
* The `ctl` flag, as all three engines actually write it.
*
* This engine wrote `"ctl":true` and the specification says `"ctl":1`.
* That was not a cosmetic disagreement. iOS decodes the field as a number
* through a synthesised `Codable`, so a boolean did not fail to set the flag,
* it failed the whole envelope's decode and dropped the message onto the
* plain-text path. An Android user turning on disappearing messages therefore
* sent an iPhone a bubble containing raw JSON, and the timer was never applied
* at that end. Nothing caught it because each engine parsed what it had written
* itself, and the piece-level tests on both sides passed.
*/
class ControlFlagInteropTest {
@Test
fun `a disappearing envelope carries the number the spec asks for`() {
val envelope = JSONObject(Content.disappearing(3600))
assertEquals(1, envelope.get("ctl"),
"the spec says {\"fnc\":1,\"kind\":\"…\",\"ctl\":1}")
assertEquals(
1,
envelope.get("ctl"),
"the spec says {\"fnc\":1,\"kind\":\"…\",\"ctl\":1}",
)
assertEquals(3600, envelope.getInt("secs"))
assertEquals(1, envelope.getInt("fnc"))
}
@@ -37,25 +30,22 @@ class ControlFlagInteropTest {
}
/**
* Both shapes are accepted on the way IN. `org.json`'s `optBoolean` does
* not coerce a number, so reading only that missed every `"ctl":1` the
* other two engines send, and reading only the number would miss the
* `true` this engine itself wrote, which is on phones now and cannot be
* recalled.
* Both shapes are accepted on the way IN.
*/
@Test
fun `both spellings of the flag are understood`() {
for (spelling in listOf("1", "true")) {
val raw = """{"fnc":1,"kind":"some_future_thing","ctl":$spelling}"""
assertTrue(Content.parse(raw).isControl,
"a control envelope written as ctl:$spelling was shown to the user")
assertTrue(
Content.parse(raw).isControl,
"a control envelope written as ctl:$spelling was shown to the user",
)
}
}
/**
* The point of the flag: a control kind from a NEWER peer must be
* swallowed, not rendered. Without it, every control kind added after a
* given release shows in that release as a bubble the person cannot act on.
* swallowed, not rendered.
*/
@Test
fun `an unknown control kind is swallowed rather than shown`() {
@@ -8,24 +8,28 @@ import kotlin.test.assertNull
/**
* Audit C1/C2: a device is bound to the user it was first proven to be, and a
* later claim of a different user does not win.
*
* The ratchet proves the DEVICE; the user id inside a sealed envelope is a
* claim its author chose. Without this binding a contact could relabel their
* own messages as anyone, and poison that person's delivery key, which forces
* the next send UNSEALED and hands the relay the sender sealing exists to hide.
*/
class DeviceOwnerBindingTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun vault() = Vault(
MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
private fun vault() = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
@Test fun `first use binds the device to its user`() {
val v = vault()
@@ -37,8 +41,11 @@ class DeviceOwnerBindingTest {
@Test fun `a later different claim does not win`() {
val v = vault()
v.bindDeviceOwner(7L, "dev-A")
assertEquals(7L, v.bindDeviceOwner(42L, "dev-A"),
"a bound device must not be re-attributed by a later claim")
assertEquals(
7L,
v.bindDeviceOwner(42L, "dev-A"),
"a bound device must not be re-attributed by a later claim",
)
assertEquals(7L, v.deviceOwner("dev-A"))
}
@@ -1,34 +1,40 @@
package io.flamenet.e2e
import javax.crypto.KeyGenerator
import org.json.JSONObject
import javax.crypto.KeyGenerator
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Disappearing messages (§6.11), at the vault.
*
* Expiry runs on read. A sweep on a timer leaves
* expired messages on disk between runs, and a "disappearing" message still
* sitting in the vault is a claim rather than a feature. Reading is the one
* path every caller shares.
*/
class DisappearingTest {
private class MapStorage : Vault.Storage {
private val records = mutableMapOf<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun vault() = Vault(
MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
private fun vault() = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
private fun line(text: String, at: Long) =
JSONObject().put("text", text).put("mine", true).put("at", at)
private fun line(
text: String,
at: Long,
) = JSONObject().put("text", text).put("mine", true).put("at", at)
private val now = 1_700_000_000_000L
@@ -66,9 +72,7 @@ class DisappearingTest {
}
/**
* A message with no timestamp predates the field. An unknown age is not
* evidence of an old one, and deleting somebody's history on a guess cannot
* be undone.
* A message with no timestamp predates the field.
*/
@Test
fun `a message with no timestamp is kept`() {
@@ -8,14 +8,8 @@ import kotlin.test.assertTrue
/**
* The ratchet, driven as a conversation.
*
* Single-step assertions would pass on a ratchet that is subtly wrong. What
* catches a counting error is a conversation that goes back and forth, drops
* messages, and delivers some out of order, because the divergence only shows
* up once the two sides have taken a different number of steps.
*/
class DoubleRatchetTest {
private val AD = "flamenet-test-ad".toByteArray()
/** Alice and Bob, having already agreed a secret via X3DH. */
@@ -24,25 +18,27 @@ class DoubleRatchetTest {
val aliceIdentity = X25519.generate()
val bobIdentity = X25519.generate()
val initiated = X3DH.initiate(
aliceIdentity.private,
X3DH.Bundle(
identityKeyDH = X25519.rawFromPublicKey(bobIdentity.public),
signedPreKey = X25519.rawFromPublicKey(bobSigned.public),
oneTimePreKey = null,
),
)
val bobSecret = X3DH.respond(
identityPrivateDH = bobIdentity.private,
signedPreKeyPrivate = bobSigned.private,
oneTimePrivate = null,
initiatorIdentityDH = X25519.rawFromPublicKey(aliceIdentity.public),
initiatorEphemeral = initiated.ephemeralPublic,
)
val initiated =
X3DH.initiate(
aliceIdentity.private,
X3DH.Bundle(
identityKeyDH = X25519.rawFromPublicKey(bobIdentity.public),
signedPreKey = X25519.rawFromPublicKey(bobSigned.public),
oneTimePreKey = null,
),
)
val bobSecret =
X3DH.respond(
identityPrivateDH = bobIdentity.private,
signedPreKeyPrivate = bobSigned.private,
oneTimePrivate = null,
initiatorIdentityDH = X25519.rawFromPublicKey(aliceIdentity.public),
initiatorEphemeral = initiated.ephemeralPublic,
)
assertContentEquals(initiated.sharedSecret, bobSecret, "X3DH disagreed before the ratchet started")
val alice = DoubleRatchet.initiator(
initiated.sharedSecret, X25519.rawFromPublicKey(bobSigned.public))
val alice =
DoubleRatchet.initiator(initiated.sharedSecret, X25519.rawFromPublicKey(bobSigned.public))
val bob = DoubleRatchet.responder(bobSecret, bobSigned)
return alice to bob
}
@@ -50,9 +46,6 @@ class DoubleRatchetTest {
/**
* The same pair, running protocol v3, a fresh ML-KEM secret mixed into
* every ratchet step (§13).
*
* Bob's PQ prekey is the shared starting point: Alice encapsulates to it,
* and Bob holds its seed so he can decapsulate the first step.
*/
private fun pqPair(): Triple<DoubleRatchet, DoubleRatchet, PQKEM.KeyPair> {
val bobSigned = X25519.generate()
@@ -60,27 +53,29 @@ class DoubleRatchetTest {
val bobIdentity = X25519.generate()
val bobPQ = PQKEM.generate()
val initiated = X3DH.initiate(
aliceIdentity.private,
X3DH.Bundle(
identityKeyDH = X25519.rawFromPublicKey(bobIdentity.public),
signedPreKey = X25519.rawFromPublicKey(bobSigned.public),
oneTimePreKey = null,
),
bobPQ = bobPQ.publicKey,
)
val bobSecret = X3DH.respond(
identityPrivateDH = bobIdentity.private,
signedPreKeyPrivate = bobSigned.private,
oneTimePrivate = null,
initiatorIdentityDH = X25519.rawFromPublicKey(aliceIdentity.public),
initiatorEphemeral = initiated.ephemeralPublic,
pq = bobPQ.seed to initiated.pqCiphertext!!,
)
val initiated =
X3DH.initiate(
aliceIdentity.private,
X3DH.Bundle(
identityKeyDH = X25519.rawFromPublicKey(bobIdentity.public),
signedPreKey = X25519.rawFromPublicKey(bobSigned.public),
oneTimePreKey = null,
),
bobPQ = bobPQ.publicKey,
)
val bobSecret =
X3DH.respond(
identityPrivateDH = bobIdentity.private,
signedPreKeyPrivate = bobSigned.private,
oneTimePrivate = null,
initiatorIdentityDH = X25519.rawFromPublicKey(aliceIdentity.public),
initiatorEphemeral = initiated.ephemeralPublic,
pq = bobPQ.seed to initiated.pqCiphertext!!,
)
assertContentEquals(initiated.sharedSecret, bobSecret, "PQ X3DH disagreed")
val alice = DoubleRatchet.initiator(
initiated.sharedSecret, X25519.rawFromPublicKey(bobSigned.public), bobPQ.publicKey)
val alice =
DoubleRatchet.initiator(initiated.sharedSecret, X25519.rawFromPublicKey(bobSigned.public), bobPQ.publicKey)
val bob = DoubleRatchet.responder(bobSecret, bobSigned, bobPQ.seed)
return Triple(alice, bob, bobPQ)
}
@@ -89,15 +84,6 @@ class DoubleRatchetTest {
/**
* The cross-engine contract, pinned as bytes.
*
* The header goes into the AAD, so three implementations must produce
* byte-identical JSON or nothing opens across engines. Key order is
* `dh, pn, n, pqdh, pqct`, there are no spaces, and absent PQ fields are
* omitted ENTIRELY rather than written as null (§13.3).
*
* This is the assertion that would catch a JSON library being introduced
* here, or a field being reordered, neither of which any conversation
* test can see, because both sides would drift together.
*/
@Test
fun `the canonical header is exactly the bytes the spec names`() {
@@ -116,8 +102,7 @@ class DoubleRatchetTest {
String(DoubleRatchet.Header(dh, 3, 7, pqdh, pqct).canonicalJson(), Charsets.UTF_8),
"the v3 header changed shape",
)
// Half a PQ step is not a PQ step. One field without the other must
// fall back to the v2 shape rather than emit a malformed header.
// Half a PQ step is not a PQ step.
assertEquals(
"{\"dh\":\"" + Bytes.b64(dh) + "\",\"pn\":0,\"n\":0}",
String(DoubleRatchet.Header(dh, 0, 0, pqdh, null).canonicalJson(), Charsets.UTF_8),
@@ -137,20 +122,19 @@ class DoubleRatchetTest {
}
/**
* The property v3 exists for. v2 already protects a whole session
* against harvest-now-decrypt-later, because the root chain never recovers
* once the adversary has missed the start. What v2 lacks is post-quantum
* *healing*: after a compromise, recovery rests entirely on X25519 steps,
* which a quantum adversary breaks. So every step must carry NEW ML-KEM
* material, not merely the handshake's.
* The property v3 exists for. v2 already protects a whole session against
* harvest-now-decrypt-later, because the root chain never recovers once the
* adversary has missed the start.
*/
@Test
fun `every ratchet step carries a fresh post-quantum key`() {
val (alice, bob, bobPQ) = pqPair()
val first = alice.encrypt("a1".toByteArray(), AD)
assertTrue(first.header.pqdh != null && first.header.pqct != null,
"the first sending chain carried no PQ step at all")
assertTrue(
first.header.pqdh != null && first.header.pqct != null,
"the first sending chain carried no PQ step at all",
)
// Encapsulated to Bob's PREKEY, which is what he can decapsulate with.
val seen = mutableListOf(Bytes.b64(first.header.pqdh!!))
bob.decrypt(first, AD)
@@ -165,17 +149,19 @@ class DoubleRatchetTest {
seen += Bytes.b64(third.header.pqdh!!)
bob.decrypt(third, AD)
assertEquals(seen.size, seen.toSet().size,
"a PQ key was reused across ratchet steps: $seen")
assertTrue(seen.none { it == Bytes.b64(bobPQ.publicKey) },
"a ratchet step reused the handshake prekey instead of a fresh key")
assertEquals(
seen.size,
seen.toSet().size,
"a PQ key was reused across ratchet steps: $seen",
)
assertTrue(
seen.none { it == Bytes.b64(bobPQ.publicKey) },
"a ratchet step reused the handshake prekey instead of a fresh key",
)
}
/**
* Repeated on every message of a chain, not just the first (§13.3).
* Sending it once would mean losing that one message strands the entire
* chain: the receiver never gets the material to advance its root key, and
* nothing later in the chain can ever be decrypted.
*/
@Test
fun `the post-quantum step repeats until the peer answers`() {
@@ -187,34 +173,36 @@ class DoubleRatchetTest {
for ((i, m) in listOf(first, second, third).withIndex()) {
assertTrue(m.header.pqct != null, "message $i of the chain dropped the PQ step")
}
assertContentEquals(first.header.pqdh, third.header.pqdh,
"the PQ key changed inside one sending chain")
assertContentEquals(
first.header.pqdh,
third.header.pqdh,
"the PQ key changed inside one sending chain",
)
// And the whole chain still opens if the FIRST message is the one
// that goes missing, which is the loss the repetition exists for.
// And the whole chain still opens if the FIRST message is the one that
// goes missing, which is the loss the repetition exists for.
assertContentEquals("a2".toByteArray(), bob.decrypt(second, AD))
assertContentEquals("a3".toByteArray(), bob.decrypt(third, AD))
}
/**
* A stripped PQ step must not silently downgrade to a v1 root step. The
* header is bound into the AAD, so removing it fails the open, which is
* the difference between a downgrade attack and a broken message.
* A stripped PQ step must not silently downgrade to a v1 root step.
*/
@Test
fun `a stripped post-quantum step does not open`() {
val (alice, bob, _) = pqPair()
val message = alice.encrypt("a1".toByteArray(), AD)
val stripped = DoubleRatchet.Message(
DoubleRatchet.Header(message.header.dh, message.header.pn, message.header.n),
message.ciphertext,
)
val stripped =
DoubleRatchet.Message(
DoubleRatchet.Header(message.header.dh, message.header.pn, message.header.n),
message.ciphertext,
)
assertFailsWith<Exception> { bob.decrypt(stripped, AD) }
}
/**
* A v3 session that lost its PQ state on a restart would keep working
* and silently fall back to X25519-only healing, nothing fails, and the
* A v3 session that lost its PQ state on a restart would keep working and
* silently fall back to X25519-only healing, nothing fails, and the
* property is simply gone.
*/
@Test
@@ -238,8 +226,10 @@ class DoubleRatchetTest {
fun `a session without post-quantum material still runs v2`() {
val (alice, bob) = pair()
val message = alice.encrypt("hello".toByteArray(), AD)
assertTrue(message.header.pqdh == null && message.header.pqct == null,
"a v2 session emitted PQ header fields")
assertTrue(
message.header.pqdh == null && message.header.pqct == null,
"a v2 session emitted PQ header fields",
)
assertContentEquals("hello".toByteArray(), bob.decrypt(message, AD))
}
@@ -252,8 +242,7 @@ class DoubleRatchetTest {
@Test
fun `a conversation going both ways stays in step`() {
// Each direction change turns the DH ratchet. Four turns is enough for a
// root-key or chain-key mistake to compound into a failure.
// Each direction change turns the DH ratchet.
val (alice, bob) = pair()
assertContentEquals("a1".toByteArray(), bob.decrypt(alice.encrypt("a1".toByteArray(), AD), AD))
assertContentEquals("b1".toByteArray(), alice.decrypt(bob.encrypt("b1".toByteArray(), AD), AD))
@@ -279,7 +268,7 @@ class DoubleRatchetTest {
@Test
fun `a message lost forever does not break the ones after it`() {
val (alice, bob) = pair()
alice.encrypt("lost".toByteArray(), AD) // never delivered
alice.encrypt("lost".toByteArray(), AD) // never delivered
val after = alice.encrypt("after".toByteArray(), AD)
assertContentEquals("after".toByteArray(), bob.decrypt(after, AD))
}
@@ -292,21 +281,23 @@ class DoubleRatchetTest {
assertContentEquals("a1".toByteArray(), bob.decrypt(alice.encrypt("a1".toByteArray(), AD), AD))
assertContentEquals("b1".toByteArray(), alice.decrypt(bob.encrypt("b1".toByteArray(), AD), AD))
alice.encrypt("dropped".toByteArray(), AD) // never delivered
alice.encrypt("dropped".toByteArray(), AD) // never delivered
val next = alice.encrypt("next".toByteArray(), AD)
assertContentEquals("next".toByteArray(), bob.decrypt(next, AD))
}
@Test
fun `the header is bound to the ciphertext`() {
// The AAD covers the canonical header, so editing the counter must break
// the tag rather than silently addressing a different message key.
// The AAD covers the canonical header, so editing the counter must
// break the tag rather than silently addressing a different message
// key.
val (alice, bob) = pair()
val msg = alice.encrypt("bound".toByteArray(), AD)
val tampered = DoubleRatchet.Message(
DoubleRatchet.Header(msg.header.dh, msg.header.pn, msg.header.n + 5),
msg.ciphertext,
)
val tampered =
DoubleRatchet.Message(
DoubleRatchet.Header(msg.header.dh, msg.header.pn, msg.header.n + 5),
msg.ciphertext,
)
assertFailsWith<Throwable> { bob.decrypt(tampered, AD) }
}
@@ -323,10 +314,11 @@ class DoubleRatchetTest {
// cost the receiver everything.
val (alice, bob) = pair()
val msg = alice.encrypt("x".toByteArray(), AD)
val absurd = DoubleRatchet.Message(
DoubleRatchet.Header(msg.header.dh, msg.header.pn, 500_000),
msg.ciphertext,
)
val absurd =
DoubleRatchet.Message(
DoubleRatchet.Header(msg.header.dh, msg.header.pn, 500_000),
msg.ciphertext,
)
assertFailsWith<IllegalArgumentException> { bob.decrypt(absurd, AD) }
}
@@ -11,29 +11,31 @@ import kotlin.test.assertTrue
/**
* Half-written messages, kept across a relaunch.
*
* The reason this is not a two-line SharedPreferences change is where a
* draft has to live. It is plaintext the person has not decided to send, very
* often the most sensitive text in the app, because it is the thing they are
* still weighing. `SharedPreferences` writes readable XML that survives into
* backups. In the vault it is sealed under the same key as the conversation,
* with the record's own name bound into the AAD, and `forgetEverything` takes
* it on sign-out.
*/
class DraftsThatSurviveTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
// ---------------------------------------------------------------- surviving
// ----------------------------------------------------------------
// surviving.
@Test
fun `a draft survives being stored and read back`() {
@@ -46,20 +48,21 @@ class DraftsThatSurviveTest {
}
/**
* On disk it must be ciphertext. This is the assertion the whole
* ticket is about: the same feature built on SharedPreferences would put
* the text in readable XML, and no other test here would notice.
* On disk it must be ciphertext.
*/
@Test
fun `a draft is not readable on disk`() {
val storage = MapStorage()
Vault(storage, key()).saveDraft(Vault.threadForUser(7), "the thing I have not sent")
val onDisk = storage.records.values.joinToString("") { bytes ->
bytes.joinToString("") { byte -> (byte.toInt() and 0xFF).toChar().toString() }
}
assertFalse(onDisk.contains("the thing I have not sent"),
"the draft was written in the clear")
val onDisk =
storage.records.values.joinToString("") { bytes ->
bytes.joinToString("") { byte -> (byte.toInt() and 0xFF).toChar().toString() }
}
assertFalse(
onDisk.contains("the thing I have not sent"),
"the draft was written in the clear",
)
}
/** Multi-line text and awkward characters survive the round trip. */
@@ -73,12 +76,11 @@ class DraftsThatSurviveTest {
assertEquals(awkward, Vault(storage, secret).draft(Vault.threadForUser(1)))
}
// ---------------------------------------------------------------- forgetting
// ----------------------------------------------------------------
// forgetting.
/**
* Clearing the composer has to clear what was stored. A draft that
* outlives the message it became comes back as a draft of something already
* sent, which is worse than not having drafts at all.
* Clearing the composer has to clear what was stored.
*/
@Test
fun `clearing the composer forgets the draft`() {
@@ -92,9 +94,7 @@ class DraftsThatSurviveTest {
}
/**
* Blank is not a draft. Storing " " would restore a composer that
* looks empty and reports itself as having content, which on this screen
* means an enabled send button that sends nothing.
* Blank is not a draft.
*/
@Test
fun `whitespace is not a draft`() {
@@ -105,16 +105,15 @@ class DraftsThatSurviveTest {
for (blank in listOf("", " ", " ", "\n", "\t\n ")) {
Vault(storage, secret).saveDraft(thread, "something")
Vault(storage, secret).saveDraft(thread, blank)
assertNull(Vault(storage, secret).draft(thread),
"kept \"$blank\", which would enable send on an empty composer")
assertNull(
Vault(storage, secret).draft(thread),
"kept \"$blank\", which would enable send on an empty composer",
)
}
}
/**
* But stored as typed, not trimmed. Somebody who left a trailing space
* was mid-word, and handing back a subtly different string than the one
* they left is the kind of small wrongness that makes an app feel
* untrustworthy.
* But stored as typed, not trimmed.
*/
@Test
fun `text is kept exactly as it was typed`() {
@@ -124,13 +123,12 @@ class DraftsThatSurviveTest {
assertEquals("meet at ", Vault(storage, secret).draft(Vault.threadForUser(1)))
}
// ---------------------------------------------------------------- which thread
// ---------------------------------------------------------------- which
// thread.
/**
* Three kinds of conversation share one record, so the ids must not
* collide. A group called "7", the person who is user 7, and an address
* "7" are three different threads, and the transcript already names them
* apart for exactly this reason.
* collide.
*/
@Test
fun `three kinds of conversation cannot share a draft`() {
@@ -166,13 +164,11 @@ class DraftsThatSurviveTest {
assertEquals("second", Vault(storage, secret).draft(Vault.threadForUser(2)))
}
// ---------------------------------------------------------------- sign-out
// ---------------------------------------------------------------- sign-
// out.
/**
* Signing out must take drafts with it. Leaving the one piece of
* plaintext the person never chose to send behind, after the credential
* that justified it is gone, is exactly the residue the vault exists to
* avoid.
* Signing out must take drafts with it.
*/
@Test
fun `signing out forgets drafts`() {
@@ -9,17 +9,24 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue
class E2ESessionTest {
/** A bundle for [identity], as its relay would serve one. */
private fun bundleFor(identity: DeviceIdentity, withOneTime: Boolean = true): PeerBundle {
private fun bundleFor(
identity: DeviceIdentity,
withOneTime: Boolean = true,
): PeerBundle {
val oneTime = if (withOneTime) identity.generateOneTimePreKeys(1).first() else null
return PeerBundle.fromWire(identity.registrationWire().also { wire ->
if (oneTime != null) {
wire.put("opk", JSONObject()
.put("id", oneTime.id)
.put("pub", Bytes.b64(oneTime.publicKey)))
}
})
return PeerBundle.fromWire(
identity.registrationWire().also { wire ->
if (oneTime != null) {
wire.put(
"opk",
JSONObject()
.put("id", oneTime.id)
.put("pub", Bytes.b64(oneTime.publicKey)),
)
}
},
)
}
@Test
@@ -37,10 +44,8 @@ class E2ESessionTest {
}
/**
* The handshake has to ride EVERY message until one is answered, not
* just the first. Any message may be the one that arrives first, and a
* conversation where the peer never received the handshake looks, from the
* outside, like a contact who has stopped replying.
* The handshake has to ride EVERY message until one is answered, not just
* the first.
*/
@Test
fun `the handshake repeats until the peer answers`() {
@@ -51,7 +56,7 @@ class E2ESessionTest {
val queued = (1..3).map { a.encrypt("m$it") }
assertTrue(queued.all { it.type == "prekey" && it.handshake != null })
// Bob receives the THIRD one first. It must be enough on its own.
// Bob receives the THIRD one first.
val b = E2ESession.respond(bob, queued[2])
assertEquals("m3", b.decrypt(queued[2]))
assertEquals("m1", b.decrypt(queued[0]))
@@ -64,9 +69,7 @@ class E2ESessionTest {
}
/**
* The bug this exists to stop: sessions that lived only in memory. A client
* that loses them re-registers a device, and every contact is shown
* "security keys changed", the warning that is supposed to mean an attack.
* The bug this exists to stop: sessions that lived only in memory.
*/
@Test
fun `a session survives being archived mid-conversation`() {
@@ -88,20 +91,6 @@ class E2ESessionTest {
/**
* Both sides open the chat at once.
*
* Each runs X3DH before either has seen the other's prekey, so each holds
* its own chain and each is about to be handed a message on the other's.
* This is ordinary behaviour, two people tapping a conversation at the
* same moment, not an edge case.
*
* The failure it guards is not the first message. Both first messages open
* fine, because `respond` builds the peer's chain on demand. What breaks is
* everything AFTER the swap: each side now sends on the chain the other
* threw away, and a client that kept only the newest session cannot open
* any of it. Silently, an envelope that will not decrypt is skipped, not
* reported.
*
* So this asserts the second exchange, which is the one that used to die.
*/
@Test
fun `simultaneous initiation does not strand the conversation`() {
@@ -121,8 +110,7 @@ class E2ESessionTest {
val bAdopted = E2ESession.respond(bob, aFirst)
assertEquals("from alice", bAdopted.decrypt(aFirst))
// The second exchange. Alice replies on the chain she adopted; Bob has
// that chain only as the one he started, so he needs the retired copy.
// The second exchange.
val aSecond = aAdopted.encrypt("alice again")
assertEquals("alice again", bOwn.decrypt(aSecond))
@@ -141,7 +129,7 @@ class E2ESessionTest {
b.decrypt(first)
val later = (1..3).map { a.encrypt("m$it") }
b.decrypt(later[2]) // arrives early; 1 and 2 are now skipped keys
b.decrypt(later[2]) // arrives early; 1 and 2 are now skipped keys
b = E2ESession.fromArchive(JSONObject(b.archive().toString()))
assertEquals("m1", b.decrypt(later[0]))
assertEquals("m2", b.decrypt(later[1]))
@@ -154,11 +142,11 @@ class E2ESessionTest {
val impostor = DeviceIdentity.create()
// A relay substituting its own prekey, signed by a key it controls.
// Nothing else in the protocol catches this.
val tampered = bundleFor(bob).copy(
signedPreKey = X25519.rawFromPublicKey(impostor.signedPreKey.public),
signedPreKeySignature = impostor.signedPreKeySignature(),
)
val tampered =
bundleFor(bob).copy(
signedPreKey = X25519.rawFromPublicKey(impostor.signedPreKey.public),
signedPreKeySignature = impostor.signedPreKeySignature(),
)
assertFailsWith<SecurityException> { E2ESession.initiate(alice, tampered) }
}
@@ -173,8 +161,7 @@ class E2ESessionTest {
}
/**
* Rotation without retention is message loss. A sender that fetched a
* bundle before the rotation is still entitled to be understood.
* Rotation without retention is message loss.
*/
@Test
fun `an envelope built against a retired signed prekey still opens`() {
@@ -198,8 +185,7 @@ class E2ESessionTest {
val bob = DeviceIdentity.create()
val queued = E2ESession.initiate(alice, bundleFor(bob)).encrypt("far too late")
// Rotate, then let the retention window pass. Guessing at the current
// key here would derive a garbage secret and report it as tampering.
// Rotate, then let the retention window pass.
bob.rotateSignedPreKey()
bob.pruneSignedPreKeys(System.currentTimeMillis() + DeviceIdentity.RETAIN_AFTER_MS + 1)
assertFailsWith<IllegalArgumentException> { E2ESession.respond(bob, queued) }
@@ -250,6 +236,8 @@ class E2ESessionTest {
assertEquals("after a reload", E2ESession.respond(restored, first).decrypt(first))
}
private fun assertContentEqualsB64(expected: ByteArray, actual: ByteArray) =
assertEquals(Bytes.b64(expected), Bytes.b64(actual))
private fun assertContentEqualsB64(
expected: ByteArray,
actual: ByteArray,
) = assertEquals(Bytes.b64(expected), Bytes.b64(actual))
}
@@ -7,43 +7,47 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The seed→public derivation is checked against the RFC 8032 §7.1
* vectors, not against a round-trip through our own code. A round-trip proves
* only that this file agrees with itself, and every wrong clamp, endianness and
* sign convention agrees with itself perfectly. These vectors are what say the
* address a user gets here is the address they get on iOS and on the web.
* The seed→public derivation is checked against the RFC 8032 §7.1 vectors, not
* against a round-trip through our own code.
*/
class Ed25519Test {
private fun hex(s: String) = ByteArray(s.length / 2) {
s.substring(it * 2, it * 2 + 2).toInt(16).toByte()
}
private fun hex(s: String) =
ByteArray(s.length / 2) {
s.substring(it * 2, it * 2 + 2).toInt(16).toByte()
}
/** seed, expected public key, message, expected signature. */
private val vectors = listOf(
Triple(
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
"" to "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b",
),
Triple(
"4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
"72" to "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00",
),
Triple(
"c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7",
"fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025",
"af82" to "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a",
),
)
private val vectors =
listOf(
Triple(
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
"" to
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b",
),
Triple(
"4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
"72" to
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00",
),
Triple(
"c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7",
"fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025",
"af82" to
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a",
),
)
@Test
fun `a seed derives the public key RFC 8032 says it must`() {
for ((seed, expected, _) in vectors) {
val pair = Ed25519.fromSeed(hex(seed))
assertContentEquals(hex(expected), pair.publicKey,
"seed $seed derived the wrong public key")
assertContentEquals(
hex(expected),
pair.publicKey,
"seed $seed derived the wrong public key",
)
}
}
@@ -88,9 +92,7 @@ class Ed25519Test {
}
/**
* Garbage from the wire must be a `false`, not an exception. A caller that
* has to catch around every verification eventually catches one badly, and
* a swallowed throw reads as a pass.
* Garbage from the wire must be a `false`, not an exception.
*/
@Test
fun `malformed input verifies as false rather than throwing`() {
@@ -1,28 +1,15 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* Replacing the wording of a message already sent (§6.15).
*
* Without this branch an edit renders as a duplicate message. An unknown
* kind with a caption and no attachment falls through to the last case in
* `parse`, which returns the caption as text, so an iPhone correcting a typo
* would appear on Android as a second bubble beside the original, with no
* indication that either replaced the other. That is the bug this fixes today,
* before any of the applying half exists.
*
* This is the codec only. `Session.Line` carries no `mid`, so nothing in
* an Android transcript can be named by an edit and the app swallows them for
* now, the same place reactions have been sitting (PARITY.md: "codec done, no
* UI"). One transcript change unlocks replies, reactions and edits together.
*/
class EditEnvelopeTest {
@Test
fun `an edit names the message it replaces and carries the new wording`() {
val wire = Content.edit("meet at 9", of = "abc")
@@ -34,9 +21,7 @@ class EditEnvelopeTest {
}
/**
* The bug this exists to prevent. Before the branch, an edit fell
* through to the attachment path and came back as renderable text, a
* second bubble the sender never sent.
* The bug this exists to prevent.
*/
@Test
fun `an edit is not a renderable message`() {
@@ -44,21 +29,22 @@ class EditEnvelopeTest {
assertNull(content.attachment, "an edit came back carrying an attachment")
assertNull(content.reaction)
assertNull(content.sticker)
assertTrue(content.editOf != null,
"an edit that is not recognisable as one will be drawn as a message")
assertTrue(
content.editOf != null,
"an edit that is not recognisable as one will be drawn as a message",
)
}
/**
* It must not be swallowed as generic control traffic either. A build
* that consumed it silently would look like it worked while dropping every
* correction, which is worse than the duplicate bubble, because nobody
* would notice.
* It must not be swallowed as generic control traffic either.
*/
@Test
fun `an edit is a known kind, not unknown control`() {
assertTrue(Content.Kind.EDIT in Content.Kind.KNOWN)
assertTrue(Content.Kind.EDIT !in Content.Kind.CONTROL,
"an edit swallowed as control would drop corrections silently")
assertTrue(
Content.Kind.EDIT !in Content.Kind.CONTROL,
"an edit swallowed as control would drop corrections silently",
)
}
/** Group routing survives, for when a group thread can name a message. */
@@ -70,32 +56,36 @@ class EditEnvelopeTest {
}
/**
* `re.author` is a claim, not evidence, anyone can put any user id
* there. The codec must not present it as authorship, and the encoder must
* not fill it in with something that looks like one.
* `re.author` is a claim, not evidence, anyone can put any user id there.
*/
@Test
fun `the envelope makes no claim about who wrote the original`() {
val json = JSONObject(Content.edit("meet at 9", of = "abc"))
assertEquals(0L, json.getJSONObject("re").getLong("author") as Long,
"the encoder asserted an author, which a receiver might believe")
assertEquals(
0L,
json.getJSONObject("re").getLong("author") as Long,
"the encoder asserted an author, which a receiver might believe",
)
// And a forged one parses identically, because nothing downstream reads it.
val forged = Content.edit("meet at 9", of = "abc")
.replace("\"author\":0", "\"author\":99")
// And a forged one parses identically, because nothing downstream reads
// it.
val forged =
Content
.edit("meet at 9", of = "abc")
.replace("\"author\":0", "\"author\":99")
assertEquals("abc", Content.parse(forged).editOf)
}
/** `fnc` first, like every other envelope, the receive side's marker
* check matches the serialized prefix. */
/**
* `fnc` first, like every other envelope, the receive side's marker.
*/
@Test
fun `the envelope starts with the marker`() {
assertTrue(Content.edit("hi", of = "abc").startsWith("{\"fnc\":1,"))
}
/**
* An edit with no target is not an edit. It must fall out of the branch
* rather than become one naming nothing.
* An edit with no target is not an edit.
*/
@Test
fun `an edit with no target is not treated as an edit`() {
@@ -105,7 +95,7 @@ class EditEnvelopeTest {
/**
* Both clients must agree byte-for-byte on the shape, since one encodes
* what the other parses. This is the shape iOS emits.
* what the other parses.
*/
@Test
fun `the wire shape matches what iOS sends`() {
@@ -119,19 +109,15 @@ class EditEnvelopeTest {
/**
* The single decision that keeps an edit out of the transcript.
*
* `Session` filters inbound envelopes on `isRenderable`, so this property is
* the whole of what stops a correction being filed as a new message. It is
* tested apart from the codec because it is the part that would still be wrong
* if the parse branch were right.
*/
class EditIsNotRenderableTest {
@Test
fun `an edit is not filed as a message`() {
val content = Content.parse(Content.edit("meet at 9", of = "abc"))
assertTrue(!content.isRenderable,
"a correction would be filed as a second bubble beside the original")
assertTrue(
!content.isRenderable,
"a correction would be filed as a second bubble beside the original",
)
}
/** And ordinary text still is, or this fix has broken conversation. */
@@ -144,8 +130,8 @@ class EditIsNotRenderableTest {
/** A reaction and a call signal stay excluded for their own reasons. */
@Test
fun `the other non-messages are still excluded`() {
val reaction = Content.parse(
Content.reaction("👍", about = Content.Reference("abc", 7L, "")))
val reaction =
Content.parse(Content.reaction("👍", about = Content.Reference("abc", 7L, "")))
assertTrue(!reaction.isRenderable)
}
}
@@ -3,32 +3,26 @@ package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.assertFalse
/**
* Documents sent between the two apps.
*
* The envelope in [IOS_FILE] was produced by the iOS build, not by this
* engine. Two implementations of one format that each only test against
* themselves agree right up to the day somebody sends a real file, and the
* symptom is a message that arrives as "[unsupported attachment]" with no clue
* why. This is the test that catches a drift in the field names before then.
*/
class FileEnvelopeInteropTest {
// Standard base64 with padding, which is what Bytes.unb64 decodes.
private val key = "ERERERERERERERERERERERERERERERERERERERERERE="
private val digest = "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI="
/** As iOS assembles it: `fn`, `cls`, `cmp` as additive top-level keys. */
private val iosFile = """
private val iosFile =
"""
{"fnc":1,"kind":"file","caption":"📎 Lease agreement.pdf",
"att":{"id":"${"a".repeat(64)}","key":"$key","digest":"$digest",
"bytes":4096,"mime":"application/pdf","w":0,"h":0},
"fn":"Lease agreement.pdf","cls":2,"cmp":[0,1]}
""".trimIndent().replace("\n", "")
""".trimIndent().replace("\n", "")
@Test
fun `a file envelope from iOS parses with its name and label`() {
@@ -52,17 +46,18 @@ class FileEnvelopeInteropTest {
@Test
fun `this engine assembles the same shape it parses`() {
val ref = Attachment.Ref(
kind = Attachment.FILE,
id = "b".repeat(64),
key = ByteArray(32) { 0x11 },
digest = ByteArray(32) { 0x22 },
bytes = 2048,
mime = "application/pdf",
filename = "Settlement draft.docx",
classification = 3,
compartments = listOf(2),
)
val ref =
Attachment.Ref(
kind = Attachment.FILE,
id = "b".repeat(64),
key = ByteArray(32) { 0x11 },
digest = ByteArray(32) { 0x22 },
bytes = 2048,
mime = "application/pdf",
filename = "Settlement draft.docx",
classification = 3,
compartments = listOf(2),
)
val json = Content.attachment(ref)
val parsed = JSONObject(json)
assertEquals("file", parsed.getString("kind"))
@@ -77,16 +72,21 @@ class FileEnvelopeInteropTest {
}
/**
* The compatibility rule, on this side too: never captionless, because
* a build that predates this kind shows only the caption.
* The compatibility rule, on this side too: never captionless, because a
* build that predates this kind shows only the caption.
*/
@Test
fun `a file envelope always carries a caption naming the document`() {
val ref = Attachment.Ref(
kind = Attachment.FILE, id = "c".repeat(64),
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
bytes = 10, mime = "application/pdf", filename = "notes.txt",
)
val ref =
Attachment.Ref(
kind = Attachment.FILE,
id = "c".repeat(64),
key = ByteArray(32) { 0x11 },
digest = ByteArray(32) { 0x22 },
bytes = 10,
mime = "application/pdf",
filename = "notes.txt",
)
val json = Content.attachment(ref)
assertTrue(json.contains("\"caption\""), "a file envelope went out captionless")
assertEquals("📎 notes.txt", Content.parse(json).text)
@@ -94,22 +94,28 @@ class FileEnvelopeInteropTest {
@Test
fun `a caption the sender wrote is not replaced`() {
val ref = Attachment.Ref(
kind = Attachment.FILE, id = "d".repeat(64),
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
bytes = 10, mime = "application/pdf", filename = "notes.txt",
)
val ref =
Attachment.Ref(
kind = Attachment.FILE,
id = "d".repeat(64),
key = ByteArray(32) { 0x11 },
digest = ByteArray(32) { 0x22 },
bytes = 10,
mime = "application/pdf",
filename = "notes.txt",
)
assertEquals("signed copy", Content.parse(Content.attachment(ref, "signed copy")).text)
}
/** An older peer sends no `fn`/`cls`/`cmp`. It must parse, not throw. */
@Test
fun `an envelope without the new fields still parses`() {
val older = """
val older =
"""
{"fnc":1,"kind":"file","caption":"📎 old.pdf",
"att":{"id":"${"e".repeat(64)}","key":"$key","digest":"$digest",
"bytes":10,"mime":"application/pdf","w":0,"h":0}}
""".trimIndent().replace("\n", "")
""".trimIndent().replace("\n", "")
val ref = assertNotNull(Content.parse(older).attachment)
assertEquals("", ref.filename)
assertEquals(0, ref.classification)
@@ -117,17 +123,23 @@ class FileEnvelopeInteropTest {
}
/**
* The additive property that makes any of this safe: a photo envelope
* from this engine still carries none of the file keys, so a peer that has
* never heard of them reads it exactly as before.
* The additive property that makes any of this safe: a photo envelope from
* this engine still carries none of the file keys, so a peer that has never
* heard of them reads it exactly as before.
*/
@Test
fun `a photo envelope carries none of the file keys`() {
val photo = Attachment.Ref(
kind = Attachment.IMAGE, id = "f".repeat(64),
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
bytes = 1000, mime = "image/jpeg", width = 100, height = 200,
)
val photo =
Attachment.Ref(
kind = Attachment.IMAGE,
id = "f".repeat(64),
key = ByteArray(32) { 0x11 },
digest = ByteArray(32) { 0x22 },
bytes = 1000,
mime = "image/jpeg",
width = 100,
height = 200,
)
val json = Content.attachment(photo, "hi")
assertFalse(json.contains("\"fn\""), "a photo envelope carried file fields")
assertFalse(json.contains("\"cls\""))
@@ -136,27 +148,34 @@ class FileEnvelopeInteropTest {
/**
* A filename from a peer is display text and a name, never a path.
* Carried verbatim, and nothing here should ever join it onto a directory.
*/
@Test
fun `a hostile filename survives as plain text`() {
val hostile = """
val hostile =
"""
{"fnc":1,"kind":"file","caption":"x",
"att":{"id":"${"a".repeat(64)}","key":"$key","digest":"$digest",
"bytes":1,"mime":"application/pdf","w":0,"h":0},
"fn":"../../../etc/passwd","cls":0,"cmp":[]}
""".trimIndent().replace("\n", "")
assertEquals("../../../etc/passwd",
assertNotNull(Content.parse(hostile).attachment).filename)
""".trimIndent().replace("\n", "")
assertEquals(
"../../../etc/passwd",
assertNotNull(Content.parse(hostile).attachment).filename,
)
}
@Test
fun `a file in a group keeps its routing`() {
val ref = Attachment.Ref(
kind = Attachment.FILE, id = "a".repeat(64),
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
bytes = 1, mime = "application/pdf", filename = "g.pdf",
)
val ref =
Attachment.Ref(
kind = Attachment.FILE,
id = "a".repeat(64),
key = ByteArray(32) { 0x11 },
digest = ByteArray(32) { 0x22 },
bytes = 1,
mime = "application/pdf",
filename = "g.pdf",
)
val content = Content.parse(Content.attachment(ref, groupId = "g-123"))
assertEquals("g-123", content.groupId)
assertEquals(Attachment.FILE, content.attachment?.kind)
@@ -1,46 +1,28 @@
package io.flamenet.e2e
import org.json.JSONObject
import java.util.Base64
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* Interop for the Files vault, against the published reference vectors.
*
* These vectors were produced by the JavaScript engine that runs in the
* browser, a third implementation, not the Swift one. Nothing here checks
* that this code is self-consistent. It checks that a vault written by the
* software people already use opens on an Android phone, which is the only
* question that matters: a framing, authenticated-data or key-hierarchy mistake
* fails here, and nowhere else until somebody's files will not open.
*
* The file is `vectors/files-v1.json`, copied byte for byte from
* `Packages/FlamenetFiles/Tests/FilesEngineTests/vectors/v1.json` in the iOS
* repository. Copied rather than regenerated, deliberately: a vector file
* regenerated by the implementation under test proves only that it agrees with
* itself.
*
* Two of these vectors were additionally recomputed from scratch in Python,
* `struct.pack('>hQ')` for the label encoding, `hmac.new(...).digest()` for the
* name digests, before any Kotlin was run against them. The `café` case
* matches under NFC and NOT under NFD, which is what makes the normalisation
* form pinned rather than assumed.
*/
class FilesInteropTest {
private val vectors: JSONObject = JSONObject(
requireNotNull(javaClass.getResourceAsStream("/vectors/files-v1.json")) {
"the interop vectors are missing from the test resources"
}.reader().readText()
)
private val vectors: JSONObject =
JSONObject(
requireNotNull(javaClass.getResourceAsStream("/vectors/files-v1.json")) {
"the interop vectors are missing from the test resources"
}.reader().readText(),
)
private val cases: JSONObject get() = vectors.getJSONObject("cases")
private val hierarchy: JSONObject get() = cases.getJSONObject("hierarchy")
private fun b64(s: String): ByteArray = Base64.getDecoder().decode(s)
private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }
private fun labelOf(o: JSONObject): Label {
@@ -56,10 +38,9 @@ class FilesInteropTest {
/** The passphrase the vector identity was written under. */
private val passphrase: String get() = cases.getJSONObject("kdf").getString("passphrase")
private fun identityRecord(): FilesIdentity.Record =
FilesIdentity.Record.parse(hierarchy.getJSONObject("identityRecord").toString())
private fun identityRecord(): FilesIdentity.Record = FilesIdentity.Record.parse(hierarchy.getJSONObject("identityRecord").toString())
// ---- §1 The label encoding -------------------------------------------
// ---- §1 The label encoding -------------------------------------------.
@Test
fun `the label encoding matches the reference implementation`() {
@@ -76,29 +57,27 @@ class FilesInteropTest {
}
}
// ---- §2 Key derivation -----------------------------------------------
// ---- §2 Key derivation -----------------------------------------------.
/**
* The whole PBKDF2-then-HKDF chain, against a value the browser
* published. A wrong iteration count, a wrong salt position, a wrong
* context string or a password encoded as UTF-16 all land here.
* The whole PBKDF2-then-HKDF chain, against a value the browser published.
*/
@Test
fun `the auth secret derives to the reference value`() {
val kdf = cases.getJSONObject("kdf")
val derived = FilesKdf.authSecret(
passphrase = kdf.getString("passphrase"),
salt = b64(kdf.getString("salt")),
params = FilesKdf.Params(id = 1, iterations = kdf.getInt("iterations")),
)
val derived =
FilesKdf.authSecret(
passphrase = kdf.getString("passphrase"),
salt = b64(kdf.getString("salt")),
params = FilesKdf.Params(id = 1, iterations = kdf.getInt("iterations")),
)
assertEquals(hex(b64(kdf.getString("authSecret"))), hex(derived))
}
// ---- §3 The identity record ------------------------------------------
// ---- §3 The identity record ------------------------------------------.
/**
* An identity written by the browser, opened here. This is the one
* that decides whether somebody can move to an Android phone at all.
* An identity written by the browser, opened here.
*/
@Test
fun `an identity record written by the browser unlocks`() {
@@ -112,9 +91,7 @@ class FilesInteropTest {
/**
* The public key above is recovered by agreeing against the X25519 base
* point, because the JCA will not derive one from an XDH private key. That
* it returns 32 bytes proves nothing; that they are *these* 32 bytes is the
* proof, and this states it separately so the reason survives a refactor.
* point, because the JCA will not derive one from an XDH private key.
*/
@Test
fun `the base-point trick recovers the right public key`() {
@@ -133,10 +110,9 @@ class FilesInteropTest {
}
/**
* The context is authenticated data as well as a KDF separator, so a
* record made for Files must not open as a Messenger one even with the
* right passphrase. Without this, one product's compromise would be all of
* them.
* The context is authenticated data as well as a KDF separator, so a record
* made for Files must not open as a Messenger one even with the right
* passphrase.
*/
@Test
fun `a record does not open under another product's context`() {
@@ -149,24 +125,21 @@ class FilesInteropTest {
}
}
// ---- §4 The asymmetric grant -----------------------------------------
// ---- §4 The asymmetric grant -----------------------------------------.
/**
* A label key granted by the browser, accepted here. This is how an
* area is shared between two people, and the salt is both public keys in a
* fixed order, the mistake that reads as working is swapping them, which
* produces a key that is simply different, with no error until the file
* fails to open.
* A label key granted by the browser, accepted here.
*/
@Test
fun `a label key granted by the browser is accepted`() {
val opened = FilesIdentity.unlock(identityRecord(), passphrase)
val labelKey = FileKeys.acceptLabelKey(
blob = b64(hierarchy.getString("labelKeyGrant")),
label = hierarchyLabel,
privateKey = opened.privateKey,
ownPublicRaw = opened.publicKeyRaw,
)
val labelKey =
FileKeys.acceptLabelKey(
blob = b64(hierarchy.getString("labelKeyGrant")),
label = hierarchyLabel,
privateKey = opened.privateKey,
ownPublicRaw = opened.publicKeyRaw,
)
assertEquals(hex(b64(hierarchy.getString("labelKey"))), hex(labelKey))
}
@@ -184,25 +157,22 @@ class FilesInteropTest {
}
}
// ---- §5 The key hierarchy --------------------------------------------
// ---- §5 The key hierarchy --------------------------------------------.
@Test
fun `the key hierarchy unwraps as the browser wrapped it`() {
val labelKey = b64(hierarchy.getString("labelKey"))
val nodeKey = FileKeys.unwrapNodeKey(
b64(hierarchy.getString("wrappedNodeKey")), labelKey, hierarchyLabel)
val nodeKey =
FileKeys.unwrapNodeKey(b64(hierarchy.getString("wrappedNodeKey")), labelKey, hierarchyLabel)
assertEquals(hex(b64(hierarchy.getString("nodeKey"))), hex(nodeKey))
val contentKey = FileKeys.unwrapContentKey(
b64(hierarchy.getString("wrappedContentKey")), nodeKey, hierarchyLabel)
val contentKey =
FileKeys.unwrapContentKey(b64(hierarchy.getString("wrappedContentKey")), nodeKey, hierarchyLabel)
assertEquals(hex(b64(hierarchy.getString("contentKey"))), hex(contentKey))
}
/**
* Reclassification must break the key, not succeed quietly. Edit the
* stored classification and the node key no longer unwraps. Without the
* label in the authenticated data, anything that could write to the index
* could move a file down a level and it would still open.
* Reclassification must break the key, not succeed quietly.
*/
@Test
fun `a relabelled row does not unwrap`() {
@@ -215,7 +185,7 @@ class FilesInteropTest {
}
}
// ---- §6 Names ---------------------------------------------------------
// ---- §6 Names ---------------------------------------------------------.
/**
* The `café` case is the one that matters: it matches under NFC and not
@@ -242,16 +212,11 @@ class FilesInteropTest {
/**
* The composed and decomposed spellings of one name are one file.
*
* Written as escapes, not as two literals that look alike. Two source
* literals both typed as "cafe-with-an-accent" are the SAME bytes in this
* file, so the test would pass with normalisation removed entirely - which
* is precisely the failure it exists to catch.
*/
@Test
fun `a decomposed name digests the same as a composed one`() {
val composed = "caf\u00e9" // e-acute, one code point
val decomposed = "cafe\u0301" // e, then a combining acute
val composed = "caf\u00e9" // e-acute, one code point
val decomposed = "cafe\u0301" // e, then a combining acute
assertTrue(
composed.toByteArray().size != decomposed.toByteArray().size,
"these two spellings are meant to differ before normalisation",
@@ -273,12 +238,13 @@ class FilesInteropTest {
@Test
fun `a filename written by the browser decrypts`() {
val name = FileNames.decrypt(
folderKey = b64(hierarchy.getString("nodeKey")),
blob = b64(hierarchy.getString("nameCiphertext")),
digest = b64(hierarchy.getString("nameDigest")),
label = hierarchyLabel,
)
val name =
FileNames.decrypt(
folderKey = b64(hierarchy.getString("nodeKey")),
blob = b64(hierarchy.getString("nameCiphertext")),
digest = b64(hierarchy.getString("nameDigest")),
label = hierarchyLabel,
)
assertEquals(hierarchy.getString("expectedName"), name)
}
@@ -299,20 +265,20 @@ class FilesInteropTest {
}
}
// ---- §7 Content -------------------------------------------------------
// ---- §7 Content -------------------------------------------------------.
/**
* A file written by the browser, in more than one chunk. The vector
* uses a 16-byte chunk size precisely so the multi-chunk path is the one
* being tested, a single-chunk file would pass with the index and the
* final-chunk flag both wrong.
* A file written by the browser, in more than one chunk.
*/
@Test
fun `content written by the browser decrypts, across chunks`() {
val blob = b64(hierarchy.getString("content"))
val chunkSize = hierarchy.getInt("contentChunkSize")
assertEquals(chunkSize.toLong(), FileContent.readU32be(blob, 5),
"the blob's own header disagrees with the vector's chunk size")
assertEquals(
chunkSize.toLong(),
FileContent.readU32be(blob, 5),
"the blob's own header disagrees with the vector's chunk size",
)
val plaintext = b64(hierarchy.getString("expectedContent"))
assertTrue(plaintext.size > chunkSize, "this vector is not exercising the multi-chunk path")
@@ -335,17 +301,7 @@ class FilesInteropTest {
}
/**
* Truncation, on a real blob. Drop the final chunk and the file
* would simply look shorter - except that the chunk before it says it is
* not last.
*
* The second half of this test is the important half. Rejecting a
* truncated file proves nothing on its own: a corrupted length, a wrong
* offset, any old bug would also reject it. What makes the final-chunk flag
* the thing doing the work is that the SAME bytes still open perfectly
* under their original `isLast = false` - so the ciphertext is authentic,
* intact and in the right place, and the only thing refusing it is the
* claim about where the file ends.
* Truncation, on a real blob.
*/
@Test
fun `a truncated file is refused rather than silently shortened`() {
@@ -359,14 +315,15 @@ class FilesInteropTest {
assertFailsWith<Aead.DecryptionFailed> { FileContent.decrypt(truncated, key, hierarchyLabel) }
// The same surviving bytes, opened as what they actually are: chunk 0
// of a longer file. If this fails, the test above proved nothing.
// of a longer file.
val baseNonce = blob.copyOfRange(9, FileContent.HEADER_LEN)
val chunkZero = truncated.copyOfRange(FileContent.HEADER_LEN, truncated.size)
val plaintext = Aead.open(
key = key,
box = FileContent.nonce(baseNonce, 0) + chunkZero,
aad = FileContent.aad(hierarchyLabel, index = 0, isLast = false),
)
val plaintext =
Aead.open(
key = key,
box = FileContent.nonce(baseNonce, 0) + chunkZero,
aad = FileContent.aad(hierarchyLabel, index = 0, isLast = false),
)
assertEquals(
hex(b64(hierarchy.getString("expectedContent")).copyOfRange(0, plaintext.size)),
hex(plaintext),
@@ -375,12 +332,7 @@ class FilesInteropTest {
}
/**
* Reordering. Swap two chunks and every byte is still authentic -
* for a different index.
*
* Same discipline as truncation: the moved chunk is shown to open under
* the index it was written at and to fail under the one it was moved to.
* Otherwise "it threw" is all this would establish.
* Reordering.
*/
@Test
fun `swapped chunks are refused`() {
@@ -399,11 +351,12 @@ class FilesInteropTest {
// The moved chunk is intact: it opens at the index it was written at.
val baseNonce = blob.copyOfRange(9, FileContent.HEADER_LEN)
val opened = Aead.open(
key = key,
box = FileContent.nonce(baseNonce, 1) + last,
aad = FileContent.aad(hierarchyLabel, index = 1, isLast = true),
)
val opened =
Aead.open(
key = key,
box = FileContent.nonce(baseNonce, 1) + last,
aad = FileContent.aad(hierarchyLabel, index = 1, isLast = true),
)
assertTrue(opened.isNotEmpty(), "chunk 1 should hold the tail of the file")
// ...and does not open at the index it was moved to.
assertFailsWith<Aead.DecryptionFailed> {
@@ -7,43 +7,32 @@ import kotlin.test.assertTrue
/**
* The Files crypto, on the cases the reference vectors do not reach.
*
* The vectors in `FilesInteropTest` pin agreement with the browser, which is
* the question that matters most, but a vector file only covers the shapes
* somebody thought to write down. The boundaries below are where an
* implementation that matches every vector still breaks: an empty file, a file
* exactly one chunk long, a chunk size that overflows an `Int`.
*/
class FilesVaultCryptoTest {
private val label = Label(3, setOf(0, 1))
private val key = ByteArray(32) { (it * 7 + 1).toByte() }
private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }
// ---- Content boundaries ----------------------------------------------
// ---- Content boundaries ----------------------------------------------.
/**
* An empty file is one chunk, not zero. Zero chunks would leave a
* file with no authenticated end marker at all, indistinguishable from one
* truncated to nothing, which is precisely the attack the flag exists to
* stop. The round trip alone would pass either way, so the chunk count is
* asserted directly.
* An empty file is one chunk, not zero.
*/
@Test
fun `an empty file still carries an authenticated end marker`() {
val blob = FileContent.encrypt(ByteArray(0), key, label, chunkSize = 16)
assertEquals(
FileContent.HEADER_LEN + FileContent.TAG_LEN, blob.size,
FileContent.HEADER_LEN + FileContent.TAG_LEN,
blob.size,
"an empty file should be a header and exactly one empty, tagged chunk",
)
assertEquals(0, FileContent.decrypt(blob, key, label).size)
}
/**
* The off-by-one that a round trip hides: a file whose length is an
* exact multiple of the chunk size. Written as N chunks, it must not be
* read back as N+1, nor as N-1 with the tail treated as padding.
* The off-by-one that a round trip hides: a file whose length is an exact
* multiple of the chunk size.
*/
@Test
fun `a file exactly filling its chunks round-trips`() {
@@ -51,7 +40,8 @@ class FilesVaultCryptoTest {
val plaintext = ByteArray(16 * chunks) { (it % 251).toByte() }
val blob = FileContent.encrypt(plaintext, key, label, chunkSize = 16)
assertEquals(
FileContent.HEADER_LEN + chunks * (16 + FileContent.TAG_LEN), blob.size,
FileContent.HEADER_LEN + chunks * (16 + FileContent.TAG_LEN),
blob.size,
"$chunks-chunk file has the wrong framed length",
)
assertEquals(hex(plaintext), hex(FileContent.decrypt(blob, key, label)))
@@ -61,30 +51,32 @@ class FilesVaultCryptoTest {
@Test
fun `a file one byte over a chunk boundary round-trips`() {
val plaintext = ByteArray(17) { it.toByte() }
assertEquals(hex(plaintext),
hex(FileContent.decrypt(FileContent.encrypt(plaintext, key, label, 16), key, label)))
assertEquals(
hex(plaintext),
hex(FileContent.decrypt(FileContent.encrypt(plaintext, key, label, 16), key, label)),
)
}
/** Larger than one default chunk, so the real path is exercised too. */
@Test
fun `a file larger than the default chunk size round-trips`() {
val plaintext = ByteArray(FileContent.DEFAULT_CHUNK_SIZE + 1_000) { (it % 256).toByte() }
assertEquals(hex(plaintext),
hex(FileContent.decrypt(FileContent.encrypt(plaintext, key, label), key, label)))
assertEquals(
hex(plaintext),
hex(FileContent.decrypt(FileContent.encrypt(plaintext, key, label), key, label)),
)
}
/**
* A hostile chunk size in the header. `0xFFFFFFFF` read as a signed
* `Int` is -1, which passes a `> 0` check written the obvious way and then
* allocates nonsense. Read as a `Long`, it is refused.
* A hostile chunk size in the header.
*/
@Test
fun `a chunk size that overflows an Int is refused`() {
val blob = FileContent.encrypt(byteArrayOf(1, 2, 3), key, label, chunkSize = 16)
for (bytes in listOf(
byteArrayOf(-1, -1, -1, -1), // 0xFFFFFFFF
byteArrayOf(-128, 0, 0, 0), // 0x80000000
byteArrayOf(0, 0, 0, 0), // zero
byteArrayOf(-1, -1, -1, -1), // 0xFFFFFFFF
byteArrayOf(-128, 0, 0, 0), // 0x80000000
byteArrayOf(0, 0, 0, 0), // zero
)) {
val hostile = blob.copyOf()
bytes.copyInto(hostile, 5)
@@ -105,9 +97,10 @@ class FilesVaultCryptoTest {
fun `a newer format version is named, not treated as damage`() {
val blob = FileContent.encrypt(byteArrayOf(1), key, label)
val newer = blob.copyOf().also { it[4] = 9 }
val thrown = assertFailsWith<FilesEngineException.UnsupportedVersion> {
FileContent.decrypt(newer, key, label)
}
val thrown =
assertFailsWith<FilesEngineException.UnsupportedVersion> {
FileContent.decrypt(newer, key, label)
}
assertTrue(
thrown.message!!.contains("9"),
"the version somebody needs in order to act on this was thrown away",
@@ -115,34 +108,33 @@ class FilesVaultCryptoTest {
}
/**
* Every file gets a fresh base nonce. Reusing one across two files under
* one content key would repeat a nonce, and GCM does not degrade there, it
* hands over the authentication subkey.
* Every file gets a fresh base nonce.
*/
@Test
fun `two encryptions of the same bytes use different nonces`() {
val a = FileContent.encrypt(byteArrayOf(1, 2, 3), key, label)
val b = FileContent.encrypt(byteArrayOf(1, 2, 3), key, label)
assertTrue(
!a.copyOfRange(9, FileContent.HEADER_LEN).contentEquals(
b.copyOfRange(9, FileContent.HEADER_LEN)),
!a.copyOfRange(9, FileContent.HEADER_LEN).contentEquals(b.copyOfRange(9, FileContent.HEADER_LEN)),
"the base nonce is being reused across files",
)
}
// ---- Names -----------------------------------------------------------
// ---- Names -----------------------------------------------------------.
@Test
fun `a name round-trips through encryption`() {
val sealed = FileNames.encrypt(key, "Q3 Forecast.txt", label)
assertEquals("Q3 Forecast.txt",
FileNames.decrypt(key, sealed.ciphertext, sealed.digest, label))
assertEquals(
"Q3 Forecast.txt",
FileNames.decrypt(key, sealed.ciphertext, sealed.digest, label),
)
}
/**
* Two encryptions of one name differ, and that is required, not
* incidental: identical ciphertexts would let anything holding the rows see
* that two files share a name.
* Two encryptions of one name differ, and that is required, not incidental.
* identical ciphertexts would let anything holding the rows see that two
* files share a name.
*/
@Test
fun `two encryptions of one name are not equal, but both open`() {
@@ -164,8 +156,8 @@ class FilesVaultCryptoTest {
}
/**
* Per-folder keying is what stops anything holding the rows noticing
* that two folders contain a file of the same name.
* Per-folder keying is what stops anything holding the rows noticing that
* two folders contain a file of the same name.
*/
@Test
fun `the same name under two folder keys digests differently`() {
@@ -188,7 +180,7 @@ class FilesVaultCryptoTest {
}
}
// ---- Wrapping ---------------------------------------------------------
// ---- Wrapping ---------------------------------------------------------.
@Test
fun `a symmetric wrap round-trips and is bound to its label`() {
@@ -201,10 +193,8 @@ class FilesVaultCryptoTest {
}
/**
* The two wrap forms carry different version bytes, and neither will
* open the other's. Without that, a symmetric blob handed to the asymmetric
* opener would be parsed as a public key plus a box and fail somewhere far
* less obvious.
* The two wrap forms carry different version bytes, and neither will open
* the other's.
*/
@Test
fun `the two wrap forms do not open each other`() {
@@ -235,23 +225,21 @@ class FilesVaultCryptoTest {
}
/**
* A grant made for one person does not open for another, even though
* the ciphertext is well-formed and the label is right. Both public keys go
* into the salt for exactly this.
* A grant made for one person does not open for another, even though the
* ciphertext is well-formed and the label is right.
*/
@Test
fun `a grant addressed to somebody else does not open`() {
val intended = X25519.generate()
val other = X25519.generate()
val grant = FileKeys.grantLabelKey(
ByteArray(32), label, X25519.rawFromPublicKey(intended.public))
val grant =
FileKeys.grantLabelKey(ByteArray(32), label, X25519.rawFromPublicKey(intended.public))
assertFailsWith<Aead.DecryptionFailed> {
FileKeys.acceptLabelKey(
grant, label, other.private, X25519.rawFromPublicKey(other.public))
FileKeys.acceptLabelKey(grant, label, other.private, X25519.rawFromPublicKey(other.public))
}
}
// ---- The identity record ---------------------------------------------
// ---- The identity record ---------------------------------------------.
/** A fast parameter set: this asserts the format, not the work factor. */
private val fast = FilesKdf.Params(id = 1, iterations = 1_000)
@@ -273,9 +261,7 @@ class FilesVaultCryptoTest {
/**
* The record holds `ciphertext || tag` WITHOUT the nonce, which is what
* WebCrypto emits and what every other client expects. Storing the combined
* box would produce a record this engine reads back perfectly and no other
* client can open at all, so the length is asserted rather than trusted.
* WebCrypto emits and what every other client expects.
*/
@Test
fun `the stored private key is the box without its nonce`() {
@@ -291,9 +277,10 @@ class FilesVaultCryptoTest {
@Test
fun `the wrong passphrase is refused with a sentence`() {
val record = FilesIdentity.protect(ByteArray(32), "one", fast)
val thrown = assertFailsWith<FilesEngineException.Malformed> {
FilesIdentity.unlock(record, "two")
}
val thrown =
assertFailsWith<FilesEngineException.Malformed> {
FilesIdentity.unlock(record, "two")
}
assertTrue(
thrown.message!!.length > 20 && thrown.message!!.contains("passphrase"),
"a person reading this cannot tell what went wrong: ${thrown.message}",
@@ -301,9 +288,8 @@ class FilesVaultCryptoTest {
}
/**
* PKCS#8 in, raw 32 out, and a wrapper that is not ours is refused
* rather than silently sliced. A 48-byte value whose prefix belongs to some
* other curve would otherwise yield 32 bytes of something else entirely.
* PKCS#8 in, raw 32 out, and a wrapper that is not ours is refused rather
* than silently sliced.
*/
@Test
fun `a foreign PKCS8 wrapper is refused`() {
@@ -312,18 +298,17 @@ class FilesVaultCryptoTest {
assertEquals(hex(ByteArray(32) { 4 }), hex(FilesIdentity.rawFromPkcs8(good)))
assertFailsWith<FilesEngineException.Malformed> {
FilesIdentity.rawFromPkcs8(good.copyOf().also { it[9] = 0x2C }) // a different OID
FilesIdentity.rawFromPkcs8(good.copyOf().also { it[9] = 0x2C }) // a different OID
}
assertFailsWith<FilesEngineException.Malformed> {
FilesIdentity.rawFromPkcs8(good.copyOfRange(0, 47))
}
}
// ---- Derivation -------------------------------------------------------
// ---- Derivation -------------------------------------------------------.
/**
* One passphrase, one salt, two contexts, two unrelated secrets. This is
* the whole reason a Files compromise is not a Messenger compromise.
* One passphrase, one salt, two contexts, two unrelated secrets.
*/
@Test
fun `two contexts derive unrelated secrets from one passphrase`() {
@@ -335,21 +320,22 @@ class FilesVaultCryptoTest {
}
/**
* The passphrase reaches PBKDF2 as UTF-8 bytes, and this is the case
* that would differ if a provider were deciding: an astral character is one
* code point, two Kotlin chars, and four UTF-8 bytes.
* The passphrase reaches PBKDF2 as UTF-8 bytes, and this is the case that
* would differ if a provider were deciding: an astral character is one code
* point, two Kotlin chars, and four UTF-8 bytes.
*/
@Test
fun `a passphrase outside the basic plane derives from its UTF-8 bytes`() {
val salt = ByteArray(16) { 7 }
val emoji = "correct horse 🐎"
val direct = Hkdf.derive(
ikm = FilesKdf.pbkdf2HmacSha256(
emoji.toByteArray(Charsets.UTF_8), salt, fast.iterations, 32),
salt = ByteArray(0),
info = FilesKdf.Context.FILES_IDENTITY.toByteArray(Charsets.UTF_8),
length = 32,
)
val direct =
Hkdf.derive(
ikm =
FilesKdf.pbkdf2HmacSha256(emoji.toByteArray(Charsets.UTF_8), salt, fast.iterations, 32),
salt = ByteArray(0),
info = FilesKdf.Context.FILES_IDENTITY.toByteArray(Charsets.UTF_8),
length = 32,
)
assertEquals(
hex(direct),
hex(FilesKdf.derive(emoji, salt, FilesKdf.Context.FILES_IDENTITY, fast)),
@@ -1,22 +1,16 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* Passing a message on without passing on where it came from (§6.16).
*
* The codec half. The Android app cannot forward yet, that needs the
* transcript work in #10, but it must already understand an envelope the
* iPhone app sends, so a forwarded message arrives as a message and not as
* something the reader is told nothing about.
*/
class ForwardWithoutProvenanceTest {
@Test
fun `a forwarded message is marked as forwarded`() {
val content = Content.parse(Content.text("meet at 6", forwarded = true))
@@ -30,8 +24,8 @@ class ForwardWithoutProvenanceTest {
}
/**
*the envelope says THAT it was forwarded and nothing
* about who wrote it or what it answered.
* the envelope says THAT it was forwarded and nothing about who wrote it or
* what it answered.
*/
@Test
fun `the envelope carries no author and no reply chain`() {
@@ -42,9 +36,7 @@ class ForwardWithoutProvenanceTest {
}
/**
* `1`, not `true`, §6.10's shape. `org.json`'s optBoolean does not
* coerce a number, so an engine that read only one shape would silently
* miss every forward the other two engines send.
* `1`, not `true`, §6.10's shape.
*/
@Test
fun `both flag shapes are accepted`() {
@@ -53,8 +45,10 @@ class ForwardWithoutProvenanceTest {
assertTrue(Content.parse(asNumber).isForwarded)
val asBool = asNumber.replace("\"fwd\":1", "\"fwd\":true")
assertTrue(Content.parse(asBool).isForwarded,
"an engine writing `true` would have its forwards read as ordinary messages")
assertTrue(
Content.parse(asBool).isForwarded,
"an engine writing `true` would have its forwards read as ordinary messages",
)
}
/** A forward is still a message: it renders, unlike control traffic. */
@@ -66,14 +60,21 @@ class ForwardWithoutProvenanceTest {
/** The flag rides an attachment too, a forwarded photo is still a forward. */
@Test
fun `a forwarded attachment keeps the flag`() {
val ref = Attachment.Ref(
kind = Attachment.IMAGE, id = "a".repeat(64),
key = ByteArray(32) { 1 }, digest = ByteArray(32) { 2 },
bytes = 1024, mime = "image/jpeg", width = 100, height = 100,
duration = 0.0, expires = 0.0,
)
val content = Content.parse(
Content.attachment(ref, caption = "look", forwarded = true))
val ref =
Attachment.Ref(
kind = Attachment.IMAGE,
id = "a".repeat(64),
key = ByteArray(32) { 1 },
digest = ByteArray(32) { 2 },
bytes = 1024,
mime = "image/jpeg",
width = 100,
height = 100,
duration = 0.0,
expires = 0.0,
)
val content =
Content.parse(Content.attachment(ref, caption = "look", forwarded = true))
assertTrue(content.isForwarded)
assertEquals("look", content.text)
}
@@ -1,22 +1,15 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import org.json.JSONObject
/**
* Group messages carrying an id of their own (§6.9).
*
* The wire always carried one and the transcript never stored it.
* `Content.groupText` has taken a `messageId` since §6.9 shipped and `parse`
* reads it back, `Session.Line` was where it stopped. So nothing in a group
* could name an earlier message: no reply to resolve, no reaction to fold, no
* edit to apply. All three were "codec done" and none of them worked.
*/
class GroupMessageIdentityTest {
@Test
fun `a group message round trips its id`() {
val content = Content.parse(Content.groupText("meet at 6", "g1", messageId = "abc"))
@@ -25,26 +18,25 @@ class GroupMessageIdentityTest {
}
/**
* One id for the whole fan-out. A group message is ONE message: a reply
* or a reaction from any member has to name the same thing, so
* per-recipient ids would make that impossible.
* One id for the whole fan-out.
*/
fun bodiesFor(memberCount: Int, messageId: String) =
(1..memberCount).map { Content.groupText("meet at 6", "g1", messageId = messageId) }
fun bodiesFor(
memberCount: Int,
messageId: String,
) = (1..memberCount).map { Content.groupText("meet at 6", "g1", messageId = messageId) }
@Test
fun `every copy of a group message carries the same id`() {
val ids = bodiesFor(3, "shared").map { Content.parse(it).messageId }
assertEquals(setOf("shared"), ids.toSet(),
"members received different ids for one message")
assertEquals(
setOf("shared"),
ids.toSet(),
"members received different ids for one message",
)
}
/**
* The queued retry has to be the same message. `sendGroup` builds
* the envelope twice, once to send and once for the outbox, so an id
* minted inside either would differ between them, and a member who was
* offline would eventually receive a *different* message from everybody
* else, which nothing could reconcile with a reply or a reaction.
* The queued retry has to be the same message.
*/
@Test
fun `the queued copy carries the same id as the sent one`() {
@@ -57,15 +49,15 @@ class GroupMessageIdentityTest {
@Test
fun `a group reply round trips its quote`() {
val about = Content.Reference("earlier", 9L, "what they said")
val content = Content.parse(
Content.groupText("yes", "g1", messageId = "m", about = about))
val content =
Content.parse(Content.groupText("yes", "g1", messageId = "m", about = about))
assertEquals("earlier", content.about?.messageId)
assertEquals("what they said", content.about?.preview)
}
/**
* A reference survives the transcript's JSON round trip, which is the
* part that was missing, the codec was always fine.
* A reference survives the transcript's JSON round trip, which is the part
* that was missing, the codec was always fine.
*/
@Test
fun `a reference survives being stored and read back`() {
@@ -91,8 +83,12 @@ class GroupMessageIdentityTest {
@Test
fun `a message stored with an id reads it back`() {
val stored = JSONObject().put("text", "hi").put("mine", false).put("at", 1L)
.put("mid", "abc")
val stored =
JSONObject()
.put("text", "hi")
.put("mine", false)
.put("at", 1L)
.put("mid", "abc")
assertNotNull(stored.optString("mid").takeIf { it.isNotEmpty() })
assertEquals("abc", stored.optString("mid"))
}
@@ -6,19 +6,17 @@ import kotlin.test.assertTrue
/**
* Stories posted to a group (spec §6.24, ios#55).
*
* The group's roster is the audience, and the sender's story audience does
* not apply. Two different lists making two different statements, who may
* see this account's stories, and who is in a conversation. They will disagree,
* and the roster wins.
*/
class GroupStoryTest {
@Test
fun `every member but yourself is a recipient`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2, 3), me = 1,
allowedByAudience = setOf(2, 3), contacts = listOf(2, 3))
val reach =
Vault.groupStoryReach(
members = listOf(1, 2, 3),
me = 1,
allowedByAudience = setOf(2, 3),
contacts = listOf(2, 3),
)
assertEquals(listOf(2L, 3L), reach.recipients)
}
@@ -28,9 +26,13 @@ class GroupStoryTest {
*/
@Test
fun `a member excluded from your stories still receives a group story`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2, 3), me = 1,
allowedByAudience = setOf(2), contacts = listOf(2, 3))
val reach =
Vault.groupStoryReach(
members = listOf(1, 2, 3),
me = 1,
allowedByAudience = setOf(2),
contacts = listOf(2, 3),
)
assertEquals(listOf(2L, 3L), reach.recipients)
assertEquals(listOf(3L), reach.excludedByAudience)
}
@@ -38,58 +40,106 @@ class GroupStoryTest {
/** The other half: a member who is not a contact at all. */
@Test
fun `a member who is not a contact is named separately`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2, 9), me = 1,
allowedByAudience = setOf(2), contacts = listOf(2))
val reach =
Vault.groupStoryReach(
members = listOf(1, 2, 9),
me = 1,
allowedByAudience = setOf(2),
contacts = listOf(2),
)
assertEquals(listOf(9L), reach.notContacts)
assertTrue(reach.excludedByAudience.isEmpty())
}
/**
* The categories must not double-count. Somebody who is not a contact
* is not "excluded from your stories", they were never on that list.
* The categories must not double-count.
*/
@Test
fun `a non-contact is not also counted as excluded`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 9), me = 1,
allowedByAudience = emptySet(), contacts = emptyList())
val reach =
Vault.groupStoryReach(
members = listOf(1, 9),
me = 1,
allowedByAudience = emptySet(),
contacts = emptyList(),
)
assertEquals(listOf(9L), reach.notContacts)
assertTrue(reach.excludedByAudience.isEmpty())
}
@Test
fun `nothing is disclosed when the lists agree`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2), me = 1,
allowedByAudience = setOf(2), contacts = listOf(2))
val reach =
Vault.groupStoryReach(
members = listOf(1, 2),
me = 1,
allowedByAudience = setOf(2),
contacts = listOf(2),
)
assertTrue(reach.excludedByAudience.isEmpty())
assertTrue(reach.notContacts.isEmpty())
}
// -- Storage --------------------------------------------------------------
// -- Storage
// --------------------------------------------------------------.
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
/** Without persisting `gid`, a reload forgets which story went where. */
@Test
fun `the group survives the vault round trip`() {
val vault = Vault(MapStorage(),
javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
val vault =
Vault(
MapStorage(),
javax.crypto.KeyGenerator
.getInstance("AES")
.apply { init(256) }
.generateKey(),
)
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(listOf(Vault.Story(
id = "s1", authorId = 7, mine = false,
ref = Attachment.Ref(
kind = Attachment.STORY, id = "b1", key = ByteArray(32),
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = now + 600),
caption = "", postedAt = 1, gid = "g1")), now)
vault.saveStories(
listOf(
Vault.Story(
id = "s1",
authorId = 7,
mine = false,
ref =
Attachment.Ref(
kind = Attachment.STORY,
id = "b1",
key = ByteArray(32),
digest = ByteArray(32),
bytes = 1,
mime = "image/jpeg",
width = 1,
height = 1,
duration = 0.0,
expires = now + 600,
),
caption = "",
postedAt = 1,
gid = "g1",
),
),
now,
)
assertEquals("g1", vault.loadStories().single().gid)
}
}
@@ -9,27 +9,19 @@ import kotlin.test.assertTrue
/**
* From a recovery phrase to an address somebody can be paid at.
*
* The property that matters most here is that the signing key and the
* displayed address come from the same derivation. If they ever disagreed,
* the wallet would sign with a key that does not own the address it showed. The
* transaction being rejected is the GOOD outcome; the bad one is signing for an
* address somebody else controls.
*/
class HdAccountTest {
private val words by lazy { Bip39.englishWordlist() }
private val phrase by lazy { Bip39.phrase(ByteArray(16), words) }
private val seed by lazy { Bip39.seed(phrase) }
private fun account() = HdAccount(seed)
// ---- The path --------------------------------------------------------
// ---- The path --------------------------------------------------------.
/**
* BIP-44 with every level hardened, which is what this curve requires
* and what every other Solana wallet uses. A different shape here derives
* addresses nobody else can reproduce from the same phrase.
* BIP-44 with every level hardened, which is what this curve requires and
* what every other Solana wallet uses.
*/
@Test
fun `the derivation path is the standard Solana shape`() {
@@ -38,11 +30,10 @@ class HdAccountTest {
assertEquals("m/44H/501H/3H/0H/0H", HdAccount(seed, account = 3).path(0))
}
// ---- The property that guards the money ------------------------------
// ---- The property that guards the money ------------------------------.
/**
* The signing key owns the address that was displayed. Everything else
* in the wallet rests on this one line being true.
* The signing key owns the address that was displayed.
*/
@Test
fun `the signing key owns the address that was shown`() {
@@ -51,8 +42,11 @@ class HdAccountTest {
val shown = account.receive(index)
val signing = account.signingSeed(index)
val fromSigningKey = Base58.encode(Ed25519.fromSeed(signing).publicKey)
assertEquals(shown.address, fromSigningKey,
"index $index would sign with a key that does not own its address")
assertEquals(
shown.address,
fromSigningKey,
"index $index would sign with a key that does not own its address",
)
}
}
@@ -65,20 +59,17 @@ class HdAccountTest {
val message = "a transaction".toByteArray()
val signature = Ed25519.sign(seedAt0, message)
val publicKey = Base58.decode(shown.address)!!
// `(publicKey, signature, message)`. All three are ByteArray, so
// getting the order wrong compiles perfectly and fails at runtime,
// which is what happened writing this test, and is a good argument for
// the assertion existing at all.
assertTrue(Ed25519.verify(publicKey, signature, message),
"the signature does not verify under the address it was made for")
// `(publicKey, signature, message)`.
assertTrue(
Ed25519.verify(publicKey, signature, message),
"the signature does not verify under the address it was made for",
)
}
// ---- Fresh addresses -------------------------------------------------
// ---- Fresh addresses -------------------------------------------------.
/**
* A new address per receive is the point. Reusing one lets anybody
* watching a public chain group every payment somebody ever received, the
* one linkability a wallet can prevent, since the chain will not forget.
* A new address per receive is the point.
*/
@Test
fun `every index gives a different address`() {
@@ -109,11 +100,11 @@ class HdAccountTest {
)
}
// ---- Restoring -------------------------------------------------------
// ---- Restoring -------------------------------------------------------.
/**
* The whole promise of a recovery phrase: the same words give back the
* same addresses, on any device and any implementation.
* The whole promise of a recovery phrase: the same words give back the same
* addresses, on any device and any implementation.
*/
@Test
fun `the same phrase restores the same addresses`() {
@@ -129,7 +120,7 @@ class HdAccountTest {
assertNotEquals(account().receive(0).address, other.receive(0).address)
}
// ---- What an address is, and is not ----------------------------------
// ---- What an address is, and is not ----------------------------------.
/** An address is Base58 of 32 bytes, so it decodes back to the key. */
@Test
@@ -160,25 +151,19 @@ class HdAccountTest {
}
/**
* The limit, asserted as a passing test so nobody reads
* `looksLikeAddress` as stronger than it is.
*
* A Solana address carries NO checksum. A mistyped character that still
* decodes to 32 bytes is a perfectly valid address that nobody holds the
* key for, money sent there is gone, and neither this function nor the
* network will catch it. Bitcoin's Base58Check would; this cannot.
*
* The only real defences are pasting rather than typing, and showing the
* address back to be compared.
* The limit, asserted as a passing test so nobody reads `looksLikeAddress`
* as stronger than it is.
*/
@Test
fun `a corrupted address can still look valid, and that is not fixable here`() {
val real = account().receive(0).address
// Walk the alphabet at one position until the result still decodes to
// 32 bytes. On a checksummed format this search would fail.
val nearMiss = Base58.ALPHABET.asSequence()
.map { real.take(3) + it + real.drop(4) }
.firstOrNull { it != real && HdAccount.looksLikeAddress(it) }
// 32 bytes.
val nearMiss =
Base58.ALPHABET
.asSequence()
.map { real.take(3) + it + real.drop(4) }
.firstOrNull { it != real && HdAccount.looksLikeAddress(it) }
assertTrue(
nearMiss != null,
"no single-character change produced a still-valid address, if this " +
@@ -11,43 +11,62 @@ import kotlin.test.assertTrue
/**
* The history backup opens only with the right words, and merging never loses
* what a device already has. The dedupe rules are the part worth guarding:
* the same backup imported twice must add nothing the second time.
*
* These run on the JVM. The crypto here (PBKDF2, AES-GCM via the platform
* `Cipher`) is deliberately the same JCA surface the vault already exercises
* on Android, but per the Conscrypt rule, the on-device instrumentation
* suite is what proves the Android side.
* what a device already has.
*/
class HistoryExportTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun vault() = Vault(MapStorage(), key())
private fun line(text: String, mine: Boolean, at: Long): JSONObject =
JSONObject().put("author", 0).put("text", text).put("mine", mine).put("at", at)
private fun line(
text: String,
mine: Boolean,
at: Long,
): JSONObject =
JSONObject()
.put("author", 0)
.put("text", text)
.put("mine", mine)
.put("at", at)
private fun populated(): Vault {
val v = vault()
v.append("user:42", line("packing now", true, 1_756_400_000_000))
v.append("user:42", line("bring the charger", false, 1_756_400_060_000))
v.append("group:g1", line("meeting moved", false, 1_756_400_120_000))
v.saveCallLog(JSONArray().put(JSONObject()
.put("peer", 42).put("in", true).put("ok", true).put("at", 1_756_400_500_000)))
v.saveCallLog(
JSONArray().put(
JSONObject()
.put("peer", 42)
.put("in", true)
.put("ok", true)
.put("at", 1_756_400_500_000),
),
)
return v
}
// -- Sealing --------------------------------------------------------------
// -- Sealing
// --------------------------------------------------------------.
@Test
fun `a backup round trips with the right passphrase`() {
@@ -89,7 +108,8 @@ class HistoryExportTest {
assertTrue(!v.exportPayload().has("outbox"))
}
// -- Merging --------------------------------------------------------------
// -- Merging
// --------------------------------------------------------------.
@Test
fun `merging into an empty device takes everything`() {
@@ -105,9 +125,10 @@ class HistoryExportTest {
val device = vault()
device.append("user:42", line("sent after the export", false, 1_756_500_000_000))
device.mergeImportedPayload(populated().exportPayload(), nowSeconds = 0.0)
val texts = device.history("user:42").let { arr ->
(0 until arr.length()).map { arr.getJSONObject(it).getString("text") }
}
val texts =
device.history("user:42").let { arr ->
(0 until arr.length()).map { arr.getJSONObject(it).getString("text") }
}
assertEquals(listOf("packing now", "bring the charger", "sent after the export"), texts)
}
@@ -126,11 +147,21 @@ class HistoryExportTest {
@Test
fun `an expired story does not sneak back in through a backup`() {
val source = vault()
val items = JSONArray()
.put(JSONObject().put("id", "story-live").put("author", 42).put("at", 5)
.put("ref", JSONObject().put("exp", 0.0)))
.put(JSONObject().put("id", "story-dead").put("author", 42).put("at", 6)
.put("ref", JSONObject().put("exp", 100.0)))
val items =
JSONArray()
.put(
JSONObject()
.put("id", "story-live")
.put("author", 42)
.put("at", 5)
.put("ref", JSONObject().put("exp", 0.0)),
).put(
JSONObject()
.put("id", "story-dead")
.put("author", 42)
.put("at", 6)
.put("ref", JSONObject().put("exp", 100.0)),
)
// Write through the raw record shape the vault itself uses.
val payload = source.exportPayload().put("stories", JSONObject().put("items", items))
@@ -7,16 +7,9 @@ import kotlin.test.assertTrue
/**
* HKDF-SHA256 against RFC 5869's published test vectors.
*
* Deliberately the RFC's numbers and not ours. A third engine checked only
* against the first two learns whatever they both got wrong; checked against
* the document, it can disagree with them, which is the only way it is worth
* writing.
*/
class HkdfTest {
private fun hex(s: String): ByteArray =
s.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun hex(s: String): ByteArray = s.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
@@ -73,10 +66,7 @@ class HkdfTest {
}
/**
* An empty salt must mean HashLen zeros, not "no HMAC key". The distinction
* matters because `SecretKeySpec` throws on a zero-LENGTH key, so the naive
* version of this crashes on exactly the case §12 of the spec uses, the
* app derives its per-context secrets with an empty HKDF salt.
* An empty salt must mean HashLen zeros, not "no HMAC key".
*/
@Test
fun `empty salt is hashlen zeros, not an error`() {
@@ -98,9 +88,7 @@ class HkdfTest {
}
/**
* The counter is one byte starting at 1, so 255 blocks is the ceiling. A
* naive loop wraps at 256 and starts returning key material it has already
* returned, silently.
* The counter is one byte starting at 1, so 255 blocks is the ceiling.
*/
@Test
fun `refuses more than 255 blocks rather than repeating itself`() {
@@ -11,71 +11,65 @@ import kotlin.test.assertTrue
/**
* Two devices negotiating the hybrid handshake, end to end.
*
* A session is post-quantum only when both ends are. The primitive
* working proves nothing on its own: what matters is that a real registration
* publishes usable material, that a real peer picks it up, that the ciphertext
* survives the envelope, and that the responder finds the seed again. Any one
* of those failing leaves a device that advertises `proto: 2` and derives a
* different key from everybody, which surfaces as messages that will not
* decrypt and points nowhere near the cause.
*
* The other half of these tests is about what happens when the relay lies,
* because everything in a bundle passes through one.
*/
class HybridSessionTest {
/** A device that has published post-quantum material, as a peer sees it. */
private fun published(device: DeviceIdentity, withOneTime: Boolean = true): PeerBundle {
private fun published(
device: DeviceIdentity,
withOneTime: Boolean = true,
): PeerBundle {
val opk = device.generateOneTimePreKeys(1).first()
val wire = device.registrationWire()
val pqOtp = if (withOneTime) device.generatePQOneTimePreKeys(1).first() else null
val bundle = JSONObject()
.put("device_id", wire.getString("device_id"))
.put("ik_dh", wire.getString("ik_dh"))
.put("ik_sig", wire.getString("ik_sig"))
.put("spk_id", wire.getInt("spk_id"))
.put("spk", wire.getString("spk"))
.put("spk_sig", wire.getString("spk_sig"))
.put("proto", wire.getInt("proto"))
.put("caps_sig", wire.getString("caps_sig"))
.put("pqspk_id", wire.getInt("pqspk_id"))
.put("pqspk", wire.getString("pqspk"))
.put("pqspk_sig", wire.getString("pqspk_sig"))
.put("opk", JSONObject().put("id", opk.id).put("pub", Bytes.b64(opk.publicKey)))
val bundle =
JSONObject()
.put("device_id", wire.getString("device_id"))
.put("ik_dh", wire.getString("ik_dh"))
.put("ik_sig", wire.getString("ik_sig"))
.put("spk_id", wire.getInt("spk_id"))
.put("spk", wire.getString("spk"))
.put("spk_sig", wire.getString("spk_sig"))
.put("proto", wire.getInt("proto"))
.put("caps_sig", wire.getString("caps_sig"))
.put("pqspk_id", wire.getInt("pqspk_id"))
.put("pqspk", wire.getString("pqspk"))
.put("pqspk_sig", wire.getString("pqspk_sig"))
.put("opk", JSONObject().put("id", opk.id).put("pub", Bytes.b64(opk.publicKey)))
if (pqOtp != null) {
bundle.put("pqopk", JSONObject()
.put("id", pqOtp.id)
.put("pub", Bytes.b64(pqOtp.publicKey))
.put("sig", Bytes.b64(pqOtp.signature)))
bundle.put(
"pqopk",
JSONObject()
.put("id", pqOtp.id)
.put("pub", Bytes.b64(pqOtp.publicKey))
.put("sig", Bytes.b64(pqOtp.signature)),
)
}
return PeerBundle.fromWire(bundle)
}
/**
* A peer that has proven v2 and only v2, the released fleet.
*
* `copy(proto = 2)` alone would NOT do: the capability signature commits
* to the version, so a bundle whose proto was edited reads as v1 to every
* peer. It has to be re-signed for 2 to be a v2 device.
*/
private fun publishedV2(device: DeviceIdentity, withOneTime: Boolean = true): PeerBundle =
published(device, withOneTime).copy(
proto = 2, capabilitySignature = device.capabilitySignature(2))
private fun publishedV2(
device: DeviceIdentity,
withOneTime: Boolean = true,
): PeerBundle = published(device, withOneTime).copy(proto = 2, capabilitySignature = device.capabilitySignature(2))
/** A v1 device: exactly what this engine published before ML-KEM existed. */
private fun publishedV1(device: DeviceIdentity): PeerBundle {
val opk = device.generateOneTimePreKeys(1).first()
val wire = device.registrationWire(postQuantum = false)
return PeerBundle.fromWire(JSONObject()
.put("device_id", wire.getString("device_id"))
.put("ik_dh", wire.getString("ik_dh"))
.put("ik_sig", wire.getString("ik_sig"))
.put("spk_id", wire.getInt("spk_id"))
.put("spk", wire.getString("spk"))
.put("spk_sig", wire.getString("spk_sig"))
.put("proto", wire.getInt("proto"))
.put("opk", JSONObject().put("id", opk.id).put("pub", Bytes.b64(opk.publicKey))))
return PeerBundle.fromWire(
JSONObject()
.put("device_id", wire.getString("device_id"))
.put("ik_dh", wire.getString("ik_dh"))
.put("ik_sig", wire.getString("ik_sig"))
.put("spk_id", wire.getInt("spk_id"))
.put("spk", wire.getString("spk"))
.put("spk_sig", wire.getString("spk_sig"))
.put("proto", wire.getInt("proto"))
.put("opk", JSONObject().put("id", opk.id).put("pub", Bytes.b64(opk.publicKey))),
)
}
// MARK: - The happy path
@@ -86,8 +80,11 @@ class HybridSessionTest {
val bob = DeviceIdentity.create()
val session = E2ESession.initiate(alice, published(bob))
assertEquals(3, session.version,
"both ends implement the ratchet, so this must be v3")
assertEquals(
3,
session.version,
"both ends implement the ratchet, so this must be v3",
)
val envelope = session.encrypt("post-quantum hello")
assertNotNull(envelope.pq, "the KEM ciphertext has to travel with the handshake")
@@ -99,21 +96,17 @@ class HybridSessionTest {
val bobSide = E2ESession.respond(bob, delivered)
// 3, and asserting 2 here is what hid a real bug for weeks.
// The initiator is v3 two assertions above; a responder that answers
// the same handshake as v2 disagrees with its own peer about which
// protocol the session runs. Kotlin-to-Kotlin never noticed, because
// both ends drive the ratchet off their own PQ state and ignore the
// label, so this test round-tripped happily while encoding the defect.
//
// A v3 peer that does enforce it does notice: iOS refuses a ratchet
// step with no post-quantum material as a downgrade, so every message
// Android sent into an iOS-initiated session was dropped on arrival and
// the conversation ran one way only.
assertEquals(3, bobSide.version,
"answering a v3 handshake makes a v3 session, not a v2 one")
assertEquals(
3,
bobSide.version,
"answering a v3 handshake makes a v3 session, not a v2 one",
)
assertEquals("post-quantum hello", bobSide.decrypt(delivered))
assertContentEquals(session.associatedData, bobSide.associatedData,
"both sides must bind the same ciphertext digest")
assertContentEquals(
session.associatedData,
bobSide.associatedData,
"both sides must bind the same ciphertext digest",
)
// And the reply comes back on the same session.
val reply = bobSide.encrypt("and hello back")
@@ -121,10 +114,7 @@ class HybridSessionTest {
}
/**
* The last-resort key is a fallback, not a choice. Every session
* then shares one KEM key, which is a real weakening, but it is far better
* than dropping to v1 silently, which is what a device with no last-resort
* key would force.
* The last-resort key is a fallback, not a choice.
*/
@Test
fun `a drained one-time pool still gives a post-quantum session`() {
@@ -169,11 +159,7 @@ class HybridSessionTest {
// MARK: - What the relay is not allowed to do
/**
* The substitution this whole layer exists to prevent. A relay that
* can swap in an ML-KEM key of its own reads the post-quantum half of every
* new session, exactly what `spk_sig` stops classically. A key whose
* signature does not check out is treated as absent, so the session drops
* to v1 rather than to a key the relay chose.
* The substitution this whole layer exists to prevent.
*/
@Test
fun `a substituted post-quantum prekey is ignored, not used`() {
@@ -182,21 +168,25 @@ class HybridSessionTest {
val honest = published(bob)
val relaysOwnKey = PQKEM.generate()
val tampered = honest.copy(
pqOneTimePreKey = relaysOwnKey.publicKey,
pqSignedPreKey = relaysOwnKey.publicKey,
val tampered =
honest.copy(
pqOneTimePreKey = relaysOwnKey.publicKey,
pqSignedPreKey = relaysOwnKey.publicKey,
)
assertNull(
tampered.provenPostQuantumPreKey(),
"an unsigned KEM key must not be encapsulated to",
)
assertEquals(
1,
E2ESession.initiate(alice, tampered).version,
"and the session falls back to v1, not to the relay's key",
)
assertNull(tampered.provenPostQuantumPreKey(),
"an unsigned KEM key must not be encapsulated to")
assertEquals(1, E2ESession.initiate(alice, tampered).version,
"and the session falls back to v1, not to the relay's key")
}
/**
* A relay can also lie the other way, advertise a v2 device as v1 and
* quietly strip the post-quantum half off every new session. `caps_sig` is
* what makes the claim checkable; where the signature is missing the peer
* reads as v1, which is the safe reading of "we cannot tell".
* quietly strip the post-quantum half off every new session.
*/
@Test
fun `a bundle with no capability signature is treated as v1`() {
@@ -209,18 +199,17 @@ class HybridSessionTest {
fun `a capability signature does not transfer between versions`() {
val bob = DeviceIdentity.create()
// A version this engine does not claim, so the edit cannot be
// accidentally valid. This read `proto = 3` while the engine published
// 2; the constant moving is what makes a hardcoded neighbour like that
// stop testing anything.
// accidentally valid.
val forAnotherVersion = published(bob).copy(proto = DeviceIdentity.PROTOCOL_VERSION + 1)
assertNull(forAnotherVersion.provenPostQuantumPreKey(),
"the signature commits to the version it claims")
assertNull(
forAnotherVersion.provenPostQuantumPreKey(),
"the signature commits to the version it claims",
)
}
/**
* The one-time id is signed big-endian, and the id is inside the
* signature so a relay cannot renumber a key to make a peer spend the wrong
* one.
* The one-time id is signed big-endian, and the id is inside the signature
* so a relay cannot renumber a key to make a peer spend the wrong one.
*/
@Test
fun `a renumbered one-time prekey does not verify`() {
@@ -233,12 +222,6 @@ class HybridSessionTest {
/**
* The advertised version and the capability signature must agree.
*
* A peer verifies `Ed25519(ik_sig, "FlamenetE2E_Caps_v2" ‖ uint8(proto))`
* against the proto in the same bundle. Bumping the constant without the
* signature following it produces a bundle every peer silently reads as v1
* `provenPostQuantumPreKey()` returns null and the whole post-quantum
* path disappears, with nothing anywhere reporting an error.
*/
@Test
fun `the published bundle claims v3 and proves it`() {
@@ -250,24 +233,14 @@ class HybridSessionTest {
// that matters: it re-verifies the signature over the claimed version.
val seen = published(device)
assertEquals(3, seen.proto)
assertNotNull(seen.provenPostQuantumPreKey(),
"the capability signature does not cover the version claimed")
assertNotNull(
seen.provenPostQuantumPreKey(),
"the capability signature does not cover the version claimed",
)
}
/**
* A v2 peer must not be sent a v3 ratchet step.
*
* The released fleet advertises `proto: 2`: it does post-quantum key
* AGREEMENT and has no post-quantum ratchet. Attaching `pqdh`/`pqct` to a
* message for one of those devices breaks the conversation twice over,
* their root step derives without `ss` and under the `_v1` info string
* while ours used `_v3`, and their canonical header omits the two fields
* entirely so the AAD does not match either. The first message never opens
* and never will.
*
* Offering a PQ prekey is NOT the same claim as implementing §13. Every
* v2 device offers one. The version has to be what gates this, which is
* what §13.4 says and what the iOS engine already does.
*/
@Test
fun `a peer that only claims v2 is not sent a post-quantum ratchet step`() {
@@ -276,10 +249,14 @@ class HybridSessionTest {
// A real v2 bundle: PQ prekeys published, capability signed for 2.
val session = E2ESession.initiate(alice, publishedV2(bob))
val envelope = session.encrypt("hello")
assertNull(envelope.header.pqdh,
"a v2 peer was sent a v3 ratchet step it cannot read")
assertNull(envelope.header.pqct,
"a v2 peer was sent a PQ ciphertext it cannot decapsulate")
assertNull(
envelope.header.pqdh,
"a v2 peer was sent a v3 ratchet step it cannot read",
)
assertNull(
envelope.header.pqct,
"a v2 peer was sent a PQ ciphertext it cannot decapsulate",
)
}
// MARK: - Persistence
@@ -302,14 +279,15 @@ class HybridSessionTest {
/**
* A device archived before this engine had ML-KEM has no such block, and
* every existing install is one. It must load as a v1 device rather than
* fail, and publish post-quantum material the next time it registers.
* every existing install is one.
*/
@Test
fun `an archive from before post-quantum still loads`() {
val old = DeviceIdentity.create()
val archive = old.archive()
archive.remove("pqSpk"); archive.remove("pqOtp"); archive.remove("nextPQId")
archive.remove("pqSpk")
archive.remove("pqOtp")
archive.remove("nextPQId")
val loaded = DeviceIdentity.fromArchive(JSONObject(archive.toString()))
assertTrue(!loaded.hasPQMaterial)
@@ -319,11 +297,7 @@ class HybridSessionTest {
}
/**
* A prekey we already spent is not an error. A duplicate delivery of
* a v2 prekey envelope names a one-time key this device has consumed, and
* throwing there would turn a retransmission into a dead conversation. It
* simply cannot be v2, and then fails to decrypt, which the caller's three
* chains already handle.
* A prekey we already spent is not an error.
*/
@Test
fun `a replayed prekey envelope does not throw`() {
@@ -332,8 +306,7 @@ class HybridSessionTest {
val envelope = E2ESession.initiate(alice, published(bob)).encrypt("once")
assertEquals("once", E2ESession.respond(bob, envelope).decrypt(envelope))
// The one-time key is gone now. The second attempt must fail as a
// decrypt, not as an exception out of the handshake.
// The one-time key is gone now.
val second = E2ESession.respond(bob, envelope)
assertEquals(1, second.version, "no PQ seed left, so this cannot be v2")
assertFailsWith<Exception> { second.decrypt(envelope) }
@@ -357,9 +330,7 @@ class HybridSessionTest {
// MARK: - The wire
/**
* The v2 block is all-or-nothing, and the relay refuses a partial
* one. A device advertising `proto: 2` without a usable last-resort prekey
* would be picked for v2 by every peer and then fail every handshake.
* The v2 block is all-or-nothing, and the relay refuses a partial one.
*/
@Test
fun `a post-quantum registration publishes the whole block`() {
@@ -382,11 +353,7 @@ class HybridSessionTest {
}
/**
* The KEM ciphertext rides the prekey envelope and nothing after it. Every
* later message is byte-identical to v1, because the secret is already in
* the root key and the associated data binds the ciphertext for the life of
* the session (§12.6), republishing 1088 bytes per message would be pure
* cost.
* The KEM ciphertext rides the prekey envelope and nothing after it.
*/
@Test
fun `the ciphertext travels once, not on every message`() {
@@ -401,9 +368,7 @@ class HybridSessionTest {
assertNotNull(session.encrypt("two").pq)
val bobSide = E2ESession.respond(bob, first)
// Read it before replying. A responder that answers without
// decrypting has no receiving chain to step from, the same mistake a
// caller would make, so the test makes it once and not again.
// Read it before replying.
bobSide.decrypt(first)
session.decrypt(bobSide.encrypt("answered"))
assertNull(session.encrypt("three").pq, "answered, so the handshake stops repeating")
@@ -6,16 +6,15 @@ import kotlin.test.assertTrue
/**
* Audit M1: a log head is only evidence if its signature verifies under the
* PINNED issuer key. These pin the exact string the relay signs
* (`fnkt-v1|<size>|<root>`) so the two implementations cannot drift apart,
* a mismatch there would silently turn every head into "unverifiable".
* PINNED issuer key.
*/
class KeyLogHeadSignatureTest {
private val issuer = Ed25519.generate()
private fun sign(size: Int, root: String): ByteArray =
Ed25519.sign(issuer.seed, "fnkt-v1|$size|$root".toByteArray(Charsets.UTF_8))
private fun sign(
size: Int,
root: String,
): ByteArray = Ed25519.sign(issuer.seed, "fnkt-v1|$size|$root".toByteArray(Charsets.UTF_8))
@Test fun `a genuine head verifies`() {
val root = Bytes.b64(ByteArray(32) { 7 })
@@ -24,23 +23,29 @@ class KeyLogHeadSignatureTest {
@Test fun `a head signed for a different size is refused`() {
val root = Bytes.b64(ByteArray(32) { 7 })
assertFalse(KeyLog.verifyHead(13, root, sign(12, root), issuer.publicKey),
"a relay must not be able to replay a signature onto another size")
assertFalse(
KeyLog.verifyHead(13, root, sign(12, root), issuer.publicKey),
"a relay must not be able to replay a signature onto another size",
)
}
@Test fun `a head signed for a different root is refused`() {
val real = Bytes.b64(ByteArray(32) { 7 })
val forged = Bytes.b64(ByteArray(32) { 9 })
assertFalse(KeyLog.verifyHead(12, forged, sign(12, real), issuer.publicKey),
"swapping the root is exactly the fork this signature exists to catch")
assertFalse(
KeyLog.verifyHead(12, forged, sign(12, real), issuer.publicKey),
"swapping the root is exactly the fork this signature exists to catch",
)
}
@Test fun `a head signed by another key is refused`() {
val other = Ed25519.generate()
val root = Bytes.b64(ByteArray(32) { 7 })
val bySomeoneElse = Ed25519.sign(
other.seed, "fnkt-v1|12|$root".toByteArray(Charsets.UTF_8))
assertFalse(KeyLog.verifyHead(12, root, bySomeoneElse, issuer.publicKey),
"the PIN is what decides; a freshly served key must not substitute")
val bySomeoneElse =
Ed25519.sign(other.seed, "fnkt-v1|12|$root".toByteArray(Charsets.UTF_8))
assertFalse(
KeyLog.verifyHead(12, root, bySomeoneElse, issuer.publicKey),
"the PIN is what decides; a freshly served key must not substitute",
)
}
}
@@ -8,19 +8,11 @@ import kotlin.test.assertTrue
/**
* The security lattice, and the two Bell–LaPadula rules it exists to enforce.
*
* The encoding vectors were computed independently, not read off this
* implementation. The ten bytes are authenticated data for every construction
* that uses a label, so an encoding that differs between platforms does not warn
* it produces a decryption failure, or plaintext that quietly belongs to the
* wrong sensitivity. A vector taken from the code under test would pin whatever
* the code happens to do.
*/
class LabelTest {
private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) }
// ---- The encoding ----------------------------------------------------
// ---- The encoding ----------------------------------------------------.
/** `int16be classification || uint64be compartment mask`. */
@Test
@@ -32,10 +24,8 @@ class LabelTest {
}
/**
* A negative classification and a full compartment mask together, the
* case where a sign-extension mistake or an `Int` in place of a `Long`
* shows up. Both halves are all-ones, so a wrong shift produces something
* that still looks plausible.
* A negative classification and a full compartment mask together, the case
* where a sign-extension mistake or an `Int` in place of a `Long` shows up.
*/
@Test
fun `the extremes encode without sign-extension damage`() {
@@ -43,10 +33,7 @@ class LabelTest {
}
/**
* Bit 63 is the sign bit of a Long. Building the mask with an `Int`
* shift, or reading it back as one, loses it, and the compartment that
* vanishes is the one nobody notices until somebody sees a file they are
* not cleared for.
* Bit 63 is the sign bit of a Long.
*/
@Test
fun `the top compartment bit survives`() {
@@ -66,7 +53,7 @@ class LabelTest {
assertFailsWith<IllegalArgumentException> { Label(0, setOf(-1)) }
}
// ---- Domination ------------------------------------------------------
// ---- Domination ------------------------------------------------------.
@Test
fun `a higher classification dominates a lower one`() {
@@ -76,10 +63,7 @@ class LabelTest {
}
/**
* The failure that reads as working. An implementation that compares
* the classification and forgets the compartments behaves correctly on
* every test built from levels alone, right up until somebody sees a
* compartment they are not in.
* The failure that reads as working.
*/
@Test
fun `a higher level does NOT dominate a compartment it is not in`() {
@@ -96,8 +80,10 @@ class LabelTest {
val holder = Label(5, setOf(1, 2))
assertTrue(holder.dominates(Label(5, setOf(1))))
assertTrue(holder.dominates(Label(5, setOf(1, 2))))
assertFalse(holder.dominates(Label(5, setOf(1, 3))),
"an overlapping but incomplete set was treated as domination")
assertFalse(
holder.dominates(Label(5, setOf(1, 3))),
"an overlapping but incomplete set was treated as domination",
)
}
/** Two labels can be incomparable, neither dominates the other. */
@@ -115,7 +101,7 @@ class LabelTest {
assertTrue(Label(9, setOf(3)).dominates(Label.bottom))
}
// ---- Clearance: no read up -------------------------------------------
// ---- Clearance: no read up -------------------------------------------.
@Test
fun `a clearance reads what it dominates`() {
@@ -128,15 +114,14 @@ class LabelTest {
fun `a clearance cannot read above itself`() {
val cleared = Clearance(maximum = Label(5))
assertFalse(cleared.mayRead(Label(6)))
assertFalse(cleared.mayRead(Label(5, setOf(1))),
"a compartment outside the clearance was readable")
assertFalse(
cleared.mayRead(Label(5, setOf(1))),
"a compartment outside the clearance was readable",
)
}
/**
* The minimum is a real bound, not decoration. A label must sit
* INSIDE the range. Somebody cleared for sensitive work is deliberately not
* also holding the unclassified pile, which sounds like a convenience to
* remove until it is the reason an audit can say who could have seen what.
* The minimum is a real bound, not decoration.
*/
@Test
fun `a clearance with a floor cannot read below it`() {
@@ -145,26 +130,11 @@ class LabelTest {
assertFalse(cleared.mayRead(Label(1)), "a label below the floor was readable")
}
// ---- Writing ---------------------------------------------------------
// ---- Writing ---------------------------------------------------------.
/**
* Writing is gated by the same range check as reading, and the
* -property is NOT enforced, on either platform.
*
* Bell–LaPadula says somebody cleared to read sensitive material must not
* write *below* their clearance, because they would carry what they read
* down with them. iOS gates `ensureLabelKey` and `use` on
* `clearance.permits` and nothing else, so this matches it deliberately.
*
* Implementing the rule on Android alone would produce a file that saves on
* one phone and is refused on the other, with neither person able to tell
* which rule they had met, worse than the gap itself. Filed as android#15 /
* ios#65 so it is decided once, for both.
*
* This asserts the CURRENT behaviour, including the part that is a gap.
* It should fail loudly on the day somebody implements the -property,
* which is the point of writing the absence down rather than leaving it
* implicit.
* Writing is gated by the same range check as reading, and the -property is
* NOT enforced, on either platform.
*/
@Test
fun `writing uses the same range check as reading, star-property not enforced`() {
@@ -190,7 +160,9 @@ class LabelTest {
fun `a compartment outside the clearance cannot be written either`() {
val cleared = Clearance(maximum = Label(5, setOf(1)))
assertTrue(cleared.mayWrite(Label(5, setOf(1))))
assertFalse(cleared.mayWrite(Label(5, setOf(1, 2))),
"a compartment outside the clearance was writable")
assertFalse(
cleared.mayWrite(Label(5, setOf(1, 2))),
"a compartment outside the clearance was writable",
)
}
}
@@ -5,20 +5,8 @@ import kotlin.test.assertEquals
/**
* The salt for an account on a relay that keeps its own accounts.
*
* Pinned against the JavaScript engine, which is normative, the value
* below was produced by `engine/src/fnid.js`'s `localAccountSalt`, not by this
* implementation. A Kotlin test that asserts what Kotlin computes proves only
* that Kotlin is self-consistent; the failure this guards against is the three
* engines disagreeing, which surfaces as one person having two addresses
* depending on which phone they signed in from.
*
* Changing the expected value here is never the fix. It is a wire constant:
* a different salt is a different account key, and a different account key is
* a different person to everyone who pinned the old one.
*/
class LocalAccountSaltTest {
@Test
fun `matches the javascript engine`() {
assertEquals(
@@ -8,27 +8,31 @@ import kotlin.test.assertTrue
/**
* What the vault remembers about several mailboxes.
*
* Two of these keys are chosen by whoever writes to you. An envelope id
* comes from the sender, so an unbounded list of them is storage anybody on the
* federation can grow, and a relay name comes from a signed record, so a
* per-relay entry can be created by anyone who can get a record published. Both
* are bounded or scoped deliberately, and that is worth asserting rather than
* trusting to a comment.
*/
class MailboxStateTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private val storage = MapStorage()
/// Held so a SECOND vault can be opened over the same storage, which is
/// what a restart is, and the only way to test that anything persisted.
// / Held so a SECOND vault can be opened over the same storage, which is
// / what a restart is, and the only way to test that anything persisted.
private val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private val vault = Vault(storage, key)
@@ -45,11 +49,6 @@ class MailboxStateTest {
/**
* It survives a restart, or a copy at a backup home is read twice on the
* first poll after every launch.
*
* Written with one vault and read with a SECOND over the same storage,
* which is what a restart actually is. The first version of this test
* asserted that two objects were not the same object, which is true of any
* two objects and proves nothing about persistence.
*/
@Test
fun `what was seen survives reopening the vault`() {
@@ -74,9 +73,7 @@ class MailboxStateTest {
}
/**
* The key comes from whoever wrote to us. Oldest first, so a
* forgotten id can at worst show a very old message twice, far better than
* a list anybody can grow without limit.
* The key comes from whoever wrote to us.
*/
@Test
fun `the list of seen envelopes is bounded`() {
@@ -98,8 +95,7 @@ class MailboxStateTest {
/**
* Envelope ids come from each relay's own database, so id 40 there says
* nothing about id 40 here. One shared cursor would let the first mailbox's
* progress silently skip the second's mail.
* nothing about id 40 here.
*/
@Test
fun `each relay keeps its own watermark`() {
@@ -110,9 +106,9 @@ class MailboxStateTest {
}
/**
* A single global flag meant that signing in to a second relay which
* keeps no log read as the FIRST going quiet, a fork warning against an
* honest deployment, and every contact on it unreachable.
* A single global flag meant that signing in to a second relay which keeps
* no log read as the FIRST going quiet, a fork warning against an honest
* deployment, and every contact on it unreachable.
*/
@Test
fun `the served flag is per relay`() {
@@ -123,9 +119,9 @@ class MailboxStateTest {
}
/**
* The pin falls back to the pre-2026-08-30 global entry, so a phone
* already holding one keeps it rather than starting trust-on-first-use
* again, and a per-relay entry, once written, wins.
* The pin falls back to the pre-2026-08-30 global entry, so a phone already
* holding one keeps it rather than starting trust-on-first-use again, and a
* per-relay entry, once written, wins.
*/
@Test
fun `an existing global pin is inherited until a per-relay one is written`() {
@@ -134,24 +130,26 @@ class MailboxStateTest {
vault.saveKeyLogPin(Vault.KeyLogPin(9, "new-root"), "a.example.com")
assertEquals("new-root", vault.loadKeyLogPin("a.example.com")?.root)
assertEquals("old-root", vault.loadKeyLogPin("b.example.com")?.root,
"another relay still inherits the legacy pin, not this one")
assertEquals(
"old-root",
vault.loadKeyLogPin("b.example.com")?.root,
"another relay still inherits the legacy pin, not this one",
)
}
// MARK: - Extra mailboxes
/**
* A credential per relay, not one shared. An app password is minted
* by the relay that issued it and refused everywhere else, so a single
* stored credential authenticates at exactly one home and silently fails at
* the rest, which reads as "the backup mailbox never has anything in it".
* A credential per relay, not one shared.
*/
@Test
fun `each mailbox keeps its own credential and dial url`() {
vault.saveExtraHomes(listOf(
Vault.ExtraHome("a.example.com", "me", "pw-a"),
Vault.ExtraHome("b.example.com", "me", "pw-b", "http://127.0.0.1:9"),
))
vault.saveExtraHomes(
listOf(
Vault.ExtraHome("a.example.com", "me", "pw-a"),
Vault.ExtraHome("b.example.com", "me", "pw-b", "http://127.0.0.1:9"),
),
)
val homes = vault.extraHomes()
assertEquals(listOf("a.example.com", "b.example.com"), homes.map { it.relay })
@@ -171,8 +169,11 @@ class MailboxStateTest {
vault.forgetExtraHome("a.example.com")
assertTrue(vault.extraHomes().isEmpty())
assertEquals(0, vault.watermarkFor("a.example.com"),
"a re-added mailbox would resume mid-inbox and skip everything before it")
assertEquals(
0,
vault.watermarkFor("a.example.com"),
"a re-added mailbox would resume mid-inbox and skip everything before it",
)
}
@Test
@@ -1,54 +1,63 @@
package io.flamenet.e2e
import org.json.JSONObject
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* Finding a word somebody said, across every conversation on this device.
*
* There is no index, and that is the design. An index derived from
* decrypted history is plaintext by another name: it would have to be sealed as
* carefully as the vault, taken by `forgetEverything`, and kept consistent with
* the threads. This reads what already exists and throws the plaintext away,
* so nothing new comes to rest.
*/
class MessageSearchTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun vaultWith(vararg lines: Triple<String, String, Long>): Vault {
val vault = Vault(MapStorage(), key())
for ((threadId, text, at) in lines) {
vault.append(threadId, JSONObject()
.put("text", text).put("mine", false).put("at", at))
vault.append(
threadId,
JSONObject()
.put("text", text)
.put("mine", false)
.put("at", at),
)
}
return vault
}
private val now = 1_700_000_000_000L
// ---------------------------------------------------------------- finding
// ---------------------------------------------------------------- finding.
@Test
fun `it finds a message by its words`() {
val vault = vaultWith(
Triple(Vault.threadForUser(7), "meet at the cafe at six", now - 3000),
Triple(Vault.threadForUser(7), "bring the keys", now - 2000),
)
val vault =
vaultWith(
Triple(Vault.threadForUser(7), "meet at the cafe at six", now - 3000),
Triple(Vault.threadForUser(7), "bring the keys", now - 2000),
)
val hits = MessageSearch.hits("keys", vault)
assertEquals(1, hits.size)
assertEquals("bring the keys", hits.first().text)
@@ -56,17 +65,16 @@ class MessageSearchTest {
@Test
fun `matching is case insensitive`() {
val vault = vaultWith(
Triple(Vault.threadForUser(7), "CAFE is closed", now),
Triple(Vault.threadForUser(7), "the cafe again", now - 1000),
)
val vault =
vaultWith(
Triple(Vault.threadForUser(7), "CAFE is closed", now),
Triple(Vault.threadForUser(7), "the cafe again", now - 1000),
)
assertEquals(2, MessageSearch.hits("cafe", vault).size)
}
/**
* Somebody typing `cafe` means `café`. A search that knows the
* difference quietly fails for every language with accents in it, which is
* most of them, and this app ships in Spanish next.
* Somebody typing `cafe` means `café`.
*/
@Test
fun `matching ignores accents`() {
@@ -76,15 +84,15 @@ class MessageSearchTest {
}
/**
* A plain substring, never a regular expression. A stray bracket typed
* into a search box is a crash or a hang, and nobody meant a pattern.
* A plain substring, never a regular expression.
*/
@Test
fun `the query is not a pattern`() {
val vault = vaultWith(
Triple(Vault.threadForUser(7), "the answer is (42)", now),
Triple(Vault.threadForUser(7), "unrelated", now - 1000),
)
val vault =
vaultWith(
Triple(Vault.threadForUser(7), "the answer is (42)", now),
Triple(Vault.threadForUser(7), "unrelated", now - 1000),
)
assertEquals(1, MessageSearch.hits("(42)", vault).size)
// `.*` matches everything as a regex and nothing as a substring.
assertTrue(MessageSearch.hits(".*", vault).isEmpty())
@@ -93,32 +101,37 @@ class MessageSearchTest {
/** It reaches across every conversation, not just the open one. */
@Test
fun `it searches every thread`() {
val vault = vaultWith(
Triple(Vault.threadForUser(7), "hello there", now - 3000),
Triple(Vault.threadForGroup("g1"), "hello all", now - 2000),
Triple(Vault.threadForAddress("ada@other.example"), "hello afar", now - 1000),
)
val vault =
vaultWith(
Triple(Vault.threadForUser(7), "hello there", now - 3000),
Triple(Vault.threadForGroup("g1"), "hello all", now - 2000),
Triple(Vault.threadForAddress("ada@other.example"), "hello afar", now - 1000),
)
assertEquals(3, MessageSearch.hits("hello", vault).size)
assertEquals(
setOf(Vault.threadForUser(7), Vault.threadForGroup("g1"),
Vault.threadForAddress("ada@other.example")),
MessageSearch.hits("hello", vault).map { it.threadId }.toSet())
setOf(
Vault.threadForUser(7),
Vault.threadForGroup("g1"),
Vault.threadForAddress("ada@other.example"),
),
MessageSearch.hits("hello", vault).map { it.threadId }.toSet(),
)
}
// ---------------------------------------------------------------- what it must not do
// ---------------------------------------------------------------- what it
// must not do.
/**
* A short query finds NOTHING, never everything. Returning the whole
* history the moment somebody focuses the search box would put every
* message they have on one screen, and here it would decrypt every thread
* on the device to do it.
* A short query finds NOTHING, never everything.
*/
@Test
fun `a query below the minimum finds nothing`() {
val vault = vaultWith(Triple(Vault.threadForUser(7), "anything at all", now))
for (tooShort in listOf("", " ", "a", " x ")) {
assertTrue(MessageSearch.hits(tooShort, vault).isEmpty(),
"\"$tooShort\" searched anyway")
assertTrue(
MessageSearch.hits(tooShort, vault).isEmpty(),
"\"$tooShort\" searched anyway",
)
}
}
@@ -134,20 +147,24 @@ class MessageSearchTest {
fun `results are bounded and keep the newest`() {
val vault = Vault(MapStorage(), key())
for (i in 1..200) {
vault.append(Vault.threadForUser(7), JSONObject()
.put("text", "needle $i").put("mine", false).put("at", now - i * 1000L))
vault.append(
Vault.threadForUser(7),
JSONObject()
.put("text", "needle $i")
.put("mine", false)
.put("at", now - i * 1000L),
)
}
val hits = MessageSearch.hits("needle", vault, limit = 3)
assertEquals(3, hits.size)
assertEquals(listOf("needle 1", "needle 2", "needle 3"), hits.map { it.text })
}
// ---------------------------------------------------------------- the snippet
// ---------------------------------------------------------------- the
// snippet.
/**
* Bounded on both sides. A whole message means one long one pushes every
* other result off the screen; only the start means a match near the end
* looks like no match at all.
* Bounded on both sides.
*/
@Test
fun `the snippet is centred on the match`() {
@@ -167,15 +184,15 @@ class MessageSearchTest {
/**
* Folding has to be length-preserving, because the snippet slices the
* ORIGINAL string at an index found in the folded one. A fold that dropped
* or added characters would cut in the wrong place, and for an accented
* character, mid-character.
* ORIGINAL string at an index found in the folded one.
*/
@Test
fun `the snippet is correct when the text has accents before the match`() {
val text = "café café café needle here"
val snippet = MessageSearch.snippet(text, "needle")
assertTrue(snippet.contains("needle"),
"accents shifted the slice off the match: $snippet")
assertTrue(
snippet.contains("needle"),
"accents shifted the slice off the match: $snippet",
)
}
}
@@ -9,16 +9,8 @@ import kotlin.test.assertTrue
/**
* ML-KEM-768, the half of the hybrid handshake Android did not have.
*
* A session is post-quantum only when both ends are. Every Android
* device registered `proto: 1`, so iPhone's implementation protected no real
* conversation, the property was true of the code and false of the product.
* These tests are about the things that would let that keep being true while
* looking fixed: a seed that round-trips to a different key, a size that is
* wrong in a way nothing notices until a peer cannot decrypt.
*/
class PQKEMTest {
@Test
fun `a fresh keypair has the sizes FIPS 203 fixes`() {
val pair = PQKEM.generate()
@@ -37,12 +29,7 @@ class PQKEMTest {
}
/**
* The seed is the whole persistence story. Only 64 bytes go into the
* vault, and both the private key and the published public half are
* re-derived from them on every load, so a seed that produced a different
* public key on the second derivation would mean a device publishing a key
* it cannot decapsulate with, and every peer who used it silently
* unreachable.
* The seed is the whole persistence story.
*/
@Test
fun `a seed derives the same keypair every time`() {
@@ -57,11 +44,6 @@ class PQKEMTest {
/**
* The seed path and the library's own generator must build the same key.
*
* Not a tautology. `fromSeed` reconstructs a private key from `d ‖ z` by
* a different route than `generateKeyPair` takes, and a disagreement
* between the two would show up only as a peer on another implementation
* being unable to talk to us.
*/
@Test
fun `the seed constructor matches the library's own generator`() {
@@ -76,12 +58,9 @@ class PQKEMTest {
}
/**
* Decapsulation does not fail on a tampered ciphertext, by design:
* ML-KEM's implicit rejection returns a well-formed but different secret
* rather than an error. Asserted rather than assumed, because every caller
* has to be written for it, the mismatch surfaces as the first message
* failing to decrypt, which is why §12.5 also binds the ciphertext into the
* session's associated data.
* Decapsulation does not fail on a tampered ciphertext, by design: ML-KEM's
* implicit rejection returns a well-formed but different secret rather than
* an error.
*/
@Test
fun `a tampered ciphertext yields a different secret, not an error`() {
@@ -98,14 +77,15 @@ class PQKEMTest {
fun `the wrong seed opens nothing`() {
val bob = PQKEM.generate()
val out = PQKEM.encapsulate(bob.publicKey)
assertFalse(PQKEM.decapsulate(out.ciphertext, PQKEM.generate().seed)
.contentEquals(out.sharedSecret))
assertFalse(
PQKEM
.decapsulate(out.ciphertext, PQKEM.generate().seed)
.contentEquals(out.sharedSecret),
)
}
/**
* Sizes are checked at the door. A wrong length can only ever fail on every
* peer, and it fails far away from here, so it is named where it happens
* rather than discovered as a contact who stopped working.
* Sizes are checked at the door.
*/
@Test
fun `wrong sizes are refused where they can still be explained`() {
@@ -9,74 +9,57 @@ import kotlin.test.assertNotEquals
/**
* The hybrid handshake against the published known-answer vector.
*
* This is the only test here worth much. Every other assertion about
* ML-KEM in this module compares Bouncy Castle to itself: seeds round-trip,
* ciphertexts open, sizes are right, all true of an implementation that is
* internally consistent and agrees with nobody. The vector was generated by the
* normative JavaScript engine and is replayed by the Swift one, so matching it
* is the claim that actually matters: a phone can start a post-quantum session
* with an iPhone, and neither has to take our word for it.
*
* The specific thing at risk is the seed. FIPS 203 keygen takes `d ‖ z`,
* and this engine stores only those 64 bytes and re-derives everything from
* them, so if BC ordered or expanded them differently from CryptoKit, every
* cross-platform session would fail at the first message with nothing pointing
* at the cause. The vector settles it byte for byte.
*/
class PQVectorTest {
private val vector: JSONObject by lazy {
// Resolved rather than hardcoded, for the same reason PublishedVectorsTest
// does it: this module lives both as a subtree of the app repo and as a
// repo of its own beside the engine.
//
// Fails rather than skips when nothing is found. A conformance test
// that quietly stops conforming is worth less than no test at all,
// the file listing still says the handshake is checked.
val candidates = listOfNotNull(
System.getenv("FLAMENET_PQ_VECTORS"),
"../../engine/test/vectors/pqx3dh-v2.json",
"../../../flamenet-e2e/test/vectors/pqx3dh-v2.json",
"../../flamenet-e2e/test/vectors/pqx3dh-v2.json",
)
val found = candidates.map(::File).firstOrNull { it.isFile }
?: error("no pqx3dh-v2.json found; looked in $candidates. " +
"Set FLAMENET_PQ_VECTORS to point at it.")
// Resolved rather than hardcoded, for the same reason
// PublishedVectorsTest does it: this module lives both as a subtree of
// the app repo and as a repo of its own beside the engine.
val candidates =
listOfNotNull(
System.getenv("FLAMENET_PQ_VECTORS"),
"../../engine/test/vectors/pqx3dh-v2.json",
"../../../flamenet-e2e/test/vectors/pqx3dh-v2.json",
"../../flamenet-e2e/test/vectors/pqx3dh-v2.json",
)
val found =
candidates.map(::File).firstOrNull { it.isFile }
?: error(
"no pqx3dh-v2.json found; looked in $candidates. " +
"Set FLAMENET_PQ_VECTORS to point at it.",
)
JSONObject(found.readText())
}
private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) }
/**
* The cross-implementation check. The vector publishes a seed and
* the public key the normative engine derived from it; deriving a different
* one here is the failure that would make every Android↔iPhone post-quantum
* session fail on the first message, and nowhere near this code.
* The cross-implementation check.
*/
@Test
fun `a published seed derives the published public key`() {
val archive = vector.getJSONObject("bobArchive")
for (which in listOf("pqSpk", "pqOtp")) {
val entry = if (which == "pqSpk") archive.getJSONObject("pqSpk")
else archive.getJSONObject("pqOtp").getJSONObject("1")
val entry =
if (which == "pqSpk") {
archive.getJSONObject("pqSpk")
} else {
archive.getJSONObject("pqOtp").getJSONObject("1")
}
val seed = Bytes.unb64(entry.getString("seed"))
val published = Bytes.unb64(entry.getString("pub"))
assertEquals(PQKEM.SEED_LEN, seed.size, "$which seed is d‖z")
assertContentEquals(published, PQKEM.fromSeed(seed).publicKey,
"$which: this engine derives a different key from the same seed")
assertContentEquals(
published,
PQKEM.fromSeed(seed).publicKey,
"$which: this engine derives a different key from the same seed",
)
}
}
/**
* The whole responder side, end to end, against the published answer.
*
* Everything is exercised at once and that is the point: the four
* agreements, their order, the KEM secret going last, the v2 info string,
* and the associated data. A vector that only checked the KEM would pass
* while the concatenation order was wrong, and the two engines would still
* never talk.
*/
@Test
fun `the responder derives the published session key and associated data`() {
@@ -89,24 +72,29 @@ class PQVectorTest {
val ciphertext = Bytes.unb64(env.getJSONObject("pq").getString("ct"))
val pqSeed = Bytes.unb64(archive.getJSONObject("pqOtp").getJSONObject("1").getString("seed"))
assertEquals("opk", env.getJSONObject("pq").getString("kind"),
"this vector uses the one-time PQ prekey")
val sk = X3DH.respond(
identityPrivateDH = priv(archive.getJSONObject("ikDH")),
signedPreKeyPrivate = priv(archive.getJSONObject("spk")),
oneTimePrivate = priv(archive.getJSONObject("otp").getJSONObject("1")),
initiatorIdentityDH = Bytes.unb64(x3dh.getString("ik_dh")),
initiatorEphemeral = Bytes.unb64(x3dh.getString("ek")),
pq = pqSeed to ciphertext,
assertEquals(
"opk",
env.getJSONObject("pq").getString("kind"),
"this vector uses the one-time PQ prekey",
)
val sk =
X3DH.respond(
identityPrivateDH = priv(archive.getJSONObject("ikDH")),
signedPreKeyPrivate = priv(archive.getJSONObject("spk")),
oneTimePrivate = priv(archive.getJSONObject("otp").getJSONObject("1")),
initiatorIdentityDH = Bytes.unb64(x3dh.getString("ik_dh")),
initiatorEphemeral = Bytes.unb64(x3dh.getString("ek")),
pq = pqSeed to ciphertext,
)
assertEquals(expect.getString("sk"), hex(sk))
val ad = X3DH.associatedData(
first = Bytes.unb64(x3dh.getString("ik_sig")),
second = Bytes.unb64(archive.getJSONObject("ikSig").getString("pub")),
pqCiphertext = ciphertext,
)
val ad =
X3DH.associatedData(
first = Bytes.unb64(x3dh.getString("ik_sig")),
second = Bytes.unb64(archive.getJSONObject("ikSig").getString("pub")),
pqCiphertext = ciphertext,
)
assertEquals(expect.getString("ad"), hex(ad))
}
@@ -114,8 +102,7 @@ class PQVectorTest {
* A downgrade must not produce a key the other side accepts. v1's
* `DH1‖DH2‖DH3‖DH4` and v2's `DH1‖DH2‖DH3‖SS` are both 128 bytes, so
* without the info string changing with the version they are
* indistinguishable KDF inputs (§12.5). Asserted against the published
* answer rather than against another run of our own code.
* indistinguishable KDF inputs (§12.5).
*/
@Test
fun `running the same handshake without the KEM derives a different key`() {
@@ -124,20 +111,20 @@ class PQVectorTest {
fun priv(path: JSONObject) = X25519.privateKeyFromPkcs8(Bytes.unb64(path.getString("priv")))
val classical = X3DH.respond(
identityPrivateDH = priv(archive.getJSONObject("ikDH")),
signedPreKeyPrivate = priv(archive.getJSONObject("spk")),
oneTimePrivate = priv(archive.getJSONObject("otp").getJSONObject("1")),
initiatorIdentityDH = Bytes.unb64(x3dh.getString("ik_dh")),
initiatorEphemeral = Bytes.unb64(x3dh.getString("ek")),
)
val classical =
X3DH.respond(
identityPrivateDH = priv(archive.getJSONObject("ikDH")),
signedPreKeyPrivate = priv(archive.getJSONObject("spk")),
oneTimePrivate = priv(archive.getJSONObject("otp").getJSONObject("1")),
initiatorIdentityDH = Bytes.unb64(x3dh.getString("ik_dh")),
initiatorEphemeral = Bytes.unb64(x3dh.getString("ek")),
)
assertNotEquals(vector.getJSONObject("expect").getString("sk"), hex(classical))
}
/**
* The associated data binds the ciphertext, so a relay that swapped one
* cannot leave the session looking untouched. ML-KEM's implicit rejection
* means decapsulation itself will not complain.
* cannot leave the session looking untouched.
*/
@Test
fun `changing one byte of the ciphertext changes the associated data`() {
@@ -1,5 +1,6 @@
package io.flamenet.e2e
import org.json.JSONObject
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
@@ -7,32 +8,34 @@ import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* A timer on one message rather than the whole conversation (§6.17).
*
* The rule that keeps this from making the product worse is that the
* shorter timer always wins. A per-message timer may only take life away,
* never add it. Without that, a sender could defeat the recipient's
* conversation timer by putting a long one on every message, turning a setting
* the recipient chose, on their own device, into something the other party gets
* to overrule.
*/
class PerMessageTimerTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
// ---------------------------------------------------------------- whose timer wins
// ---------------------------------------------------------------- whose
// timer wins.
@Test
fun `a message timer applies when the conversation has none`() {
@@ -45,13 +48,15 @@ class PerMessageTimerTest {
}
/**
* The one that matters. A message asking to live a week, in a
* conversation set to an hour, lives an hour.
* The one that matters.
*/
@Test
fun `a longer message timer cannot extend a message's life`() {
assertEquals(3600, Vault.effectiveLifetime(message = 604_800, conversation = 3600),
"a sender overruled the recipient's own conversation timer")
assertEquals(
3600,
Vault.effectiveLifetime(message = 604_800, conversation = 3600),
"a sender overruled the recipient's own conversation timer",
)
}
@Test
@@ -65,8 +70,7 @@ class PerMessageTimerTest {
}
/**
* Zero means "no timer", not "expire immediately". Reading it the other
* way would delete a message the instant it arrived.
* Zero means "no timer", not "expire immediately".
*/
@Test
fun `a negative timer is ignored rather than expiring immediately`() {
@@ -74,19 +78,27 @@ class PerMessageTimerTest {
assertEquals(3600, Vault.effectiveLifetime(message = -1, conversation = 3600))
}
// ---------------------------------------------------------------- pruning
// ---------------------------------------------------------------- pruning.
private fun store(vault: Vault, threadId: String, at: Long, secs: Int, text: String) {
vault.append(threadId, JSONObject()
.put("text", text).put("mine", false).put("at", at)
.also { if (secs > 0) it.put("secs", secs) })
private fun store(
vault: Vault,
threadId: String,
at: Long,
secs: Int,
text: String,
) {
vault.append(
threadId,
JSONObject()
.put("text", text)
.put("mine", false)
.put("at", at)
.also { if (secs > 0) it.put("secs", secs) },
)
}
/**
* The bug the old code had. Pruning returned early when the
* CONVERSATION had no timer, so a timed message in an untimed thread was
* kept for ever, the feature would have looked like it worked on the
* sending phone and done nothing on the receiving one.
* The bug the old code had.
*/
@Test
fun `a timed message expires even when the conversation has no timer`() {
@@ -125,31 +137,36 @@ class PerMessageTimerTest {
vault.saveDisappearSeconds(thread, 60)
store(vault, thread, at = now - 120_000, secs = 604_800, text = "asked to live a week")
assertEquals(0, vault.pruneExpired(thread, now).length(),
"a sender overruled the recipient's conversation timer")
assertEquals(
0,
vault.pruneExpired(thread, now).length(),
"a sender overruled the recipient's conversation timer",
)
}
/**
* A message with no timestamp predates the field. Kept rather than
* destroyed: an unknown age is not evidence of an old one, and deleting
* somebody's history on a guess is unrecoverable.
* A message with no timestamp predates the field.
*/
@Test
fun `a message with no timestamp is kept`() {
val vault = Vault(MapStorage(), key())
val thread = Vault.threadForUser(7)
vault.append(thread, JSONObject().put("text", "ancient").put("mine", false)
.put("secs", 60))
vault.append(
thread,
JSONObject()
.put("text", "ancient")
.put("mine", false)
.put("secs", 60),
)
assertEquals(1, vault.pruneExpired(thread, 1_000_000_000L).length())
}
// ---------------------------------------------------------------- the wire
// ---------------------------------------------------------------- the
// wire.
/**
* A duration, never a deadline. An absolute expiry would be the
* sender's clock: they could backdate a message so it vanished on arrival,
* or forward-date one so it never expired.
* A duration, never a deadline.
*/
@Test
fun `the envelope carries a duration not a deadline`() {
@@ -160,9 +177,9 @@ class PerMessageTimerTest {
}
/**
* `Content.text` returns raw bytes when it has nothing to carry, so a
* timer that was not counted in that decision would be dropped and the
* message would live for ever.
* `Content.text` returns raw bytes when it has nothing to carry, so a timer
* that was not counted in that decision would be dropped and the message
* would live for ever.
*/
@Test
fun `a timed message always gets an envelope`() {
@@ -176,20 +193,23 @@ class PerMessageTimerTest {
}
/**
* A conversation-timer announcement carries `secs` too and means
* something entirely different. Reading both off one field is why this
* checks the kind.
* A conversation-timer announcement carries `secs` too and means something
* entirely different.
*/
@Test
fun `a conversation timer announcement is not a timed message`() {
val announcement = Content.parse(Content.disappearing(3600))
assertNull(announcement.lifetimeSeconds,
"a conversation timer was read as one message's own")
assertNull(
announcement.lifetimeSeconds,
"a conversation timer was read as one message's own",
)
assertEquals(3600, Content.disappearSeconds(announcement))
val timed = Content.parse(Content.text("meet at 6", lifetimeSeconds = 60))
assertNull(Content.disappearSeconds(timed),
"a timed message was read as setting the whole conversation")
assertNull(
Content.disappearSeconds(timed),
"a timed message was read as setting the whole conversation",
)
}
@Test
@@ -8,17 +8,8 @@ import kotlin.test.assertTrue
/**
* Pinning a message in a conversation (§6.19).
*
* The codec half. The Android app does not pin yet, but it must already
* understand an envelope the iPhone app sends, and in particular must not draw
* one as a message, which is what an unrecognised pin becomes.
*
* A pin is an id and never a copy of the message. A pin carrying the text
* would keep a message readable after its own timer had removed it, so pinning
* would quietly defeat disappearing messages.
*/
class PinTest {
@Test
fun `a pin names the message it pins`() {
val content = Content.parse(Content.pin("abc"))
@@ -33,9 +24,7 @@ class PinTest {
}
/**
* The bug this prevents. An unrecognised pin falls through `parse` to
* the attachment path and comes back renderable, an empty bubble in
* somebody's conversation every time the other side pins something.
* The bug this prevents.
*/
@Test
fun `a pin is not a message`() {
@@ -46,14 +35,15 @@ class PinTest {
}
/**
* Not swallowed as generic control either. A build that consumed it
* silently would drop every pin while looking like it worked.
* Not swallowed as generic control either.
*/
@Test
fun `a pin is a known kind, not unknown control`() {
assertTrue(Content.Kind.PIN in Content.Kind.KNOWN)
assertTrue(Content.Kind.PIN !in Content.Kind.CONTROL,
"a pin swallowed as control would drop silently")
assertTrue(
Content.Kind.PIN !in Content.Kind.CONTROL,
"a pin swallowed as control would drop silently",
)
}
/** The envelope carries no copy of the message, only its id. */
@@ -12,33 +12,19 @@ import kotlin.test.assertTrue
/**
* The Kotlin engine against the published vectors.
*
* These are the same bytes the JavaScript and Swift engines are held to, read
* from the one file rather than copied here, a vector transcribed into the
* test that checks it is a vector that agrees with itself.
*
* This is the point of starting Android with the engine. A third
* implementation is only worth anything if it can be checked against something
* other than a reading of our own code.
*/
class PublishedVectorsTest {
private val vectors: JSONObject by lazy {
// Where the vectors are depends on which checkout this is: android/ as a
// subtree of the app repo, or the extracted Android repo with
// flamenet-e2e cloned beside it. Resolved rather than assumed, because a
// hardcoded depth is how the harness stops testing anything.
//
// This FAILS rather than skips when it finds nothing, deliberately: the
// whole reason a third engine is worth writing is that it is checked
// against the published vectors, and one that quietly stops checking is
// worth less than no third engine at all.
val candidates = listOfNotNull(
System.getenv("FLAMENET_VECTORS"),
"../../engine/test/vectors/protocol-v1.json", // app repo subtree
"../../../flamenet-e2e/test/vectors/protocol-v1.json", // beside the engine repo
"../../flamenet-e2e/test/vectors/protocol-v1.json",
).map { File(it) }
// Where the vectors are depends on which checkout this is: android/ as
// a subtree of the app repo, or the extracted Android repo with
// flamenet-e2e cloned beside it.
val candidates =
listOfNotNull(
System.getenv("FLAMENET_VECTORS"),
"../../engine/test/vectors/protocol-v1.json", // app repo subtree
"../../../flamenet-e2e/test/vectors/protocol-v1.json", // beside the engine repo
"../../flamenet-e2e/test/vectors/protocol-v1.json",
).map { File(it) }
val file = candidates.firstOrNull { it.exists() }
assertTrue(
file != null,
@@ -87,7 +73,10 @@ class PublishedVectorsTest {
@Test
fun `account identity encodes exactly as published`() {
val ai = vectors.getJSONObject("accountIdentity")
val key = java.util.Base64.getDecoder().decode(ai.getString("accountKeyB64"))
val key =
java.util.Base64
.getDecoder()
.decode(ai.getString("accountKeyB64"))
assertEquals(ai.getString("encoded"), FNID.encode(key))
assertEquals(ai.getString("address"), FNID.address(key))
@@ -112,13 +101,14 @@ class PublishedVectorsTest {
val leaf = vectors.getJSONObject("keyLog").getJSONObject("canonicalLeaf")
val input = leaf.getJSONObject("input")
val encoded = KeyLog.canonicalLeaf(
kind = input.getString("kind"),
subject = input.getString("subject"),
deviceId = input.getString("deviceId"),
ikSigB64 = input.getString("ikSigB64"),
ikDhB64 = input.getString("ikDhB64"),
)
val encoded =
KeyLog.canonicalLeaf(
kind = input.getString("kind"),
subject = input.getString("subject"),
deviceId = input.getString("deviceId"),
ikSigB64 = input.getString("ikSigB64"),
ikDhB64 = input.getString("ikDhB64"),
)
assertEquals(leaf.getString("encoded"), String(encoded, Charsets.UTF_8))
assertEquals(leaf.getString("leafHash"), Bytes.b64(KeyLog.leafHash(encoded)))
}
@@ -163,10 +153,7 @@ class PublishedVectorsTest {
val proof = inc.getJSONArray("proof").let { a -> (0 until a.length()).map { a.bytesAt(it) } }
val root = Bytes.unb64(inc.getString("root"))
// The audit path is bottom-up. Reading it top-down still produces the
// right root for any left-spine index, so this assertion, at a
// right-descending index, with the path reversed, is the one that
// actually catches the mistake.
// The audit path is bottom-up.
assertFalse(
KeyLog.verifyInclusion(leaf, inc.getInt("index"), inc.getInt("size"), proof.reversed(), root),
"a reversed audit path verified, so the path order is not being honoured",
@@ -203,7 +190,7 @@ class PublishedVectorsTest {
assertEquals(8, KeyLog.split(9))
}
// -- §19 consistency -----------------------------------------------------
// -- §19 consistency -----------------------------------------------------.
@Test
fun `a consistency proof verifies between every pair of prefix sizes`() {
@@ -211,16 +198,15 @@ class PublishedVectorsTest {
val leaves = keyLog.getJSONArray("leafHashes")
val all = (0 until leaves.length()).map { leaves.bytesAt(it) }
// Every pair, not a sample. The traversal has a special case when the
// old size is a power of two, the old root leads the proof implicitly
// rather than being transmitted, so an implementation that gets that
// wrong still passes at 3 -> 5 and fails at 2 -> 5.
// Every pair, not a sample.
for (m in 1..all.size) {
for (n in m..all.size) {
val proof = consistencyProof(all, m, n)
assertTrue(
KeyLog.verifyConsistency(
from = m, to = n, proof = proof,
from = m,
to = n,
proof = proof,
oldRoot = rootOf(all.subList(0, m)),
newRoot = rootOf(all.subList(0, n)),
),
@@ -236,7 +222,9 @@ class PublishedVectorsTest {
val all = (0 until leaves.length()).map { leaves.bytesAt(it) }
assertFalse(
KeyLog.verifyConsistency(
from = 4, to = 2, proof = consistencyProof(all, 2, 4),
from = 4,
to = 2,
proof = consistencyProof(all, 2, 4),
oldRoot = rootOf(all.subList(0, 4)),
newRoot = rootOf(all.subList(0, 2)),
),
@@ -246,11 +234,6 @@ class PublishedVectorsTest {
/**
* The attack this whole mechanism exists to stop.
*
* A relay that rewrites history can still issue perfectly valid INCLUSION
* proofs against its new tree, they are honest proofs about a dishonest
* log. What it cannot do is reconcile the new tree with one a client
* already pinned. If this test ever passes, the log is decorative.
*/
@Test
fun `a rewritten log cannot be reconciled with a pinned root`() {
@@ -263,10 +246,11 @@ class PublishedVectorsTest {
assertFalse(
KeyLog.verifyConsistency(
from = 2, to = honest.size,
from = 2,
to = honest.size,
proof = consistencyProof(rewritten, 2, rewritten.size),
oldRoot = rootOf(honest.subList(0, 2)), // what we pinned
newRoot = rootOf(rewritten), // what it serves now
oldRoot = rootOf(honest.subList(0, 2)), // what we pinned
newRoot = rootOf(rewritten), // what it serves now
),
"a substituted leaf must break consistency against the pinned root",
)
@@ -280,7 +264,9 @@ class PublishedVectorsTest {
proof[0] = KeyLog.leafHash("not the sibling".toByteArray())
assertFalse(
KeyLog.verifyConsistency(
from = 2, to = 5, proof = proof,
from = 2,
to = 5,
proof = proof,
oldRoot = rootOf(all.subList(0, 2)),
newRoot = rootOf(all),
),
@@ -314,11 +300,6 @@ class PublishedVectorsTest {
/**
* The reference tree, built here rather than in the engine.
*
* [KeyLog] verifies and cannot construct, on purpose. The test needs a
* builder to check the published roots, so it carries its own, which also
* means the roots are checked against an independent construction rather
* than against the same code that produced them.
*/
private fun rootOf(leaves: List<ByteArray>): ByteArray {
require(leaves.isNotEmpty())
@@ -329,19 +310,21 @@ class PublishedVectorsTest {
/**
* The RFC 6962 consistency proof between two prefix sizes.
*
* Built here for the same reason as [rootOf], and trustworthy for the same
* reason: it is made of [KeyLog.split] and [KeyLog.nodeHash], and the tree
* it describes is the one whose roots already matched the published
* vectors at every size. A builder that disagreed with the spec would have
* failed that test first.
*/
private fun consistencyProof(leaves: List<ByteArray>, m: Int, n: Int): List<ByteArray> {
private fun consistencyProof(
leaves: List<ByteArray>,
m: Int,
n: Int,
): List<ByteArray> {
require(m in 1..n && n <= leaves.size)
return subProof(leaves.subList(0, n), m, true)
}
private fun subProof(leaves: List<ByteArray>, m: Int, isOldRoot: Boolean): List<ByteArray> {
private fun subProof(
leaves: List<ByteArray>,
m: Int,
isOldRoot: Boolean,
): List<ByteArray> {
if (m == leaves.size) return if (isOldRoot) emptyList() else listOf(rootOf(leaves))
val k = KeyLog.split(leaves.size)
return if (m <= k) {
@@ -7,18 +7,8 @@ import kotlin.test.Test
/**
* Renders QR codes to PNG so an INDEPENDENT decoder can read them back.
*
* The only honest way to test an encoder we wrote ourselves. A wrong QR
* still looks like a QR; asserting the matrix against values this same code
* produced proves nothing except that it is deterministic. So this writes real
* images and `Scripts/verify-qr.swift` decodes them with CoreImage's detector,
* which shares no line of code with `QrCode`.
*
* Not an assertion by itself, it is the fixture half. It passes as long as
* the encoder does not throw; the verdict comes from the decoder.
*/
class QrCodeRenderForDecodingTest {
@Test
fun `render the corpus`() {
val out = File(System.getProperty("fnmsg.qr.out") ?: "build/qr-corpus")
@@ -30,7 +20,11 @@ class QrCodeRenderForDecodingTest {
println("wrote ${CORPUS.size} QR images to ${out.absolutePath}")
}
private fun render(text: String, scale: Int, quiet: Int): BufferedImage {
private fun render(
text: String,
scale: Int,
quiet: Int,
): BufferedImage {
val m = QrCode.encode(text)
val side = (m.size + quiet * 2) * scale
val image = BufferedImage(side, side, BufferedImage.TYPE_INT_RGB)
@@ -38,8 +32,10 @@ class QrCodeRenderForDecodingTest {
g.color = java.awt.Color.WHITE
g.fillRect(0, 0, side, side)
g.color = java.awt.Color.BLACK
for (r in 0 until m.size) for (c in 0 until m.size) {
if (m[r, c]) g.fillRect((c + quiet) * scale, (r + quiet) * scale, scale, scale)
for (r in 0 until m.size) {
for (c in 0 until m.size) {
if (m[r, c]) g.fillRect((c + quiet) * scale, (r + quiet) * scale, scale, scale)
}
}
g.dispose()
return image
@@ -51,25 +47,23 @@ class QrCodeRenderForDecodingTest {
* the block layout, the alignment patterns and the version-information
* block all change with version and each is its own chance to be wrong.
*/
val CORPUS: List<String> = listOf(
"flamenet:fnid:kx7q2m", // v1
"flamenet:fnid:" + "a".repeat(52) + "@relay1.flamenet.io", // a real address
"flamenet:fnid:" + "b".repeat(52) + "@" + "long".repeat(8) + ".example.com",
// Exactly full, version by version: data codewords minus the
// mode nibble and character count. The boundary is where an
// off-by-one in the terminator or the padding shows up, and it is
// the one place a wrong encoder still produces a scannable-looking
// square.
"x".repeat(14), // v1
"x".repeat(26), // v2
"x".repeat(42), // v3
"x".repeat(62), // v4
"x".repeat(84), // v5
"x".repeat(106), // v6
"x".repeat(122), // v7, first version with a version-information block
"x".repeat(152), // v8, two block groups of different sizes
"x".repeat(180), // v9
"x".repeat(213), // v10, the largest this encoder makes
)
val CORPUS: List<String> =
listOf(
"flamenet:fnid:kx7q2m", // v1
"flamenet:fnid:" + "a".repeat(52) + "@relay1.flamenet.io", // a real address
"flamenet:fnid:" + "b".repeat(52) + "@" + "long".repeat(8) + ".example.com",
// Exactly full, version by version: data codewords minus the mode
// nibble and character count.
"x".repeat(14), // v1
"x".repeat(26), // v2
"x".repeat(42), // v3
"x".repeat(62), // v4
"x".repeat(84), // v5
"x".repeat(106), // v6
"x".repeat(122), // v7, first version with a version-information block
"x".repeat(152), // v8, two block groups of different sizes
"x".repeat(180), // v9
"x".repeat(213), // v10, the largest this encoder makes
)
}
}
@@ -6,8 +6,7 @@ import kotlin.test.assertNotEquals
/**
* Audit H3: a substituted post-quantum identity must change the digits two
* people read aloud. Cross-platform: these values are the same ones the iOS
* SafetyNumberPQTests use, so a drift between the two engines fails here.
* people read aloud.
*/
class SafetyNumberPQTest {
private val aliceIK = ByteArray(32) { 0x11 }
@@ -18,36 +17,43 @@ class SafetyNumberPQTest {
@Test fun `both sides see the same number`() {
assertEquals(
SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ),
SafetyNumber.compute(bobIK, bobPQ, aliceIK, alicePQ))
SafetyNumber.compute(bobIK, bobPQ, aliceIK, alicePQ),
)
}
@Test fun `swapping the PQ identity changes the number`() {
assertNotEquals(
SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ),
SafetyNumber.compute(aliceIK, alicePQ, bobIK, ByteArray(32) { 0xEE.toByte() }))
SafetyNumber.compute(aliceIK, alicePQ, bobIK, ByteArray(32) { 0xEE.toByte() }),
)
}
@Test fun `a classical pair keeps the classical number`() {
assertEquals(SafetyNumber.compute(aliceIK, bobIK),
SafetyNumber.compute(aliceIK, null, bobIK, bobPQ))
assertEquals(SafetyNumber.compute(aliceIK, bobIK),
SafetyNumber.compute(aliceIK, alicePQ, bobIK, null))
assertEquals(
SafetyNumber.compute(aliceIK, bobIK),
SafetyNumber.compute(aliceIK, null, bobIK, bobPQ),
)
assertEquals(
SafetyNumber.compute(aliceIK, bobIK),
SafetyNumber.compute(aliceIK, alicePQ, bobIK, null),
)
}
/**
* The exact digits, from an INDEPENDENT implementation of the rule, the
* same constant the iOS suite pins. This is what makes both platforms
* agree with the spec rather than merely with each other; a silent drift
* would show two people different numbers and read as an attack.
* same constant the iOS suite pins.
*/
@Test fun `the number matches the independently computed value`() {
assertEquals(
"526042701003577849302816285461391061377406450730104222477184",
SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ))
SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ),
)
}
@Test fun `the PQ number differs from the classical one`() {
assertNotEquals(SafetyNumber.compute(aliceIK, bobIK),
SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ))
assertNotEquals(
SafetyNumber.compute(aliceIK, bobIK),
SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ),
)
}
}
@@ -8,16 +8,10 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Telling somebody their disappearing message was screenshotted (§6.27, ios#36).
*
* A courtesy from the taker's own client, and nothing else. A modified
* build, a second phone photographing the screen, or the platform's own
* recorder all produce nothing. A notice means one was probably taken; no
* notice means NOTHING, and most of these tests are about not letting the
* interface say otherwise.
* Telling somebody their disappearing message was screenshotted (§6.27,
* ios#36).
*/
class ScreenshotNoticeTest {
@Test
fun `the notice carries when it happened`() {
val content = Content.parse(Content.screenshot(1_788_000_000))
@@ -50,7 +44,8 @@ class ScreenshotNoticeTest {
assertNull(Content.screenshotAt(Content.parse(Content.text("hi", messageId = "m"))))
}
// -- When one is sent -----------------------------------------------------
// -- When one is sent
// -----------------------------------------------------.
/** Only where a timer is set. */
@Test
@@ -80,11 +75,11 @@ class ScreenshotNoticeTest {
assertFalse(Content.shouldAnnounceScreenshot(0, 1_000, 999_000))
}
// -- What may be claimed --------------------------------------------------
// -- What may be claimed
// --------------------------------------------------.
/**
* The most important test here. Presenting the absence of notices as
* a fact would be inventing evidence out of silence.
* The most important test here.
*/
@Test
fun `the disclaimer refuses to turn silence into evidence`() {
@@ -95,9 +90,7 @@ class ScreenshotNoticeTest {
}
/**
* Android's half of the asymmetry has to be stated, not hidden. Claiming
* a symmetry with iOS that does not exist is worse than the gap, because
* somebody would rely on it.
* Android's half of the asymmetry has to be stated, not hidden.
*/
@Test
fun `the disclaimer states the android limitation`() {
@@ -8,34 +8,42 @@ import kotlin.test.assertTrue
/**
* §8.9b, the member list as something the relay cannot read.
*
* The relay used to hold `gid -> user_id` rows: a readable social graph in a
* table. This covers the client half, what goes into the opaque blob and how
* it comes back, and the properties that make it worth doing.
*
* None of this hides membership from a running relay, which still sees who
* asks about what. It hides it from a stolen database.
*/
class SealedRosterTest {
private class MapStorage : Vault.Storage {
private val records = mutableMapOf<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun vault(): Vault = Vault(
MapStorage(),
javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
).also { it.saveIdentity(DeviceIdentity.create()) }
private fun vault(): Vault =
Vault(
MapStorage(),
javax.crypto.KeyGenerator
.getInstance("AES")
.apply { init(256) }
.generateKey(),
).also { it.saveIdentity(DeviceIdentity.create()) }
private fun contents() = SealedRoster.Contents(
members = listOf(41L, 42L, 43L),
tags = mapOf("41" to SealedRoster.newTag(), "42" to SealedRoster.newTag()),
name = "Book club",
)
private fun contents() =
SealedRoster.Contents(
members = listOf(41L, 42L, 43L),
tags = mapOf("41" to SealedRoster.newTag(), "42" to SealedRoster.newTag()),
name = "Book club",
)
@Test
fun `a roster survives the round trip`() {
@@ -49,35 +57,30 @@ class SealedRosterTest {
/**
* The property the whole change rests on: the stored bytes say nothing.
* A dump that yields the member ids has bought nobody anything.
*
* The values are long on purpose, and the search covers the decoded
* bytes. This sealed ids `41`/`42` and looked for `"41"`/`"42"` in the
* BASE64, two characters out of a 64-symbol alphabet, so each had about a
* 1-in-4096 chance at any position and roughly a 14% chance across a blob
* this size. It failed on exactly that, having passed for days: a coin
* flip wearing the clothes of an assertion. The iOS twin had the same bug
* and was fixed the same way; this one was missed because only one engine
* was looked at.
*
* Nine-digit ids and a real-sounding name make a chance match impossible
* rather than unlikely, and the byte-level search is the one that actually
* states the property, base64 could in principle hide a plaintext run the
* raw bytes reveal.
*/
@Test
fun `the sealed blob reveals nothing`() {
val secret = SealedRoster.Contents(
members = listOf(918273645L, 564738291L),
tags = mapOf("918273645" to SealedRoster.newTag(),
"564738291" to SealedRoster.newTag()),
name = "Chemotherapy support",
)
val secret =
SealedRoster.Contents(
members = listOf(918273645L, 564738291L),
tags =
mapOf(
"918273645" to SealedRoster.newTag(),
"564738291" to SealedRoster.newTag(),
),
name = "Chemotherapy support",
)
val blob = SealedRoster.seal(secret, SealedRoster.newGroupKey())
val raw = Bytes.unb64(blob)
for (needle in listOf("918273645", "564738291", "Chemotherapy support",
"members", "tags", "name")) {
for (needle in listOf(
"918273645",
"564738291",
"Chemotherapy support",
"members",
"tags",
"name",
)) {
assertFalse(blob.contains(needle), "$needle is readable in the sealed roster")
assertFalse(
String(raw, Charsets.ISO_8859_1)
@@ -88,9 +91,7 @@ class SealedRosterTest {
}
/**
* A blob that will not open is an error, never an empty group. Treating
* it as empty would silently drop every member from a conversation that
* still exists.
* A blob that will not open is an error, never an empty group.
*/
@Test
fun `the wrong key throws rather than returning nothing`() {
@@ -101,8 +102,7 @@ class SealedRosterTest {
}
/**
* The relay stores these and could change them. Adding a member to
* somebody's roster is exactly the attack this refuses.
* The relay stores these and could change them.
*/
@Test
fun `a tampered blob is refused`() {
@@ -113,9 +113,7 @@ class SealedRosterTest {
}
/**
* Tags are random, not derived from the account. Anything derived would
* be a pseudonym the relay could link across groups, the same graph
* wearing a hash.
* Tags are random, not derived from the account.
*/
@Test
fun `tags are unlinkable`() {
@@ -125,22 +123,20 @@ class SealedRosterTest {
}
/**
* The bytes must match the Swift engine and the relay exactly. A
* roster signed here is verified there; one character of difference and
* every signature this client makes is refused, with nothing in the error
* pointing at the format.
* The bytes must match the Swift engine and the relay exactly.
*/
@Test
fun `the signed bytes match the other implementations`() {
assertEquals(
"fngroup-v1|abc|7|Zm9v",
String(SealedRoster.canonical("abc", 7, "Zm9v")))
String(SealedRoster.canonical("abc", 7, "Zm9v")),
)
}
/**
* The encoding must not depend on map iteration order, or two devices
* sealing the same roster produce different bytes, and a signature over
* one does not verify the other.
* sealing the same roster produce different bytes, and a signature over one
* does not verify the other.
*/
@Test
fun `the encoding is stable regardless of tag insertion order`() {
@@ -150,22 +146,23 @@ class SealedRosterTest {
// Sealing uses a random nonce, so compare what comes back out instead.
assertEquals(
SealedRoster.open(SealedRoster.seal(a, key), key).tags,
SealedRoster.open(SealedRoster.seal(b, key), key).tags)
SealedRoster.open(SealedRoster.seal(b, key), key).tags,
)
}
/**
* A backup that loses the tags loses the groups for good. The relay
* cannot say what you are a member of, so the tag is the only way to fetch
* a group and the key the only way to read it. Restoring the messages
* without these gives a device that can read old conversations and can
* never rejoin the groups they came from.
* A backup that loses the tags loses the groups for good.
*/
@Test
fun `an exported backup carries sealed group secrets`() {
val old = vault()
val secrets = Vault.GroupSecrets(
tag = SealedRoster.newTag(), key = SealedRoster.newGroupKey(),
ownerPrivate = null, version = 3)
val secrets =
Vault.GroupSecrets(
tag = SealedRoster.newTag(),
key = SealedRoster.newGroupKey(),
ownerPrivate = null,
version = 3,
)
old.saveGroupSecrets("g1", secrets)
val payload = old.exportPayload()
@@ -180,13 +177,15 @@ class SealedRosterTest {
@Test
fun `a restored backup does not overwrite secrets we already hold`() {
val old = vault()
old.saveGroupSecrets("g1", Vault.GroupSecrets(
tag = "old", key = SealedRoster.newGroupKey(), version = 1))
old.saveGroupSecrets(
"g1",
Vault.GroupSecrets(tag = "old", key = SealedRoster.newGroupKey(), version = 1),
)
val payload = old.exportPayload()
val current = vault()
val live = Vault.GroupSecrets(
tag = "live", key = SealedRoster.newGroupKey(), version = 9)
val live =
Vault.GroupSecrets(tag = "live", key = SealedRoster.newGroupKey(), version = 9)
current.saveGroupSecrets("g1", live)
current.mergeImportedPayload(payload, 0.0)
@@ -9,21 +9,27 @@ import kotlin.test.assertTrue
/**
* Sealed sender (§14), and mostly the things it must refuse.
*
* A seal that opens for the right person is easy. The properties worth testing
* are that it does NOT open for anyone else, and that the identity it carries
* is treated as a claim.
*/
class SealedSenderTest {
private fun pair(): Pair<DeviceIdentity, DeviceIdentity> =
DeviceIdentity.create() to DeviceIdentity.create()
private fun pair(): Pair<DeviceIdentity, DeviceIdentity> = DeviceIdentity.create() to DeviceIdentity.create()
/** A real envelope, so the test is not sealing a string. */
private fun envelope(from: DeviceIdentity, to: DeviceIdentity, text: String): Envelope {
private fun envelope(
from: DeviceIdentity,
to: DeviceIdentity,
text: String,
): Envelope {
val oneTime = to.generateOneTimePreKeys(1).first()
val wire = to.registrationWire()
.put("opk", org.json.JSONObject().put("id", oneTime.id).put("pub", Bytes.b64(oneTime.publicKey)))
val wire =
to
.registrationWire()
.put(
"opk",
org.json
.JSONObject()
.put("id", oneTime.id)
.put("pub", Bytes.b64(oneTime.publicKey)),
)
return E2ESession.initiate(from, PeerBundle.fromWire(wire)).encrypt(text)
}
@@ -42,17 +48,13 @@ class SealedSenderTest {
/**
* The AAD binds the destination device.
*
* Without this a relay could copy one person's sealed blob into somebody
* else's inbox and it would decrypt there, turning a privacy feature into
* a delivery bug that leaks messages to the wrong account.
*/
@Test
fun `a seal does not open on a device it was not addressed to`() {
val (alice, bob) = pair()
val eve = DeviceIdentity.create()
val blob = SealedSender.seal(
envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
val blob =
SealedSender.seal(envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
// Even holding a valid private key, and even being handed the blob.
assertFailsWith<Aead.DecryptionFailed> {
@@ -62,16 +64,12 @@ class SealedSenderTest {
/**
* Bob's own key, but the AAD says another device id.
*
* Separated from the test above so the two failure reasons cannot be
* confused: this one proves the device id is in the AAD, not merely that a
* different DH key fails.
*/
@Test
fun `the destination device id is part of what is authenticated`() {
val (alice, bob) = pair()
val blob = SealedSender.seal(
envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
val blob =
SealedSender.seal(envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
assertFailsWith<Aead.DecryptionFailed> {
SealedSender.open(blob, bob.identityDH.private, "some-other-device-id")
@@ -81,8 +79,8 @@ class SealedSenderTest {
@Test
fun `a tampered ciphertext is refused`() {
val (alice, bob) = pair()
val blob = SealedSender.seal(
envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
val blob =
SealedSender.seal(envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
val broken = blob.copy(ciphertext = blob.ciphertext.copyOf().also { it[0] = (it[0] + 1).toByte() })
assertFailsWith<Aead.DecryptionFailed> {
@@ -92,26 +90,21 @@ class SealedSenderTest {
/**
* Anyone can forge the claimed sender, and this records that on purpose.
*
* The recipient's IK_dh is published to the world, so a seal claiming to be
* from anybody is trivial to make. This test exists so that if someone ever
* "fixes" the naming to look authoritative, the reason it is a claim is
* still written down and still executing.
*/
@Test
fun `anyone holding the recipient's public key can forge the claimed sender`() {
val (alice, bob) = pair()
val eve = DeviceIdentity.create()
// Eve seals her own envelope while claiming to be Alice. Nothing stops
// her, the seal is confidentiality, not authentication.
val forged = SealedSender.seal(
envelope(eve, bob, "not really from alice"),
fromUser = 7,
fromDevice = alice.deviceId,
toDevice = bob.deviceId,
recipientIKdh = bob.identityDHPublic,
)
// Eve seals her own envelope while claiming to be Alice.
val forged =
SealedSender.seal(
envelope(eve, bob, "not really from alice"),
fromUser = 7,
fromDevice = alice.deviceId,
toDevice = bob.deviceId,
recipientIKdh = bob.identityDHPublic,
)
val opened = SealedSender.open(forged, bob.identityDH.private, bob.deviceId)
// The seal opens and the lie is right there in the claim.
@@ -143,10 +136,10 @@ class SealedSenderTest {
@Test
fun `every seal uses a fresh ephemeral`() {
val (alice, bob) = pair()
val one = SealedSender.seal(
envelope(alice, bob, "a"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
val two = SealedSender.seal(
envelope(alice, bob, "b"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
val one =
SealedSender.seal(envelope(alice, bob, "a"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
val two =
SealedSender.seal(envelope(alice, bob, "b"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
// The nonce is derived from the shared secret, so a repeated ephemeral
// is a repeated (key, nonce) pair, which under GCM leaks the
@@ -8,7 +8,6 @@ import kotlin.test.assertTrue
/** Counting the sealed-to-unsealed fallback (§14.5, ios#16). */
class SealingRecordTest {
@Test
fun `a sealed send is counted`() {
val r = SealingRecord().recording(wasSealed = true, at = 5)
@@ -26,9 +25,10 @@ class SealingRecordTest {
/** One good send afterwards does not unhappen the last bad one. */
@Test
fun `a sealed send does not clear the last unsealed stamp`() {
val r = SealingRecord()
.recording(wasSealed = false, at = 100)
.recording(wasSealed = true, at = 200)
val r =
SealingRecord()
.recording(wasSealed = false, at = 100)
.recording(wasSealed = true, at = 200)
assertEquals(100, r.lastUnsealedAt)
}
@@ -48,8 +48,7 @@ class SealingRecordTest {
}
/**
* Must not say the message was readable. An unsealed send is still
* end-to-end encrypted; the relay learns the sender, not the content.
* Must not say the message was readable.
*/
@Test
fun `the summary distinguishes sender from content`() {
@@ -9,33 +9,15 @@ import kotlin.test.assertTrue
/**
* Zeroing key material once it has been used (ios#39, Android half).
*
* The claim being tested is narrow, and the tests are shaped to keep it
* narrow. What this buys is that a derived key is not left sitting in the
* heap after the one moment it was needed. It is not "the key is gone", a
* compacting collector can have moved the array, Android has compressed swap,
* and there is no `memset_s` on the JVM. `SecureBytes` says all of that in
* prose; these assert only what is actually true.
*
* The most valuable test here is that the derived keys did not change.
* Wiping an intermediate one line too early derives every account secret from
* thirty-two zero bytes, which produces a perfectly consistent, perfectly
* wrong identity, silently, on every device. That is a far worse failure than
* the leak this feature exists to narrow, so it is checked first.
*/
class SecureBytesTest {
private val passphrase = "a-long-enough-passphrase"
private val salt = ByteArray(16) { it.toByte() }
// ---- The failure that would matter most -----------------------------
// ---- The failure that would matter most -----------------------------.
/**
* The pinned vectors already cover `authSecret` and `accountKeySeed`.
* This covers the shape wiping could break without moving a vector: that
* the two secrets from one stretch still match the two derived separately.
* If the wipe landed between the expansions, the second would be derived
* from zeroes and this diverges.
*/
@Test
fun `wiping the intermediate did not change what is derived`() {
@@ -45,9 +27,8 @@ class SecureBytesTest {
}
/**
* And neither is zeroes, which is what "derived from a wiped buffer"
* would look like. A test that only compared the two paths would pass if
* BOTH were wrong in the same way.
* And neither is zeroes, which is what "derived from a wiped buffer" would
* look like.
*/
@Test
fun `no derived secret is a run of zeroes`() {
@@ -80,7 +61,7 @@ class SecureBytesTest {
assertFalse(identity.contentEquals(account))
}
// ---- The wiping itself ----------------------------------------------
// ---- The wiping itself ----------------------------------------------.
@Test
fun `wipe zeroes the array it is given`() {
@@ -104,9 +85,7 @@ class SecureBytesTest {
}
/**
* The case the `finally` exists for. A derivation that failed
* half-way is exactly where the material gets forgotten, so the wipe has to
* survive a throw, not sit at the end of a happy path.
* The case the `finally` exists for.
*/
@Test
fun `withSecret wipes when the body throws`() {
@@ -118,10 +97,7 @@ class SecureBytesTest {
}
/**
* The honest limit, asserted rather than left to the prose. A copy
* taken before the wipe is untouched by it. This is not a bug to fix, a
* `ByteArray` cannot reach copies of itself, and stating it as a passing
* test stops anybody reading `wipe` as stronger than it is.
* The honest limit, asserted rather than left to the prose.
*/
@Test
fun `a copy taken beforehand survives the wipe`() {
@@ -129,8 +105,10 @@ class SecureBytesTest {
val copy = SecureBytes.copyOf(secret)
SecureBytes.wipe(secret)
assertTrue(secret.all { it == 0.toByte() })
assertTrue(copy.all { it == 0x41.toByte() },
"the copy was wiped too, which would mean these share a buffer")
assertTrue(
copy.all { it == 0x41.toByte() },
"the copy was wiped too, which would mean these share a buffer",
)
}
@Test
@@ -9,15 +9,8 @@ import kotlin.test.assertTrue
/**
* Stories and mentions, against envelopes the iOS build produces.
*
* Every string below is the shape iOS assembles, not this engine's own
* output. Two implementations that each only test against themselves agree
* right up to the day somebody sends a real message, and the symptom is a
* mention that never rings, or a story that arrives as "[unsupported
* attachment]", with nothing in either log to say why.
*/
class StoryAndMentionInteropTest {
// MARK: text stories
private val iosTextStory =
@@ -36,10 +29,6 @@ class StoryAndMentionInteropTest {
fun `this engine assembles a text story the same way`() {
val wire = Content.textStory("hello", 1800000000.0, "m-1")
// Asserted on the WIRE STRING, not on a JSONObject built from it.
// Round-tripping through JSONObject reorders the keys and puts `fnc`
// somewhere other than first, which is exactly the hazard that makes
// both engines hand-assemble these strings. An earlier version of this
// test did that and failed, correctly.
assertTrue(wire.startsWith("{\"fnc\":1"), wire)
assertTrue(wire.contains("\"kind\":\"story\""), wire)
assertTrue(wire.contains("\"caption\":\"hello\""), wire)
@@ -48,10 +37,7 @@ class StoryAndMentionInteropTest {
/**
* Kotlin prints a Double in scientific notation, so this engine writes
* `"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and
* both sides read either, but a story's expiry decides whether it shows
* at all, so the shape is pinned here rather than assumed. (This is not
* new: picture stories have carried `exp` this way all along.)
* `"exp":1.8E9` where iOS writes `1800000000.0`.
*/
@Test
fun `an expiry written in scientific notation reads back`() {
@@ -63,11 +49,18 @@ class StoryAndMentionInteropTest {
/** A picture story must be completely unaffected. */
@Test
fun `a picture story still parses as an attachment`() {
val ref = Attachment.Ref(
kind = Attachment.STORY, id = "a".repeat(64),
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
bytes = 10, mime = "image/jpeg", width = 4, height = 3, expires = 1800000000.0,
)
val ref =
Attachment.Ref(
kind = Attachment.STORY,
id = "a".repeat(64),
key = ByteArray(32) { 0x11 },
digest = ByteArray(32) { 0x22 },
bytes = 10,
mime = "image/jpeg",
width = 4,
height = 3,
expires = 1800000000.0,
)
val content = Content.parse(Content.attachment(ref, "look"))
assertNotNull(content.attachment)
assertEquals(null, content.textStoryExpires)
@@ -110,9 +103,7 @@ class StoryAndMentionInteropTest {
}
/**
* The separation that matters. Both are `kind: "react"`; without the
* flag a story reaction folds onto a message with that id, and if one
* ever matched, onto entirely the wrong thing.
* The separation that matters.
*/
@Test
fun `an ordinary message reaction is not read as a story one`() {
@@ -144,8 +135,12 @@ class StoryAndMentionInteropTest {
@Test
fun `an ordinary reply is not a story reply`() {
val json = Content.text("sure", messageId = "m-1",
about = Content.Reference("other", 2, "earlier"))
val json =
Content.text(
"sure",
messageId = "m-1",
about = Content.Reference("other", 2, "earlier"),
)
assertFalse(Content.parse(json).repliesToStory)
}
@@ -6,28 +6,39 @@ import kotlin.test.assertTrue
/**
* A private archive of your own stories (ios#54).
*
* This keeps something the interface said would disappear. So it is
* opt-in, and it only ever holds stories this account posted, an archive of
* other people's expiring stories is the exact opposite of what they were
* promised, and "it is private to you" does not repair that.
*/
class StoryArchiveTest {
private fun story(id: String, mine: Boolean, at: Long) = Vault.Story(
id = id, authorId = if (mine) 1L else 7L, mine = mine,
ref = Attachment.Ref(
kind = Attachment.STORY, id = "b$id", key = ByteArray(32),
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = 1.0,
),
caption = "c$id", postedAt = at,
private fun story(
id: String,
mine: Boolean,
at: Long,
) = Vault.Story(
id = id,
authorId = if (mine) 1L else 7L,
mine = mine,
ref =
Attachment.Ref(
kind = Attachment.STORY,
id = "b$id",
key = ByteArray(32),
digest = ByteArray(32),
bytes = 1,
mime = "image/jpeg",
width = 1,
height = 1,
duration = 0.0,
expires = 1.0,
),
caption = "c$id",
postedAt = at,
)
@Test
fun `your own expired story is kept`() {
assertEquals(listOf("1"),
Vault.archive(emptyList(), listOf(story("1", true, 100))).map { it.id })
assertEquals(
listOf("1"),
Vault.archive(emptyList(), listOf(story("1", true, 100))).map { it.id },
)
}
/** The rule the whole feature turns on. */
@@ -38,8 +49,11 @@ class StoryArchiveTest {
@Test
fun `a mixed batch keeps only your own`() {
val result = Vault.archive(emptyList(), listOf(
story("1", true, 100), story("2", false, 200), story("3", true, 300)))
val result =
Vault.archive(
emptyList(),
listOf(story("1", true, 100), story("2", false, 200), story("3", true, 300)),
)
assertEquals(setOf("1", "3"), result.map { it.id }.toSet())
}
@@ -54,21 +68,38 @@ class StoryArchiveTest {
/** Newest first, so the screen needs no sort. */
@Test
fun `the archive is newest first`() {
val result = Vault.archive(emptyList(), listOf(
story("1", true, 100), story("3", true, 300), story("2", true, 200)))
val result =
Vault.archive(
emptyList(),
listOf(story("1", true, 100), story("3", true, 300), story("2", true, 200)),
)
assertEquals(listOf("3", "2", "1"), result.map { it.id })
}
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun key() = javax.crypto.KeyGenerator.getInstance("AES")
.apply { init(256) }.generateKey()
private fun key() =
javax.crypto.KeyGenerator
.getInstance("AES")
.apply { init(256) }
.generateKey()
/** Kept in the vault, like everything else about somebody's stories. */
@Test
@@ -81,8 +112,8 @@ class StoryArchiveTest {
/**
* The archive must NOT be swept by the expiry rule the live set uses,
* everything in it is expired by definition, so reusing `saveStories`
* would empty it on the next write.
* everything in it is expired by definition, so reusing `saveStories` would
* empty it on the next write.
*/
@Test
fun `an expired story survives in the archive`() {
@@ -6,14 +6,8 @@ import kotlin.test.assertTrue
/**
* Saving your own story before it expires (ios#34).
*
* Only ever your own. Saving somebody else's keeps a copy of something
* they were told would disappear, which is the opposite of what the feature
* promises them. The rule lives in the engine, not in the button, because a
* guard in a composable is one refactor away from being lost.
*/
class StorySaveTest {
@Test
fun `your own story with a picture can be saved`() {
assertTrue(Vault.isSaveable(mine = true, hasBlob = true))
@@ -26,9 +20,7 @@ class StorySaveTest {
}
/**
* A text story (§6.21) has no blob and nothing to write out. False
* rather than rendering the words into an image: a story is what the
* author posted, and a picture of it is a different object.
* A text story (§6.21) has no blob and nothing to write out.
*/
@Test
fun `a text story of your own has nothing to save`() {
@@ -9,21 +9,40 @@ import kotlin.test.assertTrue
/** Multi-item stories (spec §6.23, ios#53). */
class StorySetTest {
private fun ref(id: Int, expires: Double = 100.0) = Attachment.Ref(
kind = Attachment.STORY, id = "b$id", key = ByteArray(32),
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = expires,
private fun ref(
id: Int,
expires: Double = 100.0,
) = Attachment.Ref(
kind = Attachment.STORY,
id = "b$id",
key = ByteArray(32),
digest = ByteArray(32),
bytes = 1,
mime = "image/jpeg",
width = 1,
height = 1,
duration = 0.0,
expires = expires,
)
private fun item(id: Int, set: String?, i: Int = 0, n: Int = 1, expires: Double = 100.0) =
Vault.Story(
id = "s$id", authorId = 7, mine = false, ref = ref(id, expires),
caption = "", postedAt = id.toLong(),
set = set?.let { Content.StorySet(it, i, n) },
)
private fun item(
id: Int,
set: String?,
i: Int = 0,
n: Int = 1,
expires: Double = 100.0,
) = Vault.Story(
id = "s$id",
authorId = 7,
mine = false,
ref = ref(id, expires),
caption = "",
postedAt = id.toLong(),
set = set?.let { Content.StorySet(it, i, n) },
)
// -- The wire -------------------------------------------------------------
// -- The wire
// -------------------------------------------------------------.
@Test
fun `an item carries its set position and size`() {
@@ -35,9 +54,7 @@ class StorySetTest {
}
/**
* One envelope per item. An array of attachments would fail the
* single-`att` guard on any build predating §6.23 and render the whole set
* as a message in the conversation.
* One envelope per item.
*/
@Test
fun `an item is an ordinary story envelope`() {
@@ -52,12 +69,15 @@ class StorySetTest {
assertNull(Content.parse(Content.attachment(ref(1))).storySet)
}
// -- Grouping -------------------------------------------------------------
// -- Grouping
// -------------------------------------------------------------.
@Test
fun `items of one set are grouped in position order`() {
val groups = Vault.groupedStories(listOf(
item(3, "a", i = 2, n = 3), item(1, "a", i = 0, n = 3), item(2, "a", i = 1, n = 3)))
val groups =
Vault.groupedStories(
listOf(item(3, "a", i = 2, n = 3), item(1, "a", i = 0, n = 3), item(2, "a", i = 1, n = 3)),
)
assertEquals(1, groups.size)
assertEquals(listOf("s1", "s2", "s3"), groups[0].map { it.id })
}
@@ -65,8 +85,10 @@ class StorySetTest {
/** `n` says three; two came. Render what arrived. */
@Test
fun `a partly arrived set is still rendered`() {
val groups = Vault.groupedStories(listOf(
item(1, "a", i = 0, n = 3), item(3, "a", i = 2, n = 3)))
val groups =
Vault.groupedStories(
listOf(item(1, "a", i = 0, n = 3), item(3, "a", i = 2, n = 3)),
)
assertEquals(1, groups.size)
assertEquals(2, groups[0].size)
}
@@ -74,51 +96,86 @@ class StorySetTest {
/** Two singles must not collide just because neither has a set id. */
@Test
fun `two sets and a single stay apart`() {
val groups = Vault.groupedStories(listOf(
item(1, "a", i = 0, n = 2), item(2, "b"), item(3, null), item(4, "a", i = 1, n = 2)))
val groups =
Vault.groupedStories(
listOf(item(1, "a", i = 0, n = 2), item(2, "b"), item(3, null), item(4, "a", i = 1, n = 2)),
)
assertEquals(3, groups.size)
assertEquals(listOf("s1", "s4"), groups[0].map { it.id })
}
// -- Expiry ---------------------------------------------------------------
// -- Expiry
// ---------------------------------------------------------------.
/** The earliest wins, so a sender cannot outlive part of its own set. */
@Test
fun `a set expires at its earliest stamp`() {
assertEquals(100.0, Vault.setExpiry(listOf(
item(1, "a", expires = 300.0),
item(2, "a", expires = 100.0),
item(3, "a", expires = 200.0))))
assertEquals(
100.0,
Vault.setExpiry(
listOf(
item(1, "a", expires = 300.0),
item(2, "a", expires = 100.0),
item(3, "a", expires = 200.0),
),
),
)
}
/** A zero means "never" and must not be read as "expires at the epoch". */
@Test
fun `a zero stamp does not expire the whole set`() {
assertEquals(500.0, Vault.setExpiry(listOf(
item(1, "a", expires = 0.0), item(2, "a", expires = 500.0))))
assertEquals(
500.0,
Vault.setExpiry(
listOf(item(1, "a", expires = 0.0), item(2, "a", expires = 500.0)),
),
)
}
@Test
fun `a set of never-expiring items never expires`() {
assertEquals(0.0, Vault.setExpiry(listOf(
item(1, "a", expires = 0.0), item(2, "a", expires = 0.0))))
assertEquals(
0.0,
Vault.setExpiry(
listOf(item(1, "a", expires = 0.0), item(2, "a", expires = 0.0)),
),
)
}
// -- Storage --------------------------------------------------------------
// -- Storage
// --------------------------------------------------------------.
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
/** The set survives a reload, or a relaunch scatters every sequence. */
@Test
fun `the set survives the vault round trip`() {
val vault = Vault(MapStorage(),
javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
val vault =
Vault(
MapStorage(),
javax.crypto.KeyGenerator
.getInstance("AES")
.apply { init(256) }
.generateKey(),
)
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(listOf(item(1, "a", i = 1, n = 4, expires = now + 600)), now)
val back = vault.loadStories().single()
@@ -8,17 +8,8 @@ import kotlin.test.assertTrue
/**
* Embedded live streams in stories (spec §6.13).
*
* The one media path where the viewer must contact a third party. Every
* other one is a sealed blob; a live stream is continuous and hosted elsewhere,
* so watching it means this device connects to that host, which learns the
* viewer's address and that they watched. §6.12's answer, have the sender
* fetch it once, does not transfer, because a stream cannot be fetched once.
*
* Everything here exists to make that a decision rather than a surprise.
*/
class StreamDisclosureTest {
@Test
fun `the disclosure names the host`() {
assertTrue("live.example.com" in StreamDisclosure.text("live.example.com"))
@@ -57,17 +48,26 @@ class StreamDisclosureTest {
assertFalse(StreamDisclosure.acceptable(""))
}
// -- The wire ------------------------------------------------------------
// -- The wire ------------------------------------------------------------.
private fun ref() = Attachment.Ref(
kind = Attachment.STORY, id = "abc", key = ByteArray(32) { 1 },
digest = ByteArray(32) { 2 }, bytes = 10, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = 0.0)
private fun ref() =
Attachment.Ref(
kind = Attachment.STORY,
id = "abc",
key = ByteArray(32) { 1 },
digest = ByteArray(32) { 2 },
bytes = 10,
mime = "image/jpeg",
width = 1,
height = 1,
duration = 0.0,
expires = 0.0,
)
@Test
fun `a stream url survives the wire`() {
val wire = Content.attachment(
ref(), caption = "live now", streamUrl = "https://live.example.com/s.m3u8")
val wire =
Content.attachment(ref(), caption = "live now", streamUrl = "https://live.example.com/s.m3u8")
val parsed = Content.parse(wire)
assertEquals("https://live.example.com/s.m3u8", parsed.streamUrl)
assertEquals("live now", parsed.text)
@@ -82,16 +82,20 @@ class StreamDisclosureTest {
}
/**
* The host comes from the URL, never the caption. A story captioned
* "youtube.com" pointing elsewhere is the phishing shape §6.12 guards
* against.
* The host comes from the URL, never the caption.
*/
@Test
fun `the host comes from the url not the caption`() {
val story = Vault.Story(
id = "s1", authorId = 2L, mine = false, ref = ref(),
caption = "totally youtube.com", postedAt = 0L,
streamUrl = "https://evil.example/s.m3u8")
val story =
Vault.Story(
id = "s1",
authorId = 2L,
mine = false,
ref = ref(),
caption = "totally youtube.com",
postedAt = 0L,
streamUrl = "https://evil.example/s.m3u8",
)
assertEquals("evil.example", story.streamHost)
}
@@ -13,61 +13,68 @@ import kotlin.test.assertTrue
/**
* Round trips for everything the vault remembers.
*
* This class of bug has already shipped here once. `requireKeyLog` was
* computed, held in memory, and never written down, so a security control the
* UI offered silently reverted on every relaunch. `Trust.changed` carries the
* same warning in its own doc comment: it was returned by `record()` and
* persisted by nothing, so a contact who reinstalled became permanently
* unmessageable while the header insisted nothing had changed.
*
* Neither was a hard bug to write. Both were invisible because a save and a
* load that disagree still compile, still run, and only differ after a restart
* which no test performed. Every test below writes with one `Vault` instance
* and reads with a second one built over the same bytes, because that is the
* only arrangement in which the two halves have to agree.
*/
class VaultPersistenceTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
/** Write with one instance, read with another, a restart, in effect. */
private fun <T> roundTrip(write: (Vault) -> Unit, read: (Vault) -> T): T {
val storage = MapStorage(); val secret = key()
private fun <T> roundTrip(
write: (Vault) -> Unit,
read: (Vault) -> T,
): T {
val storage = MapStorage()
val secret = key()
write(Vault(storage, secret))
return read(Vault(storage, secret))
}
// -- Credential ----------------------------------------------------------
// -- Credential ----------------------------------------------------------.
@Test
fun `a credential survives a restart`() {
val original = Vault.Credential(
relayBaseUrl = "https://relay.example", username = "ada",
appPassword = "hunter2", userId = 42, fnid = "ada@example")
val original =
Vault.Credential(
relayBaseUrl = "https://relay.example",
username = "ada",
appPassword = "hunter2",
userId = 42,
fnid = "ada@example",
)
val restored = roundTrip({ it.saveCredential(original) }, { it.loadCredential() })
assertEquals(original, restored)
}
/**
* A credential written before addresses existed is still a valid
* credential and must not fail to load.
* A credential written before addresses existed is still a valid credential
* and must not fail to load.
*/
@Test
fun `a credential with no address round-trips as null rather than empty`() {
val restored = roundTrip({
it.saveCredential(Vault.Credential(
"https://relay.example", "ada", "pw", 42, fnid = null))
}, { it.loadCredential() })
val restored =
roundTrip({
it.saveCredential(
Vault.Credential("https://relay.example", "ada", "pw", 42, fnid = null),
)
}, { it.loadCredential() })
assertNull(restored!!.fnid)
}
@@ -76,28 +83,30 @@ class VaultPersistenceTest {
assertNull(Vault(MapStorage(), key()).loadCredential())
}
// -- Trust ---------------------------------------------------------------
// -- Trust ---------------------------------------------------------------.
@Test
fun `a trust record survives a restart`() {
val original = Vault.Trust(
identities = listOf("ik-one", "ik-two"), verified = true,
fnid = "bob@example", changed = false)
val original =
Vault.Trust(
identities = listOf("ik-one", "ik-two"),
verified = true,
fnid = "bob@example",
changed = false,
)
val restored = roundTrip({ it.saveTrust(7, original) }, { it.loadTrust(7) })
assertEquals(original, restored)
}
/**
* The flag whose absence made reinstalled contacts unmessageable. If
* this does not persist, `state()` can never answer CHANGED, so the
* "Accept new keys" button never renders and the only route back is
* clearing app data.
* The flag whose absence made reinstalled contacts unmessageable.
*/
@Test
fun `a detected key change is still there after a restart`() {
val restored = roundTrip({
it.saveTrust(7, Vault.Trust(listOf("ik-old"), verified = false, changed = true))
}, { it.loadTrust(7) })
val restored =
roundTrip({
it.saveTrust(7, Vault.Trust(listOf("ik-old"), verified = false, changed = true))
}, { it.loadTrust(7) })
assertTrue(restored!!.changed)
// and the pinned identities are unchanged: the flag records that
// something else was OFFERED, not that we believe it.
@@ -106,15 +115,17 @@ class VaultPersistenceTest {
@Test
fun `trust with no address round-trips as null`() {
val restored = roundTrip({
it.saveTrust(7, Vault.Trust(listOf("ik"), verified = false, fnid = null))
}, { it.loadTrust(7) })
val restored =
roundTrip({
it.saveTrust(7, Vault.Trust(listOf("ik"), verified = false, fnid = null))
}, { it.loadTrust(7) })
assertNull(restored!!.fnid)
}
@Test
fun `trust is per contact`() {
val storage = MapStorage(); val secret = key()
val storage = MapStorage()
val secret = key()
val v = Vault(storage, secret)
v.saveTrust(1, Vault.Trust(listOf("ik-1"), verified = true))
v.saveTrust(2, Vault.Trust(listOf("ik-2"), verified = false))
@@ -127,7 +138,8 @@ class VaultPersistenceTest {
@Test
fun `forgetting a contact's trust removes it and leaves others alone`() {
val storage = MapStorage(); val secret = key()
val storage = MapStorage()
val secret = key()
val v = Vault(storage, secret)
v.saveTrust(1, Vault.Trust(listOf("ik-1"), verified = true))
v.saveTrust(2, Vault.Trust(listOf("ik-2"), verified = true))
@@ -138,15 +150,13 @@ class VaultPersistenceTest {
}
/**
* Every record's name is bound into the AAD, so a blob is not
* portable between slots. Without that, someone with file access could
* paste one contact's pinned keys into another contact's slot and the app
* would accept them, attributing a stranger's identity to someone you
* trust.
* Every record's name is bound into the AAD, so a blob is not portable
* between slots.
*/
@Test
fun `a trust record moved into another contact's slot does not open`() {
val storage = MapStorage(); val secret = key()
val storage = MapStorage()
val secret = key()
val v = Vault(storage, secret)
v.saveTrust(1, Vault.Trust(listOf("ik-victim"), verified = true))
v.saveTrust(2, Vault.Trust(listOf("ik-other"), verified = false))
@@ -158,17 +168,22 @@ class VaultPersistenceTest {
assertFailsWith<Vault.Corrupt> { Vault(storage, secret).loadTrust(2) }
}
// -- Sealed sender delivery key -----------------------------------------
// -- Sealed sender delivery key -----------------------------------------.
@Test
fun `a delivery key survives a restart with its peers`() {
val mine = ByteArray(32) { 9 }
val restored = roundTrip({
it.saveDelivery(Vault.Delivery(
key = mine, published = true,
peers = mapOf(5L to ByteArray(32) { 1 }, 6L to ByteArray(32) { 2 }),
toldPeers = setOf(5L)))
}, { it.loadDelivery() })!!
val restored =
roundTrip({
it.saveDelivery(
Vault.Delivery(
key = mine,
published = true,
peers = mapOf(5L to ByteArray(32) { 1 }, 6L to ByteArray(32) { 2 }),
toldPeers = setOf(5L),
),
)
}, { it.loadDelivery() })!!
assertContentEquals(mine, restored.key)
assertTrue(restored.published)
@@ -178,31 +193,29 @@ class VaultPersistenceTest {
}
/**
* `published` is the relay's knowledge, not ours. Losing it across a
* restart means either re-publishing forever or believing a key is
* reachable when the relay has never seen its digest.
* `published` is the relay's knowledge, not ours.
*/
@Test
fun `an unpublished delivery key does not come back as published`() {
val restored = roundTrip({
it.saveDelivery(Vault.Delivery(ByteArray(32) { 3 }, published = false))
}, { it.loadDelivery() })!!
val restored =
roundTrip({
it.saveDelivery(Vault.Delivery(ByteArray(32) { 3 }, published = false))
}, { it.loadDelivery() })!!
assertTrue(!restored.published)
}
// -- Key transparency pins ----------------------------------------------
// -- Key transparency pins ----------------------------------------------.
/**
* The pin is what makes the log append-only from this device's point of
* view: a head smaller than the one we already saw is a rewind. A pin that
* does not survive a restart makes every launch trust whatever it is
* handed first.
* view: a head smaller than the one we already saw is a rewind.
*/
@Test
fun `a key log pin survives a restart`() {
val restored = roundTrip({
it.saveKeyLogPin(Vault.KeyLogPin(size = 128, root = "cm9vdA=="))
}, { it.loadKeyLogPin() })!!
val restored =
roundTrip({
it.saveKeyLogPin(Vault.KeyLogPin(size = 128, root = "cm9vdA=="))
}, { it.loadKeyLogPin() })!!
assertEquals(128, restored.size)
assertEquals("cm9vdA==", restored.root)
}
@@ -210,8 +223,11 @@ class VaultPersistenceTest {
@Test
fun `the pinned issuer key survives a restart`() {
val issuer = ByteArray(32) { it.toByte() }
val restored = roundTrip({ it.saveKeyLogIssuerPin(issuer) },
{ it.keyLogIssuerPin() })
val restored =
roundTrip(
{ it.saveKeyLogIssuerPin(issuer) },
{ it.keyLogIssuerPin() },
)
assertContentEquals(issuer, restored)
}
@@ -220,13 +236,16 @@ class VaultPersistenceTest {
assertNull(Vault(MapStorage(), key()).keyLogIssuerPin())
}
// -- Roster --------------------------------------------------------------
// -- Roster --------------------------------------------------------------.
@Test
fun `a roster survives a restart with its off-server flag`() {
val items = JSONArray().put(JSONObject().put("id", 1).put("name", "ada"))
val (readItems, offServer) = roundTrip({ it.saveRoster(items, offServer = true) },
{ it.loadRoster() })
val (readItems, offServer) =
roundTrip(
{ it.saveRoster(items, offServer = true) },
{ it.loadRoster() },
)
assertEquals(1, readItems.length())
assertEquals("ada", readItems.getJSONObject(0).getString("name"))
assertTrue(offServer)
@@ -239,12 +258,19 @@ class VaultPersistenceTest {
assertTrue(!offServer)
}
// -- Avatars -------------------------------------------------------------
// -- Avatars -------------------------------------------------------------.
private fun ref(id: String = "blob-1") = Attachment.Ref(
kind = Attachment.IMAGE, id = id, key = ByteArray(32) { 4 },
digest = ByteArray(32) { 5 }, bytes = 100, mime = "image/jpeg",
width = 64, height = 64)
private fun ref(id: String = "blob-1") =
Attachment.Ref(
kind = Attachment.IMAGE,
id = id,
key = ByteArray(32) { 4 },
digest = ByteArray(32) { 5 },
bytes = 100,
mime = "image/jpeg",
width = 64,
height = 64,
)
@Test
fun `a contact's avatar survives a restart`() {
@@ -255,7 +281,8 @@ class VaultPersistenceTest {
@Test
fun `clearing a contact's avatar is remembered`() {
val storage = MapStorage(); val secret = key()
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveAvatar(11, ref())
Vault(storage, secret).saveAvatar(11, null)
assertNull(Vault(storage, secret).avatarFor(11))
@@ -269,29 +296,38 @@ class VaultPersistenceTest {
/**
* Whether a peer already has our avatar is what stops us re-sending it on
* every message. Forgetting it across a restart turns a one-time upload
* into a per-launch one.
* every message.
*/
@Test
fun `who we have already shared an avatar with is remembered`() {
val restored = roundTrip({ it.markAvatarShared(11, "blob-1") },
{ it.avatarSharedWith(11) })
val restored =
roundTrip(
{ it.markAvatarShared(11, "blob-1") },
{ it.avatarSharedWith(11) },
)
assertEquals("blob-1", restored)
}
// -- History deletion ----------------------------------------------------
// -- History deletion ----------------------------------------------------.
private fun line(at: Long, mine: Boolean, text: String) =
JSONObject().put("at", at).put("mine", mine).put("text", text)
private fun line(
at: Long,
mine: Boolean,
text: String,
) = JSONObject().put("at", at).put("mine", mine).put("text", text)
@Test
fun `deleting one line leaves the rest of the thread intact`() {
val storage = MapStorage(); val secret = key()
val storage = MapStorage()
val secret = key()
val v = Vault(storage, secret)
v.replaceHistory("t", JSONArray()
.put(line(1, true, "first"))
.put(line(2, false, "second"))
.put(line(3, true, "third")))
v.replaceHistory(
"t",
JSONArray()
.put(line(1, true, "first"))
.put(line(2, false, "second"))
.put(line(3, true, "third")),
)
Vault(storage, secret).deleteLine("t", at = 2, mine = false, text = "second")
@@ -307,11 +343,15 @@ class VaultPersistenceTest {
*/
@Test
fun `deleting is precise about which line it removes`() {
val storage = MapStorage(); val secret = key()
Vault(storage, secret).replaceHistory("t", JSONArray()
.put(line(5, true, "same ms mine"))
.put(line(5, false, "same ms theirs"))
.put(line(5, true, "same ms mine too")))
val storage = MapStorage()
val secret = key()
Vault(storage, secret).replaceHistory(
"t",
JSONArray()
.put(line(5, true, "same ms mine"))
.put(line(5, false, "same ms theirs"))
.put(line(5, true, "same ms mine too")),
)
Vault(storage, secret).deleteLine("t", at = 5, mine = true, text = "same ms mine")
@@ -323,7 +363,8 @@ class VaultPersistenceTest {
@Test
fun `deleting a line that is not there changes nothing`() {
val storage = MapStorage(); val secret = key()
val storage = MapStorage()
val secret = key()
Vault(storage, secret).replaceHistory("t", JSONArray().put(line(1, true, "only")))
Vault(storage, secret).deleteLine("t", at = 99, mine = true, text = "absent")
assertEquals(1, Vault(storage, secret).history("t").length())
@@ -332,7 +373,8 @@ class VaultPersistenceTest {
/** "Deleted means deleted", the record goes, not a tombstone. */
@Test
fun `forgetting a thread removes its record entirely`() {
val storage = MapStorage(); val secret = key()
val storage = MapStorage()
val secret = key()
Vault(storage, secret).replaceHistory("t", JSONArray().put(line(1, true, "hi")))
val before = storage.names().size
Vault(storage, secret).forgetHistory("t")
@@ -12,23 +12,36 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue
class VaultTest {
/** Stands in for a directory on a phone, and lets a test see the raw bytes. */
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun bundleFor(identity: DeviceIdentity): PeerBundle {
val oneTime = identity.generateOneTimePreKeys(1).first()
return PeerBundle.fromWire(identity.registrationWire().put("opk",
JSONObject().put("id", oneTime.id).put("pub", Bytes.b64(oneTime.publicKey))))
return PeerBundle.fromWire(
identity.registrationWire().put(
"opk",
JSONObject().put("id", oneTime.id).put("pub", Bytes.b64(oneTime.publicKey)),
),
)
}
@Test
@@ -71,16 +84,6 @@ class VaultTest {
/**
* The chain a re-establishment replaces must survive it.
*
* Both sides open the chat at once, so each runs X3DH before seeing the
* other's prekey and each adopts the other's chain to read what arrived.
* From then on the peer sends on the chain being replaced. A client that
* stored only the newest session could not open any of it, silently,
* because an envelope that will not decrypt is skipped, not reported.
*
* This is the storage half of the fix. `E2ESessionTest` covers the engine
* primitives, and passes with or without it, because a test holding both
* sessions in local variables never exercises what a client keeps.
*/
@Test
fun `a retired session is kept and still opens its chain`() {
@@ -111,27 +114,20 @@ class VaultTest {
val reply = onTheRetiredChain.encrypt("bob, this is the old chain")
val fresh = Vault(storage, secret)
assertNull(runCatching { fresh.loadSession(bob.deviceId)!!.decrypt(reply) }.getOrNull(),
assertNull(
runCatching { fresh.loadSession(bob.deviceId)!!.decrypt(reply) }.getOrNull(),
"the adopted session must NOT open the retired chain, otherwise this " +
"test would pass without the retired copy existing at all")
assertEquals("bob, this is the old chain",
fresh.loadRetiredSession(bob.deviceId)!!.decrypt(reply))
"test would pass without the retired copy existing at all",
)
assertEquals(
"bob, this is the old chain",
fresh.loadRetiredSession(bob.deviceId)!!.decrypt(reply),
)
}
/**
* A failed decrypt damages the session object it was handed, so the
* thing that gets retired must be re-read from the vault.
*
* This is the bug the first version of the retirement fix shipped with, and
* the reason `a retired session is kept and still opens its chain` did not
* catch it: that test retires a PRISTINE session, which is not what the
* receive path has in its hand. The real path has just proved the peer is on
* another chain, by handing this session an envelope it could not open,
* and `E2ESession` is a class, so that attempt consumed skipped keys and
* stepped the chain. Retiring THAT instance stores something that can no
* longer open anything, which looks exactly like not retiring at all.
*
* Asserts both halves: the mutated instance fails, the re-read one works.
* A failed decrypt damages the session object it was handed, so the thing
* that gets retired must be re-read from the vault.
*/
@Test
fun `the session retired after a failed decrypt must be re-read, not reused`() {
@@ -140,40 +136,49 @@ class VaultTest {
val alice = DeviceIdentity.create()
val bob = DeviceIdentity.create()
// Bob's own chain, stored, with a message Alice will later answer on it.
// Bob's own chain, stored, with a message Alice will later answer on
// it.
val bobsOwn = E2ESession.initiate(bob, bundleFor(alice))
val bobsFirst = bobsOwn.encrypt("from bob")
Vault(storage, secret).saveSession(bob.deviceId, bobsOwn)
val alicesReplyOnBobsChain = E2ESession.respond(alice, bobsFirst).let {
it.decrypt(bobsFirst)
it.encrypt("answering on the chain bob started")
}
val alicesReplyOnBobsChain =
E2ESession.respond(alice, bobsFirst).let {
it.decrypt(bobsFirst)
it.encrypt("answering on the chain bob started")
}
// The receive path: Bob's stored session is handed a foreign envelope
// and fails. THIS is the instance the buggy version retired.
// and fails.
val foreign = E2ESession.initiate(alice, bundleFor(bob)).encrypt("different chain")
val damaged = Vault(storage, secret).loadSession(bob.deviceId)!!
assertNull(runCatching { damaged.decrypt(foreign) }.getOrNull(),
"the foreign envelope must NOT open, or this test proves nothing")
assertNull(
runCatching { damaged.decrypt(foreign) }.getOrNull(),
"the foreign envelope must NOT open, or this test proves nothing",
)
// Retiring the damaged instance loses the chain.
val wrong = MapStorage().also { storage.records.forEach(it.records::put) }
Vault(wrong, secret).saveRetiredSession(bob.deviceId, damaged)
assertNull(
runCatching {
Vault(wrong, secret).loadRetiredSession(bob.deviceId)!!
Vault(wrong, secret)
.loadRetiredSession(bob.deviceId)!!
.decrypt(alicesReplyOnBobsChain)
}.getOrNull(),
"a session damaged by a failed decrypt should NOT still open its chain, " +
"if this ever starts passing, the hazard is gone and this test can go")
"if this ever starts passing, the hazard is gone and this test can go",
)
// Re-reading the vault copy keeps it. This is the fix.
// Re-reading the vault copy keeps it.
val pristine = Vault(storage, secret).loadSession(bob.deviceId)!!
Vault(storage, secret).saveRetiredSession(bob.deviceId, pristine)
assertEquals("answering on the chain bob started",
Vault(storage, secret).loadRetiredSession(bob.deviceId)!!
.decrypt(alicesReplyOnBobsChain))
assertEquals(
"answering on the chain bob started",
Vault(storage, secret)
.loadRetiredSession(bob.deviceId)!!
.decrypt(alicesReplyOnBobsChain),
)
}
/** Signing out must take the retired chains too, they hold chain keys. */
@@ -192,8 +197,10 @@ class VaultTest {
assertNull(Vault(storage, secret).loadSession(bob.deviceId))
assertNull(Vault(storage, secret).loadRetiredSession(bob.deviceId))
assertTrue(storage.records.keys.none { it.contains("session") },
"a record still names a session: ${storage.records.keys}")
assertTrue(
storage.records.keys.none { it.contains("session") },
"a record still names a session: ${storage.records.keys}",
)
}
/** What is on disk must not be readable, or none of this was worth doing. */
@@ -210,10 +217,7 @@ class VaultTest {
}
/**
* The failure this catches: a stored blob moved from one slot to
* another. Without the name in the AAD, a peer's session pasted into a
* different peer's slot decrypts perfectly and the app attributes their
* messages to the wrong person.
* The failure this catches: a stored blob moved from one slot to another.
*/
@Test
fun `a record moved to another slot does not open`() {
@@ -251,10 +255,7 @@ class VaultTest {
}
/**
* A corrupt identity must NOT read as "no identity". The caller treats
* null as a new install and mints a fresh device, which every contact sees
* as "security keys changed", the warning that is supposed to mean an
* attack. Absent and unreadable are different answers.
* A corrupt identity must NOT read as "no identity".
*/
@Test
fun `an unreadable identity is not mistaken for a missing one`() {
@@ -305,8 +306,7 @@ class VaultTest {
@Test
fun `two writes of the same value do not produce the same bytes`() {
// A fresh nonce per write. Repeating one under the same key is the
// catastrophic AES-GCM failure, not a degraded one.
// A fresh nonce per write.
val storage = MapStorage()
val secret = key()
val identity = DeviceIdentity.create()
@@ -316,23 +316,10 @@ class VaultTest {
assertNotEquals(Bytes.b64(first), Bytes.b64(storage.records.getValue("identity")))
}
// ---- Trust records and the platform's JSON ---------------------------
// ---- Trust records and the platform's JSON ---------------------------.
/**
* A contact with no known address must come back with NO address.
*
* `saveTrust` used to write `"fnid": null` explicitly, and Android's
* bundled `org.json` reads a JSON null back as the four-character string
* `"null"`, a non-null value that is not an address. `Trust.pin` compares a
* newly offered fnid against the pinned one and treats a difference as a
* different person, so the first time a real address arrived the contact was
* marked CHANGED: the "security keys changed" warning, which is meant to
* mean an attack, fired on an ordinary first sighting.
*
* The JVM's `org.json` returns `""` there, so this passed on a laptop and
* failed on a phone. The two tests below cover both halves, what is
* written now, and what has to be tolerated because it is already on
* devices in the field.
*/
@Test
fun `a trust record with no address writes no address at all`() {
@@ -340,39 +327,35 @@ class VaultTest {
val secret = key()
Vault(storage, secret).saveTrust(7, Vault.Trust(listOf("aaa"), verified = false, fnid = null))
assertNull(Vault(storage, secret).loadTrust(7)?.fnid,
"an absent address came back as a value")
assertNull(
Vault(storage, secret).loadTrust(7)?.fnid,
"an absent address came back as a value",
)
}
/**
* This is the test that reproduces the device, and the one above is
* not. On the JVM a real JSON null reads back as `""` whether the writer
* omits the key or not, so the round trip above passes with the bug intact.
* Handing the reader the literal string is what Android actually does, so
* writing it here is what makes a laptop run tell the truth.
* This is the test that reproduces the device, and the one above is not.
*/
@Test
fun `a record already holding the literal string null reads as no address`() {
// Records written the old way exist on phones already. The reader has
// to repair them, or those contacts stay permanently mis-addressed,
// `rememberIdentity` returns early once `fnid != null`, so nothing would
// ever ask again.
// Records written the old way exist on phones already.
val storage = MapStorage()
val secret = key()
val vault = Vault(storage, secret)
vault.saveTrust(7, Vault.Trust(listOf("aaa"), verified = false, fnid = "null"))
assertNull(Vault(storage, secret).loadTrust(7)?.fnid,
"the literal string \"null\" was accepted as somebody's global address")
assertNull(
Vault(storage, secret).loadTrust(7)?.fnid,
"the literal string \"null\" was accepted as somebody's global address",
)
}
@Test
fun `a real address still round-trips`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveTrust(
7, Vault.Trust(listOf("aaa"), verified = true, fnid = "fnid:abc123"))
Vault(storage, secret).saveTrust(7, Vault.Trust(listOf("aaa"), verified = true, fnid = "fnid:abc123"))
val back = Vault(storage, secret).loadTrust(7)
assertEquals("fnid:abc123", back?.fnid)
assertTrue(back!!.verified)
@@ -10,35 +10,21 @@ import kotlin.test.assertTrue
/**
* BIP-39, Base58 and SLIP-0010 against the published vectors.
*
* These are pinned to the STANDARDS, not to the Swift engine. Testing
* one implementation against the other proves only that they agree, and two
* implementations agreeing on the wrong answer is exactly how a wallet ends up
* empty: a phrase written down on an iPhone has to restore on an Android
* handset, and on somebody else's software years from now. So the vectors here
* are BIP-39's own, SLIP-0010's own, and the published word-list digest, which
* the Swift engine is also held to.
*
* Every failure in this file is silent in production. A wrong checksum
* bit, a lost leading zero, a missed NFKD: none of them error. They derive a
* different, perfectly valid, empty wallet, and the way somebody finds out is
* restoring their phrase and seeing nothing there.
*/
class WalletPrimitivesTest {
private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) }
private fun unhex(text: String) = ByteArray(text.length / 2) {
text.substring(it * 2, it * 2 + 2).toInt(16).toByte()
}
private fun unhex(text: String) =
ByteArray(text.length / 2) {
text.substring(it * 2, it * 2 + 2).toInt(16).toByte()
}
private val words by lazy { Bip39.englishWordlist() }
// ---- The word list ---------------------------------------------------
// ---- The word list ---------------------------------------------------.
/**
* The whole mitigation for vendoring 2,048 strings. A silently corrupted
* list does not error, it produces a DIFFERENT wallet from the same
* phrase.
* The whole mitigation for vendoring 2,048 strings.
*/
@Test
fun `the vendored word list matches its published fingerprint`() {
@@ -55,7 +41,7 @@ class WalletPrimitivesTest {
assertFailsWith<Bip39.Failure.WordlistWrongLength> { Bip39.verify(words.drop(1)) }
}
// ---- BIP-39 vectors --------------------------------------------------
// ---- BIP-39 vectors --------------------------------------------------.
/**
* The canonical first vector from BIP-39's own test file: all-zero entropy.
@@ -83,16 +69,15 @@ class WalletPrimitivesTest {
val entropy = ByteArray(32) { 0xff.toByte() }
val phrase = Bip39.phrase(entropy, words)
assertEquals(24, phrase.split(" ").size)
assertEquals("zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo " +
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote", phrase)
assertEquals(
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo " +
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote",
phrase,
)
assertContentEquals(entropy, Bip39.entropy(phrase, words))
// This expectation was WRONG in the first version of this file, it
// carried the seed from a different BIP-39 vector, and the Kotlin
// answer below is the correct one. It was resolved by computing the
// value independently (PBKDF2-HMAC-SHA512, NFKD, 2048 rounds) rather
// than by adopting whatever the implementation produced, which is the
// only way a vector test is worth anything: a pinned value taken from
// the code under test pins the bug.
// answer below is the correct one.
assertEquals(
"dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e16" +
"13912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad",
@@ -114,9 +99,7 @@ class WalletPrimitivesTest {
}
/**
* The passphrase silently names a different wallet. That is BIP-39's
* design, and it is the sharpest edge in the whole standard, somebody who
* mistypes it does not get an error, they get an empty account.
* The passphrase silently names a different wallet.
*/
@Test
fun `a different BIP-39 passphrase derives a different seed`() {
@@ -125,7 +108,7 @@ class WalletPrimitivesTest {
assertNotEquals(hex(Bip39.seed(phrase, "a")), hex(Bip39.seed(phrase, "A")))
}
// ---- BIP-39 refusals -------------------------------------------------
// ---- BIP-39 refusals -------------------------------------------------.
@Test
fun `a mistyped word is refused rather than resolved to a neighbour`() {
@@ -145,7 +128,12 @@ class WalletPrimitivesTest {
@Test
fun `a phrase of the wrong length is refused`() {
val short = Bip39.phrase(ByteArray(16), words).split(" ").drop(1).joinToString(" ")
val short =
Bip39
.phrase(ByteArray(16), words)
.split(" ")
.drop(1)
.joinToString(" ")
assertFailsWith<Bip39.Failure.BadPhraseLength> { Bip39.entropy(short, words) }
}
@@ -165,14 +153,10 @@ class WalletPrimitivesTest {
)
}
// ---- Base58 ----------------------------------------------------------
// ---- Base58 ----------------------------------------------------------.
/**
* The leading-zero rule, which is where money goes missing. Base58 is
* base conversion of one integer, and an integer cannot hold a leading zero
* byte, the standard prepends one `1` per zero byte instead. An
* implementation using `BigInteger` loses them and encodes two different
* keys to one address.
* The leading-zero rule, which is where money goes missing.
*/
@Test
fun `leading zero bytes each become a one`() {
@@ -198,8 +182,11 @@ class WalletPrimitivesTest {
ByteArray(32) { 0xff.toByte() },
ByteArray(32),
)) {
assertContentEquals(case, Base58.decode(Base58.encode(case)),
"round trip lost ${hex(case)}")
assertContentEquals(
case,
Base58.decode(Base58.encode(case)),
"round trip lost ${hex(case)}",
)
}
}
@@ -213,8 +200,7 @@ class WalletPrimitivesTest {
}
/**
* A mistyped address must FAIL, not resolve to somebody else's. `0`,
* `O`, `I` and `l` are absent from the alphabet for exactly this reason.
* A mistyped address must FAIL, not resolve to somebody else's.
*/
@Test
fun `a character outside the alphabet decodes to nothing`() {
@@ -223,12 +209,10 @@ class WalletPrimitivesTest {
assertNull(Base58.decode("abc+def"))
}
// ---- SLIP-0010 vectors -----------------------------------------------
// ---- SLIP-0010 vectors -----------------------------------------------.
/**
* SLIP-0010's own ed25519 test vector one. The chain code is checked as
* well as the key: a derivation that gets the key right and the chain code
* wrong produces correct CHILDREN of nothing, and diverges one level down.
* SLIP-0010's own ed25519 test vector one.
*/
@Test
fun `slip10 ed25519 vector one`() {
@@ -263,10 +247,11 @@ class WalletPrimitivesTest {
/** SLIP-0010's ed25519 vector two, with a longer seed. */
@Test
fun `slip10 ed25519 vector two`() {
val seed = unhex(
"fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a2" +
"9f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542"
)
val seed =
unhex(
"fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a2" +
"9f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542",
)
val master = HdKey.master(seed)
assertEquals(
"171cb88b1b3c1db25add599712e36245d75bc65a1a5c9e18d76f9f2b1eab4012",
@@ -281,10 +266,6 @@ class WalletPrimitivesTest {
/**
* A path without hardening is REFUSED, not silently hardened.
* SLIP-0010 defines no non-hardened derivation for ed25519. Somebody
* writing `m/44` means non-hardened; quietly handing them a different key
* than every other wallet would give them an address nobody else can
* reproduce from the same phrase.
*/
@Test
fun `an unhardened path component is refused`() {
@@ -315,8 +296,7 @@ class WalletPrimitivesTest {
}
/**
* Derivation is a pure function of seed and path. Sounds obvious; it is
* the property a restored wallet depends on entirely.
* Derivation is a pure function of seed and path.
*/
@Test
fun `the same phrase and path always give the same key`() {
@@ -333,9 +313,6 @@ class WalletPrimitivesTest {
/**
* The BIP-44 shape the wallet actually uses, end to end from a phrase.
* Not a published vector, the point is that the whole chain runs and that
* successive accounts differ, which is what "a fresh address per receive"
* rests on.
*/
@Test
fun `a phrase walks the wallet path and gives distinct keys per account`() {
@@ -10,28 +10,22 @@ import kotlin.test.assertTrue
/**
* Creating, importing and reading back a wallet.
*
* The property worth guarding hardest is a negative one: the wallet must
* NOT be derivable from the account. Deriving it from the passphrase would be
* convenient, would need no backup at all, and would tie every payment somebody
* ever received to their handle on a public ledger for ever. There is no way to
* undo that later, the chain does not forget, so it is asserted rather than
* left to good intentions.
*/
class WalletTest {
private val words by lazy { Bip39.englishWordlist() }
/** Deterministic randomness, so a create() can be asserted against. */
private fun fixedRandom(fill: Byte) = object : SecureRandom() {
override fun nextBytes(bytes: ByteArray) { bytes.fill(fill) }
}
private fun fixedRandom(fill: Byte) =
object : SecureRandom() {
override fun nextBytes(bytes: ByteArray) {
bytes.fill(fill)
}
}
// ---- What a wallet is not --------------------------------------------
// ---- What a wallet is not --------------------------------------------.
/**
* Two wallets made in a row share nothing. If `create` ever started
* deriving from something about the account, this is what would catch it.
* Two wallets made in a row share nothing.
*/
@Test
fun `every wallet created is a different one`() {
@@ -41,8 +35,22 @@ class WalletTest {
@Test
fun `a created wallet is 24 words by default and can be 12`() {
assertEquals(24, Wallet.create(words).phrase.split(" ").size)
assertEquals(12, Wallet.create(words, entropyBytes = 16).phrase.split(" ").size)
assertEquals(
24,
Wallet
.create(words)
.phrase
.split(" ")
.size,
)
assertEquals(
12,
Wallet
.create(words, entropyBytes = 16)
.phrase
.split(" ")
.size,
)
}
/** The entropy really is what the phrase encodes, not a parallel value. */
@@ -53,23 +61,31 @@ class WalletTest {
assertEquals(made.phrase, Wallet.phrase(made.entropy, words))
}
// ---- Importing -------------------------------------------------------
// ---- Importing -------------------------------------------------------.
/**
* Validated before it is stored. A phrase that fails its checksum is
* refused here rather than saved and discovered later, when the wallet it
* names turns out to be empty and nobody can say why.
* Validated before it is stored.
*/
@Test
fun `a phrase that does not check out is refused at import`() {
val real = Wallet.create(words).phrase
val broken = real.split(" ").toMutableList().also { it[0] = it[1] }.joinToString(" ")
val broken =
real
.split(" ")
.toMutableList()
.also { it[0] = it[1] }
.joinToString(" ")
assertFailsWith<Bip39.Failure.ChecksumFailed> { Wallet.import(broken, words) }
}
@Test
fun `a phrase containing a word that is not on the list is refused`() {
val real = Wallet.create(words).phrase.split(" ").toMutableList()
val real =
Wallet
.create(words)
.phrase
.split(" ")
.toMutableList()
real[3] = "flamenet"
assertFailsWith<Bip39.Failure.UnknownWord> {
Wallet.import(real.joinToString(" "), words)
@@ -88,12 +104,10 @@ class WalletTest {
)
}
// ---- The counter -----------------------------------------------------
// ---- The counter -----------------------------------------------------.
/**
* A restored wallet resumes at the index it was left at, when one is
* known. Resuming at zero would hand out addresses somebody has already
* been paid at, undoing the one linkability the wallet can prevent.
* A restored wallet resumes at the index it was left at, when one is known.
*/
@Test
fun `an account can resume from a stored index`() {
@@ -107,12 +121,10 @@ class WalletTest {
)
}
// ---- The BIP-39 passphrase -------------------------------------------
// ---- The BIP-39 passphrase -------------------------------------------.
/**
* A BIP-39 passphrase silently names a DIFFERENT wallet. That is the
* standard's design and the sharpest edge in it: somebody who mistypes one
* does not get an error, they get an empty account.
* A BIP-39 passphrase silently names a DIFFERENT wallet.
*/
@Test
fun `a BIP-39 passphrase produces a different wallet from the same phrase`() {
@@ -122,7 +134,7 @@ class WalletTest {
assertNotEquals(plain, guarded)
}
// ---- End to end ------------------------------------------------------
// ---- End to end ------------------------------------------------------.
/**
* The whole promise, in one test: write these words down, and any
@@ -131,15 +143,17 @@ class WalletTest {
@Test
fun `a phrase written down restores the same wallet`() {
val made = Wallet.create(words, random = fixedRandom(0x11))
val addresses = (0L..3L).map {
Wallet.account(made.entropy, words).receive(it).address
}
val addresses =
(0L..3L).map {
Wallet.account(made.entropy, words).receive(it).address
}
// Somebody types the words into a fresh install.
val restored = Wallet.import(made.phrase, words)
val restoredAddresses = (0L..3L).map {
Wallet.account(restored, words).receive(it).address
}
val restoredAddresses =
(0L..3L).map {
Wallet.account(restored, words).receive(it).address
}
assertEquals(addresses, restoredAddresses)
assertTrue(addresses.all { HdAccount.looksLikeAddress(it) })
@@ -11,20 +11,12 @@ import kotlin.test.assertTrue
* wire depends on.
*/
class X25519Test {
private fun hex(s: String): ByteArray =
s.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun hex(s: String): ByteArray = s.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
/**
* RFC 7748 §6.1, the worked Diffie-Hellman example.
*
* Only the public halves are checked here: the JCA will not construct an
* X25519 private key from raw scalar bytes without a PKCS#8 wrapper, and
* building one by hand to check a number the RFC already publishes would be
* testing the wrapper rather than the agreement. The agreement itself is
* checked by the symmetry test below, which is the property that matters.
*/
@Test
fun `rfc 7748 public keys survive the raw to DER round trip`() {
@@ -32,8 +24,11 @@ class X25519Test {
val bobPub = hex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f")
for (raw in listOf(alicePub, bobPub)) {
val key = X25519.publicKeyFromRaw(raw)
assertContentEquals(raw, X25519.rawFromPublicKey(key),
"raw -> DER -> raw must be the identity")
assertContentEquals(
raw,
X25519.rawFromPublicKey(key),
"raw -> DER -> raw must be the identity",
)
}
}
@@ -71,22 +66,18 @@ class X25519Test {
}
/**
* RFC 7748 §6.1 lists the small-order points. A peer sending one forces the
* shared secret to all zeros, which both sides would then happily derive
* keys from, and the sender knows it in advance.
*
* The RFC says implementations MAY check. When the peer's key arrives over
* a relay nobody trusts, that is not optional.
* RFC 7748 §6.1 lists the small-order points.
*/
@Test
fun `small-order peer keys are refused, not silently accepted`() {
val alice = X25519.generate()
val smallOrder = listOf(
"0000000000000000000000000000000000000000000000000000000000000000",
"0100000000000000000000000000000000000000000000000000000000000000",
"e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800",
"5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157",
)
val smallOrder =
listOf(
"0000000000000000000000000000000000000000000000000000000000000000",
"0100000000000000000000000000000000000000000000000000000000000000",
"e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800",
"5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157",
)
for (point in smallOrder) {
val thrown = runCatching { X25519.agree(alice.private, hex(point)) }.exceptionOrNull()
assertTrue(
@@ -7,16 +7,8 @@ import kotlin.test.assertTrue
/**
* X3DH, both directions.
*
* The property that matters is agreement: Alice computes a secret from her
* private keys and Bob's public bundle, Bob computes one from his private keys
* and what Alice sent, and the two must be identical. A wrong DH order, a
* swapped argument, or a zero-filled DH4 all still produce 32 plausible bytes,
* they just produce *different* 32 bytes on each side, which is why this checks
* equality rather than shape.
*/
class X3DHTest {
private fun raw(kp: java.security.KeyPair) = X25519.rawFromPublicKey(kp.public)
@Test
@@ -26,21 +18,24 @@ class X3DHTest {
val bobSigned = X25519.generate()
val bobOneTime = X25519.generate()
val alice = X3DH.initiate(
identityPrivateDH = aliceIdentity.private,
bundle = X3DH.Bundle(
identityKeyDH = raw(bobIdentity),
signedPreKey = raw(bobSigned),
oneTimePreKey = raw(bobOneTime),
),
)
val bob = X3DH.respond(
identityPrivateDH = bobIdentity.private,
signedPreKeyPrivate = bobSigned.private,
oneTimePrivate = bobOneTime.private,
initiatorIdentityDH = raw(aliceIdentity),
initiatorEphemeral = alice.ephemeralPublic,
)
val alice =
X3DH.initiate(
identityPrivateDH = aliceIdentity.private,
bundle =
X3DH.Bundle(
identityKeyDH = raw(bobIdentity),
signedPreKey = raw(bobSigned),
oneTimePreKey = raw(bobOneTime),
),
)
val bob =
X3DH.respond(
identityPrivateDH = bobIdentity.private,
signedPreKeyPrivate = bobSigned.private,
oneTimePrivate = bobOneTime.private,
initiatorIdentityDH = raw(aliceIdentity),
initiatorEphemeral = alice.ephemeralPublic,
)
assertContentEquals(alice.sharedSecret, bob, "the two sides disagree")
assertTrue(alice.sharedSecret.size == 32)
}
@@ -48,23 +43,24 @@ class X3DHTest {
@Test
fun `both sides agree when the one-time prekey pool is drained`() {
// A degraded but valid state: initial forward secrecy is lost, and the
// session must still work. Omitting DH4 on one side while the other
// zero-fills it is the classic way to break exactly this case.
// session must still work.
val aliceIdentity = X25519.generate()
val bobIdentity = X25519.generate()
val bobSigned = X25519.generate()
val alice = X3DH.initiate(
identityPrivateDH = aliceIdentity.private,
bundle = X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null),
)
val bob = X3DH.respond(
identityPrivateDH = bobIdentity.private,
signedPreKeyPrivate = bobSigned.private,
oneTimePrivate = null,
initiatorIdentityDH = raw(aliceIdentity),
initiatorEphemeral = alice.ephemeralPublic,
)
val alice =
X3DH.initiate(
identityPrivateDH = aliceIdentity.private,
bundle = X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null),
)
val bob =
X3DH.respond(
identityPrivateDH = bobIdentity.private,
signedPreKeyPrivate = bobSigned.private,
oneTimePrivate = null,
initiatorIdentityDH = raw(aliceIdentity),
initiatorEphemeral = alice.ephemeralPublic,
)
assertContentEquals(alice.sharedSecret, bob)
}
@@ -79,16 +75,18 @@ class X3DHTest {
val bobOneTime = X25519.generate()
val ephemeral = X25519.generate()
val withOpk = X3DH.initiate(
aliceIdentity.private,
X3DH.Bundle(raw(bobIdentity), raw(bobSigned), raw(bobOneTime)),
ephemeral,
)
val withoutOpk = X3DH.initiate(
aliceIdentity.private,
X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null),
ephemeral,
)
val withOpk =
X3DH.initiate(
aliceIdentity.private,
X3DH.Bundle(raw(bobIdentity), raw(bobSigned), raw(bobOneTime)),
ephemeral,
)
val withoutOpk =
X3DH.initiate(
aliceIdentity.private,
X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null),
ephemeral,
)
assertFalse(withOpk.sharedSecret.contentEquals(withoutOpk.sharedSecret))
}
@@ -100,15 +98,16 @@ class X3DHTest {
val malloryIdentity = X25519.generate()
val mallorySigned = X25519.generate()
val toBob = X3DH.initiate(
aliceIdentity.private, X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null))
val malloryTries = X3DH.respond(
identityPrivateDH = malloryIdentity.private,
signedPreKeyPrivate = mallorySigned.private,
oneTimePrivate = null,
initiatorIdentityDH = raw(aliceIdentity),
initiatorEphemeral = toBob.ephemeralPublic,
)
val toBob =
X3DH.initiate(aliceIdentity.private, X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null))
val malloryTries =
X3DH.respond(
identityPrivateDH = malloryIdentity.private,
signedPreKeyPrivate = mallorySigned.private,
oneTimePrivate = null,
initiatorIdentityDH = raw(aliceIdentity),
initiatorEphemeral = toBob.ephemeralPublic,
)
assertFalse(toBob.sharedSecret.contentEquals(malloryTries))
}
}