Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

auto generate additional ssh keys #33974

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
62 changes: 62 additions & 0 deletions cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -21,6 +25,7 @@ var (
Usage: "Generate Gitea's secrets/keys/tokens",
Subcommands: []*cli.Command{
subcmdSecret,
subcmdKeygen,
},
}

Expand All @@ -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",
Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it avoid overwriting existing file?

/tmp$ ssh-keygen -t ecdsa -f a
Generating public/private ecdsa key pair.
a already exists.
Overwrite (y/n)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be a good idea, I'll try to figure out how to do it.
Would comments also be a good idea to include?

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
}
63 changes: 63 additions & 0 deletions modules/generate/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,22 @@
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"

"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/ssh"
)

// NewInternalToken generate a new value intended to be used by INTERNAL_TOKEN.
Expand Down Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion modules/setting/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 33 additions & 24 deletions modules/ssh/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ package ssh
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why 3? Why generates all files?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For new default settings and parity with default (rootful) container keys. I'd like to drop it for something better though.

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 {
Expand All @@ -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()
Expand All @@ -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)
Copy link
Contributor

@wxiaoguang wxiaoguang Mar 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem right to guess the content by file extension.

(oudated)

The key itself has the correct information.

$ ssh-keygen -t ecdsa -f a
...
$ ssh-keygen -lf a
256 SHA256:NS1YmfwZDYoEgtS3Ne0wRuDvGFFWyQfc1QB8ZT2SGeA [email protected] (ECDSA)

Hmm, it is not for "parsing" but for "creating", but it still seems strange to guess the key type by file extension.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fully agreed it's a bad design. The default was []string{"ssh/gitea.rsa", "ssh/gogs.rsa"} so I figured []string{"ssh/gitea.rsa", "ssh/gitea.ed25519", "ssh/gitea.ecdsa", "ssh/gogs.rsa"} would be an acceptable workaround. I don't like it either but I had no better ideas to date.

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
Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions modules/ssh/ssh_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}