Skip to content

Commit 7a4e280

Browse files
author
huangmin
committed
feat(handler): recognise SSH user certificates in PrivateKey secrets
pkg/handler.buildSSHClientOptions currently calls gossh.ParsePrivateKey on account.Secret and silently discards any OpenSSH certificate line that follows the PEM block. This means a certificate signed by an external CA (such as step-ca, Vault SSH, or ssh-keygen -s) bundled in the same secret is never presented during user-auth, and the SSH user-auth packet reaches sshd as a plain publickey request instead of an *-cert-v01@openssh.com request. Introduce pkg/sshcert, a small helper package that detects the trailing certificate line, validates that its public key matches the embedded private key, and wraps both with ssh.NewCertSigner when present. Swap the ParsePrivateKey call in buildSSHClientOptions for sshcert.NewSigner: blobs without a matching certificate line fall back to the existing plain-key behaviour, so the change is fully backwards-compatible for any existing deployment whose account.Secret is a plain OpenSSH private key block. The PR is deliberately agnostic about the certificate source - the secret blob can be produced by step-ca, ssh-keygen, Vault SSH secrets engine, or any other CA. The companion documentation in docs/ssh-certificate.md describes the supported wire format and references the external tools, without binding koko to any particular one. Tests in pkg/sshcert/cert_test.go cover plain-key fallback, certificate wrapping, key/cert mismatch (ErrCertMismatch), and the Parse() metadata path.
1 parent 102164e commit 7a4e280

4 files changed

Lines changed: 346 additions & 1 deletion

File tree

docs/ssh-certificate.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# SSH user certificate support in koko
2+
3+
`pkg/sshcert` recognises OpenSSH user certificates that are
4+
stored alongside an OpenSSH private key in a koko `PrivateKey`
5+
secret, and exposes helpers that build an `ssh.Signer` ready for
6+
`srvconn.SSHClientPrivateAuth`.
7+
8+
## Wire format
9+
10+
A koko `PrivateKey` secret may now carry an OpenSSH user
11+
certificate line after the private key PEM block:
12+
13+
```
14+
-----BEGIN OPENSSH PRIVATE KEY-----
15+
<base64 ed25519 / ecdsa / rsa private key>
16+
-----END OPENSSH PRIVATE KEY-----
17+
ecdsa-sha2-nistp256-cert-v01@openssh.com AAAA... <key-id>
18+
```
19+
20+
`pkg/handler.buildSSHClientOptions` calls `sshcert.NewSigner`:
21+
22+
- if the secret contains a matching `-cert-v01@openssh.com` line,
23+
the returned signer is a `CertSigner` that presents the
24+
certificate during SSH user-auth;
25+
- otherwise the returned signer is the plain signer produced by
26+
`ssh.ParsePrivateKey`, so existing deployments are unaffected.
27+
28+
## Operator workflow
29+
30+
The secret blob can be produced by any tool that emits the
31+
conventional private key + certificate concatenation. Common
32+
options include:
33+
34+
- `ssh-keygen -s ca_key -I key-id id_key.pub` after appending the
35+
signed `*-cert.pub` to the secret;
36+
- `step ssh certificate <id> <key>` from
37+
[smallstep step-ca](https://smallstep.com/docs/step-ca/),
38+
which writes the certificate line into the same file as the
39+
private key;
40+
- HashiCorp Vault SSH secrets engine, AWS SSM Session Manager,
41+
or any other CA whose signed output is written alongside the
42+
private key.
43+
44+
The certificate can then be put into the JumpServer asset
45+
account `private_key` field via the JMS REST API, or pasted into
46+
the Luna UI as a single text blob. Once the field is saved, koko
47+
will negotiate the `*-cert-v01@openssh.com` SSH user-auth
48+
algorithm with the target host, and the host's sshd will validate
49+
the certificate against its `TrustedUserCAKeys` instead of its
50+
`authorized_keys`.
51+
52+
## Failure modes
53+
54+
| Scenario | Behaviour |
55+
| --- | --- |
56+
| Secret is a plain private key (no certificate line) | `NewSigner` returns the plain signer; existing deployments are unaffected. |
57+
| Secret contains a matching certificate | `NewSigner` returns a `CertSigner`; SSH user-auth is performed with the certificate. |
58+
| 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. |
59+
| Secret is malformed | `NewSigner` returns the underlying parse error; the existing log line is emitted. |
60+
61+
## Reference
62+
63+
- [OpenSSH certificates PROTOCOL.certkeys](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys)
64+
- [step-ca SSH certificate workflow](https://smallstep.com/docs/step-ca/ssh/)

pkg/handler/server_ssh.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/jumpserver/koko/pkg/proxy"
2929
"github.com/jumpserver/koko/pkg/session"
3030
"github.com/jumpserver/koko/pkg/srvconn"
31+
"github.com/jumpserver/koko/pkg/sshcert"
3132
"github.com/jumpserver/koko/pkg/utils"
3233
)
3334

@@ -672,7 +673,14 @@ func buildSSHClientOptions(asset *model.Asset, account *model.Account,
672673
sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPort(asset.ProtocolPort(model.ProtocolSSH)))
673674
sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientTimeout(timeout))
674675
if account.IsSSHKey() {
675-
if signer, err1 := gossh.ParsePrivateKey([]byte(account.Secret)); err1 == nil {
676+
// sshcert.NewSigner returns a CertSigner when the secret
677+
// blob carries an OpenSSH certificate whose key matches the
678+
// embedded private key, so the downstream AuthMethods() will
679+
// present the certificate instead of the bare public key.
680+
// Blobs without a matching -cert-v01@openssh.com line fall
681+
// back to the existing plain-key behaviour, keeping the
682+
// change backwards-compatible with every existing deployment.
683+
if signer, err1 := sshcert.NewSigner([]byte(account.Secret)); err1 == nil {
676684
sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPrivateAuth(signer))
677685
} else {
678686
logger.Errorf("Parse account %s private key failed: %s", account.Username, err1)

pkg/sshcert/cert.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Package sshcert recognises OpenSSH user certificates that are
2+
// stored alongside an OpenSSH private key in a koko "PrivateKey"
3+
// secret, and exposes helpers that build an ssh.Signer ready for
4+
// srvconn.SSHClientPrivateAuth.
5+
//
6+
// The format is the conventional concatenation of a private key
7+
// PEM block and a single certificate line emitted by tools such
8+
// as ssh-keygen -s or smallstep step-ca:
9+
//
10+
// -----BEGIN OPENSSH PRIVATE KEY-----
11+
// <base64>
12+
// -----END OPENSSH PRIVATE KEY-----
13+
// ecdsa-sha2-nistp256-cert-v01@openssh.com AAAA... <key-id>
14+
//
15+
// Parse opens such a blob, validates that the certificate's
16+
// public key matches the embedded private key, and returns the
17+
// signer and the certificate separately. NewSigner wraps the two
18+
// with ssh.NewCertSigner when a certificate is present, otherwise
19+
// it returns the plain signer produced by ssh.ParsePrivateKey - so
20+
// existing koko deployments that store only a private key
21+
// continue to authenticate exactly as before.
22+
//
23+
// The package is deliberately minimal: it relies only on
24+
// golang.org/x/crypto/ssh and never touches the filesystem, the
25+
// network or koko's session lifecycle.
26+
package sshcert
27+
28+
import (
29+
"bytes"
30+
"errors"
31+
"strings"
32+
33+
"golang.org/x/crypto/ssh"
34+
)
35+
36+
// certAlgorithmMarker is the substring that identifies an OpenSSH
37+
// user certificate line in an authorized_keys-style blob.
38+
const certAlgorithmMarker = "-cert-v01@openssh.com"
39+
40+
// ErrCertMismatch is returned when a secret blob contains a
41+
// certificate whose embedded public key does not match the
42+
// private key in the same blob. The mismatch is treated as a hard
43+
// error so that operators do not accidentally authenticate with
44+
// the wrong identity (e.g. an old certificate that outlived a
45+
// key rotation).
46+
var ErrCertMismatch = errors.New("sshcert: certificate public key does not match private key")
47+
48+
// ParseResult holds the artefacts of parsing an OpenSSH secret
49+
// blob that may or may not contain an SSH certificate.
50+
type ParseResult struct {
51+
// Signer is always non-nil on success. It signs using the
52+
// embedded private key; when HasCert is true the caller
53+
// should wrap it with ssh.NewCertSigner.
54+
Signer ssh.Signer
55+
56+
// Cert is the parsed certificate, or nil when HasCert is false.
57+
Cert *ssh.Certificate
58+
59+
// HasCert reports whether the secret blob carried a matching
60+
// certificate line.
61+
HasCert bool
62+
}
63+
64+
// Parse inspects secret and returns the underlying signer together
65+
// with any bundled SSH certificate. A returned ParseResult.Signer
66+
// is always safe to pass to ssh.PublicKeys; when HasCert is true
67+
// the caller should wrap it via ssh.NewCertSigner before use so
68+
// that the certificate is presented during authentication.
69+
//
70+
// Parse is the lower-level entry point; most callers should use
71+
// NewSigner directly.
72+
func Parse(secret []byte) (ParseResult, error) {
73+
var res ParseResult
74+
75+
signer, err := ssh.ParsePrivateKey(secret)
76+
if err != nil {
77+
return res, err
78+
}
79+
res.Signer = signer
80+
81+
signerPub := signer.PublicKey().Marshal()
82+
83+
for _, line := range strings.Split(string(secret), "\n") {
84+
line = strings.TrimSpace(line)
85+
if line == "" || strings.HasPrefix(line, "#") {
86+
continue
87+
}
88+
if !strings.Contains(line, certAlgorithmMarker) {
89+
continue
90+
}
91+
parsed, _, _, _, parseErr := ssh.ParseAuthorizedKey([]byte(line))
92+
if parseErr != nil {
93+
// Skip malformed lines but keep scanning so a
94+
// single bad line does not deny authentication with
95+
// the remaining valid key material.
96+
continue
97+
}
98+
cert, ok := parsed.(*ssh.Certificate)
99+
if !ok {
100+
continue
101+
}
102+
if !bytes.Equal(cert.Key.Marshal(), signerPub) {
103+
return res, ErrCertMismatch
104+
}
105+
res.Cert = cert
106+
res.HasCert = true
107+
break
108+
}
109+
110+
return res, nil
111+
}
112+
113+
// NewSigner returns an ssh.Signer built from secret. If secret
114+
// contains a certificate line whose public key matches the
115+
// embedded private key, the returned signer is a CertSigner that
116+
// presents the certificate during SSH user-auth. Otherwise the
117+
// returned signer is the plain signer produced by
118+
// ssh.ParsePrivateKey.
119+
//
120+
// The function never returns (nil, nil): either a usable signer is
121+
// returned together with a nil error, or the underlying parse
122+
// error is propagated.
123+
func NewSigner(secret []byte) (ssh.Signer, error) {
124+
res, err := Parse(secret)
125+
if err != nil {
126+
return nil, err
127+
}
128+
if !res.HasCert {
129+
return res.Signer, nil
130+
}
131+
return ssh.NewCertSigner(res.Cert, res.Signer)
132+
}

pkg/sshcert/cert_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package sshcert
2+
3+
import (
4+
"crypto/ed25519"
5+
"crypto/rand"
6+
"encoding/pem"
7+
"slices"
8+
"strings"
9+
"testing"
10+
11+
"golang.org/x/crypto/ssh"
12+
)
13+
14+
// makeKeyAndCert generates a fresh ed25519 key, returns the
15+
// PEM-armoured OpenSSH private key blob, the matching
16+
// certificate's authorized-keys line, and the underlying signer.
17+
func makeKeyAndCert(t *testing.T, keyID string, principals []string, serial uint64) ([]byte, string, ssh.Signer) {
18+
t.Helper()
19+
20+
_, priv, err := ed25519.GenerateKey(rand.Reader)
21+
if err != nil {
22+
t.Fatalf("ed25519.GenerateKey: %v", err)
23+
}
24+
signer, err := ssh.NewSignerFromKey(priv)
25+
if err != nil {
26+
t.Fatalf("ssh.NewSignerFromKey: %v", err)
27+
}
28+
29+
pemBlock, err := ssh.MarshalPrivateKey(priv, "")
30+
if err != nil {
31+
t.Fatalf("ssh.MarshalPrivateKey: %v", err)
32+
}
33+
keyBytes := pem.EncodeToMemory(pemBlock)
34+
35+
cert := &ssh.Certificate{
36+
Key: signer.PublicKey(),
37+
Serial: serial,
38+
CertType: ssh.UserCert,
39+
KeyId: keyID,
40+
ValidPrincipals: principals,
41+
ValidAfter: uint64(0),
42+
ValidBefore: ssh.CertTimeInfinity,
43+
}
44+
if err := cert.SignCert(rand.Reader, signer); err != nil {
45+
t.Fatalf("cert.SignCert: %v", err)
46+
}
47+
certLine := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert)))
48+
49+
return keyBytes, certLine, signer
50+
}
51+
52+
func TestNewSigner_PlainKey(t *testing.T) {
53+
keyBytes, _, _ := makeKeyAndCert(t, "k", []string{"alice"}, 1)
54+
signer, err := NewSigner(keyBytes)
55+
if err != nil {
56+
t.Fatalf("NewSigner: %v", err)
57+
}
58+
if signer == nil {
59+
t.Fatal("expected non-nil signer")
60+
}
61+
if signer.PublicKey().Type() == ssh.CertAlgoED25519v01 {
62+
t.Fatalf("plain key should not produce a cert signer, got type %q",
63+
signer.PublicKey().Type())
64+
}
65+
}
66+
67+
func TestNewSigner_WithCert(t *testing.T) {
68+
keyBytes, certLine, _ := makeKeyAndCert(t, "ops", []string{"root"}, 42)
69+
70+
var secret []byte
71+
secret = append(secret, keyBytes...)
72+
secret = append(secret, '\n')
73+
secret = append(secret, certLine...)
74+
secret = append(secret, '\n')
75+
76+
signer, err := NewSigner(secret)
77+
if err != nil {
78+
t.Fatalf("NewSigner: %v", err)
79+
}
80+
if signer.PublicKey().Type() != ssh.CertAlgoED25519v01 {
81+
t.Fatalf("expected certificate signer, got type %q",
82+
signer.PublicKey().Type())
83+
}
84+
}
85+
86+
func TestNewSigner_CertKeyMismatch(t *testing.T) {
87+
keyA, _, _ := makeKeyAndCert(t, "kA", []string{"alice"}, 1)
88+
_, certLineB, _ := makeKeyAndCert(t, "kB", []string{"bob"}, 2)
89+
90+
var secret []byte
91+
secret = append(secret, keyA...)
92+
secret = append(secret, '\n')
93+
secret = append(secret, certLineB...)
94+
secret = append(secret, '\n')
95+
96+
if _, err := NewSigner(secret); err != ErrCertMismatch {
97+
t.Fatalf("expected ErrCertMismatch, got %v", err)
98+
}
99+
}
100+
101+
func TestParse_ReportsHasCert(t *testing.T) {
102+
keyBytes, certLine, _ := makeKeyAndCert(t, "ops", []string{"root", "deploy"}, 100)
103+
104+
var secret []byte
105+
secret = append(secret, keyBytes...)
106+
secret = append(secret, '\n')
107+
secret = append(secret, certLine...)
108+
secret = append(secret, '\n')
109+
110+
res, err := Parse(secret)
111+
if err != nil {
112+
t.Fatalf("Parse: %v", err)
113+
}
114+
if !res.HasCert {
115+
t.Fatal("expected HasCert=true")
116+
}
117+
if res.Cert == nil {
118+
t.Fatal("expected non-nil Cert")
119+
}
120+
if res.Cert.Serial != 100 {
121+
t.Fatalf("expected serial=100, got %d", res.Cert.Serial)
122+
}
123+
if !slices.Equal(res.Cert.ValidPrincipals, []string{"root", "deploy"}) {
124+
t.Fatalf("principals: got %v, want [root deploy]",
125+
res.Cert.ValidPrincipals)
126+
}
127+
}
128+
129+
func TestParse_NoCert(t *testing.T) {
130+
keyBytes, _, _ := makeKeyAndCert(t, "ops", []string{"root"}, 1)
131+
res, err := Parse(keyBytes)
132+
if err != nil {
133+
t.Fatalf("Parse: %v", err)
134+
}
135+
if res.HasCert {
136+
t.Fatal("expected HasCert=false for key-only secret")
137+
}
138+
if res.Cert != nil {
139+
t.Fatal("expected nil Cert for key-only secret")
140+
}
141+
}

0 commit comments

Comments
 (0)