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// Verifier runs the WebAuthn ceremonies for the owner of one public URL.
23type Verifier struct {
24 web *gowebauthn.WebAuthn
25 // name names the one user in the options sent to the browser: the
26 // owner's name (ERMITE_OWNER), which password managers show.
27 name string
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. ownerName is
35// the name the browser shows for the passkey.
36func New(publicURL, ownerName string) (*Verifier, error) {
37 u, err := url.Parse(publicURL)
38 if err != nil {
39 return nil, fmt.Errorf("passkeys: parse public URL: %w", err)
40 }
41 host := strings.ToLower(u.Hostname())
42 if (u.Scheme != "http" && u.Scheme != "https") || host == "" {
43 return nil, fmt.Errorf("passkeys: public URL %q needs an http(s) scheme and a host", publicURL)
44 }
45 timeout := gowebauthn.TimeoutConfig{Enforce: true, Timeout: domain.PasskeyCeremonyTTL, TimeoutUVD: domain.PasskeyCeremonyTTL}
46 web, err := gowebauthn.New(&gowebauthn.Config{
47 RPID: host,
48 RPDisplayName: host,
49 RPOrigins: []string{u.Scheme + "://" + strings.ToLower(u.Host)},
50 AuthenticatorSelection: protocol.AuthenticatorSelection{
51 ResidentKey: protocol.ResidentKeyRequirementRequired,
52 RequireResidentKey: protocol.ResidentKeyRequired(),
53 UserVerification: protocol.VerificationRequired,
54 },
55 AttestationPreference: protocol.PreferNoAttestation,
56 Timeouts: gowebauthn.TimeoutsConfig{Login: timeout, Registration: timeout},
57 })
58 if err != nil {
59 return nil, fmt.Errorf("passkeys: %w", err)
60 }
61 return &Verifier{web: web, name: ownerName}, nil
62}
63
64// owner is the single WebAuthn user.
65type owner struct {
66 name string
67 handle []byte
68 credentials []gowebauthn.Credential
69}
70
71func (o owner) WebAuthnID() []byte { return o.handle }
72func (o owner) WebAuthnName() string { return o.name }
73func (o owner) WebAuthnDisplayName() string { return o.name }
74func (o owner) WebAuthnCredentials() []gowebauthn.Credential { return o.credentials }
75
76// refuse builds the error of a failed verification: an ErrUnauthenticated whose
77// text carries the reason, for logs. The library error is flattened to text so
78// that its types never leave this package.
79func refuse(what string, err error) error {
80 return fmt.Errorf("%w: passkey %s: %v", domain.ErrUnauthenticated, what, err)
81}
82
83// BeginRegistration implements ports.PasskeyVerifier.
84func (v *Verifier) BeginRegistration(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error) {
85 exclude := make([]protocol.CredentialDescriptor, 0, len(existing))
86 for _, p := range existing {
87 exclude = append(exclude, descriptor(p))
88 }
89 creation, session, err := v.web.BeginRegistration(owner{name: v.name, handle: userHandle},
90 gowebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
91 gowebauthn.WithExclusions(exclude),
92 gowebauthn.WithConveyancePreference(protocol.PreferNoAttestation),
93 )
94 if err != nil {
95 return nil, nil, fmt.Errorf("begin passkey registration: %w", err)
96 }
97 return encode(creation, session)
98}
99
100// FinishRegistration implements ports.PasskeyVerifier.
101func (v *Verifier) FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error) {
102 var session gowebauthn.SessionData
103 if err := json.Unmarshal(ceremony, &session); err != nil {
104 return domain.Passkey{}, refuse("registration", fmt.Errorf("decode ceremony: %w", err))
105 }
106 parsed, err := protocol.ParseCredentialCreationResponseBytes(response)
107 if err != nil {
108 return domain.Passkey{}, refuse("registration", err)
109 }
110 cred, err := v.web.CreateCredential(owner{name: v.name, handle: userHandle}, session, parsed)
111 if err != nil {
112 return domain.Passkey{}, refuse("registration", err)
113 }
114 transports := make([]string, 0, len(cred.Transport))
115 for _, t := range cred.Transport {
116 transports = append(transports, string(t))
117 }
118 return domain.Passkey{
119 ID: cred.ID,
120 PublicKey: cred.PublicKey,
121 AttestationType: cred.AttestationType,
122 Transports: transports,
123 SignCount: cred.Authenticator.SignCount,
124 BackupEligible: cred.Flags.BackupEligible,
125 BackupState: cred.Flags.BackupState,
126 }, nil
127}
128
129// BeginLogin implements ports.PasskeyVerifier.
130func (v *Verifier) BeginLogin() ([]byte, []byte, error) {
131 assertion, session, err := v.web.BeginDiscoverableLogin()
132 if err != nil {
133 return nil, nil, fmt.Errorf("begin passkey login: %w", err)
134 }
135 return encode(assertion, session)
136}
137
138// FinishLogin implements ports.PasskeyVerifier. The library compares the user
139// handle of the response with userHandle.
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{name: v.name, 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}