94c273780a
The RP kept its two in-flight challenge maps bare, and mavweb serves the four passkey endpoints from HTTP handlers. Two browsers beginning a challenge at once were a concurrent map write, which is a fatal runtime error rather than a recovered panic, so it takes the daemon down. The endpoint that reaches it answers before any credential is proven. Every read and write of regs and asserts is now under a mutex. Lookup and delete moved into takeReg and takeAssert so they happen under one hold, which is what makes a challenge single-use: separately, two replays of the same response both found it before either deleted it. The challenge in clientDataJSON is compared in constant time. It is the one secret in that blob, 32 bytes of crypto/rand the browser has to echo back, and a byte-at-a-time compare is the shape that leaks a guessed prefix. Also corrected the comment over ipc.codeOf, which claimed an unmatched error keeps its text server-side. rpcErr ships that text deliberately, and on a tcp seam it leaves the box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
237 lines
6.9 KiB
Go
237 lines
6.9 KiB
Go
package webauthn
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"sync"
|
|
"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)")
|
|
}
|
|
}
|
|
|
|
// The passkey endpoints are HTTP handlers, so two browsers beginning a
|
|
// challenge at once reach the same RP. Under -race this fails on the bare maps
|
|
// it used to keep, and in production a concurrent map write is fatal.
|
|
func TestChallengeMapsAreConcurrencySafe(t *testing.T) {
|
|
rp := NewRP(Config{Origin: testOrigin, RPID: testRPID, RPName: "maven"})
|
|
lookup := func(string) ([]byte, int64, error) { return nil, 0, nil }
|
|
upd := func(string, int64) error { return nil }
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 16; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := 0; j < 32; j++ {
|
|
_, chal, err := rp.AssertionOptions()
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
_, _ = rp.FinishAssertion(lookup, upd, chal, map[string]any{})
|
|
if _, _, err := rp.CreationOptions([]byte("u"), "user"); err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
rp.CleanExpired()
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// A challenge is single-use: the second presentation of one already spent is
|
|
// unknown, whichever goroutine gets there first.
|
|
func TestAssertionChallengeIsSingleUse(t *testing.T) {
|
|
rp := NewRP(Config{Origin: testOrigin, RPID: testRPID, RPName: "maven"})
|
|
_, chal, err := rp.AssertionOptions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !rp.takeAssert(chal) {
|
|
t.Fatal("first take of a fresh challenge failed")
|
|
}
|
|
if rp.takeAssert(chal) {
|
|
t.Fatal("a spent challenge was accepted twice")
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|