v0.15.0 8a6cfec

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// 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}

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 2

Tags 45

↑↓ moveenter openesc close