diff --git a/docs/ssh-certificate.md b/docs/ssh-certificate.md new file mode 100644 index 000000000..a1900f1f0 --- /dev/null +++ b/docs/ssh-certificate.md @@ -0,0 +1,64 @@ +# SSH user certificate support in koko + +`pkg/sshcert` recognises OpenSSH user certificates that are +stored alongside an OpenSSH private key in a koko `PrivateKey` +secret, and exposes helpers that build an `ssh.Signer` ready for +`srvconn.SSHClientPrivateAuth`. + +## Wire format + +A koko `PrivateKey` secret may now carry an OpenSSH user +certificate line after the private key PEM block: + +``` +-----BEGIN OPENSSH PRIVATE KEY----- + +-----END OPENSSH PRIVATE KEY----- +ecdsa-sha2-nistp256-cert-v01@openssh.com AAAA... +``` + +`pkg/handler.buildSSHClientOptions` calls `sshcert.NewSigner`: + +- if the secret contains a matching `-cert-v01@openssh.com` line, + the returned signer is a `CertSigner` that presents the + certificate during SSH user-auth; +- otherwise the returned signer is the plain signer produced by + `ssh.ParsePrivateKey`, so existing deployments are unaffected. + +## Operator workflow + +The secret blob can be produced by any tool that emits the +conventional private key + certificate concatenation. Common +options include: + +- `ssh-keygen -s ca_key -I key-id id_key.pub` after appending the + signed `*-cert.pub` to the secret; +- `step ssh certificate ` from + [smallstep step-ca](https://smallstep.com/docs/step-ca/), + which writes the certificate line into the same file as the + private key; +- HashiCorp Vault SSH secrets engine, AWS SSM Session Manager, + or any other CA whose signed output is written alongside the + private key. + +The certificate can then be put into the JumpServer asset +account `private_key` field via the JMS REST API, or pasted into +the Luna UI as a single text blob. Once the field is saved, koko +will negotiate the `*-cert-v01@openssh.com` SSH user-auth +algorithm with the target host, and the host's sshd will validate +the certificate against its `TrustedUserCAKeys` instead of its +`authorized_keys`. + +## Failure modes + +| Scenario | Behaviour | +| --- | --- | +| Secret is a plain private key (no certificate line) | `NewSigner` returns the plain signer; existing deployments are unaffected. | +| Secret contains a matching certificate | `NewSigner` returns a `CertSigner`; SSH user-auth is performed with the certificate. | +| Secret contains a certificate whose public key does not match the embedded private key | `NewSigner` returns `ErrCertMismatch`; the existing log line `Parse account X private key failed: ...` is emitted and the SSH user-auth method is left empty. | +| Secret is malformed | `NewSigner` returns the underlying parse error; the existing log line is emitted. | + +## Reference + +- [OpenSSH certificates PROTOCOL.certkeys](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys) +- [step-ca SSH certificate workflow](https://smallstep.com/docs/step-ca/ssh/) diff --git a/pkg/handler/server_ssh.go b/pkg/handler/server_ssh.go index 65d31e78e..cb64fba85 100644 --- a/pkg/handler/server_ssh.go +++ b/pkg/handler/server_ssh.go @@ -28,6 +28,7 @@ import ( "github.com/jumpserver/koko/pkg/proxy" "github.com/jumpserver/koko/pkg/session" "github.com/jumpserver/koko/pkg/srvconn" + "github.com/jumpserver/koko/pkg/sshcert" "github.com/jumpserver/koko/pkg/utils" ) @@ -672,7 +673,14 @@ func buildSSHClientOptions(asset *model.Asset, account *model.Account, sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPort(asset.ProtocolPort(model.ProtocolSSH))) sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientTimeout(timeout)) if account.IsSSHKey() { - if signer, err1 := gossh.ParsePrivateKey([]byte(account.Secret)); err1 == nil { + // sshcert.NewSigner returns a CertSigner when the secret + // blob carries an OpenSSH certificate whose key matches the + // embedded private key, so the downstream AuthMethods() will + // present the certificate instead of the bare public key. + // Blobs without a matching -cert-v01@openssh.com line fall + // back to the existing plain-key behaviour, keeping the + // change backwards-compatible with every existing deployment. + if signer, err1 := sshcert.NewSigner([]byte(account.Secret)); err1 == nil { sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPrivateAuth(signer)) } else { logger.Errorf("Parse account %s private key failed: %s", account.Username, err1) diff --git a/pkg/sshcert/cert.go b/pkg/sshcert/cert.go new file mode 100644 index 000000000..f83fb09a6 --- /dev/null +++ b/pkg/sshcert/cert.go @@ -0,0 +1,132 @@ +// Package sshcert recognises OpenSSH user certificates that are +// stored alongside an OpenSSH private key in a koko "PrivateKey" +// secret, and exposes helpers that build an ssh.Signer ready for +// srvconn.SSHClientPrivateAuth. +// +// The format is the conventional concatenation of a private key +// PEM block and a single certificate line emitted by tools such +// as ssh-keygen -s or smallstep step-ca: +// +// -----BEGIN OPENSSH PRIVATE KEY----- +// +// -----END OPENSSH PRIVATE KEY----- +// ecdsa-sha2-nistp256-cert-v01@openssh.com AAAA... +// +// Parse opens such a blob, validates that the certificate's +// public key matches the embedded private key, and returns the +// signer and the certificate separately. NewSigner wraps the two +// with ssh.NewCertSigner when a certificate is present, otherwise +// it returns the plain signer produced by ssh.ParsePrivateKey - so +// existing koko deployments that store only a private key +// continue to authenticate exactly as before. +// +// The package is deliberately minimal: it relies only on +// golang.org/x/crypto/ssh and never touches the filesystem, the +// network or koko's session lifecycle. +package sshcert + +import ( + "bytes" + "errors" + "strings" + + "golang.org/x/crypto/ssh" +) + +// certAlgorithmMarker is the substring that identifies an OpenSSH +// user certificate line in an authorized_keys-style blob. +const certAlgorithmMarker = "-cert-v01@openssh.com" + +// ErrCertMismatch is returned when a secret blob contains a +// certificate whose embedded public key does not match the +// private key in the same blob. The mismatch is treated as a hard +// error so that operators do not accidentally authenticate with +// the wrong identity (e.g. an old certificate that outlived a +// key rotation). +var ErrCertMismatch = errors.New("sshcert: certificate public key does not match private key") + +// ParseResult holds the artefacts of parsing an OpenSSH secret +// blob that may or may not contain an SSH certificate. +type ParseResult struct { + // Signer is always non-nil on success. It signs using the + // embedded private key; when HasCert is true the caller + // should wrap it with ssh.NewCertSigner. + Signer ssh.Signer + + // Cert is the parsed certificate, or nil when HasCert is false. + Cert *ssh.Certificate + + // HasCert reports whether the secret blob carried a matching + // certificate line. + HasCert bool +} + +// Parse inspects secret and returns the underlying signer together +// with any bundled SSH certificate. A returned ParseResult.Signer +// is always safe to pass to ssh.PublicKeys; when HasCert is true +// the caller should wrap it via ssh.NewCertSigner before use so +// that the certificate is presented during authentication. +// +// Parse is the lower-level entry point; most callers should use +// NewSigner directly. +func Parse(secret []byte) (ParseResult, error) { + var res ParseResult + + signer, err := ssh.ParsePrivateKey(secret) + if err != nil { + return res, err + } + res.Signer = signer + + signerPub := signer.PublicKey().Marshal() + + for _, line := range strings.Split(string(secret), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if !strings.Contains(line, certAlgorithmMarker) { + continue + } + parsed, _, _, _, parseErr := ssh.ParseAuthorizedKey([]byte(line)) + if parseErr != nil { + // Skip malformed lines but keep scanning so a + // single bad line does not deny authentication with + // the remaining valid key material. + continue + } + cert, ok := parsed.(*ssh.Certificate) + if !ok { + continue + } + if !bytes.Equal(cert.Key.Marshal(), signerPub) { + return res, ErrCertMismatch + } + res.Cert = cert + res.HasCert = true + break + } + + return res, nil +} + +// NewSigner returns an ssh.Signer built from secret. If secret +// contains a certificate line whose public key matches the +// embedded private key, the returned signer is a CertSigner that +// presents the certificate during SSH user-auth. Otherwise the +// returned signer is the plain signer produced by +// ssh.ParsePrivateKey. +// +// The function never returns (nil, nil): either a usable signer is +// returned together with a nil error, or the underlying parse +// error is propagated. +func NewSigner(secret []byte) (ssh.Signer, error) { + res, err := Parse(secret) + if err != nil { + return nil, err + } + if !res.HasCert { + return res.Signer, nil + } + return ssh.NewCertSigner(res.Cert, res.Signer) +} diff --git a/pkg/sshcert/cert_test.go b/pkg/sshcert/cert_test.go new file mode 100644 index 000000000..58b45eb83 --- /dev/null +++ b/pkg/sshcert/cert_test.go @@ -0,0 +1,141 @@ +package sshcert + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "slices" + "strings" + "testing" + + "golang.org/x/crypto/ssh" +) + +// makeKeyAndCert generates a fresh ed25519 key, returns the +// PEM-armoured OpenSSH private key blob, the matching +// certificate's authorized-keys line, and the underlying signer. +func makeKeyAndCert(t *testing.T, keyID string, principals []string, serial uint64) ([]byte, string, ssh.Signer) { + t.Helper() + + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ed25519.GenerateKey: %v", err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatalf("ssh.NewSignerFromKey: %v", err) + } + + pemBlock, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatalf("ssh.MarshalPrivateKey: %v", err) + } + keyBytes := pem.EncodeToMemory(pemBlock) + + cert := &ssh.Certificate{ + Key: signer.PublicKey(), + Serial: serial, + CertType: ssh.UserCert, + KeyId: keyID, + ValidPrincipals: principals, + ValidAfter: uint64(0), + ValidBefore: ssh.CertTimeInfinity, + } + if err := cert.SignCert(rand.Reader, signer); err != nil { + t.Fatalf("cert.SignCert: %v", err) + } + certLine := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert))) + + return keyBytes, certLine, signer +} + +func TestNewSigner_PlainKey(t *testing.T) { + keyBytes, _, _ := makeKeyAndCert(t, "k", []string{"alice"}, 1) + signer, err := NewSigner(keyBytes) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + if signer == nil { + t.Fatal("expected non-nil signer") + } + if signer.PublicKey().Type() == ssh.CertAlgoED25519v01 { + t.Fatalf("plain key should not produce a cert signer, got type %q", + signer.PublicKey().Type()) + } +} + +func TestNewSigner_WithCert(t *testing.T) { + keyBytes, certLine, _ := makeKeyAndCert(t, "ops", []string{"root"}, 42) + + var secret []byte + secret = append(secret, keyBytes...) + secret = append(secret, '\n') + secret = append(secret, certLine...) + secret = append(secret, '\n') + + signer, err := NewSigner(secret) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + if signer.PublicKey().Type() != ssh.CertAlgoED25519v01 { + t.Fatalf("expected certificate signer, got type %q", + signer.PublicKey().Type()) + } +} + +func TestNewSigner_CertKeyMismatch(t *testing.T) { + keyA, _, _ := makeKeyAndCert(t, "kA", []string{"alice"}, 1) + _, certLineB, _ := makeKeyAndCert(t, "kB", []string{"bob"}, 2) + + var secret []byte + secret = append(secret, keyA...) + secret = append(secret, '\n') + secret = append(secret, certLineB...) + secret = append(secret, '\n') + + if _, err := NewSigner(secret); err != ErrCertMismatch { + t.Fatalf("expected ErrCertMismatch, got %v", err) + } +} + +func TestParse_ReportsHasCert(t *testing.T) { + keyBytes, certLine, _ := makeKeyAndCert(t, "ops", []string{"root", "deploy"}, 100) + + var secret []byte + secret = append(secret, keyBytes...) + secret = append(secret, '\n') + secret = append(secret, certLine...) + secret = append(secret, '\n') + + res, err := Parse(secret) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if !res.HasCert { + t.Fatal("expected HasCert=true") + } + if res.Cert == nil { + t.Fatal("expected non-nil Cert") + } + if res.Cert.Serial != 100 { + t.Fatalf("expected serial=100, got %d", res.Cert.Serial) + } + if !slices.Equal(res.Cert.ValidPrincipals, []string{"root", "deploy"}) { + t.Fatalf("principals: got %v, want [root deploy]", + res.Cert.ValidPrincipals) + } +} + +func TestParse_NoCert(t *testing.T) { + keyBytes, _, _ := makeKeyAndCert(t, "ops", []string{"root"}, 1) + res, err := Parse(keyBytes) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if res.HasCert { + t.Fatal("expected HasCert=false for key-only secret") + } + if res.Cert != nil { + t.Fatal("expected nil Cert for key-only secret") + } +}