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

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 13 additions & 3 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1190,17 +1190,21 @@ 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:
;; - the GPG key ID
;; - 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 = openpgp
;;
;; Sets the default trust model for repositories. Options are: collaborator, committer, collaboratorcommitter
;DEFAULT_TRUST_MODEL = collaborator
Expand All @@ -1227,6 +1231,12 @@ 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.
;; 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
17 changes: 17 additions & 0 deletions modules/git/key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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 (
// KeyTypeOpenPGP is the key type for GPG keys, expected default of git cli
KeyTypeOpenPGP = "openpgp"
// KeyTypeSSH is the key type for SSH keys
KeyTypeSSH = "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 == KeyTypeSSH {
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", KeyTypeOpenPGP, "--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
9 changes: 6 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,11 @@ 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.KeyID != "" || opts.AlwaysSign {
if opts.Key.Format != "" {
cmd.AddConfig("gpg.format", opts.Key.Format)
}
cmd.AddOptionFormat("-S%s", opts.Key.KeyID)
}

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
Wiki []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
Wiki []string
DefaultTrustModel string
TrustedSSHKeys []string `ini:"TRUSTED_SSH_KEYS"`
}{
SigningKey: "default",
SigningName: "",
SigningEmail: "",
SigningFormat: "openpgp", // git.KeyTypeOpenPGP
InitialCommit: []string{"always"},
CRUDActions: []string{"pubkey", "twofa", "parentsigned"},
Merges: []string{"pubkey", "twofa", "basesigned", "commitssigned"},
Wiki: []string{"never"},
DefaultTrustModel: "collaborator",
TrustedSSHKeys: []string{},
},
}
RepoRootPath string
Expand Down
2 changes: 2 additions & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,7 @@ func Routes() *web.Router {
m.Group("", func() {
m.Get("/version", misc.Version)
m.Get("/signing-key.gpg", misc.SigningKey)
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 @@ -1425,6 +1426,7 @@ func Routes() *web.Router {
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.pub", misc.SigningKeySSH)
m.Group("/topics", func() {
m.Combo("").Get(repo.ListTopics).
Put(reqToken(), reqAdmin(), bind(api.RepoTopicOptions{}), repo.UpdateTopics)
Expand Down
63 changes: 62 additions & 1 deletion routers/api/v1/misc/signing.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
package misc

import (
"errors"
"fmt"

"code.gitea.io/gitea/modules/git"
asymkey_service "code.gitea.io/gitea/services/asymkey"
"code.gitea.io/gitea/services/context"
)
Expand Down Expand Up @@ -50,11 +52,70 @@ func SigningKey(ctx *context.APIContext) {
path = ctx.Repo.Repository.RepoPath()
}

content, err := asymkey_service.PublicSigningKey(ctx, path)
content, format, err := asymkey_service.PublicSigningKey(ctx, path)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if format != git.KeyTypeOpenPGP {
ctx.APIErrorNotFound(errors.New("SSH keys are used for signing, not GPG"))
return
}
_, err = ctx.Write([]byte(content))
if err != nil {
ctx.APIErrorInternal(fmt.Errorf("Error writing key content %w", err))
}
}

// SigningKey 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

// 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

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 != git.KeyTypeSSH {
ctx.APIErrorNotFound(errors.New("GPG keys are used for signing, not SSH"))
return
}
_, err = ctx.Write([]byte(content))
if err != nil {
ctx.APIErrorInternal(fmt.Errorf("Error writing key content %w", err))
Expand Down
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"] = len(signing.KeyID) > 0
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"] = len(signing.KeyID) > 0
ctx.Data["SigningSettings"] = setting.Repository.Signing
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled

Expand Down
70 changes: 69 additions & 1 deletion services/asymkey/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,11 +398,79 @@ func ParseCommitWithSSHSignature(ctx context.Context, c *git.Commit, committer *
}
}
}
// Trust more than one key for every User
for _, k := range setting.Repository.Signing.TrustedSSHKeys {
fingerprint, _ := asymkey_model.CalcFingerprint(k)
commitVerification := verifySSHCommitVerification(c.Signature.Signature, c.Signature.Payload, &asymkey_model.PublicKey{
Verified: true,
Content: k,
Fingerprint: fingerprint,
HasUsed: true,
}, committer, committer, c.Committer.Email)
if commitVerification != nil {
return commitVerification
}
}

defaultReason := asymkey_model.NoKeyFound

if setting.Repository.Signing.SigningFormat == git.KeyTypeSSH && setting.Repository.Signing.SigningKey != "" && setting.Repository.Signing.SigningKey != "default" && setting.Repository.Signing.SigningKey != "none" {
// OK we should try the default key
gpgSettings := git.GPGSettings{
Sign: true,
KeyID: setting.Repository.Signing.SigningKey,
Name: setting.Repository.Signing.SigningName,
Email: setting.Repository.Signing.SigningEmail,
Format: setting.Repository.Signing.SigningFormat,
}
if err := gpgSettings.LoadPublicKeyContent(); err != nil {
log.Error("Error getting default signing key: %s %v", gpgSettings.KeyID, err)
}
fingerprint, _ := asymkey_model.CalcFingerprint(gpgSettings.PublicKeyContent)
if commitVerification := verifySSHCommitVerification(c.Signature.Signature, c.Signature.Payload, &asymkey_model.PublicKey{
Verified: true,
Content: gpgSettings.PublicKeyContent,
Fingerprint: fingerprint,
HasUsed: true,
}, committer, committer, committer.Email); commitVerification != nil {
if commitVerification.Reason == asymkey_model.BadSignature {
defaultReason = asymkey_model.BadSignature
} else {
return commitVerification
}
}
}

defaultGPGSettings, err := c.GetRepositoryDefaultPublicGPGKey(false)
if defaultGPGSettings.Format == git.KeyTypeSSH {
if err != nil {
log.Error("Error getting default public gpg key: %v", err)
} else if defaultGPGSettings == nil {
log.Warn("Unable to get defaultGPGSettings for unattached commit: %s", c.ID.String())
} else if defaultGPGSettings.Sign {
if err := defaultGPGSettings.LoadPublicKeyContent(); err != nil {
log.Error("Error getting default signing key: %s %v", defaultGPGSettings.KeyID, err)
}
fingerprint, _ := asymkey_model.CalcFingerprint(defaultGPGSettings.PublicKeyContent)
if commitVerification := verifySSHCommitVerification(c.Signature.Signature, c.Signature.Payload, &asymkey_model.PublicKey{
Verified: true,
Content: defaultGPGSettings.PublicKeyContent,
Fingerprint: fingerprint,
HasUsed: true,
}, committer, committer, committer.Email); commitVerification != nil {
if commitVerification.Reason == asymkey_model.BadSignature {
defaultReason = asymkey_model.BadSignature
} else {
return commitVerification
}
}
}
}

return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Reason: asymkey_model.NoKeyFound,
Reason: defaultReason,
}
}

Expand Down
Loading