email verify: say when a code belongs to another account !109

merged merged by cmc on 2026-08-28 22:13 UTC · krz/gitbay:verify-wrong-account into main

7 files changed, +51 −27

Layout: unified · split

internal/control/register.go +13 −1
@@ -73,9 +73,21 @@ func runEmailVerify(c *Ctx, args []string) int {
73 if len(args) != 1 { 73 if len(args) != 1 {
74 return c.fail(protocol.ExitUsage, "usage: email verify <code>") 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 if err != nil { 78 if err != nil {
78 if errors.Is(err, store.ErrNotFound) { 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 return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used") 91 return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used")
80 } 92 }
81 return c.fail(protocol.ExitFailure, "%v", err) 93 return c.fail(protocol.ExitFailure, "%v", err)
internal/policy/codeowners_test.go +4 −4
@@ -24,10 +24,10 @@ internal/* @grace
24 {"main.go", []string{"bob", "carol"}}, 24 {"main.go", []string{"bob", "carol"}},
25 {"deep/nested/thing.go", []string{"bob", "carol"}}, 25 {"deep/nested/thing.go", []string{"bob", "carol"}},
26 {"docs/users.org", []string{"dana"}}, 26 {"docs/users.org", []string{"dana"}},
27 {"sub/docs/x.md", []string{"dana"}}, // unanchored dir matches anywhere 27 {"sub/docs/x.md", []string{"dana"}}, // unanchored dir matches anywhere
28 {"deploy/cloud-init.yaml", []string{"erin"}}, // anchored dir 28 {"deploy/cloud-init.yaml", []string{"erin"}}, // anchored dir
29 {"cmd/gitbay/main.go", []string{"frank"}}, // exact anchored path beats *.go (later rule) 29 {"cmd/gitbay/main.go", []string{"frank"}}, // exact anchored path beats *.go (later rule)
30 {"internal/policy", []string{"grace"}}, // single-segment glob 30 {"internal/policy", []string{"grace"}}, // single-segment glob
31 } 31 }
32 for _, tc := range cases { 32 for _, tc := range cases {
33 if got := OwnersFor(rules, tc.path); !reflect.DeepEqual(got, tc.want) { 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 invalid := []string{ 13 invalid := []string{
14 "", 14 "",
15 "Alice", // uppercase 15 "Alice", // uppercase
16 "-lead", // bad first char 16 "-lead", // bad first char
17 ".hidden", // bad first char 17 ".hidden", // bad first char
18 "a b", // space 18 "a b", // space
19 "repo.git", // .git suffix 19 "repo.git", // .git suffix
20 "..", // 20 "..", //
21 "login", // reserved 21 "login", // reserved
22 "admin", // reserved 22 "admin", // reserved
23 "static", // reserved 23 "static", // reserved
24 "api", // reserved 24 "api", // reserved
25 "register", // reserved 25 "register", // reserved
26 } 26 }
27 for _, n := range invalid { 27 for _, n := range invalid {
28 if err := ValidateOwnerName(n); err == nil { 28 if err := ValidateOwnerName(n); err == nil {
internal/protocol/protocol.go +6 −6
@@ -10,12 +10,12 @@ const Version = 1
10 10
11// Exit codes shared by the CLI and by control commands run over bare ssh. 11// Exit codes shared by the CLI and by control commands run over bare ssh.
12const ( 12const (
13 ExitOK = 0 13 ExitOK = 0
14 ExitFailure = 1 // general failure 14 ExitFailure = 1 // general failure
15 ExitUsage = 2 // usage error 15 ExitUsage = 2 // usage error
16 ExitNotFound = 3 16 ExitNotFound = 3
17 ExitDenied = 4 17 ExitDenied = 4
18 ExitProtocol = 5 // server/protocol error 18 ExitProtocol = 5 // server/protocol error
19) 19)
20 20
21// Envelope wraps every JSON response from a control command. 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 return address, err 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.
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
56// CreateRegisteredUser makes a self-registered account, pending until its 68// CreateRegisteredUser makes a self-registered account, pending until its
57// email is verified. 69// email is verified.
58func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) { 70func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) {
internal/store/webhooks.go +1 −1
@@ -1,7 +1,7 @@
1package store 1package store
2 2
3import ( 3import (
4 "time" 4 "time"
5) 5)
6 6
7type Webhook struct { 7type Webhook struct {
internal/webhook/webhook.go +4 −4
@@ -55,11 +55,11 @@ func isForbidden(ip net.IP) bool {
55} 55}
56 56
57type Deliverer struct { 57type Deliverer struct {
58 St *store.Store 58 St *store.Store
59 AllowLocal bool 59 AllowLocal bool
60 RetryBase time.Duration // first retry delay; doubles per attempt 60 RetryBase time.Duration // first retry delay; doubles per attempt
61 MaxAttempts int 61 MaxAttempts int
62 client *http.Client 62 client *http.Client
63} 63}
64 64
65// New builds a deliverer whose dialer re-checks resolved addresses at 65// New builds a deliverer whose dialer re-checks resolved addresses at