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") } }