Remove machine-writing tells from comments and docs

Emoji markers, markdown bold inside code comments, and em-dashes are
gone from source comments, string literals, scripts, Gradle files and
the markdown docs. Counted before and after:

  emoji markers   2680 -> 1 (the emoji picker's own list, which is the feature)
  em-dashes       2490 -> 9 (table placeholders and fenced code)

Two warning glyphs in user-facing strings in ChatScreens.kt are removed
as well; the sentences say the same thing without them. Two backtick
test names carried an em-dash and now carry a comma.

Nothing else changed. 1208 tests pass, counted from the result XML.
The three obfs4 interop tests are opted out with OBFS4_INTEROP_OPTOUT=1
for this run, as their own message instructs when no bridge is set up.

CONTRIBUTING gets a four-line rule so it does not grow back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-08 18:52:12 -04:00
co-authored by Claude Fable 5.1
parent 04e06452b6
commit 4dd544083c
277 changed files with 4885 additions and 4883 deletions
@@ -16,8 +16,8 @@ import kotlin.test.assertTrue
* 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 —
* 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.
*/
@@ -77,7 +77,7 @@ class AccountKdfTest {
val identity = AccountKdf.identityKey(pass, salt)
val account = AccountKdf.accountKeySeed(pass, salt)
// Same passphrase, same salt, same stretch — three unrelated secrets.
// 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")
@@ -86,8 +86,8 @@ class AccountKdfTest {
assertEquals(32, account.size)
}
// 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
// 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
@@ -11,7 +11,7 @@ import kotlin.test.assertTrue
* depends on.
*
* The vectors are from NIST CAVP `gcmEncryptExtIV256`, not from our other
* engines — same reasoning as [HkdfTest]. The layout assertions ARE about our
* 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.
*/
@@ -12,9 +12,9 @@ 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
* **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
* 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.
@@ -61,10 +61,10 @@ class AttachmentsInUseTest {
}
/**
* 🔴 **The archive is the case that makes a naive sweep destructive.**
* 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
* would leave somebody's own archive full of blank pictures, the exact
* thing the archive exists to prevent.
*/
@Test
@@ -77,17 +77,17 @@ class AttachmentsInUseTest {
val inUse = Vault(storage, k).attachmentIdsInUse()
assertTrue("expired-blob" in inUse,
"an archived story's attachment was reported unused — the archive would go blank")
"an archived story's attachment was reported unused, the archive would go blank")
}
/**
* 🔴 **Avatars are attachments too.** They are fetched and cached by the
* 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.
*/
@Test
fun `avatars count — both a contact's and this account's own`() {
fun `avatars count, both a contact's and this account's own`() {
val storage = MapStorage(); val k = key()
val v = Vault(storage, k)
v.saveAvatar(42L, ref("their-face"))
@@ -101,9 +101,9 @@ class AttachmentsInUseTest {
// ---- Things that look like refs and are not ---------------------------
/**
* ⚠️ A sticker carries `pack` + `id`, and a story SET carries `id` + `i` +
* 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
* would pull both in, harmless for a set, but it would keep files alive
* forever under ids that name nothing.
*/
@Test
@@ -131,7 +131,7 @@ class AttachmentsInUseTest {
// ---- Failing safe -----------------------------------------------------
/**
* 🔴 **An unreadable record must throw, not be skipped.** Skipping it
* 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.
@@ -9,10 +9,10 @@ import kotlin.test.assertTrue
/**
* Snooze and reply reminders, at the vault.
*
* ⚠️ **Both live here and nowhere else.** "I am ignoring this person until
* 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
* that neither follows you to a new device, the alternative is telling the
* relay who you are avoiding.
*/
class AttentionStoreTest {
@@ -43,7 +43,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
// 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.
assertFalse("t" in v.snoozedThreads(now + 120_000))
@@ -77,9 +77,9 @@ class AttentionStoreTest {
}
/**
* 🔴 A reminder must be clearable by a reply ARRIVING, not only by firing.
* 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.
* worse than none, it teaches people to ignore the reminder.
*/
@Test
fun `a reminder can be cancelled before it fires`() {
@@ -10,9 +10,9 @@ 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
* 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,
* 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.
@@ -33,8 +33,8 @@ 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
* `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.
@@ -47,7 +47,7 @@ class BridgeOfferTest {
}
/**
* 🔴 **What an older build does with this.** A build that has never heard
* 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.
*/
@@ -59,7 +59,7 @@ class BridgeOfferTest {
assertTrue(Content.Kind.BRIDGE in Content.Kind.CONTROL)
}
/** ⚠️ The line crosses whole. Rebuilding it from parsed fields at the far
/** 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. */
@Test
@@ -68,7 +68,7 @@ class BridgeOfferTest {
assertEquals(awkward, Content.bridgeLine(Content.parse(Content.bridgeOffer(awkward))))
}
/** ⚠️ Asked of the wrong kind, it says nothing rather than guessing. */
/** Asked of the wrong kind, it says nothing rather than guessing. */
@Test
fun `another kind carries no bridge line`() {
assertNull(Content.bridgeLine(Content.parse(Content.disappearing(60))))
@@ -76,7 +76,7 @@ class BridgeOfferTest {
}
/**
* ⚠️ An empty or whitespace line is not an offer. Storing one would put an
* 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.
*/
@Test
@@ -85,10 +85,10 @@ class BridgeOfferTest {
}
/**
* 🔴 **The engine validates nothing, deliberately.** Whether a line is
* 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.
* silence, the exact failure the settings screen was built to end.
*/
@Test
fun `a malformed line still arrives, to be judged where it can be explained`() {
@@ -9,9 +9,9 @@ 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
* 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
* 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 {
@@ -44,7 +44,7 @@ class BridgeOfferVaultTest {
}
/**
* ⚠️ One offer per contact. An operator who rotates a bridge sends a new
* 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.
*/
@Test
@@ -75,9 +75,9 @@ class BridgeOfferVaultTest {
}
/**
* 🔴 **The load-bearing one.** Offers live in their own record and touch
* 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
* 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.
*/
@@ -92,7 +92,7 @@ class BridgeOfferVaultTest {
"an offer touched something other than the offers record")
}
/** ⚠️ Reading offers on a fresh device is empty, not a crash. */
/** Reading offers on a fresh device is empty, not a crash. */
@Test
fun `no offers reads as empty`() {
assertTrue(vault().bridgeOffers().isEmpty())
@@ -8,13 +8,13 @@ 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,
* 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
* 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.
@@ -45,7 +45,7 @@ class ContactCardTest {
}
/**
* ⚠️ Kept only so an older card still parses. Nothing reads it and nothing
* 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.
@@ -62,8 +62,8 @@ 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
* 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.
*/
@Test
@@ -83,7 +83,7 @@ class ContactCardTest {
}
/**
* ⚠️ A second `@` means this is not one address. Splitting on the first
* A second `@` means this is not one address. Splitting on the first
* would silently address a different host than the string names.
*/
@Test
@@ -92,7 +92,7 @@ class ContactCardTest {
}
/**
* 🔴 Two spellings of one address would open two threads for one person, so
* Two spellings of one address would open two threads for one person, so
* everything that files a message goes through the same normaliser.
*/
@Test
@@ -22,7 +22,7 @@ class ContentTest {
)
/**
* ⚠️ The marker is a **prefix test on the bytes**, so `fnc` must be
* 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.
@@ -40,7 +40,7 @@ class ContentTest {
}
/**
* ⚠️ Plain 1:1 text stays raw UTF-8. A client predating content envelopes
* 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.
*/
@@ -89,13 +89,13 @@ class ContentTest {
assertEquals("m1", parsed.about?.messageId)
assertFalse(parsed.isRenderable)
// An empty emoji takes one back — a message, not an absence, because
// 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
* 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.
*/
@@ -138,10 +138,10 @@ class ContentTest {
}
/**
* 🔴 Forward compatibility for control traffic.
* 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
* 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.
@@ -156,8 +156,8 @@ 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
* 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.
*/
@Test
@@ -181,8 +181,8 @@ 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
* 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.
*/
@@ -203,8 +203,8 @@ 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 —
* `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.
*/
@Test
@@ -220,9 +220,9 @@ class ContentTest {
/**
* Link previews (§6.12).
*
* 🔴 The SENDER generates these and they ride inside the ciphertext. A card
* 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
* received a link, and roughly when they opened it, once per member on a
* group message.
*/
@Test
@@ -235,17 +235,17 @@ class ContentTest {
}
/**
* 🔴 The host comes from the URL, never the page. A card whose title claims
* 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.
*/
@Test
fun `the host comes from the url not the title`() {
val preview = Content.LinkPreview.of(
"https://evil.example/login", "Sign in — yourbank.com", "")
"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. */
/** 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(
@@ -254,7 +254,7 @@ class ContentTest {
assertEquals(Content.LinkPreview.MAX_DESC, preview.desc.length)
}
/** A preview with no title is not a preview — it would render as a blank card. */
/** 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", "")
@@ -307,9 +307,9 @@ 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
* 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.
*/
@Test
@@ -331,8 +331,8 @@ 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
* 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.
*/
@@ -8,9 +8,9 @@ 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`.**
* 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 —
* 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
@@ -37,9 +37,9 @@ class ControlFlagInteropTest {
}
/**
* ⚠️ Both shapes are accepted on the way IN. `org.json`'s `optBoolean` does
* 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
* 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.
*/
@@ -53,7 +53,7 @@ class ControlFlagInteropTest {
}
/**
* 🔴 The point of the flag: a control kind from a NEWER peer must be
* 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.
*/
@@ -11,7 +11,7 @@ import kotlin.test.assertNull
*
* 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
* 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 {
@@ -9,7 +9,7 @@ import kotlin.test.assertTrue
/**
* Disappearing messages (§6.11), at the vault.
*
* 🔴 **Expiry runs on read, and that is the point.** A sweep on a timer leaves
* Expiry runs on read, and that is the point. 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.
@@ -51,7 +51,7 @@ class DisappearingTest {
assertEquals("fresh", kept.getJSONObject(0).optString("text"))
}
/** ⚠️ Gone from disk, not merely filtered out of the answer. */
/** Gone from disk, not merely filtered out of the answer. */
@Test
fun `expiry is a deletion, not a filter`() {
val v = vault()
@@ -66,7 +66,7 @@ class DisappearingTest {
}
/**
* ⚠️ A message with no timestamp predates the field. An unknown age is not
* 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.
*/
@@ -98,7 +98,7 @@ class DisappearingTest {
assertEquals(0, v.disappearSeconds("t"))
}
/** Exactly at the boundary the message survives — off-by-one deletes data. */
/** Exactly at the boundary the message survives, off-by-one deletes data. */
@Test
fun `a message exactly at the cutoff survives`() {
val v = vault()
@@ -11,7 +11,7 @@ import kotlin.test.assertTrue
*
* 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
* 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 {
@@ -48,7 +48,7 @@ class DoubleRatchetTest {
}
/**
* The same pair, running protocol v3 — a fresh ML-KEM secret mixed into
* 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,
@@ -88,7 +88,7 @@ class DoubleRatchetTest {
// MARK: - Protocol v3, the post-quantum ratchet (§13)
/**
* 🔴 **The cross-engine contract, pinned as bytes.**
* 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
@@ -96,7 +96,7 @@ class DoubleRatchetTest {
* 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
* here, or a field being reordered, neither of which any conversation
* test can see, because both sides would drift together.
*/
@Test
@@ -116,7 +116,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
// 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.
assertEquals(
"{\"dh\":\"" + Bytes.b64(dh) + "\",\"pn\":0,\"n\":0}",
@@ -137,7 +137,7 @@ class DoubleRatchetTest {
}
/**
* 🔴 **The property v3 exists for.** v2 already protects a whole session
* 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,
@@ -172,7 +172,7 @@ class DoubleRatchetTest {
}
/**
* ⚠️ **Repeated on every message of a chain, not just the first (§13.3).**
* 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.
@@ -190,15 +190,15 @@ class DoubleRatchetTest {
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
* header is bound into the AAD, so removing it fails the open, which is
* the difference between a downgrade attack and a broken message.
*/
@Test
@@ -213,8 +213,8 @@ class DoubleRatchetTest {
}
/**
* ⚠️ 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
@@ -231,7 +231,7 @@ class DoubleRatchetTest {
}
/**
* 🔴 A v3 device must still speak v2 to a peer that does not advertise it
* A v3 device must still speak v2 to a peer that does not advertise it
* (§13.4), and the header then carries no PQ fields at all.
*/
@Test
@@ -269,7 +269,7 @@ class DoubleRatchetTest {
val two = alice.encrypt("two".toByteArray(), AD)
val three = alice.encrypt("three".toByteArray(), AD)
// Delivered 3, 1, 2 — the receiver must hold keys for the gap rather
// Delivered 3, 1, 2, the receiver must hold keys for the gap rather
// than advancing past them.
assertContentEquals("three".toByteArray(), bob.decrypt(three, AD))
assertContentEquals("one".toByteArray(), bob.decrypt(one, AD))
@@ -12,8 +12,8 @@ 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
* 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,
@@ -46,7 +46,7 @@ class DraftsThatSurviveTest {
}
/**
* 🔴 **On disk it must be ciphertext.** This is the assertion the whole
* 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.
*/
@@ -62,7 +62,7 @@ class DraftsThatSurviveTest {
"the draft was written in the clear")
}
/** ⚠️ Multi-line text and awkward characters survive the round trip. */
/** Multi-line text and awkward characters survive the round trip. */
@Test
fun `awkward text survives`() {
val storage = MapStorage()
@@ -76,7 +76,7 @@ class DraftsThatSurviveTest {
// ---------------------------------------------------------------- forgetting
/**
* 🔴 Clearing the composer has to clear what was stored. A draft that
* 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.
*/
@@ -92,7 +92,7 @@ class DraftsThatSurviveTest {
}
/**
* ⚠️ Blank is not a draft. Storing " " would restore a composer that
* 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.
*/
@@ -111,7 +111,7 @@ class DraftsThatSurviveTest {
}
/**
* ⚠️ But stored as typed, not trimmed. Somebody who left a trailing space
* 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.
@@ -127,9 +127,9 @@ class DraftsThatSurviveTest {
// ---------------------------------------------------------------- which thread
/**
* 🔴 Three kinds of conversation share one record, so the ids must not
* 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
* "7" are three different threads, and the transcript already names them
* apart for exactly this reason.
*/
@Test
@@ -169,7 +169,7 @@ class DraftsThatSurviveTest {
// ---------------------------------------------------------------- sign-out
/**
* 🔴 Signing out must take drafts with it. Leaving the one piece of
* 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.
@@ -186,7 +186,7 @@ class DraftsThatSurviveTest {
}
/**
* ⚠️ A vault opened with a different key cannot read the draft — the same
* A vault opened with a different key cannot read the draft, the same
* guarantee as everything else in here, asserted because a draft is the
* newest thing in it and the easiest to get wrong.
*/
@@ -37,7 +37,7 @@ class E2ESessionTest {
}
/**
* ⚠️ The handshake has to ride EVERY message until one is answered, not
* 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.
@@ -66,7 +66,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.
* "security keys changed", the warning that is supposed to mean an attack.
*/
@Test
fun `a session survives being archived mid-conversation`() {
@@ -87,18 +87,18 @@ class E2ESessionTest {
}
/**
* 🔴 **Both sides open the chat at once.**
* 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.
* 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
* 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.
@@ -115,7 +115,7 @@ class E2ESessionTest {
val bFirst = bOwn.encrypt("from bob")
// Each adopts the other's chain to read the message that arrived, and
// RETIRES its own rather than dropping it — the peer is still on it.
// RETIRES its own rather than dropping it, the peer is still on it.
val aAdopted = E2ESession.respond(alice, bFirst)
assertEquals("from bob", aAdopted.decrypt(bFirst))
val bAdopted = E2ESession.respond(bob, aFirst)
@@ -7,7 +7,7 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* ⚠️ The seed→public derivation is checked against the **RFC 8032 §7.1**
* 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
@@ -9,16 +9,16 @@ 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
* 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
* `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
* 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
* 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 {
@@ -34,8 +34,8 @@ 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
* 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.
*/
@Test
@@ -49,9 +49,9 @@ class EditEnvelopeTest {
}
/**
* ⚠️ It must not be swallowed as generic control traffic either. A build
* 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
* correction, which is worse than the duplicate bubble, because nobody
* would notice.
*/
@Test
@@ -70,7 +70,7 @@ class EditEnvelopeTest {
}
/**
* 🔴 `re.author` is a claim, not evidence — anyone can put any user id
* `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.
*/
@@ -86,7 +86,7 @@ class EditEnvelopeTest {
assertEquals("abc", Content.parse(forged).editOf)
}
/** ⚠️ `fnc` first, like every other envelope — the receive side's marker
/** `fnc` first, like every other envelope, the receive side's marker
* check matches the serialized prefix. */
@Test
fun `the envelope starts with the marker`() {
@@ -104,7 +104,7 @@ class EditEnvelopeTest {
}
/**
* ⚠️ Both clients must agree byte-for-byte on the shape, since one encodes
* Both clients must agree byte-for-byte on the shape, since one encodes
* what the other parses. This is the shape iOS emits.
*/
@Test
@@ -118,7 +118,7 @@ class EditEnvelopeTest {
}
/**
* 🔴 The single decision that keeps an edit out of the transcript.
* 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
@@ -134,7 +134,7 @@ class EditIsNotRenderableTest {
"a correction would be filed as a second bubble beside the original")
}
/** ⚠️ And ordinary text still is, or this fix has broken conversation. */
/** And ordinary text still is, or this fix has broken conversation. */
@Test
fun `ordinary text is still a message`() {
val content = Content.parse(Content.text("meet at 6"))
@@ -10,9 +10,9 @@ import kotlin.test.assertFalse
/**
* Documents sent between the two apps.
*
* 🔴 **The envelope in [IOS_FILE] was produced by the iOS build**, not by this
* 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
* 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.
*/
@@ -42,7 +42,7 @@ class FileEnvelopeInteropTest {
assertEquals("application/pdf", ref.mime)
}
/** 🔴 It must be recognised, not merely tolerated. */
/** It must be recognised, not merely tolerated. */
@Test
fun `a file is a known kind rather than needing a newer client`() {
val content = Content.parse(iosFile)
@@ -77,7 +77,7 @@ class FileEnvelopeInteropTest {
}
/**
* 🔴 The compatibility rule, on this side too: never captionless, because
* The compatibility rule, on this side too: never captionless, because
* a build that predates this kind shows only the caption.
*/
@Test
@@ -102,7 +102,7 @@ class FileEnvelopeInteropTest {
assertEquals("signed copy", Content.parse(Content.attachment(ref, "signed copy")).text)
}
/** ⚠️ An older peer sends no `fn`/`cls`/`cmp`. It must parse, not throw. */
/** 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 = """
@@ -117,7 +117,7 @@ class FileEnvelopeInteropTest {
}
/**
* 🔴 The additive property that makes any of this safe: a photo envelope
* 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.
*/
@@ -135,7 +135,7 @@ class FileEnvelopeInteropTest {
}
/**
* ⚠️ A filename from a peer is display text and a name, never a path.
* 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
@@ -10,8 +10,8 @@ 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
* **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
@@ -23,9 +23,9 @@ import org.json.JSONObject
* regenerated by the implementation under test proves only that it agrees with
* itself.
*
* ⚠️ Two of these vectors were additionally recomputed from scratch in Python —
* 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
* 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.
*/
@@ -79,7 +79,7 @@ class FilesInteropTest {
// ---- §2 Key derivation -----------------------------------------------
/**
* 🔴 The whole PBKDF2-then-HKDF chain, against a value the browser
* 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.
*/
@@ -97,7 +97,7 @@ class FilesInteropTest {
// ---- §3 The identity record ------------------------------------------
/**
* 🔴 **An identity written by the browser, opened here.** This is the one
* An identity written by the browser, opened here. This is the one
* that decides whether somebody can move to an Android phone at all.
*/
@Test
@@ -111,7 +111,7 @@ class FilesInteropTest {
}
/**
* ⚠️ The public key above is recovered by agreeing against the X25519 base
* 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.
@@ -133,7 +133,7 @@ class FilesInteropTest {
}
/**
* 🔴 The context is authenticated data as well as a KDF separator, so a
* 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.
@@ -152,9 +152,9 @@ class FilesInteropTest {
// ---- §4 The asymmetric grant -----------------------------------------
/**
* 🔴 **A label key granted by the browser, accepted here.** This is how an
* 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
* 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.
*/
@@ -199,7 +199,7 @@ class FilesInteropTest {
}
/**
* 🔴 **Reclassification must break the key, not succeed quietly.** Edit the
* 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.
@@ -218,7 +218,7 @@ class FilesInteropTest {
// ---- §6 Names ---------------------------------------------------------
/**
* 🔴 The `café` case is the one that matters: it matches under NFC and not
* The `café` case is the one that matters: it matches under NFC and not
* under NFD, so a platform normalising differently fails here rather than
* quietly failing to find a file somebody filed on another device.
*/
@@ -243,7 +243,7 @@ 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
* 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.
@@ -283,8 +283,8 @@ class FilesInteropTest {
}
/**
* ⚠️ The digest is authenticated alongside the label, so a name ciphertext
* cannot be lifted onto another row — which is how somebody would learn
* The digest is authenticated alongside the label, so a name ciphertext
* cannot be lifted onto another row, which is how somebody would learn
* which plaintext belongs to a file they cannot open.
*/
@Test
@@ -302,9 +302,9 @@ class FilesInteropTest {
// ---- §7 Content -------------------------------------------------------
/**
* 🔴 **A file written by the browser, in more than one chunk.** The vector
* 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
* being tested, a single-chunk file would pass with the index and the
* final-chunk flag both wrong.
*/
@Test
@@ -335,11 +335,11 @@ class FilesInteropTest {
}
/**
* 🔴 **Truncation, on a real blob.** Drop the final chunk and the file
* 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
* 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
@@ -375,10 +375,10 @@ class FilesInteropTest {
}
/**
* 🔴 **Reordering.** Swap two chunks and every byte is still authentic -
* 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
* 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.
*/
@@ -9,7 +9,7 @@ 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
* 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`.
@@ -24,8 +24,8 @@ class FilesVaultCryptoTest {
// ---- 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
* 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.
@@ -41,9 +41,9 @@ class FilesVaultCryptoTest {
}
/**
* ⚠️ The off-by-one that a round trip hides: a file whose length is an
* 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.
* read back as N+1, nor as N-1 with the tail treated as padding.
*/
@Test
fun `a file exactly filling its chunks round-trips`() {
@@ -74,7 +74,7 @@ class FilesVaultCryptoTest {
}
/**
* 🔴 **A hostile chunk size in the header.** `0xFFFFFFFF` read as a signed
* 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.
*/
@@ -115,8 +115,8 @@ 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
* 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.
*/
@Test
@@ -140,7 +140,7 @@ class FilesVaultCryptoTest {
}
/**
* 🔴 Two encryptions of one name differ, and that is required, not
* 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.
*/
@@ -164,7 +164,7 @@ class FilesVaultCryptoTest {
}
/**
* ⚠️ Per-folder keying is what stops anything holding the rows noticing
* Per-folder keying is what stops anything holding the rows noticing
* that two folders contain a file of the same name.
*/
@Test
@@ -201,7 +201,7 @@ class FilesVaultCryptoTest {
}
/**
* 🔴 The two wrap forms carry different version bytes, and neither will
* 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.
@@ -235,7 +235,7 @@ class FilesVaultCryptoTest {
}
/**
* 🔴 **A grant made for one person does not open for another**, even though
* 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.
*/
@@ -272,10 +272,10 @@ class FilesVaultCryptoTest {
}
/**
* 🔴 The record holds `ciphertext || tag` WITHOUT the nonce, which is what
* 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.
* client can open at all, so the length is asserted rather than trusted.
*/
@Test
fun `the stored private key is the box without its nonce`() {
@@ -301,7 +301,7 @@ class FilesVaultCryptoTest {
}
/**
* ⚠️ PKCS#8 in, raw 32 out, and a wrapper that is not ours is refused
* 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.
*/
@@ -322,7 +322,7 @@ class FilesVaultCryptoTest {
// ---- Derivation -------------------------------------------------------
/**
* 🔴 One passphrase, one salt, two contexts, two unrelated secrets. This is
* One passphrase, one salt, two contexts, two unrelated secrets. This is
* the whole reason a Files compromise is not a Messenger compromise.
*/
@Test
@@ -335,7 +335,7 @@ class FilesVaultCryptoTest {
}
/**
* ⚠️ The passphrase reaches PBKDF2 as UTF-8 bytes, and this is the case
* 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.
*/
@@ -10,8 +10,8 @@ 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
* 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.
*/
@@ -30,7 +30,7 @@ class ForwardWithoutProvenanceTest {
}
/**
* 🔴 The whole point: the envelope says THAT it was forwarded and nothing
* The whole point: the envelope says THAT it was forwarded and nothing
* about who wrote it or what it answered.
*/
@Test
@@ -42,7 +42,7 @@ class ForwardWithoutProvenanceTest {
}
/**
* ⚠️ `1`, not `true` — §6.10's shape. `org.json`'s optBoolean does not
* `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.
*/
@@ -57,13 +57,13 @@ class ForwardWithoutProvenanceTest {
"an engine writing `true` would have its forwards read as ordinary messages")
}
/** ⚠️ A forward is still a message: it renders, unlike control traffic. */
/** A forward is still a message: it renders, unlike control traffic. */
@Test
fun `a forwarded message is still renderable`() {
assertTrue(Content.parse(Content.text("meet at 6", forwarded = true)).isRenderable)
}
/** The flag rides an attachment too — a forwarded photo is still a forward. */
/** 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(
@@ -78,14 +78,14 @@ class ForwardWithoutProvenanceTest {
assertEquals("look", content.text)
}
/** ⚠️ `fnc` first, like every other envelope. */
/** `fnc` first, like every other envelope. */
@Test
fun `the envelope starts with the marker`() {
assertTrue(Content.text("hi", forwarded = true).startsWith("{\"fnc\":1,"))
}
/**
* A missing flag is not a forward — the ordinary case, and the one that
* A missing flag is not a forward, the ordinary case, and the one that
* every message sent before today takes.
*/
@Test
@@ -9,9 +9,9 @@ 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.**
* 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
* 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.
*/
@@ -25,7 +25,7 @@ class GroupMessageIdentityTest {
}
/**
* 🔴 One id for the whole fan-out. A group message is ONE message: a reply
* 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.
*/
@@ -40,8 +40,8 @@ class GroupMessageIdentityTest {
}
/**
* 🔴 **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
* 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.
@@ -64,8 +64,8 @@ class GroupMessageIdentityTest {
}
/**
* ⚠️ 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`() {
@@ -79,7 +79,7 @@ class GroupMessageIdentityTest {
}
/**
* ⚠️ A message stored before ids existed reads back with none, rather than
* A message stored before ids existed reads back with none, rather than
* with an empty string that would then look like an id and match nothing.
*/
@Test
@@ -7,8 +7,8 @@ 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
* **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.
*/
@@ -23,7 +23,7 @@ class GroupStoryTest {
}
/**
* 🔴 The hazard the disclosure exists for: somebody excluded from this
* The hazard the disclosure exists for: somebody excluded from this
* account's stories still receives a story posted to a group they are in.
*/
@Test
@@ -46,8 +46,8 @@ class GroupStoryTest {
}
/**
* ⚠️ 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. Somebody who is not a contact
* is not "excluded from your stories", they were never on that list.
*/
@Test
fun `a non-contact is not also counted as excluded`() {
@@ -77,7 +77,7 @@ class GroupStoryTest {
override fun names() = records.keys.toList()
}
/** ⚠️ Without persisting `gid`, a reload forgets which story went where. */
/** Without persisting `gid`, a reload forgets which story went where. */
@Test
fun `the group survives the vault round trip`() {
val vault = Vault(MapStorage(),
@@ -10,7 +10,7 @@ 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
* **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
@@ -27,7 +27,7 @@ class HdAccountTest {
// ---- The path --------------------------------------------------------
/**
* ⚠️ BIP-44 with every level hardened, which is what this curve requires
* 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.
*/
@@ -41,7 +41,7 @@ class HdAccountTest {
// ---- The property that guards the money ------------------------------
/**
* 🔴 The signing key owns the address that was displayed. Everything else
* The signing key owns the address that was displayed. Everything else
* in the wallet rests on this one line being true.
*/
@Test
@@ -56,7 +56,7 @@ class HdAccountTest {
}
}
/** And that key can actually sign — the engine carries RFC 8032 itself. */
/** And that key can actually sign, the engine carries RFC 8032 itself. */
@Test
fun `a derived key can sign and the signature verifies under its address`() {
val account = account()
@@ -65,8 +65,8 @@ 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 —
// `(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),
@@ -76,8 +76,8 @@ class HdAccountTest {
// ---- 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
* 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.
*/
@Test
@@ -112,7 +112,7 @@ class HdAccountTest {
// ---- Restoring -------------------------------------------------------
/**
* ⚠️ The whole promise of a recovery phrase: the same words give back the
* The whole promise of a recovery phrase: the same words give back the
* same addresses, on any device and any implementation.
*/
@Test
@@ -160,12 +160,12 @@ class HdAccountTest {
}
/**
* 🔴 **The limit, asserted as a passing test so nobody reads
* **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
* 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
@@ -181,7 +181,7 @@ class HdAccountTest {
.firstOrNull { it != real && HdAccount.looksLikeAddress(it) }
assertTrue(
nearMiss != null,
"no single-character change produced a still-valid address — if this " +
"no single-character change produced a still-valid address, if this " +
"ever becomes true the format has gained a checksum, and the " +
"warnings written around typed addresses can be revisited",
)
@@ -14,9 +14,9 @@ import kotlin.test.assertTrue
* 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
* 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
* on Android, but per the Conscrypt rule, the on-device instrumentation
* suite is what proves the Android side.
*/
class HistoryExportTest {
@@ -10,7 +10,7 @@ import kotlin.test.assertTrue
*
* 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
* the document, it can disagree with them, which is the only way it is worth
* writing.
*/
class HkdfTest {
@@ -20,7 +20,7 @@ class HkdfTest {
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
/** RFC 5869 Appendix A.1 — basic case with salt and info. */
/** RFC 5869 Appendix A.1, basic case with salt and info. */
@Test
fun `case 1 - sha256 with salt and info`() {
val prk = Hkdf.extract(ikm = hex("0b".repeat(22)), salt = hex("000102030405060708090a0b0c"))
@@ -36,7 +36,7 @@ class HkdfTest {
)
}
/** RFC 5869 Appendix A.2 — longer inputs and outputs. */
/** RFC 5869 Appendix A.2, longer inputs and outputs. */
@Test
fun `case 2 - long inputs`() {
val ikm = ByteArray(80) { it.toByte() }
@@ -56,7 +56,7 @@ class HkdfTest {
)
}
/** RFC 5869 Appendix A.3 — zero-length salt and info. */
/** RFC 5869 Appendix A.3, zero-length salt and info. */
@Test
fun `case 3 - empty salt and info`() {
val prk = Hkdf.extract(ikm = hex("0b".repeat(22)), salt = ByteArray(0))
@@ -75,7 +75,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
* 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.
*/
@Test
@@ -100,7 +100,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.
* returned, silently.
*/
@Test
fun `refuses more than 255 blocks rather than repeating itself`() {
@@ -118,7 +118,7 @@ class HkdfTest {
}
}
/** Different info must give unrelated output — this is what §12 relies on. */
/** Different info must give unrelated output, this is what §12 relies on. */
@Test
fun `different contexts give unrelated keys`() {
val ikm = hex("0b".repeat(22))
@@ -12,12 +12,12 @@ 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
* 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
* 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,
@@ -53,9 +53,9 @@ class HybridSessionTest {
}
/**
* A peer that has proven v2 and only v2 — the released fleet.
* A peer that has proven v2 and only v2, the released fleet.
*
* ⚠️ `copy(proto = 2)` alone would NOT do: the capability signature commits
* `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 genuine v2 device.
*/
@@ -98,12 +98,12 @@ class HybridSessionTest {
val delivered = Envelope.fromPayload(envelope.toPayload())
val bobSide = E2ESession.respond(bob, delivered)
// 🔴 **3, and asserting 2 here is what hid a real bug for weeks.**
// 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.
// 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
@@ -121,8 +121,8 @@ 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
* 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.
*/
@@ -169,9 +169,9 @@ class HybridSessionTest {
// MARK: - What the relay is not allowed to do
/**
* 🔴 **The substitution this whole layer exists to prevent.** A relay that
* 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
* 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.
*/
@@ -193,7 +193,7 @@ class HybridSessionTest {
}
/**
* ⚠️ A relay can also lie the other way — advertise a v2 device as v1 and
* 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".
@@ -208,7 +208,7 @@ class HybridSessionTest {
@Test
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
// 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.
@@ -218,7 +218,7 @@ class HybridSessionTest {
}
/**
* ⚠️ The one-time id is signed **big-endian**, and the id is inside the
* 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.
*/
@@ -232,12 +232,12 @@ class HybridSessionTest {
}
/**
* 🔴 **The advertised version and the capability signature must agree.**
* 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
* `provenPostQuantumPreKey()` returns null and the whole post-quantum
* path disappears, with nothing anywhere reporting an error.
*/
@Test
@@ -255,17 +255,17 @@ class HybridSessionTest {
}
/**
* 🔴 **A v2 peer must not be sent a v3 ratchet step.**
* 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 —
* 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
* 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.
*/
@@ -285,8 +285,8 @@ class HybridSessionTest {
// MARK: - Persistence
/**
* 🔴 The seed is the only thing stored, so a device that could not find it
* again would publish a key it cannot decapsulate with — and every peer who
* The seed is the only thing stored, so a device that could not find it
* again would publish a key it cannot decapsulate with, and every peer who
* used that key would be silently unreachable.
*/
@Test
@@ -301,7 +301,7 @@ class HybridSessionTest {
}
/**
* ⚠️ A device archived before this engine had ML-KEM has no such block, and
* 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.
*/
@@ -319,10 +319,10 @@ class HybridSessionTest {
}
/**
* ⚠️ **A prekey we already spent is not an error.** A duplicate delivery of
* 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
* simply cannot be v2, and then fails to decrypt, which the caller's three
* chains already handle.
*/
@Test
@@ -357,7 +357,7 @@ class HybridSessionTest {
// MARK: - The wire
/**
* ⚠️ **The v2 block is all-or-nothing**, and the relay refuses a partial
* 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.
*/
@@ -385,7 +385,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
* the session (§12.6), republishing 1088 bytes per message would be pure
* cost.
*/
@Test
@@ -401,8 +401,8 @@ 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
// 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.
bobSide.decrypt(first)
session.decrypt(bobSide.encrypt("answered"))
@@ -7,7 +7,7 @@ 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 —
* (`fnkt-v1|<size>|<root>`) so the two implementations cannot drift apart,
* a mismatch there would silently turn every head into "unverifiable".
*/
class KeyLogHeadSignatureTest {
@@ -9,10 +9,10 @@ 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
* **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
* 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.
*/
@@ -32,7 +32,7 @@ class LabelTest {
}
/**
* ⚠️ A negative classification and a full compartment mask together — the
* 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.
@@ -43,8 +43,8 @@ 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
* 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.
*/
@@ -76,9 +76,9 @@ class LabelTest {
}
/**
* 🔴 **The failure that reads as working.** An implementation that compares
* 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
* every test built from levels alone, right up until somebody sees a
* compartment they are not in.
*/
@Test
@@ -100,7 +100,7 @@ class LabelTest {
"an overlapping but incomplete set was treated as domination")
}
/** Two labels can be incomparable — neither dominates the other. */
/** Two labels can be incomparable, neither dominates the other. */
@Test
fun `disjoint compartments are incomparable in both directions`() {
val a = Label(5, setOf(1))
@@ -133,9 +133,9 @@ class LabelTest {
}
/**
* 🔴 **The minimum is a real bound, not decoration.** A label must sit
* 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
* 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.
*/
@Test
@@ -148,8 +148,8 @@ class LabelTest {
// ---- Writing ---------------------------------------------------------
/**
* 🔴 **Writing is gated by the same range check as reading, and the
* ★-property is NOT enforced — on either platform.**
* **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
@@ -158,11 +158,11 @@ class LabelTest {
*
* 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 /
* 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,
* 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.
*/
@@ -172,7 +172,7 @@ class LabelTest {
assertTrue(cleared.mayWrite(Label(5)))
assertTrue(
cleared.mayWrite(Label(1)),
"the star-property is now enforced — see android#15, and update the " +
"the star-property is now enforced, see android#15, and update the " +
"iOS side and this test together",
)
assertFalse(cleared.mayWrite(Label(8)))
@@ -6,14 +6,14 @@ 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
* 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:
* 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.
*/
@@ -9,9 +9,9 @@ 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
* 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
* 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.
@@ -27,7 +27,7 @@ class MailboxStateTest {
}
private val storage = MapStorage()
/// Held so a SECOND vault can be opened over the same storage — which is
/// 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)
@@ -46,7 +46,7 @@ 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,
* 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.
@@ -74,8 +74,8 @@ 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
* 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.
*/
@Test
@@ -97,7 +97,7 @@ class MailboxStateTest {
// MARK: - Per-relay state
/**
* 🔴 Envelope ids come from each relay's own database, so id 40 there says
* 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.
*/
@@ -110,8 +110,8 @@ 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
* 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
@@ -123,9 +123,9 @@ class MailboxStateTest {
}
/**
* ⚠️ The pin falls back to the pre-2026-08-30 global entry, so a phone
* 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.
* again, and a per-relay entry, once written, wins.
*/
@Test
fun `an existing global pin is inherited until a per-relay one is written`() {
@@ -141,10 +141,10 @@ class MailboxStateTest {
// MARK: - Extra mailboxes
/**
* ⚠️ **A credential per relay, not one shared.** An app password is minted
* 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".
* the rest, which reads as "the backup mailbox never has anything in it".
*/
@Test
fun `each mailbox keeps its own credential and dial url`() {
@@ -11,7 +11,7 @@ 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
* 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,
@@ -64,8 +64,8 @@ class MessageSearchTest {
}
/**
* ⚠️ Somebody typing `cafe` means `café`. A search that knows the
* difference quietly fails for every language with accents in it — which is
* 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.
*/
@Test
@@ -76,7 +76,7 @@ class MessageSearchTest {
}
/**
* ⚠️ A plain substring, never a regular expression. A stray bracket typed
* 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.
*/
@Test
@@ -108,9 +108,9 @@ class MessageSearchTest {
// ---------------------------------------------------------------- what it must not do
/**
* 🔴 A short query finds NOTHING, never everything. Returning the whole
* 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
* message they have on one screen, and here it would decrypt every thread
* on the device to do it.
*/
@Test
@@ -129,7 +129,7 @@ class MessageSearchTest {
assertFalse(MessageSearch.matches("anything", ""))
}
/** ⚠️ A bound, not a preference. */
/** A bound, not a preference. */
@Test
fun `results are bounded and keep the newest`() {
val vault = Vault(MapStorage(), key())
@@ -145,7 +145,7 @@ class MessageSearchTest {
// ---------------------------------------------------------------- the snippet
/**
* ⚠️ Bounded on both sides. A whole message means one long one pushes every
* 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.
*/
@@ -166,9 +166,9 @@ class MessageSearchTest {
}
/**
* 🔴 **Folding has to be length-preserving**, because the snippet slices the
* 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
* or added characters would cut in the wrong place, and for an accented
* character, mid-character.
*/
@Test
@@ -10,9 +10,9 @@ 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
* 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.
* 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.
@@ -37,9 +37,9 @@ class PQKEMTest {
}
/**
* 🔴 **The seed is the whole persistence story.** Only 64 bytes go into the
* 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
* 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.
@@ -58,7 +58,7 @@ 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
* 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.
@@ -76,10 +76,10 @@ class PQKEMTest {
}
/**
* ⚠️ **Decapsulation does not fail on a tampered ciphertext**, by design:
* 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
* 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.
*/
@@ -104,7 +104,7 @@ class PQKEMTest {
/**
* 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
* peer, and it fails far away from here, so it is named where it happens
* rather than discovered as a contact who stopped working.
*/
@Test
@@ -10,17 +10,17 @@ 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
* 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
* 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`,
* 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
* 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.
*/
@@ -31,8 +31,8 @@ class PQVectorTest {
// 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 —
// 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"),
@@ -49,7 +49,7 @@ class PQVectorTest {
private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) }
/**
* 🔴 **The cross-implementation check.** The vector publishes a seed and
* 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.
@@ -75,7 +75,7 @@ class PQVectorTest {
* 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
* while the concatenation order was wrong, and the two engines would still
* never talk.
*/
@Test
@@ -111,7 +111,7 @@ class PQVectorTest {
}
/**
* 🔴 **A downgrade must not produce a key the other side accepts.** v1's
* 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
@@ -135,7 +135,7 @@ class PQVectorTest {
}
/**
* ⚠️ The associated data binds the ciphertext, so a relay that swapped one
* 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.
*/
@@ -12,10 +12,10 @@ 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
* **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
* 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.
*/
@@ -45,7 +45,7 @@ class PerMessageTimerTest {
}
/**
* 🔴 The one that matters. A message asking to live a week, in a
* The one that matters. A message asking to live a week, in a
* conversation set to an hour, lives an hour.
*/
@Test
@@ -65,7 +65,7 @@ class PerMessageTimerTest {
}
/**
* ⚠️ Zero means "no timer", not "expire immediately". Reading it the other
* Zero means "no timer", not "expire immediately". Reading it the other
* way would delete a message the instant it arrived.
*/
@Test
@@ -83,9 +83,9 @@ class PerMessageTimerTest {
}
/**
* 🔴 **The bug the old code had.** Pruning returned early when the
* 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
* kept for ever, the feature would have looked like it worked on the
* sending phone and done nothing on the receiving one.
*/
@Test
@@ -116,7 +116,7 @@ class PerMessageTimerTest {
assertEquals("new", kept.getJSONObject(0).getString("text"))
}
/** 🔴 A long message timer does not save a message from a short thread one. */
/** A long message timer does not save a message from a short thread one. */
@Test
fun `a long message timer does not survive a short conversation timer`() {
val vault = Vault(MapStorage(), key())
@@ -130,7 +130,7 @@ class PerMessageTimerTest {
}
/**
* ⚠️ A message with no timestamp predates the field. Kept rather than
* 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.
*/
@@ -147,7 +147,7 @@ class PerMessageTimerTest {
// ---------------------------------------------------------------- the wire
/**
* 🔴 **A duration, never a deadline.** An absolute expiry would be the
* 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.
*/
@@ -160,7 +160,7 @@ class PerMessageTimerTest {
}
/**
* 🔴 **`Content.text` returns raw bytes when it has nothing to carry**, so a
* `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.
*/
@@ -176,7 +176,7 @@ class PerMessageTimerTest {
}
/**
* ⚠️ A conversation-timer announcement carries `secs` too and means
* A conversation-timer announcement carries `secs` too and means
* something entirely different. Reading both off one field is why this
* checks the kind.
*/
@@ -9,11 +9,11 @@ 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
* 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
* 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.
*/
@@ -33,8 +33,8 @@ class PinTest {
}
/**
* 🔴 The bug this prevents. An unrecognised pin falls through `parse` to
* the attachment path and comes back renderable — an empty bubble in
* 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.
*/
@Test
@@ -46,7 +46,7 @@ class PinTest {
}
/**
* ⚠️ Not swallowed as generic control either. A build that consumed it
* Not swallowed as generic control either. A build that consumed it
* silently would drop every pin while looking like it worked.
*/
@Test
@@ -56,7 +56,7 @@ class PinTest {
"a pin swallowed as control would drop silently")
}
/** 🔴 The envelope carries no copy of the message — only its id. */
/** The envelope carries no copy of the message, only its id. */
@Test
fun `the envelope carries no copy of the message`() {
val wire = Content.pin("abc")
@@ -72,7 +72,7 @@ class PinTest {
assertEquals("abc", content.pins)
}
/** ⚠️ `fnc` first, like every other envelope. */
/** `fnc` first, like every other envelope. */
@Test
fun `the envelope starts with the marker`() {
assertTrue(Content.pin("abc").startsWith("{\"fnc\":1,"))
@@ -14,7 +14,7 @@ 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
* 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 whole point of starting Android with the engine. A third
@@ -70,7 +70,7 @@ class PublishedVectorsTest {
val expected = v.getString("combined")
// Both ends have to read out the same string, or the check reports a
// mismatch that isn't one — which is the worst failure available to a
// mismatch that isn't one, which is the worst failure available to a
// control whose only job is spotting a real mismatch.
assertEquals(expected, SafetyNumber.compute(a, b))
assertEquals(expected, SafetyNumber.compute(b, a))
@@ -164,8 +164,8 @@ class PublishedVectorsTest {
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
// 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.
assertFalse(
KeyLog.verifyInclusion(leaf, inc.getInt("index"), inc.getInt("size"), proof.reversed(), root),
@@ -212,8 +212,8 @@ class PublishedVectorsTest {
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
// 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.
for (m in 1..all.size) {
for (n in m..all.size) {
@@ -245,10 +245,10 @@ class PublishedVectorsTest {
}
/**
* 🔴 The attack this whole mechanism exists to stop.
* 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
* 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.
*/
@@ -316,7 +316,7 @@ 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
* 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.
*/
@@ -8,13 +8,13 @@ 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
* 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
* 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 {
@@ -55,7 +55,7 @@ class QrCodeRenderForDecodingTest {
"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
// 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
@@ -66,10 +66,10 @@ class QrCodeRenderForDecodingTest {
"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(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
"x".repeat(213), // v10, the largest this encoder makes
)
}
}
@@ -35,7 +35,7 @@ class SafetyNumberPQTest {
}
/**
* The exact digits, from an INDEPENDENT implementation of the rule — the
* 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.
@@ -10,10 +10,10 @@ 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
* 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
* notice means NOTHING, and most of these tests are about not letting the
* interface say otherwise.
*/
class ScreenshotNoticeTest {
@@ -24,7 +24,7 @@ class ScreenshotNoticeTest {
assertEquals(1_788_000_000, Content.screenshotAt(content))
}
/** ⚠️ Control traffic, so an older build stays quiet instead of showing JSON. */
/** Control traffic, so an older build stays quiet instead of showing JSON. */
@Test
fun `the notice is marked as control traffic`() {
val json = JSONObject(Content.screenshot(1))
@@ -52,7 +52,7 @@ class ScreenshotNoticeTest {
// -- When one is sent -----------------------------------------------------
/** 🔴 Only where a timer is set. */
/** Only where a timer is set. */
@Test
fun `no notice in a conversation with no timer`() {
assertFalse(Content.shouldAnnounceScreenshot(0, null, 1_000))
@@ -63,7 +63,7 @@ class ScreenshotNoticeTest {
assertTrue(Content.shouldAnnounceScreenshot(3600, null, 1_000))
}
/** ⚠️ One screenshot often fires several platform events. */
/** One screenshot often fires several platform events. */
@Test
fun `a second event inside the window is suppressed`() {
assertFalse(Content.shouldAnnounceScreenshot(3600, 1_000, 2_000))
@@ -74,7 +74,7 @@ class ScreenshotNoticeTest {
assertTrue(Content.shouldAnnounceScreenshot(3600, 1_000, 10_000))
}
/** ⚠️ The timer check comes first, or a window would enable an untimed chat. */
/** The timer check comes first, or a window would enable an untimed chat. */
@Test
fun `the window does not override the timer rule`() {
assertFalse(Content.shouldAnnounceScreenshot(0, 1_000, 999_000))
@@ -83,7 +83,7 @@ class ScreenshotNoticeTest {
// -- What may be claimed --------------------------------------------------
/**
* 🔴 **The most important test here.** Presenting the absence of notices as
* The most important test here. Presenting the absence of notices as
* a fact would be inventing evidence out of silence.
*/
@Test
@@ -95,7 +95,7 @@ class ScreenshotNoticeTest {
}
/**
* ⚠️ Android's half of the asymmetry has to be stated, not hidden. Claiming
* 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.
*/
@@ -7,13 +7,13 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* §8.9b — the member list as something the relay cannot read.
* §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.
* 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
* 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 {
@@ -48,12 +48,12 @@ class SealedRosterTest {
}
/**
* 🔴 The property the whole change rests on: the stored bytes say nothing.
* 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
* **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
* 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
@@ -62,7 +62,7 @@ class SealedRosterTest {
*
* 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
* states the property, base64 could in principle hide a plaintext run the
* raw bytes reveal.
*/
@Test
@@ -88,7 +88,7 @@ class SealedRosterTest {
}
/**
* ⚠️ A blob that will not open is an error, never an empty group. Treating
* 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.
*/
@@ -113,8 +113,8 @@ 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
* 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.
*/
@Test
@@ -125,7 +125,7 @@ class SealedRosterTest {
}
/**
* 🔴 **The bytes must match the Swift engine and the relay exactly.** A
* 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.
@@ -138,8 +138,8 @@ class SealedRosterTest {
}
/**
* ⚠️ The encoding must not depend on map iteration order, or two devices
* sealing the same roster produce different bytes — and a signature over
* 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.
*/
@Test
@@ -154,7 +154,7 @@ class SealedRosterTest {
}
/**
* 🔴 **A backup that loses the tags loses the groups for good.** The relay
* 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
@@ -176,7 +176,7 @@ class SealedRosterTest {
assertEquals(secrets, fresh.groupSecrets("g1"))
}
/** ⚠️ Gaps only — a backup is older than the device by definition. */
/** Gaps only, a backup is older than the device by definition. */
@Test
fun `a restored backup does not overwrite secrets we already hold`() {
val old = vault()
@@ -41,10 +41,10 @@ class SealedSenderTest {
}
/**
* 🔴 The AAD binds the destination device.
* 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
* else's inbox and it would decrypt there, turning a privacy feature into
* a delivery bug that leaks messages to the wrong account.
*/
@Test
@@ -61,7 +61,7 @@ class SealedSenderTest {
}
/**
* ⚠️ Bob's own key, but the AAD says another device id.
* 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
@@ -91,7 +91,7 @@ class SealedSenderTest {
}
/**
* 🔴 Anyone can forge the claimed sender, and this records that on purpose.
* 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
@@ -104,7 +104,7 @@ class SealedSenderTest {
val eve = DeviceIdentity.create()
// Eve seals her own envelope while claiming to be Alice. Nothing stops
// her — the seal is confidentiality, not authentication.
// her, the seal is confidentiality, not authentication.
val forged = SealedSender.seal(
envelope(eve, bob, "not really from alice"),
fromUser = 7,
@@ -118,11 +118,11 @@ class SealedSenderTest {
assertEquals(alice.deviceId, opened.claimedFromDevice)
// What catches it is the inner envelope: responding to it establishes a
// session with EVE's identity key, not Alice's, so the identity pin is
// what refuses her — never this layer.
// what refuses her, never this layer.
val session = E2ESession.respond(bob, opened.envelope)
assertEquals("not really from alice", session.decrypt(opened.envelope))
// The handshake names EVE's identity key, which is what the recipient's
// identity pin compares against — and what refuses her.
// identity pin compares against, and what refuses her.
assertContentEquals(
eve.identitySigningPublic,
Bytes.unb64(opened.envelope.handshake!!.getString("ik_sig")),
@@ -149,7 +149,7 @@ class SealedSenderTest {
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
// is a repeated (key, nonce) pair, which under GCM leaks the
// authentication subkey and with it the ability to forge.
assertFalse(one.eph.contentEquals(two.eph))
}
@@ -23,7 +23,7 @@ class SealingRecordTest {
assertEquals(1_700_000_000, r.lastUnsealedAt)
}
/** ⚠️ One good send afterwards does not unhappen the last bad one. */
/** 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()
@@ -32,7 +32,7 @@ class SealingRecordTest {
assertEquals(100, r.lastUnsealedAt)
}
/** 🔴 Zero sends is no data, not zero leakage. */
/** Zero sends is no data, not zero leakage. */
@Test
fun `no sends has no share rather than zero`() {
assertNull(SealingRecord().unsealedShare)
@@ -48,7 +48,7 @@ class SealingRecordTest {
}
/**
* 🔴 **Must not say the message was readable.** An unsealed send is still
* Must not say the message was readable. An unsealed send is still
* end-to-end encrypted; the relay learns the sender, not the content.
*/
@Test
@@ -10,16 +10,16 @@ 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
* **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
* 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.**
* 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
* 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.
*/
@@ -31,7 +31,7 @@ class SecureBytesTest {
// ---- The failure that would matter most -----------------------------
/**
* 🔴 The pinned vectors already cover `authSecret` and `accountKeySeed`.
* 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
@@ -45,7 +45,7 @@ class SecureBytesTest {
}
/**
* ⚠️ And neither is zeroes, which is what "derived from a wiped buffer"
* 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.
*/
@@ -104,9 +104,9 @@ class SecureBytesTest {
}
/**
* 🔴 **The case the `finally` exists for.** A derivation that failed
* 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.
* survive a throw, not sit at the end of a happy path.
*/
@Test
fun `withSecret wipes when the body throws`() {
@@ -118,9 +118,9 @@ 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
* 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.
*/
@Test
@@ -8,11 +8,11 @@ import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Stories and mentions, against envelopes the **iOS** build produces.
* Stories and mentions, against envelopes the iOS build produces.
*
* 🔴 Every string below is the shape iOS assembles, not this engine's own
* 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
* 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.
*/
@@ -35,9 +35,9 @@ class StoryAndMentionInteropTest {
@Test
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.
// 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
// 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)
@@ -47,9 +47,9 @@ class StoryAndMentionInteropTest {
}
/**
* ⚠️ Kotlin prints a Double in scientific notation, so this engine writes
* 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
* 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.)
*/
@@ -60,7 +60,7 @@ class StoryAndMentionInteropTest {
assertEquals(1800000000.0, Content.parse(wire).textStoryExpires)
}
/** 🔴 A picture story must be completely unaffected. */
/** A picture story must be completely unaffected. */
@Test
fun `a picture story still parses as an attachment`() {
val ref = Attachment.Ref(
@@ -90,7 +90,7 @@ class StoryAndMentionInteropTest {
assertEquals(listOf(3, 7), Content.parse(json).mentions)
}
/** ⚠️ A message that merely spells a name carries no mention. */
/** A message that merely spells a name carries no mention. */
@Test
fun `spelling a name is not a mention`() {
val json = Content.groupText("hello @Ada", "g1")
@@ -110,8 +110,8 @@ 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
* 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.
*/
@Test
@@ -158,7 +158,7 @@ class StoryAndMentionInteropTest {
assertEquals("nice", content.text)
}
/** 🔴 fnc stays first, whatever else was added. */
/** fnc stays first, whatever else was added. */
@Test
fun `every envelope this engine builds starts with fnc`() {
listOf(
@@ -7,8 +7,8 @@ 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
* 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.
*/
@@ -30,7 +30,7 @@ class StoryArchiveTest {
Vault.archive(emptyList(), listOf(story("1", true, 100))).map { it.id })
}
/** 🔴 The rule the whole feature turns on. */
/** The rule the whole feature turns on. */
@Test
fun `somebody elses expired story is never kept`() {
assertTrue(Vault.archive(emptyList(), listOf(story("2", false, 100))).isEmpty())
@@ -43,7 +43,7 @@ class StoryArchiveTest {
assertEquals(setOf("1", "3"), result.map { it.id }.toSet())
}
/** ⚠️ The sweep runs on every load; without this, one story piles up. */
/** The sweep runs on every load; without this, one story piles up. */
@Test
fun `archiving is idempotent`() {
val one = story("1", true, 100)
@@ -51,7 +51,7 @@ class StoryArchiveTest {
assertEquals(1, Vault.archive(first, listOf(one)).size)
}
/** ⚠️ Newest first, so the screen needs no sort. */
/** Newest first, so the screen needs no sort. */
@Test
fun `the archive is newest first`() {
val result = Vault.archive(emptyList(), listOf(
@@ -70,7 +70,7 @@ class StoryArchiveTest {
private fun key() = javax.crypto.KeyGenerator.getInstance("AES")
.apply { init(256) }.generateKey()
/** ⚠️ Kept in the vault, like everything else about somebody's stories. */
/** Kept in the vault, like everything else about somebody's stories. */
@Test
fun `the archive round-trips through the vault`() {
val vault = Vault(MapStorage(), key())
@@ -80,7 +80,7 @@ class StoryArchiveTest {
}
/**
* 🔴 The archive must NOT be swept by the expiry rule the live set uses —
* 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.
*/
@@ -7,7 +7,7 @@ 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
* 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.
@@ -19,14 +19,14 @@ class StorySaveTest {
assertTrue(Vault.isSaveable(mine = true, hasBlob = true))
}
/** 🔴 The whole point: never somebody else's, whatever the UI offers. */
/** The whole point: never somebody else's, whatever the UI offers. */
@Test
fun `somebody elses story can never be saved`() {
assertFalse(Vault.isSaveable(mine = false, hasBlob = true))
}
/**
* ⚠️ A text story (§6.21) has no blob and nothing to write out. False
* 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.
*/
@@ -35,7 +35,7 @@ class StorySetTest {
}
/**
* 🔴 One envelope per item. An array of attachments would fail the
* 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.
*/
@@ -62,7 +62,7 @@ class StorySetTest {
assertEquals(listOf("s1", "s2", "s3"), groups[0].map { it.id })
}
/** ⚠️ `n` says three; two came. Render what arrived. */
/** `n` says three; two came. Render what arrived. */
@Test
fun `a partly arrived set is still rendered`() {
val groups = Vault.groupedStories(listOf(
@@ -71,7 +71,7 @@ class StorySetTest {
assertEquals(2, groups[0].size)
}
/** ⚠️ Two singles must not collide just because neither has a set id. */
/** 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(
@@ -82,7 +82,7 @@ class StorySetTest {
// -- Expiry ---------------------------------------------------------------
/** 🔴 The earliest wins, so a sender cannot outlive part of its own set. */
/** 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(
@@ -91,7 +91,7 @@ class StorySetTest {
item(3, "a", expires = 200.0))))
}
/** ⚠️ A zero means "never" and must not be read as "expires at the epoch". */
/** 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(
@@ -114,7 +114,7 @@ class StorySetTest {
override fun names() = records.keys.toList()
}
/** ⚠️ The set survives a reload, or a relaunch scatters every sequence. */
/** The set survives a reload, or a relaunch scatters every sequence. */
@Test
fun `the set survives the vault round trip`() {
val vault = Vault(MapStorage(),
@@ -9,11 +9,11 @@ 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
* 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.
* 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.
*/
@@ -24,7 +24,7 @@ class StreamDisclosureTest {
assertTrue("live.example.com" in StreamDisclosure.text("live.example.com"))
}
/** ⚠️ Must say what the host learns, not merely that something connects. */
/** Must say what the host learns, not merely that something connects. */
@Test
fun `the disclosure says what the host learns`() {
val text = StreamDisclosure.text("live.example.com")
@@ -39,8 +39,8 @@ class StreamDisclosureTest {
}
/**
* 🔴 A `file:` URL arriving from another person is not something to hand a
* media player — playing it would read this device.
* A `file:` URL arriving from another person is not something to hand a
* media player, playing it would read this device.
*/
@Test
fun `non-web schemes are refused`() {
@@ -49,7 +49,7 @@ class StreamDisclosureTest {
assertFalse(StreamDisclosure.acceptable("rtsp://example.com/s"))
}
/** ⚠️ A destination nobody can name is one nobody can consent to. */
/** A destination nobody can name is one nobody can consent to. */
@Test
fun `a url with no host is refused`() {
assertFalse(StreamDisclosure.acceptable("https://"))
@@ -82,7 +82,7 @@ class StreamDisclosureTest {
}
/**
* 🔴 The host comes from the URL, never the caption. A story captioned
* The host comes from the URL, never the caption. A story captioned
* "youtube.com" pointing elsewhere is the phishing shape §6.12 guards
* against.
*/
@@ -14,7 +14,7 @@ import kotlin.test.assertTrue
/**
* Round trips for everything the vault remembers.
*
* 🔴 **This class of bug has already shipped here once.** `requireKeyLog` was
* 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
@@ -23,7 +23,7 @@ import kotlin.test.assertTrue
*
* 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
* 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.
*/
@@ -40,7 +40,7 @@ class VaultPersistenceTest {
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
/** Write with one instance, read with another — a restart, in effect. */
/** 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()
write(Vault(storage, secret))
@@ -88,7 +88,7 @@ class VaultPersistenceTest {
}
/**
* 🔴 The flag whose absence made reinstalled contacts unmessageable. If
* 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.
@@ -138,10 +138,10 @@ class VaultPersistenceTest {
}
/**
* 🔴 **Every record's name is bound into the AAD**, so a blob is not
* 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
* would accept them, attributing a stranger's identity to someone you
* trust.
*/
@Test
@@ -329,7 +329,7 @@ class VaultPersistenceTest {
assertEquals(1, Vault(storage, secret).history("t").length())
}
/** "Deleted means deleted" — the record goes, not a tombstone. */
/** "Deleted means deleted", the record goes, not a tombstone. */
@Test
fun `forgetting a thread removes its record entirely`() {
val storage = MapStorage(); val secret = key()
@@ -70,12 +70,12 @@ class VaultTest {
}
/**
* 🔴 **The chain a re-establishment replaces must survive it.**
* 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,
* 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
@@ -105,28 +105,28 @@ class VaultTest {
vault.saveRetiredSession(bob.deviceId, vault.loadSession(bob.deviceId)!!)
vault.saveSession(bob.deviceId, adopted)
// Alice is still on the chain Bob started — she answers on it.
// Alice is still on the chain Bob started, she answers on it.
val onTheRetiredChain = E2ESession.respond(alice, bobsFirst)
assertEquals("from bob", onTheRetiredChain.decrypt(bobsFirst))
val reply = onTheRetiredChain.encrypt("bob, this is the old chain")
val fresh = Vault(storage, secret)
assertNull(runCatching { fresh.loadSession(bob.deviceId)!!.decrypt(reply) }.getOrNull(),
"the adopted session must NOT open the retired chain — otherwise this " +
"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))
}
/**
* 🔴 **A failed decrypt damages the session object it was handed, so the
* **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 —
* 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.
@@ -165,7 +165,7 @@ class VaultTest {
Vault(wrong, secret).loadRetiredSession(bob.deviceId)!!
.decrypt(alicesReplyOnBobsChain)
}.getOrNull(),
"a session damaged by a failed decrypt should NOT still open its chain — " +
"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")
// Re-reading the vault copy keeps it. This is the fix.
@@ -176,7 +176,7 @@ class VaultTest {
.decrypt(alicesReplyOnBobsChain))
}
/** Signing out must take the retired chains too — they hold chain keys. */
/** Signing out must take the retired chains too, they hold chain keys. */
@Test
fun `forgetting sessions forgets the retired ones as well`() {
val storage = MapStorage()
@@ -210,7 +210,7 @@ class VaultTest {
}
/**
* ⚠️ The failure this catches: a stored blob moved from one slot to
* 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.
@@ -251,9 +251,9 @@ class VaultTest {
}
/**
* ⚠️ A corrupt identity must NOT read as "no identity". The caller treats
* 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
* as "security keys changed", the warning that is supposed to mean an
* attack. Absent and unreadable are different answers.
*/
@Test
@@ -319,18 +319,18 @@ class VaultTest {
// ---- Trust records and the platform's JSON ---------------------------
/**
* 🔴 **A contact with no known address must come back with NO address.**
* 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
* `"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
* 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
* 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.
*/
@@ -345,7 +345,7 @@ class VaultTest {
}
/**
* ⚠️ **This is the test that reproduces the device**, and the one above is
* 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
@@ -354,8 +354,8 @@ class VaultTest {
@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 —
// 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.
val storage = MapStorage()
@@ -11,15 +11,15 @@ 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
* 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
* 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
* 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.
@@ -36,8 +36,8 @@ class WalletPrimitivesTest {
// ---- 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
* The whole mitigation for vendoring 2,048 strings. A silently corrupted
* list does not error, it produces a DIFFERENT wallet from the same
* phrase.
*/
@Test
@@ -86,7 +86,7 @@ class WalletPrimitivesTest {
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
// 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
@@ -114,8 +114,8 @@ 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
* 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.
*/
@Test
@@ -134,7 +134,7 @@ class WalletPrimitivesTest {
}
/**
* 🔴 The checksum's whole job: a phrase of real words in a plausible order
* The checksum's whole job: a phrase of real words in a plausible order
* that is not a phrase anybody generated.
*/
@Test
@@ -155,7 +155,7 @@ class WalletPrimitivesTest {
assertFailsWith<Bip39.Failure.BadEntropySize> { Bip39.phrase(ByteArray(64), words) }
}
/** ⚠️ Extra spacing is a typing artefact, not a different phrase. */
/** Extra spacing is a typing artefact, not a different phrase. */
@Test
fun `surrounding and repeated whitespace does not change the phrase`() {
val phrase = Bip39.phrase(ByteArray(16), words)
@@ -168,9 +168,9 @@ class WalletPrimitivesTest {
// ---- Base58 ----------------------------------------------------------
/**
* 🔴 **The leading-zero rule, which is where money goes missing.** Base58 is
* 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
* byte, the standard prepends one `1` per zero byte instead. An
* implementation using `BigInteger` loses them and encodes two different
* keys to one address.
*/
@@ -213,7 +213,7 @@ class WalletPrimitivesTest {
}
/**
* ⚠️ A mistyped address must FAIL, not resolve to somebody else's. `0`,
* 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.
*/
@Test
@@ -280,7 +280,7 @@ class WalletPrimitivesTest {
}
/**
* 🔴 **A path without hardening is REFUSED, not silently hardened.**
* 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
@@ -293,7 +293,7 @@ class WalletPrimitivesTest {
assertFailsWith<HdKey.Failure.Unhardened> { master.derive("m/44H/501") }
}
/** ⚠️ All three hardening notations are accepted, since all three are written. */
/** All three hardening notations are accepted, since all three are written. */
@Test
fun `H and h and apostrophe all mean hardened`() {
val master = HdKey.master(ByteArray(32) { 1 })
@@ -315,7 +315,7 @@ class WalletPrimitivesTest {
}
/**
* ⚠️ Derivation is a pure function of seed and path. Sounds obvious; it is
* Derivation is a pure function of seed and path. Sounds obvious; it is
* the property a restored wallet depends on entirely.
*/
@Test
@@ -333,7 +333,7 @@ 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
* 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.
*/
@@ -11,11 +11,11 @@ import kotlin.test.assertTrue
/**
* Creating, importing and reading back a wallet.
*
* 🔴 **The property worth guarding hardest is a negative one: the wallet must
* **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
* undo that later, the chain does not forget, so it is asserted rather than
* left to good intentions.
*/
class WalletTest {
@@ -30,7 +30,7 @@ class WalletTest {
// ---- What a wallet is not --------------------------------------------
/**
* 🔴 Two wallets made in a row share nothing. If `create` ever started
* Two wallets made in a row share nothing. If `create` ever started
* deriving from something about the account, this is what would catch it.
*/
@Test
@@ -56,7 +56,7 @@ class WalletTest {
// ---- Importing -------------------------------------------------------
/**
* ⚠️ Validated before it is stored. A phrase that fails its checksum is
* 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.
*/
@@ -76,7 +76,7 @@ class WalletTest {
}
}
/** 🔴 An imported phrase gives back exactly the wallet it names. */
/** An imported phrase gives back exactly the wallet it names. */
@Test
fun `importing a phrase restores the same addresses`() {
val made = Wallet.create(words)
@@ -91,7 +91,7 @@ class WalletTest {
// ---- The counter -----------------------------------------------------
/**
* ⚠️ A restored wallet resumes at the index it was left at, when one is
* 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.
*/
@@ -110,7 +110,7 @@ class WalletTest {
// ---- The BIP-39 passphrase -------------------------------------------
/**
* 🔴 A BIP-39 passphrase silently names a DIFFERENT wallet. That is the
* 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.
*/
@@ -72,7 +72,7 @@ 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
* 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
@@ -11,7 +11,7 @@ import kotlin.test.assertTrue
* 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 —
* 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.
*/
@@ -71,7 +71,7 @@ class X3DHTest {
@Test
fun `using a one-time prekey changes the result`() {
// Otherwise DH4 is contributing nothing and its absence would be
// undetectable — which would make a drained pool silently equivalent to
// undetectable, which would make a drained pool silently equivalent to
// a healthy one.
val aliceIdentity = X25519.generate()
val bobIdentity = X25519.generate()