Wrap github.com/go-webauthn/webauthn behind the api.PasskeyVerifier seam so the api package stays free of go-webauthn types. The adapter covers the credential-creation ceremony only (BeginRegistration / CreateCredential); the login/assertion path is a deferred slice. Ceremony state crosses the seam as opaque marshaled SessionData, the attestation as an io.Reader, and the verified result as a plain VerifiedCredential. SessionData carries no expiry so the challenge row's TTL stays the single liveness authority. New rejects an empty RP id or origin list so a misconfigured deployment fails at construction rather than minting unverifiable challenges. Tests drive a real relying party against a virtual authenticator (descope/virtualwebauthn): a full creation round-trip plus adversarial guards proving origin-mismatch and user-mismatch are rejected and already-bound credentials are excluded.
179 lines
7.2 KiB
Go
179 lines
7.2 KiB
Go
package passkey
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
found := false
|
|
for _, ex := range attestationOpts.ExcludeCredentials {
|
|
if ex == existingID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
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")
|
|
}
|
|
}
|