Skip to content

Commit c9505a2

Browse files
ChristopherHXlunnytechknowlogickwxiaoguang
authored
Improve instance wide ssh commit signing (#34341)
* Signed SSH commits can look in the UI like on GitHub, just like gpg keys today in Gitea * SSH format can be added in gitea config * SSH Signing worked before with DEFAULT_TRUST_MODEL=committer `TRUSTED_SSH_KEYS` can be a list of additional ssh public key contents to trust for every user of this instance Closes #34329 Related #31392 --------- Co-authored-by: Lunny Xiao <[email protected]> Co-authored-by: techknowlogick <[email protected]> Co-authored-by: wxiaoguang <[email protected]>
1 parent fbc3796 commit c9505a2

File tree

22 files changed

+467
-122
lines changed

22 files changed

+467
-122
lines changed

custom/conf/app.example.ini

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,17 +1186,24 @@ LEVEL = Info
11861186
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
11871187
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
11881188
;;
1189-
;; GPG key to use to sign commits, Defaults to the default - that is the value of git config --get user.signingkey
1189+
;; GPG or SSH key to use to sign commits, Defaults to the default - that is the value of git config --get user.signingkey
1190+
;; Depending on the value of SIGNING_FORMAT this is either:
1191+
;; - openpgp: the GPG key ID
1192+
;; - ssh: the path to the ssh public key "/path/to/key.pub": where "/path/to/key" is the private key, use ssh-keygen -t ed25519 to generate a new key pair without password
11901193
;; run in the context of the RUN_USER
11911194
;; Switch to none to stop signing completely
11921195
;SIGNING_KEY = default
11931196
;;
1194-
;; If a SIGNING_KEY ID is provided and is not set to default, use the provided Name and Email address as the signer.
1197+
;; If a SIGNING_KEY ID is provided and is not set to default, use the provided Name and Email address as the signer and the signing format.
11951198
;; These should match a publicized name and email address for the key. (When SIGNING_KEY is default these are set to
1196-
;; the results of git config --get user.name and git config --get user.email respectively and can only be overridden
1199+
;; the results of git config --get user.name, git config --get user.email and git config --default openpgp --get gpg.format respectively and can only be overridden
11971200
;; by setting the SIGNING_KEY ID to the correct ID.)
11981201
;SIGNING_NAME =
11991202
;SIGNING_EMAIL =
1203+
;; SIGNING_FORMAT can be one of:
1204+
;; - openpgp (default): use GPG to sign commits
1205+
;; - ssh: use SSH to sign commits
1206+
;SIGNING_FORMAT = openpgp
12001207
;;
12011208
;; Sets the default trust model for repositories. Options are: collaborator, committer, collaboratorcommitter
12021209
;DEFAULT_TRUST_MODEL = collaborator
@@ -1223,6 +1230,13 @@ LEVEL = Info
12231230
;; - commitssigned: require that all the commits in the head branch are signed.
12241231
;; - approved: only sign when merging an approved pr to a protected branch
12251232
;MERGES = pubkey, twofa, basesigned, commitssigned
1233+
;;
1234+
;; Determines which additional ssh keys are trusted for all signed commits regardless of the user
1235+
;; This is useful for ssh signing key rotation.
1236+
;; Exposes the provided SIGNING_NAME and SIGNING_EMAIL as the signer, regardless of the SIGNING_FORMAT value.
1237+
;; Multiple keys should be comma separated.
1238+
;; E.g."ssh-<algorithm> <key>". or "ssh-<algorithm> <key1>, ssh-<algorithm> <key2>".
1239+
;TRUSTED_SSH_KEYS =
12261240

12271241
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
12281242
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

modules/git/command.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type Command struct {
4747
globalArgsLength int
4848
brokenArgs []string
4949
cmd *exec.Cmd // for debug purpose only
50+
configArgs []string
5051
}
5152

5253
func logArgSanitize(arg string) string {
@@ -196,6 +197,16 @@ func (c *Command) AddDashesAndList(list ...string) *Command {
196197
return c
197198
}
198199

200+
func (c *Command) AddConfig(key, value string) *Command {
201+
kv := key + "=" + value
202+
if !isSafeArgumentValue(kv) {
203+
c.brokenArgs = append(c.brokenArgs, key)
204+
} else {
205+
c.configArgs = append(c.configArgs, "-c", kv)
206+
}
207+
return c
208+
}
209+
199210
// ToTrustedCmdArgs converts a list of strings (trusted as argument) to TrustedCmdArgs
200211
// In most cases, it shouldn't be used. Use NewCommand().AddXxx() function instead
201212
func ToTrustedCmdArgs(args []string) TrustedCmdArgs {
@@ -321,7 +332,7 @@ func (c *Command) run(ctx context.Context, skip int, opts *RunOpts) error {
321332

322333
startTime := time.Now()
323334

324-
cmd := exec.CommandContext(ctx, c.prog, c.args...)
335+
cmd := exec.CommandContext(ctx, c.prog, append(c.configArgs, c.args...)...)
325336
c.cmd = cmd // for debug purpose only
326337
if opts.Env == nil {
327338
cmd.Env = os.Environ()

modules/git/key.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package git
5+
6+
// Based on https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgformat
7+
const (
8+
SigningKeyFormatOpenPGP = "openpgp" // for GPG keys, the expected default of git cli
9+
SigningKeyFormatSSH = "ssh"
10+
)
11+
12+
type SigningKey struct {
13+
KeyID string
14+
Format string
15+
}

modules/git/repo.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type GPGSettings struct {
2828
Email string
2929
Name string
3030
PublicKeyContent string
31+
Format string
3132
}
3233

3334
const prettyLogFormat = `--pretty=format:%H`

modules/git/repo_gpg.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,22 @@ package git
66

77
import (
88
"fmt"
9+
"os"
910
"strings"
1011

1112
"code.gitea.io/gitea/modules/process"
1213
)
1314

1415
// LoadPublicKeyContent will load the key from gpg
1516
func (gpgSettings *GPGSettings) LoadPublicKeyContent() error {
17+
if gpgSettings.Format == SigningKeyFormatSSH {
18+
content, err := os.ReadFile(gpgSettings.KeyID)
19+
if err != nil {
20+
return fmt.Errorf("unable to read SSH public key file: %s, %w", gpgSettings.KeyID, err)
21+
}
22+
gpgSettings.PublicKeyContent = string(content)
23+
return nil
24+
}
1625
content, stderr, err := process.GetManager().Exec(
1726
"gpg -a --export",
1827
"gpg", "-a", "--export", gpgSettings.KeyID)
@@ -44,6 +53,9 @@ func (repo *Repository) GetDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings,
4453
signingKey, _, _ := NewCommand("config", "--get", "user.signingkey").RunStdString(repo.Ctx, &RunOpts{Dir: repo.Path})
4554
gpgSettings.KeyID = strings.TrimSpace(signingKey)
4655

56+
format, _, _ := NewCommand("config", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(repo.Ctx, &RunOpts{Dir: repo.Path})
57+
gpgSettings.Format = strings.TrimSpace(format)
58+
4759
defaultEmail, _, _ := NewCommand("config", "--get", "user.email").RunStdString(repo.Ctx, &RunOpts{Dir: repo.Path})
4860
gpgSettings.Email = strings.TrimSpace(defaultEmail)
4961

modules/git/repo_tree.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
type CommitTreeOpts struct {
1616
Parents []string
1717
Message string
18-
KeyID string
18+
Key *SigningKey
1919
NoGPGSign bool
2020
AlwaysSign bool
2121
}
@@ -43,8 +43,13 @@ func (repo *Repository) CommitTree(author, committer *Signature, tree *Tree, opt
4343
_, _ = messageBytes.WriteString(opts.Message)
4444
_, _ = messageBytes.WriteString("\n")
4545

46-
if opts.KeyID != "" || opts.AlwaysSign {
47-
cmd.AddOptionFormat("-S%s", opts.KeyID)
46+
if opts.Key != nil {
47+
if opts.Key.Format != "" {
48+
cmd.AddConfig("gpg.format", opts.Key.Format)
49+
}
50+
cmd.AddOptionFormat("-S%s", opts.Key.KeyID)
51+
} else if opts.AlwaysSign {
52+
cmd.AddOptionFormat("-S")
4853
}
4954

5055
if opts.NoGPGSign {

modules/setting/repository.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,13 @@ var (
100100
SigningKey string
101101
SigningName string
102102
SigningEmail string
103+
SigningFormat string
103104
InitialCommit []string
104105
CRUDActions []string `ini:"CRUD_ACTIONS"`
105106
Merges []string
106107
Wiki []string
107108
DefaultTrustModel string
109+
TrustedSSHKeys []string `ini:"TRUSTED_SSH_KEYS"`
108110
} `ini:"repository.signing"`
109111
}{
110112
DetectedCharsetsOrder: []string{
@@ -242,20 +244,24 @@ var (
242244
SigningKey string
243245
SigningName string
244246
SigningEmail string
247+
SigningFormat string
245248
InitialCommit []string
246249
CRUDActions []string `ini:"CRUD_ACTIONS"`
247250
Merges []string
248251
Wiki []string
249252
DefaultTrustModel string
253+
TrustedSSHKeys []string `ini:"TRUSTED_SSH_KEYS"`
250254
}{
251255
SigningKey: "default",
252256
SigningName: "",
253257
SigningEmail: "",
258+
SigningFormat: "openpgp", // git.SigningKeyFormatOpenPGP
254259
InitialCommit: []string{"always"},
255260
CRUDActions: []string{"pubkey", "twofa", "parentsigned"},
256261
Merges: []string{"pubkey", "twofa", "basesigned", "commitssigned"},
257262
Wiki: []string{"never"},
258263
DefaultTrustModel: "collaborator",
264+
TrustedSSHKeys: []string{},
259265
},
260266
}
261267
RepoRootPath string

routers/api/v1/api.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,8 @@ func Routes() *web.Router {
971971
// Misc (public accessible)
972972
m.Group("", func() {
973973
m.Get("/version", misc.Version)
974-
m.Get("/signing-key.gpg", misc.SigningKey)
974+
m.Get("/signing-key.gpg", misc.SigningKeyGPG)
975+
m.Get("/signing-key.pub", misc.SigningKeySSH)
975976
m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup)
976977
m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown)
977978
m.Post("/markdown/raw", reqToken(), misc.MarkdownRaw)
@@ -1427,7 +1428,8 @@ func Routes() *web.Router {
14271428
m.Combo("/file-contents", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()).
14281429
Get(repo.GetFileContentsGet).
14291430
Post(bind(api.GetFilesOptions{}), repo.GetFileContentsPost) // POST method requires "write" permission, so we also support "GET" method above
1430-
m.Get("/signing-key.gpg", misc.SigningKey)
1431+
m.Get("/signing-key.gpg", misc.SigningKeyGPG)
1432+
m.Get("/signing-key.pub", misc.SigningKeySSH)
14311433
m.Group("/topics", func() {
14321434
m.Combo("").Get(repo.ListTopics).
14331435
Put(reqToken(), reqAdmin(), bind(api.RepoTopicOptions{}), repo.UpdateTopics)

routers/api/v1/misc/signing.go

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,35 @@
44
package misc
55

66
import (
7-
"fmt"
8-
7+
"code.gitea.io/gitea/modules/git"
98
asymkey_service "code.gitea.io/gitea/services/asymkey"
109
"code.gitea.io/gitea/services/context"
1110
)
1211

13-
// SigningKey returns the public key of the default signing key if it exists
14-
func SigningKey(ctx *context.APIContext) {
12+
func getSigningKey(ctx *context.APIContext, expectedFormat string) {
13+
// if the handler is in the repo's route group, get the repo's signing key
14+
// otherwise, get the global signing key
15+
path := ""
16+
if ctx.Repo != nil && ctx.Repo.Repository != nil {
17+
path = ctx.Repo.Repository.RepoPath()
18+
}
19+
content, format, err := asymkey_service.PublicSigningKey(ctx, path)
20+
if err != nil {
21+
ctx.APIErrorInternal(err)
22+
return
23+
}
24+
if format == "" {
25+
ctx.APIErrorNotFound("no signing key")
26+
return
27+
} else if format != expectedFormat {
28+
ctx.APIErrorNotFound("signing key format is " + format)
29+
return
30+
}
31+
_, _ = ctx.Write([]byte(content))
32+
}
33+
34+
// SigningKeyGPG returns the public key of the default signing key if it exists
35+
func SigningKeyGPG(ctx *context.APIContext) {
1536
// swagger:operation GET /signing-key.gpg miscellaneous getSigningKey
1637
// ---
1738
// summary: Get default signing-key.gpg
@@ -44,19 +65,42 @@ func SigningKey(ctx *context.APIContext) {
4465
// description: "GPG armored public key"
4566
// schema:
4667
// type: string
68+
getSigningKey(ctx, git.SigningKeyFormatOpenPGP)
69+
}
4770

48-
path := ""
49-
if ctx.Repo != nil && ctx.Repo.Repository != nil {
50-
path = ctx.Repo.Repository.RepoPath()
51-
}
71+
// SigningKeySSH returns the public key of the default signing key if it exists
72+
func SigningKeySSH(ctx *context.APIContext) {
73+
// swagger:operation GET /signing-key.pub miscellaneous getSigningKeySSH
74+
// ---
75+
// summary: Get default signing-key.pub
76+
// produces:
77+
// - text/plain
78+
// responses:
79+
// "200":
80+
// description: "ssh public key"
81+
// schema:
82+
// type: string
5283

53-
content, err := asymkey_service.PublicSigningKey(ctx, path)
54-
if err != nil {
55-
ctx.APIErrorInternal(err)
56-
return
57-
}
58-
_, err = ctx.Write([]byte(content))
59-
if err != nil {
60-
ctx.APIErrorInternal(fmt.Errorf("Error writing key content %w", err))
61-
}
84+
// swagger:operation GET /repos/{owner}/{repo}/signing-key.pub repository repoSigningKeySSH
85+
// ---
86+
// summary: Get signing-key.pub for given repository
87+
// produces:
88+
// - text/plain
89+
// parameters:
90+
// - name: owner
91+
// in: path
92+
// description: owner of the repo
93+
// type: string
94+
// required: true
95+
// - name: repo
96+
// in: path
97+
// description: name of the repo
98+
// type: string
99+
// required: true
100+
// responses:
101+
// "200":
102+
// description: "ssh public key"
103+
// schema:
104+
// type: string
105+
getSigningKey(ctx, git.SigningKeyFormatSSH)
62106
}

routers/web/repo/setting/setting.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func SettingsCtxData(ctx *context.Context) {
6262
ctx.Data["CanConvertFork"] = ctx.Repo.Repository.IsFork && ctx.Doer.CanCreateRepoIn(ctx.Repo.Repository.Owner)
6363

6464
signing, _ := asymkey_service.SigningKey(ctx, ctx.Repo.Repository.RepoPath())
65-
ctx.Data["SigningKeyAvailable"] = len(signing) > 0
65+
ctx.Data["SigningKeyAvailable"] = signing != nil
6666
ctx.Data["SigningSettings"] = setting.Repository.Signing
6767
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
6868

@@ -105,7 +105,7 @@ func SettingsPost(ctx *context.Context) {
105105
ctx.Data["MinimumMirrorInterval"] = setting.Mirror.MinInterval
106106

107107
signing, _ := asymkey_service.SigningKey(ctx, ctx.Repo.Repository.RepoPath())
108-
ctx.Data["SigningKeyAvailable"] = len(signing) > 0
108+
ctx.Data["SigningKeyAvailable"] = signing != nil
109109
ctx.Data["SigningSettings"] = setting.Repository.Signing
110110
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
111111

0 commit comments

Comments
 (0)