Skip to content

Commit 5e0cf1f

Browse files
allow multi-cert pem bundles in CustomRootCATLSSecret (#1680)
Signed-off-by: Mayank Shah <mayank.shah@percona.com>
1 parent ab1076f commit 5e0cf1f

4 files changed

Lines changed: 114 additions & 10 deletions

File tree

internal/patroni/reconcile_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package patroni
66

77
import (
88
"context"
9+
"strings"
910
"testing"
1011

1112
"gotest.tools/v3/assert"
@@ -92,6 +93,44 @@ func TestReconcileInstanceCertificates(t *testing.T) {
9293
assert.DeepEqual(t, secret, before)
9394
}
9495

96+
// TestReconcileInstanceCertificatesWithCABundle covers a custom root CA
97+
// secret whose certificate file is a bundle of an intermediate CA followed
98+
// by its root, as produced by cert-manager when leaf certificates are issued
99+
// through a sub-CA. Every certificate in the bundle must reach
100+
// patroni.ca-roots, or the leaf presented by peers (signed by the
101+
// intermediate) cannot be chained to a trusted root and TLS verification
102+
// fails at bootstrap.
103+
func TestReconcileInstanceCertificatesWithCABundle(t *testing.T) {
104+
t.Parallel()
105+
106+
root, err := pki.NewRootCertificateAuthority()
107+
assert.NilError(t, err, "bug in test")
108+
intermediate, err := pki.NewRootCertificateAuthority()
109+
assert.NilError(t, err, "bug in test")
110+
111+
rootText, err := root.Certificate.MarshalText()
112+
assert.NilError(t, err, "bug in test")
113+
intermediateText, err := intermediate.Certificate.MarshalText()
114+
assert.NilError(t, err, "bug in test")
115+
116+
bundle := append(append([]byte{}, intermediateText...), rootText...)
117+
118+
var bundledCA pki.Certificate
119+
assert.NilError(t, bundledCA.UnmarshalText(bundle))
120+
121+
leaf, err := intermediate.GenerateLeafCertificate("any", nil)
122+
assert.NilError(t, err, "bug in test")
123+
124+
ctx := context.Background()
125+
secret := new(corev1.Secret)
126+
assert.NilError(t, InstanceCertificates(ctx,
127+
bundledCA, leaf.Certificate, leaf.PrivateKey, secret))
128+
129+
assert.DeepEqual(t, secret.Data["patroni.ca-roots"], bundle)
130+
assert.Equal(t,
131+
strings.Count(string(secret.Data["patroni.ca-roots"]), "-----BEGIN CERTIFICATE-----"), 2)
132+
}
133+
95134
func TestInstanceConfigMap(t *testing.T) {
96135
t.Parallel()
97136

internal/pki/encoding.go

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,32 +29,57 @@ var (
2929
_ encoding.TextUnmarshaler = (*Certificate)(nil)
3030
)
3131

32-
// MarshalText returns a PEM encoding of c that OpenSSL understands.
32+
// MarshalText returns a PEM encoding of c that OpenSSL understands. When c
33+
// was unmarshaled from a bundle containing more than one certificate, e.g. an
34+
// intermediate certificate authority followed by its root, the additional
35+
// certificates are included after the first so the full bundle round-trips.
3336
func (c Certificate) MarshalText() ([]byte, error) {
3437
if c.x509 == nil || len(c.x509.Raw) == 0 {
3538
_, err := x509.ParseCertificate(nil)
3639
return nil, err
3740
}
3841

39-
return pem.EncodeToMemory(&pem.Block{
42+
out := pem.EncodeToMemory(&pem.Block{
4043
Type: pemLabelCertificate,
4144
Bytes: c.x509.Raw,
42-
}), nil
45+
})
46+
47+
return append(out, c.chain...), nil
4348
}
4449

45-
// UnmarshalText populates c from its PEM encoding.
50+
// UnmarshalText populates c from its PEM encoding. When data contains more
51+
// than one PEM-encoded certificate, e.g. a CA bundle made up of an
52+
// intermediate certificate authority followed by its root, the first is
53+
// parsed for cryptographic use and every certificate is kept so the full
54+
// bundle round-trips through MarshalText. Any other kind of PEM block
55+
// (a private key, for example) is ignored rather than carried along.
4656
func (c *Certificate) UnmarshalText(data []byte) error {
47-
block, _ := pem.Decode(data)
57+
block, rest := pem.Decode(data)
4858

4959
if block == nil || block.Type != pemLabelCertificate {
5060
return errors.New("not a PEM-encoded certificate")
5161
}
5262

5363
parsed, err := x509.ParseCertificate(block.Bytes)
54-
if err == nil {
55-
c.x509 = parsed
64+
if err != nil {
65+
return err
5666
}
57-
return err
67+
68+
c.x509 = parsed
69+
c.chain = nil
70+
71+
for {
72+
var next *pem.Block
73+
next, rest = pem.Decode(rest)
74+
if next == nil {
75+
break
76+
}
77+
if next.Type == pemLabelCertificate {
78+
c.chain = append(c.chain, pem.EncodeToMemory(next)...)
79+
}
80+
}
81+
82+
return nil
5883
}
5984

6085
var (

internal/pki/encoding_test.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,41 @@ func TestCertificateTextMarshaling(t *testing.T) {
5151

5252
bundle := bytes.Join([][]byte{txt, otherText}, nil)
5353

54-
// Only the first certificate of a bundle is parsed.
54+
// Only the first certificate of a bundle is parsed for
55+
// cryptographic use (e.g. signing, CommonName, DNSNames), but every
56+
// certificate in the bundle is kept so the full chain — such as an
57+
// intermediate CA followed by its root — round-trips through
58+
// MarshalText. Otherwise a custom root CA bundle silently loses
59+
// every certificate after the first when it is copied into
60+
// per-instance trust stores.
5561
var sink Certificate
5662
assert.NilError(t, sink.UnmarshalText(bundle))
5763
assert.DeepEqual(t, cert, sink)
64+
65+
sinkText, err := sink.MarshalText()
66+
assert.NilError(t, err)
67+
assert.DeepEqual(t, sinkText, bundle)
68+
})
69+
70+
t.Run("BundleIgnoresNonCertificateBlocks", func(t *testing.T) {
71+
// A non-certificate PEM block (e.g. a private key) appended after
72+
// the first certificate must never be forwarded into a trust store,
73+
// even though certificates that follow are kept.
74+
keyText, err := root.PrivateKey.MarshalText()
75+
assert.NilError(t, err)
76+
77+
other, _ := NewRootCertificateAuthority()
78+
otherText, err := other.Certificate.MarshalText()
79+
assert.NilError(t, err)
80+
81+
bundle := bytes.Join([][]byte{txt, keyText, otherText}, nil)
82+
83+
var sink Certificate
84+
assert.NilError(t, sink.UnmarshalText(bundle))
85+
86+
sinkText, err := sink.MarshalText()
87+
assert.NilError(t, err)
88+
assert.DeepEqual(t, sinkText, bytes.Join([][]byte{txt, otherText}, nil))
5889
})
5990

6091
t.Run("EncodedEmpty", func(t *testing.T) {

internal/pki/pki.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,16 @@ const renewalRatio = 3
1515

1616
// Certificate represents an X.509 certificate that conforms to the Internet
1717
// PKI Profile, RFC 5280.
18-
type Certificate struct{ x509 *x509.Certificate }
18+
type Certificate struct {
19+
x509 *x509.Certificate
20+
21+
// chain holds the PEM encoding of any certificates that followed the
22+
// first one when this Certificate was unmarshaled from a bundle, e.g. an
23+
// intermediate certificate authority followed by its root. It is nil for
24+
// certificates that were generated rather than unmarshaled. MarshalText
25+
// appends it after the leading certificate so the full bundle round-trips.
26+
chain []byte
27+
}
1928

2029
// PrivateKey represents the private key of a Certificate.
2130
type PrivateKey struct{ ecdsa *ecdsa.PrivateKey }

0 commit comments

Comments
 (0)