Bridges: a screen before sign-in, several lines with failover, §24 receive rules
- "Connect through a bridge" on the sign-in and sign-up screen opens the bridge panel before any account exists: paste or scan a line, the live status, remove a line. - Up to 8 stored lines (migrated from the one-line store), tried one at a time with the one that last worked first. OkHttp's own route loop does the trying: while lines are stored the resolver answers every name with one placeholder per line, so no name is looked up on the local network, and a refused connection, no handshake within 15 s, or TLS failing inside the tunnel moves to the next line. accounts.flamenet.io and the relay keep their own names through the tunnel, so the edge behind the bridge routes each to its real service. Every line failing never connects directly, and the error says no bridge could be reached. - Offers: a gid, a line over 1024 bytes, or a line that is not a string is dropped; an oversized or blank line is never built. Ports are digits only. - Held to bridge-offer-v1.json in :engine and :obfs4; .e2e-ref moves to flamenet-e2e c75b861 and CI exports FLAMENET_BRIDGE_VECTORS. - Citations of §6.19 for the bridge offer now say §24. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4c818e1e7e
commit
9f4cb84009
@@ -1 +1 @@
|
||||
50fcc9992e81b294091e23ee1a72923899d4db1c
|
||||
c75b861fea8e90ba132f09cad2d58f41fad0a757
|
||||
|
||||
+7
-4
@@ -86,6 +86,7 @@ unit-tests:
|
||||
- export FLAMENET_SYNC_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/sync-merge-v1.json"
|
||||
- export FLAMENET_HANDLE_CERT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/handle-cert-v1.json"
|
||||
- export FLAMENET_LINK_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/links-v1.json"
|
||||
- export FLAMENET_BRIDGE_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/bridge-offer-v1.json"
|
||||
# Checked before the suite runs. A path that silently does not exist puts
|
||||
# the tests straight back where they were, and the failure would read as a
|
||||
# protocol bug rather than a missing file.
|
||||
@@ -94,7 +95,7 @@ unit-tests:
|
||||
"$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS" \
|
||||
"$FLAMENET_CONTENT_VECTORS" "$FLAMENET_POLL_VECTORS" \
|
||||
"$FLAMENET_PADDING_VECTORS" "$FLAMENET_SYNC_VECTORS" \
|
||||
"$FLAMENET_HANDLE_CERT_VECTORS" "$FLAMENET_LINK_VECTORS"; do
|
||||
"$FLAMENET_HANDLE_CERT_VECTORS" "$FLAMENET_LINK_VECTORS" "$FLAMENET_BRIDGE_VECTORS"; do
|
||||
[ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; }
|
||||
done
|
||||
# Clear old results before running, or the gate counts ghosts. The
|
||||
@@ -216,8 +217,10 @@ vectors-head:
|
||||
- export FLAMENET_SYNC_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/sync-merge-v1.json"
|
||||
- export FLAMENET_HANDLE_CERT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/handle-cert-v1.json"
|
||||
- export FLAMENET_LINK_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/links-v1.json"
|
||||
# Only the module that reads vectors. The rest of the suite has nothing
|
||||
- export FLAMENET_BRIDGE_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/bridge-offer-v1.json"
|
||||
# Only the modules that read vectors. The rest of the suite has nothing
|
||||
# to say about whether flamenet-e2e moved, and running it here would double
|
||||
# the board's cost to answer a question it cannot answer.
|
||||
- ./gradlew --no-daemon --rerun-tasks :engine:test
|
||||
# the board's cost to answer a question it cannot answer. `:obfs4` reads
|
||||
# the bridge-offer vectors (SPEC section 24).
|
||||
- ./gradlew --no-daemon --rerun-tasks :engine:test :obfs4:test
|
||||
timeout: 20m
|
||||
|
||||
@@ -272,7 +272,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
) {
|
||||
when (current) {
|
||||
is MessengerViewModel.Phase.Starting -> CircularProgressIndicator()
|
||||
is MessengerViewModel.Phase.SignedOut -> SignIn(model, current)
|
||||
is MessengerViewModel.Phase.SignedOut -> SignInScreen(model, current)
|
||||
is MessengerViewModel.Phase.VaultUnreadable -> VaultUnreadable(model, current)
|
||||
else -> Unit
|
||||
}
|
||||
@@ -383,7 +383,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.SignIn(
|
||||
internal fun ColumnScope.SignInScreen(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedOut,
|
||||
) {
|
||||
@@ -592,6 +592,15 @@ private fun ColumnScope.SignIn(
|
||||
}
|
||||
|
||||
if (!registering) ResetPassword(model, handle)
|
||||
|
||||
// Before any account exists, for a network that blocks Flamenet: without
|
||||
// a bridge here, the sign-up that needs one cannot happen (SPEC §24).
|
||||
var bridges by remember { mutableStateOf(false) }
|
||||
TextButton(
|
||||
onClick = { bridges = !bridges },
|
||||
modifier = Modifier.fillMaxWidth().testTag("signin-bridges"),
|
||||
) { Text(if (bridges) "Hide bridge settings" else "Connect through a bridge") }
|
||||
if (bridges) NetworkSection()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1053,7 +1053,7 @@ class MessengerViewModel(
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bridge lines contacts have offered, with a name against each (§6.19).
|
||||
* Bridge lines contacts have offered, with a name against each (§24.5).
|
||||
*/
|
||||
data class BridgeOfferView(
|
||||
val fromUser: Long,
|
||||
@@ -5029,7 +5029,28 @@ object FileLevels {
|
||||
?: "Level $classification"
|
||||
}
|
||||
|
||||
internal fun describeFailure(failure: Throwable): String =
|
||||
internal fun describeFailure(
|
||||
failure: Throwable,
|
||||
bridgesFailed: Boolean = io.flamenet.client.Obfs4Routing.everyLineFailed,
|
||||
): String =
|
||||
when {
|
||||
// §24.6: with bridges saved and none answering, the server was never
|
||||
// asked, and the app will not try it directly. Say that, not "check
|
||||
// the address".
|
||||
bridgesFailed &&
|
||||
(
|
||||
failure is RelayException.Transport ||
|
||||
failure is AccountsClient.Failure.Transport ||
|
||||
failure is java.io.IOException
|
||||
) -> {
|
||||
"No bridge could be reached. The app keeps trying them, and won't connect " +
|
||||
"without one while a bridge is saved."
|
||||
}
|
||||
|
||||
else -> describeFailureFromTheServer(failure)
|
||||
}
|
||||
|
||||
private fun describeFailureFromTheServer(failure: Throwable): String =
|
||||
when (failure) {
|
||||
is AccountsClient.Failure.BadCredentials -> {
|
||||
"That handle or passphrase isn't right."
|
||||
|
||||
@@ -7,9 +7,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -26,10 +28,17 @@ import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.client.Obfs4Routing
|
||||
import io.flamenet.obfs4.BridgeLine
|
||||
import io.flamenet.obfs4.BridgeLineResult
|
||||
import io.flamenet.obfs4.Obfs4Stats
|
||||
import io.flamenet.obfs4.Obfs4Bridge
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Reaching Flamenet on a network that blocks it.
|
||||
* Reaching Flamenet on a network that blocks it (SPEC §24).
|
||||
*
|
||||
* The same panel sits in Settings when signed in and behind "Connect through a
|
||||
* bridge" on the sign-in and sign-up screens, before any account exists. Offers
|
||||
* from contacts and sharing a line need an account, so they appear only when
|
||||
* [model] is signed in and [buddies] is not empty.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.NetworkSection(
|
||||
@@ -38,23 +47,28 @@ fun ColumnScope.NetworkSection(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val settings = remember { Obfs4Settings(context) }
|
||||
var draft by remember { mutableStateOf(settings.bridgeLine ?: "") }
|
||||
var stored by remember { mutableStateOf(settings.bridgeLine) }
|
||||
// Bumped on save/remove so the live status below re-reads.
|
||||
var draft by remember { mutableStateOf("") }
|
||||
var refusal by remember { mutableStateOf<String?>(null) }
|
||||
// Bumped on every change so the lists and the live status re-read.
|
||||
var tick by remember { mutableIntStateOf(0) }
|
||||
// Who has been handed the line this sitting.
|
||||
var sharedWith by remember { mutableStateOf(setOf<Long>()) }
|
||||
var confirmShare by remember { mutableStateOf<io.flamenet.client.MessengerApi.Buddy?>(null) }
|
||||
var scanning by remember { mutableStateOf(false) }
|
||||
val askCamera =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts
|
||||
.RequestPermission(),
|
||||
) { _ -> scanning = true }
|
||||
|
||||
@Suppress("UNUSED_EXPRESSION")
|
||||
tick
|
||||
|
||||
val lines = remember(tick) { settings.lines }
|
||||
val parsed = if (draft.isBlank()) null else BridgeLine.parse(draft)
|
||||
val isUnchanged = draft.trim() == (stored ?: "")
|
||||
|
||||
Text("Network", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
// Reads the handshake counter and the bridge in force, never the stored
|
||||
// line.
|
||||
// Reads what the transport did, never the stored list.
|
||||
Text(
|
||||
statusLine(),
|
||||
fontSize = 13.sp,
|
||||
@@ -81,36 +95,23 @@ fun ColumnScope.NetworkSection(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
when (val parsed = BridgeLine.parse(offer.line)) {
|
||||
is BridgeLineResult.Bad -> {
|
||||
Text(
|
||||
// An offer that cannot be used says why, rather than
|
||||
// failing when it is accepted.
|
||||
parsed.problem.message,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.Danger,
|
||||
)
|
||||
}
|
||||
|
||||
is BridgeLineResult.Ok -> {
|
||||
Text(
|
||||
"Reads as ${parsed.bridge.host} port ${parsed.bridge.port}",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reading(offer.line)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
KitPrimaryButton(
|
||||
text = "Use this bridge",
|
||||
onClick = {
|
||||
if (settings.accept(offer.line) is BridgeLineResult.Ok) {
|
||||
draft = offer.line
|
||||
stored = settings.bridgeLine
|
||||
model?.dismissBridgeOffer(offer.fromUser)
|
||||
tick++
|
||||
when (val result = settings.accept(offer.line, from = offer.fromName)) {
|
||||
is Obfs4Settings.AddResult.Added, Obfs4Settings.AddResult.AlreadySaved -> {
|
||||
model?.dismissBridgeOffer(offer.fromUser)
|
||||
refusal = null
|
||||
}
|
||||
|
||||
else -> {
|
||||
refusal = result.message
|
||||
}
|
||||
}
|
||||
tick++
|
||||
},
|
||||
enabled = parsedOk(offer.line),
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -130,11 +131,53 @@ fun ColumnScope.NetworkSection(
|
||||
}
|
||||
}
|
||||
|
||||
// The stored lines (§24.6).
|
||||
if (lines.isNotEmpty()) {
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text("Your bridges", fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
lines.forEachIndexed { index, stored ->
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
stored.line,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.testTag("obfs4-line-$index"),
|
||||
)
|
||||
Reading(stored.line)
|
||||
stored.offeredBy?.let {
|
||||
Text("From $it", fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
Text(
|
||||
stored.lastWorked?.let {
|
||||
"Last worked " + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(it * 1000))
|
||||
} ?: "Hasn't connected yet",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
settings.remove(stored.line)
|
||||
tick++
|
||||
},
|
||||
modifier = Modifier.testTag("obfs4-remove-$index"),
|
||||
) { Text("Remove", color = Palette.Danger) }
|
||||
}
|
||||
Text(
|
||||
"The app tries one bridge at a time, starting with the one that last worked. " +
|
||||
"A bridge that stops working stays here until you remove it.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.md))
|
||||
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
onValueChange = {
|
||||
draft = it
|
||||
refusal = null
|
||||
},
|
||||
placeholder = { Text("obfs4 198.51.100.7:4444 …", fontSize = 13.sp) },
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
minLines = 2,
|
||||
@@ -175,47 +218,56 @@ fun ColumnScope.NetworkSection(
|
||||
Unit
|
||||
}
|
||||
}
|
||||
refusal?.let {
|
||||
Text(it, fontSize = 12.sp, color = Palette.Danger, modifier = Modifier.testTag("obfs4-refusal"))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
KitPrimaryButton(
|
||||
text = "Save this bridge",
|
||||
onClick = {
|
||||
// Goes through `save`, which parses again and stores only what it
|
||||
// Goes through `add`, which parses again and stores only what it
|
||||
// could use.
|
||||
if (settings.save(draft) is BridgeLineResult.Ok) {
|
||||
stored = settings.bridgeLine
|
||||
tick++
|
||||
when (val result = settings.add(draft)) {
|
||||
is Obfs4Settings.AddResult.Added -> {
|
||||
draft = ""
|
||||
refusal = null
|
||||
}
|
||||
|
||||
else -> {
|
||||
refusal = result.message
|
||||
}
|
||||
}
|
||||
tick++
|
||||
},
|
||||
enabled = parsed is BridgeLineResult.Ok && !isUnchanged,
|
||||
enabled = parsed is BridgeLineResult.Ok,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "obfs4-save",
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitSecondaryButton(
|
||||
text = "Scan a code",
|
||||
onClick = {
|
||||
val has =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (has) scanning = true else askCamera.launch(android.Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "obfs4-scan",
|
||||
)
|
||||
|
||||
if (stored != null) {
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitSecondaryButton(
|
||||
text = "Stop using a bridge",
|
||||
onClick = {
|
||||
settings.clear()
|
||||
draft = ""
|
||||
stored = null
|
||||
tick++
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "obfs4-remove",
|
||||
)
|
||||
}
|
||||
|
||||
// The other half of receiving one.
|
||||
if (stored != null && buddies.isNotEmpty()) {
|
||||
// The other half of receiving one (§24.3).
|
||||
val share = remember(tick) { settings.bridgeLine }
|
||||
if (share != null && model != null && buddies.isNotEmpty()) {
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text("Share it with", fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
Text(
|
||||
// Says who learns what.
|
||||
"They get the line and decide for themselves. Whoever runs the bridge will " +
|
||||
"see one more person connecting, and your contact will know you use it.",
|
||||
WHAT_THEY_LEARN,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -231,10 +283,7 @@ fun ColumnScope.NetworkSection(
|
||||
)
|
||||
KitSecondaryButton(
|
||||
text = if (sharedWith.contains(buddy.userId)) "Sent" else "Send",
|
||||
onClick = {
|
||||
model?.shareBridge(buddy.userId)
|
||||
sharedWith = sharedWith + buddy.userId
|
||||
},
|
||||
onClick = { confirmShare = buddy },
|
||||
// Not disabled after sending.
|
||||
tag = "obfs4-share-${buddy.userId}",
|
||||
)
|
||||
@@ -254,16 +303,74 @@ fun ColumnScope.NetworkSection(
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
Text(EXPLANATION, fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
|
||||
confirmShare?.let { buddy ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmShare = null },
|
||||
title = { Text("Share your bridge?") },
|
||||
text = { Text(WHAT_THEY_LEARN) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
model?.shareBridge(buddy.userId)
|
||||
sharedWith = sharedWith + buddy.userId
|
||||
confirmShare = null
|
||||
},
|
||||
modifier = Modifier.testTag("obfs4-share-confirm"),
|
||||
) { Text("Send") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmShare = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
if (scanning) {
|
||||
ScanAddressDialog(
|
||||
onFound = { raw ->
|
||||
scanning = false
|
||||
draft = raw.trim()
|
||||
refusal = null
|
||||
},
|
||||
onDismiss = { scanning = false },
|
||||
hint = "Point at the code holding the bridge line.",
|
||||
denied = "Flamenet cannot use the camera. Allow it in Android settings, or paste the line instead.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What reading a line gave (§24.2): the problem, or where it points. */
|
||||
@Composable
|
||||
private fun Reading(line: String) {
|
||||
when (val parsed = BridgeLine.parse(line)) {
|
||||
is BridgeLineResult.Bad -> {
|
||||
// An offer that cannot be used says why, rather than failing when
|
||||
// it is accepted.
|
||||
Text(parsed.problem.message, fontSize = 12.sp, color = Palette.Danger)
|
||||
}
|
||||
|
||||
is BridgeLineResult.Ok -> {
|
||||
Text(
|
||||
"Reads as ${parsed.bridge.host} port ${parsed.bridge.port}",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
parsed.caveat?.let { Text(it, fontSize = 12.sp, color = Palette.SecondaryText) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsedOk(line: String) = BridgeLine.parse(line) is BridgeLineResult.Ok
|
||||
|
||||
/**
|
||||
* What is actually happening, as opposed to what is stored.
|
||||
* What is actually happening, as opposed to what is stored (§24.6).
|
||||
*
|
||||
* @param bridge the line that carried the last connection, else the first to try.
|
||||
* @param handshakes above zero when [bridge] carried the last connection.
|
||||
*/
|
||||
internal fun statusLine(
|
||||
bridge: io.flamenet.obfs4.Obfs4Bridge? = Obfs4Routing.bridge,
|
||||
handshakes: Int = Obfs4Stats.completedHandshakes(),
|
||||
bridge: Obfs4Bridge? = Obfs4Routing.lastCarried ?: Obfs4Routing.bridge,
|
||||
handshakes: Int = if (Obfs4Routing.lastCarried != null) 1 else 0,
|
||||
count: Int = Obfs4Routing.bridges.size,
|
||||
everyLineFailed: Boolean = Obfs4Routing.everyLineFailed,
|
||||
): String =
|
||||
when {
|
||||
bridge == null -> {
|
||||
@@ -277,13 +384,30 @@ internal fun statusLine(
|
||||
"always did."
|
||||
}
|
||||
|
||||
// Never a direct connection while a line is stored.
|
||||
everyLineFailed -> {
|
||||
"No bridge could be reached. The app tried each one in turn and keeps trying, " +
|
||||
"and it won't connect without a bridge while one is saved. Remove them all " +
|
||||
"to connect directly."
|
||||
}
|
||||
|
||||
// Not "protected".
|
||||
count > 1 -> {
|
||||
"$count bridges saved. Nothing has gone through them yet, the app will try them " +
|
||||
"the next time it goes online."
|
||||
}
|
||||
|
||||
else -> {
|
||||
"Bridge saved: ${bridge.host}:${bridge.port}. Nothing has gone through it yet, the " +
|
||||
"app will try the next time it goes online."
|
||||
}
|
||||
}
|
||||
|
||||
/** §24.3: before the first send, say what the contact learns. */
|
||||
private const val WHAT_THEY_LEARN =
|
||||
"They get the line and decide for themselves. Whoever runs the bridge will " +
|
||||
"see one more person connecting, and your contact will know you use it."
|
||||
|
||||
/**
|
||||
* Says what it does not protect, in the same breath as what it does.
|
||||
*/
|
||||
|
||||
@@ -3,72 +3,190 @@ package io.flamenet.messenger.app
|
||||
import android.content.Context
|
||||
import io.flamenet.client.Obfs4Routing
|
||||
import io.flamenet.obfs4.BridgeLine
|
||||
import io.flamenet.obfs4.BridgeLineProblem
|
||||
import io.flamenet.obfs4.BridgeLineResult
|
||||
import io.flamenet.obfs4.Obfs4Bridge
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The bridge line this phone uses, if any.
|
||||
* The bridge lines this phone uses, SPEC §24.6: up to eight, in the order they
|
||||
* were added. A line the person pasted and one accepted from a contact are
|
||||
* stored alike; who offered it is kept only to show beside it.
|
||||
*
|
||||
* In the app's private preferences, which `dataExtractionRules` keeps out of
|
||||
* every backup and device transfer (§24.5).
|
||||
*/
|
||||
class Obfs4Settings(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* The stored line, exactly as it was pasted.
|
||||
*/
|
||||
var bridgeLine: String?
|
||||
get() = prefs.getString(BRIDGE_LINE, null)
|
||||
private set(value) {
|
||||
prefs
|
||||
.edit()
|
||||
.apply {
|
||||
if (value.isNullOrBlank()) remove(BRIDGE_LINE) else putString(BRIDGE_LINE, value)
|
||||
}.apply()
|
||||
/** One stored line. */
|
||||
data class StoredLine(
|
||||
val line: String,
|
||||
val addedAt: Long,
|
||||
val offeredBy: String?,
|
||||
val lastWorked: Long?,
|
||||
)
|
||||
|
||||
/** What adding a line came to. */
|
||||
sealed interface AddResult {
|
||||
val message: String
|
||||
|
||||
data class Added(
|
||||
val bridge: Obfs4Bridge,
|
||||
val caveat: String?,
|
||||
) : AddResult {
|
||||
override val message = ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a line and starts using it, or reports why it cannot be used.
|
||||
*/
|
||||
fun save(line: String): BridgeLineResult {
|
||||
data class Unreadable(
|
||||
val problem: BridgeLineProblem,
|
||||
) : AddResult {
|
||||
override val message get() = problem.message
|
||||
}
|
||||
|
||||
object TooMany : AddResult {
|
||||
override val message = "You can keep up to 8 bridges. Remove one before adding another."
|
||||
}
|
||||
|
||||
object AlreadySaved : AddResult {
|
||||
override val message = "This bridge is already in your list."
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored lines, in the order they were added. */
|
||||
val lines: List<StoredLine>
|
||||
get() {
|
||||
prefs.getString(LINES, null)?.let { return decode(it) }
|
||||
// A one-line build stored its line on its own. Take it, and remove
|
||||
// the old copy.
|
||||
val old = prefs.getString(BRIDGE_LINE, null)?.trim()
|
||||
prefs.edit().remove(BRIDGE_LINE).apply()
|
||||
if (old.isNullOrEmpty()) return emptyList()
|
||||
val migrated = listOf(StoredLine(old, 0, null, null))
|
||||
write(migrated)
|
||||
return migrated
|
||||
}
|
||||
|
||||
/** The line to hand a contact: the one in use now, else the first. */
|
||||
val bridgeLine: String?
|
||||
get() {
|
||||
val all = lines
|
||||
val live = Obfs4Routing.order().firstOrNull()
|
||||
return all.firstOrNull { BridgeLine.parse(it.line).let { r -> r is BridgeLineResult.Ok && r.bridge == live } }?.line
|
||||
?: all.firstOrNull()?.line
|
||||
}
|
||||
|
||||
private fun decode(text: String): List<StoredLine> =
|
||||
runCatching {
|
||||
val array = JSONArray(text)
|
||||
(0 until array.length()).mapNotNull { i ->
|
||||
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
||||
val line = o.optString("line").takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
StoredLine(
|
||||
line = line,
|
||||
addedAt = o.optLong("addedAt", 0),
|
||||
offeredBy = o.optString("offeredBy").takeIf { o.has("offeredBy") && it.isNotEmpty() },
|
||||
lastWorked = if (o.has("lastWorked")) o.optLong("lastWorked") else null,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun write(all: List<StoredLine>) {
|
||||
if (all.isEmpty()) {
|
||||
prefs.edit().remove(LINES).apply()
|
||||
return
|
||||
}
|
||||
val array = JSONArray()
|
||||
for (l in all) {
|
||||
array.put(
|
||||
JSONObject()
|
||||
.put("line", l.line)
|
||||
.put("addedAt", l.addedAt)
|
||||
.apply {
|
||||
l.offeredBy?.let { put("offeredBy", it) }
|
||||
l.lastWorked?.let { put("lastWorked", it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
prefs.edit().putString(LINES, array.toString()).apply()
|
||||
}
|
||||
|
||||
/** Puts the stored list into force, the line that last worked first. */
|
||||
private fun route(all: List<StoredLine>) {
|
||||
val parsed = all.mapNotNull { l -> (BridgeLine.parse(l.line) as? BridgeLineResult.Ok)?.let { l to it.bridge } }
|
||||
val latest = parsed.filter { it.first.lastWorked != null }.maxByOrNull { it.first.lastWorked!! }?.second
|
||||
Obfs4Routing.onWorked = { bridge -> markWorked(bridge) }
|
||||
Obfs4Routing.use(parsed.map { it.second }, lastWorked = latest)
|
||||
}
|
||||
|
||||
/** Adds a line at the end of the list and starts using it. */
|
||||
fun add(
|
||||
line: String,
|
||||
offeredBy: String? = null,
|
||||
): AddResult {
|
||||
val trimmed = line.trim()
|
||||
val result = BridgeLine.parse(trimmed)
|
||||
if (result is BridgeLineResult.Ok) {
|
||||
bridgeLine = trimmed
|
||||
Obfs4Routing.use(result.bridge)
|
||||
}
|
||||
return result
|
||||
val bridge =
|
||||
when (val result = BridgeLine.parse(trimmed)) {
|
||||
is BridgeLineResult.Bad -> return AddResult.Unreadable(result.problem)
|
||||
is BridgeLineResult.Ok -> result
|
||||
}
|
||||
val all = lines
|
||||
val same =
|
||||
all.any { stored ->
|
||||
(BridgeLine.parse(stored.line) as? BridgeLineResult.Ok)?.bridge?.let {
|
||||
it.host.equals(bridge.bridge.host, ignoreCase = true) && it.port == bridge.bridge.port
|
||||
} ?: false
|
||||
}
|
||||
if (same) return AddResult.AlreadySaved
|
||||
if (all.size >= MAX_LINES) return AddResult.TooMany
|
||||
val next = all + StoredLine(trimmed, System.currentTimeMillis() / 1000, offeredBy, null)
|
||||
write(next)
|
||||
route(next)
|
||||
return AddResult.Added(bridge.bridge, bridge.caveat)
|
||||
}
|
||||
|
||||
/** Stop using a bridge, and drop the connections that were using one. */
|
||||
/**
|
||||
* Removes a line. Connections it carried close at once (§24.7). Removing
|
||||
* the last turns bridges off.
|
||||
*/
|
||||
fun remove(line: String) {
|
||||
val next = lines.filter { it.line != line }
|
||||
write(next)
|
||||
route(next)
|
||||
}
|
||||
|
||||
/** Stop using bridges altogether. */
|
||||
fun clear() {
|
||||
bridgeLine = null
|
||||
Obfs4Routing.use(null)
|
||||
write(emptyList())
|
||||
prefs.edit().remove(BRIDGE_LINE).apply()
|
||||
Obfs4Routing.use(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the stored line into force.
|
||||
*/
|
||||
fun applyAtStartup() {
|
||||
val line = bridgeLine ?: return
|
||||
when (val result = BridgeLine.parse(line)) {
|
||||
is BridgeLineResult.Ok -> {
|
||||
Obfs4Routing.use(result.bridge)
|
||||
}
|
||||
|
||||
is BridgeLineResult.Bad -> {
|
||||
bridgeLine = null
|
||||
Obfs4Routing.use(null)
|
||||
}
|
||||
}
|
||||
/** Records that a line carried a connection that reached the far end. */
|
||||
fun markWorked(bridge: Obfs4Bridge) {
|
||||
val all = lines
|
||||
val i =
|
||||
all.indexOfFirst { (BridgeLine.parse(it.line) as? BridgeLineResult.Ok)?.bridge == bridge }
|
||||
if (i < 0) return
|
||||
write(all.toMutableList().also { it[i] = it[i].copy(lastWorked = System.currentTimeMillis() / 1000) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a bridge a contact offered.
|
||||
*/
|
||||
fun accept(offeredLine: String): BridgeLineResult = save(offeredLine)
|
||||
/** Puts the stored lines into force. */
|
||||
fun applyAtStartup() = route(lines)
|
||||
|
||||
/** Accept a bridge a contact offered: it joins the list. */
|
||||
fun accept(
|
||||
offeredLine: String,
|
||||
from: String,
|
||||
): AddResult = add(offeredLine, offeredBy = from)
|
||||
|
||||
private companion object {
|
||||
const val PREFS = "flamenet.obfs4"
|
||||
const val BRIDGE_LINE = "bridge-line"
|
||||
const val LINES = "bridge-lines"
|
||||
const val MAX_LINES = 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ internal fun parseScannedAddress(text: String): ScannedAddress? {
|
||||
fun ScanAddressDialog(
|
||||
onFound: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
hint: String = "Point at the code on their Address screen.",
|
||||
denied: String =
|
||||
"Flamenet cannot use the camera. Allow it in Android " +
|
||||
"settings, or paste their address into Add contact instead.",
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val granted =
|
||||
@@ -77,8 +81,7 @@ fun ScanAddressDialog(
|
||||
// Says what to do, not just that it failed.
|
||||
Column(modifier = Modifier.align(Alignment.Center).padding(24.dp)) {
|
||||
Text(
|
||||
"Flamenet cannot use the camera. Allow it in Android " +
|
||||
"settings, or paste their address into Add contact instead.",
|
||||
denied,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.testTag("scan-denied"),
|
||||
@@ -95,7 +98,7 @@ fun ScanAddressDialog(
|
||||
onRelease = { view -> (view.tag as? QrScan)?.stop() },
|
||||
)
|
||||
Text(
|
||||
"Point at the code on their Address screen.",
|
||||
hint,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
modifier =
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import io.flamenet.client.Obfs4Routing
|
||||
import org.junit.After
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* The bridge screen is reachable from the sign-in and sign-up screens, before
|
||||
* any account exists: somebody whose network blocks Flamenet cannot sign up
|
||||
* without one (SPEC §24, the design note's Milestone 2).
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav")
|
||||
class ABridgeCanBeSetBeforeSigningInTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val app get() = ApplicationProvider.getApplicationContext<Application>()
|
||||
|
||||
private val line =
|
||||
"obfs4 198.51.100.7:8443 FBEFBEFFFFFF000102030405060708090A0B0C0D " +
|
||||
"cert=++++////AAECAwQFBgcICQoLDA2FIPAJiTCnVHSLfdy0PvdaDb86DSY4GvTrpKmOqptOag iat-mode=0"
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Obfs4Settings(app).clear()
|
||||
Obfs4Routing.use(null)
|
||||
}
|
||||
|
||||
private fun render() {
|
||||
app.getSharedPreferences("flamenet.obfs4", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
val model = MessengerViewModel(app)
|
||||
compose.setContent {
|
||||
MessengerTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
// Scrollable, as MainActivity draws it.
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
SignInScreen(model, MessengerViewModel.Phase.SignedOut())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sign-in screen opens the bridge screen, and a pasted line is routed`() {
|
||||
render()
|
||||
compose.onNodeWithTag("signin-bridges").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("obfs4-bridge-line").performTextInput(line)
|
||||
compose.onNodeWithTag("obfs4-save").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("obfs4-line-0").assertExists()
|
||||
compose.onNodeWithTag("obfs4-status").assertExists()
|
||||
assertEquals("198.51.100.7", Obfs4Routing.order().single().host)
|
||||
|
||||
compose.onNodeWithTag("obfs4-remove-0").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("obfs4-line-0").assertDoesNotExist()
|
||||
assertEquals(0, Obfs4Routing.bridges.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sign-up screen has it too`() {
|
||||
render()
|
||||
compose.onNodeWithTag("toggle-register").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("signin-bridges").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("obfs4-bridge-line").assertExists()
|
||||
compose.onNodeWithTag("obfs4-scan").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import io.flamenet.client.Obfs4Routing
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Up to eight stored lines, SPEC §24.6 and §24.7.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class SeveralBridgeLinesTest {
|
||||
private val app get() = ApplicationProvider.getApplicationContext<Application>()
|
||||
private lateinit var settings: Obfs4Settings
|
||||
|
||||
private fun line(address: String): String {
|
||||
val bytes = ByteArray(52) { 0xAB.toByte() }
|
||||
bytes[0] = address.hashCode().toByte()
|
||||
bytes[1] = (address.hashCode() shr 8).toByte()
|
||||
val cert = java.util.Base64.getEncoder().encodeToString(bytes).trimEnd('=')
|
||||
return "obfs4 $address 0123456789ABCDEF0123456789ABCDEF01234567 cert=$cert iat-mode=0"
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app.getSharedPreferences("flamenet.obfs4", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
settings = Obfs4Settings(app)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
settings.clear()
|
||||
Obfs4Routing.use(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `up to eight lines are kept in the order they were added, and all are routed`() {
|
||||
val all = (1..8).map { line("198.51.100.$it:443") }
|
||||
for (each in all) assertIs<Obfs4Settings.AddResult.Added>(settings.add(each))
|
||||
assertEquals(all, settings.lines.map { it.line })
|
||||
assertEquals(8, Obfs4Routing.bridges.size)
|
||||
assertEquals("198.51.100.1", Obfs4Routing.order().first().host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ninth line is refused`() {
|
||||
for (n in 1..8) settings.add(line("198.51.100.$n:443"))
|
||||
assertEquals(Obfs4Settings.AddResult.TooMany, settings.add(line("198.51.100.9:443")))
|
||||
assertEquals(8, settings.lines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same bridge twice is refused`() {
|
||||
settings.add(line("198.51.100.7:443"))
|
||||
assertEquals(Obfs4Settings.AddResult.AlreadySaved, settings.add(line("198.51.100.7:443")))
|
||||
assertEquals(1, settings.lines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable line is refused with its problem`() {
|
||||
val result = settings.add("obfs4 198.51.100.7:443")
|
||||
assertIs<Obfs4Settings.AddResult.Unreadable>(result)
|
||||
assertTrue(settings.lines.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing one line leaves the rest, and removing the last turns bridges off`() {
|
||||
val a = line("198.51.100.1:443")
|
||||
val b = line("198.51.100.2:443")
|
||||
settings.add(a)
|
||||
settings.add(b)
|
||||
settings.remove(a)
|
||||
assertEquals(listOf(b), settings.lines.map { it.line })
|
||||
assertEquals(1, Obfs4Routing.bridges.size)
|
||||
settings.remove(b)
|
||||
assertTrue(Obfs4Routing.bridges.isEmpty(), "removing the last line left a bridge in force")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an accepted offer joins the list and remembers who gave it`() {
|
||||
val pasted = line("198.51.100.1:443")
|
||||
val offered = line("203.0.113.9:443")
|
||||
settings.add(pasted)
|
||||
assertIs<Obfs4Settings.AddResult.Added>(settings.accept(offered, from = "Alice"))
|
||||
assertEquals(listOf(pasted, offered), settings.lines.map { it.line })
|
||||
assertEquals("Alice", settings.lines.last().offeredBy)
|
||||
assertNull(settings.lines.first().offeredBy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the line that last worked is remembered, tried first after a restart, and never deleted`() {
|
||||
val a = line("198.51.100.1:443")
|
||||
val b = line("198.51.100.2:443")
|
||||
settings.add(a)
|
||||
settings.add(b)
|
||||
val routedB = Obfs4Routing.bridges[1]
|
||||
Obfs4Routing.onWorked?.invoke(routedB)
|
||||
assertTrue((settings.lines[1].lastWorked ?: 0) > 0, "when it last worked was not kept")
|
||||
|
||||
Obfs4Routing.use(null)
|
||||
Obfs4Settings(app).applyAtStartup()
|
||||
assertEquals("198.51.100.2", Obfs4Routing.order().first().host)
|
||||
assertEquals(2, settings.lines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the one line an older build stored becomes the list`() {
|
||||
val old = line("198.51.100.7:443")
|
||||
app.getSharedPreferences("flamenet.obfs4", Context.MODE_PRIVATE).edit().putString("bridge-line", old).commit()
|
||||
val fresh = Obfs4Settings(app)
|
||||
assertEquals(listOf(old), fresh.lines.map { it.line })
|
||||
assertFalse(
|
||||
app.getSharedPreferences("flamenet.obfs4", Context.MODE_PRIVATE).contains("bridge-line"),
|
||||
"the old copy was left behind",
|
||||
)
|
||||
}
|
||||
|
||||
/** §24.6: the failure says no bridge answered, not "check the address". */
|
||||
@Test
|
||||
fun `a request that no bridge carried says so`() {
|
||||
val text = describeFailure(java.net.ConnectException("refused"), bridgesFailed = true)
|
||||
assertTrue(text.startsWith("No bridge could be reached"), text)
|
||||
assertFalse(describeFailure(java.net.ConnectException("refused"), bridgesFailed = false).contains("bridge"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the refusals are written for people`() {
|
||||
for (r in listOf(Obfs4Settings.AddResult.TooMany, Obfs4Settings.AddResult.AlreadySaved)) {
|
||||
val text = r.message.lowercase()
|
||||
assertTrue(text.isNotEmpty())
|
||||
for (word in listOf("obfs4", "base64", "node id")) assertFalse(text.contains(word))
|
||||
assertFalse(r.message.contains('\u2014'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ dependencies {
|
||||
// bodies rather than a hand-written fake that agrees with the client about
|
||||
// everything including its mistakes.
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||
// Certificates for a TLS upstream behind a test bridge.
|
||||
testImplementation("com.squareup.okhttp3:okhttp-tls:4.12.0")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
@@ -1806,13 +1806,17 @@ class Conversations(
|
||||
fun offTheRecord(toUserId: Long): Boolean = vault.offTheRecord(Vault.threadForUser(toUserId))
|
||||
|
||||
/**
|
||||
* Offer a contact the bridge this phone uses (§6.19).
|
||||
* Offer a contact the bridge this phone uses (§24.3), through their
|
||||
* pairwise sessions and never a group. False, and nothing sent, for a line
|
||||
* no receiver would keep.
|
||||
*/
|
||||
fun shareBridge(
|
||||
toUserId: Long,
|
||||
line: String,
|
||||
) {
|
||||
sendPlaintext(toUserId, Content.bridgeOffer(line))
|
||||
): Boolean {
|
||||
val envelope = Content.bridgeOffer(line) ?: return false
|
||||
sendPlaintext(toUserId, envelope)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Bridge lines contacts have offered, newest first. Never applied here. */
|
||||
@@ -3189,12 +3193,14 @@ class Conversations(
|
||||
trust.gossip(raw.optInt("size"), raw.optString("root"))
|
||||
}
|
||||
|
||||
// The other side set a timer (§6.11).
|
||||
// §24.5, a contact's bridge offer: kept for the person to look at,
|
||||
// never applied. `bridgeLine` is null for one §24.5 drops.
|
||||
Content.Kind.BRIDGE -> {
|
||||
val line = Content.bridgeLine(content) ?: return
|
||||
vault.saveBridgeOffer(fromUser, line, at = System.currentTimeMillis() / 1000)
|
||||
}
|
||||
|
||||
// The other side set a timer (§6.11).
|
||||
Content.Kind.DISAPPEAR -> {
|
||||
val seconds = Content.disappearSeconds(content) ?: return
|
||||
vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds)
|
||||
|
||||
@@ -1,30 +1,199 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.obfs4.Obfs4Bridge
|
||||
import okhttp3.Call
|
||||
import okhttp3.Connection
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Dns
|
||||
import okhttp3.EventListener
|
||||
import okhttp3.Protocol
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Which bridge, if any, the app's HTTP clients route through.
|
||||
* Which bridges, if any, the app's HTTP clients route through (SPEC §24.6).
|
||||
*
|
||||
* Up to eight lines, tried one at a time: the one that last completed a
|
||||
* connection first, then the rest in stored order. The trying is OkHttp's own
|
||||
* route loop. [dns] answers every name with one placeholder address per line,
|
||||
* in that order, and [bridgeFor] maps the placeholder back to its line. So a
|
||||
* refused connection, a handshake that never completes, or TLS to the relay
|
||||
* failing inside the tunnel moves OkHttp on to the next line, exactly as it
|
||||
* would move to the next address of a host.
|
||||
*
|
||||
* Two things follow from the placeholder. No name is looked up on the local
|
||||
* network while bridges are on, which would tell the network which service
|
||||
* this is and fail outright where the name is blocked. And TLS inside the
|
||||
* tunnel still names the real host, `accounts.flamenet.io` or the relay,
|
||||
* because OkHttp takes that from the URL, so the edge behind the bridge routes
|
||||
* each request to the service it was for.
|
||||
*
|
||||
* With no line stored the client resolves and connects directly, which is the
|
||||
* person's choice and the only case that does.
|
||||
*/
|
||||
object Obfs4Routing {
|
||||
@Volatile
|
||||
private var current: Obfs4Bridge? = null
|
||||
private var current: List<Obfs4Bridge> = emptyList()
|
||||
|
||||
@Volatile
|
||||
private var worked: Obfs4Bridge? = null
|
||||
|
||||
/**
|
||||
* Where the next request starts after one failed on an address OkHttp did
|
||||
* not resolve. A URL with an IP literal skips [dns], so it gets one route,
|
||||
* and failing over then happens across requests instead of within one.
|
||||
*/
|
||||
@Volatile
|
||||
private var startAt: Obfs4Bridge? = null
|
||||
|
||||
/**
|
||||
* One pool for every default client.
|
||||
*/
|
||||
internal val pool = ConnectionPool(8, 2, TimeUnit.MINUTES)
|
||||
|
||||
/** The bridge in force right now, consulted per socket. */
|
||||
val bridge: Obfs4Bridge? get() = current
|
||||
/** The stored lines, in stored order. */
|
||||
val bridges: List<Obfs4Bridge> get() = current
|
||||
|
||||
/** The first line to try, or null when bridges are off. */
|
||||
val bridge: Obfs4Bridge? get() = order().firstOrNull()
|
||||
|
||||
/** The line that carried the most recent connection: the live state. */
|
||||
@Volatile
|
||||
var lastCarried: Obfs4Bridge? = null
|
||||
private set
|
||||
|
||||
/** The last request tried every line and none answered. */
|
||||
@Volatile
|
||||
var everyLineFailed: Boolean = false
|
||||
private set
|
||||
|
||||
/** Told when a line carries a connection that reached the far end. */
|
||||
@Volatile
|
||||
var onWorked: ((Obfs4Bridge) -> Unit)? = null
|
||||
|
||||
/** One line, or none. */
|
||||
fun use(bridge: Obfs4Bridge?) = use(listOfNotNull(bridge))
|
||||
|
||||
/**
|
||||
* Route through [bridge] from now on, or directly when it is null.
|
||||
* Route through [bridges] from now on, or directly when the list is empty.
|
||||
* A change of list empties the pool: a pooled connection outlives a
|
||||
* settings change, and one opened through a removed line must not go on
|
||||
* carrying traffic (§24.7).
|
||||
*/
|
||||
fun use(bridge: Obfs4Bridge?) {
|
||||
if (bridge == current) return
|
||||
current = bridge
|
||||
fun use(
|
||||
bridges: List<Obfs4Bridge>,
|
||||
lastWorked: Obfs4Bridge? = worked,
|
||||
) {
|
||||
worked = lastWorked?.takeIf { it in bridges }
|
||||
if (bridges == current) return
|
||||
startAt = null
|
||||
current = bridges
|
||||
lastCarried = null
|
||||
everyLineFailed = false
|
||||
pool.evictAll()
|
||||
}
|
||||
|
||||
/** The order of attempts. */
|
||||
fun order(): List<Obfs4Bridge> {
|
||||
val all = current
|
||||
val first = (startAt ?: worked)?.takeIf { it in all } ?: return all
|
||||
return listOf(first) + all.filter { it != first }
|
||||
}
|
||||
|
||||
/** Placeholder addresses live in 0.70.78.0/24, which is never routed. */
|
||||
private fun placeholder(
|
||||
host: String,
|
||||
index: Int,
|
||||
): InetAddress = InetAddress.getByAddress(host, byteArrayOf(0, 70, 78, (index + 1).toByte()))
|
||||
|
||||
private fun indexOf(address: InetAddress?): Int? {
|
||||
val b = address?.address ?: return null
|
||||
if (b.size != 4 || b[0] != 0.toByte() || b[1] != 70.toByte() || b[2] != 78.toByte()) return null
|
||||
return (b[3].toInt() and 0xff) - 1
|
||||
}
|
||||
|
||||
/** Never looks a name up while a line is stored. */
|
||||
val dns: Dns =
|
||||
object : Dns {
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
val all = current
|
||||
if (all.isEmpty()) return Dns.SYSTEM.lookup(hostname)
|
||||
return order().map { placeholder(hostname, all.indexOf(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The line for an endpoint: the one its placeholder names, else the first
|
||||
* in order. Null only when bridges are off.
|
||||
*/
|
||||
fun bridgeFor(endpoint: InetSocketAddress?): Obfs4Bridge? {
|
||||
val all = current
|
||||
if (all.isEmpty()) return null
|
||||
val index = indexOf(endpoint?.address)
|
||||
return index?.let { all.getOrNull(it) } ?: order().first()
|
||||
}
|
||||
|
||||
private fun worked(bridge: Obfs4Bridge) {
|
||||
lastCarried = bridge
|
||||
everyLineFailed = false
|
||||
startAt = null
|
||||
worked = bridge
|
||||
onWorked?.invoke(bridge)
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches each call: a connection that completed, TLS included, marks its
|
||||
* line as working; a call that failed with no connection, while lines are
|
||||
* stored, means every line it tried failed.
|
||||
*/
|
||||
val events: EventListener.Factory =
|
||||
EventListener.Factory {
|
||||
object : EventListener() {
|
||||
private var connected = false
|
||||
|
||||
override fun connectEnd(
|
||||
call: Call,
|
||||
inetSocketAddress: InetSocketAddress,
|
||||
proxy: Proxy,
|
||||
protocol: Protocol?,
|
||||
) {
|
||||
if (current.isEmpty()) return
|
||||
connected = true
|
||||
bridgeFor(inetSocketAddress)?.let(::worked)
|
||||
}
|
||||
|
||||
override fun connectFailed(
|
||||
call: Call,
|
||||
inetSocketAddress: InetSocketAddress,
|
||||
proxy: Proxy,
|
||||
protocol: Protocol?,
|
||||
ioe: IOException,
|
||||
) {
|
||||
if (current.isEmpty() || indexOf(inetSocketAddress.address) != null) return
|
||||
val order = order()
|
||||
val failed = bridgeFor(inetSocketAddress) ?: return
|
||||
startAt = order[(order.indexOf(failed) + 1) % order.size]
|
||||
}
|
||||
|
||||
override fun connectionAcquired(
|
||||
call: Call,
|
||||
connection: Connection,
|
||||
) {
|
||||
connected = true
|
||||
}
|
||||
|
||||
override fun callFailed(
|
||||
call: Call,
|
||||
ioe: IOException,
|
||||
) {
|
||||
if (current.isNotEmpty() && !connected) {
|
||||
lastCarried = null
|
||||
everyLineFailed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,12 @@ class RelayClient(
|
||||
@JvmStatic
|
||||
var CLIENT_VERSION: String = "0"
|
||||
|
||||
/** A client pinned to one bridge looks nothing up either. */
|
||||
private val PINNED_BRIDGE_DNS =
|
||||
object : okhttp3.Dns {
|
||||
override fun lookup(hostname: String) = listOf(java.net.InetAddress.getByAddress(hostname, byteArrayOf(0, 70, 78, 1)))
|
||||
}
|
||||
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private val OCTETS = "application/octet-stream".toMediaType()
|
||||
|
||||
@@ -129,10 +135,21 @@ class RelayClient(
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.callTimeout(25, TimeUnit.SECONDS)
|
||||
// §24.6: a line gets 15 seconds to complete a handshake.
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
// No response cache, deliberately.
|
||||
.cache(null)
|
||||
// Installed unconditionally.
|
||||
.socketFactory(Obfs4SocketFactory { bridge ?: Obfs4Routing.bridge })
|
||||
// Installed unconditionally. With lines stored, every socket
|
||||
// goes to a bridge and no name is looked up locally; with none,
|
||||
// both are the platform's own (see `Obfs4Routing`).
|
||||
.socketFactory(
|
||||
if (bridge != null) {
|
||||
Obfs4SocketFactory(bridge)
|
||||
} else {
|
||||
Obfs4SocketFactory({ Obfs4Routing.bridges.isNotEmpty() }, Obfs4Routing::bridgeFor)
|
||||
},
|
||||
).dns(if (bridge != null) PINNED_BRIDGE_DNS else Obfs4Routing.dns)
|
||||
.eventListenerFactory(Obfs4Routing.events)
|
||||
// Connections must not outlive a transport change, and an obfs4
|
||||
// handshake makes each one expensive to establish, so the pool
|
||||
// is shared, `Obfs4Routing.use` empties it, and its idle window
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.obfs4.Obfs4Bridge
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.tls.HandshakeCertificates
|
||||
import okhttp3.tls.HeldCertificate
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Several stored lines, tried one at a time, and never a direct connection
|
||||
* while any is stored (SPEC §24.6).
|
||||
*
|
||||
* Every URL names a host that resolves nowhere (`*.flamenet.test`), so a
|
||||
* request that looked the name up, or went anywhere but the bridge, fails.
|
||||
* Every success also counts handshakes at the bridge that carried it.
|
||||
*/
|
||||
class BridgeFailoverTest {
|
||||
private val server = MockWebServer()
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
Obfs4Routing.use(null)
|
||||
Obfs4Routing.onWorked = null
|
||||
runCatching { server.shutdown() }
|
||||
}
|
||||
|
||||
private fun client(connectSeconds: Long = 15) =
|
||||
RelayClient
|
||||
.defaultHttpClient()
|
||||
.newBuilder()
|
||||
.connectTimeout(connectSeconds, TimeUnit.SECONDS)
|
||||
.callTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun get(
|
||||
url: String,
|
||||
connectSeconds: Long = 15,
|
||||
): String =
|
||||
client(connectSeconds).newCall(Request.Builder().url(url).build()).execute().use {
|
||||
it.body?.string().orEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused first line fails over to the second`() {
|
||||
server.enqueue(MockResponse().setBody("second line"))
|
||||
server.start()
|
||||
TestBridge(server.port).use { good ->
|
||||
val worked = mutableListOf<Obfs4Bridge>()
|
||||
Obfs4Routing.onWorked = { worked += it }
|
||||
Obfs4Routing.use(listOf(TestBridge.refusing(), good.bridge))
|
||||
assertEquals("second line", get("http://relay.flamenet.test:${server.port}/probe"))
|
||||
assertEquals(1, good.completed.get())
|
||||
assertEquals(listOf(good.bridge), worked)
|
||||
assertEquals(good.bridge, Obfs4Routing.lastCarried)
|
||||
assertEquals(good.bridge, Obfs4Routing.order().first(), "the line that worked is not tried first next")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a silent first line fails over after the time limit`() {
|
||||
server.enqueue(MockResponse().setBody("after the silence"))
|
||||
server.start()
|
||||
SilentListener().use { silent ->
|
||||
TestBridge(server.port).use { good ->
|
||||
Obfs4Routing.use(listOf(silent.bridge, good.bridge))
|
||||
val started = System.nanoTime()
|
||||
assertEquals("after the silence", get("http://relay.flamenet.test:${server.port}/", connectSeconds = 1))
|
||||
assertEquals(1, good.completed.get())
|
||||
assertTrue(System.nanoTime() - started < TimeUnit.SECONDS.toNanos(10))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A handshake alone is not success. This bridge completes one and reaches
|
||||
* nothing; TLS to the relay's name inside the tunnel fails, and the next
|
||||
* line carries the request.
|
||||
*/
|
||||
@Test
|
||||
fun `a bridge that cannot reach the relay fails over at the TLS check`() {
|
||||
val held = HeldCertificate.Builder().addSubjectAlternativeName("relay.flamenet.test").build()
|
||||
val serverCerts = HandshakeCertificates.Builder().heldCertificate(held).build()
|
||||
val clientCerts = HandshakeCertificates.Builder().addTrustedCertificate(held.certificate).build()
|
||||
server.useHttps(serverCerts.sslSocketFactory(), false)
|
||||
server.enqueue(MockResponse().setBody("over tls"))
|
||||
server.start()
|
||||
TestBridge(TestBridge.closedPort()).use { cutOff ->
|
||||
TestBridge(server.port).use { good ->
|
||||
val worked = mutableListOf<Obfs4Bridge>()
|
||||
Obfs4Routing.onWorked = { worked += it }
|
||||
Obfs4Routing.use(listOf(cutOff.bridge, good.bridge))
|
||||
val tls =
|
||||
client()
|
||||
.newBuilder()
|
||||
.sslSocketFactory(clientCerts.sslSocketFactory(), clientCerts.trustManager)
|
||||
// HTTP/1.1, so the name arrives as a Host header to read.
|
||||
.protocols(listOf(okhttp3.Protocol.HTTP_1_1))
|
||||
.build()
|
||||
val body =
|
||||
tls.newCall(Request.Builder().url("https://relay.flamenet.test:${server.port}/").build()).execute().use {
|
||||
it.body?.string()
|
||||
}
|
||||
assertEquals("over tls", body)
|
||||
assertEquals(1, cutOff.completed.get(), "the first line was never tried")
|
||||
assertEquals(1, good.completed.get())
|
||||
assertEquals(listOf(good.bridge), worked, "a bridge that reached nothing counted as working")
|
||||
// The name inside the tunnel was the relay's own.
|
||||
assertEquals("relay.flamenet.test:${server.port}", server.takeRequest().getHeader("Host"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when every line fails nothing goes direct`() {
|
||||
server.enqueue(MockResponse().setBody("direct"))
|
||||
server.start()
|
||||
Obfs4Routing.use(listOf(TestBridge.refusing(), TestBridge.refusing()))
|
||||
// The URL is the upstream's real address: going direct would work.
|
||||
assertFailsWith<IOException> { get("http://127.0.0.1:${server.port}/") }
|
||||
assertEquals(0, server.requestCount, "traffic went direct")
|
||||
assertTrue(Obfs4Routing.everyLineFailed)
|
||||
}
|
||||
|
||||
/**
|
||||
* The account service is reached through the bridge under its own name:
|
||||
* not looked up on the local network, and not rewritten to the relay.
|
||||
*/
|
||||
@Test
|
||||
fun `the account service keeps its own name through the bridge`() {
|
||||
server.enqueue(MockResponse().setBody("{}"))
|
||||
server.start()
|
||||
TestBridge(server.port).use { good ->
|
||||
Obfs4Routing.use(listOf(good.bridge))
|
||||
get("http://accounts.flamenet.test:${server.port}/accounts/params")
|
||||
assertEquals("accounts.flamenet.test:${server.port}", server.takeRequest().getHeader("Host"))
|
||||
assertEquals(1, good.completed.get())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lines are tried one at a time`() {
|
||||
server.enqueue(MockResponse().setBody("one"))
|
||||
server.start()
|
||||
TestBridge(server.port).use { first ->
|
||||
TestBridge(server.port).use { second ->
|
||||
Obfs4Routing.use(listOf(first.bridge, second.bridge))
|
||||
get("http://relay.flamenet.test:${server.port}/")
|
||||
assertEquals(1, first.completed.get())
|
||||
assertEquals(0, second.completed.get(), "a working first line still had the second dialled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bridge at an IPv6 address`() {
|
||||
server.enqueue(MockResponse().setBody("over six"))
|
||||
server.start()
|
||||
TestBridge(server.port, ipv6 = true).use { v6 ->
|
||||
Obfs4Routing.use(listOf(v6.bridge))
|
||||
assertEquals("over six", get("http://relay.flamenet.test:${server.port}/"))
|
||||
assertEquals(1, v6.completed.get())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bridge by name`() {
|
||||
server.enqueue(MockResponse().setBody("by name"))
|
||||
server.start()
|
||||
TestBridge(server.port).use { b ->
|
||||
Obfs4Routing.use(listOf(b.at("localhost")))
|
||||
assertEquals("by name", get("http://relay.flamenet.test:${server.port}/"))
|
||||
assertEquals(1, b.completed.get())
|
||||
}
|
||||
}
|
||||
|
||||
/** The order of attempts: the line that last worked, then stored order. */
|
||||
@Test
|
||||
fun `the order starts with the line that last worked`() {
|
||||
val a = TestBridge.refusing()
|
||||
val b = TestBridge.refusing()
|
||||
val c = TestBridge.refusing()
|
||||
Obfs4Routing.use(listOf(a, b, c))
|
||||
assertEquals(listOf(a, b, c), Obfs4Routing.order())
|
||||
Obfs4Routing.use(listOf(a, b, c), lastWorked = c)
|
||||
assertEquals(listOf(c, a, b), Obfs4Routing.order())
|
||||
}
|
||||
|
||||
/** Off is the person's choice, and the one case that connects directly. */
|
||||
@Test
|
||||
fun `with no line stored the connection is direct`() {
|
||||
server.enqueue(MockResponse().setBody("direct"))
|
||||
server.start()
|
||||
Obfs4Routing.use(emptyList())
|
||||
assertEquals("direct", get("http://127.0.0.1:${server.port}/"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.obfs4.Elligator2
|
||||
import io.flamenet.obfs4.Ladder
|
||||
import io.flamenet.obfs4.Obfs4Bridge
|
||||
import io.flamenet.obfs4.Obfs4DataPlane
|
||||
import io.flamenet.obfs4.Obfs4Handshake
|
||||
import io.flamenet.obfs4.Obfs4ServerHandshake
|
||||
import io.flamenet.obfs4.Obfs4Session
|
||||
import io.flamenet.obfs4.ReplayFilter
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* A minimal obfs4 bridge in the test process, proxying plaintext to
|
||||
* `127.0.0.1:upstreamPort`. Counts completed handshakes, which is how a test
|
||||
* tells traffic that went through it from traffic that went direct.
|
||||
*/
|
||||
class TestBridge(
|
||||
private val upstreamPort: Int,
|
||||
ipv6: Boolean = false,
|
||||
) : AutoCloseable {
|
||||
private val random = SecureRandom()
|
||||
private val identityPrivate = ByteArray(32).also { random.nextBytes(it) }
|
||||
private val identityPublic = Ladder.publicKey(identityPrivate)
|
||||
private val nodeId = ByteArray(20).also { random.nextBytes(it) }
|
||||
private val filter = ReplayFilter()
|
||||
private val listener = ServerSocket(0, 16, InetAddress.getByName(if (ipv6) "::1" else "127.0.0.1"))
|
||||
val completed = AtomicInteger(0)
|
||||
|
||||
val bridge = Obfs4Bridge(if (ipv6) "[::1]" else "127.0.0.1", listener.localPort, nodeId, identityPublic)
|
||||
|
||||
/** The same bridge, named differently. */
|
||||
fun at(host: String) = bridge.copy(host = host)
|
||||
|
||||
init {
|
||||
thread(isDaemon = true) {
|
||||
while (!listener.isClosed) {
|
||||
val conn = runCatching { listener.accept() }.getOrNull() ?: return@thread
|
||||
thread(isDaemon = true) { runCatching { serve(conn) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun keypair(): Triple<ByteArray, ByteArray, ByteArray> {
|
||||
while (true) {
|
||||
val candidate = ByteArray(32).also { random.nextBytes(it) }
|
||||
val pub = Ladder.dirtyPublicKey(candidate)
|
||||
val repr = Elligator2.representativeForPublicKey(pub, random.nextInt(256))
|
||||
if (repr != null) return Triple(candidate, pub, repr)
|
||||
}
|
||||
}
|
||||
|
||||
private fun serve(conn: Socket) {
|
||||
conn.use {
|
||||
conn.soTimeout = 15000
|
||||
val (priv, pub, repr) = keypair()
|
||||
val handshake =
|
||||
Obfs4ServerHandshake(nodeId, identityPrivate, identityPublic, priv, pub, repr, Obfs4Handshake.currentEpochHour())
|
||||
val buf = ByteArray(16384)
|
||||
var accepted: Obfs4ServerHandshake.Accepted? = null
|
||||
for (i in 0 until 16) {
|
||||
val n = conn.getInputStream().read(buf)
|
||||
if (n <= 0) return
|
||||
accepted = synchronized(filter) { handshake.receive(buf.copyOfRange(0, n), ByteArray(0), filter) }
|
||||
if (accepted != null) break
|
||||
}
|
||||
val session = accepted ?: return
|
||||
completed.incrementAndGet()
|
||||
conn.getOutputStream().write(session.reply)
|
||||
conn.getOutputStream().flush()
|
||||
val keys = Obfs4Session.directionKeys(session.keySeed, isClient = false)
|
||||
val plane = Obfs4DataPlane(keys.first, keys.second)
|
||||
val upstream = runCatching { Socket("127.0.0.1", upstreamPort) }.getOrNull() ?: return
|
||||
upstream.use {
|
||||
thread(isDaemon = true) {
|
||||
val up = ByteArray(16384)
|
||||
runCatching {
|
||||
while (true) {
|
||||
val n = upstream.getInputStream().read(up)
|
||||
if (n <= 0) break
|
||||
synchronized(plane) {
|
||||
conn.getOutputStream().write(plane.send(up.copyOfRange(0, n)))
|
||||
}
|
||||
conn.getOutputStream().flush()
|
||||
}
|
||||
}
|
||||
runCatching { conn.close() }
|
||||
}
|
||||
runCatching {
|
||||
while (true) {
|
||||
val n = conn.getInputStream().read(buf)
|
||||
if (n <= 0) break
|
||||
val payload = synchronized(plane) { plane.receive(buf.copyOfRange(0, n), isClient = false) }
|
||||
if (payload.isNotEmpty()) {
|
||||
upstream.getOutputStream().write(payload)
|
||||
upstream.getOutputStream().flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() = listener.close()
|
||||
|
||||
companion object {
|
||||
/** A port nothing listens on: bound, then closed. */
|
||||
fun closedPort(): Int = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).use { it.localPort }
|
||||
|
||||
/** A bridge nothing answers for. */
|
||||
fun refusing() = Obfs4Bridge("127.0.0.1", closedPort(), ByteArray(20) { 1 }, ByteArray(32) { 2 })
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts and says nothing, the way a bridge treats a handshake it cannot verify. */
|
||||
class SilentListener : AutoCloseable {
|
||||
private val listener = ServerSocket(0, 16, InetAddress.getByName("127.0.0.1"))
|
||||
private val held = mutableListOf<Socket>()
|
||||
val bridge = Obfs4Bridge("127.0.0.1", listener.localPort, ByteArray(20) { 3 }, ByteArray(32) { 4 })
|
||||
|
||||
init {
|
||||
thread(isDaemon = true) {
|
||||
while (!listener.isClosed) {
|
||||
val conn = runCatching { listener.accept() }.getOrNull() ?: return@thread
|
||||
synchronized(held) { held += conn }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
listener.close()
|
||||
synchronized(held) { held.forEach { runCatching { it.close() } } }
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ tasks.test {
|
||||
"FLAMENET_SYNC_VECTORS",
|
||||
"FLAMENET_HANDLE_CERT_VECTORS",
|
||||
"FLAMENET_LINK_VECTORS",
|
||||
"FLAMENET_BRIDGE_VECTORS",
|
||||
).forEach { name ->
|
||||
val path = System.getenv(name)
|
||||
if (path != null && file(path).exists()) {
|
||||
|
||||
@@ -288,7 +288,7 @@ data class Content(
|
||||
const val AVATAR = "avatar"
|
||||
|
||||
/**
|
||||
* A bridge line, offered by a contact (§6.19).
|
||||
* A bridge line, offered by a contact (§24).
|
||||
*/
|
||||
const val BRIDGE = "bridge"
|
||||
|
||||
@@ -724,21 +724,36 @@ data class Content(
|
||||
*/
|
||||
fun screenshot(atSeconds: Long): String = "{\"fnc\":1,\"kind\":${quote(Kind.SCREENSHOT)},\"ctl\":1,\"at\":$atSeconds}"
|
||||
|
||||
/**
|
||||
* Offer a contact the bridge this phone is using (§6.19).
|
||||
*/
|
||||
fun bridgeOffer(line: String): String = "{\"fnc\":1,\"kind\":${quote(Kind.BRIDGE)},\"ctl\":1,\"line\":${quote(line)}}"
|
||||
/** §24.4: the longest line an offer may carry, in UTF-8 bytes. */
|
||||
const val MAX_BRIDGE_LINE_BYTES = 1024
|
||||
|
||||
private fun offerable(line: String) = line.isNotBlank() && line.toByteArray(Charsets.UTF_8).size <= MAX_BRIDGE_LINE_BYTES
|
||||
|
||||
/**
|
||||
* The bridge line in a [Kind.BRIDGE] envelope, or null.
|
||||
* Offer a contact the bridge this phone is using (§24.3). Null for a
|
||||
* line no receiver would keep: blank, or longer than §24.4 allows.
|
||||
*/
|
||||
fun bridgeLine(content: Content): String? =
|
||||
if (content.kind == Kind.BRIDGE) {
|
||||
content.raw?.optString("line")?.takeIf { it.isNotBlank() }
|
||||
fun bridgeOffer(line: String): String? =
|
||||
if (offerable(line)) {
|
||||
"{\"fnc\":1,\"kind\":${quote(Kind.BRIDGE)},\"ctl\":1,\"line\":${quote(line)}}"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge line in a [Kind.BRIDGE] envelope, or null when it is not
|
||||
* an offer §24.5 keeps: `line` must be a string, not blank, at most
|
||||
* 1024 bytes, and the envelope must carry no `gid`, because an offer
|
||||
* goes to one person and never through a group.
|
||||
*/
|
||||
fun bridgeLine(content: Content): String? {
|
||||
if (content.kind != Kind.BRIDGE) return null
|
||||
val raw = content.raw ?: return null
|
||||
if (raw.has("gid")) return null
|
||||
val line = raw.opt("line") as? String ?: return null
|
||||
return line.takeIf(::offerable)
|
||||
}
|
||||
|
||||
/** When a [Kind.SCREENSHOT] envelope says it happened, or null. */
|
||||
fun screenshotAt(content: Content): Long? =
|
||||
if (content.kind == Kind.SCREENSHOT) {
|
||||
|
||||
@@ -1284,7 +1284,7 @@ class Vault(
|
||||
}
|
||||
|
||||
/**
|
||||
* A bridge line a contact offered, and who offered it (§6.19).
|
||||
* A bridge line a contact offered, and who offered it (§24.5).
|
||||
*/
|
||||
fun bridgeOffers(): List<BridgeOffer> {
|
||||
val all = readJson(BRIDGE_OFFERS) ?: return emptyList()
|
||||
|
||||
@@ -8,7 +8,7 @@ import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Handing a contact the bridge you use (§6.19).
|
||||
* Handing a contact the bridge you use (§24).
|
||||
*/
|
||||
class BridgeOfferTest {
|
||||
private val line =
|
||||
@@ -22,7 +22,7 @@ class BridgeOfferTest {
|
||||
|
||||
@Test
|
||||
fun `an offer round trips through the envelope`() {
|
||||
val parsed = Content.parse(Content.bridgeOffer(line))
|
||||
val parsed = Content.parse(Content.bridgeOffer(line)!!)
|
||||
assertEquals(Content.Kind.BRIDGE, parsed.kind)
|
||||
assertEquals(line, Content.bridgeLine(parsed))
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class BridgeOfferTest {
|
||||
*/
|
||||
@Test
|
||||
fun `the control flag is the number one on the wire`() {
|
||||
val json = JSONObject(Content.bridgeOffer(line))
|
||||
val json = JSONObject(Content.bridgeOffer(line)!!)
|
||||
assertEquals(1, json.getInt("ctl"))
|
||||
assertEquals(Content.Kind.BRIDGE, json.getString("kind"))
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class BridgeOfferTest {
|
||||
*/
|
||||
@Test
|
||||
fun `an offer is control traffic and never renders`() {
|
||||
val parsed = Content.parse(Content.bridgeOffer(line))
|
||||
val parsed = Content.parse(Content.bridgeOffer(line)!!)
|
||||
assertTrue(parsed.isControl, "an offer must never become a bubble")
|
||||
assertFalse(parsed.isRenderable)
|
||||
assertTrue(Content.Kind.BRIDGE in Content.Kind.CONTROL)
|
||||
@@ -57,7 +57,7 @@ class BridgeOfferTest {
|
||||
@Test
|
||||
fun `the line is carried verbatim`() {
|
||||
val awkward = "obfs4 [2001:db8::1]:9001 ABC cert=zz+/9 iat-mode=1 sni=x.example"
|
||||
assertEquals(awkward, Content.bridgeLine(Content.parse(Content.bridgeOffer(awkward))))
|
||||
assertEquals(awkward, Content.bridgeLine(Content.parse(Content.bridgeOffer(awkward)!!)))
|
||||
}
|
||||
|
||||
/** Asked of the wrong kind, it says nothing rather than guessing. */
|
||||
@@ -72,7 +72,35 @@ class BridgeOfferTest {
|
||||
*/
|
||||
@Test
|
||||
fun `an empty line is not an offer`() {
|
||||
assertNull(Content.bridgeLine(Content.parse(Content.bridgeOffer(" "))))
|
||||
assertNull(Content.bridgeOffer(" "), "built an offer of nothing")
|
||||
assertNull(Content.bridgeLine(Content.parse("{\"fnc\":1,\"kind\":\"bridge\",\"ctl\":1,\"line\":\" \"}")))
|
||||
}
|
||||
|
||||
/** §24.5: an offer goes to one person. One that came through a group is dropped. */
|
||||
@Test
|
||||
fun `an offer carrying a group id is dropped`() {
|
||||
val wire = "{\"fnc\":1,\"kind\":\"bridge\",\"ctl\":1,\"gid\":\"g-1\",\"line\":" + JSONObject.quote(line) + "}"
|
||||
assertNull(Content.bridgeLine(Content.parse(wire)))
|
||||
}
|
||||
|
||||
/** §24.4: at most 1024 bytes, measured on the decoded string. */
|
||||
@Test
|
||||
fun `a line over the size limit is dropped and never built`() {
|
||||
val atLimit = line + " x=" + "a".repeat(1024 - line.toByteArray().size - 3)
|
||||
assertEquals(1024, atLimit.toByteArray().size)
|
||||
assertEquals(atLimit, Content.bridgeLine(Content.parse(Content.bridgeOffer(atLimit)!!)))
|
||||
val over = atLimit + "a"
|
||||
assertNull(Content.bridgeOffer(over), "built an offer a receiver must drop")
|
||||
val wire = "{\"fnc\":1,\"kind\":\"bridge\",\"ctl\":1,\"line\":" + JSONObject.quote(over) + "}"
|
||||
assertNull(Content.bridgeLine(Content.parse(wire)))
|
||||
}
|
||||
|
||||
/** §24.3: `line` is a string. A number is not a line, and is not read as one. */
|
||||
@Test
|
||||
fun `a line that is not a string is not an offer`() {
|
||||
val content = Content.parse("{\"fnc\":1,\"kind\":\"bridge\",\"ctl\":1,\"line\":5}")
|
||||
assertNull(Content.bridgeLine(content))
|
||||
assertFalse(content.isRenderable)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +109,6 @@ class BridgeOfferTest {
|
||||
@Test
|
||||
fun `a malformed line still arrives, to be judged where it can be explained`() {
|
||||
val junk = "obfs4 this-is-not-a-bridge-line"
|
||||
assertEquals(junk, Content.bridgeLine(Content.parse(Content.bridgeOffer(junk))))
|
||||
assertEquals(junk, Content.bridgeLine(Content.parse(Content.bridgeOffer(junk)!!)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* This engine against `bridge-offer-v1.json`, SPEC §24.10: the receive rules
|
||||
* of §24.5 for every envelope, as a phone writes it and as org.json on the JVM
|
||||
* writes it, and what this engine builds for each line.
|
||||
*
|
||||
* The `android_today` notes in the file describe the build this change fixes.
|
||||
* Nothing here reads them: the engine is held to the rule.
|
||||
*
|
||||
* Fails rather than skips when the file is missing, like every vector suite.
|
||||
*/
|
||||
class BridgeOfferVectorTest {
|
||||
private val vectors by lazy {
|
||||
val candidates =
|
||||
listOfNotNull(
|
||||
System.getenv("FLAMENET_BRIDGE_VECTORS"),
|
||||
"../../flamenet-e2e/test/vectors/bridge-offer-v1.json",
|
||||
"../../../flamenet-e2e/test/vectors/bridge-offer-v1.json",
|
||||
).map { File(it) }
|
||||
val found =
|
||||
candidates.firstOrNull { it.exists() }
|
||||
?: throw AssertionError(
|
||||
"bridge-offer-v1.json not found. Looked in: " + candidates.joinToString { it.absolutePath },
|
||||
)
|
||||
JSONObject(found.readText())
|
||||
}
|
||||
|
||||
private fun cases(key: String) = vectors.getJSONArray(key).let { a -> (0 until a.length()).map { a.getJSONObject(it) } }
|
||||
|
||||
/** What a receiver must do with one envelope, compared with this engine. Null when they agree. */
|
||||
private fun disagreement(
|
||||
name: String,
|
||||
wire: String,
|
||||
receive: JSONObject,
|
||||
line: String?,
|
||||
): String? {
|
||||
val content = Content.parse(wire)
|
||||
// Never a bubble, offer or not (§24.5).
|
||||
if (content.isRenderable || content.text.isNotEmpty()) return "$name: renders as \"${content.text.take(60)}\""
|
||||
val got = Content.bridgeLine(content)
|
||||
return if (receive.getBoolean("offer")) {
|
||||
if (got == line) null else "$name: expected an offer, got $got"
|
||||
} else {
|
||||
if (got == null) null else "$name: ${receive.optString("reason")} was kept as an offer"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every offer is received as section 24_5 says, as a phone writes it and as the JVM does`() {
|
||||
val wrong = mutableListOf<String>()
|
||||
for (c in cases("cases")) {
|
||||
for (key in listOf("envelope", "envelope_jvm")) {
|
||||
disagreement("${c.getString("name")}/$key", c.getString(key), c.getJSONObject("receive"), c.getString("line"))
|
||||
?.let(wrong::add)
|
||||
}
|
||||
}
|
||||
assertEquals(18, cases("cases").size)
|
||||
assertTrue(wrong.isEmpty(), wrong.joinToString("\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the receive-only envelopes are decided as the vectors record`() {
|
||||
val wrong = mutableListOf<String>()
|
||||
for (c in cases("receive_only")) {
|
||||
val receive = c.getJSONObject("receive")
|
||||
disagreement(c.getString("name"), c.getString("envelope"), receive, receive.optString("line").ifEmpty { null })
|
||||
?.let(wrong::add)
|
||||
}
|
||||
assertEquals(7, cases("receive_only").size)
|
||||
assertTrue(wrong.isEmpty(), wrong.joinToString("\n"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Section 24.3: receivers compare decoded strings, never bytes. What this
|
||||
* engine builds decodes to the line, and a line a receiver must drop is
|
||||
* never built (section 24.4).
|
||||
*/
|
||||
@Test
|
||||
fun `what this engine builds carries the line, and an oversized line is not built`() {
|
||||
val wrong = mutableListOf<String>()
|
||||
for (c in cases("cases")) {
|
||||
val name = c.getString("name")
|
||||
val line = c.getString("line")
|
||||
val built = Content.bridgeOffer(line)
|
||||
if (!c.getJSONObject("receive").getBoolean("offer")) {
|
||||
if (built != null) wrong += "$name: built an envelope a receiver must drop"
|
||||
continue
|
||||
}
|
||||
if (built == null) {
|
||||
wrong += "$name: refused to build"
|
||||
continue
|
||||
}
|
||||
if (JSONObject(built).getString("line") != line) wrong += "$name: a different line"
|
||||
if (Content.bridgeLine(Content.parse(built)) != line) wrong += "$name: does not read back"
|
||||
}
|
||||
assertTrue(wrong.isEmpty(), wrong.joinToString("\n"))
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,14 @@ java {
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
testLogging { events("passed", "failed", "skipped"); showStandardStreams = true }
|
||||
// `BridgeLineVectorTest` reads `bridge-offer-v1.json` by environment
|
||||
// variable, which Gradle does not track. Declaring the file as an input is
|
||||
// what makes a moved vector rerun the task (see `:engine`).
|
||||
System.getenv("FLAMENET_BRIDGE_VECTORS")?.let { path ->
|
||||
if (file(path).exists()) {
|
||||
inputs.file(path).withPropertyName("vector-FLAMENET_BRIDGE_VECTORS").withPathSensitivity(PathSensitivity.NONE)
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.withType<Test>().configureEach { finalizedBy(tasks.named("jacocoTestReport")) }
|
||||
tasks.named<JacocoReport>("jacocoTestReport") {
|
||||
|
||||
@@ -93,7 +93,8 @@ object BridgeLine {
|
||||
val host = address.substring(0, colon)
|
||||
val portText = address.substring(colon + 1)
|
||||
if (host.isEmpty()) return BridgeLineResult.Bad(BridgeLineProblem.NoAddress)
|
||||
val port = portText.toIntOrNull()
|
||||
// Decimal digits only (§24.2): `toIntOrNull` would also take "+443".
|
||||
val port = portText.takeIf { t -> t.isNotEmpty() && t.all { it in '0'..'9' } }?.toIntOrNull()
|
||||
if (port == null || port !in 1..65535) {
|
||||
return BridgeLineResult.Bad(BridgeLineProblem.BadPort(portText))
|
||||
}
|
||||
|
||||
@@ -52,10 +52,21 @@ data class Obfs4Bridge(
|
||||
|
||||
/**
|
||||
* A `Socket` that speaks obfs4 to a bridge and plaintext to its caller.
|
||||
*
|
||||
* [route] picks the bridge from the endpoint the caller asked for, which lets
|
||||
* a resolver hand each stored line its own placeholder address and the
|
||||
* caller's own retry loop try them one at a time (SPEC §24.6).
|
||||
*/
|
||||
class Obfs4Socket(
|
||||
private val bridge: Obfs4Bridge,
|
||||
private val route: (InetSocketAddress?) -> Obfs4Bridge,
|
||||
) : Socket() {
|
||||
constructor(bridge: Obfs4Bridge) : this({ bridge })
|
||||
|
||||
/** The bridge this socket went to, once connected. */
|
||||
@Volatile
|
||||
var bridge: Obfs4Bridge? = null
|
||||
private set
|
||||
|
||||
private var plane: Obfs4DataPlane? = null
|
||||
private val random = SecureRandom()
|
||||
private var pending = ByteArray(0)
|
||||
@@ -64,15 +75,18 @@ class Obfs4Socket(
|
||||
endpoint: SocketAddress?,
|
||||
timeout: Int,
|
||||
) {
|
||||
// The endpoint OkHttp asks for is the RELAY's address, and it is
|
||||
// deliberately ignored: the socket goes to the bridge, which proxies
|
||||
// on.
|
||||
super.connect(InetSocketAddress(bridge.host, bridge.port), timeout)
|
||||
// The endpoint OkHttp asks for is the RELAY's address, or a
|
||||
// placeholder standing for one stored line. Either way the socket goes
|
||||
// to a bridge, which proxies on.
|
||||
val chosen = route(endpoint as? InetSocketAddress)
|
||||
bridge = chosen
|
||||
super.connect(InetSocketAddress(chosen.host, chosen.port), timeout)
|
||||
soTimeout = if (timeout > 0) timeout else HANDSHAKE_TIMEOUT_MILLIS
|
||||
performHandshake()
|
||||
}
|
||||
|
||||
private fun performHandshake() {
|
||||
val bridge = this.bridge ?: throw IllegalStateException("not connected")
|
||||
var priv: ByteArray
|
||||
var pub: ByteArray
|
||||
var repr: ByteArray
|
||||
@@ -190,7 +204,7 @@ class Obfs4Socket(
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val HANDSHAKE_TIMEOUT_MILLIS = 20_000
|
||||
const val HANDSHAKE_TIMEOUT_MILLIS = 15_000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,28 +218,46 @@ class Obfs4HandshakeRefused :
|
||||
"is invalid, so this means either the bridge line is wrong or nothing is listening.",
|
||||
)
|
||||
|
||||
/**
|
||||
* No stored line can carry this connection. Never a reason to connect
|
||||
* directly (SPEC §24.6).
|
||||
*/
|
||||
class Obfs4NoBridge :
|
||||
java.io.IOException("no bridge could be reached")
|
||||
|
||||
/**
|
||||
* Hands OkHttp obfs4 sockets.
|
||||
*
|
||||
* [route] picks the bridge for an endpoint, or returns null when bridges are
|
||||
* off, which is the one case that gets a plain socket. [on] says whether any
|
||||
* bridge is stored, read per socket so a factory built before a bridge was set
|
||||
* still follows the setting.
|
||||
*/
|
||||
class Obfs4SocketFactory(
|
||||
private val bridgeFor: () -> Obfs4Bridge?,
|
||||
private val on: () -> Boolean,
|
||||
private val route: (InetSocketAddress?) -> Obfs4Bridge?,
|
||||
) : SocketFactory() {
|
||||
/** One bridge chosen per socket, whatever the endpoint. */
|
||||
constructor(bridgeFor: () -> Obfs4Bridge?) : this({ bridgeFor() != null }, { bridgeFor() })
|
||||
|
||||
/** A factory pinned to one bridge, as the interop tests use it. */
|
||||
constructor(bridge: Obfs4Bridge) : this({ bridge })
|
||||
|
||||
/**
|
||||
* `null` means a PLAIN socket, not a failure.
|
||||
*/
|
||||
private fun plain(): SocketFactory = getDefault()
|
||||
|
||||
override fun createSocket(): Socket = bridgeFor()?.let { Obfs4Socket(it) } ?: plain().createSocket()
|
||||
private fun obfs4(): Socket = Obfs4Socket { endpoint -> route(endpoint) ?: throw Obfs4NoBridge() }
|
||||
|
||||
override fun createSocket(): Socket = if (on()) obfs4() else plain().createSocket()
|
||||
|
||||
override fun createSocket(
|
||||
host: String,
|
||||
port: Int,
|
||||
): Socket =
|
||||
bridgeFor()?.let { Obfs4Socket(it).apply { connect(InetSocketAddress(host, port)) } }
|
||||
?: plain().createSocket(host, port)
|
||||
if (on()) {
|
||||
obfs4().apply { connect(InetSocketAddress.createUnresolved(host, port)) }
|
||||
} else {
|
||||
plain().createSocket(host, port)
|
||||
}
|
||||
|
||||
override fun createSocket(
|
||||
host: String,
|
||||
@@ -233,19 +265,24 @@ class Obfs4SocketFactory(
|
||||
localHost: InetAddress,
|
||||
localPort: Int,
|
||||
): Socket =
|
||||
bridgeFor()?.let {
|
||||
Obfs4Socket(it).apply {
|
||||
if (on()) {
|
||||
obfs4().apply {
|
||||
bind(InetSocketAddress(localHost, localPort))
|
||||
connect(InetSocketAddress(host, port))
|
||||
connect(InetSocketAddress.createUnresolved(host, port))
|
||||
}
|
||||
} ?: plain().createSocket(host, port, localHost, localPort)
|
||||
} else {
|
||||
plain().createSocket(host, port, localHost, localPort)
|
||||
}
|
||||
|
||||
override fun createSocket(
|
||||
host: InetAddress,
|
||||
port: Int,
|
||||
): Socket =
|
||||
bridgeFor()?.let { Obfs4Socket(it).apply { connect(InetSocketAddress(host, port)) } }
|
||||
?: plain().createSocket(host, port)
|
||||
if (on()) {
|
||||
obfs4().apply { connect(InetSocketAddress(host, port)) }
|
||||
} else {
|
||||
plain().createSocket(host, port)
|
||||
}
|
||||
|
||||
override fun createSocket(
|
||||
address: InetAddress,
|
||||
@@ -253,10 +290,12 @@ class Obfs4SocketFactory(
|
||||
localAddress: InetAddress,
|
||||
localPort: Int,
|
||||
): Socket =
|
||||
bridgeFor()?.let {
|
||||
Obfs4Socket(it).apply {
|
||||
if (on()) {
|
||||
obfs4().apply {
|
||||
bind(InetSocketAddress(localAddress, localPort))
|
||||
connect(InetSocketAddress(address, port))
|
||||
}
|
||||
} ?: plain().createSocket(address, port, localAddress, localPort)
|
||||
} else {
|
||||
plain().createSocket(address, port, localAddress, localPort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package io.flamenet.obfs4
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Reading every line in `bridge-offer-v1.json` the way SPEC §24.2 says: the
|
||||
* same bridge, or the same problem found in the same order.
|
||||
*/
|
||||
class BridgeLineVectorTest {
|
||||
private val vectors by lazy {
|
||||
val candidates =
|
||||
listOfNotNull(
|
||||
System.getenv("FLAMENET_BRIDGE_VECTORS"),
|
||||
"../../flamenet-e2e/test/vectors/bridge-offer-v1.json",
|
||||
"../../../flamenet-e2e/test/vectors/bridge-offer-v1.json",
|
||||
).map { File(it) }
|
||||
val found =
|
||||
candidates.firstOrNull { it.exists() }
|
||||
?: throw AssertionError(
|
||||
"bridge-offer-v1.json not found. Looked in: " + candidates.joinToString { it.absolutePath },
|
||||
)
|
||||
JSONObject(found.readText())
|
||||
}
|
||||
|
||||
private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }
|
||||
|
||||
@Test
|
||||
fun `every line reads as section 24_2 says`() {
|
||||
val cases = vectors.getJSONArray("cases")
|
||||
assertEquals(18, cases.length())
|
||||
val wrong = mutableListOf<String>()
|
||||
for (i in 0 until cases.length()) {
|
||||
val c = cases.getJSONObject(i)
|
||||
val name = c.getString("name")
|
||||
val parse = c.getJSONObject("parse")
|
||||
val got = BridgeLine.parse(c.getString("line"))
|
||||
val ok = parse.optJSONObject("ok")
|
||||
if (ok != null) {
|
||||
if (got !is BridgeLineResult.Ok) {
|
||||
wrong += "$name: refused, $got"
|
||||
continue
|
||||
}
|
||||
if (got.bridge.host != ok.getString("host")) wrong += "$name: host ${got.bridge.host}"
|
||||
if (got.bridge.port != ok.getInt("port")) wrong += "$name: port ${got.bridge.port}"
|
||||
if (hex(got.bridge.nodeId) != ok.getString("node_id_hex")) wrong += "$name: node id"
|
||||
if (hex(got.bridge.identityPublic) != ok.getString("public_key_hex")) wrong += "$name: key"
|
||||
if ((got.caveat != null) != ok.getBoolean("caveat")) wrong += "$name: caveat ${got.caveat}"
|
||||
} else {
|
||||
val problem = parse.getJSONObject("problem")
|
||||
if (got !is BridgeLineResult.Bad) {
|
||||
wrong += "$name: accepted a line the vector refuses"
|
||||
continue
|
||||
}
|
||||
val expected: BridgeLineProblem =
|
||||
when (problem.getString("name")) {
|
||||
"Empty" -> BridgeLineProblem.Empty
|
||||
"NoAddress" -> BridgeLineProblem.NoAddress
|
||||
"BadPort" -> BridgeLineProblem.BadPort(problem.getString("text"))
|
||||
"NoCert" -> BridgeLineProblem.NoCert
|
||||
"CertNotReadable" -> BridgeLineProblem.CertNotReadable
|
||||
"CertWrongLength" -> BridgeLineProblem.CertWrongLength(problem.getInt("got"))
|
||||
else -> {
|
||||
wrong += "$name: unknown problem $problem"
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (got.problem != expected) wrong += "$name: ${got.problem}, expected $expected"
|
||||
}
|
||||
}
|
||||
assertTrue(wrong.isEmpty(), wrong.joinToString("\n"))
|
||||
}
|
||||
|
||||
/** Section 24.2: the port is decimal digits. A sign is not a digit. */
|
||||
@Test
|
||||
fun `a port with a sign is not a port`() {
|
||||
val line =
|
||||
"obfs4 198.51.100.7:+443 cert=++++////AAECAwQFBgcICQoLDA2FIPAJiTCnVHSLfdy0PvdaDb86DSY4GvTrpKmOqptOag"
|
||||
assertEquals(BridgeLineResult.Bad(BridgeLineProblem.BadPort("+443")), BridgeLine.parse(line))
|
||||
}
|
||||
|
||||
/** Section 24.2: the URL-safe alphabet is refused. */
|
||||
@Test
|
||||
fun `a url-safe cert is not readable`() {
|
||||
val line =
|
||||
"obfs4 198.51.100.7:443 cert=----____AAECAwQFBgcICQoLDA2FIPAJiTCnVHSLfdy0PvdaDb86DSY4GvTrpKmOqptOag"
|
||||
assertEquals(BridgeLineResult.Bad(BridgeLineProblem.CertNotReadable), BridgeLine.parse(line))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user