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`.
|
||||
- `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
|
||||
|
||||
1. Deploy to a real Android phone with physical ChromeCard via USB
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package main
|
|||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -12,17 +11,10 @@ import (
|
|||
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)")
|
||||
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)
|
||||
|
|
@ -38,7 +30,7 @@ func main() {
|
|||
log.Printf("loaded %d gated entries from %s", gated.Len(), *gatedFile)
|
||||
}
|
||||
|
||||
phone := NewPhoneClient(*phoneURL, *username)
|
||||
phone := NewPhoneClient(*phoneURL)
|
||||
|
||||
proxy := &Proxy{
|
||||
phone: phone,
|
||||
|
|
|
|||
|
|
@ -2,70 +2,42 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"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.
|
||||
// PhoneClient fetches a per-request FIDO2 assertion token from Component 2.
|
||||
// There is no session caching — each call triggers a card interaction.
|
||||
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}
|
||||
func NewPhoneClient(baseURL string) *PhoneClient {
|
||||
return &PhoneClient{baseURL: baseURL}
|
||||
}
|
||||
|
||||
// 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("{}")))
|
||||
// GetTokenForRequest calls /auth/get-token with url+method+nonce, triggering
|
||||
// a fresh FIDO2 assertion on the card. Returns the self-contained assertion
|
||||
// bundle that the endpoint can verify independently.
|
||||
func (c *PhoneClient) GetTokenForRequest(rawURL, method string) (string, error) {
|
||||
nonce, err := randomHex(16)
|
||||
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()
|
||||
|
||||
|
|
@ -73,55 +45,24 @@ func (c *PhoneClient) getToken() (string, int, error) {
|
|||
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)
|
||||
return "", fmt.Errorf("parse token response: %w (body: %s)", err, raw)
|
||||
}
|
||||
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.
|
||||
// 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)
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", 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
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
stripHopByHop(out.Header)
|
||||
|
||||
if isGated {
|
||||
token, err := p.phone.EnsureSession()
|
||||
token, err := p.phone.GetTokenForRequest(r.URL.String(), r.Method)
|
||||
if err != nil {
|
||||
http.Error(w, "auth: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -46,11 +46,13 @@ class GetAssertionResult {
|
|||
final Uint8List authData;
|
||||
final Uint8List signature;
|
||||
final Uint8List clientDataHash;
|
||||
final String clientDataJson;
|
||||
|
||||
GetAssertionResult({
|
||||
required this.authData,
|
||||
required this.signature,
|
||||
required this.clientDataHash,
|
||||
required this.clientDataJson,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -116,15 +118,17 @@ Future<MakeCredentialResult> makeCredential(
|
|||
|
||||
/// Runs CTAP2 authenticatorGetAssertion against the card on [cid].
|
||||
/// [credentialDataB64] is the base64url of the stored AttestedCredentialData.
|
||||
/// [challenge] overrides the random challenge — use for per-request token binding.
|
||||
Future<GetAssertionResult> getAssertion(
|
||||
int cid,
|
||||
String credentialDataB64,
|
||||
) async {
|
||||
String credentialDataB64, {
|
||||
Uint8List? challenge,
|
||||
}) async {
|
||||
final credData = _b64uDecode(credentialDataB64);
|
||||
final credId = _extractCredentialId(credData);
|
||||
|
||||
final challenge = _randomBytes(32);
|
||||
final clientDataJson = _buildClientDataJson('webauthn.get', challenge);
|
||||
final actualChallenge = challenge ?? _randomBytes(32);
|
||||
final clientDataJson = _buildClientDataJson('webauthn.get', actualChallenge);
|
||||
final clientDataHash = _sha256(utf8.encode(clientDataJson));
|
||||
|
||||
final requestMap = CborMap({
|
||||
|
|
@ -154,6 +158,7 @@ Future<GetAssertionResult> getAssertion(
|
|||
authData: authData,
|
||||
signature: signature,
|
||||
clientDataHash: clientDataHash,
|
||||
clientDataJson: clientDataJson,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
|
|
@ -405,7 +406,7 @@ class FilterProxy {
|
|||
) async {
|
||||
String token;
|
||||
try {
|
||||
token = await _getAuthToken();
|
||||
token = await _getAuthToken(uri, method);
|
||||
} catch (_) {
|
||||
_deny(client, sub, 407, 'Proxy Authentication Required');
|
||||
return;
|
||||
|
|
@ -449,9 +450,16 @@ class FilterProxy {
|
|||
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 {
|
||||
// Calls POST /auth/get-token on Component 2 with per-request binding and
|
||||
// returns the bearer token (a self-contained FIDO2 assertion bundle).
|
||||
// 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()
|
||||
..connectionTimeout = const Duration(seconds: 5);
|
||||
try {
|
||||
|
|
@ -459,8 +467,8 @@ class FilterProxy {
|
|||
Uri(scheme: 'http', host: '127.0.0.1', port: _component2Port, path: '/auth/get-token'),
|
||||
);
|
||||
req.headers.contentType = ContentType.json;
|
||||
req.contentLength = 2;
|
||||
req.write('{}');
|
||||
req.contentLength = payload.length;
|
||||
req.add(payload);
|
||||
final resp = await req.close();
|
||||
final body = await resp.transform(utf8.decoder).join();
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_background_service/flutter_background_service.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
|
||||
// a bearer token they can attach to requests when calling endpoints directly.
|
||||
// Component 2 never calls endpoints itself — it only issues tokens.
|
||||
// Component 1 (filter_proxy) and Component 3 (Go binary) call this with
|
||||
// {url, method, nonce} for each gated request. A fresh FIDO2 assertion is
|
||||
// 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 {
|
||||
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 active = _sessions.anyActive();
|
||||
if (active != null) {
|
||||
final (token, session) = active;
|
||||
final secondsRemaining =
|
||||
session.expires.difference(DateTime.now()).inSeconds.clamp(0, 99999);
|
||||
await _send(req.response, 200, {
|
||||
'ok': true,
|
||||
'token': token,
|
||||
'username': session.username,
|
||||
'expires_in': secondsRemaining,
|
||||
});
|
||||
final url = body['url'] as String? ?? '';
|
||||
final method = body['method'] as String? ?? '';
|
||||
final nonce = body['nonce'] as String? ?? '';
|
||||
|
||||
if (url.isEmpty || method.isEmpty || nonce.isEmpty) {
|
||||
await _send(req.response, 400, {'ok': false, 'error': 'url, method, nonce required'});
|
||||
return;
|
||||
}
|
||||
|
||||
// No active session — caller must trigger /session/login first.
|
||||
await _send(req.response, 401, {
|
||||
'ok': false,
|
||||
'error': 'no active session',
|
||||
'login_required': true,
|
||||
if (!_cardAttached || _cardCid == null) {
|
||||
await _send(req.response, 503, {'ok': false, 'error': 'card not available'});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import ssl
|
||||
import threading
|
||||
|
|
@ -19,6 +21,86 @@ from typing import Any
|
|||
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:
|
||||
# All state is process-local; a restart resets the counter to zero.
|
||||
def __init__(self, proxy_token: str):
|
||||
|
|
@ -52,7 +134,17 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self.rfile.read(length)
|
||||
|
||||
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
|
||||
path = urlparse(self.path).path
|
||||
|
|
|
|||
Loading…
Reference in New Issue