From 920d702dea31fc9af18140873d0be90d257c83c0 Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Tue, 5 May 2026 21:04:19 +0200 Subject: [PATCH] Refactor k_phone (v2) and add component3 Go binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit k_phone: - filter_proxy.dart: extract _writeProxyHeaders/_forwardHttpRequest helpers, removing ~30 lines of duplication; simplify _handleDirectHttp signature - proxy_service.dart: import portal_html, merge _serveHtml/_serveEnrollHtml → _serveHtmlBytes, extract _parseUsername/_parseUsernameAndDisplay helpers, remove dead _loadTlsContext stub, use SessionManager.ttlSeconds (872→455 lines) - portal_html.dart (new): kPortalHtml/kEnrollHtml/kPortalHtmlBytes/kEnrollHtmlBytes - session_manager.dart: expose ttlSeconds as public constant - filter_proxy_test.dart: rewritten for v2 — gated HTTP tests now verify Bearer token injection to endpoint directly; 24/24 pass - k_server_client.dart: deleted (dead code) component3 (Go proxy — first commit of entire directory): - gated.go: fix IsGated(host,port) — was silently missing host:port entries - proxy.go: pass port to IsGated in both handleHTTP and handleConnect - phone.go: add getToken() calling /auth/get-token to avoid unnecessary FIDO2 card interactions; fix login() JSON field expires_in→ttl_seconds Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 27 +- Workplan.md | 56 +++- component3/gated.go | 64 +++++ component3/go.mod | 3 + component3/main.go | 77 ++++++ component3/mitm.go | 216 +++++++++++++++ component3/phone.go | 127 +++++++++ component3/proxy.go | 266 ++++++++++++++++++ k_phone/lib/filter_proxy.dart | 165 ++++++++--- k_phone/lib/k_server_client.dart | 83 ------ k_phone/lib/portal_html.dart | 231 ++++++++++++++++ k_phone/lib/proxy_service.dart | 414 +++++----------------------- k_phone/lib/session_manager.dart | 12 +- k_phone/test/filter_proxy_test.dart | 173 +++++++++--- 14 files changed, 1373 insertions(+), 541 deletions(-) create mode 100644 component3/gated.go create mode 100644 component3/go.mod create mode 100644 component3/main.go create mode 100644 component3/mitm.go create mode 100644 component3/phone.go create mode 100644 component3/proxy.go delete mode 100644 k_phone/lib/k_server_client.dart create mode 100644 k_phone/lib/portal_html.dart diff --git a/CLAUDE.md b/CLAUDE.md index 3088e5d..5caef99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,18 +82,25 @@ Files are deployed to VMs via `scp :~` and run via `ssh time.Minute { + return c, nil + } + } + + cert, err := m.issueCert(hostname) + if err != nil { + return nil, err + } + m.cache[hostname] = cert + return cert, nil +} + +func (m *MITM) issueCert(hostname string) (*tls.Certificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + + tmpl := &x509.Certificate{ + SerialNumber: randomSerial(), + Subject: pkix.Name{ + CommonName: hostname, + Organization: []string{"ChromeCard Component3"}, + }, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + if ip := net.ParseIP(hostname); ip != nil { + tmpl.IPAddresses = []net.IP{ip} + } else { + tmpl.DNSNames = []string{hostname} + } + + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, m.caCert, &key.PublicKey, m.caKey) + if err != nil { + return nil, err + } + leaf, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, err + } + + return &tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + Leaf: leaf, + }, nil +} + +func (m *MITM) loadCA(certPath, keyPath string) error { + certPEM, err := os.ReadFile(certPath) + if err != nil { + return err + } + block, _ := pem.Decode(certPEM) + if block == nil { + return fmt.Errorf("no PEM block in %s", certPath) + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return err + } + + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return err + } + block, _ = pem.Decode(keyPEM) + if block == nil { + return fmt.Errorf("no PEM block in %s", keyPath) + } + key, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return err + } + + m.caCert = cert + m.caKey = key + return nil +} + +func generateCA(certPath, keyPath string) error { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + + tmpl := &x509.Certificate{ + SerialNumber: randomSerial(), + Subject: pkix.Name{ + CommonName: "ChromeCard Component3 CA", + Organization: []string{"ChromeCard"}, + }, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return err + } + + if err := writePEM(certPath, "CERTIFICATE", certDER, 0644); err != nil { + return err + } + + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return err + } + return writePEM(keyPath, "EC PRIVATE KEY", keyDER, 0600) +} + +func writePEM(path, pemType string, der []byte, perm os.FileMode) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return err + } + defer f.Close() + return pem.Encode(f, &pem.Block{Type: pemType, Bytes: der}) +} + +func randomSerial() *big.Int { + n, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + return n +} diff --git a/component3/phone.go b/component3/phone.go new file mode 100644 index 0000000..b5ae4ea --- /dev/null +++ b/component3/phone.go @@ -0,0 +1,127 @@ +package main + +import ( + "bytes" + "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. +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} +} + +// 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("{}"))) + if err != nil { + return "", 0, 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 +} + +// 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) + } + 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 +} diff --git a/component3/proxy.go b/component3/proxy.go new file mode 100644 index 0000000..7745657 --- /dev/null +++ b/component3/proxy.go @@ -0,0 +1,266 @@ +package main + +import ( + "bufio" + "crypto/tls" + "fmt" + "io" + "log" + "net" + "net/http" + "strings" + "time" +) + +// hopByHop headers that must not be forwarded by a proxy (RFC 7230 §6.1). +var hopByHopHeaders = map[string]bool{ + "connection": true, + "keep-alive": true, + "proxy-authenticate": true, + "proxy-authorization": true, + "te": true, + "trailers": true, + "transfer-encoding": true, + "upgrade": true, + "proxy-connection": true, // non-standard but common +} + +// Proxy is the HTTP/HTTPS proxy handler. +// +// For plain HTTP requests to gated hosts: +// browser → Proxy → (session token from phone) → endpoint directly → browser +// +// For HTTPS CONNECT to gated hosts: +// browser → Proxy → MITM TLS → (session token from phone) → endpoint directly → browser +// +// For non-gated hosts: +// browser → Proxy → internet (transparent, no auth) +type Proxy struct { + phone *PhoneClient + gated *GatedHosts + mitm *MITM + verbose bool +} + +func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + p.handleConnect(w, r) + } else { + p.handleHTTP(w, r) + } +} + +// handleHTTP handles plain HTTP proxy requests. +// For gated hosts: acquires a session token from the phone, adds it as +// Authorization: Bearer, then calls the endpoint directly. +func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Host == "" { + http.Error(w, "not a proxy request", http.StatusBadRequest) + return + } + + host := r.URL.Hostname() + port := r.URL.Port() + if port == "" { + port = "80" + } + isGated := p.gated.IsGated(host, port) + p.logf("HTTP %s %s (gated=%v)", r.Method, r.URL, isGated) + + // Build outgoing request. RequestURI must be empty for http.Client/RoundTrip. + out := r.Clone(r.Context()) + out.RequestURI = "" + stripHopByHop(out.Header) + + if isGated { + token, err := p.phone.EnsureSession() + if err != nil { + http.Error(w, "auth: "+err.Error(), http.StatusUnauthorized) + return + } + out.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := http.DefaultTransport.RoundTrip(out) + if err != nil { + http.Error(w, "upstream: "+err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + copyHeaders(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} + +// handleConnect handles HTTPS CONNECT tunnels. +// For gated hosts: does TLS MITM so Authorization can be injected into each +// inner HTTP request before it is forwarded to the actual server. +// For non-gated hosts: transparent byte-level tunnel. +func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { + host, portStr, err := net.SplitHostPort(r.Host) + if err != nil { + // No port — default to 443. + host = r.Host + portStr = "443" + } + if portStr == "" { + portStr = "443" + } + target := net.JoinHostPort(host, portStr) + isGated := p.gated.IsGated(host, portStr) + p.logf("CONNECT %s (gated=%v)", target, isGated) + + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijack not supported", http.StatusInternalServerError) + return + } + clientConn, _, err := hijacker.Hijack() + if err != nil { + log.Printf("hijack: %v", err) + return + } + + if isGated { + p.handleGatedConnect(clientConn, host, target) + } else { + p.handleDirectConnect(clientConn, target) + } +} + +// handleDirectConnect tunnels bytes transparently — no auth, no inspection. +func (p *Proxy) handleDirectConnect(clientConn net.Conn, target string) { + defer clientConn.Close() + + upConn, err := net.DialTimeout("tcp", target, 10*time.Second) + if err != nil { + fmt.Fprintf(clientConn, "HTTP/1.1 502 Bad Gateway\r\n\r\n") + return + } + defer upConn.Close() + + fmt.Fprintf(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n") + pipe(clientConn, upConn) +} + +// handleGatedConnect performs MITM TLS interception for a gated HTTPS host. +// Flow: +// 1. Acquire session token from phone (triggers FIDO2 card interaction). +// 2. Respond 200 to browser. +// 3. Wrap browser connection in TLS using a dynamically issued leaf cert. +// 4. Read each inner HTTP request, inject Authorization: Bearer, forward to +// the actual server over a fresh TLS connection. +// 5. Write the server response back to the browser. +func (p *Proxy) handleGatedConnect(clientConn net.Conn, hostname, target string) { + defer clientConn.Close() + + token, err := p.phone.EnsureSession() + if err != nil { + fmt.Fprintf(clientConn, "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n") + log.Printf("gated CONNECT %s: auth failed: %v", hostname, err) + return + } + + if _, err := fmt.Fprintf(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + return + } + + // Wrap the browser-side connection in TLS (we present a MITM cert). + tlsClient := tls.Server(clientConn, p.mitm.TLSConfig(hostname)) + if err := tlsClient.Handshake(); err != nil { + p.logf("MITM TLS handshake for %s: %v", hostname, err) + return + } + defer tlsClient.Close() + + // Serve inner HTTP requests on the now-decrypted browser stream. + clientBuf := bufio.NewReader(tlsClient) + for { + req, err := http.ReadRequest(clientBuf) + if err != nil { + if err != io.EOF { + p.logf("read inner request from browser (%s): %v", hostname, err) + } + return + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Connection", "close") // no keep-alive to upstream + stripHopByHop(req.Header) + + if err := p.forwardToUpstream(tlsClient, req, hostname, target); err != nil { + p.logf("forward to upstream %s: %v", target, err) + return + } + } +} + +// forwardToUpstream dials the real server over TLS, sends req, writes the +// response back to clientConn. +func (p *Proxy) forwardToUpstream(clientConn io.Writer, req *http.Request, hostname, target string) error { + upConn, err := tls.Dial("tcp", target, &tls.Config{ + ServerName: hostname, + // TODO: pin server CA cert for production; accept any for now. + InsecureSkipVerify: true, + }) + if err != nil { + fmt.Fprintf(clientConn, "HTTP/1.1 502 Bad Gateway\r\n\r\n") + return fmt.Errorf("dial: %w", err) + } + defer upConn.Close() + + // Reconstruct a minimal absolute request for writing on the wire. + req.URL.Scheme = "https" + req.URL.Host = hostname + req.RequestURI = "" + + if err := req.Write(upConn); err != nil { + return fmt.Errorf("write request: %w", err) + } + + resp, err := http.ReadResponse(bufio.NewReader(upConn), req) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + defer resp.Body.Close() + + return resp.Write(clientConn) +} + +// pipe copies bytes bidirectionally between two connections until either closes. +func pipe(a, b net.Conn) { + done := make(chan struct{}, 2) + go func() { io.Copy(a, b); done <- struct{}{} }() + go func() { io.Copy(b, a); done <- struct{}{} }() + <-done +} + +// stripHopByHop removes hop-by-hop headers and any headers named in Connection. +func stripHopByHop(h http.Header) { + if conn := h.Get("Connection"); conn != "" { + for _, name := range strings.Split(conn, ",") { + h.Del(strings.TrimSpace(name)) + } + } + for name := range hopByHopHeaders { + h.Del(name) + } +} + +// copyHeaders copies non-hop-by-hop headers from src to dst. +func copyHeaders(dst, src http.Header) { + for k, vs := range src { + if !hopByHopHeaders[strings.ToLower(k)] { + for _, v := range vs { + dst.Add(k, v) + } + } + } +} + +func (p *Proxy) logf(format string, args ...any) { + if p.verbose { + log.Printf(format, args...) + } +} diff --git a/k_phone/lib/filter_proxy.dart b/k_phone/lib/filter_proxy.dart index fca3a0a..9697abf 100644 --- a/k_phone/lib/filter_proxy.dart +++ b/k_phone/lib/filter_proxy.dart @@ -1,25 +1,18 @@ -// Component 1 — HTTP proxy with URL gating filter. +// Component 1 — HTTP proxy with URL gating filter (v2 architecture). // -// All browser traffic enters here. The routing rule is a single binary decision: -// gated host → relay through Component 2 on localhost:_component2Port -// other host → forward directly to the target host:port +// Routing rule — binary decision per request: +// gated host → ask Component 2 for a bearer token (POST /auth/get-token), +// then call the endpoint directly with Authorization: Bearer. +// other host → forward directly to the target host:port (no auth, port 80) // -// "Gated hosts" are resources that require FIDO2 card authentication before -// they can be accessed. Traffic to them is relayed through Component 2, which -// checks for an active session before forwarding. +// For HTTPS (CONNECT) to gated hosts the CONNECT is still relayed through +// Component 2 (session-gate check), with Component 2 opening the upstream TCP +// connection. TODO: replace with local MITM so Component 2 never contacts +// endpoints directly. // // Gated hosts file (gated_hosts.txt in the app documents directory): one entry -// per line, either "host" or "host:port". Lines starting with "#" and blank +// per line, either "host" or "host:port". Lines starting with "#" and blank // lines are ignored. -// -// Example gated_hosts.txt: -// # External test resource (requires card login) -// httpbin.org -// -// For HTTPS (CONNECT) traffic to gated hosts this proxy sends a CONNECT request -// to Component 2 and waits for its 200/4xx response before responding to the -// browser. This lets Component 2 enforce the session check before the TLS -// tunnel is established; the raw TLS bytes are never exposed to Component 2. import 'dart:async'; import 'dart:convert'; @@ -362,9 +355,7 @@ class FilterProxy { } // --------------------------------------------------------------------------- - // Plain HTTP request (both gated and non-gated use the same handler here — - // gating for plain HTTP is enforced by Component 2 when it receives the - // forwarded request and checks the Host header) + // Plain HTTP request // --------------------------------------------------------------------------- Future _handleHttp( @@ -386,7 +377,6 @@ class FilterProxy { final host = uri.host; final port = uri.hasPort ? uri.port : 80; - final path = _relativePath(uri); int contentLength = 0; for (final h in headerLines) { @@ -396,35 +386,130 @@ class FilterProxy { } } - // For gated plain-HTTP hosts, route through Component 2; for others, direct. - final Socket upstream; + if (_isGated(host, port)) { + await _handleGatedHttp(client, sub, method, uri, headerLines, remainder, contentLength); + } else { + await _handleDirectHttp(client, sub, method, uri, headerLines, remainder, contentLength); + } + } + + // Gated plain HTTP (v2): get token from Component 2, then call endpoint directly. + Future _handleGatedHttp( + Socket client, + StreamSubscription> sub, + String method, + Uri uri, + List headerLines, + List remainder, + int contentLength, + ) async { + String token; try { - if (_isGated(host, port)) { - upstream = await Socket.connect('127.0.0.1', _component2Port) - .timeout(const Duration(seconds: 5)); - } else { - upstream = await Socket.connect(host, port) - .timeout(const Duration(seconds: 10)); - } - } catch (e) { + token = await _getAuthToken(); + } catch (_) { + _deny(client, sub, 407, 'Proxy Authentication Required'); + return; + } + + Socket upstream; + try { + upstream = await Socket.connect(uri.host, uri.hasPort ? uri.port : 80) + .timeout(const Duration(seconds: 10)); + } catch (_) { _deny(client, sub, 502, 'Bad Gateway'); return; } - final out = StringBuffer() + final out = StringBuffer(); + _writeProxyHeaders(out, method, _relativePath(uri), uri, headerLines, bearerToken: token); + await _forwardHttpRequest(client, sub, upstream, out.toString(), remainder, contentLength); + } + + // Non-gated plain HTTP: forward directly, no auth. + Future _handleDirectHttp( + Socket client, + StreamSubscription> sub, + String method, + Uri uri, + List headerLines, + List remainder, + int contentLength, + ) async { + Socket upstream; + try { + upstream = await Socket.connect(uri.host, uri.hasPort ? uri.port : 80) + .timeout(const Duration(seconds: 10)); + } catch (_) { + _deny(client, sub, 502, 'Bad Gateway'); + return; + } + + final out = StringBuffer(); + _writeProxyHeaders(out, method, _relativePath(uri), uri, headerLines); + 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 { + final httpClient = HttpClient() + ..connectionTimeout = const Duration(seconds: 5); + try { + final req = await httpClient.postUrl( + Uri(scheme: 'http', host: '127.0.0.1', port: _component2Port, path: '/auth/get-token'), + ); + req.headers.contentType = ContentType.json; + req.contentLength = 2; + req.write('{}'); + final resp = await req.close(); + final body = await resp.transform(utf8.decoder).join(); + final json = jsonDecode(body) as Map; + if (json['ok'] == true) { + return json['token'] as String; + } + throw Exception(json['error'] ?? 'auth failed'); + } finally { + httpClient.close(); + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + void _writeProxyHeaders( + StringBuffer out, + String method, + String path, + Uri uri, + List headerLines, { + String? bearerToken, + }) { + out ..write('$method $path HTTP/1.1\r\n') - ..write('Host: ${uri.host}${uri.hasPort ? ':${uri.port}' : ''}\r\n'); + ..write('Host: ${uri.host}${uri.hasPort ? ":${uri.port}" : ""}\r\n'); + if (bearerToken != null) out.write('Authorization: Bearer $bearerToken\r\n'); for (final h in headerLines) { if (h.isEmpty) continue; final lower = h.toLowerCase(); if (lower.startsWith('host:') || lower.startsWith('proxy-connection:') || - lower.startsWith('proxy-authorization:')) continue; + lower.startsWith('proxy-authorization:') || + (bearerToken != null && lower.startsWith('authorization:'))) continue; out.write('$h\r\n'); } out.write('Connection: close\r\n\r\n'); + } - upstream.add(utf8.encode(out.toString())); + Future _forwardHttpRequest( + Socket client, + StreamSubscription> sub, + Socket upstream, + String headers, + List remainder, + int contentLength, + ) async { + upstream.add(utf8.encode(headers)); if (remainder.isNotEmpty) upstream.add(remainder); final bodyLeft = contentLength - remainder.length; @@ -442,20 +527,12 @@ class FilterProxy { client.add, // flush() drains the write buffer before closing; destroy() would drop it. onDone: () { client.flush().whenComplete(client.destroy).whenComplete(() { if (!done.isCompleted) done.complete(); }); }, - onError: (_) { - upstream.destroy(); - client.destroy(); - if (!done.isCompleted) done.complete(); - }, + onError: (_) { upstream.destroy(); client.destroy(); if (!done.isCompleted) done.complete(); }, cancelOnError: true, ); await done.future; } - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- - void _deny(Socket client, StreamSubscription> sub, int code, String reason) { sub.cancel(); client.add(utf8.encode( diff --git a/k_phone/lib/k_server_client.dart b/k_phone/lib/k_server_client.dart deleted file mode 100644 index 200e319..0000000 --- a/k_phone/lib/k_server_client.dart +++ /dev/null @@ -1,83 +0,0 @@ -// Client for forwarding requests to k_server (:8780). -// Mirrors the k_proxy → k_server leg in k_proxy_app.py. - -import 'dart:io'; -import 'dart:typed_data'; - -const String kServerHost = '127.0.0.1'; // k_server address (same device or Qubes forward) -const int kServerPort = 8780; - -class KServerResponse { - final int statusCode; - final HttpHeaders headers; - final Uint8List body; - - KServerResponse({ - required this.statusCode, - required this.headers, - required this.body, - }); -} - -class KServerClient { - HttpClient? _client; - - HttpClient _getClient() { - // TLS: k_server uses self-signed cert from generate_phase2_certs.py. - // In dev, accept any cert; in prod, pin the CA cert. - _client ??= HttpClient() - ..badCertificateCallback = (cert, host, port) { - // TODO: replace with CA pinning once certs are bundled. - return true; - }; - return _client!; - } - - Future forward({ - required String method, - required String path, - required HttpHeaders headers, - required Uint8List body, - }) async { - final client = _getClient(); - final uri = Uri( - scheme: 'https', - host: kServerHost, - port: kServerPort, - path: path, - ); - - final req = await client.openUrl(method, uri); - - // Forward relevant headers - headers.forEach((name, values) { - if (_shouldForwardHeader(name)) { - for (final v in values) req.headers.add(name, v); - } - }); - - if (body.isNotEmpty) { - req.headers.contentLength = body.length; - req.add(body); - } - - final res = await req.close(); - final resBody = await res.fold>([], (a, b) => a..addAll(b)); - - return KServerResponse( - statusCode: res.statusCode, - headers: res.headers, - body: Uint8List.fromList(resBody), - ); - } - - bool _shouldForwardHeader(String name) { - // 'authorization' is intentionally stripped: it carries the k_phone session - // token which is meaningless to k_server. k_server authenticates via the - // X-Proxy-Token header added by the Kotlin layer, not by bearer tokens. - const skip = {'host', 'connection', 'transfer-encoding', 'authorization'}; - return !skip.contains(name.toLowerCase()); - } - - void close() => _client?.close(); -} diff --git a/k_phone/lib/portal_html.dart b/k_phone/lib/portal_html.dart new file mode 100644 index 0000000..77e9a3d --- /dev/null +++ b/k_phone/lib/portal_html.dart @@ -0,0 +1,231 @@ +import 'dart:convert'; + +final List kPortalHtmlBytes = utf8.encode(kPortalHtml); +final List kEnrollHtmlBytes = utf8.encode(kEnrollHtml); + +const String kPortalHtml = ''' + + + + + ChromeCard k_phone Portal + + + +
+
+

ChromeCard k_phone Portal

+

Phone-mediated FIDO2 proxy. Registration and assertion happen on the Android app via USB HID or emulator bridge.

+
+
+
+

Enrollment

+ + + + +
+ + + + + +
+
+
Stored username: none
+
Session active: no
+
+
+
+

Session Flow

+
+ + + + +
+
+
+

+  
+ + +'''; + +const String kEnrollHtml = ''' + + + + + ChromeCard — Registration + + + +
+

ChromeCard — User Registration

+ +
+

Registered users

+
Loading…
+
+ +
+

Register new user

+
+ + + + + +
+
+
+
+ + +'''; diff --git a/k_phone/lib/proxy_service.dart b/k_phone/lib/proxy_service.dart index ce7daf8..b84c3fa 100644 --- a/k_phone/lib/proxy_service.dart +++ b/k_phone/lib/proxy_service.dart @@ -1,8 +1,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; - import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -10,12 +8,10 @@ import 'ctaphid_channel.dart'; import 'enrollment_db.dart'; import 'filter_proxy.dart'; import 'fido2_ops.dart'; -import 'k_server_client.dart'; +import 'portal_html.dart'; import 'session_manager.dart'; const int kProxyPort = 8771; -// Must match SessionManager._ttl; used only in the API response payload. -const int _kSessionTtlSeconds = 300; const String kNotificationChannelId = 'kphone_proxy'; const String kNotificationChannelName = 'k_phone proxy service'; @@ -84,7 +80,6 @@ class _ProxyServer { final FilterProxy _filterProxy = FilterProxy(); final SessionManager _sessions = SessionManager(); final EnrollmentDb _db = EnrollmentDb(); - final KServerClient _kserver = KServerClient(); int? _cardCid; bool _cardAttached = false; bool _running = false; @@ -116,19 +111,9 @@ class _ProxyServer { // Card detection and DB loading are independent — run in parallel. await Future.wait([_tryOpenCard(), _db.ensureLoaded()]); - SecurityContext? tlsCtx; + _emit('No TLS certs found — running plain HTTP (dev mode)'); try { - tlsCtx = await _loadTlsContext(); - } catch (_) { - _emit('No TLS certs found — running plain HTTP (dev mode)'); - } - - try { - if (tlsCtx != null) { - _server = await HttpServer.bindSecure(InternetAddress.anyIPv4, kProxyPort, tlsCtx); - } else { - _server = await HttpServer.bind(InternetAddress.anyIPv4, kProxyPort); - } + _server = await HttpServer.bind(InternetAddress.anyIPv4, kProxyPort); _emit('Listening on :$kProxyPort'); _server!.listen(_handleRequest, onError: (e) => _emit('Server error: $e')); } catch (e) { @@ -160,9 +145,9 @@ class _ProxyServer { if (req.method == 'GET') { switch (path) { case '/': - await _serveHtml(req); + await _serveHtmlBytes(req, kPortalHtmlBytes); case '/enroll': - await _serveEnrollHtml(req); + await _serveHtmlBytes(req, kEnrollHtmlBytes); case '/health': await _handleHealth(req); case '/enroll/list': @@ -188,8 +173,8 @@ class _ProxyServer { await _handleSessionStatus(req); case '/session/logout': await _handleSessionLogout(req); - case '/resource/counter': - await _handleResourceCounter(req); + case '/auth/get-token': + await _handleAuthGetToken(req); default: await _send(req.response, 404, {'ok': false, 'error': 'not found'}); } @@ -214,18 +199,9 @@ class _ProxyServer { final body = await _readJson(req); if (body == null) return; - final rawUsername = body['username'] as String? ?? ''; - final rawDisplay = body['display_name'] as String?; - - String canonical; - String? pretty; - try { - canonical = normalizeUsername(rawUsername); - pretty = normalizeDisplayName(rawDisplay); - } on ArgumentError catch (e) { - await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); - return; - } + final r = await _parseUsernameAndDisplay(req, body); + if (r == null) return; + final (canonical, pretty) = r; MakeCredentialResult? credential; if (_cardAttached && _cardCid != null) { @@ -258,18 +234,9 @@ class _ProxyServer { final body = await _readJson(req); if (body == null) return; - final rawUsername = body['username'] as String? ?? ''; - final rawDisplay = body['display_name'] as String?; - - String canonical; - String? pretty; - try { - canonical = normalizeUsername(rawUsername); - pretty = normalizeDisplayName(rawDisplay); - } on ArgumentError catch (e) { - await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); - return; - } + final r = await _parseUsernameAndDisplay(req, body); + if (r == null) return; + final (canonical, pretty) = r; try { final enrollment = await _db.update(username: canonical, displayName: pretty); @@ -283,15 +250,8 @@ class _ProxyServer { final body = await _readJson(req); if (body == null) return; - final rawUsername = body['username'] as String? ?? ''; - - String canonical; - try { - canonical = normalizeUsername(rawUsername); - } on ArgumentError catch (e) { - await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); - return; - } + final canonical = await _parseUsername(req, body); + if (canonical == null) return; try { final enrollment = await _db.delete(canonical); @@ -338,14 +298,8 @@ class _ProxyServer { final body = await _readJson(req); if (body == null) return; - final rawUsername = body['username'] as String? ?? ''; - String canonical; - try { - canonical = normalizeUsername(rawUsername); - } on ArgumentError catch (e) { - await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); - return; - } + final canonical = await _parseUsername(req, body); + if (canonical == null) return; final enrollment = await _db.get(canonical); if (enrollment == null) { @@ -389,7 +343,7 @@ class _ProxyServer { 'username': canonical, 'session_token': token, 'expires_at': expiresAt, - 'ttl_seconds': _kSessionTtlSeconds, + 'ttl_seconds': SessionManager.ttlSeconds, 'auth_mode': authMode, }); } @@ -485,46 +439,36 @@ class _ProxyServer { } // ------------------------------------------------------------------------- - // Resource forwarding + // Auth token endpoint (v2 architecture) + // + // 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. // ------------------------------------------------------------------------- - Future _handleResourceCounter(HttpRequest req) async { + Future _handleAuthGetToken(HttpRequest req) async { await _drainBody(req); - final token = _extractBearerToken(req); - if (token == null) { - await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'}); - return; - } - final session = _sessions.getSession(token); - if (session == null) { - await _send(req.response, 401, {'ok': false, 'error': 'invalid or expired session'}); + + // 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, + }); return; } - final result = await _kserver.forward( - method: 'POST', - path: '/resource/counter', - headers: req.headers, - body: Uint8List(0), - ); - - if (result.statusCode != 200) { - await _send(req.response, result.statusCode, {'ok': false, 'error': 'upstream failed'}); - return; - } - - Map upstream; - try { - upstream = jsonDecode(utf8.decode(result.body)) as Map; - } catch (_) { - upstream = {}; - } - - await _send(req.response, 200, { - 'ok': true, - 'username': session.username, - 'session_reused': true, - 'upstream': upstream, + // No active session — caller must trigger /session/login first. + await _send(req.response, 401, { + 'ok': false, + 'error': 'no active session', + 'login_required': true, }); } @@ -542,19 +486,11 @@ class _ProxyServer { }); } - Future _serveHtml(HttpRequest req) async { + Future _serveHtmlBytes(HttpRequest req, List bytes) async { req.response.statusCode = 200; req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8'); - req.response.headers.contentLength = _kPortalHtmlBytes.length; - req.response.add(_kPortalHtmlBytes); - await req.response.close(); - } - - Future _serveEnrollHtml(HttpRequest req) async { - req.response.statusCode = 200; - req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8'); - req.response.headers.contentLength = _kEnrollHtmlBytes.length; - req.response.add(_kEnrollHtmlBytes); + req.response.headers.contentLength = bytes.length; + req.response.add(bytes); await req.response.close(); } @@ -641,245 +577,25 @@ class _ProxyServer { return m; } - Future _loadTlsContext() async { - throw UnimplementedError('TLS cert loading not yet wired up'); + Future _parseUsername(HttpRequest req, Map body) async { + try { + return normalizeUsername(body['username'] as String? ?? ''); + } on ArgumentError catch (e) { + await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); + return null; + } + } + + Future<(String, String?)?> _parseUsernameAndDisplay( + HttpRequest req, Map body) async { + try { + return ( + normalizeUsername(body['username'] as String? ?? ''), + normalizeDisplayName(body['display_name'] as String?), + ); + } on ArgumentError catch (e) { + await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); + return null; + } } } - -// --------------------------------------------------------------------------- -// Portal HTML (mirrors k_proxy_app.py HTML) -// --------------------------------------------------------------------------- - -final _kPortalHtmlBytes = utf8.encode(_kPortalHtml); -final _kEnrollHtmlBytes = utf8.encode(_kEnrollHtml); - -const String _kPortalHtml = ''' - - - - - ChromeCard k_phone Portal - - - -
-
-

ChromeCard k_phone Portal

-

Phone-mediated FIDO2 proxy. Registration and assertion happen on the Android app via USB HID or emulator bridge.

-
-
-
-

Enrollment

- - - - -
- - - - - -
-
-
Stored username: none
-
Session active: no
-
-
-
-

Session Flow

-
- - - - -
-
-
-

-  
- - -'''; - -// --------------------------------------------------------------------------- -// Enrollment / Registration HTML (GET /enroll) -// --------------------------------------------------------------------------- - -const String _kEnrollHtml = ''' - - - - - ChromeCard — Registration - - - -
-

ChromeCard — User Registration

- -
-

Registered users

-
Loading…
-
- -
-

Register new user

-
- - - - - -
-
-
-
- - -'''; diff --git a/k_phone/lib/session_manager.dart b/k_phone/lib/session_manager.dart index 504b774..92d60e5 100644 --- a/k_phone/lib/session_manager.dart +++ b/k_phone/lib/session_manager.dart @@ -11,7 +11,8 @@ class SessionEntry { class SessionManager { final Map _sessions = {}; - static const Duration _ttl = Duration(seconds: 300); + static const int ttlSeconds = 300; + static const Duration _ttl = Duration(seconds: ttlSeconds); /// Issue a new session token for [username]. /// _purgeExpired is only called here, not on every lookup, so tokens accumulate @@ -51,6 +52,15 @@ class SessionManager { return _sessions.isNotEmpty; } + /// Returns the token and session entry of any currently active session. + /// Used by /auth/get-token to return an existing token without card interaction. + (String token, SessionEntry session)? anyActive() { + _purgeExpired(); + if (_sessions.isEmpty) return null; + final e = _sessions.entries.first; + return (e.key, e.value); + } + /// Revoke all sessions for [username]. void revokeAll(String username) { _sessions.removeWhere((_, s) => s.username == username); diff --git a/k_phone/test/filter_proxy_test.dart b/k_phone/test/filter_proxy_test.dart index 974e5df..503d839 100644 --- a/k_phone/test/filter_proxy_test.dart +++ b/k_phone/test/filter_proxy_test.dart @@ -19,8 +19,7 @@ import '../lib/filter_proxy.dart'; const _kTimeout = Duration(seconds: 5); -// Start an HttpServer that accepts one request, records it, and replies 200 OK. -// Returns the server and a Completer (use .future to await; .isCompleted to check). +// Start an HttpServer that records the first request and replies 200 OK. Future<({HttpServer server, Completer completer})> _mockHttp() async { final server = await HttpServer.bind('127.0.0.1', 0); final c = Completer(); @@ -38,6 +37,32 @@ Future<({HttpServer server, Completer completer})> _mockHttp() asyn return (server: server, completer: c); } +// Mock for Component 2's /auth/get-token endpoint. +// Responds to every request, completing [tokenReq] on the first one. +// When [ok] is false, returns 401 with an error payload. +Future<({HttpServer server, Completer tokenReq})> _mockTokenServer({ + String token = 'test-bearer-token', + bool ok = true, +}) async { + final server = await HttpServer.bind('127.0.0.1', 0); + final c = Completer(); + server.listen((req) async { + await req.drain(); + if (!c.isCompleted) c.complete(req); + final body = ok + ? '{"ok":true,"token":"$token","expires_in":300}' + : '{"ok":false,"error":"no active session","login_required":true}'; + req.response + ..statusCode = ok ? 200 : 401 + ..headers.set('content-type', 'application/json') + ..headers.set('content-length', '${body.length}') + ..headers.set('connection', 'close') + ..write(body); + await req.response.close(); + }); + return (server: server, tokenReq: c); +} + // Start a raw TCP server that hands back the accepted Socket. Future<({ServerSocket server, Future socket})> _mockTcp() async { final server = await ServerSocket.bind('127.0.0.1', 0); @@ -162,56 +187,85 @@ void main() { }); // ------------------------------------------------------------------------- - // Group 2: HTTP routing + // Group 2: HTTP routing (v2 semantics) + // + // Gated HTTP: proxy calls comp2 POST /auth/get-token, then forwards the + // request directly to the endpoint with Authorization: Bearer . + // Non-gated HTTP: proxy forwards directly, no token fetch. // ------------------------------------------------------------------------- group('HTTP routing', () { late FilterProxy proxy; late HttpServer comp2; - late Completer comp2Req; - late HttpServer direct; + late Completer comp2TokenReq; + late HttpServer endpoint; + late Completer endpointReq; + late HttpServer directServer; late Completer directReq; - setUp(() async { - final c2 = await _mockHttp(); - comp2 = c2.server; - comp2Req = c2.completer; + const testToken = 'test-bearer-token'; + setUp(() async { + // Component 2 mock: handles POST /auth/get-token → returns token. + final c2 = await _mockTokenServer(token: testToken); + comp2 = c2.server; + comp2TokenReq = c2.tokenReq; + + // Gated endpoint mock: the actual resource the proxy calls directly. + final ep = await _mockHttp(); + endpoint = ep.server; + endpointReq = ep.completer; + + // Non-gated target mock. final d = await _mockHttp(); - direct = d.server; + directServer = d.server; directReq = d.completer; proxy = FilterProxy( listenPort: 0, component2Port: comp2.port, ); - // 'auth.local' is gated; '127.0.0.1' is not. - proxy.setGatedEntries(['auth.local']); + // Gate the endpoint address; 127.0.0.1 + endpoint port is resolvable in tests. + proxy.setGatedEntries(['127.0.0.1:${endpoint.port}']); await proxy.start(); }); tearDown(() async { await proxy.stop(); await comp2.close(force: true); - await direct.close(force: true); + await endpoint.close(force: true); + await directServer.close(force: true); }); - test('gated host is forwarded to component2', () async { + test('gated host: token is fetched from component2', () async { + await _round( + proxy.port, + 'GET http://127.0.0.1:${endpoint.port}/api HTTP/1.1\r\n' + 'Host: 127.0.0.1:${endpoint.port}\r\n\r\n', + ); + final req = await comp2TokenReq.future.timeout(_kTimeout); + expect(req.method, 'POST'); + expect(req.uri.path, '/auth/get-token'); + }); + + test('gated host: request goes directly to endpoint with Bearer token', () async { final response = await _round( proxy.port, - 'GET http://auth.local/api HTTP/1.1\r\nHost: auth.local\r\n\r\n', + 'GET http://127.0.0.1:${endpoint.port}/api HTTP/1.1\r\n' + 'Host: 127.0.0.1:${endpoint.port}\r\n\r\n', ); - final req = await comp2Req.future.timeout(_kTimeout); + final req = await endpointReq.future.timeout(_kTimeout); expect(req.method, 'GET'); expect(req.uri.path, '/api'); + expect(req.headers.value('authorization'), 'Bearer $testToken'); expect(response, contains('200 OK')); }); test('non-gated host is forwarded directly', () async { final response = await _round( proxy.port, - 'GET http://127.0.0.1:${direct.port}/page HTTP/1.1\r\n' - 'Host: 127.0.0.1:${direct.port}\r\n\r\n', + 'GET http://127.0.0.1:${directServer.port}/page HTTP/1.1\r\n' + 'Host: 127.0.0.1:${directServer.port}\r\n\r\n', ); final req = await directReq.future.timeout(_kTimeout); @@ -223,78 +277,109 @@ void main() { test('non-gated request does NOT reach component2', () async { await _round( proxy.port, - 'GET http://127.0.0.1:${direct.port}/page HTTP/1.1\r\n' - 'Host: 127.0.0.1:${direct.port}\r\n\r\n', + 'GET http://127.0.0.1:${directServer.port}/page HTTP/1.1\r\n' + 'Host: 127.0.0.1:${directServer.port}\r\n\r\n', ); await directReq.future.timeout(_kTimeout); - - // comp2 should never have received anything - expect(comp2Req.isCompleted, isFalse); + expect(comp2TokenReq.isCompleted, isFalse); }); - test('request line is rewritten from absolute URL to relative path', () async { + test('gated: request line is rewritten to relative path', () async { await _round( proxy.port, - 'GET http://auth.local/session/login?foo=bar HTTP/1.1\r\n' - 'Host: auth.local\r\n\r\n', + 'GET http://127.0.0.1:${endpoint.port}/session/login?foo=bar HTTP/1.1\r\n' + 'Host: 127.0.0.1:${endpoint.port}\r\n\r\n', ); - final req = await comp2Req.future.timeout(_kTimeout); - // The mock HttpServer parses the rewritten request. + final req = await endpointReq.future.timeout(_kTimeout); expect(req.uri.path, '/session/login'); expect(req.uri.query, 'foo=bar'); }); - test('Proxy-Connection header is stripped', () async { + test('gated: Proxy-Connection header is stripped', () async { await _round( proxy.port, - 'GET http://auth.local/health HTTP/1.1\r\n' - 'Host: auth.local\r\n' + 'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n' + 'Host: 127.0.0.1:${endpoint.port}\r\n' 'Proxy-Connection: keep-alive\r\n\r\n', ); - final req = await comp2Req.future.timeout(_kTimeout); + final req = await endpointReq.future.timeout(_kTimeout); expect(req.headers.value('proxy-connection'), isNull); }); - test('Proxy-Authorization header is stripped', () async { + test('gated: Proxy-Authorization header is stripped', () async { await _round( proxy.port, - 'GET http://auth.local/health HTTP/1.1\r\n' - 'Host: auth.local\r\n' + 'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n' + 'Host: 127.0.0.1:${endpoint.port}\r\n' 'Proxy-Authorization: Basic dXNlcjpwYXNz\r\n\r\n', ); - final req = await comp2Req.future.timeout(_kTimeout); + final req = await endpointReq.future.timeout(_kTimeout); expect(req.headers.value('proxy-authorization'), isNull); }); - test('custom header is preserved', () async { + test('gated: existing Authorization header is replaced with Bearer token', () async { await _round( proxy.port, - 'GET http://auth.local/health HTTP/1.1\r\n' - 'Host: auth.local\r\n' + 'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n' + 'Host: 127.0.0.1:${endpoint.port}\r\n' + 'Authorization: Basic dXNlcjpwYXNz\r\n\r\n', + ); + final req = await endpointReq.future.timeout(_kTimeout); + expect(req.headers.value('authorization'), 'Bearer $testToken'); + }); + + test('gated: custom header is preserved', () async { + await _round( + proxy.port, + 'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n' + 'Host: 127.0.0.1:${endpoint.port}\r\n' 'X-Custom: hello\r\n\r\n', ); - final req = await comp2Req.future.timeout(_kTimeout); + final req = await endpointReq.future.timeout(_kTimeout); expect(req.headers.value('x-custom'), 'hello'); }); - test('POST body is forwarded to component2', () async { + test('gated: POST body is forwarded to endpoint', () async { const body = '{"username":"alice"}'; await _round( proxy.port, - 'POST http://auth.local/session/login HTTP/1.1\r\n' - 'Host: auth.local\r\n' + 'POST http://127.0.0.1:${endpoint.port}/session/login HTTP/1.1\r\n' + 'Host: 127.0.0.1:${endpoint.port}\r\n' 'Content-Type: application/json\r\n' 'Content-Length: ${body.length}\r\n\r\n' '$body', ); - final req = await comp2Req.future.timeout(_kTimeout); + final req = await endpointReq.future.timeout(_kTimeout); expect(req.method, 'POST'); expect(req.uri.path, '/session/login'); + expect(req.headers.value('authorization'), 'Bearer $testToken'); + }); + + test('gated: 407 returned when component2 has no active session', () async { + final c2Err = await _mockTokenServer(ok: false); + final proxy2 = FilterProxy( + listenPort: 0, + component2Port: c2Err.server.port, + ); + proxy2.setGatedEntries(['127.0.0.1:${endpoint.port}']); + await proxy2.start(); + addTearDown(() async { + await proxy2.stop(); + await c2Err.server.close(force: true); + }); + + final response = await _round( + proxy2.port, + 'GET http://127.0.0.1:${endpoint.port}/api HTTP/1.1\r\n' + 'Host: 127.0.0.1:${endpoint.port}\r\n\r\n', + ); + expect(response, contains('407')); }); }); // ------------------------------------------------------------------------- // Group 3: CONNECT tunnel routing + // (Gated CONNECT is still relayed through Component 2 — unchanged in v2.) // ------------------------------------------------------------------------- group('CONNECT routing', () { late FilterProxy proxy;