diff --git a/Workplan.md b/Workplan.md index 6ff0d2a..fb066b6 100644 --- a/Workplan.md +++ b/Workplan.md @@ -547,6 +547,12 @@ The following changes replace the v1 architecture. Source: `chromecard_arkitektu **New open decisions:** Rendezvous mechanism for Component 3; iOS vs Android priority. +**Architectural decision (2026-05-08) — token binding model:** +Current choice: per-request authentication. No session is opened. Each request to a gated resource requires a fresh FIDO2 assertion from the card, with the challenge bound to the specific request (URL + method + nonce). The server verifies that the assertion's challenge matches the resource being requested. A token cannot be replayed for a different resource. +Consequence: one card interaction per request. This is intentional for now. +May change to: session model (one card interaction opens a time-limited session for all gated resources). If changed, token must at minimum be bound to a specific server (audience) to prevent cross-server replay. +Trigger for revisiting: user experience — if per-request card interaction proves too slow or disruptive. + ### Target architecture (v2) Four physical devices: optional client computer, phone, chromecard, server. diff --git a/component3/main.go b/component3/main.go index 572346b..ad6ebc3 100644 --- a/component3/main.go +++ b/component3/main.go @@ -14,7 +14,6 @@ func main() { 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)") - caDir := flag.String("ca-dir", "", "CA cert directory (default: ~/.config/component3/)") verbose := flag.Bool("v", false, "verbose logging") flag.Parse() @@ -31,9 +30,6 @@ func main() { if *gatedFile == "" { *gatedFile = filepath.Join(cfgDir, "gated_hosts.txt") } - if *caDir == "" { - *caDir = cfgDir - } gated := &GatedHosts{} if err := gated.Load(*gatedFile); err != nil { @@ -44,17 +40,9 @@ func main() { phone := NewPhoneClient(*phoneURL, *username) - mitm, err := NewMITM(*caDir) - if err != nil { - log.Fatalf("MITM init: %v", err) - } - log.Printf("CA cert: %s", mitm.CACertPath()) - log.Printf("To trust HTTPS interception, add the above CA cert to your browser trust store.") - proxy := &Proxy{ phone: phone, gated: gated, - mitm: mitm, verbose: *verbose, } diff --git a/component3/mitm.go b/component3/mitm.go deleted file mode 100644 index 2f41b03..0000000 --- a/component3/mitm.go +++ /dev/null @@ -1,216 +0,0 @@ -package main - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "fmt" - "log" - "math/big" - "net" - "os" - "path/filepath" - "sync" - "time" -) - -const ( - caCertFile = "ca.crt" - caKeyFile = "ca.key" -) - -// MITM manages a local CA and issues per-host leaf certificates on demand. -// The browser must trust the CA cert for HTTPS interception to work. -// On first run the CA is auto-generated and stored in the config directory. -type MITM struct { - dir string - caCert *x509.Certificate - caKey *ecdsa.PrivateKey - - mu sync.Mutex - cache map[string]*tls.Certificate // hostname → leaf cert -} - -func NewMITM(dir string) (*MITM, error) { - m := &MITM{dir: dir, cache: make(map[string]*tls.Certificate)} - - certPath := filepath.Join(dir, caCertFile) - keyPath := filepath.Join(dir, caKeyFile) - - if _, err := os.Stat(certPath); os.IsNotExist(err) { - log.Printf("mitm: generating CA in %s", dir) - if err := generateCA(certPath, keyPath); err != nil { - return nil, fmt.Errorf("generate CA: %w", err) - } - } - - if err := m.loadCA(certPath, keyPath); err != nil { - return nil, fmt.Errorf("load CA: %w", err) - } - - return m, nil -} - -// CACertPath returns the path to the CA cert file the browser must trust. -func (m *MITM) CACertPath() string { - return filepath.Join(m.dir, caCertFile) -} - -// TLSConfig returns a server-side TLS config that dynamically issues certs -// for each hostname presented in the TLS SNI extension. -func (m *MITM) TLSConfig(fallbackHost string) *tls.Config { - return &tls.Config{ - GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { - host := hello.ServerName - if host == "" { - host = fallbackHost - } - return m.certForHost(host) - }, - } -} - -func (m *MITM) certForHost(hostname string) (*tls.Certificate, error) { - m.mu.Lock() - defer m.mu.Unlock() - - if c, ok := m.cache[hostname]; ok { - // Reissue if the cached cert expires within the next minute. - if time.Until(c.Leaf.NotAfter) > 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/proxy.go b/component3/proxy.go index 7745657..a312913 100644 --- a/component3/proxy.go +++ b/component3/proxy.go @@ -1,8 +1,6 @@ package main import ( - "bufio" - "crypto/tls" "fmt" "io" "log" @@ -31,14 +29,13 @@ var hopByHopHeaders = map[string]bool{ // 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 +// 407 — HTTPS interception is not supported; use plain HTTP for gated endpoints. // // For non-gated hosts: // browser → Proxy → internet (transparent, no auth) type Proxy struct { phone *PhoneClient gated *GatedHosts - mitm *MITM verbose bool } @@ -123,7 +120,9 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { } if isGated { - p.handleGatedConnect(clientConn, host, target) + fmt.Fprintf(clientConn, "HTTP/1.1 407 Proxy Authentication Required\r\nContent-Type: text/plain\r\nProxy-Authenticate: Bearer realm=\"chromecard\"\r\n\r\nHTTPS tunnels to gated hosts are not supported. Use plain HTTP.\r\n") + p.logf("CONNECT %s: gated HTTPS not supported, returned 407", target) + clientConn.Close() } else { p.handleDirectConnect(clientConn, target) } @@ -144,90 +143,6 @@ func (p *Proxy) handleDirectConnect(clientConn net.Conn, target string) { 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)