A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

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 {
7373 if len(args) != 1 {
7474 return c.fail(protocol.ExitUsage, "usage: email verify <code>")
7575 }
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)
7778 if err != nil {
7879 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 }
7991 return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used")
8092 }
8193 return c.fail(protocol.ExitFailure, "%v", err)
internal/policy/codeowners_test.go +4 −4
@@ -24,10 +24,10 @@ internal/* @grace
2424 {"main.go", []string{"bob", "carol"}},
2525 {"deep/nested/thing.go", []string{"bob", "carol"}},
2626 {"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
3131 }
3232 for _, tc := range cases {
3333 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) {
1212
1313 invalid := []string{
1414 "",
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
2626 }
2727 for _, n := range invalid {
2828 if err := ValidateOwnerName(n); err == nil {
internal/protocol/protocol.go +6 −6
@@ -10,12 +10,12 @@ const Version = 1
1010
1111 // Exit codes shared by the CLI and by control commands run over bare ssh.
1212 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
1919 )
2020
2121 // 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
5353 return address, err
5454 }
5555
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.
59func (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
5668 // CreateRegisteredUser makes a self-registered account, pending until its
5769 // email is verified.
5870 func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) {
internal/store/webhooks.go +1 −1
@@ -1,7 +1,7 @@
11 package store
22
33 import (
4 "time"
4 "time"
55 )
66
77 type Webhook struct {
internal/webhook/webhook.go +4 −4
@@ -55,11 +55,11 @@ func isForbidden(ip net.IP) bool {
5555 }
5656
5757 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
6161 MaxAttempts int
62 client *http.Client
62 client *http.Client
6363 }
6464
6565 // New builds a deliverer whose dialer re-checks resolved addresses at