Implement per-request FIDO2 token binding across all components
Each request to a gated endpoint now triggers a fresh FIDO2 assertion.
Challenge = SHA256(url|method|nonce) — bound to the specific resource.
The self-contained assertion bundle lets the server verify independently
without calling back to the phone.
- fido2_ops.dart: GetAssertionResult gains clientDataJson; getAssertion
accepts optional challenge override
- proxy_service.dart: _handleAuthGetToken accepts {url,method,nonce},
derives challenge, runs card assertion, returns b64url bundle
- filter_proxy.dart: _getAuthToken(uri, method) generates nonce and
passes binding fields to Component 2
- component3/phone.go: stateless GetTokenForRequest(url, method) —
no session caching, no expiry, one card touch per request
- component3/proxy.go: use GetTokenForRequest
- component3/main.go: remove --user flag (Component 2 picks enrolled user)
- k_server_app.py: _verify_assertion_token() — verifies path+method
match, challenge claim, and ECDSA-P256 signature; accepts both
legacy X-Proxy-Token and new Bearer assertion tokens
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ffa5bea1c7
commit
3fc40fc395
15
Workplan.md
15
Workplan.md
|
|
@ -652,6 +652,21 @@ CTAP2 cmd=0x02 body=113 bytes → getAssertion OK auth_data=37 bytes sig=71 byte
|
||||||
- `proxy.go`: `handleHTTP` extracts `port` from URL (defaults `"80"`), passes to `IsGated`; `handleConnect` passes `portStr` to `IsGated`.
|
- `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.
|
- `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.
|
||||||
|
|
||||||
|
### Parallel-change note: Component 1 and Component 3 share the same proxy logic
|
||||||
|
|
||||||
|
Component 3 (`component3/`) and Component 1 (`k_phone/lib/filter_proxy.dart`) implement the same core behaviour: intercept HTTP/HTTPS traffic, decide per-request whether the target is gated, fetch a WebAuthn token if so, and call the endpoint directly with the token. Any structural change to one (new gating logic, token-binding changes, CONNECT handling, error semantics) will almost certainly need a corresponding change in the other. Treat them as a pair: when modifying Component 3, check Component 1 for the same fix, and vice versa.
|
||||||
|
|
||||||
|
### Work completed (2026-05-08, per-request token binding)
|
||||||
|
|
||||||
|
- `fido2_ops.dart`: `GetAssertionResult` now includes `clientDataJson`; `getAssertion()` accepts optional `challenge` param for binding.
|
||||||
|
- `proxy_service.dart`: `_handleAuthGetToken` rewritten — accepts `{url, method, nonce}`, derives `challenge = SHA256(url|method|nonce)`, calls card (getAssertion), returns self-contained assertion bundle as base64url Bearer token. No session involved.
|
||||||
|
- `filter_proxy.dart`: `_getAuthToken(uri, method)` generates a secure 16-byte nonce, posts `{url, method, nonce}` to Component 2, uses returned assertion token directly.
|
||||||
|
- `component3/phone.go`: rewritten as stateless `GetTokenForRequest(url, method)` — no session caching, no mutex, no expiry tracking.
|
||||||
|
- `component3/proxy.go`: `handleHTTP` uses `GetTokenForRequest(r.URL.String(), r.Method)`.
|
||||||
|
- `component3/main.go`: `--user` flag removed (Component 2 picks the enrolled user).
|
||||||
|
- `k_server_app.py`: `_verify_assertion_token()` added — decodes bundle, verifies path+method match, verifies challenge claim, verifies ECDSA-P256 signature over authData||clientDataHash using public key extracted from bundle's credentialData. `_is_proxy_authorized()` accepts either X-Proxy-Token (legacy k_proxy path) or Bearer assertion token.
|
||||||
|
- 46/46 Flutter tests pass; `go build ./...` clean; `flutter analyze` no issues.
|
||||||
|
|
||||||
### Next action
|
### Next action
|
||||||
|
|
||||||
1. Deploy to a real Android phone with physical ChromeCard via USB
|
1. Deploy to a real Android phone with physical ChromeCard via USB
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -12,17 +11,10 @@ import (
|
||||||
func main() {
|
func main() {
|
||||||
listen := flag.String("listen", "127.0.0.1:9090", "local proxy address (configure browser to use this)")
|
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)")
|
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)")
|
gatedFile := flag.String("gated", "", "gated hosts file (default: ~/.config/component3/gated_hosts.txt)")
|
||||||
verbose := flag.Bool("v", false, "verbose logging")
|
verbose := flag.Bool("v", false, "verbose logging")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *username == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "error: -user is required")
|
|
||||||
flag.Usage()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfgDir := defaultConfigDir()
|
cfgDir := defaultConfigDir()
|
||||||
if err := os.MkdirAll(cfgDir, 0700); err != nil {
|
if err := os.MkdirAll(cfgDir, 0700); err != nil {
|
||||||
log.Fatalf("cannot create config dir: %v", err)
|
log.Fatalf("cannot create config dir: %v", err)
|
||||||
|
|
@ -38,7 +30,7 @@ func main() {
|
||||||
log.Printf("loaded %d gated entries from %s", gated.Len(), *gatedFile)
|
log.Printf("loaded %d gated entries from %s", gated.Len(), *gatedFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
phone := NewPhoneClient(*phoneURL, *username)
|
phone := NewPhoneClient(*phoneURL)
|
||||||
|
|
||||||
proxy := &Proxy{
|
proxy := &Proxy{
|
||||||
phone: phone,
|
phone: phone,
|
||||||
|
|
|
||||||
|
|
@ -2,70 +2,42 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// PhoneClient acquires and caches a session token from Component 2 on the phone.
|
// PhoneClient fetches a per-request FIDO2 assertion token from Component 2.
|
||||||
// The token represents a completed FIDO2 assertion (user fingerprint on card).
|
// There is no session caching — each call triggers a card interaction.
|
||||||
// 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 {
|
type PhoneClient struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
username string
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
token string
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPhoneClient(baseURL, username string) *PhoneClient {
|
func NewPhoneClient(baseURL string) *PhoneClient {
|
||||||
return &PhoneClient{baseURL: baseURL, username: username}
|
return &PhoneClient{baseURL: baseURL}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureSession returns a valid session token. It first tries /auth/get-token
|
// GetTokenForRequest calls /auth/get-token with url+method+nonce, triggering
|
||||||
// (no card interaction if the phone already has an active session), then falls
|
// a fresh FIDO2 assertion on the card. Returns the self-contained assertion
|
||||||
// back to /session/login (triggers FIDO2 assertion on the card).
|
// bundle that the endpoint can verify independently.
|
||||||
func (c *PhoneClient) EnsureSession() (string, error) {
|
func (c *PhoneClient) GetTokenForRequest(rawURL, method string) (string, error) {
|
||||||
c.mu.Lock()
|
nonce, err := randomHex(16)
|
||||||
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 {
|
if err != nil {
|
||||||
return "", 0, err
|
return "", fmt.Errorf("nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"url": rawURL,
|
||||||
|
"method": method,
|
||||||
|
"nonce": nonce,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := http.Post(c.baseURL+"/auth/get-token", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("phone unreachable (%s): %w", c.baseURL, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
|
@ -73,55 +45,24 @@ func (c *PhoneClient) getToken() (string, int, error) {
|
||||||
var result struct {
|
var result struct {
|
||||||
Ok bool `json:"ok"`
|
Ok bool `json:"ok"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &result); err != nil {
|
if err := json.Unmarshal(raw, &result); err != nil {
|
||||||
return "", 0, fmt.Errorf("parse token response: %w", err)
|
return "", fmt.Errorf("parse token response: %w (body: %s)", err, raw)
|
||||||
}
|
}
|
||||||
if !result.Ok {
|
if !result.Ok {
|
||||||
return "", 0, fmt.Errorf("no active session: %s", result.Error)
|
return "", fmt.Errorf("auth failed: %s", result.Error)
|
||||||
}
|
}
|
||||||
return result.Token, result.ExpiresIn, nil
|
if result.Token == "" {
|
||||||
|
return "", fmt.Errorf("phone returned empty token")
|
||||||
|
}
|
||||||
|
return result.Token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// login posts to /session/login on the phone and stores the returned token.
|
func randomHex(n int) (string, error) {
|
||||||
// Caller must hold c.mu.
|
b := make([]byte, n)
|
||||||
func (c *PhoneClient) login() (string, error) {
|
if _, err := rand.Read(b); err != nil {
|
||||||
body, _ := json.Marshal(map[string]string{"username": c.username})
|
return "", err
|
||||||
|
|
||||||
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()
|
return hex.EncodeToString(b), nil
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
stripHopByHop(out.Header)
|
stripHopByHop(out.Header)
|
||||||
|
|
||||||
if isGated {
|
if isGated {
|
||||||
token, err := p.phone.EnsureSession()
|
token, err := p.phone.GetTokenForRequest(r.URL.String(), r.Method)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "auth: "+err.Error(), http.StatusUnauthorized)
|
http.Error(w, "auth: "+err.Error(), http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,13 @@ class GetAssertionResult {
|
||||||
final Uint8List authData;
|
final Uint8List authData;
|
||||||
final Uint8List signature;
|
final Uint8List signature;
|
||||||
final Uint8List clientDataHash;
|
final Uint8List clientDataHash;
|
||||||
|
final String clientDataJson;
|
||||||
|
|
||||||
GetAssertionResult({
|
GetAssertionResult({
|
||||||
required this.authData,
|
required this.authData,
|
||||||
required this.signature,
|
required this.signature,
|
||||||
required this.clientDataHash,
|
required this.clientDataHash,
|
||||||
|
required this.clientDataJson,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,15 +118,17 @@ Future<MakeCredentialResult> makeCredential(
|
||||||
|
|
||||||
/// Runs CTAP2 authenticatorGetAssertion against the card on [cid].
|
/// Runs CTAP2 authenticatorGetAssertion against the card on [cid].
|
||||||
/// [credentialDataB64] is the base64url of the stored AttestedCredentialData.
|
/// [credentialDataB64] is the base64url of the stored AttestedCredentialData.
|
||||||
|
/// [challenge] overrides the random challenge — use for per-request token binding.
|
||||||
Future<GetAssertionResult> getAssertion(
|
Future<GetAssertionResult> getAssertion(
|
||||||
int cid,
|
int cid,
|
||||||
String credentialDataB64,
|
String credentialDataB64, {
|
||||||
) async {
|
Uint8List? challenge,
|
||||||
|
}) async {
|
||||||
final credData = _b64uDecode(credentialDataB64);
|
final credData = _b64uDecode(credentialDataB64);
|
||||||
final credId = _extractCredentialId(credData);
|
final credId = _extractCredentialId(credData);
|
||||||
|
|
||||||
final challenge = _randomBytes(32);
|
final actualChallenge = challenge ?? _randomBytes(32);
|
||||||
final clientDataJson = _buildClientDataJson('webauthn.get', challenge);
|
final clientDataJson = _buildClientDataJson('webauthn.get', actualChallenge);
|
||||||
final clientDataHash = _sha256(utf8.encode(clientDataJson));
|
final clientDataHash = _sha256(utf8.encode(clientDataJson));
|
||||||
|
|
||||||
final requestMap = CborMap({
|
final requestMap = CborMap({
|
||||||
|
|
@ -154,6 +158,7 @@ Future<GetAssertionResult> getAssertion(
|
||||||
authData: authData,
|
authData: authData,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
clientDataHash: clientDataHash,
|
clientDataHash: clientDataHash,
|
||||||
|
clientDataJson: clientDataJson,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
|
@ -405,7 +406,7 @@ class FilterProxy {
|
||||||
) async {
|
) async {
|
||||||
String token;
|
String token;
|
||||||
try {
|
try {
|
||||||
token = await _getAuthToken();
|
token = await _getAuthToken(uri, method);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_deny(client, sub, 407, 'Proxy Authentication Required');
|
_deny(client, sub, 407, 'Proxy Authentication Required');
|
||||||
return;
|
return;
|
||||||
|
|
@ -449,9 +450,16 @@ class FilterProxy {
|
||||||
await _forwardHttpRequest(client, sub, upstream, out.toString(), remainder, contentLength);
|
await _forwardHttpRequest(client, sub, upstream, out.toString(), remainder, contentLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calls POST /auth/get-token on Component 2 and returns the bearer token.
|
// Calls POST /auth/get-token on Component 2 with per-request binding and
|
||||||
// Throws if no active session or Component 2 is unreachable.
|
// returns the bearer token (a self-contained FIDO2 assertion bundle).
|
||||||
Future<String> _getAuthToken() async {
|
// Throws if card is unavailable or Component 2 is unreachable.
|
||||||
|
Future<String> _getAuthToken(Uri uri, String method) async {
|
||||||
|
final nonce = _secureHex(16);
|
||||||
|
final payload = utf8.encode(jsonEncode({
|
||||||
|
'url': uri.toString(),
|
||||||
|
'method': method,
|
||||||
|
'nonce': nonce,
|
||||||
|
}));
|
||||||
final httpClient = HttpClient()
|
final httpClient = HttpClient()
|
||||||
..connectionTimeout = const Duration(seconds: 5);
|
..connectionTimeout = const Duration(seconds: 5);
|
||||||
try {
|
try {
|
||||||
|
|
@ -459,8 +467,8 @@ class FilterProxy {
|
||||||
Uri(scheme: 'http', host: '127.0.0.1', port: _component2Port, path: '/auth/get-token'),
|
Uri(scheme: 'http', host: '127.0.0.1', port: _component2Port, path: '/auth/get-token'),
|
||||||
);
|
);
|
||||||
req.headers.contentType = ContentType.json;
|
req.headers.contentType = ContentType.json;
|
||||||
req.contentLength = 2;
|
req.contentLength = payload.length;
|
||||||
req.write('{}');
|
req.add(payload);
|
||||||
final resp = await req.close();
|
final resp = await req.close();
|
||||||
final body = await resp.transform(utf8.decoder).join();
|
final body = await resp.transform(utf8.decoder).join();
|
||||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||||
|
|
@ -473,6 +481,13 @@ class FilterProxy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _secureHex(int bytes) {
|
||||||
|
final rng = Random.secure();
|
||||||
|
return List.generate(bytes, (_) => rng.nextInt(256))
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
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';
|
||||||
|
|
||||||
|
|
@ -439,37 +442,72 @@ class _ProxyServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Auth token endpoint (v2 architecture)
|
// Auth token endpoint (v2 architecture — per-request token binding)
|
||||||
//
|
//
|
||||||
// Component 1 (filter_proxy) and Component 3 (Go binary) call this to get
|
// Component 1 (filter_proxy) and Component 3 (Go binary) call this with
|
||||||
// a bearer token they can attach to requests when calling endpoints directly.
|
// {url, method, nonce} for each gated request. A fresh FIDO2 assertion is
|
||||||
// Component 2 never calls endpoints itself — it only issues tokens.
|
// produced with challenge = SHA256(url|method|nonce). The self-contained
|
||||||
|
// assertion bundle is returned as a base64url Bearer token the server can
|
||||||
|
// verify without calling back to this service.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
Future<void> _handleAuthGetToken(HttpRequest req) async {
|
Future<void> _handleAuthGetToken(HttpRequest req) async {
|
||||||
await _drainBody(req);
|
final body = await _readJson(req);
|
||||||
|
if (body == null) return;
|
||||||
|
|
||||||
// If there is already an active session return its token — no card needed.
|
final url = body['url'] as String? ?? '';
|
||||||
final active = _sessions.anyActive();
|
final method = body['method'] as String? ?? '';
|
||||||
if (active != null) {
|
final nonce = body['nonce'] as String? ?? '';
|
||||||
final (token, session) = active;
|
|
||||||
final secondsRemaining =
|
if (url.isEmpty || method.isEmpty || nonce.isEmpty) {
|
||||||
session.expires.difference(DateTime.now()).inSeconds.clamp(0, 99999);
|
await _send(req.response, 400, {'ok': false, 'error': 'url, method, nonce required'});
|
||||||
await _send(req.response, 200, {
|
|
||||||
'ok': true,
|
|
||||||
'token': token,
|
|
||||||
'username': session.username,
|
|
||||||
'expires_in': secondsRemaining,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No active session — caller must trigger /session/login first.
|
if (!_cardAttached || _cardCid == null) {
|
||||||
await _send(req.response, 401, {
|
await _send(req.response, 503, {'ok': false, 'error': 'card not available'});
|
||||||
'ok': false,
|
return;
|
||||||
'error': 'no active session',
|
}
|
||||||
'login_required': true,
|
|
||||||
|
// Find first enrolled user with a FIDO2 credential.
|
||||||
|
final users = await _db.list();
|
||||||
|
Enrollment? enrolled;
|
||||||
|
for (final u in users) {
|
||||||
|
if (u.hasCredential) { enrolled = u; break; }
|
||||||
|
}
|
||||||
|
if (enrolled == null) {
|
||||||
|
await _send(req.response, 401, {'ok': false, 'error': 'no enrolled credential'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Challenge = SHA256(url | "|" | method | "|" | nonce)
|
||||||
|
final challenge = Uint8List.fromList(
|
||||||
|
sha256.convert(utf8.encode('$url|$method|$nonce')).bytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
GetAssertionResult assertionResult;
|
||||||
|
try {
|
||||||
|
assertionResult = await getAssertion(_cardCid!, enrolled.credentialDataB64!, challenge: challenge);
|
||||||
|
} catch (e) {
|
||||||
|
await _send(req.response, 401, {'ok': false, 'error': 'card assertion failed: $e'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-contained bundle the server can verify without calling back to the phone.
|
||||||
|
final bundleJson = jsonEncode({
|
||||||
|
'v': 1,
|
||||||
|
'url': url,
|
||||||
|
'method': method,
|
||||||
|
'nonce': nonce,
|
||||||
|
'authData': base64Url.encode(assertionResult.authData).replaceAll('=', ''),
|
||||||
|
'sig': base64Url.encode(assertionResult.signature).replaceAll('=', ''),
|
||||||
|
'cdj': base64Url.encode(utf8.encode(assertionResult.clientDataJson)).replaceAll('=', ''),
|
||||||
|
'cred': enrolled.credentialDataB64,
|
||||||
|
'user': enrolled.username,
|
||||||
});
|
});
|
||||||
|
final token = base64Url.encode(utf8.encode(bundleJson)).replaceAll('=', '');
|
||||||
|
|
||||||
|
await _send(req.response, 200, {'ok': true, 'token': token, 'username': enrolled.username});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ All state is process-local and resets on restart.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import ssl
|
import ssl
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -19,6 +21,86 @@ from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def _b64u_decode(s: str) -> bytes:
|
||||||
|
padded = s + "=" * ((4 - len(s) % 4) % 4)
|
||||||
|
return base64.urlsafe_b64decode(padded)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_assertion_token(token: str, request_path: str, request_method: str) -> bool:
|
||||||
|
"""Verify a base64url-encoded FIDO2 per-request assertion bundle.
|
||||||
|
|
||||||
|
Bundle fields (JSON, then base64url-encoded):
|
||||||
|
v version (1)
|
||||||
|
url full URL used to derive the challenge
|
||||||
|
method HTTP method used to derive the challenge
|
||||||
|
nonce random hex nonce used to derive the challenge
|
||||||
|
authData base64url authenticator data
|
||||||
|
sig base64url ECDSA signature
|
||||||
|
cdj base64url clientDataJson bytes
|
||||||
|
cred base64url AttestedCredentialData (aaguid+credIdLen+credId+coseKey)
|
||||||
|
user enrolled username (informational)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import cbor2
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||||
|
ECDSA,
|
||||||
|
EllipticCurvePublicNumbers,
|
||||||
|
SECP256R1,
|
||||||
|
)
|
||||||
|
from cryptography.hazmat.primitives.hashes import SHA256
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
|
||||||
|
bundle = json.loads(_b64u_decode(token).decode("utf-8"))
|
||||||
|
|
||||||
|
# Path and method must match the actual request.
|
||||||
|
bundle_path = urlparse(bundle["url"]).path
|
||||||
|
if bundle_path != request_path:
|
||||||
|
return False
|
||||||
|
if bundle["method"].upper() != request_method.upper():
|
||||||
|
return False
|
||||||
|
|
||||||
|
url = bundle["url"]
|
||||||
|
method = bundle["method"]
|
||||||
|
nonce = bundle["nonce"]
|
||||||
|
|
||||||
|
# Verify challenge claim: challenge == b64u(SHA256(url|method|nonce))
|
||||||
|
binding = f"{url}|{method}|{nonce}".encode()
|
||||||
|
expected_challenge = base64.urlsafe_b64encode(hashlib.sha256(binding).digest()).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
cdj_bytes = _b64u_decode(bundle["cdj"])
|
||||||
|
cdj = json.loads(cdj_bytes)
|
||||||
|
if cdj.get("type") != "webauthn.get":
|
||||||
|
return False
|
||||||
|
if cdj.get("challenge") != expected_challenge:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Verify ECDSA-P256 signature over authData || SHA256(clientDataJson).
|
||||||
|
auth_data = _b64u_decode(bundle["authData"])
|
||||||
|
signature = _b64u_decode(bundle["sig"])
|
||||||
|
client_data_hash = hashlib.sha256(cdj_bytes).digest()
|
||||||
|
message = auth_data + client_data_hash
|
||||||
|
|
||||||
|
# Extract P-256 public key from AttestedCredentialData.
|
||||||
|
cred_data = _b64u_decode(bundle["cred"])
|
||||||
|
cred_id_len = (cred_data[16] << 8) | cred_data[17]
|
||||||
|
cose_bytes = cred_data[18 + cred_id_len:]
|
||||||
|
cose_key = cbor2.loads(cose_bytes)
|
||||||
|
x = cose_key[-2]
|
||||||
|
y = cose_key[-3]
|
||||||
|
|
||||||
|
pub_key = EllipticCurvePublicNumbers(
|
||||||
|
x=int.from_bytes(x, "big"),
|
||||||
|
y=int.from_bytes(y, "big"),
|
||||||
|
curve=SECP256R1(),
|
||||||
|
).public_key(default_backend())
|
||||||
|
|
||||||
|
pub_key.verify(signature, message, ECDSA(SHA256()))
|
||||||
|
return True
|
||||||
|
except (InvalidSignature, Exception):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ServerState:
|
class ServerState:
|
||||||
# All state is process-local; a restart resets the counter to zero.
|
# All state is process-local; a restart resets the counter to zero.
|
||||||
def __init__(self, proxy_token: str):
|
def __init__(self, proxy_token: str):
|
||||||
|
|
@ -52,7 +134,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self.rfile.read(length)
|
self.rfile.read(length)
|
||||||
|
|
||||||
def _is_proxy_authorized(self) -> bool:
|
def _is_proxy_authorized(self) -> bool:
|
||||||
return self.headers.get("X-Proxy-Token") == self.state.proxy_token
|
# Accept legacy X-Proxy-Token (k_proxy_app.py) or FIDO2 assertion Bearer.
|
||||||
|
if self.headers.get("X-Proxy-Token") == self.state.proxy_token:
|
||||||
|
return True
|
||||||
|
auth = self.headers.get("Authorization", "")
|
||||||
|
if auth.startswith("Bearer "):
|
||||||
|
return _verify_assertion_token(
|
||||||
|
auth[7:].strip(),
|
||||||
|
request_path=urlparse(self.path).path,
|
||||||
|
request_method=self.command,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802
|
def do_GET(self) -> None: # noqa: N802
|
||||||
path = urlparse(self.path).path
|
path = urlparse(self.path).path
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue