From 26f31f73f4919b77e72cbe9dd62a91b5f6e36e43 Mon Sep 17 00:00:00 2001 From: Percy Wegmann Date: Tue, 15 Apr 2025 11:50:39 -0500 Subject: [PATCH] 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 --- cmd/dist/dist.go | 25 ++++++--- .../dist/qnap/files/scripts/Dockerfile.qpkg | 20 +++++-- release/dist/qnap/files/scripts/sign-qpkg.sh | 40 ++++++++++++++ release/dist/qnap/pkgs.go | 54 +++++++++---------- release/dist/qnap/targets.go | 27 +++++++--- 5 files changed, 119 insertions(+), 47 deletions(-) create mode 100755 release/dist/qnap/files/scripts/sign-qpkg.sh diff --git a/cmd/dist/dist.go b/cmd/dist/dist.go index 05f5bbfb2..038ced708 100644 --- a/cmd/dist/dist.go +++ b/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") } } diff --git a/release/dist/qnap/files/scripts/Dockerfile.qpkg b/release/dist/qnap/files/scripts/Dockerfile.qpkg index 135d5d20f..1f4c2406d 100644 --- a/release/dist/qnap/files/scripts/Dockerfile.qpkg +++ b/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}" \ No newline at end of file +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 diff --git a/release/dist/qnap/files/scripts/sign-qpkg.sh b/release/dist/qnap/files/scripts/sign-qpkg.sh new file mode 100755 index 000000000..5629672f8 --- /dev/null +++ b/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 - diff --git a/release/dist/qnap/pkgs.go b/release/dist/qnap/pkgs.go index 9df649ddb..7dc3b9495 100644 --- a/release/dist/qnap/pkgs.go +++ b/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 } diff --git a/release/dist/qnap/targets.go b/release/dist/qnap/targets.go index a069dd623..1c1818a70 100644 --- a/release/dist/qnap/targets.go +++ b/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{