add ssh user CA support

This commit is contained in:
Mikaël Cluseau
2025-06-28 11:04:44 +02:00
parent 4b05458cec
commit af41df6ab4
7 changed files with 172 additions and 77 deletions

View File

@ -1,5 +1,5 @@
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
from golang:1.24.3-bookworm as build from golang:1.24.4-bookworm as build
run apt-get update && apt-get install -y git run apt-get update && apt-get install -y git

View File

@ -275,6 +275,10 @@ func (ctx *renderContext) templateFuncs(ctxMap map[string]any) map[string]interf
return getKeyCert(name, "tls_dir") return getKeyCert(name, "tls_dir")
}, },
"ssh_user_ca": func(path string) (s string) {
return fmt.Sprintf("{{ ssh_user_ca %q %q}}",
path, cluster)
},
"ssh_host_keys": func(dir string) (s string) { "ssh_host_keys": func(dir string) (s string) {
return fmt.Sprintf("{{ ssh_host_keys %q %q \"\"}}", return fmt.Sprintf("{{ ssh_host_keys %q %q \"\"}}",
dir, cluster) dir, cluster)

View File

@ -69,11 +69,19 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
cat.AppendBytes(cfgBytes, "config.yaml", 0600) cat.AppendBytes(cfgBytes, "config.yaml", 0600)
// ssh keys // ssh keys
// FIXME we want a bootstrap-stage key instead of the real host key // XXX do we want a bootstrap-stage key instead of the real host key?
for _, format := range []string{"rsa", "dsa", "ecdsa", "ed25519"} { for _, format := range []string{"rsa", "dsa", "ecdsa", "ed25519"} {
cat.AppendBytes(cfg.FileContent("/etc/ssh/ssh_host_"+format+"_key"), "id_"+format, 0600) cat.AppendBytes(cfg.FileContent("/etc/ssh/ssh_host_"+format+"_key"), "id_"+format, 0600)
} }
// ssh user CA
userCA, err := sshCAPubKey(ctx.Host.ClusterName)
if err != nil {
return fmt.Errorf("failed to get SSH user CA: %w", err)
}
cat.AppendBytes(userCA, "user_ca.pub", 0600)
return cat.Close() return cat.Close()
} }

View File

@ -148,7 +148,7 @@ func (ctx *renderContext) Tag() (string, error) {
enc := yaml.NewEncoder(h) enc := yaml.NewEncoder(h)
for _, o := range []interface{}{cfg, ctx} { for _, o := range []any{cfg, ctx} {
if err := enc.Encode(o); err != nil { if err := enc.Encode(o); err != nil {
return "", err return "", err
} }
@ -157,21 +157,6 @@ func (ctx *renderContext) Tag() (string, error) {
return hex.EncodeToString(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }
func asMap(v interface{}) map[string]interface{} {
ba, err := yaml.Marshal(v)
if err != nil {
panic(err) // shouldn't happen
}
result := make(map[string]interface{})
if err := yaml.Unmarshal(ba, result); err != nil {
panic(err) // shouldn't happen
}
return result
}
func (ctx *renderContext) TemplateFuncs() map[string]any { func (ctx *renderContext) TemplateFuncs() map[string]any {
funcs := templateFuncs(ctx.SSLConfig) funcs := templateFuncs(ctx.SSLConfig)
@ -187,6 +172,14 @@ func (ctx *renderContext) TemplateFuncs() map[string]any {
return hex.EncodeToString(ba[:]) return hex.EncodeToString(ba[:])
}, },
"ssh_user_ca": func(path, cluster string) (s string, err error) {
userCA, err := sshCAPubKey(cluster)
return asYaml([]config.FileDef{{
Path: path,
Mode: 0644,
Content: string(userCA),
}})
},
"ssh_host_keys": func(dir, cluster, host string) (s string, err error) { "ssh_host_keys": func(dir, cluster, host string) (s string, err error) {
if host == "" { if host == "" {
host = ctx.Host.Name host = ctx.Host.Name

View File

@ -1,17 +1,19 @@
package main package main
import ( import (
"crypto/dsa" "bytes"
"crypto/ecdsa" "crypto"
"crypto/ed25519" "crypto/ed25519"
"crypto/elliptic" "encoding/pem"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
) )
var sshHostKeys = KVSecrets[[]SSHKeyPair]{"hosts/ssh-host-keys"} var sshHostKeys = KVSecrets[[]SSHKeyPair]{"hosts/ssh-host-keys"}
@ -104,64 +106,106 @@ genLoop:
return return
} }
func sshKeyGenDSA() (data []byte, pubKey interface{}, err error) { var sshCAKeys = KVSecrets[string]{"ssh-ca-keys"}
privKey := &dsa.PrivateKey{}
err = dsa.GenerateParameters(&privKey.Parameters, rand.Reader, dsa.L1024N160) func sshCAKey(cluster string) (caKeyPem string, err error) {
storeKey := "clusters/" + cluster
caKeyPem, _, err = sshCAKeys.Get(storeKey)
if err != nil { if err != nil {
return return
} }
err = dsa.GenerateKey(privKey, rand.Reader) if caKeyPem == "" {
if err != nil { _, pk, err := ed25519.GenerateKey(nil)
return if err != nil {
} return "", err
}
data, err = asn1.Marshal(*privKey) pemBlock, err := ssh.MarshalPrivateKey(crypto.PrivateKey(pk), "")
//data, err = x509.MarshalPKCS8PrivateKey(privKey) if err != nil {
if err != nil { return "", err
return }
}
pubKey = privKey.PublicKey caKeyPem = string(pem.EncodeToMemory(pemBlock))
return sshCAKeys.Put(storeKey, caKeyPem)
}
func sshKeyGenRSA() (data []byte, pubKey interface{}, err error) {
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return
}
data = x509.MarshalPKCS1PrivateKey(privKey)
pubKey = privKey.Public()
return
}
func sshKeyGenECDSA() (data []byte, pubKey interface{}, err error) {
privKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
if err != nil {
return
}
data, err = x509.MarshalPKCS8PrivateKey(privKey)
if err != nil {
return
}
pubKey = privKey.Public()
return
}
func sshKeyGenED25519() (data []byte, pubKey interface{}, err error) {
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
data, err = x509.MarshalPKCS8PrivateKey(privKey)
if err != nil {
return
} }
return return
} }
func sshCAPubKey(cluster string) (pubKey []byte, err error) {
keyPem, err := sshCAKey(cluster)
if err != nil {
return
}
k, err := ssh.ParsePrivateKey([]byte(keyPem))
if err != nil {
return
}
pubKey = ssh.MarshalAuthorizedKey(k.PublicKey())
return
}
// principal: user (login) to allow (ie: "root")
// validity: ssh-keygen validity string (ie: "+1h", "202506280811:202506281011", ""=forever)
// options: ssh-keygen options (ie: "force-command=/bin/date +\"%F %T\"", "source-address=192.168.1.0/24,192.168.42.0/24"
func sshCASign(cluster string, userPubKey []byte, principal, validity string, options ...string) (cert []byte, err error) {
caKey, err := sshCAKey(cluster)
if err != nil {
return
}
_, identity, _, _, err := ssh.ParseAuthorizedKey(userPubKey)
if err != nil {
return
}
userPubKeyFile, err := os.CreateTemp("/tmp", "user.pub")
if err != nil {
return
}
defer os.Remove(userPubKeyFile.Name())
_, err = io.Copy(userPubKeyFile, bytes.NewBuffer(userPubKey))
userPubKeyFile.Close()
if err != nil {
return
}
err = os.WriteFile(userPubKeyFile.Name(), userPubKey, 0600)
if err != nil {
return
}
serial := strconv.FormatInt(time.Now().Unix(), 10)
cmd := exec.Command("ssh-keygen", "-q", "-s", "/dev/stdin", "-I", identity, "-z", serial, "-n", principal)
if validity != "" {
cmd.Args = append(cmd.Args, "-V", validity)
}
for _, opt := range options {
cmd.Args = append(cmd.Args, "-O", opt)
}
cmd.Args = append(cmd.Args, userPubKeyFile.Name())
stderr := new(bytes.Buffer)
cmd.Stdin = bytes.NewBuffer([]byte(caKey))
cmd.Stderr = stderr
err = cmd.Run()
if err != nil {
err = fmt.Errorf("ssh-keygen sign failed: %s", strings.TrimSpace(stderr.String()))
return
}
certFile := userPubKeyFile.Name() + "-cert.pub"
cert, err = os.ReadFile(certFile)
os.Remove(certFile)
return
}

View File

@ -121,3 +121,41 @@ func wsClusterSignedCert(req *restful.Request, resp *restful.Response) {
resp.AddHeader("Content-Disposition", "attachment; filename="+strconv.Quote(clusterName+"_"+caName+"_"+url.PathEscape(name)+".crt")) resp.AddHeader("Content-Disposition", "attachment; filename="+strconv.Quote(clusterName+"_"+caName+"_"+url.PathEscape(name)+".crt"))
resp.Write(kc.Cert) resp.Write(kc.Cert)
} }
type SSHSignReq struct {
PubKey string
Principal string
Validity string
Options []string
}
func wsClusterSSHUserCAPubKey(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
pubkey, err := sshCAPubKey(clusterName)
if err != nil {
wsError(resp, err)
return
}
resp.Write(pubkey)
}
func wsClusterSSHUserCASign(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
signReq := SSHSignReq{}
err := req.ReadEntity(&signReq)
if err != nil {
wsError(resp, err)
return
}
cert, err := sshCASign(clusterName, []byte(signReq.PubKey), signReq.Principal, signReq.Validity, signReq.Options...)
if err != nil {
wsError(resp, err)
return
}
resp.Write(cert)
}

View File

@ -86,8 +86,9 @@ func registerWS(rest *restful.Container) {
Doc("Delete a host template instance")) Doc("Delete a host template instance"))
const ( const (
GET = http.MethodGet GET = http.MethodGet
PUT = http.MethodPut PUT = http.MethodPut
POST = http.MethodPost
) )
cluster := func(method, subPath string) *restful.RouteBuilder { cluster := func(method, subPath string) *restful.RouteBuilder {
@ -126,6 +127,13 @@ func registerWS(rest *restful.Container) {
Produces(mime.CERT). Produces(mime.CERT).
Param(ws.QueryParameter("name", "signed reference name").Required(true)). Param(ws.QueryParameter("name", "signed reference name").Required(true)).
Doc("Get cluster's certificate signed by the CA"), Doc("Get cluster's certificate signed by the CA"),
cluster(GET, "/ssh/user-ca").To(wsClusterSSHUserCAPubKey).
Produces(mime.OCTET).
Doc("User CA public key for this cluster"),
cluster(POST, "/ssh/user-ca/sign").To(wsClusterSSHUserCASign).
Produces(mime.OCTET).
Doc("Sign a user's SSH public key for this cluster"),
} { } {
ws.Route(builder) ws.Route(builder)
} }