Skip to content

Commit 26cee69

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 26cee69

4 files changed

Lines changed: 354 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: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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 as
8+
// 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 public
16+
// key matches the embedded private key, and returns the signer and
17+
// the certificate separately. NewSigner wraps the two with
18+
// ssh.NewCertSigner when a certificate is present, otherwise it
19+
// returns the plain signer produced by ssh.ParsePrivateKey - so
20+
// existing koko deployments that store only a private key continue
21+
// 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+
// ErrCertMismatch is returned when a secret blob contains a
37+
// certificate whose embedded public key does not match the
38+
// private key in the same blob. The mismatch is treated as a hard
39+
// error so that operators do not accidentally authenticate with
40+
// the wrong identity (e.g. an old certificate that outlived a key
41+
// rotation).
42+
var ErrCertMismatch = errors.New("sshcert: certificate public key does not match private key")
43+
44+
// ParseResult holds the artefacts of parsing an OpenSSH secret
45+
// blob that may or may not contain an SSH certificate.
46+
type ParseResult struct {
47+
// Signer is always non-nil on success. It signs using the
48+
// embedded private key; when HasCert is true the caller
49+
// should wrap it with ssh.NewCertSigner.
50+
Signer ssh.Signer
51+
52+
// Cert is the parsed certificate, or nil when HasCert is false.
53+
Cert *ssh.Certificate
54+
55+
// HasCert reports whether the secret blob carried a matching
56+
// certificate line.
57+
HasCert bool
58+
}
59+
60+
// Parse inspects secret and returns the underlying signer together
61+
// with any bundled SSH certificate. A returned ParseResult.Signer is
62+
// always safe to pass to ssh.PublicKeys; when HasCert is true the
63+
// caller should wrap it via ssh.NewCertSigner before use so that the
64+
// certificate is presented during authentication.
65+
//
66+
// Parse is the lower-level entry point; most callers should use
67+
// NewSigner directly.
68+
func Parse(secret []byte) (ParseResult, error) {
69+
res := ParseResult{}
70+
71+
signer, err := ssh.ParsePrivateKey(secret)
72+
if err != nil {
73+
return res, err
74+
}
75+
res.Signer = signer
76+
77+
signerPub := signer.PublicKey().Marshal()
78+
79+
for _, raw := range strings.Split(string(secret), "\n") {
80+
line := strings.TrimSpace(raw)
81+
if line == "" || strings.HasPrefix(line, "#") {
82+
continue
83+
}
84+
if !strings.Contains(line, "-cert-v01@openssh.com") {
85+
continue
86+
}
87+
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
88+
if err != nil {
89+
// Skip malformed lines but keep scanning - a single
90+
// bad line should not deny authentication with the
91+
// remaining valid key material.
92+
continue
93+
}
94+
cert, ok := key.(*ssh.Certificate)
95+
if !ok {
96+
continue
97+
}
98+
if !bytes.Equal(cert.Key.Marshal(), signerPub) {
99+
return res, ErrCertMismatch
100+
}
101+
res.Cert = cert
102+
res.HasCert = true
103+
break
104+
}
105+
106+
return res, nil
107+
}
108+
109+
// NewSigner returns an ssh.Signer built from secret. If secret
110+
// contains a certificate line whose public key matches the embedded
111+
// private key, the returned signer is a CertSigner that presents
112+
// the certificate during SSH user-auth. Otherwise the returned
113+
// signer is the plain signer produced by ssh.ParsePrivateKey.
114+
//
115+
// The function never returns (nil, nil): either a usable signer is
116+
// returned together with a nil error, or the underlying parse
117+
// error is propagated.
118+
func NewSigner(secret []byte) (ssh.Signer, error) {
119+
res, err := Parse(secret)
120+
if err != nil {
121+
return nil, err
122+
}
123+
if !res.HasCert {
124+
return res.Signer, nil
125+
}
126+
return ssh.NewCertSigner(res.Cert, res.Signer)
127+
}

pkg/sshcert/cert_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package sshcert
2+
3+
import (
4+
"crypto/ed25519"
5+
"crypto/rand"
6+
"encoding/pem"
7+
"strings"
8+
"testing"
9+
10+
"golang.org/x/crypto/ssh"
11+
)
12+
13+
// makeKeyAndCert generates a fresh ed25519 key, returns the
14+
// PEM-armoured OpenSSH private key blob, the matching certificate's
15+
// authorized-keys line, and the underlying signer.
16+
func makeKeyAndCert(t *testing.T, keyID string, principals []string, serial uint64) ([]byte, string, ssh.Signer) {
17+
t.Helper()
18+
19+
pub, priv, err := ed25519.GenerateKey(rand.Reader)
20+
if err != nil {
21+
t.Fatalf("ed25519.GenerateKey: %v", err)
22+
}
23+
signer, err := ssh.NewSignerFromKey(priv)
24+
if err != nil {
25+
t.Fatalf("ssh.NewSignerFromKey: %v", err)
26+
}
27+
_ = pub
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+
// Strip the certificate line - the secret is a plain key only.
55+
signer, err := NewSigner(keyBytes)
56+
if err != nil {
57+
t.Fatalf("NewSigner: %v", err)
58+
}
59+
if signer == nil {
60+
t.Fatal("expected non-nil signer")
61+
}
62+
if signer.PublicKey().Type() == ssh.CertAlgoED25519v01 {
63+
t.Fatalf("plain key should not produce a cert signer, got type %q",
64+
signer.PublicKey().Type())
65+
}
66+
}
67+
68+
func TestNewSigner_WithCert(t *testing.T) {
69+
keyBytes, certLine, _ := makeKeyAndCert(t, "ops", []string{"root"}, 42)
70+
71+
var secret []byte
72+
secret = append(secret, keyBytes...)
73+
secret = append(secret, '\n')
74+
secret = append(secret, certLine...)
75+
secret = append(secret, '\n')
76+
77+
signer, err := NewSigner(secret)
78+
if err != nil {
79+
t.Fatalf("NewSigner: %v", err)
80+
}
81+
if signer.PublicKey().Type() != ssh.CertAlgoED25519v01 {
82+
t.Fatalf("expected certificate signer, got type %q",
83+
signer.PublicKey().Type())
84+
}
85+
}
86+
87+
func TestNewSigner_CertKeyMismatch(t *testing.T) {
88+
keyA, _, _ := makeKeyAndCert(t, "kA", []string{"alice"}, 1)
89+
_, certLineB, _ := makeKeyAndCert(t, "kB", []string{"bob"}, 2)
90+
91+
// Concatenate key A with cert B (whose key is different).
92+
var secret []byte
93+
secret = append(secret, keyA...)
94+
secret = append(secret, '\n')
95+
secret = append(secret, certLineB...)
96+
secret = append(secret, '\n')
97+
98+
if _, err := NewSigner(secret); err != ErrCertMismatch {
99+
t.Fatalf("expected ErrCertMismatch, got %v", err)
100+
}
101+
}
102+
103+
func TestParse_ReportsHasCert(t *testing.T) {
104+
keyBytes, certLine, _ := makeKeyAndCert(t, "ops", []string{"root", "deploy"}, 100)
105+
106+
var secret []byte
107+
secret = append(secret, keyBytes...)
108+
secret = append(secret, '\n')
109+
secret = append(secret, certLine...)
110+
secret = append(secret, '\n')
111+
112+
res, err := Parse(secret)
113+
if err != nil {
114+
t.Fatalf("Parse: %v", err)
115+
}
116+
if !res.HasCert {
117+
t.Fatal("expected HasCert=true")
118+
}
119+
if res.Cert == nil {
120+
t.Fatal("expected non-nil Cert")
121+
}
122+
if res.Cert.Serial != 100 {
123+
t.Fatalf("expected serial=100, got %d", res.Cert.Serial)
124+
}
125+
if got, want := res.Cert.ValidPrincipals, []string{"root", "deploy"}; !equalStrings(got, want) {
126+
t.Fatalf("principals: got %v, want %v", got, want)
127+
}
128+
}
129+
130+
func TestParse_NoCert(t *testing.T) {
131+
keyBytes, _, _ := makeKeyAndCert(t, "ops", []string{"root"}, 1)
132+
res, err := Parse(keyBytes)
133+
if err != nil {
134+
t.Fatalf("Parse: %v", err)
135+
}
136+
if res.HasCert {
137+
t.Fatal("expected HasCert=false for key-only secret")
138+
}
139+
if res.Cert != nil {
140+
t.Fatal("expected nil Cert for key-only secret")
141+
}
142+
}
143+
144+
func equalStrings(a, b []string) bool {
145+
if len(a) != len(b) {
146+
return false
147+
}
148+
for i := range a {
149+
if a[i] != b[i] {
150+
return false
151+
}
152+
}
153+
return true
154+
}

0 commit comments

Comments
 (0)