Skip to content

Commit b55c728

Browse files
GustedGusted
authored andcommitted
feat(sec): Add SSH signing support for instances (go-gitea#6897)
- Add support to set `gpg.format` in the Git config, via the new `[repository.signing].FORMAT` option. This is to tell Git that the instance would like to use SSH instead of OpenPGP to sign its commits. This is guarded behind a Git version check for v2.34.0 and a check that a `ssh-keygen` binary is present. - Add support to recognize the public SSH key that is given to `[repository.signing].SIGNING_KEY` as the signing key by the instance. - Thus this allows the instance to use SSH commit signing for commits that the instance creates (e.g. initial and squash commits) instead of using PGP. - Technically (although I have no clue how as this is not documented) you can have a different PGP signing key for different repositories; this is not implemented for SSH signing. - Add unit and integration testing. - `TestInstanceSigning` was reworked from `TestGPGGit`, now also includes testing for SHA256 repositories. Is the main integration test that actually signs commits and checks that they are marked as verified by Forgejo. - `TestParseCommitWithSSHSignature` is a unit test that makes sure that if a SSH instnace signing key is set, that it is used to possibly verify instance SSH signed commits. - `TestSyncConfigGPGFormat` is a unit test that makes sure the correct git config is set according to the signing format setting. Also checks that the guarded git version check and ssh-keygen binary presence check is done correctly. - `TestSSHInstanceKey` is a unit test that makes sure the parsing of a SSH signing key is done correctly. - `TestAPISSHSigningKey` is a integration test that makes sure the newly added API route `/api/v1/signing-key.ssh` responds correctly. Documentation PR: forgejo/docs#1122 Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6897 Reviewed-by: Earl Warren <[email protected]> Co-authored-by: Gusted <[email protected]> Co-committed-by: Gusted <[email protected]>
1 parent eb85681 commit b55c728

File tree

17 files changed

+687
-306
lines changed

17 files changed

+687
-306
lines changed

custom/conf/app.example.ini

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1163,9 +1163,13 @@ LEVEL = Info
11631163
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
11641164
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
11651165
;;
1166+
;; Signing format that Forgejo should use, openpgp uses GPG and ssh uses OpenSSH.
1167+
;FORMAT = openpgp
1168+
;;
11661169
;; GPG key to use to sign commits, Defaults to the default - that is the value of git config --get user.signingkey
11671170
;; run in the context of the RUN_USER
1168-
;; Switch to none to stop signing completely
1171+
;; Switch to none to stop signing completely.
1172+
;; If `FORMAT` is set to **ssh** this should be set to an absolute path to an public OpenSSH key.
11691173
;SIGNING_KEY = default
11701174
;;
11711175
;; If a SIGNING_KEY ID is provided and is not set to default, use the provided Name and Email address as the signer.

models/asymkey/gpg_key_object_verification.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ func ParseObjectWithSignature(ctx context.Context, c *GitObject) *ObjectVerifica
201201
}
202202
}
203203

204-
if setting.Repository.Signing.SigningKey != "" && setting.Repository.Signing.SigningKey != "default" && setting.Repository.Signing.SigningKey != "none" {
204+
if setting.Repository.Signing.Format == "openpgp" && setting.Repository.Signing.SigningKey != "" && setting.Repository.Signing.SigningKey != "default" && setting.Repository.Signing.SigningKey != "none" {
205205
// OK we should try the default key
206206
gpgSettings := git.GPGSettings{
207207
Sign: true,

models/asymkey/ssh_key_object_verification.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import (
1212
"forgejo.org/models/db"
1313
user_model "forgejo.org/models/user"
1414
"forgejo.org/modules/log"
15+
"forgejo.org/modules/setting"
1516

1617
"github.com/42wim/sshsig"
18+
"golang.org/x/crypto/ssh"
1719
)
1820

1921
// ParseObjectWithSSHSignature check if signature is good against keystore.
@@ -62,6 +64,22 @@ func ParseObjectWithSSHSignature(ctx context.Context, c *GitObject, committer *u
6264
}
6365
}
6466

67+
// If the SSH instance key is set, try to verify it with that key.
68+
if setting.SSHInstanceKey != nil {
69+
instanceSSHKey := &PublicKey{
70+
Content: string(ssh.MarshalAuthorizedKey(setting.SSHInstanceKey)),
71+
Fingerprint: ssh.FingerprintSHA256(setting.SSHInstanceKey),
72+
}
73+
instanceUser := &user_model.User{
74+
Name: setting.Repository.Signing.SigningName,
75+
Email: setting.Repository.Signing.SigningEmail,
76+
}
77+
commitVerification := verifySSHObjectVerification(c.Signature.Signature, c.Signature.Payload, instanceSSHKey, committer, instanceUser, setting.Repository.Signing.SigningEmail)
78+
if commitVerification != nil {
79+
return commitVerification
80+
}
81+
}
82+
6583
return &ObjectVerification{
6684
CommittingUser: committer,
6785
Verified: false,

models/asymkey/ssh_key_object_verification_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package asymkey
55

66
import (
7+
"os"
78
"testing"
89

910
"forgejo.org/models/db"
@@ -15,6 +16,7 @@ import (
1516

1617
"github.com/stretchr/testify/assert"
1718
"github.com/stretchr/testify/require"
19+
"golang.org/x/crypto/ssh"
1820
)
1921

2022
func TestParseCommitWithSSHSignature(t *testing.T) {
@@ -150,4 +152,43 @@ muPLbvEduU+Ze/1Ol1pgk=
150152
assert.Equal(t, "user2 / SHA256:TKfwbZMR7e9OnlV2l1prfah1TXH8CmqR0PvFEXVCXA4", commitVerification.Reason)
151153
assert.Equal(t, sshKey, commitVerification.SigningSSHKey)
152154
})
155+
156+
t.Run("Instance key", func(t *testing.T) {
157+
pubKeyContent, err := os.ReadFile("../../tests/integration/ssh-signing-key.pub")
158+
require.NoError(t, err)
159+
pubKey, _, _, _, err := ssh.ParseAuthorizedKey(pubKeyContent)
160+
require.NoError(t, err)
161+
162+
defer test.MockVariableValue(&setting.Repository.Signing.SigningName, "UwU")()
163+
defer test.MockVariableValue(&setting.Repository.Signing.SigningEmail, "[email protected]")()
164+
defer test.MockVariableValue(&setting.SSHInstanceKey, pubKey)()
165+
166+
gitCommit := &git.Commit{
167+
Committer: &git.Signature{
168+
169+
},
170+
Signature: &git.ObjectSignature{
171+
Payload: `tree f96f1a4f1a51dc42e2983592f503980b60b8849c
172+
parent 93f84db542dd8c6e952c8130bc2fcbe2e299b8b4
173+
author OwO <[email protected]> 1738961379 +0100
174+
committer UwU <[email protected]> 1738961379 +0100
175+
176+
Fox
177+
`,
178+
Signature: `-----BEGIN SSH SIGNATURE-----
179+
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgV5ELwZ8XJe2LLR/UTuEu/vsFdb
180+
t7ry0W8hyzz/b1iocAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
181+
AAAAQCnyMRkWVVNoZxZkvi/ZoknUhs4LNBmEwZs9e9214WIt+mhKfc6BiHoE2qeluR2McD
182+
Y5RzHnA8Ke9wXddEePCQE=
183+
-----END SSH SIGNATURE-----
184+
`,
185+
},
186+
}
187+
188+
o := commitToGitObject(gitCommit)
189+
commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user2)
190+
assert.True(t, commitVerification.Verified)
191+
assert.Equal(t, "UwU / SHA256:QttK41r/zMUeAW71b5UgVSb8xGFF/DlZJ6TyADW+uoI", commitVerification.Reason)
192+
assert.Equal(t, "SHA256:QttK41r/zMUeAW71b5UgVSb8xGFF/DlZJ6TyADW+uoI", commitVerification.SigningSSHKey.Fingerprint)
193+
})
153194
}

modules/git/git.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,49 @@ func syncGitConfig() (err error) {
278278
return err
279279
}
280280

281+
switch setting.Repository.Signing.Format {
282+
case "ssh":
283+
// First do a git version check.
284+
if CheckGitVersionAtLeast("2.34.0") != nil {
285+
return errors.New("ssh signing requires Git >= 2.34.0")
286+
}
287+
288+
// Get the ssh-keygen binary that Git will use.
289+
// This can be overriden in app.ini in [git.config] section, so we must
290+
// query this information.
291+
sshKeygenPath, err := configGet("gpg.ssh.program")
292+
if err != nil {
293+
return err
294+
}
295+
// git is very stubborn and does not give a default value, so we must do
296+
// this ourselves.
297+
if len(sshKeygenPath) == 0 {
298+
// Default value of git, very unlikely to change.
299+
// https://github.com/git/git/blob/5b97a56fa0e7d580dc8865b73107407c9b3f0eff/gpg-interface.c#L116
300+
sshKeygenPath = "ssh-keygen"
301+
}
302+
303+
// Although there's a version requirement of 8.2p1, there's no cross-version
304+
// method to get the version of ssh-keygen. Therefore we do a simple binary
305+
// presence check and hope for the best.
306+
if _, err := exec.LookPath(sshKeygenPath); err != nil {
307+
if errors.Is(err, exec.ErrNotFound) {
308+
return errors.New("git signing requires a ssh-keygen binary")
309+
}
310+
return err
311+
}
312+
313+
if err := configSet("gpg.format", "ssh"); err != nil {
314+
return err
315+
}
316+
// openpgp is already the default value, so in the case of a non SSH format
317+
// set the value to openpgp.
318+
default:
319+
if err := configSet("gpg.format", "openpgp"); err != nil {
320+
return err
321+
}
322+
}
323+
281324
// By default partial clones are disabled, enable them from git v2.22
282325
if !setting.Git.DisablePartialClone && CheckGitVersionAtLeast("2.22") == nil {
283326
if err = configSet("uploadpack.allowfilter", "true"); err != nil {
@@ -324,6 +367,15 @@ func CheckGitVersionEqual(equal string) error {
324367
return nil
325368
}
326369

370+
func configGet(key string) (string, error) {
371+
stdout, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
372+
if err != nil && !IsErrorExitCode(err, 1) {
373+
return "", fmt.Errorf("failed to get git config %s, err: %w", key, err)
374+
}
375+
376+
return strings.TrimSpace(stdout), nil
377+
}
378+
327379
func configSet(key, value string) error {
328380
stdout, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
329381
if err != nil && !IsErrorExitCode(err, 1) {

modules/git/git_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import (
1111
"testing"
1212

1313
"forgejo.org/modules/setting"
14+
"forgejo.org/modules/test"
1415
"forgejo.org/modules/util"
1516

17+
"github.com/hashicorp/go-version"
1618
"github.com/stretchr/testify/assert"
1719
"github.com/stretchr/testify/require"
1820
)
@@ -94,3 +96,57 @@ func TestSyncConfig(t *testing.T) {
9496
assert.True(t, gitConfigContains("[sync-test]"))
9597
assert.True(t, gitConfigContains("cfg-key-a = CfgValA"))
9698
}
99+
100+
func TestSyncConfigGPGFormat(t *testing.T) {
101+
defer test.MockProtect(&setting.GitConfig)()
102+
103+
t.Run("No format", func(t *testing.T) {
104+
defer test.MockVariableValue(&setting.Repository.Signing.Format, "")()
105+
require.NoError(t, syncGitConfig())
106+
assert.True(t, gitConfigContains("[gpg]"))
107+
assert.True(t, gitConfigContains("format = openpgp"))
108+
})
109+
110+
t.Run("SSH format", func(t *testing.T) {
111+
r, err := os.OpenRoot(t.TempDir())
112+
require.NoError(t, err)
113+
f, err := r.OpenFile("ssh-keygen", os.O_CREATE|os.O_TRUNC, 0o700)
114+
require.NoError(t, f.Close())
115+
require.NoError(t, err)
116+
t.Setenv("PATH", r.Name())
117+
defer test.MockVariableValue(&setting.Repository.Signing.Format, "ssh")()
118+
119+
require.NoError(t, syncGitConfig())
120+
assert.True(t, gitConfigContains("[gpg]"))
121+
assert.True(t, gitConfigContains("format = ssh"))
122+
123+
t.Run("Old version", func(t *testing.T) {
124+
oldVersion, err := version.NewVersion("2.33.0")
125+
require.NoError(t, err)
126+
defer test.MockVariableValue(&gitVersion, oldVersion)()
127+
require.ErrorContains(t, syncGitConfig(), "ssh signing requires Git >= 2.34.0")
128+
})
129+
130+
t.Run("No ssh-keygen binary", func(t *testing.T) {
131+
require.NoError(t, r.Remove("ssh-keygen"))
132+
require.ErrorContains(t, syncGitConfig(), "git signing requires a ssh-keygen binary")
133+
})
134+
135+
t.Run("Dynamic ssh-keygen binary location", func(t *testing.T) {
136+
f, err := r.OpenFile("ssh-keygen-2", os.O_CREATE|os.O_TRUNC, 0o700)
137+
require.NoError(t, f.Close())
138+
require.NoError(t, err)
139+
defer test.MockVariableValue(&setting.GitConfig.Options, map[string]string{
140+
"gpg.ssh.program": "ssh-keygen-2",
141+
})()
142+
require.NoError(t, syncGitConfig())
143+
})
144+
})
145+
146+
t.Run("OpenPGP format", func(t *testing.T) {
147+
defer test.MockVariableValue(&setting.Repository.Signing.Format, "openpgp")()
148+
require.NoError(t, syncGitConfig())
149+
assert.True(t, gitConfigContains("[gpg]"))
150+
assert.True(t, gitConfigContains("format = openpgp"))
151+
})
152+
}

modules/setting/repository.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
package setting
55

66
import (
7+
"os"
78
"os/exec"
89
"path"
910
"path/filepath"
1011
"strings"
1112

1213
"forgejo.org/modules/log"
14+
15+
"golang.org/x/crypto/ssh"
1316
)
1417

1518
// enumerates all the policy repository creating
@@ -26,6 +29,8 @@ var MaxUserCardsPerPage = 36
2629
// MaxForksPerPage sets maximum amount of forks shown per page
2730
var MaxForksPerPage = 40
2831

32+
var SSHInstanceKey ssh.PublicKey
33+
2934
// Repository settings
3035
var (
3136
Repository = struct {
@@ -109,6 +114,7 @@ var (
109114
SigningKey string
110115
SigningName string
111116
SigningEmail string
117+
Format string
112118
InitialCommit []string
113119
CRUDActions []string `ini:"CRUD_ACTIONS"`
114120
Merges []string
@@ -262,6 +268,7 @@ var (
262268
SigningKey string
263269
SigningName string
264270
SigningEmail string
271+
Format string
265272
InitialCommit []string
266273
CRUDActions []string `ini:"CRUD_ACTIONS"`
267274
Merges []string
@@ -271,6 +278,7 @@ var (
271278
SigningKey: "default",
272279
SigningName: "",
273280
SigningEmail: "",
281+
Format: "openpgp",
274282
InitialCommit: []string{"always"},
275283
CRUDActions: []string{"pubkey", "twofa", "parentsigned"},
276284
Merges: []string{"pubkey", "twofa", "basesigned", "commitssigned"},
@@ -376,4 +384,15 @@ func loadRepositoryFrom(rootCfg ConfigProvider) {
376384
log.Fatal("loadRepoArchiveFrom: %v", err)
377385
}
378386
Repository.EnableFlags = sec.Key("ENABLE_FLAGS").MustBool()
387+
388+
if Repository.Signing.Format == "ssh" && Repository.Signing.SigningKey != "none" && Repository.Signing.SigningKey != "" {
389+
sshPublicKey, err := os.ReadFile(Repository.Signing.SigningKey)
390+
if err != nil {
391+
log.Fatal("Could not read repository signing key in %q: %v", Repository.Signing.SigningKey, err)
392+
}
393+
SSHInstanceKey, _, _, _, err = ssh.ParseAuthorizedKey(sshPublicKey)
394+
if err != nil {
395+
log.Fatal("Could not parse the SSH signing key %q: %v", sshPublicKey, err)
396+
}
397+
}
379398
}

modules/setting/repository_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright 2025 The Forgejo Authors. All rights reserved.
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package setting
5+
6+
import (
7+
"fmt"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"golang.org/x/crypto/ssh"
14+
)
15+
16+
func TestSSHInstanceKey(t *testing.T) {
17+
sshSigningKeyPath, err := filepath.Abs("../../tests/integration/ssh-signing-key.pub")
18+
require.NoError(t, err)
19+
20+
t.Run("None value", func(t *testing.T) {
21+
cfg, err := NewConfigProviderFromData(`
22+
[repository.signing]
23+
FORMAT = ssh
24+
SIGNING_KEY = none
25+
`)
26+
require.NoError(t, err)
27+
28+
loadRepositoryFrom(cfg)
29+
30+
assert.Nil(t, SSHInstanceKey)
31+
})
32+
33+
t.Run("No value", func(t *testing.T) {
34+
cfg, err := NewConfigProviderFromData(`
35+
[repository.signing]
36+
FORMAT = ssh
37+
`)
38+
require.NoError(t, err)
39+
40+
loadRepositoryFrom(cfg)
41+
42+
assert.Nil(t, SSHInstanceKey)
43+
})
44+
t.Run("Normal", func(t *testing.T) {
45+
iniStr := fmt.Sprintf(`
46+
[repository.signing]
47+
FORMAT = ssh
48+
SIGNING_KEY = %s
49+
`, sshSigningKeyPath)
50+
cfg, err := NewConfigProviderFromData(iniStr)
51+
require.NoError(t, err)
52+
53+
loadRepositoryFrom(cfg)
54+
55+
assert.NotNil(t, SSHInstanceKey)
56+
assert.Equal(t, "ssh-ed25519", SSHInstanceKey.Type())
57+
assert.EqualValues(t, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFeRC8GfFyXtiy0f1E7hLv77BXW7e68tFvIcs8/29YqH\n", ssh.MarshalAuthorizedKey(SSHInstanceKey))
58+
})
59+
}

routers/api/v1/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,7 @@ func Routes() *web.Route {
865865
m.Group("", func() {
866866
m.Get("/version", misc.Version)
867867
m.Get("/signing-key.gpg", misc.SigningKey)
868+
m.Get("/signing-key.ssh", misc.SSHSigningKey)
868869
m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup)
869870
m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown)
870871
m.Post("/markdown/raw", reqToken(), misc.MarkdownRaw)

0 commit comments

Comments
 (0)