Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ android {
minSdk = 28
targetSdk = 34
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
versionCode = 15
versionName = "0.1.15"
versionCode = 16
versionName = "0.1.16"
buildConfigField("int", "LOCAL_HTTP_PORT", "8420")
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ object UOAAccount {
private val mutableState = MutableStateFlow(State())
val state = mutableState.asStateFlow()
private var generation = UUID.randomUUID()
val pairingSession: String? get() = state.value.profile?.let { generation.toString() }
private var attempt: Attempt? = null
private var lifetime: Job? = null
private var credentials: UOACredentials? = null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.kelpie.browser.account

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull

/** UOA owns the account comparison. Keep all session fences on the account's main dispatcher. */
object UOALocalPairing {
suspend fun session(): String? = withContext(Dispatchers.Main.immediate) { UOAAccount.pairingSession }

suspend fun verify(
ticket: String,
challenge: String,
clientId: String,
session: String,
): Long? =
withContext(Dispatchers.Main.immediate) {
if (UOAAccount.pairingSession != session) return@withContext null
val decision =
runCatching {
val body =
buildJsonObject {
put("ticket", JsonPrimitive(ticket))
put("challenge", JsonPrimitive(challenge))
put("clientId", JsonPrimitive(clientId))
}
UOAAccount.request("/oauth/kelpie/pair/validate", "POST", body.toString()).json()
}.getOrNull() ?: return@withContext null
if (decision["active"]?.jsonPrimitive?.booleanOrNull != true || UOAAccount.pairingSession != session) {
return@withContext null
}
decision["expires_at"]?.jsonPrimitive?.longOrNull
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.kelpie.browser.network

import com.kelpie.browser.account.UOALocalPairing
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull

/** Account proofs share the existing ephemeral pairing store; no parallel durable trust exists. */
class AccountPairingAuthorization(
private val store: PairingStore,
private val session: suspend () -> String? = UOALocalPairing::session,
private val verify: suspend (String, String, String, String) -> Long? = UOALocalPairing::verify,
) {
companion object {
private fun parse(body: String): JsonObject? = runCatching { Json.parseToJsonElement(body) as? JsonObject }.getOrNull()

fun handles(body: String): Boolean = (parse(body)?.get("account") as? JsonPrimitive)?.booleanOrNull == true

private fun string(
value: JsonObject,
key: String,
): String? = (value[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
}

suspend fun handlePost(
body: String,
sourceAddress: String,
): PairEndpoints.JsonResult {
val value = parse(body) ?: return error(400, "INVALID_JSON", "Request body is not valid JSON")
val clientId =
string(value, "clientId")?.takeIf { it.isNotEmpty() && it.length <= 200 }
?: return error(400, "MISSING_PARAM", "clientId is required")
val current = session() ?: return error(409, "ACCOUNT_PAIRING_UNAVAILABLE", "Kelpie is not signed in")
if (value.containsKey("accountTicket") || value.containsKey("requestId")) {
val ticket =
string(value, "accountTicket")?.takeIf { it.isNotEmpty() && it.toByteArray().size <= 8192 }
?: return error(403, "ACCOUNT_AUTH_FAILED", "Account pairing verification failed")
val challenge = string(value, "requestId") ?: return error(403, "ACCOUNT_AUTH_FAILED", "Account pairing verification failed")
val pending = store.pendingRequest(challenge)
if (pending == null || pending.clientId != clientId || pending.sourceAddress != sourceAddress || pending.accountSession != current) {
return error(403, "ACCOUNT_AUTH_FAILED", "Account pairing verification failed")
}
val expiry = verify(ticket, challenge, clientId, current)
val now = System.currentTimeMillis()
if (expiry == null || expiry <= now || expiry > now + 120_000 || session() != current) {
return error(403, "ACCOUNT_AUTH_FAILED", "Account pairing verification failed")
}
val grantedExpiry = minOf(expiry, pending.expiresAt)
val grant = PairingStore.AccountGrant(current, ticket, challenge, grantedExpiry, sourceAddress)
if (store.approve(challenge, false, grant) == null) return error(403, "ACCOUNT_AUTH_FAILED", "Account pairing verification failed")
val issued = store.takeIssuance(challenge, sourceAddress) ?: return error(403, "ACCOUNT_AUTH_FAILED", "Account pairing verification failed")
return PairEndpoints.JsonResult(200, mapOf("status" to "approved", "scope" to "session", "token" to issued.token, "expiresAt" to grantedExpiry))
}
val start = store.startPairing(clientId, string(value, "clientName").orEmpty(), sourceAddress, current)
val pending = start.request ?: return error(403, "DENIED", "Pair requests from this source are currently suppressed")
return PairEndpoints.JsonResult(202, mapOf("status" to "account_challenge", "requestId" to pending.requestId, "expiresAt" to pending.expiresAt))
}

suspend fun evaluate(request: ParsedRequest): AuthDecision {
val middleware = AuthMiddleware(store)
val decision = middleware.evaluate(request)
if (decision !is AuthDecision.Reject || decision.status != 401) return decision
val header = request.headers["authorization"]?.firstOrNull()?.takeIf { it.lowercase().startsWith("bearer ") } ?: return decision
val token = header.drop(7).trim()
val approval = store.accountApproval(token) ?: return decision
val grant = approval.accountGrant ?: return decision
if (request.sourceAddress != grant.sourceAddress || session() != grant.session) return decision
val expiry = verify(grant.ticket, grant.challenge, approval.clientId, grant.session) ?: return decision
if (expiry <= System.currentTimeMillis() || session() != grant.session || store.accountApproval(token) != approval) return decision
return middleware.evaluate(request, verifiedAccountClientId = approval.clientId)
}

private fun error(
status: Int,
code: String,
message: String,
): PairEndpoints.JsonResult = PairEndpoints.JsonResult(status, mapOf("success" to false, "error" to mapOf("code" to code, "message" to message)))
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ class AuthMiddleware(
)
}

fun evaluate(req: ParsedRequest): AuthDecision {
fun evaluate(
req: ParsedRequest,
verifiedAccountClientId: String? = null,
): AuthDecision {
val ambiguous = rejectAmbiguousHeaders(req)
if (ambiguous != null) return ambiguous

Expand All @@ -66,7 +69,7 @@ class AuthMiddleware(
extractBearer(req.headers)
?: return AuthDecision.Reject(401, "UNAUTHORIZED", "Authentication required")
val clientId =
store.validateBearer(bearer)
(store.validateBearer(bearer) ?: verifiedAccountClientId)
?: return AuthDecision.Reject(401, "UNAUTHORIZED", "Invalid bearer token")
if (req.path == "/mcp" || req.path == "/v1/mcp") {
if (req.headers.containsKey("origin")) return AuthDecision.Reject(403, "FORBIDDEN", "Browser Origin is not permitted on MCP")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class HTTPServer(
private set

fun start() {
val middleware = AuthMiddleware(pairingStore)
val middleware = AccountPairingAuthorization(pairingStore)
val pairEndpoints =
PairEndpoints(pairingStore) { onPendingPairChanged() }

Expand Down Expand Up @@ -157,7 +157,12 @@ class HTTPServer(
is AuthDecision.Reject -> respondReject(call, decision)
AuthDecision.Unauthenticated -> {
val bodyText = call.receiveText()
val result = pairEndpoints.handlePairPost(bodyText, parsed.sourceAddress)
val result =
if (AccountPairingAuthorization.handles(bodyText)) {
middleware.handlePost(bodyText, parsed.sourceAddress)
} else {
pairEndpoints.handlePairPost(bodyText, parsed.sourceAddress)
}
respondPairing(call, result.status, result.json, noStore = true)
}
is AuthDecision.Authenticated -> {
Expand All @@ -167,7 +172,12 @@ class HTTPServer(
// there is no special-casing — fall through to the
// unauth path.
val bodyText = call.receiveText()
val result = pairEndpoints.handlePairPost(bodyText, parsed.sourceAddress)
val result =
if (AccountPairingAuthorization.handles(bodyText)) {
middleware.handlePost(bodyText, parsed.sourceAddress)
} else {
pairEndpoints.handlePairPost(bodyText, parsed.sourceAddress)
}
respondPairing(call, result.status, result.json, noStore = true)
}
}
Expand Down Expand Up @@ -235,7 +245,7 @@ class HTTPServer(

private suspend fun handlePublicDeviceInfo(
call: ApplicationCall,
middleware: AuthMiddleware,
middleware: AccountPairingAuthorization,
pairEndpoints: PairEndpoints,
) {
val parsed = parsedRequest(call)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,24 @@ class PairingStore(
val sourceAddress: String,
val createdAt: Long,
val expiresAt: Long,
val accountSession: String? = null,
)

data class SessionApproval(
val clientId: String,
val clientName: String,
val tokenHashSha256: String,
val approvedAt: Long,
val accountGrant: AccountGrant? = null,
)

/** UOA-verified challenge proof; kept only in memory and excluded from manual bearer validation. */
data class AccountGrant(
val session: String,
val ticket: String,
val challenge: String,
val expiresAt: Long,
val sourceAddress: String,
)

data class PendingIssuance(
Expand Down Expand Up @@ -166,6 +177,7 @@ class PairingStore(
clientId: String,
clientName: String,
sourceAddress: String,
accountSession: String? = null,
): PairStart {
synchronized(lock) {
gcLocked(System.currentTimeMillis())
Expand All @@ -182,7 +194,8 @@ class PairingStore(
clientName = sanitizeClientName(clientName),
sourceAddress = sourceAddress,
createdAt = now,
expiresAt = now + PENDING_TTL_MS,
expiresAt = now + if (accountSession == null) PENDING_TTL_MS else 120_000,
accountSession = accountSession,
)
pending[req.requestId] = req
pendingBySource[sourceAddress] = req.requestId
Expand All @@ -193,7 +206,7 @@ class PairingStore(
fun visiblePending(): List<PendingRequest> =
synchronized(lock) {
gcLocked(System.currentTimeMillis())
pending.values.sortedBy { it.createdAt }
pending.values.filter { it.accountSession == null }.sortedBy { it.createdAt }
}

fun pendingRequest(requestId: String): PendingRequest? =
Expand All @@ -205,10 +218,23 @@ class PairingStore(
fun approve(
requestId: String,
persist: Boolean,
accountGrant: AccountGrant? = null,
): Pair<String, String>? {
synchronized(lock) {
gcLocked(System.currentTimeMillis())
val entry = pending.remove(requestId) ?: return null
val entry = pending[requestId] ?: return null
if (entry.accountSession != accountGrant?.session) return null
if (accountGrant != null &&
(
persist ||
accountGrant.expiresAt <= System.currentTimeMillis() ||
accountGrant.challenge != requestId ||
accountGrant.sourceAddress != entry.sourceAddress
)
) {
return null
}
pending.remove(requestId)
pendingBySource.remove(entry.sourceAddress)

val token = generateToken()
Expand Down Expand Up @@ -236,6 +262,7 @@ class PairingStore(
clientName = entry.clientName,
tokenHashSha256 = hash,
approvedAt = now,
accountGrant = accountGrant,
),
)
scope = "session"
Expand Down Expand Up @@ -288,14 +315,24 @@ class PairingStore(
match.lastSeenAt = System.currentTimeMillis()
return match.clientId
}
val session = sessions.firstOrNull { constantTimeEqualsHex(it.tokenHashSha256, hash) }
val session = sessions.firstOrNull { it.accountGrant == null && constantTimeEqualsHex(it.tokenHashSha256, hash) }
return session?.clientId
}
}

fun accountApproval(token: String): SessionApproval? =
synchronized(lock) {
gcLocked(System.currentTimeMillis())
val hash = hashToken(token)
sessions.firstOrNull { it.accountGrant != null && constantTimeEqualsHex(it.tokenHashSha256, hash) }
}

fun revoke(clientId: String): Boolean {
synchronized(lock) {
val sizeBefore = persistent.size + sessions.size
sessions.filter { it.clientId == clientId }.forEach { approval ->
approval.accountGrant?.let { deniedSources[it.sourceAddress] = System.currentTimeMillis() + DENY_TTL_MS }
}
persistent.removeAll { it.clientId == clientId }
sessions.removeAll { it.clientId == clientId }
val changed = sizeBefore != persistent.size + sessions.size
Expand All @@ -306,7 +343,11 @@ class PairingStore(

fun listPersistent(): List<PairingRecord> = synchronized(lock) { persistent.toList() }

fun listSessions(): List<SessionApproval> = synchronized(lock) { sessions.toList() }
fun listSessions(): List<SessionApproval> =
synchronized(lock) {
gcLocked(System.currentTimeMillis())
sessions.toList()
}

fun listDeniedSources(): List<Pair<String, Long>> =
synchronized(lock) {
Expand All @@ -322,5 +363,6 @@ class PairingStore(
}
deniedSources.entries.removeAll { it.value <= now }
pendingIssuances.entries.removeAll { it.value.expiresAt <= now }
sessions.removeAll { it.accountGrant?.expiresAt?.let { expiry -> expiry <= now } == true }
}
}
Loading
Loading