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:
parent
920d702dea
commit
ffa5bea1c7
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue