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.go +40

@@ -368,6 +368,13 @@ func (s *Store) RemoveCredential(name string, credentialID []byte, actor string)
368 368 if err != nil {
369 369 return err
370 370 }
371 // The sessions table has a cascading foreign key, and this says the same thing
372 // on purpose: a passkey that is gone cannot keep a browser session alive,
373 // including the one the person is using at this moment.
374 if _, err := tx.Exec(`DELETE FROM sessions WHERE credential_id=?`,
375 credentialIDString(credentialID)); err != nil {
376 return fmt.Errorf("identity: end the sessions of a removed passkey: %w", err)
377 }
371 378 res, err := tx.Exec(`DELETE FROM user_credentials WHERE credential_id=? AND user_name=?`,
372 379 credentialIDString(credentialID), a.Name)
373 380 if err != nil {
@@ -384,6 +391,39 @@ func (s *Store) RemoveCredential(name string, credentialID []byte, actor string)
384 391 })
385 392 }
386 393
394 // RenameCredential changes the label on one of an account's passkeys. A label is
395 // display text, not identity: the credential id is what authenticates, so a rename
396 // cannot affect signing in, and an empty label is allowed and means "no label".
397 func (s *Store) RenameCredential(name string, credentialID []byte, label, actor string) error {
398 if len(credentialID) == 0 {
399 return ErrCredentialNotFound
400 }
401 label = strings.TrimSpace(label)
402 if err := ValidateCredentialLabel(label); err != nil {
403 return err
404 }
405 return s.mutate("identity.credential-rename", actor, name,
406 credentialIDString(credentialID)+" "+label, func(tx *sql.Tx) error {
407 a, err := findTx(tx, name)
408 if err != nil {
409 return err
410 }
411 res, err := tx.Exec(`UPDATE user_credentials SET label=? WHERE credential_id=? AND user_name=?`,
412 label, credentialIDString(credentialID), a.Name)
413 if err != nil {
414 return fmt.Errorf("identity: rename passkey: %w", err)
415 }
416 n, err := res.RowsAffected()
417 if err != nil {
418 return err
419 }
420 if n == 0 {
421 return ErrCredentialNotFound
422 }
423 return nil
424 })
425 }
426
387 427 // ---------- internal ----------
388 428
389 429 func inviteTTL(ttl time.Duration) time.Duration {