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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Let people rename and remove their own passkeys

7d7718fac3edfc9b8e27d1e55721a6f9ca9106af · Keenan Nemetz · 7h ago · parent 5f6163bb

Let people rename and remove their own passkeys

The Account page listed and added passkeys and could do nothing else: a lost or
shared device, or a label typo, needed an instance admin and the CLI. Both are the
person's own credential, so both belong on the page.

- Rename: `identity.RenameCredential` writes the label in one transaction and audits
  `identity.credential-rename` with the passkey and its new label. A label is display
  text, so the credential id and key are untouched and the cap is the one the
  ceremony already uses (`MaxCredentialLabel`). An empty label is allowed and the
  page calls it a passkey.
- Remove: `identity.RemoveCredential` now deletes the sessions of that passkey in the
  same transaction. The foreign key cascades already, and saying it out loud is what
  makes it the intent rather than a schema accident: the session the person is
  reading the page from ends with the passkey it signed in with.
- The last passkey is refused, with an explanation, while the account has no SSH key
  either. With one it is a way in for Git, so an account that has one loses only its
  browser sign-in; with neither, the only way back is an admin invite, and losing
  that should never be a click that silently succeeded. Two passkeys are enough to
  rotate: the last one is protected, not every one.
- The page gets a rename form and a remove form per passkey, each with the CSRF token
  and a confirmation on the removal, and a line saying what removing one does. The
  handlers are scoped by the session account, so the form cannot name somebody else's
  credential, and an unknown one is a 404 rather than a silent no-op.

Docs: `docs/accounts-and-access.md` covers renaming, the last-passkey rule, and the
audit actions for both.

Tests: the label changes and the credential does not, an empty label clears it, an
over-long one is refused where it is accepted, and another account's passkey is not
renameable; a removed passkey takes its sessions with it (identity); and end to end
over the account page — rename one of two, remove one of several, a post without the
token is refused, and the last passkey is refused until an SSH key is added.

Closes 01M6SASJYFVH6Z3P6BXTEMA83

forge/identity/passkey_test.go +92

@@ -349,3 +349,95 @@ func TestCredentialLabelBound(t *testing.T) {
349 349 t.Fatal("an oversized label was accepted")
350 350 }
351 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.
355 func 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.
415 func 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 }