Skip to content

Commit ece5172

Browse files
committed
fix(pki): refuse to silently reissue or blank an existing kubernetes CA keypair
kops update cluster could propose creating a new kubernetes-ca keypair and blank ConfigServer.CACertificates in generated nodeup configs even when a valid CA already existed. If applied, this would break node bootstrap (empty CA bundle) or, worse, rotate the cluster's CA out from under it. - CreateKeyset() now re-checks the keystore immediately before minting a new "ca"-type certificate and refuses if one unexpectedly already exists there, protecting the dangerous real-apply outcome. - Keyset gains an IsPlaceholder() helper, and nodeup config rendering (loadCertificates) now emits an unambiguous placeholder marker instead of a bare empty string for an unresolved keyset, so a preview can never look like an existing CA cert was blanked. Neither change affects a genuinely new cluster's first update cluster dry run, since nothing exists in the keystore to conflict with there. See kubernetes/kops issue 18680 for background.
1 parent 7cfb7bf commit ece5172

6 files changed

Lines changed: 303 additions & 2 deletions

File tree

pkg/nodemodel/nodeupconfigbuilder.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,19 @@ func loadCertificates(keysets map[string]*fi.Keyset, name string, config *nodeup
465465
if keyset == nil {
466466
return fmt.Errorf("key %q not found", name)
467467
}
468+
if keyset.IsPlaceholder() {
469+
// The keypair has not actually been created yet (e.g. this is a preview
470+
// of a not-yet-created cluster/keypair). Emit an unambiguous, obviously
471+
// non-certificate placeholder rather than an empty string: an empty
472+
// string here is indistinguishable from "this CA legitimately has no
473+
// certificates" and, for an *existing* CA, could be silently mistaken
474+
// for a real blanking of a live trust bundle.
475+
config.CAs[name] = fi.PlaceholderKeypairID
476+
if includeKeypairID {
477+
config.KeypairIDs[name] = fi.PlaceholderKeypairID
478+
}
479+
return nil
480+
}
468481
certificates, err := keyset.ToCertificateBytes()
469482
if err != nil {
470483
return fmt.Errorf("failed to read %q certificates: %w", name, err)

pkg/nodemodel/nodeupconfigbuilder_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,19 @@ limitations under the License.
1717
package nodemodel
1818

1919
import (
20+
"crypto/rand"
21+
"crypto/rsa"
22+
"crypto/x509"
23+
"crypto/x509/pkix"
24+
"math/big"
2025
"reflect"
2126
"testing"
27+
"time"
2228

2329
"k8s.io/kops/pkg/apis/kops"
30+
"k8s.io/kops/pkg/apis/nodeup"
31+
"k8s.io/kops/pkg/pki"
32+
"k8s.io/kops/upup/pkg/fi"
2433
)
2534

2635
func TestSelectControlPlaneIPs(t *testing.T) {
@@ -104,3 +113,90 @@ func TestBuildConfigServerOptionsUsesDNSNameByDefault(t *testing.T) {
104113
t.Fatalf("Servers = %v, want %v", got, want)
105114
}
106115
}
116+
117+
func selfSignedTestCert(t *testing.T, commonName string) *pki.Certificate {
118+
t.Helper()
119+
120+
key, err := rsa.GenerateKey(rand.Reader, 2048)
121+
if err != nil {
122+
t.Fatalf("generating test key: %v", err)
123+
}
124+
template := &x509.Certificate{
125+
SerialNumber: big.NewInt(1),
126+
Subject: pkix.Name{CommonName: commonName},
127+
NotBefore: time.Now(),
128+
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
129+
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
130+
BasicConstraintsValid: true,
131+
IsCA: true,
132+
}
133+
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
134+
if err != nil {
135+
t.Fatalf("creating test certificate: %v", err)
136+
}
137+
cert, err := x509.ParseCertificate(der)
138+
if err != nil {
139+
t.Fatalf("parsing test certificate: %v", err)
140+
}
141+
return &pki.Certificate{Certificate: cert}
142+
}
143+
144+
// TestLoadCertificatesNeverEmitsBlankCAForPlaceholderKeyset is a regression test for
145+
// https://github.com/kubernetes/kops/issues/18680: an unresolved (not-yet-created)
146+
// keypair must never silently turn into an empty-string CA bundle, which is
147+
// indistinguishable from a real, dangerous blanking of an existing trust bundle.
148+
func TestLoadCertificatesNeverEmitsBlankCAForPlaceholderKeyset(t *testing.T) {
149+
config := &nodeup.Config{
150+
CAs: map[string]string{},
151+
KeypairIDs: map[string]string{},
152+
}
153+
keysets := map[string]*fi.Keyset{
154+
fi.CertificateIDCA: {
155+
Primary: &fi.KeysetItem{Id: fi.PlaceholderKeypairID},
156+
},
157+
}
158+
159+
if err := loadCertificates(keysets, fi.CertificateIDCA, config, true); err != nil {
160+
t.Fatalf("unexpected error for a placeholder (not-yet-created) keypair: %v", err)
161+
}
162+
163+
if got := config.CAs[fi.CertificateIDCA]; got == "" {
164+
t.Errorf("CAs[%q] = %q; must not be blank for a placeholder keyset", fi.CertificateIDCA, got)
165+
}
166+
if got, want := config.CAs[fi.CertificateIDCA], fi.PlaceholderKeypairID; got != want {
167+
t.Errorf("CAs[%q] = %q, want unambiguous placeholder %q", fi.CertificateIDCA, got, want)
168+
}
169+
if got, want := config.KeypairIDs[fi.CertificateIDCA], fi.PlaceholderKeypairID; got != want {
170+
t.Errorf("KeypairIDs[%q] = %q, want %q", fi.CertificateIDCA, got, want)
171+
}
172+
}
173+
174+
// TestLoadCertificatesPreservesRealCertificate is the control case: a real,
175+
// resolved keyset must still round-trip its actual certificate bytes unchanged.
176+
func TestLoadCertificatesPreservesRealCertificate(t *testing.T) {
177+
cert := selfSignedTestCert(t, "kubernetes-ca")
178+
179+
config := &nodeup.Config{
180+
CAs: map[string]string{},
181+
KeypairIDs: map[string]string{},
182+
}
183+
keysets := map[string]*fi.Keyset{
184+
fi.CertificateIDCA: {
185+
Primary: &fi.KeysetItem{Id: "123", Certificate: cert},
186+
Items: map[string]*fi.KeysetItem{
187+
"123": {Id: "123", Certificate: cert},
188+
},
189+
},
190+
}
191+
192+
if err := loadCertificates(keysets, fi.CertificateIDCA, config, true); err != nil {
193+
t.Fatalf("unexpected error: %v", err)
194+
}
195+
196+
if got := config.CAs[fi.CertificateIDCA]; got == "" || got == fi.PlaceholderKeypairID {
197+
t.Errorf("CAs[%q] = %q, want the real certificate PEM", fi.CertificateIDCA, got)
198+
}
199+
if got, want := config.KeypairIDs[fi.CertificateIDCA], "123"; got != want {
200+
t.Errorf("KeypairIDs[%q] = %q, want %q", fi.CertificateIDCA, got, want)
201+
}
202+
}

upup/pkg/fi/ca.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ const (
4747
keysetFormatLatest = "v1alpha2"
4848
)
4949

50+
// PlaceholderKeypairID is the sentinel Primary.Id used by a Keypair task's
51+
// Keyset() when the real certificate/key data has not yet been resolved by
52+
// Find() or Render() (e.g. a keypair that is only about to be created). It
53+
// must never be mistaken for a real keypair identifier.
54+
const PlaceholderKeypairID = "<< TO BE GENERATED >>"
55+
5056
// Keyset is a parsed api.Keyset.
5157
type Keyset struct {
5258
// LegacyFormat instructs a keypair task to convert a Legacy Keyset to the new Keyset API format.
@@ -58,6 +64,12 @@ type Keyset struct {
5864
Primary *KeysetItem
5965
}
6066

67+
// IsPlaceholder returns true if this Keyset is an unresolved placeholder
68+
// (see PlaceholderKeypairID) rather than a real result from the keystore.
69+
func (k *Keyset) IsPlaceholder() bool {
70+
return k != nil && k.Primary != nil && k.Primary.Id == PlaceholderKeypairID && len(k.Items) == 0
71+
}
72+
6173
// KeysetItem is a certificate/key pair in a Keyset.
6274
type KeysetItem struct {
6375
// Id is the identifier of this keypair.

upup/pkg/fi/ca_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,60 @@ func TestAddItem(t *testing.T) {
375375
}
376376
}
377377

378+
func TestKeysetIsPlaceholder(t *testing.T) {
379+
cert, _ := pki.ParsePEMCertificate([]byte(certData))
380+
privateKey, _ := pki.ParsePEMPrivateKey([]byte(privatekeyData))
381+
382+
grid := []struct {
383+
name string
384+
keyset *fi.Keyset
385+
want bool
386+
}{
387+
{
388+
name: "nil keyset",
389+
keyset: nil,
390+
want: false,
391+
},
392+
{
393+
name: "zero-value keyset",
394+
keyset: &fi.Keyset{},
395+
want: false,
396+
},
397+
{
398+
name: "unresolved placeholder",
399+
keyset: &fi.Keyset{
400+
Primary: &fi.KeysetItem{Id: fi.PlaceholderKeypairID},
401+
},
402+
want: true,
403+
},
404+
{
405+
name: "real keyset with matching item count coincidence",
406+
keyset: &fi.Keyset{
407+
Primary: &fi.KeysetItem{
408+
Id: "6952323604391556590983096308",
409+
Certificate: cert,
410+
PrivateKey: privateKey,
411+
},
412+
Items: map[string]*fi.KeysetItem{
413+
"6952323604391556590983096308": {
414+
Id: "6952323604391556590983096308",
415+
Certificate: cert,
416+
PrivateKey: privateKey,
417+
},
418+
},
419+
},
420+
want: false,
421+
},
422+
}
423+
424+
for _, g := range grid {
425+
t.Run(g.name, func(t *testing.T) {
426+
got := g.keyset.IsPlaceholder()
427+
assert.Equal(t, g.want, got)
428+
})
429+
}
430+
}
431+
378432
func assertSerialNotInFuture(t *testing.T, id string) {
379433
version, ok := big.NewInt(0).SetString(id, 10)
380434
require.True(t, ok, "parses as integer")

upup/pkg/fi/fitasks/keypair.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,15 @@ func CreateKeyset(ctx context.Context, keystore fi.Keystore, name string, req pk
273273
keyset = &fi.Keyset{
274274
Items: map[string]*fi.KeysetItem{},
275275
}
276+
} else if req.Type == "ca" && keyset.Primary != nil && keyset.Primary.Certificate != nil {
277+
// The caller believed this CA needed to be (re)created, but a direct,
278+
// authoritative lookup right before mutating the keystore shows an
279+
// existing, valid CA certificate. Silently reissuing it here would
280+
// rotate the cluster's trust anchor out from under it. This mismatch
281+
// indicates a task-planning inconsistency (e.g. the keypair task's
282+
// own Find() did not see this same data) rather than a genuinely
283+
// missing CA, so refuse instead of proceeding.
284+
return nil, fmt.Errorf("refusing to create keypair %q: an existing CA certificate was found in the keystore; this indicates a task-planning inconsistency rather than a genuinely missing CA — aborting to avoid rotating a cluster's trust anchor", name)
276285
}
277286

278287
if req.Serial == nil {
@@ -348,12 +357,12 @@ func parsePkixName(s string) (*pkix.Name, error) {
348357
func (e *Keypair) ensureResources() {
349358
if e.certificates == nil {
350359
e.certificates = &fi.CloudupTaskDependentResource{
351-
Resource: fi.NewStringResource("<< TO BE GENERATED >>\n"),
360+
Resource: fi.NewStringResource(fi.PlaceholderKeypairID + "\n"),
352361
Task: e,
353362
}
354363
e.keyset = &fi.Keyset{
355364
Primary: &fi.KeysetItem{
356-
Id: "<< TO BE GENERATED >>",
365+
Id: fi.PlaceholderKeypairID,
357366
},
358367
}
359368
}

upup/pkg/fi/fitasks/keypair_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,19 @@ limitations under the License.
1717
package fitasks
1818

1919
import (
20+
"context"
21+
"crypto/rand"
22+
"crypto/rsa"
23+
"crypto/x509"
24+
"crypto/x509/pkix"
25+
"math/big"
2026
"strings"
2127
"testing"
28+
"time"
2229

30+
"k8s.io/kops/pkg/pki"
2331
"k8s.io/kops/upup/pkg/fi"
32+
"k8s.io/kops/util/pkg/vfs"
2433
)
2534

2635
func TestKeypairDeps(t *testing.T) {
@@ -46,3 +55,111 @@ func TestKeypairDeps(t *testing.T) {
4655
t.Errorf("unexpected dependencies for cert: %v", deps["cert"])
4756
}
4857
}
58+
59+
// fakeKeystore is a minimal in-memory fi.Keystore for exercising CreateKeyset
60+
// without going through a real VFS-backed store.
61+
type fakeKeystore struct {
62+
keysets map[string]*fi.Keyset
63+
stored bool
64+
}
65+
66+
func (f *fakeKeystore) FindKeyset(ctx context.Context, name string) (*fi.Keyset, error) {
67+
return f.keysets[name], nil
68+
}
69+
70+
func (f *fakeKeystore) StoreKeyset(ctx context.Context, name string, keyset *fi.Keyset) error {
71+
f.stored = true
72+
if f.keysets == nil {
73+
f.keysets = map[string]*fi.Keyset{}
74+
}
75+
f.keysets[name] = keyset
76+
return nil
77+
}
78+
79+
func (f *fakeKeystore) MirrorTo(ctx context.Context, basedir vfs.Path) error {
80+
return nil
81+
}
82+
83+
var _ fi.Keystore = &fakeKeystore{}
84+
85+
// selfSignedTestCA builds a minimal self-signed CA certificate/key pair for tests.
86+
func selfSignedTestCA(t *testing.T, commonName string) (*pki.Certificate, *pki.PrivateKey) {
87+
t.Helper()
88+
89+
key, err := rsa.GenerateKey(rand.Reader, 2048)
90+
if err != nil {
91+
t.Fatalf("generating test key: %v", err)
92+
}
93+
94+
template := &x509.Certificate{
95+
SerialNumber: big.NewInt(1),
96+
Subject: pkix.Name{CommonName: commonName},
97+
NotBefore: time.Now(),
98+
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
99+
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
100+
BasicConstraintsValid: true,
101+
IsCA: true,
102+
}
103+
104+
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
105+
if err != nil {
106+
t.Fatalf("creating test certificate: %v", err)
107+
}
108+
cert, err := x509.ParseCertificate(der)
109+
if err != nil {
110+
t.Fatalf("parsing test certificate: %v", err)
111+
}
112+
113+
return &pki.Certificate{Certificate: cert}, &pki.PrivateKey{Key: key}
114+
}
115+
116+
func TestCreateKeysetRefusesToReissueExistingCA(t *testing.T) {
117+
ctx := context.Background()
118+
119+
existingCert, existingKey := selfSignedTestCA(t, "kubernetes-ca")
120+
121+
store := &fakeKeystore{
122+
keysets: map[string]*fi.Keyset{
123+
"kubernetes-ca": {
124+
Primary: &fi.KeysetItem{Id: "1", Certificate: existingCert, PrivateKey: existingKey},
125+
Items: map[string]*fi.KeysetItem{
126+
"1": {Id: "1", Certificate: existingCert, PrivateKey: existingKey},
127+
},
128+
},
129+
},
130+
}
131+
132+
_, err := CreateKeyset(ctx, store, "kubernetes-ca", pki.IssueCertRequest{
133+
Type: "ca",
134+
Subject: pkix.Name{CommonName: "kubernetes-ca"},
135+
})
136+
137+
if err == nil {
138+
t.Fatal("expected CreateKeyset to refuse reissuing an existing CA, got nil error")
139+
}
140+
if !strings.Contains(err.Error(), "refusing to create keypair") {
141+
t.Errorf("unexpected error message: %v", err)
142+
}
143+
if store.stored {
144+
t.Error("CreateKeyset must not store a new keyset when refusing to reissue an existing CA")
145+
}
146+
}
147+
148+
func TestCreateKeysetCreatesNewCAWhenNoneExists(t *testing.T) {
149+
ctx := context.Background()
150+
store := &fakeKeystore{}
151+
152+
keyset, err := CreateKeyset(ctx, store, "kubernetes-ca", pki.IssueCertRequest{
153+
Type: "ca",
154+
Subject: pkix.Name{CommonName: "kubernetes-ca"},
155+
})
156+
if err != nil {
157+
t.Fatalf("unexpected error creating a brand-new CA: %v", err)
158+
}
159+
if keyset == nil || keyset.Primary == nil || keyset.Primary.Certificate == nil {
160+
t.Fatal("expected a populated keyset for a brand-new CA")
161+
}
162+
if !store.stored {
163+
t.Error("expected CreateKeyset to store the newly created CA")
164+
}
165+
}

0 commit comments

Comments
 (0)