mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-18 19:39:19 +03:00
feat(ssh): auto generate additional ssh keys (#33974)
adds capabilities for gitea to generate ecdsa and ed25519 keys by default adds cli for built-in ssh key generation helpers closes: https://github.com/go-gitea/gitea/issues/33783 --------- Co-authored-by: Nicolas <bircni@icloud.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consts
|
||||
|
||||
const (
|
||||
AsymKeyMinBitsRsa = 3071 // 3072-1 to tolerate the leading zero
|
||||
AsymKeyMinBitsEC = 256
|
||||
|
||||
AsymKeyDefaultBitsRsa = 4096 // ssh-keygen command defaults to 3072
|
||||
AsymKeyDefaultBitsEcdsa = 256
|
||||
)
|
||||
@@ -5,15 +5,23 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/consts"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// NewInternalToken generate a new value intended to be used by INTERNAL_TOKEN.
|
||||
@@ -67,3 +75,75 @@ func NewJwtSecretWithBase64() ([]byte, string) {
|
||||
func NewSecretKey() (string, error) {
|
||||
return util.CryptoRandomString(64), nil
|
||||
}
|
||||
|
||||
type SSHKeyType string
|
||||
|
||||
const (
|
||||
SSHKeyRSA SSHKeyType = "rsa"
|
||||
SSHKeyECDSA SSHKeyType = "ecdsa"
|
||||
SSHKeyED25519 SSHKeyType = "ed25519"
|
||||
)
|
||||
|
||||
func NewSSHKey(keyType SSHKeyType, bits int) (ssh.PublicKey, *pem.Block, error) {
|
||||
pub, priv, err := commonKeyGen(keyType, bits)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pemPriv, err := ssh.MarshalPrivateKey(priv, "")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sshPub, err := ssh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return sshPub, pemPriv, nil
|
||||
}
|
||||
|
||||
// commonKeyGen is an abstraction over rsa, ecdsa, and ed25519 generating functions
|
||||
func commonKeyGen(keyType SSHKeyType, bits int) (crypto.PublicKey, crypto.PrivateKey, error) {
|
||||
switch keyType {
|
||||
case SSHKeyRSA:
|
||||
bits = util.IfZero(bits, consts.AsymKeyDefaultBitsRsa)
|
||||
if bits < consts.AsymKeyMinBitsRsa {
|
||||
return nil, nil, util.NewInvalidArgumentErrorf("invalid rsa bits: %d", bits)
|
||||
}
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, bits)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &privateKey.PublicKey, privateKey, nil
|
||||
case SSHKeyED25519:
|
||||
return ed25519.GenerateKey(rand.Reader)
|
||||
case SSHKeyECDSA:
|
||||
bits = util.IfZero(bits, consts.AsymKeyDefaultBitsEcdsa)
|
||||
if bits < consts.AsymKeyMinBitsEC {
|
||||
return nil, nil, util.NewInvalidArgumentErrorf("invalid elliptic-curve bits: %d", bits)
|
||||
}
|
||||
curve, err := getEllipticCurve(bits)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
privateKey, err := ecdsa.GenerateKey(curve, rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &privateKey.PublicKey, privateKey, nil
|
||||
default:
|
||||
return nil, nil, util.NewInvalidArgumentErrorf("unknown key type: %s", keyType)
|
||||
}
|
||||
}
|
||||
|
||||
func getEllipticCurve(bits int) (elliptic.Curve, error) {
|
||||
switch bits {
|
||||
case 256:
|
||||
return elliptic.P256(), nil
|
||||
case 384:
|
||||
return elliptic.P384(), nil
|
||||
case 521:
|
||||
return elliptic.P521(), nil
|
||||
default:
|
||||
return nil, util.NewInvalidArgumentErrorf("unsupported elliptic-curve bits: %d", bits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/consts"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
@@ -52,8 +53,8 @@ var SSH = struct {
|
||||
Domain: "",
|
||||
Port: 22,
|
||||
MinimumKeySizeCheck: true,
|
||||
MinimumKeySizes: map[string]int{"ed25519": 256, "ed25519-sk": 256, "ecdsa": 256, "ecdsa-sk": 256, "rsa": 3071},
|
||||
ServerHostKeys: []string{"ssh/gitea.rsa", "ssh/gogs.rsa"},
|
||||
MinimumKeySizes: map[string]int{"ed25519": consts.AsymKeyMinBitsEC, "ed25519-sk": consts.AsymKeyMinBitsEC, "ecdsa": consts.AsymKeyMinBitsEC, "ecdsa-sk": consts.AsymKeyMinBitsEC, "rsa": consts.AsymKeyMinBitsRsa},
|
||||
ServerHostKeys: []string{"ssh/gitea.rsa", "ssh/gitea.ed25519", "ssh/gitea.ecdsa", "ssh/gogs.rsa"},
|
||||
AuthorizedKeysCommandTemplate: "{{.AppPath}} --config={{.CustomConf}} serv key-{{.Key.ID}}",
|
||||
PerWriteTimeout: PerWriteTimeout,
|
||||
PerWritePerKbTimeout: PerWritePerKbTimeout,
|
||||
|
||||
+48
-53
@@ -6,9 +6,6 @@ package ssh
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -23,11 +20,11 @@ import (
|
||||
"syscall"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/modules/generate"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
@@ -59,7 +56,7 @@ func getExitStatusFromError(err error) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
@@ -322,7 +319,7 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||
}
|
||||
|
||||
// sshConnectionFailed logs a failed connection
|
||||
// - this mainly exists to give a nice function name in logging
|
||||
// - this mainly exists to give a nice function name in logging
|
||||
func sshConnectionFailed(conn net.Conn, err error) {
|
||||
// Log the underlying error with a specific message
|
||||
log.Warn("Failed connection from %s with error: %v", conn.RemoteAddr(), err)
|
||||
@@ -351,40 +348,37 @@ func Listen(host string, port int, ciphers, keyExchanges, macs []string) {
|
||||
},
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(setting.SSH.ServerHostKeys))
|
||||
hostKeyFiles := make([]string, 0, len(setting.SSH.ServerHostKeys))
|
||||
for _, key := range setting.SSH.ServerHostKeys {
|
||||
isExist, err := util.IsExist(key)
|
||||
_, err := os.Stat(key)
|
||||
if err != nil {
|
||||
log.Fatal("Unable to check if %s exists. Error: %v", setting.SSH.ServerHostKeys, err)
|
||||
}
|
||||
if isExist {
|
||||
keys = append(keys, key)
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Fatal("Unable to check if %s exists. Error: %v", setting.SSH.ServerHostKeys, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
hostKeyFiles = append(hostKeyFiles, key)
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
filePath := filepath.Dir(setting.SSH.ServerHostKeys[0])
|
||||
|
||||
if err := os.MkdirAll(filePath, os.ModePerm); err != nil {
|
||||
log.Error("Failed to create dir %s: %v", filePath, err)
|
||||
if len(hostKeyFiles) == 0 {
|
||||
hostKeyDir := filepath.Dir(setting.SSH.ServerHostKeys[0])
|
||||
err := os.MkdirAll(hostKeyDir, os.ModePerm)
|
||||
if err != nil {
|
||||
log.Error("Failed to create dir %s: %v", hostKeyDir, err)
|
||||
}
|
||||
|
||||
err := GenKeyPair(setting.SSH.ServerHostKeys[0])
|
||||
hostKeyFiles, err = InitDefaultHostKeys(hostKeyDir)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to generate private key: %v", err)
|
||||
}
|
||||
log.Trace("New private key is generated: %s", setting.SSH.ServerHostKeys[0])
|
||||
keys = append(keys, setting.SSH.ServerHostKeys[0])
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
log.Info("Adding SSH host key: %s", key)
|
||||
err := srv.SetOption(ssh.HostKeyFile(key))
|
||||
for _, keyFile := range hostKeyFiles {
|
||||
log.Info("Adding SSH host key: %s", keyFile)
|
||||
err := srv.SetOption(ssh.HostKeyFile(keyFile))
|
||||
if err != nil {
|
||||
log.Error("Failed to set Host Key. %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Service: Built-in SSH server", process.SystemProcessType, true)
|
||||
defer finished()
|
||||
@@ -395,43 +389,44 @@ func Listen(host string, port int, ciphers, keyExchanges, macs []string) {
|
||||
// GenKeyPair make a pair of public and private keys for SSH access.
|
||||
// Public key is encoded in the format for inclusion in an OpenSSH authorized_keys file.
|
||||
// Private Key generated is PEM encoded
|
||||
func GenKeyPair(keyPath string) error {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
func GenKeyPair(keyPath string, keyType generate.SSHKeyType, bits int) error {
|
||||
publicKey, privateKeyPEM, err := generate.NewSSHKey(keyType, bits)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
|
||||
f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err = f.Close(); err != nil {
|
||||
log.Error("Close: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := pem.Encode(f, privateKeyPEM); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// generate public key
|
||||
pub, err := gossh.NewPublicKey(&privateKey.PublicKey)
|
||||
public := gossh.MarshalAuthorizedKey(publicKey)
|
||||
privateKeyBuf := &bytes.Buffer{}
|
||||
err = pem.Encode(privateKeyBuf, privateKeyPEM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
public := gossh.MarshalAuthorizedKey(pub)
|
||||
p, err := os.OpenFile(keyPath+".pub", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
err = os.WriteFile(keyPath, privateKeyBuf.Bytes(), 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err = p.Close(); err != nil {
|
||||
log.Error("Close: %v", err)
|
||||
}
|
||||
}()
|
||||
_, err = p.Write(public)
|
||||
return err
|
||||
|
||||
return os.WriteFile(keyPath+".pub", public, 0o644)
|
||||
}
|
||||
|
||||
// InitDefaultHostKeys mirrors how ssh-keygen -A operates
|
||||
// it runs checks if public and private keys are already defined and creates new ones if not present
|
||||
// key naming does not follow the OpenSSH convention due to existing settings being gitea.{KeyType} so generation follows gitea convention
|
||||
func InitDefaultHostKeys(path string) (keyFiles []string, _ error) {
|
||||
var errs []error
|
||||
keyTypes := []generate.SSHKeyType{generate.SSHKeyRSA, generate.SSHKeyECDSA, generate.SSHKeyED25519}
|
||||
for _, keyType := range keyTypes {
|
||||
keyPath := filepath.Join(path, "gitea."+string(keyType))
|
||||
_, errStatPriv := os.Stat(keyPath)
|
||||
if errStatPriv != nil {
|
||||
err := GenKeyPair(keyPath, keyType, 0)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
keyFiles = append(keyFiles, keyPath)
|
||||
}
|
||||
return keyFiles, errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/generate"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestGenKeyPair(t *testing.T) {
|
||||
testCases := []struct {
|
||||
keyType generate.SSHKeyType
|
||||
expectedType any
|
||||
}{
|
||||
{
|
||||
keyType: generate.SSHKeyRSA,
|
||||
expectedType: &rsa.PrivateKey{},
|
||||
},
|
||||
{
|
||||
keyType: generate.SSHKeyED25519,
|
||||
expectedType: &ed25519.PrivateKey{},
|
||||
},
|
||||
{
|
||||
keyType: generate.SSHKeyECDSA,
|
||||
expectedType: &ecdsa.PrivateKey{},
|
||||
},
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
for _, tc := range testCases {
|
||||
name := "gitea." + string(tc.keyType)
|
||||
fn := filepath.Join(tmpDir, name)
|
||||
t.Run("Generate "+name, func(t *testing.T) {
|
||||
require.NoError(t, GenKeyPair(fn, tc.keyType, 0))
|
||||
|
||||
bytes, err := os.ReadFile(fn)
|
||||
require.NoError(t, err)
|
||||
|
||||
privateKey, err := gossh.ParseRawPrivateKey(bytes)
|
||||
require.NoError(t, err)
|
||||
assert.IsType(t, tc.expectedType, privateKey)
|
||||
})
|
||||
}
|
||||
t.Run("Generate unknown key type", func(t *testing.T) {
|
||||
err := GenKeyPair(t.TempDir()+"gitea.badkey", "badkey", 0)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInitKeys(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
keyTypes := []string{"rsa", "ecdsa", "ed25519"}
|
||||
for _, keyType := range keyTypes {
|
||||
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
||||
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
||||
assert.NoFileExists(t, privKeyPath)
|
||||
assert.NoFileExists(t, pubKeyPath)
|
||||
}
|
||||
|
||||
// Test basic creation
|
||||
keyFiles, err := InitDefaultHostKeys(tempDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, keyFiles, len(keyTypes))
|
||||
|
||||
metadata := map[string]os.FileInfo{}
|
||||
for _, keyType := range keyTypes {
|
||||
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
||||
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
||||
info, err := os.Stat(privKeyPath)
|
||||
require.NoError(t, err)
|
||||
metadata[privKeyPath] = info
|
||||
|
||||
info, err = os.Stat(pubKeyPath)
|
||||
require.NoError(t, err)
|
||||
metadata[pubKeyPath] = info
|
||||
}
|
||||
|
||||
// Test recreation on missing private key and noop for missing pub key
|
||||
require.NoError(t, os.Remove(filepath.Join(tempDir, "gitea.ecdsa.pub")))
|
||||
require.NoError(t, os.Remove(filepath.Join(tempDir, "gitea.ed25519")))
|
||||
|
||||
keyFiles, err = InitDefaultHostKeys(tempDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, keyFiles, len(keyTypes))
|
||||
|
||||
for _, keyType := range keyTypes {
|
||||
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
||||
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
||||
|
||||
infoPriv, err := os.Stat(privKeyPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
switch keyType {
|
||||
case "rsa":
|
||||
// No modification to RSA key
|
||||
infoPub, err := os.Stat(pubKeyPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, metadata[privKeyPath], infoPriv)
|
||||
assert.Equal(t, metadata[pubKeyPath], infoPub)
|
||||
case "ecdsa":
|
||||
// ECDSA public key should be missing, private unchanged
|
||||
assert.Equal(t, metadata[privKeyPath], infoPriv)
|
||||
assert.NoFileExists(t, pubKeyPath)
|
||||
case "ed25519":
|
||||
// ed25519 private key was removed, so both keys regenerated
|
||||
infoPub, err := os.Stat(pubKeyPath)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, metadata[privKeyPath], infoPriv)
|
||||
assert.NotEqual(t, metadata[pubKeyPath], infoPub)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user