m/test/swptm/certtool: init
This implements a minimal GnuTLS certtool replacement. It will be used
by swtpm_setup and friends when generating an emulated TPM certificate.
Change-Id: I7635ccdc50459fec9287ea790488e110c6ce3094
Reviewed-on: https://review.monogon.dev/c/monogon/+/3128
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/metropolis/test/swtpm/README.md b/metropolis/test/swtpm/README.md
new file mode 100644
index 0000000..e00892e
--- /dev/null
+++ b/metropolis/test/swtpm/README.md
@@ -0,0 +1,32 @@
+swtpm enhancements
+==================
+
+Metropolis uses [swtpm](https://github.com/stefanberger/swtpm) for emulating a
+TPM device when running tests in qemu, eg. end-to-end-tests.
+
+swtpm consists of a runtime emulator (`swtpm`) which runs against a state
+directory and exposes TPM functionality over the socket; and of tooling
+designed to create said state directory (`swtpm_setup`, `swtpm_localca`, etc).
+
+Getting the former to be built with Bazel is generally trivial, as it mostly
+depends on libraries we are already building (glib, openssl/boringssll, etc).
+However, the tooling is another story: it depends heavily on GnuTLS, both as a
+library to link against and as a runtime tool (`certtool`). We already have one
+C implementation of cryptographic primitives in `//third_party` (boringssl),
+dragging another one in would be shameful.
+
+The tooling is also not a single C binary, but a handful of different ones that
+call eachother based on the requested functionality (presumably as a way to
+implement modularity to allow creating swtpm secrets using a HSM, etc).
+
+This subdirectory contains bits and pieces that allow us to use the
+aforementioned tooling without depending on GnuTLS. This is done by patching
+some tools to rip out GnuTLS support, and by replacing other with native Go
+reimplementations.
+
+certtool
+--------
+
+This is a minimal GnuTLS certtool reimplementation in Go. It's used by `swtpm_localca` to generate TLS certificates. An
+alternative to this would be to rewrite `swtpm_localca` entirely to Go, but that seems like a bit too much effort for
+now.
\ No newline at end of file
diff --git a/metropolis/test/swtpm/certtool/BUILD.bazel b/metropolis/test/swtpm/certtool/BUILD.bazel
new file mode 100644
index 0000000..b017ed8
--- /dev/null
+++ b/metropolis/test/swtpm/certtool/BUILD.bazel
@@ -0,0 +1,15 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+ name = "certtool_lib",
+ srcs = ["main.go"],
+ importpath = "source.monogon.dev/metropolis/test/swtpm/certtool",
+ visibility = ["//visibility:private"],
+ deps = ["@com_github_spf13_pflag//:pflag"],
+)
+
+go_binary(
+ name = "certtool",
+ embed = [":certtool_lib"],
+ visibility = ["//visibility:public"],
+)
diff --git a/metropolis/test/swtpm/certtool/main.go b/metropolis/test/swtpm/certtool/main.go
new file mode 100644
index 0000000..5079c54
--- /dev/null
+++ b/metropolis/test/swtpm/certtool/main.go
@@ -0,0 +1,231 @@
+package main
+
+// Minimal GnuTLS certtool-like tool in Go. Implements only what's needed for
+// compatibility with `swtpm_localca`.
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+ "log"
+ "math/big"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/spf13/pflag"
+)
+
+var (
+ flagGeneratePrivkey bool
+ flagGenerateSelfSigned bool
+ flagGenerateCertificate bool
+
+ flagOutfile string
+ flagTemplate string
+ flagLoadPrivkey string
+ flagLoadCAPrivkey string
+ flagLoadCACertificate string
+)
+
+func main() {
+ pflag.BoolVar(&flagGeneratePrivkey, "generate-privkey", false, "Generate RSA private kay")
+ pflag.BoolVar(&flagGenerateSelfSigned, "generate-self-signed", false, "Generate self-signed certificate")
+ pflag.BoolVar(&flagGenerateCertificate, "generate-certificate", false, "Sign certificate")
+
+ pflag.StringVar(&flagOutfile, "outfile", "", "Output file for operation")
+ pflag.StringVar(&flagTemplate, "template", "", "Certificate template file (GnuTLS proprietary)")
+ pflag.StringVar(&flagLoadPrivkey, "load-privkey", "", "Path to private key")
+ pflag.StringVar(&flagLoadCAPrivkey, "load-ca-privkey", "", "Path to CA private key")
+ pflag.StringVar(&flagLoadCACertificate, "load-ca-certificate", "", "Path to CA certificate")
+ pflag.Parse()
+
+ modesActive := 0
+ for _, mode := range []bool{flagGeneratePrivkey, flagGenerateSelfSigned, flagGenerateCertificate} {
+ if mode {
+ modesActive++
+ }
+ }
+ if modesActive != 1 {
+ log.Fatalf("Exactly one of --generate-privkey, --generate-self-signed, --generate-certificate must be set")
+ }
+
+ if flagGeneratePrivkey || flagGenerateSelfSigned || flagGenerateCertificate {
+ if flagOutfile == "" {
+ log.Fatalf("--outfile must be set")
+ }
+ }
+ if flagGenerateSelfSigned || flagGenerateCertificate {
+ if flagTemplate == "" {
+ log.Fatalf("--template must be set")
+ }
+ if flagLoadPrivkey == "" {
+ log.Fatalf("--load-privkey must be set")
+ }
+ if flagOutfile == "" {
+ log.Fatalf("--outfile must be set")
+ }
+ }
+ if flagGenerateCertificate {
+ if flagLoadCAPrivkey == "" {
+ log.Fatalf("--load-ca-privkey must be set")
+ }
+ if flagLoadCACertificate == "" {
+ log.Fatalf("--load-ca-certificate must be set")
+ }
+ }
+ switch {
+ case flagGeneratePrivkey:
+ generatePrivkey(flagOutfile)
+ case flagGenerateSelfSigned:
+ generateSelfSigned(flagTemplate, flagLoadPrivkey, flagOutfile)
+ case flagGenerateCertificate:
+ generateCertificate(flagTemplate, flagLoadPrivkey, flagLoadCAPrivkey, flagLoadCACertificate, flagOutfile)
+ }
+}
+
+func generatePrivkey(outfile string) {
+ priv, err := rsa.GenerateKey(rand.Reader, 3072)
+ if err != nil {
+ log.Fatalf("Could not generate RSA key: %v", err)
+ }
+ block := pem.Block{
+ Type: "RSA PRIVATE KEY",
+ Bytes: x509.MarshalPKCS1PrivateKey(priv),
+ }
+ if err := os.WriteFile(outfile, pem.EncodeToMemory(&block), 0600); err != nil {
+ log.Fatalf("Could not write RSA key: %v", err)
+ }
+}
+
+// certificateFromTemplate parses a GnuTLS 'template' file. This template file is
+// made up of newline-separated stanzas, with an optional 'data' part after a =
+// character.
+//
+// Supported stanzas (on conflict last stanza wins):
+//
+// cn=data: set subject CN to data
+// ca: mark certificate as CA
+// cert_signing_key: enable keyCertSign KeyUsage
+// expiration_days: number of days in which cert expires (if -1/default, no expiry date)
+func certificateFromTemplate(template string) *x509.Certificate {
+ serial, err := rand.Int(rand.Reader, big.NewInt(1).Lsh(big.NewInt(1), 128))
+ if err != nil {
+ log.Fatalf("Could not generate serial: %v", err)
+ }
+ res := x509.Certificate{
+ SerialNumber: serial,
+ NotBefore: time.Now().Add(-time.Minute),
+ BasicConstraintsValid: true,
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+ }
+ for _, line := range strings.Split(template, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, "=", 2)
+ for i := range parts {
+ parts[i] = strings.TrimSpace(parts[i])
+ }
+ switch parts[0] {
+ case "cn":
+ res.Subject.CommonName = parts[1]
+ case "ca":
+ res.IsCA = true
+ case "cert_signing_key":
+ res.KeyUsage |= x509.KeyUsageCertSign
+ case "expiration_days":
+ days, err := strconv.ParseInt(parts[1], 10, 64)
+ if err != nil {
+ log.Fatalf("Invalid expiration_days: %q", err)
+ }
+ if days != -1 {
+ res.NotAfter = time.Now().Add(time.Hour * 24 * time.Duration(days))
+ } else {
+ res.NotAfter = time.Unix(253402300799, 0)
+ }
+ default:
+ log.Fatalf("Unhandled template line %q", line)
+ }
+ }
+ return &res
+}
+
+func readPrivkey(path string) *rsa.PrivateKey {
+ bytes, err := os.ReadFile(path)
+ if err != nil {
+ log.Fatalf("Could not read private key: %v", err)
+ }
+ block, _ := pem.Decode(bytes)
+ if block.Type != "RSA PRIVATE KEY" {
+ log.Fatalf("Private key contains invalid PEM data")
+ }
+ res, err := x509.ParsePKCS1PrivateKey(block.Bytes)
+ if err != nil {
+ log.Fatalf("Could not parse private key: %v", err)
+ }
+ return res
+}
+
+func readCertificate(path string) *x509.Certificate {
+ bytes, err := os.ReadFile(path)
+ if err != nil {
+ log.Fatalf("Could not read certificate: %v", err)
+ }
+ block, _ := pem.Decode(bytes)
+ if block.Type != "CERTIFICATE" {
+ log.Fatalf("Certificate contains invalid PEM data")
+ }
+ res, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ log.Fatalf("Could not parse certificate: %v", err)
+ }
+ return res
+}
+
+func generateSelfSigned(templatePath, privkeyPath, outfile string) {
+ template, err := os.ReadFile(templatePath)
+ if err != nil {
+ log.Fatalf("Could not read template: %v", err)
+ }
+ priv := readPrivkey(privkeyPath)
+ cert := certificateFromTemplate(string(template))
+
+ derBytes, err := x509.CreateCertificate(rand.Reader, cert, cert, priv.Public(), priv)
+ if err != nil {
+ log.Fatalf("Could not generate self-signed certificate: %v", err)
+ }
+ block := pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: derBytes,
+ }
+ if err := os.WriteFile(outfile, pem.EncodeToMemory(&block), 0600); err != nil {
+ log.Fatalf("Could not write self-signed certificate: %v", err)
+ }
+}
+
+func generateCertificate(templatePath, privkeyPath, caPrivkeyPath, caCertificatePath, outfile string) {
+ template, err := os.ReadFile(templatePath)
+ if err != nil {
+ log.Fatalf("Could not read template: %v", err)
+ }
+ priv := readPrivkey(privkeyPath)
+ caPriv := readPrivkey(caPrivkeyPath)
+ cert := certificateFromTemplate(string(template))
+ ca := readCertificate(caCertificatePath)
+
+ derBytes, err := x509.CreateCertificate(rand.Reader, cert, ca, priv.Public(), caPriv)
+ if err != nil {
+ log.Fatalf("Could not generate certificate: %v", err)
+ }
+ block := pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: derBytes,
+ }
+ if err := os.WriteFile(outfile, pem.EncodeToMemory(&block), 0600); err != nil {
+ log.Fatalf("Could not write certificate: %v", err)
+ }
+}