Skip to content

Allow pushmirror to use publickey authentication #18835

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

Closed
wants to merge 59 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
eefa9e5
Add cryptographic code part
Feb 19, 2022
81d5e26
Simply code
Feb 19, 2022
4e1731a
Proto-typing UI
Feb 20, 2022
560494a
Polish UI further & add backend for SSH Keys
Feb 20, 2022
0631c76
Merge branch 'main' into publickey-auth-push-mirror
Feb 20, 2022
f8b28db
Fix typo
Feb 20, 2022
3dcf98e
Big Refactor
Feb 20, 2022
17e6ae8
Remove left-over
Feb 20, 2022
25bd4db
3 hour wasted commit
Feb 20, 2022
c627b15
Fix copy command
Feb 20, 2022
37f5359
Fix linting
Feb 20, 2022
ed6e4f8
Apply feedback
Feb 20, 2022
7acceb2
Merge branch 'main' into publickey-auth-push-mirror
Feb 20, 2022
b2653cd
Remove JS
Feb 20, 2022
af81b21
Remove left-overs
Feb 20, 2022
6bcd6ba
Use i18n
Feb 20, 2022
0328a48
Add migration
Feb 20, 2022
f50e3df
Fix checkbox value
Feb 20, 2022
4f22413
Update copyright year
Feb 20, 2022
6bbb821
Merge branch 'main' into publickey-auth-push-mirror
Feb 25, 2022
475ae03
Merge branch 'main' into publickey-auth-push-mirror
Feb 28, 2022
084126b
Merge branch 'main' into publickey-auth-push-mirror
techknowlogick Mar 15, 2022
409945a
Merge remote-tracking branch 'origin/main' into publickey-auth-push-m…
Apr 9, 2022
22e7162
Merge branch 'main' into publickey-auth-push-mirror
Apr 9, 2022
e562702
Don't show Public Key in UI
Apr 9, 2022
d46e666
Merge branch 'main' into publickey-auth-push-mirror
Apr 14, 2022
dc80fdf
Merge branch 'main' into publickey-auth-push-mirror
Apr 25, 2022
4adf2e1
Specify Privatekey length in database
Apr 25, 2022
b57c09b
Merge branch 'main' into publickey-auth-push-mirror
Apr 28, 2022
08be2f2
Merge branch 'main' into publickey-auth-push-mirror
May 5, 2022
41dcf87
Fix v213.go
May 5, 2022
a296667
Merge branch 'main' into publickey-auth-push-mirror
May 28, 2022
ceaf8dd
Fix displaying buttons
May 28, 2022
8892708
Merge branch 'main' into publickey-auth-push-mirror
lunny Jun 4, 2022
435ce88
Update initArgs
Jun 8, 2022
23ec50e
Merge branch 'main' into publickey-auth-push-mirror
Jun 8, 2022
05f5eb0
Actually update it
Jun 8, 2022
2a0d8f0
Add test function
Jun 9, 2022
81d7346
Merge branch 'main' into publickey-auth-push-mirror
Jun 9, 2022
3961dcb
Avoid `len()`
Jun 9, 2022
26a486c
Add copyright
Jun 9, 2022
b4f56b9
Merge branch 'main' into publickey-auth-push-mirror
Jul 30, 2022
801741d
Merge branch 'main' into publickey-auth-push-mirror
Jul 30, 2022
0c0ffff
Merge branch 'main' into publickey-auth-push-mirror
Aug 20, 2022
15193cd
Remove pointless readonly
Aug 21, 2022
7be530f
Revamp
Aug 21, 2022
f51832b
Add comments
Aug 21, 2022
86fec2d
Fix SSH
Aug 21, 2022
e110c67
Fix bad automatic format
Aug 21, 2022
1516cab
Merge branch 'main' into publickey-auth-push-mirror
Aug 21, 2022
a83a75c
Fix settings bug
Aug 22, 2022
ac5cf7a
Fix trailing newline
Aug 22, 2022
5162525
Update options/locale/locale_en-US.ini
Aug 22, 2022
d85d495
Merge branch 'main' into publickey-auth-push-mirror
Aug 22, 2022
81e1e96
Add some comments and slightly restructure
zeripath Aug 23, 2022
e129fb5
Apply suggestions from code review
Oct 1, 2022
2622046
Make `disabled` class work
Oct 1, 2022
9064406
Merge branch 'main' into publickey-auth-push-mirror
Oct 1, 2022
ba1e6a1
Update web_src/js/features/repo-legacy.js
6543 Oct 20, 2022
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
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,8 @@ var migrations = []Migration{
NewMigration("Add badges to users", createUserBadgesTable),
// v225 -> v226
NewMigration("Alter gpg_key/public_key content TEXT fields to MEDIUMTEXT", alterPublicGPGKeyContentFieldsToMediumText),
// v226 -> v227
NewMigration("Add keypair fields to PushMirror struct", addKeypairToPushMirror),
}

// GetCurrentDBVersion returns the current db version
Expand Down
34 changes: 34 additions & 0 deletions models/migrations/v226.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package migrations

import (
"time"

"code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/xorm"
)

func addKeypairToPushMirror(x *xorm.Engine) error {
type PushMirror struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
Repo *repo.Repository `xorm:"-"`
RemoteName string

// A keypair formatted in OpenSSH format.
PublicKey string
PrivateKey string `xorm:"VARCHAR(400)"`

SyncOnCommit bool `xorm:"NOT NULL DEFAULT true"`
Interval time.Duration
CreatedUnix timeutil.TimeStamp `xorm:"created"`
LastUpdateUnix timeutil.TimeStamp `xorm:"INDEX last_update"`
LastError string `xorm:"text"`
}

return x.Sync2(new(PushMirror))
}
11 changes: 11 additions & 0 deletions models/repo/pushmirror.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package repo
import (
"context"
"errors"
"strings"
"time"

"code.gitea.io/gitea/models/db"
Expand All @@ -26,6 +27,10 @@ type PushMirror struct {
Repo *Repository `xorm:"-"`
RemoteName string

// A keypair formatted in OpenSSH format.
PublicKey string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be a TEXT field on MySQL which is potentially too small for an RSA key - how big is the potential representation for ED25519 keys?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I once run a script with the old code in modules/crypto/ed25519.go to generate a few million private keys and get their max length. IIRC it was 387, so I just got at 400 to be safe.

Public keys are: ssh-ed25519 + base64 of the ssh's marshaled of the public key bytes + new line And should result in a max of 81 bytes per the analysis of the code (we likely should have a test that verifies these lengths)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might still want to enlarge it now to accomodate future key formats. There's no need to define database fields so tightly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not able to guess future key formats. I prefer to just limit it to Ed25519 signatures and only add other signatures scheme if there's a need for them.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we enlarge now, it saves us from the pain of a future migration. So, unless this is a critical performance code path where there are indexing benefits by using the smaller type, I would enlarge.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright then, I give in. What would be a good size to use here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, MySQL TEXT should be limited to 65535 bytes, which seems plenty. So this is LGTM.

PrivateKey string `xorm:"VARCHAR(400)"`

SyncOnCommit bool `xorm:"NOT NULL DEFAULT true"`
Interval time.Duration
CreatedUnix timeutil.TimeStamp `xorm:"created"`
Expand Down Expand Up @@ -74,6 +79,12 @@ func (m *PushMirror) GetRemoteName() string {
return m.RemoteName
}

// GetPublicKey returns a sanitized version of the public key.
// This should only be used when displaying the public key to the user, not for actual code.
func (m *PushMirror) GetPublicKey() string {
return strings.TrimSuffix(m.PublicKey, "\n")
}

// InsertPushMirror inserts a push-mirror to database
func InsertPushMirror(ctx context.Context, m *PushMirror) error {
_, err := db.GetEngine(ctx).Insert(m)
Expand Down
136 changes: 136 additions & 0 deletions modules/crypto/ed25519.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package crypto

import (
"crypto/rand"
"encoding/binary"
"encoding/pem"
"fmt"

"golang.org/x/crypto/ed25519"
"golang.org/x/crypto/ssh"
)

// GenerateEd25519Keypair generates a new public and private key from the 25519 curve.
func GenerateEd25519Keypair() (publicKey, privateKey []byte, err error) {
// Generate the private key from ed25519.
public, private, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, nil, fmt.Errorf("ed25519.GenerateKey: %v", err)
}

// Marshal the privateKey into the OpenSSH format.
privPEM, err := marshalPrivateKey(private)
if err != nil {
return nil, nil, fmt.Errorf("not able to marshal private key into OpenSSH format: %v", err)
}

sshPublicKey, err := ssh.NewPublicKey(public)
if err != nil {
return nil, nil, fmt.Errorf("not able to create new SSH public key: %v", err)
}

return ssh.MarshalAuthorizedKey(sshPublicKey), pem.EncodeToMemory(privPEM), nil
}

// openSSHMagic contains the magic bytes, which is used to indicate it's a v1
// OpenSSH key format. "openssh-key-v1\x00" in bytes.
const openSSHMagic = "openssh-key-v1\x00"

// MarshalPrivateKey returns a PEM block with the private key serialized in the
// OpenSSH format.
// Adopted from: https://go-review.googlesource.com/c/crypto/+/218620/
func marshalPrivateKey(key ed25519.PrivateKey) (*pem.Block, error) {
// The ed25519.PrivateKey is a []byte (Seed, Public)

// Split the provided key in to a public key and private key bytes.
publicKeyBytes := make([]byte, ed25519.PublicKeySize)
privateKeyBytes := make([]byte, ed25519.PrivateKeySize)
copy(publicKeyBytes, key[ed25519.SeedSize:])
copy(privateKeyBytes, key)

// Now we want to eventually marshal the sshPrivateKeyStruct below but ssh.Marshal doesn't allow submarshalling
// So we need to create a number of structs in order to marshal them and build the struct we need.
//
// 1. Create a struct that holds the public key for this private key
pubKeyStruct := struct {
KeyType string
Pub []byte
}{
KeyType: ssh.KeyAlgoED25519,
Pub: publicKeyBytes,
}

// 2. Create a struct to contain the privateKeyBlock
// 2a. Marshal keypair as the rest struct
restStruct := struct {
Pub []byte
Priv []byte
Comment string
}{
publicKeyBytes, privateKeyBytes, "",
}
// 2b. Generate a random uint32 number.
// These can be random bytes or anything else, as long it's the same.
// See: https://github.com/openssh/openssh-portable/blob/f7fc6a43f1173e8b2c38770bf6cee485a562d03b/sshkey.c#L4228-L4235
var check uint32
if err := binary.Read(rand.Reader, binary.BigEndian, &check); err != nil {
return nil, err
}

// 2c. Create the privateKeyBlock struct
privateKeyBlockStruct := struct {
Check1 uint32
Check2 uint32
Keytype string
Rest []byte `ssh:"rest"`
}{
Check1: check,
Check2: check,
Keytype: ssh.KeyAlgoED25519,
Rest: ssh.Marshal(restStruct),
}

// 3. Now we're finally ready to create the OpenSSH sshPrivateKey
// Head struct of the OpenSSH format.
sshPrivateKeyStruct := struct {
CipherName string
KdfName string
KdfOpts string
NumKeys uint32
PubKey []byte // See pubKey
PrivKeyBlock []byte // See KeyPair
}{
CipherName: "none", // This is not a password protected key
KdfName: "none", // so these fields are left as none and empty
KdfOpts: "", //
NumKeys: 1,
PubKey: ssh.Marshal(pubKeyStruct),
PrivKeyBlock: generateOpenSSHPadding(ssh.Marshal(privateKeyBlockStruct)),
}

// 4. Finally marshal the sshPrivateKeyStruct struct.
bs := ssh.Marshal(sshPrivateKeyStruct)
block := &pem.Block{
Type: "OPENSSH PRIVATE KEY",
Bytes: append([]byte(openSSHMagic), bs...),
}

return block, nil
}

// generateOpenSSHPaddins converts the block to
// accomplish a block size of 8 bytes.
func generateOpenSSHPadding(block []byte) []byte {
padding := []byte{1, 2, 3, 4, 5, 6, 7}

mod8 := len(block) % 8
if mod8 > 0 {
block = append(block, padding[:8-mod8]...)
}

return block
}
47 changes: 47 additions & 0 deletions modules/crypto/ed25519_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package crypto

import (
"bytes"
"crypto/rand"
"testing"

"github.com/stretchr/testify/assert"
)

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-----
`
)

func TestGeneratingEd25519Keypair(t *testing.T) {
// Temp override the rand.Reader for deterministic testing.
oldReader := rand.Reader
defer func() {
rand.Reader = oldReader
}()

// 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 := GenerateEd25519Keypair()
assert.NoError(t, err)
assert.EqualValues(t, testPublicKey, string(publicKey))
assert.EqualValues(t, testPrivateKey, string(privateKey))
}
20 changes: 13 additions & 7 deletions modules/git/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,17 +186,21 @@ func CloneWithArgs(ctx context.Context, from, to string, args []string, opts Clo

// 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
InitArgs []string
Timeout time.Duration
}

// Push pushs local commits to given remote branch.
func Push(ctx context.Context, repoPath string, opts PushOptions) error {
cmd := NewCommand(ctx, "push")
initArgs := opts.InitArgs
initArgs = append(initArgs, "push")

cmd := NewCommand(ctx, initArgs...)
if opts.Force {
cmd.AddArguments("-f")
}
Expand All @@ -207,11 +211,13 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error {
if len(opts.Branch) > 0 {
cmd.AddArguments(opts.Branch)
}

if strings.Contains(opts.Remote, "://") && strings.Contains(opts.Remote, "@") {
cmd.SetDescription(fmt.Sprintf("push branch %s to %s (force: %t, mirror: %t)", opts.Branch, util.SanitizeCredentialURLs(opts.Remote), opts.Force, opts.Mirror))
} else {
cmd.SetDescription(fmt.Sprintf("push branch %s to %s (force: %t, mirror: %t)", opts.Branch, opts.Remote, opts.Force, opts.Mirror))
}

var outbuf, errbuf strings.Builder

if opts.Timeout == 0 {
Expand Down
4 changes: 4 additions & 0 deletions modules/lfs/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ func endpointFromURL(rawurl string) *url.URL {
case "git":
u.Scheme = "https"
return u
case "ssh":
u.Scheme = "https"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that really intended?
Why should SSH be rewritten to HTTPS?
I would say it's a copy-paste error, but the git case also uses https…

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah module/lfs doesn't have an SSH client, just HTTP. Which is a problem regarding you don't provide any auth.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, just to recap:
We know that this is going to cause problems with private repos, but we cannot change it. Time for a comment?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's going to be a problem with any repository that has LFS. As pushing the LFS data over HTTP without any auth will just fail... I'm not sure if I want to implement SSH client here(as I know nothing about git-LFS and git + ssh is on it's own already tedious for non-interactive usages). Might just force to ask credentials when LFS is enabled on the repository

u.User = nil
return u
case "file":
return u
default:
Expand Down
5 changes: 5 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,10 @@ mirror_prune = Prune
mirror_prune_desc = Remove obsolete remote-tracking references
mirror_interval = Mirror Interval (valid time units are 'h', 'm', 's'). 0 to disable periodic sync. (Minimum interval: %s)
mirror_interval_invalid = The mirror interval is not valid.
mirror_public_key = Public SSH Key
mirror_use_ssh = Use SSH authentication
mirror_use_ssh_tooltip = Gitea will mirror the repository via Git over SSH and create a keypair for you when you select this option. You must check afterwards that the generated public key is authorized to push to the repository. You cannot use password-based authorization when selecting this.
mirror_denied_combination = Cannot use public key and password based authentication in combination.
mirror_sync_on_commit = Sync when commits are pushed
mirror_address = Clone From URL
mirror_address_desc = Put any required credentials in the Authorization section.
Expand Down Expand Up @@ -1791,6 +1795,7 @@ settings.mirror_settings.last_update = Last update
settings.mirror_settings.push_mirror.none = No push mirrors configured
settings.mirror_settings.push_mirror.remote_url = Git Remote Repository URL
settings.mirror_settings.push_mirror.add = Add Push Mirror
settings.mirror_settings.push_mirror.copy_public_key = Copy Public Key
settings.sync_mirror = Synchronize Now
settings.mirror_sync_in_progress = Mirror synchronization is in progress. Check back in a minute.
settings.site = Website
Expand Down
18 changes: 18 additions & 0 deletions routers/web/repo/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/crypto"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/indexer/code"
"code.gitea.io/gitea/modules/indexer/stats"
Expand Down Expand Up @@ -345,6 +346,12 @@ func SettingsPost(ctx *context.Context) {
return
}

if form.PushMirrorUseSSH && (form.PushMirrorUsername != "" || form.PushMirrorPassword != "") {
ctx.Data["Err_PushMirrorUseSSH"] = true
ctx.RenderWithErr(ctx.Tr("repo.mirror_denied_combination"), tplSettingsOptions, &form)
return
}

address, err := forms.ParseRemoteAddr(form.PushMirrorAddress, form.PushMirrorUsername, form.PushMirrorPassword)
if err == nil {
err = migrations.IsMigrateURLAllowed(address, ctx.Doer)
Expand All @@ -368,6 +375,17 @@ func SettingsPost(ctx *context.Context) {
SyncOnCommit: form.PushMirrorSyncOnCommit,
Interval: interval,
}

if form.PushMirrorUseSSH {
publicKey, privateKey, err := crypto.GenerateEd25519Keypair()
if err != nil {
ctx.ServerError("GenerateEd25519Keypair", err)
return
}
m.PrivateKey = string(privateKey)
m.PublicKey = string(publicKey)
}

if err := repo_model.InsertPushMirror(ctx, m); err != nil {
ctx.ServerError("InsertPushMirror", err)
return
Expand Down
Loading