m/pkg/pki: refactor, allow for external certificates
The pki library supported managing certificates in two modes:
- default, when name != ""
- volatile/ephemeral, when name == ""
The difference between the two being that default certificates were
fully stored in etcd (key and x509 certificate), while volatile
certificates weren't stored at all. However, both kinds needed private
keys passed to the pki library.
We want to be able to emit certificates without having private keys for
that certificate, so we end up a third mode of operation: 'external
certificates'. These are still stored in etcd, but without any
corresponding private key.
In the future we might actually get rid of ephemeral certificates by
expanding the logic of external certificates to provide a full audit log
and revocation system, instead of matching by Certificate Name. But this
will do for now.
We also use this opportunity to write some simple tests for this
package.
Change-Id: I193f4b147273b0a3981c38d749b43362d3c1b69a
Reviewed-on: https://review.monogon.dev/c/monogon/+/263
Reviewed-by: Mateusz Zalega <mateusz@monogon.tech>
diff --git a/metropolis/pkg/pki/certificate_test.go b/metropolis/pkg/pki/certificate_test.go
new file mode 100644
index 0000000..da8dee9
--- /dev/null
+++ b/metropolis/pkg/pki/certificate_test.go
@@ -0,0 +1,172 @@
+package pki
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/x509"
+ "testing"
+
+ "go.etcd.io/etcd/integration"
+)
+
+// TestManaged ensures Managed Certificates work, including re-ensuring
+// certificates with the same data and issuing subordinate certificates.
+func TestManaged(t *testing.T) {
+ cluster := integration.NewClusterV3(nil, &integration.ClusterConfig{
+ Size: 1,
+ })
+ cl := cluster.Client(0)
+ defer cluster.Terminate(nil)
+ ctx, ctxC := context.WithCancel(context.Background())
+ defer ctxC()
+ ns := Namespaced("/test-managed/")
+
+ // Test CA certificate issuance.
+ ca := &Certificate{
+ Namespace: &ns,
+ Issuer: SelfSigned,
+ Name: "ca",
+ Template: CA("Test CA"),
+ }
+ caBytes, err := ca.Ensure(ctx, cl)
+ if err != nil {
+ t.Fatalf("Failed to Ensure CA: %v", err)
+ }
+ caCert, err := x509.ParseCertificate(caBytes)
+ if err != nil {
+ t.Fatalf("Failed to parse newly emited CA cert: %v", err)
+ }
+ if !caCert.IsCA {
+ t.Errorf("Newly emitted CA cert is not CA")
+ }
+ if ca.PublicKey == nil {
+ t.Errorf("Newly emitted CA cert has no public key")
+ }
+ if ca.PrivateKey == nil {
+ t.Errorf("Newly emitted CA cert has no public key")
+ }
+
+ // Re-emitting CA certificate with same parameters should return exact same
+ // data.
+ ca2 := &Certificate{
+ Namespace: &ns,
+ Issuer: SelfSigned,
+ Name: "ca",
+ Template: CA("Test CA"),
+ }
+ caBytes2, err := ca2.Ensure(ctx, cl)
+ if err != nil {
+ t.Fatalf("Failed to re-Ensure CA: %v", err)
+ }
+ if !bytes.Equal(caBytes, caBytes2) {
+ t.Errorf("New CA has different x509 certificate")
+ }
+ if !bytes.Equal(ca.PublicKey, ca2.PublicKey) {
+ t.Errorf("New CA has different public key")
+ }
+ if !bytes.Equal(ca.PrivateKey, ca2.PrivateKey) {
+ t.Errorf("New CA has different private key")
+ }
+
+ // Emitting a subordinate certificate should work.
+ client := &Certificate{
+ Namespace: &ns,
+ Issuer: ca2,
+ Name: "client",
+ Template: Client("foo", nil),
+ }
+ clientBytes, err := client.Ensure(ctx, cl)
+ if err != nil {
+ t.Fatalf("Failed to ensure client certificate: %v", err)
+ }
+ clientCert, err := x509.ParseCertificate(clientBytes)
+ if err != nil {
+ t.Fatalf("Failed to parse newly emitted client certificate: %v", err)
+ }
+ if clientCert.IsCA {
+ t.Errorf("New client cert is CA")
+ }
+ if want, got := "foo", clientCert.Subject.CommonName; want != got {
+ t.Errorf("New client CN should be %q, got %q", want, got)
+ }
+ if want, got := caCert.Subject.String(), clientCert.Issuer.String(); want != got {
+ t.Errorf("New client issuer should be %q, got %q", want, got)
+ }
+}
+
+// TestExternal ensures External certificates work correctly, including
+// re-Ensuring certificates with the same public key, and attempting to re-issue
+// the same certificate with a different public key (which should fail).
+func TestExternal(t *testing.T) {
+ cluster := integration.NewClusterV3(nil, &integration.ClusterConfig{
+ Size: 1,
+ })
+ cl := cluster.Client(0)
+ defer cluster.Terminate(nil)
+ ctx, ctxC := context.WithCancel(context.Background())
+ defer ctxC()
+ ns := Namespaced("/test-external/")
+
+ ca := &Certificate{
+ Namespace: &ns,
+ Issuer: SelfSigned,
+ Name: "ca",
+ Template: CA("Test CA"),
+ }
+
+ // Issuing an external certificate should work.
+ pk, _, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ server := &Certificate{
+ Namespace: &ns,
+ Issuer: ca,
+ Name: "server",
+ Template: Server([]string{"server"}, nil),
+ Mode: CertificateExternal,
+ PublicKey: pk,
+ }
+ serverBytes, err := server.Ensure(ctx, cl)
+ if err != nil {
+ t.Fatalf("Failed to Ensure server certificate: %v", err)
+ }
+
+ // Issuing an external certificate with the same name but different public key
+ // should fail.
+ pk2, _, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ server2 := &Certificate{
+ Namespace: &ns,
+ Issuer: ca,
+ Name: "server",
+ Template: Server([]string{"server"}, nil),
+ Mode: CertificateExternal,
+ PublicKey: pk2,
+ }
+ if _, err := server2.Ensure(ctx, cl); err == nil {
+ t.Fatalf("Issuing server certificate with different public key should have failed")
+ }
+
+ // Issuing the external certificate with the same name and same public key
+ // should work and yield the same x509 bytes.
+ server3 := &Certificate{
+ Namespace: &ns,
+ Issuer: ca,
+ Name: "server",
+ Template: Server([]string{"server"}, nil),
+ Mode: CertificateExternal,
+ PublicKey: pk,
+ }
+ serverBytes3, err := server3.Ensure(ctx, cl)
+ if err != nil {
+ t.Fatalf("Failed to re-Ensure server certificate: %v", err)
+ }
+ if !bytes.Equal(serverBytes, serverBytes3) {
+ t.Errorf("New server certificate has different x509 certificate")
+ }
+}