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 8 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
12 changes: 11 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,15 @@ 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)
}
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 +331,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(append([]string{}, 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 == KeyTypeOpenPGP {
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