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:
parent
ddeed9b71e
commit
920d702dea
27
CLAUDE.md
27
CLAUDE.md
|
|
@ -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.
|
Four physical devices: optional client computer, phone, chromecard, server.
|
||||||
|
|
||||||
**Devices:**
|
**Devices:**
|
||||||
- **Client (optional):** Computer with browser configured to use the phone as HTTP/HTTPS proxy. No knowledge of the auth system.
|
- **Client (optional):** Computer with Component 3 installed. No browser proxy configuration needed.
|
||||||
- **Phone:** Central hub. Runs two components, hosts registration page, connects to chromecard via USB or WiFi.
|
- **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).
|
- **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.
|
- **Server:** Accepts TLS only. Runs WebAuthn service that validates FIDO2 tokens before granting access to protected resources.
|
||||||
|
|
||||||
**Components on the phone:**
|
**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 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 — 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 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.
|
- **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:**
|
**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 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.
|
- **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
|
- PIN on card (in addition to biometrics) — not yet decided
|
||||||
- User database location: on-card only vs. external — not yet decided
|
- User database location: on-card only vs. external — not yet decided
|
||||||
- Network-level access control on registration page — 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)
|
### 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 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.
|
**`k_phone/lib/fido2_ops.dart`** — `makeCredential`, `getAssertion`, ECDSA-P256 assertion verification against the card via CTAPHID.
|
||||||
|
|
||||||
|
|
|
||||||
56
Workplan.md
56
Workplan.md
|
|
@ -529,23 +529,41 @@ Exit criteria:
|
||||||
|
|
||||||
## Phase 9: Migrate to Phone-Mediated Wireless Validation
|
## 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.
|
Four physical devices: optional client computer, phone, chromecard, server.
|
||||||
|
|
||||||
**Phone components:**
|
**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 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 — 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 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.
|
- **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:**
|
**Three flows:**
|
||||||
- **Flow A:** Browser → phone (comp 1 + 2) → card (user biometric) → server WebAuthn → resource
|
- **Flow A (phone browser):** Browser → Comp 1 → Comp 2 → card → token → Comp 1 → endpoint → resource
|
||||||
- **Flow B:** Browser → phone (comp 1 + 2, registration URL) → card (admin biometric) → enroll/delete user
|
- **Flow A (external client):** Browser → Comp 3 → Comp 1 → Comp 2 → card → token → Comp 1 → Comp 3 → endpoint → resource
|
||||||
- **Flow C:** Non-gated host → comp 1 → internet port 80 (no TLS, no card)
|
- **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`
|
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
|
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
|
### 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.
|
1. Deploy to a real Android phone with physical ChromeCard via USB
|
||||||
2. 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. 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
|
||||||
4. 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)
|
### k_phone API contract (must match k_proxy_app.py exactly)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/chromecard/component3
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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:
|
// Routing rule — binary decision per request:
|
||||||
// gated host → relay through Component 2 on localhost:_component2Port
|
// gated host → ask Component 2 for a bearer token (POST /auth/get-token),
|
||||||
// other host → forward directly to the target host:port
|
// 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
|
// For HTTPS (CONNECT) to gated hosts the CONNECT is still relayed through
|
||||||
// they can be accessed. Traffic to them is relayed through Component 2, which
|
// Component 2 (session-gate check), with Component 2 opening the upstream TCP
|
||||||
// checks for an active session before forwarding.
|
// 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
|
// 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
|
// per line, either "host" or "host:port". Lines starting with "#" and blank
|
||||||
// lines are ignored.
|
// 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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
@ -362,9 +355,7 @@ class FilterProxy {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plain HTTP request (both gated and non-gated use the same handler here —
|
// Plain HTTP request
|
||||||
// gating for plain HTTP is enforced by Component 2 when it receives the
|
|
||||||
// forwarded request and checks the Host header)
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
Future<void> _handleHttp(
|
Future<void> _handleHttp(
|
||||||
|
|
@ -386,7 +377,6 @@ class FilterProxy {
|
||||||
|
|
||||||
final host = uri.host;
|
final host = uri.host;
|
||||||
final port = uri.hasPort ? uri.port : 80;
|
final port = uri.hasPort ? uri.port : 80;
|
||||||
final path = _relativePath(uri);
|
|
||||||
|
|
||||||
int contentLength = 0;
|
int contentLength = 0;
|
||||||
for (final h in headerLines) {
|
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)) {
|
if (_isGated(host, port)) {
|
||||||
upstream = await Socket.connect('127.0.0.1', _component2Port)
|
await _handleGatedHttp(client, sub, method, uri, headerLines, remainder, contentLength);
|
||||||
.timeout(const Duration(seconds: 5));
|
|
||||||
} else {
|
} else {
|
||||||
upstream = await Socket.connect(host, port)
|
await _handleDirectHttp(client, sub, method, uri, headerLines, remainder, contentLength);
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
}
|
}
|
||||||
} 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');
|
_deny(client, sub, 502, 'Bad Gateway');
|
||||||
return;
|
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('$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) {
|
for (final h in headerLines) {
|
||||||
if (h.isEmpty) continue;
|
if (h.isEmpty) continue;
|
||||||
final lower = h.toLowerCase();
|
final lower = h.toLowerCase();
|
||||||
if (lower.startsWith('host:') ||
|
if (lower.startsWith('host:') ||
|
||||||
lower.startsWith('proxy-connection:') ||
|
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('$h\r\n');
|
||||||
}
|
}
|
||||||
out.write('Connection: close\r\n\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);
|
if (remainder.isNotEmpty) upstream.add(remainder);
|
||||||
|
|
||||||
final bodyLeft = contentLength - remainder.length;
|
final bodyLeft = contentLength - remainder.length;
|
||||||
|
|
@ -442,20 +527,12 @@ class FilterProxy {
|
||||||
client.add,
|
client.add,
|
||||||
// flush() drains the write buffer before closing; destroy() would drop it.
|
// flush() drains the write buffer before closing; destroy() would drop it.
|
||||||
onDone: () { client.flush().whenComplete(client.destroy).whenComplete(() { if (!done.isCompleted) done.complete(); }); },
|
onDone: () { client.flush().whenComplete(client.destroy).whenComplete(() { if (!done.isCompleted) done.complete(); }); },
|
||||||
onError: (_) {
|
onError: (_) { upstream.destroy(); client.destroy(); if (!done.isCompleted) done.complete(); },
|
||||||
upstream.destroy();
|
|
||||||
client.destroy();
|
|
||||||
if (!done.isCompleted) done.complete();
|
|
||||||
},
|
|
||||||
cancelOnError: true,
|
cancelOnError: true,
|
||||||
);
|
);
|
||||||
await done.future;
|
await done.future;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
void _deny(Socket client, StreamSubscription<List<int>> sub, int code, String reason) {
|
void _deny(Socket client, StreamSubscription<List<int>> sub, int code, String reason) {
|
||||||
sub.cancel();
|
sub.cancel();
|
||||||
client.add(utf8.encode(
|
client.add(utf8.encode(
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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>''';
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter_background_service/flutter_background_service.dart';
|
import 'package:flutter_background_service/flutter_background_service.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
|
@ -10,12 +8,10 @@ import 'ctaphid_channel.dart';
|
||||||
import 'enrollment_db.dart';
|
import 'enrollment_db.dart';
|
||||||
import 'filter_proxy.dart';
|
import 'filter_proxy.dart';
|
||||||
import 'fido2_ops.dart';
|
import 'fido2_ops.dart';
|
||||||
import 'k_server_client.dart';
|
import 'portal_html.dart';
|
||||||
import 'session_manager.dart';
|
import 'session_manager.dart';
|
||||||
|
|
||||||
const int kProxyPort = 8771;
|
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 kNotificationChannelId = 'kphone_proxy';
|
||||||
const String kNotificationChannelName = 'k_phone proxy service';
|
const String kNotificationChannelName = 'k_phone proxy service';
|
||||||
|
|
||||||
|
|
@ -84,7 +80,6 @@ class _ProxyServer {
|
||||||
final FilterProxy _filterProxy = FilterProxy();
|
final FilterProxy _filterProxy = FilterProxy();
|
||||||
final SessionManager _sessions = SessionManager();
|
final SessionManager _sessions = SessionManager();
|
||||||
final EnrollmentDb _db = EnrollmentDb();
|
final EnrollmentDb _db = EnrollmentDb();
|
||||||
final KServerClient _kserver = KServerClient();
|
|
||||||
int? _cardCid;
|
int? _cardCid;
|
||||||
bool _cardAttached = false;
|
bool _cardAttached = false;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
|
|
@ -116,19 +111,9 @@ class _ProxyServer {
|
||||||
// Card detection and DB loading are independent — run in parallel.
|
// Card detection and DB loading are independent — run in parallel.
|
||||||
await Future.wait([_tryOpenCard(), _db.ensureLoaded()]);
|
await Future.wait([_tryOpenCard(), _db.ensureLoaded()]);
|
||||||
|
|
||||||
SecurityContext? tlsCtx;
|
|
||||||
try {
|
|
||||||
tlsCtx = await _loadTlsContext();
|
|
||||||
} catch (_) {
|
|
||||||
_emit('No TLS certs found — running plain HTTP (dev mode)');
|
_emit('No TLS certs found — running plain HTTP (dev mode)');
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (tlsCtx != null) {
|
|
||||||
_server = await HttpServer.bindSecure(InternetAddress.anyIPv4, kProxyPort, tlsCtx);
|
|
||||||
} else {
|
|
||||||
_server = await HttpServer.bind(InternetAddress.anyIPv4, kProxyPort);
|
_server = await HttpServer.bind(InternetAddress.anyIPv4, kProxyPort);
|
||||||
}
|
|
||||||
_emit('Listening on :$kProxyPort');
|
_emit('Listening on :$kProxyPort');
|
||||||
_server!.listen(_handleRequest, onError: (e) => _emit('Server error: $e'));
|
_server!.listen(_handleRequest, onError: (e) => _emit('Server error: $e'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -160,9 +145,9 @@ class _ProxyServer {
|
||||||
if (req.method == 'GET') {
|
if (req.method == 'GET') {
|
||||||
switch (path) {
|
switch (path) {
|
||||||
case '/':
|
case '/':
|
||||||
await _serveHtml(req);
|
await _serveHtmlBytes(req, kPortalHtmlBytes);
|
||||||
case '/enroll':
|
case '/enroll':
|
||||||
await _serveEnrollHtml(req);
|
await _serveHtmlBytes(req, kEnrollHtmlBytes);
|
||||||
case '/health':
|
case '/health':
|
||||||
await _handleHealth(req);
|
await _handleHealth(req);
|
||||||
case '/enroll/list':
|
case '/enroll/list':
|
||||||
|
|
@ -188,8 +173,8 @@ class _ProxyServer {
|
||||||
await _handleSessionStatus(req);
|
await _handleSessionStatus(req);
|
||||||
case '/session/logout':
|
case '/session/logout':
|
||||||
await _handleSessionLogout(req);
|
await _handleSessionLogout(req);
|
||||||
case '/resource/counter':
|
case '/auth/get-token':
|
||||||
await _handleResourceCounter(req);
|
await _handleAuthGetToken(req);
|
||||||
default:
|
default:
|
||||||
await _send(req.response, 404, {'ok': false, 'error': 'not found'});
|
await _send(req.response, 404, {'ok': false, 'error': 'not found'});
|
||||||
}
|
}
|
||||||
|
|
@ -214,18 +199,9 @@ class _ProxyServer {
|
||||||
final body = await _readJson(req);
|
final body = await _readJson(req);
|
||||||
if (body == null) return;
|
if (body == null) return;
|
||||||
|
|
||||||
final rawUsername = body['username'] as String? ?? '';
|
final r = await _parseUsernameAndDisplay(req, body);
|
||||||
final rawDisplay = body['display_name'] as String?;
|
if (r == null) return;
|
||||||
|
final (canonical, pretty) = r;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
MakeCredentialResult? credential;
|
MakeCredentialResult? credential;
|
||||||
if (_cardAttached && _cardCid != null) {
|
if (_cardAttached && _cardCid != null) {
|
||||||
|
|
@ -258,18 +234,9 @@ class _ProxyServer {
|
||||||
final body = await _readJson(req);
|
final body = await _readJson(req);
|
||||||
if (body == null) return;
|
if (body == null) return;
|
||||||
|
|
||||||
final rawUsername = body['username'] as String? ?? '';
|
final r = await _parseUsernameAndDisplay(req, body);
|
||||||
final rawDisplay = body['display_name'] as String?;
|
if (r == null) return;
|
||||||
|
final (canonical, pretty) = r;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final enrollment = await _db.update(username: canonical, displayName: pretty);
|
final enrollment = await _db.update(username: canonical, displayName: pretty);
|
||||||
|
|
@ -283,15 +250,8 @@ class _ProxyServer {
|
||||||
final body = await _readJson(req);
|
final body = await _readJson(req);
|
||||||
if (body == null) return;
|
if (body == null) return;
|
||||||
|
|
||||||
final rawUsername = body['username'] as String? ?? '';
|
final canonical = await _parseUsername(req, body);
|
||||||
|
if (canonical == null) return;
|
||||||
String canonical;
|
|
||||||
try {
|
|
||||||
canonical = normalizeUsername(rawUsername);
|
|
||||||
} on ArgumentError catch (e) {
|
|
||||||
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final enrollment = await _db.delete(canonical);
|
final enrollment = await _db.delete(canonical);
|
||||||
|
|
@ -338,14 +298,8 @@ class _ProxyServer {
|
||||||
final body = await _readJson(req);
|
final body = await _readJson(req);
|
||||||
if (body == null) return;
|
if (body == null) return;
|
||||||
|
|
||||||
final rawUsername = body['username'] as String? ?? '';
|
final canonical = await _parseUsername(req, body);
|
||||||
String canonical;
|
if (canonical == null) return;
|
||||||
try {
|
|
||||||
canonical = normalizeUsername(rawUsername);
|
|
||||||
} on ArgumentError catch (e) {
|
|
||||||
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final enrollment = await _db.get(canonical);
|
final enrollment = await _db.get(canonical);
|
||||||
if (enrollment == null) {
|
if (enrollment == null) {
|
||||||
|
|
@ -389,7 +343,7 @@ class _ProxyServer {
|
||||||
'username': canonical,
|
'username': canonical,
|
||||||
'session_token': token,
|
'session_token': token,
|
||||||
'expires_at': expiresAt,
|
'expires_at': expiresAt,
|
||||||
'ttl_seconds': _kSessionTtlSeconds,
|
'ttl_seconds': SessionManager.ttlSeconds,
|
||||||
'auth_mode': authMode,
|
'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);
|
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, {
|
await _send(req.response, 200, {
|
||||||
'ok': true,
|
'ok': true,
|
||||||
|
'token': token,
|
||||||
'username': session.username,
|
'username': session.username,
|
||||||
'session_reused': true,
|
'expires_in': secondsRemaining,
|
||||||
'upstream': upstream,
|
});
|
||||||
|
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.statusCode = 200;
|
||||||
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
|
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
|
||||||
req.response.headers.contentLength = _kPortalHtmlBytes.length;
|
req.response.headers.contentLength = bytes.length;
|
||||||
req.response.add(_kPortalHtmlBytes);
|
req.response.add(bytes);
|
||||||
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);
|
|
||||||
await req.response.close();
|
await req.response.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -641,245 +577,25 @@ class _ProxyServer {
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SecurityContext> _loadTlsContext() async {
|
Future<String?> _parseUsername(HttpRequest req, Map<String, dynamic> body) async {
|
||||||
throw UnimplementedError('TLS cert loading not yet wired up');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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 {
|
try {
|
||||||
var r=await fetch("/enroll/list"),d=await r.json();
|
return normalizeUsername(body['username'] as String? ?? '');
|
||||||
renderUsers(d.users||[]);
|
} on ArgumentError catch (e) {
|
||||||
}catch(e){listEl.innerHTML='<div class="empty">Could not load users</div>';}
|
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(username){
|
Future<(String, String?)?> _parseUsernameAndDisplay(
|
||||||
if(!confirm('Delete user "'+username+'"?'))return;
|
HttpRequest req, Map<String, dynamic> body) async {
|
||||||
clearMsg();
|
|
||||||
try {
|
try {
|
||||||
var r=await fetch("/enroll/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username})});
|
return (
|
||||||
var d=await r.json();
|
normalizeUsername(body['username'] as String? ?? ''),
|
||||||
if(!r.ok)throw new Error(d.error||"Delete failed");
|
normalizeDisplayName(body['display_name'] as String?),
|
||||||
renderUsers(d.users||[]);
|
);
|
||||||
setMsg('"'+username+'" deleted.',true);
|
} on ArgumentError catch (e) {
|
||||||
}catch(e){setMsg(e.message,false);}
|
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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>''';
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ class SessionEntry {
|
||||||
|
|
||||||
class SessionManager {
|
class SessionManager {
|
||||||
final Map<String, SessionEntry> _sessions = {};
|
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].
|
/// Issue a new session token for [username].
|
||||||
/// _purgeExpired is only called here, not on every lookup, so tokens accumulate
|
/// _purgeExpired is only called here, not on every lookup, so tokens accumulate
|
||||||
|
|
@ -51,6 +52,15 @@ class SessionManager {
|
||||||
return _sessions.isNotEmpty;
|
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].
|
/// Revoke all sessions for [username].
|
||||||
void revokeAll(String username) {
|
void revokeAll(String username) {
|
||||||
_sessions.removeWhere((_, s) => s.username == username);
|
_sessions.removeWhere((_, s) => s.username == username);
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@ import '../lib/filter_proxy.dart';
|
||||||
|
|
||||||
const _kTimeout = Duration(seconds: 5);
|
const _kTimeout = Duration(seconds: 5);
|
||||||
|
|
||||||
// Start an HttpServer that accepts one request, records it, and replies 200 OK.
|
// Start an HttpServer that records the first request and replies 200 OK.
|
||||||
// Returns the server and a Completer (use .future to await; .isCompleted to check).
|
|
||||||
Future<({HttpServer server, Completer<HttpRequest> completer})> _mockHttp() async {
|
Future<({HttpServer server, Completer<HttpRequest> completer})> _mockHttp() async {
|
||||||
final server = await HttpServer.bind('127.0.0.1', 0);
|
final server = await HttpServer.bind('127.0.0.1', 0);
|
||||||
final c = Completer<HttpRequest>();
|
final c = Completer<HttpRequest>();
|
||||||
|
|
@ -38,6 +37,32 @@ Future<({HttpServer server, Completer<HttpRequest> completer})> _mockHttp() asyn
|
||||||
return (server: server, completer: c);
|
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.
|
// Start a raw TCP server that hands back the accepted Socket.
|
||||||
Future<({ServerSocket server, Future<Socket> socket})> _mockTcp() async {
|
Future<({ServerSocket server, Future<Socket> socket})> _mockTcp() async {
|
||||||
final server = await ServerSocket.bind('127.0.0.1', 0);
|
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', () {
|
group('HTTP routing', () {
|
||||||
late FilterProxy proxy;
|
late FilterProxy proxy;
|
||||||
late HttpServer comp2;
|
late HttpServer comp2;
|
||||||
late Completer<HttpRequest> comp2Req;
|
late Completer<HttpRequest> comp2TokenReq;
|
||||||
late HttpServer direct;
|
late HttpServer endpoint;
|
||||||
|
late Completer<HttpRequest> endpointReq;
|
||||||
|
late HttpServer directServer;
|
||||||
late Completer<HttpRequest> directReq;
|
late Completer<HttpRequest> directReq;
|
||||||
|
|
||||||
setUp(() async {
|
const testToken = 'test-bearer-token';
|
||||||
final c2 = await _mockHttp();
|
|
||||||
comp2 = c2.server;
|
|
||||||
comp2Req = c2.completer;
|
|
||||||
|
|
||||||
|
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();
|
final d = await _mockHttp();
|
||||||
direct = d.server;
|
directServer = d.server;
|
||||||
directReq = d.completer;
|
directReq = d.completer;
|
||||||
|
|
||||||
proxy = FilterProxy(
|
proxy = FilterProxy(
|
||||||
listenPort: 0,
|
listenPort: 0,
|
||||||
component2Port: comp2.port,
|
component2Port: comp2.port,
|
||||||
);
|
);
|
||||||
// 'auth.local' is gated; '127.0.0.1' is not.
|
// Gate the endpoint address; 127.0.0.1 + endpoint port is resolvable in tests.
|
||||||
proxy.setGatedEntries(['auth.local']);
|
proxy.setGatedEntries(['127.0.0.1:${endpoint.port}']);
|
||||||
await proxy.start();
|
await proxy.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
await proxy.stop();
|
await proxy.stop();
|
||||||
await comp2.close(force: true);
|
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(
|
final response = await _round(
|
||||||
proxy.port,
|
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.method, 'GET');
|
||||||
expect(req.uri.path, '/api');
|
expect(req.uri.path, '/api');
|
||||||
|
expect(req.headers.value('authorization'), 'Bearer $testToken');
|
||||||
expect(response, contains('200 OK'));
|
expect(response, contains('200 OK'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('non-gated host is forwarded directly', () async {
|
test('non-gated host is forwarded directly', () async {
|
||||||
final response = await _round(
|
final response = await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://127.0.0.1:${direct.port}/page HTTP/1.1\r\n'
|
'GET http://127.0.0.1:${directServer.port}/page HTTP/1.1\r\n'
|
||||||
'Host: 127.0.0.1:${direct.port}\r\n\r\n',
|
'Host: 127.0.0.1:${directServer.port}\r\n\r\n',
|
||||||
);
|
);
|
||||||
final req = await directReq.future.timeout(_kTimeout);
|
final req = await directReq.future.timeout(_kTimeout);
|
||||||
|
|
||||||
|
|
@ -223,78 +277,109 @@ void main() {
|
||||||
test('non-gated request does NOT reach component2', () async {
|
test('non-gated request does NOT reach component2', () async {
|
||||||
await _round(
|
await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://127.0.0.1:${direct.port}/page HTTP/1.1\r\n'
|
'GET http://127.0.0.1:${directServer.port}/page HTTP/1.1\r\n'
|
||||||
'Host: 127.0.0.1:${direct.port}\r\n\r\n',
|
'Host: 127.0.0.1:${directServer.port}\r\n\r\n',
|
||||||
);
|
);
|
||||||
await directReq.future.timeout(_kTimeout);
|
await directReq.future.timeout(_kTimeout);
|
||||||
|
expect(comp2TokenReq.isCompleted, isFalse);
|
||||||
// comp2 should never have received anything
|
|
||||||
expect(comp2Req.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(
|
await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://auth.local/session/login?foo=bar HTTP/1.1\r\n'
|
'GET http://127.0.0.1:${endpoint.port}/session/login?foo=bar HTTP/1.1\r\n'
|
||||||
'Host: auth.local\r\n\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);
|
||||||
// The mock HttpServer parses the rewritten request.
|
|
||||||
expect(req.uri.path, '/session/login');
|
expect(req.uri.path, '/session/login');
|
||||||
expect(req.uri.query, 'foo=bar');
|
expect(req.uri.query, 'foo=bar');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Proxy-Connection header is stripped', () async {
|
test('gated: Proxy-Connection header is stripped', () async {
|
||||||
await _round(
|
await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://auth.local/health HTTP/1.1\r\n'
|
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
|
||||||
'Host: auth.local\r\n'
|
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
||||||
'Proxy-Connection: keep-alive\r\n\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);
|
expect(req.headers.value('proxy-connection'), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Proxy-Authorization header is stripped', () async {
|
test('gated: Proxy-Authorization header is stripped', () async {
|
||||||
await _round(
|
await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://auth.local/health HTTP/1.1\r\n'
|
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
|
||||||
'Host: auth.local\r\n'
|
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
||||||
'Proxy-Authorization: Basic dXNlcjpwYXNz\r\n\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);
|
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(
|
await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://auth.local/health HTTP/1.1\r\n'
|
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
|
||||||
'Host: auth.local\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',
|
'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');
|
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"}';
|
const body = '{"username":"alice"}';
|
||||||
await _round(
|
await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'POST http://auth.local/session/login HTTP/1.1\r\n'
|
'POST http://127.0.0.1:${endpoint.port}/session/login HTTP/1.1\r\n'
|
||||||
'Host: auth.local\r\n'
|
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
||||||
'Content-Type: application/json\r\n'
|
'Content-Type: application/json\r\n'
|
||||||
'Content-Length: ${body.length}\r\n\r\n'
|
'Content-Length: ${body.length}\r\n\r\n'
|
||||||
'$body',
|
'$body',
|
||||||
);
|
);
|
||||||
final req = await comp2Req.future.timeout(_kTimeout);
|
final req = await endpointReq.future.timeout(_kTimeout);
|
||||||
expect(req.method, 'POST');
|
expect(req.method, 'POST');
|
||||||
expect(req.uri.path, '/session/login');
|
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
|
// Group 3: CONNECT tunnel routing
|
||||||
|
// (Gated CONNECT is still relayed through Component 2 — unchanged in v2.)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
group('CONNECT routing', () {
|
group('CONNECT routing', () {
|
||||||
late FilterProxy proxy;
|
late FilterProxy proxy;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue