1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "bytes"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/hex"
10 "encoding/json"
11 "net/http"
12 "net/url"
13 "strings"
14 "testing"
15
16 "github.com/descope/virtualwebauthn"
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/require"
19
20 "bnema.dev/ermite/internal/domain"
21)
22
23const passkeyOrigin = "https://git.example.test"
24
25var passkeyRP = virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: passkeyOrigin}
26
27// passkeyHarness is a forge whose public URL allows passkeys, with a virtual
28// authenticator in front of its real web handler.
29type passkeyHarness struct {
30 *cliHarness
31 b browser
32 auth virtualwebauthn.Authenticator
33 cred virtualwebauthn.Credential
34}
35
36func newPasskeyHarness(t *testing.T) *passkeyHarness {
37 t.Helper()
38 h := newHarness(t, harnessOpts{publicURL: passkeyOrigin})
39 c := &cliHarness{harness: h, url: h.srv.URL}
40 return &passkeyHarness{
41 cliHarness: c, b: browser{h},
42 auth: virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{BackupEligible: true}),
43 cred: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2),
44 }
45}
46
47// postJSON posts a JSON body as the browser's script does.
48func (p *passkeyHarness) postJSON(path, token string, body any) page {
49 p.t.Helper()
50 var buf bytes.Buffer
51 if body != nil {
52 require.NoError(p.t, json.NewEncoder(&buf).Encode(body))
53 }
54 req, err := http.NewRequest("POST", p.url+path, &buf)
55 require.NoError(p.t, err)
56 req.Header.Set("Origin", passkeyOrigin)
57 req.Header.Set("Content-Type", "application/json")
58 if token != "" {
59 req.Header.Set("Cookie", sessionCookie+"="+token)
60 }
61 rec, err := http.DefaultClient.Do(req)
62 require.NoError(p.t, err)
63 defer func() { _ = rec.Body.Close() }()
64 var out bytes.Buffer
65 _, _ = out.ReadFrom(rec.Body)
66 return page{status: rec.StatusCode, hdr: rec.Header, body: out.String()}
67}
68
69// passkeyLoginPath turns the link the CLI printed into a path on the test
70// server (the harness here has an https public URL).
71func passkeyLoginPath(t *testing.T, link string) string {
72 t.Helper()
73 u, err := url.Parse(strings.TrimSpace(link))
74 require.NoError(t, err)
75 assert.Equal(t, passkeyOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL")
76 return u.Path
77}
78
79type beginReply struct {
80 Ceremony string `json:"ceremony"`
81 Options json.RawMessage `json:"options"`
82}
83
84// register adds the authenticator's credential as the owner holding token.
85func (p *passkeyHarness) register(token, name string) page {
86 p.t.Helper()
87 begin := p.postJSON("/-/passkeys/register/begin", token, map[string]any{"name": name})
88 require.Equal(p.t, http.StatusOK, begin.status, begin.body)
89 var br beginReply
90 require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))
91 opts, err := virtualwebauthn.ParseAttestationOptions(string(br.Options))
92 require.NoError(p.t, err)
93 resp := virtualwebauthn.CreateAttestationResponse(passkeyRP, p.auth, p.cred, *opts)
94 fin := p.postJSON("/-/passkeys/register/finish", token, map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp)})
95 if fin.status == http.StatusNoContent {
96 p.auth.AddCredential(p.cred)
97 p.auth.Options.UserHandle = p.userHandle()
98 }
99 return fin
100}
101
102// userHandle reads the owner's WebAuthn user handle, which a real
103// authenticator got from the creation options.
104func (p *passkeyHarness) userHandle() []byte {
105 h, err := p.forge.store.Passkeys().UserHandle(p.ctx)
106 require.NoError(p.t, err)
107 return h
108}
109
110// login signs in with the authenticator; it returns the answer of finish.
111func (p *passkeyHarness) login() page {
112 p.t.Helper()
113 begin := p.postJSON("/-/passkeys/login/begin", "", nil)
114 require.Equal(p.t, http.StatusOK, begin.status, begin.body)
115 var br beginReply
116 require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))
117 opts, err := virtualwebauthn.ParseAssertionOptions(string(br.Options))
118 require.NoError(p.t, err)
119 resp := virtualwebauthn.CreateAssertionResponse(passkeyRP, p.auth, p.cred, *opts)
120 return p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp)})
121}
122
123// AC1, AC2, AC3: the owner signs in with the CLI link, adds a passkey, signs
124// out, signs in with the passkey, sees it in the CLI, and removing it ends
125// every session.
126func TestPasskeyEndToEnd(t *testing.T) {
127 p := newPasskeyHarness(t)
128 p.seed("secret", domain.VisibilityPrivate, "notes.txt")
129
130 // The server announces passkeys, and the pages link to them.
131 ver := p.cli(t, "", "version", "--json").ok(t)
132 assert.Contains(t, ver.stdout, `"passkeys"`)
133 assert.Empty(t, ver.stderr, "no feature is missing")
134 login := p.b.get("/-/login", nil)
135 require.Equal(t, http.StatusOK, login.status)
136 assert.Contains(t, login.body, `data-passkey="login"`)
137
138 // Anonymous: the list is a 404, registration is refused.
139 assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", nil).status)
140 assert.Equal(t, http.StatusForbidden, p.postJSON("/-/passkeys/register/begin", "", map[string]any{}).status)
141
142 // Sign in with the CLI link (the way in before any passkey), add a passkey.
143 link := p.cli(t, adminToken, "web", "login").ok(t)
144 pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
145 require.Equal(t, http.StatusSeeOther, pg.status, pg.body)
146 token := pg.cookie(sessionCookie).Value
147
148 fin := p.register(token, "Test phone")
149 require.Equal(t, http.StatusNoContent, fin.status, fin.body)
150
151 list := p.b.get("/-/passkeys", as(token))
152 require.Equal(t, http.StatusOK, list.status)
153 assert.Contains(t, list.body, "Test phone")
154 assert.Equal(t, "private, no-store", list.hdr.Get("Cache-Control"))
155
156 // The same credential cannot be added twice.
157 dup := p.register(token, "again")
158 assert.Equal(t, http.StatusConflict, dup.status, dup.body)
159
160 // AC2: an anonymous browser signs in with the passkey: same cookie.
161 res := p.login()
162 require.Equal(t, http.StatusNoContent, res.status, res.body)
163 var ck *http.Cookie
164 for _, c := range (&http.Response{Header: res.hdr}).Cookies() {
165 if c.Name == sessionCookie {
166 ck = c
167 }
168 }
169 require.NotNil(t, ck)
170 assert.True(t, ck.Secure)
171 assert.True(t, ck.HttpOnly)
172 assert.Equal(t, http.SameSiteStrictMode, ck.SameSite)
173 assert.Equal(t, 12*3600, ck.MaxAge)
174 home := p.b.get("/", as(ck.Value))
175 assert.Contains(t, home.body, "secret", "the passkey session is the owner's")
176
177 // AC3: the CLI lists it, with its use; the id removes it.
178 out := p.cli(t, adminToken, "web", "passkey", "list", "--json").ok(t)
179 var listed []struct {
180 ID string `json:"id"`
181 Name string `json:"name"`
182 LastUsedAt *string `json:"last_used_at"`
183 Synced bool `json:"synced"`
184 }
185 require.NoError(t, json.Unmarshal([]byte(out.stdout), &listed))
186 require.Len(t, listed, 1)
187 assert.Equal(t, "Test phone", listed[0].Name)
188 assert.Equal(t, base64.RawURLEncoding.EncodeToString(p.cred.ID), listed[0].ID)
189 assert.NotNil(t, listed[0].LastUsedAt, "the sign-in was recorded")
190
191 rm := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID).ok(t)
192 assert.Contains(t, rm.stderr, "every browser is signed out")
193 for _, tok := range []string{token, ck.Value} {
194 assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", as(tok)).status, "the epoch moved: both sessions are gone")
195 }
196 // The removed passkey no longer signs in, and is not there to remove again.
197 res = p.login()
198 assert.Equal(t, http.StatusUnauthorized, res.status, res.body)
199 assert.Empty(t, res.hdr.Values("Set-Cookie"))
200 again := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID)
201 require.Error(t, again.err)
202 assert.Contains(t, again.err.Error(), "not found")
203}
204
205// A passkey that signs in is refused when the origin is wrong, and nothing is set.
206func TestPasskeyLoginRefusedFromAnotherOrigin(t *testing.T) {
207 p := newPasskeyHarness(t)
208 link := p.cli(t, adminToken, "web", "login").ok(t)
209 pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
210 token := pg.cookie(sessionCookie).Value
211 require.Equal(t, http.StatusNoContent, p.register(token, "k").status)
212
213 // The Origin header is wrong: the gate refuses before anything else.
214 req, err := http.NewRequest("POST", p.url+"/-/passkeys/login/begin", strings.NewReader(""))
215 require.NoError(t, err)
216 req.Header.Set("Origin", "https://evil.example")
217 resp, err := http.DefaultClient.Do(req)
218 require.NoError(t, err)
219 _ = resp.Body.Close()
220 assert.Equal(t, http.StatusForbidden, resp.StatusCode)
221
222 // The authenticator answers for another relying party.
223 begin := p.postJSON("/-/passkeys/login/begin", "", nil)
224 var br beginReply
225 require.NoError(t, json.Unmarshal([]byte(begin.body), &br))
226 opts, err := virtualwebauthn.ParseAssertionOptions(string(br.Options))
227 require.NoError(t, err)
228 evil := virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "x", Origin: "https://evil.example"}
229 resp2 := virtualwebauthn.CreateAssertionResponse(evil, p.auth, p.cred, *opts)
230 fin := p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp2)})
231 assert.Equal(t, http.StatusUnauthorized, fin.status, fin.body)
232 assert.Empty(t, fin.hdr.Values("Set-Cookie"))
233
234 // The ceremony is burnt: even the right answer is refused afterwards.
235 good := virtualwebauthn.CreateAssertionResponse(passkeyRP, p.auth, p.cred, *opts)
236 fin = p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(good)})
237 assert.Equal(t, http.StatusUnauthorized, fin.status)
238}
239
240// AC4: on the harness's http public URL (not https), passkeys are off: no
241// route, no link, no feature, and the RPCs say so.
242func TestPasskeysDisabledOnHTTPPublicURL(t *testing.T) {
243 c := newCLIHarness(t) // http://example.test
244 b := browser{c.harness}
245 assert.Equal(t, http.StatusNotFound, b.get("/-/login", nil).status)
246 assert.Equal(t, http.StatusForbidden, b.do("POST", "/-/passkeys/login/begin", map[string]string{"Origin": publicOrigin}, "").status, "no session, no route")
247 token := c.signIn(t)
248 assert.Equal(t, http.StatusNotFound, b.get("/-/passkeys", as(token)).status)
249 home := b.get("/", as(token))
250 assert.NotContains(t, home.body, "/-/passkeys")
251 assert.NotContains(t, b.get("/", nil).body, `href="/-/login"`)
252
253 res := c.cli(t, adminToken, "web", "passkey", "list")
254 require.Error(t, res.err)
255 assert.Contains(t, res.err.Error(), "passkeys are not available on this server")
256 ver := c.cli(t, "", "version", "--json").ok(t)
257 assert.NotContains(t, ver.stdout, `"features": [`+"\n"+` "passkeys"`)
258}
259
260// A passkey whose counter does not advance (a hardware key that was cloned) is
261// refused end to end, with nothing set and the stored counter kept, while a
262// counter that advances signs in. Without the sign-count check of the use case
263// the clone would sign in: the WebAuthn adapter accepts it and only reports the
264// stored counter back.
265func TestPasskeyClonedAuthenticator(t *testing.T) {
266 p := newPasskeyHarness(t)
267 link := p.cli(t, adminToken, "web", "login").ok(t)
268 pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
269 token := pg.cookie(sessionCookie).Value
270
271 // A hardware key: its counter counts up from 1.
272 p.cred.Counter = 1
273 require.Equal(t, http.StatusNoContent, p.register(token, "key").status)
274
275 p.cred.Counter = 5
276 res := p.login()
277 require.Equal(t, http.StatusNoContent, res.status, res.body)
278
279 // The clone presents the counter it last saw: not higher than the stored 5.
280 for _, counter := range []uint32{5, 3} {
281 p.cred.Counter = counter
282 res = p.login()
283 assert.Equal(t, http.StatusUnauthorized, res.status, "counter %d: %s", counter, res.body)
284 assert.Empty(t, res.hdr.Values("Set-Cookie"), "no session for a clone")
285 }
286
287 // The refusals recorded nothing: the next higher counter still works.
288 p.cred.Counter = 6
289 res = p.login()
290 assert.Equal(t, http.StatusNoContent, res.status, res.body)
291
292 stored, err := p.forge.store.Passkeys().List(p.ctx)
293 require.NoError(t, err)
294 require.Len(t, stored, 1)
295 assert.Equal(t, uint32(6), stored[0].SignCount)
296
297 // The warning names the passkey by its short reference, never by its id.
298 sum := sha256.Sum256(p.cred.ID)
299 ref := hex.EncodeToString(sum[:])[:8]
300 var warns []string
301 for _, line := range strings.Split(p.logs.String(), "\n") {
302 var m map[string]any
303 if json.Unmarshal([]byte(line), &m) == nil && m["level"] == "warn" && m["passkey"] == ref {
304 warns = append(warns, line)
305 }
306 }
307 assert.Len(t, warns, 2, "one warning per refused clone")
308 assert.NotContains(t, p.logs.String(), base64.RawURLEncoding.EncodeToString(p.cred.ID))
309}