diff --git a/cmd/generate.go b/cmd/generate.go index 90b32ecaf0e1c..d9c682c4235c9 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -5,13 +5,17 @@ package cmd import ( + "bufio" + "encoding/pem" "fmt" "os" + "strings" "code.gitea.io/gitea/modules/generate" "github.com/mattn/go-isatty" "github.com/urfave/cli/v2" + "golang.org/x/crypto/ssh" ) var ( @@ -21,6 +25,7 @@ var ( Usage: "Generate Gitea's secrets/keys/tokens", Subcommands: []*cli.Command{ subcmdSecret, + subcmdKeygen, }, } @@ -33,6 +38,17 @@ var ( microcmdGenerateSecretKey, }, } + keygenFlags = []cli.Flag{ + &cli.StringFlag{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: "Keytype to generate"}, + &cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "Specifies the filename of the key file", Required: true}, + } + subcmdKeygen = &cli.Command{ + Name: "ssh-keygen", + Usage: "Generate a ssh keypair", + Flags: keygenFlags, + Action: runGenerateKeyPair, + } microcmdGenerateInternalToken = &cli.Command{ Name: "INTERNAL_TOKEN", @@ -98,3 +114,49 @@ func runGenerateSecretKey(c *cli.Context) error { return nil } + +func runGenerateKeyPair(c *cli.Context) error { + file := c.String("file") + + // Check if file exists to prevent overwrites + if _, err := os.Stat(file); err == nil { + scanner := bufio.NewScanner(os.Stdin) + fmt.Printf("%s already exists.\nOverwrite (y/n)? ", file) + scanner.Scan() + if strings.ToLower(strings.TrimSpace(scanner.Text())) != "y" { + fmt.Println("Aborting") + return nil + } + } + keytype := c.String("type") + bits := c.Int("bits") + // provide defaults for bits, ed25519 ignores bit length so it's omitted + if bits == 0 { + if keytype == "rsa" { + bits = 3072 + } else { + bits = 256 + } + } + + pub, priv, err := generate.NewSSHKey(keytype, bits) + if err != nil { + return err + } + f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer f.Close() + err = pem.Encode(f, priv) + if err != nil { + return err + } + fmt.Printf("Your identification has been saved in %s\n", file) + err = os.WriteFile(file+".pub", ssh.MarshalAuthorizedKey(pub), 0o644) + if err != nil { + return err + } + fmt.Printf("Your public key has been saved in %s", file+".pub") + return nil +} diff --git a/modules/generate/generate.go b/modules/generate/generate.go index 2d9a3dd902245..cb54fd7b12c5a 100644 --- a/modules/generate/generate.go +++ b/modules/generate/generate.go @@ -5,8 +5,14 @@ package generate import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" "crypto/rand" + "crypto/rsa" "encoding/base64" + "encoding/pem" "fmt" "io" "time" @@ -14,6 +20,7 @@ import ( "code.gitea.io/gitea/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. @@ -72,3 +79,59 @@ func NewSecretKey() (string, error) { return secretKey, nil } + +func NewSSHKey(keytype string, 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 string, bits int) (publicKey, privateKey crypto.PublicKey, err error) { + switch keytype { + case "rsa": + privateKey, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return nil, nil, err + } + return &privateKey.PublicKey, privateKey, nil + case "ed25519": + return ed25519.GenerateKey(rand.Reader) + case "ecdsa": + curve, err := getElipticCurve(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, fmt.Errorf("unknown keytype: %s", keytype) + } +} + +func getElipticCurve(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, fmt.Errorf("unsupported ECDSA curve bit length: %d", bits) + } +} diff --git a/modules/setting/ssh.go b/modules/setting/ssh.go index ea387e521fad5..89d590109a6f9 100644 --- a/modules/setting/ssh.go +++ b/modules/setting/ssh.go @@ -61,7 +61,7 @@ var SSH = struct { KeygenPath: "", 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"}, + 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, diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go index 7479cfbd95a7f..f997e7af7c03b 100644 --- a/modules/ssh/ssh.go +++ b/modules/ssh/ssh.go @@ -6,9 +6,6 @@ package ssh import ( "bytes" "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" "encoding/pem" "errors" "fmt" @@ -24,6 +21,7 @@ import ( "syscall" asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/modules/generate" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" @@ -55,6 +53,14 @@ import ( const giteaPermissionExtensionKeyID = "gitea-perm-ext-key-id" +type KeyType string + +const ( + RSA KeyType = "rsa" + ECDSA KeyType = "ecdsa" + ED25519 KeyType = "ed25519" +) + func getExitStatusFromError(err error) int { if err == nil { return 0 @@ -367,18 +373,19 @@ func Listen(host string, port int, ciphers, keyExchanges, macs []string) { } 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) - } - - err := GenKeyPair(setting.SSH.ServerHostKeys[0]) - if err != nil { - log.Fatal("Failed to generate private key: %v", err) + for i := range 3 { + filename := setting.SSH.ServerHostKeys[i] + filePath := filepath.Dir(filename) + if err := os.MkdirAll(filePath, os.ModePerm); err != nil { + log.Error("Failed to create dir %s: %v", filePath, err) + } + err := GenKeyPair(filename) + if err != nil { + log.Fatal("Failed to generate private key: %v", err) + } + log.Trace("New private key is generated: %s", filename) + keys = append(keys, filename) } - log.Trace("New private key is generated: %s", setting.SSH.ServerHostKeys[0]) - keys = append(keys, setting.SSH.ServerHostKeys[0]) } for _, key := range keys { @@ -388,7 +395,6 @@ func Listen(host string, port int, ciphers, keyExchanges, macs []string) { 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() @@ -400,12 +406,21 @@ func Listen(host string, port int, ciphers, keyExchanges, macs []string) { // 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) + bits := 4096 + keytype := filepath.Ext(keyPath) + if keytype == ".ed25519" { + keytype = "ed25519" + } else if keytype == ".ecdsa" { + bits = 256 + keytype = "ecdsa" + } else { + keytype = "rsa" + } + 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 @@ -420,13 +435,7 @@ func GenKeyPair(keyPath string) error { return err } - // generate public key - pub, err := gossh.NewPublicKey(&privateKey.PublicKey) - if err != nil { - return err - } - - public := gossh.MarshalAuthorizedKey(pub) + public := gossh.MarshalAuthorizedKey(publicKey) p, err := os.OpenFile(keyPath+".pub", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return err diff --git a/modules/ssh/ssh_test.go b/modules/ssh/ssh_test.go new file mode 100644 index 0000000000000..c72ba26367f6b --- /dev/null +++ b/modules/ssh/ssh_test.go @@ -0,0 +1,56 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ssh_test + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "io" + "os" + "path/filepath" + "testing" + + "code.gitea.io/gitea/modules/ssh" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gossh "golang.org/x/crypto/ssh" +) + +func TestGenKeyPair(t *testing.T) { + testCases := []struct { + keyPath string + expectedType any + }{ + { + keyPath: "/gitea.rsa", + expectedType: &rsa.PrivateKey{}, + }, + { + keyPath: "/gitea.ed25519", + expectedType: &ed25519.PrivateKey{}, + }, + { + keyPath: "/gitea.ecdsa", + expectedType: &ecdsa.PrivateKey{}, + }, + } + for _, tC := range testCases { + t.Run("Generate "+filepath.Ext(tC.keyPath), func(t *testing.T) { + path := t.TempDir() + tC.keyPath + require.NoError(t, ssh.GenKeyPair(path)) + + file, err := os.Open(path) + require.NoError(t, err) + + bytes, err := io.ReadAll(file) + require.NoError(t, err) + + privateKey, err := gossh.ParseRawPrivateKey(bytes) + require.NoError(t, err) + assert.IsType(t, tC.expectedType, privateKey) + }) + } +}