API-style comments and ktlint formatting
Every comment is reduced to its first sentence plus @param, @return and @throws tags. Comment lines: 20040 to 8526. ktlint 1.8 applied with its default (official Kotlin) style across all modules; 262 files reformatted. The 264 remaining ktlint findings are naming and filename rules, which are code changes, and are left alone. Comments and formatting only. 1208 tests pass, counted from the XML. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
8a7f40fb13
commit
2fa430f21c
@@ -4,52 +4,23 @@ import io.flamenet.e2e.Ed25519
|
||||
import io.flamenet.e2e.FNID
|
||||
|
||||
/**
|
||||
* Turning a passphrase into an address, and telling relays where to deliver.
|
||||
*
|
||||
* ## Why this exists at all
|
||||
*
|
||||
* A relay's `user_id` is a row number it assigned itself. Register at one relay
|
||||
* and everything about you, your name there, your devices, your mailbox, is
|
||||
* that relay's to keep or to lose. This makes the *identity* the user's
|
||||
* instead: an Ed25519 key stretched from the passphrase, whose public half is
|
||||
* the address. No server issues it, so no server can take it away.
|
||||
*
|
||||
* The practical consequence is the one worth stating: sign in with the same
|
||||
* passphrase at a relay that has never heard of you and you get the same
|
||||
* address. Contacts who accepted you before still see the same person. A
|
||||
* relay shutting down costs a mailbox, not an identity.
|
||||
*
|
||||
* ## Why it is here and not in the engine
|
||||
*
|
||||
* The engine holds no network. Deriving the key and building the bytes to sign
|
||||
* are engine work and live in [FNID] and `AccountKdf`; presenting the result to
|
||||
* a relay is client work and lives here, so the engine stays testable without
|
||||
* one and this stays swappable without touching crypto.
|
||||
* Turning a passphrase into an address, and telling relays where to deliver. ##
|
||||
* Why this exists at all A relay's `user_id` is a row number it assigned
|
||||
* itself.
|
||||
*/
|
||||
object AccountIdentity {
|
||||
|
||||
/** The address for an account seed. Derived, never fetched. */
|
||||
fun address(seed: ByteArray): String = FNID.address(Ed25519.fromSeed(seed).publicKey)
|
||||
|
||||
/**
|
||||
* Claim the address at [relay], learning which audience to bind from the
|
||||
* relay itself.
|
||||
*
|
||||
* The audience is not the caller's to guess. It is the string the
|
||||
* relay checks the signature against, so a caller that inferred it from a
|
||||
* URL would produce a claim that verifies nowhere, silently, because at
|
||||
* the point of failure a wrong audience and a wrong key look identical.
|
||||
* Reading it from the descriptor means the only way to get it wrong is for
|
||||
* the relay to lie about its own name, which costs it the claim rather than
|
||||
* us the identity.
|
||||
*
|
||||
* Self-certifying: the signature is made with the very key being
|
||||
* registered, so the relay confirms possession and forms no opinion about
|
||||
* whose key it is, the property that lets a hostile relay be tolerated.
|
||||
*
|
||||
* Returns the address the relay recorded.
|
||||
*/
|
||||
fun claim(relay: RelayClient, seed: ByteArray, userId: Long): String {
|
||||
fun claim(
|
||||
relay: RelayClient,
|
||||
seed: ByteArray,
|
||||
userId: Long,
|
||||
): String {
|
||||
val key = Ed25519.fromSeed(seed)
|
||||
val pub = b64(key.publicKey)
|
||||
val audience = relay.relayDescriptor().getString("audience")
|
||||
@@ -59,26 +30,12 @@ object AccountIdentity {
|
||||
|
||||
/**
|
||||
* Publish where this account keeps mailboxes, to every relay in the list.
|
||||
*
|
||||
* Every listed relay is told, not just the one being added: a home that
|
||||
* does not know it is one will still accept mail, but a *sender* asking it
|
||||
* for the record would be handed a stale list and would skip the others.
|
||||
* Delivering the record everywhere is what keeps them from disagreeing.
|
||||
*
|
||||
* Takes a client per home, and that is the shape of the feature rather
|
||||
* than an implementation detail: a relay will not accept a record from
|
||||
* somebody with no account there, and a token minted by one relay is
|
||||
* rejected by every other because the audience is bound. So homing
|
||||
* somewhere means registering there first, and a map makes that a
|
||||
* compile-time fact instead of a runtime surprise.
|
||||
*
|
||||
* Returns the relays that refused, with why. Reported rather than thrown on
|
||||
* the first failure: one home being unreachable while the rest accept is a
|
||||
* partial success the user can act on, and collapsing it into a single
|
||||
* error would hide which.
|
||||
*/
|
||||
fun publishHomes(
|
||||
seed: ByteArray, fnid: String, serial: Int, homes: Map<String, RelayClient>
|
||||
seed: ByteArray,
|
||||
fnid: String,
|
||||
serial: Int,
|
||||
homes: Map<String, RelayClient>,
|
||||
): Map<String, Throwable> {
|
||||
val relays = homes.keys.toList()
|
||||
val sig = b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays)))
|
||||
@@ -93,12 +50,11 @@ object AccountIdentity {
|
||||
/**
|
||||
* The next serial to publish, read from what is already out there rather
|
||||
* than from local state.
|
||||
*
|
||||
* A device that was offline while another added a relay would otherwise
|
||||
* reuse a serial and have its record refused as a rollback, by the very
|
||||
* check that exists to stop an attacker doing the same thing.
|
||||
*/
|
||||
fun nextSerial(fnid: String, clients: Collection<RelayClient>): Int {
|
||||
fun nextSerial(
|
||||
fnid: String,
|
||||
clients: Collection<RelayClient>,
|
||||
): Int {
|
||||
var highest = 0
|
||||
for (c in clients) {
|
||||
val record = runCatching { c.homes(fnid) }.getOrNull() ?: continue
|
||||
@@ -109,12 +65,13 @@ object AccountIdentity {
|
||||
|
||||
/**
|
||||
* Check a home record against the address it claims to be for.
|
||||
*
|
||||
* Needs nothing but the record, because the address *is* the public key,
|
||||
* which is the point. A sender must not have to ask a relay whether that
|
||||
* relay is telling the truth about where to deliver.
|
||||
*/
|
||||
fun verifyHomes(fnid: String, serial: Int, relays: List<String>, signatureB64: String): Boolean {
|
||||
fun verifyHomes(
|
||||
fnid: String,
|
||||
serial: Int,
|
||||
relays: List<String>,
|
||||
signatureB64: String,
|
||||
): Boolean {
|
||||
val pub = FNID.decode(fnid) ?: return false
|
||||
val sig = runCatching { unb64(signatureB64) }.getOrNull() ?: return false
|
||||
return Ed25519.verify(pub, sig, FNID.homesCanonical(fnid, serial, relays))
|
||||
@@ -123,27 +80,6 @@ object AccountIdentity {
|
||||
/**
|
||||
* Relays named by a record, but only once it verifies AND is not older than
|
||||
* one already seen.
|
||||
*
|
||||
* A signature alone does not stop a rollback, and that is the whole
|
||||
* reason the serial exists. An older record is *signed*: it was
|
||||
* published by the account holder, before they added a second home. A relay
|
||||
* that wants to be somebody's only mailbox does not have to forge anything
|
||||
* it serves the record it saw first, that record verifies perfectly, and
|
||||
* the sender delivers to one relay believing it is delivering to all of
|
||||
* them.
|
||||
*
|
||||
* That is the attack multi-homing exists to survive, and until this both
|
||||
* clients checked only the signature. The relay refuses a lower serial on
|
||||
* WRITE; nothing refused one on READ.
|
||||
*
|
||||
* [floor] is the highest serial this device has already accepted for
|
||||
* this address, or null on first sight. Equal is fine, the same record
|
||||
* re-read is the ordinary case. Only strictly lower is a rollback.
|
||||
*
|
||||
* A refusal here is not proof of an attack, and the caller must not
|
||||
* present it as one. A relay that has fallen behind serves an old
|
||||
* record for honest reasons; what the client can say is that it has seen a
|
||||
* newer one, and that it will keep using it.
|
||||
*/
|
||||
fun verifiedRelays(
|
||||
record: org.json.JSONObject,
|
||||
@@ -171,9 +107,8 @@ object AccountIdentity {
|
||||
}
|
||||
|
||||
// The module's own helper, so this encodes identically to every other blob
|
||||
// the transport sends. `android.util.Base64` is deliberately not reachable
|
||||
// here: this module is plain JVM precisely so its tests run against a real
|
||||
// relay with no emulator.
|
||||
// the transport sends.
|
||||
private fun b64(b: ByteArray): String = Wire.b64(b)
|
||||
|
||||
private fun unb64(s: String): ByteArray = Wire.unb64(s)
|
||||
}
|
||||
|
||||
@@ -12,50 +12,30 @@ import java.security.SecureRandom
|
||||
/**
|
||||
* The shared FlameNet account service, one credential across Messenger and
|
||||
* Files.
|
||||
*
|
||||
* The passphrase never leaves the device. It is stretched here and only
|
||||
* the derived secret is sent, which is why sign-in is two round trips rather
|
||||
* than one: the salt and iteration count are per account, and the service
|
||||
* cannot derive the secret itself. That is the point, and collapsing it
|
||||
* to one call would mean sending the passphrase.
|
||||
*
|
||||
* Used only for relays whose accounts live in the shared service. A self-hosted
|
||||
* relay authenticates against its own table and never touches this, see
|
||||
* [SiteConfig], which decides from the hostname rather than from anything the
|
||||
* relay says about itself.
|
||||
*/
|
||||
class AccountsClient(
|
||||
baseUrl: String = "https://accounts.flamenet.io",
|
||||
private val http: OkHttpClient = RelayClient.defaultHttpClient(),
|
||||
) {
|
||||
|
||||
private val base = baseUrl.trimEnd('/').toHttpUrl()
|
||||
|
||||
/**
|
||||
* The product a session is scoped to.
|
||||
*
|
||||
* Sessions are per product, and that is the privacy property. The
|
||||
* token carries a pseudonym derived for this product, so Messenger and
|
||||
* Files receive different opaque ids for the same person and cannot
|
||||
* correlate their users with each other.
|
||||
*/
|
||||
private val defaultProduct = "messenger"
|
||||
|
||||
/**
|
||||
* What the service advertises for a handle.
|
||||
*
|
||||
* An unknown handle gets a deterministic decoy salt, not an error,
|
||||
* so this route cannot be used to test whether somebody has an account.
|
||||
* Treat a success as "here are parameters", never as "this handle exists",
|
||||
* a UI that says "no such account" here undoes the whole design.
|
||||
*/
|
||||
data class Params(val salt: ByteArray, val kdfId: Int, val iterations: Int)
|
||||
data class Params(
|
||||
val salt: ByteArray,
|
||||
val kdfId: Int,
|
||||
val iterations: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* @property accountKeySeed the 32-byte Ed25519 seed whose public half is
|
||||
* this account's address. Derived from the same stretch as the auth
|
||||
* secret and never sent anywhere, see [AccountKdf.signInSecrets] for
|
||||
* why it comes back here rather than being asked for separately.
|
||||
* this account's address.
|
||||
*/
|
||||
data class Session(
|
||||
val token: String,
|
||||
@@ -64,54 +44,64 @@ class AccountsClient(
|
||||
val accountKeySeed: ByteArray? = null,
|
||||
)
|
||||
|
||||
sealed class Failure(message: String) : Exception(message) {
|
||||
sealed class Failure(
|
||||
message: String,
|
||||
) : Exception(message) {
|
||||
/** Wrong handle and wrong passphrase are the same answer, on purpose. */
|
||||
object BadCredentials : Failure("that handle or passphrase isn't right")
|
||||
|
||||
/**
|
||||
* The session is no longer accepted, expired, or revoked elsewhere.
|
||||
*
|
||||
* Not the same as a wrong passphrase, and saying so matters.
|
||||
* Every 401 and 403 used to become [BadCredentials], so a session that
|
||||
* simply aged out told the user "that handle or passphrase isn't
|
||||
* right", in the middle of sending a message, hours after they signed
|
||||
* in successfully. The natural reading is that their password stopped
|
||||
* working or was changed by someone else, and the natural response is
|
||||
* to change a password that was never wrong.
|
||||
*/
|
||||
object SessionExpired : Failure("your session has expired, sign in again")
|
||||
|
||||
object HandleTaken : Failure("that handle is already taken")
|
||||
|
||||
/**
|
||||
* A 409 is not always a taken handle, and saying so was wrong.
|
||||
* The relay answers 409 for a taken handle, a registered email, AND a
|
||||
* handle too close to an existing one, three different problems with
|
||||
* three different fixes. Collapsing them told somebody whose EMAIL was
|
||||
* already registered to pick a different handle, which cannot work and
|
||||
* gives them no way to discover why.
|
||||
*
|
||||
* The status is the transport's opinion; the `code` in the body is the
|
||||
* relay's, and it is the one that means something.
|
||||
*/
|
||||
object EmailTaken : Failure("an account with that email already exists")
|
||||
|
||||
/** The handle folds onto an existing one, see the relay's
|
||||
* `User.confusableSkeleton`. Changing a digit will not help. */
|
||||
/**
|
||||
* The handle folds onto an existing one, see the relay's.
|
||||
*/
|
||||
object HandleConfusable : Failure("that handle is too easily mistaken for another")
|
||||
|
||||
object DisposableEmail : Failure("that email provider isn't accepted")
|
||||
|
||||
object BadHandle : Failure("that handle isn't allowed")
|
||||
|
||||
object BadEmail : Failure("that doesn't look like an email address")
|
||||
|
||||
object WeakPassword : Failure("that passphrase is too short")
|
||||
|
||||
object RateLimited : Failure("too many attempts, wait a few minutes")
|
||||
class Service(val status: Int) : Failure("the account service returned $status")
|
||||
class Transport(cause: String) : Failure("couldn't reach the account service: $cause")
|
||||
|
||||
class Service(
|
||||
val status: Int,
|
||||
) : Failure("the account service returned $status")
|
||||
|
||||
class Transport(
|
||||
cause: String,
|
||||
) : Failure("couldn't reach the account service: $cause")
|
||||
}
|
||||
|
||||
fun parameters(handle: String): Params {
|
||||
val url = base.newBuilder().addPathSegment("accounts").addPathSegment(handle)
|
||||
.addPathSegment("params").build()
|
||||
val json = send(Request.Builder().url(url).get().build())
|
||||
val url =
|
||||
base
|
||||
.newBuilder()
|
||||
.addPathSegment("accounts")
|
||||
.addPathSegment(handle)
|
||||
.addPathSegment("params")
|
||||
.build()
|
||||
val json =
|
||||
send(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build(),
|
||||
)
|
||||
return Params(
|
||||
salt = Wire.unb64(json.getString("kdfSalt")),
|
||||
kdfId = json.getInt("kdfID"),
|
||||
@@ -119,26 +109,30 @@ class AccountsClient(
|
||||
)
|
||||
}
|
||||
|
||||
fun signIn(handle: String, passphrase: String, product: String = defaultProduct): Session {
|
||||
fun signIn(
|
||||
handle: String,
|
||||
passphrase: String,
|
||||
product: String = defaultProduct,
|
||||
): Session {
|
||||
val params = parameters(handle)
|
||||
// The ADVERTISED iteration count wins over the local constant. An
|
||||
// account created under an older count must still be derivable, and the
|
||||
// service is the one that knows which, hardcoding it here silently
|
||||
// locks out every older account.
|
||||
val effective = AccountKdf.Params(
|
||||
id = params.kdfId,
|
||||
iterations = params.iterations,
|
||||
saltBytes = params.salt.size,
|
||||
)
|
||||
// Both secrets from ONE stretch. Asking for the account key separately
|
||||
// would re-run 650,000 iterations for a value already in hand, several
|
||||
// seconds on a phone, spent twice, to sign in once.
|
||||
// The ADVERTISED iteration count wins over the local constant.
|
||||
val effective =
|
||||
AccountKdf.Params(
|
||||
id = params.kdfId,
|
||||
iterations = params.iterations,
|
||||
saltBytes = params.salt.size,
|
||||
)
|
||||
// Both secrets from ONE stretch.
|
||||
val (secret, accountKeySeed) = AccountKdf.signInSecrets(passphrase, params.salt, effective)
|
||||
|
||||
val json = post("sessions", JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("authSecret", Wire.b64(secret))
|
||||
.put("product", product))
|
||||
val json =
|
||||
post(
|
||||
"sessions",
|
||||
JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("authSecret", Wire.b64(secret))
|
||||
.put("product", product),
|
||||
)
|
||||
return Session(
|
||||
token = json.getString("token"),
|
||||
pseudonym = json.optString("pseudonym", ""),
|
||||
@@ -148,89 +142,117 @@ class AccountsClient(
|
||||
}
|
||||
|
||||
/** Create an account. The salt is generated here, not by the service. */
|
||||
fun register(handle: String, passphrase: String) {
|
||||
fun register(
|
||||
handle: String,
|
||||
passphrase: String,
|
||||
) {
|
||||
val params = AccountKdf.Params.PBKDF2_V1
|
||||
val salt = ByteArray(params.saltBytes).also { SecureRandom().nextBytes(it) }
|
||||
val secret = AccountKdf.authSecret(passphrase, salt, params)
|
||||
post("accounts", JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("authSecret", Wire.b64(secret))
|
||||
.put("kdfSalt", Wire.b64(salt))
|
||||
.put("kdfIterations", params.iterations))
|
||||
post(
|
||||
"accounts",
|
||||
JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("authSecret", Wire.b64(secret))
|
||||
.put("kdfSalt", Wire.b64(salt))
|
||||
.put("kdfIterations", params.iterations),
|
||||
)
|
||||
}
|
||||
|
||||
private fun post(path: String, body: JSONObject): JSONObject = send(
|
||||
Request.Builder()
|
||||
.url(base.newBuilder().addPathSegments(path).build())
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("Accept", "application/json")
|
||||
.build(),
|
||||
// Only these two actually present a passphrase, so only these two can
|
||||
// meaningfully report one as wrong.
|
||||
carriesCredentials = path == "sessions" || path == "accounts",
|
||||
)
|
||||
private fun post(
|
||||
path: String,
|
||||
body: JSONObject,
|
||||
): JSONObject =
|
||||
send(
|
||||
Request
|
||||
.Builder()
|
||||
.url(base.newBuilder().addPathSegments(path).build())
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("Accept", "application/json")
|
||||
.build(),
|
||||
// Only these two actually present a passphrase, so only these two can
|
||||
// meaningfully report one as wrong.
|
||||
carriesCredentials = path == "sessions" || path == "accounts",
|
||||
)
|
||||
|
||||
private fun send(request: Request, carriesCredentials: Boolean = false): JSONObject {
|
||||
val response = try {
|
||||
http.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
throw Failure.Transport(e.message ?: e.toString())
|
||||
}
|
||||
private fun send(
|
||||
request: Request,
|
||||
carriesCredentials: Boolean = false,
|
||||
): JSONObject {
|
||||
val response =
|
||||
try {
|
||||
http.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
throw Failure.Transport(e.message ?: e.toString())
|
||||
}
|
||||
response.use {
|
||||
val text = it.body?.string().orEmpty()
|
||||
when (it.code) {
|
||||
in 200..299 -> return if (text.isBlank()) JSONObject() else try {
|
||||
JSONObject(text)
|
||||
} catch (_: Exception) {
|
||||
throw Failure.Transport("unreadable response")
|
||||
in 200..299 -> {
|
||||
return if (text.isBlank()) {
|
||||
JSONObject()
|
||||
} else {
|
||||
try {
|
||||
JSONObject(text)
|
||||
} catch (_: Exception) {
|
||||
throw Failure.Transport("unreadable response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The SAME status means different things depending on what was
|
||||
// sent.
|
||||
401, 403 -> {
|
||||
throw if (carriesCredentials) {
|
||||
Failure.BadCredentials
|
||||
} else {
|
||||
Failure.SessionExpired
|
||||
}
|
||||
}
|
||||
|
||||
// Read the relay's own code, not just the status.
|
||||
409, 400 -> {
|
||||
throw failureFor(code(text), status = it.code)
|
||||
}
|
||||
|
||||
429 -> {
|
||||
throw Failure.RateLimited
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw Failure.Service(it.code)
|
||||
}
|
||||
// The SAME status means different things depending on what
|
||||
// was sent. Rejecting a passphrase is BadCredentials; refusing
|
||||
// a token the user cannot see is an expired session.
|
||||
401, 403 ->
|
||||
throw if (carriesCredentials) Failure.BadCredentials
|
||||
else Failure.SessionExpired
|
||||
// Read the relay's own code, not just the status. A
|
||||
// 400 became a bare "the account service returned 400" and a
|
||||
// 409 became "that handle is already taken" whatever the
|
||||
// actual reason, so a weak passphrase, an unusable email and
|
||||
// an already-registered address were all reported as something
|
||||
// the person could not act on, or as the wrong problem
|
||||
// entirely. The relay has always sent a specific code; nothing
|
||||
// here ever read it.
|
||||
409, 400 -> throw failureFor(code(text), status = it.code)
|
||||
429 -> throw Failure.RateLimited
|
||||
else -> throw Failure.Service(it.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay's error code, or null when the body is not its error shape.
|
||||
*
|
||||
* Never throws. A body that cannot be parsed is a transport problem, not
|
||||
* a reason to lose the status we already have.
|
||||
*/
|
||||
private fun code(body: String): String? = runCatching {
|
||||
JSONObject(body).optString("code").takeIf { it.isNotEmpty() }
|
||||
}.getOrNull()
|
||||
private fun code(body: String): String? =
|
||||
runCatching {
|
||||
JSONObject(body).optString("code").takeIf { it.isNotEmpty() }
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Falls back to the STATUS when the code is unknown, so a refusal this
|
||||
* build has never heard of still reports as a refusal rather than as a
|
||||
* success or a crash. A relay newer than the app is the normal case, not
|
||||
* the exception.
|
||||
* success or a crash.
|
||||
*/
|
||||
private fun failureFor(code: String?, status: Int): Failure = when (code) {
|
||||
"username_taken" -> Failure.HandleTaken
|
||||
"email_taken" -> Failure.EmailTaken
|
||||
"username_confusable" -> Failure.HandleConfusable
|
||||
"disposable_email" -> Failure.DisposableEmail
|
||||
"bad_username" -> Failure.BadHandle
|
||||
"bad_email" -> Failure.BadEmail
|
||||
"weak_password" -> Failure.WeakPassword
|
||||
else -> if (status == 409) Failure.HandleTaken else Failure.Service(status)
|
||||
}
|
||||
private fun failureFor(
|
||||
code: String?,
|
||||
status: Int,
|
||||
): Failure =
|
||||
when (code) {
|
||||
"username_taken" -> Failure.HandleTaken
|
||||
"email_taken" -> Failure.EmailTaken
|
||||
"username_confusable" -> Failure.HandleConfusable
|
||||
"disposable_email" -> Failure.DisposableEmail
|
||||
"bad_username" -> Failure.BadHandle
|
||||
"bad_email" -> Failure.BadEmail
|
||||
"weak_password" -> Failure.WeakPassword
|
||||
else -> if (status == 409) Failure.HandleTaken else Failure.Service(status)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val JSON = "application/json".toMediaType()
|
||||
|
||||
@@ -9,63 +9,63 @@ import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Talking to a chain node, for the wallet.
|
||||
*
|
||||
* The node is somebody else's server and it is not trusted with
|
||||
* anything. It is asked what a public address holds and handed transactions
|
||||
* that are already signed; it never sees a key, and it cannot move money. What
|
||||
* it CAN do is lie about a balance or withhold a history, so nothing here
|
||||
* treats an answer as authoritative beyond "this is what that node said".
|
||||
*
|
||||
* Which node somebody points at is a privacy decision, and the app has to
|
||||
* say so. Asking a public RPC endpoint for the balance of an address ties
|
||||
* that address to this device's IP, on somebody else's logs. Running your own
|
||||
* node is the only answer that removes it; the default exists so the wallet
|
||||
* works at all, not because it is private.
|
||||
*/
|
||||
class ChainRpc(
|
||||
val endpoint: String,
|
||||
private val http: OkHttpClient = OkHttpClient(),
|
||||
) {
|
||||
|
||||
sealed class Failure(message: String) : Exception(message) {
|
||||
sealed class Failure(
|
||||
message: String,
|
||||
) : Exception(message) {
|
||||
/** The transport refused before any JSON-RPC happened. */
|
||||
class Http(val code: Int) : Failure("The node answered with $code.")
|
||||
class Http(
|
||||
val code: Int,
|
||||
) : Failure("The node answered with $code.")
|
||||
|
||||
/**
|
||||
* The node's own words. It knows why it refused and we do not, so its
|
||||
* message is carried rather than replaced with a guess.
|
||||
* The node's own words.
|
||||
*/
|
||||
class Node(val detail: String) : Failure(detail)
|
||||
class Node(
|
||||
val detail: String,
|
||||
) : Failure(detail)
|
||||
|
||||
class Malformed(val what: String) :
|
||||
Failure("That node's answer wasn't something this app understands.")
|
||||
class Malformed(
|
||||
val what: String,
|
||||
) : Failure("That node's answer wasn't something this app understands.")
|
||||
}
|
||||
|
||||
// ---- Wire ------------------------------------------------------------
|
||||
// ---- Wire ------------------------------------------------------------.
|
||||
|
||||
private fun call(method: String, params: JSONArray): Any {
|
||||
val body = JSONObject()
|
||||
.put("jsonrpc", "2.0")
|
||||
.put("id", 1)
|
||||
.put("method", method)
|
||||
.put("params", params)
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
private fun call(
|
||||
method: String,
|
||||
params: JSONArray,
|
||||
): Any {
|
||||
val body =
|
||||
JSONObject()
|
||||
.put("jsonrpc", "2.0")
|
||||
.put("id", 1)
|
||||
.put("method", method)
|
||||
.put("params", params)
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder().url(endpoint).post(body).build()
|
||||
val text = http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw Failure.Http(response.code)
|
||||
response.body?.string().orEmpty()
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(endpoint)
|
||||
.post(body)
|
||||
.build()
|
||||
val text =
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw Failure.Http(response.code)
|
||||
response.body?.string().orEmpty()
|
||||
}
|
||||
|
||||
val json = runCatching { JSONObject(text) }.getOrNull()
|
||||
?: throw Failure.Malformed("not an object")
|
||||
val json =
|
||||
runCatching { JSONObject(text) }.getOrNull()
|
||||
?: throw Failure.Malformed("not an object")
|
||||
|
||||
// A JSON-RPC error arrives with HTTP 200. Reading only the status
|
||||
// code takes a refusal for an answer, and then something fails further
|
||||
// down with the node's own explanation already thrown away, which is
|
||||
// the difference between "insufficient funds for rent" on screen and a
|
||||
// parse error nobody can act on.
|
||||
// A JSON-RPC error arrives with HTTP 200.
|
||||
json.optJSONObject("error")?.let { error ->
|
||||
throw Failure.Node(error.optString("message").ifEmpty { "The node refused." })
|
||||
}
|
||||
@@ -73,14 +73,10 @@ class ChainRpc(
|
||||
return json.get("result")
|
||||
}
|
||||
|
||||
// ---- Reading ---------------------------------------------------------
|
||||
// ---- Reading ---------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The balance in lamports, the chain's smallest unit.
|
||||
*
|
||||
* Lamports, not SOL, all the way to the edge of the UI. Converting early
|
||||
* means carrying a fractional value through code that compares and
|
||||
* subtracts, and a rounding error in a balance is money.
|
||||
*/
|
||||
fun balance(address: String): Long {
|
||||
val result = call("getBalance", JSONArray().put(address))
|
||||
@@ -90,45 +86,48 @@ class ChainRpc(
|
||||
}
|
||||
|
||||
/** What has happened to an address, newest first. */
|
||||
fun history(address: String, limit: Int = 25): List<Entry> {
|
||||
val result = call(
|
||||
"getSignaturesForAddress",
|
||||
JSONArray().put(address).put(JSONObject().put("limit", limit)),
|
||||
)
|
||||
fun history(
|
||||
address: String,
|
||||
limit: Int = 25,
|
||||
): List<Entry> {
|
||||
val result =
|
||||
call(
|
||||
"getSignaturesForAddress",
|
||||
JSONArray().put(address).put(JSONObject().put("limit", limit)),
|
||||
)
|
||||
val rows = result as? JSONArray ?: throw Failure.Malformed("history")
|
||||
return (0 until rows.length()).mapNotNull { Entry.from(rows.optJSONObject(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of an address's history.
|
||||
*
|
||||
* A FAILED transaction still appears in an address's history. Not
|
||||
* showing that it failed would be the worst kind of wrong: somebody would
|
||||
* believe a payment went through.
|
||||
*/
|
||||
data class Entry(
|
||||
val signature: String,
|
||||
val slot: Long,
|
||||
/**
|
||||
* Absent while a transaction is very new, the node has it but has not
|
||||
* dated it yet. Null means "just now", never 1970.
|
||||
* dated it yet.
|
||||
*/
|
||||
val blockTimeSeconds: Long?,
|
||||
val failed: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
fun from(json: JSONObject?): Entry? {
|
||||
val signature = json?.optString("signature")?.takeIf { it.isNotEmpty() }
|
||||
?: return null
|
||||
val signature =
|
||||
json?.optString("signature")?.takeIf { it.isNotEmpty() }
|
||||
?: return null
|
||||
return Entry(
|
||||
signature = signature,
|
||||
slot = json.optLong("slot", 0),
|
||||
blockTimeSeconds = if (json.isNull("blockTime")) null
|
||||
else json.optLong("blockTime"),
|
||||
// `err` is an object or an array depending on the
|
||||
// failure, and `null` when there was none. Testing for
|
||||
// "not null" rather than for a shape is what makes both
|
||||
// kinds of failure visible.
|
||||
blockTimeSeconds =
|
||||
if (json.isNull("blockTime")) {
|
||||
null
|
||||
} else {
|
||||
json.optLong("blockTime")
|
||||
},
|
||||
// `err` is an object or an array depending on the failure,
|
||||
// and `null` when there was none.
|
||||
failed = !json.isNull("err"),
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,10 @@
|
||||
package io.flamenet.client
|
||||
|
||||
/**
|
||||
* What the client already knows about delivery, kept where a person can read
|
||||
* it (ios#21, ios#23, the Android half).
|
||||
*
|
||||
* The client has always known this; it only ever told the log. Every
|
||||
* refusal went to `System.err` and nowhere else, so a send blocked by the
|
||||
* identity guard reached the person sending it as "Something went wrong
|
||||
* reaching that server", which was neither true nor actionable.
|
||||
*
|
||||
* Shape only, never content. Counts, reasons and timestamps. No message
|
||||
* text, no key material, no payloads, and no identifiers, see
|
||||
* [normalisedReason]. A diagnostic reading that leaks what it is diagnosing is
|
||||
* worse than none, and this one is meant to be safe to screenshot.
|
||||
*
|
||||
* In memory, deliberately. It describes the session you are in; persisting
|
||||
* it would be a growing record of who you talked to and when, which is the
|
||||
* metadata this project spends its effort not keeping.
|
||||
* What the client already knows about delivery, kept where a person can read it
|
||||
* (ios#21, ios#23, the Android half).
|
||||
*/
|
||||
object DeliveryDiagnostics {
|
||||
|
||||
data class Peer(
|
||||
var envelopesSeen: Int = 0,
|
||||
var delivered: Int = 0,
|
||||
@@ -33,9 +18,7 @@ object DeliveryDiagnostics {
|
||||
val dropTotal: Int get() = drops.values.sum()
|
||||
|
||||
/**
|
||||
* A client that repeatedly repairs is a symptom, not a cure. One is
|
||||
* ordinary, a contact reinstalled. Three against the same peer means
|
||||
* something is not settling, and that is worth saying out loud.
|
||||
* A client that repeatedly repairs is a symptom, not a cure.
|
||||
*/
|
||||
val repairsLookAbnormal: Boolean get() = repairs >= 3
|
||||
}
|
||||
@@ -54,27 +37,39 @@ object DeliveryDiagnostics {
|
||||
peers.getOrPut(from) { Peer() }.delivered++
|
||||
}
|
||||
|
||||
@Synchronized fun dropped(reason: String, from: Long) {
|
||||
@Synchronized fun dropped(
|
||||
reason: String,
|
||||
from: Long,
|
||||
) {
|
||||
val p = peers.getOrPut(from) { Peer() }
|
||||
val slug = normalisedReason(reason)
|
||||
p.drops[slug] = (p.drops[slug] ?: 0) + 1
|
||||
}
|
||||
|
||||
@Synchronized fun repaired(userId: Long, at: Long = System.currentTimeMillis()) {
|
||||
@Synchronized fun repaired(
|
||||
userId: Long,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val p = peers.getOrPut(userId) { Peer() }
|
||||
p.repairs++
|
||||
p.lastRepairAt = at
|
||||
}
|
||||
|
||||
@Synchronized fun sendFailed(reason: String, to: Long,
|
||||
at: Long = System.currentTimeMillis()) {
|
||||
@Synchronized fun sendFailed(
|
||||
reason: String,
|
||||
to: Long,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val p = peers.getOrPut(to) { Peer() }
|
||||
p.lastSendFailure = reason
|
||||
p.lastSendFailureAt = at
|
||||
}
|
||||
|
||||
@Synchronized fun clearSendFailure(to: Long) {
|
||||
peers[to]?.let { it.lastSendFailure = null; it.lastSendFailureAt = 0L }
|
||||
peers[to]?.let {
|
||||
it.lastSendFailure = null
|
||||
it.lastSendFailureAt = 0L
|
||||
}
|
||||
}
|
||||
|
||||
/** Signing out ends the session this describes. */
|
||||
@@ -82,16 +77,6 @@ object DeliveryDiagnostics {
|
||||
|
||||
/**
|
||||
* Strip anything identifying before a reason is counted.
|
||||
*
|
||||
* Refusal messages name the thing that was wrong, which sometimes means
|
||||
* naming a device id, "device … has published keys, but none match".
|
||||
* A log may carry that; a screen the user is invited to screenshot may not.
|
||||
* Runs of digits and hex become "…".
|
||||
*
|
||||
* Short hex ids are the ones that slip through: `9f0e` is neither a long
|
||||
* hex run nor an all-digit run. Hence "four or more hex characters
|
||||
* containing at least one digit", which catches truncated device ids
|
||||
* without eating ordinary words like `base64`.
|
||||
*/
|
||||
fun normalisedReason(reason: String): String =
|
||||
reason
|
||||
|
||||
@@ -9,46 +9,11 @@ import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* What the time is, when the device's own answer cannot be trusted (ios#45).
|
||||
*
|
||||
* A wrong device clock breaks a promise made to somebody else. Almost
|
||||
* everything time-dependent here is a commitment: a story disappears after an
|
||||
* hour, a message clears itself after a day. Those are promises the SENDER
|
||||
* made, and they are enforced on the READER's device, so a reader whose clock
|
||||
* is running behind keeps a message the sender was told would be gone, and
|
||||
* nobody involved can tell.
|
||||
*
|
||||
* That asymmetry decides the whole design. Being wrong in one direction costs
|
||||
* the reader something they were going to lose anyway; being wrong in the other
|
||||
* direction breaks somebody else's expectation silently. So:
|
||||
*
|
||||
* For expiry, take the LATEST plausible time. Never the device's alone,
|
||||
* never the server's alone, whichever is further ahead. A device clock set back
|
||||
* cannot extend a message's life, and neither can a relay that lies about the
|
||||
* time, because the other source overrules it. The cost is that a device clock
|
||||
* set FORWARD expires things early, which is the failure worth having.
|
||||
*
|
||||
* The relay is a hint, not an authority. It is told the time by nobody we
|
||||
* trust, and a hostile one would like to move time backwards so that
|
||||
* disappearing messages linger. Taking the later of the two is exactly what
|
||||
* stops that being useful to it.
|
||||
*
|
||||
* Process-wide and synchronised, not a singleton object with plain vars.
|
||||
* The Swift version can be `nonisolated(unsafe) static var` because the places
|
||||
* that write it are on one actor; here the observation is written from whatever
|
||||
* OkHttp thread finished a call and read from the UI thread, so the fields are
|
||||
* `@Volatile` and the pair is updated under a lock. A torn read of "skew from
|
||||
* now, timestamp from an hour ago" would age out an observation that had just
|
||||
* been made, or trust one that had not.
|
||||
*/
|
||||
object DeviceClock {
|
||||
|
||||
/**
|
||||
* Difference between the last server time seen and this device's clock, in
|
||||
* milliseconds. Positive means the device is BEHIND.
|
||||
*
|
||||
* Not persisted. A skew learned yesterday says nothing about today, the
|
||||
* clock may have been corrected, or the phone rebooted with a fresh answer
|
||||
* from the network.
|
||||
* milliseconds.
|
||||
*/
|
||||
@Volatile
|
||||
private var skewMillis: Long? = null
|
||||
@@ -61,10 +26,6 @@ object DeviceClock {
|
||||
|
||||
/**
|
||||
* How far out the clock has to be before it is worth saying anything.
|
||||
*
|
||||
* Five minutes. Ordinary drift is seconds; a phone whose clock is out by
|
||||
* more than this will have visible symptoms, stories that vanish, messages
|
||||
* that will not send, and being told why is better than guessing.
|
||||
*/
|
||||
const val SUSPECT_THRESHOLD_MS = 300_000L
|
||||
|
||||
@@ -73,11 +34,11 @@ object DeviceClock {
|
||||
|
||||
/**
|
||||
* Learn the skew from a response's `Date` header.
|
||||
*
|
||||
* Free: every HTTP response already carries one, so this costs no extra
|
||||
* request and no extra round trip.
|
||||
*/
|
||||
fun observe(serverMillis: Long, deviceNow: Long = System.currentTimeMillis()) {
|
||||
fun observe(
|
||||
serverMillis: Long,
|
||||
deviceNow: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
synchronized(lock) {
|
||||
skewMillis = serverMillis - deviceNow
|
||||
observedAt = deviceNow
|
||||
@@ -85,7 +46,10 @@ object DeviceClock {
|
||||
}
|
||||
|
||||
/** Parse and record a `Date` header, if there is a usable one. */
|
||||
fun observe(header: String?, deviceNow: Long = System.currentTimeMillis()): Boolean {
|
||||
fun observe(
|
||||
header: String?,
|
||||
deviceNow: Long = System.currentTimeMillis(),
|
||||
): Boolean {
|
||||
val parsed = header?.let { parseHttpDate(it) } ?: return false
|
||||
observe(parsed, deviceNow)
|
||||
return true
|
||||
@@ -100,12 +64,13 @@ object DeviceClock {
|
||||
}
|
||||
|
||||
/** The skew, if it is recent enough to mean anything. */
|
||||
fun usableSkew(now: Long = System.currentTimeMillis()): Long? = synchronized(lock) {
|
||||
val skew = skewMillis ?: return null
|
||||
val at = observedAt ?: return null
|
||||
if (now - at >= OBSERVATION_LIFETIME_MS) return null
|
||||
skew
|
||||
}
|
||||
fun usableSkew(now: Long = System.currentTimeMillis()): Long? =
|
||||
synchronized(lock) {
|
||||
val skew = skewMillis ?: return null
|
||||
val at = observedAt ?: return null
|
||||
if (now - at >= OBSERVATION_LIFETIME_MS) return null
|
||||
skew
|
||||
}
|
||||
|
||||
/** Whether the clock is far enough out to be worth telling somebody about. */
|
||||
fun isSuspect(now: Long = System.currentTimeMillis()): Boolean {
|
||||
@@ -115,10 +80,6 @@ object DeviceClock {
|
||||
|
||||
/**
|
||||
* What to say about it, or null when there is nothing to say.
|
||||
*
|
||||
* Names the symptom before the cause. Somebody whose stories keep
|
||||
* vanishing does not think "my clock is wrong"; they think the app is
|
||||
* broken.
|
||||
*/
|
||||
fun warning(now: Long = System.currentTimeMillis()): String? {
|
||||
val skew = usableSkew(now) ?: return null
|
||||
@@ -133,14 +94,6 @@ object DeviceClock {
|
||||
|
||||
/**
|
||||
* The time to judge an expiry against, in milliseconds.
|
||||
*
|
||||
* The later of the device's clock and the server's. Neither alone: a
|
||||
* device set back would keep a disappearing message past the moment its
|
||||
* sender was told it would go, and a relay that lied about the time could
|
||||
* arrange the same thing. Taking whichever is further ahead means both have
|
||||
* to be wrong in the same direction to extend anything, and the remaining
|
||||
* failure, expiring early, costs the reader something they were about to
|
||||
* lose anyway.
|
||||
*/
|
||||
fun nowForExpiry(deviceNow: Long = System.currentTimeMillis()): Long {
|
||||
val skew = usableSkew(deviceNow) ?: return deviceNow
|
||||
@@ -149,21 +102,11 @@ object DeviceClock {
|
||||
|
||||
/**
|
||||
* The same in seconds, which is how the vault stores expiries.
|
||||
*
|
||||
* A Double of seconds, matching `Vault.Story.expires`, converting to a
|
||||
* Long of seconds here would floor away sub-second precision on a boundary
|
||||
* that decides whether a story is still on screen.
|
||||
*/
|
||||
fun expirySeconds(deviceNow: Long = System.currentTimeMillis()): Double =
|
||||
nowForExpiry(deviceNow) / 1000.0
|
||||
fun expirySeconds(deviceNow: Long = System.currentTimeMillis()): Double = nowForExpiry(deviceNow) / 1000.0
|
||||
|
||||
/**
|
||||
* RFC 7231 `Date`, which is what an HTTP header carries.
|
||||
*
|
||||
* Fixed locale and zone. A formatter that picked those up from the device
|
||||
* parses nothing on a phone set to most of the world, and a `SimpleDateFormat`
|
||||
* is not thread-safe, so one is built per call rather than shared. This runs
|
||||
* once per HTTP response, which is nowhere near hot enough to care.
|
||||
*/
|
||||
private fun parseHttpDate(header: String): Long? {
|
||||
val formatter = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.Content
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Fetches a preview for a link the user is about to send (spec §6.12).
|
||||
*
|
||||
* This runs on the SENDER's device, and the result travels inside the
|
||||
* ciphertext. The obvious implementation, each recipient fetching the URL to
|
||||
* draw a card, is the one that must not be built: it tells the site the
|
||||
* reader's IP address, that they received a link, and roughly when they opened
|
||||
* it, once per member on a group message. A messenger that hides the message
|
||||
* and then has every reader announce its contents to a third party has given
|
||||
* away what it was protecting.
|
||||
*
|
||||
* Sender-side is a smaller leak, not no leak: the site learns somebody is
|
||||
* about to share that URL, from this address, before the message is sent. That
|
||||
* is what the setting is for.
|
||||
*/
|
||||
object LinkPreviewFetcher {
|
||||
|
||||
/** A short timeout: a preview is a decoration and must never delay a send. */
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(4, TimeUnit.SECONDS)
|
||||
.readTimeout(4, TimeUnit.SECONDS)
|
||||
.callTimeout(6, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val http =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(4, TimeUnit.SECONDS)
|
||||
.readTimeout(4, TimeUnit.SECONDS)
|
||||
.callTimeout(6, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/** 64 KiB is plenty for a `<head>`, and a cap is what bounds a hostile page. */
|
||||
private const val MAX_BYTES = 64 * 1024
|
||||
@@ -38,59 +27,78 @@ object LinkPreviewFetcher {
|
||||
fun firstUrl(text: String): String? = URL_RE.find(text)?.value?.trimEnd('.', ',', ')')
|
||||
|
||||
/**
|
||||
* Fetch and parse Open Graph tags. Null for anything unusual, no title, a
|
||||
* refusal, a timeout, each of which leaves a message with a plain URL in
|
||||
* it, which is a perfectly good thing to be.
|
||||
* Fetch and parse Open Graph tags.
|
||||
*/
|
||||
fun fetch(url: String): Content.LinkPreview? = runCatching {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "text/html")
|
||||
// Identifies as a link preview rather than impersonating a browser:
|
||||
// a site that wants to refuse these should be able to.
|
||||
.header("User-Agent", "FlamenetLinkPreview/1.0")
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val head = response.body?.source()?.let { source ->
|
||||
source.request(MAX_BYTES.toLong())
|
||||
source.buffer.snapshot(minOf(MAX_BYTES.toLong(), source.buffer.size).toInt())
|
||||
.utf8()
|
||||
} ?: return null
|
||||
fun fetch(url: String): Content.LinkPreview? =
|
||||
runCatching {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "text/html")
|
||||
// Identifies as a link preview rather than impersonating a browser.
|
||||
// a site that wants to refuse these should be able to.
|
||||
.header("User-Agent", "FlamenetLinkPreview/1.0")
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val head =
|
||||
response.body?.source()?.let { source ->
|
||||
source.request(MAX_BYTES.toLong())
|
||||
source.buffer
|
||||
.snapshot(minOf(MAX_BYTES.toLong(), source.buffer.size).toInt())
|
||||
.utf8()
|
||||
} ?: return null
|
||||
|
||||
val title = meta(head, "og:title") ?: titleTag(head) ?: return null
|
||||
val desc = meta(head, "og:description") ?: metaName(head, "description") ?: ""
|
||||
Content.LinkPreview.of(url, title, desc)
|
||||
}
|
||||
}.getOrNull()
|
||||
val title = meta(head, "og:title") ?: titleTag(head) ?: return null
|
||||
val desc = meta(head, "og:description") ?: metaName(head, "description") ?: ""
|
||||
Content.LinkPreview.of(url, title, desc)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
// Regex over a bounded prefix, deliberately. A full HTML parser for two
|
||||
// attributes is a dependency and an attack surface; this reads what it
|
||||
// recognises and gives up otherwise, which is the correct failure here.
|
||||
// Regex over a bounded prefix, deliberately.
|
||||
|
||||
private fun meta(html: String, property: String): String? =
|
||||
Regex("""<meta[^>]+property=["']${Regex.escape(property)}["'][^>]+content=["']([^"']*)["']""",
|
||||
RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
?: Regex("""<meta[^>]+content=["']([^"']*)["'][^>]+property=["']${Regex.escape(property)}["']""",
|
||||
RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
private fun meta(
|
||||
html: String,
|
||||
property: String,
|
||||
): String? =
|
||||
Regex(
|
||||
"""<meta[^>]+property=["']${Regex.escape(property)}["'][^>]+content=["']([^"']*)["']""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
?: Regex(
|
||||
"""<meta[^>]+content=["']([^"']*)["'][^>]+property=["']${Regex.escape(property)}["']""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
|
||||
private fun metaName(html: String, name: String): String? =
|
||||
Regex("""<meta[^>]+name=["']${Regex.escape(name)}["'][^>]+content=["']([^"']*)["']""",
|
||||
RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
private fun metaName(
|
||||
html: String,
|
||||
name: String,
|
||||
): String? =
|
||||
Regex(
|
||||
"""<meta[^>]+name=["']${Regex.escape(name)}["'][^>]+content=["']([^"']*)["']""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
|
||||
private fun titleTag(html: String): String? =
|
||||
Regex("""<title[^>]*>([^<]*)</title>""", RegexOption.IGNORE_CASE)
|
||||
.find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
.find(html)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.let(::clean)
|
||||
|
||||
/**
|
||||
* The handful of entities that actually appear in titles. Anything else is
|
||||
* left alone, a literal `©` is a cosmetic flaw, and expanding
|
||||
* arbitrary entities is how a parser grows teeth.
|
||||
* The handful of entities that actually appear in titles.
|
||||
*/
|
||||
private fun clean(s: String): String = s
|
||||
.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace(""", "\"").replace("'", "'").replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() } ?: ""
|
||||
private fun clean(s: String): String =
|
||||
s
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() } ?: ""
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import io.flamenet.e2e.FilesKdf
|
||||
import io.flamenet.e2e.Hkdf
|
||||
import io.flamenet.e2e.Label
|
||||
import io.flamenet.e2e.Random
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
@@ -16,42 +18,9 @@ import java.util.Base64
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Files that live on this phone.
|
||||
*
|
||||
* The server is not storage any more. It is a courier: it holds
|
||||
* ciphertext for as long as it takes to reach the other device, and then it does
|
||||
* not. Everything a person owns is here, in this app's own container, and a
|
||||
* phone that never syncs still has all of it.
|
||||
*
|
||||
* What that costs is worth stating plainly, because it changed and it changed
|
||||
* silently everywhere else:
|
||||
*
|
||||
* Read-down is no longer enforced by anything that can refuse. When the
|
||||
* rows lived in Postgres, `session_dominates(classification, compartments)` was
|
||||
* an RLS predicate, the database itself would not return a row above your
|
||||
* clearance, whatever the client asked for. There is no database here. A label
|
||||
* still gates *decryption*, absolutely: without the label key the bytes are
|
||||
* bytes. But nothing stops a sender transferring a file above a recipient's
|
||||
* level, and the clearance shown in the interface now means "the keys this
|
||||
* device holds", not "what a server will show you". That is a deliberate trade,
|
||||
* and the interface must not keep using the old words for the new thing.
|
||||
*
|
||||
* The index file is device-local and is NOT an interchange format. The
|
||||
* node *fields* match iOS's exactly, deliberately, so that a courier transfer is
|
||||
* a copy rather than a translation. The JSON around them is not: iOS encodes
|
||||
* dates as Swift's reference-date doubles, which is an accident of `JSONEncoder`
|
||||
* rather than a decision, and pinning Kotlin to it would make an accident into a
|
||||
* format. Anybody building vault migration between phones should move nodes
|
||||
* through a declared wire shape, not by copying this file.
|
||||
*
|
||||
* Synchronous, and it touches the disk. Call it off the main thread. The
|
||||
* lock below makes concurrent calls safe, not fast, a `ReentrantLock` rather
|
||||
* than a coroutine `Mutex` because this module deliberately has no coroutines
|
||||
* dependency, which is what lets its tests run without an emulator.
|
||||
*/
|
||||
class LocalVault(
|
||||
private val root: File,
|
||||
@@ -59,60 +28,39 @@ class LocalVault(
|
||||
private val identityRawPrivate: ByteArray,
|
||||
/**
|
||||
* How a referenced file is reached.
|
||||
*
|
||||
* Required, with no default. A no-op default would make every reference
|
||||
* row read as "missing" in an app that simply forgot to wire this up, a
|
||||
* vault that looks like it has lost half its contents, with nothing
|
||||
* anywhere saying why.
|
||||
*/
|
||||
private val references: ReferenceResolver,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Where a row's bytes actually are.
|
||||
*
|
||||
* The two are not equally protected and the interface must not present
|
||||
* them as if they were.
|
||||
*
|
||||
* - [VAULT]: copied in and encrypted under the label key. A stolen backup or
|
||||
* container yields ciphertext, and the classification is a control.
|
||||
* - [REFERENCE]: a pointer to a file that stays where it is on the phone,
|
||||
* encrypted only at the moment it is sent. Nothing is duplicated, and the
|
||||
* file keeps whatever protection the platform gives it, but we are not
|
||||
* adding any, the label is advisory until it is sent, and the row breaks
|
||||
* if the file is moved or deleted.
|
||||
*/
|
||||
enum class Storage(val wire: String) {
|
||||
enum class Storage(
|
||||
val wire: String,
|
||||
) {
|
||||
VAULT("vault"),
|
||||
REFERENCE("reference");
|
||||
REFERENCE("reference"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Absence means [VAULT], and that is not a nicety. iOS shipped a
|
||||
* `storage` field with a Swift default and discovered that a default
|
||||
* does not make a `Codable` key optional, every index written
|
||||
* before referencing existed failed to decode, which presents as a
|
||||
* vault that has lost everything. Reading an unknown or missing
|
||||
* value as `vault` is what those rows actually are.
|
||||
* `storage` field with a Swift default and discovered that a
|
||||
* default does not make a `Codable` key optional, every index
|
||||
* written before referencing existed failed to decode, which
|
||||
* presents as a vault that has lost everything.
|
||||
*/
|
||||
fun parse(raw: String?): Storage =
|
||||
entries.firstOrNull { it.wire == raw } ?: VAULT
|
||||
fun parse(raw: String?): Storage = entries.firstOrNull { it.wire == raw } ?: VAULT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a referenced file is reached again after a relaunch.
|
||||
*
|
||||
* The [bookmark] is opaque here on purpose. On Android it is a Storage
|
||||
* Access Framework document URI whose read permission was persisted; on iOS
|
||||
* it is a security-scoped bookmark. Neither belongs in this module, which
|
||||
* has no platform in it.
|
||||
*/
|
||||
interface ReferenceResolver {
|
||||
/**
|
||||
* Whether the file is still there, asked separately from reading it,
|
||||
* because a row that silently does nothing when tapped is worse than one
|
||||
* that says the file has moved.
|
||||
* because a row that silently does nothing when tapped is worse than
|
||||
* one that says the file has moved.
|
||||
*/
|
||||
fun exists(bookmark: String): Boolean
|
||||
|
||||
@@ -148,19 +96,6 @@ class LocalVault(
|
||||
|
||||
/**
|
||||
* Every field, with the byte arrays compared by CONTENT.
|
||||
*
|
||||
* This was `other.id == id`, written to dodge the fact that a data
|
||||
* class compares `ByteArray` by identity, and wrong in a way that broke
|
||||
* a whole feature. Promoting a link to the vault keeps the node's id and
|
||||
* changes its `storage`, so the new row compared EQUAL to the old one:
|
||||
* the state object it was in compared equal too, `StateFlow` conflated
|
||||
* the update away, and Compose never saw it. On screen the file vanished
|
||||
* from Linked and did not appear in the vault until the app was
|
||||
* restarted, which reads as losing somebody's file.
|
||||
*
|
||||
* The identity-vs-content trap is real, so it is handled rather than
|
||||
* avoided: `contentEquals` for the four byte arrays, ordinary equality
|
||||
* for the rest.
|
||||
*/
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is Node &&
|
||||
@@ -172,8 +107,10 @@ class LocalVault(
|
||||
other.wrappedNodeKey.contentEquals(wrappedNodeKey) &&
|
||||
other.nameDigest.contentEquals(nameDigest) &&
|
||||
other.wrappedName.contentEquals(wrappedName) &&
|
||||
(other.wrappedContentKey?.contentEquals(wrappedContentKey ?: ByteArray(0))
|
||||
?: (wrappedContentKey == null)) &&
|
||||
(
|
||||
other.wrappedContentKey?.contentEquals(wrappedContentKey ?: ByteArray(0))
|
||||
?: (wrappedContentKey == null)
|
||||
) &&
|
||||
other.byteCount == byteCount &&
|
||||
other.addedAtMillis == addedAtMillis &&
|
||||
other.storage == storage &&
|
||||
@@ -203,7 +140,6 @@ class LocalVault(
|
||||
val name: String?,
|
||||
/**
|
||||
* True when this is a reference whose file is no longer where it was.
|
||||
* Surfaced rather than hidden.
|
||||
*/
|
||||
val isMissing: Boolean,
|
||||
) {
|
||||
@@ -223,70 +159,57 @@ class LocalVault(
|
||||
val isCompletelyDone: Boolean get() = keptNonEmpty.isEmpty()
|
||||
}
|
||||
|
||||
class VaultException(message: String) : Exception(message)
|
||||
class VaultException(
|
||||
message: String,
|
||||
) : Exception(message)
|
||||
|
||||
private val lock = ReentrantLock()
|
||||
private var nodes = mutableListOf<Node>()
|
||||
|
||||
private val indexFile: File get() = File(root, "index.json")
|
||||
private val blobs: File get() = File(root, "blobs")
|
||||
|
||||
private fun blobFile(id: UUID) = File(blobs, id.toString())
|
||||
|
||||
// ---- Keys ------------------------------------------------------------
|
||||
// ---- Keys ------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The key for a label, derived rather than fetched.
|
||||
*
|
||||
* Deterministic from the identity key, so every device holding the
|
||||
* identity derives the same one and a vault restored from a courier transfer
|
||||
* opens without a grant round-trip. Sharing an area means sending this
|
||||
* value, wrapped to the recipient, the same grant the server used to
|
||||
* broker, minus the broker.
|
||||
*
|
||||
* Binds the whole canonical label encoding, not just the
|
||||
* classification. Deriving from the level alone would give one key to
|
||||
* every compartment at that level, which is precisely the separation
|
||||
* compartments exist for.
|
||||
*/
|
||||
internal fun labelKey(label: Label): ByteArray = Hkdf.derive(
|
||||
ikm = identityRawPrivate,
|
||||
salt = label.bytes(),
|
||||
info = FilesKdf.Context.LOCAL_LABEL.toByteArray(Charsets.UTF_8),
|
||||
length = 32,
|
||||
)
|
||||
internal fun labelKey(label: Label): ByteArray =
|
||||
Hkdf.derive(
|
||||
ikm = identityRawPrivate,
|
||||
salt = label.bytes(),
|
||||
info = FilesKdf.Context.LOCAL_LABEL.toByteArray(Charsets.UTF_8),
|
||||
length = 32,
|
||||
)
|
||||
|
||||
/**
|
||||
* The name, or null when this device cannot read the row.
|
||||
*
|
||||
* On a local vault every label the identity can derive is readable, so
|
||||
* this is non-null for anything this device filed itself. It exists for the
|
||||
* case that is not that: a node that arrived from somebody else, filed under
|
||||
* a label whose key was never granted. Those decrypt to null and the row
|
||||
* says so.
|
||||
*/
|
||||
private fun nameOf(node: Node): String? = try {
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
FileNames.decrypt(nodeKey, node.wrappedName, node.nameDigest, node.label)
|
||||
} catch (e: Aead.DecryptionFailed) {
|
||||
null
|
||||
} catch (e: FilesEngineException) {
|
||||
null
|
||||
}
|
||||
private fun nameOf(node: Node): String? =
|
||||
try {
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
FileNames.decrypt(nodeKey, node.wrappedName, node.nameDigest, node.label)
|
||||
} catch (e: Aead.DecryptionFailed) {
|
||||
null
|
||||
} catch (e: FilesEngineException) {
|
||||
null
|
||||
}
|
||||
|
||||
// ---- Storage ---------------------------------------------------------
|
||||
// ---- Storage ---------------------------------------------------------.
|
||||
|
||||
fun load() = lock.withLock {
|
||||
blobs.mkdirs()
|
||||
val text = if (indexFile.exists()) indexFile.readText() else return@withLock
|
||||
val root = JSONObject(text)
|
||||
val array = root.optJSONArray("nodes") ?: JSONArray()
|
||||
nodes = (0 until array.length()).mapTo(mutableListOf()) { decode(array.getJSONObject(it)) }
|
||||
}
|
||||
fun load() =
|
||||
lock.withLock {
|
||||
blobs.mkdirs()
|
||||
val text = if (indexFile.exists()) indexFile.readText() else return@withLock
|
||||
val root = JSONObject(text)
|
||||
val array = root.optJSONArray("nodes") ?: JSONArray()
|
||||
nodes = (0 until array.length()).mapTo(mutableListOf()) { decode(array.getJSONObject(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Written to a temporary file and moved into place. An index truncated by
|
||||
* a crash mid-write is a vault that has lost every file at once, and the
|
||||
* blobs would still be sitting there, unreferenced, taking up the space.
|
||||
* Written to a temporary file and moved into place.
|
||||
*/
|
||||
private fun save() {
|
||||
root.mkdirs()
|
||||
@@ -295,59 +218,44 @@ class LocalVault(
|
||||
JSONObject()
|
||||
.put("version", 1)
|
||||
.put("nodes", JSONArray().also { array -> nodes.forEach { array.put(encode(it)) } })
|
||||
.toString()
|
||||
.toString(),
|
||||
)
|
||||
Files.move(
|
||||
temporary.toPath(), indexFile.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE,
|
||||
temporary.toPath(),
|
||||
indexFile.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun b64(bytes: ByteArray) = Base64.getEncoder().encodeToString(bytes)
|
||||
|
||||
private fun unb64(text: String) = Base64.getDecoder().decode(text)
|
||||
|
||||
private fun encode(node: Node): JSONObject = JSONObject()
|
||||
.put("id", node.id.toString())
|
||||
.put("parentID", node.parentId?.toString() ?: JSONObject.NULL)
|
||||
.put("isFolder", node.isFolder)
|
||||
.put("classification", node.classification.toInt())
|
||||
.put("compartments", JSONArray(node.compartments))
|
||||
.put("wrappedNodeKey", b64(node.wrappedNodeKey))
|
||||
.put("nameDigest", b64(node.nameDigest))
|
||||
.put("wrappedName", b64(node.wrappedName))
|
||||
.put("wrappedContentKey", node.wrappedContentKey?.let { b64(it) } ?: JSONObject.NULL)
|
||||
.put("byteCount", node.byteCount)
|
||||
.put("addedAt", node.addedAtMillis)
|
||||
.put("storage", node.storage.wire)
|
||||
.put("bookmark", node.bookmark ?: JSONObject.NULL)
|
||||
private fun encode(node: Node): JSONObject =
|
||||
JSONObject()
|
||||
.put("id", node.id.toString())
|
||||
.put("parentID", node.parentId?.toString() ?: JSONObject.NULL)
|
||||
.put("isFolder", node.isFolder)
|
||||
.put("classification", node.classification.toInt())
|
||||
.put("compartments", JSONArray(node.compartments))
|
||||
.put("wrappedNodeKey", b64(node.wrappedNodeKey))
|
||||
.put("nameDigest", b64(node.nameDigest))
|
||||
.put("wrappedName", b64(node.wrappedName))
|
||||
.put("wrappedContentKey", node.wrappedContentKey?.let { b64(it) } ?: JSONObject.NULL)
|
||||
.put("byteCount", node.byteCount)
|
||||
.put("addedAt", node.addedAtMillis)
|
||||
.put("storage", node.storage.wire)
|
||||
.put("bookmark", node.bookmark ?: JSONObject.NULL)
|
||||
|
||||
/**
|
||||
* `optString` is not safe on a nullable field, and the JVM will not
|
||||
* tell you. Android's bundled `org.json` coerces a JSON `null` to the
|
||||
* four-character string `"null"`; the Maven `org.json` these tests run
|
||||
* against returns `""`. Every nullable field below therefore passed on a
|
||||
* laptop and broke on a phone:
|
||||
*
|
||||
* - `parentID` threw `Invalid UUID string: null`, so a vault holding any
|
||||
* top-level file failed to load AT ALL. Adding the first file worked,
|
||||
* because it was already in memory; reopening the app lost everything,
|
||||
* with an error nobody could act on.
|
||||
* - `wrappedContentKey` was worse, because it did NOT throw: `"null"` is
|
||||
* valid base64, so the node came back with a plausible three-byte content
|
||||
* key and simply refused to decrypt.
|
||||
* - `bookmark` produced a reference pointing at `"null"`, permanently
|
||||
* missing.
|
||||
*
|
||||
* So absence is decided by [JSONObject.isNull], never by comparing a
|
||||
* decoded string to a sentinel. The literal `"null"` is rejected as well:
|
||||
* it is not a valid value for any of these fields, and tolerating it costs
|
||||
* nothing next to reintroducing this.
|
||||
*
|
||||
* Found by running the app on a device (android!52). No JVM test could have
|
||||
* caught it, which is the argument for the device run, not against the
|
||||
* tests.
|
||||
* `optString` is not safe on a nullable field, and the JVM will not tell
|
||||
* you.
|
||||
*/
|
||||
private fun optional(o: JSONObject, key: String): String? {
|
||||
private fun optional(
|
||||
o: JSONObject,
|
||||
key: String,
|
||||
): String? {
|
||||
if (o.isNull(key)) return null
|
||||
val value = o.optString(key, "")
|
||||
return value.takeIf { it.isNotEmpty() && it != "null" }
|
||||
@@ -372,19 +280,21 @@ class LocalVault(
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Reading ---------------------------------------------------------
|
||||
// ---- Reading ---------------------------------------------------------.
|
||||
|
||||
fun list(parentId: UUID?): List<Entry> = lock.withLock {
|
||||
nodes.filter { it.parentId == parentId }
|
||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||
.sortedWith(
|
||||
// Folders first, then by name; unreadable rows last, where they
|
||||
// are least in the way of the ones you can act on.
|
||||
compareByDescending<Entry> { it.isFolder }
|
||||
.thenBy { it.name == null }
|
||||
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" }
|
||||
)
|
||||
}
|
||||
fun list(parentId: UUID?): List<Entry> =
|
||||
lock.withLock {
|
||||
nodes
|
||||
.filter { it.parentId == parentId }
|
||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||
.sortedWith(
|
||||
// Folders first, then by name; unreadable rows last, where they
|
||||
// are least in the way of the ones you can act on.
|
||||
compareByDescending<Entry> { it.isFolder }
|
||||
.thenBy { it.name == null }
|
||||
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" },
|
||||
)
|
||||
}
|
||||
|
||||
private fun isMissing(node: Node): Boolean =
|
||||
node.storage == Storage.REFERENCE &&
|
||||
@@ -396,51 +306,35 @@ class LocalVault(
|
||||
|
||||
/**
|
||||
* Every id in a subtree, so a screen can offer "and everything in it".
|
||||
*
|
||||
* Bounded by what it has already seen, not by a step counter. A cycle
|
||||
* cannot be built through this API, but the index is a file on disk and
|
||||
* something else could write one, so this has to terminate. iOS bounds it
|
||||
* with `guardRail < 10_000`, which terminates and then hands back ten
|
||||
* thousand copies of the same two ids: a screen offering to delete a folder
|
||||
* "and everything in it" would say 10,000 things. Refusing to revisit a node
|
||||
* both terminates AND returns the right answer, and needs no magic number.
|
||||
*/
|
||||
fun descendants(of: UUID): List<UUID> = lock.withLock {
|
||||
val found = mutableListOf<UUID>()
|
||||
val seen = mutableSetOf(of)
|
||||
val frontier = ArrayDeque(listOf(of))
|
||||
while (frontier.isNotEmpty()) {
|
||||
val next = frontier.removeLast()
|
||||
for (child in nodes.filter { it.parentId == next }) {
|
||||
if (!seen.add(child.id)) continue
|
||||
found += child.id
|
||||
frontier.addLast(child.id)
|
||||
fun descendants(of: UUID): List<UUID> =
|
||||
lock.withLock {
|
||||
val found = mutableListOf<UUID>()
|
||||
val seen = mutableSetOf(of)
|
||||
val frontier = ArrayDeque(listOf(of))
|
||||
while (frontier.isNotEmpty()) {
|
||||
val next = frontier.removeLast()
|
||||
for (child in nodes.filter { it.parentId == next }) {
|
||||
if (!seen.add(child.id)) continue
|
||||
found += child.id
|
||||
frontier.addLast(child.id)
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file in the whole tree, for a picker that has no business showing
|
||||
* folders.
|
||||
*
|
||||
* The whole tree, not the top of it. iOS's picker lists `list(nil)`,
|
||||
* so a document filed into a folder cannot be sent from a conversation at
|
||||
* all, and nothing says so; the file is simply absent from the list, which
|
||||
* reads as "it isn't there". Filed as ios#68.
|
||||
*
|
||||
* Unreadable and missing rows are dropped here rather than shown
|
||||
* disabled. Everywhere else this class insists on showing a row you cannot
|
||||
* open, because hiding it would leave somebody unable to see that anything
|
||||
* is there. A picker is the exception: its only purpose is choosing
|
||||
* something to send, and a row that can never be chosen is furniture.
|
||||
*/
|
||||
fun allFiles(): List<Entry> = lock.withLock {
|
||||
nodes.filter { !it.isFolder }
|
||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||
.filter { it.name != null && !it.isMissing }
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" })
|
||||
}
|
||||
fun allFiles(): List<Entry> =
|
||||
lock.withLock {
|
||||
nodes
|
||||
.filter { !it.isFolder }
|
||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||
.filter { it.name != null && !it.isMissing }
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" })
|
||||
}
|
||||
|
||||
fun contents(of: Node): ByteArray = lock.withLock { readContents(of) }
|
||||
|
||||
@@ -450,14 +344,14 @@ class LocalVault(
|
||||
if (node.storage == Storage.REFERENCE) {
|
||||
val bookmark = node.bookmark
|
||||
if (bookmark == null || !references.exists(bookmark)) {
|
||||
throw VaultException(
|
||||
"That file isn't where it was, it may have been moved or deleted.")
|
||||
throw VaultException("That file isn't where it was, it may have been moved or deleted.")
|
||||
}
|
||||
return references.read(bookmark)
|
||||
}
|
||||
|
||||
val wrappedContentKey = node.wrappedContentKey
|
||||
?: throw VaultException("That file has no contents.")
|
||||
val wrappedContentKey =
|
||||
node.wrappedContentKey
|
||||
?: throw VaultException("That file has no contents.")
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
val contentKey = FileKeys.unwrapContentKey(wrappedContentKey, nodeKey, node.label)
|
||||
return FileContent.decrypt(blobFile(node.id).readBytes(), contentKey, node.label)
|
||||
@@ -465,62 +359,73 @@ class LocalVault(
|
||||
|
||||
/**
|
||||
* The bytes, encrypted for sending, whichever way they are stored.
|
||||
*
|
||||
* This is where a referenced file first becomes ciphertext. It is
|
||||
* sealed under a fresh content key bound to the label, so the courier
|
||||
* carries something it cannot read and the classification finally gates
|
||||
* something. Returns the sealed blob and the key to hand the recipient.
|
||||
*/
|
||||
data class Sealed(val blob: ByteArray, val contentKey: ByteArray)
|
||||
data class Sealed(
|
||||
val blob: ByteArray,
|
||||
val contentKey: ByteArray,
|
||||
)
|
||||
|
||||
fun sealedForSending(node: Node): Sealed = lock.withLock {
|
||||
val plaintext = readContents(node)
|
||||
val contentKey = Random.bytes(32)
|
||||
Sealed(FileContent.encrypt(plaintext, contentKey, node.label), contentKey)
|
||||
}
|
||||
fun sealedForSending(node: Node): Sealed =
|
||||
lock.withLock {
|
||||
val plaintext = readContents(node)
|
||||
val contentKey = Random.bytes(32)
|
||||
Sealed(FileContent.encrypt(plaintext, contentKey, node.label), contentKey)
|
||||
}
|
||||
|
||||
// ---- Writing ---------------------------------------------------------
|
||||
// ---- Writing ---------------------------------------------------------.
|
||||
|
||||
fun add(bytes: ByteArray, name: String, label: Label, parentId: UUID? = null): Node =
|
||||
fun add(
|
||||
bytes: ByteArray,
|
||||
name: String,
|
||||
label: Label,
|
||||
parentId: UUID? = null,
|
||||
): Node =
|
||||
lock.withLock {
|
||||
val node = makeNode(name, label, parentId, isFolder = false, byteCount = bytes.size)
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(label), label)
|
||||
val contentKey = Random.bytes(32)
|
||||
|
||||
// Bytes to disk BEFORE the index. The other order leaves an index
|
||||
// entry for a file that is not there, which reads as corruption;
|
||||
// this order leaves at worst an unreferenced blob, which is a
|
||||
// cleanup job nobody notices.
|
||||
// Bytes to disk BEFORE the index.
|
||||
blobs.mkdirs()
|
||||
writeAtomically(blobFile(node.id), FileContent.encrypt(bytes, contentKey, label))
|
||||
|
||||
val stored = node.copy(
|
||||
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, label))
|
||||
val stored =
|
||||
node.copy(wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, label))
|
||||
nodes += stored
|
||||
save()
|
||||
stored
|
||||
}
|
||||
|
||||
fun createFolder(name: String, label: Label, parentId: UUID? = null): Node = lock.withLock {
|
||||
val node = makeNode(name, label, parentId, isFolder = true, byteCount = 0)
|
||||
nodes += node
|
||||
save()
|
||||
node
|
||||
}
|
||||
fun createFolder(
|
||||
name: String,
|
||||
label: Label,
|
||||
parentId: UUID? = null,
|
||||
): Node =
|
||||
lock.withLock {
|
||||
val node = makeNode(name, label, parentId, isFolder = true, byteCount = 0)
|
||||
nodes += node
|
||||
save()
|
||||
node
|
||||
}
|
||||
|
||||
/**
|
||||
* File a document that stays where it is.
|
||||
*
|
||||
* Nothing is copied and nothing is encrypted here, see [Storage]. The
|
||||
* bytes get their content key at send time, not now, so until then this
|
||||
* row's label is a statement of intent rather than a lock.
|
||||
*/
|
||||
fun reference(bookmark: String, name: String, label: Label, parentId: UUID? = null): Node =
|
||||
fun reference(
|
||||
bookmark: String,
|
||||
name: String,
|
||||
label: Label,
|
||||
parentId: UUID? = null,
|
||||
): Node =
|
||||
lock.withLock {
|
||||
val node = makeNode(
|
||||
name, label, parentId, isFolder = false,
|
||||
byteCount = references.size(bookmark),
|
||||
).copy(storage = Storage.REFERENCE, bookmark = bookmark)
|
||||
val node =
|
||||
makeNode(
|
||||
name,
|
||||
label,
|
||||
parentId,
|
||||
isFolder = false,
|
||||
byteCount = references.size(bookmark),
|
||||
).copy(storage = Storage.REFERENCE, bookmark = bookmark)
|
||||
nodes += node
|
||||
save()
|
||||
node
|
||||
@@ -528,53 +433,38 @@ class LocalVault(
|
||||
|
||||
/**
|
||||
* Turn a reference into an encrypted copy, in place.
|
||||
*
|
||||
* The bridge between the two ways of holding a file, and the reason
|
||||
* both exist. Referencing avoids duplicating anything; vaulting is what
|
||||
* makes a classification a control rather than a note. Most files want the
|
||||
* first and a few want the second, and nobody knows which is which until
|
||||
* after they have added them, so this promotes one without re-picking it.
|
||||
*
|
||||
* It does NOT delete the person's original: that is their file, and
|
||||
* removing it is a separate decision the interface asks about afterwards.
|
||||
* Until they do, this is two copies, the honest trade for having
|
||||
* the encrypted one.
|
||||
*
|
||||
* Returns the bookmark the original was read from, so a caller can offer
|
||||
* that, or null when the row was already vaulted.
|
||||
*/
|
||||
fun encrypt(id: UUID): String? = lock.withLock {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) throw VaultException("That file isn't here any more.")
|
||||
val node = nodes[position]
|
||||
if (node.storage != Storage.REFERENCE) return@withLock null
|
||||
val origin = node.bookmark
|
||||
if (origin == null || !references.exists(origin)) {
|
||||
throw VaultException("That file isn't where it was, it may have been moved or deleted.")
|
||||
fun encrypt(id: UUID): String? =
|
||||
lock.withLock {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) throw VaultException("That file isn't here any more.")
|
||||
val node = nodes[position]
|
||||
if (node.storage != Storage.REFERENCE) return@withLock null
|
||||
val origin = node.bookmark
|
||||
if (origin == null || !references.exists(origin)) {
|
||||
throw VaultException("That file isn't where it was, it may have been moved or deleted.")
|
||||
}
|
||||
|
||||
val plaintext = readContents(node)
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
val contentKey = Random.bytes(32)
|
||||
|
||||
// Bytes before index, as everywhere else here.
|
||||
blobs.mkdirs()
|
||||
writeAtomically(blobFile(node.id), FileContent.encrypt(plaintext, contentKey, node.label))
|
||||
|
||||
nodes[position] =
|
||||
node.copy(
|
||||
storage = Storage.VAULT,
|
||||
// The bookmark goes.
|
||||
bookmark = null,
|
||||
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label),
|
||||
byteCount = plaintext.size,
|
||||
)
|
||||
save()
|
||||
origin
|
||||
}
|
||||
|
||||
val plaintext = readContents(node)
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
val contentKey = Random.bytes(32)
|
||||
|
||||
// Bytes before index, as everywhere else here.
|
||||
blobs.mkdirs()
|
||||
writeAtomically(blobFile(node.id), FileContent.encrypt(plaintext, contentKey, node.label))
|
||||
|
||||
nodes[position] = node.copy(
|
||||
storage = Storage.VAULT,
|
||||
// The bookmark goes. Keeping it would leave a vaulted row still
|
||||
// able to read through to a plaintext original, so the encrypted
|
||||
// copy would not be the only way in, which is the point of
|
||||
// vaulting it.
|
||||
bookmark = null,
|
||||
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label),
|
||||
byteCount = plaintext.size,
|
||||
)
|
||||
save()
|
||||
origin
|
||||
}
|
||||
|
||||
private fun makeNode(
|
||||
name: String,
|
||||
label: Label,
|
||||
@@ -590,10 +480,7 @@ class LocalVault(
|
||||
isFolder = isFolder,
|
||||
classification = label.classification,
|
||||
compartments = label.compartments.sorted(),
|
||||
// Wrapped to the node's OWN label key, never the parent's. The
|
||||
// tree allows a Restricted file inside a Confidential folder, so a
|
||||
// folder-derived key would hand every folder-key holder content
|
||||
// above their clearance.
|
||||
// Wrapped to the node's OWN label key, never the parent's.
|
||||
wrappedNodeKey = FileKeys.wrapNodeKey(nodeKey, labelKey(label), label),
|
||||
nameDigest = sealed.digest,
|
||||
wrappedName = sealed.ciphertext,
|
||||
@@ -603,96 +490,84 @@ class LocalVault(
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeAtomically(target: File, bytes: ByteArray) {
|
||||
private fun writeAtomically(
|
||||
target: File,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
val temporary = File(target.parentFile, target.name + ".new")
|
||||
temporary.writeBytes(bytes)
|
||||
Files.move(
|
||||
temporary.toPath(), target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE,
|
||||
temporary.toPath(),
|
||||
target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Deleting --------------------------------------------------------
|
||||
// ---- Deleting --------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Removes a node and its bytes.
|
||||
*
|
||||
* Refuses a folder that still has anything in it. Deleting a folder and
|
||||
* orphaning its contents would leave rows nothing can reach and bytes
|
||||
* nothing will ever free.
|
||||
*/
|
||||
fun remove(id: UUID) = lock.withLock {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) return@withLock
|
||||
val node = nodes[position]
|
||||
if (node.isFolder && nodes.any { it.parentId == id }) {
|
||||
throw VaultException("That folder isn't empty.")
|
||||
fun remove(id: UUID) =
|
||||
lock.withLock {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) return@withLock
|
||||
val node = nodes[position]
|
||||
if (node.isFolder && nodes.any { it.parentId == id }) {
|
||||
throw VaultException("That folder isn't empty.")
|
||||
}
|
||||
nodes.removeAt(position)
|
||||
save()
|
||||
// Never the person's own file, see the note in the bulk version.
|
||||
if (node.storage == Storage.VAULT) blobFile(id).delete()
|
||||
}
|
||||
nodes.removeAt(position)
|
||||
save()
|
||||
// Never the person's own file, see the note in the bulk version.
|
||||
if (node.storage == Storage.VAULT) blobFile(id).delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete several things at once.
|
||||
*
|
||||
* This reports rather than returning nothing. Selecting eleven things
|
||||
* and having nine disappear is a data-loss-shaped event in the other
|
||||
* direction: the two that survived are the ones somebody now thinks are
|
||||
* gone. The screen says which, and why.
|
||||
*
|
||||
* Deletes deepest-first, so selecting a folder AND everything in it
|
||||
* works. A folder whose contents were NOT all selected is kept, because the
|
||||
* alternative is a bulk gesture that silently takes rows the person never
|
||||
* selected, including ones filed above what this device can even read,
|
||||
* which they could not have reviewed before tapping.
|
||||
*/
|
||||
fun remove(ids: List<UUID>): Removal = lock.withLock {
|
||||
val removed = mutableListOf<UUID>()
|
||||
val keptNonEmpty = mutableListOf<UUID>()
|
||||
val removedVaultIds = mutableListOf<UUID>()
|
||||
val wanted = ids.toSet()
|
||||
fun remove(ids: List<UUID>): Removal =
|
||||
lock.withLock {
|
||||
val removed = mutableListOf<UUID>()
|
||||
val keptNonEmpty = mutableListOf<UUID>()
|
||||
val removedVaultIds = mutableListOf<UUID>()
|
||||
val wanted = ids.toSet()
|
||||
|
||||
// Depth first: a child must go before the parent that contains it.
|
||||
fun depth(id: UUID): Int {
|
||||
var steps = 0
|
||||
var current = nodes.firstOrNull { it.id == id }?.parentId
|
||||
while (current != null && steps < 64) {
|
||||
steps++
|
||||
current = nodes.firstOrNull { it.id == current }?.parentId
|
||||
}
|
||||
return steps
|
||||
}
|
||||
val ordered = ids.filter { id -> nodes.any { it.id == id } }
|
||||
.sortedByDescending { depth(it) }
|
||||
|
||||
for (id in ordered) {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) continue
|
||||
val node = nodes[position]
|
||||
if (node.isFolder) {
|
||||
// Anything left inside that was not itself selected?
|
||||
if (nodes.any { it.parentId == id && it.id !in wanted }) {
|
||||
keptNonEmpty += id
|
||||
continue
|
||||
// Depth first: a child must go before the parent that contains it.
|
||||
fun depth(id: UUID): Int {
|
||||
var steps = 0
|
||||
var current = nodes.firstOrNull { it.id == id }?.parentId
|
||||
while (current != null && steps < 64) {
|
||||
steps++
|
||||
current = nodes.firstOrNull { it.id == current }?.parentId
|
||||
}
|
||||
return steps
|
||||
}
|
||||
if (node.storage == Storage.VAULT) removedVaultIds += id
|
||||
nodes.removeAt(position)
|
||||
removed += id
|
||||
}
|
||||
val ordered =
|
||||
ids
|
||||
.filter { id -> nodes.any { it.id == id } }
|
||||
.sortedByDescending { depth(it) }
|
||||
|
||||
// One save for the whole batch, after the index is consistent. A save
|
||||
// per row would leave a half-deleted tree on disk if the process died
|
||||
// mid-loop; the blobs go afterwards, so a crash costs orphaned bytes
|
||||
// rather than rows pointing at files that are gone.
|
||||
save()
|
||||
// Only ever our own blobs. A referenced row's bytes are the person's
|
||||
// own file, sitting where they put it, deleting the row must not delete
|
||||
// their document, and `blobFile` for a reference names a file that was
|
||||
// never written.
|
||||
removedVaultIds.forEach { blobFile(it).delete() }
|
||||
Removal(removed, keptNonEmpty)
|
||||
}
|
||||
for (id in ordered) {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) continue
|
||||
val node = nodes[position]
|
||||
if (node.isFolder) {
|
||||
// Anything left inside that was not itself selected?
|
||||
if (nodes.any { it.parentId == id && it.id !in wanted }) {
|
||||
keptNonEmpty += id
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (node.storage == Storage.VAULT) removedVaultIds += id
|
||||
nodes.removeAt(position)
|
||||
removed += id
|
||||
}
|
||||
|
||||
// One save for the whole batch, after the index is consistent.
|
||||
save()
|
||||
// Only ever our own blobs.
|
||||
removedVaultIds.forEach { blobFile(it).delete() }
|
||||
Removal(removed, keptNonEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,34 +4,9 @@ import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
|
||||
/**
|
||||
* Keeping a mailbox at more than one relay (spec §20.4).
|
||||
*
|
||||
* ## What this buys, and what it does not
|
||||
*
|
||||
* Your address already survives a relay disappearing: it comes from your
|
||||
* passphrase, so signing in elsewhere produces the same one. What does not
|
||||
* survive is your mailbox, mail sitting at a relay that goes away is gone,
|
||||
* and contacts holding only that relay's name have nowhere to write.
|
||||
*
|
||||
* A second home fixes both, and only in advance. Senders deliver a copy to
|
||||
* every relay in a signed record, so losing one loses no message, and a contact
|
||||
* whose delivery fails at the first home finds the second in the same record.
|
||||
* Added after a relay disappears, none of that helps: there is nothing left
|
||||
* to publish an updated record from and no way to tell anybody where you went.
|
||||
*
|
||||
* ## Why it is several requests
|
||||
*
|
||||
* Homing somewhere means having an account there. A relay will not take a
|
||||
* mailbox record from a stranger, and a token minted by one relay is refused by
|
||||
* every other because the audience is bound. So: get a credential at the new
|
||||
* relay, register this device so there is a mailbox to deliver into, claim the
|
||||
* same address so a sender can find it, and only then publish the record, at
|
||||
* every home rather than only the new one.
|
||||
*
|
||||
* That last part is not tidiness. A home that does not know it is one still
|
||||
* accepts mail, but a sender asking *it* for the record is handed a stale list
|
||||
* and skips the others, so the redundancy quietly depends on which relay the
|
||||
* sender happened to ask.
|
||||
* Keeping a mailbox at more than one relay (spec §20.4). ## What this buys, and
|
||||
* what it does not Your address already survives a relay disappearing: it comes
|
||||
* from your passphrase, so signing in elsewhere produces the same one.
|
||||
*/
|
||||
class MailboxHoming(
|
||||
private val vault: Vault,
|
||||
@@ -40,17 +15,13 @@ class MailboxHoming(
|
||||
private val primaryRelay: String,
|
||||
private val primary: RelayClient,
|
||||
) {
|
||||
|
||||
class Refused(message: String) : Exception(message)
|
||||
class Refused(
|
||||
message: String,
|
||||
) : Exception(message)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Normalise what somebody typed into a hostname, or return null.
|
||||
*
|
||||
* Refuses a URL rather than parsing one out. A relay is a hostname,
|
||||
* and accepting `https://x/y` here would let a pasted string point this
|
||||
* device at a path on somebody else's host, and then send it a signed
|
||||
* record naming that host as a mailbox.
|
||||
*/
|
||||
fun hostname(typed: String): String? {
|
||||
val host = typed.trim().lowercase()
|
||||
@@ -65,12 +36,7 @@ class MailboxHoming(
|
||||
|
||||
/**
|
||||
* Sign in at [typed], register this device, claim the address, and publish
|
||||
* the record everywhere. Returns the home list as it now stands.
|
||||
*
|
||||
* Nothing is written to the vault until the relay has actually accepted
|
||||
* a registration. A half-added home, stored locally, unknown to the relay
|
||||
* would be polled forever and would show on screen as a mailbox that
|
||||
* never has anything in it.
|
||||
* the record everywhere.
|
||||
*/
|
||||
fun add(
|
||||
typed: String,
|
||||
@@ -82,56 +48,41 @@ class MailboxHoming(
|
||||
/**
|
||||
* How to reach [typed], which is a separate question from what it is
|
||||
* called.
|
||||
*
|
||||
* The signed record names a hostname, it is inside the
|
||||
* signature, so it can carry no scheme and no port, while this is how
|
||||
* this device dials that host. `https://<host>` is the only correct
|
||||
* answer in production and the default; naming the two separately is
|
||||
* what lets a self-hosted or test deployment reachable some other way
|
||||
* work without the record lying about its own name.
|
||||
*/
|
||||
dialAs: String? = null,
|
||||
): List<String> {
|
||||
val host = hostname(typed) ?: throw Refused(
|
||||
"That does not look like a relay's address. It is a hostname, like " +
|
||||
"relay.example.com, no https:// and no path.")
|
||||
val host =
|
||||
hostname(typed) ?: throw Refused(
|
||||
"That does not look like a relay's address. It is a hostname, like " +
|
||||
"relay.example.com, no https:// and no path.",
|
||||
)
|
||||
if (host == primaryRelay || host in existingHomes) {
|
||||
throw Refused("That relay already holds a mailbox for you.")
|
||||
}
|
||||
|
||||
val base = dialAs ?: "https://$host"
|
||||
val account = try {
|
||||
RelayAccounts(base).signIn(handle, passphrase)
|
||||
} catch (e: Exception) {
|
||||
// Says which of the two things went wrong. "No account there" and
|
||||
// "wrong passphrase" call for opposite actions, and one message
|
||||
// covering both leaves somebody retyping a correct passphrase.
|
||||
throw Refused(
|
||||
"$host did not accept those details. Sign up there with the same " +
|
||||
"passphrase first, that is what gives you the same address.")
|
||||
}
|
||||
val account =
|
||||
try {
|
||||
RelayAccounts(base).signIn(handle, passphrase)
|
||||
} catch (e: Exception) {
|
||||
// Says which of the two things went wrong.
|
||||
throw Refused(
|
||||
"$host did not accept those details. Sign up there with the same " +
|
||||
"passphrase first, that is what gives you the same address.",
|
||||
)
|
||||
}
|
||||
|
||||
val client = RelayClient(base, account.username, account.appPassword)
|
||||
client.useRelayAuth(identity)
|
||||
|
||||
// A mailbox to deliver into.
|
||||
//
|
||||
// No one-time prekeys here. A one-time prekey is only usable if
|
||||
// this device kept its private half, and those live in the identity
|
||||
// archive keyed to no particular relay, publishing keys at a second
|
||||
// home whose privates this device would not offer back makes every
|
||||
// session opened with one undecryptable, which is worse than offering
|
||||
// none. Without them a sender falls back to the signed prekey: it costs
|
||||
// initial forward secrecy on the first message and nothing else.
|
||||
try {
|
||||
client.registerDevice(identity, emptyList())
|
||||
} catch (e: Exception) {
|
||||
throw Refused("$host refused the registration: ${e.message}")
|
||||
}
|
||||
|
||||
// The same address here as everywhere. A sender resolving `fnid@host`
|
||||
// asks that relay, and until the claim lands the answer is "no such
|
||||
// address", an unclaimed home is a mailbox nobody can address.
|
||||
// The same address here as everywhere.
|
||||
try {
|
||||
AccountIdentity.claim(client, accountKeySeed, account.userId)
|
||||
} catch (e: Exception) {
|
||||
@@ -142,40 +93,31 @@ class MailboxHoming(
|
||||
val clients = LinkedHashMap<String, RelayClient>()
|
||||
clients[primaryRelay] = primary
|
||||
for (existing in vault.extraHomes()) {
|
||||
clients[existing.relay] = RelayClient(
|
||||
existing.baseUrl, existing.username, existing.appPassword
|
||||
).also { it.useRelayAuth(identity) }
|
||||
clients[existing.relay] =
|
||||
RelayClient(
|
||||
existing.baseUrl,
|
||||
existing.username,
|
||||
existing.appPassword,
|
||||
).also { it.useRelayAuth(identity) }
|
||||
}
|
||||
clients[host] = client
|
||||
|
||||
val serial = AccountIdentity.nextSerial(fnid, clients.values)
|
||||
val failures = AccountIdentity.publishHomes(
|
||||
accountKeySeed, fnid, serial, homes.associateWith { clients[it] ?: primary })
|
||||
val failures =
|
||||
AccountIdentity.publishHomes(accountKeySeed, fnid, serial, homes.associateWith { clients[it] ?: primary })
|
||||
|
||||
// The NEW relay accepting is what decides. A record every old home
|
||||
// took but the new one refused describes a mailbox that does not exist,
|
||||
// and a sender told to deliver there gets nothing.
|
||||
// The NEW relay accepting is what decides.
|
||||
failures[host]?.let { throw Refused("$host refused the mailbox: ${it.message}") }
|
||||
|
||||
vault.saveExtraHomes(
|
||||
vault.extraHomes() +
|
||||
Vault.ExtraHome(host, account.username, account.appPassword, base))
|
||||
Vault.ExtraHome(host, account.username, account.appPassword, base),
|
||||
)
|
||||
return homes
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop keeping a mailbox at [relay].
|
||||
*
|
||||
* The shorter list is published first, and the local credential goes
|
||||
* only if that worked. Forgetting locally while senders are still told to
|
||||
* deliver there leaves mail arriving at a mailbox this device no longer
|
||||
* reads, indistinguishable, from the sender's side, from delivery.
|
||||
*
|
||||
* The relay being removed is not asked to accept the new record. It
|
||||
* is the one party with a reason to refuse, and it is not in the list any
|
||||
* more so it would refuse on those grounds regardless. Its copy simply goes
|
||||
* stale, which costs nothing: the record is self-certifying and every other
|
||||
* home serves the current one.
|
||||
*/
|
||||
fun remove(
|
||||
relay: String,
|
||||
@@ -186,7 +128,8 @@ class MailboxHoming(
|
||||
if (relay == primaryRelay) {
|
||||
throw Refused(
|
||||
"This is the relay you are signed in to, so it cannot be removed " +
|
||||
"while you are using it.")
|
||||
"while you are using it.",
|
||||
)
|
||||
}
|
||||
val remaining = currentHomes.filterNot { it == relay }
|
||||
if (remaining.isEmpty()) throw Refused("An account needs at least one mailbox.")
|
||||
@@ -195,14 +138,17 @@ class MailboxHoming(
|
||||
clients[primaryRelay] = primary
|
||||
for (existing in vault.extraHomes()) {
|
||||
if (existing.relay == relay) continue
|
||||
clients[existing.relay] = RelayClient(
|
||||
existing.baseUrl, existing.username, existing.appPassword
|
||||
).also { it.useRelayAuth(identity) }
|
||||
clients[existing.relay] =
|
||||
RelayClient(
|
||||
existing.baseUrl,
|
||||
existing.username,
|
||||
existing.appPassword,
|
||||
).also { it.useRelayAuth(identity) }
|
||||
}
|
||||
|
||||
val serial = AccountIdentity.nextSerial(fnid, clients.values)
|
||||
val failures = AccountIdentity.publishHomes(
|
||||
accountKeySeed, fnid, serial, remaining.associateWith { clients[it] ?: primary })
|
||||
val failures =
|
||||
AccountIdentity.publishHomes(accountKeySeed, fnid, serial, remaining.associateWith { clients[it] ?: primary })
|
||||
failures[primaryRelay]?.let {
|
||||
throw Refused("Your own relay would not take the new list: ${it.message}")
|
||||
}
|
||||
|
||||
@@ -12,12 +12,6 @@ import org.json.JSONObject
|
||||
/**
|
||||
* The non-cryptographic half of the product: who your contacts are, who is
|
||||
* online, what you have been notified about, who you have blocked.
|
||||
*
|
||||
* These routes take the account credential, not the relay token. The
|
||||
* `/e2e/…` routes deliberately refuse the account password because a
|
||||
* credential good for every device is too much authority for key material.
|
||||
* These routes are the opposite case, they are about the account, not a
|
||||
* device, so Basic is correct here and a device-scoped token would be wrong.
|
||||
*/
|
||||
class MessengerApi(
|
||||
baseUrl: String,
|
||||
@@ -25,19 +19,11 @@ class MessengerApi(
|
||||
appPassword: String,
|
||||
private val http: OkHttpClient = RelayClient.defaultHttpClient(),
|
||||
) {
|
||||
|
||||
private val base: HttpUrl = baseUrl.trimEnd('/').toHttpUrl()
|
||||
private val appPassword = appPassword.replace(" ", "")
|
||||
|
||||
/**
|
||||
* Someone in your list.
|
||||
*
|
||||
* `handle` is the name they signed up with and the only one another
|
||||
* person can type to reach them. It was null on this relay for a while,
|
||||
* having started life as an OSCAR screen name, which meant you could not
|
||||
* find your own contacts by the name you knew them by. Show it.
|
||||
*
|
||||
* `presence` is their chosen status, not an inference from activity.
|
||||
*/
|
||||
data class Buddy(
|
||||
val userId: Long,
|
||||
@@ -47,11 +33,6 @@ class MessengerApi(
|
||||
/**
|
||||
* Whether this person is somebody the user chose, or somebody who
|
||||
* turned up because they wrote first.
|
||||
*
|
||||
* Defaults to true, and that default is the compatibility story.
|
||||
* A relay that predates this field says nothing about acceptance, and
|
||||
* the only safe reading of silence is "these are your contacts", the
|
||||
* alternative labels every contact on every older relay a stranger.
|
||||
*/
|
||||
val accepted: Boolean = true,
|
||||
)
|
||||
@@ -67,8 +48,8 @@ class MessengerApi(
|
||||
val list = get("buddies").optJSONArray("buddies") ?: JSONArray()
|
||||
return (0 until list.length()).map {
|
||||
val b = list.getJSONObject(it)
|
||||
// `id`, `display_name`, `screen_name`, `presence`, this route
|
||||
// does NOT use the `user_id`/`status` shape the §8 routes use, and
|
||||
// `id`, `display_name`, `screen_name`, `presence`, this route does
|
||||
// NOT use the `user_id`/`status` shape the §8 routes use, and
|
||||
// guessing otherwise threw "No value for user_id" at the exact
|
||||
// moment a contact was added.
|
||||
Buddy(
|
||||
@@ -82,20 +63,17 @@ class MessengerApi(
|
||||
}
|
||||
|
||||
/**
|
||||
* By username, not by id. A person types a name; the relay is the only
|
||||
* thing that can turn that into an id, and doing the lookup client-side
|
||||
* would need a directory this product deliberately does not have.
|
||||
* By username, not by id.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param auto true when the app is adopting somebody who messaged first,
|
||||
* rather than the user choosing to add them.
|
||||
*
|
||||
* The distinction is the whole feature. Adopting a stranger keeps their
|
||||
* first message visible, which is why adoption exists, while leaving the
|
||||
* decision about whether they are a contact to the person, instead of
|
||||
* making it for them silently.
|
||||
*/
|
||||
fun addBuddy(username: String, auto: Boolean = false) {
|
||||
fun addBuddy(
|
||||
username: String,
|
||||
auto: Boolean = false,
|
||||
) {
|
||||
post("buddies", JSONObject().put("username", username).put("auto", auto))
|
||||
}
|
||||
|
||||
@@ -106,24 +84,17 @@ class MessengerApi(
|
||||
|
||||
/**
|
||||
* Resolve a user id to a name, `GET /messenger/users/:id`.
|
||||
*
|
||||
* This is how a message from a stranger becomes visible. An E2E
|
||||
* envelope names its sender by id only, and the buddy list is what the
|
||||
* Messages screen renders, so first contact from somebody who added US
|
||||
* arrived, decrypted, filed, counted as unread, and never appeared
|
||||
* anywhere. The receive path uses this to name the sender and add them.
|
||||
*
|
||||
* Null when the relay does not know the id (or predates the route).
|
||||
*/
|
||||
fun userInfo(userId: Long): Buddy? = runCatching {
|
||||
val b = get("users/$userId")
|
||||
Buddy(
|
||||
userId = b.getLong("id"),
|
||||
displayName = b.optString("display_name", ""),
|
||||
handle = b.optString("screen_name", ""),
|
||||
presence = "offline",
|
||||
)
|
||||
}.getOrNull()
|
||||
fun userInfo(userId: Long): Buddy? =
|
||||
runCatching {
|
||||
val b = get("users/$userId")
|
||||
Buddy(
|
||||
userId = b.getLong("id"),
|
||||
displayName = b.optString("display_name", ""),
|
||||
handle = b.optString("screen_name", ""),
|
||||
presence = "offline",
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
fun removeBuddy(userId: Long) {
|
||||
delete("buddies/$userId")
|
||||
@@ -136,26 +107,12 @@ class MessengerApi(
|
||||
|
||||
/**
|
||||
* Set our presence, and return what the relay says it now is.
|
||||
*
|
||||
* Return the server's answer, never the value we asked for. The
|
||||
* relay used to accept an unknown status with 200 and store nothing, so a
|
||||
* caller that assumed success showed a state the server had discarded,
|
||||
* "offline" on this screen while every contact still saw us online. The
|
||||
* relay refuses unknown values now, but a client that reports its own
|
||||
* request back to itself is wrong regardless of how the server behaves.
|
||||
*
|
||||
* The answer is not always the word sent: `offline` is stored as
|
||||
* `invisible`, which is what "appear offline" has always meant.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Read the status the relay currently holds for us, without changing it.
|
||||
*
|
||||
* An empty body is a READ. The route only writes when `status` is
|
||||
* present, and answers with the stored value either way, so this needs no
|
||||
* route of its own, and cannot accidentally set something.
|
||||
*/
|
||||
fun currentStatus(): String =
|
||||
post("status", JSONObject()).optString("status").ifEmpty { "online" }
|
||||
fun currentStatus(): String = post("status", JSONObject()).optString("status").ifEmpty { "online" }
|
||||
|
||||
fun setStatus(status: String): String {
|
||||
val reply = post("status", JSONObject().put("status", status))
|
||||
@@ -168,9 +125,6 @@ class MessengerApi(
|
||||
return (0 until list.length()).map {
|
||||
val n = list.getJSONObject(it)
|
||||
// `type`/`message`/`is_read`, the relay's actual field names.
|
||||
// This read `kind`/`body`/`read` for months, which exist nowhere,
|
||||
// so every alert decoded as an empty unread row and the screen
|
||||
// looked permanently blank.
|
||||
Notification(
|
||||
id = n.optLong("id"),
|
||||
kind = n.optString("type", ""),
|
||||
@@ -186,44 +140,39 @@ class MessengerApi(
|
||||
|
||||
/**
|
||||
* The ids this account has blocked.
|
||||
*
|
||||
* `id`, not `user_id`. This route answers `{id, name, slug}`, the
|
||||
* same shape as the buddy list and NOT the `user_id` shape the §8 routes
|
||||
* use. Reading `user_id` here threw `No value for user_id`, and the reason
|
||||
* it survived so long is the reason it was dangerous: the list is empty
|
||||
* until somebody blocks their first person, so the map never ran and every
|
||||
* test passed. The first block anybody performed broke it.
|
||||
*
|
||||
* It threw where a caller was defaulting to "nobody is blocked", which
|
||||
* turned a field-name typo into a blocked person being auto-added back to
|
||||
* your contacts. See `adoptStrangers`.
|
||||
*/
|
||||
fun blocked(): List<Long> {
|
||||
val list = get("blocks").optJSONArray("blocked") ?: JSONArray()
|
||||
return (0 until list.length()).map { list.getJSONObject(it).getLong("id") }
|
||||
}
|
||||
|
||||
fun setBlocked(userId: Long, blocked: Boolean) {
|
||||
fun setBlocked(
|
||||
userId: Long,
|
||||
blocked: Boolean,
|
||||
) {
|
||||
if (blocked) post("blocks/$userId", JSONObject()) else delete("blocks/$userId")
|
||||
}
|
||||
|
||||
// -- The account itself --------------------------------------------------
|
||||
// -- The account itself --------------------------------------------------.
|
||||
|
||||
data class Me(val userId: Long, val displayName: String, val handle: String)
|
||||
data class Me(
|
||||
val userId: Long,
|
||||
val displayName: String,
|
||||
val handle: String,
|
||||
)
|
||||
|
||||
fun me(): Me = raw("api/flamenet/v1/messenger/me", "GET", null).let {
|
||||
Me(it.getLong("id"), it.optString("name"), it.optString("slug"))
|
||||
}
|
||||
fun me(): Me =
|
||||
raw("api/flamenet/v1/messenger/me", "GET", null).let {
|
||||
Me(it.getLong("id"), it.optString("name"), it.optString("slug"))
|
||||
}
|
||||
|
||||
fun updateDisplayName(name: String) {
|
||||
raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("name", name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Changing the password invalidates other credentials, which is the
|
||||
* point of changing it, so this device has to sign in again afterwards.
|
||||
* A UI that changes the password and carries on looks fine until the next
|
||||
* request 401s for no visible reason.
|
||||
* Changing the password invalidates other credentials, which is the point
|
||||
* of changing it, so this device has to sign in again afterwards.
|
||||
*/
|
||||
fun updatePassword(password: String) {
|
||||
raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("password", password))
|
||||
@@ -235,38 +184,27 @@ class MessengerApi(
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact-username lookup. Deliberately not a directory: no listing exists.
|
||||
*
|
||||
* The relay answers with an ARRAY, empty for a miss and one element
|
||||
* for a hit, `RoutesAccount.lookup` returns `[MeResponse]` precisely so a
|
||||
* miss and a hit differ only in length and neither leaks timing. This went
|
||||
* through `send()`, which does `JSONObject(text)` and throws on an array,
|
||||
* and the throw was swallowed by `runCatching { }.getOrNull()`.
|
||||
*
|
||||
* The result: every lookup reported "no account called X here" even when
|
||||
* the relay had found the account and returned it. Adding a contact was
|
||||
* impossible on Android, which is the first thing anyone does. Measured
|
||||
* against relay.flamenet.io, the relay returned
|
||||
* `[{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]` while the
|
||||
* app said no such account.
|
||||
*
|
||||
* So parse the array here, and keep `getOrNull` for TRANSPORT failure
|
||||
* only, a miss is an empty array, not an exception.
|
||||
* Exact-username lookup.
|
||||
*/
|
||||
fun lookup(username: String): Me? {
|
||||
val text = rawText(
|
||||
"api/flamenet/v1/messenger/users", query = mapOf("slug" to username)
|
||||
) ?: return null
|
||||
val text =
|
||||
rawText(
|
||||
"api/flamenet/v1/messenger/users",
|
||||
query = mapOf("slug" to username),
|
||||
) ?: return null
|
||||
val found = runCatching { JSONArray(text) }.getOrNull() ?: return null
|
||||
if (found.length() == 0) return null
|
||||
val o = found.getJSONObject(0)
|
||||
return Me(o.getLong("id"), o.optString("name"), o.optString("slug"))
|
||||
}
|
||||
|
||||
// -- Internals -----------------------------------------------------------
|
||||
// -- Internals -----------------------------------------------------------.
|
||||
|
||||
private fun url(path: String) = base.newBuilder()
|
||||
.addPathSegments("api/flamenet/v1/messenger/$path").build()
|
||||
private fun url(path: String) =
|
||||
base
|
||||
.newBuilder()
|
||||
.addPathSegments("api/flamenet/v1/messenger/$path")
|
||||
.build()
|
||||
|
||||
/** For the routes that are not under the messenger prefix. */
|
||||
private fun raw(
|
||||
@@ -275,8 +213,12 @@ class MessengerApi(
|
||||
body: JSONObject?,
|
||||
query: Map<String, String> = emptyMap(),
|
||||
): JSONObject {
|
||||
val target = base.newBuilder().addPathSegments(path)
|
||||
.also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } }.build()
|
||||
val target =
|
||||
base
|
||||
.newBuilder()
|
||||
.addPathSegments(path)
|
||||
.also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } }
|
||||
.build()
|
||||
val builder = Request.Builder().url(target).header("Accept", "application/json")
|
||||
when (method) {
|
||||
"GET" -> builder.get()
|
||||
@@ -287,33 +229,60 @@ class MessengerApi(
|
||||
return send(builder)
|
||||
}
|
||||
|
||||
private fun get(path: String) = send(
|
||||
Request.Builder().url(url(path)).get().header("Accept", "application/json"))
|
||||
private fun get(path: String) =
|
||||
send(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url(path))
|
||||
.get()
|
||||
.header("Accept", "application/json"),
|
||||
)
|
||||
|
||||
private fun post(path: String, body: JSONObject) = send(
|
||||
Request.Builder().url(url(path))
|
||||
private fun post(
|
||||
path: String,
|
||||
body: JSONObject,
|
||||
) = send(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url(path))
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("Accept", "application/json"))
|
||||
.header("Accept", "application/json"),
|
||||
)
|
||||
|
||||
private fun delete(path: String) = send(
|
||||
Request.Builder().url(url(path)).delete().header("Accept", "application/json"))
|
||||
private fun delete(path: String) =
|
||||
send(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url(path))
|
||||
.delete()
|
||||
.header("Accept", "application/json"),
|
||||
)
|
||||
|
||||
/**
|
||||
* A GET whose body is returned as text rather than forced into a
|
||||
* `JSONObject`.
|
||||
*
|
||||
* Exists for the ONE route that answers with an array. Everything else
|
||||
* goes through `send()`; do not widen this without checking the route,
|
||||
* because losing the typed error mapping is a real cost.
|
||||
*/
|
||||
private fun rawText(path: String, query: Map<String, String> = emptyMap()): String? {
|
||||
val target = base.newBuilder().addPathSegments(path)
|
||||
.also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } }.build()
|
||||
val request = Request.Builder().url(target)
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", "Basic " + Wire.b64(
|
||||
"$username:$appPassword".toByteArray(Charsets.UTF_8)))
|
||||
.get().build()
|
||||
private fun rawText(
|
||||
path: String,
|
||||
query: Map<String, String> = emptyMap(),
|
||||
): String? {
|
||||
val target =
|
||||
base
|
||||
.newBuilder()
|
||||
.addPathSegments(path)
|
||||
.also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } }
|
||||
.build()
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(target)
|
||||
.header("Accept", "application/json")
|
||||
.header(
|
||||
"Authorization",
|
||||
"Basic " +
|
||||
Wire.b64("$username:$appPassword".toByteArray(Charsets.UTF_8)),
|
||||
).get()
|
||||
.build()
|
||||
return runCatching {
|
||||
http.newCall(request).execute().use {
|
||||
if (it.isSuccessful) it.body?.string() else null
|
||||
@@ -322,29 +291,44 @@ class MessengerApi(
|
||||
}
|
||||
|
||||
private fun send(builder: Request.Builder): JSONObject {
|
||||
val request = builder
|
||||
.header("Authorization", "Basic " + Wire.b64(
|
||||
"$username:$appPassword".toByteArray(Charsets.UTF_8)))
|
||||
.build()
|
||||
val response = try {
|
||||
http.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
throw RelayException.Transport(e.message ?: e.toString(), e)
|
||||
}
|
||||
val request =
|
||||
builder
|
||||
.header(
|
||||
"Authorization",
|
||||
"Basic " +
|
||||
Wire.b64("$username:$appPassword".toByteArray(Charsets.UTF_8)),
|
||||
).build()
|
||||
val response =
|
||||
try {
|
||||
http.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
throw RelayException.Transport(e.message ?: e.toString(), e)
|
||||
}
|
||||
response.use {
|
||||
val text = it.body?.string().orEmpty()
|
||||
if (it.isSuccessful) {
|
||||
return if (text.isBlank()) JSONObject() else runCatching { JSONObject(text) }
|
||||
.getOrElse { throw RelayException.Malformed("the relay's reply was not JSON") }
|
||||
return if (text.isBlank()) {
|
||||
JSONObject()
|
||||
} else {
|
||||
runCatching { JSONObject(text) }
|
||||
.getOrElse { throw RelayException.Malformed("the relay's reply was not JSON") }
|
||||
}
|
||||
}
|
||||
val message = runCatching { JSONObject(text).optString("message") }
|
||||
.getOrNull().orEmpty().ifBlank { text }
|
||||
val message =
|
||||
runCatching { JSONObject(text).optString("message") }
|
||||
.getOrNull()
|
||||
.orEmpty()
|
||||
.ifBlank { text }
|
||||
throw when (it.code) {
|
||||
401, 403 -> RelayException.Unauthorized(message)
|
||||
|
||||
404 -> RelayException.NotFound(message.ifBlank { request.url.encodedPath })
|
||||
|
||||
429 -> RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull())
|
||||
// The geo-gate, not the relay. Needs a sentence, not a number.
|
||||
|
||||
// The geo-gate, not the relay.
|
||||
451 -> RelayException.Unavailable(message)
|
||||
|
||||
else -> RelayException.Http(it.code, message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,38 +6,13 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Which bridge, if any, the app's HTTP clients route through.
|
||||
*
|
||||
* A holder rather than a constructor argument, because there are ten
|
||||
* construction sites and no way to be sure of catching them all. `RelayClient`,
|
||||
* `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`,
|
||||
* `RelayDiscovery` and the background service each build their own client, some
|
||||
* from default parameters evaluated at field-initialisation time. Threading a
|
||||
* bridge through every one would work until somebody adds an eleventh, and the
|
||||
* eleventh would connect directly, successfully, with nothing to show that it
|
||||
* had. A missed site here does not fail; it silently does not protect.
|
||||
*
|
||||
* The socket factory is installed whether or not a bridge is set. A
|
||||
* client is built once and the setting changes later, so the factory has to be
|
||||
* able to follow it. With no bridge it delegates to the platform's own factory,
|
||||
* which is exactly what OkHttp would have used.
|
||||
*/
|
||||
object Obfs4Routing {
|
||||
|
||||
@Volatile
|
||||
private var current: Obfs4Bridge? = null
|
||||
|
||||
/**
|
||||
* One pool for every default client.
|
||||
*
|
||||
* Shared so it can be emptied. OkHttp keeps connections alive across
|
||||
* requests; a connection opened before the bridge changed still runs over
|
||||
* the old transport, and would go on serving requests for minutes after the
|
||||
* user switched bridges, or after they turned one OFF, which is the
|
||||
* dangerous direction. Eviction on change is the only thing that stops that,
|
||||
* and it needs a pool we hold.
|
||||
*
|
||||
* The idle window is short for the same reason a bridge that goes away
|
||||
* must not leave requests queued behind a dead tunnel.
|
||||
*/
|
||||
internal val pool = ConnectionPool(8, 2, TimeUnit.MINUTES)
|
||||
|
||||
@@ -46,9 +21,6 @@ object Obfs4Routing {
|
||||
|
||||
/**
|
||||
* Route through [bridge] from now on, or directly when it is null.
|
||||
*
|
||||
* Existing pooled connections are dropped, because they are already
|
||||
* committed to the previous transport.
|
||||
*/
|
||||
fun use(bridge: Obfs4Bridge?) {
|
||||
if (bridge == current) return
|
||||
|
||||
@@ -9,21 +9,11 @@ import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A relay's own account table, the identity provider a self-hosted relay is.
|
||||
*
|
||||
* This is what keeps self-hosting one service rather than two. A relay with
|
||||
* no shared account service configured still signs people up, signs them in and
|
||||
* resets their passwords, on its own.
|
||||
*
|
||||
* What comes back is not the password. The relay mints a long-lived
|
||||
* credential, returns it once, and keeps only its hash. That credential is what
|
||||
* every later request carries; the password is used at this moment and then
|
||||
* should not be kept.
|
||||
*/
|
||||
class RelayAccounts(
|
||||
baseUrl: String,
|
||||
private val http: OkHttpClient = RelayClient.defaultHttpClient(),
|
||||
) {
|
||||
|
||||
private val base = baseUrl.trimEnd('/').toHttpUrl()
|
||||
|
||||
/** Who the relay says you are, and the credential to prove it with. */
|
||||
@@ -36,80 +26,76 @@ class RelayAccounts(
|
||||
|
||||
/**
|
||||
* The challenge is fetched only when the relay asks for one.
|
||||
* Fetching up front would add a round trip to every sign-up on every
|
||||
* server, for a feature that is off by default and off on every relay
|
||||
* running today, a second wait on the one screen where somebody is
|
||||
* already waiting. So the first attempt goes out plain, and only a relay
|
||||
* that refuses it sends us to solve anything.
|
||||
*
|
||||
* The refused attempt costs one of fifty per hour from this address, which
|
||||
* is the right trade for not charging everybody else a round trip.
|
||||
*/
|
||||
fun signUp(username: String, email: String, password: String, displayName: String? = null): Account {
|
||||
fun body() = JSONObject()
|
||||
.put("username", username)
|
||||
.put("email", email)
|
||||
.put("password", password)
|
||||
.put("display_name", displayName ?: username)
|
||||
fun signUp(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
displayName: String? = null,
|
||||
): Account {
|
||||
fun body() =
|
||||
JSONObject()
|
||||
.put("username", username)
|
||||
.put("email", email)
|
||||
.put("password", password)
|
||||
.put("display_name", displayName ?: username)
|
||||
|
||||
return try {
|
||||
account(post("account/signup", body()))
|
||||
} catch (asked: RelayException.ChallengeRequired) {
|
||||
// Rethrown, not swallowed, when we cannot answer. A difficulty
|
||||
// above the cap or a search that ran out leaves this app // unable to sign up here, and "update the app" is then the true
|
||||
// sentence, quietly failing with something else would send
|
||||
// somebody looking in the wrong place.
|
||||
// Rethrown, not swallowed, when we cannot answer.
|
||||
val solved = SignupProofOfWork.satisfy(base.toString(), http) ?: throw asked
|
||||
account(post("account/signup", body()
|
||||
.put("challenge", solved.challenge)
|
||||
.put("nonce", solved.nonce)))
|
||||
account(
|
||||
post(
|
||||
"account/signup",
|
||||
body()
|
||||
.put("challenge", solved.challenge)
|
||||
.put("nonce", solved.nonce),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The field is "username or email" and always has been. A client
|
||||
* that only sends usernames rejects half the people who typed exactly what
|
||||
* they were asked for.
|
||||
* The field is "username or email" and always has been.
|
||||
*/
|
||||
fun signIn(usernameOrEmail: String, password: String): Account =
|
||||
account(post("account/signin", JSONObject()
|
||||
.put("username", usernameOrEmail)
|
||||
.put("password", password)))
|
||||
fun signIn(
|
||||
usernameOrEmail: String,
|
||||
password: String,
|
||||
): Account =
|
||||
account(
|
||||
post(
|
||||
"account/signin",
|
||||
JSONObject()
|
||||
.put("username", usernameOrEmail)
|
||||
.put("password", password),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Exchange a shared FlameNet account session for a relay session.
|
||||
*
|
||||
* Present only on a relay configured to trust an account service. A
|
||||
* self-hosted relay answers 404 here, and that is the correct answer rather
|
||||
* than a misconfiguration, it is its own identity provider.
|
||||
*
|
||||
* The account service's token carries a product-scoped pseudonym, not
|
||||
* the handle, so the relay learns an opaque id and nothing that ties the
|
||||
* person to any other FlameNet product.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The handle travels beside the token, not inside it. The account
|
||||
* service will not put a handle in a token, so that a relay cannot
|
||||
* correlate its users with another product's, the token carries a
|
||||
* product-scoped pseudonym instead. The relay uses the handle only the
|
||||
* first time it sees that pseudonym; after that the pseudonym decides, and
|
||||
* honouring the handle would let somebody rename themselves into an
|
||||
* existing account.
|
||||
* The handle travels beside the token, not inside it.
|
||||
*/
|
||||
fun sharedSession(accountToken: String, handle: String, displayName: String? = null): Account =
|
||||
account(post("api/flamenet/v1/messenger/session", JSONObject()
|
||||
.put("token", accountToken)
|
||||
.put("handle", handle)
|
||||
.also { if (!displayName.isNullOrEmpty()) it.put("display_name", displayName) }))
|
||||
fun sharedSession(
|
||||
accountToken: String,
|
||||
handle: String,
|
||||
displayName: String? = null,
|
||||
): Account =
|
||||
account(
|
||||
post(
|
||||
"api/flamenet/v1/messenger/session",
|
||||
JSONObject()
|
||||
.put("token", accountToken)
|
||||
.put("handle", handle)
|
||||
.also { if (!displayName.isNullOrEmpty()) it.put("display_name", displayName) },
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Ask the relay to send a reset code.
|
||||
*
|
||||
* The relay answers identically whatever the truth is, unknown handle,
|
||||
* no address on file, an account that authenticates elsewhere. So this
|
||||
* cannot report whether it worked, and a UI that claims it can is lying.
|
||||
* Anything thrown here is a transport or server failure, never "no such
|
||||
* account".
|
||||
*/
|
||||
fun requestPasswordReset(handle: String) {
|
||||
post("account/reset/request", JSONObject().put("handle", handle))
|
||||
@@ -117,81 +103,121 @@ class RelayAccounts(
|
||||
|
||||
/**
|
||||
* Spend an email verification code.
|
||||
*
|
||||
* Unauthenticated, exactly like reset confirm: the code IS the proof.
|
||||
* Requiring a signed-in session would mean somebody kept out *because* they
|
||||
* are unverified could never verify.
|
||||
*/
|
||||
fun verifyEmail(token: String) {
|
||||
post("account/verify", JSONObject().put("token", token))
|
||||
}
|
||||
|
||||
fun confirmPasswordReset(handle: String, code: String, newPassword: String) {
|
||||
post("account/reset/confirm", JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("code", code)
|
||||
.put("password", newPassword))
|
||||
fun confirmPasswordReset(
|
||||
handle: String,
|
||||
code: String,
|
||||
newPassword: String,
|
||||
) {
|
||||
post(
|
||||
"account/reset/confirm",
|
||||
JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("code", code)
|
||||
.put("password", newPassword),
|
||||
)
|
||||
}
|
||||
|
||||
private fun account(json: JSONObject) = Account(
|
||||
userId = json.getLong("user_id"),
|
||||
username = json.getString("username"),
|
||||
displayName = json.optString("display_name", json.getString("username")),
|
||||
appPassword = json.getString("app_password"),
|
||||
)
|
||||
private fun account(json: JSONObject) =
|
||||
Account(
|
||||
userId = json.getLong("user_id"),
|
||||
username = json.getString("username"),
|
||||
displayName = json.optString("display_name", json.getString("username")),
|
||||
appPassword = json.getString("app_password"),
|
||||
)
|
||||
|
||||
private fun post(path: String, body: JSONObject): JSONObject {
|
||||
val request = Request.Builder()
|
||||
.url(base.newBuilder().addPathSegments(path).build())
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
val response = try {
|
||||
http.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
throw RelayException.Transport(e.message ?: e.toString(), e)
|
||||
}
|
||||
private fun post(
|
||||
path: String,
|
||||
body: JSONObject,
|
||||
): JSONObject {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(base.newBuilder().addPathSegments(path).build())
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
val response =
|
||||
try {
|
||||
http.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
throw RelayException.Transport(e.message ?: e.toString(), e)
|
||||
}
|
||||
response.use {
|
||||
val text = it.body?.string().orEmpty()
|
||||
if (it.isSuccessful) {
|
||||
return if (text.isBlank()) JSONObject() else try {
|
||||
JSONObject(text)
|
||||
} catch (_: Exception) {
|
||||
throw RelayException.Malformed("the relay's reply was not a JSON object")
|
||||
return if (text.isBlank()) {
|
||||
JSONObject()
|
||||
} else {
|
||||
try {
|
||||
JSONObject(text)
|
||||
} catch (_: Exception) {
|
||||
throw RelayException.Malformed("the relay's reply was not a JSON object")
|
||||
}
|
||||
}
|
||||
}
|
||||
// The relay puts a human-readable reason in the body, and it is
|
||||
// better than anything this layer could invent from a status code.
|
||||
val message = runCatching { JSONObject(text).optString("message") }
|
||||
.getOrNull().orEmpty().ifBlank { text }
|
||||
val code = runCatching { JSONObject(text).optString("code") }
|
||||
.getOrNull().orEmpty()
|
||||
val message =
|
||||
runCatching { JSONObject(text).optString("message") }
|
||||
.getOrNull()
|
||||
.orEmpty()
|
||||
.ifBlank { text }
|
||||
val code =
|
||||
runCatching { JSONObject(text).optString("code") }
|
||||
.getOrNull()
|
||||
.orEmpty()
|
||||
throw when {
|
||||
// Decided by the relay's own `code`, never by the status. A
|
||||
// 400 is also a malformed body and a bad email; telling either
|
||||
// of those to go and update the app would send somebody to the
|
||||
// Play Store to fix a typo.
|
||||
code == "challenge_required" -> RelayException.ChallengeRequired(message)
|
||||
code == "challenge_failed" -> RelayException.ChallengeFailed(message)
|
||||
// A 403 is not an expired session. 401 means the token
|
||||
// was refused and signing in again is the fix. 403 means a
|
||||
// RULE refused this, too many devices, a retired device, an
|
||||
// Decided by the relay's own `code`, never by the status.
|
||||
code == "challenge_required" -> {
|
||||
RelayException.ChallengeRequired(message)
|
||||
}
|
||||
|
||||
code == "challenge_failed" -> {
|
||||
RelayException.ChallengeFailed(message)
|
||||
}
|
||||
|
||||
// A 403 is not an expired session. 401 means the token was
|
||||
// refused and signing in again is the fix. 403 means a RULE
|
||||
// refused this, too many devices, a retired device, an
|
||||
// unverified address, and the relay writes a sentence saying
|
||||
// which. Reporting those as "your session expired, sign out and
|
||||
// back in" sends somebody round a loop that cannot end: they
|
||||
// sign in again, hit the same rule, and are told the same
|
||||
// wrong thing. Observed tonight on a fresh install.
|
||||
it.code == 401 -> RelayException.Unauthorized(message)
|
||||
it.code == 403 && code == "email_unverified" ->
|
||||
// which.
|
||||
it.code == 401 -> {
|
||||
RelayException.Unauthorized(message)
|
||||
}
|
||||
|
||||
it.code == 403 && code == "email_unverified" -> {
|
||||
RelayException.EmailUnverified(message)
|
||||
it.code == 403 -> RelayException.Http(403, message)
|
||||
it.code == 404 -> RelayException.NotFound(path)
|
||||
it.code == 409 -> RelayException.Http(409, message)
|
||||
it.code == 429 ->
|
||||
}
|
||||
|
||||
it.code == 403 -> {
|
||||
RelayException.Http(403, message)
|
||||
}
|
||||
|
||||
it.code == 404 -> {
|
||||
RelayException.NotFound(path)
|
||||
}
|
||||
|
||||
it.code == 409 -> {
|
||||
RelayException.Http(409, message)
|
||||
}
|
||||
|
||||
it.code == 429 -> {
|
||||
RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull())
|
||||
// The geo-gate, not the relay. Needs a sentence, not a number.
|
||||
it.code == 451 -> RelayException.Unavailable(message)
|
||||
else -> RelayException.Http(it.code, message)
|
||||
}
|
||||
|
||||
// The geo-gate, not the relay.
|
||||
it.code == 451 -> {
|
||||
RelayException.Unavailable(message)
|
||||
}
|
||||
|
||||
else -> {
|
||||
RelayException.Http(it.code, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,99 +6,76 @@ import java.util.UUID
|
||||
|
||||
/**
|
||||
* Authentication for the relay's `/e2e/…` routes (spec §8.3).
|
||||
*
|
||||
* The relay refuses the account's own password on these routes, and that is a
|
||||
* design decision rather than an oversight to work around. A credential good
|
||||
* for every route on every one of an account's devices means a token lifted
|
||||
* from one device drains another's prekey pool and reads its inbox. What the
|
||||
* relay wants instead is a short-lived token scoped to one device and one
|
||||
* relay, plus a per-request signature proving the sender holds the identity
|
||||
* key that token names, so a stolen token, on its own, is inert.
|
||||
*
|
||||
* The nonce is not decoration. Without it, two inbox polls in the same
|
||||
* second with the same empty body produce byte-identical signatures, and the
|
||||
* relay's replay cache cannot tell an honest repeat from a captured one. It
|
||||
* then refuses the honest client, and the symptom is an app that stops
|
||||
* receiving mail for no visible reason.
|
||||
*
|
||||
* A deployment with no token endpoint answers 404 once and is then asked no
|
||||
* more; the caller falls back to Basic. Discovered rather than configured, so
|
||||
* one build talks to both.
|
||||
*/
|
||||
class RelayAuth(
|
||||
private val deviceId: String,
|
||||
private val identityKey: Ed25519.KeyPair,
|
||||
private val mint: (deviceId: String, identityPublicB64: String) -> Minted?,
|
||||
) {
|
||||
|
||||
/** A minted token and the absolute second it stops being accepted. */
|
||||
data class Minted(val token: String, val lifetimeSeconds: Int)
|
||||
data class Minted(
|
||||
val token: String,
|
||||
val lifetimeSeconds: Int,
|
||||
)
|
||||
|
||||
private class Cached(val token: String, val expiresAt: Long)
|
||||
private class Cached(
|
||||
val token: String,
|
||||
val expiresAt: Long,
|
||||
)
|
||||
|
||||
@Volatile private var cached: Cached? = null
|
||||
|
||||
/**
|
||||
* Set once the relay answers "no such route".
|
||||
*
|
||||
* Without it, a deployment that will never mint a token is asked on every
|
||||
* single request, a round trip per call, forever, to learn the same thing.
|
||||
*/
|
||||
@Volatile private var unavailable = false
|
||||
|
||||
/**
|
||||
* A valid token, minting one when the cache is empty or close to expiry.
|
||||
* Null when this deployment does not issue them.
|
||||
*/
|
||||
/** Plain Kotlin module: no android.util.Log here. On device this lands in
|
||||
* logcat as `W/System.err`, which is what matters, it is READABLE. */
|
||||
|
||||
/**
|
||||
* Plain Kotlin module: no android.util.Log here.
|
||||
*/
|
||||
private fun warn(message: String) = System.err.println("fnmsg-auth: $message")
|
||||
|
||||
fun token(now: Long = System.currentTimeMillis() / 1000): String? {
|
||||
if (unavailable) return null
|
||||
cached?.let { if (it.expiresAt - SLACK_SECONDS > now) return it.token }
|
||||
val minted = try {
|
||||
mint(deviceId, Wire.b64(identityKey.publicKey))
|
||||
} catch (e: RelayException.NotFound) {
|
||||
// Say it. Every failure here used to be swallowed in
|
||||
// silence, and the consequence was invisible: with no token the
|
||||
// client falls back to Basic, the relay refuses `/e2e/*`, which it
|
||||
// does by design, and the user sees "the call failed", "could not
|
||||
// finish setting up", or an inbox that 410s and wipes itself. Four
|
||||
// hours of debugging produced symptoms and no cause, because the
|
||||
// one line that knew what went wrong discarded it.
|
||||
warn("no token: relay has no /account/token route; " +
|
||||
"falling back to Basic, which /e2e/* refuses. Calls and device " +
|
||||
"registration will fail until this is fixed." + " [" + e + "]")
|
||||
unavailable = true
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
warn("no token: minting failed (${e.javaClass.simpleName}: " +
|
||||
"${e.message}); falling back to Basic, which /e2e/* refuses.")
|
||||
return null
|
||||
} ?: run {
|
||||
// A non-2xx that is not 404 arrives here as null, not as a
|
||||
// throw, so this branch is NOT unreachable, it was the quietest
|
||||
// path of the three.
|
||||
warn("no token: /account/token answered a non-success " +
|
||||
"status; falling back to Basic, which /e2e/* refuses.")
|
||||
return null
|
||||
}
|
||||
val minted =
|
||||
try {
|
||||
mint(deviceId, Wire.b64(identityKey.publicKey))
|
||||
} catch (e: RelayException.NotFound) {
|
||||
// Say it.
|
||||
warn(
|
||||
"no token: relay has no /account/token route; " +
|
||||
"falling back to Basic, which /e2e/* refuses. Calls and device " +
|
||||
"registration will fail until this is fixed." + " [" + e + "]",
|
||||
)
|
||||
unavailable = true
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
warn(
|
||||
"no token: minting failed (${e.javaClass.simpleName}: " +
|
||||
"${e.message}); falling back to Basic, which /e2e/* refuses.",
|
||||
)
|
||||
return null
|
||||
} ?: run {
|
||||
// A non-2xx that is not 404 arrives here as null, not as a throw,
|
||||
// so this branch is NOT unreachable, it was the quietest path of
|
||||
// the three.
|
||||
warn(
|
||||
"no token: /account/token answered a non-success " +
|
||||
"status; falling back to Basic, which /e2e/* refuses.",
|
||||
)
|
||||
return null
|
||||
}
|
||||
cached = Cached(minted.token, now + minted.lifetimeSeconds)
|
||||
return minted.token
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard the cached token, so one stale token costs a single retry.
|
||||
*
|
||||
* Also un-latches [unavailable]. That flag is a told-once marker: it
|
||||
* concludes "this deployment has no token endpoint" from a single 404, but
|
||||
* a reverse proxy answers 404 for *every* route while the relay behind it
|
||||
* is down, so an outage at the wrong moment latched it against a relay that
|
||||
* does issue tokens, and every request thereafter went out with no token at
|
||||
* all. The 401 that brings callers here is direct evidence the conclusion
|
||||
* was wrong: a deployment that didn't want tokens would not refuse us for
|
||||
* lacking one.
|
||||
*/
|
||||
fun invalidate() {
|
||||
cached = null
|
||||
@@ -107,13 +84,6 @@ class RelayAuth(
|
||||
|
||||
/**
|
||||
* The proof headers for one request.
|
||||
*
|
||||
* `path` is the logical §8 path, not the URL's path. The relay mounts
|
||||
* these routes at both `/e2e/…` and under a REST prefix, and signs against
|
||||
* the former, so the proof must be computed over the same string whichever
|
||||
* mount the request is addressed to. Signing the URL path instead produces
|
||||
* a valid signature over the wrong thing, and the relay rejects it with no
|
||||
* hint as to why.
|
||||
*/
|
||||
fun proofHeaders(
|
||||
method: String,
|
||||
@@ -123,8 +93,11 @@ class RelayAuth(
|
||||
): Map<String, String> {
|
||||
val timestamp = now.toString()
|
||||
val nonce = UUID.randomUUID().toString()
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(body)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
val digest =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest(body)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
val canonical = "$method\n$path\n$timestamp\n$nonce\n$digest".toByteArray(Charsets.UTF_8)
|
||||
return mapOf(
|
||||
"X-FN-Timestamp" to timestamp,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,42 +7,19 @@ import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* What a relay says about itself (`GET /.well-known/flamenet/relay`).
|
||||
*
|
||||
* A client must know where accounts live before it sends a credential
|
||||
* anywhere. The two modes stretch the passphrase differently and send the
|
||||
* result to different hosts; guessing wrong means sending an authentication
|
||||
* secret to a service that was never meant to receive it.
|
||||
*
|
||||
* This replaces a hardcoded list of hostnames. The list was safe but wrong: it
|
||||
* meant a self-hosted relay could never use the shared account service,
|
||||
* because it would never be on the list, so the "self-hosted, shared identity"
|
||||
* deployment shape was unreachable in practice.
|
||||
*/
|
||||
object RelayDiscovery {
|
||||
|
||||
/**
|
||||
* Relays whose account service is pinned here and not taken from them.
|
||||
*
|
||||
* These are ours, and what they advertise is ignored: a compromise of
|
||||
* one must not be able to redirect its users' passphrase-derived secrets to
|
||||
* a host the attacker controls.
|
||||
*
|
||||
* Every other relay is believed, and that is deliberate rather than
|
||||
* lax. A self-hoster running Messenger and Files together wants one account
|
||||
* across both, held by their own service, the file server has taken
|
||||
* `ENCLAVE_ACCOUNTS_URL` for exactly that since it shipped, and a client
|
||||
* that refused the relay's answer would make "run both" mean "run them with
|
||||
* two separate logins". The user typed that hostname themselves; a server
|
||||
* they chose naming its own account service is a choice they already made,
|
||||
* and it cannot redirect anybody else's users.
|
||||
*/
|
||||
val PINNED_RELAYS = mapOf(
|
||||
"flamenet.io" to OUR_ACCOUNT_SERVICE,
|
||||
"www.flamenet.io" to OUR_ACCOUNT_SERVICE,
|
||||
"messenger.flamenet.io" to OUR_ACCOUNT_SERVICE,
|
||||
// The same service under a second name, not a second service.
|
||||
"relay.flamenet.io" to OUR_ACCOUNT_SERVICE,
|
||||
)
|
||||
val PINNED_RELAYS =
|
||||
mapOf(
|
||||
"flamenet.io" to OUR_ACCOUNT_SERVICE,
|
||||
"www.flamenet.io" to OUR_ACCOUNT_SERVICE,
|
||||
"messenger.flamenet.io" to OUR_ACCOUNT_SERVICE,
|
||||
// The same service under a second name, not a second service.
|
||||
"relay.flamenet.io" to OUR_ACCOUNT_SERVICE,
|
||||
)
|
||||
|
||||
const val OUR_ACCOUNT_SERVICE = "https://accounts.flamenet.io"
|
||||
|
||||
@@ -59,11 +36,6 @@ object RelayDiscovery {
|
||||
|
||||
/**
|
||||
* The service to actually authenticate against.
|
||||
*
|
||||
* For a relay we pin, ours, whatever it advertised. For anyone
|
||||
* else, what they advertised. That asymmetry is the whole rule: our
|
||||
* relays cannot be made to redirect their users, and a relay somebody
|
||||
* chose to type can name its own service.
|
||||
*/
|
||||
val effectiveAccountsUrl: String?
|
||||
get() = PINNED_RELAYS[audience.lowercase()] ?: accountsUrl
|
||||
@@ -71,33 +43,46 @@ object RelayDiscovery {
|
||||
|
||||
/**
|
||||
* Ask a relay what it is.
|
||||
*
|
||||
* Returns null when the relay does not answer, an older deployment, or
|
||||
* something that is not a relay at all. The caller then falls back to
|
||||
* local, which is the safe default: it sends the passphrase only to the
|
||||
* server the user typed in.
|
||||
*/
|
||||
fun describe(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Descriptor? {
|
||||
val url = baseUrl.trimEnd('/').toHttpUrl().newBuilder()
|
||||
.addPathSegments(".well-known/flamenet/relay").build()
|
||||
fun describe(
|
||||
baseUrl: String,
|
||||
http: OkHttpClient = RelayClient.defaultHttpClient(),
|
||||
): Descriptor? {
|
||||
val url =
|
||||
baseUrl
|
||||
.trimEnd('/')
|
||||
.toHttpUrl()
|
||||
.newBuilder()
|
||||
.addPathSegments(".well-known/flamenet/relay")
|
||||
.build()
|
||||
return try {
|
||||
http.newCall(Request.Builder().url(url).get().build()).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val json = JSONObject(response.body?.string().orEmpty())
|
||||
val versions = json.optJSONArray("protocolVersions")
|
||||
Descriptor(
|
||||
audience = json.optString("audience"),
|
||||
accounts = if (json.optString("accounts") == "shared") {
|
||||
Accounts.SHARED
|
||||
} else {
|
||||
Accounts.LOCAL
|
||||
},
|
||||
accountsUrl = json.optString("accountsURL").takeIf { it.isNotEmpty() },
|
||||
federation = json.optString("federation", "closed"),
|
||||
protocolVersions = (0 until (versions?.length() ?: 0))
|
||||
.map { versions!!.getInt(it) },
|
||||
)
|
||||
}
|
||||
http
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val json = JSONObject(response.body?.string().orEmpty())
|
||||
val versions = json.optJSONArray("protocolVersions")
|
||||
Descriptor(
|
||||
audience = json.optString("audience"),
|
||||
accounts =
|
||||
if (json.optString("accounts") == "shared") {
|
||||
Accounts.SHARED
|
||||
} else {
|
||||
Accounts.LOCAL
|
||||
},
|
||||
accountsUrl = json.optString("accountsURL").takeIf { it.isNotEmpty() },
|
||||
federation = json.optString("federation", "closed"),
|
||||
protocolVersions =
|
||||
(0 until (versions?.length() ?: 0))
|
||||
.map { versions!!.getInt(it) },
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -2,126 +2,116 @@ package io.flamenet.client
|
||||
|
||||
/**
|
||||
* What can go wrong talking to a relay.
|
||||
*
|
||||
* Typed rather than a single message-carrying exception because two of these
|
||||
* are decisions, not diagnostics: [NotFound] tells [RelayAuth] this deployment
|
||||
* mints no tokens, and [Unauthorized] triggers exactly one retry. A caller that
|
||||
* has to match on a string to make those decisions eventually matches wrong.
|
||||
*/
|
||||
sealed class RelayException(message: String, cause: Throwable? = null) : Exception(message, cause) {
|
||||
|
||||
sealed class RelayException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
) : Exception(message, cause) {
|
||||
/** The network, TLS, or a response that was not HTTP at all. */
|
||||
class Transport(message: String, cause: Throwable? = null) : RelayException(message, cause)
|
||||
class Transport(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
) : RelayException(message, cause)
|
||||
|
||||
/** 401. The caller discards its token and tries once more. */
|
||||
class Unauthorized(val body: String) : RelayException("relay refused the credential")
|
||||
class Unauthorized(
|
||||
val body: String,
|
||||
) : RelayException("relay refused the credential")
|
||||
|
||||
/**
|
||||
* 404. On the token route this means "this deployment has no token
|
||||
* endpoint", which is a supported configuration and not an error.
|
||||
* 404.
|
||||
*/
|
||||
class NotFound(val path: String) : RelayException("no such route: $path")
|
||||
class NotFound(
|
||||
val path: String,
|
||||
) : RelayException("no such route: $path")
|
||||
|
||||
/** 429, with the relay's own retry hint when it gave one. */
|
||||
class RateLimited(val retryAfterSeconds: Int?) :
|
||||
RelayException("rate limited" + (retryAfterSeconds?.let { ", retry in ${it}s" } ?: ""))
|
||||
class RateLimited(
|
||||
val retryAfterSeconds: Int?,
|
||||
) : RelayException("rate limited" + (retryAfterSeconds?.let { ", retry in ${it}s" } ?: ""))
|
||||
|
||||
/**
|
||||
* 451, this service is not offered in the caller's country.
|
||||
*
|
||||
* Its own type because the generic one reads as a broken app. The
|
||||
* edge geo-gate answers 451 before the request reaches the relay, and the
|
||||
* download site is deliberately NOT gated, so somebody can read the page,
|
||||
* install the app, choose a password, tap sign up, and be shown "relay
|
||||
* returned 451". Nothing in that mentions their country, and it is not a
|
||||
* failure they can retry past, so the only sensible reading is that the app
|
||||
* does not work.
|
||||
*/
|
||||
class Unavailable(val body: String) : RelayException(
|
||||
"Flamenet isn't available in your country yet. " +
|
||||
"This isn't a problem with your account or your connection.")
|
||||
class Unavailable(
|
||||
val body: String,
|
||||
) : RelayException(
|
||||
"Flamenet isn't available in your country yet. " +
|
||||
"This isn't a problem with your account or your connection.",
|
||||
)
|
||||
|
||||
/**
|
||||
* This relay charges a few seconds of arithmetic before a new account, and
|
||||
* this app is too old to pay it.
|
||||
*
|
||||
* The remedy is an update, and it is the only refusal in sign-up where
|
||||
* that is true, which is exactly why it cannot share a sentence with the
|
||||
* one below. Telling somebody to try again when the app will never be able
|
||||
* to answer sends them round a loop that has no exit.
|
||||
*/
|
||||
class ChallengeRequired(val body: String) : RelayException(
|
||||
"This server asks new accounts to do a quick check first, and this " +
|
||||
"version of the app can't do it yet. Update the app and try again.")
|
||||
class ChallengeRequired(
|
||||
val body: String,
|
||||
) : RelayException(
|
||||
"This server asks new accounts to do a quick check first, and this " +
|
||||
"version of the app can't do it yet. Update the app and try again.",
|
||||
)
|
||||
|
||||
/**
|
||||
* The check was answered and the answer did not hold up.
|
||||
*
|
||||
* Nearly always a challenge that expired while somebody was typing.
|
||||
* The relay deliberately gives one message for all four of its refusals,
|
||||
* forged, stale, unsolved, malformed, because telling an attacker which
|
||||
* knob to turn is worse than being vague with everybody else, so this side
|
||||
* cannot be more specific than "start a fresh one" either.
|
||||
*/
|
||||
class ChallengeFailed(val body: String) : RelayException(
|
||||
"That check took too long. Tap Create account again and it will " +
|
||||
"start a fresh one.")
|
||||
class ChallengeFailed(
|
||||
val body: String,
|
||||
) : RelayException(
|
||||
"That check took too long. Tap Create account again and it will " +
|
||||
"start a fresh one.",
|
||||
)
|
||||
|
||||
/** Any other non-2xx. The body is kept because relays put the reason in it. */
|
||||
|
||||
/**
|
||||
* The account exists and the credential is right; the address has not been
|
||||
* proved reachable yet.
|
||||
*
|
||||
* Its own type because everything else about it looks like a
|
||||
* credential failure. Without this it arrives as a bare 403 and
|
||||
* [RelayAuth] logs "minting failed" before falling back to Basic, which
|
||||
* the e2e routes refuse, so the person sees calls not working and an inbox
|
||||
* that does nothing, and nothing anywhere says the remedy is a code sitting
|
||||
* in their email.
|
||||
*
|
||||
* Do not write the e2e route glob in a KDoc here: Kotlin NESTS block
|
||||
* comments, so a slash-star inside one opens a comment that never closes
|
||||
* and takes the whole module down with ninety unrelated errors.
|
||||
*/
|
||||
class EmailUnverified(val body: String) : RelayException(
|
||||
"this account's email address has not been confirmed yet")
|
||||
class EmailUnverified(
|
||||
val body: String,
|
||||
) : RelayException("this account's email address has not been confirmed yet")
|
||||
|
||||
class Http(val status: Int, val body: String) : RelayException("relay returned $status: $body")
|
||||
class Http(
|
||||
val status: Int,
|
||||
val body: String,
|
||||
) : RelayException("relay returned $status: $body")
|
||||
|
||||
/** A 2xx whose body was not what the route promised. */
|
||||
class Malformed(message: String) : RelayException(message)
|
||||
class Malformed(
|
||||
message: String,
|
||||
) : RelayException(message)
|
||||
|
||||
/**
|
||||
* Sealing was required and this send could not be sealed (§14.5, ios#16).
|
||||
*
|
||||
* Its own type rather than a generic failure. The user turned this on
|
||||
* deliberately and the message did not go for a reason they chose, a bare
|
||||
* "couldn't send" reads as the app being broken, and the next thing
|
||||
* somebody does is switch the setting off without knowing that is what
|
||||
* they are doing.
|
||||
*/
|
||||
class SealingRequired : RelayException(
|
||||
"Not sent. This message could not be sealed, so the server would have seen " +
|
||||
"that you sent it, and you asked Flamenet to refuse rather than send " +
|
||||
"anyway. It will seal again once they message you.")
|
||||
class SealingRequired :
|
||||
RelayException(
|
||||
"Not sent. This message could not be sealed, so the server would have seen " +
|
||||
"that you sent it, and you asked Flamenet to refuse rather than send " +
|
||||
"anyway. It will seal again once they message you.",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 for this module.
|
||||
*
|
||||
* The engine keeps its own helper `internal` on purpose, its public surface is
|
||||
* the protocol, not its utilities, and widening that so the transport can
|
||||
* reach in would be the tail wagging the dog. Base64 has one right answer, so a
|
||||
* second call site cannot drift.
|
||||
*/
|
||||
internal object Wire {
|
||||
fun b64(data: ByteArray): String = java.util.Base64.getEncoder().encodeToString(data)
|
||||
fun unb64(text: String): ByteArray = java.util.Base64.getDecoder().decode(text)
|
||||
fun b64(data: ByteArray): String =
|
||||
java.util.Base64
|
||||
.getEncoder()
|
||||
.encodeToString(data)
|
||||
|
||||
/// The engine keeps its own `Bytes.sha256` `internal`, for the reason
|
||||
/// above: its public surface is the protocol, not its utilities. §21.4
|
||||
/// needs one here to hash a capability before uploading it, and SHA-256 has
|
||||
/// one right answer, so a second call site cannot drift.
|
||||
fun unb64(text: String): ByteArray =
|
||||
java.util.Base64
|
||||
.getDecoder()
|
||||
.decode(text)
|
||||
|
||||
// / The engine keeps its own `Bytes.sha256` `internal`, for the reason
|
||||
// / above: its public surface is the protocol, not its utilities. §21.4
|
||||
// / needs one here to hash a capability before uploading it, and SHA-256 has
|
||||
// / one right answer, so a second call site cannot drift.
|
||||
fun sha256(data: ByteArray): ByteArray =
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(data)
|
||||
java.security.MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest(data)
|
||||
}
|
||||
|
||||
@@ -2,48 +2,16 @@ package io.flamenet.client
|
||||
|
||||
/**
|
||||
* Which relay this is, beyond which certificate it presented (ios#19).
|
||||
*
|
||||
* TLS answers "is this host who DNS says it is". It does not answer "is
|
||||
* this the same relay I have been talking to". A mis-issued certificate, a
|
||||
* hijacked DNS record, or a hostile party who obtains the domain all produce a
|
||||
* server with a perfectly valid certificate for the right name, and every one
|
||||
* of them is a different relay wearing the same address.
|
||||
*
|
||||
* The relay publishes an issuer public key in its descriptor. Pinning that on
|
||||
* first contact turns "the certificate is valid" into "this is the same relay",
|
||||
* which is a materially stronger statement and the one that matters.
|
||||
*
|
||||
* Android already pinned this key and drew the wrong conclusion from it.
|
||||
* `Trust.verifyHeadSignature` pins `issuerPublicKey` the first time a signed log
|
||||
* head arrives, and if the key later changes the signature simply fails to
|
||||
* verify, so the client raises `KeyLogForkException`, sets `forkDetected`, and
|
||||
* tells somebody the relay's key log does not add up.
|
||||
*
|
||||
* A rotated relay key is not a forked log. They need different responses: a
|
||||
* fork means the relay is showing two different histories and nothing it says
|
||||
* can be trusted; a rotation means somebody ran a command, or somebody else is
|
||||
* answering on that address. Reporting the second as the first is both alarming
|
||||
* and unactionable, and an alarm people cannot act on is one they learn to
|
||||
* dismiss, which costs the real fork detection its credibility too.
|
||||
*
|
||||
* A relay that publishes no key is a weaker deployment, not an attack.
|
||||
* Somebody self-hosting an older build has nothing to pin, and refusing to talk
|
||||
* to them would be a worse failure than the one this prevents. That case is
|
||||
* distinguished from a key that CHANGED, which is not weakness.
|
||||
*
|
||||
* A change is surfaced, never silently accepted and never silently refused.
|
||||
* The same shape as a contact's identity changing: stop, say so, and let a
|
||||
* person decide, because a legitimate rotation exists and an app that cannot
|
||||
* survive one is an app that gets uninstalled.
|
||||
*/
|
||||
object RelayIdentityPin {
|
||||
|
||||
/** What a descriptor's key means against what was pinned before. */
|
||||
sealed interface Verdict {
|
||||
/** Nothing pinned and a key published: pin it. */
|
||||
data class FirstSight(val offered: ByteArray) : Verdict {
|
||||
override fun equals(other: Any?) =
|
||||
other is FirstSight && offered.contentEquals(other.offered)
|
||||
data class FirstSight(
|
||||
val offered: ByteArray,
|
||||
) : Verdict {
|
||||
override fun equals(other: Any?) = other is FirstSight && offered.contentEquals(other.offered)
|
||||
|
||||
override fun hashCode() = offered.contentHashCode()
|
||||
}
|
||||
|
||||
@@ -54,41 +22,43 @@ object RelayIdentityPin {
|
||||
data object NotPublished : Verdict
|
||||
|
||||
/**
|
||||
* A different key than the one pinned. Either a deliberate rotation
|
||||
* or a different server answering on this address, and nothing here can
|
||||
* tell those apart, only a person can.
|
||||
* A different key than the one pinned.
|
||||
*/
|
||||
data class Changed(val pinned: ByteArray, val offered: ByteArray) : Verdict {
|
||||
override fun equals(other: Any?) = other is Changed &&
|
||||
pinned.contentEquals(other.pinned) && offered.contentEquals(other.offered)
|
||||
data class Changed(
|
||||
val pinned: ByteArray,
|
||||
val offered: ByteArray,
|
||||
) : Verdict {
|
||||
override fun equals(other: Any?) =
|
||||
other is Changed &&
|
||||
pinned.contentEquals(other.pinned) && offered.contentEquals(other.offered)
|
||||
|
||||
override fun hashCode() = 31 * pinned.contentHashCode() + offered.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare what the relay just published against what is pinned.
|
||||
*
|
||||
* An empty or unparseable key counts as "not published" rather than as a
|
||||
* change. A relay that answers with rubbish is broken, and treating broken
|
||||
* as hostile would raise an alarm nobody can act on.
|
||||
*/
|
||||
fun check(offeredBase64: String?, pinned: ByteArray?): Verdict {
|
||||
val offered = offeredBase64
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { runCatching { Wire.unb64(it) }.getOrNull() }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: return Verdict.NotPublished
|
||||
fun check(
|
||||
offeredBase64: String?,
|
||||
pinned: ByteArray?,
|
||||
): Verdict {
|
||||
val offered =
|
||||
offeredBase64
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { runCatching { Wire.unb64(it) }.getOrNull() }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: return Verdict.NotPublished
|
||||
if (pinned == null || pinned.isEmpty()) return Verdict.FirstSight(offered)
|
||||
return if (offered.contentEquals(pinned)) Verdict.Unchanged
|
||||
else Verdict.Changed(pinned = pinned, offered = offered)
|
||||
return if (offered.contentEquals(pinned)) {
|
||||
Verdict.Unchanged
|
||||
} else {
|
||||
Verdict.Changed(pinned = pinned, offered = offered)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to tell somebody when it changed.
|
||||
*
|
||||
* Names the two possibilities and does not pretend to know which. An
|
||||
* alarm that asserts an attack when a rotation is equally likely trains
|
||||
* people to dismiss alarms.
|
||||
*/
|
||||
fun warning(host: String): String =
|
||||
"The server at $host is presenting a different identity than the one " +
|
||||
@@ -101,12 +71,7 @@ object RelayIdentityPin {
|
||||
|
||||
/**
|
||||
* The relay is not the one this app pinned.
|
||||
*
|
||||
* Deliberately NOT a [KeyLogForkException]. A fork means the relay is
|
||||
* showing two different histories and nothing it says can be trusted. This means
|
||||
* the server's identity key is not the one recorded, a rotation somebody
|
||||
* performed, or somebody else's server. The remedies are different, so the
|
||||
* exceptions are different.
|
||||
*/
|
||||
class RelayIdentityChangedException(val host: String) :
|
||||
RelayException(RelayIdentityPin.warning(host))
|
||||
class RelayIdentityChangedException(
|
||||
val host: String,
|
||||
) : RelayException(RelayIdentityPin.warning(host))
|
||||
|
||||
@@ -8,61 +8,54 @@ import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* A few seconds of this phone's arithmetic, spent before an account exists.
|
||||
*
|
||||
* Why this and not a CAPTCHA. A CAPTCHA hands a third party every
|
||||
* visitor's connection, which is a strange thing to do in a product whose
|
||||
* argument is that it collects almost nothing. This collects nothing at all:
|
||||
* no personal data, no third party, no picture of a crosswalk. It costs one
|
||||
* person a couple of seconds and costs a farm of registrations real money.
|
||||
*
|
||||
* Every branch here has to end in "sign up anyway". A relay that
|
||||
* predates the feature, a request that times out, a proxy that returns HTML,
|
||||
* none of those are reasons somebody cannot create an account. The relay
|
||||
* decides whether a challenge is *required*; this side only tries to satisfy
|
||||
* one when it is offered.
|
||||
*/
|
||||
object SignupProofOfWork {
|
||||
|
||||
/**
|
||||
* What the relay hands out. `bits == 0` means it wants no work, the relay
|
||||
* answers that rather than 404ing, so asking is always safe and the answer
|
||||
* never reveals how a deployment is configured.
|
||||
* What the relay hands out.
|
||||
*/
|
||||
data class Challenge(val challenge: String, val bits: Int, val expiresIn: Int)
|
||||
data class Challenge(
|
||||
val challenge: String,
|
||||
val bits: Int,
|
||||
val expiresIn: Int,
|
||||
)
|
||||
|
||||
/** A solved challenge, ready to travel beside the sign-up fields. */
|
||||
data class Solution(val challenge: String, val nonce: String)
|
||||
data class Solution(
|
||||
val challenge: String,
|
||||
val nonce: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Refuse to grind past this even if the relay asks. The relay caps
|
||||
* itself at 24 bits, but a client that trusts a number from the network
|
||||
* with an unbounded loop is one misconfiguration away from an app that
|
||||
* appears to hang on the sign-up button, which reads as broken, not as
|
||||
* spam protection. Above the cap we send nothing and let the relay refuse,
|
||||
* because a refusal with a sentence beats a spinner that never ends.
|
||||
* Refuse to grind past this even if the relay asks.
|
||||
*/
|
||||
const val MAXIMUM_BITS = 24
|
||||
|
||||
/**
|
||||
* Hashes to try before giving up. At 24 bits the expected count is ~16.7M,
|
||||
* so this is roughly a 4x margin, generous enough that an unlucky run
|
||||
* still finishes, bounded enough that a wrong difficulty cannot run for
|
||||
* ever.
|
||||
* Hashes to try before giving up.
|
||||
*/
|
||||
const val CEILING = 64_000_000
|
||||
|
||||
/**
|
||||
* Ask what work this server wants, if any.
|
||||
*
|
||||
* Returns null for every failure, deliberately. 404 is the common one,
|
||||
* an older relay, or a site that has no such route, and it is
|
||||
* indistinguishable from success-with-no-work as far as what the caller
|
||||
* should do next.
|
||||
*/
|
||||
fun fetch(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Challenge? {
|
||||
val url = baseUrl.trimEnd('/').toHttpUrl().newBuilder()
|
||||
.addPathSegments("account/challenge").build()
|
||||
val request = Request.Builder().url(url).get().header("Accept", "application/json").build()
|
||||
fun fetch(
|
||||
baseUrl: String,
|
||||
http: OkHttpClient = RelayClient.defaultHttpClient(),
|
||||
): Challenge? {
|
||||
val url =
|
||||
baseUrl
|
||||
.trimEnd('/')
|
||||
.toHttpUrl()
|
||||
.newBuilder()
|
||||
.addPathSegments("account/challenge")
|
||||
.build()
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
return try {
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
@@ -74,8 +67,8 @@ object SignupProofOfWork {
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Transport, a 404 page that is not JSON, a missing field, all
|
||||
// mean the same thing here, and it is not "no account for you".
|
||||
// Transport, a 404 page that is not JSON, a missing field, all mean
|
||||
// the same thing here, and it is not "no account for you".
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -88,15 +81,16 @@ object SignupProofOfWork {
|
||||
}
|
||||
|
||||
/**
|
||||
* The search itself. Separated so tests can drive it at a low difficulty
|
||||
* without going through a [Challenge] that claims an expiry.
|
||||
* The search itself.
|
||||
*/
|
||||
fun nonce(challenge: String, bits: Int, ceiling: Int = CEILING): String? {
|
||||
fun nonce(
|
||||
challenge: String,
|
||||
bits: Int,
|
||||
ceiling: Int = CEILING,
|
||||
): String? {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
// Hashed as `challenge.nonce`, which is exactly what the relay
|
||||
// verifies. The prefix never changes, so it is encoded once rather than
|
||||
// per attempt, at tens of millions of iterations that is not a
|
||||
// micro-optimisation, it is most of the running time.
|
||||
// verifies.
|
||||
val prefix = "$challenge.".toByteArray(Charsets.UTF_8)
|
||||
for (n in 0 until ceiling) {
|
||||
digest.reset()
|
||||
@@ -108,16 +102,16 @@ object SignupProofOfWork {
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted in BITS, not leading zero bytes. Bytes move in steps of 256x,
|
||||
* which leaves no way to ask for two seconds rather than half a second or
|
||||
* two minutes. This must agree with the relay exactly or every solution
|
||||
* this phone produces is rejected by a server that is working perfectly.
|
||||
* Counted in BITS, not leading zero bytes.
|
||||
*/
|
||||
fun leadingZeroBits(digest: ByteArray): Int {
|
||||
var count = 0
|
||||
for (byte in digest) {
|
||||
val value = byte.toInt() and 0xFF
|
||||
if (value == 0) { count += 8; continue }
|
||||
if (value == 0) {
|
||||
count += 8
|
||||
continue
|
||||
}
|
||||
count += Integer.numberOfLeadingZeros(value) - 24
|
||||
break
|
||||
}
|
||||
@@ -126,15 +120,9 @@ object SignupProofOfWork {
|
||||
|
||||
/**
|
||||
* Fetch and solve, for the caller that just wants fields to send.
|
||||
*
|
||||
* Returns null when this server asks for no work, which is the normal case
|
||||
* today and stays the normal case for anyone self-hosting who never turns
|
||||
* it on.
|
||||
*
|
||||
* Blocking and CPU-bound, like every other call in this module. The
|
||||
* caller is already off the main thread; if it were not, sign-up would
|
||||
* already be an ANR without any of this.
|
||||
*/
|
||||
fun satisfy(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Solution? =
|
||||
fetch(baseUrl, http)?.let { solve(it) }
|
||||
fun satisfy(
|
||||
baseUrl: String,
|
||||
http: OkHttpClient = RelayClient.defaultHttpClient(),
|
||||
): Solution? = fetch(baseUrl, http)?.let { solve(it) }
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@ import io.flamenet.e2e.Vault
|
||||
|
||||
/**
|
||||
* What we believe about someone's identity keys.
|
||||
*
|
||||
* Ordered by how much a person has actually done, not by severity: [CHANGED]
|
||||
* is last because it is the only one that needs them.
|
||||
*/
|
||||
enum class IdentityTrust {
|
||||
/** Never seen. Nothing to compare against, so nothing can be said. */
|
||||
@@ -24,145 +21,98 @@ enum class IdentityTrust {
|
||||
|
||||
/**
|
||||
* A key we have never seen is being offered for someone we have.
|
||||
*
|
||||
* This is not "they got a new phone" and it is not "you are being
|
||||
* attacked". It is exactly the state where those two are
|
||||
* indistinguishable to software, which is why it stops and asks a human
|
||||
* rather than picking one.
|
||||
*/
|
||||
CHANGED,
|
||||
}
|
||||
|
||||
/** The relay showed two different logs, or a log that shrank. */
|
||||
|
||||
/** Turn a key-log entry into a sentence. S4's "name what changed". Pure. */
|
||||
object KeyChangeDescription {
|
||||
fun describe(kind: String, atIso: String?): String {
|
||||
val what = when (kind) {
|
||||
"publish" -> "A new device key was added"
|
||||
"expire" -> "A device key expired"
|
||||
"revoke" -> "A device key was revoked"
|
||||
else -> "Their key set changed"
|
||||
}
|
||||
val date = atIso?.let {
|
||||
try {
|
||||
val inst = java.time.Instant.parse(it)
|
||||
java.time.format.DateTimeFormatter
|
||||
.ofLocalizedDateTime(java.time.format.FormatStyle.MEDIUM)
|
||||
.withZone(java.time.ZoneId.systemDefault())
|
||||
.format(inst)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
fun describe(
|
||||
kind: String,
|
||||
atIso: String?,
|
||||
): String {
|
||||
val what =
|
||||
when (kind) {
|
||||
"publish" -> "A new device key was added"
|
||||
"expire" -> "A device key expired"
|
||||
"revoke" -> "A device key was revoked"
|
||||
else -> "Their key set changed"
|
||||
}
|
||||
val date =
|
||||
atIso?.let {
|
||||
try {
|
||||
val inst = java.time.Instant.parse(it)
|
||||
java.time.format.DateTimeFormatter
|
||||
.ofLocalizedDateTime(java.time.format.FormatStyle.MEDIUM)
|
||||
.withZone(java.time.ZoneId.systemDefault())
|
||||
.format(inst)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
return if (date != null) "$what on $date." else "$what."
|
||||
}
|
||||
}
|
||||
|
||||
class KeyLogForkException(detail: String) :
|
||||
RelayException("the relay's key log does not add up: $detail")
|
||||
class KeyLogForkException(
|
||||
detail: String,
|
||||
) : RelayException("the relay's key log does not add up: $detail")
|
||||
|
||||
/**
|
||||
* A served identity key could not be found in the log the relay publishes.
|
||||
*
|
||||
* Two very different situations, and the difference matters enough to
|
||||
* carry. [hadEntries] is false when the log holds nothing at all for this
|
||||
* device, the key was never published, which is what a substitution looks
|
||||
* like. It is true when the device HAS publications but none of them hash to
|
||||
* the leaf this bundle implies under the subject we were able to compute.
|
||||
*
|
||||
* The second case is usually not an attack. The subject is inside the hashed
|
||||
* bytes, and an account that has claimed an fnid is logged under it rather than
|
||||
* under `local:<audience>/<user_id>`, so a verifier that does not know the
|
||||
* fnid cannot recompute the leaf even though the key was published perfectly
|
||||
* well. Both refuse, because refusing is the safe direction, but reporting them
|
||||
* identically means an honest conversation and an interception produce the same
|
||||
* sentence.
|
||||
*/
|
||||
class KeyNotLoggedException(val deviceId: String, val hadEntries: Boolean = false) :
|
||||
RelayException(
|
||||
class KeyNotLoggedException(
|
||||
val deviceId: String,
|
||||
val hadEntries: Boolean = false,
|
||||
) : RelayException(
|
||||
if (hadEntries) {
|
||||
"device $deviceId has published keys, but none match what this relay served, " +
|
||||
"if this account has a global identity, its fnid is needed to check it"
|
||||
} else {
|
||||
"device $deviceId offered a key the relay never logged"
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Someone's identity changed and the change has not been accepted. */
|
||||
class IdentityChangedException(val userId: Long) :
|
||||
RelayException("identity keys changed for user $userId")
|
||||
class IdentityChangedException(
|
||||
val userId: Long,
|
||||
) : RelayException("identity keys changed for user $userId")
|
||||
|
||||
/**
|
||||
* Identity pinning and key-transparency enforcement (spec §19).
|
||||
*
|
||||
* This is the part that makes the transparency log worth having. A log
|
||||
* nothing checks is a log that faithfully records the substitution and is
|
||||
* never read; the relay hands out a key it minted, the conversation encrypts
|
||||
* to it, and every byte on the wire is correctly encrypted to the wrong
|
||||
* person. Two independent checks are needed and neither is sufficient alone:
|
||||
*
|
||||
* - Pinning catches a key changing under a person you already talk to,
|
||||
* but says nothing the first time you meet them.
|
||||
* - The log says a key was published where the world can see it, but a
|
||||
* relay that rewrites history reissues valid proofs against the new tree,
|
||||
* so inclusion is only meaningful against a head this device already
|
||||
* pinned and can demand a consistency proof for.
|
||||
*
|
||||
* Automatic wherever the relay can answer. This used to be behind
|
||||
* [requireKeyLog], off by default, so the protection shipped and almost
|
||||
* nobody had it. The argument for opt-in (a relay predating the log has none,
|
||||
* and refusing every peer there is worse than the attack) was an argument for
|
||||
* skipping relays that *cannot* answer, never for skipping the ones that can.
|
||||
* [enforceKeyLogIfServed] draws exactly that line. Pinning is always on, it
|
||||
* costs nothing and needs no cooperation from the relay.
|
||||
*/
|
||||
class Trust(
|
||||
private val identity: DeviceIdentity,
|
||||
private val vault: Vault,
|
||||
private val relay: RelayClient,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Refuse a peer even when the relay keeps no log at all.
|
||||
*
|
||||
* Not the switch that turns checking on, checking is automatic, see
|
||||
* [enforceKeyLogIfServed]. This is the narrower question underneath it:
|
||||
* what to do about a relay that publishes no log, where there is nothing to
|
||||
* check a key against.
|
||||
*
|
||||
* Off by default because the failure it prevents is rarer than the one it
|
||||
* causes. A relay predating §19 answers nothing, so turning this on there
|
||||
* makes every contact unreachable, and somebody locked out of their
|
||||
* messages moves to something with no ratchet at all.
|
||||
*/
|
||||
var requireKeyLog: Boolean = false
|
||||
|
||||
/**
|
||||
* Set when gossip proved the relay showed two different logs (§19.4).
|
||||
*
|
||||
* A standing condition rather than a thrown error: a fork is discovered
|
||||
* while processing inbound traffic, where there is nothing to fail, and it
|
||||
* stays true once discovered. Nothing here clears it, a relay that forked
|
||||
* once does not become trustworthy because the next request looked fine.
|
||||
*/
|
||||
var forkDetected: Boolean = false
|
||||
private set
|
||||
|
||||
// -- Pinning -------------------------------------------------------------
|
||||
// -- Pinning -------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Compare what the directory just offered against what we pinned.
|
||||
*
|
||||
* Trust on first use: with nothing pinned, the offered keys are taken and
|
||||
* recorded. That is the weakest link in the whole scheme and it is a
|
||||
* deliberate one, the alternative is a client that cannot start a
|
||||
* conversation without an out-of-band channel it does not have.
|
||||
*/
|
||||
fun record(userId: Long, offered: List<ByteArray>, fnid: String? = null): IdentityTrust {
|
||||
fun record(
|
||||
userId: Long,
|
||||
offered: List<ByteArray>,
|
||||
fnid: String? = null,
|
||||
): IdentityTrust {
|
||||
val encoded = offered.map(Wire::b64)
|
||||
val existing = vault.loadTrust(userId)
|
||||
|
||||
// A different address for someone we already know is not a new device.
|
||||
// The fnid is derived from the account key, so a new one means a new
|
||||
// account key, a different person, however the relay presents it.
|
||||
if (fnid != null && existing?.fnid != null && existing.fnid != fnid) {
|
||||
markChanged(userId, existing)
|
||||
return IdentityTrust.CHANGED
|
||||
@@ -176,19 +126,9 @@ class Trust(
|
||||
)
|
||||
return IdentityTrust.PINNED
|
||||
}
|
||||
// An empty offer is not agreement (audit LOW). `all {}` on an
|
||||
// empty list is vacuously true, so a directory answering with NO keys
|
||||
// for someone already pinned read as "unchanged", the strongest
|
||||
// verdict, when it had said nothing at all. It is not evidence of a
|
||||
// swap either, so it reports UNKNOWN: nothing to compare, nothing to
|
||||
// send to. The first-contact branch above already guards this case;
|
||||
// this is the same rule once keys exist.
|
||||
// An empty offer is not agreement (audit LOW).
|
||||
if (encoded.isEmpty()) return IdentityTrust.UNKNOWN
|
||||
// EVERY offered key must already be pinned, not "any". A relay
|
||||
// adding one key of its own alongside the real ones would otherwise
|
||||
// pass, and that is precisely the attack: the victim's real devices
|
||||
// still work, so nothing looks wrong, while a device the attacker
|
||||
// controls receives a copy of everything.
|
||||
// EVERY offered key must already be pinned, not "any".
|
||||
if (!encoded.all { existing.identities.contains(it) }) {
|
||||
markChanged(userId, existing)
|
||||
return IdentityTrust.CHANGED
|
||||
@@ -203,38 +143,21 @@ class Trust(
|
||||
|
||||
/**
|
||||
* Learn this contact's global address and pin it.
|
||||
*
|
||||
* Pinned on first sight and never quietly replaced. The address is
|
||||
* what the key log files their publications under, so it decides which leaf
|
||||
* a served key has to hash to. Re-reading it from the relay on every check
|
||||
* would let a relay that wants to substitute a key simply also change the
|
||||
* name it files it under, and every verification would agree.
|
||||
*
|
||||
* A DIFFERENT address later is therefore treated like a changed identity
|
||||
* key, and for a stronger reason than one: an fnid is derived from the
|
||||
* account key, so a new address is not a new device, it is a different
|
||||
* account key, which is to say a different person.
|
||||
*
|
||||
* First contact is still trust-on-first-use. Taking the relay's word
|
||||
* once is not a check; what it buys is that every later answer has to match
|
||||
* it. The safety number is what covers the first sighting, and nothing here
|
||||
* replaces reading it aloud.
|
||||
*
|
||||
* Cheap and idempotent: does nothing once an address is pinned. Safe to
|
||||
* call whenever the contact list is refreshed.
|
||||
*/
|
||||
fun rememberIdentity(userId: Long, handle: String): IdentityTrust {
|
||||
fun rememberIdentity(
|
||||
userId: Long,
|
||||
handle: String,
|
||||
): IdentityTrust {
|
||||
val existing = vault.loadTrust(userId)
|
||||
if (existing?.fnid != null) return state(userId)
|
||||
|
||||
val resolved = try {
|
||||
relay.resolveIdentity(handle)
|
||||
} catch (_: Exception) {
|
||||
// Unreachable is not "has no identity". Pinning null on a failed
|
||||
// lookup would bake a wrong answer in permanently, and the next
|
||||
// successful lookup would then read as an identity change.
|
||||
return state(userId)
|
||||
} ?: return state(userId)
|
||||
val resolved =
|
||||
try {
|
||||
relay.resolveIdentity(handle)
|
||||
} catch (_: Exception) {
|
||||
// Unreachable is not "has no identity".
|
||||
return state(userId)
|
||||
} ?: return state(userId)
|
||||
|
||||
vault.saveTrust(
|
||||
userId,
|
||||
@@ -249,22 +172,14 @@ class Trust(
|
||||
|
||||
/**
|
||||
* Record the address the relay served for this contact.
|
||||
*
|
||||
* Not a trust decision, it changes nothing about which identity keys
|
||||
* are accepted. It only records the SUBJECT their key-log leaves are filed
|
||||
* under, which a verifier needs and the device directory does not serve.
|
||||
* Storing it means a later send that establishes no new session can still
|
||||
* check the log.
|
||||
*/
|
||||
fun rememberFnid(userId: Long, fnid: String) {
|
||||
fun rememberFnid(
|
||||
userId: Long,
|
||||
fnid: String,
|
||||
) {
|
||||
val existing = vault.loadTrust(userId) ?: return
|
||||
if (existing.fnid == fnid) return
|
||||
// The paragraph above was documentation, not behaviour. This
|
||||
// overwrote a DIFFERENT address in place, the one thing it says it must
|
||||
// never do, and it went unnoticed while only the bundle route carried
|
||||
// an address, because that route is reached once per conversation. The
|
||||
// directory carries it on every send, so a silent replacement here
|
||||
// would erase the change the pin exists to notice.
|
||||
// The paragraph above was documentation, not behaviour.
|
||||
if (existing.fnid != null) {
|
||||
markChanged(userId, existing)
|
||||
return
|
||||
@@ -275,12 +190,7 @@ class Trust(
|
||||
/** Trust as it stands, for UI that has not triggered a fetch. */
|
||||
fun state(userId: Long): IdentityTrust {
|
||||
val t = vault.loadTrust(userId) ?: return IdentityTrust.UNKNOWN
|
||||
// CHANGED first, and it is persisted rather than recomputed. This
|
||||
// function used to be unable to return it at all: CHANGED was worked
|
||||
// out inside `record()` during a send and then dropped, so the header
|
||||
// said "keys unchanged since you met" while every send was refused for
|
||||
// exactly that reason, and the Accept button, which only shows for
|
||||
// CHANGED, could never appear.
|
||||
// CHANGED first, and it is persisted rather than recomputed.
|
||||
if (t.changed) return IdentityTrust.CHANGED
|
||||
if (t.identities.isEmpty()) return IdentityTrust.UNKNOWN
|
||||
return if (t.verified) IdentityTrust.VERIFIED else IdentityTrust.PINNED
|
||||
@@ -288,12 +198,6 @@ class Trust(
|
||||
|
||||
/**
|
||||
* The number both people read to each other.
|
||||
*
|
||||
* Covers the FIRST pinned key only. Someone with several devices has
|
||||
* several identity keys and one number cannot represent all of them,
|
||||
* per-device numbers are the honest fix and are not done here. Saying so
|
||||
* matters: a number that silently describes one of three devices invites
|
||||
* people to believe they checked more than they did.
|
||||
*/
|
||||
fun safetyNumber(userId: Long): String? {
|
||||
val theirs = vault.loadTrust(userId)?.identities?.firstOrNull() ?: return null
|
||||
@@ -301,61 +205,58 @@ class Trust(
|
||||
}
|
||||
|
||||
/** Record that two people compared numbers out of band. */
|
||||
fun setVerified(userId: Long, verified: Boolean) {
|
||||
fun setVerified(
|
||||
userId: Long,
|
||||
verified: Boolean,
|
||||
) {
|
||||
val current = vault.loadTrust(userId) ?: return
|
||||
vault.saveTrust(userId, current.copy(verified = verified))
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a changed identity, they really did reinstall or add a device.
|
||||
*
|
||||
* Re-pins AND drops the verified flag. The old comparison was about a
|
||||
* key that is no longer in use and says nothing about the new one; leaving
|
||||
* the tick in place would show "verified" for something nobody verified.
|
||||
*/
|
||||
fun acceptIdentityChange(userId: Long, handle: String? = null) {
|
||||
fun acceptIdentityChange(
|
||||
userId: Long,
|
||||
handle: String? = null,
|
||||
) {
|
||||
// The address is taken from the SAME response that carries the keys.
|
||||
// Accepting a change and then filing it under a stale address leaves
|
||||
// the key log uncheckable for the very keys just accepted.
|
||||
val served = relay.fetchKeys(userId)
|
||||
val offered = served.bundles.map { it.identitySigning }
|
||||
// An empty answer is a FAILURE, not an acceptance of nothing.
|
||||
// This returned quietly, so a fetch that was rate-limited, timed out or
|
||||
// 500'd left the pin exactly as it was, while the UI reported the
|
||||
// change accepted. The person taps "accept", nothing happens, every
|
||||
// message stays refused, and the app insists the problem is on the
|
||||
// other end. Observed after a burst of retries tripped the per-contact
|
||||
// key-fetch limit, which is precisely when somebody is trying to
|
||||
// recover a conversation and most needs this to work.
|
||||
if (offered.isEmpty()) {
|
||||
throw RelayException.Malformed(
|
||||
"the server returned no keys for this contact, so there was " +
|
||||
"nothing to accept, try again in a moment")
|
||||
"nothing to accept, try again in a moment",
|
||||
)
|
||||
}
|
||||
served.fnid?.let { rememberFnid(userId, it) }
|
||||
// Re-resolve rather than keep the old address: if what changed WAS the
|
||||
// address, keeping it would leave every later check recomputing against
|
||||
// a name this account no longer publishes under, and honest keys would
|
||||
// stay refused for ever.
|
||||
val address = handle?.let { h -> runCatching { relay.resolveIdentity(h) }.getOrNull() }
|
||||
?: vault.loadTrust(userId)?.fnid
|
||||
val address =
|
||||
handle?.let { h -> runCatching { relay.resolveIdentity(h) }.getOrNull() }
|
||||
?: vault.loadTrust(userId)?.fnid
|
||||
vault.saveTrust(
|
||||
userId,
|
||||
// changed = false: this IS the acceptance. Leaving it set would
|
||||
// re-raise the banner on the very keys the user just approved.
|
||||
Vault.Trust(offered.map(Wire::b64), verified = false, fnid = address,
|
||||
changed = false),
|
||||
// changed = false: this IS the acceptance.
|
||||
Vault.Trust(
|
||||
offered.map(Wire::b64),
|
||||
verified = false,
|
||||
fnid = address,
|
||||
changed = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember that something other than the pinned keys was offered.
|
||||
*
|
||||
* The pinned `identities` are left exactly as they were, and `verified`
|
||||
* is dropped: whatever was checked in person was checked against the OLD
|
||||
* key, so the tick cannot carry over to a key nobody has compared.
|
||||
*/
|
||||
private fun markChanged(userId: Long, existing: Vault.Trust) {
|
||||
private fun markChanged(
|
||||
userId: Long,
|
||||
existing: Vault.Trust,
|
||||
) {
|
||||
if (existing.changed && !existing.verified) return
|
||||
vault.saveTrust(userId, existing.copy(changed = true, verified = false))
|
||||
}
|
||||
@@ -363,63 +264,36 @@ class Trust(
|
||||
/** Forget a person entirely, used when they are removed as a contact. */
|
||||
fun forget(userId: Long) = vault.forgetTrust(userId)
|
||||
|
||||
// -- Key transparency ----------------------------------------------------
|
||||
// -- Key transparency ----------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Enforce §19 for one served bundle. Throws rather than returning a verdict:
|
||||
* there is no sensible way to continue with a key that failed this.
|
||||
* Enforce §19 for one served bundle.
|
||||
*/
|
||||
/**
|
||||
* @param fnid the peer's global identity. Defaults to the address pinned
|
||||
* for this contact by [rememberIdentity], which is the answer that should
|
||||
* almost always be used.
|
||||
*
|
||||
* Required for any account that has claimed one, because the relay
|
||||
* files a leaf under the fnid once an account has it and under
|
||||
* `local:<audience>/<user_id>` before that, and the subject is inside the
|
||||
* hashed bytes, so guessing wrong makes an honest key unverifiable.
|
||||
*
|
||||
* Taking it from the contact record rather than from the relay's current
|
||||
* answer is the point: it checks the key was logged under the person this
|
||||
* contact has always been, not under whatever subject the relay would
|
||||
* like to file it against today.
|
||||
* @param fnid the peer's global identity.
|
||||
*/
|
||||
|
||||
/**
|
||||
* §19 enforcement, applied wherever the relay can answer.
|
||||
*
|
||||
* ## Why the head is asked for separately
|
||||
*
|
||||
* The obvious shape, call [enforceKeyLog] and read a 404 as "no log here"
|
||||
* hands the relay the switch. [enforceKeyLog] reads the head, then the
|
||||
* device's leaves, then an inclusion proof, so a relay wanting to skip the
|
||||
* check need only 404 the *middle* one to make a substituted key look like
|
||||
* a deployment without transparency. The "is there a log" question is
|
||||
* therefore asked once, of the head route alone, and every later 404 means
|
||||
* what it has always meant: this key is not in the log.
|
||||
*
|
||||
* ## Why a relay cannot stop answering
|
||||
*
|
||||
* The pin is in the vault and outlives a restart. A relay that served a
|
||||
* head before and 404s now has not lost its log, logs do not disappear,
|
||||
* so that is a fork, not an absence. Without this the downgrade is one
|
||||
* line: serve the log until a key is pinned, then stop.
|
||||
* §19 enforcement, applied wherever the relay can answer. ## Why the head
|
||||
* is asked for separately The obvious shape, call [enforceKeyLog] and read
|
||||
* a 404 as "no log here" hands the relay the switch.
|
||||
*/
|
||||
fun enforceKeyLogIfServed(
|
||||
bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId)
|
||||
bundle: PeerBundle,
|
||||
userId: Long,
|
||||
fnid: String? = pinnedFnid(userId),
|
||||
) {
|
||||
val head = try {
|
||||
head().also { vault.rememberKeyLogServed(relay.host) }
|
||||
} catch (e: RelayException.NotFound) {
|
||||
// Only a route that is absent. A timeout, a 500 or a 429
|
||||
// is a relay having a bad day and must not read as one without
|
||||
// transparency, that would make enforcement skippable by anybody
|
||||
// able to make one request fail.
|
||||
if (vault.keyLogServed(relay.host)) {
|
||||
throw KeyLogForkException("this relay served a log before and now serves none")
|
||||
val head =
|
||||
try {
|
||||
head().also { vault.rememberKeyLogServed(relay.host) }
|
||||
} catch (e: RelayException.NotFound) {
|
||||
// Only a route that is absent.
|
||||
if (vault.keyLogServed(relay.host)) {
|
||||
throw KeyLogForkException("this relay served a log before and now serves none")
|
||||
}
|
||||
if (requireKeyLog) throw KeyNotLoggedException(bundle.deviceId, hadEntries = false)
|
||||
return
|
||||
}
|
||||
if (requireKeyLog) throw KeyNotLoggedException(bundle.deviceId, hadEntries = false)
|
||||
return
|
||||
}
|
||||
if (forkDetected) {
|
||||
throw KeyLogForkException("a fork was already detected on this relay")
|
||||
}
|
||||
@@ -433,7 +307,9 @@ class Trust(
|
||||
* checked against a root this device never pinned.
|
||||
*/
|
||||
fun enforceKeyLog(
|
||||
bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId),
|
||||
bundle: PeerBundle,
|
||||
userId: Long,
|
||||
fnid: String? = pinnedFnid(userId),
|
||||
servedHead: Head? = null,
|
||||
) {
|
||||
val head = servedHead ?: head()
|
||||
@@ -454,53 +330,36 @@ class Trust(
|
||||
}
|
||||
|
||||
// Recomputed from the bundle we were served, NEVER read from the
|
||||
// relay's own reply. Taking the leaf the relay names would let it echo
|
||||
// its own bytes back and satisfy the check against anything at all.
|
||||
//
|
||||
// `head.audience` rather than the host we dialled: the leaf was written
|
||||
// by whichever relay signed this head, and the qualified subject embeds
|
||||
// that relay's own name.
|
||||
//
|
||||
// Both subject forms are accepted, and the reason is a migration
|
||||
// rather than a weakness. A leaf names its subject as `fnid:` once
|
||||
// that account has claimed an address and `local:<audience>/<id>`
|
||||
// before, and the leaf hash committed to whichever string was true
|
||||
// when it was written. A contact who registered a device and then
|
||||
// claimed an address therefore has older leaves under one form and
|
||||
// newer ones under the other, so a verifier computing a single subject
|
||||
// would refuse half the network on the day claiming turned on.
|
||||
//
|
||||
// This concedes nothing to the relay: either candidate still requires
|
||||
// it to have appended a leaf binding the exact key it just served, in a
|
||||
// tree it has already committed to and must prove inclusion against.
|
||||
// What it gives up is the stronger property of checking the key is
|
||||
// logged under *that person*, which needs the address pinned in the
|
||||
// contact record.
|
||||
val wants = setOf(
|
||||
KeyLog.logSubject(fnid, head.audience, userId),
|
||||
KeyLog.logSubject(null, head.audience, userId),
|
||||
).map { subject ->
|
||||
Wire.b64(
|
||||
KeyLog.leafHash(
|
||||
KeyLog.canonicalLeaf(
|
||||
kind = "publish",
|
||||
subject = subject,
|
||||
deviceId = bundle.deviceId,
|
||||
ikSigB64 = Wire.b64(bundle.identitySigning),
|
||||
ikDhB64 = Wire.b64(bundle.identityDH),
|
||||
)
|
||||
// relay's own reply.
|
||||
val wants =
|
||||
setOf(
|
||||
KeyLog.logSubject(fnid, head.audience, userId),
|
||||
KeyLog.logSubject(null, head.audience, userId),
|
||||
).map { subject ->
|
||||
Wire.b64(
|
||||
KeyLog.leafHash(
|
||||
KeyLog.canonicalLeaf(
|
||||
kind = "publish",
|
||||
subject = subject,
|
||||
deviceId = bundle.deviceId,
|
||||
ikSigB64 = Wire.b64(bundle.identitySigning),
|
||||
ikDhB64 = Wire.b64(bundle.identityDH),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val entries = relay.logDeviceEntries(bundle.deviceId).optJSONArray("entries")
|
||||
?: throw KeyNotLoggedException(bundle.deviceId, hadEntries = false)
|
||||
val entries =
|
||||
relay.logDeviceEntries(bundle.deviceId).optJSONArray("entries")
|
||||
?: throw KeyNotLoggedException(bundle.deviceId, hadEntries = false)
|
||||
var index = -1
|
||||
var want = ""
|
||||
for (i in 0 until entries.length()) {
|
||||
val leaf = entries.getJSONObject(i).optString("leaf")
|
||||
if (leaf in wants) {
|
||||
index = entries.getJSONObject(i).getInt("index"); want = leaf; break
|
||||
index = entries.getJSONObject(i).getInt("index")
|
||||
want = leaf
|
||||
break
|
||||
}
|
||||
}
|
||||
if (index < 0) {
|
||||
@@ -510,28 +369,25 @@ class Trust(
|
||||
}
|
||||
|
||||
val inc = relay.logInclusion(index.toLong(), head.size.toLong())
|
||||
val included = inc.optString("leaf") == want && KeyLog.verifyInclusion(
|
||||
leaf = Wire.unb64(inc.getString("leaf")),
|
||||
index = inc.getInt("index"),
|
||||
size = inc.getInt("size"),
|
||||
proof = inc.strings("proof").map(Wire::unb64),
|
||||
root = Wire.unb64(head.root),
|
||||
)
|
||||
val included =
|
||||
inc.optString("leaf") == want &&
|
||||
KeyLog.verifyInclusion(
|
||||
leaf = Wire.unb64(inc.getString("leaf")),
|
||||
index = inc.getInt("index"),
|
||||
size = inc.getInt("size"),
|
||||
proof = inc.strings("proof").map(Wire::unb64),
|
||||
root = Wire.unb64(head.root),
|
||||
)
|
||||
// A proof that does not check out IS evidence, not a naming problem.
|
||||
if (!included) throw KeyNotLoggedException(bundle.deviceId, hadEntries = false)
|
||||
|
||||
vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root), relay.host)
|
||||
}
|
||||
|
||||
// -- Gossip (§19.4) ------------------------------------------------------
|
||||
// -- Gossip (§19.4) ------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Our view of the log, to ride along with a message.
|
||||
*
|
||||
* Gossip is what turns a per-device check into a detectable fork. Alone,
|
||||
* each client only knows the relay was consistent *with itself*; two
|
||||
* clients comparing heads make split-view serving visible, because
|
||||
* reconciling them needs a proof that cannot exist.
|
||||
*/
|
||||
fun headPayload(): String? {
|
||||
val pin = vault.loadKeyLogPin(relay.host) ?: return null
|
||||
@@ -539,27 +395,28 @@ class Trust(
|
||||
}
|
||||
|
||||
/** Compare a peer's view of the log against ours. */
|
||||
fun gossip(size: Int, root: String) {
|
||||
fun gossip(
|
||||
size: Int,
|
||||
root: String,
|
||||
) {
|
||||
val pin = vault.loadKeyLogPin(relay.host) ?: return
|
||||
if (size == pin.size) {
|
||||
if (root != pin.root) forkDetected = true
|
||||
return
|
||||
}
|
||||
// Different sizes are not evidence by themselves, one of us is simply
|
||||
// behind. The relay has to reconcile them, and if it served two
|
||||
// branches it cannot.
|
||||
// behind.
|
||||
val older = if (size < pin.size) size else pin.size
|
||||
val newer = if (size < pin.size) pin.size else size
|
||||
val olderRoot = if (size < pin.size) root else pin.root
|
||||
val newerRoot = if (size < pin.size) pin.root else root
|
||||
val ok = try {
|
||||
consistent(older, newer, olderRoot, newerRoot)
|
||||
} catch (_: Exception) {
|
||||
// A failed fetch is NOT a fork. The network is allowed to be
|
||||
// down, and calling that a fork cries wolf until the warning means
|
||||
// nothing, which is worse than not warning at all.
|
||||
return
|
||||
}
|
||||
val ok =
|
||||
try {
|
||||
consistent(older, newer, olderRoot, newerRoot)
|
||||
} catch (_: Exception) {
|
||||
// A failed fetch is NOT a fork.
|
||||
return
|
||||
}
|
||||
if (!ok) forkDetected = true
|
||||
}
|
||||
|
||||
@@ -570,87 +427,95 @@ class Trust(
|
||||
/**
|
||||
* Verify the log is still an append-only extension of our pin, on a
|
||||
* schedule rather than only when a session is being established (S4).
|
||||
*
|
||||
* This is what makes transparency act instead of merely record: a relay
|
||||
* that quietly forked or rewound its log between two bundle fetches would
|
||||
* otherwise go unseen, for an established conversation, possibly for ever.
|
||||
* Best effort on the network (a failed fetch is retried next cycle); a
|
||||
* proven fork raises [forkDetected], the standing alarm. Call from the
|
||||
* poll loop; it self-throttles.
|
||||
*/
|
||||
fun verifyLogConsistencyIfDue(now: Long = System.currentTimeMillis()) {
|
||||
val pin = vault.loadKeyLogPin(relay.host) ?: return // nothing pinned to extend
|
||||
val pin = vault.loadKeyLogPin(relay.host) ?: return // nothing pinned to extend
|
||||
if (now - lastLogCheck < logCheckIntervalMs) return
|
||||
val head = try { head() } catch (_: Exception) { return } // network, not evidence
|
||||
val ok = try {
|
||||
when {
|
||||
pin.size > head.size -> false // shrank
|
||||
pin.size == head.size -> pin.root == head.root // same size, same root
|
||||
else -> consistent(pin.size, head.size, pin.root, head.root)
|
||||
val head =
|
||||
try {
|
||||
head()
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
} // network, not evidence
|
||||
val ok =
|
||||
try {
|
||||
when {
|
||||
pin.size > head.size -> false
|
||||
|
||||
// shrank
|
||||
pin.size == head.size -> pin.root == head.root
|
||||
|
||||
// same size, same root
|
||||
else -> consistent(pin.size, head.size, pin.root, head.root)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
} catch (_: Exception) { return }
|
||||
if (!ok) { forkDetected = true; return }
|
||||
if (!ok) {
|
||||
forkDetected = true
|
||||
return
|
||||
}
|
||||
vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root), relay.host)
|
||||
lastLogCheck = now
|
||||
}
|
||||
|
||||
// -- Reading the relay's log ---------------------------------------------
|
||||
// -- Reading the relay's log ---------------------------------------------.
|
||||
|
||||
/**
|
||||
* A verified log head. Public because [enforceKeyLog] takes one that its
|
||||
* caller has already fetched, the head is part of that contract rather
|
||||
* than an internal detail.
|
||||
* A verified log head.
|
||||
*/
|
||||
data class Head(val size: Int, val root: String, val audience: String)
|
||||
data class Head(
|
||||
val size: Int,
|
||||
val root: String,
|
||||
val audience: String,
|
||||
)
|
||||
|
||||
private fun head(): Head = relay.logHead().let {
|
||||
val head = Head(it.getInt("size"), it.getString("root"), it.optString("audience"))
|
||||
verifyHeadSignature(head, it.optString("signature"))
|
||||
head
|
||||
}
|
||||
private fun head(): Head =
|
||||
relay.logHead().let {
|
||||
val head = Head(it.getInt("size"), it.getString("root"), it.optString("audience"))
|
||||
verifyHeadSignature(head, it.optString("signature"))
|
||||
head
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the relay's signature over a head against the pinned issuer key
|
||||
* (§19.3, audit M1).
|
||||
*
|
||||
* Silent when nothing is published or the relay predates the field, a
|
||||
* deployment that cannot sign is weaker, not hostile, and refusing it
|
||||
* outright is a worse failure than the one prevented. A signature that is
|
||||
* present and WRONG, or that stops matching a key already pinned, is a
|
||||
* fork and is treated as one.
|
||||
*/
|
||||
private fun verifyHeadSignature(head: Head, signatureB64: String) {
|
||||
private fun verifyHeadSignature(
|
||||
head: Head,
|
||||
signatureB64: String,
|
||||
) {
|
||||
if (signatureB64.isEmpty()) return
|
||||
val signature = runCatching { Wire.unb64(signatureB64) }.getOrNull() ?: return
|
||||
|
||||
// ios#19, decide what the relay's published identity MEANS before
|
||||
// using it, rather than inferring it from a signature that failed.
|
||||
//
|
||||
// This is the bug the pin previously hid. A rotated relay key made
|
||||
// the signature stop verifying, and the only conclusion available at
|
||||
// that point was "fork", so a command somebody ran on purpose was
|
||||
// reported as the relay showing two different histories. Different
|
||||
// situations, different remedies, and an alarm nobody can act on is one
|
||||
// people learn to dismiss, which costs real fork detection too.
|
||||
val offered = runCatching {
|
||||
relay.relayDescriptor().optString("issuerPublicKey")
|
||||
}.getOrNull()
|
||||
val issuer = when (val verdict = RelayIdentityPin.check(offered, vault.keyLogIssuerPin())) {
|
||||
is RelayIdentityPin.Verdict.NotPublished ->
|
||||
// Nothing published and nothing pinned: a weaker deployment,
|
||||
// not an attack. Nothing to check the signature against.
|
||||
vault.keyLogIssuerPin() ?: return
|
||||
is RelayIdentityPin.Verdict.FirstSight -> {
|
||||
vault.saveKeyLogIssuerPin(verdict.offered)
|
||||
verdict.offered
|
||||
// ios#19, decide what the relay's published identity MEANS before using
|
||||
// it, rather than inferring it from a signature that failed.
|
||||
val offered =
|
||||
runCatching {
|
||||
relay.relayDescriptor().optString("issuerPublicKey")
|
||||
}.getOrNull()
|
||||
val issuer =
|
||||
when (val verdict = RelayIdentityPin.check(offered, vault.keyLogIssuerPin())) {
|
||||
is RelayIdentityPin.Verdict.NotPublished -> {
|
||||
// Nothing published and nothing pinned: a weaker deployment,
|
||||
// not an attack.
|
||||
vault.keyLogIssuerPin() ?: return
|
||||
}
|
||||
|
||||
is RelayIdentityPin.Verdict.FirstSight -> {
|
||||
vault.saveKeyLogIssuerPin(verdict.offered)
|
||||
verdict.offered
|
||||
}
|
||||
|
||||
is RelayIdentityPin.Verdict.Unchanged -> {
|
||||
vault.keyLogIssuerPin() ?: return
|
||||
}
|
||||
|
||||
is RelayIdentityPin.Verdict.Changed -> {
|
||||
// NOT a fork, and `forkDetected` is deliberately not set.
|
||||
throw RelayIdentityChangedException(relayHostForPin())
|
||||
}
|
||||
}
|
||||
is RelayIdentityPin.Verdict.Unchanged -> vault.keyLogIssuerPin() ?: return
|
||||
is RelayIdentityPin.Verdict.Changed ->
|
||||
// NOT a fork, and `forkDetected` is deliberately not set.
|
||||
// The log may be perfectly consistent; it is the server's
|
||||
// identity that is not the one recorded.
|
||||
throw RelayIdentityChangedException(relayHostForPin())
|
||||
}
|
||||
|
||||
if (!KeyLog.verifyHead(head.size, head.root, signature, issuer)) {
|
||||
forkDetected = true
|
||||
@@ -661,7 +526,12 @@ class Trust(
|
||||
/** The host to name in an identity-change warning. */
|
||||
private fun relayHostForPin(): String = relay.host
|
||||
|
||||
private fun consistent(from: Int, to: Int, oldRoot: String, newRoot: String): Boolean {
|
||||
private fun consistent(
|
||||
from: Int,
|
||||
to: Int,
|
||||
oldRoot: String,
|
||||
newRoot: String,
|
||||
): Boolean {
|
||||
val c = relay.logConsistency(from.toLong(), to.toLong())
|
||||
return KeyLog.verifyConsistency(
|
||||
from = from,
|
||||
|
||||
@@ -1,47 +1,16 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Whether a newer build than this one is published, for an app that is
|
||||
* installed by hand.
|
||||
*
|
||||
* Nothing updates this app on its own. It is downloaded as an APK from a
|
||||
* web page, so there is no store to notice a new release and no background
|
||||
* updater to install it. Without this check a person can run a build from
|
||||
* months ago indefinitely and never know, and the builds they are missing
|
||||
* include security fixes. A sideloaded app that cannot tell you it is out of
|
||||
* date is the part of sideloading that is worse, and this is the only
|
||||
* thing that closes it.
|
||||
*
|
||||
* Asked of the relay you already talk to, not of a fixed address of ours.
|
||||
* The obvious implementation points every install at flamenetmessenger.com, and
|
||||
* that would mean an app whose whole claim is that it does not phone home
|
||||
* checking in with us on a timer, from every device, including the self-hosted
|
||||
* ones that deliberately never contact us at all. Their traffic is their
|
||||
* server's business. So the manifest is fetched from the relay this install is
|
||||
* signed in to, which learns nothing it does not already know from the traffic
|
||||
* it is carrying.
|
||||
*
|
||||
* The consequence, stated rather than hidden: a relay that publishes no
|
||||
* manifest produces no prompt. Self-hosters who do not republish the APK get no
|
||||
* update prompts, which is correct, we do not know what build they are meant
|
||||
* to be running, but it does mean this cannot be relied on as the only way a
|
||||
* fix reaches everybody.
|
||||
*
|
||||
* The download URL is built here and never read from the manifest. A
|
||||
* `url` field in the JSON would let any relay send its users to an APK on a
|
||||
* host of its choosing, which is a much larger thing to hand a server than
|
||||
* "tell me a version number". Android's own signature check is the backstop,
|
||||
* it refuses to install a differently-signed APK over an existing one, but a
|
||||
* backstop is not a reason to build the hole in front of it.
|
||||
*/
|
||||
object UpdateCheck {
|
||||
|
||||
/** Where a relay publishes what it considers the current Android build. */
|
||||
const val MANIFEST_PATH = "downloads/android-latest.json"
|
||||
|
||||
@@ -50,10 +19,8 @@ object UpdateCheck {
|
||||
|
||||
/**
|
||||
* A published release, as the relay describes it.
|
||||
*
|
||||
* @param sha256 the APK's fingerprint, so somebody who wants to verify the
|
||||
* file they downloaded can. Optional: a manifest without one is still
|
||||
* usable, it just cannot be checked by hand.
|
||||
* @param sha256 the APK's fingerprint, so somebody who wants to verify
|
||||
* the file they downloaded can.
|
||||
*/
|
||||
data class Release(
|
||||
val versionCode: Int,
|
||||
@@ -64,56 +31,65 @@ object UpdateCheck {
|
||||
)
|
||||
|
||||
/** Short timeouts: this is a background courtesy, never worth a stall. */
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.callTimeout(8, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val http =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.callTimeout(8, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/** A manifest is a handful of fields; anything larger is not one. */
|
||||
private const val MAX_BYTES = 16L * 1024
|
||||
|
||||
/**
|
||||
* What the relay publishes, or null if it publishes nothing usable.
|
||||
*
|
||||
* Null for every unusual case, no manifest, a refusal, a timeout, a body
|
||||
* that is not the JSON expected. None of them are worth telling the user
|
||||
* about: the failure mode of this whole feature is "no prompt", and a
|
||||
* messenger that interrupts you to say it could not check for updates has
|
||||
* made itself the subject.
|
||||
*/
|
||||
fun published(baseUrl: String, client: OkHttpClient = http): Release? = runCatching {
|
||||
val base = baseUrl.trimEnd('/').toHttpUrl()
|
||||
val url = base.newBuilder().addPathSegments(MANIFEST_PATH).build()
|
||||
client.newCall(Request.Builder().url(url).get().build()).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val body = response.body?.source()?.let { source ->
|
||||
source.request(MAX_BYTES)
|
||||
source.buffer.snapshot(minOf(MAX_BYTES, source.buffer.size).toInt()).utf8()
|
||||
} ?: return null
|
||||
fun published(
|
||||
baseUrl: String,
|
||||
client: OkHttpClient = http,
|
||||
): Release? =
|
||||
runCatching {
|
||||
val base = baseUrl.trimEnd('/').toHttpUrl()
|
||||
val url = base.newBuilder().addPathSegments(MANIFEST_PATH).build()
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val body =
|
||||
response.body?.source()?.let { source ->
|
||||
source.request(MAX_BYTES)
|
||||
source.buffer.snapshot(minOf(MAX_BYTES, source.buffer.size).toInt()).utf8()
|
||||
} ?: return null
|
||||
|
||||
val json = JSONObject(body)
|
||||
val code = json.optInt("versionCode", 0)
|
||||
// A manifest that cannot say which build it describes is not one.
|
||||
if (code <= 0) return null
|
||||
Release(
|
||||
versionCode = code,
|
||||
versionName = json.optString("versionName").ifEmpty { code.toString() },
|
||||
notes = json.optString("notes"),
|
||||
sha256 = json.optString("sha256").takeIf { it.length == 64 },
|
||||
downloadUrl = base.newBuilder().addPathSegments(APK_PATH).build().toString(),
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
val json = JSONObject(body)
|
||||
val code = json.optInt("versionCode", 0)
|
||||
// A manifest that cannot say which build it describes is not one.
|
||||
if (code <= 0) return null
|
||||
Release(
|
||||
versionCode = code,
|
||||
versionName = json.optString("versionName").ifEmpty { code.toString() },
|
||||
notes = json.optString("notes"),
|
||||
sha256 = json.optString("sha256").takeIf { it.length == 64 },
|
||||
downloadUrl =
|
||||
base
|
||||
.newBuilder()
|
||||
.addPathSegments(APK_PATH)
|
||||
.build()
|
||||
.toString(),
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* The published release if it is newer than [installedVersionCode], else null.
|
||||
*
|
||||
* Strictly greater. Equal is the common case, almost every check finds
|
||||
* the build the user already has, and a prompt offering to install what is
|
||||
* already installed teaches people to dismiss these without reading. Older
|
||||
* is possible too, on a relay that has not been updated in a while, and is
|
||||
* likewise not something to offer.
|
||||
* The published release if it is newer than [installedVersionCode], else
|
||||
* null.
|
||||
*/
|
||||
fun availableUpdate(
|
||||
baseUrl: String,
|
||||
|
||||
@@ -19,23 +19,23 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* An address nobody issued, and the record that says where mail for it waits.
|
||||
*
|
||||
* The relay must not be able to lie about either. The address is the
|
||||
* public half of a key derived from the passphrase, so claiming it proves
|
||||
* possession and nothing else, a relay confirms, it does not vouch. The home
|
||||
* record is signed by that same key, so a relay serving a truncated list is
|
||||
* caught by arithmetic rather than by trust. These tests are about the places
|
||||
* that could quietly stop being true.
|
||||
*/
|
||||
class AccountIdentityTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
private fun seed(): ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
|
||||
@@ -44,10 +44,7 @@ class AccountIdentityTest {
|
||||
// MARK: - Claiming
|
||||
|
||||
/**
|
||||
* The audience is not the caller's to guess. It is the string the
|
||||
* relay checks the signature against, so a caller inferring it from a URL
|
||||
* would produce a claim that verifies nowhere, silently, because at the
|
||||
* point of failure a wrong audience and a wrong key look identical.
|
||||
* The audience is not the caller's to guess.
|
||||
*/
|
||||
fun claimingSignsWhatTheRelaySaysItsNameIs() {}
|
||||
|
||||
@@ -58,34 +55,45 @@ class AccountIdentityTest {
|
||||
val expected = FNID.address(key.publicKey)
|
||||
var seenBody: JSONObject? = null
|
||||
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse = when {
|
||||
".well-known/flamenet/relay" in request.path.orEmpty() ->
|
||||
json("""{"audience":"relay.example.com","issuerPublicKey":""}""")
|
||||
"/e2e/account/identity" in request.path.orEmpty() -> {
|
||||
seenBody = JSONObject(request.body.readUtf8())
|
||||
json("""{"ok":true,"fnid":"$expected","created":true}""")
|
||||
}
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||
when {
|
||||
".well-known/flamenet/relay" in request.path.orEmpty() -> {
|
||||
json("""{"audience":"relay.example.com","issuerPublicKey":""}""")
|
||||
}
|
||||
|
||||
"/e2e/account/identity" in request.path.orEmpty() -> {
|
||||
seenBody = JSONObject(request.body.readUtf8())
|
||||
json("""{"ok":true,"fnid":"$expected","created":true}""")
|
||||
}
|
||||
|
||||
else -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(expected, AccountIdentity.claim(relay(), seed, userId = 42))
|
||||
|
||||
val body = seenBody!!
|
||||
val signature = Wire.unb64(body.getString("sig"))
|
||||
assertTrue(
|
||||
Ed25519.verify(key.publicKey, signature,
|
||||
FNID.claimCanonical("relay.example.com", 42, body.getString("account_key"))),
|
||||
Ed25519.verify(
|
||||
key.publicKey,
|
||||
signature,
|
||||
FNID.claimCanonical("relay.example.com", 42, body.getString("account_key")),
|
||||
),
|
||||
"the claim must verify against the audience the relay named",
|
||||
)
|
||||
// And not against a different one. A claim captured at one relay must
|
||||
// not be replayable at another, the same account key is MEANT to be
|
||||
// registered in several places, but each registration has to be an act
|
||||
// the holder took there.
|
||||
// And not against a different one.
|
||||
assertFalse(
|
||||
Ed25519.verify(key.publicKey, signature,
|
||||
FNID.claimCanonical("other.example.com", 42, body.getString("account_key"))))
|
||||
Ed25519.verify(
|
||||
key.publicKey,
|
||||
signature,
|
||||
FNID.claimCanonical("other.example.com", 42, body.getString("account_key")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The address is derived, never taken from the relay's reply. */
|
||||
@@ -99,35 +107,43 @@ class AccountIdentityTest {
|
||||
|
||||
// MARK: - Home records
|
||||
|
||||
private fun signedRecord(seed: ByteArray, serial: Int, relays: List<String>): JSONObject {
|
||||
private fun signedRecord(
|
||||
seed: ByteArray,
|
||||
serial: Int,
|
||||
relays: List<String>,
|
||||
): JSONObject {
|
||||
val fnid = AccountIdentity.address(seed)
|
||||
val sig = Wire.b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays)))
|
||||
return JSONObject()
|
||||
.put("fnid", fnid).put("serial", serial)
|
||||
.put("relays", JSONArray(relays)).put("sig", sig)
|
||||
.put("fnid", fnid)
|
||||
.put("serial", serial)
|
||||
.put("relays", JSONArray(relays))
|
||||
.put("sig", sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a record signed by the account verifies`() {
|
||||
val seed = seed()
|
||||
val record = signedRecord(seed, 3, listOf("a.example.com", "b.example.com"))
|
||||
assertEquals(listOf("a.example.com", "b.example.com"),
|
||||
AccountIdentity.verifiedRelays(record))
|
||||
assertEquals(
|
||||
listOf("a.example.com", "b.example.com"),
|
||||
AccountIdentity.verifiedRelays(record),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay serving a truncated list is the exact failure multi-homing
|
||||
* exists to survive. Drop a home from the record and it stops being
|
||||
* signed, the sender then delivers everywhere the real record names,
|
||||
* because it checks rather than trusts.
|
||||
* A relay serving a truncated list is the exact failure multi-homing exists
|
||||
* to survive.
|
||||
*/
|
||||
@Test
|
||||
fun `a shortened list does not verify`() {
|
||||
val seed = seed()
|
||||
val record = signedRecord(seed, 3, listOf("a.example.com", "b.example.com"))
|
||||
record.put("relays", JSONArray(listOf("a.example.com")))
|
||||
assertNull(AccountIdentity.verifiedRelays(record),
|
||||
"a relay removed itself from the competition and was believed")
|
||||
assertNull(
|
||||
AccountIdentity.verifiedRelays(record),
|
||||
"a relay removed itself from the competition and was believed",
|
||||
)
|
||||
}
|
||||
|
||||
/** And neither does an added one, which is the same attack pointed outward. */
|
||||
@@ -141,9 +157,9 @@ class AccountIdentityTest {
|
||||
|
||||
/**
|
||||
* The serial is inside the signature, which is what stops the rollback a
|
||||
* signature alone permits: an old list is signed, so without a
|
||||
* counter a relay could serve one from before a second home was added and
|
||||
* quietly become the only mailbox again.
|
||||
* signature alone permits: an old list is signed, so without a counter a
|
||||
* relay could serve one from before a second home was added and quietly
|
||||
* become the only mailbox again.
|
||||
*/
|
||||
@Test
|
||||
fun `the serial is bound into the signature`() {
|
||||
@@ -164,47 +180,53 @@ class AccountIdentityTest {
|
||||
@Test
|
||||
fun `a malformed record is refused rather than guessed at`() {
|
||||
assertNull(AccountIdentity.verifiedRelays(JSONObject()))
|
||||
assertNull(AccountIdentity.verifiedRelays(
|
||||
JSONObject().put("fnid", "not-an-address").put("serial", 1)
|
||||
.put("relays", JSONArray(listOf("a.example.com"))).put("sig", "!!")))
|
||||
assertNull(
|
||||
AccountIdentity.verifiedRelays(
|
||||
JSONObject()
|
||||
.put("fnid", "not-an-address")
|
||||
.put("serial", 1)
|
||||
.put("relays", JSONArray(listOf("a.example.com")))
|
||||
.put("sig", "!!"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Serial numbers
|
||||
|
||||
/**
|
||||
* Read from what is already published, not from local state. A device
|
||||
* offline while another added a relay would otherwise reuse a serial and
|
||||
* have its record refused as a rollback, by the very check that exists to
|
||||
* stop an attacker doing the same thing.
|
||||
* Read from what is already published, not from local state.
|
||||
*/
|
||||
@Test
|
||||
fun `the next serial is one past the highest any home admits to`() {
|
||||
val seed = seed()
|
||||
val fnid = AccountIdentity.address(seed)
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) =
|
||||
json(signedRecord(seed, 11, listOf("a.example.com")).toString())
|
||||
}
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) = json(signedRecord(seed, 11, listOf("a.example.com")).toString())
|
||||
}
|
||||
assertEquals(12, AccountIdentity.nextSerial(fnid, listOf(relay())))
|
||||
}
|
||||
|
||||
/** A home that answers nothing does not drag the serial backwards. */
|
||||
@Test
|
||||
fun `a home with no record contributes nothing`() {
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(404)
|
||||
}
|
||||
assertEquals(1, AccountIdentity.nextSerial(AccountIdentity.address(seed()),
|
||||
listOf(relay())))
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(404)
|
||||
}
|
||||
assertEquals(
|
||||
1,
|
||||
AccountIdentity.nextSerial(
|
||||
AccountIdentity.address(seed()),
|
||||
listOf(relay()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Publishing
|
||||
|
||||
/**
|
||||
* Every listed relay is told, not only the one being added. A home that
|
||||
* does not know it is one still accepts mail, but a sender asking IT for
|
||||
* the record gets a stale list and skips the others, so the redundancy
|
||||
* would quietly depend on which relay the sender happened to ask.
|
||||
* Every listed relay is told, not only the one being added.
|
||||
*/
|
||||
@Test
|
||||
fun `publishing reaches every home and reports the ones that refuse`() {
|
||||
@@ -212,25 +234,35 @@ class AccountIdentityTest {
|
||||
val fnid = AccountIdentity.address(seed)
|
||||
|
||||
val good = MockWebServer().also { it.start() }
|
||||
good.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) = json("""{"ok":true}""")
|
||||
}
|
||||
good.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) = json("""{"ok":true}""")
|
||||
}
|
||||
val bad = MockWebServer().also { it.start() }
|
||||
bad.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) =
|
||||
MockResponse().setResponseCode(500).setBody("{}")
|
||||
}
|
||||
bad.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(500).setBody("{}")
|
||||
}
|
||||
|
||||
val failures = AccountIdentity.publishHomes(
|
||||
seed, fnid, serial = 4,
|
||||
homes = mapOf(
|
||||
"good.example.com" to RelayClient(good.url("/").toString(), "a", "b"),
|
||||
"bad.example.com" to RelayClient(bad.url("/").toString(), "a", "b"),
|
||||
))
|
||||
val failures =
|
||||
AccountIdentity.publishHomes(
|
||||
seed,
|
||||
fnid,
|
||||
serial = 4,
|
||||
homes =
|
||||
mapOf(
|
||||
"good.example.com" to RelayClient(good.url("/").toString(), "a", "b"),
|
||||
"bad.example.com" to RelayClient(bad.url("/").toString(), "a", "b"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(setOf("bad.example.com"), failures.keys,
|
||||
"a partial success has to say WHICH copies landed")
|
||||
good.shutdown(); bad.shutdown()
|
||||
assertEquals(
|
||||
setOf("bad.example.com"),
|
||||
failures.keys,
|
||||
"a partial success has to say WHICH copies landed",
|
||||
)
|
||||
good.shutdown()
|
||||
bad.shutdown()
|
||||
}
|
||||
|
||||
/** What goes out is signed over the list as sent, in order. */
|
||||
@@ -239,22 +271,34 @@ class AccountIdentityTest {
|
||||
val seed = seed()
|
||||
val fnid = AccountIdentity.address(seed)
|
||||
var body: JSONObject? = null
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
body = JSONObject(request.body.readUtf8())
|
||||
return json("""{"ok":true}""")
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
body = JSONObject(request.body.readUtf8())
|
||||
return json("""{"ok":true}""")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AccountIdentity.publishHomes(seed, fnid, serial = 2,
|
||||
homes = mapOf("only.example.com" to relay()))
|
||||
AccountIdentity.publishHomes(
|
||||
seed,
|
||||
fnid,
|
||||
serial = 2,
|
||||
homes = mapOf("only.example.com" to relay()),
|
||||
)
|
||||
|
||||
val sent = body!!
|
||||
val relays = sent.getJSONArray("relays").let { a ->
|
||||
(0 until a.length()).map { a.getString(it) }
|
||||
}
|
||||
val relays =
|
||||
sent.getJSONArray("relays").let { a ->
|
||||
(0 until a.length()).map { a.getString(it) }
|
||||
}
|
||||
assertEquals(listOf("only.example.com"), relays)
|
||||
assertTrue(AccountIdentity.verifyHomes(fnid, sent.getInt("serial"), relays,
|
||||
sent.getString("sig")))
|
||||
assertTrue(
|
||||
AccountIdentity.verifyHomes(
|
||||
fnid,
|
||||
sent.getInt("serial"),
|
||||
relays,
|
||||
sent.getString("sig"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,32 +9,33 @@ import kotlin.test.assertFailsWith
|
||||
|
||||
/**
|
||||
* What a 401 from the account service is allowed to mean.
|
||||
*
|
||||
* Guards a bug found by driving the app: every 401 and 403 became
|
||||
* [AccountsClient.Failure.BadCredentials], so a session that simply aged out
|
||||
* told the user "that handle or passphrase isn't right", mid-send, hours
|
||||
* after a successful sign-in. The obvious reading is that the password stopped
|
||||
* working, and the obvious response is to change one that was never wrong.
|
||||
*/
|
||||
class AccountsClientStatusTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() = server.shutdown()
|
||||
|
||||
private fun client() = AccountsClient(server.url("/").toString())
|
||||
|
||||
private fun unauthorized() = MockResponse().setResponseCode(401)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"error":true,"reason":"unauthorized"}""")
|
||||
private fun unauthorized() =
|
||||
MockResponse()
|
||||
.setResponseCode(401)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"error":true,"reason":"unauthorized"}""")
|
||||
|
||||
@Test
|
||||
fun `a rejected passphrase is reported as bad credentials`() {
|
||||
// The params lookup succeeds; the session request is what gets refused.
|
||||
server.enqueue(MockResponse().setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"kdfSalt":"AAAAAAAAAAAAAAAAAAAAAA==","kdfIterations":1,"kdfID":1}"""))
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"kdfSalt":"AAAAAAAAAAAAAAAAAAAAAA==","kdfIterations":1,"kdfID":1}"""),
|
||||
)
|
||||
server.enqueue(unauthorized())
|
||||
|
||||
assertFailsWith<AccountsClient.Failure.BadCredentials> {
|
||||
@@ -45,8 +46,7 @@ class AccountsClientStatusTest {
|
||||
@Test
|
||||
fun `a refused token is an expired session, not a wrong passphrase`() {
|
||||
server.enqueue(unauthorized())
|
||||
// Any authenticated call that does NOT present a passphrase. Telling
|
||||
// this user their passphrase is wrong would be a lie.
|
||||
// Any authenticated call that does NOT present a passphrase.
|
||||
assertFailsWith<AccountsClient.Failure.SessionExpired> {
|
||||
client().parameters("someone")
|
||||
}
|
||||
|
||||
@@ -17,22 +17,24 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* §19 enforcement with no setting to find first.
|
||||
*
|
||||
* This used to be opt-in and off, so the protection shipped and almost
|
||||
* nobody had it. The argument for a switch, a relay predating the log has
|
||||
* none, and refusing every peer there is worse than the attack, was an
|
||||
* argument for skipping relays that *cannot* answer, never for skipping the
|
||||
* ones that can. These tests pin that distinction, because it is the whole
|
||||
* feature: the relay decides which case it is in, and it must not be able to
|
||||
* choose the lenient one after having already chosen the strict one.
|
||||
*/
|
||||
class AutomaticKeyLogTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
@@ -42,8 +44,11 @@ class AutomaticKeyLogTest {
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
vault = Vault(MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
vault =
|
||||
Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
)
|
||||
identity = DeviceIdentity.create()
|
||||
}
|
||||
|
||||
@@ -51,8 +56,11 @@ class AutomaticKeyLogTest {
|
||||
|
||||
private fun trust() = Trust(identity, vault, RelayClient(server.url("/").toString(), "a", "b"))
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
/** A peer whose keys are real, so nothing here passes on a malformed bundle. */
|
||||
private fun peer(): PeerBundle {
|
||||
@@ -62,37 +70,40 @@ class AutomaticKeyLogTest {
|
||||
|
||||
/**
|
||||
* A relay's log routes, answered by path.
|
||||
*
|
||||
* [head] null means the route is absent, a deployment from before §19.
|
||||
* An unsigned head on purpose: `verifyHeadSignature` is silent when the
|
||||
* field is empty, which is the documented behaviour for a relay that
|
||||
* publishes no issuer key, and it keeps these tests about the presence
|
||||
* question rather than about signing.
|
||||
*/
|
||||
private fun relayServing(head: String?, entries: String = """{"entries":[]}""") {
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"/e2e/log/head" in path ->
|
||||
head?.let(::json) ?: MockResponse().setResponseCode(404)
|
||||
"/e2e/log/device/" in path -> json(entries)
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
private fun relayServing(
|
||||
head: String?,
|
||||
entries: String = """{"entries":[]}""",
|
||||
) {
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"/e2e/log/head" in path -> {
|
||||
head?.let(::json) ?: MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
"/e2e/log/device/" in path -> {
|
||||
json(entries)
|
||||
}
|
||||
|
||||
else -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val aHead = """{"size":4,"root":"${Wire.b64(ByteArray(32) { 3 })}",""" +
|
||||
""""audience":"relay.example.com"}"""
|
||||
private val aHead =
|
||||
"""{"size":4,"root":"${Wire.b64(ByteArray(32) { 3 })}",""" +
|
||||
""""audience":"relay.example.com"}"""
|
||||
|
||||
// MARK: - A relay that keeps no log
|
||||
|
||||
/**
|
||||
* The case the old switch existed for. A relay from before §19 answers
|
||||
* nothing, and refusing every contact on it would push people to something
|
||||
* with no ratchet at all, a far worse outcome than the substitution being
|
||||
* guarded against.
|
||||
* The case the old switch existed for.
|
||||
*/
|
||||
@Test
|
||||
fun `a relay with no log at all does not block the send`() {
|
||||
@@ -111,9 +122,7 @@ class AutomaticKeyLogTest {
|
||||
// MARK: - A relay that keeps one
|
||||
|
||||
/**
|
||||
* The behaviour change. Before this, a relay serving a log and a key
|
||||
* that is not in it was accepted without a murmur unless the user had found
|
||||
* a switch in Privacy settings. Now the absent leaf is the answer.
|
||||
* The behaviour change.
|
||||
*/
|
||||
@Test
|
||||
fun `a key that is not in the log is refused, with no setting turned on`() {
|
||||
@@ -125,28 +134,26 @@ class AutomaticKeyLogTest {
|
||||
|
||||
/**
|
||||
* Entries that exist but do not match are still a refusal, and the flag
|
||||
* says which case it was, "we could not compute your subject" and "this
|
||||
* key is not published" call for different things being said to the user.
|
||||
* says which case it was, "we could not compute your subject" and "this key
|
||||
* is not published" call for different things being said to the user.
|
||||
*/
|
||||
@Test
|
||||
fun `entries that match nothing are refused and say so`() {
|
||||
relayServing(head = aHead,
|
||||
entries = """{"entries":[{"index":0,"leaf":"${Wire.b64(ByteArray(32))}"}]}""")
|
||||
val failure = assertFailsWith<KeyNotLoggedException> {
|
||||
trust().enforceKeyLogIfServed(peer(), userId = 7)
|
||||
}
|
||||
relayServing(
|
||||
head = aHead,
|
||||
entries = """{"entries":[{"index":0,"leaf":"${Wire.b64(ByteArray(32))}"}]}""",
|
||||
)
|
||||
val failure =
|
||||
assertFailsWith<KeyNotLoggedException> {
|
||||
trust().enforceKeyLogIfServed(peer(), userId = 7)
|
||||
}
|
||||
assertTrue(failure.hadEntries, "the device is in the log, this key is not")
|
||||
}
|
||||
|
||||
// MARK: - The downgrade
|
||||
|
||||
/**
|
||||
* The attack automatic enforcement would otherwise invite. Skipping
|
||||
* relays that keep no log means a relay can opt out of being checked, so
|
||||
* publish a log until clients are talking, then stop answering, and every
|
||||
* client silently goes back to trusting whatever key it is handed. Logs do
|
||||
* not disappear; a relay that answered before and 404s now is not a
|
||||
* deployment without transparency, it is one that changed its mind.
|
||||
* The attack automatic enforcement would otherwise invite.
|
||||
*/
|
||||
@Test
|
||||
fun `a relay that served a log and then stops is a fork, not an absence`() {
|
||||
@@ -164,25 +171,19 @@ class AutomaticKeyLogTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A bad day is not an absence. A timeout, a 500 or a 429 must not
|
||||
* read as "this relay keeps no log", or enforcement becomes skippable by
|
||||
* anybody able to make one request fail, including the relay itself, which
|
||||
* would only have to be unreliable rather than dishonest.
|
||||
* A bad day is not an absence.
|
||||
*/
|
||||
@Test
|
||||
fun `a server error is not treated as a relay without a log`() {
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(500)
|
||||
}
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(500)
|
||||
}
|
||||
assertFailsWith<RelayException> { trust().enforceKeyLogIfServed(peer(), userId = 7) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The flag is per relay, and the first version was not. A single
|
||||
* global one meant that signing in to a second relay which keeps no log
|
||||
* read as the FIRST going quiet, so an honest deployment without
|
||||
* transparency became a fork warning, and every contact on it became
|
||||
* unreachable.
|
||||
* The flag is per relay, and the first version was not.
|
||||
*/
|
||||
@Test
|
||||
fun `the served flag is remembered per relay`() {
|
||||
@@ -190,8 +191,11 @@ class AutomaticKeyLogTest {
|
||||
assertFailsWith<KeyNotLoggedException> { trust().enforceKeyLogIfServed(peer(), userId = 7) }
|
||||
|
||||
assertTrue(vault.keyLogServed(server.url("/").host))
|
||||
assertEquals(false, vault.keyLogServed("somewhere.else"),
|
||||
"one relay answering must say nothing about another")
|
||||
assertEquals(
|
||||
false,
|
||||
vault.keyLogServed("somewhere.else"),
|
||||
"one relay answering must say nothing about another",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,26 +10,19 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The block list's wire shape, parsed from a NON-EMPTY list.
|
||||
*
|
||||
* This is the test that was missing, and its absence had teeth. The
|
||||
* client read `user_id` from rows the relay emits as `{id, name, slug}`, the
|
||||
* buddy-list shape, not the `user_id` shape the §8 routes use. It threw
|
||||
* `No value for user_id`, and it survived because the only thing ever asserted
|
||||
* was an EMPTY block list: with no rows, the map never runs and the parser is
|
||||
* never exercised. Everything passed until the first person blocked somebody.
|
||||
*
|
||||
* So the case that matters is a list with something in it. An empty-list
|
||||
* assertion here would be worse than none, because it looks like coverage.
|
||||
*/
|
||||
class BlockListShapeTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
private fun api() = MessengerApi(
|
||||
server.url("/").toString().trimEnd('/'), "someone", "app-password")
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun api() = MessengerApi(server.url("/").toString().trimEnd('/'), "someone", "app-password")
|
||||
|
||||
private fun serve(body: String) {
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(body))
|
||||
@@ -37,13 +30,14 @@ class BlockListShapeTest {
|
||||
|
||||
@Test
|
||||
fun `a populated block list parses, using id rather than user_id`() {
|
||||
// Exactly what the relay emits, see BlockListResponse in Wire.swift
|
||||
// and the conformance assertion "the block list carries id, name and
|
||||
// slug".
|
||||
serve("""{"blocked":[
|
||||
// Exactly what the relay emits, see BlockListResponse in Wire.swift and
|
||||
// the conformance assertion "the block list carries id, name and slug".
|
||||
serve(
|
||||
"""{"blocked":[
|
||||
{"id": 42, "name": "Spam Lord", "slug": "spamlord"},
|
||||
{"id": 77, "name": "Someone Else", "slug": "someone-else"}
|
||||
]}""")
|
||||
]}""",
|
||||
)
|
||||
assertEquals(listOf(42L, 77L), api().blocked())
|
||||
}
|
||||
|
||||
@@ -54,10 +48,7 @@ class BlockListShapeTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A row shaped like the §8 routes must NOT quietly parse. If the relay
|
||||
* ever changed shape, silently reading a different field would hand back
|
||||
* an under-populated block list, and an under-populated block list is a
|
||||
* blocked person becoming visible again.
|
||||
* A row shaped like the §8 routes must NOT quietly parse.
|
||||
*/
|
||||
@Test
|
||||
fun `a row without id is a failure, not a silent gap in the list`() {
|
||||
|
||||
@@ -7,49 +7,59 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The multi-device fan-out rule (found 2026-08-29 in live two-device testing).
|
||||
*
|
||||
* `listDevices` returns identity-only STUBS, enough for a device we already
|
||||
* have a session with, useless for one we do not, because
|
||||
* `E2ESession.initiate` verifies the signed prekey's signature and a stub has
|
||||
* none. Choosing between the two lists meant that whenever ANY device already
|
||||
* had a session, every device got a stub, and the one still needing a session
|
||||
* dropped out of the fan-out. The other device still succeeded, so nothing
|
||||
* reported a partial delivery: a contact who added a second device simply
|
||||
* stopped receiving on it.
|
||||
*
|
||||
* The rule is UNION, not choice: full bundle where one exists, stub otherwise.
|
||||
*/
|
||||
class BundleMergeTest {
|
||||
private fun stub(id: String) =
|
||||
PeerBundle(
|
||||
deviceId = id,
|
||||
identityDH = ByteArray(32),
|
||||
identitySigning = ByteArray(32),
|
||||
signedPreKeyId = 0,
|
||||
signedPreKey = ByteArray(0),
|
||||
signedPreKeySignature = ByteArray(0),
|
||||
oneTimePreKeyId = 0,
|
||||
oneTimePreKey = null,
|
||||
proto = 1,
|
||||
)
|
||||
|
||||
private fun stub(id: String) = PeerBundle(
|
||||
deviceId = id, identityDH = ByteArray(32), identitySigning = ByteArray(32),
|
||||
signedPreKeyId = 0, signedPreKey = ByteArray(0),
|
||||
signedPreKeySignature = ByteArray(0), oneTimePreKeyId = 0,
|
||||
oneTimePreKey = null, proto = 1)
|
||||
|
||||
private fun full(id: String) = PeerBundle(
|
||||
deviceId = id, identityDH = ByteArray(32), identitySigning = ByteArray(32),
|
||||
signedPreKeyId = 7, signedPreKey = ByteArray(32) { 3 },
|
||||
signedPreKeySignature = ByteArray(64) { 4 }, oneTimePreKeyId = 1,
|
||||
oneTimePreKey = ByteArray(32) { 5 }, proto = 3)
|
||||
private fun full(id: String) =
|
||||
PeerBundle(
|
||||
deviceId = id,
|
||||
identityDH = ByteArray(32),
|
||||
identitySigning = ByteArray(32),
|
||||
signedPreKeyId = 7,
|
||||
signedPreKey = ByteArray(32) { 3 },
|
||||
signedPreKeySignature = ByteArray(64) { 4 },
|
||||
oneTimePreKeyId = 1,
|
||||
oneTimePreKey = ByteArray(32) { 5 },
|
||||
proto = 3,
|
||||
)
|
||||
|
||||
/** The merge as `sendPlaintext` performs it. */
|
||||
private fun merge(stubs: List<PeerBundle>, fetched: List<PeerBundle>): List<PeerBundle> {
|
||||
private fun merge(
|
||||
stubs: List<PeerBundle>,
|
||||
fetched: List<PeerBundle>,
|
||||
): List<PeerBundle> {
|
||||
val byId = fetched.associateBy { it.deviceId }
|
||||
return stubs.map { byId[it.deviceId] ?: it }
|
||||
}
|
||||
|
||||
@Test fun `every device the directory knows survives the merge`() {
|
||||
val merged = merge(listOf(stub("old"), stub("new")), listOf(full("new")))
|
||||
assertEquals(listOf("old", "new"), merged.map { it.deviceId },
|
||||
"a device must never drop out of the fan-out")
|
||||
assertEquals(
|
||||
listOf("old", "new"),
|
||||
merged.map { it.deviceId },
|
||||
"a device must never drop out of the fan-out",
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `a device needing a session gets the full bundle`() {
|
||||
val merged = merge(listOf(stub("old"), stub("new")), listOf(full("new")))
|
||||
val fresh = merged.first { it.deviceId == "new" }
|
||||
assertTrue(fresh.signedPreKey.isNotEmpty() && fresh.signedPreKeySignature.isNotEmpty(),
|
||||
"initiate verifies this signature; a stub fails on empty bytes")
|
||||
assertTrue(
|
||||
fresh.signedPreKey.isNotEmpty() && fresh.signedPreKeySignature.isNotEmpty(),
|
||||
"initiate verifies this signature; a stub fails on empty bytes",
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `devices the key route omits keep their stub`() {
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.flamenet.client
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
@@ -9,46 +10,39 @@ import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Talking to a chain node.
|
||||
*
|
||||
* The failure this suite is really about is a refusal read as an answer.
|
||||
* A JSON-RPC error arrives with HTTP 200, the transport succeeded, the node
|
||||
* said no. A client that checks only the status code carries on with a body
|
||||
* that has no `result` in it, fails somewhere less obvious, and throws away the
|
||||
* node's own explanation on the way. "Insufficient funds for rent exemption" on
|
||||
* screen versus a parse error nobody can act on is the whole difference.
|
||||
*
|
||||
* Against a `MockWebServer`, so these reach nothing real. See
|
||||
* `TestSafetyTest`, a suite that signs up against a mock is the correct way to
|
||||
* test signing up.
|
||||
*/
|
||||
class ChainRpcTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun rpc() = ChainRpc(server.url("/").toString())
|
||||
|
||||
private fun reply(json: String) =
|
||||
server.enqueue(MockResponse().setBody(json).setHeader("Content-Type", "application/json"))
|
||||
private fun reply(json: String) = server.enqueue(MockResponse().setBody(json).setHeader("Content-Type", "application/json"))
|
||||
|
||||
// ---- The refusal that looks like success ------------------------------
|
||||
// ---- The refusal that looks like success ------------------------------.
|
||||
|
||||
/**
|
||||
* The case the whole file exists for: HTTP 200, and a refusal in the
|
||||
* body.
|
||||
* The case the whole file exists for: HTTP 200, and a refusal in the body.
|
||||
*/
|
||||
@Test
|
||||
fun `a JSON-RPC error arriving with HTTP 200 is a failure, carrying the node's words`() {
|
||||
reply("""{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"insufficient funds"}}""")
|
||||
val thrown = assertFailsWith<ChainRpc.Failure.Node> { rpc().balance("addr") }
|
||||
assertEquals("insufficient funds", thrown.detail,
|
||||
"the node's own explanation was replaced with a guess")
|
||||
assertEquals(
|
||||
"insufficient funds",
|
||||
thrown.detail,
|
||||
"the node's own explanation was replaced with a guess",
|
||||
)
|
||||
}
|
||||
|
||||
/** An error with no message still refuses, rather than reading as success. */
|
||||
@@ -73,8 +67,7 @@ class ChainRpcTest {
|
||||
|
||||
/**
|
||||
* A body with neither `result` nor `error` is malformed rather than a
|
||||
* balance of zero. Reading a missing result as zero would show somebody an
|
||||
* empty wallet that is not empty.
|
||||
* balance of zero.
|
||||
*/
|
||||
@Test
|
||||
fun `a body with no result is malformed rather than a zero balance`() {
|
||||
@@ -82,7 +75,7 @@ class ChainRpcTest {
|
||||
assertFailsWith<ChainRpc.Failure.Malformed> { rpc().balance("addr") }
|
||||
}
|
||||
|
||||
// ---- Balance ---------------------------------------------------------
|
||||
// ---- Balance ---------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a balance is read in lamports`() {
|
||||
@@ -99,8 +92,7 @@ class ChainRpcTest {
|
||||
|
||||
/**
|
||||
* Lamports exceed what a 32-bit value holds long before the balance is
|
||||
* interesting: 5 SOL is already over Int.MAX_VALUE. Read as a Long
|
||||
* throughout, and this pins it.
|
||||
* interesting: 5 SOL is already over Int.MAX_VALUE.
|
||||
*/
|
||||
@Test
|
||||
fun `a balance larger than an Int is read intact`() {
|
||||
@@ -118,12 +110,10 @@ class ChainRpcTest {
|
||||
assertEquals("SomeAddress", sent.getJSONArray("params").getString(0))
|
||||
}
|
||||
|
||||
// ---- History ---------------------------------------------------------
|
||||
// ---- History ---------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* A failed transaction still appears in an address's history. Not
|
||||
* showing that it failed would be the worst kind of wrong: somebody would
|
||||
* believe a payment went through.
|
||||
* A failed transaction still appears in an address's history.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed transaction is reported as failed`() {
|
||||
@@ -131,7 +121,7 @@ class ChainRpcTest {
|
||||
"""{"jsonrpc":"2.0","id":1,"result":[
|
||||
{"signature":"sig-ok","slot":10,"blockTime":1700000000,"err":null},
|
||||
{"signature":"sig-bad","slot":11,"blockTime":1700000100,
|
||||
"err":{"InstructionError":[0,{"Custom":1}]}}]}"""
|
||||
"err":{"InstructionError":[0,{"Custom":1}]}}]}""",
|
||||
)
|
||||
val history = rpc().history("addr")
|
||||
assertEquals(2, history.size)
|
||||
@@ -141,34 +131,36 @@ class ChainRpcTest {
|
||||
|
||||
/**
|
||||
* `err` is an object for one kind of failure and an array for another.
|
||||
* Testing for "not null" rather than for a shape is what makes both
|
||||
* visible, checking for an object alone would silently pass one class of
|
||||
* failure off as a success.
|
||||
*/
|
||||
@Test
|
||||
fun `an error reported as an array is still a failure`() {
|
||||
reply("""{"jsonrpc":"2.0","id":1,"result":[
|
||||
{"signature":"sig","slot":1,"blockTime":1,"err":["something"]}]}""")
|
||||
reply(
|
||||
"""{"jsonrpc":"2.0","id":1,"result":[
|
||||
{"signature":"sig","slot":1,"blockTime":1,"err":["something"]}]}""",
|
||||
)
|
||||
assertTrue(rpc().history("addr").single().failed)
|
||||
}
|
||||
|
||||
/**
|
||||
* A very new transaction has no `blockTime` yet, the node has it and
|
||||
* has not dated it. Null means "just now"; reading a missing value as 0
|
||||
* would date it to 1970 on somebody's screen.
|
||||
* A very new transaction has no `blockTime` yet, the node has it and has
|
||||
* not dated it.
|
||||
*/
|
||||
@Test
|
||||
fun `a transaction the node has not dated yet has no time, not 1970`() {
|
||||
reply("""{"jsonrpc":"2.0","id":1,"result":[
|
||||
{"signature":"sig","slot":1,"blockTime":null,"err":null}]}""")
|
||||
reply(
|
||||
"""{"jsonrpc":"2.0","id":1,"result":[
|
||||
{"signature":"sig","slot":1,"blockTime":null,"err":null}]}""",
|
||||
)
|
||||
assertNull(rpc().history("addr").single().blockTimeSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row with no signature is dropped rather than shown blank`() {
|
||||
reply("""{"jsonrpc":"2.0","id":1,"result":[
|
||||
reply(
|
||||
"""{"jsonrpc":"2.0","id":1,"result":[
|
||||
{"slot":1,"err":null},
|
||||
{"signature":"sig","slot":2,"err":null}]}""")
|
||||
{"signature":"sig","slot":2,"err":null}]}""",
|
||||
)
|
||||
assertEquals(listOf("sig"), rpc().history("addr").map { it.signature })
|
||||
}
|
||||
|
||||
|
||||
@@ -17,24 +17,24 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The inbound path, with real ciphertext.
|
||||
*
|
||||
* `poll` was at 0%. The tests that cover it, the `Live*` suites, are
|
||||
* opt-in via `FN_LIVE_FLEET` and skip in an ordinary run, so the single most
|
||||
* important path in the client contributed nothing to a normal build. That is
|
||||
* worse than having no test, because the file listing says it is covered.
|
||||
*
|
||||
* Nothing here is stubbed below the transport: the peer is a real
|
||||
* `DeviceIdentity`, the envelopes are real ciphertext from a real ratchet, and
|
||||
* only the relay's HTTP is a mock. A decrypt that succeeds here succeeded for
|
||||
* the same reason it does in the field.
|
||||
*/
|
||||
class ConversationsPollTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
@@ -44,33 +44,44 @@ class ConversationsPollTest {
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
vault = Vault(MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
vault =
|
||||
Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
)
|
||||
me = DeviceIdentity.create()
|
||||
}
|
||||
|
||||
@AfterTest fun stop() = server.shutdown()
|
||||
|
||||
private fun conversations() =
|
||||
Conversations(me, vault, RelayClient(server.url("/").toString(), "a", "b"))
|
||||
private fun conversations() = Conversations(me, vault, RelayClient(server.url("/").toString(), "a", "b"))
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
/** My own bundle, as a peer would fetch it. */
|
||||
private fun myBundle(): PeerBundle {
|
||||
val opk = me.generateOneTimePreKeys(1).first()
|
||||
return PeerBundle.fromWire(me.registrationWire().put("opk",
|
||||
JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey))))
|
||||
return PeerBundle.fromWire(
|
||||
me.registrationWire().put(
|
||||
"opk",
|
||||
JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** One inbox page. */
|
||||
private fun inbox(vararg messages: String) =
|
||||
json("""{"messages":[${messages.joinToString(",")}]}""")
|
||||
private fun inbox(vararg messages: String) = json("""{"messages":[${messages.joinToString(",")}]}""")
|
||||
|
||||
private fun envelopeJson(id: Long, fromDevice: String, payload: String,
|
||||
fromUser: Long = 7) =
|
||||
"""{"id":$id,"from_user":$fromUser,"from_device":"$fromDevice","payload":"$payload"}"""
|
||||
private fun envelopeJson(
|
||||
id: Long,
|
||||
fromDevice: String,
|
||||
payload: String,
|
||||
fromUser: Long = 7,
|
||||
) = """{"id":$id,"from_user":$fromUser,"from_device":"$fromDevice","payload":"$payload"}"""
|
||||
|
||||
/** A peer who has never written to us before. */
|
||||
private fun stranger(): Pair<DeviceIdentity, E2ESession> {
|
||||
@@ -94,15 +105,17 @@ class ConversationsPollTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* An advancing watermark is what stops the same message arriving for
|
||||
* ever. It must move even though the relay decides the ids.
|
||||
* An advancing watermark is what stops the same message arriving for ever.
|
||||
*/
|
||||
@Test
|
||||
fun `the watermark advances past what was read`() {
|
||||
val (peer, session) = stranger()
|
||||
server.enqueue(inbox(
|
||||
envelopeJson(11, peer.deviceId, session.encrypt("one").toPayload()),
|
||||
envelopeJson(12, peer.deviceId, session.encrypt("two").toPayload())))
|
||||
server.enqueue(
|
||||
inbox(
|
||||
envelopeJson(11, peer.deviceId, session.encrypt("one").toPayload()),
|
||||
envelopeJson(12, peer.deviceId, session.encrypt("two").toPayload()),
|
||||
),
|
||||
)
|
||||
|
||||
conversations().poll()
|
||||
assertEquals(12, vault.inboxWatermark)
|
||||
@@ -115,16 +128,7 @@ class ConversationsPollTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Three chains to try, not one. This used to be
|
||||
* `existingSession(fromDevice) ?: respond(...)`, which reads as a fallback
|
||||
* and is not one: when a stored session existed it was the ONLY thing
|
||||
* tried, decrypt threw for a peer on a different chain, and the catch
|
||||
* swallowed it, so `respond` was never reached.
|
||||
*
|
||||
* That happens whenever both sides run X3DH before either has processed the
|
||||
* other's prekey: two people opening the same chat at once, which is
|
||||
* ordinary behaviour. Each adopts the other's chain, and from then on every
|
||||
* message arrives on a chain the receiver has thrown away.
|
||||
* Three chains to try, not one.
|
||||
*/
|
||||
@Test
|
||||
fun `a peer who started a new chain is still readable`() {
|
||||
@@ -138,8 +142,15 @@ class ConversationsPollTest {
|
||||
|
||||
// The peer restarts and initiates a brand new chain to the same device.
|
||||
val fresh = E2ESession.initiate(peer, myBundle())
|
||||
server.enqueue(inbox(envelopeJson(2, peer.deviceId,
|
||||
fresh.encrypt("from the new chain").toPayload())))
|
||||
server.enqueue(
|
||||
inbox(
|
||||
envelopeJson(
|
||||
2,
|
||||
peer.deviceId,
|
||||
fresh.encrypt("from the new chain").toPayload(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val received = conversations().poll()
|
||||
assertEquals(1, received.size, "the new chain was dropped")
|
||||
@@ -148,21 +159,23 @@ class ConversationsPollTest {
|
||||
|
||||
/**
|
||||
* A message that cannot be opened must not take the rest of the page with
|
||||
* it. One corrupt envelope from one peer would otherwise stall every
|
||||
* conversation behind it.
|
||||
* it.
|
||||
*/
|
||||
@Test
|
||||
fun `an unreadable message does not discard the rest of the batch`() {
|
||||
val (peer, session) = stranger()
|
||||
server.enqueue(inbox(
|
||||
envelopeJson(1, "garbage-device", "not-a-real-payload"),
|
||||
envelopeJson(2, peer.deviceId, session.encrypt("still delivered").toPayload())))
|
||||
server.enqueue(
|
||||
inbox(
|
||||
envelopeJson(1, "garbage-device", "not-a-real-payload"),
|
||||
envelopeJson(2, peer.deviceId, session.encrypt("still delivered").toPayload()),
|
||||
),
|
||||
)
|
||||
|
||||
val received = conversations().poll()
|
||||
assertEquals(1, received.size)
|
||||
assertEquals("still delivered", received.single().text)
|
||||
// And the watermark passes BOTH, or the unreadable one is retried
|
||||
// for ever and the inbox never drains.
|
||||
// And the watermark passes BOTH, or the unreadable one is retried for
|
||||
// ever and the inbox never drains.
|
||||
assertEquals(2, vault.inboxWatermark)
|
||||
}
|
||||
|
||||
@@ -174,20 +187,27 @@ class ConversationsPollTest {
|
||||
|
||||
/**
|
||||
* Two peers in one page are each opened on their own chain.
|
||||
*
|
||||
* They must be different USERS. Two identities claiming one user id is
|
||||
* exactly what the C1 gate refuses, so writing this with the default sender
|
||||
* makes the second message vanish, correctly, and confusingly.
|
||||
*/
|
||||
@Test
|
||||
fun `messages from two peers are both delivered`() {
|
||||
val (alice, aliceSession) = stranger()
|
||||
val (bob, bobSession) = stranger()
|
||||
server.enqueue(inbox(
|
||||
envelopeJson(1, alice.deviceId, aliceSession.encrypt("from alice").toPayload(),
|
||||
fromUser = 7),
|
||||
envelopeJson(2, bob.deviceId, bobSession.encrypt("from bob").toPayload(),
|
||||
fromUser = 8)))
|
||||
server.enqueue(
|
||||
inbox(
|
||||
envelopeJson(
|
||||
1,
|
||||
alice.deviceId,
|
||||
aliceSession.encrypt("from alice").toPayload(),
|
||||
fromUser = 7,
|
||||
),
|
||||
envelopeJson(
|
||||
2,
|
||||
bob.deviceId,
|
||||
bobSession.encrypt("from bob").toPayload(),
|
||||
fromUser = 8,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val texts = conversations().poll().map { it.text }.toSet()
|
||||
assertEquals(setOf("from alice", "from bob"), texts)
|
||||
@@ -200,14 +220,28 @@ class ConversationsPollTest {
|
||||
@Test
|
||||
fun `the opened session is stored for the next message`() {
|
||||
val (peer, session) = stranger()
|
||||
server.enqueue(inbox(envelopeJson(1, peer.deviceId,
|
||||
session.encrypt("first").toPayload())))
|
||||
server.enqueue(
|
||||
inbox(
|
||||
envelopeJson(
|
||||
1,
|
||||
peer.deviceId,
|
||||
session.encrypt("first").toPayload(),
|
||||
),
|
||||
),
|
||||
)
|
||||
conversations().poll()
|
||||
|
||||
// A second message on the SAME chain, read by a fresh Conversations
|
||||
// over the same vault.
|
||||
server.enqueue(inbox(envelopeJson(2, peer.deviceId,
|
||||
session.encrypt("second").toPayload())))
|
||||
server.enqueue(
|
||||
inbox(
|
||||
envelopeJson(
|
||||
2,
|
||||
peer.deviceId,
|
||||
session.encrypt("second").toPayload(),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals("second", conversations().poll().single().text)
|
||||
}
|
||||
|
||||
@@ -219,36 +253,41 @@ class ConversationsPollTest {
|
||||
val second = session.encrypt("two")
|
||||
val third = session.encrypt("three")
|
||||
|
||||
server.enqueue(inbox(
|
||||
envelopeJson(1, peer.deviceId, first.toPayload()),
|
||||
envelopeJson(3, peer.deviceId, third.toPayload()),
|
||||
envelopeJson(2, peer.deviceId, second.toPayload())))
|
||||
server.enqueue(
|
||||
inbox(
|
||||
envelopeJson(1, peer.deviceId, first.toPayload()),
|
||||
envelopeJson(3, peer.deviceId, third.toPayload()),
|
||||
envelopeJson(2, peer.deviceId, second.toPayload()),
|
||||
),
|
||||
)
|
||||
|
||||
val texts = conversations().poll().map { it.text }
|
||||
assertEquals(setOf("one", "two", "three"), texts.toSet())
|
||||
}
|
||||
|
||||
/**
|
||||
* The receive-side identity gate (audit C1). Building a session from
|
||||
* a prekey envelope means accepting whatever identity key it offers, so it
|
||||
* must pass the SAME pin check the send path applies. Without it, anyone
|
||||
* could hand us a handshake with their own key while claiming to be a
|
||||
* contact we have already pinned, and every later message from that session
|
||||
* would render as that contact.
|
||||
* The receive-side identity gate (audit C1).
|
||||
*/
|
||||
@Test
|
||||
fun `a handshake from an unpinned key claiming a pinned contact is refused`() {
|
||||
val realContact = DeviceIdentity.create()
|
||||
// We have pinned the real contact's identity for user 7.
|
||||
vault.saveTrust(7, Vault.Trust(
|
||||
identities = listOf(Wire.b64(realContact.identitySigning.publicKey)),
|
||||
verified = true))
|
||||
vault.saveTrust(
|
||||
7,
|
||||
Vault.Trust(
|
||||
identities = listOf(Wire.b64(realContact.identitySigning.publicKey)),
|
||||
verified = true,
|
||||
),
|
||||
)
|
||||
|
||||
// An impostor initiates, claiming to be user 7.
|
||||
val impostor = DeviceIdentity.create()
|
||||
val session = E2ESession.initiate(impostor, myBundle())
|
||||
server.enqueue(inbox(envelopeJson(
|
||||
1, impostor.deviceId, session.encrypt("trust me").toPayload(), fromUser = 7)))
|
||||
server.enqueue(
|
||||
inbox(
|
||||
envelopeJson(1, impostor.deviceId, session.encrypt("trust me").toPayload(), fromUser = 7),
|
||||
),
|
||||
)
|
||||
|
||||
val received = conversations().poll()
|
||||
assertEquals(0, received.size, "an unpinned key was accepted as a pinned contact")
|
||||
@@ -258,13 +297,20 @@ class ConversationsPollTest {
|
||||
@Test
|
||||
fun `a handshake from the pinned key is accepted`() {
|
||||
val contact = DeviceIdentity.create()
|
||||
vault.saveTrust(7, Vault.Trust(
|
||||
identities = listOf(Wire.b64(contact.identitySigning.publicKey)),
|
||||
verified = true))
|
||||
vault.saveTrust(
|
||||
7,
|
||||
Vault.Trust(
|
||||
identities = listOf(Wire.b64(contact.identitySigning.publicKey)),
|
||||
verified = true,
|
||||
),
|
||||
)
|
||||
|
||||
val session = E2ESession.initiate(contact, myBundle())
|
||||
server.enqueue(inbox(envelopeJson(
|
||||
1, contact.deviceId, session.encrypt("it is me").toPayload(), fromUser = 7)))
|
||||
server.enqueue(
|
||||
inbox(
|
||||
envelopeJson(1, contact.deviceId, session.encrypt("it is me").toPayload(), fromUser = 7),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("it is me", conversations().poll().single().text)
|
||||
}
|
||||
@@ -278,7 +324,11 @@ class ConversationsPollTest {
|
||||
fun `a failed poll is not reported as an empty inbox`() {
|
||||
server.enqueue(MockResponse().setResponseCode(503).setBody("down"))
|
||||
var threw = false
|
||||
try { conversations().poll() } catch (_: Exception) { threw = true }
|
||||
try {
|
||||
conversations().poll()
|
||||
} catch (_: Exception) {
|
||||
threw = true
|
||||
}
|
||||
assertTrue(threw, "a 503 was reported as an empty inbox")
|
||||
// The watermark did not move on a failure.
|
||||
assertEquals(0, vault.inboxWatermark)
|
||||
|
||||
@@ -8,18 +8,16 @@ import kotlin.test.assertTrue
|
||||
|
||||
/** What the app is allowed to say about why delivery failed (ios#21, #22, #23). */
|
||||
class DeliveryDiagnosticsTest {
|
||||
|
||||
@Test
|
||||
fun `identifiers are stripped from reasons before they are counted`() {
|
||||
// A reading meant to be safe to screenshot must not carry device ids.
|
||||
// These are the real shapes: the refusal messages name what was wrong,
|
||||
// and that sometimes means naming an id.
|
||||
val cases = listOf(
|
||||
"device ce0c0fb4 has published keys, but none match what this relay served",
|
||||
"device claims 41 but is bound to 12",
|
||||
"we do not hold signed prekey 7",
|
||||
"device claims 3f8a9c2e11bd but is bound to 9f0e",
|
||||
)
|
||||
val cases =
|
||||
listOf(
|
||||
"device ce0c0fb4 has published keys, but none match what this relay served",
|
||||
"device claims 41 but is bound to 12",
|
||||
"we do not hold signed prekey 7",
|
||||
"device claims 3f8a9c2e11bd but is bound to 9f0e",
|
||||
)
|
||||
for (raw in cases) {
|
||||
val out = DeliveryDiagnostics.normalisedReason(raw)
|
||||
assertFalse(out.any { it.isDigit() }, "'$out' still carries a digit from '$raw'")
|
||||
@@ -28,8 +26,7 @@ class DeliveryDiagnosticsTest {
|
||||
|
||||
@Test
|
||||
fun `a reason with nothing identifying survives intact`() {
|
||||
// It has to stay readable, or the screen says nothing. "base64"
|
||||
// contains a digit and must NOT be mangled.
|
||||
// It has to stay readable, or the screen says nothing.
|
||||
val plain = "the payload was not valid base64"
|
||||
assertEquals(plain, DeliveryDiagnostics.normalisedReason(plain))
|
||||
}
|
||||
|
||||
@@ -20,26 +20,24 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Single-use delivery capabilities (§21.4).
|
||||
*
|
||||
* The property under test is that a capability is SPENT, not merely
|
||||
* checked. One that survives its use is a reusable per-contact key, which is
|
||||
* a stable value the relay sees on every message from that sender, exactly the
|
||||
* pseudonym §21.2 rejects. So "the same one twice is refused" is not a
|
||||
* nice-to-have; it is the whole reason this is not per-contact delivery keys
|
||||
* with extra steps.
|
||||
*
|
||||
* The fixture keeps capability hashes in one flat set per recipient, with no
|
||||
* sender anywhere, because that is what the real relay stores. A stub that
|
||||
* indexed them by sender would let these tests pass on a property the relay
|
||||
* does not have.
|
||||
*/
|
||||
class DeliveryTokenTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
@@ -52,59 +50,81 @@ class DeliveryTokenTest {
|
||||
private val held = mutableSetOf<String>()
|
||||
private var spent = 0
|
||||
private var lastSend: JSONObject? = null
|
||||
|
||||
/** Set to make the relay refuse the next capability, as a revocation does. */
|
||||
private var refuseTokens = false
|
||||
|
||||
@BeforeTest fun start() {
|
||||
me = DeviceIdentity.create()
|
||||
peer = DeviceIdentity.create()
|
||||
server = MockWebServer().also { it.dispatcher = relay(); it.start() }
|
||||
vault = Vault(MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
server =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = relay()
|
||||
it.start()
|
||||
}
|
||||
vault =
|
||||
Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
)
|
||||
vault.saveIdentity(me)
|
||||
}
|
||||
|
||||
@AfterTest fun stop() = server.shutdown()
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
private fun relay() = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"/e2e/log/" in path -> MockResponse().setResponseCode(404)
|
||||
"/e2e/delivery-tokens" in path -> {
|
||||
val b = JSONObject(request.body.readUtf8())
|
||||
// Revoke first, so a client cutting one contact off while
|
||||
// topping another up in the same call keeps what it added.
|
||||
b.optJSONArray("revoke")?.let { a ->
|
||||
(0 until a.length()).forEach { held.remove(a.getString(it)) }
|
||||
private fun relay() =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"/e2e/log/" in path -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
b.optJSONArray("add")?.let { a ->
|
||||
(0 until a.length()).forEach { held.add(a.getString(it)) }
|
||||
}
|
||||
json("""{"ok":true,"remaining":${held.size}}""")
|
||||
}
|
||||
"/e2e/delivery-key" in path -> json("""{"ok":true}""")
|
||||
"/e2e/messages" in path && request.method == "POST" -> {
|
||||
val b = JSONObject(request.body.readUtf8())
|
||||
lastSend = b
|
||||
val token = b.optString("delivery_token").ifEmpty { null }
|
||||
if (token != null) {
|
||||
val hash = Wire.b64(Wire.sha256(Wire.unb64(token)))
|
||||
if (refuseTokens || !held.contains(hash)) {
|
||||
return MockResponse().setResponseCode(401).setBody("{}")
|
||||
|
||||
"/e2e/delivery-tokens" in path -> {
|
||||
val b = JSONObject(request.body.readUtf8())
|
||||
// Revoke first, so a client cutting one contact off while
|
||||
// topping another up in the same call keeps what it added.
|
||||
b.optJSONArray("revoke")?.let { a ->
|
||||
(0 until a.length()).forEach { held.remove(a.getString(it)) }
|
||||
}
|
||||
held.remove(hash)
|
||||
spent++
|
||||
b.optJSONArray("add")?.let { a ->
|
||||
(0 until a.length()).forEach { held.add(a.getString(it)) }
|
||||
}
|
||||
json("""{"ok":true,"remaining":${held.size}}""")
|
||||
}
|
||||
|
||||
"/e2e/delivery-key" in path -> {
|
||||
json("""{"ok":true}""")
|
||||
}
|
||||
|
||||
"/e2e/messages" in path && request.method == "POST" -> {
|
||||
val b = JSONObject(request.body.readUtf8())
|
||||
lastSend = b
|
||||
val token = b.optString("delivery_token").ifEmpty { null }
|
||||
if (token != null) {
|
||||
val hash = Wire.b64(Wire.sha256(Wire.unb64(token)))
|
||||
if (refuseTokens || !held.contains(hash)) {
|
||||
return MockResponse().setResponseCode(401).setBody("{}")
|
||||
}
|
||||
held.remove(hash)
|
||||
spent++
|
||||
}
|
||||
json("""{"ok":true}""")
|
||||
}
|
||||
|
||||
else -> {
|
||||
json("""{"devices":[${peer.registrationWire()}]}""")
|
||||
}
|
||||
json("""{"ok":true}""")
|
||||
}
|
||||
else -> json("""{"devices":[${peer.registrationWire()}]}""")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun conversations() =
|
||||
Conversations(me, vault, RelayClient(server.url("/").toString(), "me", "pw"))
|
||||
@@ -119,25 +139,28 @@ class DeliveryTokenTest {
|
||||
|
||||
private fun myBundle(): PeerBundle {
|
||||
val opk = me.generateOneTimePreKeys(1).first()
|
||||
return PeerBundle.fromWire(me.registrationWire().put("opk",
|
||||
JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey))))
|
||||
return PeerBundle.fromWire(
|
||||
me.registrationWire().put(
|
||||
"opk",
|
||||
JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Issuing
|
||||
|
||||
/**
|
||||
* The batch rides the EXISTING `delivery_key` envelope. A new control
|
||||
* kind would have arrived on shipped phones as a bubble full of raw JSON,
|
||||
* because they read the `ctl` flag with `optBoolean` and it does not coerce
|
||||
* the number the spec calls for.
|
||||
* The batch rides the EXISTING `delivery_key` envelope.
|
||||
*/
|
||||
@Test
|
||||
fun `a batch travels with the delivery key, not as a kind of its own`() {
|
||||
val c = withDeliveryKey()
|
||||
c.send(toUserId = 42, text = "hello")
|
||||
|
||||
val envelope = JSONObject(SealedSender.deliveryKeyEnvelope(
|
||||
vault.loadDelivery()!!.key, vault.issuedTokens(42)))
|
||||
val envelope =
|
||||
JSONObject(
|
||||
SealedSender.deliveryKeyEnvelope(vault.loadDelivery()!!.key, vault.issuedTokens(42)),
|
||||
)
|
||||
assertEquals("delivery_key", envelope.getString("kind"))
|
||||
assertEquals(64, envelope.getJSONArray("tokens").length())
|
||||
assertEquals(64, vault.issuedTokens(42).size)
|
||||
@@ -167,16 +190,13 @@ class DeliveryTokenTest {
|
||||
|
||||
assertEquals(1, spent)
|
||||
assertTrue(lastSend!!.optString("delivery_token").isNotEmpty())
|
||||
// And NOT the key alongside it. Sending both would let the relay
|
||||
// accept on the key when the capability had been revoked, so it would
|
||||
// never learn the capability was refused.
|
||||
// And NOT the key alongside it.
|
||||
assertTrue(lastSend!!.optString("delivery_key").isEmpty())
|
||||
assertTrue(vault.peerTokens(42).isEmpty(), "the capability was not spent locally")
|
||||
}
|
||||
|
||||
/**
|
||||
* The load-bearing one. A capability that survived its use is a reusable
|
||||
* per-contact key, and the relay then sees one constant per sender.
|
||||
* The load-bearing one.
|
||||
*/
|
||||
@Test
|
||||
fun `the same capability is not presented twice`() {
|
||||
@@ -213,9 +233,8 @@ class DeliveryTokenTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoking moves a sender back onto the shared key; it does not
|
||||
* silence them. Stated as a test because a client that implied otherwise
|
||||
* would be lying about the one thing this section is for.
|
||||
* Revoking moves a sender back onto the shared key; it does not silence
|
||||
* them.
|
||||
*/
|
||||
@Test
|
||||
fun `a refused capability falls back to the shared key rather than failing`() {
|
||||
@@ -237,10 +256,8 @@ class DeliveryTokenTest {
|
||||
// MARK: - Replenishment, counted rather than asked for
|
||||
|
||||
/**
|
||||
* A "running low" control message would have been a new kind, with the
|
||||
* same rendering problem on shipped phones. It is not needed: the issuer
|
||||
* counts, because every message it receives from a contact spent one of
|
||||
* their capabilities.
|
||||
* A "running low" control message would have been a new kind, with the same
|
||||
* rendering problem on shipped phones.
|
||||
*/
|
||||
@Test
|
||||
fun `the issuer counts what it receives and mints again at half a batch`() {
|
||||
@@ -272,15 +289,7 @@ class DeliveryTokenTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the act the whole section exists to make possible. The
|
||||
* relay cannot enforce a block against a sealed sender, the request
|
||||
* carries no sender to compare against a block list, so before §21.4,
|
||||
* blocking stopped the identified path and nothing else, and a blocked
|
||||
* person's sealed messages were dropped by the app after decryption rather
|
||||
* than refused at the door.
|
||||
*
|
||||
* The view model calls this alongside the block; asserted at this layer
|
||||
* because it is where the behaviour lives.
|
||||
* This is the act the whole section exists to make possible.
|
||||
*/
|
||||
@Test
|
||||
fun `revoking is what stops a sealed sender the relay cannot identify`() {
|
||||
@@ -310,43 +319,62 @@ class DeliveryTokenTest {
|
||||
val fresh = (0 until 3).map { Wire.b64(ByteArray(32) { i -> (it + i).toByte() }) }
|
||||
val envelope = SealedSender.deliveryKeyEnvelope(ByteArray(32) { 4 }, fresh)
|
||||
|
||||
c.consumeControl(42, io.flamenet.e2e.Content.parse(envelope))
|
||||
c.consumeControl(
|
||||
42,
|
||||
io.flamenet.e2e.Content
|
||||
.parse(envelope),
|
||||
)
|
||||
|
||||
assertEquals(fresh, vault.peerTokens(42))
|
||||
assertFalse(io.flamenet.e2e.Content.parse(envelope).isRenderable,
|
||||
"a capability batch reached the thread")
|
||||
assertFalse(
|
||||
io.flamenet.e2e.Content
|
||||
.parse(envelope)
|
||||
.isRenderable,
|
||||
"a capability batch reached the thread",
|
||||
)
|
||||
}
|
||||
|
||||
/** Malformed entries are dropped rather than stored: one that is not 32
|
||||
* bytes could never be spent, and keeping it would burn a send. */
|
||||
/**
|
||||
* Malformed entries are dropped rather than stored: one that is not 32.
|
||||
*/
|
||||
@Test
|
||||
fun `a malformed capability in a batch is dropped`() {
|
||||
val c = conversations()
|
||||
val good = Wire.b64(ByteArray(32) { 1 })
|
||||
val envelope = SealedSender.deliveryKeyEnvelope(
|
||||
ByteArray(32) { 4 }, listOf(good, "!!!!", Wire.b64(ByteArray(31))))
|
||||
val envelope =
|
||||
SealedSender.deliveryKeyEnvelope(ByteArray(32) { 4 }, listOf(good, "!!!!", Wire.b64(ByteArray(31))))
|
||||
|
||||
c.consumeControl(42, io.flamenet.e2e.Content.parse(envelope))
|
||||
c.consumeControl(
|
||||
42,
|
||||
io.flamenet.e2e.Content
|
||||
.parse(envelope),
|
||||
)
|
||||
|
||||
assertEquals(listOf(good), vault.peerTokens(42))
|
||||
}
|
||||
|
||||
/**
|
||||
* A contact who sends no batch leaves us on the shared key, which works.
|
||||
*
|
||||
* Needs our OWN key published, because a peer's key is stored inside our
|
||||
* delivery record. That is the app's real state, `ensureDeliveryKey` runs
|
||||
* at sign-in, and it is exactly why the capability store is separate and
|
||||
* does NOT depend on it.
|
||||
*/
|
||||
@Test
|
||||
fun `an envelope with no batch leaves the shared key alone`() {
|
||||
val c = withDeliveryKey()
|
||||
val envelope = SealedSender.deliveryKeyEnvelope(ByteArray(32) { 4 })
|
||||
|
||||
c.consumeControl(42, io.flamenet.e2e.Content.parse(envelope))
|
||||
c.consumeControl(
|
||||
42,
|
||||
io.flamenet.e2e.Content
|
||||
.parse(envelope),
|
||||
)
|
||||
|
||||
assertTrue(vault.peerTokens(42).isEmpty())
|
||||
assertEquals(32, vault.loadDelivery()?.peers?.get(42L)?.size)
|
||||
assertEquals(
|
||||
32,
|
||||
vault
|
||||
.loadDelivery()
|
||||
?.peers
|
||||
?.get(42L)
|
||||
?.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,24 +10,15 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Handling a wrong device clock (ios#45, Android half).
|
||||
*
|
||||
* The rule under test is an asymmetry, not a correction. A story
|
||||
* disappearing after an hour is a promise the SENDER made, enforced on the
|
||||
* READER's device. A reader whose clock runs behind keeps a message the sender
|
||||
* was told would be gone, and nobody involved can tell. Being wrong the other
|
||||
* way costs the reader something they were about to lose anyway.
|
||||
*
|
||||
* So expiry takes the LATER of the device's clock and the relay's, and these
|
||||
* tests are mostly about that one decision holding in every direction.
|
||||
*/
|
||||
class DeviceClockTest {
|
||||
|
||||
@BeforeTest fun clean() = DeviceClock.forget()
|
||||
|
||||
@AfterTest fun tidy() = DeviceClock.forget()
|
||||
|
||||
private val now = 1_700_000_000_000L
|
||||
|
||||
// ---- Learning the skew ----------------------------------------------
|
||||
// ---- Learning the skew ----------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a Date header teaches the skew`() {
|
||||
@@ -48,7 +39,7 @@ class DeviceClockTest {
|
||||
/**
|
||||
* A skew learned yesterday says nothing about today: the clock may have
|
||||
* been corrected, or the phone rebooted with a fresh answer from the
|
||||
* network. An observation that has aged out is the same as none.
|
||||
* network.
|
||||
*/
|
||||
@Test
|
||||
fun `an old observation is not used`() {
|
||||
@@ -58,13 +49,10 @@ class DeviceClockTest {
|
||||
assertNull(DeviceClock.usableSkew(now + DeviceClock.OBSERVATION_LIFETIME_MS + 1))
|
||||
}
|
||||
|
||||
// ---- The rule that matters ------------------------------------------
|
||||
// ---- The rule that matters ------------------------------------------.
|
||||
|
||||
/**
|
||||
* A device clock set BACK cannot extend a message's life. This is the
|
||||
* case the whole file exists for: the phone thinks it is earlier than it is,
|
||||
* so on its own reading a disappearing message is still alive. The server's
|
||||
* time overrules it.
|
||||
* A device clock set BACK cannot extend a message's life.
|
||||
*/
|
||||
@Test
|
||||
fun `a device running behind does not keep a message alive`() {
|
||||
@@ -74,15 +62,16 @@ class DeviceClockTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* And a relay that lies about the time cannot either. A hostile relay
|
||||
* would like to move time BACKWARDS so disappearing messages linger. Taking
|
||||
* the later of the two means its answer is simply ignored.
|
||||
* And a relay that lies about the time cannot either.
|
||||
*/
|
||||
@Test
|
||||
fun `a relay claiming an earlier time is ignored`() {
|
||||
DeviceClock.observe(now - 600_000L, now)
|
||||
assertEquals(now, DeviceClock.nowForExpiry(now),
|
||||
"the relay's earlier time was taken, so a hostile relay can extend expiries")
|
||||
assertEquals(
|
||||
now,
|
||||
DeviceClock.nowForExpiry(now),
|
||||
"the relay's earlier time was taken, so a hostile relay can extend expiries",
|
||||
)
|
||||
}
|
||||
|
||||
/** With nothing learned, the device's own clock is all there is. */
|
||||
@@ -115,7 +104,7 @@ class DeviceClockTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Telling somebody -----------------------------------------------
|
||||
// ---- Telling somebody -----------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `ordinary drift says nothing`() {
|
||||
@@ -130,8 +119,7 @@ class DeviceClockTest {
|
||||
assertTrue(DeviceClock.isSuspect(now))
|
||||
val warning = DeviceClock.warning(now)!!
|
||||
assertTrue(warning.contains("15 minutes behind"), warning)
|
||||
// The symptom before the cause. Somebody whose stories keep vanishing
|
||||
// does not think "my clock is wrong"; they think the app is broken.
|
||||
// The symptom before the cause.
|
||||
assertTrue(warning.contains("Stories and disappearing messages"), warning)
|
||||
}
|
||||
|
||||
@@ -151,12 +139,10 @@ class DeviceClockTest {
|
||||
assertTrue(DeviceClock.isSuspect(now))
|
||||
}
|
||||
|
||||
// ---- Shape ----------------------------------------------------------
|
||||
// ---- Shape ----------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Seconds as a Double, matching `Vault.Story.expires`. Flooring to a Long
|
||||
* of seconds here would lose the sub-second part on the boundary that
|
||||
* decides whether a story is still on screen.
|
||||
* Seconds as a Double, matching `Vault.Story.expires`.
|
||||
*/
|
||||
@Test
|
||||
fun `expiry seconds keep sub-second precision`() {
|
||||
@@ -175,9 +161,7 @@ class DeviceClockTest {
|
||||
|
||||
/**
|
||||
* The round trip through the header format, because that is what actually
|
||||
* arrives. A formatter that took its locale from the device parses nothing
|
||||
* on a phone set to most of the world, and the failure is silent, the
|
||||
* header is simply never understood and the skew is never learned.
|
||||
* arrives.
|
||||
*/
|
||||
@Test
|
||||
fun `the header format round-trips`() {
|
||||
|
||||
@@ -12,33 +12,44 @@ import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* The seam where a peer's timer announcement becomes a stored fact (§6.11).
|
||||
*
|
||||
* Symmetric or worthless. If only the party who set the timer expires
|
||||
* their copy, the feature is a lie: they believe their words are gone from a
|
||||
* phone that is still holding them. So a `ttl` envelope from the other side has
|
||||
* to be adopted, not merely offered, which is what this asserts.
|
||||
*/
|
||||
class DisappearingControlTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun conversations(): Pair<Conversations, Vault> {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
).also { it.saveIdentity(identity) }
|
||||
val vault =
|
||||
Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
).also { it.saveIdentity(identity) }
|
||||
val relay = RelayClient(server.url("/").toString(), "alice", "app pass word")
|
||||
return Conversations(identity, vault, relay) to vault
|
||||
}
|
||||
|
||||
@@ -11,20 +11,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A 403 that means "check your email", told apart from every other 403.
|
||||
*
|
||||
* Without this it is indistinguishable from a credential problem. An
|
||||
* unverified account arrives as a bare 403; `RelayAuth` logs "minting failed"
|
||||
* and falls back to Basic, which the e2e routes refuse. The person then sees
|
||||
* calls that do not work and an inbox that does nothing, and nothing anywhere
|
||||
* says the remedy is a code sitting in their inbox.
|
||||
*
|
||||
* The status alone is not enough: 403 is also "that device belongs to
|
||||
* another account". Telling somebody to check their email for that would send
|
||||
* them looking for a message that was never sent, so the relay's own `code` is
|
||||
* what decides, not the number.
|
||||
*/
|
||||
class EmailUnverifiedTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() {
|
||||
@@ -36,34 +24,41 @@ class EmailUnverifiedTest {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun relay() = RelayClient(
|
||||
server.url("/").toString(), username = "ada", appPassword = "credential")
|
||||
private fun relay() = RelayClient(server.url("/").toString(), username = "ada", appPassword = "credential")
|
||||
|
||||
private fun refuse(code: String?, status: Int = 403) {
|
||||
val body = if (code == null) """{"message":"no"}"""
|
||||
else """{"code":"$code","message":"Check your email and enter the code."}"""
|
||||
private fun refuse(
|
||||
code: String?,
|
||||
status: Int = 403,
|
||||
) {
|
||||
val body =
|
||||
if (code == null) {
|
||||
"""{"message":"no"}"""
|
||||
} else {
|
||||
"""{"code":"$code","message":"Check your email and enter the code."}"""
|
||||
}
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(status)
|
||||
MockResponse()
|
||||
.setResponseCode(status)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
.setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unverified account is its own failure, not a generic 403`() {
|
||||
refuse("email_unverified")
|
||||
val failure = assertFailsWith<RelayException.EmailUnverified> {
|
||||
relay().listDevices(1)
|
||||
}
|
||||
val failure =
|
||||
assertFailsWith<RelayException.EmailUnverified> {
|
||||
relay().listDevices(1)
|
||||
}
|
||||
assertTrue(
|
||||
failure.body.contains("code"),
|
||||
"the relay's sentence should survive: ${failure.body}"
|
||||
"the relay's sentence should survive: ${failure.body}",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinction that matters. A device conflict is also a 403 and must
|
||||
* NOT be reported as an unverified address.
|
||||
* The distinction that matters.
|
||||
*/
|
||||
@Test
|
||||
fun `another 403 is not mistaken for an unverified address`() {
|
||||
@@ -83,9 +78,10 @@ class EmailUnverifiedTest {
|
||||
@Test
|
||||
fun `an unparseable 403 body does not crash`() {
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(403)
|
||||
MockResponse()
|
||||
.setResponseCode(403)
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.setBody("<html>a proxy wrote this</html>")
|
||||
.setBody("<html>a proxy wrote this</html>"),
|
||||
)
|
||||
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
|
||||
}
|
||||
|
||||
@@ -20,25 +20,24 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Messages that crossed a relay boundary.
|
||||
*
|
||||
* The relay records no sender for one of these, deliberately: it was not
|
||||
* told who wrote it and must not invent an attribution. So `from_user` arrives
|
||||
* as 0 and the only thing naming the sender is the address sealed inside the
|
||||
* blob. Every assertion here is about what happens to a message with no
|
||||
* trustworthy sender on the transport, because the failure mode is not a
|
||||
* crash, it is every federated message from everybody silently piling into one
|
||||
* nameless thread, or worse, into a local contact's.
|
||||
*
|
||||
* Real ciphertext throughout: a real peer identity, a real ratchet, a real
|
||||
* seal. Only the relay's HTTP is a mock.
|
||||
*/
|
||||
class FederatedReceiveTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
@@ -48,47 +47,58 @@ class FederatedReceiveTest {
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
vault = Vault(MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
vault =
|
||||
Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
)
|
||||
me = DeviceIdentity.create()
|
||||
}
|
||||
|
||||
@AfterTest fun stop() = server.shutdown()
|
||||
|
||||
private fun conversations() =
|
||||
Conversations(me, vault, RelayClient(server.url("/").toString(), "a", "b"))
|
||||
private fun conversations() = Conversations(me, vault, RelayClient(server.url("/").toString(), "a", "b"))
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
private fun myBundle(): PeerBundle {
|
||||
val opk = me.generateOneTimePreKeys(1).first()
|
||||
return PeerBundle.fromWire(me.registrationWire().put("opk",
|
||||
JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey))))
|
||||
return PeerBundle.fromWire(
|
||||
me.registrationWire().put(
|
||||
"opk",
|
||||
JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One envelope as a federated deposit looks on our inbox: no sender at all.
|
||||
*
|
||||
* `from_user: 0` and an empty `from_device` are not laziness in the
|
||||
* fixture, they are exactly what `federationDeposit` writes, and a test
|
||||
* that filled them in would be testing the local path with extra steps.
|
||||
*/
|
||||
private fun deposited(id: Long, payload: String) =
|
||||
json("""{"messages":[{"id":$id,"from_user":0,"from_device":"","payload":"$payload"}]}""")
|
||||
private fun deposited(
|
||||
id: Long,
|
||||
payload: String,
|
||||
) = json("""{"messages":[{"id":$id,"from_user":0,"from_device":"","payload":"$payload"}]}""")
|
||||
|
||||
/** A peer on another relay, writing to us for the first time. */
|
||||
private fun remotePeer(address: String, text: String): Pair<DeviceIdentity, String> {
|
||||
private fun remotePeer(
|
||||
address: String,
|
||||
text: String,
|
||||
): Pair<DeviceIdentity, String> {
|
||||
val peer = DeviceIdentity.create()
|
||||
val session = E2ESession.initiate(peer, myBundle())
|
||||
val blob = SealedSender.seal(
|
||||
envelope = session.encrypt(text),
|
||||
fromUser = 12, // their row number, meaningless here
|
||||
fromDevice = peer.deviceId,
|
||||
toDevice = me.deviceId,
|
||||
recipientIKdh = me.identityDHPublic,
|
||||
fromAddress = address,
|
||||
)
|
||||
val blob =
|
||||
SealedSender.seal(
|
||||
envelope = session.encrypt(text),
|
||||
fromUser = 12, // their row number, meaningless here
|
||||
fromDevice = peer.deviceId,
|
||||
toDevice = me.deviceId,
|
||||
recipientIKdh = me.identityDHPublic,
|
||||
fromAddress = address,
|
||||
)
|
||||
return peer to blob.toPayload()
|
||||
}
|
||||
|
||||
@@ -107,32 +117,31 @@ class FederatedReceiveTest {
|
||||
assertEquals("hello from over there", received.text)
|
||||
assertEquals(theirAddress, received.fromAddress)
|
||||
assertEquals(peer.deviceId, received.fromDevice)
|
||||
// The point of the whole test. Their `from_user` of 12 is a row
|
||||
// number on their relay; adopting it here would file this under
|
||||
// whoever user 12 happens to be on ours.
|
||||
// The point of the whole test.
|
||||
assertEquals(0L, received.fromUser)
|
||||
}
|
||||
|
||||
/**
|
||||
* The claim inside a seal is only a claim: anyone holding this device's
|
||||
* published `IK_dh` can write any address they like there. The ratchet
|
||||
* proves the DEVICE, so the two are bound first-use-wins, and a device
|
||||
* that later claims somebody else's address keeps the one it was seen with.
|
||||
*
|
||||
* Without this, an established thread can be renamed out from under the
|
||||
* person reading it, which is the whole value of a stable address undone.
|
||||
* published `IK_dh` can write any address they like there.
|
||||
*/
|
||||
@Test
|
||||
fun `a device that changes its claimed address keeps the first one`() {
|
||||
val peer = DeviceIdentity.create()
|
||||
val session = E2ESession.initiate(peer, myBundle())
|
||||
|
||||
fun sealed(address: String, text: String) = SealedSender.seal(
|
||||
envelope = session.encrypt(text),
|
||||
fromUser = 12, fromDevice = peer.deviceId,
|
||||
toDevice = me.deviceId, recipientIKdh = me.identityDHPublic,
|
||||
fromAddress = address,
|
||||
).toPayload()
|
||||
fun sealed(
|
||||
address: String,
|
||||
text: String,
|
||||
) = SealedSender
|
||||
.seal(
|
||||
envelope = session.encrypt(text),
|
||||
fromUser = 12,
|
||||
fromDevice = peer.deviceId,
|
||||
toDevice = me.deviceId,
|
||||
recipientIKdh = me.identityDHPublic,
|
||||
fromAddress = address,
|
||||
).toPayload()
|
||||
|
||||
val conversations = conversations()
|
||||
server.enqueue(deposited(1, sealed(theirAddress, "first")))
|
||||
@@ -143,8 +152,11 @@ class FederatedReceiveTest {
|
||||
val second = conversations.poll().single()
|
||||
|
||||
assertEquals("second", second.text, "the message is genuine and still delivered")
|
||||
assertEquals(theirAddress, second.fromAddress,
|
||||
"a device keeps the address it was first bound to")
|
||||
assertEquals(
|
||||
theirAddress,
|
||||
second.fromAddress,
|
||||
"a device keeps the address it was first bound to",
|
||||
)
|
||||
}
|
||||
|
||||
/** An ordinary local message is untouched by any of this. */
|
||||
@@ -152,9 +164,13 @@ class FederatedReceiveTest {
|
||||
fun `a local message still has no address and keeps its user id`() {
|
||||
val peer = DeviceIdentity.create()
|
||||
val session = E2ESession.initiate(peer, myBundle())
|
||||
server.enqueue(json("""{"messages":[{"id":1,"from_user":7,""" +
|
||||
""""from_device":"${peer.deviceId}","payload":"${
|
||||
session.encrypt("ordinary").toPayload()}"}]}"""))
|
||||
server.enqueue(
|
||||
json(
|
||||
"""{"messages":[{"id":1,"from_user":7,""" +
|
||||
""""from_device":"${peer.deviceId}","payload":"${
|
||||
session.encrypt("ordinary").toPayload()}"}]}""",
|
||||
),
|
||||
)
|
||||
|
||||
val received = conversations().poll().single()
|
||||
|
||||
@@ -199,10 +215,7 @@ class FederatedReceiveTest {
|
||||
// MARK: - What an address is allowed to be
|
||||
|
||||
/**
|
||||
* A relay is a hostname, never a URL. An address is parsed from a seal
|
||||
* written by somebody nobody authenticated, so accepting `https://x/y` here
|
||||
* would let a stranger point this client at a path on a host of their
|
||||
* choosing, and would do it from inside a message.
|
||||
* A relay is a hostname, never a URL.
|
||||
*/
|
||||
@Test
|
||||
fun `an address carrying a URL is not an address`() {
|
||||
@@ -227,27 +240,28 @@ class FederatedReceiveTest {
|
||||
|
||||
/**
|
||||
* A federated message cannot write control state keyed by user id.
|
||||
*
|
||||
* `consumeControl(0L, ...)` would poison user 0's delivery key and
|
||||
* avatar, and user 0 is the id every unattributed message carries, so one
|
||||
* stranger on another relay could break sealing for everybody.
|
||||
*/
|
||||
@Test
|
||||
fun `a remote sender cannot set a local delivery key`() {
|
||||
val peer = DeviceIdentity.create()
|
||||
val session = E2ESession.initiate(peer, myBundle())
|
||||
val control = SealedSender.deliveryKeyEnvelope(ByteArray(32) { 9 })
|
||||
val blob = SealedSender.seal(
|
||||
envelope = session.encrypt(control),
|
||||
fromUser = 0, fromDevice = peer.deviceId,
|
||||
toDevice = me.deviceId, recipientIKdh = me.identityDHPublic,
|
||||
fromAddress = theirAddress,
|
||||
)
|
||||
val blob =
|
||||
SealedSender.seal(
|
||||
envelope = session.encrypt(control),
|
||||
fromUser = 0,
|
||||
fromDevice = peer.deviceId,
|
||||
toDevice = me.deviceId,
|
||||
recipientIKdh = me.identityDHPublic,
|
||||
fromAddress = theirAddress,
|
||||
)
|
||||
server.enqueue(deposited(1, blob.toPayload()))
|
||||
|
||||
conversations().poll()
|
||||
|
||||
assertNull(vault.loadDelivery()?.peers?.get(0L),
|
||||
"a federated sender must not write into the local keyspace")
|
||||
assertNull(
|
||||
vault.loadDelivery()?.peers?.get(0L),
|
||||
"a federated sender must not write into the local keyspace",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* 451, the country gate (§ edge policy).
|
||||
*
|
||||
* The download site is deliberately NOT gated. Someone outside the
|
||||
* served countries can read the page, install the app, choose a password, tap
|
||||
* sign up, and only then does the edge answer 451, before the request ever
|
||||
* reaches the relay. Reported as a status code, that reads as a broken app:
|
||||
* nothing mentions their country, and it is not a failure they can retry past.
|
||||
*/
|
||||
class GeoUnavailableTest {
|
||||
|
||||
@Test
|
||||
fun `the message names the country, not the status code`() {
|
||||
val message = RelayException.Unavailable("").message.orEmpty()
|
||||
@@ -22,8 +15,7 @@ class GeoUnavailableTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* It must also say what it is NOT. Someone who has just typed a password
|
||||
* will otherwise assume they got it wrong and try again, and again.
|
||||
* It must also say what it is NOT.
|
||||
*/
|
||||
@Test
|
||||
fun `the message rules out the account and the connection`() {
|
||||
|
||||
@@ -15,81 +15,79 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* §19.4, our view of the log has to actually go out.
|
||||
*
|
||||
* This was dead code on both apps. `Trust.headPayload` (and iOS's
|
||||
* `keyLogHeadPayload`) built the envelope and nothing ever called it, while
|
||||
* the *receiving* half was fully wired. The result is the failure that looks
|
||||
* like success: every client verifies proofs, pins heads, and reports
|
||||
* transparency working, and a relay serving two branches is never caught,
|
||||
* because catching it requires two clients to COMPARE heads and no client ever
|
||||
* offered one. Only the JS reference client sent it, so gossip worked web→app
|
||||
* and in no other direction.
|
||||
*
|
||||
* The receive path already has tests. This asserts the half that did not: that
|
||||
* a send carries the head, that it rides the ratchet rather than going out in
|
||||
* the clear where the audited party could rewrite it, and that it is not
|
||||
* repeated on every message.
|
||||
*/
|
||||
class GossipHeadSentTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
/** A real peer, published in the shape `PeerBundle.fromWire` parses. */
|
||||
private val bob = DeviceIdentity.create()
|
||||
|
||||
private fun bobWire(): String = JSONObject()
|
||||
.put("device_id", bob.deviceId)
|
||||
.put("ik_dh", Wire.b64(bob.identityDHPublic))
|
||||
.put("ik_sig", Wire.b64(bob.identitySigningPublic))
|
||||
.put("spk_id", bob.signedPreKeyId)
|
||||
.put("spk", Wire.b64(X25519.rawFromPublicKey(bob.signedPreKey.public)))
|
||||
.put("spk_sig", Wire.b64(bob.signedPreKeySignature()))
|
||||
.put("proto", 1)
|
||||
.toString()
|
||||
private fun bobWire(): String =
|
||||
JSONObject()
|
||||
.put("device_id", bob.deviceId)
|
||||
.put("ik_dh", Wire.b64(bob.identityDHPublic))
|
||||
.put("ik_sig", Wire.b64(bob.identitySigningPublic))
|
||||
.put("spk_id", bob.signedPreKeyId)
|
||||
.put("spk", Wire.b64(X25519.rawFromPublicKey(bob.signedPreKey.public)))
|
||||
.put("spk_sig", Wire.b64(bob.signedPreKeySignature()))
|
||||
.put("proto", 1)
|
||||
.toString()
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
/**
|
||||
* A relay that keeps no transparency log.
|
||||
*
|
||||
* Not incidental setup. Sending now asks for a head on its own, because
|
||||
* key-log checking is automatic on any relay that serves one, so a queue
|
||||
* of bodies matched purely by ORDER would hand the head request the device
|
||||
* listing and fail on a parse error miles from the cause. Answering by path
|
||||
* says what this fixture is: a relay from before §19, where gossip is the
|
||||
* only part of transparency in play.
|
||||
*/
|
||||
private fun noKeyLog() {
|
||||
server.dispatcher = object : okhttp3.mockwebserver.Dispatcher() {
|
||||
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
// `contains`, not `startsWith`: every route is under the
|
||||
// REST mount, so the recorded path begins with that prefix
|
||||
// rather than with `/e2e`.
|
||||
"/e2e/log/" in path -> MockResponse().setResponseCode(404)
|
||||
path.contains("/e2e/messages") -> json("""{"ok":true}""")
|
||||
else -> json("""{"devices":[${bobWire()}]}""")
|
||||
server.dispatcher =
|
||||
object : okhttp3.mockwebserver.Dispatcher() {
|
||||
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
// `contains`, not `startsWith`: every route is under the
|
||||
// REST mount, so the recorded path begins with that prefix
|
||||
// rather than with `/e2e`.
|
||||
"/e2e/log/" in path -> MockResponse().setResponseCode(404)
|
||||
|
||||
path.contains("/e2e/messages") -> json("""{"ok":true}""")
|
||||
|
||||
else -> json("""{"devices":[${bobWire()}]}""")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Alice, with a head already pinned, nothing is gossiped without one. */
|
||||
@@ -103,8 +101,6 @@ class GossipHeadSentTest {
|
||||
return Conversations(identity, vault, relay)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Every payload POSTed to `/e2e/messages`, as the relay would see them. */
|
||||
private fun postedPayloads(): List<String> {
|
||||
val bodies = mutableListOf<String>()
|
||||
@@ -127,18 +123,17 @@ class GossipHeadSentTest {
|
||||
val sent = alice(pinned = true).send(toUserId = 42, text = "an ordinary message")
|
||||
|
||||
assertTrue(sent.failed.isEmpty(), "the send itself should succeed: ${sent.failed}")
|
||||
// Two envelopes for the one device: the gossiped head, then the message.
|
||||
// Before the fix this was one, and the head never left the phone.
|
||||
assertEquals(2, postedPayloads().size,
|
||||
"a first send to a contact must carry the §19.4 head alongside the message")
|
||||
// Two envelopes for the one device: the gossiped head, then the
|
||||
// message.
|
||||
assertEquals(
|
||||
2,
|
||||
postedPayloads().size,
|
||||
"a first send to a contact must carry the §19.4 head alongside the message",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* In-band is the point. The relay is the party being audited, so
|
||||
* it must not be able to read which head we hold nor rewrite it in transit.
|
||||
* A head that appears in the clear in the POST body would let a forked
|
||||
* relay answer each client with the branch that client already believes,
|
||||
* which is exactly the attack this mechanism exists to catch.
|
||||
* In-band is the point.
|
||||
*/
|
||||
@Test
|
||||
fun `the head is encrypted, never posted in the clear`() {
|
||||
@@ -151,10 +146,14 @@ class GossipHeadSentTest {
|
||||
// exactly like a test that checks everything.
|
||||
assertEquals(2, payloads.size, "expected the head and the message")
|
||||
for (payload in payloads) {
|
||||
assertTrue("kt_head" !in payload,
|
||||
"the gossiped head must ride the ratchet, not the envelope: $payload")
|
||||
assertTrue("an ordinary message" !in payload,
|
||||
"sanity: message text must not be in the clear either")
|
||||
assertTrue(
|
||||
"kt_head" !in payload,
|
||||
"the gossiped head must ride the ratchet, not the envelope: $payload",
|
||||
)
|
||||
assertTrue(
|
||||
"an ordinary message" !in payload,
|
||||
"sanity: message text must not be in the clear either",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,8 +165,11 @@ class GossipHeadSentTest {
|
||||
conversations.send(toUserId = 42, text = "first")
|
||||
conversations.send(toUserId = 42, text = "second")
|
||||
|
||||
assertEquals(3, postedPayloads().size,
|
||||
"the head goes once: head + first + second, not a head on each message")
|
||||
assertEquals(
|
||||
3,
|
||||
postedPayloads().size,
|
||||
"the head goes once: head + first + second, not a head on each message",
|
||||
)
|
||||
}
|
||||
|
||||
/** Nothing to gossip before a head has ever been verified and pinned. */
|
||||
@@ -176,7 +178,10 @@ class GossipHeadSentTest {
|
||||
noKeyLog()
|
||||
alice(pinned = false).send(toUserId = 42, text = "an ordinary message")
|
||||
|
||||
assertEquals(1, postedPayloads().size,
|
||||
"with no pin there is no view of the log to offer")
|
||||
assertEquals(
|
||||
1,
|
||||
postedPayloads().size,
|
||||
"with no pin there is no view of the log to offer",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,37 +19,42 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Group names, after the relay stopped holding them.
|
||||
*
|
||||
* This was the largest single thing the server knew about anybody. Group
|
||||
* membership it needs, to route. The NAME it never needed and never read, no
|
||||
* push, no enforcement, no lookup, and it was sitting in a column in the
|
||||
* clear: "Chemotherapy support", "Union organising", a sentence about somebody
|
||||
* on a machine that can be dumped, seized or subpoenaed.
|
||||
*
|
||||
* So the name moved to the members, who already have a way to tell each other
|
||||
* things the server cannot read. What these tests hold down is the pair of
|
||||
* claims that makes that a real change rather than a rearrangement: the name
|
||||
* never reaches the relay, and it still reaches the people in the group.
|
||||
*/
|
||||
class GroupNameTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun vault(): Vault = Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
).also { it.saveIdentity(DeviceIdentity.create()) }
|
||||
private fun vault(): Vault =
|
||||
Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
).also { it.saveIdentity(DeviceIdentity.create()) }
|
||||
|
||||
private fun conversations(vault: Vault): Conversations {
|
||||
val identity = vault.loadIdentity() ?: DeviceIdentity.create()
|
||||
@@ -57,19 +62,17 @@ class GroupNameTest {
|
||||
return Conversations(identity, vault, relay)
|
||||
}
|
||||
|
||||
// -- What the relay is told -----------------------------------------------
|
||||
// -- What the relay is told
|
||||
// -----------------------------------------------.
|
||||
|
||||
/**
|
||||
* Absent, not blank. A request that carried an empty `name` would
|
||||
* still be a request in which the client had decided to trust the relay
|
||||
* with the field; this asserts the field is not there at all, so there is
|
||||
* no version of the server that could start writing it down again without
|
||||
* a client change.
|
||||
* Absent, not blank.
|
||||
*/
|
||||
@Test
|
||||
fun `creating a group sends no name to the relay`() {
|
||||
server.enqueue(MockResponse().setBody(
|
||||
"""{"ok":true,"gid":"g1","name":"Group","owner":1,"members":[{"user_id":2}]}"""))
|
||||
server.enqueue(
|
||||
MockResponse().setBody("""{"ok":true,"gid":"g1","name":"Group","owner":1,"members":[{"user_id":2}]}"""),
|
||||
)
|
||||
val relay = RelayClient(server.url("/").toString(), "alice", "app pass word")
|
||||
|
||||
relay.createGroup(listOf(2L))
|
||||
@@ -79,13 +82,11 @@ class GroupNameTest {
|
||||
assertTrue(body.contains("\"members\""), "the roster is missing: $body")
|
||||
}
|
||||
|
||||
// -- What the members are told --------------------------------------------
|
||||
// -- What the members are told
|
||||
// --------------------------------------------.
|
||||
|
||||
/**
|
||||
* Every group envelope repeats the name. The announcement sent at
|
||||
* creation only reaches who was online and only the devices that existed
|
||||
* then; a phone set up later would otherwise sit on the relay's placeholder
|
||||
* for ever, because there is nobody left to ask.
|
||||
* Every group envelope repeats the name.
|
||||
*/
|
||||
@Test
|
||||
fun `a group message carries the group name`() {
|
||||
@@ -103,21 +104,17 @@ class GroupNameTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The announcement rides `delivery_key` precisely because that kind is
|
||||
* old. A new `group_name` kind would arrive on every phone running v5 as
|
||||
* a bubble full of raw JSON: those builds recognise control traffic by kind
|
||||
* and read the `ctl` flag with `optBoolean`, which does not coerce the
|
||||
* number §6.10 specifies. This asserts the envelope is still a well-formed
|
||||
* delivery-key message, the half a v5 phone acts on, with the name as
|
||||
* extra fields it will ignore.
|
||||
* The announcement rides `delivery_key` precisely because that kind is old.
|
||||
*/
|
||||
@Test
|
||||
fun `the announcement is a delivery key envelope an older build still understands`() {
|
||||
val key = ByteArray(32) { it.toByte() }
|
||||
val wire = SealedSender.deliveryKeyEnvelope(key, groupId = "g1", groupName = "Book club")
|
||||
|
||||
assertTrue(wire.startsWith("""{"fnc":1,"kind":"delivery_key""""),
|
||||
"the marker or kind moved, so an older build would render this: $wire")
|
||||
assertTrue(
|
||||
wire.startsWith("""{"fnc":1,"kind":"delivery_key""""),
|
||||
"the marker or kind moved, so an older build would render this: $wire",
|
||||
)
|
||||
val json = JSONObject(wire)
|
||||
assertEquals("delivery_key", json.optString("kind"))
|
||||
assertTrue(json.optString("key").isNotEmpty(), "the key an older build reads is gone")
|
||||
@@ -133,45 +130,44 @@ class GroupNameTest {
|
||||
assertFalse(wire.contains("gid"))
|
||||
}
|
||||
|
||||
// -- What is believed ------------------------------------------------------
|
||||
// -- What is believed
|
||||
// ------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* A claim, not a fact, and this layer must not adopt it. Anyone who
|
||||
* can reach us can send one of these naming any gid they like. Adopting on
|
||||
* the sender's word would put whatever a stranger wants into somebody's
|
||||
* chat list, under a group heading the reader has no reason to doubt. The
|
||||
* roster lives a layer up, so the check does too, `Session.drainGroupNames`
|
||||
* is what turns a claim into a name.
|
||||
* A claim, not a fact, and this layer must not adopt it.
|
||||
*/
|
||||
@Test
|
||||
fun `an announced name is recorded as a claim, not stored`() {
|
||||
val vault = vault()
|
||||
val conversations = conversations(vault)
|
||||
val wire = SealedSender.deliveryKeyEnvelope(
|
||||
ByteArray(32), groupId = "g1", groupName = "Everything is fine")
|
||||
val wire =
|
||||
SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = "g1", groupName = "Everything is fine")
|
||||
|
||||
conversations.consumeControl(99L, Content.parse(wire))
|
||||
|
||||
assertNull(vault.groupName("g1"), "the name was adopted without a membership check")
|
||||
assertEquals(listOf(Triple(99L, "g1", "Everything is fine")),
|
||||
conversations.takeClaimedGroupNames())
|
||||
assertEquals(
|
||||
listOf(Triple(99L, "g1", "Everything is fine")),
|
||||
conversations.takeClaimedGroupNames(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Drained, so the same claim is not re-checked on every poll. */
|
||||
@Test
|
||||
fun `claims are taken once`() {
|
||||
val conversations = conversations(vault())
|
||||
conversations.consumeControl(99L, Content.parse(
|
||||
SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = "g1", groupName = "n")))
|
||||
conversations.consumeControl(
|
||||
99L,
|
||||
Content.parse(SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = "g1", groupName = "n")),
|
||||
)
|
||||
|
||||
assertEquals(1, conversations.takeClaimedGroupNames().size)
|
||||
assertTrue(conversations.takeClaimedGroupNames().isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* The batch and the key still have to survive an envelope that also
|
||||
* carries a group name. They are independent halves of the same message and
|
||||
* a name must not be able to take either of them down with it.
|
||||
* The batch and the key still have to survive an envelope that also carries
|
||||
* a group name.
|
||||
*/
|
||||
@Test
|
||||
fun `a named announcement still delivers the delivery key`() {
|
||||
@@ -180,21 +176,22 @@ class GroupNameTest {
|
||||
val conversations = conversations(vault)
|
||||
val theirKey = ByteArray(32) { 9 }
|
||||
|
||||
conversations.consumeControl(42L, Content.parse(
|
||||
SealedSender.deliveryKeyEnvelope(theirKey, groupId = "g1", groupName = "Book club")))
|
||||
conversations.consumeControl(
|
||||
42L,
|
||||
Content.parse(SealedSender.deliveryKeyEnvelope(theirKey, groupId = "g1", groupName = "Book club")),
|
||||
)
|
||||
|
||||
assertTrue(vault.loadDelivery()!!.peers[42L]!!.contentEquals(theirKey),
|
||||
"the delivery key was lost because the envelope also named a group")
|
||||
assertTrue(
|
||||
vault.loadDelivery()!!.peers[42L]!!.contentEquals(theirKey),
|
||||
"the delivery key was lost because the envelope also named a group",
|
||||
)
|
||||
}
|
||||
|
||||
// -- Renaming --------------------------------------------------------------
|
||||
// -- Renaming
|
||||
// --------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Renaming is how a group gets its name back. Every group that
|
||||
* existed before the relay dropped its names reads as the placeholder on
|
||||
* every device, and nothing can recover the old one: the relay deleted its
|
||||
* copy and no client had ever stored one. Names were previously fixed at
|
||||
* creation, so this path did not exist and had to be built.
|
||||
* Renaming is how a group gets its name back.
|
||||
*/
|
||||
@Test
|
||||
fun `a rename is stored and announced to the members`() {
|
||||
@@ -206,20 +203,17 @@ class GroupNameTest {
|
||||
vault.saveGroupName("g1", "Book club, Tuesdays")
|
||||
assertEquals("Book club, Tuesdays", vault.groupName("g1"))
|
||||
|
||||
// Asserted on the envelope rather than on delivery: the announcement
|
||||
// is best-effort per member, and a test that required a member to be
|
||||
// Asserted on the envelope rather than on delivery: the announcement is
|
||||
// best-effort per member, and a test that required a member to be
|
||||
// reachable would be testing the fixture's relay, not the rename.
|
||||
val wire = SealedSender.deliveryKeyEnvelope(
|
||||
ByteArray(32), groupId = "g1", groupName = vault.groupName("g1")!!)
|
||||
val wire =
|
||||
SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = "g1", groupName = vault.groupName("g1")!!)
|
||||
assertEquals("Book club, Tuesdays", JSONObject(wire).optString("gname"))
|
||||
assertTrue(conversations.takeClaimedGroupNames().isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* An announcement with no name is not sent at all. A group we have
|
||||
* never been told the name of has nothing to announce, and an envelope
|
||||
* carrying an empty `gname` would let a member overwrite a good name with
|
||||
* a blank one.
|
||||
* An announcement with no name is not sent at all.
|
||||
*/
|
||||
@Test
|
||||
fun `announcing a group with no known name sends nothing`() {
|
||||
@@ -227,25 +221,22 @@ class GroupNameTest {
|
||||
vault.saveDelivery(Vault.Delivery(key = ByteArray(32) { 3 }, published = true))
|
||||
val conversations = conversations(vault)
|
||||
|
||||
// No throw, no request: it returns before building anything. If it did
|
||||
// send, the MockWebServer with no queued response would fail the call.
|
||||
// No throw, no request: it returns before building anything.
|
||||
conversations.announceGroupName("unknown-gid", listOf(2L))
|
||||
assertEquals(0, server.requestCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sender decides how many claims to send, so the queue is capped.
|
||||
* It is drained by a layer that may not run on every path, and an unbounded
|
||||
* list whose growth a stranger controls is a slow leak with someone else's
|
||||
* hand on the tap.
|
||||
*/
|
||||
@Test
|
||||
fun `the claim queue is bounded`() {
|
||||
val conversations = conversations(vault())
|
||||
repeat(400) { i ->
|
||||
conversations.consumeControl(99L, Content.parse(
|
||||
SealedSender.deliveryKeyEnvelope(
|
||||
ByteArray(32), groupId = "g$i", groupName = "n$i")))
|
||||
conversations.consumeControl(
|
||||
99L,
|
||||
Content.parse(SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = "g$i", groupName = "n$i")),
|
||||
)
|
||||
}
|
||||
|
||||
val drained = conversations.takeClaimedGroupNames()
|
||||
@@ -254,14 +245,11 @@ class GroupNameTest {
|
||||
assertEquals("g399", drained.last().second)
|
||||
}
|
||||
|
||||
// -- Surviving a reinstall -------------------------------------------------
|
||||
// -- Surviving a reinstall
|
||||
// -------------------------------------------------.
|
||||
|
||||
/**
|
||||
* A restored backup must bring the group names with it. The relay
|
||||
* used to answer with the name, so a reinstalled device got it back for
|
||||
* free; it holds none now, which makes the device's copy the only copy. An
|
||||
* export carrying the messages but not the names restores a chat list of
|
||||
* groups all called "Group", the exact loss the export exists to stop.
|
||||
* A restored backup must bring the group names with it.
|
||||
*/
|
||||
@Test
|
||||
fun `an exported backup carries group names into a fresh device`() {
|
||||
@@ -277,8 +265,7 @@ class GroupNameTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A backup is older than the device by definition. A rename made since
|
||||
* the export must not be undone by restoring it.
|
||||
* A backup is older than the device by definition.
|
||||
*/
|
||||
@Test
|
||||
fun `a restored backup does not overwrite a newer name`() {
|
||||
@@ -294,12 +281,8 @@ class GroupNameTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* `Received` carries whether the sender was proven, because a caller
|
||||
* that acts on `fromUser` has to be able to ask. An unproven device's
|
||||
* sender id is a claim, the binding says it belongs to somebody else,
|
||||
* so a roster check keyed on it asks "is the person they are pretending to
|
||||
* be in this group", which answers yes for any member they name.
|
||||
* `Session.receive` gates group-name adoption on this.
|
||||
* `Received` carries whether the sender was proven, because a caller that
|
||||
* acts on `fromUser` has to be able to ask.
|
||||
*/
|
||||
@Test
|
||||
fun `received messages say whether the sender was proven`() {
|
||||
@@ -309,21 +292,13 @@ class GroupNameTest {
|
||||
val proven = Conversations.Received(1L, 42L, "dev", Content("hi"))
|
||||
assertTrue(proven.senderProven)
|
||||
|
||||
val unproven = Conversations.Received(
|
||||
1L, 42L, "dev", Content("hi"), senderProven = false)
|
||||
val unproven =
|
||||
Conversations.Received(1L, 42L, "dev", Content("hi"), senderProven = false)
|
||||
assertFalse(unproven.senderProven)
|
||||
}
|
||||
|
||||
/**
|
||||
* The announcement goes to our own account too.
|
||||
* Fan-out excludes us everywhere else, so a second phone we own would be
|
||||
* told by nobody: the relay has no name to serve it and the members only
|
||||
* send to each other.
|
||||
*
|
||||
* Safe only because the fan-out loop already skips our own device
|
||||
* ("never talk to ourselves"). This asserts the recipient list, which is
|
||||
* the half this file owns; the device-level skip has its own guard in the
|
||||
* send path.
|
||||
*/
|
||||
@Test
|
||||
fun `the announcement addresses our own account as well as the members`() {
|
||||
@@ -335,18 +310,19 @@ class GroupNameTest {
|
||||
val asked = mutableListOf<Long>()
|
||||
// Every send starts by listing the recipient's devices, so the paths
|
||||
// the relay is asked for name exactly who was addressed.
|
||||
server.dispatcher = object : okhttp3.mockwebserver.Dispatcher() {
|
||||
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest):
|
||||
MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
Regex("/e2e/devices/(\\d+)").find(path)?.let {
|
||||
asked += it.groupValues[1].toLong()
|
||||
server.dispatcher =
|
||||
object : okhttp3.mockwebserver.Dispatcher() {
|
||||
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
Regex("/e2e/devices/(\\d+)").find(path)?.let {
|
||||
asked += it.groupValues[1].toLong()
|
||||
}
|
||||
return MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"user_id":0,"devices":[]}""")
|
||||
}
|
||||
return MockResponse().setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"user_id":0,"devices":[]}""")
|
||||
}
|
||||
}
|
||||
|
||||
conversations.announceGroupName("g1", listOf(2L, 3L))
|
||||
|
||||
@@ -354,12 +330,11 @@ class GroupNameTest {
|
||||
assertTrue(2L in asked && 3L in asked, "a member was missed: $asked")
|
||||
}
|
||||
|
||||
// -- Sealed group invitations (§8.9b) --------------------------------------
|
||||
// -- Sealed group invitations (§8.9b)
|
||||
// --------------------------------------.
|
||||
|
||||
/**
|
||||
* The invitation is the only way a member ever gets in. The relay
|
||||
* holds neither the tag nor the group key, by construction, so somebody who
|
||||
* is not told cannot fetch the group at all, there is nobody left to ask.
|
||||
* The invitation is the only way a member ever gets in.
|
||||
*/
|
||||
@Test
|
||||
fun `an invitation carries the tag and the group key`() {
|
||||
@@ -367,8 +342,10 @@ class GroupNameTest {
|
||||
val key = SealedRoster.newGroupKey()
|
||||
val tag = SealedRoster.newTag()
|
||||
|
||||
conversations.consumeControlForTesting(7L, Content.parse(
|
||||
SealedSender.sealedGroupEnvelope(ByteArray(32), "g1", tag, key, "Book club")))
|
||||
conversations.consumeControlForTesting(
|
||||
7L,
|
||||
Content.parse(SealedSender.sealedGroupEnvelope(ByteArray(32), "g1", tag, key, "Book club")),
|
||||
)
|
||||
|
||||
val invites = conversations.takeGroupInvitations()
|
||||
assertEquals(1, invites.size)
|
||||
@@ -379,20 +356,23 @@ class GroupNameTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A group key that is not 32 bytes is not a group key. Storing one would
|
||||
* make every roster for that group fail to open, which reads as a group
|
||||
* with no members rather than as a bad invitation.
|
||||
* A group key that is not 32 bytes is not a group key.
|
||||
*/
|
||||
@Test
|
||||
fun `an invitation with a malformed key is ignored`() {
|
||||
val conversations = conversations(vault())
|
||||
// Built by hand rather than through `sealedGroupEnvelope`, which
|
||||
// only ever emits a well-formed key, the shape under test is one a
|
||||
// hostile or broken sender produces, not one this engine can make.
|
||||
fun b64(b: ByteArray) = java.util.Base64.getEncoder().encodeToString(b)
|
||||
val wire = """{"fnc":1,"kind":"delivery_key","key":"${b64(ByteArray(32))}",""" +
|
||||
""""gid":"g1","gname":"G","gtag":"${SealedRoster.newTag()}",""" +
|
||||
""""gkey":"${b64(ByteArray(8))}"}"""
|
||||
|
||||
// Built by hand rather than through `sealedGroupEnvelope`, which only
|
||||
// ever emits a well-formed key, the shape under test is one a hostile
|
||||
// or broken sender produces, not one this engine can make.
|
||||
fun b64(b: ByteArray) =
|
||||
java.util.Base64
|
||||
.getEncoder()
|
||||
.encodeToString(b)
|
||||
val wire =
|
||||
"""{"fnc":1,"kind":"delivery_key","key":"${b64(ByteArray(32))}",""" +
|
||||
""""gid":"g1","gname":"G","gtag":"${SealedRoster.newTag()}",""" +
|
||||
""""gkey":"${b64(ByteArray(8))}"}"""
|
||||
|
||||
conversations.consumeControlForTesting(7L, Content.parse(wire))
|
||||
|
||||
@@ -403,16 +383,25 @@ class GroupNameTest {
|
||||
@Test
|
||||
fun `invitations are taken once`() {
|
||||
val conversations = conversations(vault())
|
||||
conversations.consumeControlForTesting(7L, Content.parse(
|
||||
SealedSender.sealedGroupEnvelope(
|
||||
ByteArray(32), "g1", SealedRoster.newTag(),
|
||||
SealedRoster.newGroupKey(), "G")))
|
||||
conversations.consumeControlForTesting(
|
||||
7L,
|
||||
Content.parse(
|
||||
SealedSender.sealedGroupEnvelope(
|
||||
ByteArray(32),
|
||||
"g1",
|
||||
SealedRoster.newTag(),
|
||||
SealedRoster.newGroupKey(),
|
||||
"G",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(1, conversations.takeGroupInvitations().size)
|
||||
assertTrue(conversations.takeGroupInvitations().isEmpty())
|
||||
}
|
||||
|
||||
// -- Where it is kept ------------------------------------------------------
|
||||
// -- Where it is kept
|
||||
// ------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a name is kept, replaced and forgotten`() {
|
||||
@@ -425,8 +414,7 @@ class GroupNameTest {
|
||||
vault.saveGroupName("g1", "Book club, Tuesdays")
|
||||
assertEquals("Book club, Tuesdays", vault.groupName("g1"))
|
||||
|
||||
// Leaving a group forgets it. The one string the server was asked to
|
||||
// stop holding should not outlive the membership that justified it.
|
||||
// Leaving a group forgets it.
|
||||
vault.clearGroupName("g1")
|
||||
assertNull(vault.groupName("g1"))
|
||||
}
|
||||
|
||||
@@ -2,35 +2,26 @@ package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.Ed25519
|
||||
import io.flamenet.e2e.FNID
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A home record that is older than one already seen must be refused (§20.4).
|
||||
*
|
||||
* A signature alone does not stop a rollback, and both clients checked
|
||||
* only the signature. An older home record is *signed*, the
|
||||
* account holder published it, before they added a second relay. So a relay
|
||||
* that wants to be somebody's only mailbox does not have to forge anything: it
|
||||
* serves the record it saw first, that record verifies perfectly, and the
|
||||
* sender delivers to one relay believing it is delivering to all of them.
|
||||
*
|
||||
* That is precisely the attack multi-homing exists to survive, and the serial
|
||||
* is what was added to stop it. The relay refuses a lower serial on WRITE;
|
||||
* until this, nothing refused one on READ, on either platform.
|
||||
*/
|
||||
class HomeRecordRollbackTest {
|
||||
|
||||
private val seed = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
private val fnid = FNID.address(Ed25519.fromSeed(seed).publicKey)
|
||||
|
||||
/** A record the account holder really did sign. */
|
||||
private fun record(serial: Int, relays: List<String>): JSONObject {
|
||||
private fun record(
|
||||
serial: Int,
|
||||
relays: List<String>,
|
||||
): JSONObject {
|
||||
val sig = Wire.b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays)))
|
||||
return JSONObject()
|
||||
.put("fnid", fnid)
|
||||
@@ -42,16 +33,17 @@ class HomeRecordRollbackTest {
|
||||
private val old = record(1, listOf("one.example"))
|
||||
private val current = record(4, listOf("one.example", "two.example"))
|
||||
|
||||
// ---- The attack ------------------------------------------------------
|
||||
// ---- The attack ------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The whole finding, in one assertion. Both records verify; only the
|
||||
* serial separates them.
|
||||
* The whole finding, in one assertion.
|
||||
*/
|
||||
@Test
|
||||
fun `an older record is refused once a newer one has been seen`() {
|
||||
assertNotNull(AccountIdentity.verifiedRelays(old),
|
||||
"the old record does not verify, so this test proves nothing")
|
||||
assertNotNull(
|
||||
AccountIdentity.verifiedRelays(old),
|
||||
"the old record does not verify, so this test proves nothing",
|
||||
)
|
||||
assertNotNull(AccountIdentity.verifiedRelays(current))
|
||||
|
||||
assertNull(
|
||||
@@ -71,8 +63,7 @@ class HomeRecordRollbackTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Equal is fine. Re-reading the same record is the ordinary case, and
|
||||
* refusing it would break every second lookup.
|
||||
* Equal is fine.
|
||||
*/
|
||||
@Test
|
||||
fun `the same serial twice is not a rollback`() {
|
||||
@@ -86,24 +77,23 @@ class HomeRecordRollbackTest {
|
||||
assertNotNull(AccountIdentity.verifiedRelays(current, floor = null))
|
||||
}
|
||||
|
||||
// ---- The floor cannot be poisoned ------------------------------------
|
||||
// ---- The floor cannot be poisoned ------------------------------------.
|
||||
|
||||
/**
|
||||
* A serial is only reported for a record that VERIFIES. Otherwise a
|
||||
* hostile relay could hand over an unsigned record carrying serial
|
||||
* 2,000,000,000, have the client raise its floor to it, and lock out every
|
||||
* record for ever, turning the rollback defence into a denial of
|
||||
* service against the account holder.
|
||||
* A serial is only reported for a record that VERIFIES.
|
||||
*/
|
||||
@Test
|
||||
fun `an unsigned record reports no serial to raise the floor with`() {
|
||||
val forged = JSONObject()
|
||||
.put("fnid", fnid)
|
||||
.put("serial", Int.MAX_VALUE)
|
||||
.put("relays", JSONArray(listOf("attacker.example")))
|
||||
.put("sig", Wire.b64(ByteArray(64)))
|
||||
assertNull(AccountIdentity.verifiedSerial(forged),
|
||||
"a forged record could raise the floor and lock out every real one")
|
||||
val forged =
|
||||
JSONObject()
|
||||
.put("fnid", fnid)
|
||||
.put("serial", Int.MAX_VALUE)
|
||||
.put("relays", JSONArray(listOf("attacker.example")))
|
||||
.put("sig", Wire.b64(ByteArray(64)))
|
||||
assertNull(
|
||||
AccountIdentity.verifiedSerial(forged),
|
||||
"a forged record could raise the floor and lock out every real one",
|
||||
)
|
||||
assertNull(AccountIdentity.verifiedRelays(forged, floor = 1))
|
||||
}
|
||||
|
||||
@@ -113,30 +103,37 @@ class HomeRecordRollbackTest {
|
||||
val otherSeed = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val relays = listOf("attacker.example")
|
||||
val sig = Wire.b64(Ed25519.sign(otherSeed, FNID.homesCanonical(fnid, 9, relays)))
|
||||
val impostor = JSONObject()
|
||||
.put("fnid", fnid).put("serial", 9)
|
||||
.put("relays", JSONArray(relays)).put("sig", sig)
|
||||
val impostor =
|
||||
JSONObject()
|
||||
.put("fnid", fnid)
|
||||
.put("serial", 9)
|
||||
.put("relays", JSONArray(relays))
|
||||
.put("sig", sig)
|
||||
assertNull(AccountIdentity.verifiedSerial(impostor))
|
||||
assertNull(AccountIdentity.verifiedRelays(impostor, floor = null))
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay list is part of what is signed, so swapping it invalidates
|
||||
* the record, a relay cannot keep a valid signature and quietly drop the
|
||||
* other homes out of the list.
|
||||
* The relay list is part of what is signed, so swapping it invalidates the
|
||||
* record, a relay cannot keep a valid signature and quietly drop the other
|
||||
* homes out of the list.
|
||||
*/
|
||||
@Test
|
||||
fun `the relay list cannot be edited without breaking the signature`() {
|
||||
val tampered = record(4, listOf("one.example", "two.example"))
|
||||
.put("relays", JSONArray(listOf("one.example")))
|
||||
assertNull(AccountIdentity.verifiedRelays(tampered),
|
||||
"a relay dropped the other homes and kept a valid signature")
|
||||
val tampered =
|
||||
record(4, listOf("one.example", "two.example"))
|
||||
.put("relays", JSONArray(listOf("one.example")))
|
||||
assertNull(
|
||||
AccountIdentity.verifiedRelays(tampered),
|
||||
"a relay dropped the other homes and kept a valid signature",
|
||||
)
|
||||
}
|
||||
|
||||
/** A negative or absent serial is malformed, not a floor of zero. */
|
||||
@Test
|
||||
fun `a malformed serial is refused rather than treated as zero`() {
|
||||
assertNull(AccountIdentity.verifiedRelays(
|
||||
JSONObject().put("fnid", fnid).put("relays", JSONArray()).put("sig", "x")))
|
||||
assertNull(
|
||||
AccountIdentity.verifiedRelays(JSONObject().put("fnid", fnid).put("relays", JSONArray()).put("sig", "x")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import kotlin.test.assertTrue
|
||||
|
||||
/** S4's "name what changed" formatter, pinned so the sentence can't drift. */
|
||||
class KeyChangeDescriptionTest {
|
||||
|
||||
@Test fun `each kind names itself`() {
|
||||
assertTrue(KeyChangeDescription.describe("publish", null).startsWith("A new device key was added"))
|
||||
assertTrue(KeyChangeDescription.describe("expire", null).startsWith("A device key expired"))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.Test
|
||||
@@ -11,37 +11,17 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Two accounts, one relay, a real conversation.
|
||||
*
|
||||
* This is the test the whole stack exists to pass. Everything below it,
|
||||
* X3DH, the ratchet, the vault, the transport, has been proven in isolation;
|
||||
* this is the first thing that proves they were assembled correctly.
|
||||
*
|
||||
* Opt-in via `FN_LIVE_FLEET`, and pointed at the throwaway fleet. It creates
|
||||
* accounts and sends mail.
|
||||
*
|
||||
* FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test
|
||||
*/
|
||||
class LiveConversationTest {
|
||||
|
||||
private val fleet: String? = System.getenv("FN_LIVE_FLEET")
|
||||
|
||||
|
||||
/**
|
||||
* Skips are announced, not silent. `return` from an unmet
|
||||
* precondition makes a test that did nothing report as PASSED, and a suite
|
||||
* of those is indistinguishable from a suite that works. `assumeTrue`
|
||||
* reports SKIPPED, which is the truth. `LiveShapesTest` has always done it
|
||||
* this way; these files did not, so 6 tests here reported green on every
|
||||
* pipeline while running nothing at all.
|
||||
* Skips are announced, not silent.
|
||||
*/
|
||||
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.
|
||||
// The passphrase comes from the environment with no fallback.
|
||||
assumeTrue(
|
||||
LiveTarget.passphrase().isNotEmpty(),
|
||||
"set FN_LIVE_PASSPHRASE, these tests create real accounts and this " +
|
||||
@@ -62,18 +42,34 @@ class LiveConversationTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun signUp(base: String, tag: String, preKeys: Int = 20): Person {
|
||||
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", LiveTarget.passphrase())
|
||||
val account =
|
||||
RelayAccounts(base)
|
||||
.signUp(handle, "$handle@example.invalid", LiveTarget.passphrase())
|
||||
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key())
|
||||
@@ -97,20 +93,8 @@ class LiveConversationTest {
|
||||
|
||||
/**
|
||||
* What a person would see, which is what every assertion below means.
|
||||
*
|
||||
* `poll` returns control traffic too, and since key-log checking
|
||||
* became automatic there is reliably some: the first send to a contact now
|
||||
* gossips this device's view of the transparency log (§19.4), because
|
||||
* enforcement sets the head pin that gossip had nothing to send without.
|
||||
* That envelope is consumed by the client and never shown, and asserting on
|
||||
* the raw list counted it as a message, so these tests failed on a change
|
||||
* that made the product more correct, not less.
|
||||
*
|
||||
* The app has always filtered this way; the tests were reading a layer
|
||||
* lower than the thing they were describing.
|
||||
*/
|
||||
private fun Conversations.readable(): List<Conversations.Received> =
|
||||
poll().filter { it.content.isRenderable }
|
||||
private fun Conversations.readable(): List<Conversations.Received> = poll().filter { it.content.isRenderable }
|
||||
|
||||
@Test
|
||||
fun `two people exchange messages through a real relay`() {
|
||||
@@ -119,8 +103,7 @@ class LiveConversationTest {
|
||||
val alice = signUp(base, "a")
|
||||
val bob = signUp(base, "b")
|
||||
|
||||
// Alice opens the conversation. Bob has never heard of her, so this is
|
||||
// a prekey envelope and it has to stand on its own.
|
||||
// Alice opens the conversation.
|
||||
val sent = alice.conversations.send(bob.userId, "hello from the Android engine 👋")
|
||||
assertTrue(sent.reachedSomeone, "nothing was delivered: ${sent.failed}")
|
||||
assertEquals(listOf(bob.identity.deviceId), sent.delivered)
|
||||
@@ -141,17 +124,26 @@ class LiveConversationTest {
|
||||
// Several turns, so the ratchet is exercised past the handshake.
|
||||
for (turn in 1..3) {
|
||||
alice.conversations.send(bob.userId, "alice $turn")
|
||||
assertEquals("alice $turn", bob.conversations.readable().single().text)
|
||||
assertEquals(
|
||||
"alice $turn",
|
||||
bob.conversations
|
||||
.readable()
|
||||
.single()
|
||||
.text,
|
||||
)
|
||||
bob.conversations.send(alice.userId, "bob $turn")
|
||||
assertEquals("bob $turn", alice.conversations.readable().single().text)
|
||||
assertEquals(
|
||||
"bob $turn",
|
||||
alice.conversations
|
||||
.readable()
|
||||
.single()
|
||||
.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The watermark must not re-deliver. A client that re-reads its inbox
|
||||
* from zero shows every message again on every poll, and the second
|
||||
* decryption fails because the ratchet has moved, so the user sees their
|
||||
* history replaced by errors.
|
||||
* The watermark must not re-deliver.
|
||||
*/
|
||||
@Test
|
||||
fun `polling twice does not deliver the same message twice`() {
|
||||
@@ -167,10 +159,6 @@ class LiveConversationTest {
|
||||
|
||||
/**
|
||||
* A person signed in on two devices must receive on both.
|
||||
*
|
||||
* This is the failure that looks fine in every test where everyone has
|
||||
* one device: encrypting to "the" recipient device delivers to whichever
|
||||
* came back first and silently drops the rest.
|
||||
*/
|
||||
@Test
|
||||
fun `a message reaches every device a person has`() {
|
||||
@@ -192,16 +180,18 @@ class LiveConversationTest {
|
||||
val sent = alice.conversations.send(bob.userId, "to both of your devices")
|
||||
assertEquals(2, sent.delivered.size, "only reached ${sent.delivered}; failed: ${sent.failed}")
|
||||
|
||||
assertEquals("to both of your devices", bob.conversations.readable().single().text)
|
||||
assertEquals(
|
||||
"to both of your devices",
|
||||
bob.conversations
|
||||
.readable()
|
||||
.single()
|
||||
.text,
|
||||
)
|
||||
assertEquals("to both of your devices", secondConversations.readable().single().text)
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay stores ciphertext and nothing else.
|
||||
*
|
||||
* Worth asserting rather than assuming: the entire claim this product
|
||||
* makes is that the operator cannot read the mail, and the cheapest way to
|
||||
* break it is a client that sends a plaintext field "just for the preview".
|
||||
*/
|
||||
@Test
|
||||
fun `the relay never sees the plaintext`() {
|
||||
@@ -223,12 +213,6 @@ class LiveConversationTest {
|
||||
|
||||
/**
|
||||
* An image, end to end.
|
||||
*
|
||||
* This is the exact path that was 401 on iOS for months: the
|
||||
* attachment routes were sending the account password, and the relay
|
||||
* refuses that on `/e2e/…`. The upload here goes through the same
|
||||
* device-scoped token and per-request signature as everything else, and
|
||||
* this test is what says so.
|
||||
*/
|
||||
@Test
|
||||
fun `an image crosses end to end and the relay cannot read it`() {
|
||||
@@ -242,8 +226,8 @@ class LiveConversationTest {
|
||||
val marker = "PNG-MARKER-7f3c-not-encrypted".toByteArray()
|
||||
val picture = marker + ByteArray(4096) { (it % 251).toByte() }
|
||||
|
||||
val sent = alice.conversations.sendAttachment(
|
||||
bob.userId, picture, "image/png", width = 64, height = 48)
|
||||
val sent =
|
||||
alice.conversations.sendAttachment(bob.userId, picture, "image/png", width = 64, height = 48)
|
||||
assertTrue(sent.reachedSomeone, "nothing delivered: ${sent.failed}")
|
||||
|
||||
val received = bob.conversations.readable().single()
|
||||
@@ -257,19 +241,14 @@ class LiveConversationTest {
|
||||
|
||||
// What the relay stores must not contain the marker.
|
||||
val stored = bob.relay.downloadAttachment(ref.id)
|
||||
assertTrue(!String(stored, Charsets.ISO_8859_1).contains("PNG-MARKER"),
|
||||
"the relay is holding the plaintext")
|
||||
assertTrue(
|
||||
!String(stored, Charsets.ISO_8859_1).contains("PNG-MARKER"),
|
||||
"the relay is holding the plaintext",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A group, against the real relay.
|
||||
*
|
||||
* This is the shape the iOS client got wrong: it required a `name` on
|
||||
* each member, and the relay has never sent one, it has no user table
|
||||
* and deliberately will not grow one, because a relay caching display names
|
||||
* is a relay that leaks them. Every group decode failed, so groups were
|
||||
* completely broken while looking implemented. Asserting the member shape
|
||||
* against live bytes is the point of this test.
|
||||
*/
|
||||
@Test
|
||||
fun `a group is created, described and messaged`() {
|
||||
@@ -281,33 +260,37 @@ class LiveConversationTest {
|
||||
|
||||
val group = alice.relay.createGroup(listOf(bob.userId, carol.userId))
|
||||
assertTrue(group.id.isNotEmpty(), "no group id came back")
|
||||
// The relay does not know what the group is called, and this
|
||||
// asserts it against a live one. It used to store the name in the clear
|
||||
// in a column it never read, the single most revealing string it held
|
||||
// and now answers with the same generic placeholder for every group.
|
||||
// The real name travels between members inside their ratchets.
|
||||
assertEquals("Group", group.name,
|
||||
"the relay is serving a real group name again")
|
||||
// The relay does not know what the group is called, and this asserts it
|
||||
// against a live one.
|
||||
assertEquals(
|
||||
"Group",
|
||||
group.name,
|
||||
"the relay is serving a real group name again",
|
||||
)
|
||||
assertEquals(alice.userId, group.owner)
|
||||
// Creator plus the two invited. If this is 2, the relay does not add
|
||||
// the owner and the client must.
|
||||
assertTrue(group.memberIds.containsAll(listOf(bob.userId, carol.userId)),
|
||||
"members were ${group.memberIds}")
|
||||
// Creator plus the two invited.
|
||||
assertTrue(
|
||||
group.memberIds.containsAll(listOf(bob.userId, carol.userId)),
|
||||
"members were ${group.memberIds}",
|
||||
)
|
||||
|
||||
assertTrue(alice.relay.listGroups().any { it.id == group.id },
|
||||
"the group did not appear in the list")
|
||||
assertTrue(
|
||||
alice.relay.listGroups().any { it.id == group.id },
|
||||
"the group did not appear in the list",
|
||||
)
|
||||
|
||||
// The relay does no fan-out. A group message is still pairwise
|
||||
// encryption, one ratchet per recipient device, that is the honest
|
||||
// cost of the relay not being able to read anything.
|
||||
// The relay does no fan-out.
|
||||
val sent = alice.conversations.sendToGroup(group, "hello everyone", alice.userId)
|
||||
assertTrue(sent.reachedSomeone, "nothing delivered: ${sent.failed}")
|
||||
|
||||
for (member in listOf(bob, carol)) {
|
||||
val got = member.conversations.readable().single()
|
||||
assertEquals("hello everyone", got.text)
|
||||
assertEquals(group.id, got.content.groupId,
|
||||
"the message did not carry group routing, so it would file under the sender")
|
||||
assertEquals(
|
||||
group.id,
|
||||
got.content.groupId,
|
||||
"the message did not carry group routing, so it would file under the sender",
|
||||
)
|
||||
}
|
||||
|
||||
// Membership is editable, and leaving works.
|
||||
|
||||
@@ -5,6 +5,7 @@ import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.FilesIdentity
|
||||
import io.flamenet.e2e.Label
|
||||
import io.flamenet.e2e.Vault
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import java.io.File
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
@@ -13,38 +14,16 @@ 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.
|
||||
* Announced, not silent.
|
||||
*/
|
||||
private fun needFnLiveFleet() {
|
||||
LiveTarget.refuseProduction(fleet)
|
||||
@@ -58,20 +37,30 @@ class LiveFileTransferTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
/** 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")
|
||||
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()
|
||||
@@ -87,10 +76,14 @@ class LiveFileTransferTest {
|
||||
val root: File,
|
||||
)
|
||||
|
||||
private fun signUp(base: String, tag: String): Person {
|
||||
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 account =
|
||||
RelayAccounts(base)
|
||||
.signUp(handle, "$handle@example.invalid", LiveTarget.passphrase())
|
||||
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key())
|
||||
@@ -102,10 +95,20 @@ class LiveFileTransferTest {
|
||||
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 }
|
||||
// 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,
|
||||
@@ -118,8 +121,7 @@ class LiveFileTransferTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun Conversations.readable(): List<Conversations.Received> =
|
||||
poll().filter { it.content.isRenderable }
|
||||
private fun Conversations.readable(): List<Conversations.Received> = poll().filter { it.content.isRenderable }
|
||||
|
||||
@Test
|
||||
fun `a classified document crosses between two vaults with its label intact`() {
|
||||
@@ -138,47 +140,63 @@ class LiveFileTransferTest {
|
||||
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(),
|
||||
)
|
||||
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")
|
||||
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")
|
||||
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()),
|
||||
)
|
||||
// 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")
|
||||
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",
|
||||
@@ -189,11 +207,8 @@ class LiveFileTransferTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* An Unclassified document must arrive as one, and be distinguishable from
|
||||
* a label that went missing.
|
||||
*/
|
||||
@Test
|
||||
fun `an unclassified document is still recognisably a document`() {
|
||||
@@ -204,11 +219,22 @@ class LiveFileTransferTest {
|
||||
|
||||
val bytes = "nothing secret here".toByteArray()
|
||||
alice.conversations.sendAttachment(
|
||||
bob.userId, bytes, "application/octet-stream", Attachment.FILE,
|
||||
filename = "notes.txt", classification = 0, compartments = emptyList(),
|
||||
bob.userId,
|
||||
bytes,
|
||||
"application/octet-stream",
|
||||
Attachment.FILE,
|
||||
filename = "notes.txt",
|
||||
classification = 0,
|
||||
compartments = emptyList(),
|
||||
)
|
||||
|
||||
val ref = assertNotNull(bob.conversations.readable().single().content.attachment)
|
||||
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)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import javax.crypto.KeyGenerator
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -12,37 +12,17 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* §19 enforcement against a relay that is actually running.
|
||||
*
|
||||
* Opt-in via `FN_LIVE_FLEET`, and pointed at the disposable fleet rather
|
||||
* than production. It creates accounts and publishes keys, which is what a
|
||||
* throwaway relay is for and what `relay.flamenet.io` is not.
|
||||
*
|
||||
* FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test
|
||||
*
|
||||
* The unit tests drive a MockWebServer with responses this repository wrote,
|
||||
* which proves the verifier agrees with our idea of the relay. This proves it
|
||||
* agrees with the relay, a distinction that has already cost this protocol
|
||||
* three silent wire-shape bugs, every one of them invisible to a fake.
|
||||
*/
|
||||
class LiveKeyLogTest {
|
||||
|
||||
private val base: String? = System.getenv("FN_LIVE_FLEET")
|
||||
|
||||
|
||||
/**
|
||||
* Skips are announced, not silent. `return` from an unmet
|
||||
* precondition makes a test that did nothing report as PASSED, and a suite
|
||||
* of those is indistinguishable from a suite that works. `assumeTrue`
|
||||
* reports SKIPPED, which is the truth. `LiveShapesTest` has always done it
|
||||
* this way; these files did not, so 7 tests here reported green on every
|
||||
* pipeline while running nothing at all.
|
||||
* Skips are announced, not silent.
|
||||
*/
|
||||
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.
|
||||
// No fallback.
|
||||
assumeTrue(
|
||||
LiveTarget.passphrase().isNotEmpty(),
|
||||
"set FN_LIVE_PASSPHRASE, this suite creates real accounts and will " +
|
||||
@@ -52,25 +32,41 @@ class LiveKeyLogTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
/** A real account on the fleet, with a device registered and keys published. */
|
||||
private class Actor(base: String, tag: String) {
|
||||
private class Actor(
|
||||
base: String,
|
||||
tag: String,
|
||||
) {
|
||||
val handle = "kt$tag${System.currentTimeMillis() % 100_000_000}"
|
||||
val account = RelayAccounts(base).signUp(
|
||||
username = handle,
|
||||
email = "$handle@example.invalid",
|
||||
password = LiveTarget.passphrase(),
|
||||
)
|
||||
val account =
|
||||
RelayAccounts(base).signUp(
|
||||
username = handle,
|
||||
email = "$handle@example.invalid",
|
||||
password = LiveTarget.passphrase(),
|
||||
)
|
||||
val identity: DeviceIdentity = DeviceIdentity.create()
|
||||
val vault = Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
)
|
||||
val vault =
|
||||
Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
)
|
||||
val relay = RelayClient(base, username = account.username, appPassword = account.appPassword)
|
||||
val userId: Long get() = account.userId
|
||||
|
||||
@@ -90,10 +86,12 @@ class LiveKeyLogTest {
|
||||
val bob = Actor(url, "b")
|
||||
|
||||
// Alice looks Bob up the way a send does, and enforces §19 on what she
|
||||
// was served. Bob published this key a moment ago, so the
|
||||
// relay can prove it, and if this throws, enforcement is refusing
|
||||
// honest keys, which is worse than not enforcing at all.
|
||||
val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId }
|
||||
// was served.
|
||||
val bundle =
|
||||
alice.relay
|
||||
.fetchKeys(bob.userId)
|
||||
.bundles
|
||||
.single { it.deviceId == bob.identity.deviceId }
|
||||
val trust = alice.trust()
|
||||
trust.enforceKeyLog(bundle, bob.userId)
|
||||
|
||||
@@ -104,11 +102,6 @@ class LiveKeyLogTest {
|
||||
|
||||
/**
|
||||
* The check has to refuse something, or it is decorative.
|
||||
*
|
||||
* A device id that exists with an identity key the relay never logged for
|
||||
* it. Nothing in the tree hashes to the leaf this bundle implies, so no
|
||||
* proof can be produced, which is exactly the position a relay handing
|
||||
* out a substituted key is in.
|
||||
*/
|
||||
@Test
|
||||
fun `a key the relay never published is refused by the real log`() {
|
||||
@@ -117,22 +110,28 @@ class LiveKeyLogTest {
|
||||
val alice = Actor(url, "c")
|
||||
val bob = Actor(url, "d")
|
||||
|
||||
val real = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId }
|
||||
val substituted = io.flamenet.e2e.PeerBundle(
|
||||
deviceId = real.deviceId,
|
||||
identityDH = real.identityDH,
|
||||
identitySigning = DeviceIdentity.create().identitySigningPublic, // not Bob's
|
||||
signedPreKeyId = real.signedPreKeyId,
|
||||
signedPreKey = real.signedPreKey,
|
||||
signedPreKeySignature = real.signedPreKeySignature,
|
||||
oneTimePreKeyId = real.oneTimePreKeyId,
|
||||
oneTimePreKey = real.oneTimePreKey,
|
||||
proto = real.proto,
|
||||
)
|
||||
val real =
|
||||
alice.relay
|
||||
.fetchKeys(bob.userId)
|
||||
.bundles
|
||||
.single { it.deviceId == bob.identity.deviceId }
|
||||
val substituted =
|
||||
io.flamenet.e2e.PeerBundle(
|
||||
deviceId = real.deviceId,
|
||||
identityDH = real.identityDH,
|
||||
identitySigning = DeviceIdentity.create().identitySigningPublic, // not Bob's
|
||||
signedPreKeyId = real.signedPreKeyId,
|
||||
signedPreKey = real.signedPreKey,
|
||||
signedPreKeySignature = real.signedPreKeySignature,
|
||||
oneTimePreKeyId = real.oneTimePreKeyId,
|
||||
oneTimePreKey = real.oneTimePreKey,
|
||||
proto = real.proto,
|
||||
)
|
||||
|
||||
val e = assertFailsWith<KeyNotLoggedException> {
|
||||
alice.trust().enforceKeyLog(substituted, bob.userId)
|
||||
}
|
||||
val e =
|
||||
assertFailsWith<KeyNotLoggedException> {
|
||||
alice.trust().enforceKeyLog(substituted, bob.userId)
|
||||
}
|
||||
assertEquals(real.deviceId, e.deviceId)
|
||||
}
|
||||
|
||||
@@ -149,16 +148,21 @@ class LiveKeyLogTest {
|
||||
val bob = Actor(url, "f")
|
||||
|
||||
val trust = alice.trust()
|
||||
val first = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId }
|
||||
val first =
|
||||
alice.relay
|
||||
.fetchKeys(bob.userId)
|
||||
.bundles
|
||||
.single { it.deviceId == bob.identity.deviceId }
|
||||
trust.enforceKeyLog(first, bob.userId)
|
||||
val pinned = alice.vault.loadKeyLogPin(alice.relay.host)!!
|
||||
|
||||
// Grow the log by publishing another device, then check again. The
|
||||
// second call must produce and verify a real consistency proof from the
|
||||
// pinned size to the new one.
|
||||
// Grow the log by publishing another device, then check again.
|
||||
val second = Actor(url, "g")
|
||||
val third = alice.relay.fetchKeys(second.userId).bundles
|
||||
.single { it.deviceId == second.identity.deviceId }
|
||||
val third =
|
||||
alice.relay
|
||||
.fetchKeys(second.userId)
|
||||
.bundles
|
||||
.single { it.deviceId == second.identity.deviceId }
|
||||
trust.enforceKeyLog(third, second.userId)
|
||||
|
||||
val after = alice.vault.loadKeyLogPin(alice.relay.host)!!
|
||||
@@ -167,9 +171,6 @@ class LiveKeyLogTest {
|
||||
|
||||
/**
|
||||
* A pin the relay cannot reconcile must be a fork, against the real log.
|
||||
*
|
||||
* Same size, different root: a relay that served two trees. There is no
|
||||
* proof that makes these agree and there never can be.
|
||||
*/
|
||||
@Test
|
||||
fun `a root we never saw at our pinned size is a fork`() {
|
||||
@@ -180,10 +181,15 @@ class LiveKeyLogTest {
|
||||
|
||||
val head = alice.relay.logHead()
|
||||
alice.vault.saveKeyLogPin(
|
||||
relay = alice.relay.host, pin = Vault.KeyLogPin(head.getInt("size"), Wire.b64(ByteArray(32) { 0x5A }))
|
||||
relay = alice.relay.host,
|
||||
pin = Vault.KeyLogPin(head.getInt("size"), Wire.b64(ByteArray(32) { 0x5A })),
|
||||
)
|
||||
|
||||
val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId }
|
||||
val bundle =
|
||||
alice.relay
|
||||
.fetchKeys(bob.userId)
|
||||
.bundles
|
||||
.single { it.deviceId == bob.identity.deviceId }
|
||||
assertFailsWith<KeyLogForkException> {
|
||||
alice.trust().enforceKeyLog(bundle, bob.userId)
|
||||
}
|
||||
@@ -191,17 +197,6 @@ class LiveKeyLogTest {
|
||||
|
||||
/**
|
||||
* An account with a global identity, verified through the pinned address.
|
||||
*
|
||||
* The relay files a leaf under an account's fnid once it has one, and
|
||||
* under `local:<audience>/<user_id>` before that. The subject is inside the
|
||||
* hashed bytes, so a verifier that does not know the address cannot
|
||||
* recompute the leaf, and before contacts carried one, an honest key from
|
||||
* such an account was refused. That is a false positive on a real
|
||||
* conversation, which is how a security setting gets switched off.
|
||||
*
|
||||
* This asserts all three states, because only having all three makes the
|
||||
* mechanism legible: refused with no address pinned, accepted once the
|
||||
* contact carries the right one, and refused again if the address is wrong.
|
||||
*/
|
||||
@Test
|
||||
fun `a contact carrying the peer's address verifies a key logged under it`() {
|
||||
@@ -211,38 +206,46 @@ class LiveKeyLogTest {
|
||||
val bob = Actor(url, "m")
|
||||
|
||||
// Bob claims a global identity, then publishes a device under it.
|
||||
val accountKey = io.flamenet.e2e.Ed25519.generate()
|
||||
val accountKey =
|
||||
io.flamenet.e2e.Ed25519
|
||||
.generate()
|
||||
val accountKeyB64 = Wire.b64(accountKey.publicKey)
|
||||
val audience = java.net.URI(url).host
|
||||
val sig = Wire.b64(io.flamenet.e2e.Ed25519.sign(
|
||||
accountKey,
|
||||
io.flamenet.e2e.FNID.claimCanonical(audience, bob.userId, accountKeyB64),
|
||||
))
|
||||
val sig =
|
||||
Wire.b64(
|
||||
io.flamenet.e2e.Ed25519.sign(
|
||||
accountKey,
|
||||
io.flamenet.e2e.FNID
|
||||
.claimCanonical(audience, bob.userId, accountKeyB64),
|
||||
),
|
||||
)
|
||||
bob.relay.claimAccountIdentity(accountKeyB64, sig)
|
||||
val fnid = io.flamenet.e2e.FNID.address(accountKey.publicKey)
|
||||
val fnid =
|
||||
io.flamenet.e2e.FNID
|
||||
.address(accountKey.publicKey)
|
||||
|
||||
val second = DeviceIdentity.create()
|
||||
RelayClient(url, bob.account.username, bob.account.appPassword)
|
||||
.also { it.useRelayAuth(second) }
|
||||
.registerDevice(second, second.generateOneTimePreKeys(3))
|
||||
|
||||
val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == second.deviceId }
|
||||
val bundle =
|
||||
alice.relay
|
||||
.fetchKeys(bob.userId)
|
||||
.bundles
|
||||
.single { it.deviceId == second.deviceId }
|
||||
val trust = alice.trust()
|
||||
|
||||
// 1. Nothing pinned: the honest key is refused, and says why, there
|
||||
// ARE publications for this device, just none we can recompute.
|
||||
// 1.
|
||||
val refusal = assertFailsWith<KeyNotLoggedException> { trust.enforceKeyLog(bundle, bob.userId) }
|
||||
assertTrue(refusal.hadEntries, "an address miss must not read as 'never published'")
|
||||
|
||||
// 2. The contact learns Bob's address from his handle, the way the
|
||||
// buddy list does, and the very same key now verifies, with no
|
||||
// fnid passed at the call site, because it comes from the pin.
|
||||
// 2.
|
||||
trust.rememberIdentity(bob.userId, bob.account.username)
|
||||
assertEquals(fnid, trust.pinnedFnid(bob.userId))
|
||||
trust.enforceKeyLog(bundle, bob.userId)
|
||||
|
||||
// 3. And a WRONG address still refuses, so what makes this pass is the
|
||||
// address being right rather than the check having been softened.
|
||||
// 3.
|
||||
assertFailsWith<KeyNotLoggedException> {
|
||||
trust.enforceKeyLog(bundle, bob.userId, "fnid:definitelynotbob")
|
||||
}
|
||||
@@ -250,9 +253,6 @@ class LiveKeyLogTest {
|
||||
|
||||
/**
|
||||
* A changed address is a changed identity, against the real relay.
|
||||
*
|
||||
* An fnid is derived from the account key, so a different one is a
|
||||
* different account key, not a new device, whatever it looks like.
|
||||
*/
|
||||
@Test
|
||||
fun `a peer whose address changes is reported as an identity change`() {
|
||||
@@ -262,15 +262,21 @@ class LiveKeyLogTest {
|
||||
val bob = Actor(url, "o")
|
||||
|
||||
val trust = alice.trust()
|
||||
val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId }
|
||||
val bundle =
|
||||
alice.relay
|
||||
.fetchKeys(bob.userId)
|
||||
.bundles
|
||||
.single { it.deviceId == bob.identity.deviceId }
|
||||
|
||||
// Pin Bob under an address, with his real keys.
|
||||
alice.vault.saveTrust(
|
||||
bob.userId,
|
||||
Vault.Trust(listOf(Wire.b64(bundle.identitySigning)), verified = false, fnid = "fnid:first"),
|
||||
)
|
||||
assertEquals(IdentityTrust.PINNED,
|
||||
trust.record(bob.userId, listOf(bundle.identitySigning), "fnid:first"))
|
||||
assertEquals(
|
||||
IdentityTrust.PINNED,
|
||||
trust.record(bob.userId, listOf(bundle.identitySigning), "fnid:first"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
IdentityTrust.CHANGED,
|
||||
@@ -290,8 +296,9 @@ class LiveKeyLogTest {
|
||||
val alice = Actor(url, "j")
|
||||
val bob = Actor(url, "k")
|
||||
|
||||
val conversations = Conversations(alice.identity, alice.vault, alice.relay)
|
||||
.also { it.myUserId = alice.userId }
|
||||
val conversations =
|
||||
Conversations(alice.identity, alice.vault, alice.relay)
|
||||
.also { it.myUserId = alice.userId }
|
||||
conversations.trust.requireKeyLog = true
|
||||
|
||||
// Honest first: enforcement on must NOT break an ordinary send.
|
||||
@@ -301,7 +308,8 @@ class LiveKeyLogTest {
|
||||
// Then pin a root the relay cannot reconcile, and watch the same send
|
||||
// path refuse rather than encrypting to a key it cannot vouch for.
|
||||
alice.vault.saveKeyLogPin(
|
||||
relay = alice.relay.host, pin = Vault.KeyLogPin(alice.relay.logHead().getInt("size"), Wire.b64(ByteArray(32) { 0x11 }))
|
||||
relay = alice.relay.host,
|
||||
pin = Vault.KeyLogPin(alice.relay.logHead().getInt("size"), Wire.b64(ByteArray(32) { 0x11 })),
|
||||
)
|
||||
assertFailsWith<KeyLogForkException> {
|
||||
conversations.send(bob.userId, "this one must not go out")
|
||||
|
||||
@@ -7,31 +7,12 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The client against a real relay, not a MockWebServer.
|
||||
*
|
||||
* Opt-in, and silent when off. Set `FN_LIVE_RELAY` to a relay's base URL
|
||||
* to run it. A test that reaches the network cannot be part of a hermetic suite
|
||||
* it fails on an aeroplane and on a build machine with no egress, and a suite
|
||||
* that is red for reasons unrelated to the code stops being read.
|
||||
*
|
||||
* FN_LIVE_RELAY=https://relay.flamenet.io ./gradlew :client:test
|
||||
*
|
||||
* What it checks is the one thing MockWebServer structurally cannot: that the
|
||||
* URL this client builds is a URL the deployed relay actually serves. The
|
||||
* transparency-log routes are unauthenticated by design, anyone may audit the
|
||||
* log, including someone who is not a user, so this needs no credentials.
|
||||
*/
|
||||
class LiveRelayTest {
|
||||
|
||||
private val relay: String? = System.getenv("FN_LIVE_RELAY")
|
||||
|
||||
|
||||
/**
|
||||
* Skips are announced, not silent. `return` from an unmet
|
||||
* precondition makes a test that did nothing report as PASSED, and a suite
|
||||
* of those is indistinguishable from a suite that works. `assumeTrue`
|
||||
* reports SKIPPED, which is the truth. `LiveShapesTest` has always done it
|
||||
* this way; these files did not, so 2 tests here reported green on every
|
||||
* pipeline while running nothing at all.
|
||||
* Skips are announced, not silent.
|
||||
*/
|
||||
private fun needFnLiveRelay() {
|
||||
LiveTarget.refuseProduction(relay)
|
||||
@@ -60,7 +41,7 @@ class LiveRelayTest {
|
||||
val client = RelayClient(base, username = "", appPassword = "")
|
||||
val head = client.logHead()
|
||||
val size = head.getInt("size")
|
||||
if (size == 0) return // a fresh relay has nothing to prove
|
||||
if (size == 0) return // a fresh relay has nothing to prove
|
||||
|
||||
val proof = client.logInclusion(index = 0, size = size.toLong())
|
||||
val path = proof.getJSONArray("proof")
|
||||
@@ -68,15 +49,17 @@ class LiveRelayTest {
|
||||
|
||||
// The point of the log is that a client can check it, so the client
|
||||
// checks it here rather than trusting the relay's own head.
|
||||
assertTrue(io.flamenet.e2e.KeyLog.verifyInclusion(
|
||||
leaf = Wire.unb64(proof.getString("leaf")),
|
||||
index = 0,
|
||||
size = size,
|
||||
proof = audit,
|
||||
// Checked against the head fetched separately, NOT against the
|
||||
// `root` the proof response carries. A server that gets to supply
|
||||
// both halves of the comparison has proven nothing.
|
||||
root = Wire.unb64(head.getString("root")),
|
||||
), "the deployed relay's inclusion proof does not reconstruct its own root")
|
||||
assertTrue(
|
||||
io.flamenet.e2e.KeyLog.verifyInclusion(
|
||||
leaf = Wire.unb64(proof.getString("leaf")),
|
||||
index = 0,
|
||||
size = size,
|
||||
proof = audit,
|
||||
// Checked against the head fetched separately, NOT against the
|
||||
// `root` the proof response carries.
|
||||
root = Wire.unb64(head.getString("root")),
|
||||
),
|
||||
"the deployed relay's inclusion proof does not reconstruct its own root",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.SealedSender
|
||||
import io.flamenet.e2e.Vault
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import javax.crypto.KeyGenerator
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -13,35 +13,17 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Sealed sender (§14) against a relay that is actually running.
|
||||
*
|
||||
* Opt-in via `FN_LIVE_FLEET`, pointed at the disposable fleet.
|
||||
*
|
||||
* FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test
|
||||
*
|
||||
* The engine tests prove the seal is sound. What only a real relay can show is
|
||||
* that the relay ACCEPTS an unauthenticated sealed submission, that it stores
|
||||
* no sender against it, and that the recipient still gets the message, which
|
||||
* is the entire claim the feature makes.
|
||||
*/
|
||||
class LiveSealedSenderTest {
|
||||
|
||||
private val base: String? = System.getenv("FN_LIVE_FLEET")
|
||||
|
||||
|
||||
/**
|
||||
* Skips are announced, not silent. `return` from an unmet
|
||||
* precondition makes a test that did nothing report as PASSED, and a suite
|
||||
* of those is indistinguishable from a suite that works. `assumeTrue`
|
||||
* reports SKIPPED, which is the truth. `LiveShapesTest` has always done it
|
||||
* this way; these files did not, so 2 tests here reported green on every
|
||||
* pipeline while running nothing at all.
|
||||
* Skips are announced, not silent.
|
||||
*/
|
||||
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.
|
||||
// No fallback.
|
||||
assumeTrue(
|
||||
LiveTarget.passphrase().isNotEmpty(),
|
||||
"set FN_LIVE_PASSPHRASE, this suite creates real accounts and will " +
|
||||
@@ -51,24 +33,40 @@ class LiveSealedSenderTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private class Actor(base: String, tag: String) {
|
||||
private class Actor(
|
||||
base: String,
|
||||
tag: String,
|
||||
) {
|
||||
val handle = "sl$tag${System.currentTimeMillis() % 100_000_000}"
|
||||
val account = RelayAccounts(base).signUp(
|
||||
username = handle,
|
||||
email = "$handle@example.invalid",
|
||||
password = LiveTarget.passphrase(),
|
||||
)
|
||||
val account =
|
||||
RelayAccounts(base).signUp(
|
||||
username = handle,
|
||||
email = "$handle@example.invalid",
|
||||
password = LiveTarget.passphrase(),
|
||||
)
|
||||
val identity: DeviceIdentity = DeviceIdentity.create()
|
||||
val vault = Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
)
|
||||
val vault =
|
||||
Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
)
|
||||
val relay = RelayClient(base, username = account.username, appPassword = account.appPassword)
|
||||
val userId: Long get() = account.userId
|
||||
val conversations = Conversations(identity, vault, relay).also { it.myUserId = userId }
|
||||
@@ -80,8 +78,8 @@ class LiveSealedSenderTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The claim, end to end: the relay carries the message and does not
|
||||
* learn who sent it.
|
||||
* The claim, end to end: the relay carries the message and does not learn
|
||||
* who sent it.
|
||||
*/
|
||||
@Test
|
||||
fun `a sealed message arrives and the relay records no sender`() {
|
||||
@@ -93,16 +91,12 @@ class LiveSealedSenderTest {
|
||||
bob.conversations.ensureDeliveryKey()
|
||||
alice.conversations.ensureDeliveryKey()
|
||||
|
||||
// First contact is necessarily UNSEALED. Alice does not hold Bob's
|
||||
// delivery key yet, and the key arrives inside an E2E message. This is
|
||||
// the honest shape of the feature, not a gap: sealing needs a prior
|
||||
// exchange, and a client that claimed otherwise would be lying.
|
||||
// First contact is necessarily UNSEALED.
|
||||
val first = alice.conversations.send(bob.userId, "hello, unsealed")
|
||||
assertTrue(first.reachedSomeone)
|
||||
assertFalse(first.sealed, "the first message to a new contact cannot be sealed")
|
||||
|
||||
// Bob reads it and, with it, learns nothing yet. Alice has not been
|
||||
// told Bob's key. Bob replying is what hands Alice his key.
|
||||
// Bob reads it and, with it, learns nothing yet.
|
||||
val bobRead = bob.conversations.poll()
|
||||
assertTrue(bobRead.any { it.content.text == "hello, unsealed" })
|
||||
val reply = bob.conversations.send(alice.userId, "hello back")
|
||||
@@ -117,8 +111,7 @@ class LiveSealedSenderTest {
|
||||
assertTrue(sealed.sealed, "Alice holds Bob's delivery key and must seal")
|
||||
assertTrue(sealed.reachedSomeone)
|
||||
|
||||
// The relay stored no sender for it. `from_user` 0 is the relay
|
||||
// saying it does not know, which is the whole feature.
|
||||
// The relay stored no sender for it.
|
||||
val inbox = bob.relay.pollInbox(bob.identity.deviceId, 0, 100)
|
||||
val sealedRow = inbox.last()
|
||||
assertEquals(0L, sealedRow.fromUser, "the relay must not know who sent a sealed message")
|
||||
@@ -146,26 +139,37 @@ class LiveSealedSenderTest {
|
||||
bob.conversations.ensureDeliveryKey()
|
||||
val old = bob.vault.loadDelivery()!!.key
|
||||
|
||||
// Bob replaces it. The relay now holds a digest the old key misses.
|
||||
// Bob replaces it.
|
||||
bob.conversations.rotateDeliveryKey()
|
||||
assertFalse(bob.vault.loadDelivery()!!.key.contentEquals(old))
|
||||
assertFalse(
|
||||
bob.vault
|
||||
.loadDelivery()!!
|
||||
.key
|
||||
.contentEquals(old),
|
||||
)
|
||||
|
||||
// A submission presenting the OLD key must be refused outright. Sent
|
||||
// through the client rather than by hand, so this exercises the same
|
||||
// path a real sealed send takes.
|
||||
val bundle = alice.relay.fetchKeys(bob.userId).bundles.first { it.deviceId == bob.identity.deviceId }
|
||||
val envelope = io.flamenet.e2e.E2ESession.initiate(alice.identity, bundle).encrypt("stale")
|
||||
val blob = SealedSender.seal(
|
||||
envelope, alice.userId, alice.identity.deviceId, bundle.deviceId, bundle.identityDH)
|
||||
// A submission presenting the OLD key must be refused outright.
|
||||
val bundle =
|
||||
alice.relay
|
||||
.fetchKeys(bob.userId)
|
||||
.bundles
|
||||
.first { it.deviceId == bob.identity.deviceId }
|
||||
val envelope =
|
||||
io.flamenet.e2e.E2ESession
|
||||
.initiate(alice.identity, bundle)
|
||||
.encrypt("stale")
|
||||
val blob =
|
||||
SealedSender.seal(envelope, alice.userId, alice.identity.deviceId, bundle.deviceId, bundle.identityDH)
|
||||
|
||||
val refused = runCatching {
|
||||
alice.relay.sendEnvelopes(
|
||||
bob.userId,
|
||||
listOf(RelayClient.ToDevice(bundle.deviceId, blob.toPayload())),
|
||||
alice.identity.deviceId,
|
||||
deliveryKey = Wire.b64(old),
|
||||
)
|
||||
}
|
||||
val refused =
|
||||
runCatching {
|
||||
alice.relay.sendEnvelopes(
|
||||
bob.userId,
|
||||
listOf(RelayClient.ToDevice(bundle.deviceId, blob.toPayload())),
|
||||
alice.identity.deviceId,
|
||||
deliveryKey = Wire.b64(old),
|
||||
)
|
||||
}
|
||||
assertTrue(refused.isFailure, "the relay must refuse a retired delivery key")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,47 +2,19 @@ package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* All three deployment shapes, three rounds each, repeatably.
|
||||
*
|
||||
* The shapes are not variations on a theme, they differ in where a
|
||||
* passphrase-derived secret is sent, which is the one thing a messenger must
|
||||
* not get wrong:
|
||||
*
|
||||
* - Managed (`relay.flamenet.io`), accounts in the shared FlameNet
|
||||
* service. One credential across Messenger and Files.
|
||||
* - Self-hosted, shared identity (`relay2`), somebody else's relay, still
|
||||
* the shared account service. This shape was unreachable until the
|
||||
* client started reading `/.well-known/flamenet/relay`: the old hardcoded
|
||||
* hostname list could never contain a stranger's relay.
|
||||
* - Self-hosted, local (`relay1`, `relay3`), the relay is its own
|
||||
* identity provider. The default, and not a lesser mode.
|
||||
*
|
||||
* Every round uses fresh handles. Reusing one makes round two fail with
|
||||
* "that name is taken", a relay answering correctly, which reads as a product
|
||||
* bug. Rounds are therefore repeatable by construction rather than by cleanup.
|
||||
*
|
||||
* Managed rounds create real accounts on `accounts.flamenet.io`. Enabled
|
||||
* explicitly by `FN_LIVE_MANAGED=1`, because that is not litter to leave by
|
||||
* accident.
|
||||
*
|
||||
* FN_LIVE_FLEET=https://relay1.flamenet.io FN_LIVE_MANAGED=1 \
|
||||
* ./gradlew :client:test --tests '*LiveShapesTest*'
|
||||
*/
|
||||
class LiveShapesTest {
|
||||
|
||||
/**
|
||||
* Skips are announced, not silent. `return` from an unmet
|
||||
* precondition makes a test that did nothing report as PASSED, and a suite
|
||||
* of those is indistinguishable from a suite that works. `assumeTrue`
|
||||
* reports SKIPPED, which is the truth.
|
||||
* Skips are announced, not silent.
|
||||
*/
|
||||
private fun needFleet() {
|
||||
LiveTarget.refuseProduction(System.getenv("FN_LIVE_FLEET"))
|
||||
@@ -53,11 +25,13 @@ class LiveShapesTest {
|
||||
private fun needManaged() {
|
||||
assumeTrue(
|
||||
System.getenv("FN_LIVE_MANAGED") == "1",
|
||||
"set FN_LIVE_MANAGED=1, these create real accounts on accounts.flamenet.io")
|
||||
"set FN_LIVE_MANAGED=1, these create real accounts on accounts.flamenet.io",
|
||||
)
|
||||
assumeTrue(
|
||||
PASSPHRASE.isNotEmpty(),
|
||||
"set FN_LIVE_PASSPHRASE, these create real accounts and will not use " +
|
||||
"a passphrase written into the repository")
|
||||
"a passphrase written into the repository",
|
||||
)
|
||||
}
|
||||
|
||||
private val local = "https://relay1.flamenet.io"
|
||||
@@ -66,9 +40,20 @@ class LiveShapesTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
@@ -78,15 +63,11 @@ class LiveShapesTest {
|
||||
|
||||
/**
|
||||
* Register against the shared account service, pacing for its rate limit.
|
||||
*
|
||||
* The throttle is the service working correctly, not a client failure,
|
||||
* three rounds across two shapes is a dozen registrations in a burst, and
|
||||
* any sane account service refuses that. So this backs off and retries
|
||||
* rather than reporting a product bug, and still FAILS if the service never
|
||||
* lets us through: a rate limit that never clears is a real problem, and
|
||||
* skipping on it would hide one.
|
||||
*/
|
||||
private fun registerPaced(accounts: AccountsClient, name: String) {
|
||||
private fun registerPaced(
|
||||
accounts: AccountsClient,
|
||||
name: String,
|
||||
) {
|
||||
var wait = 15_000L
|
||||
repeat(3) { attempt ->
|
||||
try {
|
||||
@@ -95,14 +76,12 @@ class LiveShapesTest {
|
||||
} catch (e: AccountsClient.Failure.RateLimited) {
|
||||
if (attempt == 2) {
|
||||
// Not a product failure, and not something to retry
|
||||
// through. The service allows three registrations per
|
||||
// address per HOUR, deliberately, a short backoff cannot
|
||||
// outwait that, and pretending otherwise would turn a
|
||||
// correct defence into a flaky test. Say what happened.
|
||||
// through.
|
||||
throw AssertionError(
|
||||
"the account service's registration budget is spent: it allows 3 per " +
|
||||
"address per hour and this run needed more. Not a defect, wait for " +
|
||||
"the window to roll over, or run one shape at a time.")
|
||||
"address per hour and this run needed more. Not a defect, wait for " +
|
||||
"the window to roll over, or run one shape at a time.",
|
||||
)
|
||||
}
|
||||
Thread.sleep(wait)
|
||||
wait *= 2
|
||||
@@ -110,7 +89,7 @@ class LiveShapesTest {
|
||||
}
|
||||
}
|
||||
|
||||
// -- What each shape claims about itself ---------------------------------
|
||||
// -- What each shape claims about itself ---------------------------------.
|
||||
|
||||
@Test
|
||||
fun `each relay reports the shape it actually is`() {
|
||||
@@ -124,56 +103,58 @@ class LiveShapesTest {
|
||||
val sharedDescriptor = RelayDiscovery.describe(sharedSelfHosted)
|
||||
assertTrue(sharedDescriptor != null, "relay2 served no descriptor")
|
||||
assertEquals(RelayDiscovery.Accounts.SHARED, sharedDescriptor!!.accounts)
|
||||
assertTrue(sharedDescriptor.usesSharedAccounts,
|
||||
"relay2 names ${sharedDescriptor.accountsUrl}, which is not trusted")
|
||||
assertTrue(
|
||||
sharedDescriptor.usesSharedAccounts,
|
||||
"relay2 names ${sharedDescriptor.accountsUrl}, which is not trusted",
|
||||
)
|
||||
|
||||
if (System.getenv("FN_LIVE_MANAGED") == "1") {
|
||||
val managedDescriptor = RelayDiscovery.describe(managed)
|
||||
assertTrue(managedDescriptor?.usesSharedAccounts == true,
|
||||
"the managed relay does not report shared accounts")
|
||||
assertTrue(
|
||||
managedDescriptor?.usesSharedAccounts == true,
|
||||
"the managed relay does not report shared accounts",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Our relays cannot be made to redirect their users. Whatever
|
||||
* `relay.flamenet.io` advertises, the client authenticates against the
|
||||
* service pinned in the app, so compromising one of ours does not let it
|
||||
* harvest passphrase-derived secrets.
|
||||
* Our relays cannot be made to redirect their users.
|
||||
*/
|
||||
@Test
|
||||
fun `a pinned relay cannot redirect its users to another account service`() {
|
||||
val hijacked = RelayDiscovery.Descriptor(
|
||||
audience = "relay.flamenet.io",
|
||||
accounts = RelayDiscovery.Accounts.SHARED,
|
||||
accountsUrl = "https://accounts.attacker.example",
|
||||
federation = "closed",
|
||||
protocolVersions = listOf(1),
|
||||
val hijacked =
|
||||
RelayDiscovery.Descriptor(
|
||||
audience = "relay.flamenet.io",
|
||||
accounts = RelayDiscovery.Accounts.SHARED,
|
||||
accountsUrl = "https://accounts.attacker.example",
|
||||
federation = "closed",
|
||||
protocolVersions = listOf(1),
|
||||
)
|
||||
assertEquals(
|
||||
RelayDiscovery.OUR_ACCOUNT_SERVICE,
|
||||
hijacked.effectiveAccountsUrl,
|
||||
"a pinned relay redirected its users' derived secrets",
|
||||
)
|
||||
assertEquals(RelayDiscovery.OUR_ACCOUNT_SERVICE, hijacked.effectiveAccountsUrl,
|
||||
"a pinned relay redirected its users' derived secrets")
|
||||
}
|
||||
|
||||
/**
|
||||
* And a relay that is NOT ours IS believed, deliberately. A self-hoster
|
||||
* running Messenger and Files together needs one account across both, held
|
||||
* by their own service. Refusing here would make "run both" mean "run them
|
||||
* with two separate logins". The user typed that hostname; it cannot
|
||||
* redirect anybody else's users.
|
||||
* And a relay that is NOT ours IS believed, deliberately.
|
||||
*/
|
||||
@Test
|
||||
fun `a self-hosted relay may name its own account service`() {
|
||||
val theirs = RelayDiscovery.Descriptor(
|
||||
audience = "relay.someone-else.example",
|
||||
accounts = RelayDiscovery.Accounts.SHARED,
|
||||
accountsUrl = "https://accounts.someone-else.example",
|
||||
federation = "closed",
|
||||
protocolVersions = listOf(1),
|
||||
)
|
||||
val theirs =
|
||||
RelayDiscovery.Descriptor(
|
||||
audience = "relay.someone-else.example",
|
||||
accounts = RelayDiscovery.Accounts.SHARED,
|
||||
accountsUrl = "https://accounts.someone-else.example",
|
||||
federation = "closed",
|
||||
protocolVersions = listOf(1),
|
||||
)
|
||||
assertTrue(theirs.usesSharedAccounts)
|
||||
assertEquals("https://accounts.someone-else.example", theirs.effectiveAccountsUrl)
|
||||
}
|
||||
|
||||
// -- Sign-up, three rounds, per shape ------------------------------------
|
||||
// -- Sign-up, three rounds, per shape ------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `local self-hosted sign-up works three times running`() {
|
||||
@@ -183,21 +164,11 @@ class LiveShapesTest {
|
||||
|
||||
/**
|
||||
* One registration, then three full rounds on it.
|
||||
*
|
||||
* The account service allows three registrations per address per hour,
|
||||
* on purpose: forty sign-in attempts is a person with a password manager
|
||||
* and a bad afternoon, forty accounts is a flood. Three sign-UPS per shape
|
||||
* would consume an hour's budget for one shape and fail the next, so a
|
||||
* test written that way measures the rate limiter, not the product.
|
||||
*
|
||||
* So sign-up is proven once, and the repeatable part is the flow a person
|
||||
* actually repeats: sign in, register a device, be reachable. Fresh
|
||||
* registrations are covered by the local shape, which has no such limit
|
||||
* because a self-hosted relay is not a shared service.
|
||||
*/
|
||||
@Test
|
||||
fun `shared self-hosted works three rounds running`() {
|
||||
needFleet(); needManaged()
|
||||
needFleet()
|
||||
needManaged()
|
||||
sharedRoundsOnOneAccount(sharedSelfHosted, "s")
|
||||
}
|
||||
|
||||
@@ -207,7 +178,10 @@ class LiveShapesTest {
|
||||
sharedRoundsOnOneAccount(managed, "m")
|
||||
}
|
||||
|
||||
private fun sharedRoundsOnOneAccount(base: String, tag: String) {
|
||||
private fun sharedRoundsOnOneAccount(
|
||||
base: String,
|
||||
tag: String,
|
||||
) {
|
||||
val name = handle(tag)
|
||||
val descriptor = RelayDiscovery.describe(base)
|
||||
assertTrue(descriptor?.usesSharedAccounts == true, "$base is not a shared-identity relay")
|
||||
@@ -222,28 +196,23 @@ class LiveShapesTest {
|
||||
val account = RelayAccounts(base).sharedSession(session.token, name)
|
||||
assertTrue(account.appPassword.isNotEmpty(), "round $round: no relay credential")
|
||||
|
||||
// Every round must land on the SAME relay account. The relay
|
||||
// keys on the product-scoped pseudonym, not the handle; keying on
|
||||
// the handle would make each round a new account and nobody would
|
||||
// notice until messages stopped arriving.
|
||||
// Every round must land on the SAME relay account.
|
||||
if (firstUserId == null) firstUserId = account.userId
|
||||
assertEquals(firstUserId, account.userId,
|
||||
"round $round produced a different relay account")
|
||||
assertEquals(
|
||||
firstUserId,
|
||||
account.userId,
|
||||
"round $round produced a different relay account",
|
||||
)
|
||||
|
||||
registerAndProve(base, account)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Messaging on each shape ---------------------------------------------
|
||||
// -- Messaging on each shape ---------------------------------------------.
|
||||
|
||||
/**
|
||||
* A conversation on every shape, so "it works" is not a claim about one
|
||||
* relay's configuration.
|
||||
*
|
||||
* Deliberately three separate conversations rather than one crossing
|
||||
* relays. Cross-relay delivery is federation, which is a different
|
||||
* mechanism with a different failure mode; conflating them would let a
|
||||
* federation problem masquerade as a messaging problem.
|
||||
*/
|
||||
@Test
|
||||
fun `a conversation works on a local self-hosted relay`() {
|
||||
@@ -252,40 +221,41 @@ class LiveShapesTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Split per shape rather than one test covering all three. The shared
|
||||
* shapes go through the account service, which rate-limits, correctly,
|
||||
* and a single combined test lets one shape's throttle hide another
|
||||
* shape's result.
|
||||
* Split per shape rather than one test covering all three.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A conversation on a shared-identity relay needs TWO accounts, and the
|
||||
* account service allows three registrations an hour. Enabled separately
|
||||
* from the round tests so a single run cannot silently consume the whole
|
||||
* budget and leave the next test looking broken.
|
||||
* account service allows three registrations an hour.
|
||||
*/
|
||||
@Test
|
||||
fun `a conversation works on a shared self-hosted relay`() {
|
||||
needFleet(); needManaged(); needBudget()
|
||||
needFleet()
|
||||
needManaged()
|
||||
needBudget()
|
||||
talk(sharedSelfHosted) { tag -> sharedAccount(sharedSelfHosted, tag) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a conversation works on the managed relay`() {
|
||||
needManaged(); needBudget()
|
||||
needManaged()
|
||||
needBudget()
|
||||
talk(managed) { tag -> sharedAccount(managed, tag) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The account service permits three registrations per address per hour.
|
||||
* These tests each need two, so they are opt-in on top of `FN_LIVE_MANAGED`
|
||||
* running everything at once cannot fit, and a suite that cannot fit its
|
||||
* own limits reports failures that are really scheduling.
|
||||
*/
|
||||
private fun needBudget() = assumeTrue(
|
||||
System.getenv("FN_LIVE_SIGNUP_BUDGET") == "1",
|
||||
"set FN_LIVE_SIGNUP_BUDGET=1, needs 2 of the 3 registrations allowed per hour")
|
||||
private fun needBudget() =
|
||||
assumeTrue(
|
||||
System.getenv("FN_LIVE_SIGNUP_BUDGET") == "1",
|
||||
"set FN_LIVE_SIGNUP_BUDGET=1, needs 2 of the 3 registrations allowed per hour",
|
||||
)
|
||||
|
||||
private fun talk(base: String, make: (String) -> RelayAccounts.Account) {
|
||||
private fun talk(
|
||||
base: String,
|
||||
make: (String) -> RelayAccounts.Account,
|
||||
) {
|
||||
val alice = ready(base, make("ta"))
|
||||
val bob = ready(base, make("tb"))
|
||||
|
||||
@@ -293,21 +263,17 @@ class LiveShapesTest {
|
||||
val sent = alice.second.send(bob.first, text)
|
||||
assertTrue(sent.reachedSomeone, "$base delivered nothing: ${sent.failed}")
|
||||
|
||||
// What a PERSON would see. `poll` also returns control traffic, and
|
||||
// since key-log checking became automatic there is reliably some: the
|
||||
// first send to a contact gossips this device's view of the
|
||||
// transparency log (§19.4), which enforcement is what sets the pin for.
|
||||
// The client consumes that envelope and never shows it, counting it as
|
||||
// a message made this test fail on a change that made the product more
|
||||
// correct.
|
||||
// What a PERSON would see.
|
||||
val got = bob.second.poll().filter { it.content.isRenderable }
|
||||
assertEquals(1, got.size, "$base: expected one message, got ${got.size}")
|
||||
assertEquals(text, got.single().text)
|
||||
}
|
||||
|
||||
/** An account plus a registered device and a ready conversation layer. */
|
||||
private fun ready(base: String, account: RelayAccounts.Account):
|
||||
Pair<Long, Conversations> {
|
||||
private fun ready(
|
||||
base: String,
|
||||
account: RelayAccounts.Account,
|
||||
): Pair<Long, Conversations> {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key())
|
||||
vault.saveIdentity(identity)
|
||||
@@ -319,12 +285,18 @@ class LiveShapesTest {
|
||||
Conversations(identity, vault, relay).also { it.myUserId = account.userId }
|
||||
}
|
||||
|
||||
private fun localAccount(base: String, tag: String): RelayAccounts.Account {
|
||||
private fun localAccount(
|
||||
base: String,
|
||||
tag: String,
|
||||
): RelayAccounts.Account {
|
||||
val name = handle(tag)
|
||||
return RelayAccounts(base).signUp(name, "$name@example.invalid", PASSPHRASE)
|
||||
}
|
||||
|
||||
private fun sharedAccount(base: String, tag: String): RelayAccounts.Account {
|
||||
private fun sharedAccount(
|
||||
base: String,
|
||||
tag: String,
|
||||
): RelayAccounts.Account {
|
||||
val name = handle(tag)
|
||||
val descriptor = RelayDiscovery.describe(base)!!
|
||||
val accounts = AccountsClient(descriptor.accountsUrl!!)
|
||||
@@ -332,10 +304,13 @@ class LiveShapesTest {
|
||||
return RelayAccounts(base).sharedSession(accounts.signIn(name, PASSPHRASE).token, name)
|
||||
}
|
||||
|
||||
// -- One round of each kind ----------------------------------------------
|
||||
// -- One round of each kind ----------------------------------------------.
|
||||
|
||||
/** Relay-local: the relay is its own identity provider. */
|
||||
private fun localRound(base: String, tag: String) {
|
||||
private fun localRound(
|
||||
base: String,
|
||||
tag: String,
|
||||
) {
|
||||
val name = handle(tag)
|
||||
val account = RelayAccounts(base).signUp(name, "$name@example.invalid", PASSPHRASE)
|
||||
assertEquals(name, account.username)
|
||||
@@ -352,7 +327,10 @@ class LiveShapesTest {
|
||||
* derived secret reaches the account service, and the token it returns is
|
||||
* exchanged with the relay for a relay credential.
|
||||
*/
|
||||
private fun sharedRound(base: String, tag: String) {
|
||||
private fun sharedRound(
|
||||
base: String,
|
||||
tag: String,
|
||||
) {
|
||||
val name = handle(tag)
|
||||
val descriptor = RelayDiscovery.describe(base)
|
||||
assertTrue(descriptor?.usesSharedAccounts == true, "$base is not a shared-identity relay")
|
||||
@@ -366,17 +344,22 @@ class LiveShapesTest {
|
||||
assertTrue(account.appPassword.isNotEmpty())
|
||||
registerAndProve(base, account)
|
||||
|
||||
// A second sign-in must land on the SAME relay account. The relay
|
||||
// keys on the product-scoped pseudonym, not the handle; if it keyed on
|
||||
// the handle, signing in twice would either fail or make a duplicate.
|
||||
val second = RelayAccounts(base)
|
||||
.sharedSession(accounts.signIn(name, PASSPHRASE).token, name)
|
||||
assertEquals(account.userId, second.userId,
|
||||
"a second sign-in produced a different relay account")
|
||||
// A second sign-in must land on the SAME relay account.
|
||||
val second =
|
||||
RelayAccounts(base)
|
||||
.sharedSession(accounts.signIn(name, PASSPHRASE).token, name)
|
||||
assertEquals(
|
||||
account.userId,
|
||||
second.userId,
|
||||
"a second sign-in produced a different relay account",
|
||||
)
|
||||
}
|
||||
|
||||
/** Register a device and prove the account is actually reachable. */
|
||||
private fun registerAndProve(base: String, account: RelayAccounts.Account) {
|
||||
private fun registerAndProve(
|
||||
base: String,
|
||||
account: RelayAccounts.Account,
|
||||
) {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key())
|
||||
vault.saveIdentity(identity)
|
||||
@@ -386,8 +369,6 @@ class LiveShapesTest {
|
||||
relay.registerDevice(identity, identity.generateOneTimePreKeys(10))
|
||||
|
||||
// Reachable means a stranger can fetch a bundle WITH a one-time prekey.
|
||||
// A device published without them can send and can never be started
|
||||
// with, which looks identical from the account's own side.
|
||||
val bundles = relay.fetchKeys(account.userId).bundles
|
||||
val mine = bundles.firstOrNull { it.deviceId == identity.deviceId }
|
||||
assertTrue(mine != null, "$base served no bundle for the device just registered")
|
||||
@@ -396,8 +377,8 @@ class LiveShapesTest {
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Was `"a-long-enough-passphrase"`, a literal in a source file used
|
||||
* to create real accounts. See [LiveTarget.passphrase].
|
||||
* Was `"a-long-enough-passphrase"`, a literal in a source file used to
|
||||
* create real accounts.
|
||||
*/
|
||||
val PASSPHRASE: String get() = LiveTarget.passphrase()
|
||||
}
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The whole sign-up-and-register path against a throwaway relay.
|
||||
*
|
||||
* Opt-in via `FN_LIVE_FLEET`, and pointed at the disposable fleet rather
|
||||
* than production. It creates accounts, which is exactly what a disposable
|
||||
* relay is for and exactly what `relay.flamenet.io` is not.
|
||||
*
|
||||
* FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test
|
||||
*
|
||||
* This exists because the same path through the emulator takes four minutes to
|
||||
* tell you it failed, and tells you nothing about where.
|
||||
*/
|
||||
class LiveSignUpTest {
|
||||
|
||||
private val relay: String? = System.getenv("FN_LIVE_FLEET")
|
||||
|
||||
|
||||
/**
|
||||
* Skips are announced, not silent. `return` from an unmet
|
||||
* precondition makes a test that did nothing report as PASSED, and a suite
|
||||
* of those is indistinguishable from a suite that works. `assumeTrue`
|
||||
* reports SKIPPED, which is the truth. `LiveShapesTest` has always done it
|
||||
* this way; these files did not, so 1 tests here reported green on every
|
||||
* pipeline while running nothing at all.
|
||||
* Skips are announced, not silent.
|
||||
*/
|
||||
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.
|
||||
// No fallback.
|
||||
assumeTrue(
|
||||
LiveTarget.passphrase().isNotEmpty(),
|
||||
"set FN_LIVE_PASSPHRASE, this suite creates real accounts and will " +
|
||||
@@ -50,11 +32,12 @@ class LiveSignUpTest {
|
||||
val base = relay!!
|
||||
val handle = "jvmt" + (System.currentTimeMillis() % 100_000_000)
|
||||
|
||||
val account = RelayAccounts(base).signUp(
|
||||
username = handle,
|
||||
email = "$handle@example.invalid",
|
||||
password = LiveTarget.passphrase(),
|
||||
)
|
||||
val account =
|
||||
RelayAccounts(base).signUp(
|
||||
username = handle,
|
||||
email = "$handle@example.invalid",
|
||||
password = LiveTarget.passphrase(),
|
||||
)
|
||||
assertEquals(handle, account.username)
|
||||
assertTrue(account.appPassword.isNotEmpty())
|
||||
|
||||
@@ -68,9 +51,13 @@ class LiveSignUpTest {
|
||||
// The device is only really registered if a stranger can fetch a bundle
|
||||
// for it, that is what makes this phone reachable.
|
||||
val bundles = client.fetchKeys(account.userId).bundles
|
||||
assertTrue(bundles.any { it.deviceId == identity.deviceId },
|
||||
"the relay did not serve a bundle for the device we just registered")
|
||||
assertTrue(bundles.first { it.deviceId == identity.deviceId }.oneTimePreKey != null,
|
||||
"the relay served no one-time prekey, so the pool never landed")
|
||||
assertTrue(
|
||||
bundles.any { it.deviceId == identity.deviceId },
|
||||
"the relay did not serve a bundle for the device we just registered",
|
||||
)
|
||||
assertTrue(
|
||||
bundles.first { it.deviceId == identity.deviceId }.oneTimePreKey != null,
|
||||
"the relay served no one-time prekey, so the pool never landed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,69 +4,39 @@ import java.net.URI
|
||||
|
||||
/**
|
||||
* Where a live suite is allowed to point.
|
||||
*
|
||||
* On 2026-08-31 a thirty-minute test round created 39 accounts in the
|
||||
* PRODUCTION database. They are still there: `jvmt…`, `kt…`, `sl…`, `live…`,
|
||||
* 55 of 86 accounts on the relay, which made the launch signup metric unusable
|
||||
* a real person signing up was invisible inside the burst.
|
||||
*
|
||||
* Every one of those suites is correctly written. They are opt-in, and their
|
||||
* own headers say to point them at the disposable fleet and not at production.
|
||||
* That was the whole protection, and documentation is not a protection: it
|
||||
* cannot refuse anything. Something ran them against production regardless, and
|
||||
* nothing at any layer objected.
|
||||
*
|
||||
* A deny list, not an allow list, and that is a deliberate trade. An
|
||||
* allow list would be stricter, and would also refuse a developer's own
|
||||
* throwaway relay on localhost or a self-hosted instance, which is a normal
|
||||
* thing to test against and would push people to disable the check. Naming the
|
||||
* hosts that must never receive test traffic keeps the guard narrow enough to
|
||||
* survive.
|
||||
*
|
||||
* A refusal here is an ERROR, never a skip. "This run was pointed at
|
||||
* production" is not a precondition that happens to be unmet; it is a mistake
|
||||
* that has already done damage on one occasion, and a skip is how it would go
|
||||
* unnoticed on the next.
|
||||
*/
|
||||
object LiveTarget {
|
||||
|
||||
/**
|
||||
* The disposable fleet lives on the same domain as production, so it
|
||||
* is named explicitly and checked FIRST. Without this, denying
|
||||
* `flamenet.io` and its subdomains refuses `relay1.flamenet.io`, the exact
|
||||
* place these suites are supposed to point, and the guard blocks every
|
||||
* legitimate run while appearing to block only production. A test caught
|
||||
* that before it shipped, which is the argument for testing a guard rather
|
||||
* than reading it.
|
||||
* The disposable fleet lives on the same domain as production, so it is
|
||||
* named explicitly and checked FIRST.
|
||||
*/
|
||||
private val DISPOSABLE = listOf(
|
||||
"relay1.flamenet.io",
|
||||
"relay2.flamenet.io",
|
||||
"relay3.flamenet.io",
|
||||
)
|
||||
private val DISPOSABLE =
|
||||
listOf(
|
||||
"relay1.flamenet.io",
|
||||
"relay2.flamenet.io",
|
||||
"relay3.flamenet.io",
|
||||
)
|
||||
|
||||
/**
|
||||
* Hosts that serve real accounts and real people's messages.
|
||||
*
|
||||
* The bare domain is in here on purpose: a production host added later
|
||||
* is denied by default rather than needing somebody to remember this file.
|
||||
* That is only safe because [DISPOSABLE] names the exceptions above.
|
||||
*/
|
||||
private val PRODUCTION = listOf(
|
||||
"flamenetmessenger.com",
|
||||
"relay.flamenet.io",
|
||||
"accounts.flamenet.io",
|
||||
"flamenet.io",
|
||||
)
|
||||
private val PRODUCTION =
|
||||
listOf(
|
||||
"flamenetmessenger.com",
|
||||
"relay.flamenet.io",
|
||||
"accounts.flamenet.io",
|
||||
"flamenet.io",
|
||||
)
|
||||
|
||||
/**
|
||||
* Throw if [url] names production. Null is fine, that is "not configured",
|
||||
* which the caller turns into an announced skip.
|
||||
* Throw if [url] names production.
|
||||
*/
|
||||
fun refuseProduction(url: String?) {
|
||||
val host = url?.let { runCatching { URI(it).host }.getOrNull() }
|
||||
?.lowercase()
|
||||
?: return
|
||||
val host =
|
||||
url
|
||||
?.let { runCatching { URI(it).host }.getOrNull() }
|
||||
?.lowercase()
|
||||
?: return
|
||||
if (DISPOSABLE.any { host == it }) return
|
||||
// `endsWith(".$it")` so a subdomain cannot slip past a bare-host match.
|
||||
if (PRODUCTION.any { host == it || host.endsWith(".$it") }) {
|
||||
@@ -80,23 +50,6 @@ object LiveTarget {
|
||||
|
||||
/**
|
||||
* The passphrase a live suite signs up with.
|
||||
*
|
||||
* From the environment, with no fallback, and that is the whole
|
||||
* point. Both live suites carried
|
||||
* `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create
|
||||
* accounts on `accounts.flamenet.io` whenever somebody set
|
||||
* `FN_LIVE_MANAGED=1`. A credential written into a source file is a
|
||||
* published password: every account those runs ever made can be signed into
|
||||
* by anybody who can read this repository, and nothing about the accounts
|
||||
* being throwaway changes that, they are real accounts on a real service,
|
||||
* and they persist.
|
||||
*
|
||||
* Empty rather than throwing, so the caller turns it into an announced
|
||||
* skip. A live suite that cannot run must report SKIPPED and not fail: a
|
||||
* red suite for a missing variable teaches people to ignore red.
|
||||
*
|
||||
* The iOS side had the same class of bug twice (ios#62), a literal
|
||||
* fallback used against production. This is the Kotlin half of that fix.
|
||||
*/
|
||||
fun passphrase(): String = System.getenv("FN_LIVE_PASSPHRASE").orEmpty()
|
||||
}
|
||||
|
||||
@@ -5,13 +5,8 @@ import kotlin.test.assertFailsWith
|
||||
|
||||
/**
|
||||
* The guard that stops a live suite creating accounts on production.
|
||||
*
|
||||
* This is the test that runs in every pipeline, unlike the live suites it
|
||||
* protects, those skip without `FN_LIVE_FLEET`, so if the rule itself were
|
||||
* only exercised there it would never be checked at all.
|
||||
*/
|
||||
class LiveTargetTest {
|
||||
|
||||
@Test
|
||||
fun `production hosts are refused`() {
|
||||
for (url in listOf(
|
||||
@@ -19,8 +14,7 @@ class LiveTargetTest {
|
||||
"https://relay.flamenet.io",
|
||||
"https://accounts.flamenet.io",
|
||||
"https://flamenet.io",
|
||||
// Subdomains too. A deny list matching only the bare host would
|
||||
// wave through anything in front of it.
|
||||
// Subdomains too.
|
||||
"https://api.flamenetmessenger.com",
|
||||
// Scheme and port must not matter, the host is the whole question.
|
||||
"http://relay.flamenet.io:8080/e2e",
|
||||
@@ -39,8 +33,7 @@ class LiveTargetTest {
|
||||
"https://relay3.flamenet.io",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:18973",
|
||||
// A self-hoster's own instance. Refusing this would push people to
|
||||
// disable the check, which is worse than not having it.
|
||||
// A self-hoster's own instance.
|
||||
"https://relay.example.org",
|
||||
)) {
|
||||
LiveTarget.refuseProduction(url)
|
||||
@@ -48,9 +41,7 @@ class LiveTargetTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Null is "not configured", which the caller turns into an announced
|
||||
* skip. It must not throw, or an unconfigured machine cannot run the suite
|
||||
* at all.
|
||||
* Null is "not configured", which the caller turns into an announced skip.
|
||||
*/
|
||||
@Test
|
||||
fun `an unset target is not an error`() {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import okhttp3.Request
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import org.json.JSONObject
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
@@ -10,17 +10,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* One credential, two products, and whether either is optional.
|
||||
*
|
||||
* The claim the product makes is narrow and worth testing exactly: hosting
|
||||
* only Messenger leaves nothing missing. Flamenet Files is not a dependency,
|
||||
* a fallback, or a hidden requirement. What the shared account service buys is
|
||||
* one credential across both, and nothing else.
|
||||
*
|
||||
* Creates a real account on `accounts.flamenet.io`. Enabled by
|
||||
* `FN_LIVE_MANAGED=1`.
|
||||
*/
|
||||
class LiveTwoProductsTest {
|
||||
|
||||
private val accountsUrl = "https://accounts.flamenet.io"
|
||||
private val messengerRelay = "https://relay.flamenet.io"
|
||||
private val filesHost = "https://files.flamenet.io"
|
||||
@@ -29,11 +20,13 @@ class LiveTwoProductsTest {
|
||||
private fun needManaged() {
|
||||
assumeTrue(
|
||||
System.getenv("FN_LIVE_MANAGED") == "1",
|
||||
"set FN_LIVE_MANAGED=1, this creates a real account")
|
||||
"set FN_LIVE_MANAGED=1, this creates a real account",
|
||||
)
|
||||
assumeTrue(
|
||||
PASSPHRASE.isNotEmpty(),
|
||||
"set FN_LIVE_PASSPHRASE, this creates a real account and will not " +
|
||||
"use a passphrase written into the repository")
|
||||
"use a passphrase written into the repository",
|
||||
)
|
||||
}
|
||||
|
||||
private fun needFleet() {
|
||||
@@ -43,9 +36,7 @@ class LiveTwoProductsTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Messenger-only case, which is the one that matters. A relay
|
||||
* keeping its own accounts must work with no account service and no Files
|
||||
* anywhere, no probe of either, no degraded mode, no missing feature.
|
||||
* The Messenger-only case, which is the one that matters.
|
||||
*/
|
||||
@Test
|
||||
fun `a messenger-only self-hoster needs no account service and no files`() {
|
||||
@@ -53,14 +44,17 @@ class LiveTwoProductsTest {
|
||||
val descriptor = RelayDiscovery.describe(localRelay)
|
||||
assertTrue(descriptor != null, "the relay served no descriptor")
|
||||
assertEquals(RelayDiscovery.Accounts.LOCAL, descriptor!!.accounts)
|
||||
assertTrue(descriptor.accountsUrl == null,
|
||||
"a local relay named an account service: ${descriptor.accountsUrl}")
|
||||
assertTrue(
|
||||
descriptor.accountsUrl == null,
|
||||
"a local relay named an account service: ${descriptor.accountsUrl}",
|
||||
)
|
||||
|
||||
// And it can actually run an account, start to finish, with nothing
|
||||
// else involved.
|
||||
val handle = "solo" + (System.nanoTime() % 10_000_000)
|
||||
val account = RelayAccounts(localRelay)
|
||||
.signUp(handle, "$handle@example.invalid", PASSPHRASE)
|
||||
val account =
|
||||
RelayAccounts(localRelay)
|
||||
.signUp(handle, "$handle@example.invalid", PASSPHRASE)
|
||||
assertEquals(handle, account.username)
|
||||
assertTrue(account.appPassword.isNotEmpty())
|
||||
assertEquals(account.userId, RelayAccounts(localRelay).signIn(handle, PASSPHRASE).userId)
|
||||
@@ -71,22 +65,27 @@ class LiveTwoProductsTest {
|
||||
fun `files and the managed relay point at the same account service`() {
|
||||
needManaged()
|
||||
val http = RelayClient.defaultHttpClient()
|
||||
val filesConfig = http.newCall(
|
||||
Request.Builder().url("$filesHost/api/config").get().build()
|
||||
).execute().use { JSONObject(it.body?.string().orEmpty()) }
|
||||
val filesConfig =
|
||||
http
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url("$filesHost/api/config")
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { JSONObject(it.body?.string().orEmpty()) }
|
||||
|
||||
assertEquals(accountsUrl, filesConfig.getString("accountsURL"),
|
||||
"Files uses a different account service than Messenger")
|
||||
assertEquals(
|
||||
accountsUrl,
|
||||
filesConfig.getString("accountsURL"),
|
||||
"Files uses a different account service than Messenger",
|
||||
)
|
||||
assertEquals(accountsUrl, RelayDiscovery.describe(messengerRelay)?.accountsUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* One handle and passphrase, signed in to both products.
|
||||
*
|
||||
* And the two tokens must carry different pseudonyms. That is the
|
||||
* whole reason sessions are product-scoped: if Messenger and Files received
|
||||
* the same opaque id, the two services could correlate their users, which
|
||||
* is exactly what a shared account service must not enable.
|
||||
*/
|
||||
@Test
|
||||
fun `one account signs in to both products without letting them correlate`() {
|
||||
@@ -100,30 +99,37 @@ class LiveTwoProductsTest {
|
||||
assertTrue(messengerSession.token.isNotEmpty(), "no messenger session")
|
||||
assertTrue(filesSession.token.isNotEmpty(), "no files session")
|
||||
|
||||
assertNotEquals(messengerSession.pseudonym, filesSession.pseudonym,
|
||||
"the two products got the SAME pseudonym, they could correlate their users")
|
||||
assertNotEquals(
|
||||
messengerSession.pseudonym,
|
||||
filesSession.pseudonym,
|
||||
"the two products got the SAME pseudonym, they could correlate their users",
|
||||
)
|
||||
|
||||
// The messenger half is not merely issued, it is accepted: exchanged at
|
||||
// the relay for a working relay credential.
|
||||
val relayAccount = RelayAccounts(messengerRelay)
|
||||
.sharedSession(messengerSession.token, handle)
|
||||
assertTrue(relayAccount.appPassword.isNotEmpty(),
|
||||
"the relay refused a session the account service issued")
|
||||
val relayAccount =
|
||||
RelayAccounts(messengerRelay)
|
||||
.sharedSession(messengerSession.token, handle)
|
||||
assertTrue(
|
||||
relayAccount.appPassword.isNotEmpty(),
|
||||
"the relay refused a session the account service issued",
|
||||
)
|
||||
|
||||
// A files token must NOT be accepted by the messenger relay. The
|
||||
// product scope is a boundary, not a label; if the relay took it, a
|
||||
// token minted for one product would work on another.
|
||||
val refused = runCatching {
|
||||
RelayAccounts(messengerRelay).sharedSession(filesSession.token, handle)
|
||||
}
|
||||
assertTrue(refused.isFailure,
|
||||
"the messenger relay accepted a token scoped to Files")
|
||||
// A files token must NOT be accepted by the messenger relay.
|
||||
val refused =
|
||||
runCatching {
|
||||
RelayAccounts(messengerRelay).sharedSession(filesSession.token, handle)
|
||||
}
|
||||
assertTrue(
|
||||
refused.isFailure,
|
||||
"the messenger relay accepted a token scoped to Files",
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Was `"a-long-enough-passphrase"`, a literal in a source file used
|
||||
* to create real accounts. See [LiveTarget.passphrase].
|
||||
* Was `"a-long-enough-passphrase"`, a literal in a source file used to
|
||||
* create real accounts.
|
||||
*/
|
||||
val PASSPHRASE: String get() = LiveTarget.passphrase()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import io.flamenet.e2e.Aead
|
||||
import io.flamenet.e2e.FileContent
|
||||
import io.flamenet.e2e.FileKeys
|
||||
import io.flamenet.e2e.Label
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlin.test.AfterTest
|
||||
@@ -15,35 +16,28 @@ import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The local vault.
|
||||
*
|
||||
* Against a real directory in a temporary folder, not an in-memory fake. Half
|
||||
* of what this class does IS the filesystem, atomic replaces, blobs written
|
||||
* before rows, blobs that must survive a row being removed, and a fake would
|
||||
* agree with the implementation about all of it.
|
||||
*/
|
||||
class LocalVaultTest {
|
||||
|
||||
/**
|
||||
* Files somebody else owns, that we only point at.
|
||||
*
|
||||
* Records every call, because for the delete path "the file survived" is not
|
||||
* a claim worth making on its own, see the test that uses [calls].
|
||||
*/
|
||||
private class FakeReferences : LocalVault.ReferenceResolver {
|
||||
val files = mutableMapOf<String, ByteArray>()
|
||||
val calls = mutableListOf<String>()
|
||||
|
||||
override fun exists(bookmark: String): Boolean {
|
||||
calls += "exists($bookmark)"
|
||||
return files.containsKey(bookmark)
|
||||
}
|
||||
|
||||
override fun read(bookmark: String): ByteArray {
|
||||
calls += "read($bookmark)"
|
||||
return files[bookmark] ?: throw IllegalStateException("gone")
|
||||
}
|
||||
|
||||
override fun size(bookmark: String): Int {
|
||||
calls += "size($bookmark)"
|
||||
return files[bookmark]?.size ?: 0
|
||||
@@ -62,19 +56,28 @@ class LocalVaultTest {
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
root = File.createTempFile("vault", "").let { it.delete(); it.mkdirs(); it }
|
||||
root =
|
||||
File.createTempFile("vault", "").let {
|
||||
it.delete()
|
||||
it.mkdirs()
|
||||
it
|
||||
}
|
||||
refs = FakeReferences()
|
||||
vault = LocalVault(root, identity, refs).also { it.load() }
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() { root.deleteRecursively() }
|
||||
fun tearDown() {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun reopen(): LocalVault = LocalVault(root, identity, refs).also { it.load() }
|
||||
|
||||
private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun blobsOnDisk() = File(root, "blobs").listFiles()?.map { it.name }?.toSet() ?: emptySet()
|
||||
|
||||
// ---- The round trip --------------------------------------------------
|
||||
// ---- The round trip --------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a file goes in and comes back out`() {
|
||||
@@ -89,10 +92,7 @@ class LocalVaultTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The plaintext must not be on disk anywhere. The whole claim of a
|
||||
* vault is that a stolen container yields ciphertext, and a search for the
|
||||
* bytes is the only way to be sure, a round trip passes just as happily if
|
||||
* `add` wrote the file out unencrypted and read it straight back.
|
||||
* The plaintext must not be on disk anywhere.
|
||||
*/
|
||||
@Test
|
||||
fun `nothing on disk contains the plaintext`() {
|
||||
@@ -102,14 +102,17 @@ class LocalVaultTest {
|
||||
val needle = phrase.toByteArray()
|
||||
for (file in root.walkTopDown().filter { it.isFile }) {
|
||||
val haystack = file.readBytes()
|
||||
val found = (0..(haystack.size - needle.size)).any { at ->
|
||||
needle.indices.all { haystack[at + it] == needle[it] }
|
||||
}
|
||||
val found =
|
||||
(0..(haystack.size - needle.size)).any { at ->
|
||||
needle.indices.all { haystack[at + it] == needle[it] }
|
||||
}
|
||||
assertFalse(found, "${file.name} holds the plaintext")
|
||||
}
|
||||
// ...and the name is not sitting in the index either.
|
||||
assertFalse(File(root, "index.json").readText().contains("Payroll"),
|
||||
"the filename is stored in the clear")
|
||||
assertFalse(
|
||||
File(root, "index.json").readText().contains("Payroll"),
|
||||
"the filename is stored in the clear",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -124,14 +127,14 @@ class LocalVaultTest {
|
||||
assertEquals(setOf("Reports", "big.bin"), second.list(null).map { it.name }.toSet())
|
||||
}
|
||||
|
||||
// ---- The index -------------------------------------------------------
|
||||
// ---- The index -------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* An index with no `storage` field must read as vaulted. iOS shipped
|
||||
* this field with a Swift default and found out that a default does not make
|
||||
* a `Codable` key optional: every index written before referencing existed
|
||||
* An index with no `storage` field must read as vaulted. iOS shipped this
|
||||
* field with a Swift default and found out that a default does not make a
|
||||
* `Codable` key optional: every index written before referencing existed
|
||||
* failed to decode, which presents to a person as a vault that has lost
|
||||
* everything. The field is stripped here on purpose, from a real index.
|
||||
* everything.
|
||||
*/
|
||||
@Test
|
||||
fun `an index written before storage existed still loads, as vaulted`() {
|
||||
@@ -161,18 +164,7 @@ class LocalVaultTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The bug the JVM could not see. Android's bundled `org.json`
|
||||
* coerces a JSON `null` to the four-character string `"null"`; the Maven
|
||||
* `org.json` this suite runs against returns `""`. Every nullable field in
|
||||
* the index therefore passed here and broke on a phone, a vault with any
|
||||
* top-level file threw `Invalid UUID string: null` on load, so adding the
|
||||
* first file worked and reopening the app lost everything.
|
||||
*
|
||||
* This test writes the literal string `"null"` on purpose. That is
|
||||
* exactly what the decoder is handed on a device, so putting it in the JSON
|
||||
* here is what makes the JVM run reproduce the device's behaviour. Feeding
|
||||
* a real JSON `null`, which is what the vault actually writes, would pass
|
||||
* with the broken code and prove nothing.
|
||||
* The bug the JVM could not see.
|
||||
*/
|
||||
@Test
|
||||
fun `a nullable field decodes as absent however the platform hands it over`() {
|
||||
@@ -181,8 +173,9 @@ class LocalVaultTest {
|
||||
|
||||
for (shape in listOf(
|
||||
reference.replace("\"parentID\":null", "\"parentID\":\"null\""),
|
||||
reference.replace("\"parentID\":null", ""). // key missing entirely
|
||||
replace(",,", ","),
|
||||
reference
|
||||
.replace("\"parentID\":null", "") // key missing entirely
|
||||
.replace(",,", ","),
|
||||
reference,
|
||||
)) {
|
||||
File(root, "index.json").writeText(shape)
|
||||
@@ -194,25 +187,28 @@ class LocalVaultTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The one that would NOT have thrown. `"null"` is valid base64, so a
|
||||
* content key decoded from it is a plausible three bytes, and the file
|
||||
* simply refuses to decrypt, for ever, with nothing to explain why.
|
||||
* The one that would NOT have thrown.
|
||||
*/
|
||||
@Test
|
||||
fun `a bookmark or content key of the literal string null is treated as absent`() {
|
||||
val node = vault.add("x".toByteArray(), "f.txt", secret)
|
||||
val index = File(root, "index.json")
|
||||
index.writeText(
|
||||
index.readText()
|
||||
index
|
||||
.readText()
|
||||
.replace("\"bookmark\":null", "\"bookmark\":\"null\"")
|
||||
.replace(Regex("\"wrappedContentKey\":\"[^\"]*\""),
|
||||
"\"wrappedContentKey\":\"null\"")
|
||||
.replace(
|
||||
Regex("\"wrappedContentKey\":\"[^\"]*\""),
|
||||
"\"wrappedContentKey\":\"null\"",
|
||||
),
|
||||
)
|
||||
val reopened = reopen()
|
||||
val back = assertNotNull(reopened.node(node.id))
|
||||
assertNull(back.bookmark)
|
||||
assertNull(back.wrappedContentKey,
|
||||
"a three-byte content key decoded from the string \"null\" was accepted")
|
||||
assertNull(
|
||||
back.wrappedContentKey,
|
||||
"a three-byte content key decoded from the string \"null\" was accepted",
|
||||
)
|
||||
// And the failure is one somebody can act on, not a decryption error.
|
||||
assertFailsWith<LocalVault.VaultException> { reopened.contents(back) }
|
||||
}
|
||||
@@ -224,9 +220,7 @@ class LocalVaultTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The temporary file must not survive a save. One left behind is
|
||||
* harmless today and is exactly what a future "load the newest index"
|
||||
* shortcut would pick up.
|
||||
* The temporary file must not survive a save.
|
||||
*/
|
||||
@Test
|
||||
fun `saving leaves no temporary index behind`() {
|
||||
@@ -235,28 +229,25 @@ class LocalVaultTest {
|
||||
assertTrue(File(root, "index.json").exists())
|
||||
}
|
||||
|
||||
// ---- Labels ----------------------------------------------------------
|
||||
// ---- Labels ----------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The label key binds the WHOLE canonical encoding. Deriving from the
|
||||
* classification alone would hand one key to every compartment at that
|
||||
* level, which is the exact separation compartments exist for, and it would
|
||||
* pass every test built from levels alone.
|
||||
* The label key binds the WHOLE canonical encoding.
|
||||
*/
|
||||
@Test
|
||||
fun `two labels at one level but different compartments derive different keys`() {
|
||||
val a = vault.labelKey(Label(5, setOf(1)))
|
||||
val b = vault.labelKey(Label(5, setOf(2)))
|
||||
val bare = vault.labelKey(Label(5))
|
||||
assertEquals(3, setOf(hex(a), hex(b), hex(bare)).size,
|
||||
"compartments are not reaching the key derivation")
|
||||
assertEquals(
|
||||
3,
|
||||
setOf(hex(a), hex(b), hex(bare)).size,
|
||||
"compartments are not reaching the key derivation",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A node key is wrapped to its OWN label, never its parent's. The tree
|
||||
* deliberately allows a low-classification file inside a high one, so a
|
||||
* folder-derived key would hand every holder of the folder's key content
|
||||
* filed above them.
|
||||
* A node key is wrapped to its OWN label, never its parent's.
|
||||
*/
|
||||
@Test
|
||||
fun `a file's key does not come from the folder above it`() {
|
||||
@@ -272,9 +263,8 @@ class LocalVaultTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A row filed by somebody else, under a label whose key was never granted:
|
||||
* unreadable, but still LISTED, with its classification visible. A row that
|
||||
* simply vanished would leave a person unable to see that anything is there.
|
||||
* A row filed by somebody else, under a label whose key was never granted.
|
||||
* unreadable, but still LISTED, with its classification visible.
|
||||
*/
|
||||
@Test
|
||||
fun `a row this device cannot read is listed without its name`() {
|
||||
@@ -300,7 +290,7 @@ class LocalVaultTest {
|
||||
assertTrue(stranger.list(null).all { it.name == null })
|
||||
}
|
||||
|
||||
// ---- Sending ---------------------------------------------------------
|
||||
// ---- Sending ---------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a sealed file opens with the key that was handed over`() {
|
||||
@@ -311,10 +301,7 @@ class LocalVaultTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh content key every time. Reusing the stored one would mean two
|
||||
* recipients of one file hold the same key, so revoking either is
|
||||
* impossible, and the vault's own blob opens with a key that has left the
|
||||
* device.
|
||||
* A fresh content key every time.
|
||||
*/
|
||||
@Test
|
||||
fun `sealing twice uses two different keys`() {
|
||||
@@ -335,7 +322,7 @@ class LocalVaultTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- References ------------------------------------------------------
|
||||
// ---- References ------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a referenced file is read through without being copied`() {
|
||||
@@ -349,9 +336,7 @@ class LocalVaultTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A file the person moved or deleted is not an error. It is their file.
|
||||
* The row reports itself missing and they decide what to do, a row that
|
||||
* silently does nothing when tapped is worse than one that says so.
|
||||
* A file the person moved or deleted is not an error.
|
||||
*/
|
||||
@Test
|
||||
fun `a reference whose file has gone reports itself missing`() {
|
||||
@@ -361,16 +346,16 @@ class LocalVaultTest {
|
||||
|
||||
refs.files.remove("doc://1")
|
||||
assertTrue(vault.list(null).single().isMissing)
|
||||
assertEquals("Their doc.pdf", vault.list(null).single().name,
|
||||
"the row lost its name as well as its file")
|
||||
assertEquals(
|
||||
"Their doc.pdf",
|
||||
vault.list(null).single().name,
|
||||
"the row lost its name as well as its file",
|
||||
)
|
||||
assertFailsWith<LocalVault.VaultException> { vault.contents(node) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Promoting to the vault drops the bookmark. Keeping it would leave a
|
||||
* vaulted row still able to read through to a plaintext original, so the
|
||||
* encrypted copy would not be the only way in, which is the point of
|
||||
* vaulting it.
|
||||
* Promoting to the vault drops the bookmark.
|
||||
*/
|
||||
@Test
|
||||
fun `promoting a reference encrypts it and lets go of the original`() {
|
||||
@@ -404,22 +389,10 @@ class LocalVaultTest {
|
||||
assertFailsWith<LocalVault.VaultException> { vault.encrypt(node.id) }
|
||||
}
|
||||
|
||||
// ---- Change detection ------------------------------------------------
|
||||
// ---- Change detection ------------------------------------------------.
|
||||
|
||||
/**
|
||||
* A changed row must not compare equal to what it replaced.
|
||||
*
|
||||
* `Node.equals` was `other.id == id`, written to dodge `ByteArray`
|
||||
* identity comparison. Promoting a link keeps the node's id and changes its
|
||||
* `storage`, so the new row compared equal to the old one, the state object
|
||||
* holding it compared equal too, `StateFlow` conflated the update away, and
|
||||
* the screen never changed. On a device the file vanished from Linked and
|
||||
* did not reappear in the vault until the app was restarted, which reads as
|
||||
* losing somebody's file.
|
||||
*
|
||||
* Found by watching it on an emulator, not by any test. The suite passed
|
||||
* throughout, because everything here reads the vault rather than a
|
||||
* `StateFlow`, so this asserts the property the UI actually depends on.
|
||||
*/
|
||||
@Test
|
||||
fun `a promoted row does not compare equal to the reference it replaced`() {
|
||||
@@ -429,9 +402,11 @@ class LocalVaultTest {
|
||||
val after = assertNotNull(vault.node(before.id))
|
||||
|
||||
assertEquals(before.id, after.id, "the promote should keep the same row")
|
||||
assertTrue(before != after,
|
||||
assertTrue(
|
||||
before != after,
|
||||
"a row whose storage changed compares equal to its old self, any " +
|
||||
"state holder that conflates on equality will drop the update")
|
||||
"state holder that conflates on equality will drop the update",
|
||||
)
|
||||
assertTrue(listOf(before) != listOf(after), "the LIST compares equal too")
|
||||
}
|
||||
|
||||
@@ -441,36 +416,39 @@ class LocalVaultTest {
|
||||
val node = vault.add("x".toByteArray(), "f.txt", secret)
|
||||
val a = assertNotNull(reopen().node(node.id))
|
||||
val b = assertNotNull(reopen().node(node.id))
|
||||
assertEquals(a, b, "two reads of one unchanged row differ, every refresh " +
|
||||
"would redraw and byte arrays are being compared by identity again")
|
||||
assertEquals(
|
||||
a,
|
||||
b,
|
||||
"two reads of one unchanged row differ, every refresh " +
|
||||
"would redraw and byte arrays are being compared by identity again",
|
||||
)
|
||||
assertEquals(a.hashCode(), b.hashCode())
|
||||
}
|
||||
|
||||
/**
|
||||
* Every mutable field, not just `storage`. A rename, a move, a
|
||||
* re-classification and a re-wrap all have to be visible to a UI that
|
||||
* conflates on equality.
|
||||
* Every mutable field, not just `storage`.
|
||||
*/
|
||||
@Test
|
||||
fun `each field that can change is visible in equality`() {
|
||||
val node = vault.add("x".toByteArray(), "f.txt", secret)
|
||||
val base = assertNotNull(vault.node(node.id))
|
||||
val changed = listOf(
|
||||
"parentId" to base.copy(parentId = UUID.randomUUID()),
|
||||
"classification" to base.copy(classification = 9),
|
||||
"compartments" to base.copy(compartments = listOf(4)),
|
||||
"byteCount" to base.copy(byteCount = base.byteCount + 1),
|
||||
"storage" to base.copy(storage = LocalVault.Storage.REFERENCE),
|
||||
"bookmark" to base.copy(bookmark = "doc://x"),
|
||||
"wrappedContentKey" to base.copy(wrappedContentKey = null),
|
||||
"wrappedName" to base.copy(wrappedName = ByteArray(9)),
|
||||
)
|
||||
val changed =
|
||||
listOf(
|
||||
"parentId" to base.copy(parentId = UUID.randomUUID()),
|
||||
"classification" to base.copy(classification = 9),
|
||||
"compartments" to base.copy(compartments = listOf(4)),
|
||||
"byteCount" to base.copy(byteCount = base.byteCount + 1),
|
||||
"storage" to base.copy(storage = LocalVault.Storage.REFERENCE),
|
||||
"bookmark" to base.copy(bookmark = "doc://x"),
|
||||
"wrappedContentKey" to base.copy(wrappedContentKey = null),
|
||||
"wrappedName" to base.copy(wrappedName = ByteArray(9)),
|
||||
)
|
||||
for ((field, other) in changed) {
|
||||
assertTrue(base != other, "a change to $field is invisible to equality")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Deleting --------------------------------------------------------
|
||||
// ---- Deleting --------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `deleting a file takes its bytes with it`() {
|
||||
@@ -483,16 +461,6 @@ class LocalVaultTest {
|
||||
|
||||
/**
|
||||
* Deleting a REFERENCE row must not touch the person's document.
|
||||
* Those bytes are their file, sitting where they put it. This is the one
|
||||
* mistake in the whole class that cannot be undone.
|
||||
*
|
||||
* Asserting only that the file survived would be a test that cannot
|
||||
* fail, because the delete path does not hold a resolver call to remove,
|
||||
* the protection is structural: [LocalVault.ReferenceResolver] has no way to
|
||||
* delete anything. So this asserts the property that actually constrains
|
||||
* future code: removing a row reaches the person's storage not at all.
|
||||
* Anybody who later routes the delete path through the resolver has to come
|
||||
* here and say why.
|
||||
*/
|
||||
@Test
|
||||
fun `deleting a reference row does not reach the person's storage at all`() {
|
||||
@@ -502,16 +470,23 @@ class LocalVaultTest {
|
||||
refs.calls.clear()
|
||||
vault.remove(node.id)
|
||||
|
||||
assertEquals(emptyList(), refs.calls,
|
||||
"the delete path is talking to the person's own storage")
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
refs.calls,
|
||||
"the delete path is talking to the person's own storage",
|
||||
)
|
||||
assertTrue(refs.files.containsKey("doc://1"), "removing a row deleted somebody's document")
|
||||
|
||||
// Same again for the bulk path, which is the one a screen actually uses.
|
||||
// Same again for the bulk path, which is the one a screen actually
|
||||
// uses.
|
||||
val second = vault.reference("doc://1", "doc.pdf", open)
|
||||
refs.calls.clear()
|
||||
vault.remove(listOf(second.id))
|
||||
assertEquals(emptyList(), refs.calls,
|
||||
"the bulk delete path is talking to the person's own storage")
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
refs.calls,
|
||||
"the bulk delete path is talking to the person's own storage",
|
||||
)
|
||||
assertTrue(refs.files.containsKey("doc://1"))
|
||||
}
|
||||
|
||||
@@ -524,9 +499,7 @@ class LocalVaultTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deepest-first, so selecting a folder AND everything in it works. Doing
|
||||
* it in the order given would refuse the folder for being non-empty and
|
||||
* leave half the selection behind.
|
||||
* Deepest-first, so selecting a folder AND everything in it works.
|
||||
*/
|
||||
@Test
|
||||
fun `selecting a folder and its contents removes all of it`() {
|
||||
@@ -543,9 +516,7 @@ class LocalVaultTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The report is the point. Selecting eleven things and having nine go
|
||||
* is a data-loss-shaped event in the other direction: the two that survived
|
||||
* are the ones somebody now believes are gone.
|
||||
* The report is the point.
|
||||
*/
|
||||
@Test
|
||||
fun `a folder whose contents were not all selected is kept, and reported`() {
|
||||
@@ -562,8 +533,6 @@ class LocalVaultTest {
|
||||
|
||||
/**
|
||||
* A row filed above what this device can read still counts as contents.
|
||||
* Otherwise a bulk gesture would take rows the person could not have
|
||||
* reviewed before tapping.
|
||||
*/
|
||||
@Test
|
||||
fun `an unreadable row inside a folder still protects it from a bulk delete`() {
|
||||
@@ -591,13 +560,13 @@ class LocalVaultTest {
|
||||
assertEquals(listOf("b.txt"), reopen().list(null).map { it.name })
|
||||
}
|
||||
|
||||
// ---- The picker ------------------------------------------------------
|
||||
// ---- The picker ------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The whole tree, not the top of it. iOS's picker lists the root
|
||||
* only, so a document filed into a folder cannot be sent from a
|
||||
* conversation at all, and nothing says so: the file is simply absent,
|
||||
* which reads as "it isn't there". Filed as ios#68.
|
||||
* The whole tree, not the top of it. iOS's picker lists the root only, so a
|
||||
* document filed into a folder cannot be sent from a conversation at all,
|
||||
* and nothing says so: the file is simply absent, which reads as "it isn't
|
||||
* there".
|
||||
*/
|
||||
@Test
|
||||
fun `everything sendable is offered, however deep it is filed`() {
|
||||
@@ -617,10 +586,7 @@ class LocalVaultTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place this class hides a row it cannot read. Everywhere
|
||||
* else an unreadable row is shown, because hiding it would leave somebody
|
||||
* unable to see that anything is there. A picker's only purpose is choosing
|
||||
* something to send, and a row that can never be chosen is furniture.
|
||||
* The one place this class hides a row it cannot read.
|
||||
*/
|
||||
@Test
|
||||
fun `rows that could never be sent are left out of the picker`() {
|
||||
@@ -629,15 +595,20 @@ class LocalVaultTest {
|
||||
vault.add("readable".toByteArray(), "fine.txt", open)
|
||||
refs.files.remove("doc://gone")
|
||||
|
||||
assertEquals(listOf("fine.txt"), vault.allFiles().map { it.name },
|
||||
"a file that has moved was offered as sendable")
|
||||
assertEquals(
|
||||
listOf("fine.txt"),
|
||||
vault.allFiles().map { it.name },
|
||||
"a file that has moved was offered as sendable",
|
||||
)
|
||||
|
||||
val stranger = LocalVault(root, ByteArray(32) { 99 }, refs).also { it.load() }
|
||||
assertTrue(stranger.allFiles().isEmpty(),
|
||||
"a row this device cannot decrypt was offered as sendable")
|
||||
assertTrue(
|
||||
stranger.allFiles().isEmpty(),
|
||||
"a row this device cannot decrypt was offered as sendable",
|
||||
)
|
||||
}
|
||||
|
||||
// ---- The tree --------------------------------------------------------
|
||||
// ---- The tree --------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `descendants walks the whole subtree`() {
|
||||
@@ -651,9 +622,8 @@ class LocalVaultTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A cycle cannot happen through this API, and the guard is here because
|
||||
* an index is a file on disk that something else could write. Without it
|
||||
* this is an infinite loop in a method a screen calls.
|
||||
* A cycle cannot happen through this API, and the guard is here because an
|
||||
* index is a file on disk that something else could write.
|
||||
*/
|
||||
@Test
|
||||
fun `a cycle in the index does not hang the tree walk`() {
|
||||
@@ -670,10 +640,7 @@ class LocalVaultTest {
|
||||
index.writeText(json.toString())
|
||||
|
||||
val cyclic = reopen()
|
||||
// Not merely "it terminated": each node once. A step-counter guard
|
||||
// would end the loop and hand a screen ten thousand copies of two ids,
|
||||
// which is what "delete this folder and everything in it" would then
|
||||
// offer to do.
|
||||
// Not merely "it terminated": each node once.
|
||||
assertEquals(listOf(b.id), cyclic.descendants(a.id))
|
||||
assertEquals(listOf(a.id), cyclic.descendants(b.id))
|
||||
// And a bulk delete over it terminates rather than spinning in `depth`.
|
||||
|
||||
@@ -22,22 +22,24 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Adding and dropping a mailbox (spec §20.4).
|
||||
*
|
||||
* Your address survives a relay disappearing; your mailbox does not. So
|
||||
* the order of operations here is the whole substance: nothing is written down
|
||||
* locally until the relay has actually accepted a registration, and the record
|
||||
* is published everywhere rather than only at the relay being added. A home
|
||||
* that does not know it is one still accepts mail, but a sender asking IT for
|
||||
* the record gets a stale list and skips the others, the redundancy would
|
||||
* quietly depend on which relay the sender happened to ask.
|
||||
*/
|
||||
class MailboxHomingTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
@@ -54,12 +56,6 @@ class MailboxHomingTest {
|
||||
|
||||
/**
|
||||
* What each relay would serve back if asked.
|
||||
*
|
||||
* Separate from [published], which a test may clear to check who was
|
||||
* told THIS time. A relay does not forget a record because the test stopped
|
||||
* watching, and `nextSerial` reads exactly this, so sharing one map made
|
||||
* every publish after a clear reuse serial 1, which a real relay would
|
||||
* refuse as a rollback.
|
||||
*/
|
||||
private val served = LinkedHashMap<String, JSONObject>()
|
||||
private var newHomeRejectsSignIn = false
|
||||
@@ -67,88 +63,132 @@ class MailboxHomingTest {
|
||||
|
||||
@BeforeTest fun start() {
|
||||
identity = DeviceIdentity.create()
|
||||
vault = Vault(MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
vault =
|
||||
Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
)
|
||||
vault.saveIdentity(identity)
|
||||
home = MockWebServer().also { it.dispatcher = relay("home"); it.start() }
|
||||
newHome = MockWebServer().also { it.dispatcher = relay("new"); it.start() }
|
||||
home =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = relay("home")
|
||||
it.start()
|
||||
}
|
||||
newHome =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = relay("new")
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
@AfterTest fun stop() { home.shutdown(); newHome.shutdown() }
|
||||
@AfterTest fun stop() {
|
||||
home.shutdown()
|
||||
newHome.shutdown()
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
private fun relay(tag: String) = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"account/signin" in path -> {
|
||||
if (tag == "new" && newHomeRejectsSignIn) MockResponse().setResponseCode(401)
|
||||
else json("""{"user_id":9,"username":"me","display_name":"Me","app_password":"pw"}""")
|
||||
}
|
||||
".well-known/flamenet/relay" in path ->
|
||||
json("""{"audience":"$tag.example.com","issuerPublicKey":""}""")
|
||||
"/e2e/devices" in path -> json("""{"ok":true,"device_id":"d","opk_remaining":0}""")
|
||||
"/e2e/account/identity" in path ->
|
||||
json("""{"ok":true,"fnid":"$fnid","created":true}""")
|
||||
"/e2e/account/homes" in path && request.method == "POST" -> {
|
||||
if (tag == "new" && newHomeRejectsRecord) {
|
||||
MockResponse().setResponseCode(400).setBody("{}")
|
||||
} else {
|
||||
val record = JSONObject(request.body.readUtf8())
|
||||
published.getOrPut(tag) { mutableListOf() }.add(record)
|
||||
served[tag] = record
|
||||
json("""{"ok":true}""")
|
||||
private fun relay(tag: String) =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"account/signin" in path -> {
|
||||
if (tag == "new" && newHomeRejectsSignIn) {
|
||||
MockResponse().setResponseCode(401)
|
||||
} else {
|
||||
json("""{"user_id":9,"username":"me","display_name":"Me","app_password":"pw"}""")
|
||||
}
|
||||
}
|
||||
|
||||
".well-known/flamenet/relay" in path -> {
|
||||
json("""{"audience":"$tag.example.com","issuerPublicKey":""}""")
|
||||
}
|
||||
|
||||
"/e2e/devices" in path -> {
|
||||
json("""{"ok":true,"device_id":"d","opk_remaining":0}""")
|
||||
}
|
||||
|
||||
"/e2e/account/identity" in path -> {
|
||||
json("""{"ok":true,"fnid":"$fnid","created":true}""")
|
||||
}
|
||||
|
||||
"/e2e/account/homes" in path && request.method == "POST" -> {
|
||||
if (tag == "new" && newHomeRejectsRecord) {
|
||||
MockResponse().setResponseCode(400).setBody("{}")
|
||||
} else {
|
||||
val record = JSONObject(request.body.readUtf8())
|
||||
published.getOrPut(tag) { mutableListOf() }.add(record)
|
||||
served[tag] = record
|
||||
json("""{"ok":true}""")
|
||||
}
|
||||
}
|
||||
|
||||
// Serves back what it was told.
|
||||
"/e2e/account/homes/" in path -> {
|
||||
served[tag]?.let {
|
||||
json(
|
||||
JSONObject()
|
||||
.put("fnid", fnid)
|
||||
.put("serial", it.getInt("serial"))
|
||||
.put("relays", it.getJSONArray("relays"))
|
||||
.put("sig", it.getString("sig"))
|
||||
.toString(),
|
||||
)
|
||||
} ?: MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
else -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
// Serves back what it was told. `nextSerial` reads the
|
||||
// published records to decide the next one, so a relay that
|
||||
// stores a record and then 404s it would have every publish
|
||||
// reuse serial 1, and the second would be refused as a
|
||||
// rollback by the very check that stops an attacker replaying
|
||||
// an old list. A mock that only accepts writes hides that.
|
||||
"/e2e/account/homes/" in path ->
|
||||
served[tag]?.let {
|
||||
json(JSONObject()
|
||||
.put("fnid", fnid)
|
||||
.put("serial", it.getInt("serial"))
|
||||
.put("relays", it.getJSONArray("relays"))
|
||||
.put("sig", it.getString("sig")).toString())
|
||||
} ?: MockResponse().setResponseCode(404)
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun homing() = MailboxHoming(
|
||||
vault, identity, "home.example.com",
|
||||
RelayClient(home.url("/").toString(), "me", "pw"))
|
||||
private fun homing() =
|
||||
MailboxHoming(
|
||||
vault,
|
||||
identity,
|
||||
"home.example.com",
|
||||
RelayClient(home.url("/").toString(), "me", "pw"),
|
||||
)
|
||||
|
||||
/**
|
||||
* The record names `new.example.com`; the URL this device dials is the
|
||||
* mock server. That split is the production shape too, the hostname is
|
||||
* inside the signature and cannot carry a port.
|
||||
* The record names `new.example.com`; the URL this device dials is the mock
|
||||
* server.
|
||||
*/
|
||||
private fun add(existing: List<String> = emptyList()) =
|
||||
homing().add("new.example.com", handle = "me", passphrase = "pass",
|
||||
accountKeySeed = seed, fnid = fnid, existingHomes = existing,
|
||||
dialAs = newHome.url("/").toString())
|
||||
homing().add(
|
||||
"new.example.com",
|
||||
handle = "me",
|
||||
passphrase = "pass",
|
||||
accountKeySeed = seed,
|
||||
fnid = fnid,
|
||||
existingHomes = existing,
|
||||
dialAs = newHome.url("/").toString(),
|
||||
)
|
||||
|
||||
// MARK: - What a relay's name is allowed to be
|
||||
|
||||
/**
|
||||
* A relay is a hostname, never a URL. This is parsed from something a
|
||||
* person typed or pasted, and accepting `https://x/y` would let it point
|
||||
* the device at a path on a host of somebody else's choosing, and then
|
||||
* send that host a signed record naming it as a mailbox.
|
||||
* A relay is a hostname, never a URL.
|
||||
*/
|
||||
@Test
|
||||
fun `only a hostname is accepted as a relay`() {
|
||||
assertEquals("relay.example.com", MailboxHoming.hostname(" Relay.Example.COM "))
|
||||
for (bad in listOf("https://relay.example.com", "relay.example.com/path",
|
||||
"relay.example.com:8443", "me@relay.example.com",
|
||||
"localhost", "", "relay example com")) {
|
||||
for (bad in listOf(
|
||||
"https://relay.example.com",
|
||||
"relay.example.com/path",
|
||||
"relay.example.com:8443",
|
||||
"me@relay.example.com",
|
||||
"localhost",
|
||||
"",
|
||||
"relay example com",
|
||||
)) {
|
||||
assertNull(MailboxHoming.hostname(bad), "accepted $bad")
|
||||
}
|
||||
}
|
||||
@@ -166,9 +206,9 @@ class MailboxHomingTest {
|
||||
// MARK: - Nothing is written down until the relay agrees
|
||||
|
||||
/**
|
||||
* A half-added home, stored locally, unknown to the relay, would be
|
||||
* polled forever and would show on screen as a mailbox that never has
|
||||
* anything in it.
|
||||
* A half-added home, stored locally, unknown to the relay, would be polled
|
||||
* forever and would show on screen as a mailbox that never has anything in
|
||||
* it.
|
||||
*/
|
||||
@Test
|
||||
fun `a relay that refuses the sign-in leaves nothing behind`() {
|
||||
@@ -178,9 +218,7 @@ class MailboxHomingTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The NEW relay accepting is what decides. A record every old home
|
||||
* took but the new one refused describes a mailbox that does not exist, and
|
||||
* a sender told to deliver there gets nothing.
|
||||
* The NEW relay accepting is what decides.
|
||||
*/
|
||||
@Test
|
||||
fun `a relay that refuses the record is not remembered as a mailbox`() {
|
||||
@@ -196,18 +234,26 @@ class MailboxHomingTest {
|
||||
val homes = add()
|
||||
|
||||
assertEquals(listOf("home.example.com", "new.example.com"), homes)
|
||||
assertEquals(setOf("home", "new"), published.keys,
|
||||
"a home that was not told serves a stale list to senders")
|
||||
assertEquals(
|
||||
setOf("home", "new"),
|
||||
published.keys,
|
||||
"a home that was not told serves a stale list to senders",
|
||||
)
|
||||
|
||||
for ((tag, records) in published) {
|
||||
val record = records.single()
|
||||
val relays = record.getJSONArray("relays").let { a ->
|
||||
(0 until a.length()).map { a.getString(it) }
|
||||
}
|
||||
val relays =
|
||||
record.getJSONArray("relays").let { a ->
|
||||
(0 until a.length()).map { a.getString(it) }
|
||||
}
|
||||
assertEquals(homes, relays, "$tag was told a different list")
|
||||
assertTrue(
|
||||
AccountIdentity.verifyHomes(fnid, record.getInt("serial"), relays,
|
||||
record.getString("sig")),
|
||||
AccountIdentity.verifyHomes(
|
||||
fnid,
|
||||
record.getInt("serial"),
|
||||
relays,
|
||||
record.getString("sig"),
|
||||
),
|
||||
"$tag was sent a record that does not verify",
|
||||
)
|
||||
}
|
||||
@@ -226,23 +272,23 @@ class MailboxHomingTest {
|
||||
// MARK: - Dropping one
|
||||
|
||||
/**
|
||||
* The shorter list is published first and the credential goes only if
|
||||
* that worked. Forgetting locally while senders are still told to deliver
|
||||
* there leaves mail arriving at a mailbox this device no longer reads,
|
||||
* which, from the sender's side, is indistinguishable from delivery.
|
||||
* The shorter list is published first and the credential goes only if that
|
||||
* worked.
|
||||
*/
|
||||
@Test
|
||||
fun `removing a mailbox publishes the shorter list and then forgets it`() {
|
||||
add()
|
||||
published.clear()
|
||||
|
||||
homing().remove("new.example.com", seed, fnid,
|
||||
listOf("home.example.com", "new.example.com"))
|
||||
homing().remove(
|
||||
"new.example.com",
|
||||
seed,
|
||||
fnid,
|
||||
listOf("home.example.com", "new.example.com"),
|
||||
)
|
||||
|
||||
assertTrue(vault.extraHomes().isEmpty(), "the credential outlived the mailbox")
|
||||
// The relay being dropped is NOT asked to accept the new record. It
|
||||
// is the one party with a reason to refuse, and it is not in the list
|
||||
// any more so it would refuse on those grounds regardless.
|
||||
// The relay being dropped is NOT asked to accept the new record.
|
||||
assertEquals(setOf("home"), published.keys)
|
||||
val relays = published["home"]!!.single().getJSONArray("relays")
|
||||
assertEquals(1, relays.length())
|
||||
@@ -271,10 +317,18 @@ class MailboxHomingTest {
|
||||
@Test
|
||||
fun `each published record carries a serial above the last`() {
|
||||
add()
|
||||
val first = published.values.first().single().getInt("serial")
|
||||
val first =
|
||||
published.values
|
||||
.first()
|
||||
.single()
|
||||
.getInt("serial")
|
||||
published.clear()
|
||||
homing().remove("new.example.com", seed, fnid,
|
||||
listOf("home.example.com", "new.example.com"))
|
||||
homing().remove(
|
||||
"new.example.com",
|
||||
seed,
|
||||
fnid,
|
||||
listOf("home.example.com", "new.example.com"),
|
||||
)
|
||||
val second = published["home"]!!.single().getInt("serial")
|
||||
assertTrue(second > first, "serial went $first -> $second")
|
||||
}
|
||||
|
||||
@@ -10,49 +10,37 @@ import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* The relay answers a lookup with an ARRAY, and the client used to insist
|
||||
* it was an object.
|
||||
*
|
||||
* `RoutesAccount.lookup` returns `[MeResponse]`, empty for a miss, one element
|
||||
* for a hit, deliberately, so that a miss and a hit differ only in length and
|
||||
* neither timing nor shape is an oracle. The client sent it through `send()`,
|
||||
* which does `JSONObject(text)` and throws on an array, and `lookup()` wrapped
|
||||
* the whole thing in `runCatching { }.getOrNull()`.
|
||||
*
|
||||
* Every lookup therefore reported "No account called X here" while the relay
|
||||
* was returning the account. Adding a contact was impossible on Android,
|
||||
* the first thing anyone does, and it shipped in an APK that way.
|
||||
*
|
||||
* Measured against relay.flamenet.io before the fix: the relay returned
|
||||
* `[{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]` and the app said
|
||||
* no such account existed.
|
||||
*
|
||||
* Nothing caught it because all three layers were quiet: the relay was
|
||||
* correct, the parse failure was swallowed, and a miss and a crash render
|
||||
* identically. `CrudSurfaceTest` asserted the lookup CONTROL exists, which it
|
||||
* did. The lesson is that a control being present says nothing about the
|
||||
* feature working.
|
||||
* The relay answers a lookup with an ARRAY, and the client used to insist it
|
||||
* was an object.
|
||||
*/
|
||||
class MessengerApiLookupTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun api() = MessengerApi(server.url("/").toString(), "alice", "app pass word")
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
@Test
|
||||
fun `a hit comes back as a one element array and is parsed`() {
|
||||
server.enqueue(json("""[{"id":30,"name":"Bravo","slug":"appreview2demo"}]"""))
|
||||
val found = api().lookup("appreview2demo")
|
||||
assertNotNull(found, "the relay returned the account and the client dropped it, " +
|
||||
"this is the shape bug that made adding a contact impossible on Android")
|
||||
assertNotNull(
|
||||
found,
|
||||
"the relay returned the account and the client dropped it, " +
|
||||
"this is the shape bug that made adding a contact impossible on Android",
|
||||
)
|
||||
assertEquals(30L, found.userId)
|
||||
assertEquals("appreview2demo", found.handle)
|
||||
}
|
||||
|
||||
@@ -12,48 +12,39 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What the roster, notification and account routes actually decode.
|
||||
*
|
||||
* Three separate field-name bugs are recorded in comments in this one
|
||||
* file, and all three shipped. The block list read `user_id` where the relay
|
||||
* sends `id`, so the Block button did nothing and survived review because the
|
||||
* list was always empty. Notifications read `kind`/`body`/`read`, names that
|
||||
* exist nowhere, for months, so every alert decoded as a blank unread row and
|
||||
* the screen looked permanently empty. The roster route does not use the
|
||||
* `user_id` shape the §8 routes use, and guessing otherwise threw
|
||||
* "No value for user_id" at the exact moment a contact was added.
|
||||
*
|
||||
* None of them are hard bugs. They are all the same bug: a decoder and a
|
||||
* producer that disagree, where the disagreement produces empty rather than an
|
||||
* error, and empty looks exactly like "nothing to show yet". A test that feeds
|
||||
* the relay's real response shape catches every one of them in a second, and
|
||||
* there was no such test.
|
||||
*
|
||||
* So each decode test below is paired with a wrong-shape test asserting that
|
||||
* the historical mistake yields nothing, which is what makes the pairing
|
||||
* meaningful rather than decorative.
|
||||
*/
|
||||
class MessengerApiShapeTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
private fun api() = MessengerApi(
|
||||
server.url("/").toString().trimEnd('/'), "someone", "app-password")
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
private fun api() = MessengerApi(server.url("/").toString().trimEnd('/'), "someone", "app-password")
|
||||
|
||||
// -- Roster --------------------------------------------------------------
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
// -- Roster --------------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `the roster decodes the relay's actual field names`() {
|
||||
server.enqueue(json("""
|
||||
server.enqueue(
|
||||
json(
|
||||
"""
|
||||
{"buddies":[
|
||||
{"id":7,"display_name":"Ada L","screen_name":"ada","presence":"away","accepted":true},
|
||||
{"id":8,"display_name":"Bob","screen_name":"bob","presence":"online","accepted":false}
|
||||
]}"""))
|
||||
]}""",
|
||||
),
|
||||
)
|
||||
|
||||
val list = api().buddies()
|
||||
assertEquals(2, list.size)
|
||||
@@ -66,10 +57,7 @@ class MessengerApiShapeTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Defaults to true, and that default is the compatibility story. A
|
||||
* relay predating the field says nothing about acceptance, and the only
|
||||
* safe reading of silence is "these are your contacts", the alternative
|
||||
* labels every contact on every older relay a stranger.
|
||||
* Defaults to true, and that default is the compatibility story.
|
||||
*/
|
||||
@Test
|
||||
fun `a relay that predates acceptance does not turn every contact into a stranger`() {
|
||||
@@ -106,12 +94,10 @@ class MessengerApiShapeTest {
|
||||
assertEquals("/api/flamenet/v1/messenger/buddies", server.takeRequest().path)
|
||||
}
|
||||
|
||||
// -- Blocks --------------------------------------------------------------
|
||||
// -- Blocks --------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The bug: this parsed `user_id`, the relay sends `id`. The Block button
|
||||
* did nothing, and it survived because the list it produced was always
|
||||
* empty, so nothing ever disagreed with it.
|
||||
* The bug: this parsed `user_id`, the relay sends `id`.
|
||||
*/
|
||||
@Test
|
||||
fun `the block list decodes the id field the relay actually sends`() {
|
||||
@@ -134,20 +120,22 @@ class MessengerApiShapeTest {
|
||||
assertEquals("/api/flamenet/v1/messenger/blocks/11", unblockReq.path)
|
||||
}
|
||||
|
||||
// -- Notifications -------------------------------------------------------
|
||||
// -- Notifications -------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* This read `kind`/`body`/`read` for months. Those names exist nowhere,
|
||||
* so every alert decoded as an empty unread row and the screen looked
|
||||
* permanently blank, the failure mode of a decode mismatch is silence.
|
||||
* This read `kind`/`body`/`read` for months.
|
||||
*/
|
||||
@Test
|
||||
fun `notifications decode type message and is_read`() {
|
||||
server.enqueue(json("""
|
||||
server.enqueue(
|
||||
json(
|
||||
"""
|
||||
{"notifications":[
|
||||
{"id":1,"type":"buddy_request","message":"Ada added you","is_read":false},
|
||||
{"id":2,"type":"message","message":"New message","is_read":true}
|
||||
]}"""))
|
||||
]}""",
|
||||
),
|
||||
)
|
||||
|
||||
val list = api().notifications()
|
||||
assertEquals(2, list.size)
|
||||
@@ -160,8 +148,12 @@ class MessengerApiShapeTest {
|
||||
/** The exact historical mistake, asserted to produce nothing useful. */
|
||||
@Test
|
||||
fun `the old field names decode to a blank unread row`() {
|
||||
server.enqueue(json("""
|
||||
{"notifications":[{"id":1,"kind":"buddy_request","body":"Ada added you","read":true}]}"""))
|
||||
server.enqueue(
|
||||
json(
|
||||
"""
|
||||
{"notifications":[{"id":1,"kind":"buddy_request","body":"Ada added you","read":true}]}""",
|
||||
),
|
||||
)
|
||||
val n = api().notifications().single()
|
||||
assertEquals("", n.kind)
|
||||
assertEquals("", n.body)
|
||||
@@ -174,13 +166,10 @@ class MessengerApiShapeTest {
|
||||
assertEquals(0, api().notifications().size)
|
||||
}
|
||||
|
||||
// -- Lookup by id --------------------------------------------------------
|
||||
// -- Lookup by id --------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* This is how a message from a stranger becomes visible. An E2E envelope
|
||||
* names its sender by id only, so without this a first contact from someone
|
||||
* who added US arrived, decrypted, was filed and counted as unread, and
|
||||
* never appeared anywhere.
|
||||
* This is how a message from a stranger becomes visible.
|
||||
*/
|
||||
@Test
|
||||
fun `a user lookup names the sender`() {
|
||||
@@ -203,7 +192,7 @@ class MessengerApiShapeTest {
|
||||
assertNull(api().userInfo(7))
|
||||
}
|
||||
|
||||
// -- Contact management --------------------------------------------------
|
||||
// -- Contact management --------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `adding a contact posts the username and the auto flag`() {
|
||||
@@ -219,14 +208,19 @@ class MessengerApiShapeTest {
|
||||
|
||||
/**
|
||||
* The auto flag is what separates "the user typed this name" from "they
|
||||
* wrote to us first". Defaulting it wrong would file strangers as chosen
|
||||
* contacts.
|
||||
* wrote to us first".
|
||||
*/
|
||||
@Test
|
||||
fun `adding a contact is not automatic unless asked`() {
|
||||
server.enqueue(json("""{"ok":true}"""))
|
||||
api().addBuddy("ada")
|
||||
assertTrue(server.takeRequest().body.readUtf8().contains("\"auto\":false"))
|
||||
assertTrue(
|
||||
server
|
||||
.takeRequest()
|
||||
.body
|
||||
.readUtf8()
|
||||
.contains("\"auto\":false"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -259,13 +253,10 @@ class MessengerApiShapeTest {
|
||||
assertEquals("/api/flamenet/v1/messenger/buddies", req.path)
|
||||
}
|
||||
|
||||
// -- Presence ------------------------------------------------------------
|
||||
// -- Presence ------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Return the server's answer, never the value we asked for. The
|
||||
* relay used to accept an unknown status with 200 and store nothing, so a
|
||||
* caller that assumed success showed a state the server had discarded,
|
||||
* "offline" on this screen while every contact still saw us online.
|
||||
* Return the server's answer, never the value we asked for.
|
||||
*/
|
||||
@Test
|
||||
fun `setting a status reports what the relay stored, not what we asked for`() {
|
||||
@@ -291,7 +282,7 @@ class MessengerApiShapeTest {
|
||||
assertEquals("away", api().currentStatus())
|
||||
}
|
||||
|
||||
// -- The account itself --------------------------------------------------
|
||||
// -- The account itself --------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `me decodes the account route's own field names`() {
|
||||
|
||||
@@ -18,24 +18,24 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Reading mail from more than one relay (spec §20.4).
|
||||
*
|
||||
* Publishing a home record is only half of it. The record tells senders
|
||||
* to deliver a copy everywhere and the relay fans out, but a copy sitting in a
|
||||
* mailbox nothing reads is storage, not redundancy. These tests are about the
|
||||
* half that reads, and about the three ways it goes wrong quietly: a copy read
|
||||
* twice, one relay's cursor applied to another's ids, and a backup mailbox's
|
||||
* bad day taking the signed-in relay down with it.
|
||||
*
|
||||
* Real ciphertext throughout: a real peer, a real ratchet, real envelopes. Only
|
||||
* the HTTP is a mock.
|
||||
*/
|
||||
class MultiMailboxPollTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ class MultiMailboxPollTest {
|
||||
/** What each mailbox is holding: id to payload, plus an envelope id. */
|
||||
private val homeQueue = mutableListOf<Triple<Long, String, String>>()
|
||||
private val backupQueue = mutableListOf<Triple<Long, String, String>>()
|
||||
|
||||
/** Set to make the backup answer 410, a device revoked at a BACKUP home. */
|
||||
private var backupGone = false
|
||||
private var homeDown = false
|
||||
@@ -55,20 +56,33 @@ class MultiMailboxPollTest {
|
||||
private val backupHost get() = "backup.example.com"
|
||||
|
||||
@BeforeTest fun start() {
|
||||
home = MockWebServer().also { it.dispatcher = queueOf({ homeQueue }, { homeDown }); it.start() }
|
||||
backup = MockWebServer().also { it.dispatcher = queueOf({ backupQueue }, { backupGone }); it.start() }
|
||||
vault = Vault(MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
home =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = queueOf({ homeQueue }, { homeDown })
|
||||
it.start()
|
||||
}
|
||||
backup =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = queueOf({ backupQueue }, { backupGone })
|
||||
it.start()
|
||||
}
|
||||
vault =
|
||||
Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
)
|
||||
me = DeviceIdentity.create()
|
||||
}
|
||||
|
||||
@AfterTest fun stop() { home.shutdown(); backup.shutdown() }
|
||||
@AfterTest fun stop() {
|
||||
home.shutdown()
|
||||
backup.shutdown()
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay that serves one mailbox.
|
||||
*
|
||||
* 404s the transparency-log routes: these fixtures are pre-§19
|
||||
* deployments, so the tests are about mailboxes and nothing else.
|
||||
* A relay that serves one mailbox. 404s the transparency-log routes: these
|
||||
* fixtures are pre-§19 deployments, so the tests are about mailboxes and
|
||||
* nothing else.
|
||||
*/
|
||||
private fun queueOf(
|
||||
rows: () -> List<Triple<Long, String, String>>,
|
||||
@@ -79,47 +93,70 @@ class MultiMailboxPollTest {
|
||||
if ("/e2e/log/" in path) return MockResponse().setResponseCode(404)
|
||||
if ("/e2e/messages" in path) {
|
||||
if (gone()) return MockResponse().setResponseCode(410)
|
||||
val after = Regex("after=(\\d+)").find(path)?.groupValues?.get(1)?.toLong() ?: 0
|
||||
val body = rows().filter { it.first > after }.joinToString(",") { (id, payload, env) ->
|
||||
"""{"id":$id,"from_user":7,"from_device":"","payload":"$payload","envelope_id":"$env"}"""
|
||||
}
|
||||
val after =
|
||||
Regex("after=(\\d+)")
|
||||
.find(path)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.toLong() ?: 0
|
||||
val body =
|
||||
rows().filter { it.first > after }.joinToString(",") { (id, payload, env) ->
|
||||
"""{"id":$id,"from_user":7,"from_device":"","payload":"$payload","envelope_id":"$env"}"""
|
||||
}
|
||||
return json("""{"messages":[$body],"cursor":0}""")
|
||||
}
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
private fun conversations(): Conversations {
|
||||
// Written into the vault rather than passed in: the client rebuilds
|
||||
// its list of homes from the vault on every poll, which is what lets a
|
||||
// Written into the vault rather than passed in: the client rebuilds its
|
||||
// list of homes from the vault on every poll, which is what lets a
|
||||
// mailbox added or dropped mid-session take effect without a relaunch.
|
||||
vault.saveExtraHomes(listOf(Vault.ExtraHome(
|
||||
relay = backupHost, username = "me", appPassword = "pw",
|
||||
// The record names a hostname and cannot carry a port; how this
|
||||
// device DIALS that host is a separate field, which is what lets a
|
||||
// test, or a self-hoster on a nonstandard port, work without the
|
||||
// record lying about its own name.
|
||||
baseUrl = backup.url("/").toString())))
|
||||
vault.saveExtraHomes(
|
||||
listOf(
|
||||
Vault.ExtraHome(
|
||||
relay = backupHost,
|
||||
username = "me",
|
||||
appPassword = "pw",
|
||||
// The record names a hostname and cannot carry a port; how this
|
||||
// device DIALS that host is a separate field, which is what lets a
|
||||
// test, or a self-hoster on a nonstandard port, work without the
|
||||
// record lying about its own name.
|
||||
baseUrl = backup.url("/").toString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Conversations(me, vault, RelayClient(home.url("/").toString(), "me", "pw"))
|
||||
}
|
||||
|
||||
private fun myBundle(): PeerBundle {
|
||||
val opk = me.generateOneTimePreKeys(1).first()
|
||||
return PeerBundle.fromWire(me.registrationWire().put("opk",
|
||||
JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey))))
|
||||
return PeerBundle.fromWire(
|
||||
me.registrationWire().put(
|
||||
"opk",
|
||||
JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** One real envelope from a peer, ready to be put in a mailbox. */
|
||||
private fun envelope(session: E2ESession, text: String) = session.encrypt(text).toPayload()
|
||||
private fun envelope(
|
||||
session: E2ESession,
|
||||
text: String,
|
||||
) = session.encrypt(text).toPayload()
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
/**
|
||||
*A relay this device is not signed in to is holding
|
||||
* mail, and without this the message is simply never read.
|
||||
* A relay this device is not signed in to is holding mail, and without this
|
||||
* the message is simply never read.
|
||||
*/
|
||||
@Test
|
||||
fun `a message waiting at another relay is read`() {
|
||||
@@ -134,10 +171,7 @@ class MultiMailboxPollTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy at every home is the design, so a copy read twice is the
|
||||
* failure. And it would not present as a duplicate: the ratchet deletes a
|
||||
* message key once used, so the second copy fails to open and is logged as
|
||||
* a dropped envelope, a message that looks lost rather than repeated.
|
||||
* A copy at every home is the design, so a copy read twice is the failure.
|
||||
*/
|
||||
@Test
|
||||
fun `the same message at two relays is read once`() {
|
||||
@@ -151,14 +185,16 @@ class MultiMailboxPollTest {
|
||||
|
||||
val received = conversations().poll()
|
||||
|
||||
assertEquals(1, received.count { it.text == "delivered twice" },
|
||||
"one message, however many mailboxes carried it")
|
||||
assertEquals(
|
||||
1,
|
||||
received.count { it.text == "delivered twice" },
|
||||
"one message, however many mailboxes carried it",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Envelope ids come from each relay's own database, so id 900 there says
|
||||
* nothing about id 900 here. One shared cursor would let the first
|
||||
* mailbox's progress silently skip the second's mail.
|
||||
* nothing about id 900 here.
|
||||
*/
|
||||
@Test
|
||||
fun `each mailbox keeps its own cursor`() {
|
||||
@@ -170,8 +206,11 @@ class MultiMailboxPollTest {
|
||||
assertEquals(1, conversations.poll().size)
|
||||
|
||||
assertEquals(900L, vault.watermarkFor(backupHost), "the backup's cursor has to move")
|
||||
assertEquals(0L, vault.inboxWatermark,
|
||||
"one relay's ids must not advance another relay's cursor")
|
||||
assertEquals(
|
||||
0L,
|
||||
vault.inboxWatermark,
|
||||
"one relay's ids must not advance another relay's cursor",
|
||||
)
|
||||
|
||||
// And a second poll does not re-read what the backup already gave us.
|
||||
assertTrue(conversations.poll().isEmpty(), "a second poll re-read the same mailbox")
|
||||
@@ -180,9 +219,7 @@ class MultiMailboxPollTest {
|
||||
// MARK: - When something goes wrong
|
||||
|
||||
/**
|
||||
* A 410 from a BACKUP home is that mailbox ending, not this account
|
||||
* ending. Treating it like the signed-in relay's 410 would purge
|
||||
* somebody's messages because one spare relay dropped a registration.
|
||||
* A 410 from a BACKUP home is that mailbox ending, not this account ending.
|
||||
*/
|
||||
@Test
|
||||
fun `a revoked backup mailbox does not stop the signed-in one`() {
|
||||
@@ -195,10 +232,7 @@ class MultiMailboxPollTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure this whole feature exists to survive. A home relay that
|
||||
* is down must not take every other mailbox down with it, which is exactly
|
||||
* what happens if the poll returns on the first error instead of carrying
|
||||
* on to the others.
|
||||
* The failure this whole feature exists to survive.
|
||||
*/
|
||||
@Test
|
||||
fun `a dead home relay does not hide the backup mailbox`() {
|
||||
@@ -211,17 +245,18 @@ class MultiMailboxPollTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed poll is still not an empty inbox. "No new messages" and
|
||||
* "could not reach anything" are different, and the app says different
|
||||
* words for them, so when nothing at all could be read, the failure is
|
||||
* raised rather than swallowed into an empty list.
|
||||
* A failed poll is still not an empty inbox.
|
||||
*/
|
||||
@Test
|
||||
fun `when no mailbox can be read at all the failure is raised`() {
|
||||
homeDown = true
|
||||
backupGone = true
|
||||
var threw = false
|
||||
try { conversations().poll() } catch (_: Exception) { threw = true }
|
||||
try {
|
||||
conversations().poll()
|
||||
} catch (_: Exception) {
|
||||
threw = true
|
||||
}
|
||||
assertTrue(threw, "every mailbox failed and it was reported as an empty inbox")
|
||||
}
|
||||
|
||||
|
||||
@@ -12,18 +12,12 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The seam that carries the setting to the ten places that build HTTP clients.
|
||||
*
|
||||
* The bug this is for: obfs4 was reachable from nothing. Every client in
|
||||
* the app took `defaultHttpClient()`'s null default, so the transport that had
|
||||
* been built, tested and proven against lyrebird was not on any code path a
|
||||
* user could reach. Threading a bridge through each construction site would
|
||||
* have worked until the eleventh was added, and the eleventh would connect
|
||||
* directly, successfully, with nothing to show for it.
|
||||
*/
|
||||
class Obfs4RoutingTest {
|
||||
|
||||
private fun bridge(host: String = "198.51.100.7", port: Int = 4444) =
|
||||
Obfs4Bridge(host, port, ByteArray(20) { 1 }, ByteArray(32) { 2 })
|
||||
private fun bridge(
|
||||
host: String = "198.51.100.7",
|
||||
port: Int = 4444,
|
||||
) = Obfs4Bridge(host, port, ByteArray(20) { 1 }, ByteArray(32) { 2 })
|
||||
|
||||
@AfterTest
|
||||
fun clearRouting() = Obfs4Routing.use(null)
|
||||
@@ -44,11 +38,8 @@ class Obfs4RoutingTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The factory is installed whether or not a bridge is set, so the
|
||||
* no-bridge path has to be an ordinary socket. If it were an
|
||||
* `Obfs4Socket` pointed at nothing, installing the factory would break the
|
||||
* app for the overwhelming majority who never configure a bridge, and it
|
||||
* would break it at connect time, on the network, not at build time.
|
||||
* The factory is installed whether or not a bridge is set, so the no-bridge
|
||||
* path has to be an ordinary socket.
|
||||
*/
|
||||
@Test
|
||||
fun `with no bridge the factory hands out a plain socket`() {
|
||||
@@ -71,9 +62,6 @@ class Obfs4RoutingTest {
|
||||
|
||||
/**
|
||||
* The factory reads the setting per socket, not once at construction.
|
||||
* A client is built long before the user opens settings; a factory that
|
||||
* captured the bridge at build time would keep using whatever was in force
|
||||
* then, including nothing at all, for the life of the process.
|
||||
*/
|
||||
@Test
|
||||
fun `a factory built before the bridge was set still picks it up`() {
|
||||
@@ -89,16 +77,18 @@ class Obfs4RoutingTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The default client must carry the routing factory, or none of the
|
||||
* above reaches anything the app actually uses.
|
||||
* The default client must carry the routing factory, or none of the above
|
||||
* reaches anything the app actually uses.
|
||||
*/
|
||||
@Test
|
||||
fun `the default http client routes through the holder`() {
|
||||
Obfs4Routing.use(bridge())
|
||||
val client = RelayClient.defaultHttpClient()
|
||||
val socket = client.socketFactory.createSocket()
|
||||
assertTrue(socket is Obfs4Socket,
|
||||
"the app's default client ignored the configured bridge")
|
||||
assertTrue(
|
||||
socket is Obfs4Socket,
|
||||
"the app's default client ignored the configured bridge",
|
||||
)
|
||||
socket.close()
|
||||
}
|
||||
|
||||
@@ -112,17 +102,17 @@ class Obfs4RoutingTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pooled connections outlive the setting that created them. OkHttp
|
||||
* keeps a connection alive across requests, so one opened through the old
|
||||
* bridge would go on serving requests for minutes after the user switched,
|
||||
* or after they turned a bridge OFF, which is the dangerous direction.
|
||||
* Pooled connections outlive the setting that created them.
|
||||
*/
|
||||
@Test
|
||||
fun `changing the bridge empties the connection pool`() {
|
||||
Obfs4Routing.use(bridge(host = "198.51.100.7"))
|
||||
Obfs4Routing.use(bridge(host = "203.0.113.9"))
|
||||
assertEquals(0, Obfs4Routing.pool.connectionCount(),
|
||||
"connections committed to the previous transport were kept")
|
||||
assertEquals(
|
||||
0,
|
||||
Obfs4Routing.pool.connectionCount(),
|
||||
"connections committed to the previous transport were kept",
|
||||
)
|
||||
}
|
||||
|
||||
/** Setting the same bridge twice must not tear down live connections. */
|
||||
|
||||
@@ -12,31 +12,29 @@ import kotlin.test.assertTrue
|
||||
/**
|
||||
* Sign-up, sign-in and password reset against a relay that is its own identity
|
||||
* provider.
|
||||
*
|
||||
* This whole class was at 0%, every route a person meets before they
|
||||
* can send a single message, including the error mapping that decides what they
|
||||
* are told when it goes wrong. Nothing here had ever been executed by a test.
|
||||
*
|
||||
* The error mapping deserves the attention as much as the happy path does.
|
||||
* These are the only failures a person sees before they have an account, and a
|
||||
* 409 shown as "something went wrong" instead of "that name is taken" is the
|
||||
* difference between fixing it in five seconds and giving up.
|
||||
*/
|
||||
class RelayAccountsTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun accounts() = RelayAccounts(server.url("/").toString().trimEnd('/'))
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
private val ok = """{"user_id":7,"username":"ada","display_name":"Ada L","app_password":"abcd efgh"}"""
|
||||
|
||||
// -- Sign-up -------------------------------------------------------------
|
||||
// -- Sign-up -------------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `signing up returns the credential the relay minted`() {
|
||||
@@ -67,7 +65,13 @@ class RelayAccountsTest {
|
||||
fun `the display name falls back to the username`() {
|
||||
server.enqueue(json(ok))
|
||||
accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
|
||||
assertTrue(server.takeRequest().body.readUtf8().contains("\"display_name\":\"ada\""))
|
||||
assertTrue(
|
||||
server
|
||||
.takeRequest()
|
||||
.body
|
||||
.readUtf8()
|
||||
.contains("\"display_name\":\"ada\""),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,12 +84,10 @@ class RelayAccountsTest {
|
||||
assertEquals("ada", accounts().signIn("ada", "pw").displayName)
|
||||
}
|
||||
|
||||
// -- Sign-in -------------------------------------------------------------
|
||||
// -- Sign-in -------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The field is "username or email" and always has been. A client
|
||||
* that only sends usernames rejects half the people who typed exactly what
|
||||
* they were asked for.
|
||||
* The field is "username or email" and always has been.
|
||||
*/
|
||||
@Test
|
||||
fun `an email address is sent in the username field`() {
|
||||
@@ -102,12 +104,12 @@ class RelayAccountsTest {
|
||||
assertEquals("abcd efgh", a.appPassword)
|
||||
}
|
||||
|
||||
// -- Shared session ------------------------------------------------------
|
||||
// -- Shared session ------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The handle travels BESIDE the token, not inside it: the account
|
||||
* service will not put a handle in a token, so a relay cannot correlate its
|
||||
* users with another product's.
|
||||
* The handle travels BESIDE the token, not inside it: the account service
|
||||
* will not put a handle in a token, so a relay cannot correlate its users
|
||||
* with another product's.
|
||||
*/
|
||||
@Test
|
||||
fun `a shared session sends the handle alongside the token`() {
|
||||
@@ -125,13 +127,18 @@ class RelayAccountsTest {
|
||||
fun `a shared session omits an empty display name rather than sending blank`() {
|
||||
server.enqueue(json(ok))
|
||||
accounts().sharedSession("tok-123", "ada")
|
||||
assertTrue(!server.takeRequest().body.readUtf8().contains("display_name"))
|
||||
assertTrue(
|
||||
!server
|
||||
.takeRequest()
|
||||
.body
|
||||
.readUtf8()
|
||||
.contains("display_name"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A self-hosted relay answers 404 here, and that is the CORRECT answer
|
||||
* rather than a misconfiguration, it is its own identity provider. The
|
||||
* caller has to be able to tell that apart from a broken relay.
|
||||
* rather than a misconfiguration, it is its own identity provider.
|
||||
*/
|
||||
@Test
|
||||
fun `a relay that is its own identity provider reports not-found distinctly`() {
|
||||
@@ -139,17 +146,16 @@ class RelayAccountsTest {
|
||||
assertFailsWith<RelayException.NotFound> { accounts().sharedSession("tok", "ada") }
|
||||
}
|
||||
|
||||
// -- Password reset ------------------------------------------------------
|
||||
// -- Password reset ------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The relay answers identically whatever the truth is, unknown handle,
|
||||
* no address on file, an account that authenticates elsewhere. So this
|
||||
* cannot report whether it worked, and a UI that claims it can is lying.
|
||||
* The relay answers identically whatever the truth is, unknown handle, no
|
||||
* address on file, an account that authenticates elsewhere.
|
||||
*/
|
||||
@Test
|
||||
fun `requesting a reset says nothing about whether the account exists`() {
|
||||
server.enqueue(json("""{"ok":true}"""))
|
||||
accounts().requestPasswordReset("ada") // returns Unit, by design
|
||||
accounts().requestPasswordReset("ada") // returns Unit, by design
|
||||
val req = server.takeRequest()
|
||||
assertEquals("/account/reset/request", req.path)
|
||||
assertTrue(req.body.readUtf8().contains("\"handle\":\"ada\""))
|
||||
@@ -166,72 +172,77 @@ class RelayAccountsTest {
|
||||
assertTrue(body.contains("\"password\":\"newpassword1\""))
|
||||
}
|
||||
|
||||
// -- What people are told when it fails ----------------------------------
|
||||
// -- What people are told when it fails ----------------------------------.
|
||||
|
||||
/**
|
||||
* The relay puts a human-readable reason in the body, and it is better
|
||||
* than anything this layer could invent from a status code. Losing it means
|
||||
* "that name is taken" becomes "something went wrong".
|
||||
* The relay puts a human-readable reason in the body, and it is better than
|
||||
* anything this layer could invent from a status code.
|
||||
*/
|
||||
@Test
|
||||
fun `a taken username keeps the relay's own explanation`() {
|
||||
server.enqueue(MockResponse().setResponseCode(409)
|
||||
.setBody("""{"message":"That name is already taken."}"""))
|
||||
val e = assertFailsWith<RelayException.Http> {
|
||||
accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
|
||||
}
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(409)
|
||||
.setBody("""{"message":"That name is already taken."}"""),
|
||||
)
|
||||
val e =
|
||||
assertFailsWith<RelayException.Http> {
|
||||
accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
|
||||
}
|
||||
assertEquals(409, e.status)
|
||||
assertTrue(e.message!!.contains("already taken"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bad password is unauthorized rather than a generic failure`() {
|
||||
server.enqueue(MockResponse().setResponseCode(401)
|
||||
.setBody("""{"message":"Wrong password."}"""))
|
||||
val e = assertFailsWith<RelayException.Unauthorized> {
|
||||
accounts().signIn("ada", "wrong")
|
||||
}
|
||||
// The relay's text lives in `body`; `message` is fixed. The type is
|
||||
// a DECISION. Unauthorized triggers exactly one retry, and a caller
|
||||
// matching on a string to decide that eventually matches wrong.
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(401)
|
||||
.setBody("""{"message":"Wrong password."}"""),
|
||||
)
|
||||
val e =
|
||||
assertFailsWith<RelayException.Unauthorized> {
|
||||
accounts().signIn("ada", "wrong")
|
||||
}
|
||||
// The relay's text lives in `body`; `message` is fixed.
|
||||
assertTrue(e.body.contains("Wrong password"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throttling carries the retry-after the relay gave`() {
|
||||
server.enqueue(MockResponse().setResponseCode(429).setHeader("Retry-After", "90"))
|
||||
val e = assertFailsWith<RelayException.RateLimited> {
|
||||
accounts().signIn("ada", "hunter2hunter2")
|
||||
}
|
||||
val e =
|
||||
assertFailsWith<RelayException.RateLimited> {
|
||||
accounts().signIn("ada", "hunter2hunter2")
|
||||
}
|
||||
assertEquals(90, e.retryAfterSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throttling without a retry-after is still throttling`() {
|
||||
server.enqueue(MockResponse().setResponseCode(429))
|
||||
val e = assertFailsWith<RelayException.RateLimited> {
|
||||
accounts().signIn("ada", "hunter2hunter2")
|
||||
}
|
||||
val e =
|
||||
assertFailsWith<RelayException.RateLimited> {
|
||||
accounts().signIn("ada", "hunter2hunter2")
|
||||
}
|
||||
assertEquals(null, e.retryAfterSeconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* The geo-gate, not the relay. It needs a sentence, not a number, a
|
||||
* person blocked by region who is shown "error 451" has no idea what
|
||||
* happened or that nothing is wrong with their account.
|
||||
* The geo-gate, not the relay.
|
||||
*/
|
||||
@Test
|
||||
fun `a region block is reported as unavailable with its explanation`() {
|
||||
server.enqueue(MockResponse().setResponseCode(451)
|
||||
.setBody("""{"message":"Not available in your region."}"""))
|
||||
val e = assertFailsWith<RelayException.Unavailable> {
|
||||
accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
|
||||
}
|
||||
// The message is deliberately OURS, not the relay's. The download
|
||||
// site is not geo-gated, so somebody can read the page, install the
|
||||
// app, pick a password, tap sign up and be told "relay returned 451",
|
||||
// which mentions nothing about their country and cannot be retried
|
||||
// past, so the only sensible reading is that the app is broken.
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(451)
|
||||
.setBody("""{"message":"Not available in your region."}"""),
|
||||
)
|
||||
val e =
|
||||
assertFailsWith<RelayException.Unavailable> {
|
||||
accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
|
||||
}
|
||||
// The message is deliberately OURS, not the relay's.
|
||||
assertTrue(e.message!!.contains("isn't available in your country"))
|
||||
assertTrue(e.message!!.contains("isn't a problem with your account"))
|
||||
assertTrue(e.body.contains("region"))
|
||||
@@ -241,9 +252,10 @@ class RelayAccountsTest {
|
||||
@Test
|
||||
fun `a non-JSON error body is passed through as the message`() {
|
||||
server.enqueue(MockResponse().setResponseCode(500).setBody("upstream exploded"))
|
||||
val e = assertFailsWith<RelayException.Http> {
|
||||
accounts().signIn("ada", "hunter2hunter2")
|
||||
}
|
||||
val e =
|
||||
assertFailsWith<RelayException.Http> {
|
||||
accounts().signIn("ada", "hunter2hunter2")
|
||||
}
|
||||
assertEquals(500, e.status)
|
||||
assertTrue(e.message!!.contains("upstream exploded"))
|
||||
}
|
||||
|
||||
@@ -20,44 +20,33 @@ import kotlin.test.fail
|
||||
|
||||
/**
|
||||
* Against a real HTTP server, not a hand-written fake.
|
||||
*
|
||||
* A fake written alongside the client agrees with it about everything,
|
||||
* including the field name it got wrong, which is exactly how this protocol's
|
||||
* one shipped bug survived until a live round trip. MockWebServer at least
|
||||
* makes the client produce bytes and parse responses.
|
||||
*/
|
||||
class RelayClientTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
private fun client(): RelayClient =
|
||||
RelayClient(server.url("/").toString(), "alice", "app pass word")
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
private fun client(): RelayClient = RelayClient(server.url("/").toString(), "alice", "app pass word")
|
||||
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
/**
|
||||
* The next request the client made.
|
||||
*
|
||||
* Bounded on purpose. `takeRequest()` with no timeout blocks forever, and
|
||||
* because token minting is lazy, `useRelayAuth` does not mint, the
|
||||
* first authenticated call does, it is easy to wait for a request that has
|
||||
* not happened yet. That reads as a hung build, not a failing test.
|
||||
*/
|
||||
private fun next(): RecordedRequest =
|
||||
server.takeRequest(5, TimeUnit.SECONDS) ?: fail("no request arrived")
|
||||
private fun next(): RecordedRequest = server.takeRequest(5, TimeUnit.SECONDS) ?: fail("no request arrived")
|
||||
|
||||
/**
|
||||
* A client with a token response already queued.
|
||||
*
|
||||
* The mint has NOT happened when this returns, it happens on the first
|
||||
* authenticated call, so `/account/token` is the first request to read
|
||||
* back, after whatever the test then does.
|
||||
*/
|
||||
private fun authedClient(): Pair<RelayClient, DeviceIdentity> {
|
||||
val identity = DeviceIdentity.create()
|
||||
@@ -67,7 +56,7 @@ class RelayClientTest {
|
||||
return client to identity
|
||||
}
|
||||
|
||||
// -- Field names ---------------------------------------------------------
|
||||
// -- Field names ---------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `registering a device sends the names the relay expects`() {
|
||||
@@ -86,29 +75,33 @@ class RelayClientTest {
|
||||
assertEquals(Wire.b64(identity.identitySigningPublic), body.getString("ik_sig"))
|
||||
assertEquals(identity.signedPreKeyId, body.getInt("spk_id"))
|
||||
assertTrue(body.has("spk") && body.has("spk_sig"))
|
||||
// `prekeys`, not `opks`. The relay accepts a registration carrying
|
||||
// the wrong name with a 200 and stores no prekeys, so the device is
|
||||
// published and cannot be started with, a failure with no error.
|
||||
// `prekeys`, not `opks`.
|
||||
assertEquals(2, body.getJSONArray("prekeys").length())
|
||||
assertEquals(preKeys[0].id, body.getJSONArray("prekeys").getJSONObject(0).getInt("id"))
|
||||
|
||||
// The published prekey signature must verify under the published
|
||||
// identity key, or every peer refuses the bundle.
|
||||
assertTrue(Ed25519.verify(
|
||||
Wire.unb64(body.getString("ik_sig")),
|
||||
Wire.unb64(body.getString("spk_sig")),
|
||||
Wire.unb64(body.getString("spk")),
|
||||
))
|
||||
assertTrue(
|
||||
Ed25519.verify(
|
||||
Wire.unb64(body.getString("ik_sig")),
|
||||
Wire.unb64(body.getString("spk_sig")),
|
||||
Wire.unb64(body.getString("spk")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `polling the inbox asks the right question and reads the answer`() {
|
||||
server.enqueue(json("""
|
||||
{"messages":[
|
||||
{"id":7,"from_user":3,"from_device":"phone","payload":"cGF5"},
|
||||
{"id":9,"from_user":4,"from_device":"tablet","payload":"bG9hZA=="}
|
||||
]}
|
||||
""".trimIndent()))
|
||||
server.enqueue(
|
||||
json(
|
||||
"""
|
||||
{"messages":[
|
||||
{"id":7,"from_user":3,"from_device":"phone","payload":"cGF5"},
|
||||
{"id":9,"from_user":4,"from_device":"tablet","payload":"bG9hZA=="}
|
||||
]}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val messages = client().pollInbox("my-device", after = 6, limit = 50)
|
||||
|
||||
@@ -124,16 +117,17 @@ class RelayClientTest {
|
||||
@Test
|
||||
fun `an application password pasted with spaces still authenticates`() {
|
||||
// The account service prints these in readable groups and users paste
|
||||
// what they were shown. Sending the spaces is a 401 the user cannot
|
||||
// diagnose, because the password on their screen looks correct.
|
||||
// what they were shown.
|
||||
server.enqueue(json("{}"))
|
||||
client().logHead()
|
||||
val header = next().getHeader("Authorization")!!
|
||||
assertEquals("alice:apppassword",
|
||||
String(Wire.unb64(header.removePrefix("Basic ")), Charsets.UTF_8))
|
||||
assertEquals(
|
||||
"alice:apppassword",
|
||||
String(Wire.unb64(header.removePrefix("Basic ")), Charsets.UTF_8),
|
||||
)
|
||||
}
|
||||
|
||||
// -- Authentication ------------------------------------------------------
|
||||
// -- Authentication ------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a token is minted once and reused`() {
|
||||
@@ -151,10 +145,7 @@ class RelayClientTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The signature covers the LOGICAL §8 path, not the URL's path. The
|
||||
* relay mounts these routes twice and signs against `/e2e/…`; signing the
|
||||
* REST-prefixed path produces a valid signature over the wrong string, and
|
||||
* the relay rejects it with no hint as to why.
|
||||
* The signature covers the LOGICAL §8 path, not the URL's path.
|
||||
*/
|
||||
@Test
|
||||
fun `the proof signature covers the logical path, not the url path`() {
|
||||
@@ -162,7 +153,7 @@ class RelayClientTest {
|
||||
server.enqueue(json("{}"))
|
||||
client.logHead()
|
||||
|
||||
next() // the mint
|
||||
next() // the mint
|
||||
val request = next()
|
||||
assertEquals("/api/flamenet/v1/e2e/log/head", request.path)
|
||||
assertTrue(verifyProof(request, identity, logicalPath = "/e2e/log/head", body = ByteArray(0)))
|
||||
@@ -175,7 +166,7 @@ class RelayClientTest {
|
||||
server.enqueue(json("""{"ok":true}"""))
|
||||
client.setDeliveryKey(ByteArray(32) { 3 })
|
||||
|
||||
next() // the mint
|
||||
next() // the mint
|
||||
val request = next()
|
||||
val body = request.body.readByteArray()
|
||||
assertTrue(verifyProof(request, identity, "/e2e/delivery-key", body))
|
||||
@@ -197,7 +188,7 @@ class RelayClientTest {
|
||||
client.logHead()
|
||||
client.logHead()
|
||||
|
||||
next() // the mint
|
||||
next() // the mint
|
||||
val a = next()
|
||||
val b = next()
|
||||
assertNotEquals(a.getHeader("X-FN-Nonce"), b.getHeader("X-FN-Nonce"))
|
||||
@@ -213,7 +204,7 @@ class RelayClientTest {
|
||||
|
||||
assertEquals(4, client.logHead().getInt("size"))
|
||||
|
||||
next() // the first mint
|
||||
next() // the first mint
|
||||
val rejected = next()
|
||||
val minted = next()
|
||||
val retried = next()
|
||||
@@ -234,8 +225,7 @@ class RelayClientTest {
|
||||
|
||||
/**
|
||||
* A deployment with no token route is a supported configuration, not an
|
||||
* error, and it must be asked exactly once. Asking on every request costs
|
||||
* a round trip per call, forever, to learn the same thing.
|
||||
* error, and it must be asked exactly once.
|
||||
*/
|
||||
@Test
|
||||
fun `a relay with no token endpoint falls back to Basic and is asked once`() {
|
||||
@@ -258,7 +248,7 @@ class RelayClientTest {
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
// -- Errors --------------------------------------------------------------
|
||||
// -- Errors --------------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `status codes that mean something become the thing they mean`() {
|
||||
@@ -283,14 +273,16 @@ class RelayClientTest {
|
||||
assertFailsWith<RelayException.Malformed> { client().listGroups() }
|
||||
}
|
||||
|
||||
// -- Bundles -------------------------------------------------------------
|
||||
// -- Bundles -------------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `fetched bundles parse into something a session can use`() {
|
||||
val peer = DeviceIdentity.create()
|
||||
val oneTime = peer.generateOneTimePreKeys(1).first()
|
||||
val wire = peer.registrationWire()
|
||||
.put("opk", JSONObject().put("id", oneTime.id).put("pub", Wire.b64(oneTime.publicKey)))
|
||||
val wire =
|
||||
peer
|
||||
.registrationWire()
|
||||
.put("opk", JSONObject().put("id", oneTime.id).put("pub", Wire.b64(oneTime.publicKey)))
|
||||
server.enqueue(json("""{"devices":[$wire]}"""))
|
||||
|
||||
// `fetchKeys` answers with the fnid alongside the bundles now, the
|
||||
@@ -303,13 +295,19 @@ class RelayClientTest {
|
||||
assertEquals(oneTime.id, bundles[0].oneTimePreKeyId)
|
||||
|
||||
// The real proof that the parse is right: a session actually starts.
|
||||
val session = io.flamenet.e2e.E2ESession.initiate(DeviceIdentity.create(), bundles[0])
|
||||
val session =
|
||||
io.flamenet.e2e.E2ESession
|
||||
.initiate(DeviceIdentity.create(), bundles[0])
|
||||
val envelope = session.encrypt("through a fetched bundle")
|
||||
assertEquals("through a fetched bundle",
|
||||
io.flamenet.e2e.E2ESession.respond(peer, envelope).decrypt(envelope))
|
||||
assertEquals(
|
||||
"through a fetched bundle",
|
||||
io.flamenet.e2e.E2ESession
|
||||
.respond(peer, envelope)
|
||||
.decrypt(envelope),
|
||||
)
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
// -- Helpers -------------------------------------------------------------.
|
||||
|
||||
private fun verifyProof(
|
||||
request: RecordedRequest,
|
||||
@@ -317,15 +315,19 @@ class RelayClientTest {
|
||||
logicalPath: String,
|
||||
body: ByteArray,
|
||||
): Boolean {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(body)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
val canonical = listOf(
|
||||
request.method!!,
|
||||
logicalPath,
|
||||
request.getHeader("X-FN-Timestamp")!!,
|
||||
request.getHeader("X-FN-Nonce")!!,
|
||||
digest,
|
||||
).joinToString("\n").toByteArray(Charsets.UTF_8)
|
||||
val digest =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest(body)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
val canonical =
|
||||
listOf(
|
||||
request.method!!,
|
||||
logicalPath,
|
||||
request.getHeader("X-FN-Timestamp")!!,
|
||||
request.getHeader("X-FN-Nonce")!!,
|
||||
digest,
|
||||
).joinToString("\n").toByteArray(Charsets.UTF_8)
|
||||
return Ed25519.verify(
|
||||
identity.identitySigningPublic,
|
||||
Wire.unb64(request.getHeader("X-FN-Signature")!!),
|
||||
|
||||
@@ -8,27 +8,13 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Pinning the relay's identity (ios#19, Android half).
|
||||
*
|
||||
* The finding this closes is not a missing feature, it is a wrong
|
||||
* conclusion. Android already pinned `issuerPublicKey`, inside
|
||||
* `Trust.verifyHeadSignature`. When the key later changed, the head signature
|
||||
* stopped verifying and the only conclusion available at that point was
|
||||
* `KeyLogForkException`, so a rotation somebody performed on purpose was
|
||||
* reported to the user as the relay showing two different histories.
|
||||
*
|
||||
* Those need different responses. A fork means nothing the relay says can be
|
||||
* trusted. A rotation means somebody ran a command, or somebody else is
|
||||
* answering on that address, and a person has to decide which. Reporting the
|
||||
* second as the first is alarming and unactionable, and an alarm people cannot
|
||||
* act on is one they learn to dismiss, which costs the real fork detection its
|
||||
* credibility too.
|
||||
*/
|
||||
class RelayIdentityPinTest {
|
||||
|
||||
private fun key(byte: Int) = ByteArray(32) { byte.toByte() }
|
||||
|
||||
private fun b64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes)
|
||||
|
||||
// ---- The four verdicts ----------------------------------------------
|
||||
// ---- The four verdicts ----------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a published key with nothing pinned is first sight`() {
|
||||
@@ -46,8 +32,8 @@ class RelayIdentityPinTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The case the whole file exists for, and the one that used to be
|
||||
* reported as a forked log.
|
||||
* The case the whole file exists for, and the one that used to be reported
|
||||
* as a forked log.
|
||||
*/
|
||||
@Test
|
||||
fun `a different key is a change, carrying both halves`() {
|
||||
@@ -58,10 +44,7 @@ class RelayIdentityPinTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay that publishes no key is a weaker deployment, not an
|
||||
* attack. Somebody self-hosting an older build has nothing to pin, and
|
||||
* refusing to talk to them would be a worse failure than the one this
|
||||
* prevents.
|
||||
* A relay that publishes no key is a weaker deployment, not an attack.
|
||||
*/
|
||||
@Test
|
||||
fun `a deployment that publishes nothing is not an attack`() {
|
||||
@@ -76,10 +59,7 @@ class RelayIdentityPinTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Broken is not hostile. A relay answering with rubbish counts as
|
||||
* "not published" rather than as a change, treating a malformed field as
|
||||
* an attack would raise an alarm nobody can act on, on a deployment that is
|
||||
* simply misconfigured.
|
||||
* Broken is not hostile.
|
||||
*/
|
||||
@Test
|
||||
fun `an unparseable key reads as not published, not as a change`() {
|
||||
@@ -104,12 +84,10 @@ class RelayIdentityPinTest {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- What somebody is told ------------------------------------------
|
||||
// ---- What somebody is told ------------------------------------------.
|
||||
|
||||
/**
|
||||
* The warning names BOTH possibilities and asserts neither. An alarm that
|
||||
* claims an attack when a rotation is equally likely trains people to
|
||||
* dismiss alarms, and the next one may be real.
|
||||
* The warning names BOTH possibilities and asserts neither.
|
||||
*/
|
||||
@Test
|
||||
fun `the warning does not pretend to know which it is`() {
|
||||
@@ -121,17 +99,12 @@ class RelayIdentityPinTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The exception is not a fork exception, and that separation is the
|
||||
* point of this change. A caller catching `KeyLogForkException` to decide
|
||||
* "the log is untrustworthy" must not be handed a rotation.
|
||||
* The exception is not a fork exception, and that separation is the point
|
||||
* of this change.
|
||||
*/
|
||||
@Test
|
||||
fun `an identity change is not reported as a key-log fork`() {
|
||||
// Held as the sealed base, deliberately. Typed as the concrete class
|
||||
// the compiler folds `!is KeyLogForkException` to a constant and warns,
|
||||
// which is the compiler proving the point statically, and also means the
|
||||
// assertion would test nothing. Through the base type it is the runtime
|
||||
// check a caller's `when` actually performs.
|
||||
// Held as the sealed base, deliberately.
|
||||
val thrown: RelayException = RelayIdentityChangedException("relay.example.com")
|
||||
assertTrue(
|
||||
thrown !is KeyLogForkException,
|
||||
@@ -142,12 +115,11 @@ class RelayIdentityPinTest {
|
||||
assertTrue(identity.message!!.contains("rotated on purpose"))
|
||||
}
|
||||
|
||||
// ---- Shape -----------------------------------------------------------
|
||||
// ---- Shape -----------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* `ByteArray` has reference equality by default, so the verdicts carry
|
||||
* hand-written `equals`. Without them every comparison in this suite would
|
||||
* be false and the tests would have been written around it.
|
||||
* hand-written `equals`.
|
||||
*/
|
||||
@Test
|
||||
fun `verdicts compare by content, not by reference`() {
|
||||
|
||||
@@ -20,25 +20,24 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Sending to somebody on another relay (spec §20.5).
|
||||
*
|
||||
* Always sealed, and here that is not a preference. A relay that accepts
|
||||
* a federated envelope stores it with no sender, it was not told and must not
|
||||
* invent one, so an unsealed envelope arrives unattributable and is dropped.
|
||||
* Locally, sealing is an improvement; across a boundary it is the only thing
|
||||
* that makes the message deliverable at all.
|
||||
*
|
||||
* The other half is the return address. Their relay records none, and our user
|
||||
* id means nothing on it, so a message without one decrypts on their phone and
|
||||
* has nowhere to reply to, which reads to the recipient as the app being
|
||||
* broken rather than as a message being incomplete.
|
||||
*/
|
||||
class SendToAddressTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
@@ -52,45 +51,69 @@ class SendToAddressTest {
|
||||
private var accepted = 1
|
||||
private var unknown = listOf<String>()
|
||||
private var unreachable = listOf<String>()
|
||||
|
||||
/** Devices the far relay reports for the address, empty for "nobody there". */
|
||||
private var remoteDevices: () -> String = { "" }
|
||||
|
||||
private val theirCard = ContactCard(
|
||||
"fnid:0123456789abcdefghjkmnpqrstvwxyz0123456789abcdefghjk", "peer.example.com")
|
||||
private val theirCard =
|
||||
ContactCard("fnid:0123456789abcdefghjkmnpqrstvwxyz0123456789abcdefghjk", "peer.example.com")
|
||||
|
||||
@BeforeTest fun start() {
|
||||
me = DeviceIdentity.create()
|
||||
peer = DeviceIdentity.create()
|
||||
remoteDevices = { peer.registrationWire().toString() }
|
||||
server = MockWebServer().also { it.dispatcher = relay(); it.start() }
|
||||
vault = Vault(MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
server =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = relay()
|
||||
it.start()
|
||||
}
|
||||
vault =
|
||||
Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
)
|
||||
vault.saveIdentity(me)
|
||||
}
|
||||
|
||||
@AfterTest fun stop() = server.shutdown()
|
||||
|
||||
private fun relay() = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"/e2e/log/" in path -> MockResponse().setResponseCode(404)
|
||||
"/e2e/send/remote" in path -> {
|
||||
sentBody = JSONObject(request.body.readUtf8())
|
||||
json(JSONObject()
|
||||
.put("ok", true).put("accepted", accepted)
|
||||
.put("unknown", org.json.JSONArray(unknown))
|
||||
.put("unreachable", org.json.JSONArray(unreachable)).toString())
|
||||
private fun relay() =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"/e2e/log/" in path -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
"/e2e/send/remote" in path -> {
|
||||
sentBody = JSONObject(request.body.readUtf8())
|
||||
json(
|
||||
JSONObject()
|
||||
.put("ok", true)
|
||||
.put("accepted", accepted)
|
||||
.put("unknown", org.json.JSONArray(unknown))
|
||||
.put("unreachable", org.json.JSONArray(unreachable))
|
||||
.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
"/e2e/remote/" in path -> {
|
||||
json("""{"devices":[${remoteDevices()}]}""")
|
||||
}
|
||||
|
||||
else -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
"/e2e/remote/" in path ->
|
||||
json("""{"devices":[${remoteDevices()}]}""")
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
private fun conversations(withAddress: Boolean = true): Conversations =
|
||||
Conversations(me, vault, RelayClient(server.url("/").toString(), "me", "pw")).also {
|
||||
@@ -116,18 +139,16 @@ class SendToAddressTest {
|
||||
val payload = messages.getJSONObject(0).getString("payload")
|
||||
assertTrue(SealedSender.isSealed(payload), "the envelope reached the wire unsealed")
|
||||
|
||||
// The return address is inside the seal, where the relay cannot read
|
||||
// it and cannot strip it.
|
||||
val opened = SealedSender.open(
|
||||
SealedSender.blobFromPayload(payload), peer.identityDH.private, peer.deviceId)
|
||||
// The return address is inside the seal, where the relay cannot read it
|
||||
// and cannot strip it.
|
||||
val opened =
|
||||
SealedSender.open(SealedSender.blobFromPayload(payload), peer.identityDH.private, peer.deviceId)
|
||||
assertEquals("fnid:mine@home.example.com", opened.claimedFromAddress)
|
||||
assertEquals(me.deviceId, opened.claimedFromDevice)
|
||||
}
|
||||
|
||||
/**
|
||||
* One id per MESSAGE, not per device or per mailbox. A recipient polling
|
||||
* several homes collapses the copies by it; a fresh id per copy would show
|
||||
* them the same message once per mailbox.
|
||||
* One id per MESSAGE, not per device or per mailbox.
|
||||
*/
|
||||
@Test
|
||||
fun `every copy of one message carries the same envelope id`() {
|
||||
@@ -140,8 +161,10 @@ class SendToAddressTest {
|
||||
|
||||
val messages = sentBody!!.getJSONArray("messages")
|
||||
assertEquals(2, messages.length(), "both of their devices must be addressed")
|
||||
assertEquals(messages.getJSONObject(0).getString("envelope_id"),
|
||||
messages.getJSONObject(1).getString("envelope_id"))
|
||||
assertEquals(
|
||||
messages.getJSONObject(0).getString("envelope_id"),
|
||||
messages.getJSONObject(1).getString("envelope_id"),
|
||||
)
|
||||
}
|
||||
|
||||
/** The address is remembered, or the conversation is write-only. */
|
||||
@@ -156,16 +179,18 @@ class SendToAddressTest {
|
||||
// MARK: - Refusals
|
||||
|
||||
/**
|
||||
* Refused up front rather than sent into a dead end. Their relay
|
||||
* records no sender, so without our address the message decrypts on their
|
||||
* phone and has nowhere to reply to.
|
||||
* Refused up front rather than sent into a dead end.
|
||||
*/
|
||||
@Test
|
||||
fun `a device with no address of its own will not send`() {
|
||||
val sent = conversations(withAddress = false).sendToAddress(theirCard, "hello")
|
||||
assertFalse(sent.reachedSomeone)
|
||||
assertTrue(sent.failed.values.single().contains("address"),
|
||||
"the refusal has to say why: ${sent.failed}")
|
||||
assertTrue(
|
||||
sent.failed.values
|
||||
.single()
|
||||
.contains("address"),
|
||||
"the refusal has to say why: ${sent.failed}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -176,9 +201,7 @@ class SendToAddressTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* `accepted` first. Checking only the failure fields reported a send
|
||||
* as successful when the far relay had taken nothing at all: the app said
|
||||
* sent, the peer's inbox stayed empty, and no error anywhere explained it.
|
||||
* `accepted` first.
|
||||
*/
|
||||
@Test
|
||||
fun `taking no copies is a failed send whatever else the reply says`() {
|
||||
@@ -193,30 +216,37 @@ class SendToAddressTest {
|
||||
accepted = 0
|
||||
unknown = listOf(theirCard.fnid)
|
||||
val sent = conversations().sendToAddress(theirCard, "hello")
|
||||
assertTrue(sent.failed.values.single().contains("does not know"),
|
||||
"got: ${sent.failed}")
|
||||
assertTrue(
|
||||
sent.failed.values
|
||||
.single()
|
||||
.contains("does not know"),
|
||||
"got: ${sent.failed}",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial delivery is a real outcome once an account has several
|
||||
* mailboxes, and silence about it is how somebody believes a message landed
|
||||
* when a copy did not.
|
||||
* Partial delivery is a real outcome once an account has several mailboxes,
|
||||
* and silence about it is how somebody believes a message landed when a
|
||||
* copy did not.
|
||||
*/
|
||||
@Test
|
||||
fun `a mailbox that did not take the copy is reported`() {
|
||||
unreachable = listOf("backup.example.com")
|
||||
val sent = conversations().sendToAddress(theirCard, "hello")
|
||||
assertTrue(sent.reachedSomeone, "the message did reach somewhere")
|
||||
assertTrue(sent.failed.values.single().contains("backup.example.com"),
|
||||
"got: ${sent.failed}")
|
||||
assertTrue(
|
||||
sent.failed.values
|
||||
.single()
|
||||
.contains("backup.example.com"),
|
||||
"got: ${sent.failed}",
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Trust in the address keyspace
|
||||
|
||||
/**
|
||||
* A key never seen before is either a new device of theirs or one their
|
||||
* relay minted, and nothing on the wire tells those apart. The pin hangs
|
||||
* off the address, because a user id on their relay means nothing on ours.
|
||||
* relay minted, and nothing on the wire tells those apart.
|
||||
*/
|
||||
@Test
|
||||
fun `a changed identity key at an address refuses the send`() {
|
||||
|
||||
@@ -15,44 +15,38 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A message nobody can receive must not report success.
|
||||
*
|
||||
* This shipped, and it lost real messages. Signing out deregisters the
|
||||
* device, so a signed-out recipient has no devices. Envelopes are
|
||||
* addressed per device, so `sendPlaintext` looped over an empty bundle list,
|
||||
* made no request at all, and returned `Sent(delivered = [], failed = {})`.
|
||||
* Every caller reads an empty `failed` as "it went", the message was written
|
||||
* into the sender's own thread and existed nowhere else. It was reported as
|
||||
* "I wasn't signed in on the other end and the message got lost", and the same
|
||||
* path is why a group text vanished: the group senders call this once per
|
||||
* member.
|
||||
*
|
||||
* iOS never had this bug, `transmit` throws on an empty device list.
|
||||
* The two platforms disagreed about a delivery guarantee for as long as both
|
||||
* have existed, and nothing failed, because the only tests that covered
|
||||
* sending had both ends signed in. That is the actual lesson here: a test that
|
||||
* only exercises the happy path cannot tell a delivery from a no-op, since
|
||||
* both of them are silent.
|
||||
*
|
||||
* So this asserts the *unhappy* path directly, on the seam where the two
|
||||
* platforms drifted.
|
||||
*/
|
||||
class SendToNobodyTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun conversations(): Conversations {
|
||||
val identity = DeviceIdentity.create()
|
||||
@@ -61,10 +55,11 @@ class SendToNobodyTest {
|
||||
return Conversations(identity, vault, relay)
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
@Test
|
||||
fun `sending to somebody with no registered devices is reported as failed`() {
|
||||
@@ -82,10 +77,7 @@ class SendToNobodyTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* And it must not quietly post the envelope anyway. The bug was not only
|
||||
* in the return value: with no devices there is nothing to address, so a
|
||||
* request here would mean the relay was being handed an envelope for a
|
||||
* device that does not exist.
|
||||
* And it must not quietly post the envelope anyway.
|
||||
*/
|
||||
@Test
|
||||
fun `no envelope is posted when there is nobody to address`() {
|
||||
@@ -93,8 +85,11 @@ class SendToNobodyTest {
|
||||
|
||||
conversations().send(toUserId = 99, text = "are you there?")
|
||||
|
||||
// Exactly one request: the device lookup. Nothing after it.
|
||||
assertEquals(1, server.requestCount,
|
||||
"a send to nobody should stop after the lookup")
|
||||
// Exactly one request: the device lookup.
|
||||
assertEquals(
|
||||
1,
|
||||
server.requestCount,
|
||||
"a send to nobody should stop after the lookup",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,21 +10,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What a refused sign-up is reported as.
|
||||
*
|
||||
* A 409 is not always a taken handle, and this used to say it was. The
|
||||
* relay answers 409 for a taken handle, an already-registered email, and a
|
||||
* handle too close to an existing one, three problems with three different
|
||||
* fixes. Collapsing them told somebody whose EMAIL was registered to pick a
|
||||
* different handle, which cannot work and leaves them no way to find out why.
|
||||
*
|
||||
* 400 was worse: every format refusal became "the account service returned
|
||||
* 400", which is not a sentence anybody can act on.
|
||||
*
|
||||
* The status is the transport's opinion. The `code` in the body is the relay's,
|
||||
* and it is the one that means something.
|
||||
*/
|
||||
class SignupFailureMappingTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() {
|
||||
@@ -39,11 +26,15 @@ class SignupFailureMappingTest {
|
||||
private fun client() = AccountsClient(server.url("/").toString())
|
||||
|
||||
/** Ask the server to refuse with a given code and status, and see what comes back. */
|
||||
private fun refusalFor(code: String, status: Int): AccountsClient.Failure {
|
||||
private fun refusalFor(
|
||||
code: String,
|
||||
status: Int,
|
||||
): AccountsClient.Failure {
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(status)
|
||||
MockResponse()
|
||||
.setResponseCode(status)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"code":"$code","message":"refused"}""")
|
||||
.setBody("""{"code":"$code","message":"refused"}"""),
|
||||
)
|
||||
return try {
|
||||
client().register("someone", "passphrase-long-enough")
|
||||
@@ -55,24 +46,23 @@ class SignupFailureMappingTest {
|
||||
|
||||
@Test
|
||||
fun `each refusal code maps to its own failure`() {
|
||||
val cases = listOf(
|
||||
Triple("username_taken", 409, AccountsClient.Failure.HandleTaken),
|
||||
Triple("email_taken", 409, AccountsClient.Failure.EmailTaken),
|
||||
Triple("username_confusable", 409, AccountsClient.Failure.HandleConfusable),
|
||||
Triple("disposable_email", 400, AccountsClient.Failure.DisposableEmail),
|
||||
Triple("bad_username", 400, AccountsClient.Failure.BadHandle),
|
||||
Triple("bad_email", 400, AccountsClient.Failure.BadEmail),
|
||||
Triple("weak_password", 400, AccountsClient.Failure.WeakPassword),
|
||||
)
|
||||
val cases =
|
||||
listOf(
|
||||
Triple("username_taken", 409, AccountsClient.Failure.HandleTaken),
|
||||
Triple("email_taken", 409, AccountsClient.Failure.EmailTaken),
|
||||
Triple("username_confusable", 409, AccountsClient.Failure.HandleConfusable),
|
||||
Triple("disposable_email", 400, AccountsClient.Failure.DisposableEmail),
|
||||
Triple("bad_username", 400, AccountsClient.Failure.BadHandle),
|
||||
Triple("bad_email", 400, AccountsClient.Failure.BadEmail),
|
||||
Triple("weak_password", 400, AccountsClient.Failure.WeakPassword),
|
||||
)
|
||||
for ((code, status, expected) in cases) {
|
||||
assertEquals(expected, refusalFor(code, status), "$code should map to $expected")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this whole change exists for. An already-registered
|
||||
* email must NOT be reported as a taken handle, the person would change
|
||||
* their handle, be refused again, and have no way to work out why.
|
||||
* The regression this whole change exists for.
|
||||
*/
|
||||
@Test
|
||||
fun `an already registered email is not reported as a taken handle`() {
|
||||
@@ -80,14 +70,12 @@ class SignupFailureMappingTest {
|
||||
assertEquals(AccountsClient.Failure.EmailTaken, failure)
|
||||
assertTrue(
|
||||
failure.message!!.contains("email"),
|
||||
"the message should mention the email, not the handle: ${failure.message}"
|
||||
"the message should mention the email, not the handle: ${failure.message}",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay newer than the app is the normal case, not the exception. An
|
||||
* unknown code must still refuse, falling through to success would create
|
||||
* an account the server never made.
|
||||
* A relay newer than the app is the normal case, not the exception.
|
||||
*/
|
||||
@Test
|
||||
fun `an unknown refusal code still refuses`() {
|
||||
@@ -95,7 +83,7 @@ class SignupFailureMappingTest {
|
||||
val fourHundred = refusalFor("invented_next_year", 400)
|
||||
assertTrue(
|
||||
fourHundred is AccountsClient.Failure.Service,
|
||||
"an unknown 400 should keep its status: $fourHundred"
|
||||
"an unknown 400 should keep its status: $fourHundred",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,9 +91,10 @@ class SignupFailureMappingTest {
|
||||
@Test
|
||||
fun `an unparseable body falls back to the status`() {
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(409)
|
||||
MockResponse()
|
||||
.setResponseCode(409)
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.setBody("<html>a proxy wrote this</html>")
|
||||
.setBody("<html>a proxy wrote this</html>"),
|
||||
)
|
||||
try {
|
||||
client().register("someone", "passphrase-long-enough")
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.flamenet.client
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.json.JSONObject
|
||||
import java.security.MessageDigest
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
@@ -11,25 +12,11 @@ import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* The client half of the sign-up puzzle.
|
||||
*
|
||||
* The bit counting has to agree with the relay exactly. Both sides count
|
||||
* leading zero *bits* of `SHA256("challenge.nonce")`. If this side counted
|
||||
* bytes, every solution this phone produced would still be accepted, it would
|
||||
* simply do hundreds of times the work, and no assertion about "at least N
|
||||
* leading zeros" could see it, because overshooting also satisfies N. That is
|
||||
* why the vectors below are pinned to exact values.
|
||||
*
|
||||
* The other half of the contract is that nothing here may prevent a
|
||||
* sign-up. A relay that predates the feature, a request that times out, a
|
||||
* proxy returning HTML, all of those mean "no work wanted", not "no account
|
||||
* for you".
|
||||
*/
|
||||
class SignupProofOfWorkTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() {
|
||||
@@ -43,32 +30,35 @@ class SignupProofOfWorkTest {
|
||||
|
||||
private fun url() = server.url("/").toString()
|
||||
|
||||
// ---------------------------------------------------------------- the arithmetic
|
||||
// ---------------------------------------------------------------- the
|
||||
// arithmetic.
|
||||
|
||||
/**
|
||||
* The input is chosen so the answer is NOT a multiple of eight.
|
||||
* `SHA256("pin754")` starts `000f d989…`, twelve zero bits, which a
|
||||
* byte-counting implementation reports as eight. Almost every digest has no
|
||||
* leading zeros at all and cannot tell the two apart.
|
||||
*/
|
||||
@Test
|
||||
fun `leading zeros are counted in bits, not bytes`() {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest("pin754".toByteArray(Charsets.UTF_8))
|
||||
assertEquals(12, SignupProofOfWork.leadingZeroBits(digest),
|
||||
"counted to a byte boundary instead of a bit")
|
||||
val digest =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest("pin754".toByteArray(Charsets.UTF_8))
|
||||
assertEquals(
|
||||
12,
|
||||
SignupProofOfWork.leadingZeroBits(digest),
|
||||
"counted to a byte boundary instead of a bit",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact nonce, not merely "a nonce that is good enough". A
|
||||
* byte-rounding implementation still satisfies a 9-bit difficulty, it
|
||||
* overshoots to 16 bits and returns 4416 instead of 19, which is 230x the
|
||||
* work for the person signing up.
|
||||
* The exact nonce, not merely "a nonce that is good enough".
|
||||
*/
|
||||
@Test
|
||||
fun `the first satisfying nonce is the one returned`() {
|
||||
assertEquals("19", SignupProofOfWork.nonce("1700000000.c2FsdA.bWFj", bits = 9),
|
||||
"solved a harder puzzle than the relay asked for")
|
||||
assertEquals(
|
||||
"19",
|
||||
SignupProofOfWork.nonce("1700000000.c2FsdA.bWFj", bits = 9),
|
||||
"solved a harder puzzle than the relay asked for",
|
||||
)
|
||||
}
|
||||
|
||||
/** The same string the relay hashes: `challenge` then a dot then `nonce`. */
|
||||
@@ -76,26 +66,35 @@ class SignupProofOfWorkTest {
|
||||
fun `a solved nonce satisfies the relay's own check`() {
|
||||
val challenge = "1700000000.c2FsdA.bWFj"
|
||||
val nonce = assertNotNull(SignupProofOfWork.nonce(challenge, bits = 12))
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest("$challenge.$nonce".toByteArray(Charsets.UTF_8))
|
||||
assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 12,
|
||||
"the relay would refuse this as unsolved")
|
||||
val digest =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest("$challenge.$nonce".toByteArray(Charsets.UTF_8))
|
||||
assertTrue(
|
||||
SignupProofOfWork.leadingZeroBits(digest) >= 12,
|
||||
"the relay would refuse this as unsolved",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A nonce solved for one challenge must not satisfy another, or the
|
||||
* puzzle is solved once and reused for every registration in a farm.
|
||||
* A nonce solved for one challenge must not satisfy another, or the puzzle
|
||||
* is solved once and reused for every registration in a farm.
|
||||
*/
|
||||
@Test
|
||||
fun `a nonce does not transfer between challenges`() {
|
||||
val nonce = assertNotNull(SignupProofOfWork.nonce("challenge-one", bits = 12))
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest("challenge-two.$nonce".toByteArray(Charsets.UTF_8))
|
||||
assertTrue(SignupProofOfWork.leadingZeroBits(digest) < 12,
|
||||
"a nonce solved elsewhere satisfied this challenge")
|
||||
val digest =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest("challenge-two.$nonce".toByteArray(Charsets.UTF_8))
|
||||
assertTrue(
|
||||
SignupProofOfWork.leadingZeroBits(digest) < 12,
|
||||
"a nonce solved elsewhere satisfied this challenge",
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- refusing to grind
|
||||
// ---------------------------------------------------------------- refusing
|
||||
// to grind.
|
||||
|
||||
/** The ceiling is a real bound. A search that cannot finish must return. */
|
||||
@Test
|
||||
@@ -104,14 +103,15 @@ class SignupProofOfWorkTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A difficulty above the cap is not attempted at all. Trusting a number
|
||||
* from the network with a long loop is how an app comes to look hung.
|
||||
* A difficulty above the cap is not attempted at all.
|
||||
*/
|
||||
@Test
|
||||
fun `a difficulty above the cap is not attempted`() {
|
||||
val absurd = SignupProofOfWork.Challenge("x", SignupProofOfWork.MAXIMUM_BITS + 1, 600)
|
||||
assertNull(SignupProofOfWork.solve(absurd),
|
||||
"ground on a difficulty the relay is not even allowed to ask for")
|
||||
assertNull(
|
||||
SignupProofOfWork.solve(absurd),
|
||||
"ground on a difficulty the relay is not even allowed to ask for",
|
||||
)
|
||||
}
|
||||
|
||||
/** `bits: 0` is how a relay says it wants none. That must cost nothing. */
|
||||
@@ -120,38 +120,47 @@ class SignupProofOfWorkTest {
|
||||
assertNull(SignupProofOfWork.solve(SignupProofOfWork.Challenge("x", 0, 600)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- never in the way
|
||||
// ---------------------------------------------------------------- never in
|
||||
// the way.
|
||||
|
||||
/** Every one of these servers must still be able to create an account. */
|
||||
@Test
|
||||
fun `every server that cannot offer a challenge still allows sign-up`() {
|
||||
val cases = listOf(
|
||||
"an older relay with no such route" to MockResponse().setResponseCode(404),
|
||||
"a relay that is unwell" to MockResponse().setResponseCode(500),
|
||||
"a proxy returning HTML with a 200" to
|
||||
MockResponse().setResponseCode(200).setBody("<html>not json</html>"),
|
||||
"an empty body" to MockResponse().setResponseCode(200).setBody(""),
|
||||
"JSON with no challenge field" to
|
||||
MockResponse().setResponseCode(200).setBody("""{"bits":10}"""),
|
||||
)
|
||||
val cases =
|
||||
listOf(
|
||||
"an older relay with no such route" to MockResponse().setResponseCode(404),
|
||||
"a relay that is unwell" to MockResponse().setResponseCode(500),
|
||||
"a proxy returning HTML with a 200" to
|
||||
MockResponse().setResponseCode(200).setBody("<html>not json</html>"),
|
||||
"an empty body" to MockResponse().setResponseCode(200).setBody(""),
|
||||
"JSON with no challenge field" to
|
||||
MockResponse().setResponseCode(200).setBody("""{"bits":10}"""),
|
||||
)
|
||||
for ((name, response) in cases) {
|
||||
server.enqueue(response)
|
||||
assertNull(SignupProofOfWork.satisfy(url()),
|
||||
"$name should mean no work, not no account")
|
||||
assertNull(
|
||||
SignupProofOfWork.satisfy(url()),
|
||||
"$name should mean no work, not no account",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** And the ordinary case: an offer of real work is fetched, parsed and met. */
|
||||
@Test
|
||||
fun `an offered challenge is fetched and solved`() {
|
||||
server.enqueue(MockResponse().setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":10,"expires_in":600}"""))
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":10,"expires_in":600}"""),
|
||||
)
|
||||
|
||||
val solution = assertNotNull(SignupProofOfWork.satisfy(url()))
|
||||
assertEquals("1700000000.c2FsdA.bWFj", solution.challenge)
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest("${solution.challenge}.${solution.nonce}".toByteArray(Charsets.UTF_8))
|
||||
val digest =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest("${solution.challenge}.${solution.nonce}".toByteArray(Charsets.UTF_8))
|
||||
assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 10)
|
||||
}
|
||||
|
||||
@@ -163,84 +172,86 @@ class SignupProofOfWorkTest {
|
||||
assertEquals("/account/challenge", server.takeRequest().path)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- what people read
|
||||
// ---------------------------------------------------------------- what
|
||||
// people read.
|
||||
|
||||
private fun refuse(code: String) {
|
||||
server.enqueue(MockResponse().setResponseCode(400)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"code":"$code","message":"the relay said something"}"""))
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(400)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"code":"$code","message":"the relay said something"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole reactive flow. Refused for a missing challenge, we go
|
||||
* and get one, solve it, and send the same fields again with the answer.
|
||||
*
|
||||
* Three exchanges, in this order. The first sign-up carries no challenge
|
||||
* at all: a relay with this feature off, every relay
|
||||
* running today, sees exactly one request and nobody waits twice.
|
||||
* The whole reactive flow.
|
||||
*/
|
||||
@Test
|
||||
fun `a relay that asks for work is answered and the sign-up goes through`() {
|
||||
refuse("challenge_required")
|
||||
server.enqueue(MockResponse().setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":8,"expires_in":600}"""))
|
||||
server.enqueue(MockResponse().setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"user_id":7,"username":"ada","app_password":"credential"}"""))
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":8,"expires_in":600}"""),
|
||||
)
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"user_id":7,"username":"ada","app_password":"credential"}"""),
|
||||
)
|
||||
|
||||
val account = RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase")
|
||||
assertEquals("ada", account.username)
|
||||
|
||||
val first = server.takeRequest()
|
||||
assertEquals("/account/signup", first.path)
|
||||
assertTrue(!first.body.readUtf8().contains("challenge"),
|
||||
"the first attempt should carry no challenge, that round trip is the cost")
|
||||
assertTrue(
|
||||
!first.body.readUtf8().contains("challenge"),
|
||||
"the first attempt should carry no challenge, that round trip is the cost",
|
||||
)
|
||||
|
||||
assertEquals("/account/challenge", server.takeRequest().path)
|
||||
|
||||
val retry = JSONObject(server.takeRequest().body.readUtf8())
|
||||
assertEquals("1700000000.c2FsdA.bWFj", retry.getString("challenge"))
|
||||
// Every original field survives the retry. Dropping one here would look
|
||||
// like a validation failure on a sign-up that was fine.
|
||||
// Every original field survives the retry.
|
||||
assertEquals("ada", retry.getString("username"))
|
||||
assertEquals("ada@example.com", retry.getString("email"))
|
||||
assertEquals("passphrase", retry.getString("password"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Rethrown, not swallowed. When this app cannot answer, a
|
||||
* difficulty above the cap, or a relay that will not hand out a challenge,
|
||||
* "update the app" is the true sentence, and failing with anything else
|
||||
* sends somebody looking in the wrong place.
|
||||
* Rethrown, not swallowed.
|
||||
*/
|
||||
@Test
|
||||
fun `an app that cannot answer is told to update`() {
|
||||
refuse("challenge_required")
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
val failure = assertFailsWith<RelayException.ChallengeRequired> {
|
||||
RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase")
|
||||
}
|
||||
val failure =
|
||||
assertFailsWith<RelayException.ChallengeRequired> {
|
||||
RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase")
|
||||
}
|
||||
assertTrue(failure.message!!.contains("Update"), "no remedy offered: ${failure.message}")
|
||||
}
|
||||
|
||||
/**
|
||||
* A different sentence from the one above, and it must stay different.
|
||||
* This one is nearly always a challenge that expired while somebody was
|
||||
* typing; the remedy is another tap, not a trip to the Play Store.
|
||||
*/
|
||||
@Test
|
||||
fun `an expired check is told to try again`() {
|
||||
refuse("challenge_failed")
|
||||
val failure = assertFailsWith<RelayException.ChallengeFailed> {
|
||||
RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase")
|
||||
}
|
||||
val failure =
|
||||
assertFailsWith<RelayException.ChallengeFailed> {
|
||||
RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase")
|
||||
}
|
||||
assertTrue(failure.message!!.contains("again"), "no remedy offered: ${failure.message}")
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinction that matters. A 400 is also a bad email, and telling
|
||||
* somebody to update the app to fix a typo is worse than saying nothing.
|
||||
* The distinction that matters.
|
||||
*/
|
||||
@Test
|
||||
fun `another 400 is not mistaken for a challenge problem`() {
|
||||
@@ -253,10 +264,11 @@ class SignupProofOfWorkTest {
|
||||
/** Neither sentence blames the person or their password. */
|
||||
@Test
|
||||
fun `neither refusal reads as a credential problem`() {
|
||||
val sentences = listOf(
|
||||
RelayException.ChallengeRequired("").message!!,
|
||||
RelayException.ChallengeFailed("").message!!,
|
||||
)
|
||||
val sentences =
|
||||
listOf(
|
||||
RelayException.ChallengeRequired("").message!!,
|
||||
RelayException.ChallengeFailed("").message!!,
|
||||
)
|
||||
for (text in sentences.map { it.lowercase() }) {
|
||||
for (misleading in listOf("password", "passphrase", "incorrect", "invalid", "wrong")) {
|
||||
assertTrue(!text.contains(misleading), "reads as a credential problem: $text")
|
||||
|
||||
@@ -6,34 +6,17 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A test run must not publish a credential (the Kotlin half of ios#62).
|
||||
*
|
||||
* The iOS side had this bug twice, and Android had a quieter version of
|
||||
* it. Every live suite here is properly gated behind `assumeTrue`, better
|
||||
* than iOS managed, but two of them carried
|
||||
* `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create
|
||||
* accounts on `accounts.flamenet.io` whenever somebody set `FN_LIVE_MANAGED=1`.
|
||||
*
|
||||
* The gate stops it running by accident. It does not stop the password being
|
||||
* written down. Every account those runs ever created can be signed into by
|
||||
* anybody who can read this repository, and "they were throwaway accounts"
|
||||
* does not change it, they are real accounts on a real service and they
|
||||
* persist until somebody deletes them.
|
||||
*
|
||||
* A source scan, and coarse on purpose. It cannot prove a suite is safe;
|
||||
* it proves the specific thing that went wrong cannot come back silently.
|
||||
* That is worth having even though it is not a proof, and it costs a few
|
||||
* milliseconds in the JVM suite rather than a device.
|
||||
*/
|
||||
class TestSafetyTest {
|
||||
|
||||
private fun testSources(): List<File> {
|
||||
// From `client/build/classes/...` back to the repository root, then to
|
||||
// every Kotlin test source under it. Walking from the working directory
|
||||
// rather than a compiled resource, so this sees the tree as written.
|
||||
val root = generateSequence(File(".").absoluteFile) { it.parentFile }
|
||||
.firstOrNull { File(it, "settings.gradle.kts").exists() || File(it, "PARITY.md").exists() }
|
||||
?: File(".").absoluteFile
|
||||
return root.walkTopDown()
|
||||
// every Kotlin test source under it.
|
||||
val root =
|
||||
generateSequence(File(".").absoluteFile) { it.parentFile }
|
||||
.firstOrNull { File(it, "settings.gradle.kts").exists() || File(it, "PARITY.md").exists() }
|
||||
?: File(".").absoluteFile
|
||||
return root
|
||||
.walkTopDown()
|
||||
.onEnter { it.name != "build" && it.name != ".git" }
|
||||
.filter { it.isFile && it.extension == "kt" }
|
||||
.filter { it.path.contains("/test/") || it.path.contains("/androidTest/") }
|
||||
@@ -42,19 +25,14 @@ class TestSafetyTest {
|
||||
|
||||
/**
|
||||
* Lines that are code rather than prose.
|
||||
*
|
||||
* Trailing comments go too. The Swift version of this guard flagged an
|
||||
* *email* default because the word "password" appeared in a comment at the
|
||||
* end of the line, and a guard that cries wolf is a guard somebody
|
||||
* switches off.
|
||||
*/
|
||||
private fun codeLines(body: String): List<String> =
|
||||
body.lineSequence()
|
||||
body
|
||||
.lineSequence()
|
||||
.filterNot {
|
||||
val t = it.trimStart()
|
||||
t.startsWith("//") || t.startsWith("*") || t.startsWith("/*")
|
||||
}
|
||||
.map { line ->
|
||||
}.map { line ->
|
||||
var i = 0
|
||||
while (true) {
|
||||
val at = line.indexOf("//", i)
|
||||
@@ -62,22 +40,12 @@ class TestSafetyTest {
|
||||
if (at == 0 || line[at - 1] != ':') return@map line.substring(0, at)
|
||||
i = at + 2
|
||||
}
|
||||
@Suppress("UNREACHABLE_CODE") line
|
||||
}
|
||||
.toList()
|
||||
@Suppress("UNREACHABLE_CODE")
|
||||
line
|
||||
}.toList()
|
||||
|
||||
/**
|
||||
* Whether this file can actually reach a relay somebody else runs.
|
||||
*
|
||||
* `MockWebServer` is the discriminator, and getting this wrong made
|
||||
* the guard useless twice. Keying on the `Live` name prefix flagged the
|
||||
* guard helper and its own unit test; keying on the string "http" flagged
|
||||
* four suites that sign up against a local mock, which is exactly the
|
||||
* correct way to test sign-up. A file that stands up a mock server cannot
|
||||
* reach anybody, whatever else it contains.
|
||||
*
|
||||
* What is left is: it signs up or signs in, it does NOT mock the server,
|
||||
* and it names a real host or takes one from the environment.
|
||||
*/
|
||||
private fun reachesALiveRelay(code: List<String>): Boolean {
|
||||
val reaching = listOf("signUp(", "signIn(", "register(")
|
||||
@@ -88,10 +56,6 @@ class TestSafetyTest {
|
||||
|
||||
/**
|
||||
* No credential may be a literal in a suite that reaches a relay.
|
||||
*
|
||||
* The rule is scoped to files that actually sign up or sign in, because a
|
||||
* purely local unit test using `passphrase = "pass"` against an in-memory
|
||||
* store publishes nothing and is not the problem.
|
||||
*/
|
||||
@Test
|
||||
fun `no live suite carries a credential literal`() {
|
||||
@@ -99,18 +63,13 @@ class TestSafetyTest {
|
||||
|
||||
/**
|
||||
* 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) }
|
||||
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("/")
|
||||
fun isNotACredential(value: String) = value.startsWith("http") || value.contains("@") || value.contains("/")
|
||||
|
||||
for (file in testSources()) {
|
||||
val code = codeLines(file.readText())
|
||||
@@ -122,30 +81,14 @@ class TestSafetyTest {
|
||||
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.
|
||||
// 1.
|
||||
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.
|
||||
// 2.
|
||||
if (assignedTo != null) continue
|
||||
if (Regex("""\bsign(Up|In)\s*\(""").containsMatchIn(before)) {
|
||||
offenders += "${file.name}: $value (passed to sign-up)"
|
||||
@@ -176,20 +119,16 @@ class TestSafetyTest {
|
||||
|
||||
/**
|
||||
* Every suite that reaches a live relay must be able to decline.
|
||||
*
|
||||
* `assumeTrue` reports SKIPPED, which is the truth. A bare `return` makes a
|
||||
* test that did nothing report PASSED, and a suite of those is
|
||||
* indistinguishable from a suite that works, the point `LiveShapesTest`
|
||||
* already makes in prose and this makes checkable.
|
||||
*/
|
||||
@Test
|
||||
fun `every live suite can announce a skip`() {
|
||||
// Scoped to what a file DOES, not to what it is called, see
|
||||
// `reachesALiveRelay`.
|
||||
val offenders = testSources()
|
||||
.filter { reachesALiveRelay(codeLines(it.readText())) }
|
||||
.filterNot { codeLines(it.readText()).any { line -> line.contains("assumeTrue") } }
|
||||
.map { it.name }
|
||||
val offenders =
|
||||
testSources()
|
||||
.filter { reachesALiveRelay(codeLines(it.readText())) }
|
||||
.filterNot { codeLines(it.readText()).any { line -> line.contains("assumeTrue") } }
|
||||
.map { it.name }
|
||||
|
||||
assertTrue(
|
||||
offenders.isEmpty(),
|
||||
@@ -199,9 +138,7 @@ class TestSafetyTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A scan that finds nothing passes. This proves it is reading the real
|
||||
* tree, a moved directory would otherwise turn both checks above into
|
||||
* no-ops reporting success.
|
||||
* A scan that finds nothing passes.
|
||||
*/
|
||||
@Test
|
||||
fun `the scan is looking at the real test sources`() {
|
||||
@@ -212,9 +149,7 @@ class TestSafetyTest {
|
||||
found.any { codeLines(it.readText()).any { line -> line.contains("assumeTrue") } },
|
||||
"the scan cannot see into the files it is reading",
|
||||
)
|
||||
// And the live-relay predicate must actually match something. If it
|
||||
// matched nothing, both checks above would pass by looking at an empty
|
||||
// set, the exact way a guard reports success while guarding nothing.
|
||||
// And the live-relay predicate must actually match something.
|
||||
val live = found.filter { reachesALiveRelay(codeLines(it.readText())) }
|
||||
assertTrue(
|
||||
live.size >= 5,
|
||||
|
||||
@@ -16,26 +16,24 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The scheduled half of key transparency (§19.3, §19.4, audit S4/M1).
|
||||
*
|
||||
* `verifyLogConsistencyIfDue` and `consistent` were at 0%, and
|
||||
* `verifyHeadSignature` at 7%. These are what make transparency *act* rather
|
||||
* than merely record. Checking the log only while establishing a session
|
||||
* leaves an established conversation unchecked indefinitely, which is exactly
|
||||
* the conversation worth attacking, so a relay that rewound or forked between
|
||||
* two bundle fetches would go unseen, possibly for ever.
|
||||
*
|
||||
* Two failure directions matter equally here and pull against each other. A
|
||||
* check that never fires misses a real fork. A check that treats every network
|
||||
* blip as a fork cries wolf until the alarm means nothing, which is worse than
|
||||
* no alarm, so half of these tests assert that something is NOT reported.
|
||||
*/
|
||||
class TrustLogConsistencyTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
@@ -43,11 +41,6 @@ class TrustLogConsistencyTest {
|
||||
|
||||
/**
|
||||
* The relay the object under test is pointed at.
|
||||
*
|
||||
* The key-log pin is stored per relay, because a single pin meant that
|
||||
* signing in to a second relay checked ITS head against the first relay's
|
||||
* root, which cannot reconcile, so an honest relay was reported as forked.
|
||||
* A test reading the pin has to name the same relay that wrote it.
|
||||
*/
|
||||
private val relayHost: String get() = server.url("/").host
|
||||
private lateinit var vault: Vault
|
||||
@@ -55,59 +48,84 @@ class TrustLogConsistencyTest {
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
vault = Vault(MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
vault =
|
||||
Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
)
|
||||
identity = DeviceIdentity.create()
|
||||
}
|
||||
|
||||
@AfterTest fun stop() = server.shutdown()
|
||||
|
||||
private fun trust() =
|
||||
Trust(identity, vault, RelayClient(server.url("/").toString(), "a", "b"))
|
||||
private fun trust() = Trust(identity, vault, RelayClient(server.url("/").toString(), "a", "b"))
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
// -- A real tree, so proofs are rather than stubbed --------------
|
||||
// -- A real tree, so proofs are rather than stubbed --------------.
|
||||
|
||||
private val leaves = (0 until 8).map { KeyLog.leafHash(byteArrayOf(it.toByte())) }
|
||||
|
||||
private fun root(ls: List<ByteArray>): ByteArray = when {
|
||||
ls.size == 1 -> ls[0]
|
||||
else -> {
|
||||
val k = KeyLog.split(ls.size)
|
||||
KeyLog.nodeHash(root(ls.take(k)), root(ls.drop(k)))
|
||||
private fun root(ls: List<ByteArray>): ByteArray =
|
||||
when {
|
||||
ls.size == 1 -> {
|
||||
ls[0]
|
||||
}
|
||||
|
||||
else -> {
|
||||
val k = KeyLog.split(ls.size)
|
||||
KeyLog.nodeHash(root(ls.take(k)), root(ls.drop(k)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun consistencyProof(ls: List<ByteArray>, m: Int, n: Int): List<ByteArray> =
|
||||
if (m == n) emptyList() else sub(ls, m, n, true)
|
||||
private fun consistencyProof(
|
||||
ls: List<ByteArray>,
|
||||
m: Int,
|
||||
n: Int,
|
||||
): List<ByteArray> = if (m == n) emptyList() else sub(ls, m, n, true)
|
||||
|
||||
private fun sub(ls: List<ByteArray>, m: Int, n: Int, complete: Boolean): List<ByteArray> {
|
||||
private fun sub(
|
||||
ls: List<ByteArray>,
|
||||
m: Int,
|
||||
n: Int,
|
||||
complete: Boolean,
|
||||
): List<ByteArray> {
|
||||
if (m == n) return if (complete) emptyList() else listOf(root(ls.take(n)))
|
||||
val k = KeyLog.split(n)
|
||||
return if (m <= k) sub(ls.take(k), m, k, complete) + root(ls.subList(k, n))
|
||||
else sub(ls.subList(k, n), m - k, n - k, false) + root(ls.take(k))
|
||||
return if (m <= k) {
|
||||
sub(ls.take(k), m, k, complete) + root(ls.subList(k, n))
|
||||
} else {
|
||||
sub(ls.subList(k, n), m - k, n - k, false) + root(ls.take(k))
|
||||
}
|
||||
}
|
||||
|
||||
private fun rootB64(n: Int) = Wire.b64(root(leaves.take(n)))
|
||||
|
||||
private fun pin(size: Int) = vault.saveKeyLogPin(Vault.KeyLogPin(size, rootB64(size)))
|
||||
|
||||
private fun headBody(size: Int, root: String = rootB64(size), sig: String = "") =
|
||||
"""{"size":$size,"root":"$root","audience":"r"${if (sig.isEmpty()) "" else ",\"signature\":\"$sig\""}}"""
|
||||
private fun headBody(
|
||||
size: Int,
|
||||
root: String = rootB64(size),
|
||||
sig: String = "",
|
||||
) = """{"size":$size,"root":"$root","audience":"r"${if (sig.isEmpty()) "" else ",\"signature\":\"$sig\""}}"""
|
||||
|
||||
private fun proofBody(m: Int, n: Int) =
|
||||
"""{"proof":[${consistencyProof(leaves, m, n).joinToString(",") { "\"${Wire.b64(it)}\"" }}]}"""
|
||||
private fun proofBody(
|
||||
m: Int,
|
||||
n: Int,
|
||||
) = """{"proof":[${consistencyProof(leaves, m, n).joinToString(",") { "\"${Wire.b64(it)}\"" }}]}"""
|
||||
|
||||
// -- Scheduled consistency ----------------------------------------------
|
||||
// -- Scheduled consistency ----------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `with nothing pinned there is nothing to check`() {
|
||||
val t = trust()
|
||||
t.verifyLogConsistencyIfDue()
|
||||
assertFalse(t.forkDetected)
|
||||
assertEquals(0, server.requestCount) // and it did not go to the network
|
||||
assertEquals(0, server.requestCount) // and it did not go to the network
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -125,9 +143,7 @@ class TrustLogConsistencyTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A log that got SMALLER is a rewind, and no proof can excuse it. This
|
||||
* is the cheapest attack to attempt and the one an unscheduled check would
|
||||
* never see.
|
||||
* A log that got SMALLER is a rewind, and no proof can excuse it.
|
||||
*/
|
||||
@Test
|
||||
fun `a log that shrank is reported as a fork`() {
|
||||
@@ -138,7 +154,7 @@ class TrustLogConsistencyTest {
|
||||
t.verifyLogConsistencyIfDue()
|
||||
|
||||
assertTrue(t.forkDetected)
|
||||
// the pin is NOT advanced onto the smaller log
|
||||
// the pin is NOT advanced onto the smaller log.
|
||||
assertEquals(8, vault.loadKeyLogPin(relayHost)!!.size)
|
||||
}
|
||||
|
||||
@@ -163,8 +179,8 @@ class TrustLogConsistencyTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A head that does not extend our pin, with a proof that does not close
|
||||
* the gap, is the actual split-view attack.
|
||||
* A head that does not extend our pin, with a proof that does not close the
|
||||
* gap, is the actual split-view attack.
|
||||
*/
|
||||
@Test
|
||||
fun `growth whose consistency proof does not verify is a fork`() {
|
||||
@@ -180,8 +196,7 @@ class TrustLogConsistencyTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The network is allowed to be down. Reporting a fork because a fetch
|
||||
* failed would raise a standing, unclearable alarm on a working relay.
|
||||
* The network is allowed to be down.
|
||||
*/
|
||||
@Test
|
||||
fun `an unreachable relay is not a fork`() {
|
||||
@@ -207,9 +222,7 @@ class TrustLogConsistencyTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* It self-throttles because it is called from the poll loop. Without that
|
||||
* it would fetch the head on every poll, which is a request per few
|
||||
* seconds per device for a check that only needs to be hourly.
|
||||
* It self-throttles because it is called from the poll loop.
|
||||
*/
|
||||
@Test
|
||||
fun `the sweep throttles itself between runs`() {
|
||||
@@ -221,7 +234,7 @@ class TrustLogConsistencyTest {
|
||||
t.verifyLogConsistencyIfDue(now)
|
||||
assertEquals(1, server.requestCount)
|
||||
|
||||
t.verifyLogConsistencyIfDue(now + 60_000) // a minute later
|
||||
t.verifyLogConsistencyIfDue(now + 60_000) // a minute later
|
||||
assertEquals(1, server.requestCount, "should not have re-fetched")
|
||||
}
|
||||
|
||||
@@ -238,9 +251,12 @@ class TrustLogConsistencyTest {
|
||||
assertEquals(2, server.requestCount)
|
||||
}
|
||||
|
||||
// -- Head signatures (§19.3, audit M1) ----------------------------------
|
||||
// -- Head signatures (§19.3, audit M1) ----------------------------------.
|
||||
|
||||
private fun signedHead(size: Int, signer: Ed25519.KeyPair): String {
|
||||
private fun signedHead(
|
||||
size: Int,
|
||||
signer: Ed25519.KeyPair,
|
||||
): String {
|
||||
val sig = Ed25519.sign(signer, "fnkt-v1|$size|${rootB64(size)}".toByteArray())
|
||||
return headBody(size, sig = Wire.b64(sig))
|
||||
}
|
||||
@@ -260,8 +276,7 @@ class TrustLogConsistencyTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinned, not fetched-and-trusted. Once an issuer key is pinned, a head
|
||||
* signed by anyone else is a fork, that is the whole value of pinning it.
|
||||
* Pinned, not fetched-and-trusted.
|
||||
*/
|
||||
@Test
|
||||
fun `a head signed by a different key than the pinned one is a fork`() {
|
||||
@@ -277,9 +292,7 @@ class TrustLogConsistencyTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Silent when nothing is signed. A deployment that cannot sign is
|
||||
* weaker, not hostile, and refusing it outright is a worse failure than the
|
||||
* one prevented, self-hosters would simply be locked out.
|
||||
* Silent when nothing is signed.
|
||||
*/
|
||||
@Test
|
||||
fun `an unsigned head is accepted rather than refused`() {
|
||||
@@ -306,7 +319,7 @@ class TrustLogConsistencyTest {
|
||||
val issuer = Ed25519.generate()
|
||||
pin(4)
|
||||
server.enqueue(json(signedHead(4, issuer)))
|
||||
server.enqueue(json("""{}""")) // descriptor publishes nothing
|
||||
server.enqueue(json("""{}""")) // descriptor publishes nothing
|
||||
|
||||
val t = trust()
|
||||
t.verifyLogConsistencyIfDue()
|
||||
@@ -314,7 +327,7 @@ class TrustLogConsistencyTest {
|
||||
assertEquals(null, vault.keyLogIssuerPin())
|
||||
}
|
||||
|
||||
// -- Gossip (§19.4) ------------------------------------------------------
|
||||
// -- Gossip (§19.4) ------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a peer reporting a different root at the same size proves a fork`() {
|
||||
@@ -334,7 +347,7 @@ class TrustLogConsistencyTest {
|
||||
|
||||
/**
|
||||
* Different sizes are not evidence by themselves, one of us is simply
|
||||
* behind. Treating that as a fork would fire constantly in normal use.
|
||||
* behind.
|
||||
*/
|
||||
@Test
|
||||
fun `a peer merely further ahead is not a fork`() {
|
||||
@@ -373,7 +386,7 @@ class TrustLogConsistencyTest {
|
||||
|
||||
/**
|
||||
* A relay that forked once does not become trustworthy because the next
|
||||
* request looked fine. The alarm is standing, and nothing here clears it.
|
||||
* request looked fine.
|
||||
*/
|
||||
@Test
|
||||
fun `a detected fork is not cleared by later good news`() {
|
||||
@@ -386,7 +399,7 @@ class TrustLogConsistencyTest {
|
||||
assertTrue(t.forkDetected, "the alarm must stand")
|
||||
}
|
||||
|
||||
// -- headPayload ---------------------------------------------------------
|
||||
// -- headPayload ---------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `there is nothing to gossip before anything is pinned`() {
|
||||
@@ -402,7 +415,7 @@ class TrustLogConsistencyTest {
|
||||
assertTrue(payload.contains(rootB64(4)))
|
||||
}
|
||||
|
||||
// -- forget --------------------------------------------------------------
|
||||
// -- forget --------------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `forgetting a contact drops its pinned identities`() {
|
||||
|
||||
@@ -18,18 +18,24 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Pinning and key-log enforcement (§19), against a real HTTP server.
|
||||
*
|
||||
* The cases that matter here are the refusals. A trust check that accepts
|
||||
* everything passes every happy-path test ever written, and the only way to
|
||||
* know it does anything is to hand it something it must reject.
|
||||
*/
|
||||
class TrustTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
@@ -37,11 +43,6 @@ class TrustTest {
|
||||
|
||||
/**
|
||||
* The relay the object under test is pointed at.
|
||||
*
|
||||
* The key-log pin is stored per relay, because a single pin meant that
|
||||
* signing in to a second relay checked ITS head against the first relay's
|
||||
* root, which cannot reconcile, so an honest relay was reported as forked.
|
||||
* A test reading the pin has to name the same relay that wrote it.
|
||||
*/
|
||||
private val relayHost: String get() = server.url("/").host
|
||||
private lateinit var vault: Vault
|
||||
@@ -57,21 +58,20 @@ class TrustTest {
|
||||
|
||||
private fun trust() = Trust(identity, vault, RelayClient(server.url("/").toString(), "a", "b"))
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
private fun key(seed: Byte) = ByteArray(32) { seed }
|
||||
|
||||
// -- A detected change has to SURVIVE ------------------------------------
|
||||
// -- A detected change has to SURVIVE ------------------------------------.
|
||||
|
||||
/**
|
||||
* The bug these guard, found by driving two real apps: `record()` worked
|
||||
* out CHANGED during a send and then threw it away, because nothing stored
|
||||
* it. `state()` could only answer UNKNOWN, VERIFIED or PINNED, so the chat
|
||||
* header went on saying "keys unchanged since you met" while every send was
|
||||
* refused with "identity keys changed", and the "Accept new keys" button,
|
||||
* which only renders for CHANGED, could never appear. A contact who
|
||||
* reinstalled was unmessageable for ever with no way out.
|
||||
* it.
|
||||
*/
|
||||
@Test
|
||||
fun `a detected key change is still CHANGED when asked again later`() {
|
||||
@@ -80,10 +80,12 @@ class TrustTest {
|
||||
assertEquals(IdentityTrust.PINNED, t.state(7))
|
||||
|
||||
assertEquals(IdentityTrust.CHANGED, t.record(7, listOf(key(2))))
|
||||
// The point: ASK AGAIN. This is what the UI does, and it used to be
|
||||
// told everything was fine.
|
||||
assertEquals(IdentityTrust.CHANGED, t.state(7),
|
||||
"the change was detected and then forgotten")
|
||||
// The point: ASK AGAIN.
|
||||
assertEquals(
|
||||
IdentityTrust.CHANGED,
|
||||
t.state(7),
|
||||
"the change was detected and then forgotten",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -91,19 +93,13 @@ class TrustTest {
|
||||
val t = trust()
|
||||
t.record(7, listOf(key(1)))
|
||||
t.record(7, listOf(key(2)))
|
||||
// Still the ORIGINAL key. Detecting a substitution must not adopt it.
|
||||
// Still the ORIGINAL key.
|
||||
assertEquals(listOf(Wire.b64(key(1))), vault.loadTrust(7)?.identities)
|
||||
}
|
||||
|
||||
/**
|
||||
* The doc comment on `rememberFnid` said this and the code did the
|
||||
* opposite. It overwrote a different address in place, the one thing an
|
||||
* address pin exists to prevent, since an fnid is derived from the account
|
||||
* key, so a new address is not a new device but a different account.
|
||||
*
|
||||
* It went unnoticed while only the bundle route carried an address:
|
||||
* that route is reached once per conversation, when a session is first
|
||||
* established. The directory carries it on every send.
|
||||
* opposite.
|
||||
*/
|
||||
@Test
|
||||
fun `a different address is a change, not a quiet replacement`() {
|
||||
@@ -111,8 +107,11 @@ class TrustTest {
|
||||
t.record(7, listOf(key(1)), fnid = "fnid:first")
|
||||
t.rememberFnid(7, "fnid:second")
|
||||
|
||||
assertEquals("fnid:first", vault.loadTrust(7)?.fnid,
|
||||
"the pinned address was replaced instead of challenged")
|
||||
assertEquals(
|
||||
"fnid:first",
|
||||
vault.loadTrust(7)?.fnid,
|
||||
"the pinned address was replaced instead of challenged",
|
||||
)
|
||||
assertEquals(IdentityTrust.CHANGED, t.state(7))
|
||||
}
|
||||
|
||||
@@ -153,7 +152,7 @@ class TrustTest {
|
||||
assertEquals(IdentityTrust.PINNED, t.state(7))
|
||||
}
|
||||
|
||||
// -- Pinning -------------------------------------------------------------
|
||||
// -- Pinning -------------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a first sighting is pinned rather than doubted`() {
|
||||
@@ -174,17 +173,12 @@ class TrustTest {
|
||||
fun `a subset is still the same person, devices go away`() {
|
||||
val t = trust()
|
||||
t.record(7, listOf(key(1), key(2)))
|
||||
// One device removed is not an identity change. Treating it as one
|
||||
// would fire the alarm every time somebody signs out on their tablet.
|
||||
// One device removed is not an identity change.
|
||||
assertEquals(IdentityTrust.PINNED, t.record(7, listOf(key(1))))
|
||||
}
|
||||
|
||||
/**
|
||||
* The attack. A relay adds ONE key of its own alongside the real ones.
|
||||
*
|
||||
* Every device still works, so nothing looks wrong to either
|
||||
* person, while a device the attacker controls receives a copy of
|
||||
* everything. A check written as "any offered key is pinned" accepts this.
|
||||
* The attack.
|
||||
*/
|
||||
@Test
|
||||
fun `an extra unseen key alongside the real ones is a change`() {
|
||||
@@ -202,7 +196,10 @@ class TrustTest {
|
||||
|
||||
@Test
|
||||
fun `verifying survives a restart, because it is in the vault`() {
|
||||
trust().also { it.record(7, listOf(key(1))); it.setVerified(7, true) }
|
||||
trust().also {
|
||||
it.record(7, listOf(key(1)))
|
||||
it.setVerified(7, true)
|
||||
}
|
||||
assertEquals(IdentityTrust.VERIFIED, trust().state(7))
|
||||
}
|
||||
|
||||
@@ -231,7 +228,7 @@ class TrustTest {
|
||||
assertEquals(60, t.safetyNumber(7)?.length)
|
||||
}
|
||||
|
||||
// -- Global address (fnid) -----------------------------------------------
|
||||
// -- Global address (fnid) -----------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `an address is resolved once and pinned`() {
|
||||
@@ -241,8 +238,7 @@ class TrustTest {
|
||||
assertEquals("fnid:abc", t.pinnedFnid(7))
|
||||
|
||||
// Idempotent: no second request, so the queue staying empty is the
|
||||
// assertion. A lookup per check is a lookup a relay can answer
|
||||
// differently each time.
|
||||
// assertion.
|
||||
t.rememberIdentity(7, "bob")
|
||||
assertEquals("fnid:abc", t.pinnedFnid(7))
|
||||
assertEquals(1, server.requestCount)
|
||||
@@ -258,10 +254,6 @@ class TrustTest {
|
||||
|
||||
/**
|
||||
* An unreachable relay must not pin "no address".
|
||||
*
|
||||
* Baking that in would be permanent, `rememberIdentity` does nothing once
|
||||
* an address is pinned, and the next successful lookup would then look
|
||||
* like the address changing, which is the alarm.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed lookup pins nothing rather than pinning absence`() {
|
||||
@@ -277,9 +269,6 @@ class TrustTest {
|
||||
|
||||
/**
|
||||
* A different address is a different account key, so a different person.
|
||||
*
|
||||
* Not a new device, whatever the relay presents it as: the fnid is derived
|
||||
* from the account key.
|
||||
*/
|
||||
@Test
|
||||
fun `a changed address is an identity change`() {
|
||||
@@ -309,10 +298,18 @@ class TrustTest {
|
||||
|
||||
val b = bundle("d1", key(1))
|
||||
// The leaf the relay would have written for an account WITH this fnid.
|
||||
val leaf = Wire.b64(KeyLog.leafHash(KeyLog.canonicalLeaf(
|
||||
kind = "publish", subject = "fnid:abc", deviceId = b.deviceId,
|
||||
ikSigB64 = Wire.b64(key(1)), ikDhB64 = Wire.b64(key(2)),
|
||||
)))
|
||||
val leaf =
|
||||
Wire.b64(
|
||||
KeyLog.leafHash(
|
||||
KeyLog.canonicalLeaf(
|
||||
kind = "publish",
|
||||
subject = "fnid:abc",
|
||||
deviceId = b.deviceId,
|
||||
ikSigB64 = Wire.b64(key(1)),
|
||||
ikDhB64 = Wire.b64(key(2)),
|
||||
),
|
||||
),
|
||||
)
|
||||
server.enqueue(json("""{"size":1,"root":"$leaf","audience":"r"}"""))
|
||||
server.enqueue(json("""{"device_id":"d1","size":1,"entries":[{"index":0,"kind":"publish","leaf":"$leaf"}]}"""))
|
||||
server.enqueue(json("""{"index":0,"size":1,"leaf":"$leaf","root":"$leaf","proof":[]}"""))
|
||||
@@ -322,7 +319,7 @@ class TrustTest {
|
||||
assertEquals(leaf, vault.loadKeyLogPin(relayHost)?.root)
|
||||
}
|
||||
|
||||
// -- Key-log enforcement -------------------------------------------------
|
||||
// -- Key-log enforcement -------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a head that goes backwards is a fork`() {
|
||||
@@ -344,21 +341,18 @@ class TrustTest {
|
||||
|
||||
/**
|
||||
* A key the relay serves but never logged must be refused.
|
||||
*
|
||||
* This is the point of the mechanism: the relay is free to hand out
|
||||
* whatever key it likes, and the client's job is to notice that no such
|
||||
* publication exists in the log the relay itself publishes.
|
||||
*/
|
||||
@Test
|
||||
fun `a served key that is not in the log is refused`() {
|
||||
server.enqueue(json("""{"size":1,"root":"${Wire.b64(key(3))}","audience":"r"}"""))
|
||||
server.enqueue(json("""{"device_id":"d1","size":1,"entries":[]}"""))
|
||||
val e = assertFailsWith<KeyNotLoggedException> {
|
||||
trust().enforceKeyLog(bundle("d1", key(1)), 7)
|
||||
}
|
||||
val e =
|
||||
assertFailsWith<KeyNotLoggedException> {
|
||||
trust().enforceKeyLog(bundle("d1", key(1)), 7)
|
||||
}
|
||||
assertEquals("d1", e.deviceId)
|
||||
// Nothing logged at all, this is the shape a substitution takes, and
|
||||
// it must not be confused with a subject we could not compute.
|
||||
// Nothing logged at all, this is the shape a substitution takes, and it
|
||||
// must not be confused with a subject we could not compute.
|
||||
assertFalse(e.hadEntries)
|
||||
}
|
||||
|
||||
@@ -381,9 +375,6 @@ class TrustTest {
|
||||
|
||||
/**
|
||||
* The relay must not be able to pass by echoing its own bytes back.
|
||||
*
|
||||
* Here the log lists an entry and the inclusion response is internally
|
||||
* consistent, but for a leaf that is not the one this bundle implies.
|
||||
*/
|
||||
@Test
|
||||
fun `an inclusion proof for someone else's leaf is refused`() {
|
||||
@@ -395,7 +386,7 @@ class TrustTest {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Gossip --------------------------------------------------------------
|
||||
// -- Gossip --------------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `no pin means nothing to gossip and nothing to conclude`() {
|
||||
@@ -423,9 +414,6 @@ class TrustTest {
|
||||
|
||||
/**
|
||||
* An unreachable relay is not evidence of anything.
|
||||
*
|
||||
* Crying fork whenever the network is down trains everyone to ignore the
|
||||
* warning, which is worse than not having it.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed consistency fetch is not treated as a fork`() {
|
||||
@@ -445,29 +433,43 @@ class TrustTest {
|
||||
assertEquals("ROOTB64", payload.getString("root"))
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
// -- Helpers -------------------------------------------------------------.
|
||||
|
||||
private fun bundle(deviceId: String, ikSig: ByteArray) =
|
||||
io.flamenet.e2e.PeerBundle.fromWire(JSONObject(deviceWire(deviceId, ikSig)))
|
||||
private fun bundle(
|
||||
deviceId: String,
|
||||
ikSig: ByteArray,
|
||||
) = io.flamenet.e2e.PeerBundle
|
||||
.fromWire(JSONObject(deviceWire(deviceId, ikSig)))
|
||||
|
||||
private fun deviceWire(deviceId: String, ikSig: ByteArray): String = JSONObject()
|
||||
.put("device_id", deviceId)
|
||||
.put("ik_sig", Wire.b64(ikSig))
|
||||
.put("ik_dh", Wire.b64(key(2)))
|
||||
.put("spk_id", 1)
|
||||
.put("spk", Wire.b64(key(4)))
|
||||
.put("spk_sig", Wire.b64(ByteArray(64) { 5 }))
|
||||
.put("opk", JSONObject().put("id", 1).put("pub", Wire.b64(key(6))))
|
||||
.put("proto", 1)
|
||||
.toString()
|
||||
|
||||
private fun expectedLeaf(deviceId: String, ikSig: ByteArray, audience: String, userId: Long) =
|
||||
Wire.b64(KeyLog.leafHash(KeyLog.canonicalLeaf(
|
||||
kind = "publish",
|
||||
subject = KeyLog.logSubject(null, audience, userId),
|
||||
deviceId = deviceId,
|
||||
ikSigB64 = Wire.b64(ikSig),
|
||||
ikDhB64 = Wire.b64(key(2)),
|
||||
)))
|
||||
private fun deviceWire(
|
||||
deviceId: String,
|
||||
ikSig: ByteArray,
|
||||
): String =
|
||||
JSONObject()
|
||||
.put("device_id", deviceId)
|
||||
.put("ik_sig", Wire.b64(ikSig))
|
||||
.put("ik_dh", Wire.b64(key(2)))
|
||||
.put("spk_id", 1)
|
||||
.put("spk", Wire.b64(key(4)))
|
||||
.put("spk_sig", Wire.b64(ByteArray(64) { 5 }))
|
||||
.put("opk", JSONObject().put("id", 1).put("pub", Wire.b64(key(6))))
|
||||
.put("proto", 1)
|
||||
.toString()
|
||||
|
||||
private fun expectedLeaf(
|
||||
deviceId: String,
|
||||
ikSig: ByteArray,
|
||||
audience: String,
|
||||
userId: Long,
|
||||
) = Wire.b64(
|
||||
KeyLog.leafHash(
|
||||
KeyLog.canonicalLeaf(
|
||||
kind = "publish",
|
||||
subject = KeyLog.logSubject(null, audience, userId),
|
||||
deviceId = deviceId,
|
||||
ikSigB64 = Wire.b64(ikSig),
|
||||
ikDhB64 = Wire.b64(key(2)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,22 +11,24 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The update check for an app nothing else updates.
|
||||
*
|
||||
* The case worth pinning hardest is the one that is not about versions at
|
||||
* all: the download URL must come from the relay's own origin, whatever the
|
||||
* manifest says. A relay that could name the APK host could hand its users a
|
||||
* different app.
|
||||
*/
|
||||
class UpdateCheckTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun base() = server.url("/").toString().trimEnd('/')
|
||||
|
||||
private fun serve(body: String, code: Int = 200) {
|
||||
private fun serve(
|
||||
body: String,
|
||||
code: Int = 200,
|
||||
) {
|
||||
server.enqueue(MockResponse().setResponseCode(code).setBody(body))
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ class UpdateCheckTest {
|
||||
|
||||
@Test
|
||||
fun `the download url is built from the relay, never taken from the manifest`() {
|
||||
// A hostile manifest naming somebody else's APK. The field is ignored.
|
||||
// A hostile manifest naming somebody else's APK.
|
||||
serve(
|
||||
"""{"versionCode":9,"versionName":"9","url":"https://evil.example/pwn.apk",
|
||||
"downloadUrl":"https://evil.example/pwn.apk"}""",
|
||||
|
||||
Reference in New Issue
Block a user