Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions docs/ssh-certificate.md
Original file line number Diff line number Diff line change
@@ -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-----
<base64 ed25519 / ecdsa / rsa private key>
-----END OPENSSH PRIVATE KEY-----
ecdsa-sha2-nistp256-cert-v01@openssh.com AAAA... <key-id>
```

`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 <id> <key>` 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/)
10 changes: 9 additions & 1 deletion pkg/handler/server_ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down
132 changes: 132 additions & 0 deletions pkg/sshcert/cert.go
Original file line number Diff line number Diff line change
@@ -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-----
// <base64>
// -----END OPENSSH PRIVATE KEY-----
// ecdsa-sha2-nistp256-cert-v01@openssh.com AAAA... <key-id>
//
// 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)
}
141 changes: 141 additions & 0 deletions pkg/sshcert/cert_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}