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.

Let people rename and remove their own passkeys

7d7718fac3edfc9b8e27d1e55721a6f9ca9106af · Keenan Nemetz · 2h 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

server/auth_test.go +109

@@ -940,3 +940,112 @@ func TestCrossOriginPostIsRefused(t *testing.T) {
940 940 t.Fatalf("same-origin logout = %d, want 303", resp.StatusCode)
941 941 }
942 942 }
943
944 // A passkey can be relabelled and removed from the account page. The writes are the
945 // person's own, scoped by the session account, and a removal takes its sessions with
946 // it.
947 func TestAccountRenamesAndRemovesItsOwnPasskeys(t *testing.T) {
948 t.Parallel()
949 f := browserFixture(t)
950 sess, cookie := f.browserSession(t, f.ownerID)
951 laptop := identity.Credential{ID: []byte("passkey-laptop"), PublicKey: []byte("key-laptop"), Label: "laptop"}
952 phone := identity.Credential{ID: []byte("passkey-phone"), PublicKey: []byte("key-phone"), Label: "phone"}
953 for _, cred := range []identity.Credential{laptop, phone} {
954 if err := f.srv.Identity.AddCredential(f.ownerID, cred, "test"); err != nil {
955 t.Fatal(err)
956 }
957 }
958
959 resp, body := f.browserPost(t, "/-/account/passkeys/rename", cookie, url.Values{
960 "csrf": {sess.CSRF}, "credential": {identity.CredentialIDString(laptop.ID)}, "label": {"desk key"},
961 })
962 if resp.StatusCode != http.StatusSeeOther {
963 t.Fatalf("rename: status=%d body=%s", resp.StatusCode, body)
964 }
965 creds, err := f.srv.Identity.Credentials(f.ownerID)
966 if err != nil {
967 t.Fatal(err)
968 }
969 labels := map[string]string{}
970 for _, cred := range creds {
971 labels[identity.CredentialIDString(cred.ID)] = cred.Label
972 }
973 if got := labels[identity.CredentialIDString(laptop.ID)]; got != "desk key" {
974 t.Fatalf("label = %q, want desk key", got)
975 }
976 if got := labels[identity.CredentialIDString(phone.ID)]; got != "phone" {
977 t.Fatalf("the other passkey's label changed to %q", got)
978 }
979
980 // One of several goes, and the account keeps the way in it still has.
981 resp, body = f.browserPost(t, "/-/account/passkeys/remove", cookie, url.Values{
982 "csrf": {sess.CSRF}, "credential": {identity.CredentialIDString(laptop.ID)},
983 })
984 if resp.StatusCode != http.StatusSeeOther {
985 t.Fatalf("remove: status=%d body=%s", resp.StatusCode, body)
986 }
987 if creds, _ = f.srv.Identity.Credentials(f.ownerID); len(creds) != 1 {
988 t.Fatalf("after the removal the account has %d passkey(s), want 1", len(creds))
989 }
990
991 // The forms are writes, so they carry the token like every other one.
992 resp, _ = f.browserPost(t, "/-/account/passkeys/remove", cookie, url.Values{
993 "csrf": {"not-the-token"}, "credential": {identity.CredentialIDString(phone.ID)},
994 })
995 if resp.StatusCode != http.StatusForbidden {
996 t.Fatalf("a post without the token: status=%d, want 403", resp.StatusCode)
997 }
998 if creds, _ = f.srv.Identity.Credentials(f.ownerID); len(creds) != 1 {
999 t.Fatal("a post without the token removed a passkey")
1000 }
1001 }
1002
1003 // The last passkey is the last way into the account. With an SSH key the account can
1004 // still push, so the removal is allowed; with nothing else it is refused rather than
1005 // locking somebody out of a page they are reading.
1006 func TestTheLastPasskeyIsOnlyRemovedWhenSomethingElseGetsIn(t *testing.T) {
1007 t.Parallel()
1008 f := browserFixture(t)
1009 sess, cookie := f.browserSession(t, f.ownerID)
1010 only := identity.Credential{ID: []byte("passkey-only"), PublicKey: []byte("key-only"), Label: "only"}
1011 if err := f.srv.Identity.AddCredential(f.ownerID, only, "test"); err != nil {
1012 t.Fatal(err)
1013 }
1014 // Model an account whose only credential is the passkey: the fixture's owner was
1015 // created with an SSH key.
1016 a, err := f.srv.Identity.Find(f.ownerID)
1017 if err != nil {
1018 t.Fatal(err)
1019 }
1020 for _, key := range a.Keys {
1021 if err := f.srv.Identity.RemoveKey(f.ownerID, key.Fingerprint, "test"); err != nil {
1022 t.Fatal(err)
1023 }
1024 }
1025
1026 resp, body := f.browserPost(t, "/-/account/passkeys/remove", cookie, url.Values{
1027 "csrf": {sess.CSRF}, "credential": {identity.CredentialIDString(only.ID)},
1028 })
1029 if resp.StatusCode != http.StatusConflict {
1030 t.Fatalf("removing the only way in: status=%d body=%s", resp.StatusCode, body)
1031 }
1032 if !strings.Contains(body, "only way into your account") {
1033 t.Fatalf("the refusal does not explain itself:\n%s", body)
1034 }
1035 if creds, _ := f.srv.Identity.Credentials(f.ownerID); len(creds) != 1 {
1036 t.Fatal("the refused removal took the passkey anyway")
1037 }
1038
1039 // An SSH key is a way in — it pushes and clones — so the same removal is allowed.
1040 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_last_way_in"))
1041 resp, body = f.browserPost(t, "/-/account/keys", cookie, url.Values{"csrf": {sess.CSRF}, "key": {pub}})
1042 if resp.StatusCode != http.StatusSeeOther {
1043 t.Fatalf("add key: status=%d body=%s", resp.StatusCode, body)
1044 }
1045 resp, body = f.browserPost(t, "/-/account/passkeys/remove", cookie, url.Values{
1046 "csrf": {sess.CSRF}, "credential": {identity.CredentialIDString(only.ID)},
1047 })
1048 if resp.StatusCode != http.StatusSeeOther {
1049 t.Fatalf("removing a passkey with an SSH key on the account: status=%d body=%s", resp.StatusCode, body)
1050 }
1051 }