Files
tailscale/ssh/tailssh/hostkeys.go
Brad Fitzpatrick f905871fb1 ipn/ipnlocal, feature/ssh: move SSH code out of LocalBackend to feature
This makes tsnet apps not depend on x/crypto/ssh and locks that in with a test.

It also paves the wave for tsnet apps to opt-in to SSH support via a
blank feature import in the future.

Updates #12614

Change-Id: Ica85628f89c8f015413b074f5001b82b27c953a9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-03-10 17:27:17 -07:00

156 lines
4.5 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9
package tailssh
import (
"bytes"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
gossh "golang.org/x/crypto/ssh"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
)
// 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"}
// getHostKeys returns the SSH host keys, using system keys when running as root
// and generating Tailscale-specific keys as needed.
func getHostKeys(varRoot string, logf logger.Logf) ([]gossh.Signer, error) {
var existing map[string]gossh.Signer
if os.Geteuid() == 0 {
existing = getSystemHostKeys(logf)
}
return getTailscaleHostKeys(varRoot, existing)
}
// getHostKeyPublicStrings returns the SSH host key public key strings.
func getHostKeyPublicStrings(varRoot string, logf logger.Logf) ([]string, error) {
signers, err := getHostKeys(varRoot, logf)
if err != nil {
return nil, err
}
var keyStrings []string
for _, signer := range signers {
keyStrings = append(keyStrings, strings.TrimSpace(string(gossh.MarshalAuthorizedKey(signer.PublicKey()))))
}
return keyStrings, nil
}
// getTailscaleHostKeys returns the three (rsa, ecdsa, ed25519) SSH host
// keys, reusing the provided ones in existing if present in the map.
func getTailscaleHostKeys(varRoot string, existing map[string]gossh.Signer) (keys []gossh.Signer, err error) {
var keyDir string // lazily initialized $TAILSCALE_VAR/ssh dir.
for _, typ := range keyTypes {
if s, ok := existing[typ]; ok {
keys = append(keys, s)
continue
}
if keyDir == "" {
if varRoot == "" {
return nil, errors.New("no var root for ssh keys")
}
keyDir = filepath.Join(varRoot, "ssh")
if err := os.MkdirAll(keyDir, 0700); err != nil {
return nil, err
}
}
hostKey, err := hostKeyFileOrCreate(keyDir, typ)
if err != nil {
return nil, fmt.Errorf("error creating SSH host key type %q in %q: %w", typ, keyDir, err)
}
signer, err := gossh.ParsePrivateKey(hostKey)
if err != nil {
return nil, fmt.Errorf("error parsing SSH host key type %q from %q: %w", typ, keyDir, err)
}
keys = append(keys, signer)
}
return keys, nil
}
// keyGenMu protects concurrent generation of host keys with
// [hostKeyFileOrCreate], making sure two callers don't try to concurrently find
// a missing key and generate it at the same time, returning different keys to
// their callers.
//
// Technically we actually want to have a mutex per directory (the keyDir
// passed), but that's overkill for how rarely keys are loaded or generated.
var keyGenMu sync.Mutex
func hostKeyFileOrCreate(keyDir, typ string) ([]byte, error) {
keyGenMu.Lock()
defer keyGenMu.Unlock()
path := filepath.Join(keyDir, "ssh_host_"+typ+"_key")
v, err := os.ReadFile(path)
if err == nil {
return v, nil
}
if !os.IsNotExist(err) {
return nil, err
}
var priv any
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 getSystemHostKeys(logf logger.Logf) (ret map[string]gossh.Signer) {
for _, typ := range keyTypes {
filename := "/etc/ssh/ssh_host_" + typ + "_key"
hostKey, err := os.ReadFile(filename)
if err != nil || len(bytes.TrimSpace(hostKey)) == 0 {
continue
}
signer, err := gossh.ParsePrivateKey(hostKey)
if err != nil {
logf("warning: error reading host key %s: %v (generating one instead)", filename, err)
continue
}
mak.Set(&ret, typ, signer)
}
return ret
}