diff --git a/ipn/ipnlocal/ssh.go b/ipn/ipnlocal/ssh.go index 8cb5dafbe..4a588dc86 100644 --- a/ipn/ipnlocal/ssh.go +++ b/ipn/ipnlocal/ssh.go @@ -8,10 +8,20 @@ package ipnlocal import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "errors" + "fmt" "io/ioutil" "os" + "path/filepath" "strings" + "sync" "golang.org/x/crypto/ssh" "tailscale.com/envknob" @@ -19,17 +29,94 @@ import ( var useHostKeys = envknob.Bool("TS_USE_SYSTEM_SSH_HOST_KEYS") -func (b *LocalBackend) GetSSH_HostKeys() ([]ssh.Signer, error) { - // TODO(bradfitz): generate host keys, at least as needed if - // an existing SSH server didn't put them on disk. But also - // because people may want tailscale-specific ones. For now be - // lazy and reuse the host ones. - return b.getSystemSSH_HostKeys() +// keyTypes are the SSH key types that we either try to read from the +// system's OpenSSH keys or try to generate for ourselves when not +// running as root. +var keyTypes = []string{"rsa", "ecdsa", "ed25519"} + +func (b *LocalBackend) GetSSH_HostKeys() (keys []ssh.Signer, err error) { + if os.Geteuid() == 0 { + keys, err = b.getSystemSSH_HostKeys() + if err != nil || len(keys) > 0 { + return keys, err + } + // Otherwise, perhaps they don't have OpenSSH etc installed. + // Generate our own keys... + } + return b.getTailscaleSSH_HostKeys() +} + +func (b *LocalBackend) getTailscaleSSH_HostKeys() (keys []ssh.Signer, err error) { + root := b.TailscaleVarRoot() + if root == "" { + return nil, errors.New("no var root for ssh keys") + } + keyDir := filepath.Join(root, "ssh") + if err := os.MkdirAll(keyDir, 0700); err != nil { + return nil, err + } + for _, typ := range keyTypes { + hostKey, err := b.hostKeyFileOrCreate(keyDir, typ) + if err != nil { + return nil, err + } + signer, err := ssh.ParsePrivateKey(hostKey) + if err != nil { + return nil, err + } + keys = append(keys, signer) + } + return keys, nil +} + +var keyGenMu sync.Mutex + +func (b *LocalBackend) hostKeyFileOrCreate(keyDir, typ string) ([]byte, error) { + keyGenMu.Lock() + defer keyGenMu.Unlock() + + path := filepath.Join(keyDir, "ssh_host_"+typ+"_key") + v, err := ioutil.ReadFile(path) + if err == nil { + return v, nil + } + if !os.IsNotExist(err) { + return nil, err + } + var priv interface{} + switch typ { + default: + return nil, fmt.Errorf("unsupported key type %q", typ) + case "ed25519": + _, priv, err = ed25519.GenerateKey(rand.Reader) + case "ecdsa": + // curve is arbitrary. We pick whatever will at + // least pacify clients as the actual encryption + // doesn't matter: it's all over WireGuard anyway. + curve := elliptic.P256() + priv, err = ecdsa.GenerateKey(curve, rand.Reader) + case "rsa": + // keySize is arbitrary. We pick whatever will at + // least pacify clients as the actual encryption + // doesn't matter: it's all over WireGuard anyway. + const keySize = 2048 + priv, err = rsa.GenerateKey(rand.Reader, keySize) + } + if err != nil { + return nil, err + } + mk, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, err + } + pemGen := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk}) + err = os.WriteFile(path, pemGen, 0700) + return pemGen, err } func (b *LocalBackend) getSystemSSH_HostKeys() (ret []ssh.Signer, err error) { // TODO(bradfitz): cache this? - for _, typ := range []string{"rsa", "ecdsa", "ed25519"} { + for _, typ := range keyTypes { hostKey, err := ioutil.ReadFile("/etc/ssh/ssh_host_" + typ + "_key") if os.IsNotExist(err) { continue @@ -43,9 +130,6 @@ func (b *LocalBackend) getSystemSSH_HostKeys() (ret []ssh.Signer, err error) { } ret = append(ret, signer) } - if len(ret) == 0 { - return nil, errors.New("no system SSH host keys found") - } return ret, nil } diff --git a/ipn/ipnlocal/ssh_test.go b/ipn/ipnlocal/ssh_test.go new file mode 100644 index 000000000..db0ad86b6 --- /dev/null +++ b/ipn/ipnlocal/ssh_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux +// +build linux + +package ipnlocal + +import ( + "reflect" + "testing" +) + +func TestSSHKeyGen(t *testing.T) { + dir := t.TempDir() + lb := &LocalBackend{varRoot: dir} + keys, err := lb.getTailscaleSSH_HostKeys() + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, k := range keys { + got[k.PublicKey().Type()] = true + } + want := map[string]bool{ + "ssh-rsa": true, + "ecdsa-sha2-nistp256": true, + "ssh-ed25519": true, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("keys = %v; want %v", got, want) + } + + keys2, err := lb.getTailscaleSSH_HostKeys() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(keys, keys2) { + t.Errorf("got different keys on second call") + } +}