643 lines
30 KiB
Go
643 lines
30 KiB
Go
package passkey
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
virtualwebauthn "github.com/descope/virtualwebauthn"
|
|
|
|
"felis.lolicon.best/internal/api"
|
|
)
|
|
|
|
// Test relying party. RPID is a bare host; the origin is that host as an https URL. Both
|
|
// the go-webauthn config (via New) and the virtual authenticator (via RelyingParty) must
|
|
// agree on these, exactly as a real deployment's config and a real browser must agree.
|
|
const (
|
|
testRPID = "mc.example.net"
|
|
testRPName = "Felis"
|
|
testOrigin = "https://mc.example.net"
|
|
testUserID = "u1"
|
|
testUserEml = "[email protected]"
|
|
)
|
|
|
|
func newTestVerifier(t *testing.T) *Verifier {
|
|
t.Helper()
|
|
v, err := New(testRPID, testRPName, []string{testOrigin})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func testUser(creds ...api.PasskeyCredential) api.PasskeyUser {
|
|
return api.PasskeyUser{ID: testUserID, Name: testUserEml, DisplayName: testUserEml, Credentials: creds}
|
|
}
|
|
|
|
// virtualRP mirrors the verifier's RP so the authenticator signs clientDataJSON with the
|
|
// origin go-webauthn will check against.
|
|
func virtualRP() virtualwebauthn.RelyingParty {
|
|
return virtualwebauthn.RelyingParty{ID: testRPID, Name: testRPName, Origin: testOrigin}
|
|
}
|
|
|
|
// TestRegisterRoundTrip is the PARITY check: a real go-webauthn relying party (through our
|
|
// adapter) issues a creation challenge, a virtual authenticator produces a real attestation
|
|
// response, and the adapter verifies it end to end. This exercises the pieces a CODE-ONLY
|
|
// adapter can silently get wrong: the {"publicKey":{...}} options marshal, the SessionData
|
|
// []byte round-trip through the seam, and the base64url/base64 encoding of the verified
|
|
// credential id and public key. A green run means the adapter's ceremony logic and the
|
|
// underlying crypto agree — it does NOT prove production works: the RP id/origin here are
|
|
// the test's, so whether cfg.Auth.PanelHostname matches the real browser origin stays an
|
|
// integration concern, and the pgrepo persistence remains unverified until a real DB run.
|
|
func TestRegisterRoundTrip(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
rp := virtualRP()
|
|
authenticator := virtualwebauthn.NewAuthenticator()
|
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
|
|
options, sessionData, err := v.BeginRegistration(testUser())
|
|
if err != nil {
|
|
t.Fatalf("BeginRegistration: %v", err)
|
|
}
|
|
// The options the browser receives must be a WebAuthn creation document. The virtual
|
|
// authenticator's parser (like a browser) reads the publicKey member.
|
|
attestationOpts, err := virtualwebauthn.ParseAttestationOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAttestationOptions: %v (options=%s)", err, options)
|
|
}
|
|
if attestationOpts.RelyingPartyID != testRPID {
|
|
t.Fatalf("options RP id = %q, want %q", attestationOpts.RelyingPartyID, testRPID)
|
|
}
|
|
|
|
attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOpts)
|
|
|
|
vc, err := v.FinishRegistration(testUser(), sessionData, strings.NewReader(attestationResponse))
|
|
if err != nil {
|
|
t.Fatalf("FinishRegistration: %v", err)
|
|
}
|
|
|
|
// The verified credential id must be the authenticator's credential id, base64url.
|
|
wantID := base64.RawURLEncoding.EncodeToString(cred.ID)
|
|
if vc.CredentialID != wantID {
|
|
t.Errorf("CredentialID = %q, want %q", vc.CredentialID, wantID)
|
|
}
|
|
if vc.PublicKey == "" {
|
|
t.Error("PublicKey is empty; expected the COSE public key")
|
|
}
|
|
if _, err := base64.StdEncoding.DecodeString(vc.PublicKey); err != nil {
|
|
t.Errorf("PublicKey is not valid base64: %v", err)
|
|
}
|
|
// The default virtual authenticator performs user verification, and enrollment now
|
|
// requires it — so the recorded UserVerified flag must be true. This proves the flag is
|
|
// captured from the ceremony (not left at its zero value) end to end.
|
|
if !vc.UserVerified {
|
|
t.Error("UserVerified = false; a verified enrollment must record UV=true")
|
|
}
|
|
}
|
|
|
|
// TestRegisterRequiresUserVerification proves the required-UV policy is enforced, not just
|
|
// advertised: an authenticator that tests presence but does NOT verify the user (no
|
|
// PIN/biometric) must be rejected at finish. go-webauthn stamps UV=required into the
|
|
// SessionData at begin and checks the UV flag at CreateCredential; without this guard a
|
|
// silent, presence-only passkey could be bound. Pairs with TestRegisterRoundTrip (which
|
|
// proves a UV-capable authenticator still succeeds), so the policy neither over- nor
|
|
// under-blocks.
|
|
func TestRegisterRequiresUserVerification(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
rp := virtualRP()
|
|
// UserNotVerified: the authenticator signs with the UV flag clear.
|
|
authenticator := virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{UserNotVerified: true})
|
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
|
|
options, sessionData, err := v.BeginRegistration(testUser())
|
|
if err != nil {
|
|
t.Fatalf("BeginRegistration: %v", err)
|
|
}
|
|
attestationOpts, err := virtualwebauthn.ParseAttestationOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAttestationOptions: %v", err)
|
|
}
|
|
attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOpts)
|
|
|
|
if _, err := v.FinishRegistration(testUser(), sessionData, strings.NewReader(attestationResponse)); err == nil {
|
|
t.Fatal("FinishRegistration accepted a presence-only (no user verification) attestation; want rejection")
|
|
}
|
|
}
|
|
|
|
// TestRegisterOriginMismatchRejected proves the adapter is really checking the origin: an
|
|
// authenticator that signs a DIFFERENT origin than the RP is configured for must fail
|
|
// verification. If this passed, the round-trip test above would be meaningless (it would
|
|
// accept anything).
|
|
func TestRegisterOriginMismatchRejected(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
// Authenticator signs an origin the verifier does not permit.
|
|
rp := virtualwebauthn.RelyingParty{ID: testRPID, Name: testRPName, Origin: "https://evil.example.net"}
|
|
authenticator := virtualwebauthn.NewAuthenticator()
|
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
|
|
options, sessionData, err := v.BeginRegistration(testUser())
|
|
if err != nil {
|
|
t.Fatalf("BeginRegistration: %v", err)
|
|
}
|
|
attestationOpts, err := virtualwebauthn.ParseAttestationOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAttestationOptions: %v", err)
|
|
}
|
|
attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOpts)
|
|
|
|
if _, err := v.FinishRegistration(testUser(), sessionData, strings.NewReader(attestationResponse)); err == nil {
|
|
t.Fatal("FinishRegistration accepted an attestation signed for a foreign origin; want rejection")
|
|
}
|
|
}
|
|
|
|
// TestRegisterUserMismatchRejected proves the user handle is bound across the ceremony:
|
|
// finishing as a different principal than began must fail (go-webauthn checks
|
|
// bytes.Equal(user.WebAuthnID(), session.UserID)). This is the guard that a stashed
|
|
// challenge cannot be redeemed for a different account.
|
|
func TestRegisterUserMismatchRejected(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
rp := virtualRP()
|
|
authenticator := virtualwebauthn.NewAuthenticator()
|
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
|
|
options, sessionData, err := v.BeginRegistration(testUser())
|
|
if err != nil {
|
|
t.Fatalf("BeginRegistration: %v", err)
|
|
}
|
|
attestationOpts, err := virtualwebauthn.ParseAttestationOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAttestationOptions: %v", err)
|
|
}
|
|
attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOpts)
|
|
|
|
other := api.PasskeyUser{ID: "u2", Name: "[email protected]", DisplayName: "[email protected]"}
|
|
if _, err := v.FinishRegistration(other, sessionData, strings.NewReader(attestationResponse)); err == nil {
|
|
t.Fatal("FinishRegistration accepted a finish by a different principal; want rejection")
|
|
}
|
|
}
|
|
|
|
// TestBeginExcludesBoundCredentials proves an already-bound passkey is surfaced to the
|
|
// authenticator as an excludeCredentials entry, so a device cannot double-bind. The
|
|
// exclusion id must be the stored credential id, base64url.
|
|
func TestBeginExcludesBoundCredentials(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
existingRaw := []byte("existing-credential-id-bytes")
|
|
existingID := base64.RawURLEncoding.EncodeToString(existingRaw)
|
|
|
|
options, _, err := v.BeginRegistration(testUser(api.PasskeyCredential{CredentialID: existingID}))
|
|
if err != nil {
|
|
t.Fatalf("BeginRegistration: %v", err)
|
|
}
|
|
attestationOpts, err := virtualwebauthn.ParseAttestationOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAttestationOptions: %v", err)
|
|
}
|
|
if !slices.Contains(attestationOpts.ExcludeCredentials, existingID) {
|
|
t.Errorf("excludeCredentials = %v, want it to contain %q", attestationOpts.ExcludeCredentials, existingID)
|
|
}
|
|
}
|
|
|
|
// TestNewRejectsEmptyRPID proves a misconfigured deployment fails loudly at construction
|
|
// rather than minting challenges no browser can honor.
|
|
func TestNewRejectsEmptyRPID(t *testing.T) {
|
|
if _, err := New("", testRPName, []string{testOrigin}); err == nil {
|
|
t.Fatal("New accepted an empty RP id; want an error")
|
|
}
|
|
}
|
|
|
|
// enrollCredential runs a real credential-creation ceremony and returns the verified
|
|
// credential as the persist-ready stored view a later login validates against. Starting
|
|
// the login tests from a GENUINE COSE public key (not a hand-built one) is what makes them
|
|
// exercise WebAuthnCredentials()' base64 decode path — the exact spot an adapter silently
|
|
// breaks.
|
|
func enrollCredential(t *testing.T, v *Verifier, rp virtualwebauthn.RelyingParty, auth virtualwebauthn.Authenticator, cred virtualwebauthn.Credential) api.PasskeyCredential {
|
|
t.Helper()
|
|
options, sessionData, err := v.BeginRegistration(testUser())
|
|
if err != nil {
|
|
t.Fatalf("BeginRegistration: %v", err)
|
|
}
|
|
attestationOpts, err := virtualwebauthn.ParseAttestationOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAttestationOptions: %v", err)
|
|
}
|
|
attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, auth, cred, *attestationOpts)
|
|
vc, err := v.FinishRegistration(testUser(), sessionData, strings.NewReader(attestationResponse))
|
|
if err != nil {
|
|
t.Fatalf("FinishRegistration: %v", err)
|
|
}
|
|
// The ceremony flags travel with the row exactly as PGRepo stores and reloads them
|
|
// (migration 0009), so a login test validates against what production would hold.
|
|
return api.PasskeyCredential{CredentialID: vc.CredentialID, PublicKey: vc.PublicKey, SignCount: vc.SignCount,
|
|
UserVerified: vc.UserVerified, BackupEligible: vc.BackupEligible, BackupState: vc.BackupState}
|
|
}
|
|
|
|
// TestLoginRoundTrip is the PARITY check for the assertion (login) half, deliberately
|
|
// chained onto a REAL enrollment so the login validates against a genuine COSE public key.
|
|
// A real go-webauthn RP (through our adapter) enrolls a virtual authenticator's credential;
|
|
// the verified public key + credential id are fed back as the user's STORED credential into
|
|
// BeginLogin → the virtual authenticator signs an assertion → FinishLogin verifies it. This
|
|
// exercises exactly the path a hand-built credential would skip: WebAuthnCredentials()
|
|
// decoding the base64 COSE key so ValidateLogin can check the signature against it. The
|
|
// authenticator's counter is advanced before the assertion so the test also proves
|
|
// FinishLogin surfaces the real signature counter rather than a hardcoded 0.
|
|
func TestLoginRoundTrip(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
rp := virtualRP()
|
|
authenticator := virtualwebauthn.NewAuthenticator()
|
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
|
|
stored := enrollCredential(t, v, rp, authenticator, cred)
|
|
|
|
// The authenticator reports an advanced counter; a fresh enrollment stored 0, so this
|
|
// is a strict increase (no clone warning) and must survive through to VerifiedAssertion.
|
|
cred.Counter = 7
|
|
|
|
options, sessionData, err := v.BeginLogin(testUser(stored))
|
|
if err != nil {
|
|
t.Fatalf("BeginLogin: %v", err)
|
|
}
|
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAssertionOptions: %v (options=%s)", err, options)
|
|
}
|
|
if assertionOpts.RelyingPartyID != testRPID {
|
|
t.Fatalf("options RP id = %q, want %q", assertionOpts.RelyingPartyID, testRPID)
|
|
}
|
|
// The bound credential must be offered as an allowCredentials entry — username-first,
|
|
// the ceremony names which credentials the known user may assert.
|
|
wantID := base64.RawURLEncoding.EncodeToString(cred.ID)
|
|
if !slices.Contains(assertionOpts.AllowCredentials, wantID) {
|
|
t.Fatalf("allowCredentials = %v, want it to contain %q", assertionOpts.AllowCredentials, wantID)
|
|
}
|
|
|
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
|
|
va, err := v.FinishLogin(testUser(stored), sessionData, strings.NewReader(assertionResponse))
|
|
if err != nil {
|
|
t.Fatalf("FinishLogin: %v", err)
|
|
}
|
|
if va.CredentialID != stored.CredentialID {
|
|
t.Errorf("asserted CredentialID = %q, want %q", va.CredentialID, stored.CredentialID)
|
|
}
|
|
if va.SignCount != 7 {
|
|
t.Errorf("SignCount = %d, want 7 (the authenticator's advanced counter)", va.SignCount)
|
|
}
|
|
}
|
|
|
|
// TestLoginOriginMismatchRejected proves FinishLogin actually checks the origin: an
|
|
// assertion signed for an origin the RP does not permit must fail. Without this guard the
|
|
// round-trip test would be hollow — it would accept a signature from anywhere.
|
|
func TestLoginOriginMismatchRejected(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
rp := virtualRP()
|
|
authenticator := virtualwebauthn.NewAuthenticator()
|
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
stored := enrollCredential(t, v, rp, authenticator, cred)
|
|
|
|
options, sessionData, err := v.BeginLogin(testUser(stored))
|
|
if err != nil {
|
|
t.Fatalf("BeginLogin: %v", err)
|
|
}
|
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAssertionOptions: %v", err)
|
|
}
|
|
// Sign the assertion for a foreign origin the verifier does not permit.
|
|
evil := virtualwebauthn.RelyingParty{ID: testRPID, Name: testRPName, Origin: "https://evil.example.net"}
|
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(evil, authenticator, cred, *assertionOpts)
|
|
if _, err := v.FinishLogin(testUser(stored), sessionData, strings.NewReader(assertionResponse)); err == nil {
|
|
t.Fatal("FinishLogin accepted an assertion signed for a foreign origin; want rejection")
|
|
}
|
|
}
|
|
|
|
// TestLoginUnknownCredentialRejected proves the credential-ownership binding: a valid
|
|
// signature over the right challenge is NOT enough — it must come from one of the user's
|
|
// own bound credentials. The user's stored credential is A, but the assertion is signed by
|
|
// a different, never-bound credential B; go-webauthn must reject it (B is not in the
|
|
// challenge's allowCredentials, nor among the user's credentials).
|
|
func TestLoginUnknownCredentialRejected(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
rp := virtualRP()
|
|
authenticator := virtualwebauthn.NewAuthenticator()
|
|
credA := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
stored := enrollCredential(t, v, rp, authenticator, credA)
|
|
|
|
options, sessionData, err := v.BeginLogin(testUser(stored))
|
|
if err != nil {
|
|
t.Fatalf("BeginLogin: %v", err)
|
|
}
|
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAssertionOptions: %v", err)
|
|
}
|
|
credB := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, credB, *assertionOpts)
|
|
if _, err := v.FinishLogin(testUser(stored), sessionData, strings.NewReader(assertionResponse)); err == nil {
|
|
t.Fatal("FinishLogin accepted an assertion from an unbound credential; want rejection")
|
|
}
|
|
}
|
|
|
|
// TestDiscoverableLoginRoundTrip is the PARITY check for the usernameless (from-zero) half
|
|
// (task #40), and the proof its VERIFY path is real crypto rather than a stub. It differs from
|
|
// TestLoginRoundTrip in the two ways that define discoverable login: the begin names no user
|
|
// (so the request's allowCredentials must be EMPTY), and the account is revealed only by the
|
|
// userHandle the authenticator embeds in the signed assertion — the verifier hands that handle
|
|
// to a resolve callback that stands in for the handler's userHandle → UserByID lookup. Chained
|
|
// onto a REAL enrollment so the assertion validates against a genuine COSE key, and the
|
|
// authenticator's counter is advanced first so the surfaced SignCount is proven real, not a
|
|
// hardcoded 0. What this does NOT prove: that a real authenticator actually STORED a resident
|
|
// key — that residency is a device property (see TestEnrollmentRequestsResidentKey for the only
|
|
// thing a unit test can pin, the request the browser receives).
|
|
func TestDiscoverableLoginRoundTrip(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
rp := virtualRP()
|
|
authenticator := virtualwebauthn.NewAuthenticator()
|
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
|
|
stored := enrollCredential(t, v, rp, authenticator, cred)
|
|
|
|
// The authenticator returns the user handle in the assertion — this is what a resident
|
|
// credential does and what lets the account be resolved from nothing typed. It is the
|
|
// account's stable user id (WebAuthnID), so the resolver must receive exactly these bytes.
|
|
authenticator.Options.UserHandle = []byte(testUserID)
|
|
// Advance the counter so a real (non-zero, strictly increasing) SignCount must survive.
|
|
cred.Counter = 9
|
|
|
|
options, sessionData, err := v.BeginDiscoverableLogin()
|
|
if err != nil {
|
|
t.Fatalf("BeginDiscoverableLogin: %v", err)
|
|
}
|
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAssertionOptions: %v (options=%s)", err, options)
|
|
}
|
|
if assertionOpts.RelyingPartyID != testRPID {
|
|
t.Fatalf("options RP id = %q, want %q", assertionOpts.RelyingPartyID, testRPID)
|
|
}
|
|
// The defining property of a usernameless request: no credential is named. If this were
|
|
// non-empty the ceremony would be username-first and the test would prove nothing about #40.
|
|
if len(assertionOpts.AllowCredentials) != 0 {
|
|
t.Fatalf("allowCredentials = %v, want empty (usernameless request names no credential)", assertionOpts.AllowCredentials)
|
|
}
|
|
|
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
|
|
|
|
// resolve stands in for the handler's userHandle → UserByID lookup: it records the handle
|
|
// it was handed (to prove the account is revealed by the authenticator, not the client) and
|
|
// returns the stored credential so ValidateDiscoverableLogin can verify the signature.
|
|
var gotHandle []byte
|
|
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
|
|
gotHandle = userHandle
|
|
return testUser(stored), nil
|
|
}
|
|
va, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse))
|
|
if err != nil {
|
|
t.Fatalf("FinishDiscoverableLogin: %v", err)
|
|
}
|
|
if string(gotHandle) != testUserID {
|
|
t.Errorf("resolver received userHandle %q, want %q (the account is revealed by the assertion)", gotHandle, testUserID)
|
|
}
|
|
if va.CredentialID != stored.CredentialID {
|
|
t.Errorf("asserted CredentialID = %q, want %q", va.CredentialID, stored.CredentialID)
|
|
}
|
|
if va.SignCount != 9 {
|
|
t.Errorf("SignCount = %d, want 9 (the authenticator's advanced counter)", va.SignCount)
|
|
}
|
|
}
|
|
|
|
// TestDiscoverableLoginResolveFailsClosed proves the from-zero door fails CLOSED when the
|
|
// authenticator-revealed account cannot be resolved: a resolve callback that returns an error
|
|
// (the handler's UserByID found nothing — a handle for a deleted/unknown account) must abort
|
|
// the ceremony, never mint an assertion. Without this the usernameless path could be coaxed
|
|
// into treating an unresolvable handle as success. Pairs with the round-trip above so the
|
|
// resolver neither over- nor under-blocks.
|
|
func TestDiscoverableLoginResolveFailsClosed(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
rp := virtualRP()
|
|
authenticator := virtualwebauthn.NewAuthenticator()
|
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
|
|
enrollCredential(t, v, rp, authenticator, cred)
|
|
authenticator.Options.UserHandle = []byte("nonexistent-account")
|
|
|
|
options, sessionData, err := v.BeginDiscoverableLogin()
|
|
if err != nil {
|
|
t.Fatalf("BeginDiscoverableLogin: %v", err)
|
|
}
|
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAssertionOptions: %v", err)
|
|
}
|
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
|
|
|
|
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
|
|
return api.PasskeyUser{}, api.ErrNotFound
|
|
}
|
|
if _, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse)); err == nil {
|
|
t.Fatal("FinishDiscoverableLogin accepted an assertion whose account could not be resolved; want rejection")
|
|
}
|
|
}
|
|
|
|
// TestDiscoverableLoginUnboundCredentialRejected proves the credential-ownership binding for
|
|
// the usernameless door — the defense unique to it. In username-first login the server names
|
|
// allowCredentials, so an assertion must match a credential the server itself offered. The
|
|
// from-zero door names NOTHING: the authenticator reveals BOTH the userHandle and the signing
|
|
// credential, so the ONLY barrier stopping an attacker from signing with their own resident key
|
|
// while embedding a victim's userHandle is go-webauthn's check that the asserted credential id
|
|
// belongs to the resolved user. Here the resolve callback succeeds (the handle names a REAL
|
|
// account, u1, holding credential A) — unlike TestDiscoverableLoginResolveFailsClosed where it
|
|
// resolves to nothing — but the assertion is signed by credential B, never bound to u1.
|
|
// FinishDiscoverableLogin must reject: a good signature over the right challenge under a valid
|
|
// userHandle is still not enough without membership. This is the exact guard the "account is
|
|
// revealed by the assertion, never named by the client" claim leans on.
|
|
func TestDiscoverableLoginUnboundCredentialRejected(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
rp := virtualRP()
|
|
authenticator := virtualwebauthn.NewAuthenticator()
|
|
credA := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
stored := enrollCredential(t, v, rp, authenticator, credA)
|
|
|
|
// A valid handle: it resolves to the real account u1, which holds credential A.
|
|
authenticator.Options.UserHandle = []byte(testUserID)
|
|
|
|
options, sessionData, err := v.BeginDiscoverableLogin()
|
|
if err != nil {
|
|
t.Fatalf("BeginDiscoverableLogin: %v", err)
|
|
}
|
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAssertionOptions: %v", err)
|
|
}
|
|
// Sign with a fresh credential never bound to u1. The resolver still returns u1's real
|
|
// credential set (credential A) — so the ONLY thing that can reject this is the check that
|
|
// the asserted credential (B) is among the resolved user's credentials.
|
|
credB := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, credB, *assertionOpts)
|
|
|
|
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
|
|
return testUser(stored), nil
|
|
}
|
|
if _, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse)); err == nil {
|
|
t.Fatal("FinishDiscoverableLogin accepted an assertion signed by a credential not bound to the resolved user; want rejection")
|
|
}
|
|
}
|
|
|
|
// TestLoginCloneWarningSurfaced proves the adapter SURFACES go-webauthn's clone verdict (task #40
|
|
// item 5) on the username-first door: when the authenticator presents a signature counter at or
|
|
// below the stored value, go-webauthn raises CloneWarning but does NOT itself reject (the counter
|
|
// is advisory; the RP decides). The adapter must carry that verdict out in VerifiedAssertion so
|
|
// the handler can fail closed — without this the handler would have nothing to key clone policy
|
|
// on. Note the assertion still VERIFIES (err is nil): a regressed counter is a policy signal, not
|
|
// a broken signature.
|
|
func TestLoginCloneWarningSurfaced(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
rp := virtualRP()
|
|
authenticator := virtualwebauthn.NewAuthenticator()
|
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
stored := enrollCredential(t, v, rp, authenticator, cred)
|
|
|
|
// The stored counter is AHEAD of what the authenticator will present: a regression, which is
|
|
// exactly the cloned-authenticator signal go-webauthn's UpdateCounter raises.
|
|
stored.SignCount = 100
|
|
cred.Counter = 50
|
|
|
|
options, sessionData, err := v.BeginLogin(testUser(stored))
|
|
if err != nil {
|
|
t.Fatalf("BeginLogin: %v", err)
|
|
}
|
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAssertionOptions: %v", err)
|
|
}
|
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
|
|
va, err := v.FinishLogin(testUser(stored), sessionData, strings.NewReader(assertionResponse))
|
|
if err != nil {
|
|
t.Fatalf("FinishLogin: %v (a counter regression must still VERIFY, only flag CloneWarning)", err)
|
|
}
|
|
if !va.CloneWarning {
|
|
t.Fatal("va.CloneWarning = false, want true (presented counter at/below the stored counter is a clone signal)")
|
|
}
|
|
}
|
|
|
|
// TestDiscoverableLoginCloneWarningSurfaced is the same clone-verdict proof for the usernameless
|
|
// door (task #40 item 5): a from-zero assertion whose counter regressed must come back VERIFIED
|
|
// but with CloneWarning set, so the discoverable handler refuses it in the one shared place the
|
|
// username-first door uses. Chained onto a real enrollment so the assertion is genuine crypto.
|
|
func TestDiscoverableLoginCloneWarningSurfaced(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
rp := virtualRP()
|
|
authenticator := virtualwebauthn.NewAuthenticator()
|
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
stored := enrollCredential(t, v, rp, authenticator, cred)
|
|
|
|
authenticator.Options.UserHandle = []byte(testUserID)
|
|
stored.SignCount = 100
|
|
cred.Counter = 50
|
|
|
|
options, sessionData, err := v.BeginDiscoverableLogin()
|
|
if err != nil {
|
|
t.Fatalf("BeginDiscoverableLogin: %v", err)
|
|
}
|
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
|
if err != nil {
|
|
t.Fatalf("ParseAssertionOptions: %v (options=%s)", err, options)
|
|
}
|
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
|
|
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
|
|
return testUser(stored), nil
|
|
}
|
|
va, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse))
|
|
if err != nil {
|
|
t.Fatalf("FinishDiscoverableLogin: %v (a counter regression must still VERIFY, only flag CloneWarning)", err)
|
|
}
|
|
if !va.CloneWarning {
|
|
t.Fatal("va.CloneWarning = false, want true (presented counter at/below the stored counter is a clone signal)")
|
|
}
|
|
}
|
|
|
|
// TestEnrollmentRequestsResidentKey pins the ONLY server-side half of the from-zero enabler a
|
|
// unit test can prove: that enrollment ASKS the browser for a resident (discoverable) key, i.e.
|
|
// the creation options carry authenticatorSelection.residentKey = "preferred". Whether a real
|
|
// authenticator honors the request — actually persisting a resident key so it can later be
|
|
// asserted usernamelessly — is a device property no unit test can reach, which is exactly why
|
|
// the from-zero door is inert for a credential until its owner enrolls a NEW passkey against
|
|
// these options. "preferred" (not "required") is deliberate: an authenticator that cannot store
|
|
// a resident key still binds a working username-first passkey and falls back to email-OTP, so
|
|
// no one is locked out — asserting the exact string guards against a silent drop to "" (ask for
|
|
// nothing) or a tightening to "required" (which would break the no-lockout ethos).
|
|
func TestEnrollmentRequestsResidentKey(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
options, _, err := v.BeginRegistration(testUser())
|
|
if err != nil {
|
|
t.Fatalf("BeginRegistration: %v", err)
|
|
}
|
|
var doc struct {
|
|
PublicKey struct {
|
|
AuthenticatorSelection struct {
|
|
ResidentKey string `json:"residentKey"`
|
|
} `json:"authenticatorSelection"`
|
|
} `json:"publicKey"`
|
|
}
|
|
if err := json.Unmarshal(options, &doc); err != nil {
|
|
t.Fatalf("unmarshal creation options: %v (options=%s)", err, options)
|
|
}
|
|
if got := doc.PublicKey.AuthenticatorSelection.ResidentKey; got != "preferred" {
|
|
t.Errorf("authenticatorSelection.residentKey = %q, want %q", got, "preferred")
|
|
}
|
|
}
|
|
|
|
// TestSyncedPasskeyLogsIn covers a passkey kept in a cloud keychain (iCloud Keychain,
|
|
// Google Password Manager), which reports backup-eligible on every ceremony. go-webauthn
|
|
// refuses an assertion whose BE flag differs from the stored credential's, so the stored
|
|
// flags must reach it: dropped, every synced passkey would enroll and then never log in.
|
|
// The asserted UV flag must come back too, for the handler's per-credential check.
|
|
func TestSyncedPasskeyLogsIn(t *testing.T) {
|
|
for _, door := range []string{"username-first", "discoverable"} {
|
|
t.Run(door, func(t *testing.T) {
|
|
v := newTestVerifier(t)
|
|
rp := virtualRP()
|
|
authenticator := virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true})
|
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
|
stored := enrollCredential(t, v, rp, authenticator, cred)
|
|
if !stored.BackupEligible || !stored.BackupState {
|
|
t.Fatalf("enrollment recorded BE=%v BS=%v, want both true", stored.BackupEligible, stored.BackupState)
|
|
}
|
|
|
|
var va api.VerifiedAssertion
|
|
var err error
|
|
if door == "username-first" {
|
|
options, sessionData, berr := v.BeginLogin(testUser(stored))
|
|
if berr != nil {
|
|
t.Fatalf("BeginLogin: %v", berr)
|
|
}
|
|
opts, perr := virtualwebauthn.ParseAssertionOptions(string(options))
|
|
if perr != nil {
|
|
t.Fatalf("ParseAssertionOptions: %v", perr)
|
|
}
|
|
resp := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *opts)
|
|
va, err = v.FinishLogin(testUser(stored), sessionData, strings.NewReader(resp))
|
|
} else {
|
|
authenticator.Options.UserHandle = []byte(testUserID)
|
|
options, sessionData, berr := v.BeginDiscoverableLogin()
|
|
if berr != nil {
|
|
t.Fatalf("BeginDiscoverableLogin: %v", berr)
|
|
}
|
|
opts, perr := virtualwebauthn.ParseAssertionOptions(string(options))
|
|
if perr != nil {
|
|
t.Fatalf("ParseAssertionOptions: %v", perr)
|
|
}
|
|
resp := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *opts)
|
|
va, err = v.FinishDiscoverableLogin(func([]byte) (api.PasskeyUser, error) { return testUser(stored), nil },
|
|
sessionData, strings.NewReader(resp))
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("finish: %v (a synced passkey must log in)", err)
|
|
}
|
|
if !va.UserVerified {
|
|
t.Error("va.UserVerified = false, want true (the authenticator verified the user)")
|
|
}
|
|
})
|
|
}
|
|
}
|