From 3fc40fc3950c05eb8d1c03ac20d32965e16c28ce Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Fri, 8 May 2026 12:01:23 +0200 Subject: [PATCH] Implement per-request FIDO2 token binding across all components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each request to a gated endpoint now triggers a fresh FIDO2 assertion. Challenge = SHA256(url|method|nonce) — bound to the specific resource. The self-contained assertion bundle lets the server verify independently without calling back to the phone. - fido2_ops.dart: GetAssertionResult gains clientDataJson; getAssertion accepts optional challenge override - proxy_service.dart: _handleAuthGetToken accepts {url,method,nonce}, derives challenge, runs card assertion, returns b64url bundle - filter_proxy.dart: _getAuthToken(uri, method) generates nonce and passes binding fields to Component 2 - component3/phone.go: stateless GetTokenForRequest(url, method) — no session caching, no expiry, one card touch per request - component3/proxy.go: use GetTokenForRequest - component3/main.go: remove --user flag (Component 2 picks enrolled user) - k_server_app.py: _verify_assertion_token() — verifies path+method match, challenge claim, and ECDSA-P256 signature; accepts both legacy X-Proxy-Token and new Bearer assertion tokens Co-Authored-By: Claude Sonnet 4.6 --- Workplan.md | 15 ++++ component3/main.go | 10 +-- component3/phone.go | 129 +++++++++------------------------ component3/proxy.go | 2 +- k_phone/lib/fido2_ops.dart | 13 +++- k_phone/lib/filter_proxy.dart | 27 +++++-- k_phone/lib/proxy_service.dart | 82 +++++++++++++++------ k_server_app.py | 94 +++++++++++++++++++++++- 8 files changed, 235 insertions(+), 137 deletions(-) diff --git a/Workplan.md b/Workplan.md index fb066b6..aa3fc15 100644 --- a/Workplan.md +++ b/Workplan.md @@ -652,6 +652,21 @@ CTAP2 cmd=0x02 body=113 bytes → getAssertion OK auth_data=37 bytes sig=71 byte - `proxy.go`: `handleHTTP` extracts `port` from URL (defaults `"80"`), passes to `IsGated`; `handleConnect` passes `portStr` to `IsGated`. - `phone.go`: added `getToken()` calling `/auth/get-token` — avoids FIDO2 card interaction if the phone already has an active session. `EnsureSession()` tries `getToken()` first, falls back to `login()`. Fixed `login()` JSON field: `expires_in` → `ttl_seconds` (actual server field name). `go build ./...` passes. +### Parallel-change note: Component 1 and Component 3 share the same proxy logic + +Component 3 (`component3/`) and Component 1 (`k_phone/lib/filter_proxy.dart`) implement the same core behaviour: intercept HTTP/HTTPS traffic, decide per-request whether the target is gated, fetch a WebAuthn token if so, and call the endpoint directly with the token. Any structural change to one (new gating logic, token-binding changes, CONNECT handling, error semantics) will almost certainly need a corresponding change in the other. Treat them as a pair: when modifying Component 3, check Component 1 for the same fix, and vice versa. + +### Work completed (2026-05-08, per-request token binding) + +- `fido2_ops.dart`: `GetAssertionResult` now includes `clientDataJson`; `getAssertion()` accepts optional `challenge` param for binding. +- `proxy_service.dart`: `_handleAuthGetToken` rewritten — accepts `{url, method, nonce}`, derives `challenge = SHA256(url|method|nonce)`, calls card (getAssertion), returns self-contained assertion bundle as base64url Bearer token. No session involved. +- `filter_proxy.dart`: `_getAuthToken(uri, method)` generates a secure 16-byte nonce, posts `{url, method, nonce}` to Component 2, uses returned assertion token directly. +- `component3/phone.go`: rewritten as stateless `GetTokenForRequest(url, method)` — no session caching, no mutex, no expiry tracking. +- `component3/proxy.go`: `handleHTTP` uses `GetTokenForRequest(r.URL.String(), r.Method)`. +- `component3/main.go`: `--user` flag removed (Component 2 picks the enrolled user). +- `k_server_app.py`: `_verify_assertion_token()` added — decodes bundle, verifies path+method match, verifies challenge claim, verifies ECDSA-P256 signature over authData||clientDataHash using public key extracted from bundle's credentialData. `_is_proxy_authorized()` accepts either X-Proxy-Token (legacy k_proxy path) or Bearer assertion token. +- 46/46 Flutter tests pass; `go build ./...` clean; `flutter analyze` no issues. + ### Next action 1. Deploy to a real Android phone with physical ChromeCard via USB diff --git a/component3/main.go b/component3/main.go index ad6ebc3..0ee0872 100644 --- a/component3/main.go +++ b/component3/main.go @@ -2,7 +2,6 @@ package main import ( "flag" - "fmt" "log" "net/http" "os" @@ -12,17 +11,10 @@ import ( func main() { listen := flag.String("listen", "127.0.0.1:9090", "local proxy address (configure browser to use this)") phoneURL := flag.String("phone", "http://192.168.1.10:8771", "phone base URL (Component 1/2)") - username := flag.String("user", "", "FIDO2 username (required)") gatedFile := flag.String("gated", "", "gated hosts file (default: ~/.config/component3/gated_hosts.txt)") verbose := flag.Bool("v", false, "verbose logging") flag.Parse() - if *username == "" { - fmt.Fprintln(os.Stderr, "error: -user is required") - flag.Usage() - os.Exit(1) - } - cfgDir := defaultConfigDir() if err := os.MkdirAll(cfgDir, 0700); err != nil { log.Fatalf("cannot create config dir: %v", err) @@ -38,7 +30,7 @@ func main() { log.Printf("loaded %d gated entries from %s", gated.Len(), *gatedFile) } - phone := NewPhoneClient(*phoneURL, *username) + phone := NewPhoneClient(*phoneURL) proxy := &Proxy{ phone: phone, diff --git a/component3/phone.go b/component3/phone.go index b5ae4ea..f70128c 100644 --- a/component3/phone.go +++ b/component3/phone.go @@ -2,126 +2,67 @@ package main import ( "bytes" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "io" - "log" "net/http" - "sync" - "time" ) -// PhoneClient acquires and caches a session token from Component 2 on the phone. -// The token represents a completed FIDO2 assertion (user fingerprint on card). -// Component 3 includes this token in Authorization headers when calling endpoints -// directly, so the server can verify that a valid card session was established. +// PhoneClient fetches a per-request FIDO2 assertion token from Component 2. +// There is no session caching — each call triggers a card interaction. type PhoneClient struct { - baseURL string - username string - - mu sync.Mutex - token string - expiresAt time.Time + baseURL string } -func NewPhoneClient(baseURL, username string) *PhoneClient { - return &PhoneClient{baseURL: baseURL, username: username} +func NewPhoneClient(baseURL string) *PhoneClient { + return &PhoneClient{baseURL: baseURL} } -// EnsureSession returns a valid session token. It first tries /auth/get-token -// (no card interaction if the phone already has an active session), then falls -// back to /session/login (triggers FIDO2 assertion on the card). -func (c *PhoneClient) EnsureSession() (string, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.token != "" && time.Now().Before(c.expiresAt) { - return c.token, nil - } - - if tok, expiresIn, err := c.getToken(); err == nil { - ttl := time.Duration(expiresIn) * time.Second - if ttl <= 0 { - ttl = 5 * time.Minute - } - c.token = tok - c.expiresAt = time.Now().Add(ttl - 30*time.Second) - return tok, nil - } - - log.Printf("phone: logging in as %q (FIDO2 card interaction required)", c.username) - return c.login() -} - -// Invalidate clears the cached token, forcing a fresh login on the next call. -func (c *PhoneClient) Invalidate() { - c.mu.Lock() - c.token = "" - c.mu.Unlock() -} - -// getToken calls /auth/get-token on the phone and returns the token if the phone -// already has an active session. Avoids card interaction. Caller must hold c.mu. -func (c *PhoneClient) getToken() (string, int, error) { - resp, err := http.Post(c.baseURL+"/auth/get-token", "application/json", bytes.NewReader([]byte("{}"))) +// GetTokenForRequest calls /auth/get-token with url+method+nonce, triggering +// a fresh FIDO2 assertion on the card. Returns the self-contained assertion +// bundle that the endpoint can verify independently. +func (c *PhoneClient) GetTokenForRequest(rawURL, method string) (string, error) { + nonce, err := randomHex(16) if err != nil { - return "", 0, err + return "", fmt.Errorf("nonce: %w", err) } - defer resp.Body.Close() - raw, _ := io.ReadAll(resp.Body) - var result struct { - Ok bool `json:"ok"` - Token string `json:"token"` - ExpiresIn int `json:"expires_in"` - Error string `json:"error"` - } - if err := json.Unmarshal(raw, &result); err != nil { - return "", 0, fmt.Errorf("parse token response: %w", err) - } - if !result.Ok { - return "", 0, fmt.Errorf("no active session: %s", result.Error) - } - return result.Token, result.ExpiresIn, nil -} + body, _ := json.Marshal(map[string]string{ + "url": rawURL, + "method": method, + "nonce": nonce, + }) -// login posts to /session/login on the phone and stores the returned token. -// Caller must hold c.mu. -func (c *PhoneClient) login() (string, error) { - body, _ := json.Marshal(map[string]string{"username": c.username}) - - resp, err := http.Post(c.baseURL+"/session/login", "application/json", bytes.NewReader(body)) + resp, err := http.Post(c.baseURL+"/auth/get-token", "application/json", bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("phone unreachable (%s): %w", c.baseURL, err) } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) - var result struct { - Ok bool `json:"ok"` - SessionToken string `json:"session_token"` - TtlSeconds int `json:"ttl_seconds"` - Error string `json:"error"` + Ok bool `json:"ok"` + Token string `json:"token"` + Error string `json:"error"` } if err := json.Unmarshal(raw, &result); err != nil { - return "", fmt.Errorf("parse phone response: %w (body: %s)", err, raw) + return "", fmt.Errorf("parse token response: %w (body: %s)", err, raw) } if !result.Ok { - return "", fmt.Errorf("login rejected: %s", result.Error) + return "", fmt.Errorf("auth failed: %s", result.Error) } - if result.SessionToken == "" { - return "", fmt.Errorf("phone returned empty session token") + if result.Token == "" { + return "", fmt.Errorf("phone returned empty token") } - - ttl := time.Duration(result.TtlSeconds) * time.Second - if ttl <= 0 { - ttl = 5 * time.Minute - } - // Expire the cached token 30 s early to avoid racing the server-side expiry. - c.token = result.SessionToken - c.expiresAt = time.Now().Add(ttl - 30*time.Second) - - log.Printf("phone: session acquired (expires in %v)", ttl) - return c.token, nil + return result.Token, nil +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil } diff --git a/component3/proxy.go b/component3/proxy.go index a312913..e237fb3 100644 --- a/component3/proxy.go +++ b/component3/proxy.go @@ -70,7 +70,7 @@ func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) { stripHopByHop(out.Header) if isGated { - token, err := p.phone.EnsureSession() + token, err := p.phone.GetTokenForRequest(r.URL.String(), r.Method) if err != nil { http.Error(w, "auth: "+err.Error(), http.StatusUnauthorized) return diff --git a/k_phone/lib/fido2_ops.dart b/k_phone/lib/fido2_ops.dart index 34c2b4e..660ba50 100644 --- a/k_phone/lib/fido2_ops.dart +++ b/k_phone/lib/fido2_ops.dart @@ -46,11 +46,13 @@ class GetAssertionResult { final Uint8List authData; final Uint8List signature; final Uint8List clientDataHash; + final String clientDataJson; GetAssertionResult({ required this.authData, required this.signature, required this.clientDataHash, + required this.clientDataJson, }); } @@ -116,15 +118,17 @@ Future makeCredential( /// Runs CTAP2 authenticatorGetAssertion against the card on [cid]. /// [credentialDataB64] is the base64url of the stored AttestedCredentialData. +/// [challenge] overrides the random challenge — use for per-request token binding. Future getAssertion( int cid, - String credentialDataB64, -) async { + String credentialDataB64, { + Uint8List? challenge, +}) async { final credData = _b64uDecode(credentialDataB64); final credId = _extractCredentialId(credData); - final challenge = _randomBytes(32); - final clientDataJson = _buildClientDataJson('webauthn.get', challenge); + final actualChallenge = challenge ?? _randomBytes(32); + final clientDataJson = _buildClientDataJson('webauthn.get', actualChallenge); final clientDataHash = _sha256(utf8.encode(clientDataJson)); final requestMap = CborMap({ @@ -154,6 +158,7 @@ Future getAssertion( authData: authData, signature: signature, clientDataHash: clientDataHash, + clientDataJson: clientDataJson, ); } diff --git a/k_phone/lib/filter_proxy.dart b/k_phone/lib/filter_proxy.dart index 9697abf..569c10d 100644 --- a/k_phone/lib/filter_proxy.dart +++ b/k_phone/lib/filter_proxy.dart @@ -17,6 +17,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:path_provider/path_provider.dart'; @@ -405,7 +406,7 @@ class FilterProxy { ) async { String token; try { - token = await _getAuthToken(); + token = await _getAuthToken(uri, method); } catch (_) { _deny(client, sub, 407, 'Proxy Authentication Required'); return; @@ -449,9 +450,16 @@ class FilterProxy { await _forwardHttpRequest(client, sub, upstream, out.toString(), remainder, contentLength); } - // Calls POST /auth/get-token on Component 2 and returns the bearer token. - // Throws if no active session or Component 2 is unreachable. - Future _getAuthToken() async { + // Calls POST /auth/get-token on Component 2 with per-request binding and + // returns the bearer token (a self-contained FIDO2 assertion bundle). + // Throws if card is unavailable or Component 2 is unreachable. + Future _getAuthToken(Uri uri, String method) async { + final nonce = _secureHex(16); + final payload = utf8.encode(jsonEncode({ + 'url': uri.toString(), + 'method': method, + 'nonce': nonce, + })); final httpClient = HttpClient() ..connectionTimeout = const Duration(seconds: 5); try { @@ -459,8 +467,8 @@ class FilterProxy { Uri(scheme: 'http', host: '127.0.0.1', port: _component2Port, path: '/auth/get-token'), ); req.headers.contentType = ContentType.json; - req.contentLength = 2; - req.write('{}'); + req.contentLength = payload.length; + req.add(payload); final resp = await req.close(); final body = await resp.transform(utf8.decoder).join(); final json = jsonDecode(body) as Map; @@ -473,6 +481,13 @@ class FilterProxy { } } + String _secureHex(int bytes) { + final rng = Random.secure(); + return List.generate(bytes, (_) => rng.nextInt(256)) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + } + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/k_phone/lib/proxy_service.dart b/k_phone/lib/proxy_service.dart index b84c3fa..1923476 100644 --- a/k_phone/lib/proxy_service.dart +++ b/k_phone/lib/proxy_service.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -439,37 +442,72 @@ class _ProxyServer { } // ------------------------------------------------------------------------- - // Auth token endpoint (v2 architecture) + // Auth token endpoint (v2 architecture — per-request token binding) // - // Component 1 (filter_proxy) and Component 3 (Go binary) call this to get - // a bearer token they can attach to requests when calling endpoints directly. - // Component 2 never calls endpoints itself — it only issues tokens. + // Component 1 (filter_proxy) and Component 3 (Go binary) call this with + // {url, method, nonce} for each gated request. A fresh FIDO2 assertion is + // produced with challenge = SHA256(url|method|nonce). The self-contained + // assertion bundle is returned as a base64url Bearer token the server can + // verify without calling back to this service. // ------------------------------------------------------------------------- Future _handleAuthGetToken(HttpRequest req) async { - await _drainBody(req); + final body = await _readJson(req); + if (body == null) return; - // If there is already an active session return its token — no card needed. - final active = _sessions.anyActive(); - if (active != null) { - final (token, session) = active; - final secondsRemaining = - session.expires.difference(DateTime.now()).inSeconds.clamp(0, 99999); - await _send(req.response, 200, { - 'ok': true, - 'token': token, - 'username': session.username, - 'expires_in': secondsRemaining, - }); + final url = body['url'] as String? ?? ''; + final method = body['method'] as String? ?? ''; + final nonce = body['nonce'] as String? ?? ''; + + if (url.isEmpty || method.isEmpty || nonce.isEmpty) { + await _send(req.response, 400, {'ok': false, 'error': 'url, method, nonce required'}); return; } - // No active session — caller must trigger /session/login first. - await _send(req.response, 401, { - 'ok': false, - 'error': 'no active session', - 'login_required': true, + if (!_cardAttached || _cardCid == null) { + await _send(req.response, 503, {'ok': false, 'error': 'card not available'}); + return; + } + + // Find first enrolled user with a FIDO2 credential. + final users = await _db.list(); + Enrollment? enrolled; + for (final u in users) { + if (u.hasCredential) { enrolled = u; break; } + } + if (enrolled == null) { + await _send(req.response, 401, {'ok': false, 'error': 'no enrolled credential'}); + return; + } + + // Challenge = SHA256(url | "|" | method | "|" | nonce) + final challenge = Uint8List.fromList( + sha256.convert(utf8.encode('$url|$method|$nonce')).bytes, + ); + + GetAssertionResult assertionResult; + try { + assertionResult = await getAssertion(_cardCid!, enrolled.credentialDataB64!, challenge: challenge); + } catch (e) { + await _send(req.response, 401, {'ok': false, 'error': 'card assertion failed: $e'}); + return; + } + + // Self-contained bundle the server can verify without calling back to the phone. + final bundleJson = jsonEncode({ + 'v': 1, + 'url': url, + 'method': method, + 'nonce': nonce, + 'authData': base64Url.encode(assertionResult.authData).replaceAll('=', ''), + 'sig': base64Url.encode(assertionResult.signature).replaceAll('=', ''), + 'cdj': base64Url.encode(utf8.encode(assertionResult.clientDataJson)).replaceAll('=', ''), + 'cred': enrolled.credentialDataB64, + 'user': enrolled.username, }); + final token = base64Url.encode(utf8.encode(bundleJson)).replaceAll('=', ''); + + await _send(req.response, 200, {'ok': true, 'token': token, 'username': enrolled.username}); } // ------------------------------------------------------------------------- diff --git a/k_server_app.py b/k_server_app.py index b95902a..23dbe2e 100644 --- a/k_server_app.py +++ b/k_server_app.py @@ -10,6 +10,8 @@ All state is process-local and resets on restart. from __future__ import annotations import argparse +import base64 +import hashlib import json import ssl import threading @@ -19,6 +21,86 @@ from typing import Any from urllib.parse import urlparse +def _b64u_decode(s: str) -> bytes: + padded = s + "=" * ((4 - len(s) % 4) % 4) + return base64.urlsafe_b64decode(padded) + + +def _verify_assertion_token(token: str, request_path: str, request_method: str) -> bool: + """Verify a base64url-encoded FIDO2 per-request assertion bundle. + + Bundle fields (JSON, then base64url-encoded): + v version (1) + url full URL used to derive the challenge + method HTTP method used to derive the challenge + nonce random hex nonce used to derive the challenge + authData base64url authenticator data + sig base64url ECDSA signature + cdj base64url clientDataJson bytes + cred base64url AttestedCredentialData (aaguid+credIdLen+credId+coseKey) + user enrolled username (informational) + """ + try: + import cbor2 + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.asymmetric.ec import ( + ECDSA, + EllipticCurvePublicNumbers, + SECP256R1, + ) + from cryptography.hazmat.primitives.hashes import SHA256 + from cryptography.exceptions import InvalidSignature + + bundle = json.loads(_b64u_decode(token).decode("utf-8")) + + # Path and method must match the actual request. + bundle_path = urlparse(bundle["url"]).path + if bundle_path != request_path: + return False + if bundle["method"].upper() != request_method.upper(): + return False + + url = bundle["url"] + method = bundle["method"] + nonce = bundle["nonce"] + + # Verify challenge claim: challenge == b64u(SHA256(url|method|nonce)) + binding = f"{url}|{method}|{nonce}".encode() + expected_challenge = base64.urlsafe_b64encode(hashlib.sha256(binding).digest()).rstrip(b"=").decode() + + cdj_bytes = _b64u_decode(bundle["cdj"]) + cdj = json.loads(cdj_bytes) + if cdj.get("type") != "webauthn.get": + return False + if cdj.get("challenge") != expected_challenge: + return False + + # Verify ECDSA-P256 signature over authData || SHA256(clientDataJson). + auth_data = _b64u_decode(bundle["authData"]) + signature = _b64u_decode(bundle["sig"]) + client_data_hash = hashlib.sha256(cdj_bytes).digest() + message = auth_data + client_data_hash + + # Extract P-256 public key from AttestedCredentialData. + cred_data = _b64u_decode(bundle["cred"]) + cred_id_len = (cred_data[16] << 8) | cred_data[17] + cose_bytes = cred_data[18 + cred_id_len:] + cose_key = cbor2.loads(cose_bytes) + x = cose_key[-2] + y = cose_key[-3] + + pub_key = EllipticCurvePublicNumbers( + x=int.from_bytes(x, "big"), + y=int.from_bytes(y, "big"), + curve=SECP256R1(), + ).public_key(default_backend()) + + pub_key.verify(signature, message, ECDSA(SHA256())) + return True + except (InvalidSignature, Exception): + return False + + class ServerState: # All state is process-local; a restart resets the counter to zero. def __init__(self, proxy_token: str): @@ -52,7 +134,17 @@ class Handler(BaseHTTPRequestHandler): self.rfile.read(length) def _is_proxy_authorized(self) -> bool: - return self.headers.get("X-Proxy-Token") == self.state.proxy_token + # Accept legacy X-Proxy-Token (k_proxy_app.py) or FIDO2 assertion Bearer. + if self.headers.get("X-Proxy-Token") == self.state.proxy_token: + return True + auth = self.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return _verify_assertion_token( + auth[7:].strip(), + request_path=urlparse(self.path).path, + request_method=self.command, + ) + return False def do_GET(self) -> None: # noqa: N802 path = urlparse(self.path).path