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 da8cdf58d2579..4e5ca04e20bb6 100644 --- a/modules/setting/ssh.go +++ b/modules/setting/ssh.go @@ -56,7 +56,7 @@ var SSH = struct { ServerMACs: []string{"hmac-sha2-256-etm@openssh.com", "hmac-sha2-256", "hmac-sha1"}, 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 ff0ad34a0d127..150c21ec88546 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" "io" @@ -23,6 +20,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" @@ -54,6 +52,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,17 +373,17 @@ 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]) + err := initDefaultKeys(filePath) 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 _, keytype := range []string{"rsa", "ecdsa", "ed25519"} { + filename := filePath + "/gitea." + keytype + keys = append(keys, filename) + } } for _, key := range keys { @@ -387,7 +393,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() @@ -398,13 +403,17 @@ 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, keytype string) error { + bits := 4096 + if keytype == "ecdsa" { + bits = 256 + } + + 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 @@ -419,13 +428,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 @@ -438,3 +441,19 @@ func GenKeyPair(keyPath string) error { _, err = p.Write(public) return err } + +// initDefaultKeys 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 initDefaultKeys(path string) error { + var errs []error + keytypes := []string{"rsa", "ecdsa", "ed25519"} + for _, keytype := range keytypes { + privExists, _ := util.IsExist(path + "/gitea." + keytype) + pubExists, _ := util.IsExist(path + "/gitea." + keytype + ".pub") + if !privExists || !pubExists { + errs = append(errs, GenKeyPair(path+"/gitea."+keytype, keytype)) + } + } + return errors.Join(errs...) +} diff --git a/modules/ssh/ssh_test.go b/modules/ssh/ssh_test.go new file mode 100644 index 0000000000000..f47dcaa6e455c --- /dev/null +++ b/modules/ssh/ssh_test.go @@ -0,0 +1,118 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ssh + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gossh "golang.org/x/crypto/ssh" +) + +func TestGenKeyPair(t *testing.T) { + testCases := []struct { + keyType string + expectedType any + }{ + { + keyType: "rsa", + expectedType: &rsa.PrivateKey{}, + }, + { + keyType: "ed25519", + expectedType: &ed25519.PrivateKey{}, + }, + { + keyType: "ecdsa", + expectedType: &ecdsa.PrivateKey{}, + }, + } + for _, tC := range testCases { + t.Run("Generate "+filepath.Ext(tC.keyType), func(t *testing.T) { + path := t.TempDir() + "gitea." + tC.keyType + require.NoError(t, GenKeyPair(path, tC.keyType)) + + 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) + }) + } + t.Run("Generate unknown keytype", func(t *testing.T) { + path := t.TempDir() + "gitea.badkey" + + err := GenKeyPair(path, "badkey") + 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 + err := initDefaultKeys(tempDir) + require.NoError(t, err) + + metadata := map[string]os.FileInfo{} + for _, keytype := range keytypes { + privKeyPath := filepath.Join(tempDir, "gitea."+keytype) + pubKeyPath := filepath.Join(tempDir, "gitea."+keytype+".pub") + assert.FileExists(t, privKeyPath) + assert.FileExists(t, pubKeyPath) + + 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 public or private key + require.NoError(t, os.Remove(filepath.Join(tempDir, "gitea.ecdsa.pub"))) + require.NoError(t, os.Remove(filepath.Join(tempDir, "gitea.ed25519"))) + + err = initDefaultKeys(tempDir) + require.NoError(t, err) + + for _, keytype := range keytypes { + privKeyPath := filepath.Join(tempDir, "gitea."+keytype) + pubKeyPath := filepath.Join(tempDir, "gitea."+keytype+".pub") + assert.FileExists(t, privKeyPath) + assert.FileExists(t, pubKeyPath) + + infoPriv, err := os.Stat(privKeyPath) + require.NoError(t, err) + infoPub, err := os.Stat(pubKeyPath) + require.NoError(t, err) + if keytype == "rsa" { + assert.Equal(t, metadata[privKeyPath], infoPriv) + assert.Equal(t, metadata[pubKeyPath], infoPub) + } else { + assert.NotEqual(t, metadata[privKeyPath], infoPriv) + assert.NotEqual(t, metadata[pubKeyPath], infoPub) + } + } +} diff --git a/tests/integration/git_helper_for_declarative_test.go b/tests/integration/git_helper_for_declarative_test.go index 7d42508bfe20f..17c1821b02055 100644 --- a/tests/integration/git_helper_for_declarative_test.go +++ b/tests/integration/git_helper_for_declarative_test.go @@ -31,7 +31,7 @@ func withKeyFile(t *testing.T, keyname string, callback func(string)) { assert.NoError(t, err) keyFile := filepath.Join(tmpDir, keyname) - err = ssh.GenKeyPair(keyFile) + err = ssh.GenKeyPair(keyFile, "ecdsa") assert.NoError(t, err) err = os.WriteFile(filepath.Join(tmpDir, "ssh"), []byte("#!/bin/bash\n"+