Skip to content

Commit 48483b3

Browse files
committed
Merge branch 'ssh-mirroring' of github.com:techknowlogick/gitea into techknowlogick-ssh-mirroring
2 parents 53dfbbb + 8579b08 commit 48483b3

File tree

30 files changed

+1654
-28
lines changed

30 files changed

+1654
-28
lines changed

models/repo/mirror_ssh_keypair.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package repo
5+
6+
import (
7+
"context"
8+
"crypto/ed25519"
9+
"crypto/rand"
10+
"crypto/sha256"
11+
"encoding/hex"
12+
"fmt"
13+
"strings"
14+
15+
"code.gitea.io/gitea/models/db"
16+
user_model "code.gitea.io/gitea/models/user"
17+
"code.gitea.io/gitea/modules/secret"
18+
"code.gitea.io/gitea/modules/setting"
19+
"code.gitea.io/gitea/modules/util"
20+
21+
"golang.org/x/crypto/ssh"
22+
)
23+
24+
// UserSSHKeypair represents an SSH keypair for repository mirroring
25+
type UserSSHKeypair struct {
26+
OwnerID int64
27+
PrivateKeyEncrypted string
28+
PublicKey string
29+
Fingerprint string
30+
}
31+
32+
// GetUserSSHKeypairByOwner gets the SSH keypair for the given owner
33+
func GetUserSSHKeypairByOwner(ctx context.Context, ownerID int64) (*UserSSHKeypair, error) {
34+
settings, err := user_model.GetSettings(ctx, ownerID, []string{
35+
user_model.UserSSHMirrorPrivPem,
36+
user_model.UserSSHMirrorPubPem,
37+
user_model.UserSSHMirrorFingerprint,
38+
})
39+
if err != nil {
40+
return nil, err
41+
}
42+
if len(settings) == 0 {
43+
return nil, util.NewNotExistErrorf("SSH keypair does not exist for owner %d", ownerID)
44+
}
45+
46+
keypair := &UserSSHKeypair{
47+
OwnerID: ownerID,
48+
}
49+
50+
if privSetting, exists := settings[user_model.UserSSHMirrorPrivPem]; exists {
51+
keypair.PrivateKeyEncrypted = privSetting.SettingValue
52+
}
53+
if pubSetting, exists := settings[user_model.UserSSHMirrorPubPem]; exists {
54+
keypair.PublicKey = pubSetting.SettingValue
55+
}
56+
if fpSetting, exists := settings[user_model.UserSSHMirrorFingerprint]; exists {
57+
keypair.Fingerprint = fpSetting.SettingValue
58+
}
59+
60+
if keypair.PrivateKeyEncrypted == "" || keypair.PublicKey == "" || keypair.Fingerprint == "" {
61+
return nil, util.NewNotExistErrorf("SSH keypair incomplete for owner %d", ownerID)
62+
}
63+
64+
return keypair, nil
65+
}
66+
67+
// CreateUserSSHKeypair creates a new SSH keypair for mirroring
68+
func CreateUserSSHKeypair(ctx context.Context, ownerID int64) (*UserSSHKeypair, error) {
69+
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
70+
if err != nil {
71+
return nil, fmt.Errorf("failed to generate Ed25519 keypair: %w", err)
72+
}
73+
74+
sshPublicKey, err := ssh.NewPublicKey(publicKey)
75+
if err != nil {
76+
return nil, fmt.Errorf("failed to convert public key to SSH format: %w", err)
77+
}
78+
79+
publicKeyStr := string(ssh.MarshalAuthorizedKey(sshPublicKey))
80+
81+
fingerprint := sha256.Sum256(sshPublicKey.Marshal())
82+
fingerprintStr := hex.EncodeToString(fingerprint[:])
83+
84+
privateKeyEncrypted, err := secret.EncryptSecret(setting.SecretKey, string(privateKey))
85+
if err != nil {
86+
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
87+
}
88+
89+
err = db.WithTx(ctx, func(ctx context.Context) error {
90+
if err := user_model.SetUserSetting(ctx, ownerID, user_model.UserSSHMirrorPrivPem, privateKeyEncrypted); err != nil {
91+
return fmt.Errorf("failed to save private key: %w", err)
92+
}
93+
if err := user_model.SetUserSetting(ctx, ownerID, user_model.UserSSHMirrorPubPem, publicKeyStr); err != nil {
94+
return fmt.Errorf("failed to save public key: %w", err)
95+
}
96+
if err := user_model.SetUserSetting(ctx, ownerID, user_model.UserSSHMirrorFingerprint, fingerprintStr); err != nil {
97+
return fmt.Errorf("failed to save fingerprint: %w", err)
98+
}
99+
return nil
100+
})
101+
if err != nil {
102+
return nil, err
103+
}
104+
105+
keypair := &UserSSHKeypair{
106+
OwnerID: ownerID,
107+
PrivateKeyEncrypted: privateKeyEncrypted,
108+
PublicKey: publicKeyStr,
109+
Fingerprint: fingerprintStr,
110+
}
111+
112+
return keypair, nil
113+
}
114+
115+
// GetDecryptedPrivateKey returns the decrypted private key
116+
func (k *UserSSHKeypair) GetDecryptedPrivateKey() (ed25519.PrivateKey, error) {
117+
decrypted, err := secret.DecryptSecret(setting.SecretKey, k.PrivateKeyEncrypted)
118+
if err != nil {
119+
return nil, fmt.Errorf("failed to decrypt private key: %w", err)
120+
}
121+
return ed25519.PrivateKey(decrypted), nil
122+
}
123+
124+
// GetPublicKeyWithComment returns the public key with a descriptive comment (namespace-fingerprint@domain)
125+
func (k *UserSSHKeypair) GetPublicKeyWithComment(ctx context.Context) (string, error) {
126+
owner, err := user_model.GetUserByID(ctx, k.OwnerID)
127+
if err != nil {
128+
return k.PublicKey, nil
129+
}
130+
131+
domain := setting.Domain
132+
if domain == "" {
133+
domain = "gitea"
134+
}
135+
136+
keyID := k.Fingerprint
137+
if len(keyID) > 8 {
138+
keyID = keyID[:8]
139+
}
140+
141+
comment := fmt.Sprintf("%s-%s@%s", owner.Name, keyID, domain)
142+
return strings.TrimSpace(k.PublicKey) + " " + comment, nil
143+
}
144+
145+
// DeleteUserSSHKeypair deletes an SSH keypair
146+
func DeleteUserSSHKeypair(ctx context.Context, ownerID int64) error {
147+
return db.WithTx(ctx, func(ctx context.Context) error {
148+
if err := user_model.DeleteUserSetting(ctx, ownerID, user_model.UserSSHMirrorPrivPem); err != nil {
149+
return err
150+
}
151+
if err := user_model.DeleteUserSetting(ctx, ownerID, user_model.UserSSHMirrorPubPem); err != nil {
152+
return err
153+
}
154+
return user_model.DeleteUserSetting(ctx, ownerID, user_model.UserSSHMirrorFingerprint)
155+
})
156+
}
157+
158+
// RegenerateUserSSHKeypair regenerates an SSH keypair for the given owner
159+
func RegenerateUserSSHKeypair(ctx context.Context, ownerID int64) (*UserSSHKeypair, error) {
160+
return db.WithTx2(ctx, func(ctx context.Context) (*UserSSHKeypair, error) {
161+
_ = DeleteUserSSHKeypair(ctx, ownerID)
162+
163+
newKeypair, err := CreateUserSSHKeypair(ctx, ownerID)
164+
if err != nil {
165+
return nil, err
166+
}
167+
return newKeypair, nil
168+
})
169+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package repo_test
5+
6+
import (
7+
"crypto/ed25519"
8+
"testing"
9+
10+
repo_model "code.gitea.io/gitea/models/repo"
11+
"code.gitea.io/gitea/models/unittest"
12+
"code.gitea.io/gitea/modules/setting"
13+
"code.gitea.io/gitea/modules/util"
14+
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
func TestUserSSHKeypair(t *testing.T) {
20+
require.NoError(t, unittest.PrepareTestDatabase())
21+
22+
t.Run("CreateUserSSHKeypair", func(t *testing.T) {
23+
// Test creating a new SSH keypair for a user
24+
keypair, err := repo_model.CreateUserSSHKeypair(t.Context(), 1)
25+
require.NoError(t, err)
26+
assert.NotNil(t, keypair)
27+
assert.Equal(t, int64(1), keypair.OwnerID)
28+
assert.NotEmpty(t, keypair.PublicKey)
29+
assert.NotEmpty(t, keypair.PrivateKeyEncrypted)
30+
assert.NotEmpty(t, keypair.Fingerprint)
31+
32+
// Verify the public key is in SSH format
33+
assert.Contains(t, keypair.PublicKey, "ssh-ed25519")
34+
35+
// Test creating a keypair for an organization
36+
orgKeypair, err := repo_model.CreateUserSSHKeypair(t.Context(), 2)
37+
require.NoError(t, err)
38+
assert.NotNil(t, orgKeypair)
39+
assert.Equal(t, int64(2), orgKeypair.OwnerID)
40+
41+
// Ensure different owners get different keypairs
42+
assert.NotEqual(t, keypair.PublicKey, orgKeypair.PublicKey)
43+
assert.NotEqual(t, keypair.Fingerprint, orgKeypair.Fingerprint)
44+
})
45+
46+
t.Run("GetUserSSHKeypairByOwner", func(t *testing.T) {
47+
// Create a keypair first
48+
created, err := repo_model.CreateUserSSHKeypair(t.Context(), 3)
49+
require.NoError(t, err)
50+
51+
// Test retrieving the keypair
52+
retrieved, err := repo_model.GetUserSSHKeypairByOwner(t.Context(), 3)
53+
require.NoError(t, err)
54+
assert.Equal(t, created.OwnerID, retrieved.OwnerID)
55+
assert.Equal(t, created.PublicKey, retrieved.PublicKey)
56+
assert.Equal(t, created.Fingerprint, retrieved.Fingerprint)
57+
58+
// Test retrieving non-existent keypair
59+
_, err = repo_model.GetUserSSHKeypairByOwner(t.Context(), 999)
60+
assert.ErrorIs(t, err, util.ErrNotExist)
61+
})
62+
63+
t.Run("GetDecryptedPrivateKey", func(t *testing.T) {
64+
// Ensure we have a valid SECRET_KEY for testing
65+
if setting.SecretKey == "" {
66+
setting.SecretKey = "test-secret-key-for-testing"
67+
}
68+
69+
// Create a keypair
70+
keypair, err := repo_model.CreateUserSSHKeypair(t.Context(), 4)
71+
require.NoError(t, err)
72+
73+
// Test decrypting the private key
74+
privateKey, err := keypair.GetDecryptedPrivateKey()
75+
require.NoError(t, err)
76+
assert.IsType(t, ed25519.PrivateKey{}, privateKey)
77+
assert.Len(t, privateKey, ed25519.PrivateKeySize)
78+
79+
// Verify the private key corresponds to the public key
80+
publicKey := privateKey.Public().(ed25519.PublicKey)
81+
assert.Len(t, publicKey, ed25519.PublicKeySize)
82+
})
83+
84+
t.Run("DeleteUserSSHKeypair", func(t *testing.T) {
85+
// Create a keypair
86+
_, err := repo_model.CreateUserSSHKeypair(t.Context(), 5)
87+
require.NoError(t, err)
88+
89+
// Verify it exists
90+
_, err = repo_model.GetUserSSHKeypairByOwner(t.Context(), 5)
91+
require.NoError(t, err)
92+
93+
// Delete it
94+
err = repo_model.DeleteUserSSHKeypair(t.Context(), 5)
95+
require.NoError(t, err)
96+
97+
// Verify it's gone
98+
_, err = repo_model.GetUserSSHKeypairByOwner(t.Context(), 5)
99+
assert.ErrorIs(t, err, util.ErrNotExist)
100+
})
101+
102+
t.Run("RegenerateUserSSHKeypair", func(t *testing.T) {
103+
// Create initial keypair
104+
original, err := repo_model.CreateUserSSHKeypair(t.Context(), 6)
105+
require.NoError(t, err)
106+
107+
// Regenerate it
108+
regenerated, err := repo_model.RegenerateUserSSHKeypair(t.Context(), 6)
109+
require.NoError(t, err)
110+
111+
// Verify it's different
112+
assert.NotEqual(t, original.PublicKey, regenerated.PublicKey)
113+
assert.NotEqual(t, original.PrivateKeyEncrypted, regenerated.PrivateKeyEncrypted)
114+
assert.NotEqual(t, original.Fingerprint, regenerated.Fingerprint)
115+
assert.Equal(t, original.OwnerID, regenerated.OwnerID)
116+
})
117+
}
118+
119+
func TestUserSSHKeypairConcurrency(t *testing.T) {
120+
require.NoError(t, unittest.PrepareTestDatabase())
121+
122+
if setting.SecretKey == "" {
123+
setting.SecretKey = "test-secret-key-for-testing"
124+
}
125+
126+
// Test concurrent creation of keypairs to ensure no race conditions
127+
t.Run("ConcurrentCreation", func(t *testing.T) {
128+
ctx := t.Context()
129+
results := make(chan error, 10)
130+
131+
// Start multiple goroutines creating keypairs for different owners
132+
for i := range 10 {
133+
go func(ownerID int64) {
134+
_, err := repo_model.CreateUserSSHKeypair(ctx, ownerID+100)
135+
results <- err
136+
}(int64(i))
137+
}
138+
139+
// Check all creations succeeded
140+
for range 10 {
141+
err := <-results
142+
assert.NoError(t, err)
143+
}
144+
})
145+
}

models/user/setting_options.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,8 @@ const (
2626
SettingEmailNotificationGiteaActionsAll = "all"
2727
SettingEmailNotificationGiteaActionsFailureOnly = "failure-only" // Default for actions email preference
2828
SettingEmailNotificationGiteaActionsDisabled = "disabled"
29+
30+
UserSSHMirrorPrivPem = "ssh_mirror.priv_pem"
31+
UserSSHMirrorPubPem = "ssh_mirror.pub_pem"
32+
UserSSHMirrorFingerprint = "ssh_mirror.fingerprint"
2933
)

modules/git/gitcmd/command.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ type RunOpts struct {
220220
// In the future, ideally the git module itself should have full control of the stdin, to avoid such problems and make it easier to refactor to a better architecture.
221221
Stdin io.Reader
222222

223+
// SSHAuthSock is the path to an SSH agent socket for authentication
224+
// If provided, SSH_AUTH_SOCK environment variable will be set
225+
SSHAuthSock string
226+
223227
PipelineFunc func(context.Context, context.CancelFunc) error
224228
}
225229

@@ -319,6 +323,11 @@ func (c *Command) run(ctx context.Context, skip int, opts *RunOpts) error {
319323

320324
process.SetSysProcAttribute(cmd)
321325
cmd.Env = append(cmd.Env, CommonGitCmdEnvs()...)
326+
327+
if opts.SSHAuthSock != "" {
328+
cmd.Env = append(cmd.Env, "SSH_AUTH_SOCK="+opts.SSHAuthSock)
329+
}
330+
322331
cmd.Dir = opts.Dir
323332
cmd.Stdout = opts.Stdout
324333
cmd.Stderr = opts.Stderr
@@ -434,6 +443,7 @@ func (c *Command) runStdBytes(ctx context.Context, opts *RunOpts) (stdout, stder
434443
Stdout: stdoutBuf,
435444
Stderr: stderrBuf,
436445
Stdin: opts.Stdin,
446+
SSHAuthSock: opts.SSHAuthSock,
437447
PipelineFunc: opts.PipelineFunc,
438448
}
439449

0 commit comments

Comments
 (0)