Commit 8e41115b91
Verified · cmc
cmd/gitbay/main.go +5
| @@ -214,6 +214,11 @@ func repoCmd() *cobra.Command { | ||
| 214 | 214 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), |
| 215 | 215 | local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone), |
| 216 | 216 | importCmd(), |
| 217 | group("deploy-key", "repository-bound CI keys", | |
| 218 | pass("add", "bind a key: [--rw] < key.pub", passOpts{server: []string{"repo", "deploy-key", "add"}, needsRepo: true, stdinOK: true}), | |
| 219 | pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}), | |
| 220 | pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}), | |
| 221 | ), | |
| 217 | 222 | group("access", "manage access grants", |
| 218 | 223 | pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}), |
| 219 | 224 | pass("revoke", "revoke access: ... <user>", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}), |
e2e/deploykey_test.go added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | func TestDeployKeys(t *testing.T) { | |
| 12 | inst := startInstance(t) | |
| 13 | aliceKey := inst.newKey(t, "alice") | |
| 14 | bobKey := inst.newKey(t, "bob") | |
| 15 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 16 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 17 | ||
| 18 | // Private repo with content. | |
| 19 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app", "--private"); code != 0 { | |
| 20 | t.Fatalf("repo create: %s", errOut) | |
| 21 | } | |
| 22 | work := t.TempDir() | |
| 23 | env := inst.gitEnv(aliceKey) | |
| 24 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 25 | dir := filepath.Join(work, "w") | |
| 26 | os.WriteFile(filepath.Join(dir, "app.txt"), []byte("v1\n"), 0o644) | |
| 27 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 28 | mustGit(t, dir, env, "add", ".") | |
| 29 | mustGit(t, dir, env, "commit", "-q", "-m", "init") | |
| 30 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 31 | ||
| 32 | // Bind a read-only deploy key; non-admins cannot. | |
| 33 | roKey := inst.newKey(t, "ci-ro") | |
| 34 | roPub, _ := os.ReadFile(roKey + ".pub") | |
| 35 | if _, _, code := inst.ssh(t, bobKey, string(roPub), "repo", "deploy-key", "add", "alice/app"); code != 3 { | |
| 36 | t.Fatalf("non-admin deploy-key add on private repo: want not-found parity") | |
| 37 | } | |
| 38 | out, errOut, code := inst.ssh(t, aliceKey, string(roPub), "repo", "deploy-key", "add", "alice/app", "--json") | |
| 39 | if code != 0 { | |
| 40 | t.Fatalf("deploy-key add: %s", errOut) | |
| 41 | } | |
| 42 | var env2 struct { | |
| 43 | Data struct { | |
| 44 | Fingerprint string `json:"fingerprint"` | |
| 45 | } `json:"data"` | |
| 46 | } | |
| 47 | json.Unmarshal([]byte(out), &env2) | |
| 48 | roFP := env2.Data.Fingerprint | |
| 49 | ||
| 50 | // The ro key clones the private repo but cannot push, cannot touch any | |
| 51 | // other repo, and cannot run control commands. | |
| 52 | roEnv := inst.gitEnv(roKey) | |
| 53 | roWork := t.TempDir() | |
| 54 | mustGit(t, roWork, roEnv, "clone", inst.sshURL("alice/app"), "w") | |
| 55 | roDir := filepath.Join(roWork, "w") | |
| 56 | mustGit(t, roDir, roEnv, "commit", "-q", "--allow-empty", "-m", "try") | |
| 57 | if out, code := gitRun(t, roDir, roEnv, "push", "origin", "main"); code == 0 { | |
| 58 | t.Fatalf("ro deploy key pushed:\n%s", out) | |
| 59 | } | |
| 60 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/other", "--private"); code != 0 { | |
| 61 | t.Fatal("other repo failed") | |
| 62 | } | |
| 63 | if out, code := gitRun(t, t.TempDir(), roEnv, "clone", inst.sshURL("alice/other")); code == 0 || !strings.Contains(out, "repository not found") { | |
| 64 | t.Fatalf("deploy key crossed repos: %d\n%s", code, out) | |
| 65 | } | |
| 66 | if _, _, code := inst.ssh(t, roKey, "", "whoami"); code != 4 { | |
| 67 | t.Fatal("deploy key ran a control command") | |
| 68 | } | |
| 69 | ||
| 70 | // An rw key pushes. | |
| 71 | rwKey := inst.newKey(t, "ci-rw") | |
| 72 | rwPub, _ := os.ReadFile(rwKey + ".pub") | |
| 73 | if _, errOut, code = inst.ssh(t, aliceKey, string(rwPub), "repo", "deploy-key", "add", "alice/app", "--rw"); code != 0 { | |
| 74 | t.Fatalf("rw add: %s", errOut) | |
| 75 | } | |
| 76 | rwEnv := inst.gitEnv(rwKey) | |
| 77 | rwWork := t.TempDir() | |
| 78 | mustGit(t, rwWork, rwEnv, "clone", inst.sshURL("alice/app"), "w") | |
| 79 | rwDir := filepath.Join(rwWork, "w") | |
| 80 | mustGit(t, rwDir, rwEnv, "commit", "-q", "--allow-empty", "-m", "ci push") | |
| 81 | mustGit(t, rwDir, rwEnv, "push", "-q", "origin", "main") | |
| 82 | ||
| 83 | // The binding survives an owner rename (keys bind to the repo ID). | |
| 84 | if _, errOut, code = inst.ssh(t, aliceKey, "", "org", "create", "moved"); code != 0 { | |
| 85 | t.Fatalf("org create: %s", errOut) | |
| 86 | } | |
| 87 | if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/app", "moved"); code != 0 { | |
| 88 | t.Fatalf("transfer: %s", errOut) | |
| 89 | } | |
| 90 | mustGit(t, t.TempDir(), roEnv, "clone", inst.sshURL("moved/app")) | |
| 91 | ||
| 92 | // List shows both with modes; removal severs access immediately. | |
| 93 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "deploy-key", "list", "moved/app") | |
| 94 | if !strings.Contains(out, "ro") || !strings.Contains(out, "rw") { | |
| 95 | t.Fatalf("deploy-key list: %s", out) | |
| 96 | } | |
| 97 | if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "deploy-key", "remove", "moved/app", roFP); code != 0 { | |
| 98 | t.Fatalf("remove: %s", errOut) | |
| 99 | } | |
| 100 | if out, code := gitRun(t, t.TempDir(), roEnv, "clone", inst.sshURL("moved/app")); code == 0 { | |
| 101 | t.Fatalf("removed deploy key still works:\n%s", out) | |
| 102 | } | |
| 103 | } | |
internal/control/deploykey.go added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | ||
| 8 | "golang.org/x/crypto/ssh" | |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/policy" | |
| 11 | "gitbay.org/gitbay/internal/protocol" | |
| 12 | "gitbay.org/gitbay/internal/store" | |
| 13 | ) | |
| 14 | ||
| 15 | func init() { | |
| 16 | register(Command{Path: []string{"repo", "deploy-key", "add"}, | |
| 17 | Summary: "bind a read-only (or --rw) key to one repository: repo deploy-key add <owner/name> [--rw] < key.pub", | |
| 18 | ReadsStdin: true, Run: runDeployKeyAdd}) | |
| 19 | register(Command{Path: []string{"repo", "deploy-key", "list"}, | |
| 20 | Summary: "list deploy keys: repo deploy-key list <owner/name>", ReadOnly: true, Run: runDeployKeyList}) | |
| 21 | register(Command{Path: []string{"repo", "deploy-key", "remove"}, | |
| 22 | Summary: "remove a deploy key: repo deploy-key remove <owner/name> <fingerprint>", Run: runDeployKeyRemove}) | |
| 23 | } | |
| 24 | ||
| 25 | func runDeployKeyAdd(c *Ctx, args []string) int { | |
| 26 | mode := "ro" | |
| 27 | var path string | |
| 28 | for _, a := range args { | |
| 29 | switch a { | |
| 30 | case "--rw": | |
| 31 | mode = "rw" | |
| 32 | default: | |
| 33 | if path != "" { | |
| 34 | return c.fail(protocol.ExitUsage, "usage: repo deploy-key add <owner/name> [--rw] < key.pub") | |
| 35 | } | |
| 36 | path = a | |
| 37 | } | |
| 38 | } | |
| 39 | if path == "" { | |
| 40 | return c.fail(protocol.ExitUsage, "usage: repo deploy-key add <owner/name> [--rw] < key.pub") | |
| 41 | } | |
| 42 | repo, code := resolveRepo(c, path, policy.CanAdmin) | |
| 43 | if code >= 0 { | |
| 44 | return code | |
| 45 | } | |
| 46 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) | |
| 47 | if err != nil { | |
| 48 | return c.fail(protocol.ExitFailure, "reading key: %v", err) | |
| 49 | } | |
| 50 | pub, _, _, _, err := ssh.ParseAuthorizedKey(raw) | |
| 51 | if err != nil { | |
| 52 | return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err) | |
| 53 | } | |
| 54 | fp := ssh.FingerprintSHA256(pub) | |
| 55 | scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode) | |
| 56 | if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil { | |
| 57 | if errors.Is(err, store.ErrDuplicateKey) { | |
| 58 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 59 | } | |
| 60 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 61 | } | |
| 62 | return c.emit(map[string]string{"fingerprint": fp, "mode": mode}, func(w io.Writer) { | |
| 63 | fmt.Fprintf(w, "deploy key %s (%s) bound to %s\n", fp, mode, repo.Path()) | |
| 64 | }) | |
| 65 | } | |
| 66 | ||
| 67 | func runDeployKeyList(c *Ctx, args []string) int { | |
| 68 | if len(args) != 1 { | |
| 69 | return c.fail(protocol.ExitUsage, "usage: repo deploy-key list <owner/name>") | |
| 70 | } | |
| 71 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 72 | if code >= 0 { | |
| 73 | return code | |
| 74 | } | |
| 75 | keys, err := c.Store.ListDeployKeys(repo.ID) | |
| 76 | if err != nil { | |
| 77 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 78 | } | |
| 79 | type out struct { | |
| 80 | Fingerprint string `json:"fingerprint"` | |
| 81 | Algo string `json:"algo"` | |
| 82 | Mode string `json:"mode"` | |
| 83 | } | |
| 84 | var ds []out | |
| 85 | for _, k := range keys { | |
| 86 | mode := "ro" | |
| 87 | if policy.DeployScopeAllows(k.Scope, repo.ID, true) { | |
| 88 | mode = "rw" | |
| 89 | } | |
| 90 | ds = append(ds, out{k.Fingerprint, k.Algo, mode}) | |
| 91 | } | |
| 92 | return c.emit(ds, func(w io.Writer) { | |
| 93 | for _, d := range ds { | |
| 94 | fmt.Fprintf(w, "%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Mode) | |
| 95 | } | |
| 96 | }) | |
| 97 | } | |
| 98 | ||
| 99 | func runDeployKeyRemove(c *Ctx, args []string) int { | |
| 100 | if len(args) != 2 { | |
| 101 | return c.fail(protocol.ExitUsage, "usage: repo deploy-key remove <owner/name> <fingerprint>") | |
| 102 | } | |
| 103 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 104 | if code >= 0 { | |
| 105 | return code | |
| 106 | } | |
| 107 | if err := c.Store.RemoveDeployKey(repo.ID, args[1]); err != nil { | |
| 108 | if errors.Is(err, store.ErrNotFound) { | |
| 109 | return c.fail(protocol.ExitNotFound, "no deploy key %s on %s", args[1], repo.Path()) | |
| 110 | } | |
| 111 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 112 | } | |
| 113 | return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) { | |
| 114 | fmt.Fprintf(w, "removed deploy key %s from %s\n", args[1], repo.Path()) | |
| 115 | }) | |
| 116 | } | |
internal/policy/access.go +15 −4
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package policy |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "strconv" | |
| 4 | 5 | "strings" |
| 5 | 6 | |
| 6 | 7 | "gitbay.org/gitbay/internal/store" |
| @@ -39,19 +40,26 @@ func isOwner(user store.User, repo store.Repo) bool { | ||
| 39 | 40 | return repo.OwnerKind == "user" && repo.OwnerID == user.ID |
| 40 | 41 | } |
| 41 | 42 | |
| 42 | // ScopeAllowsGit reports whether an SSH key scope permits the requested git | |
| 43 | // transport on repoPath ("owner/name"). write=true for receive-pack. | |
| 43 | // ScopeAllowsGit reports whether an account-scoped SSH key permits git | |
| 44 | // transport at all. Deploy scopes are decided by DeployScopeAllows instead. | |
| 44 | 45 | func ScopeAllowsGit(scope, repoPath string, write bool) bool { |
| 45 | 46 | switch scope { |
| 46 | 47 | case "full", "git": |
| 47 | 48 | return true |
| 48 | 49 | } |
| 50 | return false | |
| 51 | } | |
| 52 | ||
| 53 | // DeployScopeAllows authorizes a deploy key purely by its scope: the key is | |
| 54 | // bound to a repository ID (rename- and transfer-proof), grants nothing | |
| 55 | // anywhere else, and never inherits the access of whoever registered it. | |
| 56 | func DeployScopeAllows(scope string, repoID int64, write bool) bool { | |
| 49 | 57 | rest, ok := strings.CutPrefix(scope, "deploy:") |
| 50 | 58 | if !ok { |
| 51 | 59 | return false |
| 52 | 60 | } |
| 53 | target, mode, ok := strings.Cut(rest, ":") | |
| 54 | if !ok || target != repoPath { | |
| 61 | idStr, mode, ok := strings.Cut(rest, ":") | |
| 62 | if !ok || idStr != strconv.FormatInt(repoID, 10) { | |
| 55 | 63 | return false |
| 56 | 64 | } |
| 57 | 65 | switch mode { |
| @@ -63,6 +71,9 @@ func ScopeAllowsGit(scope, repoPath string, write bool) bool { | ||
| 63 | 71 | return false |
| 64 | 72 | } |
| 65 | 73 | |
| 74 | // IsDeployScope reports whether a key scope is a deploy binding. | |
| 75 | func IsDeployScope(scope string) bool { return strings.HasPrefix(scope, "deploy:") } | |
| 76 | ||
| 66 | 77 | // RefUpdate is one proposed ref change, with git facts computed by the hook |
| 67 | 78 | // process (which can see quarantined objects; the daemon cannot). |
| 68 | 79 | type RefUpdate struct { |
internal/policy/access_test.go +22 −5
| @@ -55,11 +55,7 @@ func TestScopeAllowsGit(t *testing.T) { | ||
| 55 | 55 | }{ |
| 56 | 56 | {"full", "a/b", true, true}, |
| 57 | 57 | {"git", "a/b", true, true}, |
| 58 | {"deploy:a/b:ro", "a/b", false, true}, | |
| 59 | {"deploy:a/b:ro", "a/b", true, false}, | |
| 60 | {"deploy:a/b:rw", "a/b", true, true}, | |
| 61 | {"deploy:a/b:rw", "a/c", false, false}, // wrong repo | |
| 62 | {"deploy:a/b", "a/b", false, false}, // malformed | |
| 58 | {"deploy:7:ro", "a/b", false, false}, // deploy keys never pass the account path | |
| 63 | 59 | {"", "a/b", false, false}, |
| 64 | 60 | } |
| 65 | 61 | for _, tc := range cases { |
| @@ -69,6 +65,27 @@ func TestScopeAllowsGit(t *testing.T) { | ||
| 69 | 65 | } |
| 70 | 66 | } |
| 71 | 67 | |
| 68 | func TestDeployScopeAllows(t *testing.T) { | |
| 69 | cases := []struct { | |
| 70 | scope string | |
| 71 | repoID int64 | |
| 72 | write bool | |
| 73 | want bool | |
| 74 | }{ | |
| 75 | {"deploy:7:ro", 7, false, true}, | |
| 76 | {"deploy:7:ro", 7, true, false}, | |
| 77 | {"deploy:7:rw", 7, true, true}, | |
| 78 | {"deploy:7:rw", 8, false, false}, // wrong repo | |
| 79 | {"deploy:7", 7, false, false}, // malformed | |
| 80 | {"full", 7, false, false}, // not a deploy scope | |
| 81 | } | |
| 82 | for _, tc := range cases { | |
| 83 | if got := DeployScopeAllows(tc.scope, tc.repoID, tc.write); got != tc.want { | |
| 84 | t.Errorf("DeployScopeAllows(%q, %d, write=%v) = %v, want %v", tc.scope, tc.repoID, tc.write, got, tc.want) | |
| 85 | } | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 72 | 89 | func TestCheckPush(t *testing.T) { |
| 73 | 90 | repo := store.Repo{Settings: store.RepoSettings{ProtectedBranches: []string{"main"}}} |
| 74 | 91 | cases := []struct { |
internal/sshd/sshd.go +27 −17
| @@ -270,23 +270,33 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a | ||
| 270 | 270 | fmt.Fprintln(stderr, "repository not found") |
| 271 | 271 | return protocol.ExitNotFound |
| 272 | 272 | } |
| 273 | grant, err := st.AccessRole(repo.ID, user.ID) | |
| 274 | if err != nil { | |
| 275 | fmt.Fprintln(stderr, "internal error") | |
| 276 | return protocol.ExitFailure | |
| 277 | } | |
| 278 | if !policy.CanRead(user, repo, grant) { | |
| 279 | // Same answer as nonexistence: private repos must not be enumerable. | |
| 280 | fmt.Fprintln(stderr, "repository not found") | |
| 281 | return protocol.ExitNotFound | |
| 282 | } | |
| 283 | if !policy.ScopeAllowsGit(scope, repo.Path(), write) { | |
| 284 | fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path()) | |
| 285 | return protocol.ExitDenied | |
| 286 | } | |
| 287 | if write && !policy.CanWrite(user, repo, grant) { | |
| 288 | fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path()) | |
| 289 | return protocol.ExitDenied | |
| 273 | if policy.IsDeployScope(scope) { | |
| 274 | // A deploy key authorizes by its binding alone: one repository, | |
| 275 | // its mode, nothing inherited from whoever registered it. Any | |
| 276 | // mismatch reads as nonexistence, same as the access rules. | |
| 277 | if !policy.DeployScopeAllows(scope, repo.ID, write) { | |
| 278 | fmt.Fprintln(stderr, "repository not found") | |
| 279 | return protocol.ExitNotFound | |
| 280 | } | |
| 281 | } else { | |
| 282 | grant, err := st.AccessRole(repo.ID, user.ID) | |
| 283 | if err != nil { | |
| 284 | fmt.Fprintln(stderr, "internal error") | |
| 285 | return protocol.ExitFailure | |
| 286 | } | |
| 287 | if !policy.CanRead(user, repo, grant) { | |
| 288 | // Same answer as nonexistence: private repos must not be enumerable. | |
| 289 | fmt.Fprintln(stderr, "repository not found") | |
| 290 | return protocol.ExitNotFound | |
| 291 | } | |
| 292 | if !policy.ScopeAllowsGit(scope, repo.Path(), write) { | |
| 293 | fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path()) | |
| 294 | return protocol.ExitDenied | |
| 295 | } | |
| 296 | if write && !policy.CanWrite(user, repo, grant) { | |
| 297 | fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path()) | |
| 298 | return protocol.ExitDenied | |
| 299 | } | |
| 290 | 300 | } |
| 291 | 301 | |
| 292 | 302 | dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name) |
internal/store/users.go +43
| @@ -217,3 +217,46 @@ func (s *Store) SSHKeyByID(id int64) (SSHKey, error) { | ||
| 217 | 217 | } |
| 218 | 218 | return k, err |
| 219 | 219 | } |
| 220 | ||
| 221 | // ListDeployKeys returns the deploy keys bound to a repository. | |
| 222 | func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) { | |
| 223 | rows, err := s.DB.Query( | |
| 224 | "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id", | |
| 225 | repoID) | |
| 226 | if err != nil { | |
| 227 | return nil, err | |
| 228 | } | |
| 229 | defer rows.Close() | |
| 230 | var keys []SSHKey | |
| 231 | for rows.Next() { | |
| 232 | var k SSHKey | |
| 233 | if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope); err != nil { | |
| 234 | return nil, err | |
| 235 | } | |
| 236 | keys = append(keys, k) | |
| 237 | } | |
| 238 | return keys, rows.Err() | |
| 239 | } | |
| 240 | ||
| 241 | // RemoveDeployKey removes a deploy key from a repository by fingerprint; | |
| 242 | // any repo admin may remove it regardless of who added it. | |
| 243 | func (s *Store) RemoveDeployKey(repoID int64, fingerprint string) error { | |
| 244 | tx, err := s.DB.Begin() | |
| 245 | if err != nil { | |
| 246 | return err | |
| 247 | } | |
| 248 | defer tx.Rollback() | |
| 249 | res, err := tx.Exec( | |
| 250 | "DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%'", | |
| 251 | fingerprint, repoID) | |
| 252 | if err != nil { | |
| 253 | return err | |
| 254 | } | |
| 255 | if n, _ := res.RowsAffected(); n == 0 { | |
| 256 | return ErrNotFound | |
| 257 | } | |
| 258 | if err := bumpKeyEpoch(tx); err != nil { | |
| 259 | return err | |
| 260 | } | |
| 261 | return tx.Commit() | |
| 262 | } | |