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.
6package webauthn
7
8import (
9 "encoding/json"
10 "errors"
11 "fmt"
12 "net/url"
13 "strings"
14
15 "github.com/go-webauthn/webauthn/protocol"
16 gowebauthn "github.com/go-webauthn/webauthn/webauthn"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// ownerName names the one user in the options sent to the browser.
23const ownerName = "owner"
24
25// Verifier runs the WebAuthn ceremonies for the owner of one public URL.
26type Verifier struct {
27 web *gowebauthn.WebAuthn
28}
29
30var _ ports.PasskeyVerifier = (*Verifier)(nil)
31
32// New builds a verifier for publicURL: the relying party ID is its host name,
33// the only accepted origin is its origin. It fails when the library refuses the
34// configuration, for example for a host that is an IP address.
35func New(publicURL string) (*Verifier, error) {
36 u, err := url.Parse(publicURL)
37 if err != nil {
38 return nil, fmt.Errorf("passkeys: parse public URL: %w", err)
39 }
40 host := strings.ToLower(u.Hostname())
41 if (u.Scheme != "http" && u.Scheme != "https") || host == "" {
42 return nil, fmt.Errorf("passkeys: public URL %q needs an http(s) scheme and a host", publicURL)
43 }
44 timeout := gowebauthn.TimeoutConfig{Enforce: true, Timeout: domain.PasskeyCeremonyTTL, TimeoutUVD: domain.PasskeyCeremonyTTL}
45 web, err := gowebauthn.New(&gowebauthn.Config{
46 RPID: host,
47 RPDisplayName: host,
48 RPOrigins: []string{u.Scheme + "://" + strings.ToLower(u.Host)},
49 AuthenticatorSelection: protocol.AuthenticatorSelection{
50 ResidentKey: protocol.ResidentKeyRequirementRequired,
51 RequireResidentKey: protocol.ResidentKeyRequired(),
52 UserVerification: protocol.VerificationRequired,
53 },
54 AttestationPreference: protocol.PreferNoAttestation,
55 Timeouts: gowebauthn.TimeoutsConfig{Login: timeout, Registration: timeout},
56 })
57 if err != nil {
58 return nil, fmt.Errorf("passkeys: %w", err)
59 }
60 return &Verifier{web: web}, nil
61}
62
63// owner is the single WebAuthn user.
64type owner struct {
65 handle []byte
66 credentials []gowebauthn.Credential
67}
68
69func (o owner) WebAuthnID() []byte { return o.handle }
70func (o owner) WebAuthnName() string { return ownerName }
71func (o owner) WebAuthnDisplayName() string { return ownerName }
72func (o owner) WebAuthnCredentials() []gowebauthn.Credential { return o.credentials }
73
74// refuse builds the error of a failed verification: an ErrUnauthenticated whose
75// text carries the reason, for logs. The library error is flattened to text so
76// that its types never leave this package.
77func refuse(what string, err error) error {
78 return fmt.Errorf("%w: passkey %s: %v", domain.ErrUnauthenticated, what, err)
79}
80
81// BeginRegistration implements ports.PasskeyVerifier.
82func (v *Verifier) BeginRegistration(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error) {
83 exclude := make([]protocol.CredentialDescriptor, 0, len(existing))
84 for _, p := range existing {
85 exclude = append(exclude, descriptor(p))
86 }
87 creation, session, err := v.web.BeginRegistration(owner{handle: userHandle},
88 gowebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
89 gowebauthn.WithExclusions(exclude),
90 gowebauthn.WithConveyancePreference(protocol.PreferNoAttestation),
91 )
92 if err != nil {
93 return nil, nil, fmt.Errorf("begin passkey registration: %w", err)
94 }
95 return encode(creation, session)
96}
97
98// FinishRegistration implements ports.PasskeyVerifier.
99func (v *Verifier) FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error) {
100 var session gowebauthn.SessionData
101 if err := json.Unmarshal(ceremony, &session); err != nil {
102 return domain.Passkey{}, refuse("registration", fmt.Errorf("decode ceremony: %w", err))
103 }
104 parsed, err := protocol.ParseCredentialCreationResponseBytes(response)
105 if err != nil {
106 return domain.Passkey{}, refuse("registration", err)
107 }
108 cred, err := v.web.CreateCredential(owner{handle: userHandle}, session, parsed)
109 if err != nil {
110 return domain.Passkey{}, refuse("registration", err)
111 }
112 transports := make([]string, 0, len(cred.Transport))
113 for _, t := range cred.Transport {
114 transports = append(transports, string(t))
115 }
116 return domain.Passkey{
117 ID: cred.ID,
118 PublicKey: cred.PublicKey,
119 AttestationType: cred.AttestationType,
120 Transports: transports,
121 SignCount: cred.Authenticator.SignCount,
122 BackupEligible: cred.Flags.BackupEligible,
123 BackupState: cred.Flags.BackupState,
124 }, nil
125}
126
127// BeginLogin implements ports.PasskeyVerifier.
128func (v *Verifier) BeginLogin() ([]byte, []byte, error) {
129 assertion, session, err := v.web.BeginDiscoverableLogin()
130 if err != nil {
131 return nil, nil, fmt.Errorf("begin passkey login: %w", err)
132 }
133 return encode(assertion, session)
134}
135
136// FinishLogin implements ports.PasskeyVerifier. The library compares the user
137// handle of the response with userHandle.
138//
139//hexcheck:ignore suspicious-business-logic-in-adapter copies the counter and backup state the library verified into the stored passkey; it decides nothing: the use case refuses a counter that does not advance
140func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) {
141 var session gowebauthn.SessionData
142 if err := json.Unmarshal(ceremony, &session); err != nil {
143 return domain.Passkey{}, refuse("login", fmt.Errorf("decode ceremony: %w", err))
144 }
145 parsed, err := protocol.ParseCredentialRequestResponseBytes(response)
146 if err != nil {
147 return domain.Passkey{}, refuse("login", err)
148 }
149
150 var stored domain.Passkey
151 var lookupErr error // a failure of the store, as opposed to an unknown credential
152 handler := func(rawID, _ []byte) (gowebauthn.User, error) {
153 p, err := lookup(rawID)
154 if err != nil {
155 if !errors.Is(err, domain.ErrNotFound) {
156 lookupErr = err
157 }
158 return nil, err
159 }
160 stored = p
161 return owner{handle: userHandle, credentials: []gowebauthn.Credential{credential(p)}}, nil
162 }
163 _, cred, err := v.web.ValidatePasskeyLogin(handler, session, parsed)
164 if lookupErr != nil {
165 return domain.Passkey{}, lookupErr // fail closed, as it is
166 }
167 if err != nil {
168 return domain.Passkey{}, refuse("login", err)
169 }
170 // The library keeps the stored counter when the received one does not
171 // advance (its clone warning); the use case refuses that, under its lock.
172 stored.SignCount = cred.Authenticator.SignCount
173 stored.BackupState = cred.Flags.BackupState
174 return stored, nil
175}
176
177// encode returns the JSON of the options for the browser and of the session.
178func encode(options any, session *gowebauthn.SessionData) (opts, ceremony []byte, err error) {
179 if opts, err = json.Marshal(options); err != nil {
180 return nil, nil, fmt.Errorf("encode passkey options: %w", err)
181 }
182 if ceremony, err = json.Marshal(session); err != nil {
183 return nil, nil, fmt.Errorf("encode passkey ceremony: %w", err)
184 }
185 return opts, ceremony, nil
186}
187
188// credential converts a stored passkey into the library's form.
189func credential(p domain.Passkey) gowebauthn.Credential {
190 c := gowebauthn.Credential{
191 ID: p.ID,
192 PublicKey: p.PublicKey,
193 AttestationType: p.AttestationType,
194 Flags: gowebauthn.CredentialFlags{BackupEligible: p.BackupEligible, BackupState: p.BackupState},
195 Authenticator: gowebauthn.Authenticator{SignCount: p.SignCount},
196 }
197 for _, t := range p.Transports {
198 c.Transport = append(c.Transport, protocol.AuthenticatorTransport(t))
199 }
200 return c
201}
202
203// descriptor names a stored passkey for the exclusion list.
204func descriptor(p domain.Passkey) protocol.CredentialDescriptor {
205 c := credential(p)
206 return c.Descriptor()
207}