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:
co-authored by
Claude Fable 5.1
parent
04e06452b6
commit
4dd544083c
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user