Commit 6020b2b81e
6020b2b81eb399ecfc81886c697511924fb5975f
parent: de43af4650
Verified · cmc ci/build: success
cmc <hello@cleberg.net> · 2026-08-28T22:06:27Z
email verify: say when a code belongs to another account
The lookup is scoped to the authenticated user, so a code minted for
someone else came back "invalid, expired, or already used" — which is
wrong on all three counts when the code is fine and the key is not.
Running the command with a default SSH key authenticates as whichever
account owns that key, and nothing said so.
It now checks whether a live code exists under another user and names
the account you actually authenticated as. It never names the owner:
that would make a guessed code an oracle for which accounts exist.
internal/control/register.go
+13 −1
| @@ -73,9 +73,21 @@ func runEmailVerify(c *Ctx, args []string) int { |
| 73 | 73 | if len(args) != 1 { |
| 74 | 74 | return c.fail(protocol.ExitUsage, "usage: email verify <code>") |
| 75 | 75 | } |
| 76 | | address, err := c.Store.ConsumeEmailToken(c.User.ID, store.HashToken(args[0])) |
| 76 | hash := store.HashToken(args[0]) |
| 77 | address, err := c.Store.ConsumeEmailToken(c.User.ID, hash) |
| 77 | 78 | if err != nil { |
| 78 | 79 | if errors.Is(err, store.ErrNotFound) { |
| 80 | // A code is scoped to the account that asked for it. Running |
| 81 | // this with the wrong key authenticates as the wrong account |
| 82 | // and looks exactly like a bad code, which is misleading when |
| 83 | // the code is fine and the key is not. |
| 84 | if other, e := c.Store.EmailTokenBelongsToAnotherUser(c.User.ID, hash); e == nil && other { |
| 85 | return c.fail(protocol.ExitDenied, |
| 86 | "that code belongs to a different account; this key authenticated you as %s. "+ |
| 87 | "Re-run with the key registered to the account being verified: "+ |
| 88 | "ssh -i <that key> git@<host> email verify <code>", |
| 89 | c.User.Username) |
| 90 | } |
| 79 | 91 | return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used") |
| 80 | 92 | } |
| 81 | 93 | return c.fail(protocol.ExitFailure, "%v", err) |
internal/policy/codeowners_test.go
+4 −4
| @@ -24,10 +24,10 @@ internal/* @grace |
| 24 | 24 | {"main.go", []string{"bob", "carol"}}, |
| 25 | 25 | {"deep/nested/thing.go", []string{"bob", "carol"}}, |
| 26 | 26 | {"docs/users.org", []string{"dana"}}, |
| 27 | | {"sub/docs/x.md", []string{"dana"}}, // unanchored dir matches anywhere |
| 28 | | {"deploy/cloud-init.yaml", []string{"erin"}}, // anchored dir |
| 29 | | {"cmd/gitbay/main.go", []string{"frank"}}, // exact anchored path beats *.go (later rule) |
| 30 | | {"internal/policy", []string{"grace"}}, // single-segment glob |
| 27 | {"sub/docs/x.md", []string{"dana"}}, // unanchored dir matches anywhere |
| 28 | {"deploy/cloud-init.yaml", []string{"erin"}}, // anchored dir |
| 29 | {"cmd/gitbay/main.go", []string{"frank"}}, // exact anchored path beats *.go (later rule) |
| 30 | {"internal/policy", []string{"grace"}}, // single-segment glob |
| 31 | 31 | } |
| 32 | 32 | for _, tc := range cases { |
| 33 | 33 | if got := OwnersFor(rules, tc.path); !reflect.DeepEqual(got, tc.want) { |
internal/policy/names_test.go
+11 −11
| @@ -12,17 +12,17 @@ func TestValidateOwnerName(t *testing.T) { |
| 12 | 12 | |
| 13 | 13 | invalid := []string{ |
| 14 | 14 | "", |
| 15 | | "Alice", // uppercase |
| 16 | | "-lead", // bad first char |
| 17 | | ".hidden", // bad first char |
| 18 | | "a b", // space |
| 19 | | "repo.git", // .git suffix |
| 20 | | "..", // |
| 21 | | "login", // reserved |
| 22 | | "admin", // reserved |
| 23 | | "static", // reserved |
| 24 | | "api", // reserved |
| 25 | | "register", // reserved |
| 15 | "Alice", // uppercase |
| 16 | "-lead", // bad first char |
| 17 | ".hidden", // bad first char |
| 18 | "a b", // space |
| 19 | "repo.git", // .git suffix |
| 20 | "..", // |
| 21 | "login", // reserved |
| 22 | "admin", // reserved |
| 23 | "static", // reserved |
| 24 | "api", // reserved |
| 25 | "register", // reserved |
| 26 | 26 | } |
| 27 | 27 | for _, n := range invalid { |
| 28 | 28 | if err := ValidateOwnerName(n); err == nil { |
internal/protocol/protocol.go
+6 −6
| @@ -10,12 +10,12 @@ const Version = 1 |
| 10 | 10 | |
| 11 | 11 | // Exit codes shared by the CLI and by control commands run over bare ssh. |
| 12 | 12 | const ( |
| 13 | | ExitOK = 0 |
| 14 | | ExitFailure = 1 // general failure |
| 15 | | ExitUsage = 2 // usage error |
| 16 | | ExitNotFound = 3 |
| 17 | | ExitDenied = 4 |
| 18 | | ExitProtocol = 5 // server/protocol error |
| 13 | ExitOK = 0 |
| 14 | ExitFailure = 1 // general failure |
| 15 | ExitUsage = 2 // usage error |
| 16 | ExitNotFound = 3 |
| 17 | ExitDenied = 4 |
| 18 | ExitProtocol = 5 // server/protocol error |
| 19 | 19 | ) |
| 20 | 20 | |
| 21 | 21 | // Envelope wraps every JSON response from a control command. |
internal/store/registration.go
+12
| @@ -53,6 +53,18 @@ func (s *Store) ConsumeEmailToken(userID int64, tokenHash string) (string, error |
| 53 | 53 | return address, err |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | // EmailTokenBelongsToAnotherUser reports whether a live code exists but |
| 57 | // is owned by someone else. It answers only yes or no: naming the owner |
| 58 | // would turn a guessed code into an account oracle. |
| 59 | func (s *Store) EmailTokenBelongsToAnotherUser(userID int64, tokenHash string) (bool, error) { |
| 60 | var n int |
| 61 | err := s.DB.QueryRow(` |
| 62 | SELECT COUNT(*) FROM email_tokens |
| 63 | WHERE token_hash = ? AND user_id != ? AND used_at IS NULL AND expires_at > ?`, |
| 64 | tokenHash, userID, fmtTime(time.Now())).Scan(&n) |
| 65 | return n > 0, err |
| 66 | } |
| 67 | |
| 56 | 68 | // CreateRegisteredUser makes a self-registered account, pending until its |
| 57 | 69 | // email is verified. |
| 58 | 70 | func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) { |
internal/store/webhooks.go
+1 −1
| @@ -1,7 +1,7 @@ |
| 1 | 1 | package store |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | | "time" |
| 4 | "time" |
| 5 | 5 | ) |
| 6 | 6 | |
| 7 | 7 | type Webhook struct { |
internal/webhook/webhook.go
+4 −4
| @@ -55,11 +55,11 @@ func isForbidden(ip net.IP) bool { |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | 57 | type Deliverer struct { |
| 58 | | St *store.Store |
| 59 | | AllowLocal bool |
| 60 | | RetryBase time.Duration // first retry delay; doubles per attempt |
| 58 | St *store.Store |
| 59 | AllowLocal bool |
| 60 | RetryBase time.Duration // first retry delay; doubles per attempt |
| 61 | 61 | MaxAttempts int |
| 62 | | client *http.Client |
| 62 | client *http.Client |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | 65 | // New builds a deliverer whose dialer re-checks resolved addresses at |