detached 27e453c

webauthn.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close