Remove MITM from Component 3; record per-request token binding decision

- Delete mitm.go: CA generation and HTTPS interception removed entirely
- proxy.go: remove handleGatedConnect, forwardToUpstream, MITM struct field;
  gated CONNECT now returns 407 with explanation
- main.go: remove --ca-dir flag and MITM initialisation
- Workplan.md: record per-request auth decision (challenge bound to
  URL + method + nonce; no session opened; may revisit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Morten V. Christiansen 2026-05-08 10:47:34 +02:00
parent 920d702dea
commit ffa5bea1c7
4 changed files with 10 additions and 317 deletions

View File

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

View File

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

View File

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

View File

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