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 { | |