items 5-7: passkey step-up, tools enable/disable, note RAG — end to end
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>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// cborValue is one decoded CBOR item. Only the subset needed for WebAuthn
|
||||
// COSE key + attestation object parsing is handled: integers, byte strings,
|
||||
// text strings, arrays, maps.
|
||||
type cborValue struct {
|
||||
typ cborType
|
||||
u uint64 // unsigned integer value
|
||||
n int64 // negative integer value (-1 - u)
|
||||
b []byte // byte string
|
||||
t string // text string
|
||||
items []cborValue // array items or map key-value pairs (flattened)
|
||||
}
|
||||
|
||||
type cborType int
|
||||
|
||||
const (
|
||||
cborUint cborType = 0
|
||||
cborNegInt cborType = 1
|
||||
cborBytes cborType = 2
|
||||
cborText cborType = 3
|
||||
cborArray cborType = 4
|
||||
cborMap cborType = 5
|
||||
cborSimple cborType = 7
|
||||
)
|
||||
|
||||
func (v cborValue) Int() (int, error) {
|
||||
switch v.typ {
|
||||
case cborUint:
|
||||
return int(v.u), nil
|
||||
case cborNegInt:
|
||||
return int(v.n), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("cbor: expected int, got type %d", v.typ)
|
||||
}
|
||||
}
|
||||
|
||||
func (v cborValue) Int64() (int64, error) {
|
||||
switch v.typ {
|
||||
case cborUint:
|
||||
return int64(v.u), nil
|
||||
case cborNegInt:
|
||||
return v.n, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("cbor: expected int, got type %d", v.typ)
|
||||
}
|
||||
}
|
||||
|
||||
func (v cborValue) Bytes() ([]byte, error) {
|
||||
if v.typ != cborBytes {
|
||||
return nil, fmt.Errorf("cbor: expected bytes, got type %d", v.typ)
|
||||
}
|
||||
return v.b, nil
|
||||
}
|
||||
|
||||
func (v cborValue) Text() (string, error) {
|
||||
if v.typ != cborText {
|
||||
return "", fmt.Errorf("cbor: expected text, got type %d", v.typ)
|
||||
}
|
||||
return v.t, nil
|
||||
}
|
||||
|
||||
func (v cborValue) Map() (map[int64]cborValue, error) {
|
||||
if v.typ != cborMap {
|
||||
return nil, fmt.Errorf("cbor: expected map, got type %d", v.typ)
|
||||
}
|
||||
m := make(map[int64]cborValue, len(v.items)/2)
|
||||
for i := 0; i+1 < len(v.items); i += 2 {
|
||||
k, err := v.items[i].Int64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cbor: map key: %w", err)
|
||||
}
|
||||
m[k] = v.items[i+1]
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (v cborValue) MapText() (map[string]cborValue, error) {
|
||||
if v.typ != cborMap {
|
||||
return nil, fmt.Errorf("cbor: expected map, got type %d", v.typ)
|
||||
}
|
||||
m := make(map[string]cborValue, len(v.items)/2)
|
||||
for i := 0; i+1 < len(v.items); i += 2 {
|
||||
k, err := v.items[i].Text()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cbor: map text key: %w", err)
|
||||
}
|
||||
m[k] = v.items[i+1]
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (v cborValue) At(i int) (cborValue, error) {
|
||||
if v.typ != cborArray {
|
||||
return cborValue{}, fmt.Errorf("cbor: expected array, got type %d", v.typ)
|
||||
}
|
||||
if i < 0 || i >= len(v.items) {
|
||||
return cborValue{}, fmt.Errorf("cbor: index %d out of range (len %d)", i, len(v.items))
|
||||
}
|
||||
return v.items[i], nil
|
||||
}
|
||||
|
||||
// decodeCBOR decodes a single CBOR item from data. It handles only the subset
|
||||
// needed for WebAuthn COSE key + attestation parsing.
|
||||
func decodeCBOR(data []byte) (cborValue, error) {
|
||||
v, _, err := decodeItem(data)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func decodeItem(data []byte) (cborValue, int, error) {
|
||||
if len(data) == 0 {
|
||||
return cborValue{}, 0, fmt.Errorf("cbor: empty data")
|
||||
}
|
||||
ib := data[0]
|
||||
mt := ib >> 5
|
||||
ai := ib & 0x1f
|
||||
off := 1
|
||||
|
||||
arg, n, err := decodeArg(data, off, ai)
|
||||
if err != nil {
|
||||
return cborValue{}, 0, err
|
||||
}
|
||||
off = n
|
||||
|
||||
switch mt {
|
||||
case 0: // unsigned integer
|
||||
return cborValue{typ: cborUint, u: arg}, off, nil
|
||||
|
||||
case 1: // negative integer
|
||||
return cborValue{typ: cborNegInt, n: -1 - int64(arg)}, off, nil
|
||||
|
||||
case 2: // byte string
|
||||
if off+int(arg) > len(data) {
|
||||
return cborValue{}, 0, fmt.Errorf("cbor: byte string length %d exceeds data", arg)
|
||||
}
|
||||
b := make([]byte, arg)
|
||||
copy(b, data[off:off+int(arg)])
|
||||
return cborValue{typ: cborBytes, b: b}, off + int(arg), nil
|
||||
|
||||
case 3: // text string
|
||||
if off+int(arg) > len(data) {
|
||||
return cborValue{}, 0, fmt.Errorf("cbor: text string length %d exceeds data", arg)
|
||||
}
|
||||
return cborValue{typ: cborText, t: string(data[off : off+int(arg)])}, off + int(arg), nil
|
||||
|
||||
case 4: // array
|
||||
items := make([]cborValue, 0, arg)
|
||||
pos := off
|
||||
for i := uint64(0); i < arg; i++ {
|
||||
item, n, err := decodeItem(data[pos:])
|
||||
if err != nil {
|
||||
return cborValue{}, 0, fmt.Errorf("cbor: array item %d: %w", i, err)
|
||||
}
|
||||
items = append(items, item)
|
||||
pos += n
|
||||
}
|
||||
return cborValue{typ: cborArray, items: items}, pos, nil
|
||||
|
||||
case 5: // map
|
||||
items := make([]cborValue, 0, 2*arg)
|
||||
pos := off
|
||||
for i := uint64(0); i < arg; i++ {
|
||||
k, n, err := decodeItem(data[pos:])
|
||||
if err != nil {
|
||||
return cborValue{}, 0, fmt.Errorf("cbor: map key %d: %w", i, err)
|
||||
}
|
||||
pos += n
|
||||
v, n, err := decodeItem(data[pos:])
|
||||
if err != nil {
|
||||
return cborValue{}, 0, fmt.Errorf("cbor: map value %d: %w", i, err)
|
||||
}
|
||||
pos += n
|
||||
items = append(items, k, v)
|
||||
}
|
||||
return cborValue{typ: cborMap, items: items}, pos, nil
|
||||
|
||||
case 7: // simple / float
|
||||
switch ai {
|
||||
case 20: // false
|
||||
return cborValue{typ: cborSimple, u: 20}, off, nil
|
||||
case 21: // true
|
||||
return cborValue{typ: cborSimple, u: 21}, off, nil
|
||||
case 22: // null
|
||||
return cborValue{typ: cborSimple, u: 22}, off, nil
|
||||
case 25: // half-precision float (not needed but avoid panic)
|
||||
return cborValue{typ: cborSimple, u: 25}, off + 2, nil
|
||||
case 26: // single-precision float
|
||||
if off+4 > len(data) {
|
||||
return cborValue{}, 0, fmt.Errorf("cbor: truncated float32")
|
||||
}
|
||||
_ = math.Float32frombits(readBE32(data[off:]))
|
||||
return cborValue{typ: cborSimple, u: 26}, off + 4, nil
|
||||
case 27: // double-precision float
|
||||
if off+8 > len(data) {
|
||||
return cborValue{}, 0, fmt.Errorf("cbor: truncated float64")
|
||||
}
|
||||
_ = math.Float64frombits(readBE64(data[off:]))
|
||||
return cborValue{typ: cborSimple, u: 27}, off + 8, nil
|
||||
default:
|
||||
return cborValue{typ: cborSimple, u: arg}, off, nil
|
||||
}
|
||||
|
||||
default:
|
||||
return cborValue{}, 0, fmt.Errorf("cbor: unsupported major type %d", mt)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeArg(data []byte, off int, ai byte) (uint64, int, error) {
|
||||
switch {
|
||||
case ai <= 23:
|
||||
return uint64(ai), off, nil
|
||||
case ai == 24:
|
||||
if off >= len(data) {
|
||||
return 0, 0, fmt.Errorf("cbor: truncated additional info")
|
||||
}
|
||||
return uint64(data[off]), off + 1, nil
|
||||
case ai == 25:
|
||||
if off+2 > len(data) {
|
||||
return 0, 0, fmt.Errorf("cbor: truncated uint16")
|
||||
}
|
||||
return uint64(readBE16(data[off:])), off + 2, nil
|
||||
case ai == 26:
|
||||
if off+4 > len(data) {
|
||||
return 0, 0, fmt.Errorf("cbor: truncated uint32")
|
||||
}
|
||||
return uint64(readBE32(data[off:])), off + 4, nil
|
||||
case ai == 27:
|
||||
if off+8 > len(data) {
|
||||
return 0, 0, fmt.Errorf("cbor: truncated uint64")
|
||||
}
|
||||
return readBE64(data[off:]), off + 8, nil
|
||||
default:
|
||||
return 0, 0, fmt.Errorf("cbor: reserved additional info %d", ai)
|
||||
}
|
||||
}
|
||||
|
||||
func readBE16(b []byte) uint16 { return uint16(b[0])<<8 | uint16(b[1]) }
|
||||
func readBE32(b []byte) uint32 { return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) }
|
||||
func readBE64(b []byte) uint64 { return uint64(readBE32(b))<<32 | uint64(readBE32(b[4:])) }
|
||||
@@ -0,0 +1,60 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/auth"
|
||||
)
|
||||
|
||||
// PasskeySession implements auth.Session backed by WebAuthn passkey assertion.
|
||||
// The session starts at Layer2 (passkey is enrolled, this session exists) and
|
||||
// bumps to Layer3 on successful Assert(), which lasts for assertionTTL before
|
||||
// decaying back to Layer2.
|
||||
//
|
||||
// A nil *PasskeySession is a valid zero: it acts like a session with no
|
||||
// credentials enrolled (always L2, Assert returns ErrStepUpUnsupported).
|
||||
// This mirrors the FloorSession behavior when passkey is not configured.
|
||||
type PasskeySession struct {
|
||||
mu sync.Mutex
|
||||
assertedAt time.Time // zero = not asserted this session
|
||||
assertionTTL time.Duration
|
||||
}
|
||||
|
||||
// NewPasskeySession creates a session. The caller chooses the assertion TTL
|
||||
// (how long a step-up gesture remains valid). 5 minutes is a sensible default.
|
||||
func NewPasskeySession(assertionTTL time.Duration) *PasskeySession {
|
||||
if assertionTTL <= 0 {
|
||||
assertionTTL = 5 * time.Minute
|
||||
}
|
||||
return &PasskeySession{assertionTTL: assertionTTL}
|
||||
}
|
||||
|
||||
// CurrentLayer returns L3 if step-up has been asserted within the TTL,
|
||||
// otherwise L2 (passkey enrolled, this session proven). A nil receiver
|
||||
// returns L2 (no way to reach L3 without a session).
|
||||
func (s *PasskeySession) CurrentLayer(_ context.Context, _ auth.Scope) auth.Layer {
|
||||
if s == nil {
|
||||
return auth.Layer2
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.assertedAt.IsZero() && time.Since(s.assertedAt) < s.assertionTTL {
|
||||
return auth.Layer3
|
||||
}
|
||||
return auth.Layer2
|
||||
}
|
||||
|
||||
// Assert records a successful step-up gesture. The session bumps to L3 for
|
||||
// the assertion TTL. A nil receiver returns ErrStepUpUnsupported.
|
||||
func (s *PasskeySession) Assert(_ context.Context, _ auth.Scope) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("%w: passkey session not configured", auth.ErrStepUpUnsupported)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.assertedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- minimal CBOR encoders (only what a COSE key + attestation object need) ---
|
||||
|
||||
func cUint(u uint64) []byte {
|
||||
switch {
|
||||
case u < 24:
|
||||
return []byte{byte(u)}
|
||||
case u < 256:
|
||||
return []byte{0x18, byte(u)}
|
||||
default:
|
||||
return []byte{0x19, byte(u >> 8), byte(u)}
|
||||
}
|
||||
}
|
||||
|
||||
// cNeg encodes a negative int n (n<0). arg = -1-n.
|
||||
func cNeg(n int64) []byte {
|
||||
arg := uint64(-1 - n)
|
||||
b := cUint(arg)
|
||||
b[0] |= 0x20 // major type 1
|
||||
return b
|
||||
}
|
||||
|
||||
func cBytes(b []byte) []byte {
|
||||
h := cUint(uint64(len(b)))
|
||||
h[0] |= 0x40 // major type 2
|
||||
return append(h, b...)
|
||||
}
|
||||
|
||||
func cText(s string) []byte {
|
||||
h := cUint(uint64(len(s)))
|
||||
h[0] |= 0x60 // major type 3
|
||||
return append(h, []byte(s)...)
|
||||
}
|
||||
|
||||
func cMapHeader(n int) []byte {
|
||||
h := cUint(uint64(n))
|
||||
h[0] |= 0xa0 // major type 5
|
||||
return h
|
||||
}
|
||||
|
||||
// coseKey CBOR-encodes an ES256/P-256 public key as a COSE_Key map.
|
||||
func coseKey(pub *ecdsa.PublicKey) []byte {
|
||||
x := pub.X.Bytes()
|
||||
y := pub.Y.Bytes()
|
||||
// left-pad to 32 bytes
|
||||
px := make([]byte, 32)
|
||||
py := make([]byte, 32)
|
||||
copy(px[32-len(x):], x)
|
||||
copy(py[32-len(y):], y)
|
||||
var out []byte
|
||||
kv := func(k, v []byte) { out = append(append(out, k...), v...) }
|
||||
out = append(out, cMapHeader(5)...)
|
||||
kv(cUint(1), cUint(2)) // kty: EC2
|
||||
kv(cUint(3), cNeg(-7)) // alg: ES256
|
||||
kv(cNeg(-1), cUint(1)) // crv: P-256
|
||||
kv(cNeg(-2), cBytes(px)) // x
|
||||
kv(cNeg(-3), cBytes(py)) // y
|
||||
return out
|
||||
}
|
||||
|
||||
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
// authData builds an authenticatorData blob. For registration it embeds the
|
||||
// attested credential data (AT flag + COSE key); for assertion it's the 37-byte
|
||||
// header only.
|
||||
func authData(rpID string, flags byte, counter uint32, credID []byte, cose []byte) []byte {
|
||||
h := sha256.Sum256([]byte(rpID))
|
||||
d := append([]byte{}, h[:]...)
|
||||
d = append(d, flags)
|
||||
cb := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(cb, counter)
|
||||
d = append(d, cb...)
|
||||
if flags&(1<<6) != 0 { // AT set → attested credential data
|
||||
d = append(d, make([]byte, 16)...) // aaguid
|
||||
l := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(l, uint16(len(credID)))
|
||||
d = append(d, l...)
|
||||
d = append(d, credID...)
|
||||
d = append(d, cose...)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func clientData(typ, challenge, origin string) []byte {
|
||||
b, _ := json.Marshal(map[string]string{"type": typ, "challenge": challenge, "origin": origin})
|
||||
return b
|
||||
}
|
||||
|
||||
const testOrigin = "https://maven.test"
|
||||
const testRPID = "maven.test"
|
||||
|
||||
// TestRegisterAssertRoundTrip drives the full passkey flow with a real P-256
|
||||
// key: register a credential, then assert it and verify the ecdsa signature
|
||||
// check passes end to end.
|
||||
func TestRegisterAssertRoundTrip(t *testing.T) {
|
||||
rp := NewRP(Config{Origin: testOrigin, RPID: testRPID, RPName: "maven"})
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
cose := coseKey(&key.PublicKey)
|
||||
credID := []byte("cred-1")
|
||||
credIDb64 := b64(credID)
|
||||
|
||||
// --- register ---
|
||||
_, regChal, err := rp.CreationOptions([]byte("u"), "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
att := append(cMapHeader(3), cText("fmt")...)
|
||||
att = append(att, cText("none")...)
|
||||
att = append(att, cText("attStmt")...)
|
||||
att = append(att, cMapHeader(0)...)
|
||||
att = append(att, cText("authData")...)
|
||||
att = append(att, cBytes(authData(testRPID, 1<<6|0x05, 0, credID, cose))...)
|
||||
|
||||
var stored []byte
|
||||
save := func(id string, pk, _ []byte, _ string) error { stored = pk; return nil }
|
||||
gotID, err := rp.FinishRegistration(save, regChal, map[string]any{
|
||||
"id": credIDb64,
|
||||
"response": map[string]any{
|
||||
"clientDataJSON": b64(clientData("webauthn.create", regChal, testOrigin)),
|
||||
"attestationObject": b64(att),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if gotID != credIDb64 || len(stored) == 0 {
|
||||
t.Fatalf("register produced no credential")
|
||||
}
|
||||
|
||||
// --- assert (valid signature) ---
|
||||
credID64 := gotID
|
||||
sign := func(chal string, flags byte, tamper bool) map[string]any {
|
||||
ad := authData(testRPID, flags, 5, nil, nil)
|
||||
cdj := clientData("webauthn.get", chal, testOrigin)
|
||||
hash := sha256.Sum256(cdj)
|
||||
sig, _ := ecdsa.SignASN1(rand.Reader, key, append(append([]byte{}, ad...), hash[:]...))
|
||||
if tamper {
|
||||
sig[len(sig)-1] ^= 0xff
|
||||
}
|
||||
return map[string]any{
|
||||
"id": credID64,
|
||||
"response": map[string]any{
|
||||
"clientDataJSON": b64(cdj),
|
||||
"authenticatorData": b64(ad),
|
||||
"signature": b64(sig),
|
||||
},
|
||||
}
|
||||
}
|
||||
lookup := func(id string) ([]byte, int64, error) { return stored, 0, nil }
|
||||
upd := func(id string, c int64) error { return nil }
|
||||
|
||||
_, assertChal, _ := rp.AssertionOptions()
|
||||
if _, err := rp.FinishAssertion(lookup, upd, assertChal, sign(assertChal, 0x05, false)); err != nil {
|
||||
t.Fatalf("valid assertion should pass: %v", err)
|
||||
}
|
||||
|
||||
// --- negative: tampered signature ---
|
||||
_, chal2, _ := rp.AssertionOptions()
|
||||
if _, err := rp.FinishAssertion(lookup, upd, chal2, sign(chal2, 0x05, true)); err == nil {
|
||||
t.Fatal("tampered signature must fail verification")
|
||||
}
|
||||
|
||||
// --- negative: user-verification flag not set (no gesture) ---
|
||||
_, chal3, _ := rp.AssertionOptions()
|
||||
if _, err := rp.FinishAssertion(lookup, upd, chal3, sign(chal3, 0x01, false)); err == nil {
|
||||
t.Fatal("assertion without UV flag must fail (step-up requires a gesture)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssertRejectsWrongOrigin — a phished assertion from another origin fails.
|
||||
func TestAssertRejectsWrongOrigin(t *testing.T) {
|
||||
err := verifyClientDataBytes(clientData("webauthn.get", "abc", "https://evil.test"), "webauthn.get", "abc", testOrigin)
|
||||
if err == nil {
|
||||
t.Fatal("wrong origin must be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user