6239eca243
Completes the three in-flight open items and fixes the away-fallthrough bug. Item 7 — passkey step-up (WebAuthn): - internal/webauthn: ES256/P-256 register + assert with real ecdsa signature verification, minimal CBOR/COSE decode, PasskeySession (L2→L3 on assert, decays after TTL). Drop the RS256 offer we can't verify (register-ok/ assert-fail trap). Verify rpIdHash + UP/UV flags in FinishAssertion — UV is the step-up gesture. Round-trip test with negative cases (tampered sig, missing UV, wrong origin). - cmd/mavweb: /auth/passkey enroll+assert page (the only surface that can do a WebAuthn gesture) + the four begin/finish endpoints. Without this the daemon's PasskeySession swap leaves /tools enable permanently blocked. - daemon wires PasskeySession as the auth Session + srv.StepUp; policy gates MethodAssertStepUp at AuthRead. Item 5 — tools page: DisableTool through store/ipc/client/wire; /tools grows a disable action and a link to the passkey page. Lifecycle test. Item 6 — note RAG: PhraseQuery on the phraser (LLM-composed answer over top-k notes, raw-notes fallback); IntentQuery routes through it. Stub returns a deterministic summary. Item 2 — away-fallthrough: on ErrVoiceNoSession the dispatcher now reroutes through the AWAY table (sev3→ntfy, sev4→telegram-repeat-til-ack, sev≤2→drop) instead of silently dropping / mis-routing to the present-list remainder. Covers DispatchNudge + DispatchReminder. 4 tests. Also: re-add ProposeTool to CoreAPI (dropped in a comment rewrite), fix missing imports + a duplicate block left mid-edit, drop dead AssertStepUpFunc, gitignore /mavcaldav. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
414 lines
12 KiB
Go
414 lines
12 KiB
Go
// Package webauthn implements the server side of the WebAuthn (FIDO2) protocol
|
|
// for passkey-based user verification. It handles both registration (creating
|
|
// a new credential) and assertion (verifying the user), using standard library
|
|
// crypto and a minimal CBOR decoder.
|
|
//
|
|
// Only ECDSA P-256 (ES256, COSE algorithm -7) credentials are supported.
|
|
// Attestation is read but not verified — we trust the authenticator attestation
|
|
// is honest for this deployment (single-user, self-hosted).
|
|
package webauthn
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"time"
|
|
)
|
|
|
|
// Config — the WebAuthn Relying Party parameters. Must match what the browser
|
|
// sees (origin = the page's origin, rpID = the effective domain).
|
|
type Config struct {
|
|
Origin string // e.g. "https://maven.kvmx.ru"
|
|
RPID string // e.g. "maven.kvmx.ru"
|
|
RPName string // e.g. "maven"
|
|
}
|
|
|
|
// CredentialLookup is the function signature the RP needs to load a stored
|
|
// credential for assertion verification. Returns the COSE public key bytes
|
|
// and the current sign count.
|
|
type CredentialLookup func(id string) (publicKey []byte, signCount int64, err error)
|
|
|
|
// CredentialSaver stores a newly registered credential.
|
|
type CredentialSaver func(id string, publicKey []byte, userID []byte, userDisplayName string) error
|
|
|
|
// SignCountUpdater persists an updated sign counter after a successful assertion.
|
|
type SignCountUpdater func(id string, count int64) error
|
|
|
|
// credentialRegistration is the in-memory state for an in-flight registration.
|
|
type credentialRegistration struct {
|
|
Challenge string
|
|
UserID []byte
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// credentialAssertion is the in-memory state for an in-flight assertion.
|
|
type credentialAssertion struct {
|
|
Challenge string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// RP — the relying party instance. Holds config and transient challenge state.
|
|
// A single-user daemon has one RP.
|
|
type RP struct {
|
|
cfg Config
|
|
regs map[string]*credentialRegistration
|
|
asserts map[string]*credentialAssertion
|
|
challengeTTL time.Duration
|
|
}
|
|
|
|
// NewRP creates a relying party with the given WebAuthn configuration.
|
|
func NewRP(cfg Config) *RP {
|
|
return &RP{
|
|
cfg: cfg,
|
|
regs: make(map[string]*credentialRegistration),
|
|
asserts: make(map[string]*credentialAssertion),
|
|
challengeTTL: 5 * time.Minute,
|
|
}
|
|
}
|
|
|
|
// CleanExpired removes challenges older than the TTL.
|
|
func (rp *RP) CleanExpired() {
|
|
now := time.Now()
|
|
for k, r := range rp.regs {
|
|
if now.Sub(r.CreatedAt) > rp.challengeTTL {
|
|
delete(rp.regs, k)
|
|
}
|
|
}
|
|
for k, a := range rp.asserts {
|
|
if now.Sub(a.CreatedAt) > rp.challengeTTL {
|
|
delete(rp.asserts, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// CreationOptions returns the PublicKeyCredentialCreationOptions as a
|
|
// JSON-serializable map for the browser to create a credential.
|
|
func (rp *RP) CreationOptions(userID []byte, userName string) (map[string]any, string, error) {
|
|
challenge := make([]byte, 32)
|
|
if _, err := rand.Read(challenge); err != nil {
|
|
return nil, "", fmt.Errorf("webauthn: challenge: %w", err)
|
|
}
|
|
challengeB64 := base64.RawURLEncoding.EncodeToString(challenge)
|
|
|
|
rp.CleanExpired()
|
|
rp.regs[challengeB64] = &credentialRegistration{
|
|
Challenge: challengeB64,
|
|
UserID: userID,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
return map[string]any{
|
|
"rp": map[string]string{
|
|
"name": rp.cfg.RPName,
|
|
"id": rp.cfg.RPID,
|
|
},
|
|
"user": map[string]any{
|
|
"id": base64.RawURLEncoding.EncodeToString(userID),
|
|
"name": userName,
|
|
"displayName": userName,
|
|
},
|
|
"challenge": challengeB64,
|
|
// ES256 only — parseCOSEKey verifies P-256/ES256 exclusively. Offering
|
|
// RS256 here would let an authenticator register a key we can never
|
|
// verify at assertion time (register-ok, assert-fail forever).
|
|
"pubKeyCredParams": []map[string]any{
|
|
{"type": "public-key", "alg": -7}, // ES256
|
|
},
|
|
"timeout": 60000,
|
|
"attestation": "none",
|
|
"excludeCredentials": []any{},
|
|
}, challengeB64, nil
|
|
}
|
|
|
|
// FinishRegistration parses the browser's response and stores the credential.
|
|
func (rp *RP) FinishRegistration(save CredentialSaver, challengeB64 string, resp map[string]any) (string, error) {
|
|
rp.CleanExpired()
|
|
reg, ok := rp.regs[challengeB64]
|
|
if !ok {
|
|
return "", fmt.Errorf("webauthn: unknown or expired challenge")
|
|
}
|
|
delete(rp.regs, challengeB64)
|
|
|
|
credID := rawString(resp, "id")
|
|
if credID == "" {
|
|
return "", fmt.Errorf("webauthn: missing credential id")
|
|
}
|
|
|
|
rawResponse, ok := resp["response"].(map[string]any)
|
|
if !ok {
|
|
return "", fmt.Errorf("webauthn: missing response")
|
|
}
|
|
|
|
cdjB64 := rawString(rawResponse, "clientDataJSON")
|
|
cdjRaw, err := base64.RawURLEncoding.DecodeString(cdjB64)
|
|
if err != nil {
|
|
return "", fmt.Errorf("webauthn: clientDataJSON: %w", err)
|
|
}
|
|
if err := verifyClientDataBytes(cdjRaw, "webauthn.create", challengeB64, rp.cfg.Origin); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
attObjB64 := rawString(rawResponse, "attestationObject")
|
|
attObj, err := base64.RawURLEncoding.DecodeString(attObjB64)
|
|
if err != nil {
|
|
return "", fmt.Errorf("webauthn: attestationObject: %w", err)
|
|
}
|
|
|
|
publicKey, err := extractPublicKey(attObj)
|
|
if err != nil {
|
|
return "", fmt.Errorf("webauthn: extract key: %w", err)
|
|
}
|
|
|
|
if err := save(credID, publicKey, reg.UserID, "maven user"); err != nil {
|
|
return "", fmt.Errorf("webauthn: store credential: %w", err)
|
|
}
|
|
|
|
return credID, nil
|
|
}
|
|
|
|
// AssertionOptions returns a JSON-serializable map for browser authentication.
|
|
func (rp *RP) AssertionOptions() (map[string]any, string, error) {
|
|
challenge := make([]byte, 32)
|
|
if _, err := rand.Read(challenge); err != nil {
|
|
return nil, "", fmt.Errorf("webauthn: challenge: %w", err)
|
|
}
|
|
challengeB64 := base64.RawURLEncoding.EncodeToString(challenge)
|
|
|
|
rp.CleanExpired()
|
|
rp.asserts[challengeB64] = &credentialAssertion{
|
|
Challenge: challengeB64,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
return map[string]any{
|
|
"challenge": challengeB64,
|
|
"timeout": 60000,
|
|
"rpId": rp.cfg.RPID,
|
|
"allowCredentials": []any{},
|
|
"userVerification": "required",
|
|
}, challengeB64, nil
|
|
}
|
|
|
|
// FinishAssertion verifies the browser's assertion response and returns the
|
|
// verified credential ID.
|
|
func (rp *RP) FinishAssertion(lookup CredentialLookup, updateSignCount SignCountUpdater, challengeB64 string, resp map[string]any) (string, error) {
|
|
rp.CleanExpired()
|
|
if _, ok := rp.asserts[challengeB64]; !ok {
|
|
return "", fmt.Errorf("webauthn: unknown or expired challenge")
|
|
}
|
|
delete(rp.asserts, challengeB64)
|
|
|
|
credID := rawString(resp, "id")
|
|
if credID == "" {
|
|
return "", fmt.Errorf("webauthn: missing credential id")
|
|
}
|
|
|
|
rawResponse, ok := resp["response"].(map[string]any)
|
|
if !ok {
|
|
return "", fmt.Errorf("webauthn: missing response")
|
|
}
|
|
|
|
cdjRaw, err := base64.RawURLEncoding.DecodeString(rawString(rawResponse, "clientDataJSON"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("webauthn: clientDataJSON: %w", err)
|
|
}
|
|
if err := verifyClientDataBytes(cdjRaw, "webauthn.get", challengeB64, rp.cfg.Origin); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
authenticatorData, err := base64.RawURLEncoding.DecodeString(rawString(rawResponse, "authenticatorData"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("webauthn: authenticatorData: %w", err)
|
|
}
|
|
// Bind the assertion to this RP and require a verified user gesture. Origin
|
|
// is checked via clientDataJSON above; rpIdHash + flags bind the
|
|
// authenticator half. We requested userVerification:required, so UV must be
|
|
// set — that IS the step-up gesture (biometric/PIN).
|
|
if len(authenticatorData) < 37 {
|
|
return "", fmt.Errorf("webauthn: authenticatorData too short (%d)", len(authenticatorData))
|
|
}
|
|
rpIDHash := sha256.Sum256([]byte(rp.cfg.RPID))
|
|
if !bytes.Equal(authenticatorData[:32], rpIDHash[:]) {
|
|
return "", fmt.Errorf("webauthn: rpIdHash mismatch")
|
|
}
|
|
const flagUP, flagUV = 1 << 0, 1 << 2
|
|
if authenticatorData[32]&flagUP == 0 {
|
|
return "", fmt.Errorf("webauthn: user-present flag not set")
|
|
}
|
|
if authenticatorData[32]&flagUV == 0 {
|
|
return "", fmt.Errorf("webauthn: user-verification flag not set")
|
|
}
|
|
|
|
sig, err := base64.RawURLEncoding.DecodeString(rawString(rawResponse, "signature"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("webauthn: signature: %w", err)
|
|
}
|
|
|
|
pubKeyBytes, signCount, err := lookup(credID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("webauthn: credential not found: %w", err)
|
|
}
|
|
|
|
clientDataHash := sha256.Sum256(cdjRaw)
|
|
sigData := append(authenticatorData, clientDataHash[:]...)
|
|
|
|
pubKey, err := parseCOSEKey(pubKeyBytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("webauthn: parse key: %w", err)
|
|
}
|
|
if !ecdsa.VerifyASN1(pubKey, sigData, sig) {
|
|
return "", fmt.Errorf("webauthn: signature verification failed")
|
|
}
|
|
|
|
if len(authenticatorData) >= 37 {
|
|
counter := int64(binary.BigEndian.Uint32(authenticatorData[33:37]))
|
|
if counter > 0 && counter <= signCount {
|
|
return "", fmt.Errorf("webauthn: sign count not greater (old=%d, new=%d)", signCount, counter)
|
|
}
|
|
if counter > 0 {
|
|
if err := updateSignCount(credID, counter); err != nil {
|
|
return "", fmt.Errorf("webauthn: update sign count: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return credID, nil
|
|
}
|
|
|
|
func verifyClientDataBytes(clientDataJSON []byte, expectedType, expectedChallenge, expectedOrigin string) error {
|
|
var cdj struct {
|
|
Type string `json:"type"`
|
|
Challenge string `json:"challenge"`
|
|
Origin string `json:"origin"`
|
|
}
|
|
if err := json.Unmarshal(clientDataJSON, &cdj); err != nil {
|
|
return fmt.Errorf("webauthn: parse clientDataJSON: %w", err)
|
|
}
|
|
if cdj.Type != expectedType {
|
|
return fmt.Errorf("webauthn: unexpected type %q", cdj.Type)
|
|
}
|
|
if cdj.Challenge != expectedChallenge {
|
|
return fmt.Errorf("webauthn: challenge mismatch")
|
|
}
|
|
if cdj.Origin != expectedOrigin {
|
|
return fmt.Errorf("webauthn: origin mismatch: %q != %q", cdj.Origin, expectedOrigin)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func extractPublicKey(attObj []byte) ([]byte, error) {
|
|
v, err := decodeCBOR(attObj)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("webauthn: decode attestation: %w", err)
|
|
}
|
|
m, err := v.MapText()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("webauthn: attestation is not a map: %w", err)
|
|
}
|
|
|
|
authDataV, ok := m["authData"]
|
|
if !ok {
|
|
return nil, fmt.Errorf("webauthn: attestation missing authData")
|
|
}
|
|
authData, err := authDataV.Bytes()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("webauthn: authData not bytes: %w", err)
|
|
}
|
|
return extractCOSEKeyFromAuthData(authData)
|
|
}
|
|
|
|
func extractCOSEKeyFromAuthData(authData []byte) ([]byte, error) {
|
|
if len(authData) < 37 {
|
|
return nil, fmt.Errorf("webauthn: authData too short (%d)", len(authData))
|
|
}
|
|
flags := authData[32]
|
|
if flags&(1<<6) == 0 {
|
|
return nil, fmt.Errorf("webauthn: AT flag not set in authData")
|
|
}
|
|
acd := authData[37:]
|
|
if len(acd) < 18 {
|
|
return nil, fmt.Errorf("webauthn: attested credential data too short (%d)", len(acd))
|
|
}
|
|
credIDLen := int(binary.BigEndian.Uint16(acd[16:18]))
|
|
coseKeyOff := 18 + credIDLen
|
|
if coseKeyOff > len(acd) {
|
|
return nil, fmt.Errorf("webauthn: credential ID length %d exceeds data (%d)", credIDLen, len(acd))
|
|
}
|
|
return acd[coseKeyOff:], nil
|
|
}
|
|
|
|
func parseCOSEKey(raw []byte) (*ecdsa.PublicKey, error) {
|
|
v, err := decodeCBOR(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cose: decode: %w", err)
|
|
}
|
|
m, err := v.Map()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cose: not a map: %w", err)
|
|
}
|
|
|
|
kty, ok := m[1]
|
|
if !ok {
|
|
return nil, fmt.Errorf("cose: missing kty")
|
|
}
|
|
ktyV, err := kty.Int()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cose: kty: %w", err)
|
|
}
|
|
if ktyV != 2 {
|
|
return nil, fmt.Errorf("cose: unsupported kty %d (expected 2=EC2)", ktyV)
|
|
}
|
|
|
|
crv, ok := m[-1]
|
|
if !ok {
|
|
return nil, fmt.Errorf("cose: missing crv")
|
|
}
|
|
crvV, err := crv.Int()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cose: crv: %w", err)
|
|
}
|
|
if crvV != 1 {
|
|
return nil, fmt.Errorf("cose: unsupported crv %d (expected 1=P-256)", crvV)
|
|
}
|
|
|
|
xV, ok := m[-2]
|
|
if !ok {
|
|
return nil, fmt.Errorf("cose: missing x coordinate")
|
|
}
|
|
x, err := xV.Bytes()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cose: x: %w", err)
|
|
}
|
|
|
|
yV, ok := m[-3]
|
|
if !ok {
|
|
return nil, fmt.Errorf("cose: missing y coordinate")
|
|
}
|
|
y, err := yV.Bytes()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cose: y: %w", err)
|
|
}
|
|
|
|
return &ecdsa.PublicKey{
|
|
Curve: elliptic.P256(),
|
|
X: new(big.Int).SetBytes(x),
|
|
Y: new(big.Int).SetBytes(y),
|
|
}, nil
|
|
}
|
|
|
|
func rawString(m map[string]any, key string) string {
|
|
v, ok := m[key]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
s, _ := v.(string)
|
|
return s
|
|
}
|