From bde153efc00dde614d1bf856c0ba7da78136033f Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sat, 22 Mar 2025 16:16:33 +0100 Subject: [PATCH 01/16] add test for keygen --- modules/ssh/ssh_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 modules/ssh/ssh_test.go diff --git a/modules/ssh/ssh_test.go b/modules/ssh/ssh_test.go new file mode 100644 index 0000000000000..56d0f5db3fd93 --- /dev/null +++ b/modules/ssh/ssh_test.go @@ -0,0 +1,32 @@ +package ssh_test + +import ( + "crypto/x509" + "encoding/pem" + "io" + "os" + "testing" + + "code.gitea.io/gitea/modules/ssh" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenKeyPair(t *testing.T) { + path := t.TempDir() + "/gitea.rsa" + ssh.GenKeyPair(path) + + file, err := os.Open(path) + require.NoError(t, err) + + bytes, err := io.ReadAll(file) + require.NoError(t, err) + + block, _ := pem.Decode(bytes) + require.NotNil(t, block) + assert.Equal(t, "RSA PRIVATE KEY", block.Type) + + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + require.NoError(t, err) + assert.NotNil(t, privateKey) +} From ac013f06b6e8a4ccd9e40bf62d03ae3a2852ffc7 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sat, 22 Mar 2025 17:30:37 +0100 Subject: [PATCH 02/16] swap to PKCS8 form this allows storing different keys --- modules/ssh/ssh.go | 35 +++++++++++++++++++++++++++++------ modules/ssh/ssh_test.go | 9 +++++---- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go index 7479cfbd95a7f..343f601e04397 100644 --- a/modules/ssh/ssh.go +++ b/modules/ssh/ssh.go @@ -55,6 +55,12 @@ import ( const giteaPermissionExtensionKeyID = "gitea-perm-ext-key-id" +type KeyType string + +const ( + RSA KeyType = "rsa" +) + func getExitStatusFromError(err error) int { if err == nil { return 0 @@ -373,7 +379,7 @@ func Listen(host string, port int, ciphers, keyExchanges, macs []string) { log.Error("Failed to create dir %s: %v", filePath, err) } - err := GenKeyPair(setting.SSH.ServerHostKeys[0]) + err := GenKeyPair(setting.SSH.ServerHostKeys[0], RSA) if err != nil { log.Fatal("Failed to generate private key: %v", err) } @@ -388,7 +394,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() @@ -399,13 +404,18 @@ 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 KeyType) error { + privateKey, publicKey, err := keyGen(keyType) if err != nil { return err } - privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} + privateKeyPKCS8, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return err + } + + privateKeyPEM := &pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyPKCS8} f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return err @@ -421,7 +431,7 @@ func GenKeyPair(keyPath string) error { } // generate public key - pub, err := gossh.NewPublicKey(&privateKey.PublicKey) + pub, err := gossh.NewPublicKey(publicKey) if err != nil { return err } @@ -439,3 +449,16 @@ func GenKeyPair(keyPath string) error { _, err = p.Write(public) return err } + +func keyGen(keytype KeyType) (any, any, error) { + switch keytype { + case RSA: + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, nil, err + } + return privateKey, &privateKey.PublicKey, nil + default: + return nil, nil, errors.New("unknown keyType") + } +} diff --git a/modules/ssh/ssh_test.go b/modules/ssh/ssh_test.go index 56d0f5db3fd93..ecd12bcac397b 100644 --- a/modules/ssh/ssh_test.go +++ b/modules/ssh/ssh_test.go @@ -1,6 +1,7 @@ package ssh_test import ( + "crypto/rsa" "crypto/x509" "encoding/pem" "io" @@ -14,7 +15,7 @@ import ( func TestGenKeyPair(t *testing.T) { path := t.TempDir() + "/gitea.rsa" - ssh.GenKeyPair(path) + require.NoError(t, ssh.GenKeyPair(path, ssh.RSA)) file, err := os.Open(path) require.NoError(t, err) @@ -24,9 +25,9 @@ func TestGenKeyPair(t *testing.T) { block, _ := pem.Decode(bytes) require.NotNil(t, block) - assert.Equal(t, "RSA PRIVATE KEY", block.Type) + assert.Equal(t, "PRIVATE KEY", block.Type) - privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) require.NoError(t, err) - assert.NotNil(t, privateKey) + assert.IsType(t, &rsa.PrivateKey{}, privateKey) } From e1230541ba0d2acf49e799320b1f3e2a67eaf5fe Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sat, 22 Mar 2025 17:35:33 +0100 Subject: [PATCH 03/16] prepare test for more keys --- modules/ssh/ssh_test.go | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/modules/ssh/ssh_test.go b/modules/ssh/ssh_test.go index ecd12bcac397b..8d59745909e7f 100644 --- a/modules/ssh/ssh_test.go +++ b/modules/ssh/ssh_test.go @@ -14,20 +14,33 @@ import ( ) func TestGenKeyPair(t *testing.T) { - path := t.TempDir() + "/gitea.rsa" - require.NoError(t, ssh.GenKeyPair(path, ssh.RSA)) + testCases := []struct { + keyType ssh.KeyType + expectedType any + }{ + { + keyType: ssh.RSA, + expectedType: &rsa.PrivateKey{}, + }, + } + for _, tC := range testCases { + t.Run("Generate"+string(tC.keyType), func(t *testing.T) { + path := t.TempDir() + "/gitea." + string(tC.keyType) + require.NoError(t, ssh.GenKeyPair(path, tC.keyType)) - file, err := os.Open(path) - require.NoError(t, err) + file, err := os.Open(path) + require.NoError(t, err) - bytes, err := io.ReadAll(file) - require.NoError(t, err) + bytes, err := io.ReadAll(file) + require.NoError(t, err) - block, _ := pem.Decode(bytes) - require.NotNil(t, block) - assert.Equal(t, "PRIVATE KEY", block.Type) + block, _ := pem.Decode(bytes) + require.NotNil(t, block) + assert.Equal(t, "PRIVATE KEY", block.Type) - privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) - require.NoError(t, err) - assert.IsType(t, &rsa.PrivateKey{}, privateKey) + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + require.NoError(t, err) + assert.IsType(t, tC.expectedType, privateKey) + }) + } } From ea31ae850e4b95515887f30b5aaacf811acad7c1 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sat, 22 Mar 2025 17:44:15 +0100 Subject: [PATCH 04/16] add ecdsa and ed25519 --- modules/ssh/ssh.go | 16 +++++++++++++++- modules/ssh/ssh_test.go | 10 ++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go index 343f601e04397..98cc8caaf1fc7 100644 --- a/modules/ssh/ssh.go +++ b/modules/ssh/ssh.go @@ -6,6 +6,9 @@ package ssh import ( "bytes" "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/x509" @@ -58,7 +61,9 @@ const giteaPermissionExtensionKeyID = "gitea-perm-ext-key-id" type KeyType string const ( - RSA KeyType = "rsa" + RSA KeyType = "rsa" + ECDSA KeyType = "ecdsa" + ED25519 KeyType = "ed25519" ) func getExitStatusFromError(err error) int { @@ -458,6 +463,15 @@ func keyGen(keytype KeyType) (any, any, error) { return nil, nil, err } return privateKey, &privateKey.PublicKey, nil + case ED25519: + pub, priv, err := ed25519.GenerateKey(rand.Reader) + return priv, pub, err + case ECDSA: + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + return priv, &priv.PublicKey, nil default: return nil, nil, errors.New("unknown keyType") } diff --git a/modules/ssh/ssh_test.go b/modules/ssh/ssh_test.go index 8d59745909e7f..0bcf29bf863ae 100644 --- a/modules/ssh/ssh_test.go +++ b/modules/ssh/ssh_test.go @@ -1,6 +1,8 @@ package ssh_test import ( + "crypto/ecdsa" + "crypto/ed25519" "crypto/rsa" "crypto/x509" "encoding/pem" @@ -22,6 +24,14 @@ func TestGenKeyPair(t *testing.T) { keyType: ssh.RSA, expectedType: &rsa.PrivateKey{}, }, + { + keyType: ssh.ED25519, + expectedType: ed25519.PrivateKey{}, + }, + { + keyType: ssh.ECDSA, + expectedType: &ecdsa.PrivateKey{}, + }, } for _, tC := range testCases { t.Run("Generate"+string(tC.keyType), func(t *testing.T) { From c51f3ca01eeb339bad48bc1f68bdef2e291b91df Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sat, 22 Mar 2025 18:02:54 +0100 Subject: [PATCH 05/16] fix formatting --- modules/ssh/ssh_test.go | 1 + tests/integration/git_helper_for_declarative_test.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/ssh/ssh_test.go b/modules/ssh/ssh_test.go index 0bcf29bf863ae..a5352fc5c532c 100644 --- a/modules/ssh/ssh_test.go +++ b/modules/ssh/ssh_test.go @@ -11,6 +11,7 @@ import ( "testing" "code.gitea.io/gitea/modules/ssh" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/tests/integration/git_helper_for_declarative_test.go b/tests/integration/git_helper_for_declarative_test.go index 435253c9c7bad..d72a18a656f6d 100644 --- a/tests/integration/git_helper_for_declarative_test.go +++ b/tests/integration/git_helper_for_declarative_test.go @@ -32,7 +32,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, ssh.RSA) assert.NoError(t, err) err = os.WriteFile(path.Join(tmpDir, "ssh"), []byte("#!/bin/bash\n"+ From 3a67769ef5f53f5e5e29ec40f5444e241c642aa4 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sat, 22 Mar 2025 18:12:02 +0100 Subject: [PATCH 06/16] add copyright --- modules/ssh/ssh_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/ssh/ssh_test.go b/modules/ssh/ssh_test.go index a5352fc5c532c..335e20f7e178b 100644 --- a/modules/ssh/ssh_test.go +++ b/modules/ssh/ssh_test.go @@ -1,3 +1,6 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + package ssh_test import ( From 17cf418a3219b779e0dab1a641d8b46ac6e83d8b Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sat, 22 Mar 2025 18:36:49 +0100 Subject: [PATCH 07/16] extend settings rework keygen to guess the keysize from extension --- modules/setting/ssh.go | 2 +- modules/ssh/ssh.go | 35 ++++++++++--------- modules/ssh/ssh_test.go | 15 ++++---- .../git_helper_for_declarative_test.go | 2 +- 4 files changed, 28 insertions(+), 26 deletions(-) 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 98cc8caaf1fc7..1188ef03b8430 100644 --- a/modules/ssh/ssh.go +++ b/modules/ssh/ssh.go @@ -378,18 +378,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], RSA) - 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 { @@ -409,8 +410,8 @@ 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, keyType KeyType) error { - privateKey, publicKey, err := keyGen(keyType) +func GenKeyPair(keyPath string) error { + privateKey, publicKey, err := keyGen(filepath.Ext(keyPath)) if err != nil { return err } @@ -455,18 +456,18 @@ func GenKeyPair(keyPath string, keyType KeyType) error { return err } -func keyGen(keytype KeyType) (any, any, error) { +func keyGen(keytype string) (any, any, error) { switch keytype { - case RSA: + case ".rsa": privateKey, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { return nil, nil, err } return privateKey, &privateKey.PublicKey, nil - case ED25519: + case ".ed25519": pub, priv, err := ed25519.GenerateKey(rand.Reader) return priv, pub, err - case ECDSA: + case ".ecdsa": priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, nil, err diff --git a/modules/ssh/ssh_test.go b/modules/ssh/ssh_test.go index 335e20f7e178b..7fb8b7f350829 100644 --- a/modules/ssh/ssh_test.go +++ b/modules/ssh/ssh_test.go @@ -11,6 +11,7 @@ import ( "encoding/pem" "io" "os" + "path/filepath" "testing" "code.gitea.io/gitea/modules/ssh" @@ -21,26 +22,26 @@ import ( func TestGenKeyPair(t *testing.T) { testCases := []struct { - keyType ssh.KeyType + keyPath string expectedType any }{ { - keyType: ssh.RSA, + keyPath: "/gitea.rsa", expectedType: &rsa.PrivateKey{}, }, { - keyType: ssh.ED25519, + keyPath: "/gitea.ed25519", expectedType: ed25519.PrivateKey{}, }, { - keyType: ssh.ECDSA, + keyPath: "/gitea.ecdsa", expectedType: &ecdsa.PrivateKey{}, }, } for _, tC := range testCases { - t.Run("Generate"+string(tC.keyType), func(t *testing.T) { - path := t.TempDir() + "/gitea." + string(tC.keyType) - require.NoError(t, ssh.GenKeyPair(path, tC.keyType)) + 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) diff --git a/tests/integration/git_helper_for_declarative_test.go b/tests/integration/git_helper_for_declarative_test.go index d72a18a656f6d..435253c9c7bad 100644 --- a/tests/integration/git_helper_for_declarative_test.go +++ b/tests/integration/git_helper_for_declarative_test.go @@ -32,7 +32,7 @@ func withKeyFile(t *testing.T, keyname string, callback func(string)) { assert.NoError(t, err) keyFile := filepath.Join(tmpDir, keyname) - err = ssh.GenKeyPair(keyFile, ssh.RSA) + err = ssh.GenKeyPair(keyFile) assert.NoError(t, err) err = os.WriteFile(path.Join(tmpDir, "ssh"), []byte("#!/bin/bash\n"+ From ee9397049984cbe973fba5de05d9a0b485adb425 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 23 Mar 2025 00:08:07 +0100 Subject: [PATCH 08/16] remove failure possibilty --- modules/ssh/ssh.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go index 1188ef03b8430..4f4b28d8b7549 100644 --- a/modules/ssh/ssh.go +++ b/modules/ssh/ssh.go @@ -467,13 +467,12 @@ func keyGen(keytype string) (any, any, error) { case ".ed25519": pub, priv, err := ed25519.GenerateKey(rand.Reader) return priv, pub, err - case ".ecdsa": + default: + // case ".ecdsa": priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, nil, err } return priv, &priv.PublicKey, nil - default: - return nil, nil, errors.New("unknown keyType") } } From 1ef01945548ef1b0c9483bc80b7c7e033934c187 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 23 Mar 2025 15:51:02 +0100 Subject: [PATCH 09/16] add keygen command make key generation happen in generation module --- cmd/generate.go | 45 ++++++++++++++++++++++++++ modules/generate/generate.go | 63 ++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/cmd/generate.go b/cmd/generate.go index 90b32ecaf0e1c..96cef2c1a1aa4 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -5,10 +5,12 @@ package cmd import ( + "encoding/pem" "fmt" "os" "code.gitea.io/gitea/modules/generate" + "golang.org/x/crypto/ssh" "github.com/mattn/go-isatty" "github.com/urfave/cli/v2" @@ -21,6 +23,7 @@ var ( Usage: "Generate Gitea's secrets/keys/tokens", Subcommands: []*cli.Command{ subcmdSecret, + subcmdKeygen, }, } @@ -33,6 +36,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 +112,34 @@ func runGenerateSecretKey(c *cli.Context) error { return nil } + +func runGenerateKeyPair(c *cli.Context) error { + keytype := c.String("type") + file := c.String("file") + bits := c.Int("bits") + // provide defaults for bits, ed25519 ignores bit length so it's ommited + if bits == 0 { + if keytype == "rsa" { + bits = 3096 + } 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 + } + return os.WriteFile(file+".pub", ssh.MarshalAuthorizedKey(pub), 0o644) + + return nil +} diff --git a/modules/generate/generate.go b/modules/generate/generate.go index 2d9a3dd902245..a55ab0f652e3c 100644 --- a/modules/generate/generate.go +++ b/modules/generate/generate.go @@ -5,13 +5,20 @@ package generate import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" "crypto/rand" + "crypto/rsa" "encoding/base64" + "encoding/pem" "fmt" "io" "time" "code.gitea.io/gitea/modules/util" + "golang.org/x/crypto/ssh" "github.com/golang-jwt/jwt/v5" ) @@ -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 crypto.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) + } +} From 9de2cce9726460fc693a64809f2b41b60c83f495 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 23 Mar 2025 21:52:44 +0100 Subject: [PATCH 10/16] reuse generate module --- modules/ssh/ssh.go | 36 ++---------------------------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go index 4f4b28d8b7549..6b0dfbc8dd36d 100644 --- a/modules/ssh/ssh.go +++ b/modules/ssh/ssh.go @@ -6,12 +6,6 @@ package ssh import ( "bytes" "context" - "crypto/ecdsa" - "crypto/ed25519" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "crypto/x509" "encoding/pem" "errors" "fmt" @@ -27,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" @@ -411,17 +406,11 @@ 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, publicKey, err := keyGen(filepath.Ext(keyPath)) + publicKey, privateKeyPEM, err := generate.NewSSHKey("rsa", 4096) if err != nil { return err } - privateKeyPKCS8, err := x509.MarshalPKCS8PrivateKey(privateKey) - if err != nil { - return err - } - - privateKeyPEM := &pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyPKCS8} f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return err @@ -455,24 +444,3 @@ func GenKeyPair(keyPath string) error { _, err = p.Write(public) return err } - -func keyGen(keytype string) (any, any, error) { - switch keytype { - case ".rsa": - privateKey, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - return nil, nil, err - } - return privateKey, &privateKey.PublicKey, nil - case ".ed25519": - pub, priv, err := ed25519.GenerateKey(rand.Reader) - return priv, pub, err - default: - // case ".ecdsa": - priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, nil, err - } - return priv, &priv.PublicKey, nil - } -} From 423cb0ebe9b26142eb0bc9c03897efa96bc2a72e Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 23 Mar 2025 22:38:59 +0100 Subject: [PATCH 11/16] fix bad defaults --- cmd/generate.go | 4 +--- modules/ssh/ssh.go | 21 +++++++++++++-------- modules/ssh/ssh_test.go | 11 +++-------- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/cmd/generate.go b/cmd/generate.go index 96cef2c1a1aa4..46cab1e98ad09 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -120,7 +120,7 @@ func runGenerateKeyPair(c *cli.Context) error { // provide defaults for bits, ed25519 ignores bit length so it's ommited if bits == 0 { if keytype == "rsa" { - bits = 3096 + bits = 3072 } else { bits = 256 } @@ -140,6 +140,4 @@ func runGenerateKeyPair(c *cli.Context) error { return err } return os.WriteFile(file+".pub", ssh.MarshalAuthorizedKey(pub), 0o644) - - return nil } diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go index 6b0dfbc8dd36d..2c7318184c04c 100644 --- a/modules/ssh/ssh.go +++ b/modules/ssh/ssh.go @@ -406,7 +406,18 @@ 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 { - publicKey, privateKeyPEM, err := generate.NewSSHKey("rsa", 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 } @@ -425,13 +436,7 @@ func GenKeyPair(keyPath string) error { return err } - // generate public key - pub, err := gossh.NewPublicKey(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 index 7fb8b7f350829..c72ba26367f6b 100644 --- a/modules/ssh/ssh_test.go +++ b/modules/ssh/ssh_test.go @@ -7,8 +7,6 @@ import ( "crypto/ecdsa" "crypto/ed25519" "crypto/rsa" - "crypto/x509" - "encoding/pem" "io" "os" "path/filepath" @@ -18,6 +16,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + gossh "golang.org/x/crypto/ssh" ) func TestGenKeyPair(t *testing.T) { @@ -31,7 +30,7 @@ func TestGenKeyPair(t *testing.T) { }, { keyPath: "/gitea.ed25519", - expectedType: ed25519.PrivateKey{}, + expectedType: &ed25519.PrivateKey{}, }, { keyPath: "/gitea.ecdsa", @@ -49,11 +48,7 @@ func TestGenKeyPair(t *testing.T) { bytes, err := io.ReadAll(file) require.NoError(t, err) - block, _ := pem.Decode(bytes) - require.NotNil(t, block) - assert.Equal(t, "PRIVATE KEY", block.Type) - - privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + privateKey, err := gossh.ParseRawPrivateKey(bytes) require.NoError(t, err) assert.IsType(t, tC.expectedType, privateKey) }) From 3c4ad30ced26eaf992b212780bfbba2d55f2decf Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 23 Mar 2025 23:33:38 +0100 Subject: [PATCH 12/16] fix checks --- cmd/generate.go | 4 ++-- modules/generate/generate.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/generate.go b/cmd/generate.go index 46cab1e98ad09..feaa0815092e3 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -10,10 +10,10 @@ import ( "os" "code.gitea.io/gitea/modules/generate" - "golang.org/x/crypto/ssh" "github.com/mattn/go-isatty" "github.com/urfave/cli/v2" + "golang.org/x/crypto/ssh" ) var ( @@ -117,7 +117,7 @@ func runGenerateKeyPair(c *cli.Context) error { keytype := c.String("type") file := c.String("file") bits := c.Int("bits") - // provide defaults for bits, ed25519 ignores bit length so it's ommited + // provide defaults for bits, ed25519 ignores bit length so it's omitted if bits == 0 { if keytype == "rsa" { bits = 3072 diff --git a/modules/generate/generate.go b/modules/generate/generate.go index a55ab0f652e3c..cb54fd7b12c5a 100644 --- a/modules/generate/generate.go +++ b/modules/generate/generate.go @@ -18,9 +18,9 @@ import ( "time" "code.gitea.io/gitea/modules/util" - "golang.org/x/crypto/ssh" "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/ssh" ) // NewInternalToken generate a new value intended to be used by INTERNAL_TOKEN. @@ -98,7 +98,7 @@ func NewSSHKey(keytype string, bits int) (ssh.PublicKey, *pem.Block, error) { } // commonKeyGen is an abstraction over rsa, ecdsa and ed25519 generating functions -func commonKeyGen(keytype string, bits int) (publicKey crypto.PublicKey, privateKey crypto.PublicKey, err error) { +func commonKeyGen(keytype string, bits int) (publicKey, privateKey crypto.PublicKey, err error) { switch keytype { case "rsa": privateKey, err := rsa.GenerateKey(rand.Reader, bits) From f8e81dfafc633c5f404f474f6fbdfc8c172a039f Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 24 Mar 2025 00:07:29 +0100 Subject: [PATCH 13/16] remove blankline --- modules/ssh/ssh.go | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go index 2c7318184c04c..f997e7af7c03b 100644 --- a/modules/ssh/ssh.go +++ b/modules/ssh/ssh.go @@ -413,7 +413,6 @@ func GenKeyPair(keyPath string) error { } else if keytype == ".ecdsa" { bits = 256 keytype = "ecdsa" - } else { keytype = "rsa" } From 61f496212090eedac5782cdd597a4ad01d56dfa6 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Tue, 25 Mar 2025 21:37:59 +0100 Subject: [PATCH 14/16] add overwrite protection and prompt --- cmd/generate.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/cmd/generate.go b/cmd/generate.go index feaa0815092e3..d9c682c4235c9 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -5,9 +5,11 @@ package cmd import ( + "bufio" "encoding/pem" "fmt" "os" + "strings" "code.gitea.io/gitea/modules/generate" @@ -114,8 +116,19 @@ func runGenerateSecretKey(c *cli.Context) error { } func runGenerateKeyPair(c *cli.Context) error { - keytype := c.String("type") 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 { @@ -139,5 +152,11 @@ func runGenerateKeyPair(c *cli.Context) error { if err != nil { return err } - return os.WriteFile(file+".pub", ssh.MarshalAuthorizedKey(pub), 0o644) + 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 } From 8cd766edc1691192bb08af16dbef1f529a5903a8 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 27 Apr 2025 00:44:37 +0200 Subject: [PATCH 15/16] add keyinit to better mimick behavior of ssh-keygen --- modules/ssh/ssh.go | 48 ++++++++++++++---------- modules/ssh/ssh_test.go | 82 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 29 deletions(-) diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go index f997e7af7c03b..66048f857d5dd 100644 --- a/modules/ssh/ssh.go +++ b/modules/ssh/ssh.go @@ -373,17 +373,16 @@ func Listen(host string, port int, ciphers, keyExchanges, macs []string) { } if len(keys) == 0 { - 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) + 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 := initDefaultKeys(filePath) + if err != nil { + log.Fatal("Failed to generate private key: %v", err) + } + for _, keytype := range []string{"rsa", "ecdsa", "ed25519"} { + filename := filePath + "/gitea." + keytype keys = append(keys, filename) } } @@ -405,17 +404,12 @@ 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 { +func GenKeyPair(keyPath, keytype string) error { bits := 4096 - keytype := filepath.Ext(keyPath) - if keytype == ".ed25519" { - keytype = "ed25519" - } else if keytype == ".ecdsa" { + if keytype == "ecdsa" { bits = 256 - keytype = "ecdsa" - } else { - keytype = "rsa" } + publicKey, privateKeyPEM, err := generate.NewSSHKey(keytype, bits) if err != nil { return err @@ -448,3 +442,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 index c72ba26367f6b..f47dcaa6e455c 100644 --- a/modules/ssh/ssh_test.go +++ b/modules/ssh/ssh_test.go @@ -1,7 +1,7 @@ // Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package ssh_test +package ssh import ( "crypto/ecdsa" @@ -12,8 +12,6 @@ import ( "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" @@ -21,26 +19,26 @@ import ( func TestGenKeyPair(t *testing.T) { testCases := []struct { - keyPath string + keyType string expectedType any }{ { - keyPath: "/gitea.rsa", + keyType: "rsa", expectedType: &rsa.PrivateKey{}, }, { - keyPath: "/gitea.ed25519", + keyType: "ed25519", expectedType: &ed25519.PrivateKey{}, }, { - keyPath: "/gitea.ecdsa", + keyType: "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)) + 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) @@ -53,4 +51,68 @@ func TestGenKeyPair(t *testing.T) { 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) + } + } } From 8b5c59a6542a022df374529b3e6da28e347983ce Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 27 Apr 2025 00:52:53 +0200 Subject: [PATCH 16/16] fix integration test call --- tests/integration/git_helper_for_declarative_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"+