Implement per-request FIDO2 token binding across all components

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 <noreply@anthropic.com>
This commit is contained in:
Morten V. Christiansen 2026-05-08 12:01:23 +02:00
parent ffa5bea1c7
commit 3fc40fc395
8 changed files with 235 additions and 137 deletions

View File

@ -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

View File

@ -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,

View File

@ -2,70 +2,42 @@ 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
}
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)
}
body, _ := json.Marshal(map[string]string{
"url": rawURL,
"method": method,
"nonce": nonce,
})
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()
@ -73,55 +45,24 @@ func (c *PhoneClient) getToken() (string, int, error) {
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)
return "", fmt.Errorf("parse token response: %w (body: %s)", err, raw)
}
if !result.Ok {
return "", 0, fmt.Errorf("no active session: %s", result.Error)
return "", fmt.Errorf("auth failed: %s", result.Error)
}
return result.Token, result.ExpiresIn, nil
if result.Token == "" {
return "", fmt.Errorf("phone returned empty token")
}
return result.Token, nil
}
// 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))
if err != nil {
return "", fmt.Errorf("phone unreachable (%s): %w", c.baseURL, err)
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", 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"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", fmt.Errorf("parse phone response: %w (body: %s)", err, raw)
}
if !result.Ok {
return "", fmt.Errorf("login rejected: %s", result.Error)
}
if result.SessionToken == "" {
return "", fmt.Errorf("phone returned empty session 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 hex.EncodeToString(b), nil
}

View File

@ -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

View File

@ -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<MakeCredentialResult> 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<GetAssertionResult> 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<GetAssertionResult> getAssertion(
authData: authData,
signature: signature,
clientDataHash: clientDataHash,
clientDataJson: clientDataJson,
);
}

View File

@ -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<String> _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<String> _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<String, dynamic>;
@ -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
// ---------------------------------------------------------------------------

View File

@ -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<void> _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});
}
// -------------------------------------------------------------------------

View File

@ -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