From e6913ae60e430383743dc006ef505009314bd795 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 4 Sep 2026 16:22:01 -0400 Subject: [PATCH 1/2] =?UTF-8?q?PARITY:=20history=20at=20rest=20was=20alrea?= =?UTF-8?q?dy=20built=20=E2=80=94=20the=20third=20stale=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marked βšͺ and implemented the whole time. Every read goes to disk: `Session.history` calls `Vault.history`, which decrypts the record. There is no in-memory copy to be fooled by. Checked against iOS's `E2EHistoryStore` rather than assumed. Same cipher, same posture, two honest differences recorded rather than papered over: - 🟒 Android binds the RECORD NAME into the AAD, so a sealed blob cannot be moved between slots. iOS keeps one snapshot file and does not need that β€” but would, the day it splits. - iOS's `.completeFileProtection` is a per-file assertion. Android's equivalent is Credential-Encrypted storage, a property of the volume rather than of this app's request. Neither favours one platform overall, and both clients agree on the behaviour that matters: a plain sign-out KEEPS history, and erasing the device destroys it β€” on Android by wiping every record and deleting the Keystore key, so what is left is unopenable as well as gone. ⚠️ Also recorded: `Vault.history` prunes expired messages on the way out, so disappearing messages are enforced on READ rather than by a timer nothing runs β€” and it rewrites the record only when something actually expired, so a read is not a write. Services (AIM/ICQ) retired rather than left as a gap: not a goal for this app (owner, 2026-09-04). That leaves only the two post-quantum rows, and the note above them already says why they are not a gap in effect: iOS's PQ files are inert because CryptoKit exposes ML-KEM on iOS 26 and the app targets 16, so neither client does post-quantum anything today. Closing it properly is one decision for both clients, not an Android task. Co-Authored-By: Claude Opus 5 --- PARITY.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/PARITY.md b/PARITY.md index 24a54f5..760b629 100644 --- a/PARITY.md +++ b/PARITY.md @@ -113,7 +113,7 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Trust state, verification, identity-change acceptance | βœ… pinned in the vault, surfaced on the thread | | Key-log enforcement and head gossip | βœ… opt-in enforcement, gossip detects a fork | | Clear thread | βœ… keeps the session, so the chat still works | -| History at rest (encrypted, device-only key) | βšͺ | +| History at rest (encrypted, device-only key) | βœ… **this row was stale** β€” sealed per thread under a Keystore key, read straight from disk; see below | | Presence (read and set) | βœ… shown from the relay, never inferred | | Buddy list, add by username | βœ… | | Remove, block, unblock | βœ… | @@ -121,7 +121,35 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Buddy commands | β€” retired; not a feature any more (owner, 2026-08-25) | | Voice calls (WebRTC, DTLS-SRTP, TURN) | βœ… P2P-first; ⚠️ no TURN deployed | | Notifications list, read state | βœ… | -| Services (AIM/ICQ via `flamenet-oscar`) | βšͺ | +| Services (AIM/ICQ via `flamenet-oscar`) | β€” retired; not a goal for this app (owner, 2026-09-04) | + +πŸ”΄ **History at rest was marked βšͺ and it has been built the whole time β€” the +third stale row in this file.** Every read goes to disk: `Session.history` calls +`Vault.history`, which decrypts the record. There is no in-memory copy to be +fooled by. + +What Android does, against what iOS's `E2EHistoryStore` does: + +| | iOS | Android | +|---|---|---| +| Cipher | AES-256-GCM | AES-256-GCM | +| Key | Keychain, `ThisDeviceOnly` | Android Keystore β€” never leaves the process at all | +| Bound to its slot | one file, nothing to confuse | 🟒 **the record NAME is the AAD**, so a blob cannot be moved between slots | +| Shape | one snapshot for everything | one record per thread | +| Off backups | excluded from iCloud | `allowBackup="false"`, and a Keystore key is unbackupable anyway | +| At rest, locked | `.completeFileProtection` | Credential-Encrypted storage: `filesDir` is unreadable until the first unlock after boot | +| Plain sign-out | keeps history | keeps history | +| Erase this device | `eraseLocalData()` | `forgetEverything()` **and** the Keystore key is deleted | + +⚠️ Two honest differences rather than a claim of equivalence. Android's +per-record AAD is a binding iOS's single file does not need; iOS's +`.completeFileProtection` is a per-file assertion, while Android's equivalent is +a property of the storage volume rather than of this app's request. Neither gap +favours one platform overall. + +⚠️ `Vault.history` prunes expired messages on the way out, so disappearing +messages are enforced on READ rather than by a timer nothing runs. It rewrites +the record only when something actually expired β€” a read is not a write. βœ… Calls are built, on `io.github.webrtc-sdk:android` β€” the one third-party library in this app, and unavoidable: the alternative is hand-rolling ICE, -- 2.54.0 From ead31053a4b02aa47d67930e8969b181105bf22e Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 4 Sep 2026 16:28:11 -0400 Subject: [PATCH 2/2] A live test for the file round trip, and four published passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LiveFileTransferTest` covers the one seam nothing else does: a document leaving one vault, crossing a real relay, and arriving in another's with its label intact. `FileShareWireTest` proves the envelope's shape and `LocalVaultTest` proves the vault at both ends; neither puts a byte on a wire. ⚠️ **Written, compiles, UNRUN** β€” the same state `LiveSignUpFleetTest` is in, and the same caveat applies: an unrun test proves nothing. It creates accounts, so it needs a throwaway relay and an explicit decision to point it at one. πŸ”΄ **Writing it surfaced four published passwords and a guard that could not see them.** `TestSafetyTest` exists to stop a credential being written into a suite that creates real accounts. It matched `val X = "…"` only. So: - `LiveConversationTest` passed the literal straight as an ARGUMENT, twice β€” invisible to the check. - `LiveSignUpTest`, `LiveKeyLogTest` and `LiveSealedSenderTest` wrote `password = "…"` on its own line inside a multi-line argument list, the most ordinary Kotlin formatting there is β€” also invisible. I copied the same literal into the new suite before noticing, which is how I found it. **A guard that passes while the thing it guards against is present in the tree is worse than no guard, because it is read as evidence.** All five call sites now read `LiveTarget.passphrase()` from the environment with no fallback, and announce a SKIP when it is unset. The check itself took two wrong turns worth recording: - Skipping every named argument, to avoid flagging `product = "messenger"` β€” which let the three `password = "…"` suites through. Trading one blind spot for another is not a fix; a named argument is now judged by its NAME. - Widening the sign-up match to a few lines around the call β€” which flagged forty test tags, assertion messages and message bodies as credentials. That is how a check stops being read, so it is same-line only, and rule 1 covers the multi-line case properly. "secret" was dropped from the credential names for the same reason: test prose is full of it, and a marker string called `secret` is a fixture. Co-Authored-By: Claude Opus 5 --- PARITY.md | 2 +- .../flamenet/client/LiveConversationTest.kt | 15 +- .../flamenet/client/LiveFileTransferTest.kt | 221 ++++++++++++++++++ .../io/flamenet/client/LiveKeyLogTest.kt | 10 +- .../flamenet/client/LiveSealedSenderTest.kt | 10 +- .../io/flamenet/client/LiveSignUpTest.kt | 10 +- .../io/flamenet/client/TestSafetyTest.kt | 62 ++++- 7 files changed, 312 insertions(+), 18 deletions(-) create mode 100644 client/src/test/kotlin/io/flamenet/client/LiveFileTransferTest.kt diff --git a/PARITY.md b/PARITY.md index 760b629..524a953 100644 --- a/PARITY.md +++ b/PARITY.md @@ -369,7 +369,7 @@ the messenger rather than staying separate apps. | Local vault: add, organise, delete | βœ… index, folders, both storage modes, bulk delete with its report | | Files screen: set up, unlock, browse, add, folders, delete, promote | βœ… **verified on a device** β€” set up, encrypt, lock, cold-start reload, link via SAF | | The security lattice (labels, clearance) | βœ… encoding and clearance in Kotlin, pinned to independently-computed vectors. ⚠️ **Not Bell–LaPadula** β€” the β˜…-property is deliberately unenforced on both clients; see android#15 | -| Share a file into a chat or group | βœ… both directions β€” send from the vault, save a received one back at the label it arrived with | +| Share a file into a chat or group | βœ… both directions; `LiveFileTransferTest` proves the label survives a real relay ⚠️ **unrun** | | Wallet engine: BIP-39, Base58, SLIP-0010 | βœ… pinned to the published vectors | | Wallet engine: address derivation and signing | βœ… BIP-44 path, Base58 address, and the engine's own RFC 8032 signs β€” no API-33 floor | | Wallet: BIP-39 seed, create and restore | βœ… create, import, restore, sealed at rest, with the phrase shown once and explained | diff --git a/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt index 52f5619..7389837 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt @@ -37,6 +37,16 @@ class LiveConversationTest { private fun needFnLiveFleet() { LiveTarget.refuseProduction(fleet) assumeTrue(fleet != null, "set FN_LIVE_FLEET to run this against a relay") + // πŸ”΄ The passphrase comes from the environment with no fallback. This + // file carried a hard-coded literal β€” a published password, used to + // create REAL accounts β€” and `TestSafetyTest` could + // not see it, because its check matched an ASSIGNMENT and this was an + // ARGUMENT. Both are fixed together. + assumeTrue( + LiveTarget.passphrase().isNotEmpty(), + "set FN_LIVE_PASSPHRASE β€” these tests create real accounts and this " + + "suite will not sign up with a value written into the source", + ) } /** A signed-up account with a registered device, ready to talk. */ @@ -62,7 +72,8 @@ class LiveConversationTest { private fun signUp(base: String, tag: String, preKeys: Int = 20): Person { val handle = "live$tag" + (System.nanoTime() % 10_000_000) - val account = RelayAccounts(base).signUp(handle, "$handle@example.invalid", "a-long-enough-passphrase") + val account = RelayAccounts(base) + .signUp(handle, "$handle@example.invalid", LiveTarget.passphrase()) val identity = DeviceIdentity.create() val vault = Vault(MapStorage(), key()) @@ -311,6 +322,6 @@ class LiveConversationTest { private fun appPasswordOf(person: Person): String = appPasswords.getOrPut(person.handle) { - RelayAccounts(fleet!!).signIn(person.handle, "a-long-enough-passphrase").appPassword + RelayAccounts(fleet!!).signIn(person.handle, LiveTarget.passphrase()).appPassword } } diff --git a/client/src/test/kotlin/io/flamenet/client/LiveFileTransferTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveFileTransferTest.kt new file mode 100644 index 0000000..564c449 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/LiveFileTransferTest.kt @@ -0,0 +1,221 @@ +package io.flamenet.client + +import io.flamenet.e2e.Attachment +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.FilesIdentity +import io.flamenet.e2e.Label +import io.flamenet.e2e.Vault +import java.io.File +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.jupiter.api.Assumptions.assumeTrue + +/** + * A document leaves one person's vault and arrives in another's, through a real + * relay. + * + * πŸ”΄ **This is the seam nothing else covers.** `FileShareWireTest` proves the + * envelope's shape, `LocalVaultTest` proves the vault at both ends, and neither + * of them puts a byte on a wire. What is only provable here is that the label + * survives an actual upload, an actual envelope, an actual poll and an actual + * save β€” the four steps between two vaults that each look right on their own. + * + * ⚠️ **The classification is the only thing that survives the trip.** The + * receiving phone files the document at whatever number arrives, because there + * is nothing else to consult and no server to ask. A label lost in transit does + * not error anywhere: the file simply arrives Unclassified and gets filed that + * way, on the most permissive setting it could possibly have. That is what this + * test is really watching for. + * + * ⚠️ Opt-in via `FN_LIVE_FLEET`, and pointed at a throwaway relay. It creates + * accounts and uploads blobs. + * + * FN_LIVE_FLEET=http://127.0.0.1:8080 ./gradlew :client:test + */ +class LiveFileTransferTest { + + private val fleet: String? = System.getenv("FN_LIVE_FLEET") + + /** + * ⚠️ Announced, not silent. A `return` from an unmet precondition makes a + * test that did nothing report as PASSED β€” see the note in + * `LiveConversationTest`, which is where that lesson was paid for. + */ + private fun needFnLiveFleet() { + LiveTarget.refuseProduction(fleet) + assumeTrue(fleet != null, "set FN_LIVE_FLEET to run this against a relay") + assumeTrue( + LiveTarget.passphrase().isNotEmpty(), + "set FN_LIVE_PASSPHRASE β€” these tests create real accounts and this " + + "suite will not sign up with a value written into the source", + ) + } + + private class MapStorage : Vault.Storage { + private val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + /** Nothing is linked in these tests, so nothing should ever ask. */ + private object NoReferences : LocalVault.ReferenceResolver { + override fun exists(bookmark: String) = + error("a vaulted file asked for a referenced one") + override fun read(bookmark: String): ByteArray = + error("a vaulted file asked for a referenced one") + override fun size(bookmark: String) = + error("a vaulted file asked for a referenced one") + } + + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + /** Somebody with a device, a relay session and a Files vault. */ + private class Person( + val handle: String, + val userId: Long, + val identity: DeviceIdentity, + val conversations: Conversations, + val api: MessengerApi, + val files: LocalVault, + val root: File, + ) + + private fun signUp(base: String, tag: String): Person { + val handle = "livef$tag" + (System.nanoTime() % 10_000_000) + val account = RelayAccounts(base) + .signUp(handle, "$handle@example.invalid", LiveTarget.passphrase()) + + val identity = DeviceIdentity.create() + val vault = Vault(MapStorage(), key()) + vault.saveIdentity(identity) + + val relay = RelayClient(base, account.username, account.appPassword) + relay.useRelayAuth(identity) + relay.registerDevice(identity, identity.generateOneTimePreKeys(20)) + vault.saveIdentity(identity) + + // A real Files identity, so the label keys are the ones the app derives + // β€” 1,000 rounds rather than 650,000 because this asserts the format, + // not the work factor. + val raw = FilesIdentity.rawFromPkcs8(io.flamenet.e2e.X25519.generate().private.encoded) + val root = File.createTempFile("livevault$tag", "").let { it.delete(); it.mkdirs(); it } + + return Person( + handle = handle, + userId = account.userId, + identity = identity, + conversations = Conversations(identity, vault, relay).also { it.myUserId = account.userId }, + api = MessengerApi(base, account.username, account.appPassword), + files = LocalVault(root, raw, NoReferences).also { it.load() }, + root = root, + ) + } + + private fun Conversations.readable(): List = + poll().filter { it.content.isRenderable } + + @Test + fun `a classified document crosses between two vaults with its label intact`() { + needFnLiveFleet() + val base = fleet!! + val alice = signUp(base, "a") + val bob = signUp(base, "b") + + val bytes = "revenue: 41\nheadcount: 12\n".toByteArray() + val label = Label(3, setOf(1, 2)) + val filed = alice.files.add(bytes, "Q3 Forecast.txt", label) + + // Out of Alice's vault and onto the wire, exactly as `Session.sendFile` + // does it: plaintext read out, the transport seals it, the label rides + // alongside. + val plaintext = alice.files.contents(filed) + assertContentEquals(bytes, plaintext, "the vault handed back the wrong bytes") + + val sent = alice.conversations.sendAttachment( + bob.userId, plaintext, "application/octet-stream", Attachment.FILE, + filename = "Q3 Forecast.txt", + classification = label.classification.toInt(), + compartments = label.compartments.toList(), + ) + assertTrue(sent.reachedSomeone, "nothing was delivered: ${sent.failed}") + + // In on Bob's side. + val received = bob.conversations.readable() + assertEquals(1, received.size, "Bob's inbox had ${received.size} items") + val ref = assertNotNull(received[0].content.attachment, + "the attachment did not survive the envelope") + + assertEquals(Attachment.FILE, ref.kind) + assertEquals("Q3 Forecast.txt", ref.filename, "the document's own name was lost") + // πŸ”΄ The assertion this test exists for. + assertEquals(3, ref.classification, + "the classification was lost in transit β€” the file would be filed Unclassified") + assertEquals(listOf(1, 2), ref.compartments.sorted(), + "the compartments were lost β€” the file would be filed outside its areas") + + // And the bytes come back off the relay. + val fetched = bob.conversations.fetchAttachment(ref) + assertContentEquals(bytes, fetched, "the bytes did not survive the round trip") + + // Bob saves it, at the label it arrived with β€” never one his phone chose. + val saved = bob.files.add( + fetched, ref.filename, + Label(ref.classification.toShort(), ref.compartments.toSet()), + ) + val entry = bob.files.list(null).single() + assertEquals("Q3 Forecast.txt", entry.name) + assertEquals(label, entry.label, + "the document was filed under a different label than it was sent at") + assertContentEquals(bytes, bob.files.contents(saved)) + + // πŸ”΄ **Two vaults, two identities, two different keys for one label.** + // The label key is derived from the identity, so the same + // classification does NOT mean the same key β€” which is what stops a + // stolen container from one phone opening anything on the other. + assertTrue( + !alice.files.labelKey(label).contentEquals(bob.files.labelKey(label)), + "two people derived the same key for one label β€” a stolen vault would open both", + ) + + alice.root.deleteRecursively() + bob.root.deleteRecursively() + } + + /** + * ⚠️ An Unclassified document must arrive as one, and be distinguishable + * from a label that went missing. Zero is the default for an absent field, + * so this is the case where a dropped label and a deliberate zero look + * identical at the far end β€” the filename and the kind are what say the + * envelope was understood. + */ + @Test + fun `an unclassified document is still recognisably a document`() { + needFnLiveFleet() + val base = fleet!! + val alice = signUp(base, "c") + val bob = signUp(base, "d") + + val bytes = "nothing secret here".toByteArray() + alice.conversations.sendAttachment( + bob.userId, bytes, "application/octet-stream", Attachment.FILE, + filename = "notes.txt", classification = 0, compartments = emptyList(), + ) + + val ref = assertNotNull(bob.conversations.readable().single().content.attachment) + assertEquals(Attachment.FILE, ref.kind, "an unlabelled file stopped being a file") + assertEquals("notes.txt", ref.filename) + assertEquals(0, ref.classification) + assertTrue(ref.compartments.isEmpty()) + assertContentEquals(bytes, bob.conversations.fetchAttachment(ref)) + + alice.root.deleteRecursively() + bob.root.deleteRecursively() + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt index 9666227..b31d65b 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt @@ -40,6 +40,14 @@ class LiveKeyLogTest { private fun needFnLiveFleet() { LiveTarget.refuseProduction(base) assumeTrue(base != null, "set FN_LIVE_FLEET to run this against a relay") + // πŸ”΄ No fallback. This suite carried a literal passphrase and used it + // to create REAL accounts β€” a published password, since anybody who can + // read this repository can read it. + assumeTrue( + LiveTarget.passphrase().isNotEmpty(), + "set FN_LIVE_PASSPHRASE β€” this suite creates real accounts and will " + + "not sign up with a value written into the source", + ) } private class MapStorage : Vault.Storage { @@ -56,7 +64,7 @@ class LiveKeyLogTest { val account = RelayAccounts(base).signUp( username = handle, email = "$handle@example.invalid", - password = "a-long-enough-passphrase", + password = LiveTarget.passphrase(), ) val identity: DeviceIdentity = DeviceIdentity.create() val vault = Vault( diff --git a/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt index fe4cedb..f86e40a 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt @@ -39,6 +39,14 @@ class LiveSealedSenderTest { private fun needFnLiveFleet() { LiveTarget.refuseProduction(base) assumeTrue(base != null, "set FN_LIVE_FLEET to run this against a relay") + // πŸ”΄ No fallback. This suite carried a literal passphrase and used it + // to create REAL accounts β€” a published password, since anybody who can + // read this repository can read it. + assumeTrue( + LiveTarget.passphrase().isNotEmpty(), + "set FN_LIVE_PASSPHRASE β€” this suite creates real accounts and will " + + "not sign up with a value written into the source", + ) } private class MapStorage : Vault.Storage { @@ -54,7 +62,7 @@ class LiveSealedSenderTest { val account = RelayAccounts(base).signUp( username = handle, email = "$handle@example.invalid", - password = "a-long-enough-passphrase", + password = LiveTarget.passphrase(), ) val identity: DeviceIdentity = DeviceIdentity.create() val vault = Vault( diff --git a/client/src/test/kotlin/io/flamenet/client/LiveSignUpTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveSignUpTest.kt index f0715a3..53233c6 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveSignUpTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveSignUpTest.kt @@ -34,6 +34,14 @@ class LiveSignUpTest { private fun needFnLiveFleet() { LiveTarget.refuseProduction(relay) assumeTrue(relay != null, "set FN_LIVE_FLEET to run this against a relay") + // πŸ”΄ No fallback. This suite carried a literal passphrase and used it + // to create REAL accounts β€” a published password, since anybody who can + // read this repository can read it. + assumeTrue( + LiveTarget.passphrase().isNotEmpty(), + "set FN_LIVE_PASSPHRASE β€” this suite creates real accounts and will " + + "not sign up with a value written into the source", + ) } @Test @@ -45,7 +53,7 @@ class LiveSignUpTest { val account = RelayAccounts(base).signUp( username = handle, email = "$handle@example.invalid", - password = "a-long-enough-passphrase", + password = LiveTarget.passphrase(), ) assertEquals(handle, account.username) assertTrue(account.appPassword.isNotEmpty()) diff --git a/client/src/test/kotlin/io/flamenet/client/TestSafetyTest.kt b/client/src/test/kotlin/io/flamenet/client/TestSafetyTest.kt index 43c8168..0c5052f 100644 --- a/client/src/test/kotlin/io/flamenet/client/TestSafetyTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/TestSafetyTest.kt @@ -97,21 +97,59 @@ class TestSafetyTest { fun `no live suite carries a credential literal`() { val offenders = mutableListOf() + /** + * A name that puts whatever follows it in a credential position. + * + * ⚠️ Deliberately NOT "secret". Test prose is full of it β€” a marker + * string called `secret` that the test then greps for in a container is + * a fixture, not a credential β€” and a check that cries wolf is a check + * somebody deletes. + */ + fun isCredentialName(name: String) = listOf("password", "passphrase", "credential") + .any { name.contains(it, ignoreCase = true) } + + /** A literal that is plainly something else. */ + fun isNotACredential(value: String) = + value.startsWith("http") || value.contains("@") || value.contains("/") + for (file in testSources()) { val code = codeLines(file.readText()) if (!reachesALiveRelay(code)) continue - for (line in code) { - val looksLikeCredential = line.contains("PASSPHRASE") || - line.contains("passphrase", ignoreCase = true) || - line.contains("password", ignoreCase = true) - if (!looksLikeCredential) continue - // `val X = "…"` or `const val X = "…"` β€” an assignment to a - // non-empty literal. A getter that reads the environment has no - // `= "` on it and is the shape this wants people to use. - val assigned = Regex("""\bva[lr]\s+\w+\s*(:\s*String\s*)?=\s*"([^"]+)"""") - .find(line)?.groupValues?.getOrNull(2) - if (assigned != null) { - offenders += "${file.name}: $assigned" + + for ((index, line) in code.withIndex()) { + for (literal in Regex(""""([^"\\$]{6,})"""").findAll(line)) { + val value = literal.groupValues[1] + if (isNotACredential(value)) continue + val before = line.substring(0, literal.range.first) + + // 1. Anything assigned to a credential-shaped name, with or + // without `val` β€” `const val PASSPHRASE = "…"`, + // `password = "…"`, `passphrase = "…"`. + // + // πŸ”΄ The `val` was once required, and `password = "…"` on + // its own line inside a multi-line argument list β€” the most + // ordinary Kotlin formatting there is β€” walked past it in + // three suites. + val assignedTo = Regex("""(\w+)\s*=\s*$""").find(before)?.groupValues?.get(1) + if (assignedTo != null && isCredentialName(assignedTo)) { + offenders += "${file.name}: $value (assigned to $assignedTo)" + continue + } + + // 2. A positional literal handed to sign-up on the SAME + // line. Nothing else it could be. + // + // ⚠️ Same line only, and that is a deliberate limit rather + // than an oversight. Widening it to a few lines around the + // call flagged test tags, assertion messages and message + // bodies as credentials β€” forty of them β€” which is how a + // check stops being read. The multi-line case is covered by + // rule 1 instead, which sees `password = "…"` wherever it + // sits, and that is the formatting that actually occurs. + if (assignedTo != null) continue + if (Regex("""\bsign(Up|In)\s*\(""").containsMatchIn(before)) { + offenders += "${file.name}: $value (passed to sign-up)" + } } } } -- 2.54.0