feat(webauthn): add the passkey verifier adapter over go-webauthn
- commit
- 3246333677a3c852fd33f6f1bb08c7a7952dacba
- author
- brice · · 2026-10-12 14:52 UTC
- parent
- dbbae13
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +666 -2
M go.mod +8 -0
| @@ -19,6 +19,8 @@ require ( | |||
| 19 | 19 | github.com/a-h/templ v0.3.1020 | |
| 20 | 20 | github.com/alecthomas/chroma/v2 v2.27.0 | |
| 21 | 21 | github.com/bnema/zerowrap v1.4.1 | |
| 22 | + | github.com/descope/virtualwebauthn v1.0.5 | |
| 23 | + | github.com/go-webauthn/webauthn v0.18.2 | |
| 22 | 24 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 23 | 25 | github.com/spf13/cobra v1.10.2 | |
| 24 | 26 | github.com/stretchr/testify v1.12.1 | |
| @@ -78,14 +80,17 @@ require ( | |||
| 78 | 80 | github.com/fatih/structs v1.1.0 // indirect | |
| 79 | 81 | github.com/felixge/httpsnoop v1.1.0 // indirect | |
| 80 | 82 | github.com/fsnotify/fsnotify v1.9.0 // indirect | |
| 83 | + | github.com/fxamacker/cbor/v2 v2.9.4 // indirect | |
| 81 | 84 | github.com/go-logr/logr v1.4.4 // indirect | |
| 82 | 85 | github.com/go-logr/stdr v1.2.2 // indirect | |
| 83 | 86 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | |
| 87 | + | github.com/go-webauthn/x v0.3.1 // indirect | |
| 84 | 88 | github.com/gobwas/glob v0.2.3 // indirect | |
| 85 | 89 | github.com/gofrs/flock v0.13.1 // indirect | |
| 86 | 90 | github.com/gohugoio/hashstructure v0.6.0 // indirect | |
| 87 | 91 | github.com/gohugoio/hugo v0.164.0 // indirect | |
| 88 | 92 | github.com/google/go-containerregistry v0.22.1 // indirect | |
| 93 | + | github.com/google/go-tpm v0.9.8 // indirect | |
| 89 | 94 | github.com/google/uuid v1.6.0 // indirect | |
| 90 | 95 | github.com/huandu/xstrings v1.5.0 // indirect | |
| 91 | 96 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| @@ -115,6 +120,7 @@ require ( | |||
| 115 | 120 | github.com/pelletier/go-toml v1.9.5 // indirect | |
| 116 | 121 | github.com/pelletier/go-toml/v2 v2.4.3 // indirect | |
| 117 | 122 | github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect | |
| 123 | + | github.com/philhofer/fwd v1.2.0 // indirect | |
| 118 | 124 | github.com/quic-go/qpack v0.6.0 // indirect | |
| 119 | 125 | github.com/quic-go/quic-go v0.62.0 // indirect | |
| 120 | 126 | github.com/rivo/uniseg v0.4.7 // indirect | |
| @@ -131,7 +137,9 @@ require ( | |||
| 131 | 137 | github.com/tdewolff/parse/v2 v2.8.12 // indirect | |
| 132 | 138 | github.com/tetratelabs/wazero v1.12.0 // indirect | |
| 133 | 139 | github.com/tidwall/btree v1.8.1 // indirect | |
| 140 | + | github.com/tinylib/msgp v1.6.4 // indirect | |
| 134 | 141 | github.com/vektra/mockery/v3 v3.8.0 // indirect | |
| 142 | + | github.com/x448/float16 v0.8.4 // indirect | |
| 135 | 143 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect | |
| 136 | 144 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect | |
| 137 | 145 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect | |
| @@ -19,6 +19,8 @@ require ( | |||||
| 19 | github.com/a-h/templ v0.3.1020 | 19 | github.com/a-h/templ v0.3.1020 | ||
| 20 | github.com/alecthomas/chroma/v2 v2.27.0 | 20 | github.com/alecthomas/chroma/v2 v2.27.0 | ||
| 21 | github.com/bnema/zerowrap v1.4.1 | 21 | github.com/bnema/zerowrap v1.4.1 | ||
| 22 | + | github.com/descope/virtualwebauthn v1.0.5 | |||
| 23 | + | github.com/go-webauthn/webauthn v0.18.2 | |||
| 22 | github.com/golang-jwt/jwt/v5 v5.3.1 | 24 | github.com/golang-jwt/jwt/v5 v5.3.1 | ||
| 23 | github.com/spf13/cobra v1.10.2 | 25 | github.com/spf13/cobra v1.10.2 | ||
| 24 | github.com/stretchr/testify v1.12.1 | 26 | github.com/stretchr/testify v1.12.1 | ||
| @@ -78,14 +80,17 @@ require ( | |||||
| 78 | github.com/fatih/structs v1.1.0 // indirect | 80 | github.com/fatih/structs v1.1.0 // indirect | ||
| 79 | github.com/felixge/httpsnoop v1.1.0 // indirect | 81 | github.com/felixge/httpsnoop v1.1.0 // indirect | ||
| 80 | github.com/fsnotify/fsnotify v1.9.0 // indirect | 82 | github.com/fsnotify/fsnotify v1.9.0 // indirect | ||
| 83 | + | github.com/fxamacker/cbor/v2 v2.9.4 // indirect | |||
| 81 | github.com/go-logr/logr v1.4.4 // indirect | 84 | github.com/go-logr/logr v1.4.4 // indirect | ||
| 82 | github.com/go-logr/stdr v1.2.2 // indirect | 85 | github.com/go-logr/stdr v1.2.2 // indirect | ||
| 83 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | 86 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | ||
| 87 | + | github.com/go-webauthn/x v0.3.1 // indirect | |||
| 84 | github.com/gobwas/glob v0.2.3 // indirect | 88 | github.com/gobwas/glob v0.2.3 // indirect | ||
| 85 | github.com/gofrs/flock v0.13.1 // indirect | 89 | github.com/gofrs/flock v0.13.1 // indirect | ||
| 86 | github.com/gohugoio/hashstructure v0.6.0 // indirect | 90 | github.com/gohugoio/hashstructure v0.6.0 // indirect | ||
| 87 | github.com/gohugoio/hugo v0.164.0 // indirect | 91 | github.com/gohugoio/hugo v0.164.0 // indirect | ||
| 88 | github.com/google/go-containerregistry v0.22.1 // indirect | 92 | github.com/google/go-containerregistry v0.22.1 // indirect | ||
| 93 | + | github.com/google/go-tpm v0.9.8 // indirect | |||
| 89 | github.com/google/uuid v1.6.0 // indirect | 94 | github.com/google/uuid v1.6.0 // indirect | ||
| 90 | github.com/huandu/xstrings v1.5.0 // indirect | 95 | github.com/huandu/xstrings v1.5.0 // indirect | ||
| 91 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 96 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| @@ -115,6 +120,7 @@ require ( | |||||
| 115 | github.com/pelletier/go-toml v1.9.5 // indirect | 120 | github.com/pelletier/go-toml v1.9.5 // indirect | ||
| 116 | github.com/pelletier/go-toml/v2 v2.4.3 // indirect | 121 | github.com/pelletier/go-toml/v2 v2.4.3 // indirect | ||
| 117 | github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect | 122 | github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect | ||
| 123 | + | github.com/philhofer/fwd v1.2.0 // indirect | |||
| 118 | github.com/quic-go/qpack v0.6.0 // indirect | 124 | github.com/quic-go/qpack v0.6.0 // indirect | ||
| 119 | github.com/quic-go/quic-go v0.62.0 // indirect | 125 | github.com/quic-go/quic-go v0.62.0 // indirect | ||
| 120 | github.com/rivo/uniseg v0.4.7 // indirect | 126 | github.com/rivo/uniseg v0.4.7 // indirect | ||
| @@ -131,7 +137,9 @@ require ( | |||||
| 131 | github.com/tdewolff/parse/v2 v2.8.12 // indirect | 137 | github.com/tdewolff/parse/v2 v2.8.12 // indirect | ||
| 132 | github.com/tetratelabs/wazero v1.12.0 // indirect | 138 | github.com/tetratelabs/wazero v1.12.0 // indirect | ||
| 133 | github.com/tidwall/btree v1.8.1 // indirect | 139 | github.com/tidwall/btree v1.8.1 // indirect | ||
| 140 | + | github.com/tinylib/msgp v1.6.4 // indirect | |||
| 134 | github.com/vektra/mockery/v3 v3.8.0 // indirect | 141 | github.com/vektra/mockery/v3 v3.8.0 // indirect | ||
| 142 | + | github.com/x448/float16 v0.8.4 // indirect | |||
| 135 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect | 143 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect | ||
| 136 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect | 144 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect | ||
| 137 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect | 145 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect | ||
M go.sum +20 -2 generated file: collapsed
A internal/adapters/webauthn/webauthn.go +216 -0
| @@ -0,0 +1,216 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package webauthn implements ports.PasskeyVerifier over go-webauthn (ADR | |
| 4 | + | // 0026). It is the only importer of the library: the use case sees plain | |
| 5 | + | // domain values and opaque ceremony bytes. | |
| 6 | + | package webauthn | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "encoding/json" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "net/url" | |
| 13 | + | "strings" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/go-webauthn/webauthn/protocol" | |
| 17 | + | gowebauthn "github.com/go-webauthn/webauthn/webauthn" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // ceremonyTimeout is how long the browser may take, enforced here too. The | |
| 24 | + | // use case keeps its own, equal TTL for the ceremony data. | |
| 25 | + | const ceremonyTimeout = 2 * time.Minute | |
| 26 | + | ||
| 27 | + | // ownerName names the one user in the options sent to the browser. | |
| 28 | + | const ownerName = "owner" | |
| 29 | + | ||
| 30 | + | // Verifier runs the WebAuthn ceremonies for the owner of one public URL. | |
| 31 | + | type Verifier struct { | |
| 32 | + | web *gowebauthn.WebAuthn | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | var _ ports.PasskeyVerifier = (*Verifier)(nil) | |
| 36 | + | ||
| 37 | + | // New builds a verifier for publicURL: the relying party ID is its host name, | |
| 38 | + | // the only accepted origin is its origin. It fails when the library refuses the | |
| 39 | + | // configuration, for example for a host that is an IP address. | |
| 40 | + | func New(publicURL string) (*Verifier, error) { | |
| 41 | + | u, err := url.Parse(publicURL) | |
| 42 | + | if err != nil { | |
| 43 | + | return nil, fmt.Errorf("passkeys: parse public URL: %w", err) | |
| 44 | + | } | |
| 45 | + | host := strings.ToLower(u.Hostname()) | |
| 46 | + | if (u.Scheme != "http" && u.Scheme != "https") || host == "" { | |
| 47 | + | return nil, fmt.Errorf("passkeys: public URL %q needs an http(s) scheme and a host", publicURL) | |
| 48 | + | } | |
| 49 | + | timeout := gowebauthn.TimeoutConfig{Enforce: true, Timeout: ceremonyTimeout, TimeoutUVD: ceremonyTimeout} | |
| 50 | + | web, err := gowebauthn.New(&gowebauthn.Config{ | |
| 51 | + | RPID: host, | |
| 52 | + | RPDisplayName: host, | |
| 53 | + | RPOrigins: []string{u.Scheme + "://" + strings.ToLower(u.Host)}, | |
| 54 | + | AuthenticatorSelection: protocol.AuthenticatorSelection{ | |
| 55 | + | ResidentKey: protocol.ResidentKeyRequirementRequired, | |
| 56 | + | RequireResidentKey: protocol.ResidentKeyRequired(), | |
| 57 | + | UserVerification: protocol.VerificationRequired, | |
| 58 | + | }, | |
| 59 | + | AttestationPreference: protocol.PreferNoAttestation, | |
| 60 | + | Timeouts: gowebauthn.TimeoutsConfig{Login: timeout, Registration: timeout}, | |
| 61 | + | }) | |
| 62 | + | if err != nil { | |
| 63 | + | return nil, fmt.Errorf("passkeys: %w", err) | |
| 64 | + | } | |
| 65 | + | return &Verifier{web: web}, nil | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // owner is the single WebAuthn user. | |
| 69 | + | type owner struct { | |
| 70 | + | handle []byte | |
| 71 | + | credentials []gowebauthn.Credential | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (o owner) WebAuthnID() []byte { return o.handle } | |
| 75 | + | func (o owner) WebAuthnName() string { return ownerName } | |
| 76 | + | func (o owner) WebAuthnDisplayName() string { return ownerName } | |
| 77 | + | func (o owner) WebAuthnCredentials() []gowebauthn.Credential { return o.credentials } | |
| 78 | + | ||
| 79 | + | // refuse builds the error of a failed verification: an ErrUnauthenticated whose | |
| 80 | + | // text carries the reason, for logs. The library error is flattened to text so | |
| 81 | + | // that its types never leave this package. | |
| 82 | + | func refuse(what string, err error) error { | |
| 83 | + | return fmt.Errorf("%w: passkey %s: %v", domain.ErrUnauthenticated, what, err) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // BeginRegistration implements ports.PasskeyVerifier. | |
| 87 | + | func (v *Verifier) BeginRegistration(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error) { | |
| 88 | + | exclude := make([]protocol.CredentialDescriptor, 0, len(existing)) | |
| 89 | + | for _, p := range existing { | |
| 90 | + | exclude = append(exclude, descriptor(p)) | |
| 91 | + | } | |
| 92 | + | creation, session, err := v.web.BeginRegistration(owner{handle: userHandle}, | |
| 93 | + | gowebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), | |
| 94 | + | gowebauthn.WithExclusions(exclude), | |
| 95 | + | gowebauthn.WithConveyancePreference(protocol.PreferNoAttestation), | |
| 96 | + | ) | |
| 97 | + | if err != nil { | |
| 98 | + | return nil, nil, fmt.Errorf("begin passkey registration: %w", err) | |
| 99 | + | } | |
| 100 | + | return encode(creation, session) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // FinishRegistration implements ports.PasskeyVerifier. | |
| 104 | + | func (v *Verifier) FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error) { | |
| 105 | + | var session gowebauthn.SessionData | |
| 106 | + | if err := json.Unmarshal(ceremony, &session); err != nil { | |
| 107 | + | return domain.Passkey{}, refuse("registration", fmt.Errorf("decode ceremony: %w", err)) | |
| 108 | + | } | |
| 109 | + | parsed, err := protocol.ParseCredentialCreationResponseBytes(response) | |
| 110 | + | if err != nil { | |
| 111 | + | return domain.Passkey{}, refuse("registration", err) | |
| 112 | + | } | |
| 113 | + | cred, err := v.web.CreateCredential(owner{handle: userHandle}, session, parsed) | |
| 114 | + | if err != nil { | |
| 115 | + | return domain.Passkey{}, refuse("registration", err) | |
| 116 | + | } | |
| 117 | + | if n := len(cred.ID); n == 0 || n > domain.MaxPasskeyIDLen { | |
| 118 | + | return domain.Passkey{}, refuse("registration", fmt.Errorf("credential id has %d bytes", n)) | |
| 119 | + | } | |
| 120 | + | transports := make([]string, 0, len(cred.Transport)) | |
| 121 | + | for _, t := range cred.Transport { | |
| 122 | + | transports = append(transports, string(t)) | |
| 123 | + | } | |
| 124 | + | return domain.Passkey{ | |
| 125 | + | ID: cred.ID, | |
| 126 | + | PublicKey: cred.PublicKey, | |
| 127 | + | AttestationType: cred.AttestationType, | |
| 128 | + | Transports: transports, | |
| 129 | + | SignCount: cred.Authenticator.SignCount, | |
| 130 | + | BackupEligible: cred.Flags.BackupEligible, | |
| 131 | + | BackupState: cred.Flags.BackupState, | |
| 132 | + | }, nil | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // BeginLogin implements ports.PasskeyVerifier. | |
| 136 | + | func (v *Verifier) BeginLogin() ([]byte, []byte, error) { | |
| 137 | + | assertion, session, err := v.web.BeginDiscoverableLogin() | |
| 138 | + | if err != nil { | |
| 139 | + | return nil, nil, fmt.Errorf("begin passkey login: %w", err) | |
| 140 | + | } | |
| 141 | + | return encode(assertion, session) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // FinishLogin implements ports.PasskeyVerifier. The library compares the user | |
| 145 | + | // handle of the response with userHandle. | |
| 146 | + | // | |
| 147 | + | //hexcheck:ignore suspicious-business-logic-in-adapter copies the counter and backup state the library verified into the stored passkey; the clone rule is the library's, the refusal is the port contract | |
| 148 | + | func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) { | |
| 149 | + | var session gowebauthn.SessionData | |
| 150 | + | if err := json.Unmarshal(ceremony, &session); err != nil { | |
| 151 | + | return domain.Passkey{}, refuse("login", fmt.Errorf("decode ceremony: %w", err)) | |
| 152 | + | } | |
| 153 | + | parsed, err := protocol.ParseCredentialRequestResponseBytes(response) | |
| 154 | + | if err != nil { | |
| 155 | + | return domain.Passkey{}, refuse("login", err) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | var stored domain.Passkey | |
| 159 | + | var lookupErr error // a failure of the store, as opposed to an unknown credential | |
| 160 | + | handler := func(rawID, _ []byte) (gowebauthn.User, error) { | |
| 161 | + | p, err := lookup(rawID) | |
| 162 | + | if err != nil { | |
| 163 | + | if !errors.Is(err, domain.ErrNotFound) { | |
| 164 | + | lookupErr = err | |
| 165 | + | } | |
| 166 | + | return nil, err | |
| 167 | + | } | |
| 168 | + | stored = p | |
| 169 | + | return owner{handle: userHandle, credentials: []gowebauthn.Credential{credential(p)}}, nil | |
| 170 | + | } | |
| 171 | + | _, cred, err := v.web.ValidatePasskeyLogin(handler, session, parsed) | |
| 172 | + | if lookupErr != nil { | |
| 173 | + | return domain.Passkey{}, lookupErr // fail closed, as it is | |
| 174 | + | } | |
| 175 | + | if err != nil { | |
| 176 | + | return domain.Passkey{}, refuse("login", err) | |
| 177 | + | } | |
| 178 | + | if cred.Authenticator.CloneWarning { | |
| 179 | + | return domain.Passkey{}, domain.ErrPasskeyCloned | |
| 180 | + | } | |
| 181 | + | stored.SignCount = cred.Authenticator.SignCount | |
| 182 | + | stored.BackupState = cred.Flags.BackupState | |
| 183 | + | return stored, nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // encode returns the JSON of the options for the browser and of the session. | |
| 187 | + | func encode(options any, session *gowebauthn.SessionData) (opts, ceremony []byte, err error) { | |
| 188 | + | if opts, err = json.Marshal(options); err != nil { | |
| 189 | + | return nil, nil, fmt.Errorf("encode passkey options: %w", err) | |
| 190 | + | } | |
| 191 | + | if ceremony, err = json.Marshal(session); err != nil { | |
| 192 | + | return nil, nil, fmt.Errorf("encode passkey ceremony: %w", err) | |
| 193 | + | } | |
| 194 | + | return opts, ceremony, nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // credential converts a stored passkey into the library's form. | |
| 198 | + | func credential(p domain.Passkey) gowebauthn.Credential { | |
| 199 | + | c := gowebauthn.Credential{ | |
| 200 | + | ID: p.ID, | |
| 201 | + | PublicKey: p.PublicKey, | |
| 202 | + | AttestationType: p.AttestationType, | |
| 203 | + | Flags: gowebauthn.CredentialFlags{BackupEligible: p.BackupEligible, BackupState: p.BackupState}, | |
| 204 | + | Authenticator: gowebauthn.Authenticator{SignCount: p.SignCount}, | |
| 205 | + | } | |
| 206 | + | for _, t := range p.Transports { | |
| 207 | + | c.Transport = append(c.Transport, protocol.AuthenticatorTransport(t)) | |
| 208 | + | } | |
| 209 | + | return c | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // descriptor names a stored passkey for the exclusion list. | |
| 213 | + | func descriptor(p domain.Passkey) protocol.CredentialDescriptor { | |
| 214 | + | c := credential(p) | |
| 215 | + | return c.Descriptor() | |
| 216 | + | } | |
| @@ -0,0 +1,216 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package webauthn implements ports.PasskeyVerifier over go-webauthn (ADR | |||
| 4 | + | // 0026). It is the only importer of the library: the use case sees plain | |||
| 5 | + | // domain values and opaque ceremony bytes. | |||
| 6 | + | package webauthn | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "encoding/json" | |||
| 10 | + | "errors" | |||
| 11 | + | "fmt" | |||
| 12 | + | "net/url" | |||
| 13 | + | "strings" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "github.com/go-webauthn/webauthn/protocol" | |||
| 17 | + | gowebauthn "github.com/go-webauthn/webauthn/webauthn" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // ceremonyTimeout is how long the browser may take, enforced here too. The | |||
| 24 | + | // use case keeps its own, equal TTL for the ceremony data. | |||
| 25 | + | const ceremonyTimeout = 2 * time.Minute | |||
| 26 | + | ||||
| 27 | + | // ownerName names the one user in the options sent to the browser. | |||
| 28 | + | const ownerName = "owner" | |||
| 29 | + | ||||
| 30 | + | // Verifier runs the WebAuthn ceremonies for the owner of one public URL. | |||
| 31 | + | type Verifier struct { | |||
| 32 | + | web *gowebauthn.WebAuthn | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | var _ ports.PasskeyVerifier = (*Verifier)(nil) | |||
| 36 | + | ||||
| 37 | + | // New builds a verifier for publicURL: the relying party ID is its host name, | |||
| 38 | + | // the only accepted origin is its origin. It fails when the library refuses the | |||
| 39 | + | // configuration, for example for a host that is an IP address. | |||
| 40 | + | func New(publicURL string) (*Verifier, error) { | |||
| 41 | + | u, err := url.Parse(publicURL) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return nil, fmt.Errorf("passkeys: parse public URL: %w", err) | |||
| 44 | + | } | |||
| 45 | + | host := strings.ToLower(u.Hostname()) | |||
| 46 | + | if (u.Scheme != "http" && u.Scheme != "https") || host == "" { | |||
| 47 | + | return nil, fmt.Errorf("passkeys: public URL %q needs an http(s) scheme and a host", publicURL) | |||
| 48 | + | } | |||
| 49 | + | timeout := gowebauthn.TimeoutConfig{Enforce: true, Timeout: ceremonyTimeout, TimeoutUVD: ceremonyTimeout} | |||
| 50 | + | web, err := gowebauthn.New(&gowebauthn.Config{ | |||
| 51 | + | RPID: host, | |||
| 52 | + | RPDisplayName: host, | |||
| 53 | + | RPOrigins: []string{u.Scheme + "://" + strings.ToLower(u.Host)}, | |||
| 54 | + | AuthenticatorSelection: protocol.AuthenticatorSelection{ | |||
| 55 | + | ResidentKey: protocol.ResidentKeyRequirementRequired, | |||
| 56 | + | RequireResidentKey: protocol.ResidentKeyRequired(), | |||
| 57 | + | UserVerification: protocol.VerificationRequired, | |||
| 58 | + | }, | |||
| 59 | + | AttestationPreference: protocol.PreferNoAttestation, | |||
| 60 | + | Timeouts: gowebauthn.TimeoutsConfig{Login: timeout, Registration: timeout}, | |||
| 61 | + | }) | |||
| 62 | + | if err != nil { | |||
| 63 | + | return nil, fmt.Errorf("passkeys: %w", err) | |||
| 64 | + | } | |||
| 65 | + | return &Verifier{web: web}, nil | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // owner is the single WebAuthn user. | |||
| 69 | + | type owner struct { | |||
| 70 | + | handle []byte | |||
| 71 | + | credentials []gowebauthn.Credential | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func (o owner) WebAuthnID() []byte { return o.handle } | |||
| 75 | + | func (o owner) WebAuthnName() string { return ownerName } | |||
| 76 | + | func (o owner) WebAuthnDisplayName() string { return ownerName } | |||
| 77 | + | func (o owner) WebAuthnCredentials() []gowebauthn.Credential { return o.credentials } | |||
| 78 | + | ||||
| 79 | + | // refuse builds the error of a failed verification: an ErrUnauthenticated whose | |||
| 80 | + | // text carries the reason, for logs. The library error is flattened to text so | |||
| 81 | + | // that its types never leave this package. | |||
| 82 | + | func refuse(what string, err error) error { | |||
| 83 | + | return fmt.Errorf("%w: passkey %s: %v", domain.ErrUnauthenticated, what, err) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // BeginRegistration implements ports.PasskeyVerifier. | |||
| 87 | + | func (v *Verifier) BeginRegistration(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error) { | |||
| 88 | + | exclude := make([]protocol.CredentialDescriptor, 0, len(existing)) | |||
| 89 | + | for _, p := range existing { | |||
| 90 | + | exclude = append(exclude, descriptor(p)) | |||
| 91 | + | } | |||
| 92 | + | creation, session, err := v.web.BeginRegistration(owner{handle: userHandle}, | |||
| 93 | + | gowebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), | |||
| 94 | + | gowebauthn.WithExclusions(exclude), | |||
| 95 | + | gowebauthn.WithConveyancePreference(protocol.PreferNoAttestation), | |||
| 96 | + | ) | |||
| 97 | + | if err != nil { | |||
| 98 | + | return nil, nil, fmt.Errorf("begin passkey registration: %w", err) | |||
| 99 | + | } | |||
| 100 | + | return encode(creation, session) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // FinishRegistration implements ports.PasskeyVerifier. | |||
| 104 | + | func (v *Verifier) FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error) { | |||
| 105 | + | var session gowebauthn.SessionData | |||
| 106 | + | if err := json.Unmarshal(ceremony, &session); err != nil { | |||
| 107 | + | return domain.Passkey{}, refuse("registration", fmt.Errorf("decode ceremony: %w", err)) | |||
| 108 | + | } | |||
| 109 | + | parsed, err := protocol.ParseCredentialCreationResponseBytes(response) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return domain.Passkey{}, refuse("registration", err) | |||
| 112 | + | } | |||
| 113 | + | cred, err := v.web.CreateCredential(owner{handle: userHandle}, session, parsed) | |||
| 114 | + | if err != nil { | |||
| 115 | + | return domain.Passkey{}, refuse("registration", err) | |||
| 116 | + | } | |||
| 117 | + | if n := len(cred.ID); n == 0 || n > domain.MaxPasskeyIDLen { | |||
| 118 | + | return domain.Passkey{}, refuse("registration", fmt.Errorf("credential id has %d bytes", n)) | |||
| 119 | + | } | |||
| 120 | + | transports := make([]string, 0, len(cred.Transport)) | |||
| 121 | + | for _, t := range cred.Transport { | |||
| 122 | + | transports = append(transports, string(t)) | |||
| 123 | + | } | |||
| 124 | + | return domain.Passkey{ | |||
| 125 | + | ID: cred.ID, | |||
| 126 | + | PublicKey: cred.PublicKey, | |||
| 127 | + | AttestationType: cred.AttestationType, | |||
| 128 | + | Transports: transports, | |||
| 129 | + | SignCount: cred.Authenticator.SignCount, | |||
| 130 | + | BackupEligible: cred.Flags.BackupEligible, | |||
| 131 | + | BackupState: cred.Flags.BackupState, | |||
| 132 | + | }, nil | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // BeginLogin implements ports.PasskeyVerifier. | |||
| 136 | + | func (v *Verifier) BeginLogin() ([]byte, []byte, error) { | |||
| 137 | + | assertion, session, err := v.web.BeginDiscoverableLogin() | |||
| 138 | + | if err != nil { | |||
| 139 | + | return nil, nil, fmt.Errorf("begin passkey login: %w", err) | |||
| 140 | + | } | |||
| 141 | + | return encode(assertion, session) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // FinishLogin implements ports.PasskeyVerifier. The library compares the user | |||
| 145 | + | // handle of the response with userHandle. | |||
| 146 | + | // | |||
| 147 | + | //hexcheck:ignore suspicious-business-logic-in-adapter copies the counter and backup state the library verified into the stored passkey; the clone rule is the library's, the refusal is the port contract | |||
| 148 | + | func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) { | |||
| 149 | + | var session gowebauthn.SessionData | |||
| 150 | + | if err := json.Unmarshal(ceremony, &session); err != nil { | |||
| 151 | + | return domain.Passkey{}, refuse("login", fmt.Errorf("decode ceremony: %w", err)) | |||
| 152 | + | } | |||
| 153 | + | parsed, err := protocol.ParseCredentialRequestResponseBytes(response) | |||
| 154 | + | if err != nil { | |||
| 155 | + | return domain.Passkey{}, refuse("login", err) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | var stored domain.Passkey | |||
| 159 | + | var lookupErr error // a failure of the store, as opposed to an unknown credential | |||
| 160 | + | handler := func(rawID, _ []byte) (gowebauthn.User, error) { | |||
| 161 | + | p, err := lookup(rawID) | |||
| 162 | + | if err != nil { | |||
| 163 | + | if !errors.Is(err, domain.ErrNotFound) { | |||
| 164 | + | lookupErr = err | |||
| 165 | + | } | |||
| 166 | + | return nil, err | |||
| 167 | + | } | |||
| 168 | + | stored = p | |||
| 169 | + | return owner{handle: userHandle, credentials: []gowebauthn.Credential{credential(p)}}, nil | |||
| 170 | + | } | |||
| 171 | + | _, cred, err := v.web.ValidatePasskeyLogin(handler, session, parsed) | |||
| 172 | + | if lookupErr != nil { | |||
| 173 | + | return domain.Passkey{}, lookupErr // fail closed, as it is | |||
| 174 | + | } | |||
| 175 | + | if err != nil { | |||
| 176 | + | return domain.Passkey{}, refuse("login", err) | |||
| 177 | + | } | |||
| 178 | + | if cred.Authenticator.CloneWarning { | |||
| 179 | + | return domain.Passkey{}, domain.ErrPasskeyCloned | |||
| 180 | + | } | |||
| 181 | + | stored.SignCount = cred.Authenticator.SignCount | |||
| 182 | + | stored.BackupState = cred.Flags.BackupState | |||
| 183 | + | return stored, nil | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // encode returns the JSON of the options for the browser and of the session. | |||
| 187 | + | func encode(options any, session *gowebauthn.SessionData) (opts, ceremony []byte, err error) { | |||
| 188 | + | if opts, err = json.Marshal(options); err != nil { | |||
| 189 | + | return nil, nil, fmt.Errorf("encode passkey options: %w", err) | |||
| 190 | + | } | |||
| 191 | + | if ceremony, err = json.Marshal(session); err != nil { | |||
| 192 | + | return nil, nil, fmt.Errorf("encode passkey ceremony: %w", err) | |||
| 193 | + | } | |||
| 194 | + | return opts, ceremony, nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // credential converts a stored passkey into the library's form. | |||
| 198 | + | func credential(p domain.Passkey) gowebauthn.Credential { | |||
| 199 | + | c := gowebauthn.Credential{ | |||
| 200 | + | ID: p.ID, | |||
| 201 | + | PublicKey: p.PublicKey, | |||
| 202 | + | AttestationType: p.AttestationType, | |||
| 203 | + | Flags: gowebauthn.CredentialFlags{BackupEligible: p.BackupEligible, BackupState: p.BackupState}, | |||
| 204 | + | Authenticator: gowebauthn.Authenticator{SignCount: p.SignCount}, | |||
| 205 | + | } | |||
| 206 | + | for _, t := range p.Transports { | |||
| 207 | + | c.Transport = append(c.Transport, protocol.AuthenticatorTransport(t)) | |||
| 208 | + | } | |||
| 209 | + | return c | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // descriptor names a stored passkey for the exclusion list. | |||
| 213 | + | func descriptor(p domain.Passkey) protocol.CredentialDescriptor { | |||
| 214 | + | c := credential(p) | |||
| 215 | + | return c.Descriptor() | |||
| 216 | + | } | |||
A internal/adapters/webauthn/webauthn_test.go +422 -0
| @@ -0,0 +1,422 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package webauthn_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/descope/virtualwebauthn" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/adapters/webauthn" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | var userHandle = []byte("0123456789abcdef0123456789abcdef") | |
| 20 | + | ||
| 21 | + | // site is one public URL and the relying party a browser would present for it. | |
| 22 | + | type site struct { | |
| 23 | + | url string | |
| 24 | + | rp virtualwebauthn.RelyingParty | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | var sites = map[string]site{ | |
| 28 | + | "localhost": {"http://localhost:7341", virtualwebauthn.RelyingParty{ID: "localhost", Name: "localhost", Origin: "http://localhost:7341"}}, | |
| 29 | + | "https": {"https://git.example.test", virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: "https://git.example.test"}}, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // device is a virtual authenticator with one credential and the passkey the | |
| 33 | + | // server stored for it. | |
| 34 | + | type device struct { | |
| 35 | + | auth virtualwebauthn.Authenticator | |
| 36 | + | cred virtualwebauthn.Credential | |
| 37 | + | store map[string]domain.Passkey // by credential id | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (d *device) lookup(id []byte) (domain.Passkey, error) { | |
| 41 | + | p, ok := d.store[string(id)] | |
| 42 | + | if !ok { | |
| 43 | + | return domain.Passkey{}, domain.ErrNotFound | |
| 44 | + | } | |
| 45 | + | return p, nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // register runs a registration ceremony against v with a new credential. | |
| 49 | + | func register(t *testing.T, v *webauthn.Verifier, s site, opts virtualwebauthn.AuthenticatorOptions) *device { | |
| 50 | + | t.Helper() | |
| 51 | + | opts.UserHandle = userHandle | |
| 52 | + | d := &device{ | |
| 53 | + | auth: virtualwebauthn.NewAuthenticatorWithOptions(opts), | |
| 54 | + | cred: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2), | |
| 55 | + | store: map[string]domain.Passkey{}, | |
| 56 | + | } | |
| 57 | + | options, ceremony, err := v.BeginRegistration(userHandle, nil) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | parsed, err := virtualwebauthn.ParseAttestationOptions(string(options)) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | response := virtualwebauthn.CreateAttestationResponse(s.rp, d.auth, d.cred, *parsed) | |
| 62 | + | ||
| 63 | + | p, err := v.FinishRegistration(userHandle, ceremony, []byte(response)) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | assert.Equal(t, d.cred.ID, p.ID) | |
| 66 | + | assert.NotEmpty(t, p.PublicKey) | |
| 67 | + | p.Name = "test" | |
| 68 | + | d.store[string(p.ID)] = p | |
| 69 | + | d.auth.AddCredential(d.cred) | |
| 70 | + | return d | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // assertion starts a login and returns the ceremony and the signed response. | |
| 74 | + | func (d *device) assertion(t *testing.T, v *webauthn.Verifier, s site) (ceremony []byte, options *virtualwebauthn.AssertionOptions) { | |
| 75 | + | t.Helper() | |
| 76 | + | raw, ceremony, err := v.BeginLogin() | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | options, err = virtualwebauthn.ParseAssertionOptions(string(raw)) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | return ceremony, options | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (d *device) respond(s site, options *virtualwebauthn.AssertionOptions) []byte { | |
| 84 | + | return []byte(virtualwebauthn.CreateAssertionResponse(s.rp, d.auth, d.cred, *options)) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestRegisterThenLogin(t *testing.T) { | |
| 88 | + | t.Parallel() | |
| 89 | + | for name, s := range sites { | |
| 90 | + | t.Run(name, func(t *testing.T) { | |
| 91 | + | t.Parallel() | |
| 92 | + | v, err := webauthn.New(s.url) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true}) | |
| 95 | + | d.cred.Counter = 5 | |
| 96 | + | ||
| 97 | + | ceremony, options := d.assertion(t, v, s) | |
| 98 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | assert.Equal(t, d.cred.ID, got.ID) | |
| 101 | + | assert.Equal(t, "test", got.Name, "the stored fields are kept") | |
| 102 | + | assert.Equal(t, uint32(5), got.SignCount, "the new sign count") | |
| 103 | + | assert.True(t, got.BackupEligible) | |
| 104 | + | assert.False(t, got.BackupState) | |
| 105 | + | assert.Equal(t, d.store[string(got.ID)].PublicKey, got.PublicKey) | |
| 106 | + | ||
| 107 | + | // The next login advances the counter again. | |
| 108 | + | d.store[string(got.ID)] = got | |
| 109 | + | d.cred.Counter = 6 | |
| 110 | + | ceremony, options = d.assertion(t, v, s) | |
| 111 | + | got, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | assert.Equal(t, uint32(6), got.SignCount) | |
| 114 | + | }) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestRegistrationReportsBackupFlags(t *testing.T) { | |
| 119 | + | t.Parallel() | |
| 120 | + | s := sites["https"] | |
| 121 | + | v, err := webauthn.New(s.url) | |
| 122 | + | require.NoError(t, err) | |
| 123 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true, | |
| 124 | + | Transports: []virtualwebauthn.Transport{virtualwebauthn.TransportInternal, virtualwebauthn.TransportHybrid}}) | |
| 125 | + | p := d.store[string(d.cred.ID)] | |
| 126 | + | assert.True(t, p.BackupEligible) | |
| 127 | + | assert.True(t, p.BackupState) | |
| 128 | + | assert.Equal(t, []string{"internal", "hybrid"}, p.Transports) | |
| 129 | + | ||
| 130 | + | // A login reports the backup state the authenticator has now. | |
| 131 | + | d.auth.Options.BackupState = false | |
| 132 | + | ceremony, options := d.assertion(t, v, s) | |
| 133 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 134 | + | require.NoError(t, err) | |
| 135 | + | assert.False(t, got.BackupState) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestOptions(t *testing.T) { | |
| 139 | + | t.Parallel() | |
| 140 | + | s := sites["https"] | |
| 141 | + | v, err := webauthn.New(s.url) | |
| 142 | + | require.NoError(t, err) | |
| 143 | + | ||
| 144 | + | existing := domain.Passkey{ID: []byte("already-registered"), PublicKey: []byte("k"), Transports: []string{"usb"}} | |
| 145 | + | raw, _, err := v.BeginRegistration(userHandle, []domain.Passkey{existing}) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | var creation struct { | |
| 148 | + | PublicKey struct { | |
| 149 | + | RP struct{ ID, Name string } | |
| 150 | + | User struct{ ID, Name string } | |
| 151 | + | Challenge string | |
| 152 | + | AuthenticatorSelection struct { | |
| 153 | + | ResidentKey string | |
| 154 | + | RequireResidentKey bool | |
| 155 | + | UserVerification string | |
| 156 | + | } | |
| 157 | + | Attestation string | |
| 158 | + | Timeout int | |
| 159 | + | ExcludeCredentials []struct{ ID string } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | require.NoError(t, json.Unmarshal(raw, &creation)) | |
| 163 | + | pk := creation.PublicKey | |
| 164 | + | assert.Equal(t, "git.example.test", pk.RP.ID) | |
| 165 | + | assert.Equal(t, "owner", pk.User.Name) | |
| 166 | + | assert.NotEmpty(t, pk.Challenge) | |
| 167 | + | assert.Equal(t, "required", pk.AuthenticatorSelection.ResidentKey) | |
| 168 | + | assert.True(t, pk.AuthenticatorSelection.RequireResidentKey) | |
| 169 | + | assert.Equal(t, "required", pk.AuthenticatorSelection.UserVerification) | |
| 170 | + | assert.Equal(t, "none", pk.Attestation) | |
| 171 | + | assert.Equal(t, 120000, pk.Timeout) | |
| 172 | + | require.Len(t, pk.ExcludeCredentials, 1) | |
| 173 | + | parsed, err := virtualwebauthn.ParseAttestationOptions(string(raw)) | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | require.Len(t, parsed.ExcludeCredentials, 1) | |
| 176 | + | ||
| 177 | + | raw, _, err = v.BeginLogin() | |
| 178 | + | require.NoError(t, err) | |
| 179 | + | var assertion struct { | |
| 180 | + | PublicKey struct { | |
| 181 | + | RPID string | |
| 182 | + | Challenge string | |
| 183 | + | UserVerification string | |
| 184 | + | Timeout int | |
| 185 | + | AllowCredentials []struct{ ID string } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | require.NoError(t, json.Unmarshal(raw, &assertion)) | |
| 189 | + | assert.Equal(t, "git.example.test", assertion.PublicKey.RPID) | |
| 190 | + | assert.NotEmpty(t, assertion.PublicKey.Challenge) | |
| 191 | + | assert.Equal(t, "required", assertion.PublicKey.UserVerification, "login inherits user verification") | |
| 192 | + | assert.Equal(t, 120000, assertion.PublicKey.Timeout) | |
| 193 | + | assert.Empty(t, assertion.PublicKey.AllowCredentials, "a discoverable login lists no credential") | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestLoginRefused(t *testing.T) { | |
| 197 | + | t.Parallel() | |
| 198 | + | s := sites["localhost"] | |
| 199 | + | v, err := webauthn.New(s.url) | |
| 200 | + | require.NoError(t, err) | |
| 201 | + | ||
| 202 | + | tests := []struct { | |
| 203 | + | name string | |
| 204 | + | // run returns the error of FinishLogin. | |
| 205 | + | run func(t *testing.T, d *device) error | |
| 206 | + | }{ | |
| 207 | + | {"wrong origin", func(t *testing.T, d *device) error { | |
| 208 | + | ceremony, options := d.assertion(t, v, s) | |
| 209 | + | other := s | |
| 210 | + | other.rp.Origin = "http://localhost:9999" | |
| 211 | + | return finish(v, ceremony, d.respond(other, options), d) | |
| 212 | + | }}, | |
| 213 | + | {"wrong RP ID", func(t *testing.T, d *device) error { | |
| 214 | + | ceremony, options := d.assertion(t, v, s) | |
| 215 | + | other := s | |
| 216 | + | other.rp.ID = "evil.example.test" | |
| 217 | + | return finish(v, ceremony, d.respond(other, options), d) | |
| 218 | + | }}, | |
| 219 | + | {"tampered challenge", func(t *testing.T, d *device) error { | |
| 220 | + | ceremony, options := d.assertion(t, v, s) | |
| 221 | + | options.Challenge[0] ^= 0xff | |
| 222 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 223 | + | }}, | |
| 224 | + | {"challenge of another ceremony", func(t *testing.T, d *device) error { | |
| 225 | + | ceremony, _ := d.assertion(t, v, s) | |
| 226 | + | _, other := d.assertion(t, v, s) | |
| 227 | + | return finish(v, ceremony, d.respond(s, other), d) | |
| 228 | + | }}, | |
| 229 | + | {"expired ceremony", func(t *testing.T, d *device) error { | |
| 230 | + | ceremony, options := d.assertion(t, v, s) | |
| 231 | + | return finish(v, setExpires(t, ceremony, time.Now().Add(-time.Second)), d.respond(s, options), d) | |
| 232 | + | }}, | |
| 233 | + | {"garbage ceremony", func(t *testing.T, d *device) error { | |
| 234 | + | _, options := d.assertion(t, v, s) | |
| 235 | + | return finish(v, []byte("not json"), d.respond(s, options), d) | |
| 236 | + | }}, | |
| 237 | + | {"garbage response", func(t *testing.T, d *device) error { | |
| 238 | + | ceremony, _ := d.assertion(t, v, s) | |
| 239 | + | return finish(v, ceremony, []byte("{}"), d) | |
| 240 | + | }}, | |
| 241 | + | {"user not verified", func(t *testing.T, d *device) error { | |
| 242 | + | d.auth.Options.UserNotVerified = true | |
| 243 | + | ceremony, options := d.assertion(t, v, s) | |
| 244 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 245 | + | }}, | |
| 246 | + | {"user not present", func(t *testing.T, d *device) error { | |
| 247 | + | d.auth.Options.UserNotPresent = true | |
| 248 | + | ceremony, options := d.assertion(t, v, s) | |
| 249 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 250 | + | }}, | |
| 251 | + | {"unknown credential", func(t *testing.T, d *device) error { | |
| 252 | + | ceremony, options := d.assertion(t, v, s) | |
| 253 | + | delete(d.store, string(d.cred.ID)) | |
| 254 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 255 | + | }}, | |
| 256 | + | {"other user handle", func(t *testing.T, d *device) error { | |
| 257 | + | d.auth.Options.UserHandle = []byte("a-handle-of-somebody-else-------") | |
| 258 | + | ceremony, options := d.assertion(t, v, s) | |
| 259 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 260 | + | }}, | |
| 261 | + | {"no user handle", func(t *testing.T, d *device) error { | |
| 262 | + | d.auth.Options.UserHandle = nil | |
| 263 | + | ceremony, options := d.assertion(t, v, s) | |
| 264 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 265 | + | }}, | |
| 266 | + | {"registration ceremony used for a login", func(t *testing.T, d *device) error { | |
| 267 | + | _, ceremony, err := v.BeginRegistration(userHandle, nil) | |
| 268 | + | require.NoError(t, err) | |
| 269 | + | _, options := d.assertion(t, v, s) | |
| 270 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 271 | + | }}, | |
| 272 | + | } | |
| 273 | + | for _, tc := range tests { | |
| 274 | + | t.Run(tc.name, func(t *testing.T) { | |
| 275 | + | t.Parallel() | |
| 276 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{}) | |
| 277 | + | err := tc.run(t, d) | |
| 278 | + | require.Error(t, err) | |
| 279 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 280 | + | assert.NotErrorIs(t, err, domain.ErrPasskeyCloned) | |
| 281 | + | }) | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func finish(v *webauthn.Verifier, ceremony, response []byte, d *device) error { | |
| 286 | + | _, err := v.FinishLogin(userHandle, ceremony, response, d.lookup) | |
| 287 | + | return err | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // setExpires rewrites the expiry of a ceremony the way a late finish would see | |
| 291 | + | // it, since the library checks it against the wall clock. | |
| 292 | + | func setExpires(t *testing.T, ceremony []byte, at time.Time) []byte { | |
| 293 | + | t.Helper() | |
| 294 | + | var m map[string]any | |
| 295 | + | require.NoError(t, json.Unmarshal(ceremony, &m)) | |
| 296 | + | require.Contains(t, m, "expires") | |
| 297 | + | m["expires"] = at | |
| 298 | + | out, err := json.Marshal(m) | |
| 299 | + | require.NoError(t, err) | |
| 300 | + | return out | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func TestLoginStoreFailureIsNotAnUnauthenticated(t *testing.T) { | |
| 304 | + | t.Parallel() | |
| 305 | + | s := sites["localhost"] | |
| 306 | + | v, err := webauthn.New(s.url) | |
| 307 | + | require.NoError(t, err) | |
| 308 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{}) | |
| 309 | + | ceremony, options := d.assertion(t, v, s) | |
| 310 | + | boom := errors.New("disk on fire") | |
| 311 | + | _, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), func([]byte) (domain.Passkey, error) { | |
| 312 | + | return domain.Passkey{}, boom | |
| 313 | + | }) | |
| 314 | + | require.ErrorIs(t, err, boom) | |
| 315 | + | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func TestClonedAuthenticator(t *testing.T) { | |
| 319 | + | t.Parallel() | |
| 320 | + | s := sites["localhost"] | |
| 321 | + | v, err := webauthn.New(s.url) | |
| 322 | + | require.NoError(t, err) | |
| 323 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{}) | |
| 324 | + | ||
| 325 | + | d.cred.Counter = 1 | |
| 326 | + | ceremony, options := d.assertion(t, v, s) | |
| 327 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 328 | + | require.NoError(t, err) | |
| 329 | + | assert.Equal(t, uint32(1), got.SignCount) | |
| 330 | + | d.store[string(got.ID)] = got | |
| 331 | + | ||
| 332 | + | // The counter did not advance: a copy of the key is in use. | |
| 333 | + | ceremony, options = d.assertion(t, v, s) | |
| 334 | + | _, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 335 | + | require.ErrorIs(t, err, domain.ErrPasskeyCloned) | |
| 336 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | // Synced passkeys always report 0: that is not a clone. | |
| 340 | + | func TestZeroCounterIsNotAClone(t *testing.T) { | |
| 341 | + | t.Parallel() | |
| 342 | + | s := sites["localhost"] | |
| 343 | + | v, err := webauthn.New(s.url) | |
| 344 | + | require.NoError(t, err) | |
| 345 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true}) | |
| 346 | + | for range 2 { | |
| 347 | + | ceremony, options := d.assertion(t, v, s) | |
| 348 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 349 | + | require.NoError(t, err) | |
| 350 | + | assert.Zero(t, got.SignCount) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func TestRegistrationRefused(t *testing.T) { | |
| 355 | + | t.Parallel() | |
| 356 | + | s := sites["https"] | |
| 357 | + | v, err := webauthn.New(s.url) | |
| 358 | + | require.NoError(t, err) | |
| 359 | + | ||
| 360 | + | tests := []struct { | |
| 361 | + | name string | |
| 362 | + | auth virtualwebauthn.AuthenticatorOptions | |
| 363 | + | rp func(rp *virtualwebauthn.RelyingParty) | |
| 364 | + | // ceremony replaces the ceremony data when set. | |
| 365 | + | ceremony func(t *testing.T, ceremony []byte) []byte | |
| 366 | + | handle []byte | |
| 367 | + | }{ | |
| 368 | + | {name: "wrong origin", rp: func(rp *virtualwebauthn.RelyingParty) { rp.Origin = "https://evil.example.test" }}, | |
| 369 | + | {name: "wrong RP ID", rp: func(rp *virtualwebauthn.RelyingParty) { rp.ID = "evil.example.test" }}, | |
| 370 | + | {name: "user not verified", auth: virtualwebauthn.AuthenticatorOptions{UserNotVerified: true}}, | |
| 371 | + | {name: "expired ceremony", ceremony: func(t *testing.T, c []byte) []byte { return setExpires(t, c, time.Now().Add(-time.Second)) }}, | |
| 372 | + | {name: "other user", handle: []byte("somebody-else-------------------")}, | |
| 373 | + | } | |
| 374 | + | for _, tc := range tests { | |
| 375 | + | t.Run(tc.name, func(t *testing.T) { | |
| 376 | + | t.Parallel() | |
| 377 | + | rp := s.rp | |
| 378 | + | if tc.rp != nil { | |
| 379 | + | tc.rp(&rp) | |
| 380 | + | } | |
| 381 | + | auth := virtualwebauthn.NewAuthenticatorWithOptions(tc.auth) | |
| 382 | + | cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) | |
| 383 | + | options, ceremony, err := v.BeginRegistration(userHandle, nil) | |
| 384 | + | require.NoError(t, err) | |
| 385 | + | parsed, err := virtualwebauthn.ParseAttestationOptions(string(options)) | |
| 386 | + | require.NoError(t, err) | |
| 387 | + | response := virtualwebauthn.CreateAttestationResponse(rp, auth, cred, *parsed) | |
| 388 | + | if tc.ceremony != nil { | |
| 389 | + | ceremony = tc.ceremony(t, ceremony) | |
| 390 | + | } | |
| 391 | + | handle := userHandle | |
| 392 | + | if tc.handle != nil { | |
| 393 | + | handle = tc.handle | |
| 394 | + | } | |
| 395 | + | _, err = v.FinishRegistration(handle, ceremony, []byte(response)) | |
| 396 | + | require.Error(t, err) | |
| 397 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 398 | + | }) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | t.Run("garbage", func(t *testing.T) { | |
| 402 | + | t.Parallel() | |
| 403 | + | _, ceremony, err := v.BeginRegistration(userHandle, nil) | |
| 404 | + | require.NoError(t, err) | |
| 405 | + | _, err = v.FinishRegistration(userHandle, ceremony, []byte("{")) | |
| 406 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 407 | + | _, err = v.FinishRegistration(userHandle, []byte("{"), []byte("{}")) | |
| 408 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 409 | + | }) | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func TestNew(t *testing.T) { | |
| 413 | + | t.Parallel() | |
| 414 | + | for _, ok := range []string{"https://git.example.com", "http://localhost:7341", "https://Git.Example.com:8443/"} { | |
| 415 | + | _, err := webauthn.New(ok) | |
| 416 | + | assert.NoError(t, err, ok) | |
| 417 | + | } | |
| 418 | + | for _, bad := range []string{"http://127.0.0.1:7341", "http://[::1]:7341", "git.example.com", "ftp://git.example.com", "", "https://"} { | |
| 419 | + | _, err := webauthn.New(bad) | |
| 420 | + | assert.Error(t, err, bad) | |
| 421 | + | } | |
| 422 | + | } | |
| @@ -0,0 +1,422 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package webauthn_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/descope/virtualwebauthn" | |||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/adapters/webauthn" | |||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | var userHandle = []byte("0123456789abcdef0123456789abcdef") | |||
| 20 | + | ||||
| 21 | + | // site is one public URL and the relying party a browser would present for it. | |||
| 22 | + | type site struct { | |||
| 23 | + | url string | |||
| 24 | + | rp virtualwebauthn.RelyingParty | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | var sites = map[string]site{ | |||
| 28 | + | "localhost": {"http://localhost:7341", virtualwebauthn.RelyingParty{ID: "localhost", Name: "localhost", Origin: "http://localhost:7341"}}, | |||
| 29 | + | "https": {"https://git.example.test", virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: "https://git.example.test"}}, | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // device is a virtual authenticator with one credential and the passkey the | |||
| 33 | + | // server stored for it. | |||
| 34 | + | type device struct { | |||
| 35 | + | auth virtualwebauthn.Authenticator | |||
| 36 | + | cred virtualwebauthn.Credential | |||
| 37 | + | store map[string]domain.Passkey // by credential id | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (d *device) lookup(id []byte) (domain.Passkey, error) { | |||
| 41 | + | p, ok := d.store[string(id)] | |||
| 42 | + | if !ok { | |||
| 43 | + | return domain.Passkey{}, domain.ErrNotFound | |||
| 44 | + | } | |||
| 45 | + | return p, nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // register runs a registration ceremony against v with a new credential. | |||
| 49 | + | func register(t *testing.T, v *webauthn.Verifier, s site, opts virtualwebauthn.AuthenticatorOptions) *device { | |||
| 50 | + | t.Helper() | |||
| 51 | + | opts.UserHandle = userHandle | |||
| 52 | + | d := &device{ | |||
| 53 | + | auth: virtualwebauthn.NewAuthenticatorWithOptions(opts), | |||
| 54 | + | cred: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2), | |||
| 55 | + | store: map[string]domain.Passkey{}, | |||
| 56 | + | } | |||
| 57 | + | options, ceremony, err := v.BeginRegistration(userHandle, nil) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | parsed, err := virtualwebauthn.ParseAttestationOptions(string(options)) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | response := virtualwebauthn.CreateAttestationResponse(s.rp, d.auth, d.cred, *parsed) | |||
| 62 | + | ||||
| 63 | + | p, err := v.FinishRegistration(userHandle, ceremony, []byte(response)) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | assert.Equal(t, d.cred.ID, p.ID) | |||
| 66 | + | assert.NotEmpty(t, p.PublicKey) | |||
| 67 | + | p.Name = "test" | |||
| 68 | + | d.store[string(p.ID)] = p | |||
| 69 | + | d.auth.AddCredential(d.cred) | |||
| 70 | + | return d | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // assertion starts a login and returns the ceremony and the signed response. | |||
| 74 | + | func (d *device) assertion(t *testing.T, v *webauthn.Verifier, s site) (ceremony []byte, options *virtualwebauthn.AssertionOptions) { | |||
| 75 | + | t.Helper() | |||
| 76 | + | raw, ceremony, err := v.BeginLogin() | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | options, err = virtualwebauthn.ParseAssertionOptions(string(raw)) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | return ceremony, options | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (d *device) respond(s site, options *virtualwebauthn.AssertionOptions) []byte { | |||
| 84 | + | return []byte(virtualwebauthn.CreateAssertionResponse(s.rp, d.auth, d.cred, *options)) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestRegisterThenLogin(t *testing.T) { | |||
| 88 | + | t.Parallel() | |||
| 89 | + | for name, s := range sites { | |||
| 90 | + | t.Run(name, func(t *testing.T) { | |||
| 91 | + | t.Parallel() | |||
| 92 | + | v, err := webauthn.New(s.url) | |||
| 93 | + | require.NoError(t, err) | |||
| 94 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true}) | |||
| 95 | + | d.cred.Counter = 5 | |||
| 96 | + | ||||
| 97 | + | ceremony, options := d.assertion(t, v, s) | |||
| 98 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | assert.Equal(t, d.cred.ID, got.ID) | |||
| 101 | + | assert.Equal(t, "test", got.Name, "the stored fields are kept") | |||
| 102 | + | assert.Equal(t, uint32(5), got.SignCount, "the new sign count") | |||
| 103 | + | assert.True(t, got.BackupEligible) | |||
| 104 | + | assert.False(t, got.BackupState) | |||
| 105 | + | assert.Equal(t, d.store[string(got.ID)].PublicKey, got.PublicKey) | |||
| 106 | + | ||||
| 107 | + | // The next login advances the counter again. | |||
| 108 | + | d.store[string(got.ID)] = got | |||
| 109 | + | d.cred.Counter = 6 | |||
| 110 | + | ceremony, options = d.assertion(t, v, s) | |||
| 111 | + | got, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | assert.Equal(t, uint32(6), got.SignCount) | |||
| 114 | + | }) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestRegistrationReportsBackupFlags(t *testing.T) { | |||
| 119 | + | t.Parallel() | |||
| 120 | + | s := sites["https"] | |||
| 121 | + | v, err := webauthn.New(s.url) | |||
| 122 | + | require.NoError(t, err) | |||
| 123 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true, | |||
| 124 | + | Transports: []virtualwebauthn.Transport{virtualwebauthn.TransportInternal, virtualwebauthn.TransportHybrid}}) | |||
| 125 | + | p := d.store[string(d.cred.ID)] | |||
| 126 | + | assert.True(t, p.BackupEligible) | |||
| 127 | + | assert.True(t, p.BackupState) | |||
| 128 | + | assert.Equal(t, []string{"internal", "hybrid"}, p.Transports) | |||
| 129 | + | ||||
| 130 | + | // A login reports the backup state the authenticator has now. | |||
| 131 | + | d.auth.Options.BackupState = false | |||
| 132 | + | ceremony, options := d.assertion(t, v, s) | |||
| 133 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |||
| 134 | + | require.NoError(t, err) | |||
| 135 | + | assert.False(t, got.BackupState) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestOptions(t *testing.T) { | |||
| 139 | + | t.Parallel() | |||
| 140 | + | s := sites["https"] | |||
| 141 | + | v, err := webauthn.New(s.url) | |||
| 142 | + | require.NoError(t, err) | |||
| 143 | + | ||||
| 144 | + | existing := domain.Passkey{ID: []byte("already-registered"), PublicKey: []byte("k"), Transports: []string{"usb"}} | |||
| 145 | + | raw, _, err := v.BeginRegistration(userHandle, []domain.Passkey{existing}) | |||
| 146 | + | require.NoError(t, err) | |||
| 147 | + | var creation struct { | |||
| 148 | + | PublicKey struct { | |||
| 149 | + | RP struct{ ID, Name string } | |||
| 150 | + | User struct{ ID, Name string } | |||
| 151 | + | Challenge string | |||
| 152 | + | AuthenticatorSelection struct { | |||
| 153 | + | ResidentKey string | |||
| 154 | + | RequireResidentKey bool | |||
| 155 | + | UserVerification string | |||
| 156 | + | } | |||
| 157 | + | Attestation string | |||
| 158 | + | Timeout int | |||
| 159 | + | ExcludeCredentials []struct{ ID string } | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | require.NoError(t, json.Unmarshal(raw, &creation)) | |||
| 163 | + | pk := creation.PublicKey | |||
| 164 | + | assert.Equal(t, "git.example.test", pk.RP.ID) | |||
| 165 | + | assert.Equal(t, "owner", pk.User.Name) | |||
| 166 | + | assert.NotEmpty(t, pk.Challenge) | |||
| 167 | + | assert.Equal(t, "required", pk.AuthenticatorSelection.ResidentKey) | |||
| 168 | + | assert.True(t, pk.AuthenticatorSelection.RequireResidentKey) | |||
| 169 | + | assert.Equal(t, "required", pk.AuthenticatorSelection.UserVerification) | |||
| 170 | + | assert.Equal(t, "none", pk.Attestation) | |||
| 171 | + | assert.Equal(t, 120000, pk.Timeout) | |||
| 172 | + | require.Len(t, pk.ExcludeCredentials, 1) | |||
| 173 | + | parsed, err := virtualwebauthn.ParseAttestationOptions(string(raw)) | |||
| 174 | + | require.NoError(t, err) | |||
| 175 | + | require.Len(t, parsed.ExcludeCredentials, 1) | |||
| 176 | + | ||||
| 177 | + | raw, _, err = v.BeginLogin() | |||
| 178 | + | require.NoError(t, err) | |||
| 179 | + | var assertion struct { | |||
| 180 | + | PublicKey struct { | |||
| 181 | + | RPID string | |||
| 182 | + | Challenge string | |||
| 183 | + | UserVerification string | |||
| 184 | + | Timeout int | |||
| 185 | + | AllowCredentials []struct{ ID string } | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | require.NoError(t, json.Unmarshal(raw, &assertion)) | |||
| 189 | + | assert.Equal(t, "git.example.test", assertion.PublicKey.RPID) | |||
| 190 | + | assert.NotEmpty(t, assertion.PublicKey.Challenge) | |||
| 191 | + | assert.Equal(t, "required", assertion.PublicKey.UserVerification, "login inherits user verification") | |||
| 192 | + | assert.Equal(t, 120000, assertion.PublicKey.Timeout) | |||
| 193 | + | assert.Empty(t, assertion.PublicKey.AllowCredentials, "a discoverable login lists no credential") | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func TestLoginRefused(t *testing.T) { | |||
| 197 | + | t.Parallel() | |||
| 198 | + | s := sites["localhost"] | |||
| 199 | + | v, err := webauthn.New(s.url) | |||
| 200 | + | require.NoError(t, err) | |||
| 201 | + | ||||
| 202 | + | tests := []struct { | |||
| 203 | + | name string | |||
| 204 | + | // run returns the error of FinishLogin. | |||
| 205 | + | run func(t *testing.T, d *device) error | |||
| 206 | + | }{ | |||
| 207 | + | {"wrong origin", func(t *testing.T, d *device) error { | |||
| 208 | + | ceremony, options := d.assertion(t, v, s) | |||
| 209 | + | other := s | |||
| 210 | + | other.rp.Origin = "http://localhost:9999" | |||
| 211 | + | return finish(v, ceremony, d.respond(other, options), d) | |||
| 212 | + | }}, | |||
| 213 | + | {"wrong RP ID", func(t *testing.T, d *device) error { | |||
| 214 | + | ceremony, options := d.assertion(t, v, s) | |||
| 215 | + | other := s | |||
| 216 | + | other.rp.ID = "evil.example.test" | |||
| 217 | + | return finish(v, ceremony, d.respond(other, options), d) | |||
| 218 | + | }}, | |||
| 219 | + | {"tampered challenge", func(t *testing.T, d *device) error { | |||
| 220 | + | ceremony, options := d.assertion(t, v, s) | |||
| 221 | + | options.Challenge[0] ^= 0xff | |||
| 222 | + | return finish(v, ceremony, d.respond(s, options), d) | |||
| 223 | + | }}, | |||
| 224 | + | {"challenge of another ceremony", func(t *testing.T, d *device) error { | |||
| 225 | + | ceremony, _ := d.assertion(t, v, s) | |||
| 226 | + | _, other := d.assertion(t, v, s) | |||
| 227 | + | return finish(v, ceremony, d.respond(s, other), d) | |||
| 228 | + | }}, | |||
| 229 | + | {"expired ceremony", func(t *testing.T, d *device) error { | |||
| 230 | + | ceremony, options := d.assertion(t, v, s) | |||
| 231 | + | return finish(v, setExpires(t, ceremony, time.Now().Add(-time.Second)), d.respond(s, options), d) | |||
| 232 | + | }}, | |||
| 233 | + | {"garbage ceremony", func(t *testing.T, d *device) error { | |||
| 234 | + | _, options := d.assertion(t, v, s) | |||
| 235 | + | return finish(v, []byte("not json"), d.respond(s, options), d) | |||
| 236 | + | }}, | |||
| 237 | + | {"garbage response", func(t *testing.T, d *device) error { | |||
| 238 | + | ceremony, _ := d.assertion(t, v, s) | |||
| 239 | + | return finish(v, ceremony, []byte("{}"), d) | |||
| 240 | + | }}, | |||
| 241 | + | {"user not verified", func(t *testing.T, d *device) error { | |||
| 242 | + | d.auth.Options.UserNotVerified = true | |||
| 243 | + | ceremony, options := d.assertion(t, v, s) | |||
| 244 | + | return finish(v, ceremony, d.respond(s, options), d) | |||
| 245 | + | }}, | |||
| 246 | + | {"user not present", func(t *testing.T, d *device) error { | |||
| 247 | + | d.auth.Options.UserNotPresent = true | |||
| 248 | + | ceremony, options := d.assertion(t, v, s) | |||
| 249 | + | return finish(v, ceremony, d.respond(s, options), d) | |||
| 250 | + | }}, | |||
| 251 | + | {"unknown credential", func(t *testing.T, d *device) error { | |||
| 252 | + | ceremony, options := d.assertion(t, v, s) | |||
| 253 | + | delete(d.store, string(d.cred.ID)) | |||
| 254 | + | return finish(v, ceremony, d.respond(s, options), d) | |||
| 255 | + | }}, | |||
| 256 | + | {"other user handle", func(t *testing.T, d *device) error { | |||
| 257 | + | d.auth.Options.UserHandle = []byte("a-handle-of-somebody-else-------") | |||
| 258 | + | ceremony, options := d.assertion(t, v, s) | |||
| 259 | + | return finish(v, ceremony, d.respond(s, options), d) | |||
| 260 | + | }}, | |||
| 261 | + | {"no user handle", func(t *testing.T, d *device) error { | |||
| 262 | + | d.auth.Options.UserHandle = nil | |||
| 263 | + | ceremony, options := d.assertion(t, v, s) | |||
| 264 | + | return finish(v, ceremony, d.respond(s, options), d) | |||
| 265 | + | }}, | |||
| 266 | + | {"registration ceremony used for a login", func(t *testing.T, d *device) error { | |||
| 267 | + | _, ceremony, err := v.BeginRegistration(userHandle, nil) | |||
| 268 | + | require.NoError(t, err) | |||
| 269 | + | _, options := d.assertion(t, v, s) | |||
| 270 | + | return finish(v, ceremony, d.respond(s, options), d) | |||
| 271 | + | }}, | |||
| 272 | + | } | |||
| 273 | + | for _, tc := range tests { | |||
| 274 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 275 | + | t.Parallel() | |||
| 276 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{}) | |||
| 277 | + | err := tc.run(t, d) | |||
| 278 | + | require.Error(t, err) | |||
| 279 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 280 | + | assert.NotErrorIs(t, err, domain.ErrPasskeyCloned) | |||
| 281 | + | }) | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func finish(v *webauthn.Verifier, ceremony, response []byte, d *device) error { | |||
| 286 | + | _, err := v.FinishLogin(userHandle, ceremony, response, d.lookup) | |||
| 287 | + | return err | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // setExpires rewrites the expiry of a ceremony the way a late finish would see | |||
| 291 | + | // it, since the library checks it against the wall clock. | |||
| 292 | + | func setExpires(t *testing.T, ceremony []byte, at time.Time) []byte { | |||
| 293 | + | t.Helper() | |||
| 294 | + | var m map[string]any | |||
| 295 | + | require.NoError(t, json.Unmarshal(ceremony, &m)) | |||
| 296 | + | require.Contains(t, m, "expires") | |||
| 297 | + | m["expires"] = at | |||
| 298 | + | out, err := json.Marshal(m) | |||
| 299 | + | require.NoError(t, err) | |||
| 300 | + | return out | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func TestLoginStoreFailureIsNotAnUnauthenticated(t *testing.T) { | |||
| 304 | + | t.Parallel() | |||
| 305 | + | s := sites["localhost"] | |||
| 306 | + | v, err := webauthn.New(s.url) | |||
| 307 | + | require.NoError(t, err) | |||
| 308 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{}) | |||
| 309 | + | ceremony, options := d.assertion(t, v, s) | |||
| 310 | + | boom := errors.New("disk on fire") | |||
| 311 | + | _, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), func([]byte) (domain.Passkey, error) { | |||
| 312 | + | return domain.Passkey{}, boom | |||
| 313 | + | }) | |||
| 314 | + | require.ErrorIs(t, err, boom) | |||
| 315 | + | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func TestClonedAuthenticator(t *testing.T) { | |||
| 319 | + | t.Parallel() | |||
| 320 | + | s := sites["localhost"] | |||
| 321 | + | v, err := webauthn.New(s.url) | |||
| 322 | + | require.NoError(t, err) | |||
| 323 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{}) | |||
| 324 | + | ||||
| 325 | + | d.cred.Counter = 1 | |||
| 326 | + | ceremony, options := d.assertion(t, v, s) | |||
| 327 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |||
| 328 | + | require.NoError(t, err) | |||
| 329 | + | assert.Equal(t, uint32(1), got.SignCount) | |||
| 330 | + | d.store[string(got.ID)] = got | |||
| 331 | + | ||||
| 332 | + | // The counter did not advance: a copy of the key is in use. | |||
| 333 | + | ceremony, options = d.assertion(t, v, s) | |||
| 334 | + | _, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |||
| 335 | + | require.ErrorIs(t, err, domain.ErrPasskeyCloned) | |||
| 336 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | // Synced passkeys always report 0: that is not a clone. | |||
| 340 | + | func TestZeroCounterIsNotAClone(t *testing.T) { | |||
| 341 | + | t.Parallel() | |||
| 342 | + | s := sites["localhost"] | |||
| 343 | + | v, err := webauthn.New(s.url) | |||
| 344 | + | require.NoError(t, err) | |||
| 345 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true}) | |||
| 346 | + | for range 2 { | |||
| 347 | + | ceremony, options := d.assertion(t, v, s) | |||
| 348 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |||
| 349 | + | require.NoError(t, err) | |||
| 350 | + | assert.Zero(t, got.SignCount) | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func TestRegistrationRefused(t *testing.T) { | |||
| 355 | + | t.Parallel() | |||
| 356 | + | s := sites["https"] | |||
| 357 | + | v, err := webauthn.New(s.url) | |||
| 358 | + | require.NoError(t, err) | |||
| 359 | + | ||||
| 360 | + | tests := []struct { | |||
| 361 | + | name string | |||
| 362 | + | auth virtualwebauthn.AuthenticatorOptions | |||
| 363 | + | rp func(rp *virtualwebauthn.RelyingParty) | |||
| 364 | + | // ceremony replaces the ceremony data when set. | |||
| 365 | + | ceremony func(t *testing.T, ceremony []byte) []byte | |||
| 366 | + | handle []byte | |||
| 367 | + | }{ | |||
| 368 | + | {name: "wrong origin", rp: func(rp *virtualwebauthn.RelyingParty) { rp.Origin = "https://evil.example.test" }}, | |||
| 369 | + | {name: "wrong RP ID", rp: func(rp *virtualwebauthn.RelyingParty) { rp.ID = "evil.example.test" }}, | |||
| 370 | + | {name: "user not verified", auth: virtualwebauthn.AuthenticatorOptions{UserNotVerified: true}}, | |||
| 371 | + | {name: "expired ceremony", ceremony: func(t *testing.T, c []byte) []byte { return setExpires(t, c, time.Now().Add(-time.Second)) }}, | |||
| 372 | + | {name: "other user", handle: []byte("somebody-else-------------------")}, | |||
| 373 | + | } | |||
| 374 | + | for _, tc := range tests { | |||
| 375 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 376 | + | t.Parallel() | |||
| 377 | + | rp := s.rp | |||
| 378 | + | if tc.rp != nil { | |||
| 379 | + | tc.rp(&rp) | |||
| 380 | + | } | |||
| 381 | + | auth := virtualwebauthn.NewAuthenticatorWithOptions(tc.auth) | |||
| 382 | + | cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) | |||
| 383 | + | options, ceremony, err := v.BeginRegistration(userHandle, nil) | |||
| 384 | + | require.NoError(t, err) | |||
| 385 | + | parsed, err := virtualwebauthn.ParseAttestationOptions(string(options)) | |||
| 386 | + | require.NoError(t, err) | |||
| 387 | + | response := virtualwebauthn.CreateAttestationResponse(rp, auth, cred, *parsed) | |||
| 388 | + | if tc.ceremony != nil { | |||
| 389 | + | ceremony = tc.ceremony(t, ceremony) | |||
| 390 | + | } | |||
| 391 | + | handle := userHandle | |||
| 392 | + | if tc.handle != nil { | |||
| 393 | + | handle = tc.handle | |||
| 394 | + | } | |||
| 395 | + | _, err = v.FinishRegistration(handle, ceremony, []byte(response)) | |||
| 396 | + | require.Error(t, err) | |||
| 397 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 398 | + | }) | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | t.Run("garbage", func(t *testing.T) { | |||
| 402 | + | t.Parallel() | |||
| 403 | + | _, ceremony, err := v.BeginRegistration(userHandle, nil) | |||
| 404 | + | require.NoError(t, err) | |||
| 405 | + | _, err = v.FinishRegistration(userHandle, ceremony, []byte("{")) | |||
| 406 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 407 | + | _, err = v.FinishRegistration(userHandle, []byte("{"), []byte("{}")) | |||
| 408 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 409 | + | }) | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | func TestNew(t *testing.T) { | |||
| 413 | + | t.Parallel() | |||
| 414 | + | for _, ok := range []string{"https://git.example.com", "http://localhost:7341", "https://Git.Example.com:8443/"} { | |||
| 415 | + | _, err := webauthn.New(ok) | |||
| 416 | + | assert.NoError(t, err, ok) | |||
| 417 | + | } | |||
| 418 | + | for _, bad := range []string{"http://127.0.0.1:7341", "http://[::1]:7341", "git.example.com", "ftp://git.example.com", "", "https://"} { | |||
| 419 | + | _, err := webauthn.New(bad) | |||
| 420 | + | assert.Error(t, err, bad) | |||
| 421 | + | } | |||
| 422 | + | } | |||