diff --git a/cmd/kubeadm/app/util/BUILD b/cmd/kubeadm/app/util/BUILD index 2b3d9eabe1d..9cda2c92684 100644 --- a/cmd/kubeadm/app/util/BUILD +++ b/cmd/kubeadm/app/util/BUILD @@ -55,6 +55,7 @@ filegroup( "//cmd/kubeadm/app/util/apiclient:all-srcs", "//cmd/kubeadm/app/util/config:all-srcs", "//cmd/kubeadm/app/util/kubeconfig:all-srcs", + "//cmd/kubeadm/app/util/pubkeypin:all-srcs", "//cmd/kubeadm/app/util/token:all-srcs", ], tags = ["automanaged"], diff --git a/cmd/kubeadm/app/util/pubkeypin/BUILD b/cmd/kubeadm/app/util/pubkeypin/BUILD new file mode 100644 index 00000000000..195b85c9287 --- /dev/null +++ b/cmd/kubeadm/app/util/pubkeypin/BUILD @@ -0,0 +1,35 @@ +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +load( + "@io_bazel_rules_go//go:def.bzl", + "go_library", + "go_test", +) + +go_test( + name = "go_default_test", + srcs = ["pubkeypin_test.go"], + library = ":go_default_library", + tags = ["automanaged"], +) + +go_library( + name = "go_default_library", + srcs = ["pubkeypin.go"], + tags = ["automanaged"], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], +) diff --git a/cmd/kubeadm/app/util/pubkeypin/pubkeypin.go b/cmd/kubeadm/app/util/pubkeypin/pubkeypin.go new file mode 100644 index 00000000000..1766e957555 --- /dev/null +++ b/cmd/kubeadm/app/util/pubkeypin/pubkeypin.go @@ -0,0 +1,108 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package pubkeypin provides primitives for x509 public key pinning in the +// style of RFC7469. +package pubkeypin + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "fmt" + "strings" +) + +const ( + // formatSHA256 is the prefix for pins that are full-length SHA-256 hashes encoded in base 16 (hex) + formatSHA256 = "sha256" +) + +// Set is a set of pinned x509 public keys. +type Set struct { + sha256Hashes map[string]bool +} + +// NewSet returns a new, empty PubKeyPinSet +func NewSet() *Set { + return &Set{make(map[string]bool)} +} + +// Allow adds an allowed public key hash to the Set +func (s *Set) Allow(pubKeyHashes ...string) error { + for _, pubKeyHash := range pubKeyHashes { + parts := strings.Split(pubKeyHash, ":") + if len(parts) != 2 { + return fmt.Errorf("invalid public key hash, expected \"format:value\"") + } + format, value := parts[0], parts[1] + + switch strings.ToLower(format) { + case "sha256": + return s.allowSHA256(value) + default: + return fmt.Errorf("unknown hash format %q", format) + } + } + return nil +} + +// Check if a certificate matches one of the public keys in the set +func (s *Set) Check(certificate *x509.Certificate) error { + if s.checkSHA256(certificate) { + return nil + } + return fmt.Errorf("public key %s not pinned", Hash(certificate)) +} + +// Empty returns true if the Set contains no pinned public keys. +func (s *Set) Empty() bool { + return len(s.sha256Hashes) == 0 +} + +// Hash calculates the SHA-256 hash of the Subject Public Key Information (SPKI) +// object in an x509 certificate (in DER encoding). It returns the full hash as a +// hex encoded string (suitable for passing to Set.Allow). +func Hash(certificate *x509.Certificate) string { + spkiHash := sha256.Sum256(certificate.RawSubjectPublicKeyInfo) + return formatSHA256 + ":" + strings.ToLower(hex.EncodeToString(spkiHash[:])) +} + +// allowSHA256 validates a "sha256" format hash and adds a canonical version of it into the Set +func (s *Set) allowSHA256(hash string) error { + // validate that the hash is the right length to be a full SHA-256 hash + hashLength := hex.DecodedLen(len(hash)) + if hashLength != sha256.Size { + return fmt.Errorf("expected a %d byte SHA-256 hash, found %d bytes", sha256.Size, hashLength) + } + + // validate that the hash is valid hex + _, err := hex.DecodeString(hash) + if err != nil { + return err + } + + // in the end, just store the original hex string in memory (in lowercase) + s.sha256Hashes[strings.ToLower(hash)] = true + return nil +} + +// checkSHA256 returns true if the certificate's "sha256" hash is pinned in the Set +func (s *Set) checkSHA256(certificate *x509.Certificate) bool { + actualHash := sha256.Sum256(certificate.RawSubjectPublicKeyInfo) + actualHashHex := strings.ToLower(hex.EncodeToString(actualHash[:])) + return s.sha256Hashes[actualHashHex] +} diff --git a/cmd/kubeadm/app/util/pubkeypin/pubkeypin_test.go b/cmd/kubeadm/app/util/pubkeypin/pubkeypin_test.go new file mode 100644 index 00000000000..4e578a4bdb9 --- /dev/null +++ b/cmd/kubeadm/app/util/pubkeypin/pubkeypin_test.go @@ -0,0 +1,158 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pubkeypin + +import ( + "crypto/x509" + "encoding/pem" + "strings" + "testing" +) + +// testCertPEM is a simple self-signed test certificate issued with the openssl CLI: +// openssl req -new -newkey rsa:2048 -days 36500 -nodes -x509 -keyout /dev/null -out test.crt +const testCertPEM = ` +-----BEGIN CERTIFICATE----- +MIIDRDCCAiygAwIBAgIJAJgVaCXvC6HkMA0GCSqGSIb3DQEBBQUAMB8xHTAbBgNV +BAMTFGt1YmVhZG0ta2V5cGlucy10ZXN0MCAXDTE3MDcwNTE3NDMxMFoYDzIxMTcw +NjExMTc0MzEwWjAfMR0wGwYDVQQDExRrdWJlYWRtLWtleXBpbnMtdGVzdDCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK0ba8mHU9UtYlzM1Own2Fk/XGjR +J4uJQvSeGLtz1hID1IA0dLwruvgLCPadXEOw/f/IWIWcmT+ZmvIHZKa/woq2iHi5 ++HLhXs7aG4tjKGLYhag1hLjBI7icqV7ovkjdGAt9pWkxEzhIYClFMXDjKpMSynu+ +YX6nZ9tic1cOkHmx2yiZdMkuriRQnpTOa7bb03OC1VfGl7gHlOAIYaj4539WCOr8 ++ACTUMJUFEHcRZ2o8a/v6F9GMK+7SC8SJUI+GuroXqlMAdhEv4lX5Co52enYaClN ++D9FJLRpBv2YfiCQdJRaiTvCBSxEFz6BN+PtP5l2Hs703ZWEkOqCByM6HV8CAwEA +AaOBgDB+MB0GA1UdDgQWBBRQgUX8MhK2rWBWQiPHWcKzoWDH5DBPBgNVHSMESDBG +gBRQgUX8MhK2rWBWQiPHWcKzoWDH5KEjpCEwHzEdMBsGA1UEAxMUa3ViZWFkbS1r +ZXlwaW5zLXRlc3SCCQCYFWgl7wuh5DAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEB +BQUAA4IBAQCaAUif7Pfx3X0F08cxhx8/Hdx4jcJw6MCq6iq6rsXM32ge43t8OHKC +pJW08dk58a3O1YQSMMvD6GJDAiAfXzfwcwY6j258b1ZlI9Ag0VokvhMl/XfdCsdh +AWImnL1t4hvU5jLaImUUMlYxMcSfHBGAm7WJIZ2LdEfg6YWfZh+WGbg1W7uxLxk6 +y4h5rWdNnzBHWAGf7zJ0oEDV6W6RSwNXtC0JNnLaeIUm/6xdSddJlQPwUv8YH4jX +c1vuFqTnJBPcb7W//R/GI2Paicm1cmns9NLnPR35exHxFTy+D1yxmGokpoPMdife +aH+sfuxT8xeTPb3kjzF9eJTlnEquUDLM +-----END CERTIFICATE-----` + +// expectedHash can be verified using the openssl CLI: +// openssl x509 -pubkey -in test.crt openssl rsa -pubin -outform der 2>&/dev/null | openssl dgst -sha256 -hex +const expectedHash = `sha256:345959acb2c3b2feb87d281961c893f62a314207ef02599f1cc4a5fb255480b3` + +// testCert2PEM is a second test cert generated the same way as testCertPEM +const testCert2PEM = ` +-----BEGIN CERTIFICATE----- +MIID9jCCAt6gAwIBAgIJAN5MXZDic7qYMA0GCSqGSIb3DQEBBQUAMFkxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMTCXRlc3RDZXJ0MjAgFw0xNzA3MjQxNjA0 +MDFaGA8yMTE3MDYzMDE2MDQwMVowWTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNv +bWUtU3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDESMBAG +A1UEAxMJdGVzdENlcnQyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +0brwpJYN2ytPWzRBtZSVc3dhkQlA59AzxzqeLLkano0Pxo9NIc3T/y58nnRI8uaS +I1P7BzUfJTiUEvmAtX8NggqKK4ld/gPrU+IRww1CUYS4KCkA/0d0ctPy0JwBCjD+ +b57G3rmNE8c+0jns6J96ZzNtqmv6N+ZlFBAXm1p4S+k0kGi5+hoQ6H7SYXjk2lG+ +r/8jPQEjy/NSdw1dcCA0Nc6o+hPr32927dS6J9KOhBeXNYUNdbuDDmroM9/gN2e/ +YMSA1olLeDPQ7Xvhk0PIyEDnHh83AffPCx5yM3htVRGddjIsPAVUJEL3z5leJtxe +fzyPghOhHJY0PXqznDQTcwIDAQABo4G+MIG7MB0GA1UdDgQWBBRP0IJqv/5rQ4Uf +SByl77dJeEapRDCBiwYDVR0jBIGDMIGAgBRP0IJqv/5rQ4UfSByl77dJeEapRKFd +pFswWTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNVBAoT +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDESMBAGA1UEAxMJdGVzdENlcnQyggkA +3kxdkOJzupgwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEA0RIMHc10 +wHHPMh9UflqBgDMF7gfbOL0juJfGloAOcohWWfMZBBJ0CQKMy3xRyoK3HmbW1eeb +iATjesw7t4VEAwf7mgKAd+eTfWYB952uq5qYJ2TI28mSofEq1Wz3RmrNkC1KCBs1 +u+YMFGwyl6necV9zKCeiju4jeovI1GA38TvH7MgYln6vMJ+FbgOXj7XCpek7dQiY +KGaeSSH218mGNQaWRQw2Sm3W6cFdANoCJUph4w18s7gjtFpfV63s80hXRps+vEyv +jEQMEQpG8Ss7HGJLGLBw/xAmG0e//XS/o2dDonbGbvzToFByz8OGxjMhk6yV6hdd ++iyvsLAw/MYMSA== +-----END CERTIFICATE----- +` + +// testCert is a small helper to get a test x509.Certificate from the PEM constants +func testCert(t *testing.T, pemString string) *x509.Certificate { + // Decode the example certificate from a PEM file into a PEM block + pemBlock, _ := pem.Decode([]byte(pemString)) + if pemBlock == nil { + t.Fatal("failed to parse test certificate PEM") + return nil + } + + // Parse the PEM block into an x509.Certificate + result, err := x509.ParseCertificate(pemBlock.Bytes) + if err != nil { + t.Fatalf("failed to parse test certificate: %v", err) + return nil + } + return result +} + +func TestSet(t *testing.T) { + s := NewSet() + if !s.Empty() { + t.Error("expected a new set to be empty") + return + } + err := s.Allow("xyz") + if err == nil || !s.Empty() { + t.Error("expected allowing junk to fail") + return + } + + err = s.Allow("0011223344") + if err == nil || !s.Empty() { + t.Error("expected allowing something too short to fail") + return + } + + err = s.Allow(expectedHash + expectedHash) + if err == nil || !s.Empty() { + t.Error("expected allowing something too long to fail") + return + } + + err = s.Check(testCert(t, testCertPEM)) + if err == nil { + t.Error("expected test cert to not be allowed (yet)") + return + } + + err = s.Allow(strings.ToUpper(expectedHash)) + if err != nil || s.Empty() { + t.Error("expected allowing uppercase expectedHash to succeed") + return + } + + err = s.Check(testCert(t, testCertPEM)) + if err != nil { + t.Errorf("expected test cert to be allowed, but got back: %v", err) + return + } + + err = s.Check(testCert(t, testCert2PEM)) + if err == nil { + t.Error("expected the second test cert to be disallowed") + return + } +} + +func TestHash(t *testing.T) { + actualHash := Hash(testCert(t, testCertPEM)) + if actualHash != expectedHash { + t.Errorf( + "failed to Hash() to the expected value\n\texpected: %q\n\t actual: %q", + expectedHash, + actualHash, + ) + } +}