Browse Source

cmd/dist,release/dist: sign QNAP builds with a Google Cloud hosted key

QNAP now requires builds to be signed with an HSM.

This removes support for signing with a local keypair.

This adds support for signing with a Google Cloud hosted key.

The key should be an RSA key with protection level `HSM` and that uses PSS padding and a SHA256 digest.

The GCloud project, keyring and key name are passed in as command-line arguments.

The GCloud credentials and the PEM signing certificate are passed in as Base64-encoded command-line arguments.

Updates tailscale/corp#23528

Signed-off-by: Percy Wegmann <[email protected]>
Percy Wegmann 10 months ago
parent
commit
26f31f73f4

+ 17 - 8
cmd/dist/dist.go

@@ -5,11 +5,13 @@
 package main
 
 import (
+	"cmp"
 	"context"
 	"errors"
 	"flag"
 	"log"
 	"os"
+	"slices"
 
 	"tailscale.com/release/dist"
 	"tailscale.com/release/dist/cli"
@@ -19,9 +21,12 @@ import (
 )
 
 var (
-	synologyPackageCenter bool
-	qnapPrivateKeyPath    string
-	qnapCertificatePath   string
+	synologyPackageCenter   bool
+	gcloudCredentialsBase64 string
+	gcloudProject           string
+	gcloudKeyring           string
+	qnapKeyName             string
+	qnapCertificateBase64   string
 )
 
 func getTargets() ([]dist.Target, error) {
@@ -42,10 +47,11 @@ func getTargets() ([]dist.Target, error) {
 	// To build for package center, run
 	// ./tool/go run ./cmd/dist build --synology-package-center synology
 	ret = append(ret, synology.Targets(synologyPackageCenter, nil)...)
-	if (qnapPrivateKeyPath == "") != (qnapCertificatePath == "") {
-		return nil, errors.New("both --qnap-private-key-path and --qnap-certificate-path must be set")
+	qnapSigningArgs := []string{gcloudCredentialsBase64, gcloudProject, gcloudKeyring, qnapKeyName, qnapCertificateBase64}
+	if cmp.Or(qnapSigningArgs...) != "" && slices.Contains(qnapSigningArgs, "") {
+		return nil, errors.New("all of --gcloud-credentials, --gcloud-project, --gcloud-keyring, --qnap-key-name and --qnap-certificate must be set")
 	}
-	ret = append(ret, qnap.Targets(qnapPrivateKeyPath, qnapCertificatePath)...)
+	ret = append(ret, qnap.Targets(gcloudCredentialsBase64, gcloudProject, gcloudKeyring, qnapKeyName, qnapCertificateBase64)...)
 	return ret, nil
 }
 
@@ -54,8 +60,11 @@ func main() {
 	for _, subcmd := range cmd.Subcommands {
 		if subcmd.Name == "build" {
 			subcmd.FlagSet.BoolVar(&synologyPackageCenter, "synology-package-center", false, "build synology packages with extra metadata for the official package center")
-			subcmd.FlagSet.StringVar(&qnapPrivateKeyPath, "qnap-private-key-path", "", "sign qnap packages with given key (must also provide --qnap-certificate-path)")
-			subcmd.FlagSet.StringVar(&qnapCertificatePath, "qnap-certificate-path", "", "sign qnap packages with given certificate (must also provide --qnap-private-key-path)")
+			subcmd.FlagSet.StringVar(&gcloudCredentialsBase64, "gcloud-credentials", "", "base64 encoded GCP credentials (used when signing QNAP builds)")
+			subcmd.FlagSet.StringVar(&gcloudProject, "gcloud-project", "", "name of project in GCP KMS (used when signing QNAP builds)")
+			subcmd.FlagSet.StringVar(&gcloudKeyring, "gcloud-keyring", "", "path to keyring in GCP KMS (used when signing QNAP builds)")
+			subcmd.FlagSet.StringVar(&qnapKeyName, "qnap-key-name", "", "name of GCP key to use when signing QNAP builds")
+			subcmd.FlagSet.StringVar(&qnapCertificateBase64, "qnap-certificate", "", "base64 encoded certificate to use when signing QNAP builds")
 		}
 	}
 

+ 16 - 4
release/dist/qnap/files/scripts/Dockerfile.qpkg

@@ -1,9 +1,21 @@
-FROM ubuntu:20.04
+FROM ubuntu:24.04
 
 RUN apt-get update -y && \
     apt-get install -y --no-install-recommends \
     git-core \
-    ca-certificates
-RUN git clone https://github.com/qnap-dev/QDK.git
+    ca-certificates \
+    apt-transport-https \
+    gnupg \
+    curl \
+    patch
+
+# Install QNAP QDK (force a specific version to pick up updates)
+RUN git clone https://github.com/tailscale/QDK.git && cd /QDK && git reset --hard 9a31a67387c583d19a81a378dcf7c25e2abe231d
 RUN cd /QDK && ./InstallToUbuntu.sh install
-ENV PATH="/usr/share/QDK/bin:${PATH}"
+ENV PATH="/usr/share/QDK/bin:${PATH}"
+
+# Install Google Cloud PKCS11 module
+RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg
+RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list
+RUN apt-get update -y && apt-get install -y --no-install-recommends google-cloud-cli libengine-pkcs11-openssl
+RUN curl -L https://github.com/GoogleCloudPlatform/kms-integrations/releases/download/pkcs11-v1.6/libkmsp11-1.6-linux-amd64.tar.gz | tar xz

+ 40 - 0
release/dist/qnap/files/scripts/sign-qpkg.sh

@@ -0,0 +1,40 @@
+#! /usr/bin/env bash
+set -xeu
+
+mkdir -p "$HOME/.config/gcloud"
+echo "$GCLOUD_CREDENTIALS_BASE64" | base64 --decode > /root/.config/gcloud/application_default_credentials.json
+gcloud config set project "$GCLOUD_PROJECT"
+
+echo "---
+tokens:
+  - key_ring: \"$GCLOUD_KEYRING\"
+log_directory: "/tmp/kmsp11"
+" > pkcs11-config.yaml
+chmod 0600 pkcs11-config.yaml
+
+export KMS_PKCS11_CONFIG=`readlink -f pkcs11-config.yaml`
+export PKCS11_MODULE_PATH=/libkmsp11-1.6-linux-amd64/libkmsp11.so
+
+# Verify signature of pkcs11 module
+# See https://github.com/GoogleCloudPlatform/kms-integrations/blob/master/kmsp11/docs/user_guide.md#downloading-and-verifying-the-library
+echo "-----BEGIN PUBLIC KEY-----
+MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEtfLbXkHUVc9oUPTNyaEK3hIwmuGRoTtd
+6zDhwqjJuYaMwNd1aaFQLMawTwZgR0Xn27ymVWtqJHBe0FU9BPIQ+SFmKw+9jSwu
+/FuqbJnLmTnWMJ1jRCtyHNZawvv2wbiB
+-----END PUBLIC KEY-----" > pkcs11-release-signing-key.pem
+openssl dgst -sha384 -verify pkcs11-release-signing-key.pem -signature "$PKCS11_MODULE_PATH.sig" "$PKCS11_MODULE_PATH"
+
+echo "$QNAP_SIGNING_CERT_BASE64" | base64 --decode > cert.crt
+
+openssl cms \
+	-sign \
+	-binary \
+	-nodetach \
+	-engine pkcs11 \
+	-keyform engine \
+	-inkey "pkcs11:object=$QNAP_SIGNING_KEY_NAME" \
+	-keyopt rsa_padding_mode:pss \
+	-keyopt rsa_pss_saltlen:digest \
+	-signer cert.crt \
+	-in "$1" \
+	-out -

+ 25 - 29
release/dist/qnap/pkgs.go

@@ -27,8 +27,11 @@ type target struct {
 }
 
 type signer struct {
-	privateKeyPath  string
-	certificatePath string
+	gcloudCredentialsBase64 string
+	gcloudProject           string
+	gcloudKeyring           string
+	keyName                 string
+	certificateBase64       string
 }
 
 func (t *target) String() string {
@@ -66,7 +69,8 @@ func (t *target) buildQPKG(b *dist.Build, qnapBuilds *qnapBuilds, inner *innerPk
 	filename := fmt.Sprintf("Tailscale_%s-%s_%s.qpkg", b.Version.Short, qnapTag, t.arch)
 	filePath := filepath.Join(b.Out, filename)
 
-	cmd := b.Command(b.Repo, "docker", "run", "--rm",
+	args := []string{"run", "--rm",
+		"--network=host",
 		"-e", fmt.Sprintf("ARCH=%s", t.arch),
 		"-e", fmt.Sprintf("TSTAG=%s", b.Version.Short),
 		"-e", fmt.Sprintf("QNAPTAG=%s", qnapTag),
@@ -76,10 +80,28 @@ func (t *target) buildQPKG(b *dist.Build, qnapBuilds *qnapBuilds, inner *innerPk
 		"-v", fmt.Sprintf("%s:/Tailscale", filepath.Join(qnapBuilds.tmpDir, "files/Tailscale")),
 		"-v", fmt.Sprintf("%s:/build-qpkg.sh", filepath.Join(qnapBuilds.tmpDir, "files/scripts/build-qpkg.sh")),
 		"-v", fmt.Sprintf("%s:/out", b.Out),
+	}
+
+	if t.signer != nil {
+		log.Println("Will sign with Google Cloud HSM")
+		args = append(args,
+			"-e", fmt.Sprintf("GCLOUD_CREDENTIALS_BASE64=%s", t.signer.gcloudCredentialsBase64),
+			"-e", fmt.Sprintf("GCLOUD_PROJECT=%s", t.signer.gcloudProject),
+			"-e", fmt.Sprintf("GCLOUD_KEYRING=%s", t.signer.gcloudKeyring),
+			"-e", fmt.Sprintf("QNAP_SIGNING_KEY_NAME=%s", t.signer.keyName),
+			"-e", fmt.Sprintf("QNAP_SIGNING_CERT_BASE64=%s", t.signer.certificateBase64),
+			"-e", fmt.Sprintf("QNAP_SIGNING_SCRIPT=%s", "/sign-qpkg.sh"),
+			"-v", fmt.Sprintf("%s:/sign-qpkg.sh", filepath.Join(qnapBuilds.tmpDir, "files/scripts/sign-qpkg.sh")),
+		)
+	}
+
+	args = append(args,
 		"build.tailscale.io/qdk:latest",
 		"/build-qpkg.sh",
 	)
 
+	cmd := b.Command(b.Repo, "docker", args...)
+
 	// dist.Build runs target builds in parallel goroutines by default.
 	// For QNAP, this is an issue because the underlaying qbuild builder will
 	// create tmp directories in the shared docker image that end up conflicting
@@ -176,32 +198,6 @@ func newQNAPBuilds(b *dist.Build, signer *signer) (*qnapBuilds, error) {
 		return nil, err
 	}
 
-	if signer != nil {
-		log.Print("Setting up qnap signing files")
-
-		key, err := os.ReadFile(signer.privateKeyPath)
-		if err != nil {
-			return nil, err
-		}
-		cert, err := os.ReadFile(signer.certificatePath)
-		if err != nil {
-			return nil, err
-		}
-
-		// QNAP's qbuild command expects key and cert files to be in the root
-		// of the project directory (in our case release/dist/qnap/Tailscale).
-		// So here, we copy the key and cert over to the project folder for the
-		// duration of qnap package building and then delete them on close.
-
-		keyPath := filepath.Join(m.tmpDir, "files/Tailscale/private_key")
-		if err := os.WriteFile(keyPath, key, 0400); err != nil {
-			return nil, err
-		}
-		certPath := filepath.Join(m.tmpDir, "files/Tailscale/certificate")
-		if err := os.WriteFile(certPath, cert, 0400); err != nil {
-			return nil, err
-		}
-	}
 	return m, nil
 }
 

+ 21 - 6
release/dist/qnap/targets.go

@@ -3,16 +3,31 @@
 
 package qnap
 
-import "tailscale.com/release/dist"
+import (
+	"slices"
+
+	"tailscale.com/release/dist"
+)
 
 // Targets defines the dist.Targets for QNAP devices.
 //
-// If privateKeyPath and certificatePath are both provided non-empty,
-// these targets will be signed for QNAP app store release with built.
-func Targets(privateKeyPath, certificatePath string) []dist.Target {
+// If all parameters are provided non-empty, then the build will be signed using
+// a Google Cloud hosted key.
+//
+// gcloudCredentialsBase64 is the JSON credential for connecting to Google Cloud, base64 encoded.
+// gcloudKeyring is the full path to the Google Cloud keyring containing the signing key.
+// keyName is the name of the key.
+// certificateBase64 is the PEM certificate to use in the signature, base64 encoded.
+func Targets(gcloudCredentialsBase64, gcloudProject, gcloudKeyring, keyName, certificateBase64 string) []dist.Target {
 	var signerInfo *signer
-	if privateKeyPath != "" && certificatePath != "" {
-		signerInfo = &signer{privateKeyPath, certificatePath}
+	if !slices.Contains([]string{gcloudCredentialsBase64, gcloudProject, gcloudKeyring, keyName, certificateBase64}, "") {
+		signerInfo = &signer{
+			gcloudCredentialsBase64: gcloudCredentialsBase64,
+			gcloudProject:           gcloudProject,
+			gcloudKeyring:           gcloudKeyring,
+			keyName:                 keyName,
+			certificateBase64:       certificateBase64,
+		}
 	}
 	return []dist.Target{
 		&target{