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