Refactor k_phone (v2) and add component3 Go binary

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 <noreply@anthropic.com>
This commit is contained in:
Morten V. Christiansen 2026-05-05 21:04:19 +02:00
parent ddeed9b71e
commit 920d702dea
14 changed files with 1373 additions and 541 deletions

View File

@ -82,18 +82,25 @@ Files are deployed to VMs via `scp <file> <host>:~` and run via `ssh <host> <cmd
Four physical devices: optional client computer, phone, chromecard, server.
**Devices:**
- **Client (optional):** Computer with browser configured to use the phone as HTTP/HTTPS proxy. No knowledge of the auth system.
- **Phone:** Central hub. Runs two components, hosts registration page, connects to chromecard via USB or WiFi.
- **Client (optional):** Computer with Component 3 installed. No browser proxy configuration needed.
- **Phone:** Central hub. Runs Component 1 and Component 2, hosts registration page, connects to chromecard via USB or WiFi.
- **Chromecard:** FIDO2 hardware security module. All crypto happens on-card; private keys never leave. Two fingerprint types: *user* (login) and *admin* (registration/deletion).
- **Server:** Accepts TLS only. Runs WebAuthn service that validates FIDO2 tokens before granting access to protected resources.
**Components on the phone:**
- **Component 1 — Proxy + gating filter:** Listens on a local port. Binary decision per request: host is gated → forward to Component 2 (TLS); host is not gated → forward directly to internet on port 80 (no TLS).
- **Component 2 — FIDO2 client + URL recognition:** Receives all requests from Component 1. Detects registration-URL → triggers admin registration flow; other gated URLs → triggers FIDO2 assertion flow (contacts card, gets token, forwards to server via TLS).
- **Component 1 — Proxy + gating filter:** Listens on a local port. Receives requests from the phone's own browser and from external clients via Component 3. Binary decision per request: host is gated → forward to Component 2, receive WebAuthn token back, then call the endpoint with the token (TLS); host is not gated → forward directly to internet on port 80 (no TLS).
- **Component 2 — WebAuthn client + URL recognition:** Receives requests from Component 1. Always returns a WebAuthn token to the caller — never calls endpoints itself. Detects registration-URL → triggers admin registration flow (admin fingerprint); other gated URLs → triggers FIDO2 assertion flow (contacts card, gets token, returns token to Component 1).
- **Registration page:** Local web app on phone. Requires admin fingerprint on the card for enrollment/deletion.
**Component 3 (on external client):**
- Installed on external client computers; replaces the old browser-proxy-configuration approach.
- Finds the phone on the network (currently via hardcoded IP+port — TODO: rendezvous mechanism).
- Forwards validation requests to Component 1, receives WebAuthn token back, calls the protected endpoint directly, and returns the response to the browser.
- Must be a compiled binary that runs without a specific runtime. Recommended: **Go** (single static binary, cross-platform). Alternative: Rust (stronger memory guarantees, higher implementation complexity).
**Three flows:**
- **Flow A (authenticated proxy):** Browser → Component 1 → Component 2 → Card (user fingerprint, generates FIDO2 token) → Server (WebAuthn validates token) → resource returned.
- **Flow A (authenticated access — phone browser):** Browser → Component 1 → Component 2 → Card (user fingerprint, generates FIDO2 token) → token returned to Component 1 → Component 1 calls endpoint (TLS) → resource returned.
- **Flow A (authenticated access — external client):** Browser → Component 3 → Component 1 → Component 2 → Card (user fingerprint) → token returned to Component 1 → token returned to Component 3 → Component 3 calls endpoint (TLS) → resource returned to browser.
- **Flow B (registration):** Browser → Component 1 → Component 2 (detects registration URL) → Card (admin fingerprint) → user created/deleted on card.
- **Flow C (unauthenticated):** Host not gated → Component 1 forwards directly to internet via port 80 (unencrypted, bypasses Component 2 and card). By design for normal web traffic.
@ -101,6 +108,8 @@ Four physical devices: optional client computer, phone, chromecard, server.
- PIN on card (in addition to biometrics) — not yet decided
- User database location: on-card only vs. external — not yet decided
- Network-level access control on registration page — not yet decided
- Rendezvous mechanism for Component 3 to discover the phone — not yet decided
- iOS requires a push-relay component (APNs) for background operation; Android does not — platform priority not yet decided
### Development topology (Qubes 3-VM)
@ -130,11 +139,13 @@ Inter-VM transport uses `qvm-connect-tcp` localhost forwarding (not raw VM-IP ro
### k_phone Flutter app (Phase 9 — replaces k_proxy)
**`k_phone/lib/filter_proxy.dart`** — Component 1. Raw-socket HTTP proxy with gating filter. Per-connection: gated host → CONNECT or plain-HTTP relay through Component 2; non-gated → direct to target. Gated hosts loaded from `gated_hosts.txt` in app documents dir; defaults to `httpbin.org`. Use `setGatedEntries()` in tests to inject entries directly.
**`k_phone/lib/filter_proxy.dart`** — Component 1. Raw-socket HTTP proxy with gating filter. Per-connection: gated host → fetches bearer token from Component 2 (`POST /auth/get-token`), then calls endpoint directly with `Authorization: Bearer`; non-gated → direct to target. HTTPS CONNECT to gated host: relays CONNECT through Component 2 (session-gate check). Gated hosts loaded from `gated_hosts.txt` in app documents dir; defaults to `httpbin.org`. Use `setGatedEntries()` in tests to inject entries directly.
**`k_phone/lib/proxy_service.dart`** — Component 2. Background-service HTTP server (port 8771). Handles enrollment, session (login/status/logout), resource/counter endpoints, and CONNECT tunnels. For CONNECT: checks `hasAnyActiveSession()`, connects to the actual upstream host:port, detaches the socket, and pipes bytes bidirectionally.
**`k_phone/lib/proxy_service.dart`** — Component 2. Background-service HTTP server (port 8771). Handles enrollment, session (login/status/logout), `/auth/get-token`, and CONNECT tunnels. Returns bearer token to caller via `/auth/get-token`; never calls endpoints itself. For CONNECT: checks `hasAnyActiveSession()`, connects to the actual upstream host:port, detaches the socket, and pipes bytes bidirectionally.
**`k_phone/lib/session_manager.dart`** — in-memory session store. `hasAnyActiveSession()` is the gate check for proxied traffic (personal-device model: one live session authorises all gated requests).
**`k_phone/lib/portal_html.dart`** — HTML string constants (`kPortalHtml`, `kEnrollHtml`) and pre-encoded byte lists (`kPortalHtmlBytes`, `kEnrollHtmlBytes`) for the portal and enrollment pages served by Component 2.
**`k_phone/lib/session_manager.dart`** — in-memory session store. `hasAnyActiveSession()` is the gate check for proxied traffic (personal-device model: one live session authorises all gated requests). `SessionManager.ttlSeconds` is the public TTL constant (300 s).
**`k_phone/lib/fido2_ops.dart`** — `makeCredential`, `getAssertion`, ECDSA-P256 assertion verification against the card via CTAPHID.

View File

@ -529,23 +529,41 @@ Exit criteria:
## Phase 9: Migrate to Phone-Mediated Wireless Validation
Status (2026-05-02): **ACTIVE — Component 1 + Component 2 CONNECT handler complete**
Status (2026-05-04): **ACTIVE — Architecture v2 adopted; Component 1 + Component 2 CONNECT handler complete**
### Target architecture
### Architecture v2 changes (2026-05-04)
The following changes replace the v1 architecture. Source: `chromecard_arkitektur_v2.docx`.
**Component 2 no longer calls endpoints:** Component 2 returns the WebAuthn token to whoever asked (Component 1). It is Component 1 that calls the endpoint with the token. This is the most important behavioral change.
**New Component 3 (external client):** A compiled binary (Go recommended, Rust alternative) installed on external client computers. Replaces the old browser-proxy-configuration approach. Tasks: find the phone (currently hardcoded IP+port — rendezvous TBD), forward validation requests to Component 1, receive token back, call the protected endpoint directly, return response to browser.
**Flow A splits into two paths:**
- Phone browser: Browser → Component 1 → Component 2 (returns token) → Component 1 calls endpoint → resource
- External client: Browser → Component 3 → Component 1 → Component 2 (returns token) → Component 1 → Component 3 calls endpoint → resource
**Platform note:** Android needs no extra infrastructure. iOS requires a push-relay (APNs) for background operation — platform priority is an open decision.
**New open decisions:** Rendezvous mechanism for Component 3; iOS vs Android priority.
### Target architecture (v2)
Four physical devices: optional client computer, phone, chromecard, server.
**Phone components:**
- **Component 1 — Proxy + gating filter:** Listens on a local port. Per-request binary decision: host is gated → forward to Component 2 via TLS; host is not gated → forward directly to internet on port 80 (no TLS, bypasses auth entirely).
- **Component 2 — FIDO2 client + URL recognition:** Detects registration URL → admin registration flow (admin fingerprint + PIN); other gated URLs → FIDO2 assertion flow (user fingerprint → token → server via TLS).
- **Component 1 — Proxy + gating filter:** Receives requests from phone browser and from external clients via Component 3. Per-request: gated host → forward to Component 2, receive WebAuthn token back, call endpoint with token (TLS); non-gated → forward directly to internet on port 80 (no TLS, bypasses auth entirely).
- **Component 2 — WebAuthn client + URL recognition:** Always returns token to caller, never calls endpoints itself. Detects registration URL → admin registration flow (admin fingerprint); other gated URLs → FIDO2 assertion flow (user fingerprint → token returned to Component 1).
- **Registration page:** Local web app on phone; admin fingerprint access control enforced by card.
- **Component 3 (external client):** Compiled binary, finds phone, relays auth through Component 1, calls endpoint with received token.
**Three flows:**
- **Flow A:** Browser → phone (comp 1 + 2) → card (user biometric) → server WebAuthn → resource
- **Flow B:** Browser → phone (comp 1 + 2, registration URL) → card (admin biometric) → enroll/delete user
- **Flow C:** Non-gated host → comp 1 → internet port 80 (no TLS, no card)
- **Flow A (phone browser):** Browser → Comp 1 → Comp 2 → card → token → Comp 1 → endpoint → resource
- **Flow A (external client):** Browser → Comp 3 → Comp 1 → Comp 2 → card → token → Comp 1 → Comp 3 → endpoint → resource
- **Flow B:** Browser → Comp 1 → Comp 2 (registration URL) → card (admin biometric) → enroll/delete user
- **Flow C:** Non-gated host → Comp 1 → internet port 80 (no TLS, no card)
**Open decisions (from architecture doc):** PIN on card; user DB on-card vs. external; network-level access control on registration page.
**Open decisions:** PIN on card; user DB on-card vs. external; network-level access control on registration page; Component 3 rendezvous mechanism; iOS vs Android priority.
Development chain (Qubes): `k_client browser → k_phone (Flutter Android) → USB HID → ChromeCard → k_server`
@ -613,12 +631,26 @@ CTAP2 cmd=0x01 body=180 bytes → makeCredential OK auth_data=164 bytes
CTAP2 cmd=0x02 body=113 bytes → getAssertion OK auth_data=37 bytes sig=71 bytes
```
### Work completed (2026-05-05, v2 architecture refactor)
**k_phone (Dart):**
- `filter_proxy_test.dart`: rewritten for v2 semantics — gated HTTP now hits a mock endpoint with Bearer token, not Component 2 directly. 24/24 tests pass.
- `filter_proxy.dart`: extracted `_writeProxyHeaders` and `_forwardHttpRequest` helpers to eliminate ~30 lines of duplication between `_handleGatedHttp` and `_handleDirectHttp`; simplified `_handleDirectHttp` signature (redundant `host`/`port` params removed).
- `session_manager.dart`: added `static const int ttlSeconds = 300` (public); `_ttl` now references it.
- `portal_html.dart` (new): extracted 400-line HTML blobs (`kPortalHtml`, `kEnrollHtml`, `kPortalHtmlBytes`, `kEnrollHtmlBytes`) from `proxy_service.dart`.
- `proxy_service.dart`: imports `portal_html.dart`; removed `_kSessionTtlSeconds` constant (replaced with `SessionManager.ttlSeconds`); merged `_serveHtml`/`_serveEnrollHtml` into `_serveHtmlBytes(req, bytes)`; extracted `_parseUsername` and `_parseUsernameAndDisplay` helpers eliminating repeated validation boilerplate; removed dead `_loadTlsContext` stub; simplified `start()` TLS branch. File: 872 → 455 lines.
- `k_server_client.dart`: deleted (dead code — no longer imported anywhere).
**component3 (Go):**
- `gated.go`: `IsGated(host, port string)` — was `IsGated(host string)`. Was silently missing `host:port` entries in gated_hosts.txt. Now checks both bare hostname and `host:port`.
- `proxy.go`: `handleHTTP` extracts `port` from URL (defaults `"80"`), passes to `IsGated`; `handleConnect` passes `portStr` to `IsGated`.
- `phone.go`: added `getToken()` calling `/auth/get-token` — avoids FIDO2 card interaction if the phone already has an active session. `EnsureSession()` tries `getToken()` first, falls back to `login()`. Fixed `login()` JSON field: `expires_in``ttl_seconds` (actual server field name). `go build ./...` passes.
### Next action
1. **Add `_handleConnect` to `proxy_service.dart`** — CONNECT handler for gated HTTPS tunnels; checks `hasAnyActiveSession()`, connects to upstream, detaches socket, pipes bytes. Tests needed.
2. Deploy to a real Android phone with physical ChromeCard via USB
3. Verify USB HID path (Kotlin MainActivity.kt platform channel, hidraw node auto-detection)
4. Run `phase5_chain_regression.sh` against `k_phone` on Android with k_server running
1. Deploy to a real Android phone with physical ChromeCard via USB
2. Verify USB HID path (Kotlin MainActivity.kt platform channel, hidraw node auto-detection)
3. Run `phase5_chain_regression.sh` against `k_phone` on Android with k_server running
### k_phone API contract (must match k_proxy_app.py exactly)

64
component3/gated.go Normal file
View File

@ -0,0 +1,64 @@
package main
import (
"bufio"
"os"
"strings"
"sync"
)
// GatedHosts is the set of hostnames that require FIDO2 authentication.
// Format matches k_phone's gated_hosts.txt: one "host" or "host:port" per line,
// lines starting with "#" and blank lines are ignored.
type GatedHosts struct {
mu sync.RWMutex
entries map[string]bool
}
// Load reads the gated hosts file. Missing file is not an error (empty list).
func (g *GatedHosts) Load(path string) error {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer f.Close()
entries := make(map[string]bool)
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Normalise: lowercase, strip any trailing port-free colon.
entries[strings.ToLower(line)] = true
}
g.mu.Lock()
g.entries = entries
g.mu.Unlock()
return sc.Err()
}
// Len returns the number of entries in the gated list.
func (g *GatedHosts) Len() int {
g.mu.RLock()
defer g.mu.RUnlock()
return len(g.entries)
}
// IsGated returns true if host:port matches a gated entry.
// An entry "example.com" matches any port; "example.com:8080" matches only port 8080.
func (g *GatedHosts) IsGated(host, port string) bool {
g.mu.RLock()
defer g.mu.RUnlock()
if len(g.entries) == 0 {
return false
}
h := strings.ToLower(host)
return g.entries[h] || (port != "" && g.entries[h+":"+port])
}

3
component3/go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/chromecard/component3
go 1.22

77
component3/main.go Normal file
View File

@ -0,0 +1,77 @@
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
)
func main() {
listen := flag.String("listen", "127.0.0.1:9090", "local proxy address (configure browser to use this)")
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()
if *username == "" {
fmt.Fprintln(os.Stderr, "error: -user is required")
flag.Usage()
os.Exit(1)
}
cfgDir := defaultConfigDir()
if err := os.MkdirAll(cfgDir, 0700); err != nil {
log.Fatalf("cannot create config dir: %v", err)
}
if *gatedFile == "" {
*gatedFile = filepath.Join(cfgDir, "gated_hosts.txt")
}
if *caDir == "" {
*caDir = cfgDir
}
gated := &GatedHosts{}
if err := gated.Load(*gatedFile); err != nil {
log.Printf("warning: gated hosts: %v (using empty list)", err)
} else {
log.Printf("loaded %d gated entries from %s", gated.Len(), *gatedFile)
}
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,
}
log.Printf("listening on %s — configure browser HTTP proxy to this address", *listen)
server := &http.Server{
Addr: *listen,
Handler: proxy,
}
if err := server.ListenAndServe(); err != nil {
log.Fatalf("proxy: %v", err)
}
}
func defaultConfigDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ".component3"
}
return filepath.Join(home, ".config", "component3")
}

216
component3/mitm.go Normal file
View File

@ -0,0 +1,216 @@
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
}

127
component3/phone.go Normal file
View File

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

266
component3/proxy.go Normal file
View File

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

View File

@ -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
// 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<void> _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;
try {
if (_isGated(host, port)) {
upstream = await Socket.connect('127.0.0.1', _component2Port)
.timeout(const Duration(seconds: 5));
await _handleGatedHttp(client, sub, method, uri, headerLines, remainder, contentLength);
} else {
upstream = await Socket.connect(host, port)
.timeout(const Duration(seconds: 10));
await _handleDirectHttp(client, sub, method, uri, headerLines, remainder, contentLength);
}
} catch (e) {
}
// Gated plain HTTP (v2): get token from Component 2, then call endpoint directly.
Future<void> _handleGatedHttp(
Socket client,
StreamSubscription<List<int>> sub,
String method,
Uri uri,
List<String> headerLines,
List<int> remainder,
int contentLength,
) async {
String token;
try {
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<void> _handleDirectHttp(
Socket client,
StreamSubscription<List<int>> sub,
String method,
Uri uri,
List<String> headerLines,
List<int> 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<String> _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<String, dynamic>;
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<String> 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<void> _forwardHttpRequest(
Socket client,
StreamSubscription<List<int>> sub,
Socket upstream,
String headers,
List<int> 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<List<int>> sub, int code, String reason) {
sub.cancel();
client.add(utf8.encode(

View File

@ -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<KServerResponse> 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<List<int>>([], (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();
}

View File

@ -0,0 +1,231 @@
import 'dart:convert';
final List<int> kPortalHtmlBytes = utf8.encode(kPortalHtml);
final List<int> kEnrollHtmlBytes = utf8.encode(kEnrollHtml);
const String kPortalHtml = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard k_phone Portal</title>
<style>
:root {
--bg: #f1eee8; --panel: #fffdf8; --ink: #171615; --muted: #645f56;
--line: #d6cbb9; --accent: #0c6a60; --accent-2: #8e5b2d;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
color: var(--ink);
background: radial-gradient(circle at top right, rgba(12,106,96,0.12), transparent 32%),
radial-gradient(circle at left center, rgba(142,91,45,0.10), transparent 28%),
linear-gradient(180deg, #faf7f0 0%, var(--bg) 100%);
}
main { max-width: 900px; margin: 0 auto; padding: 32px 20px 56px; }
.hero, .card { background: var(--panel); border: 1px solid var(--line); box-shadow: 0 16px 34px rgba(49,38,21,0.08); }
.hero { padding: 24px; margin-bottom: 20px; }
h1 { margin: 0 0 10px; font-size: clamp(2rem,4vw,3.5rem); line-height: 0.95; letter-spacing: -0.04em; }
.subtitle { margin: 0; color: var(--muted); max-width: 64ch; }
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.card { padding: 18px; }
.card h2 { margin: 0 0 12px; font-size: 1.15rem; }
label { display: block; margin-bottom: 8px; font-size: 0.92rem; color: var(--muted); }
input { width: 100%; padding: 10px 12px; border: 1px solid var(--line); background: #fff; font: inherit; color: var(--ink); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
button { border: 0; padding: 10px 14px; font: inherit; color: #fff; background: var(--accent); cursor: pointer; }
button.secondary { background: var(--accent-2); }
.status { display: grid; gap: 8px; margin-top: 14px; color: var(--muted); }
pre { margin: 18px 0 0; min-height: 300px; padding: 16px; overflow: auto; border: 1px solid var(--line); background: #141210; color: #efe6d8; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.9rem; line-height: 1.45; }
</style>
</head>
<body>
<main>
<section class="hero">
<h1>ChromeCard k_phone Portal</h1>
<p class="subtitle">Phone-mediated FIDO2 proxy. Registration and assertion happen on the Android app via USB HID or emulator bridge.</p>
</section>
<section class="grid">
<div class="card">
<h2>Enrollment</h2>
<label for="username">Username</label>
<input id="username" placeholder="alice" autocomplete="off">
<label for="displayName">Display Name</label>
<input id="displayName" placeholder="Alice Example" autocomplete="off">
<div class="actions">
<button id="enrollBtn">Enroll User</button>
<button id="updateBtn" class="secondary">Update User</button>
<button id="deleteBtn" class="secondary">Delete User</button>
<button id="checkBtn" class="secondary">Check Enrollment</button>
<button id="listBtn" class="secondary">List Users</button>
</div>
<div class="status">
<div>Stored username: <strong id="storedUser">none</strong></div>
<div>Session active: <strong id="sessionActive">no</strong></div>
</div>
</div>
<div class="card">
<h2>Session Flow</h2>
<div class="actions">
<button id="loginBtn">Login</button>
<button id="statusBtn" class="secondary">Status</button>
<button id="counterBtn">Get Auth Token</button>
<button id="logoutBtn" class="secondary">Logout</button>
</div>
</div>
</section>
<pre id="log"></pre>
</main>
<script>
const USER_KEY="chromecard.proxy.username", TOKEN_KEY="chromecard.proxy.session_token", EXP_KEY="chromecard.proxy.expires_at";
const logNode=document.getElementById("log"), usernameNode=document.getElementById("username"),
displayNameNode=document.getElementById("displayName"), storedUserNode=document.getElementById("storedUser"),
sessionActiveNode=document.getElementById("sessionActive");
function getStoredUser(){return localStorage.getItem(USER_KEY)||"";}
function getStoredToken(){return localStorage.getItem(TOKEN_KEY)||"";}
function syncState(){const u=getStoredUser();storedUserNode.textContent=u||"none";sessionActiveNode.textContent=getStoredToken()?"yes":"no";if(u&&!usernameNode.value)usernameNode.value=u;}
function log(msg,payload){const stamp=new Date().toLocaleTimeString();let line=`[\${stamp}] \${msg}`;if(payload!==undefined)line+="\\n"+JSON.stringify(payload,null,2);logNode.textContent=line+"\\n\\n"+logNode.textContent;}
async function jsonRequest(method,path,payload,withToken=false){const headers={"Content-Type":"application/json"};if(withToken&&getStoredToken())headers["Authorization"]="Bearer "+getStoredToken();const resp=await fetch(path,{method,headers,body:payload===undefined?undefined:JSON.stringify(payload)});const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));return data;}
document.getElementById("enrollBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/register",{username:usernameNode.value.trim(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,usernameNode.value.trim());syncState();log("Enrolled",data);}catch(err){log("Enroll failed",{error:err.message});}});
document.getElementById("checkBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const resp=await fetch("/enroll/status?username="+encodeURIComponent(u));const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Enrollment status",data);if(data.display_name)displayNameNode.value=data.display_name;}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("updateBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/update",{username:usernameNode.value.trim()||getStoredUser(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,data.username);syncState();log("Updated",data);}catch(err){log("Update failed",{error:err.message});}});
document.getElementById("deleteBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/enroll/delete",{username:u});if(getStoredUser()===u){localStorage.removeItem(USER_KEY);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);}displayNameNode.value="";syncState();log("Deleted",data);}catch(err){log("Delete failed",{error:err.message});}});
document.getElementById("listBtn").addEventListener("click",async()=>{try{const resp=await fetch("/enroll/list");const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Users",data);}catch(err){log("List failed",{error:err.message});}});
document.getElementById("loginBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/session/login",{username:u});localStorage.setItem(USER_KEY,u);localStorage.setItem(TOKEN_KEY,data.session_token||"");localStorage.setItem(EXP_KEY,String(data.expires_at||""));syncState();log("Login ok",data);}catch(err){log("Login failed",{error:err.message});}});
document.getElementById("statusBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/status",{},true);log("Session status",data);}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("counterBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/auth/get-token",{});log("Auth token acquired — Component 1/3 uses this to call endpoint directly",data);}catch(err){log("Get token failed",{error:err.message});}});
document.getElementById("logoutBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/logout",{},true);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);syncState();log("Logout",data);}catch(err){log("Logout failed",{error:err.message});}});
syncState();
</script>
</body>
</html>''';
const String kEnrollHtml = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard Registration</title>
<style>
:root{--g:#0c6a60;--r:#dc2626;--bg:#f5f4f1;--panel:#fff;--line:#e0dbd3;--muted:#6b6560}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:#181614;padding:2rem 1rem}
main{max-width:520px;margin:0 auto;display:grid;gap:2rem}
h1{font-size:1.25rem;font-weight:700}
h2{font-size:.75rem;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:.6rem}
/* user list */
#userList{background:var(--panel);border:1px solid var(--line);border-radius:6px;overflow:hidden}
#userList table{width:100%;border-collapse:collapse}
#userList td{padding:.65rem 1rem;border-bottom:1px solid var(--line);vertical-align:middle}
#userList tr:last-child td{border-bottom:none}
.uname{font-weight:600;font-size:.95rem}
.udisp{display:block;font-size:.78rem;color:var(--muted);margin-top:1px}
.badge{font-size:.68rem;font-weight:700;letter-spacing:.04em;padding:2px 7px;border-radius:3px;white-space:nowrap}
.fido2{background:#d1fae5;color:#065f46}
.probe{background:#fef3c7;color:#92400e}
.btn-del{background:none;border:1px solid var(--r);color:var(--r);padding:3px 10px;border-radius:4px;cursor:pointer;font:.82rem system-ui,sans-serif}
.btn-del:hover{background:var(--r);color:#fff}
.empty{padding:1.2rem 1rem;color:var(--muted);font-size:.9rem}
/* form */
form{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:1rem;display:grid;gap:.55rem}
label{font-size:.8rem;color:var(--muted)}
input{width:100%;padding:.5rem .7rem;border:1px solid var(--line);border-radius:4px;font:inherit}
input:focus{outline:2px solid var(--g);border-color:transparent}
#regBtn{padding:.55rem 1rem;background:var(--g);color:#fff;border:none;border-radius:4px;cursor:pointer;font:inherit;font-weight:600;justify-self:start;margin-top:.2rem}
#regBtn:disabled{opacity:.5;cursor:default}
/* status */
#msg{font-size:.85rem;min-height:1.3em;padding:.25rem 0}
#msg.ok{color:#065f46}
#msg.err{color:var(--r)}
</style>
</head>
<body>
<main>
<h1>ChromeCard User Registration</h1>
<section>
<h2>Registered users</h2>
<div id="userList"><div class="empty">Loading</div></div>
</section>
<section>
<h2>Register new user</h2>
<form id="regForm">
<label for="uname">Username</label>
<input id="uname" placeholder="alice" autocomplete="off" required>
<label for="dname">Display name (optional)</label>
<input id="dname" placeholder="Alice Example" autocomplete="off">
<button type="submit" id="regBtn">Register touch card fingerprint</button>
</form>
<div id="msg"></div>
</section>
</main>
<script>
var listEl=document.getElementById("userList"),
regForm=document.getElementById("regForm"),
unameEl=document.getElementById("uname"),
dnameEl=document.getElementById("dname"),
regBtn=document.getElementById("regBtn"),
msgEl=document.getElementById("msg");
function setMsg(t,ok){msgEl.textContent=t;msgEl.className=ok?"ok":"err";}
function clearMsg(){msgEl.textContent="";msgEl.className="";}
function renderUsers(users){
if(!users||!users.length){listEl.innerHTML='<div class="empty">No users registered yet</div>';return;}
var rows=users.map(function(u){
var disp=u.display_name?('<span class="udisp">'+u.display_name+'</span>'):'';
var mode=u.has_credential?'fido2':'probe';
var label=u.has_credential?'FIDO2':'probe';
return '<tr>'
+'<td><span class="uname">'+u.username+'</span>'+disp+'</td>'
+'<td><span class="badge '+mode+'">'+label+'</span></td>'
+'<td><button class="btn-del" data-u="'+u.username+'">Delete</button></td>'
+'</tr>';
}).join("");
listEl.innerHTML="<table><tbody>"+rows+"</tbody></table>";
listEl.querySelectorAll(".btn-del").forEach(function(b){
b.addEventListener("click",function(){del(b.dataset.u);});
});
}
async function loadUsers(){
try{
var r=await fetch("/enroll/list"),d=await r.json();
renderUsers(d.users||[]);
}catch(e){listEl.innerHTML='<div class="empty">Could not load users</div>';}
}
async function del(username){
if(!confirm('Delete user "'+username+'"?'))return;
clearMsg();
try{
var r=await fetch("/enroll/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username})});
var d=await r.json();
if(!r.ok)throw new Error(d.error||"Delete failed");
renderUsers(d.users||[]);
setMsg('"'+username+'" deleted.',true);
}catch(e){setMsg(e.message,false);}
}
regForm.addEventListener("submit",async function(e){
e.preventDefault();clearMsg();
var username=unameEl.value.trim();
var display_name=dnameEl.value.trim()||undefined;
regBtn.disabled=true;regBtn.textContent="Waiting for card fingerprint…";
try{
var r=await fetch("/enroll/register",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username,display_name:display_name})});
var d=await r.json();
if(!r.ok)throw new Error(d.error||"Registration failed");
renderUsers(d.users||[]);
setMsg('"'+d.username+'" registered ('+(d.has_credential?"FIDO2":"probe mode")+').',true);
unameEl.value="";dnameEl.value="";
}catch(e){setMsg(e.message,false);}
finally{regBtn.disabled=false;regBtn.textContent="Register — touch card fingerprint";}
});
loadUsers();
</script>
</body>
</html>''';

View File

@ -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;
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);
}
_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<void> _handleResourceCounter(HttpRequest req) async {
Future<void> _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'});
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<String, dynamic> upstream;
try {
upstream = jsonDecode(utf8.decode(result.body)) as Map<String, dynamic>;
} catch (_) {
upstream = {};
}
// 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,
'session_reused': true,
'upstream': upstream,
'expires_in': secondsRemaining,
});
return;
}
// 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<void> _serveHtml(HttpRequest req) async {
Future<void> _serveHtmlBytes(HttpRequest req, List<int> 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<void> _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<SecurityContext> _loadTlsContext() async {
throw UnimplementedError('TLS cert loading not yet wired up');
Future<String?> _parseUsername(HttpRequest req, Map<String, dynamic> 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<String, dynamic> 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 = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard k_phone Portal</title>
<style>
:root {
--bg: #f1eee8; --panel: #fffdf8; --ink: #171615; --muted: #645f56;
--line: #d6cbb9; --accent: #0c6a60; --accent-2: #8e5b2d;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
color: var(--ink);
background: radial-gradient(circle at top right, rgba(12,106,96,0.12), transparent 32%),
radial-gradient(circle at left center, rgba(142,91,45,0.10), transparent 28%),
linear-gradient(180deg, #faf7f0 0%, var(--bg) 100%);
}
main { max-width: 900px; margin: 0 auto; padding: 32px 20px 56px; }
.hero, .card { background: var(--panel); border: 1px solid var(--line); box-shadow: 0 16px 34px rgba(49,38,21,0.08); }
.hero { padding: 24px; margin-bottom: 20px; }
h1 { margin: 0 0 10px; font-size: clamp(2rem,4vw,3.5rem); line-height: 0.95; letter-spacing: -0.04em; }
.subtitle { margin: 0; color: var(--muted); max-width: 64ch; }
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.card { padding: 18px; }
.card h2 { margin: 0 0 12px; font-size: 1.15rem; }
label { display: block; margin-bottom: 8px; font-size: 0.92rem; color: var(--muted); }
input { width: 100%; padding: 10px 12px; border: 1px solid var(--line); background: #fff; font: inherit; color: var(--ink); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
button { border: 0; padding: 10px 14px; font: inherit; color: #fff; background: var(--accent); cursor: pointer; }
button.secondary { background: var(--accent-2); }
.status { display: grid; gap: 8px; margin-top: 14px; color: var(--muted); }
pre { margin: 18px 0 0; min-height: 300px; padding: 16px; overflow: auto; border: 1px solid var(--line); background: #141210; color: #efe6d8; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.9rem; line-height: 1.45; }
</style>
</head>
<body>
<main>
<section class="hero">
<h1>ChromeCard k_phone Portal</h1>
<p class="subtitle">Phone-mediated FIDO2 proxy. Registration and assertion happen on the Android app via USB HID or emulator bridge.</p>
</section>
<section class="grid">
<div class="card">
<h2>Enrollment</h2>
<label for="username">Username</label>
<input id="username" placeholder="alice" autocomplete="off">
<label for="displayName">Display Name</label>
<input id="displayName" placeholder="Alice Example" autocomplete="off">
<div class="actions">
<button id="enrollBtn">Enroll User</button>
<button id="updateBtn" class="secondary">Update User</button>
<button id="deleteBtn" class="secondary">Delete User</button>
<button id="checkBtn" class="secondary">Check Enrollment</button>
<button id="listBtn" class="secondary">List Users</button>
</div>
<div class="status">
<div>Stored username: <strong id="storedUser">none</strong></div>
<div>Session active: <strong id="sessionActive">no</strong></div>
</div>
</div>
<div class="card">
<h2>Session Flow</h2>
<div class="actions">
<button id="loginBtn">Login</button>
<button id="statusBtn" class="secondary">Status</button>
<button id="counterBtn">Counter</button>
<button id="logoutBtn" class="secondary">Logout</button>
</div>
</div>
</section>
<pre id="log"></pre>
</main>
<script>
const USER_KEY="chromecard.proxy.username", TOKEN_KEY="chromecard.proxy.session_token", EXP_KEY="chromecard.proxy.expires_at";
const logNode=document.getElementById("log"), usernameNode=document.getElementById("username"),
displayNameNode=document.getElementById("displayName"), storedUserNode=document.getElementById("storedUser"),
sessionActiveNode=document.getElementById("sessionActive");
function getStoredUser(){return localStorage.getItem(USER_KEY)||"";}
function getStoredToken(){return localStorage.getItem(TOKEN_KEY)||"";}
function syncState(){const u=getStoredUser();storedUserNode.textContent=u||"none";sessionActiveNode.textContent=getStoredToken()?"yes":"no";if(u&&!usernameNode.value)usernameNode.value=u;}
function log(msg,payload){const stamp=new Date().toLocaleTimeString();let line=`[\${stamp}] \${msg}`;if(payload!==undefined)line+="\\n"+JSON.stringify(payload,null,2);logNode.textContent=line+"\\n\\n"+logNode.textContent;}
async function jsonRequest(method,path,payload,withToken=false){const headers={"Content-Type":"application/json"};if(withToken&&getStoredToken())headers["Authorization"]="Bearer "+getStoredToken();const resp=await fetch(path,{method,headers,body:payload===undefined?undefined:JSON.stringify(payload)});const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));return data;}
document.getElementById("enrollBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/register",{username:usernameNode.value.trim(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,usernameNode.value.trim());syncState();log("Enrolled",data);}catch(err){log("Enroll failed",{error:err.message});}});
document.getElementById("checkBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const resp=await fetch("/enroll/status?username="+encodeURIComponent(u));const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Enrollment status",data);if(data.display_name)displayNameNode.value=data.display_name;}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("updateBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/update",{username:usernameNode.value.trim()||getStoredUser(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,data.username);syncState();log("Updated",data);}catch(err){log("Update failed",{error:err.message});}});
document.getElementById("deleteBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/enroll/delete",{username:u});if(getStoredUser()===u){localStorage.removeItem(USER_KEY);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);}displayNameNode.value="";syncState();log("Deleted",data);}catch(err){log("Delete failed",{error:err.message});}});
document.getElementById("listBtn").addEventListener("click",async()=>{try{const resp=await fetch("/enroll/list");const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Users",data);}catch(err){log("List failed",{error:err.message});}});
document.getElementById("loginBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/session/login",{username:u});localStorage.setItem(USER_KEY,u);localStorage.setItem(TOKEN_KEY,data.session_token||"");localStorage.setItem(EXP_KEY,String(data.expires_at||""));syncState();log("Login ok",data);}catch(err){log("Login failed",{error:err.message});}});
document.getElementById("statusBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/status",{},true);log("Session status",data);}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("counterBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/resource/counter",{},true);log("Counter",data);}catch(err){log("Counter failed",{error:err.message});}});
document.getElementById("logoutBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/logout",{},true);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);syncState();log("Logout",data);}catch(err){log("Logout failed",{error:err.message});}});
syncState();
</script>
</body>
</html>''';
// ---------------------------------------------------------------------------
// Enrollment / Registration HTML (GET /enroll)
// ---------------------------------------------------------------------------
const String _kEnrollHtml = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard Registration</title>
<style>
:root{--g:#0c6a60;--r:#dc2626;--bg:#f5f4f1;--panel:#fff;--line:#e0dbd3;--muted:#6b6560}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:#181614;padding:2rem 1rem}
main{max-width:520px;margin:0 auto;display:grid;gap:2rem}
h1{font-size:1.25rem;font-weight:700}
h2{font-size:.75rem;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:.6rem}
/* user list */
#userList{background:var(--panel);border:1px solid var(--line);border-radius:6px;overflow:hidden}
#userList table{width:100%;border-collapse:collapse}
#userList td{padding:.65rem 1rem;border-bottom:1px solid var(--line);vertical-align:middle}
#userList tr:last-child td{border-bottom:none}
.uname{font-weight:600;font-size:.95rem}
.udisp{display:block;font-size:.78rem;color:var(--muted);margin-top:1px}
.badge{font-size:.68rem;font-weight:700;letter-spacing:.04em;padding:2px 7px;border-radius:3px;white-space:nowrap}
.fido2{background:#d1fae5;color:#065f46}
.probe{background:#fef3c7;color:#92400e}
.btn-del{background:none;border:1px solid var(--r);color:var(--r);padding:3px 10px;border-radius:4px;cursor:pointer;font:.82rem system-ui,sans-serif}
.btn-del:hover{background:var(--r);color:#fff}
.empty{padding:1.2rem 1rem;color:var(--muted);font-size:.9rem}
/* form */
form{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:1rem;display:grid;gap:.55rem}
label{font-size:.8rem;color:var(--muted)}
input{width:100%;padding:.5rem .7rem;border:1px solid var(--line);border-radius:4px;font:inherit}
input:focus{outline:2px solid var(--g);border-color:transparent}
#regBtn{padding:.55rem 1rem;background:var(--g);color:#fff;border:none;border-radius:4px;cursor:pointer;font:inherit;font-weight:600;justify-self:start;margin-top:.2rem}
#regBtn:disabled{opacity:.5;cursor:default}
/* status */
#msg{font-size:.85rem;min-height:1.3em;padding:.25rem 0}
#msg.ok{color:#065f46}
#msg.err{color:var(--r)}
</style>
</head>
<body>
<main>
<h1>ChromeCard User Registration</h1>
<section>
<h2>Registered users</h2>
<div id="userList"><div class="empty">Loading</div></div>
</section>
<section>
<h2>Register new user</h2>
<form id="regForm">
<label for="uname">Username</label>
<input id="uname" placeholder="alice" autocomplete="off" required>
<label for="dname">Display name (optional)</label>
<input id="dname" placeholder="Alice Example" autocomplete="off">
<button type="submit" id="regBtn">Register touch card fingerprint</button>
</form>
<div id="msg"></div>
</section>
</main>
<script>
var listEl=document.getElementById("userList"),
regForm=document.getElementById("regForm"),
unameEl=document.getElementById("uname"),
dnameEl=document.getElementById("dname"),
regBtn=document.getElementById("regBtn"),
msgEl=document.getElementById("msg");
function setMsg(t,ok){msgEl.textContent=t;msgEl.className=ok?"ok":"err";}
function clearMsg(){msgEl.textContent="";msgEl.className="";}
function renderUsers(users){
if(!users||!users.length){listEl.innerHTML='<div class="empty">No users registered yet</div>';return;}
var rows=users.map(function(u){
var disp=u.display_name?('<span class="udisp">'+u.display_name+'</span>'):'';
var mode=u.has_credential?'fido2':'probe';
var label=u.has_credential?'FIDO2':'probe';
return '<tr>'
+'<td><span class="uname">'+u.username+'</span>'+disp+'</td>'
+'<td><span class="badge '+mode+'">'+label+'</span></td>'
+'<td><button class="btn-del" data-u="'+u.username+'">Delete</button></td>'
+'</tr>';
}).join("");
listEl.innerHTML="<table><tbody>"+rows+"</tbody></table>";
listEl.querySelectorAll(".btn-del").forEach(function(b){
b.addEventListener("click",function(){del(b.dataset.u);});
});
}
async function loadUsers(){
try{
var r=await fetch("/enroll/list"),d=await r.json();
renderUsers(d.users||[]);
}catch(e){listEl.innerHTML='<div class="empty">Could not load users</div>';}
}
async function del(username){
if(!confirm('Delete user "'+username+'"?'))return;
clearMsg();
try{
var r=await fetch("/enroll/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username})});
var d=await r.json();
if(!r.ok)throw new Error(d.error||"Delete failed");
renderUsers(d.users||[]);
setMsg('"'+username+'" deleted.',true);
}catch(e){setMsg(e.message,false);}
}
regForm.addEventListener("submit",async function(e){
e.preventDefault();clearMsg();
var username=unameEl.value.trim();
var display_name=dnameEl.value.trim()||undefined;
regBtn.disabled=true;regBtn.textContent="Waiting for card fingerprint…";
try{
var r=await fetch("/enroll/register",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username,display_name:display_name})});
var d=await r.json();
if(!r.ok)throw new Error(d.error||"Registration failed");
renderUsers(d.users||[]);
setMsg('"'+d.username+'" registered ('+(d.has_credential?"FIDO2":"probe mode")+').',true);
unameEl.value="";dnameEl.value="";
}catch(e){setMsg(e.message,false);}
finally{regBtn.disabled=false;regBtn.textContent="Register — touch card fingerprint";}
});
loadUsers();
</script>
</body>
</html>''';

View File

@ -11,7 +11,8 @@ class SessionEntry {
class SessionManager {
final Map<String, SessionEntry> _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);

View File

@ -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<HttpRequest> completer})> _mockHttp() async {
final server = await HttpServer.bind('127.0.0.1', 0);
final c = Completer<HttpRequest>();
@ -38,6 +37,32 @@ Future<({HttpServer server, Completer<HttpRequest> 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<HttpRequest> tokenReq})> _mockTokenServer({
String token = 'test-bearer-token',
bool ok = true,
}) async {
final server = await HttpServer.bind('127.0.0.1', 0);
final c = Completer<HttpRequest>();
server.listen((req) async {
await req.drain<void>();
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> 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 <token>.
// Non-gated HTTP: proxy forwards directly, no token fetch.
// -------------------------------------------------------------------------
group('HTTP routing', () {
late FilterProxy proxy;
late HttpServer comp2;
late Completer<HttpRequest> comp2Req;
late HttpServer direct;
late Completer<HttpRequest> comp2TokenReq;
late HttpServer endpoint;
late Completer<HttpRequest> endpointReq;
late HttpServer directServer;
late Completer<HttpRequest> 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;