Merge pull request 'Allow pushmirror to use publickey authentication' (#4819) from ironmagma/forgejo:publickey-auth-push-mirror into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/4819
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
Gusted 2024-08-24 16:53:56 +00:00
commit 5dbacb70f4
24 changed files with 648 additions and 66 deletions

View file

@ -1,5 +1,6 @@
// Copyright 2015 The Gogs Authors. All rights reserved.
// Copyright 2017 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
@ -18,6 +19,7 @@ import (
"time"
"code.gitea.io/gitea/modules/proxy"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
@ -190,17 +192,39 @@ func CloneWithArgs(ctx context.Context, args TrustedCmdArgs, from, to string, op
// PushOptions options when push to remote
type PushOptions struct {
Remote string
Branch string
Force bool
Mirror bool
Env []string
Timeout time.Duration
Remote string
Branch string
Force bool
Mirror bool
Env []string
Timeout time.Duration
PrivateKeyPath string
}
// Push pushs local commits to given remote branch.
func Push(ctx context.Context, repoPath string, opts PushOptions) error {
cmd := NewCommand(ctx, "push")
if opts.PrivateKeyPath != "" {
// Preserve the behavior that existing environments are used if no
// environments are passed.
if len(opts.Env) == 0 {
opts.Env = os.Environ()
}
// Use environment because it takes precedence over using -c core.sshcommand
// and it's possible that a system might have an existing GIT_SSH_COMMAND
// environment set.
opts.Env = append(opts.Env, "GIT_SSH_COMMAND=ssh"+
fmt.Sprintf(` -i %s`, opts.PrivateKeyPath)+
" -o IdentitiesOnly=yes"+
// This will store new SSH host keys and verify connections to existing
// host keys, but it doesn't allow replacement of existing host keys. This
// means TOFU is used for Git over SSH pushes.
" -o StrictHostKeyChecking=accept-new"+
" -o UserKnownHostsFile="+filepath.Join(setting.SSH.RootPath, "known_hosts"))
}
if opts.Force {
cmd.AddArguments("-f")
}

View file

@ -18,6 +18,7 @@ package keying
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/hkdf"
@ -44,6 +45,9 @@ func Init(ikm []byte) {
// This must be a hardcoded string and must not be arbitrarily constructed.
type Context string
// Used for the `push_mirror` table.
var ContextPushMirror Context = "pushmirror"
// Derive *the* key for a given context, this is a determistic function. The
// same key will be provided for the same context.
func DeriveKey(context Context) *Key {
@ -109,3 +113,13 @@ func (k *Key) Decrypt(ciphertext, additionalData []byte) ([]byte, error) {
return e.Open(nil, nonce, ciphertext, additionalData)
}
// ColumnAndID generates a context that can be used as additional context for
// encrypting and decrypting data. It requires the column name and the row ID
// (this requires to be known beforehand). Be careful when using this, as the
// table name isn't part of this context. This means it's not bound to a
// particular table. The table should be part of the context that the key was
// derived for, in which case it binds through that.
func ColumnAndID(column string, id int64) []byte {
return binary.BigEndian.AppendUint64(append([]byte(column), ':'), uint64(id))
}

View file

@ -4,6 +4,7 @@
package keying_test
import (
"math"
"testing"
"code.gitea.io/gitea/modules/keying"
@ -94,3 +95,17 @@ func TestKeying(t *testing.T) {
})
})
}
func TestKeyingColumnAndID(t *testing.T) {
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table", math.MinInt64))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table", -1))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table", 0))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, keying.ColumnAndID("table", 1))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table", math.MaxInt64))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table2", math.MinInt64))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table2", -1))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table2", 0))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, keying.ColumnAndID("table2", 1))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table2", math.MaxInt64))
}

View file

@ -60,6 +60,10 @@ func endpointFromURL(rawurl string) *url.URL {
case "git":
u.Scheme = "https"
return u
case "ssh":
u.Scheme = "https"
u.User = nil
return u
case "file":
return u
default:

View file

@ -12,6 +12,7 @@ type CreatePushMirrorOption struct {
RemotePassword string `json:"remote_password"`
Interval string `json:"interval"`
SyncOnCommit bool `json:"sync_on_commit"`
UseSSH bool `json:"use_ssh"`
}
// PushMirror represents information of a push mirror
@ -27,4 +28,5 @@ type PushMirror struct {
LastError string `json:"last_error"`
Interval string `json:"interval"`
SyncOnCommit bool `json:"sync_on_commit"`
PublicKey string `json:"public_key"`
}

View file

@ -1,11 +1,14 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package util
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"fmt"
"math/big"
"strconv"
@ -13,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/optional"
"golang.org/x/crypto/ssh"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
@ -229,3 +233,23 @@ func ReserveLineBreakForTextarea(input string) string {
// Other than this, we should respect the original content, even leading or trailing spaces.
return strings.ReplaceAll(input, "\r\n", "\n")
}
// GenerateSSHKeypair generates a ed25519 SSH-compatible keypair.
func GenerateSSHKeypair() (publicKey, privateKey []byte, err error) {
public, private, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, nil, fmt.Errorf("ed25519.GenerateKey: %w", err)
}
privPEM, err := ssh.MarshalPrivateKey(private, "")
if err != nil {
return nil, nil, fmt.Errorf("ssh.MarshalPrivateKey: %w", err)
}
sshPublicKey, err := ssh.NewPublicKey(public)
if err != nil {
return nil, nil, fmt.Errorf("ssh.NewPublicKey: %w", err)
}
return ssh.MarshalAuthorizedKey(sshPublicKey), pem.EncodeToMemory(privPEM), nil
}

View file

@ -1,14 +1,19 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package util
package util_test
import (
"bytes"
"crypto/rand"
"regexp"
"strings"
"testing"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -43,7 +48,7 @@ func TestURLJoin(t *testing.T) {
newTest("/a/b/c#hash",
"/a", "b/c#hash"),
} {
assert.Equal(t, test.Expected, URLJoin(test.Base, test.Elements...))
assert.Equal(t, test.Expected, util.URLJoin(test.Base, test.Elements...))
}
}
@ -59,7 +64,7 @@ func TestIsEmptyString(t *testing.T) {
}
for _, v := range cases {
assert.Equal(t, v.expected, IsEmptyString(v.s))
assert.Equal(t, v.expected, util.IsEmptyString(v.s))
}
}
@ -100,42 +105,42 @@ func Test_NormalizeEOL(t *testing.T) {
unix := buildEOLData(data1, "\n")
mac := buildEOLData(data1, "\r")
assert.Equal(t, unix, NormalizeEOL(dos))
assert.Equal(t, unix, NormalizeEOL(mac))
assert.Equal(t, unix, NormalizeEOL(unix))
assert.Equal(t, unix, util.NormalizeEOL(dos))
assert.Equal(t, unix, util.NormalizeEOL(mac))
assert.Equal(t, unix, util.NormalizeEOL(unix))
dos = buildEOLData(data2, "\r\n")
unix = buildEOLData(data2, "\n")
mac = buildEOLData(data2, "\r")
assert.Equal(t, unix, NormalizeEOL(dos))
assert.Equal(t, unix, NormalizeEOL(mac))
assert.Equal(t, unix, NormalizeEOL(unix))
assert.Equal(t, unix, util.NormalizeEOL(dos))
assert.Equal(t, unix, util.NormalizeEOL(mac))
assert.Equal(t, unix, util.NormalizeEOL(unix))
assert.Equal(t, []byte("one liner"), NormalizeEOL([]byte("one liner")))
assert.Equal(t, []byte("\n"), NormalizeEOL([]byte("\n")))
assert.Equal(t, []byte("\ntwo liner"), NormalizeEOL([]byte("\ntwo liner")))
assert.Equal(t, []byte("two liner\n"), NormalizeEOL([]byte("two liner\n")))
assert.Equal(t, []byte{}, NormalizeEOL([]byte{}))
assert.Equal(t, []byte("one liner"), util.NormalizeEOL([]byte("one liner")))
assert.Equal(t, []byte("\n"), util.NormalizeEOL([]byte("\n")))
assert.Equal(t, []byte("\ntwo liner"), util.NormalizeEOL([]byte("\ntwo liner")))
assert.Equal(t, []byte("two liner\n"), util.NormalizeEOL([]byte("two liner\n")))
assert.Equal(t, []byte{}, util.NormalizeEOL([]byte{}))
assert.Equal(t, []byte("mix\nand\nmatch\n."), NormalizeEOL([]byte("mix\r\nand\rmatch\n.")))
assert.Equal(t, []byte("mix\nand\nmatch\n."), util.NormalizeEOL([]byte("mix\r\nand\rmatch\n.")))
}
func Test_RandomInt(t *testing.T) {
randInt, err := CryptoRandomInt(255)
randInt, err := util.CryptoRandomInt(255)
assert.GreaterOrEqual(t, randInt, int64(0))
assert.LessOrEqual(t, randInt, int64(255))
require.NoError(t, err)
}
func Test_RandomString(t *testing.T) {
str1, err := CryptoRandomString(32)
str1, err := util.CryptoRandomString(32)
require.NoError(t, err)
matches, err := regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
require.NoError(t, err)
assert.True(t, matches)
str2, err := CryptoRandomString(32)
str2, err := util.CryptoRandomString(32)
require.NoError(t, err)
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
require.NoError(t, err)
@ -143,13 +148,13 @@ func Test_RandomString(t *testing.T) {
assert.NotEqual(t, str1, str2)
str3, err := CryptoRandomString(256)
str3, err := util.CryptoRandomString(256)
require.NoError(t, err)
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str3)
require.NoError(t, err)
assert.True(t, matches)
str4, err := CryptoRandomString(256)
str4, err := util.CryptoRandomString(256)
require.NoError(t, err)
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str4)
require.NoError(t, err)
@ -159,34 +164,34 @@ func Test_RandomString(t *testing.T) {
}
func Test_RandomBytes(t *testing.T) {
bytes1, err := CryptoRandomBytes(32)
bytes1, err := util.CryptoRandomBytes(32)
require.NoError(t, err)
bytes2, err := CryptoRandomBytes(32)
bytes2, err := util.CryptoRandomBytes(32)
require.NoError(t, err)
assert.NotEqual(t, bytes1, bytes2)
bytes3, err := CryptoRandomBytes(256)
bytes3, err := util.CryptoRandomBytes(256)
require.NoError(t, err)
bytes4, err := CryptoRandomBytes(256)
bytes4, err := util.CryptoRandomBytes(256)
require.NoError(t, err)
assert.NotEqual(t, bytes3, bytes4)
}
func TestOptionalBoolParse(t *testing.T) {
assert.Equal(t, optional.None[bool](), OptionalBoolParse(""))
assert.Equal(t, optional.None[bool](), OptionalBoolParse("x"))
assert.Equal(t, optional.None[bool](), util.OptionalBoolParse(""))
assert.Equal(t, optional.None[bool](), util.OptionalBoolParse("x"))
assert.Equal(t, optional.Some(false), OptionalBoolParse("0"))
assert.Equal(t, optional.Some(false), OptionalBoolParse("f"))
assert.Equal(t, optional.Some(false), OptionalBoolParse("False"))
assert.Equal(t, optional.Some(false), util.OptionalBoolParse("0"))
assert.Equal(t, optional.Some(false), util.OptionalBoolParse("f"))
assert.Equal(t, optional.Some(false), util.OptionalBoolParse("False"))
assert.Equal(t, optional.Some(true), OptionalBoolParse("1"))
assert.Equal(t, optional.Some(true), OptionalBoolParse("t"))
assert.Equal(t, optional.Some(true), OptionalBoolParse("True"))
assert.Equal(t, optional.Some(true), util.OptionalBoolParse("1"))
assert.Equal(t, optional.Some(true), util.OptionalBoolParse("t"))
assert.Equal(t, optional.Some(true), util.OptionalBoolParse("True"))
}
// Test case for any function which accepts and returns a single string.
@ -209,7 +214,7 @@ var upperTests = []StringTest{
func TestToUpperASCII(t *testing.T) {
for _, tc := range upperTests {
assert.Equal(t, ToUpperASCII(tc.in), tc.out)
assert.Equal(t, util.ToUpperASCII(tc.in), tc.out)
}
}
@ -217,27 +222,56 @@ func BenchmarkToUpper(b *testing.B) {
for _, tc := range upperTests {
b.Run(tc.in, func(b *testing.B) {
for i := 0; i < b.N; i++ {
ToUpperASCII(tc.in)
util.ToUpperASCII(tc.in)
}
})
}
}
func TestToTitleCase(t *testing.T) {
assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`foo bar baz`))
assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`FOO BAR BAZ`))
assert.Equal(t, `Foo Bar Baz`, util.ToTitleCase(`foo bar baz`))
assert.Equal(t, `Foo Bar Baz`, util.ToTitleCase(`FOO BAR BAZ`))
}
func TestToPointer(t *testing.T) {
assert.Equal(t, "abc", *ToPointer("abc"))
assert.Equal(t, 123, *ToPointer(123))
assert.Equal(t, "abc", *util.ToPointer("abc"))
assert.Equal(t, 123, *util.ToPointer(123))
abc := "abc"
assert.NotSame(t, &abc, ToPointer(abc))
assert.NotSame(t, &abc, util.ToPointer(abc))
val123 := 123
assert.NotSame(t, &val123, ToPointer(val123))
assert.NotSame(t, &val123, util.ToPointer(val123))
}
func TestReserveLineBreakForTextarea(t *testing.T) {
assert.Equal(t, "test\ndata", ReserveLineBreakForTextarea("test\r\ndata"))
assert.Equal(t, "test\ndata\n", ReserveLineBreakForTextarea("test\r\ndata\r\n"))
assert.Equal(t, "test\ndata", util.ReserveLineBreakForTextarea("test\r\ndata"))
assert.Equal(t, "test\ndata\n", util.ReserveLineBreakForTextarea("test\r\ndata\r\n"))
}
const (
testPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4\n"
testPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACADoQe/884Qvh1w3RjnS8CZZ+TWMJulDV8d3IZkElUxuAAA
AIggISIjICEiIwAAAAtzc2gtZWQyNTUxOQAAACADoQe/884Qvh1w3RjnS8CZZ+TW
MJulDV8d3IZkElUxuAAAAEAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0e
HwOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4AAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----` + "\n"
)
func TestGeneratingEd25519Keypair(t *testing.T) {
defer test.MockProtect(&rand.Reader)()
// Only 32 bytes needs to be provided to generate a ed25519 keypair.
// And another 32 bytes are required, which is included as random value
// in the OpenSSH format.
b := make([]byte, 64)
for i := 0; i < 64; i++ {
b[i] = byte(i)
}
rand.Reader = bytes.NewReader(b)
publicKey, privateKey, err := util.GenerateSSHKeypair()
require.NoError(t, err)
assert.EqualValues(t, testPublicKey, string(publicKey))
assert.EqualValues(t, testPrivateKey, string(privateKey))
}