Skip to content

Improve instance wide ssh commit signing #34341

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
fc8fc5a
Improve instance wide ssh commit signing
ChristopherHX May 2, 2025
0a35694
fix code style
ChristopherHX May 2, 2025
999860c
fix error style
ChristopherHX May 2, 2025
6a29629
add copyright
ChristopherHX May 2, 2025
6957ba9
set default key format to openpgp
ChristopherHX May 2, 2025
7859346
Cleanup CommitTreeOpts
ChristopherHX May 3, 2025
3c363c5
add missing docs
ChristopherHX May 3, 2025
3e992a0
add missing endpoint
ChristopherHX May 3, 2025
a73ccb4
Update modules/git/repo_gpg.go
ChristopherHX May 11, 2025
8007fba
update app.example.ini
ChristopherHX May 11, 2025
3123281
Merge branch 'main' of https://github.com/go-gitea/gitea into pr/Chri…
ChristopherHX May 11, 2025
bafd8ec
Merge branch 'improve-instance-wide-ssh-signing' of https://github.co…
ChristopherHX May 11, 2025
6ae80f9
Update modules/git/command.go
ChristopherHX May 11, 2025
6c0feaa
Update modules/git/command.go
ChristopherHX May 12, 2025
98bcc7c
fix indent
ChristopherHX May 12, 2025
4a6daf1
Merge branch 'main' into improve-instance-wide-ssh-signing
techknowlogick May 28, 2025
dce3797
reuse gpg tests and expand them to ssh
ChristopherHX May 31, 2025
48a1d6d
rename keyID to key
ChristopherHX May 31, 2025
e749a14
update comment that signKey.KeyID may be empty
ChristopherHX May 31, 2025
984557d
improve example description
ChristopherHX May 31, 2025
727429a
Merge branch 'main' of https://github.com/go-gitea/gitea into pr/Chri…
ChristopherHX May 31, 2025
da2c19e
format test code
ChristopherHX May 31, 2025
39eda94
fix error handling in test
ChristopherHX May 31, 2025
e45c6fb
handle another error in test
ChristopherHX May 31, 2025
66194b2
remove dead code
ChristopherHX May 31, 2025
d3af10f
fix error handling and add comments about what function verifies whic…
ChristopherHX Jun 6, 2025
c0b65ef
apply suggestion do not write error to already written response
ChristopherHX Jun 6, 2025
64729ae
update comments
ChristopherHX Jun 6, 2025
5af804a
do not ignore fingerprint calc error
ChristopherHX Jun 6, 2025
0f413c5
Add TestTrustedSSHKeys Test via mocked repo
ChristopherHX Jun 6, 2025
d8e123a
fix typo
ChristopherHX Jun 6, 2025
6f18abc
fix invalid APIErrorNotFound usage
ChristopherHX Jun 6, 2025
3da6646
add missing fixtures
ChristopherHX Jun 6, 2025
9c8837b
Use pointer to git.SigningKey
ChristopherHX Jun 6, 2025
6f6ddd0
fix fixture
ChristopherHX Jun 6, 2025
59eed16
.
ChristopherHX Jun 6, 2025
82e7e2d
fix more tests
ChristopherHX Jun 6, 2025
452f58b
finally fix another test
ChristopherHX Jun 6, 2025
edb4486
Merge branch 'main' of https://github.com/go-gitea/gitea into pr/Chri…
ChristopherHX Jun 6, 2025
494921a
fix my own test to use master branch
ChristopherHX Jun 6, 2025
3824905
remove verify of GetRepositoryDefaultPublicGPGKey for ssh and test fo…
ChristopherHX Jun 7, 2025
e3ec65f
Merge branch 'main' into improve-instance-wide-ssh-signing
wxiaoguang Jun 10, 2025
c771cd6
refactor ParseCommitWithSSHSignature
wxiaoguang Jun 10, 2025
745a690
resolve fixme
ChristopherHX Jun 11, 2025
f3710a3
fix test api usage
ChristopherHX Jun 11, 2025
6b67a54
do not crash the test if signinguser is nil
ChristopherHX Jun 11, 2025
6d119a0
merge duplicate code and improve err handling
wxiaoguang Jun 11, 2025
7655f5d
rename consts to match SigningKey.Format
wxiaoguang Jun 11, 2025
b3997e3
add some comments
wxiaoguang Jun 11, 2025
1f4ff81
fix lint
ChristopherHX Jun 11, 2025
70ae40f
Update default setting comment
ChristopherHX Jun 11, 2025
052257c
Merge branch 'main' into improve-instance-wide-ssh-signing
wxiaoguang Jun 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1186,17 +1186,24 @@ LEVEL = Info
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;
;; GPG key to use to sign commits, Defaults to the default - that is the value of git config --get user.signingkey
;; GPG or SSH key to use to sign commits, Defaults to the default - that is the value of git config --get user.signingkey
;; Depending on the value of SIGNING_FORMAT this is either:
;; - openpgp: the GPG key ID
;; - 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
;; run in the context of the RUN_USER
;; Switch to none to stop signing completely
;SIGNING_KEY = default
;;
;; If a SIGNING_KEY ID is provided and is not set to default, use the provided Name and Email address as the signer.
;; 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.
;; These should match a publicized name and email address for the key. (When SIGNING_KEY is default these are set to
;; the results of git config --get user.name and git config --get user.email respectively and can only be overridden
;; 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
;; by setting the SIGNING_KEY ID to the correct ID.)
;SIGNING_NAME =
;SIGNING_EMAIL =
;; SIGNING_FORMAT can be one of:
;; - openpgp (default): use GPG to sign commits
;; - ssh: use SSH to sign commits
;SIGNING_FORMAT = openpgp
;;
;; Sets the default trust model for repositories. Options are: collaborator, committer, collaboratorcommitter
;DEFAULT_TRUST_MODEL = collaborator
Expand All @@ -1223,6 +1230,13 @@ LEVEL = Info
;; - commitssigned: require that all the commits in the head branch are signed.
;; - approved: only sign when merging an approved pr to a protected branch
;MERGES = pubkey, twofa, basesigned, commitssigned
;;
;; Determines which additional ssh keys are trusted for all signed commits regardless of the user
;; This is useful for ssh signing key rotation.
;; Exposes the provided SIGNING_NAME and SIGNING_EMAIL as the signer, regardless of the SIGNING_FORMAT value.
;; Multiple keys should be comma separated.
;; E.g."ssh-<algorithm> <key>". or "ssh-<algorithm> <key1>, ssh-<algorithm> <key2>".
;TRUSTED_SSH_KEYS =

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down
13 changes: 12 additions & 1 deletion modules/git/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type Command struct {
globalArgsLength int
brokenArgs []string
cmd *exec.Cmd // for debug purpose only
configArgs []string
}

func logArgSanitize(arg string) string {
Expand Down Expand Up @@ -196,6 +197,16 @@ func (c *Command) AddDashesAndList(list ...string) *Command {
return c
}

func (c *Command) AddConfig(key, value string) *Command {
kv := key + "=" + value
if !isSafeArgumentValue(kv) {
c.brokenArgs = append(c.brokenArgs, key)
} else {
c.configArgs = append(c.configArgs, "-c", kv)
}
return c
}

// ToTrustedCmdArgs converts a list of strings (trusted as argument) to TrustedCmdArgs
// In most cases, it shouldn't be used. Use NewCommand().AddXxx() function instead
func ToTrustedCmdArgs(args []string) TrustedCmdArgs {
Expand Down Expand Up @@ -321,7 +332,7 @@ func (c *Command) run(ctx context.Context, skip int, opts *RunOpts) error {

startTime := time.Now()

cmd := exec.CommandContext(ctx, c.prog, c.args...)
cmd := exec.CommandContext(ctx, c.prog, append(c.configArgs, c.args...)...)
c.cmd = cmd // for debug purpose only
if opts.Env == nil {
cmd.Env = os.Environ()
Expand Down
15 changes: 15 additions & 0 deletions modules/git/key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package git

// Based on https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgformat
const (
SigningKeyFormatOpenPGP = "openpgp" // for GPG keys, the expected default of git cli
SigningKeyFormatSSH = "ssh"
)

type SigningKey struct {
KeyID string
Format string
}
1 change: 1 addition & 0 deletions modules/git/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type GPGSettings struct {
Email string
Name string
PublicKeyContent string
Format string
}

const prettyLogFormat = `--pretty=format:%H`
Expand Down
12 changes: 12 additions & 0 deletions modules/git/repo_gpg.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@ package git

import (
"fmt"
"os"
"strings"

"code.gitea.io/gitea/modules/process"
)

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

format, _, _ := NewCommand("config", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(repo.Ctx, &RunOpts{Dir: repo.Path})
gpgSettings.Format = strings.TrimSpace(format)

defaultEmail, _, _ := NewCommand("config", "--get", "user.email").RunStdString(repo.Ctx, &RunOpts{Dir: repo.Path})
gpgSettings.Email = strings.TrimSpace(defaultEmail)

Expand Down
11 changes: 8 additions & 3 deletions modules/git/repo_tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
type CommitTreeOpts struct {
Parents []string
Message string
KeyID string
Key *SigningKey
NoGPGSign bool
AlwaysSign bool
}
Expand Down Expand Up @@ -43,8 +43,13 @@ func (repo *Repository) CommitTree(author, committer *Signature, tree *Tree, opt
_, _ = messageBytes.WriteString(opts.Message)
_, _ = messageBytes.WriteString("\n")

if opts.KeyID != "" || opts.AlwaysSign {
cmd.AddOptionFormat("-S%s", opts.KeyID)
if opts.Key != nil {
if opts.Key.Format != "" {
cmd.AddConfig("gpg.format", opts.Key.Format)
}
cmd.AddOptionFormat("-S%s", opts.Key.KeyID)
} else if opts.AlwaysSign {
cmd.AddOptionFormat("-S")
}

if opts.NoGPGSign {
Expand Down
6 changes: 6 additions & 0 deletions modules/setting/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,13 @@ var (
SigningKey string
SigningName string
SigningEmail string
SigningFormat string
InitialCommit []string
CRUDActions []string `ini:"CRUD_ACTIONS"`
Merges []string
Uncyclo []string
DefaultTrustModel string
TrustedSSHKeys []string `ini:"TRUSTED_SSH_KEYS"`
} `ini:"repository.signing"`
}{
DetectedCharsetsOrder: []string{
Expand Down Expand Up @@ -242,20 +244,24 @@ var (
SigningKey string
SigningName string
SigningEmail string
SigningFormat string
InitialCommit []string
CRUDActions []string `ini:"CRUD_ACTIONS"`
Merges []string
Uncyclo []string
DefaultTrustModel string
TrustedSSHKeys []string `ini:"TRUSTED_SSH_KEYS"`
}{
SigningKey: "default",
SigningName: "",
SigningEmail: "",
SigningFormat: "openpgp", // git.SigningKeyFormatOpenPGP
InitialCommit: []string{"always"},
CRUDActions: []string{"pubkey", "twofa", "parentsigned"},
Merges: []string{"pubkey", "twofa", "basesigned", "commitssigned"},
Uncyclo: []string{"never"},
DefaultTrustModel: "collaborator",
TrustedSSHKeys: []string{},
},
}
RepoRootPath string
Expand Down
6 changes: 4 additions & 2 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -971,7 +971,8 @@ func Routes() *web.Router {
// Misc (public accessible)
m.Group("", func() {
m.Get("/version", misc.Version)
m.Get("/signing-key.gpg", misc.SigningKey)
m.Get("/signing-key.gpg", misc.SigningKeyGPG)
m.Get("/signing-key.pub", misc.SigningKeySSH)
m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup)
m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown)
m.Post("/markdown/raw", reqToken(), misc.MarkdownRaw)
Expand Down Expand Up @@ -1427,7 +1428,8 @@ func Routes() *web.Router {
m.Combo("/file-contents", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()).
Get(repo.GetFileContentsGet).
Post(bind(api.GetFilesOptions{}), repo.GetFileContentsPost) // POST method requires "write" permission, so we also support "GET" method above
m.Get("/signing-key.gpg", misc.SigningKey)
m.Get("/signing-key.gpg", misc.SigningKeyGPG)
m.Get("/signing-key.pub", misc.SigningKeySSH)
m.Group("/topics", func() {
m.Combo("").Get(repo.ListTopics).
Put(reqToken(), reqAdmin(), bind(api.RepoTopicOptions{}), repo.UpdateTopics)
Expand Down
78 changes: 61 additions & 17 deletions routers/api/v1/misc/signing.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,35 @@
package misc

import (
"fmt"

"code.gitea.io/gitea/modules/git"
asymkey_service "code.gitea.io/gitea/services/asymkey"
"code.gitea.io/gitea/services/context"
)

// SigningKey returns the public key of the default signing key if it exists
func SigningKey(ctx *context.APIContext) {
func getSigningKey(ctx *context.APIContext, expectedFormat string) {
// if the handler is in the repo's route group, get the repo's signing key
// otherwise, get the global signing key
path := ""
if ctx.Repo != nil && ctx.Repo.Repository != nil {
path = ctx.Repo.Repository.RepoPath()
}
content, format, err := asymkey_service.PublicSigningKey(ctx, path)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if format == "" {
ctx.APIErrorNotFound("no signing key")
return
} else if format != expectedFormat {
ctx.APIErrorNotFound("signing key format is " + format)
return
}
_, _ = ctx.Write([]byte(content))
}

// SigningKeyGPG returns the public key of the default signing key if it exists
func SigningKeyGPG(ctx *context.APIContext) {
// swagger:operation GET /signing-key.gpg miscellaneous getSigningKey
// ---
// summary: Get default signing-key.gpg
Expand Down Expand Up @@ -44,19 +65,42 @@ func SigningKey(ctx *context.APIContext) {
// description: "GPG armored public key"
// schema:
// type: string
getSigningKey(ctx, git.SigningKeyFormatOpenPGP)
}

path := ""
if ctx.Repo != nil && ctx.Repo.Repository != nil {
path = ctx.Repo.Repository.RepoPath()
}
// SigningKeySSH returns the public key of the default signing key if it exists
func SigningKeySSH(ctx *context.APIContext) {
// swagger:operation GET /signing-key.pub miscellaneous getSigningKeySSH
// ---
// summary: Get default signing-key.pub
// produces:
// - text/plain
// responses:
// "200":
// description: "ssh public key"
// schema:
// type: string

content, err := asymkey_service.PublicSigningKey(ctx, path)
if err != nil {
ctx.APIErrorInternal(err)
return
}
_, err = ctx.Write([]byte(content))
if err != nil {
ctx.APIErrorInternal(fmt.Errorf("Error writing key content %w", err))
}
// swagger:operation GET /repos/{owner}/{repo}/signing-key.pub repository repoSigningKeySSH
// ---
// summary: Get signing-key.pub for given repository
// produces:
// - text/plain
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// responses:
// "200":
// description: "ssh public key"
// schema:
// type: string
getSigningKey(ctx, git.SigningKeyFormatSSH)
}
4 changes: 2 additions & 2 deletions routers/web/repo/setting/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func SettingsCtxData(ctx *context.Context) {
ctx.Data["CanConvertFork"] = ctx.Repo.Repository.IsFork && ctx.Doer.CanCreateRepoIn(ctx.Repo.Repository.Owner)

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

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

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

Expand Down
Loading