feat(android): access-token support — same frozen contract as iOS

Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written
Cookie header on every request and on the WS upgrade (no CookieJar, matching the
frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401
upgrade as a terminal state with no reconnect loop.
This commit is contained in:
Yaojia Wang
2026-07-30 12:45:27 +02:00
parent a5fa843f00
commit 9114630c3a
42 changed files with 2419 additions and 51 deletions

View File

@@ -59,6 +59,30 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove. no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited. - [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
## Access token — `WEBTERM_TOKEN` (B5 / ios-completion §1.1) — needs a token-enabled host
Run the host as `WEBTERM_TOKEN=<16512 chars> npm start`. The pure logic is JVM-tested (`AuthProbeTest`,
`AccessTokenCookieTest`, `PairingProbeTokenTest`, `OkHttpAccessTokenTest`, `SessionEngineUnauthorizedTest`,
`InMemoryAccessTokenStoreTest`); everything below needs real hardware + a real server.
- [ ] `TinkAccessTokenStore` instrumented suite on a device:
`./gradlew :host-registry:connectedDebugAndroidTest` (real AndroidKeyStore + Tink).
- [ ] Pair a token-enabled host with the token typed → paired; the terminal attaches (the **WS upgrade**
carries `Cookie: webterm_auth=…`), the session list, projects, diff and thumbnails all load.
- [ ] Pair the same host with **no** token typed → the probe 401s → the confirm step comes back asking for
the token ("该主机启用了访问令牌"); typing it and retrying pairs.
- [ ] Wrong token → "访问令牌不正确" **under the field** (still on the confirm step, nothing stored).
- [ ] 11 wrong tries within a minute → "尝试过多" (the server's 10/min/IP `/auth` limiter).
- [ ] Pair a host with `WEBTERM_TOKEN` **unset** while typing a token → pairs, and NOTHING is stored
(204-without-`Set-Cookie`); the app must not claim to be "authenticated".
- [ ] Restart the app → the terminal re-attaches with no re-prompt (the token decrypts from the
Keystore-backed store on a cold start).
- [ ] Rotate `WEBTERM_TOKEN` on the host and restart it → the WS upgrade 401s → the terminal banner shows
the 401 copy, offers **no** "新会话", and there is **no reconnect back-off storm** (verify with
`adb logcat` / the server log: exactly one upgrade attempt).
- [ ] `adb logcat` during all of the above: the token string appears **nowhere**; no request URL contains
it (check the server's access log too).
- [ ] `adb backup` / device-to-device transfer excludes the token blob (`allowBackup=false` +
`data_extraction_rules.xml`).
## Nav / deep links / adaptive (A32/A26/A29) ## Nav / deep links / adaptive (A32/A26/A29)
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the - [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
right destination; invalid UUID ignored. right destination; invalid UUID ignored.

View File

@@ -62,8 +62,40 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
`:wire-protocol` is the **frozen shared contract** (Android analogue of `:wire-protocol` is the **frozen shared contract** (Android analogue of
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`, `src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation), `Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary `AuthCookie`/`AccessTokenRule`/`AccessTokenSource` (the single access-token-cookie
interfaces. New wire types are added **only** here (a coordination point). derivation), and the `TermTransport` / `HttpTransport` / `PingableTermTransport`
boundary interfaces. New wire types are added **only** here (a coordination point).
## Access token (`WEBTERM_TOKEN`) — how the Android client authenticates
A server started with `WEBTERM_TOKEN=<16512 chars of [A-Za-z0-9._~+/=-]>` gates **every**
HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie. The Android
client therefore:
- **hand-writes `Cookie: webterm_auth=<t>` itself** — no OkHttp `CookieJar`, no
`Set-Cookie` parsing. One derivation point (`AuthCookie`), stamped in exactly three
places: `:api-client`'s `ApiRoute.toHttpRequest` (all 12+ routes, RO GETs included),
the pairing probe's two hand-built legs, and `:transport-okhttp`'s WS upgrade. The
hand-built `GET /projects/diff` fetcher in `:app` stamps it too.
- keeps the header **orthogonal to `Origin`**: the token never replaces the CSWSH
Origin check (the server tests Origin first, then the cookie), and read-only routes
still carry no Origin.
- validates a typed token ONCE at pairing time with `POST /auth`
(`{"token":"…"}`, `Accept: application/json` — an `Accept` containing `text/html`
makes the server answer 302 instead of 204/401). Four outcomes: 204+`Set-Cookie` =
correct → store it; 204 **without** `Set-Cookie` = that host has no auth → store
nothing; 401 = wrong token; 429 = rate-limited (10/min/IP).
- stores it per host (keyed by the canonical origin) in `TinkAccessTokenStore`:
Tink AEAD under an **AndroidKeystore-wrapped, non-exportable** master key, in an
app-private `SharedPreferences` file, with `android:allowBackup="false"` +
`data_extraction_rules.xml` excluding cloud backup and device transfer. The token is
**never logged, never in a URL, never in app UI state**.
- treats a **401 on the WS upgrade as terminal** (`FailureReason.UNAUTHORIZED`): no
reconnect back-off loop, and the banner tells the user to re-enter the token. A 401 on
any REST route is the typed `ApiClientError.Unauthorized`.
A host with no `WEBTERM_TOKEN` is byte-identical to before the feature: no token stored ⇒
no `Cookie` header at all (LAN zero-config preserved).
## Toolchain ## Toolchain

View File

@@ -0,0 +1,92 @@
package wang.yaojia.webterm.api.pairing
import wang.yaojia.webterm.api.routes.Endpoints
import wang.yaojia.webterm.wire.AccessTokenRule
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
/**
* The outcome of the pairing-time access-token validation probe (`POST /auth`). The first four cases
* are the FROZEN four the server distinguishes (ios-completion §1.1); [Malformed] is a client-side
* pre-check and [Unexpected] keeps an unknown status from being mistaken for success.
*/
public sealed interface AuthProbeResult {
/** **204 + `Set-Cookie: webterm_auth=…`** — the token is correct. Persist it for this host. */
public data object Accepted : AuthProbeResult
/**
* **204 with NO `Set-Cookie`** — this host has `WEBTERM_TOKEN` unset, so there is nothing to
* authenticate. The token MUST NOT be persisted, and this MUST NOT be read as "authenticated"
* (frozen §1.1): the host is simply open, exactly as a zero-config LAN deploy.
*/
public data object AuthDisabled : AuthProbeResult
/** **401** — wrong token. UI: "访问令牌不正确". */
public data object InvalidToken : AuthProbeResult
/** **429** — the server's `/auth` limiter (10/min/IP) tripped. UI: "尝试过多,稍后再试". */
public data object RateLimited : AuthProbeResult
/** The token cannot be a valid server token ([AccessTokenRule]) — rejected before any network I/O. */
public data object Malformed : AuthProbeResult
/** Any other status (e.g. a captive portal's 302). Never treated as success. */
public data class Unexpected(val status: Int) : AuthProbeResult
}
/**
* Validate [token] against [endpoint] via **`POST /auth`** — a one-shot pairing-time probe, NOT a
* session-establishing login: the native client keeps stamping its own `Cookie` header afterwards and
* ignores the `Set-Cookie` value entirely (frozen §1.1). The response's `Set-Cookie` is used only as a
* BOOLEAN signal (was the token accepted, or is auth disabled on this host?).
*
* Transport-level failures propagate unwrapped so the caller can classify them with
* [PairingError.classify] (same convention as [runPairingProbe]).
*
* Never logs the token; the token appears only in the JSON body, never in the URL.
*/
public suspend fun probeAccessToken(
endpoint: HostEndpoint,
http: HttpTransport,
token: String,
): AuthProbeResult {
// Boundary validation first: a token outside the server's charset/length can never be correct, and
// there is no point spending a rate-limit slot (or shipping it over the wire) to find that out.
val normalized = AccessTokenRule.normalize(token) ?: return AuthProbeResult.Malformed
val request = Endpoints.auth(normalized).toHttpRequest(endpoint)
?: return AuthProbeResult.Unexpected(UNBUILDABLE_REQUEST)
val response = http.send(request)
return classifyAuthResponse(response)
}
private fun classifyAuthResponse(response: HttpResponse): AuthProbeResult = when (response.status) {
HTTP_NO_CONTENT ->
// The ONE discriminator between "token correct" and "this host has no auth at all".
if (response.carriesAuthCookie()) AuthProbeResult.Accepted else AuthProbeResult.AuthDisabled
HTTP_UNAUTHORIZED -> AuthProbeResult.InvalidToken
HTTP_TOO_MANY_REQUESTS -> AuthProbeResult.RateLimited
else -> AuthProbeResult.Unexpected(response.status)
}
/**
* True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the
* value must name [AuthCookie.NAME] — some other service's `Set-Cookie` (a proxy's session id) proves
* nothing about our token.
*/
private fun HttpResponse.carriesAuthCookie(): Boolean =
headers.entries.any { (name, value) ->
name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=")
}
/**
* Sentinel status for "the request could not even be built" — unreachable for a validated
* [HostEndpoint], surfaced instead of crashing (never mistaken for a real HTTP status).
*/
private const val UNBUILDABLE_REQUEST = 0
private const val SET_COOKIE_HEADER = "Set-Cookie"
private const val HTTP_NO_CONTENT = 204
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_TOO_MANY_REQUESTS = 429

View File

@@ -52,6 +52,17 @@ public sealed interface PairingError {
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */ /** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
public data object TlsFailure : PairingError public data object TlsFailure : PairingError
/**
* The host answered **401** to the probe's HTTP legs — it has `WEBTERM_TOKEN` set and we presented
* no cookie / a wrong one (ios-completion §1.1). Distinct from [OriginRejected]: this is fixable by
* entering the host's access token, not by editing `ALLOWED_ORIGINS`.
*
* NOTE the probe's ORDERING is what makes the two distinguishable: probe ① authenticates over HTTP
* first, so a 401 there is the token; once ① has passed with our cookie, a 401 on the later WS
* upgrade can only be the Origin allow-list (the server writes the same bare 401 for both).
*/
public data object AccessTokenRequired : PairingError
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */ /** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
public data object Timeout : PairingError public data object Timeout : PairingError

View File

@@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.ClientMessage import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpMethod
@@ -50,8 +52,9 @@ public suspend fun runPairingProbe(
endpoint: HostEndpoint, endpoint: HostEndpoint,
http: HttpTransport, http: HttpTransport,
ws: TermTransport, ws: TermTransport,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProbeResult = ): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT) runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT, tokens = tokens)
/** /**
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts * Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
@@ -63,9 +66,10 @@ internal suspend fun runPairingProbeCore(
http: HttpTransport, http: HttpTransport,
ws: TermTransport, ws: TermTransport,
timeout: Duration?, timeout: Duration?,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProbeResult { ): PairingProbeResult {
if (timeout == null) return performProbe(endpoint, http, ws) if (timeout == null) return performProbe(endpoint, http, ws, tokens)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) } return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) }
?: PairingProbeResult.Failure(PairingError.Timeout) ?: PairingProbeResult.Failure(PairingError.Timeout)
} }
@@ -75,10 +79,16 @@ private suspend fun performProbe(
endpoint: HostEndpoint, endpoint: HostEndpoint,
http: HttpTransport, http: HttpTransport,
ws: TermTransport, ws: TermTransport,
tokens: AccessTokenSource,
): PairingProbeResult { ): PairingProbeResult {
// The access token (if any) rides BOTH HTTP legs as the hand-written auth cookie. The WS leg gets
// it from the transport's own AccessTokenSource (the same store), so all three legs authenticate.
val cookieHeaders = authHeaders(tokens.tokenFor(endpoint))
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape // ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?"). // means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host
probeReachability(endpoint, http)?.let { return it } // wants an access token we do not have.
probeReachability(endpoint, http, cookieHeaders)?.let { return it }
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an // ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
// unrecognizable connect failure is classified as originRejected. // unrecognizable connect failure is classified as originRejected.
@@ -105,7 +115,7 @@ private suspend fun performProbe(
return try { return try {
when (val adoption = adoptAttachedSession(connection)) { when (val adoption = adoptAttachedSession(connection)) {
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error) is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http) is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http, cookieHeaders)
} }
} finally { } finally {
withContext(NonCancellable) { runCatching { connection.close() } } withContext(NonCancellable) { runCatching { connection.close() } }
@@ -120,14 +130,20 @@ private suspend fun performProbe(
private suspend fun probeReachability( private suspend fun probeReachability(
endpoint: HostEndpoint, endpoint: HostEndpoint,
http: HttpTransport, http: HttpTransport,
authHeaders: Map<String, String>,
): PairingProbeResult.Failure? { ): PairingProbeResult.Failure? {
val response = try { val response = try {
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint))) http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint), headers = authHeaders))
} catch (cancel: CancellationException) { } catch (cancel: CancellationException) {
throw cancel throw cancel
} catch (error: Throwable) { } catch (error: Throwable) {
return PairingProbeResult.Failure(PairingError.classify(error, endpoint)) return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
} }
// Checked BEFORE the shape check: the 401 body is the server's auth JSON, not a session array, and
// reporting "端口对吗?" for a token problem would send the user chasing the wrong thing.
if (response.status == HTTP_UNAUTHORIZED) {
return PairingProbeResult.Failure(PairingError.AccessTokenRequired)
}
if (response.status != HTTP_OK || !isJsonArray(response.body)) { if (response.status != HTTP_OK || !isJsonArray(response.body)) {
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
} }
@@ -162,13 +178,14 @@ private suspend fun killProbeSession(
sessionId: String, sessionId: String,
endpoint: HostEndpoint, endpoint: HostEndpoint,
http: HttpTransport, http: HttpTransport,
authHeaders: Map<String, String>,
): PairingProbeResult { ): PairingProbeResult {
val response = try { val response = try {
http.send( http.send(
HttpRequest( HttpRequest(
method = HttpMethod.DELETE, method = HttpMethod.DELETE,
url = killUrl(endpoint, sessionId), url = killUrl(endpoint, sessionId),
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader), headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader),
), ),
) )
} catch (cancel: CancellationException) { } catch (cancel: CancellationException) {
@@ -178,6 +195,7 @@ private suspend fun killProbeSession(
} }
return when (response.status) { return when (response.status) {
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint) HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired)
HTTP_FORBIDDEN -> PairingProbeResult.Failure( HTTP_FORBIDDEN -> PairingProbeResult.Failure(
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)), PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
) )
@@ -219,9 +237,17 @@ private fun httpBaseUrl(endpoint: HostEndpoint): String {
return "$scheme://$host$portPart" return "$scheme://$host$portPart"
} }
/**
* `Cookie: webterm_auth=<t>` for the probe's HTTP legs, or NO header when the host has no token —
* derived from the single point ([AuthCookie]), never hand-assembled.
*/
private fun authHeaders(token: String?): Map<String, String> =
if (token == null) emptyMap() else mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token))
private const val LIVE_SESSIONS_PATH = "/live-sessions" private const val LIVE_SESSIONS_PATH = "/live-sessions"
private const val ORIGIN_HEADER = "Origin" private const val ORIGIN_HEADER = "Origin"
private const val HTTP_OK = 200 private const val HTTP_OK = 200
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_NO_CONTENT = 204 private const val HTTP_NO_CONTENT = 204
private const val HTTP_FORBIDDEN = 403 private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404 private const val HTTP_NOT_FOUND = 404

View File

@@ -19,6 +19,7 @@ import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.decodeGitError import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport import wang.yaojia.webterm.wire.HttpTransport
@@ -36,10 +37,17 @@ import java.util.UUID
* *
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped), * The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input. * statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
*
* **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is
* stamped as `Cookie: webterm_auth=<t>` on EVERY route (RO included) inside the same single point that
* stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" —
* an unauthenticated LAN host behaves exactly as before. A **401** from any route becomes the typed
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token.
*/ */
public class ApiClient( public class ApiClient(
public val endpoint: HostEndpoint, public val endpoint: HostEndpoint,
private val http: HttpTransport, private val http: HttpTransport,
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
) { ) {
// ── RO (read-only — NO Origin header) ────────────────────────────────────────────────── // ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
@@ -249,9 +257,18 @@ public class ApiClient(
// ── Internals ────────────────────────────────────────────────────────────────────────── // ── Internals ──────────────────────────────────────────────────────────────────────────
/**
* The ONE dispatch point: stamp Origin (iff guarded) + the auth cookie (iff a token is stored),
* send, and translate a **401** into the typed [ApiClientError.Unauthorized] BEFORE any per-route
* status mapping — otherwise a guarded git write would degrade a 401 into a generic `Rejected`
* outcome and the UI would never learn to ask for the token (ios-completion §1.1).
*/
private suspend fun perform(route: ApiRoute): HttpResponse { private suspend fun perform(route: ApiRoute): HttpResponse {
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint))
return http.send(request) ?: throw ApiClientError.InvalidRequest
val response = http.send(request)
if (response.status == HttpStatus.UNAUTHORIZED) throw ApiClientError.Unauthorized
return response
} }
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */ /** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */

View File

@@ -20,6 +20,13 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */ /** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。") public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
/**
* **401** from ANY route (RO or guarded): this host has `WEBTERM_TOKEN` set and our request carried
* no cookie / a wrong one (ios-completion §1.1). Distinct from [Forbidden] (that is the Origin
* guard) — the UI must send the user to enter or fix the host's access token.
*/
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
/** 403 from a G route's Origin guard (CSWSH defence). */ /** 403 from a G route's Origin guard (CSWSH defence). */
public data object Forbidden : ApiClientError("服务器拒绝了此来源Origin 校验未通过)。") public data object Forbidden : ApiClientError("服务器拒绝了此来源Origin 校验未通过)。")

View File

@@ -1,5 +1,6 @@
package wang.yaojia.webterm.api.routes package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest import wang.yaojia.webterm.wire.HttpRequest
@@ -10,6 +11,7 @@ internal object HttpStatus {
const val OK = 200 const val OK = 200
const val NO_CONTENT = 204 const val NO_CONTENT = 204
const val BAD_REQUEST = 400 const val BAD_REQUEST = 400
const val UNAUTHORIZED = 401
const val FORBIDDEN = 403 const val FORBIDDEN = 403
const val NOT_FOUND = 404 const val NOT_FOUND = 404
const val TOO_MANY_REQUESTS = 429 const val TOO_MANY_REQUESTS = 429
@@ -24,6 +26,12 @@ internal object HttpStatus {
internal object HeaderName { internal object HeaderName {
const val ORIGIN = "Origin" const val ORIGIN = "Origin"
const val CONTENT_TYPE = "Content-Type" const val CONTENT_TYPE = "Content-Type"
/**
* Explicit on the `/auth` probe: the server treats an `Accept` containing `text/html` as a browser
* form submit and answers **302** instead of 204/401 (`src/server.ts` `acceptsHtml`).
*/
const val ACCEPT = "Accept"
} }
internal object ContentType { internal object ContentType {
@@ -55,19 +63,35 @@ internal class ApiRoute(
val originPolicy: OriginPolicy, val originPolicy: OriginPolicy,
val body: ByteArray? = null, val body: ByteArray? = null,
val percentEncodedQuery: String? = null, val percentEncodedQuery: String? = null,
/** Explicit `Accept` when the server's behaviour depends on it (the `/auth` probe). */
val accept: String? = null,
) { ) {
/** /**
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the * Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are * query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base * dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
* URL cannot be parsed (surfaced by the client as `InvalidRequest`). * URL cannot be parsed (surfaced by the client as `InvalidRequest`).
*
* Two headers are stamped HERE and only here, and they are ORTHOGONAL (ios-completion §1.1):
* - `Origin` **iff** the route is [OriginPolicy.GUARDED] (the CSWSH 铁律, unchanged);
* - `Cookie: webterm_auth=<t>` whenever [accessToken] is non-null — on EVERY route, read-only
* ones included, because the server's access-token gate sits in front of the whole app. A null
* token adds no header at all, keeping an unauthenticated host byte-identical to before.
* The token is secret material: it is only ever placed in this header — never in [path], never in
* [percentEncodedQuery], never logged.
*/ */
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? { fun toHttpRequest(endpoint: HostEndpoint, accessToken: String? = null): HttpRequest? {
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
val headers = LinkedHashMap<String, String>() val headers = LinkedHashMap<String, String>()
if (originPolicy == OriginPolicy.GUARDED) { if (originPolicy == OriginPolicy.GUARDED) {
headers[HeaderName.ORIGIN] = endpoint.originHeader headers[HeaderName.ORIGIN] = endpoint.originHeader
} }
if (accessToken != null) {
headers[AuthCookie.HEADER_NAME] = AuthCookie.headerValue(accessToken)
}
if (accept != null) {
headers[HeaderName.ACCEPT] = accept
}
if (body != null) { if (body != null) {
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
} }

View File

@@ -114,6 +114,33 @@ internal object Endpoints {
private const val FCM_TOKEN_PATH = "/push/fcm-token" private const val FCM_TOKEN_PATH = "/push/fcm-token"
// ── G: access-token validation probe (`POST /auth`, ios-completion §1.1) ───────────────
/**
* `POST /auth` — the pairing-time token-validation probe. Body `{"token":"<t>"}`.
*
* Two non-obvious requirements, both server-driven (`src/server.ts`):
* - **`Accept` must not contain `text/html`.** The route is dual-mode: an HTML-accepting request
* is treated as a browser form and answered with a **302** redirect instead of 204/401.
* - the route sits BEFORE the auth gate, so it is the one request that legitimately carries no
* auth cookie (the token is in the body — this IS the login).
*
* Guarded (a state-changing POST) so it stamps `Origin` like every other write; the server does not
* Origin-check `/auth`, but keeping the Origin-iff-guarded rule uniform avoids a special case.
*/
fun auth(token: String): ApiRoute {
val body = ModelJson.encodeToString(AuthBody.serializer(), AuthBody(token)).encodeToByteArray()
return ApiRoute(
HttpMethod.POST,
AUTH_PATH,
OriginPolicy.GUARDED,
body = body,
accept = ContentType.JSON,
)
}
private const val AUTH_PATH = "/auth"
// ── G: worktree write (create / remove / prune) ──────────────────────────────────────── // ── G: worktree write (create / remove / prune) ────────────────────────────────────────
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */ /** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
@@ -199,6 +226,10 @@ internal object Endpoints {
@Serializable @Serializable
private data class FcmTokenBody(val token: String) private data class FcmTokenBody(val token: String)
/** `POST /auth` body. Holds secret material — never log a request built from it. */
@Serializable
private data class AuthBody(val token: String)
@Serializable @Serializable
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null) private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)

View File

@@ -0,0 +1,159 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.io.IOException
/**
* B5 · `POST /auth` as the pairing-time validation probe (FROZEN contract, ios-completion §1.1).
*
* The four distinct server outcomes must stay distinguishable:
* | 204 **with** `Set-Cookie` | token correct → persist it |
* | 204 **without** `Set-Cookie` | the host has NO auth configured → do NOT persist, and do NOT treat as authenticated |
* | 401 | the token is wrong → UI says "令牌不正确" |
* | 429 | rate-limited (10/min/IP) → UI says "尝试过多" |
*
* Plus the request shape the server needs: JSON body `{"token":"<t>"}` and an `Accept` that does NOT
* contain `text/html` (an HTML-accepting request is treated as a browser form and answered with a 302
* instead of 204/401).
*/
class AuthProbeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val transport = FakeHttpTransport()
private fun queueAuth(status: Int, headers: Map<String, String> = emptyMap()) {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/auth", status = status, headers = headers)
}
private val setCookieHeader = mapOf(
"Set-Cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict",
)
// ── the four frozen outcomes ────────────────────────────────────────────────────────────────
@Test
fun `204 with Set-Cookie means the token is correct`() = runTest {
queueAuth(204, setCookieHeader)
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `204 without Set-Cookie means the host has no auth configured`() = runTest {
queueAuth(204)
assertEquals(
AuthProbeResult.AuthDisabled,
probeAccessToken(endpoint, transport, TOKEN),
"204-no-cookie must NEVER be read as 'authenticated' (frozen §1.1)",
)
}
@Test
fun `401 means the token is wrong`() = runTest {
queueAuth(401)
assertEquals(AuthProbeResult.InvalidToken, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `429 means rate-limited`() = runTest {
queueAuth(429)
assertEquals(AuthProbeResult.RateLimited, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `any other status is surfaced as Unexpected with the code`() = runTest {
queueAuth(302) // e.g. a proxy/login redirect — never silently treated as success
assertEquals(AuthProbeResult.Unexpected(302), probeAccessToken(endpoint, transport, TOKEN))
}
// ── request shape ───────────────────────────────────────────────────────────────────────────
@Test
fun `the probe posts the token as JSON with an Accept that excludes text-html`() = runTest {
queueAuth(204, setCookieHeader)
probeAccessToken(endpoint, transport, TOKEN)
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/auth", request.url)
assertEquals("""{"token":"$TOKEN"}""", request.body?.decodeToString())
assertEquals("application/json", request.headers["Content-Type"])
val accept = request.headers["Accept"].orEmpty()
assertTrue(accept.isNotEmpty(), "Accept must be explicit, not left to the HTTP stack")
assertFalse(accept.contains("text/html"), "text/html would make the server answer 302, not 204/401")
}
@Test
fun `the token never appears in the URL`() = runTest {
queueAuth(401)
probeAccessToken(endpoint, transport, TOKEN)
assertFalse(
transport.recordedRequests.single().url.contains(TOKEN),
"a secret must never travel in a URL (query strings land in logs/history)",
)
}
// ── boundary validation + transport failure ─────────────────────────────────────────────────
@Test
fun `a malformed token is rejected before any network IO`() = runTest {
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "short"))
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "has spaces in it here"))
assertTrue(transport.recordedRequests.isEmpty(), "a malformed token must not hit the network")
}
@Test
fun `a whitespace-padded token is trimmed once and accepted`() = runTest {
queueAuth(204, setCookieHeader)
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, " $TOKEN\n"))
assertEquals("""{"token":"$TOKEN"}""", transport.recordedRequests.single().body?.decodeToString())
}
@Test
fun `a transport failure propagates so the caller can classify it`() = runTest {
transport.queueFailure(method = HttpMethod.POST, url = "$BASE/auth", error = IOException("refused"))
val thrown = runCatching { probeAccessToken(endpoint, transport, TOKEN) }.exceptionOrNull()
assertTrue(thrown is IOException, "network classification stays with PairingError.classify")
}
@Test
fun `a Set-Cookie header is recognised case-insensitively`() = runTest {
queueAuth(204, mapOf("set-cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/"))
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `a Set-Cookie for some other cookie does not count as accepted`() = runTest {
queueAuth(204, mapOf("Set-Cookie" to "sessionid=abc; Path=/"))
assertEquals(
AuthProbeResult.AuthDisabled,
probeAccessToken(endpoint, transport, TOKEN),
"only a webterm_auth cookie proves the token was accepted",
)
}
}

View File

@@ -0,0 +1,90 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
/**
* B5 · the two-step pairing probe under an access-token-protected host:
* - both HTTP legs (`GET /live-sessions` and the guarded `DELETE /live-sessions/:id`) carry the
* hand-written `Cookie: webterm_auth=<t>`;
* - a **401** on the reachability leg is [PairingError.AccessTokenRequired] — NOT
* "端口对吗?" ([PairingError.HttpOkButNotWebTerminal]) and NOT an Origin problem, so the UI can ask
* for the token instead of sending the user on a wild goose chase;
* - with no token configured nothing changes (no `Cookie` header at all).
*/
class PairingProbeTokenTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
const val SERVER_ID = "11111111-1111-4111-8111-111111111111"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val http = FakeHttpTransport()
private val ws = FakeTermTransport()
private fun scriptHappyPath() {
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 204)
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
}
private suspend fun probe(tokens: AccessTokenSource): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = null, tokens = tokens)
@Test
fun `both HTTP legs of the probe carry the auth cookie`() = runTest {
scriptHappyPath()
val result = probe(AccessTokenSource { TOKEN })
assertEquals(PairingProbeResult.Success(endpoint), result)
assertEquals(2, http.recordedRequests.size, "reachability GET + guarded kill DELETE")
http.recordedRequests.forEach { request ->
assertEquals(
"${AuthCookie.NAME}=$TOKEN",
request.headers[AuthCookie.HEADER_NAME],
"every probe request must present the token",
)
}
}
@Test
fun `with no token the probe requests are byte-identical to before the feature`() = runTest {
scriptHappyPath()
probe(AccessTokenSource.NONE)
http.recordedRequests.forEach { request ->
assertFalse(request.headers.containsKey(AuthCookie.HEADER_NAME))
}
}
@Test
fun `a 401 on the reachability leg asks for an access token`() = runTest {
http.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
val result = probe(AccessTokenSource.NONE)
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
}
@Test
fun `a 401 on the guarded kill leg also asks for an access token`() = runTest {
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 401)
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
val result = probe(AccessTokenSource.NONE)
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
}
}

View File

@@ -0,0 +1,140 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
* B5 · the access-token cookie on the REST surface (FROZEN contract, ios-completion §1.1):
* - `Cookie: webterm_auth=<t>` is stamped on **EVERY** route — read-only GETs included — because the
* server's auth gate runs in front of the whole app, not just the guarded routes;
* - it is **orthogonal to `Origin`**: a RO route carries the cookie and still NO Origin (the
* Origin-iff-guarded 铁律 is untouched);
* - no token configured ⇒ NO `Cookie` header at all (zero-config LAN behaviour is byte-identical);
* - a **401** on any RO/G route is the typed [ApiClientError.Unauthorized], never a generic status
* error, so the UI can route the user to re-enter the token.
*/
class AccessTokenCookieTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val transport = FakeHttpTransport()
private fun clientWithToken(): ApiClient =
ApiClient(endpoint, transport, AccessTokenSource { TOKEN })
private fun clientWithoutToken(): ApiClient = ApiClient(endpoint, transport)
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
private fun assertCookie(index: Int) {
val headers = transport.recordedRequests[index].headers
assertEquals(
"${AuthCookie.NAME}=$TOKEN",
headers[AuthCookie.HEADER_NAME],
"every request must carry the hand-written auth cookie",
)
}
@Test
fun `a read-only GET carries the cookie and still no Origin`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
clientWithToken().liveSessions()
assertCookie(0)
assertFalse(
transport.recordedRequests[0].headers.containsKey(HeaderName.ORIGIN),
"the cookie is additive — a RO route must still never carry Origin",
)
}
@Test
fun `a guarded write carries BOTH the byte-equal Origin and the cookie`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204)
clientWithToken().hookDecision(ID, HookDecision.ALLOW, "cap-tok-123")
assertCookie(0)
assertEquals(endpoint.originHeader, transport.recordedRequests[0].headers[HeaderName.ORIGIN])
}
@Test
fun `the cookie rides every route shape - RO query, guarded DELETE and guarded PUT alike`() = runTest {
transport.queueSuccess(
url = "$BASE/projects/detail?path=%2Frepo",
body = """{"name":"repo","path":"/repo","isGit":true}""".toByteArray(),
)
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204)
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = "{}".toByteArray())
val client = clientWithToken()
client.projectDetail("/repo")
client.killSession(ID)
client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create())
assertEquals(3, transport.recordedRequests.size)
transport.recordedRequests.indices.forEach { assertCookie(it) }
}
@Test
fun `no configured token means no Cookie header at all`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
clientWithoutToken().liveSessions()
assertFalse(
transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME),
"an unauthenticated host must see a byte-identical request to before the feature",
)
}
@Test
fun `the token is re-read per request so a token stored later takes effect`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
var stored: String? = null
val client = ApiClient(endpoint, transport, AccessTokenSource { stored })
client.liveSessions()
stored = TOKEN
client.liveSessions()
assertFalse(transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME))
assertCookie(1)
}
@Test
fun `a 401 on a read-only route throws the typed Unauthorized error`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
val error = errorOf { clientWithoutToken().liveSessions() }
assertEquals(ApiClientError.Unauthorized, error)
assertTrue(
ApiClientError.Unauthorized.userMessage.isNotBlank(),
"the UI needs actionable copy for a missing token",
)
}
@Test
fun `a 401 on a guarded git write throws Unauthorized instead of a Rejected outcome`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 401)
assertEquals(ApiClientError.Unauthorized, errorOf { clientWithoutToken().gitPush("/repo") })
}
}

View File

@@ -15,6 +15,9 @@
<application <application
android:name=".WebTermApp" android:name=".WebTermApp"
android:label="@string/app_name" android:label="@string/app_name"
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.WebTerm" android:theme="@style/Theme.WebTerm"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"

View File

@@ -79,11 +79,16 @@ public sealed interface BannerModel {
/** /**
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no * A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance. * spinner** (reconnecting would hit the same wall forever), and — where a fresh session would
* actually help — a "新会话" affordance.
*
* [FailureReason.UNAUTHORIZED] deliberately offers NO new-session action (B5): the server 401'd the
* upgrade, so a new session would 401 too; the fix is to re-enter the host's access token (or fix its
* `ALLOWED_ORIGINS`), which the copy says.
*/ */
public data class Failed(val reason: FailureReason) : BannerModel { public data class Failed(val reason: FailureReason) : BannerModel {
override val showsSpinner: Boolean get() = false override val showsSpinner: Boolean get() = false
override val offersNewSession: Boolean get() = true override val offersNewSession: Boolean get() = reason != FailureReason.UNAUTHORIZED
} }
/** /**
@@ -184,6 +189,8 @@ private fun bannerCopy(model: BannerModel): String = when (model) {
is BannerModel.Failed -> when (model.reason) { is BannerModel.Failed -> when (model.reason) {
FailureReason.REPLAY_TOO_LARGE -> FailureReason.REPLAY_TOO_LARGE ->
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。" "回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
FailureReason.UNAUTHORIZED ->
"服务器拒绝了连接401。请在“配对主机”里重新填写访问令牌若已填写请检查主机的 ALLOWED_ORIGINS。"
} }
is BannerModel.Exited -> is BannerModel.Exited ->
if (model.isSpawnFailure) { if (model.isSpawnFailure) {

View File

@@ -0,0 +1,42 @@
package wang.yaojia.webterm.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.hostregistry.AccessTokenStore
import wang.yaojia.webterm.hostregistry.TinkAccessTokenStore
import wang.yaojia.webterm.wire.AccessTokenSource
import javax.inject.Singleton
/**
* Access-token boundary (B5 / ios-completion §1.1): the ONE per-host access-token store, exposed both as
* the mutable [AccessTokenStore] (the pairing screen writes it) and as the read-only
* [AccessTokenSource] the transports consume.
*
* It is its own Hilt module — not part of [StorageModule] — because the token is SECRET material:
* [StorageModule]'s DataStore holds only non-secret UI state, while this store is
* Tink-AEAD-encrypted under an AndroidKeystore-wrapped master key (the same posture as [TlsModule]'s
* cert store, with its own keyset so the two secrets never share a key).
*
* Both providers resolve to the SAME singleton, so a token stored during pairing is immediately visible
* to the WS upgrade and to every REST route — no cache to invalidate. The store is constructor-I/O-free
* (Tink/Keystore work is lazy and hops to `Dispatchers.IO` inside), so injecting it on `Main` is cheap.
*/
@Module
@InstallIn(SingletonComponent::class)
public object AuthModule {
@Provides
@Singleton
public fun provideAccessTokenStore(
@ApplicationContext context: Context,
): AccessTokenStore = TinkAccessTokenStore(context)
/** The transports' read seam — the SAME instance the pairing screen wrote to. */
@Provides
@Singleton
public fun provideAccessTokenSource(store: AccessTokenStore): AccessTokenSource = store
}

View File

@@ -12,6 +12,7 @@ import wang.yaojia.webterm.transport.OkHttpClientFactory
import wang.yaojia.webterm.transport.OkHttpHttpTransport import wang.yaojia.webterm.transport.OkHttpHttpTransport
import wang.yaojia.webterm.transport.OkHttpTermTransport import wang.yaojia.webterm.transport.OkHttpTermTransport
import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HttpTransport import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TermTransport import wang.yaojia.webterm.wire.TermTransport
import javax.inject.Singleton import javax.inject.Singleton
@@ -65,10 +66,18 @@ public object NetworkModule {
.connectionPool(connectionPool) .connectionPool(connectionPool)
.build() .build()
/** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */ /**
* WS transport over the shared client (it derives a streaming-tuned variant sharing the pool).
*
* [tokens] ([AuthModule]) is what makes the **WS upgrade** carry `Cookie: webterm_auth=<t>` — the
* REST half gets its cookie from `:api-client`'s single stamping point instead (B5 / §1.1).
*/
@Provides @Provides
@Singleton @Singleton
public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client) public fun provideTermTransport(
client: OkHttpClient,
tokens: AccessTokenSource,
): TermTransport = OkHttpTermTransport(client, tokens)
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */ /** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
@Provides @Provides

View File

@@ -58,6 +58,10 @@ public fun PairingPane(
runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false) runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false)
} }
}, },
// B5 · the access-token half: validated with POST /auth, then kept in the Keystore-backed store
// the transports read from.
tokenStore = env.accessTokenStore,
authProber = env.buildAccessTokenProber(),
) )
} }
LaunchedEffect(viewModel) { viewModel.bind(this) } LaunchedEffect(viewModel) { viewModel.bind(this) }
@@ -189,7 +193,8 @@ public fun DiffPane(
} }
val viewModel = remember(resolved, path) { val viewModel = remember(resolved, path) {
DiffViewModel( DiffViewModel(
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), // The hand-built diff route needs the access-token cookie too (B5 / §1.1).
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport, env.accessTokenStore),
path = path, path = path,
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security). // Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)), writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),

View File

@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -38,6 +39,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -50,8 +54,10 @@ import com.google.mlkit.vision.common.InputImage
import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.viewmodels.EntryError import wang.yaojia.webterm.viewmodels.EntryError
import wang.yaojia.webterm.viewmodels.PairingCopy
import wang.yaojia.webterm.viewmodels.PairingUiState import wang.yaojia.webterm.viewmodels.PairingUiState
import wang.yaojia.webterm.viewmodels.PairingViewModel import wang.yaojia.webterm.viewmodels.PairingViewModel
import wang.yaojia.webterm.viewmodels.TokenError
import wang.yaojia.webterm.viewmodels.WarningTier import wang.yaojia.webterm.viewmodels.WarningTier
import wang.yaojia.webterm.viewmodels.needsExplicitAck import wang.yaojia.webterm.viewmodels.needsExplicitAck
@@ -65,6 +71,12 @@ import wang.yaojia.webterm.viewmodels.needsExplicitAck
* requires an explicit acknowledge, and a tunnel host routes to the device-cert screen when the probe is * requires an explicit acknowledge, and a tunnel host routes to the device-cert screen when the probe is
* cert-gated. Every displayed URL/host is INERT [Text] (no autolink/markdown, plan §8). * cert-gated. Every displayed URL/host is INERT [Text] (no autolink/markdown, plan §8).
* *
* ### Access token (B5 / ios-completion §1.1)
* The confirm step carries an OPTIONAL access-token field (masked, with a reveal toggle). The typed token
* never enters the ViewModel's UI state — it is passed straight into `confirm()/retry()`, validated by
* `POST /auth`, and (only when the server accepts it) stored in the Keystore-backed store. A wrong token /
* rate-limit / "this host requires a token" comes back as an inert [TokenError] under the field.
*
* ### Permissions * ### Permissions
* - **CAMERA** (QR only) is requested at runtime with a rationale; a denial degrades gracefully to * - **CAMERA** (QR only) is requested at runtime with a rationale; a denial degrades gracefully to
* manual entry (the QR path is never mandatory). * manual entry (the QR path is never mandatory).
@@ -104,7 +116,7 @@ public fun PairingScreen(
is PairingUiState.Confirming -> ConfirmStep( is PairingUiState.Confirming -> ConfirmStep(
state = s, state = s,
onName = viewModel::setName, onName = viewModel::setName,
onConfirm = viewModel::confirm, onConfirm = { ack, token -> viewModel.confirm(acknowledgedPublicRisk = ack, accessToken = token) },
onCancel = viewModel::reset, onCancel = viewModel::reset,
) )
is PairingUiState.Probing -> ProbingStep(host = s.name) is PairingUiState.Probing -> ProbingStep(host = s.name)
@@ -228,10 +240,16 @@ private fun CameraPreview(onScanned: (String) -> Unit) {
private fun ConfirmStep( private fun ConfirmStep(
state: PairingUiState.Confirming, state: PairingUiState.Confirming,
onName: (String) -> Unit, onName: (String) -> Unit,
onConfirm: (Boolean) -> Unit, onConfirm: (Boolean, String) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
) { ) {
var acknowledged by remember(state.endpoint) { mutableStateOf(false) } var acknowledged by remember(state.endpoint) { mutableStateOf(false) }
// The token lives ONLY here (and in the encrypted store once accepted) — never in the ViewModel's
// UI state, so it cannot leak through a state snapshot / crash report (§1.1). Keyed on the endpoint
// so a token typed for one host is dropped when the candidate changes, but survives a re-render
// after a wrong-token error (the field keeps what the user typed).
var token by remember(state.endpoint) { mutableStateOf("") }
var tokenVisible by remember(state.endpoint) { mutableStateOf(false) }
TierWarningCard(tier = state.tier, highlight = state.awaitingAck && !acknowledged) TierWarningCard(tier = state.tier, highlight = state.awaitingAck && !acknowledged)
// The dialed URL is validated but user/QR-sourced — render INERT (plan §8). // The dialed URL is validated but user/QR-sourced — render INERT (plan §8).
@@ -249,6 +267,14 @@ private fun ConfirmStep(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
AccessTokenField(
token = token,
onToken = { token = it },
visible = tokenVisible,
onToggleVisible = { tokenVisible = !tokenVisible },
error = state.tokenError,
)
if (state.tier.needsExplicitAck) { if (state.tier.needsExplicitAck) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it }) Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it })
@@ -259,13 +285,58 @@ private fun ConfirmStep(
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") } OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
Button( Button(
onClick = { onConfirm(acknowledged) }, onClick = { onConfirm(acknowledged, token) },
enabled = !state.tier.needsExplicitAck || acknowledged, enabled = !state.tier.needsExplicitAck || acknowledged,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { Text("连接") } ) { Text("连接") }
} }
} }
/**
* The optional access-token field (B5 / ios-completion §1.1). Masked by default with an explicit
* reveal toggle (a token is often typed from another screen and mistypes are the #1 failure), and
* `KeyboardType.Password` + `autoCorrectEnabled = false` so the IME never learns or suggests it.
* [error] renders the inert, app-authored copy for the typed [TokenError].
*/
@Composable
private fun AccessTokenField(
token: String,
onToken: (String) -> Unit,
visible: Boolean,
onToggleVisible: () -> Unit,
error: TokenError?,
) {
OutlinedTextField(
value = token,
onValueChange = onToken,
label = { Text("访问令牌可选WEBTERM_TOKEN") },
singleLine = true,
isError = error != null,
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
autoCorrectEnabled = false,
),
trailingIcon = {
TextButton(onClick = onToggleVisible) { Text(if (visible) "隐藏" else "显示") }
},
modifier = Modifier.fillMaxWidth(),
)
if (error != null) {
Text(
text = PairingCopy.describeToken(error),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
} else {
Text(
text = "主机未设置 WEBTERM_TOKEN 时留空即可。",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable @Composable
private fun TierWarningCard(tier: WarningTier, highlight: Boolean) { private fun TierWarningCard(tier: WarningTier, highlight: Boolean) {
val (title, body) = tierCopy(tier) val (title, body) = tierCopy(tier)

View File

@@ -18,6 +18,8 @@ import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.routes.ApiClient import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest import wang.yaojia.webterm.wire.HttpRequest
@@ -439,14 +441,23 @@ public class DiffUnavailable(public val status: Int) : Exception("diff unavailab
/** /**
* Real [DiffFetcher]: builds the RO `GET /projects/diff` against [endpoint] and decodes tolerantly. * Real [DiffFetcher]: builds the RO `GET /projects/diff` against [endpoint] and decodes tolerantly.
* Read-only, so it stamps NO `Origin` header (the CSWSH 铁律 — only guarded routes carry it, plan §4.3). * Read-only, so it stamps NO `Origin` header (the CSWSH 铁律 — only guarded routes carry it, plan §4.3).
*
* This route is hand-built (it is not one of `:api-client`'s 12), so the access-token cookie must be
* stamped HERE too: the server's auth gate covers EVERY route, and a missing cookie 401s (B5 /
* ios-completion §1.1). The value comes from the single derivation point ([AuthCookie]); [tokens] is
* the same store the transports read, and a host with no token adds no header at all.
*/ */
public class HttpDiffFetcher( public class HttpDiffFetcher(
private val endpoint: HostEndpoint, private val endpoint: HostEndpoint,
private val http: HttpTransport, private val http: HttpTransport,
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
) : DiffFetcher { ) : DiffFetcher {
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult { override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST) val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url)) val headers = tokens.tokenFor(endpoint)
?.let { mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(it)) }
?: emptyMap()
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url, headers = headers))
if (response.status != HTTP_OK) throw DiffUnavailable(response.status) if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
return decodeDiffResult(response.body) return decodeDiffResult(response.body)
} }

View File

@@ -1,16 +1,21 @@
package wang.yaojia.webterm.viewmodels package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.pairing.AuthProbeResult
import wang.yaojia.webterm.api.pairing.PairingError import wang.yaojia.webterm.api.pairing.PairingError
import wang.yaojia.webterm.api.pairing.PairingProbeResult import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.api.pairing.probeAccessToken
import wang.yaojia.webterm.api.pairing.runPairingProbe import wang.yaojia.webterm.api.pairing.runPairingProbe
import wang.yaojia.webterm.hostregistry.AccessTokenStore
import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostClassifier import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HostNetworkTier import wang.yaojia.webterm.wire.HostNetworkTier
@@ -42,15 +47,31 @@ import java.util.UUID
* *
* On probe success the validated host is persisted via [HostStore.upsert]. * On probe success the validated host is persisted via [HostStore.upsert].
* *
* ### RULE 5 — the access token is validated and stored BEFORE the probe (B5, ios-completion §1.1)
* When the user typed a token, [confirm]/[retry] first validate it with `POST /auth` ([authProber]) and,
* only if the server ACCEPTED it (204 **with** `Set-Cookie`), persist it to the Keystore-backed
* [tokenStore]. That ordering is load-bearing: the two-step probe's own HTTP legs AND its WS upgrade
* read the token back out of that same store, so they can only authenticate if the token is already in
* it. A 204 **without** `Set-Cookie` means the host has no auth at all — nothing is stored and pairing
* continues (never treated as "authenticated"). A wrong token / rate-limit / malformed token keeps the
* user on the confirm step with a typed [TokenError] and NEVER reaches the network probe.
*
* The token itself never enters [PairingUiState] — it lives in the screen's own field and is passed in
* per call, so no state snapshot, log or crash report can carry it.
*
* @param hostStore where a successfully-paired [Host] is saved. * @param hostStore where a successfully-paired [Host] is saved.
* @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests. * @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests.
* @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO). * @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO).
* @param tokenStore per-host access-token storage (production: the Tink/AndroidKeystore store).
* @param authProber the `POST /auth` validation seam ([accessTokenProber] in production).
* @param newId host-id allocator (default random UUID; deterministic in tests). * @param newId host-id allocator (default random UUID; deterministic in tests).
*/ */
public class PairingViewModel( public class PairingViewModel(
private val hostStore: HostStore, private val hostStore: HostStore,
private val prober: PairingProber, private val prober: PairingProber,
private val hasDeviceCert: suspend () -> Boolean, private val hasDeviceCert: suspend () -> Boolean,
private val tokenStore: AccessTokenStore,
private val authProber: AccessTokenProber,
private val newId: () -> String = { UUID.randomUUID().toString() }, private val newId: () -> String = { UUID.randomUUID().toString() },
) { ) {
private val _uiState = MutableStateFlow<PairingUiState>(PairingUiState.Entry()) private val _uiState = MutableStateFlow<PairingUiState>(PairingUiState.Entry())
@@ -111,22 +132,23 @@ public class PairingViewModel(
/** /**
* Confirm the on-screen candidate and probe it. For a public host, [acknowledgedPublicRisk] MUST be * Confirm the on-screen candidate and probe it. For a public host, [acknowledgedPublicRisk] MUST be
* true or this re-arms the acknowledge reminder without touching the network (RULE 3). * true or this re-arms the acknowledge reminder without touching the network (RULE 3).
* [accessToken] is the (optional) `WEBTERM_TOKEN` the user typed — blank means "this host has none".
*/ */
public fun confirm(acknowledgedPublicRisk: Boolean = false) { public fun confirm(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") {
val candidate = _uiState.value.candidate() ?: return val candidate = _uiState.value.candidate() ?: return
runProbe(candidate, acknowledgedPublicRisk) runProbe(candidate, acknowledgedPublicRisk, accessToken)
} }
/** /**
* Retry after a failure or a cert-gate refusal. Funnels through the SAME [runProbe] choke point as * Retry after a failure or a cert-gate refusal. Funnels through the SAME [runProbe] choke point as
* [confirm] — so the tunnel cert-gate (RULE 4) cannot be bypassed by retrying. * [confirm] — so the tunnel cert-gate (RULE 4) cannot be bypassed by retrying.
*/ */
public fun retry(acknowledgedPublicRisk: Boolean = false) { public fun retry(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") {
val candidate = _uiState.value.candidate() ?: return val candidate = _uiState.value.candidate() ?: return
runProbe(candidate, acknowledgedPublicRisk) runProbe(candidate, acknowledgedPublicRisk, accessToken)
} }
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) { private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean, accessToken: String) {
val tier = candidate.tier val tier = candidate.tier
// RULE 3 — public host: no probe until the risk is explicitly acknowledged. // RULE 3 — public host: no probe until the risk is explicitly acknowledged.
if (tier.needsExplicitAck && !acknowledgedPublicRisk) { if (tier.needsExplicitAck && !acknowledgedPublicRisk) {
@@ -147,17 +169,77 @@ public class PairingViewModel(
return@launch return@launch
} }
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier) _uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
// RULE 5 — validate + store the token BEFORE probing (the probe authenticates from the store).
if (accessToken.isNotBlank() && !establishToken(candidate, accessToken)) return@launch
when (val result = prober.probe(candidate.endpoint)) { when (val result = prober.probe(candidate.endpoint)) {
is PairingProbeResult.Success -> onProbeSuccess(candidate) is PairingProbeResult.Success -> onProbeSuccess(candidate)
is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed( is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error)
}
}
}
/**
* Validate [token] with `POST /auth` and, if the server accepted it, persist it for this host.
* Returns true when pairing may continue (accepted-and-stored, OR the host has no auth at all);
* false after publishing the matching [TokenError] / failure state.
*/
private suspend fun establishToken(candidate: Candidate, token: String): Boolean {
val outcome = try {
authProber.validate(candidate.endpoint, token)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
// A network-level failure is not a token problem — classify it like any other probe error.
onProbeFailure(candidate, PairingError.classify(error, candidate.endpoint))
return false
}
return when (outcome) {
AuthProbeResult.Accepted -> storeToken(candidate, token)
// 204 with no Set-Cookie: the host has no auth. Store NOTHING (never "assume authenticated")
// and carry on — an open host pairs exactly as it did before this feature.
AuthProbeResult.AuthDisabled -> true
AuthProbeResult.InvalidToken -> failToken(candidate, TokenError.INVALID)
AuthProbeResult.RateLimited -> failToken(candidate, TokenError.TOO_MANY_ATTEMPTS)
AuthProbeResult.Malformed -> failToken(candidate, TokenError.MALFORMED)
is AuthProbeResult.Unexpected -> failToken(candidate, TokenError.UNVERIFIABLE)
}
}
private suspend fun storeToken(candidate: Candidate, token: String): Boolean {
val stored = runCatching { tokenStore.put(candidate.endpoint, token) }
return when {
stored.getOrNull() == true -> true
// put() == false ⇒ the token failed the client-side rule (unreachable after a server ACCEPT).
stored.isSuccess -> failToken(candidate, TokenError.MALFORMED)
// A throw ⇒ encrypted storage failed; never continue with a token we could not keep.
else -> failToken(candidate, TokenError.STORE_FAILED)
}
}
/** Publish a token-specific error, keeping the user ON the confirm step (the field is there). */
private fun failToken(candidate: Candidate, error: TokenError): Boolean {
_uiState.value = PairingUiState.Confirming(
endpoint = candidate.endpoint, endpoint = candidate.endpoint,
name = candidate.name, name = candidate.name,
tier = tier, tier = candidate.tier,
error = result.error, tokenError = error,
message = PairingCopy.describe(result.error, tier),
) )
return false
} }
private fun onProbeFailure(candidate: Candidate, error: PairingError) {
// The host demands a token we do not have → back to the confirm step to enter one.
if (error == PairingError.AccessTokenRequired) {
failToken(candidate, TokenError.REQUIRED)
return
} }
_uiState.value = PairingUiState.Failed(
endpoint = candidate.endpoint,
name = candidate.name,
tier = candidate.tier,
error = error,
message = PairingCopy.describe(error, candidate.tier),
)
} }
private suspend fun onProbeSuccess(candidate: Candidate) { private suspend fun onProbeSuccess(candidate: Candidate) {
@@ -186,6 +268,21 @@ public fun interface PairingProber {
public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult
} }
/**
* The `POST /auth` access-token validation seam ([probeAccessToken]); faked in tests. Kept separate from
* [PairingProber] so the ORDER (validate+store, then probe) is observable in a unit test.
*/
public fun interface AccessTokenProber {
public suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult
}
/**
* Production [AccessTokenProber]: the frozen one-shot `POST /auth` probe over the ONE shared
* `OkHttpClient`'s [HttpTransport]. The token travels in the JSON body only — never a URL, never a log.
*/
public fun accessTokenProber(http: HttpTransport): AccessTokenProber =
AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, http, token) }
/** /**
* Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared * Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared
* `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav * `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav
@@ -193,8 +290,12 @@ public fun interface PairingProber {
* `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink * `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink
* I/O on a UI thread. * I/O on a UI thread.
*/ */
public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber = public fun pairingProber(
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) } http: HttpTransport,
ws: TermTransport,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProber =
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws, tokens) }
// ── Warning tiers (plan §5.4) ─────────────────────────────────────────────────────────────────────── // ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
@@ -265,6 +366,11 @@ public sealed interface PairingUiState {
val name: String, val name: String,
val tier: WarningTier, val tier: WarningTier,
val awaitingAck: Boolean = false, val awaitingAck: Boolean = false,
/**
* Set when the access-token step failed (or the host demands one). The token VALUE is never
* carried here — only why it went wrong (§1.1: a secret never enters app state).
*/
val tokenError: TokenError? = null,
) : PairingUiState ) : PairingUiState
/** The two-step probe is running against [endpoint]. */ /** The two-step probe is running against [endpoint]. */
@@ -295,6 +401,30 @@ public enum class EntryError {
INVALID_URL, INVALID_URL,
} }
/**
* Why the access-token step failed. Rendered next to the token field on the confirm step, so the user can
* fix it in place (B5 / ios-completion §1.1).
*/
public enum class TokenError {
/** The host answered 401 to the probe: it HAS a token configured and we presented none/a wrong one. */
REQUIRED,
/** `POST /auth` → 401: the typed token is wrong. */
INVALID,
/** `POST /auth` → 429: the server's 10/min/IP limiter tripped. */
TOO_MANY_ATTEMPTS,
/** The typed token cannot be a server token at all (charset / 16512 length). No request was sent. */
MALFORMED,
/** `POST /auth` answered something unexpected — the token could not be verified either way. */
UNVERIFIABLE,
/** The token was correct but encrypted on-device storage refused to keep it. */
STORE_FAILED,
}
/** The endpoint+name+tier a probe runs against, recovered from whatever state currently holds one. */ /** The endpoint+name+tier a probe runs against, recovered from whatever state currently holds one. */
private data class Candidate(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) private data class Candidate(val endpoint: HostEndpoint, val name: String, val tier: WarningTier)
@@ -308,6 +438,22 @@ private fun PairingUiState.candidate(): Candidate? = when (this) {
/** Maps the [PairingError] taxonomy to inert, app-authored Chinese copy (never a server string, §8). */ /** Maps the [PairingError] taxonomy to inert, app-authored Chinese copy (never a server string, §8). */
internal object PairingCopy { internal object PairingCopy {
/** Inert, app-authored copy for each [TokenError]. Never echoes the token itself. */
fun describeToken(error: TokenError): String = when (error) {
TokenError.REQUIRED ->
"该主机启用了访问令牌WEBTERM_TOKEN。请填写访问令牌后再连接。"
TokenError.INVALID ->
"访问令牌不正确。请核对主机上的 WEBTERM_TOKEN 后重试。"
TokenError.TOO_MANY_ATTEMPTS ->
"尝试过多,服务器已限流(每分钟 10 次)。请稍后再试。"
TokenError.MALFORMED ->
"访问令牌格式不合法:需 16512 个字符,且只能包含 AZ az 09 . _ ~ + / = -。"
TokenError.UNVERIFIABLE ->
"无法验证访问令牌:服务器返回了意外响应。请确认地址和端口指向 web-terminal。"
TokenError.STORE_FAILED ->
"无法在本机安全保存访问令牌(加密存储写入失败)。请重试。"
}
fun describe(error: PairingError, tier: WarningTier): String = when (error) { fun describe(error: PairingError, tier: WarningTier): String = when (error) {
is PairingError.HostUnreachable -> is PairingError.HostUnreachable ->
"无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。" "无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。"
@@ -321,6 +467,7 @@ internal object PairingCopy {
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked"). // is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
if (tier == WarningTier.TUNNEL) "客户端证书无效或已被吊销。请在“设备证书”里重新导入后再试。" if (tier == WarningTier.TUNNEL) "客户端证书无效或已被吊销。请在“设备证书”里重新导入后再试。"
else "TLS 握手失败:无法验证服务器证书。" else "TLS 握手失败:无法验证服务器证书。"
PairingError.AccessTokenRequired -> describeToken(TokenError.REQUIRED)
PairingError.Timeout -> PairingError.Timeout ->
"连接超时。主机可能不可达,或响应过慢。" "连接超时。主机可能不可达,或响应过慢。"
} }

View File

@@ -2,6 +2,7 @@ package wang.yaojia.webterm.wiring
import dagger.Lazy import dagger.Lazy
import wang.yaojia.webterm.api.routes.ApiClient import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpTransport import wang.yaojia.webterm.wire.HttpTransport
import javax.inject.Inject import javax.inject.Inject
@@ -23,6 +24,7 @@ import javax.inject.Singleton
@Singleton @Singleton
public class ApiClientFactory @Inject constructor( public class ApiClientFactory @Inject constructor(
private val http: Lazy<HttpTransport>, private val http: Lazy<HttpTransport>,
private val tokens: AccessTokenSource,
) { ) {
/** /**
* Resolve the shared [HttpTransport] (→ builds the shared `OkHttpClient`) so the slow mTLS I/O runs * Resolve the shared [HttpTransport] (→ builds the shared `OkHttpClient`) so the slow mTLS I/O runs
@@ -32,6 +34,14 @@ public class ApiClientFactory @Inject constructor(
http.get() http.get()
} }
/** Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read. */ /**
public fun create(endpoint: HostEndpoint): ApiClient = ApiClient(endpoint = endpoint, http = http.get()) * Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read.
*
* Every client is handed the shared [AccessTokenSource], so EVERY REST route — including the
* read-only GETs and the push-decision POSTs — presents this host's access token when one is stored
* (B5 / ios-completion §1.1). The token is read per request, so pairing a host mid-run takes effect
* without rebuilding anything.
*/
public fun create(endpoint: HostEndpoint): ApiClient =
ApiClient(endpoint = endpoint, http = http.get(), tokens = tokens)
} }

View File

@@ -4,10 +4,15 @@ import dagger.Lazy
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import wang.yaojia.webterm.api.pairing.runPairingProbe import wang.yaojia.webterm.api.pairing.runPairingProbe
import wang.yaojia.webterm.hostregistry.AccessTokenStore
import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.LastSessionStore import wang.yaojia.webterm.hostregistry.LastSessionStore
import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.viewmodels.AccessTokenProber
import wang.yaojia.webterm.viewmodels.PairingProber import wang.yaojia.webterm.viewmodels.PairingProber
import wang.yaojia.webterm.viewmodels.accessTokenProber
import wang.yaojia.webterm.api.pairing.probeAccessToken
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HttpTransport import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TermTransport import wang.yaojia.webterm.wire.TermTransport
import javax.inject.Inject import javax.inject.Inject
@@ -46,6 +51,11 @@ import javax.inject.Singleton
public class AppEnvironment @Inject constructor( public class AppEnvironment @Inject constructor(
public val hostStore: HostStore, public val hostStore: HostStore,
public val lastSessionStore: LastSessionStore, public val lastSessionStore: LastSessionStore,
/**
* B5 · per-host access tokens (`WEBTERM_TOKEN`), Tink/AndroidKeystore-encrypted. The pairing screen
* WRITES it; the transports read the same instance through [AccessTokenSource] (see `AuthModule`).
*/
public val accessTokenStore: AccessTokenStore,
public val apiClientFactory: ApiClientFactory, public val apiClientFactory: ApiClientFactory,
public val sessionEngineFactory: SessionEngineFactory, public val sessionEngineFactory: SessionEngineFactory,
public val coldStartPolicy: ColdStartPolicy, public val coldStartPolicy: ColdStartPolicy,
@@ -80,7 +90,19 @@ public class AppEnvironment @Inject constructor(
* after [warmUp]. * after [warmUp].
*/ */
public fun buildPairingProber(): PairingProber = public fun buildPairingProber(): PairingProber =
PairingProber { endpoint -> runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) } PairingProber { endpoint ->
// The probe's HTTP legs authenticate from the SAME store the WS transport reads, so a token
// stored a moment earlier by the pairing flow applies to all three legs (B5 / §1.1).
runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get(), accessTokenStore)
}
/**
* Build the `POST /auth` access-token validation prober (B5). Like [buildPairingProber], the shared
* transport is resolved lazily INSIDE the call so building this on the UI thread does no mTLS/OkHttp
* work.
*/
public fun buildAccessTokenProber(): AccessTokenProber =
AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, httpTransportLazy.get(), token) }
/** /**
* Build the shared `OkHttpClient` and first-touch the device identity OFF `Main` (FIX 3). Resolving * Build the shared `OkHttpClient` and first-touch the device identity OFF `Main` (FIX 3). Resolving

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
B5 / ios-completion §1.1 — the app holds SECRET material on device: the per-host access token
(`WEBTERM_TOKEN`, Tink-encrypted in webterm_access_token_prefs) and the mTLS device identity
(AndroidKeyStore key + Tink-encrypted cert blob). None of it may leave the device.
`android:allowBackup="false"` already opts out of cloud backup / adb backup on every API level; this
file additionally covers API 31+ cloud-backup and device-to-device TRANSFER, where a plain
allowBackup=false does NOT by itself block transfer. Both sections exclude everything.
(The AndroidKeyStore private key is non-exportable regardless, so a transferred blob would be
undecryptable anyway — this is defence in depth, and it keeps the intent explicit.)
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>

View File

@@ -109,4 +109,27 @@ class ReconnectBannerTest {
val connecting = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Connecting)) val connecting = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Connecting))
assertEquals(connecting, bannerModel(connecting, Output("some bytes"))) assertEquals(connecting, bannerModel(connecting, Output("some bytes")))
} }
// ── 5. B5 · a 401 upgrade is terminal AND offers no new session ───────────────
@Test
fun `an unauthorized failure is terminal with no spinner and no new-session action`() {
val model = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED)))
val failed = model as BannerModel.Failed
assertEquals(FailureReason.UNAUTHORIZED, failed.reason)
assertFalse(failed.showsSpinner, "a 401 is permanent — never show a retry spinner")
assertFalse(
failed.offersNewSession,
"a new session would 401 too; the fix is the access token, not another session",
)
}
@Test
fun `an unauthorized failure outranks later transient events`() {
val failed = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED)))
assertEquals(failed, bannerModel(failed, Connection(ConnectionState.Connecting)))
assertEquals(failed, bannerModel(failed, Connection(ConnectionState.Reconnecting(1, 2.seconds))))
}
} }

View File

@@ -14,6 +14,9 @@ import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.GitWriteOutcome import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostEndpoint
/** /**
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag * A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
@@ -291,4 +294,35 @@ class DiffViewModelTest {
assertTrue(DiffUiState(canWrite = true).writeEnabled) assertTrue(DiffUiState(canWrite = true).writeEnabled)
assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled) assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
} }
// ── B5 · the hand-built diff route carries the access-token cookie ───────────────────────────
@Test
fun `the real diff fetcher stamps the auth cookie and never an Origin`() = runTest {
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
val http = FakeHttpTransport()
val url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0"
http.queueSuccess(url = url, body = """{"files":[]}""".toByteArray())
val fetcher = HttpDiffFetcher(endpoint, http, AccessTokenSource { "0123456789abcdefTOKEN" })
fetcher.fetch("/repo", staged = false, base = null)
val request = http.recordedRequests.single()
assertEquals("webterm_auth=0123456789abcdefTOKEN", request.headers["Cookie"])
assertFalse(request.headers.containsKey("Origin"), "the diff route is read-only")
}
@Test
fun `the real diff fetcher sends no cookie for a host without a token`() = runTest {
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
val http = FakeHttpTransport()
http.queueSuccess(
url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0",
body = """{"files":[]}""".toByteArray(),
)
HttpDiffFetcher(endpoint, http).fetch("/repo", staged = false, base = null)
assertFalse(http.recordedRequests.single().headers.containsKey("Cookie"))
}
} }

View File

@@ -0,0 +1,266 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.pairing.AuthProbeResult
import wang.yaojia.webterm.api.pairing.PairingError
import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.IOException
/**
* B5 · the access-token half of pairing (FROZEN contract, ios-completion §1.1). The pairing screen is
* where a token is entered, validated with `POST /auth`, and stored — so this pins:
* - **order**: the token is validated and stored BEFORE the two-step probe runs, because the probe's
* own HTTP+WS legs must already be able to authenticate;
* - the four `/auth` outcomes → the right UI state (and, for 204-no-Set-Cookie, **no** token stored);
* - a wrong token / rate-limit keeps the user ON the confirm step with a typed [TokenError] (the field
* is right there) instead of dropping them into a generic failure;
* - a host that answers 401 to the probe asks for a token ([TokenError.REQUIRED]);
* - the token NEVER appears in the UI state (no accidental leak into a state dump / crash report).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class PairingTokenViewModelTest {
private companion object {
const val LAN = "http://10.0.0.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
}
/** Records every `/auth` validation so ordering vs the pairing probe is directly assertable. */
private class FakeAuthProber(var result: (String) -> AuthProbeResult) : AccessTokenProber {
val calls = mutableListOf<String>()
override suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult {
calls += token
return result(token)
}
}
private class FakeProber(var result: (HostEndpoint) -> PairingProbeResult) : PairingProber {
val calls = mutableListOf<HostEndpoint>()
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult {
calls += endpoint
return result(endpoint)
}
}
private fun endpoint(url: String = LAN): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl(url))
private fun newVm(
prober: PairingProber,
authProber: AccessTokenProber,
tokenStore: InMemoryAccessTokenStore = InMemoryAccessTokenStore(),
store: InMemoryHostStore = InMemoryHostStore(),
) = PairingViewModel(
hostStore = store,
prober = prober,
hasDeviceCert = { false },
tokenStore = tokenStore,
authProber = authProber,
newId = { "fixed-id" },
)
// ── happy path: validate → store → probe ────────────────────────────────────────────────────
@Test
fun `a correct token is validated, stored, and only THEN is the host probed`() =
runTest(UnconfinedTestDispatcher()) {
val order = mutableListOf<String>()
val authProber = FakeAuthProber { order += "auth"; AuthProbeResult.Accepted }
val prober = FakeProber { order += "probe"; PairingProbeResult.Success(it) }
val tokens = InMemoryAccessTokenStore()
val hosts = InMemoryHostStore()
val vm = newVm(prober, authProber, tokens, hosts)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm(accessToken = TOKEN)
assertEquals(listOf("auth", "probe"), order, "the probe must be able to authenticate itself")
assertEquals(TOKEN, tokens.tokenFor(endpoint()), "an accepted token is persisted per host")
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
assertEquals(1, hosts.loadAll().size)
}
@Test
fun `a blank token skips the auth probe entirely so an open LAN host pairs as before`() =
runTest(UnconfinedTestDispatcher()) {
val authProber = FakeAuthProber { AuthProbeResult.Accepted }
val prober = FakeProber { PairingProbeResult.Success(it) }
val tokens = InMemoryAccessTokenStore()
val vm = newVm(prober, authProber, tokens)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
assertTrue(authProber.calls.isEmpty(), "no token typed ⇒ never call POST /auth")
assertEquals(1, prober.calls.size)
assertNull(tokens.tokenFor(endpoint()))
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
}
// ── the four frozen /auth outcomes ──────────────────────────────────────────────────────────
@Test
fun `204 without Set-Cookie means auth is disabled - nothing is stored but pairing continues`() =
runTest(UnconfinedTestDispatcher()) {
val authProber = FakeAuthProber { AuthProbeResult.AuthDisabled }
val prober = FakeProber { PairingProbeResult.Success(it) }
val tokens = InMemoryAccessTokenStore()
val vm = newVm(prober, authProber, tokens)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm(accessToken = TOKEN)
assertNull(tokens.tokenFor(endpoint()), "a host without auth must not get a stored token")
assertEquals(1, prober.calls.size, "the host is open — pairing proceeds")
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
}
@Test
fun `a wrong token keeps the user on the confirm step with an INVALID token error`() =
runTest(UnconfinedTestDispatcher()) {
val authProber = FakeAuthProber { AuthProbeResult.InvalidToken }
val prober = FakeProber { PairingProbeResult.Success(it) }
val tokens = InMemoryAccessTokenStore()
val vm = newVm(prober, authProber, tokens)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm(accessToken = TOKEN)
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
assertEquals(TokenError.INVALID, state.tokenError)
assertTrue(prober.calls.isEmpty(), "a wrong token must not proceed to the probe")
assertNull(tokens.tokenFor(endpoint()), "a rejected token is never stored")
}
@Test
fun `a rate-limited auth attempt surfaces TOO_MANY_ATTEMPTS`() = runTest(UnconfinedTestDispatcher()) {
val authProber = FakeAuthProber { AuthProbeResult.RateLimited }
val vm = newVm(FakeProber { PairingProbeResult.Success(it) }, authProber)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm(accessToken = TOKEN)
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
assertEquals(TokenError.TOO_MANY_ATTEMPTS, state.tokenError)
}
@Test
fun `a malformed token is reported without any network call`() = runTest(UnconfinedTestDispatcher()) {
val authProber = FakeAuthProber { AuthProbeResult.Malformed }
val prober = FakeProber { PairingProbeResult.Success(it) }
val vm = newVm(prober, authProber)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm(accessToken = "short")
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
assertEquals(TokenError.MALFORMED, state.tokenError)
assertTrue(prober.calls.isEmpty())
}
@Test
fun `an unexpected auth status never counts as success`() = runTest(UnconfinedTestDispatcher()) {
val authProber = FakeAuthProber { AuthProbeResult.Unexpected(302) }
val prober = FakeProber { PairingProbeResult.Success(it) }
val vm = newVm(prober, authProber)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm(accessToken = TOKEN)
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
assertEquals(TokenError.UNVERIFIABLE, state.tokenError)
assertTrue(prober.calls.isEmpty())
}
@Test
fun `a transport failure during auth validation maps to the normal pairing failure copy`() =
runTest(UnconfinedTestDispatcher()) {
val authProber = FakeAuthProber { throw IOException("connection refused") }
val vm = newVm(FakeProber { PairingProbeResult.Success(it) }, authProber)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm(accessToken = TOKEN)
val state = assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value)
assertTrue(state.message.isNotBlank())
}
// ── discovery: the host demands a token we do not have ──────────────────────────────────────
@Test
fun `a probe that 401s asks for an access token`() = runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber { PairingProbeResult.Failure(PairingError.AccessTokenRequired) }
val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted })
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
assertEquals(TokenError.REQUIRED, state.tokenError)
}
@Test
fun `retrying with the token now typed pairs successfully`() = runTest(UnconfinedTestDispatcher()) {
var demandToken = true
val prober = FakeProber {
if (demandToken) PairingProbeResult.Failure(PairingError.AccessTokenRequired)
else PairingProbeResult.Success(it)
}
val tokens = InMemoryAccessTokenStore()
val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
demandToken = false
vm.retry(accessToken = TOKEN)
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
assertEquals(TOKEN, tokens.tokenFor(endpoint()))
}
// ── secrecy ─────────────────────────────────────────────────────────────────────────────────
@Test
fun `the token never appears in the UI state`() = runTest(UnconfinedTestDispatcher()) {
val vm = newVm(
FakeProber { PairingProbeResult.Failure(PairingError.AccessTokenRequired) },
FakeAuthProber { AuthProbeResult.Accepted },
)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm(accessToken = TOKEN)
assertTrue(
!vm.uiState.value.toString().contains(TOKEN),
"a secret must not be reachable through a state snapshot / toString",
)
}
@Test
fun `every token error has actionable copy`() {
TokenError.entries.forEach { error ->
assertTrue(PairingCopy.describeToken(error).isNotBlank(), "copy for $error")
}
}
}

View File

@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.pairing.PairingError import wang.yaojia.webterm.api.pairing.PairingError
import wang.yaojia.webterm.api.pairing.PairingProbeResult import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
import wang.yaojia.webterm.hostregistry.InMemoryHostStore import wang.yaojia.webterm.hostregistry.InMemoryHostStore
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
@@ -44,6 +45,11 @@ class PairingViewModelTest {
hostStore = store, hostStore = store,
prober = prober, prober = prober,
hasDeviceCert = { hasCert }, hasDeviceCert = { hasCert },
// B5: these tests cover the NO-token paths — an empty store plus a prober that must never be
// called (a blank token skips `POST /auth` entirely). The token paths live in
// PairingTokenViewModelTest.
tokenStore = InMemoryAccessTokenStore(),
authProber = { _, _ -> error("POST /auth must not be called when no token was typed") },
newId = { "fixed-id" }, newId = { "fixed-id" },
) )

View File

@@ -45,6 +45,10 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
// Tink AEAD encrypts the per-host access-token blob at rest under an AndroidKeystore-wrapped
// master key (B5 / ios-completion §1.1). A SEPARATE keyset from :client-tls-android's cert store —
// different secrets, different lifecycles, and no module edge to the TLS half.
implementation(libs.tink.android)
testImplementation(libs.bundles.unit.test) testImplementation(libs.bundles.unit.test)
testRuntimeOnly(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.platform.launcher)

View File

@@ -0,0 +1,107 @@
package wang.yaojia.webterm.hostregistry
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.HostEndpoint
import java.util.UUID
/**
* Instrumented (device) contract tests for [TinkAccessTokenStore] — Tink's AEAD + the
* AndroidKeystore-wrapped master key need a real device Keystore, so these run on hardware (no
* emulator in the build env → compiled here, executed in device QA, plan §7 / DEVICE_QA_CHECKLIST).
*
* They pin the same contract the JVM [InMemoryAccessTokenStoreTest] pins for the double, PLUS the two
* things only the real store can prove: the token **survives a restart** (a fresh store instance
* decrypts the blob) and the **at-rest bytes are ciphertext**, never the token in the clear.
*/
@RunWith(AndroidJUnit4::class)
class TinkAccessTokenStoreTest {
private companion object {
const val TOKEN = "0123456789abcdefTOKEN"
}
private val context: Context get() = ApplicationProvider.getApplicationContext()
/** A store on a per-test keyset/pref-file so tests never share state. */
private fun newStore(suffix: String): TinkAccessTokenStore = TinkAccessTokenStore(
context = context,
keysetName = "test_token_keyset_$suffix",
prefFileName = "test_token_prefs_$suffix",
masterKeyUri = "android-keystore://test_token_master_$suffix",
)
private fun endpoint(url: String = "http://10.0.0.5:3000"): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl(url))
@Test
fun put_then_tokenFor_returns_the_token() = runBlocking {
val store = newStore(UUID.randomUUID().toString())
assertTrue(store.put(endpoint(), TOKEN))
assertEquals(TOKEN, store.tokenFor(endpoint()))
}
@Test
fun a_fresh_store_instance_decrypts_the_persisted_token() = runBlocking {
val suffix = UUID.randomUUID().toString()
newStore(suffix).put(endpoint(), TOKEN)
// A new instance = a cold app start: no in-memory cache, must decrypt from disk.
assertEquals(TOKEN, newStore(suffix).tokenFor(endpoint()))
}
@Test
fun the_token_is_never_stored_in_the_clear() = runBlocking {
val suffix = UUID.randomUUID().toString()
newStore(suffix).put(endpoint(), TOKEN)
val raw = context
.getSharedPreferences("test_token_prefs_$suffix", Context.MODE_PRIVATE)
.all
.values
.joinToString(" ") { it.toString() }
assertFalse("the at-rest blob must be ciphertext", raw.contains(TOKEN))
}
@Test
fun remove_drops_the_token_durably() = runBlocking {
val suffix = UUID.randomUUID().toString()
val store = newStore(suffix)
store.put(endpoint(), TOKEN)
store.remove(endpoint())
assertNull(store.tokenFor(endpoint()))
assertNull("a removed token must not come back on a cold start", newStore(suffix).tokenFor(endpoint()))
}
@Test
fun a_malformed_token_is_rejected_without_touching_storage() = runBlocking {
val suffix = UUID.randomUUID().toString()
val store = newStore(suffix)
assertFalse(store.put(endpoint(), "short"))
assertNull(store.tokenFor(endpoint()))
}
@Test
fun tokens_are_keyed_per_host() = runBlocking {
val store = newStore(UUID.randomUUID().toString())
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
assertNull(store.tokenFor(endpoint("http://10.0.0.9:3000")))
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/")))
}
}

View File

@@ -0,0 +1,70 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import wang.yaojia.webterm.wire.AccessTokenRule
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostEndpoint
/**
* Per-host storage for the optional shared access token (`WEBTERM_TOKEN`, ios-completion §1.1).
*
* It IS an [AccessTokenSource], so the very same instance that the pairing screen writes to is what
* `:api-client` (every REST route) and `:transport-okhttp` (the WS upgrade) read from — one store, no
* adapter, no cache to fall out of sync.
*
* ### Keying: the canonical origin, not the raw base URL
* The key is [HostEndpoint.originHeader] — already single-point-derived, lower-cased, default-port
* normalized. So `http://box:3000`, `HTTP://box:3000/` and `http://box:3000/some/path` are ONE host
* (they are literally the same server, and the token is the server's), while a different port or host
* is a different key. Storing under the raw `baseUrl` would silently lose the token when the same
* server was re-paired from a URL with a trailing slash.
*
* ### Security contract (non-negotiable)
* Implementations MUST keep the token device-only (Android-Keystore-wrapped, excluded from backup),
* MUST validate through [AccessTokenRule] before persisting, and MUST NEVER log it.
*/
public interface AccessTokenStore : AccessTokenSource {
/** The token stored for [endpoint]'s canonical origin, or null. Never logs. */
override suspend fun tokenFor(endpoint: HostEndpoint): String?
/**
* Store [token] for [endpoint] (replacing any previous one). Returns **false** and stores nothing
* when the token fails [AccessTokenRule] — a value the server could never accept must not be
* persisted (nor round-tripped to disk) at all.
*/
public suspend fun put(endpoint: HostEndpoint, token: String): Boolean
/** Drop [endpoint]'s token. Idempotent — removing an unknown host is a no-op. */
public suspend fun remove(endpoint: HostEndpoint)
}
/**
* In-memory [AccessTokenStore]. Lives in `main` (not `test`) on purpose — it doubles for the encrypted
* store in this module's contract tests AND in the `:app` pairing-ViewModel tests (mirrors
* [InMemoryHostStore]).
*
* A [Mutex] serializes the read-modify-write; the state is an immutable map replaced wholesale on every
* change (never mutated in place).
*/
public class InMemoryAccessTokenStore(
initial: Map<String, String> = emptyMap(),
) : AccessTokenStore {
private val mutex = Mutex()
// Defensive copy so a mutable map handed to the ctor cannot alias our state.
private var tokensByOrigin: Map<String, String> = initial.toMap()
override suspend fun tokenFor(endpoint: HostEndpoint): String? =
mutex.withLock { tokensByOrigin[endpoint.originHeader] }
override suspend fun put(endpoint: HostEndpoint, token: String): Boolean {
val normalized = AccessTokenRule.normalize(token) ?: return false
mutex.withLock { tokensByOrigin = tokensByOrigin + (endpoint.originHeader to normalized) }
return true
}
override suspend fun remove(endpoint: HostEndpoint) {
mutex.withLock { tokensByOrigin = tokensByOrigin - endpoint.originHeader }
}
}

View File

@@ -0,0 +1,153 @@
package wang.yaojia.webterm.hostregistry
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.RegistryConfiguration
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.integration.android.AndroidKeysetManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import wang.yaojia.webterm.wire.AccessTokenRule
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.IOException
/**
* The production [AccessTokenStore]: per-host access tokens AEAD-encrypted with Tink under an
* **AndroidKeystore-wrapped master key** (ios-completion §1.1 storage rule — the Android analogue of
* iOS Keychain `…AfterFirstUnlockThisDeviceOnly` + no `kSecAttrSynchronizable`).
*
* What that buys, concretely:
* - the master key lives in the Keystore (`android-keystore://…`) and is **non-exportable**, so the
* on-disk ciphertext is useless on any other device — device-only, exactly like the cert store;
* - the blob sits in an app-private `SharedPreferences` file (uninstall-wiped) and the app manifest
* sets `android:allowBackup="false"`, so it is **never** in a cloud/adb backup;
* - nothing here logs, and the token never enters a URL (see `AuthCookie`).
*
* Deliberately a SEPARATE keyset/pref-file/master-key from `:client-tls-android`'s `TinkCertStore`:
* different secrets with different lifecycles must not share an AEAD keyset (and this module must not
* gain a dependency on the TLS module). The ~20 lines of Tink setup are the price of that isolation.
*
* All disk + crypto work hops to [io] (never `Main`); the decrypted map is cached in memory after the
* first read so the per-request [tokenFor] on the WS-dial / REST path is a cheap map lookup, and a
* [Mutex] serializes the read-modify-write of a mutation. A blob that fails to decrypt/decode (tamper,
* version skew, a wiped Keystore key after a device restore) degrades to "no tokens" — the user
* re-enters the token — rather than throwing on every request.
*/
public class TinkAccessTokenStore(
context: Context,
private val io: CoroutineDispatcher = Dispatchers.IO,
private val keysetName: String = DEFAULT_KEYSET_NAME,
private val prefFileName: String = DEFAULT_PREF_FILE,
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
) : AccessTokenStore {
private val appContext: Context = context.applicationContext
private val mutex = Mutex()
/** Decrypted `origin → token` map; null until the first read. Replaced wholesale, never mutated. */
private var cache: Map<String, String>? = null
// Built on first use (registers AEAD, loads-or-creates the Keystore-wrapped keyset) — slow, so it
// only ever happens inside a withContext(io) block.
private val aead: Aead by lazy { buildAead() }
override suspend fun tokenFor(endpoint: HostEndpoint): String? =
loadTokens()[endpoint.originHeader]
/**
* Validate → encrypt → durably persist → publish to the cache, in that order: a token the server
* could never accept returns false without touching disk, and the in-memory view is only updated
* after the write is on disk (so a failed write can never leave a "stored" token that vanishes on
* the next launch).
*
* @throws IOException when the durable write fails (infrastructure failure, distinct from `false`,
* which means the token itself was malformed).
*/
override suspend fun put(endpoint: HostEndpoint, token: String): Boolean {
val normalized = AccessTokenRule.normalize(token) ?: return false
mutex.withLock {
val updated = readThrough() + (endpoint.originHeader to normalized)
persist(updated)
cache = updated
}
return true
}
override suspend fun remove(endpoint: HostEndpoint) {
mutex.withLock {
val current = readThrough()
if (!current.containsKey(endpoint.originHeader)) return // idempotent, no needless write
val updated = current - endpoint.originHeader
persist(updated)
cache = updated
}
}
private suspend fun loadTokens(): Map<String, String> =
cache ?: mutex.withLock {
val loaded = readThrough()
cache = loaded
loaded
}
/** Decrypt the stored blob (or the cache when warm). MUST be called with [mutex] held. */
private suspend fun readThrough(): Map<String, String> =
cache ?: withContext(io) { decodeBlob(prefs().getString(BLOB_KEY, null)) }
private suspend fun persist(tokens: Map<String, String>) {
withContext(io) {
val plaintext = JSON.encodeToString(TOKEN_MAP_SERIALIZER, tokens).encodeToByteArray()
val ciphertext = aead.encrypt(plaintext, ASSOCIATED_DATA)
val committed = prefs().edit()
.putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
.commit() // synchronous + reports failure (as TinkCertStore); apply() would hide it
if (!committed) throw IOException("failed to durably persist the access-token blob")
}
}
/** Any decrypt/decode failure ⇒ "no tokens" (never crash a request path on a corrupt blob). */
private fun decodeBlob(encoded: String?): Map<String, String> {
if (encoded == null) return emptyMap()
return runCatching {
val ciphertext = Base64.decode(encoded, Base64.NO_WRAP)
val plaintext = aead.decrypt(ciphertext, ASSOCIATED_DATA)
JSON.decodeFromString(TOKEN_MAP_SERIALIZER, plaintext.decodeToString())
}.getOrDefault(emptyMap())
}
private fun buildAead(): Aead {
AeadConfig.register()
val keysetHandle = AndroidKeysetManager.Builder()
.withSharedPref(appContext, keysetName, prefFileName)
.withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
.withMasterKeyUri(masterKeyUri)
.build()
.keysetHandle
return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
}
private fun prefs(): SharedPreferences =
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
public companion object {
private const val DEFAULT_KEYSET_NAME = "webterm_access_token_keyset"
private const val DEFAULT_PREF_FILE = "webterm_access_token_prefs"
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_access_token_master_key"
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
private const val BLOB_KEY = "access_tokens_blob"
/** AEAD associated data — binds the ciphertext to this context so a lifted blob won't decrypt. */
private val ASSOCIATED_DATA: ByteArray = "webterm.host-registry.access-tokens".toByteArray()
private val JSON = Json { ignoreUnknownKeys = true }
private val TOKEN_MAP_SERIALIZER = MapSerializer(String.serializer(), String.serializer())
}
}

View File

@@ -0,0 +1,118 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
/**
* B5 · the per-host access-token store contract (ios-completion §1.1), exercised through the pure
* in-memory double (the Tink/AndroidKeyStore-backed implementation is instrumented → device QA):
* - a token is keyed by the endpoint's CANONICAL origin, so the same server dialed with a trailing
* slash / different case / a path resolves to the SAME token, and a different host never does;
* - a malformed token is REJECTED at the boundary and never stored;
* - `remove` is idempotent, and the store doubles as the `AccessTokenSource` the transports consume.
*/
class InMemoryAccessTokenStoreTest {
private companion object {
const val TOKEN = "0123456789abcdefTOKEN"
const val OTHER_TOKEN = "fedcba9876543210OTHER"
}
private fun endpoint(url: String): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
@Test
fun `a stored token is returned for the same host`() = runTest {
val store = InMemoryAccessTokenStore()
val host = endpoint("http://10.0.0.5:3000")
assertTrue(store.put(host, TOKEN))
assertEquals(TOKEN, store.tokenFor(host))
}
@Test
fun `an unknown host has no token`() = runTest {
val store = InMemoryAccessTokenStore()
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
assertNull(store.tokenFor(endpoint("http://10.0.0.6:3000")))
}
@Test
fun `the key is the canonical origin so the same server matches however it was dialed`() = runTest {
val store = InMemoryAccessTokenStore()
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/")))
assertEquals(TOKEN, store.tokenFor(endpoint("HTTP://10.0.0.5:3000")))
}
@Test
fun `a different port is a different host`() = runTest {
val store = InMemoryAccessTokenStore()
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
assertNull(store.tokenFor(endpoint("http://10.0.0.5:3001")))
}
@Test
fun `putting again replaces the token for that host only`() = runTest {
val store = InMemoryAccessTokenStore()
val a = endpoint("http://10.0.0.5:3000")
val b = endpoint("http://10.0.0.6:3000")
store.put(a, TOKEN)
store.put(b, OTHER_TOKEN)
store.put(a, OTHER_TOKEN)
assertEquals(OTHER_TOKEN, store.tokenFor(a))
assertEquals(OTHER_TOKEN, store.tokenFor(b))
}
@Test
fun `a malformed token is rejected and never stored`() = runTest {
val store = InMemoryAccessTokenStore()
val host = endpoint("http://10.0.0.5:3000")
assertFalse(store.put(host, "short"), "below the server's 16-char minimum")
assertFalse(store.put(host, "has spaces in it here"), "outside the server charset")
assertNull(store.tokenFor(host), "a rejected token must not reach storage")
}
@Test
fun `a whitespace-padded token is trimmed once on the way in`() = runTest {
val store = InMemoryAccessTokenStore()
val host = endpoint("http://10.0.0.5:3000")
store.put(host, " $TOKEN\n")
assertEquals(TOKEN, store.tokenFor(host))
}
@Test
fun `remove drops the token and is idempotent`() = runTest {
val store = InMemoryAccessTokenStore()
val host = endpoint("http://10.0.0.5:3000")
store.put(host, TOKEN)
store.remove(host)
store.remove(host)
assertNull(store.tokenFor(host))
}
@Test
fun `the store is usable directly as the transports' AccessTokenSource`() = runTest {
val store: wang.yaojia.webterm.wire.AccessTokenSource = InMemoryAccessTokenStore(
initial = mapOf(endpoint("http://10.0.0.5:3000").originHeader to TOKEN),
)
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000")))
}
}

View File

@@ -20,6 +20,7 @@ import wang.yaojia.webterm.wire.TermTransport
import wang.yaojia.webterm.wire.TimelineEvent import wang.yaojia.webterm.wire.TimelineEvent
import wang.yaojia.webterm.wire.TransportConnection import wang.yaojia.webterm.wire.TransportConnection
import wang.yaojia.webterm.wire.Tunables import wang.yaojia.webterm.wire.Tunables
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
/** /**
* The session lifecycle state machine (A14, plan §5 / §6.6). The Android analogue of the iOS * The session lifecycle state machine (A14, plan §5 / §6.6). The Android analogue of the iOS
@@ -51,6 +52,9 @@ import wang.yaojia.webterm.wire.Tunables
* frame would be dropped rather than emitted onto a newer generation. * frame would be dropped rather than emitted onto a newer generation.
* - **oversized replay is terminal:** a frame past [maxFrameBytes] is the non-retryable * - **oversized replay is terminal:** a frame past [maxFrameBytes] is the non-retryable
* [FailureReason.REPLAY_TOO_LARGE] — distinct from a retryable disconnect (never fed to backoff). * [FailureReason.REPLAY_TOO_LARGE] — distinct from a retryable disconnect (never fed to backoff).
* - **a 401 upgrade is terminal:** an [UnauthorizedUpgradeException] out of the dial (missing/invalid
* access token, or a foreign Origin) is the non-retryable [FailureReason.UNAUTHORIZED] — it is
* permanent until the user fixes configuration, so it NEVER enters the back-off ladder.
*/ */
public class SessionEngine( public class SessionEngine(
private val transport: TermTransport, private val transport: TermTransport,
@@ -76,6 +80,19 @@ public class SessionEngine(
/** One live connection plus its optional ping capability (null = plain [TermTransport]). */ /** One live connection plus its optional ping capability (null = plain [TermTransport]). */
private class OpenConn(val connection: TransportConnection, val pinger: ConnectionPinger?) private class OpenConn(val connection: TransportConnection, val pinger: ConnectionPinger?)
/**
* Outcome of one dial. A dial failure is retryable ([Retryable]) EXCEPT an upgrade 401
* ([Unauthorized]), which is permanent until the user fixes the access token / Origin allow-list —
* so it must never be fed to the back-off ladder (see [FailureReason.UNAUTHORIZED]).
*/
private sealed interface DialResult {
class Open(val conn: OpenConn) : DialResult
data object Retryable : DialResult
data object Unauthorized : DialResult
}
private val eventsChannel = Channel<SessionEvent>(Channel.UNLIMITED) private val eventsChannel = Channel<SessionEvent>(Channel.UNLIMITED)
/** Engine→UI event stream (single consumer; the App's EventBus fans out per R10). */ /** Engine→UI event stream (single consumer; the App's EventBus fans out per R10). */
@@ -157,7 +174,14 @@ public class SessionEngine(
} }
private suspend fun runOneConnection(gen: Int): EndCause { private suspend fun runOneConnection(gen: Int): EndCause {
val open = dialOrNull() ?: return EndCause.Disconnected val open = when (val dial = dial()) {
DialResult.Retryable -> return EndCause.Disconnected
DialResult.Unauthorized -> {
emit(Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED)))
return EndCause.Terminal // no back-off: a 401 is permanent until reconfigured
}
is DialResult.Open -> dial.conn
}
if (closed) return abandon(open) // close() landed during dial → detach, never publish if (closed) return abandon(open) // close() landed during dial → detach, never publish
if (!attachFirst(open)) return EndCause.Disconnected if (!attachFirst(open)) return EndCause.Disconnected
if (closed) return abandon(open) // close() landed during attach → detach, never publish if (closed) return abandon(open) // close() landed during attach → detach, never publish
@@ -184,18 +208,35 @@ public class SessionEngine(
return EndCause.Terminal return EndCause.Terminal
} }
private suspend fun dialOrNull(): OpenConn? = private suspend fun dial(): DialResult =
try { try {
if (transport is PingableTermTransport) { if (transport is PingableTermTransport) {
val pingable = transport.connectPingable(endpoint) val pingable = transport.connectPingable(endpoint)
OpenConn(pingable.connection, pingable.pinger) DialResult.Open(OpenConn(pingable.connection, pingable.pinger))
} else { } else {
OpenConn(transport.connect(endpoint), null) DialResult.Open(OpenConn(transport.connect(endpoint), null))
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (_: Throwable) { } catch (error: Throwable) {
null // connect-time failure → retryable // A 401 upgrade is the ONE non-retryable dial failure; everything else backs off.
if (isUnauthorized(error)) DialResult.Unauthorized else DialResult.Retryable
}
/**
* True iff [error] (or a bounded, cycle-guarded walk of its causes) is the typed 401-upgrade
* rejection. The cause walk matters because a transport may wrap it in its own `IOException`.
*/
private fun isUnauthorized(error: Throwable): Boolean {
var current: Throwable? = error
var depth = 0
while (current != null && depth < MAX_CAUSE_DEPTH) {
if (current is UnauthorizedUpgradeException) return true
if (current === current.cause) return false // defensive: never loop on a self-cause
current = current.cause
depth++
}
return false
} }
/** attach-first (+ replay [lastDims]) BEFORE publishing the connection. */ /** attach-first (+ replay [lastDims]) BEFORE publishing the connection. */
@@ -411,5 +452,8 @@ public class SessionEngine(
public companion object { public companion object {
/** Max [AwayDigest.recent] entries carried in the once-per-reconnect digest. */ /** Max [AwayDigest.recent] entries carried in the once-per-reconnect digest. */
public const val DEFAULT_DIGEST_LIMIT: Int = 20 public const val DEFAULT_DIGEST_LIMIT: Int = 20
/** Bound on the `cause` walk in [isUnauthorized] (never trust an error graph not to cycle). */
private const val MAX_CAUSE_DEPTH: Int = 8
} }
} }

View File

@@ -68,4 +68,14 @@ public enum class FailureReason {
* back-off. UI copy: lower the server's SCROLLBACK_BYTES or raise the client cap. * back-off. UI copy: lower the server's SCROLLBACK_BYTES or raise the client cap.
*/ */
REPLAY_TOO_LARGE, REPLAY_TOO_LARGE,
/**
* The server answered the WS upgrade with **HTTP 401**
* ([wang.yaojia.webterm.wire.UnauthorizedUpgradeException]) — the host's access token
* (`WEBTERM_TOKEN`) is missing/wrong, or our `Origin` is not on its allow-list. Both are
* permanent until the user fixes configuration, so — exactly like [REPLAY_TOO_LARGE] — the
* engine goes terminal and NEVER enters the back-off ladder (a retry storm against a 401 is
* pointless and looks like an attack). UI copy: go fix / re-enter the access token.
*/
UNAUTHORIZED,
} }

View File

@@ -0,0 +1,110 @@
package wang.yaojia.webterm.session
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
import java.io.IOException
import kotlin.time.Duration.Companion.seconds
/**
* B5 · a **401 on the WS upgrade is TERMINAL** (ios-completion §1.1): the engine emits
* `Failed(UNAUTHORIZED)` and stops — it must NEVER enter the reconnect back-off ladder, because a
* missing/invalid access token (or a foreign Origin) 401s deterministically forever. Same treatment as
* `REPLAY_TOO_LARGE`.
*
* Driven with the frozen [FakeTermTransport] double under `runTest` virtual time (`runCurrent` /
* `advanceTimeBy`, matching `SessionEngineTest`) — no OkHttp, no host, no wall-clock waits.
*/
class SessionEngineUnauthorizedTest {
private fun endpoint(): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
private fun TestScope.newEngine(transport: FakeTermTransport): SessionEngine =
SessionEngine(transport = transport, endpoint = endpoint(), scope = backgroundScope)
/** Live-updating sink of everything the engine emits (single consumer, as in SessionEngineTest). */
private fun TestScope.collectEvents(engine: SessionEngine): List<SessionEvent> {
val out = mutableListOf<SessionEvent>()
backgroundScope.launch { engine.events.collect { out += it } }
return out
}
private fun List<SessionEvent>.connectionStates(): List<ConnectionState> =
filterIsInstance<Connection>().map { it.state }
@Test
fun `a 401 upgrade ends the engine terminally with UNAUTHORIZED and never dials again`() = runTest {
// Arrange: every dial would be rejected with the typed 401 (the server 401s until a token is stored).
val transport = FakeTermTransport()
repeat(3) { transport.scriptConnectFailure(UnauthorizedUpgradeException()) }
val engine = newEngine(transport)
val events = collectEvents(engine)
// Act
engine.start()
testScheduler.runCurrent()
testScheduler.advanceTimeBy(60.seconds) // a back-off ladder, if entered, would have fired by now
testScheduler.runCurrent()
// Assert: exactly ONE dial, a terminal Failed(UNAUTHORIZED), and no Reconnecting event at all.
assertEquals(1, transport.connectAttempts.size, "a 401 must not be retried")
assertEquals(
listOf(ConnectionState.Connecting, ConnectionState.Failed(FailureReason.UNAUTHORIZED)),
events.connectionStates(),
"a terminal 401 must never enter the back-off ladder",
)
}
@Test
fun `an ordinary transport failure still reconnects with back-off`() = runTest {
// Regression lock: only the typed 401 is terminal — a plain IOException stays retryable.
val transport = FakeTermTransport()
transport.scriptConnectFailure(IOException("connection refused"))
val engine = newEngine(transport)
val events = collectEvents(engine)
engine.start()
testScheduler.runCurrent()
testScheduler.advanceTimeBy(1.seconds) // first rung of the ladder
testScheduler.runCurrent()
assertEquals(2, transport.connectAttempts.size, "a retryable failure must redial")
assertTrue(
events.connectionStates().any { it is ConnectionState.Reconnecting },
"a retryable failure must announce the back-off",
)
assertTrue(
events.connectionStates().none { it is ConnectionState.Failed },
"a retryable failure must not be reported as a terminal failure",
)
engine.close()
testScheduler.runCurrent()
}
@Test
fun `a 401 wrapped as a cause of the transport error is still terminal`() = runTest {
// OkHttp hands back its own IOException; the transport wraps the 401 so the CAUSE carries it.
val transport = FakeTermTransport()
transport.scriptConnectFailure(IOException("upgrade failed", UnauthorizedUpgradeException()))
val engine = newEngine(transport)
val events = collectEvents(engine)
engine.start()
testScheduler.runCurrent()
testScheduler.advanceTimeBy(60.seconds)
testScheduler.runCurrent()
assertEquals(1, transport.connectAttempts.size)
assertEquals(
FailureReason.UNAUTHORIZED,
events.connectionStates().filterIsInstance<ConnectionState.Failed>().single().reason,
)
}
}

View File

@@ -1,6 +1,7 @@
package wang.yaojia.webterm.transport package wang.yaojia.webterm.transport
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import wang.yaojia.webterm.wire.AccessTokenSource
import javax.net.ssl.SSLSocketFactory import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
@@ -84,12 +85,19 @@ public class OkHttpTransports private constructor(
public val client: OkHttpClient, public val client: OkHttpClient,
) { ) {
public companion object { public companion object {
/**
* @param identityProvider optional mTLS material (see [OkHttpClientFactory.create]).
* @param tokens the access-token seam for the WS upgrade's `Cookie` header (ios-completion §1.1).
* REST requests get their cookie from `:api-client`'s single stamping point instead, so this
* only wires the WS half.
*/
public fun create( public fun create(
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE, identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): OkHttpTransports { ): OkHttpTransports {
val client = OkHttpClientFactory.create(identityProvider) val client = OkHttpClientFactory.create(identityProvider)
return OkHttpTransports( return OkHttpTransports(
term = OkHttpTermTransport(client), term = OkHttpTermTransport(client, tokens),
http = OkHttpHttpTransport(client), http = OkHttpHttpTransport(client),
client = client, client = client,
) )

View File

@@ -2,6 +2,8 @@ package wang.yaojia.webterm.transport
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.PingableConnection import wang.yaojia.webterm.wire.PingableConnection
import wang.yaojia.webterm.wire.PingableTermTransport import wang.yaojia.webterm.wire.PingableTermTransport
@@ -12,6 +14,9 @@ import java.util.concurrent.TimeUnit
/** The `Origin` header name — THE CSWSH defence; stamped byte-equal from [HostEndpoint.originHeader]. */ /** The `Origin` header name — THE CSWSH defence; stamped byte-equal from [HostEndpoint.originHeader]. */
internal const val HEADER_ORIGIN: String = "Origin" internal const val HEADER_ORIGIN: String = "Origin"
/** HTTP status the server answers an upgrade with when it rejects Origin OR the access-token cookie. */
internal const val HTTP_UNAUTHORIZED: Int = 401
/** /**
* The only concrete WS transport (A7): implements [PingableTermTransport] (and thus `TermTransport`) * The only concrete WS transport (A7): implements [PingableTermTransport] (and thus `TermTransport`)
* over OkHttp. `SessionEngine` (A14) cannot tell it apart from the `FakeTermTransport` double. * over OkHttp. `SessionEngine` (A14) cannot tell it apart from the `FakeTermTransport` double.
@@ -29,12 +34,25 @@ internal const val HEADER_ORIGIN: String = "Origin"
* is cancellation-safe: if the caller's coroutine is cancelled (or the handshake fails) while it is * is cancellation-safe: if the caller's coroutine is cancelled (or the handshake fails) while it is
* in flight, the just-created WebSocket is torn down before rethrowing, so no socket is leaked. * in flight, the just-created WebSocket is torn down before rethrowing, so no socket is leaked.
* *
* ### Access token (ios-completion §1.1)
* When [tokens] has a token for the host, the upgrade ALSO carries a hand-written
* `Cookie: webterm_auth=<t>` — no OkHttp `CookieJar`, no `Set-Cookie` parsing (a jar's behaviour on a
* WS upgrade is stack-specific and hard to test; the hand-written header is the same pattern as
* `Origin` and is asserted byte-equal by a MockWebServer test). The two headers are orthogonal: the
* server checks Origin first, then the cookie. No token ⇒ no header at all.
*
* An upgrade answered **401** (rejected Origin OR rejected/missing token — the server writes the same
* bare 401 for both) is surfaced as the typed `UnauthorizedUpgradeException`, which `SessionEngine`
* treats as TERMINAL. The original error is kept as its `cause` so `PairingError.classify`'s cause-walk
* is unaffected.
*
* Inbound frames flow up UNMODIFIED — there is deliberately NO transport-level frame-size cap here. * Inbound frames flow up UNMODIFIED — there is deliberately NO transport-level frame-size cap here.
* `SessionEngine` (A14) self-measures each inbound frame's UTF-8 size and is the single authoritative * `SessionEngine` (A14) self-measures each inbound frame's UTF-8 size and is the single authoritative
* classifier of an oversized ring-buffer replay (the non-retryable `REPLAY_TOO_LARGE` failure). * classifier of an oversized ring-buffer replay (the non-retryable `REPLAY_TOO_LARGE` failure).
*/ */
public class OkHttpTermTransport( public class OkHttpTermTransport(
sharedClient: OkHttpClient, sharedClient: OkHttpClient,
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
) : PingableTermTransport { ) : PingableTermTransport {
private val wsClient: OkHttpClient = sharedClient.newBuilder() private val wsClient: OkHttpClient = sharedClient.newBuilder()
@@ -51,10 +69,14 @@ public class OkHttpTermTransport(
} }
private suspend fun openConnection(endpoint: HostEndpoint): OkHttpWebSocketConnection { private suspend fun openConnection(endpoint: HostEndpoint): OkHttpWebSocketConnection {
val request = Request.Builder() val builder = Request.Builder()
.url(endpoint.wsUrl) // OkHttp maps ws(s):// → http(s):// internally .url(endpoint.wsUrl) // OkHttp maps ws(s):// → http(s):// internally
.header(HEADER_ORIGIN, endpoint.originHeader) .header(HEADER_ORIGIN, endpoint.originHeader)
.build() // Additive and orthogonal to Origin; absent entirely when the host has no token.
tokens.tokenFor(endpoint)?.let { token ->
builder.header(AuthCookie.HEADER_NAME, AuthCookie.headerValue(token))
}
val request = builder.build()
val connection = OkHttpWebSocketConnection(wsClient, request) val connection = OkHttpWebSocketConnection(wsClient, request)
try { try {
connection.awaitOpen() connection.awaitOpen()

View File

@@ -15,6 +15,7 @@ import okhttp3.WebSocketListener
import okio.ByteString import okio.ByteString
import wang.yaojia.webterm.wire.ConnectionPinger import wang.yaojia.webterm.wire.ConnectionPinger
import wang.yaojia.webterm.wire.TransportConnection import wang.yaojia.webterm.wire.TransportConnection
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
import java.io.IOException import java.io.IOException
/** RFC 6455 normal-closure status code, used for a client-initiated detach. */ /** RFC 6455 normal-closure status code, used for a client-initiated detach. */
@@ -145,9 +146,15 @@ internal class OkHttpWebSocketConnection(
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
finish(t) // A 401 handshake answer is the server's ONE upgrade rejection (foreign Origin OR a
// If we never opened, unblock connect() with the verbatim cause; no-op once opened. // missing/invalid access-token cookie) and is permanent until reconfigured, so it is TYPED
opened.completeExceptionally(t) // for SessionEngine to end terminally on. `t` is kept as the cause, so the verbatim
// transport error is still available to PairingError.classify's cause-walk. Every other
// failure propagates untouched (frozen "rethrow verbatim" contract).
val error = if (response?.code == HTTP_UNAUTHORIZED) UnauthorizedUpgradeException(t) else t
finish(error)
// If we never opened, unblock connect() with the cause; no-op once opened.
opened.completeExceptionally(error)
} }
} }
} }

View File

@@ -0,0 +1,145 @@
package wang.yaojia.webterm.transport
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
import java.io.IOException
/**
* B5 · the access token on the **WS upgrade** (FROZEN contract, ios-completion §1.1), over
* MockWebServer:
* - the upgrade request carries the hand-written `Cookie: webterm_auth=<t>` ALONGSIDE `Origin`
* (the two are orthogonal — the server checks Origin first, then the cookie);
* - no token ⇒ no `Cookie` header at all (an unauthenticated LAN host is unaffected);
* - an upgrade answered with **401** throws the typed [UnauthorizedUpgradeException] so
* `SessionEngine` can go terminal instead of back-off-looping against a permanent rejection;
* - any OTHER upgrade failure still surfaces verbatim (untyped) — only 401 is special.
*/
class OkHttpAccessTokenTest {
private companion object {
const val TIMEOUT_MS = 5_000L
const val TOKEN = "0123456789abcdefTOKEN"
}
private lateinit var server: MockWebServer
private val client: OkHttpClient = OkHttpClientFactory.create()
@BeforeEach
fun setUp() {
server = MockWebServer()
server.start()
}
@AfterEach
fun tearDown() {
client.dispatcher.cancelAll()
client.connectionPool.evictAll()
runCatching { server.shutdown() }
client.dispatcher.executorService.shutdown()
}
private fun endpoint(): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl("http://${server.hostName}:${server.port}"))
private fun transport(tokens: AccessTokenSource): OkHttpTermTransport =
OkHttpTermTransport(client, tokens)
@Test
fun stampsTheAuthCookieAlongsideOriginOnTheWsUpgrade() = runBlocking {
// Arrange
server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket()))
val endpoint = endpoint()
// Act
val connection = withTimeout(TIMEOUT_MS) { transport(AccessTokenSource { TOKEN }).connect(endpoint) }
// Assert
val upgrade = server.takeRequest()
assertEquals("${AuthCookie.NAME}=$TOKEN", upgrade.getHeader(AuthCookie.HEADER_NAME))
assertEquals(endpoint.originHeader, upgrade.getHeader("Origin"), "Origin must be untouched")
connection.close()
}
@Test
fun sendsNoCookieHeaderWhenTheHostHasNoToken() = runBlocking {
server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket()))
val connection = withTimeout(TIMEOUT_MS) { transport(AccessTokenSource.NONE).connect(endpoint()) }
assertNull(server.takeRequest().getHeader(AuthCookie.HEADER_NAME))
connection.close()
}
@Test
fun theTokenNeverAppearsInTheUpgradeUrl() = runBlocking {
server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket()))
val connection = withTimeout(TIMEOUT_MS) { transport(AccessTokenSource { TOKEN }).connect(endpoint()) }
assertFalse(server.takeRequest().path.orEmpty().contains(TOKEN), "a secret must never be in a URL")
connection.close()
}
@Test
fun aPingableDialAlsoCarriesTheAuthCookie() = runBlocking {
server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket()))
val pingable = withTimeout(TIMEOUT_MS) {
transport(AccessTokenSource { TOKEN }).connectPingable(endpoint())
}
assertEquals("${AuthCookie.NAME}=$TOKEN", server.takeRequest().getHeader(AuthCookie.HEADER_NAME))
pingable.connection.close()
}
@Test
fun a401UpgradeThrowsTheTypedUnauthorizedError() = runBlocking {
// Arrange: the server's bare `HTTP/1.1 401 Unauthorized` upgrade rejection.
server.enqueue(MockResponse().setResponseCode(401))
// Act
val thrown = runCatching {
withTimeout(TIMEOUT_MS) { transport(AccessTokenSource.NONE).connect(endpoint()) }
}.exceptionOrNull()
// Assert: typed, and the verbatim transport cause is retained for PairingError.classify.
assertTrue(
thrown is UnauthorizedUpgradeException,
"a 401 upgrade must be typed so the engine can end terminally, got $thrown",
)
assertTrue((thrown as UnauthorizedUpgradeException).cause is Throwable)
}
@Test
fun aNon401UpgradeFailureStillSurfacesVerbatim() = runBlocking {
server.enqueue(MockResponse().setResponseCode(500))
val thrown = runCatching {
withTimeout(TIMEOUT_MS) { transport(AccessTokenSource.NONE).connect(endpoint()) }
}.exceptionOrNull()
assertFalse(thrown is UnauthorizedUpgradeException, "only 401 is the auth rejection")
assertTrue(thrown is IOException || thrown is IllegalStateException, "verbatim transport error, got $thrown")
}
}
/** Server side that accepts the upgrade and says nothing. */
private class SilentServerWebSocket : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) = Unit
}

View File

@@ -0,0 +1,92 @@
package wang.yaojia.webterm.wire
/**
* The optional shared access token (`WEBTERM_TOKEN`) contract — the Android half of the FROZEN
* cross-client contract (ios-completion §1.1; server: `src/http/auth.ts`, `src/server.ts`).
*
* ### Why a hand-written header (and NOT an OkHttp `CookieJar`)
* A native client already KNOWS its token, so it stamps `Cookie: webterm_auth=<t>` itself and never
* parses `Set-Cookie` / relies on a cookie store. Reason (frozen): a cookie jar's behaviour on a **WS
* upgrade** differs between HTTP stacks and is hard to test, whereas a hand-written header is the same
* pattern as the existing `Origin` header ([HostEndpoint.originHeader]) and can be pinned by pure unit
* tests. [AuthCookie] is therefore the SINGLE point of derivation — assembling the header string
* anywhere else is a review CRITICAL, exactly like hand-assembling an Origin.
*
* ### Orthogonal to Origin
* The token does NOT replace the Origin/CSWSH check: the server runs Origin first, then the cookie
* (`src/server.ts` upgrade handler), so a request carries BOTH (Origin only on guarded routes, per the
* Origin-iff-guarded rule; the cookie on EVERY request plus the WS upgrade).
*
* ### Secret handling (non-negotiable, §1.1)
* The token is secret material: it lives only in Android-Keystore-backed storage, is NEVER logged,
* NEVER placed in a URL query, and NEVER put into a crash report. Nothing in this file logs.
*/
public object AuthCookie {
/** The server's auth cookie name (`src/http/auth.ts` `AUTH_COOKIE_NAME`). */
public const val NAME: String = "webterm_auth"
/** The request header the value is stamped on. */
public const val HEADER_NAME: String = "Cookie"
/**
* `webterm_auth=<token>` — the exact header value the server's `parseCookieHeader` expects. No
* attributes, no URL-encoding: the token charset ([AccessTokenRule]) is already cookie-safe, and
* the server reads the value verbatim.
*/
public fun headerValue(token: String): String = "$NAME=$token"
}
/**
* Client-side access-token validator (validate at the boundary). Mirrors the server's config check:
* `[A-Za-z0-9._~+/=-]`, 16512 chars — a server that fails this refuses to start, so a token outside
* it can never be correct and must be rejected before any network I/O (and before it reaches storage).
*
* The charset also guarantees the token cannot forge cookie syntax (no `;`, `,`, `=`-prefix, space or
* quote injection into the `Cookie` header).
*
* [normalize] trims ONCE here (a pasted/QR-sourced token often carries stray whitespace) and returns
* the trimmed token, or `null` when it is not a possible server token.
*/
public object AccessTokenRule {
/** Server-side minimum (CLAUDE.md / config validation). */
public const val MIN_LENGTH: Int = 16
/** Server-side maximum. */
public const val MAX_LENGTH: Int = 512
private val ALLOWED: Set<Char> =
(('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('.', '_', '~', '+', '/', '=', '-')).toSet()
/** The trimmed token when it could be a valid server token, else `null`. Never logs its input. */
public fun normalize(raw: String): String? {
val trimmed = raw.trim()
if (trimmed.length < MIN_LENGTH || trimmed.length > MAX_LENGTH) return null
if (!trimmed.all { it in ALLOWED }) return null
return trimmed
}
}
/**
* The access-token injection seam: "what token, if any, should be presented to THIS host?".
*
* Defined here (top of the dependency graph) so all three consumers share ONE seam without new module
* edges: `:api-client` (every REST route + the pairing probe), `:transport-okhttp` (the WS upgrade),
* and `:app` (the Keystore-backed store that implements it). It is the token analogue of
* `:transport-okhttp`'s `ClientIdentityProvider` mTLS seam.
*
* `suspend` because the production implementation reads encrypted at-rest storage (Tink +
* AndroidKeyStore) and must be able to hop off the caller's thread; it is consulted per request so a
* token added, changed or removed later takes effect with no client rebuild.
*
* Returning `null` means "this host has no token" — the request then carries no `Cookie` header at
* all, which is exactly the zero-config LAN behaviour of a server with `WEBTERM_TOKEN` unset.
*/
public fun interface AccessTokenSource {
/** The access token for [endpoint], or `null` when none is stored. MUST NOT log the token. */
public suspend fun tokenFor(endpoint: HostEndpoint): String?
public companion object {
/** No token for any host — the default everywhere, so an unauthenticated host is unaffected. */
public val NONE: AccessTokenSource = AccessTokenSource { null }
}
}

View File

@@ -0,0 +1,31 @@
package wang.yaojia.webterm.wire
import java.io.IOException
/**
* The server answered the WebSocket upgrade with **HTTP 401** instead of 101 — a NON-retryable
* rejection (ios-completion §1.1).
*
* Declared in `:wire-protocol` so the three modules that must agree on it have no new dependency
* edges: `:transport-okhttp` throws it out of `TermTransport.connect`, `:session-core`'s
* `SessionEngine` maps it to a TERMINAL failure (never into the reconnect back-off ladder — retrying
* would 401 forever), and `:api-client`'s pairing probe can classify it.
*
* The server writes a bare `HTTP/1.1 401 Unauthorized` for BOTH of its two upgrade rejections — a
* foreign `Origin` and a missing/invalid access-token cookie (`src/server.ts` upgrade handler) — so
* this type deliberately does NOT claim which one it was. Both are permanent-until-reconfigured, so
* the engine's treatment (terminal, no back-off) is correct either way; the pairing probe disambiguates
* by ordering (it authenticates over HTTP first, so a 401 there is the token and a 401 on the later
* upgrade is the Origin).
*
* [cause] carries the transport's verbatim error so existing cause-chain classification
* (`PairingError.classify`) still sees the original type.
*/
public class UnauthorizedUpgradeException(
cause: Throwable? = null,
) : IOException(MESSAGE, cause) {
private companion object {
/** Fixed copy — never interpolate the token or any header value into an exception message. */
const val MESSAGE = "the server rejected the WebSocket upgrade with HTTP 401"
}
}

View File

@@ -0,0 +1,109 @@
package wang.yaojia.webterm.wire
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* B5 · the FROZEN access-token contract (ios-completion §1.1) as pure functions:
* - [AuthCookie] is the SINGLE point of `Cookie: webterm_auth=<t>` derivation (the token analogue of
* [HostEndpoint.originHeader] — never hand-assembled at a call site);
* - [AccessTokenRule] mirrors the server's config validator (`[A-Za-z0-9._~+/=-]`, 16512) so a
* malformed token is rejected BEFORE any network I/O;
* - [AccessTokenSource.NONE] is the "no token configured" default (LAN zero-config unchanged).
*/
class AccessTokenTest {
// ── AuthCookie: the one derivation point ────────────────────────────────────────────────────
@Test
fun `cookie name and header name match the server contract`() {
assertEquals("webterm_auth", AuthCookie.NAME)
assertEquals("Cookie", AuthCookie.HEADER_NAME)
}
@Test
fun `header value is name equals token with no extra whitespace or attributes`() {
assertEquals("webterm_auth=abcdefghijklmnop", AuthCookie.headerValue("abcdefghijklmnop"))
}
// ── AccessTokenRule: charset + length, trimmed once ─────────────────────────────────────────
@Test
fun `a valid token in the server charset normalizes to itself`() {
val token = "Aa0._~+/=-Aa0._~+/=-"
assertEquals(token, AccessTokenRule.normalize(token))
}
@Test
fun `surrounding whitespace is trimmed once at the boundary`() {
assertEquals("0123456789abcdef", AccessTokenRule.normalize(" 0123456789abcdef\n"))
}
@Test
fun `a token shorter than the minimum or longer than the maximum is rejected`() {
assertEquals(16, AccessTokenRule.MIN_LENGTH)
assertEquals(512, AccessTokenRule.MAX_LENGTH)
assertNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MIN_LENGTH - 1)))
assertNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MAX_LENGTH + 1)))
assertNotNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MIN_LENGTH)))
assertNotNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MAX_LENGTH)))
}
@Test
fun `an empty or blank token is rejected`() {
assertNull(AccessTokenRule.normalize(""))
assertNull(AccessTokenRule.normalize(" "))
}
@Test
fun `characters outside the server charset are rejected`() {
// A `;` would forge a second cookie attribute; a space/quote/CJK char is simply not in the set.
listOf(
"abcdefghijklmno;x",
"abcdefghijklmno x",
"abcdefghijklmno\"x",
"abcdefghijklmno\nx",
"令牌令牌令牌令牌令牌令牌令牌令牌",
).forEach { raw ->
assertNull(AccessTokenRule.normalize(raw), "must reject: $raw")
}
}
// ── AccessTokenSource ───────────────────────────────────────────────────────────────────────
@Test
fun `NONE yields no token so an unauthenticated LAN host keeps working`() = runTest {
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
assertNull(AccessTokenSource.NONE.tokenFor(endpoint))
}
@Test
fun `a source can be a lambda keyed by the endpoint`() = runTest {
val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
val other = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.6:3000"))
val source = AccessTokenSource { endpoint ->
if (endpoint == lan) "0123456789abcdef" else null
}
assertEquals("0123456789abcdef", source.tokenFor(lan))
assertNull(source.tokenFor(other))
}
// ── UnauthorizedUpgradeException ────────────────────────────────────────────────────────────
@Test
fun `the 401 upgrade error is an IOException keeping the verbatim cause`() {
val cause = IllegalStateException("expected 101, got 401")
val error: java.io.IOException = UnauthorizedUpgradeException(cause)
assertEquals(cause, error.cause, "the verbatim transport cause must survive for classify()")
assertEquals(
"the server rejected the WebSocket upgrade with HTTP 401",
error.message,
"fixed copy — no header/token value may ever be interpolated into the message",
)
}
}