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.
|
**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)
|
### Target architecture (v2)
|
||||||
|
|
||||||
Four physical devices: optional client computer, phone, chromecard, server.
|
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)")
|
phoneURL := flag.String("phone", "http://192.168.1.10:8771", "phone base URL (Component 1/2)")
|
||||||
username := flag.String("user", "", "FIDO2 username (required)")
|
username := flag.String("user", "", "FIDO2 username (required)")
|
||||||
gatedFile := flag.String("gated", "", "gated hosts file (default: ~/.config/component3/gated_hosts.txt)")
|
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")
|
verbose := flag.Bool("v", false, "verbose logging")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
|
@ -31,9 +30,6 @@ func main() {
|
||||||
if *gatedFile == "" {
|
if *gatedFile == "" {
|
||||||
*gatedFile = filepath.Join(cfgDir, "gated_hosts.txt")
|
*gatedFile = filepath.Join(cfgDir, "gated_hosts.txt")
|
||||||
}
|
}
|
||||||
if *caDir == "" {
|
|
||||||
*caDir = cfgDir
|
|
||||||
}
|
|
||||||
|
|
||||||
gated := &GatedHosts{}
|
gated := &GatedHosts{}
|
||||||
if err := gated.Load(*gatedFile); err != nil {
|
if err := gated.Load(*gatedFile); err != nil {
|
||||||
|
|
@ -44,17 +40,9 @@ func main() {
|
||||||
|
|
||||||
phone := NewPhoneClient(*phoneURL, *username)
|
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{
|
proxy := &Proxy{
|
||||||
phone: phone,
|
phone: phone,
|
||||||
gated: gated,
|
gated: gated,
|
||||||
mitm: mitm,
|
|
||||||
verbose: *verbose,
|
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -31,14 +29,13 @@ var hopByHopHeaders = map[string]bool{
|
||||||
// browser → Proxy → (session token from phone) → endpoint directly → browser
|
// browser → Proxy → (session token from phone) → endpoint directly → browser
|
||||||
//
|
//
|
||||||
// For HTTPS CONNECT to gated hosts:
|
// 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:
|
// For non-gated hosts:
|
||||||
// browser → Proxy → internet (transparent, no auth)
|
// browser → Proxy → internet (transparent, no auth)
|
||||||
type Proxy struct {
|
type Proxy struct {
|
||||||
phone *PhoneClient
|
phone *PhoneClient
|
||||||
gated *GatedHosts
|
gated *GatedHosts
|
||||||
mitm *MITM
|
|
||||||
verbose bool
|
verbose bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,7 +120,9 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if isGated {
|
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 {
|
} else {
|
||||||
p.handleDirectConnect(clientConn, target)
|
p.handleDirectConnect(clientConn, target)
|
||||||
}
|
}
|
||||||
|
|
@ -144,90 +143,6 @@ func (p *Proxy) handleDirectConnect(clientConn net.Conn, target string) {
|
||||||
pipe(clientConn, upConn)
|
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.
|
// pipe copies bytes bidirectionally between two connections until either closes.
|
||||||
func pipe(a, b net.Conn) {
|
func pipe(a, b net.Conn) {
|
||||||
done := make(chan struct{}, 2)
|
done := make(chan struct{}, 2)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue