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:
TheFox0x7
2026-06-08 18:18:58 +00:00
committed by GitHub
co-authored by GitHub Nicolas wxiaoguang Giteabot
parent ade76fe838
commit d76a974b24
9 changed files with 350 additions and 73 deletions
+76
View File
@@ -6,10 +6,12 @@ package cmd
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"gitea.dev/modules/generate" "gitea.dev/modules/generate"
"gitea.dev/modules/ssh"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -21,6 +23,7 @@ func newGenerateCommand() *cli.Command {
Usage: "Generate Gitea's secrets/keys/tokens", Usage: "Generate Gitea's secrets/keys/tokens",
Commands: []*cli.Command{ Commands: []*cli.Command{
newGenerateSecretCommand(), newGenerateSecretCommand(),
newGenerateSSHCommand(),
}, },
} }
} }
@@ -37,6 +40,17 @@ func newGenerateSecretCommand() *cli.Command {
} }
} }
func newGenerateSSHCommand() *cli.Command {
return &cli.Command{
Name: "ssh",
Usage: "Generate ssh keys",
Commands: []*cli.Command{
newGenerateSSHKeyCommand(),
newGenerateSSHHostKeysCommand(),
},
}
}
func newGenerateInternalTokenCommand() *cli.Command { func newGenerateInternalTokenCommand() *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "INTERNAL_TOKEN", Name: "INTERNAL_TOKEN",
@@ -62,6 +76,30 @@ func newGenerateSecretKeyCommand() *cli.Command {
} }
} }
func newGenerateSSHKeyCommand() *cli.Command {
return &cli.Command{
Name: "key",
Usage: "Generate a new ssh key",
Flags: []cli.Flag{
&cli.IntFlag{Name: "bits", Aliases: []string{"b"}, Usage: "Number of bits in the key, ignored when key is ed25519"},
&cli.StringFlag{Name: "type", Aliases: []string{"t"}, Value: "ed25519", Usage: "Specifies the type of key to create."},
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "Specifies the path or base directory for the key file", Required: true},
},
Action: runGenerateKeyPair,
}
}
func newGenerateSSHHostKeysCommand() *cli.Command {
return &cli.Command{
Name: "host-keys",
Usage: "Generate host keys of all default key types (rsa, ecdsa, and ed25519) if they do not already exist.",
Flags: []cli.Flag{
&cli.StringFlag{Name: "dir", Aliases: []string{"d"}, Usage: "Specifies the base directory for the key files", Required: true},
},
Action: runGenerateHostKey,
}
}
func runGenerateInternalToken(_ context.Context, c *cli.Command) error { func runGenerateInternalToken(_ context.Context, c *cli.Command) error {
internalToken, err := generate.NewInternalToken() internalToken, err := generate.NewInternalToken()
if err != nil { if err != nil {
@@ -103,3 +141,41 @@ func runGenerateSecretKey(_ context.Context, c *cli.Command) error {
return nil return nil
} }
func runGenerateHostKey(_ context.Context, c *cli.Command) error {
file := c.String("dir")
info, err := os.Stat(file)
if errors.Is(err, os.ErrNotExist) {
if err = os.MkdirAll(file, 0o644); err != nil {
return err
}
} else if err != nil {
return err
} else if !info.IsDir() {
return errors.New("file already exists and is not a directory")
}
fmt.Fprintf(c.Writer, "Generating host keys in %s\n", file)
_, err = ssh.InitDefaultHostKeys(file)
return err
}
func runGenerateKeyPair(_ context.Context, c *cli.Command) error {
file := c.String("file")
keyType := c.String("type")
fmt.Fprintf(c.Writer, "Generating public/private %s key pair.\n", keyType)
// Check if file exists to prevent overwriting
if _, err := os.Stat(file); err == nil {
if !confirm(c.Reader, c.Writer, "%s already exists.\nOverwrite (y/n)? ", file) {
fmt.Println("Aborting")
return nil
}
}
bits := c.Int("bits")
err := ssh.GenKeyPair(file, generate.SSHKeyType(keyType), bits)
if err == nil {
fmt.Printf("Your SSH key has been saved in %s\n", file)
}
return err
}
+5 -12
View File
@@ -38,22 +38,15 @@ func argsSet(c *cli.Command, args ...string) error {
} }
// confirm waits for user input which confirms an action // confirm waits for user input which confirms an action
func confirm() (bool, error) { func confirm(stdin io.Reader, stdout io.Writer, msg string, args ...any) bool {
var response string var response string
_, _ = fmt.Fprintf(stdout, msg, args...)
_, err := fmt.Scanln(&response) _, _ = fmt.Fscanln(stdin, &response)
if err != nil {
return false, err
}
switch strings.ToLower(response) { switch strings.ToLower(response) {
case "y", "yes": case "y", "yes":
return true, nil return true
case "n", "no":
return false, nil
default:
return false, errors.New(response + " isn't a correct confirmation string")
} }
return false
} }
func initDB(ctx context.Context) error { func initDB(ctx context.Context) error {
+2 -6
View File
@@ -22,14 +22,10 @@ func runSendMail(ctx context.Context, c *cli.Command) error {
if !confirmSkipped { if !confirmSkipped {
if len(body) == 0 { if len(body) == 0 {
fmt.Print("warning: Content is empty") fmt.Println("warning: Content is empty")
} }
fmt.Print("Proceed with sending email? [Y/n] ") if !confirm(c.Reader, c.Writer, "Proceed with sending email? [Y/n] ") {
isConfirmed, err := confirm()
if err != nil {
return err
} else if !isConfirmed {
fmt.Println("The mail was not sent") fmt.Println("The mail was not sent")
return nil return nil
} }
+12
View File
@@ -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
)
+80
View File
@@ -5,15 +5,23 @@
package generate package generate
import ( import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/rsa"
"encoding/base64" "encoding/base64"
"encoding/pem"
"fmt" "fmt"
"io" "io"
"time" "time"
"gitea.dev/modules/consts"
"gitea.dev/modules/util" "gitea.dev/modules/util"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/ssh"
) )
// NewInternalToken generate a new value intended to be used by INTERNAL_TOKEN. // NewInternalToken generate a new value intended to be used by INTERNAL_TOKEN.
@@ -67,3 +75,75 @@ func NewJwtSecretWithBase64() ([]byte, string) {
func NewSecretKey() (string, error) { func NewSecretKey() (string, error) {
return util.CryptoRandomString(64), nil 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)
}
}
+3 -2
View File
@@ -9,6 +9,7 @@ import (
"text/template" "text/template"
"time" "time"
"gitea.dev/modules/consts"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/util" "gitea.dev/modules/util"
@@ -52,8 +53,8 @@ var SSH = struct {
Domain: "", Domain: "",
Port: 22, Port: 22,
MinimumKeySizeCheck: true, MinimumKeySizeCheck: true,
MinimumKeySizes: map[string]int{"ed25519": 256, "ed25519-sk": 256, "ecdsa": 256, "ecdsa-sk": 256, "rsa": 3071}, 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/gogs.rsa"}, ServerHostKeys: []string{"ssh/gitea.rsa", "ssh/gitea.ed25519", "ssh/gitea.ecdsa", "ssh/gogs.rsa"},
AuthorizedKeysCommandTemplate: "{{.AppPath}} --config={{.CustomConf}} serv key-{{.Key.ID}}", AuthorizedKeysCommandTemplate: "{{.AppPath}} --config={{.CustomConf}} serv key-{{.Key.ID}}",
PerWriteTimeout: PerWriteTimeout, PerWriteTimeout: PerWriteTimeout,
PerWritePerKbTimeout: PerWritePerKbTimeout, PerWritePerKbTimeout: PerWritePerKbTimeout,
+47 -52
View File
@@ -6,9 +6,6 @@ package ssh
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem" "encoding/pem"
"errors" "errors"
"io" "io"
@@ -23,11 +20,11 @@ import (
"syscall" "syscall"
asymkey_model "gitea.dev/models/asymkey" asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/modules/generate"
"gitea.dev/modules/graceful" "gitea.dev/modules/graceful"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/process" "gitea.dev/modules/process"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/util"
"github.com/gliderlabs/ssh" "github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh" gossh "golang.org/x/crypto/ssh"
@@ -59,7 +56,7 @@ func getExitStatusFromError(err error) int {
return 0 return 0
} }
exitErr, ok := err.(*exec.ExitError) exitErr, ok := errors.AsType[*exec.ExitError](err)
if !ok { if !ok {
return 1 return 1
} }
@@ -322,7 +319,7 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
} }
// sshConnectionFailed logs a failed connection // 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) { func sshConnectionFailed(conn net.Conn, err error) {
// Log the underlying error with a specific message // Log the underlying error with a specific message
log.Warn("Failed connection from %s with error: %v", conn.RemoteAddr(), err) 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 { for _, key := range setting.SSH.ServerHostKeys {
isExist, err := util.IsExist(key) _, err := os.Stat(key)
if err != nil { if err != nil {
log.Fatal("Unable to check if %s exists. Error: %v", setting.SSH.ServerHostKeys, err) if !errors.Is(err, os.ErrNotExist) {
} log.Fatal("Unable to check if %s exists. Error: %v", setting.SSH.ServerHostKeys, err)
if isExist { }
keys = append(keys, key) continue
} }
hostKeyFiles = append(hostKeyFiles, key)
} }
if len(keys) == 0 { if len(hostKeyFiles) == 0 {
filePath := filepath.Dir(setting.SSH.ServerHostKeys[0]) hostKeyDir := filepath.Dir(setting.SSH.ServerHostKeys[0])
err := os.MkdirAll(hostKeyDir, os.ModePerm)
if err := os.MkdirAll(filePath, os.ModePerm); err != nil { if err != nil {
log.Error("Failed to create dir %s: %v", filePath, err) log.Error("Failed to create dir %s: %v", hostKeyDir, err)
} }
hostKeyFiles, err = InitDefaultHostKeys(hostKeyDir)
err := GenKeyPair(setting.SSH.ServerHostKeys[0])
if err != nil { if err != nil {
log.Fatal("Failed to generate private key: %v", err) 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 { for _, keyFile := range hostKeyFiles {
log.Info("Adding SSH host key: %s", key) log.Info("Adding SSH host key: %s", keyFile)
err := srv.SetOption(ssh.HostKeyFile(key)) err := srv.SetOption(ssh.HostKeyFile(keyFile))
if err != nil { if err != nil {
log.Error("Failed to set Host Key. %s", err) log.Error("Failed to set Host Key. %s", err)
} }
} }
go func() { go func() {
_, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Service: Built-in SSH server", process.SystemProcessType, true) _, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Service: Built-in SSH server", process.SystemProcessType, true)
defer finished() 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. // 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. // Public key is encoded in the format for inclusion in an OpenSSH authorized_keys file.
// Private Key generated is PEM encoded // Private Key generated is PEM encoded
func GenKeyPair(keyPath string) error { func GenKeyPair(keyPath string, keyType generate.SSHKeyType, bits int) error {
privateKey, err := rsa.GenerateKey(rand.Reader, 4096) publicKey, privateKeyPEM, err := generate.NewSSHKey(keyType, bits)
if err != nil { if err != nil {
return err return err
} }
privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} public := gossh.MarshalAuthorizedKey(publicKey)
f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) privateKeyBuf := &bytes.Buffer{}
if err != nil { err = pem.Encode(privateKeyBuf, privateKeyPEM)
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)
if err != nil { if err != nil {
return err return err
} }
public := gossh.MarshalAuthorizedKey(pub) err = os.WriteFile(keyPath, privateKeyBuf.Bytes(), 0o600)
p, err := os.OpenFile(keyPath+".pub", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil { if err != nil {
return err return err
} }
defer func() {
if err = p.Close(); err != nil { return os.WriteFile(keyPath+".pub", public, 0o644)
log.Error("Close: %v", err) }
// 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)
_, err = p.Write(public) }
return err return keyFiles, errors.Join(errs...)
} }
+123
View File
@@ -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)
}
}
}
@@ -15,6 +15,7 @@ import (
"testing" "testing"
"time" "time"
"gitea.dev/modules/generate"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd" "gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
@@ -33,7 +34,7 @@ func withKeyFile(t *testing.T, keyname string, callback func(string)) {
assert.NoError(t, err) assert.NoError(t, err)
keyFile := filepath.Join(tmpDir, keyname) keyFile := filepath.Join(tmpDir, keyname)
err = ssh.GenKeyPair(keyFile) err = ssh.GenKeyPair(keyFile, generate.SSHKeyECDSA, 0)
assert.NoError(t, err) assert.NoError(t, err)
err = os.WriteFile(filepath.Join(tmpDir, "ssh"), []byte("#!/bin/bash\n"+ err = os.WriteFile(filepath.Join(tmpDir, "ssh"), []byte("#!/bin/bash\n"+