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 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
24 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
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