Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 2h ago 4b8c9e1d
history blame raw 13.8 KiB
1package identity
2
3import (
4 "crypto/rand"
5 "errors"
6 "strings"
7 "testing"
8 "time"
9
10 "rodent.software/vole/forge/access"
11)
12
13func testCredential(t *testing.T, label string) Credential {
14 t.Helper()
15 id := make([]byte, 20)
16 pub := make([]byte, 32)
17 if _, err := rand.Read(id); err != nil {
18 t.Fatal(err)
19 }
20 if _, err := rand.Read(pub); err != nil {
21 t.Fatal(err)
22 }
23 return Credential{
24 ID: id,
25 PublicKey: pub,
26 AttestationType: "none",
27 Transport: []string{"internal", "hybrid"},
28 Flags: CredentialFlags{UserPresent: true, UserVerified: true, BackupEligible: true, BackupState: true},
29 Authenticator: Authenticator{
30 AAGUID: []byte{1, 2, 3, 4},
31 SignCount: 7,
32 CloneWarning: false,
33 Attachment: "platform",
34 },
35 Label: label,
36 }
37}
38
39func TestCreateWithInviteAndAcceptPasskey(t *testing.T) {
40 t.Parallel()
41 s := newStore(t)
42 a, inv, err := s.CreateWithInvite("alice", "operator", time.Hour)
43 if err != nil {
44 t.Fatal(err)
45 }
46 if a.Name == "" || len(a.Keys) != 0 {
47 t.Fatalf("created account = %+v, want an id and no SSH key", a)
48 }
49 if inv.Token == "" || !inv.Expires.After(time.Now()) {
50 t.Fatalf("invite = %+v", inv)
51 }
52 got, err := s.InviteAccount(inv.Token)
53 if err != nil {
54 t.Fatal(err)
55 }
56 if got.Name != "alice" {
57 t.Fatalf("InviteAccount = %+v, want %s", got, a.Name)
58 }
59
60 cred := testCredential(t, "laptop")
61 redeemed, err := s.AcceptInvite(inv.Token, cred, "alice")
62 if err != nil {
63 t.Fatal(err)
64 }
65 if redeemed.Name != a.Name {
66 t.Fatalf("redeemed account = %s, want %s", redeemed.Name, a.Name)
67 }
68 creds, err := s.Credentials("alice")
69 if err != nil {
70 t.Fatal(err)
71 }
72 if len(creds) != 1 {
73 t.Fatalf("credentials = %d, want 1", len(creds))
74 }
75 if string(creds[0].ID) != string(cred.ID) || creds[0].Label != "laptop" {
76 t.Fatalf("stored credential = %+v, want id %x label laptop", creds[0], cred.ID)
77 }
78 if !creds[0].Flags.UserVerified || !creds[0].Flags.BackupEligible || !creds[0].Flags.BackupState {
79 t.Fatalf("credential flags lost: %+v", creds[0].Flags)
80 }
81 if creds[0].Authenticator.SignCount != 7 || string(creds[0].Authenticator.AAGUID) != string([]byte{1, 2, 3, 4}) {
82 t.Fatalf("authenticator fields lost: %+v", creds[0].Authenticator)
83 }
84
85 found, owner, err := s.FindCredential(cred.ID)
86 if err != nil {
87 t.Fatal(err)
88 }
89 if owner.Name != a.Name || string(found.ID) != string(cred.ID) {
90 t.Fatalf("FindCredential = %s/%x, want %s/%x", owner.Name, found.ID, a.Name, cred.ID)
91 }
92
93 // The link is spent, including a second attempt with a fresh credential.
94 if _, err := s.InviteAccount(inv.Token); !errors.Is(err, ErrInviteInvalid) {
95 t.Fatalf("used invite still resolves: %v", err)
96 }
97 if _, err := s.AcceptInvite(inv.Token, testCredential(t, "other"), "alice"); !errors.Is(err, ErrInviteInvalid) {
98 t.Fatalf("used invite still redeems: %v", err)
99 }
100}
101
102func TestNewInviteSupersedesTheOldOne(t *testing.T) {
103 t.Parallel()
104 s := newStore(t)
105 _, first, err := s.CreateWithInvite("alice", "operator", time.Hour)
106 if err != nil {
107 t.Fatal(err)
108 }
109 second, err := s.Invite("alice", "operator", time.Hour)
110 if err != nil {
111 t.Fatal(err)
112 }
113 if _, err := s.InviteAccount(first.Token); !errors.Is(err, ErrInviteInvalid) {
114 t.Fatalf("superseded invite still resolves: %v", err)
115 }
116 if _, err := s.InviteAccount(second.Token); err != nil {
117 t.Fatalf("new invite does not resolve: %v", err)
118 }
119}
120
121func TestExpiredInviteIsInvalid(t *testing.T) {
122 t.Parallel()
123 s := newStore(t)
124 _, inv, err := s.CreateWithInvite("alice", "operator", time.Hour)
125 if err != nil {
126 t.Fatal(err)
127 }
128 if _, err := s.db.Exec(`UPDATE user_invites SET expires=? WHERE token_hash=?`,
129 time.Now().Add(-time.Minute).Unix(), hashToken(inv.Token)); err != nil {
130 t.Fatal(err)
131 }
132 if _, err := s.InviteAccount(inv.Token); !errors.Is(err, ErrInviteInvalid) {
133 t.Fatalf("expired invite resolves: %v", err)
134 }
135 if _, err := s.AcceptInvite(inv.Token, testCredential(t, "laptop"), "alice"); !errors.Is(err, ErrInviteInvalid) {
136 t.Fatalf("expired invite redeems: %v", err)
137 }
138}
139
140func TestPasskeyOnlyAccountSurvivesReopen(t *testing.T) {
141 t.Parallel()
142 s := newStore(t)
143 a, inv, err := s.CreateWithInvite("alice", "operator", time.Hour)
144 if err != nil {
145 t.Fatal(err)
146 }
147 cred := testCredential(t, "phone")
148 if _, err := s.AcceptInvite(inv.Token, cred, "alice"); err != nil {
149 t.Fatal(err)
150 }
151 reopened, err := Open(s.Path())
152 if err != nil {
153 t.Fatal(err)
154 }
155 defer reopened.Close()
156 creds, err := reopened.Credentials(a.Name)
157 if err != nil {
158 t.Fatal(err)
159 }
160 if len(creds) != 1 || string(creds[0].ID) != string(cred.ID) {
161 t.Fatalf("reopened credentials = %+v", creds)
162 }
163}
164
165func TestDuplicatePasskeyRejected(t *testing.T) {
166 t.Parallel()
167 s := newStore(t)
168 alice := mustCreate(t, s, "alice")
169 bob := mustCreate(t, s, "bob")
170 cred := testCredential(t, "laptop")
171 if err := s.AddCredential(alice.Name, cred, "test"); err != nil {
172 t.Fatal(err)
173 }
174 if err := s.AddCredential(bob.Name, cred, "test"); err == nil {
175 t.Fatal("the same passkey was attached to two accounts")
176 }
177}
178
179func TestUpdatePasskeyCounterAndRemove(t *testing.T) {
180 t.Parallel()
181 s := newStore(t)
182 a := mustCreate(t, s, "alice")
183 cred := testCredential(t, "laptop")
184 // A minimal credential has no AAGUID and no transports; both columns are
185 // nullable and must not break a later read.
186 cred.Authenticator.AAGUID = nil
187 cred.Transport = nil
188 if err := s.AddCredential(a.Name, cred, "test"); err != nil {
189 t.Fatal(err)
190 }
191 used := time.Now().UTC().Truncate(time.Second)
192 if err := s.UpdateCredential(cred.ID, 42, used); err != nil {
193 t.Fatal(err)
194 }
195 creds, err := s.Credentials(a.Name)
196 if err != nil {
197 t.Fatal(err)
198 }
199 if creds[0].Authenticator.SignCount != 42 || !creds[0].LastUsed.Equal(used) {
200 t.Fatalf("counter update lost: %+v", creds[0])
201 }
202 if err := s.RemoveCredential(a.Name, cred.ID, "test"); err != nil {
203 t.Fatal(err)
204 }
205 if _, _, err := s.FindCredential(cred.ID); !errors.Is(err, ErrCredentialNotFound) {
206 t.Fatalf("removed credential still resolves: %v", err)
207 }
208 if err := s.RemoveCredential(a.Name, cred.ID, "test"); !errors.Is(err, ErrCredentialNotFound) {
209 t.Fatalf("removing twice = %v, want ErrCredentialNotFound", err)
210 }
211}
212
213func TestRetireEndsInvitesAndSessionsAndHidesCredentials(t *testing.T) {
214 t.Parallel()
215 s := newStore(t)
216 a, inv, err := s.CreateWithInvite("alice", "operator", time.Hour)
217 if err != nil {
218 t.Fatal(err)
219 }
220 cred := testCredential(t, "laptop")
221 if _, err := s.AcceptInvite(inv.Token, cred, "alice"); err != nil {
222 t.Fatal(err)
223 }
224 if _, err := s.db.Exec(`INSERT INTO sessions(token_hash, user_name, credential_id, created, last_seen, expires)
225VALUES('hash', ?, ?, 1, 1, 9999999999)`, a.Name, credentialIDString(cred.ID)); err != nil {
226 t.Fatal(err)
227 }
228 // An outstanding invite must also die with the account.
229 if _, err := s.Invite("alice", "operator", time.Hour); err != nil {
230 t.Fatal(err)
231 }
232 if _, err := s.Retire("alice", "operator"); err != nil {
233 t.Fatal(err)
234 }
235 var sessions, invites int
236 if err := s.db.QueryRow(`SELECT count(*) FROM sessions WHERE user_name=?`, a.Name).Scan(&sessions); err != nil {
237 t.Fatal(err)
238 }
239 if sessions != 0 {
240 t.Fatalf("retire left %d session(s)", sessions)
241 }
242 if err := s.db.QueryRow(`SELECT count(*) FROM user_invites WHERE user_name=?`, a.Name).Scan(&invites); err != nil {
243 t.Fatal(err)
244 }
245 if invites != 0 {
246 t.Fatalf("retire left %d invite(s)", invites)
247 }
248 if _, _, err := s.FindCredential(cred.ID); !errors.Is(err, ErrCredentialNotFound) {
249 t.Fatalf("retired account's passkey still resolves: %v", err)
250 }
251}
252
253func TestRemovingPasskeyCascadesItsSessions(t *testing.T) {
254 t.Parallel()
255 s := newStore(t)
256 a := mustCreate(t, s, "alice")
257 cred := testCredential(t, "laptop")
258 if err := s.AddCredential(a.Name, cred, "test"); err != nil {
259 t.Fatal(err)
260 }
261 other := testCredential(t, "phone")
262 if err := s.AddCredential(a.Name, other, "test"); err != nil {
263 t.Fatal(err)
264 }
265 for _, id := range [][]byte{cred.ID, other.ID} {
266 if _, err := s.db.Exec(`INSERT INTO sessions(token_hash, user_name, credential_id, created, last_seen, expires)
267VALUES(?, ?, ?, 1, 1, 9999999999)`, "hash-"+credentialIDString(id), a.Name, credentialIDString(id)); err != nil {
268 t.Fatal(err)
269 }
270 }
271 if err := s.RemoveCredential(a.Name, cred.ID, "test"); err != nil {
272 t.Fatal(err)
273 }
274 var n int
275 if err := s.db.QueryRow(`SELECT count(*) FROM sessions WHERE credential_id=?`, credentialIDString(cred.ID)).Scan(&n); err != nil {
276 t.Fatal(err)
277 }
278 if n != 0 {
279 t.Fatalf("removed passkey left %d session(s)", n)
280 }
281 if err := s.db.QueryRow(`SELECT count(*) FROM sessions WHERE credential_id=?`, credentialIDString(other.ID)).Scan(&n); err != nil {
282 t.Fatal(err)
283 }
284 if n != 1 {
285 t.Fatalf("removing one passkey removed another's session (count=%d)", n)
286 }
287}
288
289func TestCreatePasskeySelfRegistration(t *testing.T) {
290 t.Parallel()
291 s := newStore(t)
292 cred := testCredential(t, "first passkey")
293 a, err := s.CreatePasskeyWithRole("alice", access.Contribute, cred, "self-registration")
294 if err != nil {
295 t.Fatal(err)
296 }
297 if a.Name != "alice" || len(a.Keys) != 0 || a.Role != access.Contribute {
298 t.Fatalf("created account = %+v", a)
299 }
300 creds, err := s.Credentials(a.Name)
301 if err != nil || len(creds) != 1 || string(creds[0].ID) != string(cred.ID) {
302 t.Fatalf("credentials = %+v, %v", creds, err)
303 }
304
305 // A second registration for the same name must not leave an account behind.
306 if _, err := s.CreatePasskey("alice", testCredential(t, "other"), "self-registration"); err == nil {
307 t.Fatal("duplicate name was accepted")
308 }
309}
310
311// TestCredentialMetadataIsBounded: a label and the fields copied from the
312// authenticator are stored beside the credential, so each is bounded before it
313// reaches the roster.
314func TestCredentialMetadataIsBounded(t *testing.T) {
315 t.Parallel()
316 cases := []struct {
317 name string
318 mutate func(*Credential)
319 }{
320 {"label", func(c *Credential) { c.Label = strings.Repeat("x", MaxCredentialLabel+1) }},
321 {"transports", func(c *Credential) {
322 c.Transport = make([]string, maxCredentialTransports+1)
323 }},
324 {"transport length", func(c *Credential) {
325 c.Transport = []string{strings.Repeat("x", maxCredentialTransport+1)}
326 }},
327 {"attachment", func(c *Credential) {
328 c.Authenticator.Attachment = strings.Repeat("x", maxCredentialAttachment+1)
329 }},
330 {"attestation", func(c *Credential) {
331 c.AttestationType = strings.Repeat("x", maxCredentialAttestation+1)
332 }},
333 }
334 for _, tc := range cases {
335 cred := testCredential(t, "laptop")
336 tc.mutate(&cred)
337 if err := validateCredential(cred); err == nil {
338 t.Fatalf("%s: an oversized value was accepted", tc.name)
339 }
340 }
341}
342
343func TestCredentialLabelBound(t *testing.T) {
344 t.Parallel()
345 if err := ValidateCredentialLabel(strings.Repeat("x", MaxCredentialLabel)); err != nil {
346 t.Fatalf("a label at the limit was refused: %v", err)
347 }
348 if err := ValidateCredentialLabel(" " + strings.Repeat("x", MaxCredentialLabel+1) + " "); err == nil {
349 t.Fatal("an oversized label was accepted")
350 }
351}
352
353// A label is display text. Renaming a passkey changes what its owner calls it and
354// nothing about what signs in: the credential id and the key are untouched.
355func TestRenameCredentialChangesOnlyTheLabel(t *testing.T) {
356 t.Parallel()
357 s := newStore(t)
358 pub, _ := pubLine(t)
359 alice, err := s.Create("alice", pub, "operator")
360 if err != nil {
361 t.Fatal(err)
362 }
363 cred := testCredential(t, "laptop")
364 if err := s.AddCredential(alice.Name, cred, "alice"); err != nil {
365 t.Fatal(err)
366 }
367
368 if err := s.RenameCredential(alice.Name, cred.ID, " desk key ", "alice"); err != nil {
369 t.Fatal(err)
370 }
371 creds, err := s.Credentials(alice.Name)
372 if err != nil {
373 t.Fatal(err)
374 }
375 if len(creds) != 1 {
376 t.Fatalf("credentials = %d, want 1", len(creds))
377 }
378 if creds[0].Label != "desk key" {
379 t.Fatalf("label = %q, want %q", creds[0].Label, "desk key")
380 }
381 if string(creds[0].ID) != string(cred.ID) || string(creds[0].PublicKey) != string(cred.PublicKey) {
382 t.Fatal("a rename changed the credential itself")
383 }
384
385 // An empty label is allowed: the page calls it a passkey with no name.
386 if err := s.RenameCredential(alice.Name, cred.ID, " ", "alice"); err != nil {
387 t.Fatal(err)
388 }
389 if creds, _ = s.Credentials(alice.Name); creds[0].Label != "" {
390 t.Fatalf("label = %q, want it cleared", creds[0].Label)
391 }
392
393 // The label is capped where it is accepted, not only when a ceremony stores it.
394 if err := s.RenameCredential(alice.Name, cred.ID, strings.Repeat("x", MaxCredentialLabel+1), "alice"); err == nil {
395 t.Fatal("an over-long label was accepted")
396 }
397
398 // An unknown id, or a passkey that belongs to somebody else, is not renamed.
399 bobPub, _ := pubLine(t)
400 bob, err := s.Create("bob", bobPub, "operator")
401 if err != nil {
402 t.Fatal(err)
403 }
404 bobCred := testCredential(t, "bob's phone")
405 if err := s.AddCredential(bob.Name, bobCred, "bob"); err != nil {
406 t.Fatal(err)
407 }
408 if err := s.RenameCredential(alice.Name, bobCred.ID, "mine now", "alice"); !errors.Is(err, ErrCredentialNotFound) {
409 t.Fatalf("renaming another account's passkey = %v, want %v", err, ErrCredentialNotFound)
410 }
411}
412
413// A passkey that is revoked cannot keep a browser session alive: the session it signed
414// in ends with it, including one that is open at the time.
415func TestRemoveCredentialEndsItsSessions(t *testing.T) {
416 t.Parallel()
417 s := newStore(t)
418 pub, _ := pubLine(t)
419 alice, err := s.Create("alice", pub, "operator")
420 if err != nil {
421 t.Fatal(err)
422 }
423 cred := testCredential(t, "laptop")
424 if err := s.AddCredential(alice.Name, cred, "alice"); err != nil {
425 t.Fatal(err)
426 }
427 if _, err := s.db.Exec(`INSERT INTO sessions(token_hash, user_name, credential_id, created, last_seen, expires)
428 VALUES('hash', ?, ?, 0, 0, 0)`, alice.Name, credentialIDString(cred.ID)); err != nil {
429 t.Fatal(err)
430 }
431
432 if err := s.RemoveCredential(alice.Name, cred.ID, "alice"); err != nil {
433 t.Fatal(err)
434 }
435 var sessions int
436 if err := s.db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE credential_id=?`,
437 credentialIDString(cred.ID)).Scan(&sessions); err != nil {
438 t.Fatal(err)
439 }
440 if sessions != 0 {
441 t.Fatalf("%d session(s) outlived the passkey", sessions)
442 }
443}
444