diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f6e3c838e..4975f0b50 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -60,7 +60,7 @@ on: jobs: - discover: + deploy: runs-on: ubuntu-latest outputs: json: ${{ steps.discover.outputs.json }} @@ -93,17 +93,14 @@ jobs: with: repository: ${{ inputs.deployment_repo }} token: ${{ secrets.token }} - - name: Apply updates + - name: Update deployments run: | echo '${{ steps.discover.outputs.json }}' > /tmp/updates.json updater update deployments \ -e "${{ inputs.environment }}" \ -i /tmp/updates.json \ "${{inputs.deployment_root_path}}" - - name: Format changes - run: | cue fmt --simplify $(git diff --name-only) - cat $(git diff --name-only) - name: Run diff run: git --no-pager diff # - name: Commit and push diff --git a/tools/updater/README.md b/tools/updater/README.md index d7b0984b4..a2b084d4b 100644 --- a/tools/updater/README.md +++ b/tools/updater/README.md @@ -180,3 +180,53 @@ Prior to updating the bundle files, the `updater` CLI will replace all occurrenc This allows dynamically updating the image tag of the application deployment at runtime if you tag images using the commit SHA [timoni]: https://timoni.sh/ + +### Signing Deployments + +The `updater` CLI provides some basic functionality for signing and verifying messages using an RSA private/public key pair. +The primary use case for this functionality is for submitting "signed" PRs to a given deployment repository. +To fully understand the use case, some additional context is required. + +#### GitOps Deployments + +In a typical GitOps repository with branch-protection enabled, it's not possible to commit directly against the default branch. +This complicates the auto-deployment process as the CI/CD system has no automated way with which to apply updates to the repository. +It's possible to work around this limitation by having the CI/CD system submit a PR to the repository and then have that PR +automatically merged via automation in the GitOps repository. + +However, this introduces a serious problem: how does the GitOps repository know which PRs to automatically merge and which to +ignore? +To provide security, it's possible to include a signed message within the body of the PR. +As long as the GitOps repository has the necessary public key, it can validate the signature in the PR body and "prove" that the PR +originated from a trusted system. +Furthermore, to prevent abuse, the CI/CD system signs the commit hash of the PR. +This prevents someone from just copying a prior signature and re-using it, as commit hashes are unique across commits. + +With this in place, it's possible to verify a given PR is safe to automatically merge. +The CI/CD system submits the PR with the signed commit hash and the GitOps repository validates the signed commit hash prior to +merging the PR. + +#### Setup + +Prior to using the functionality, you must first establish an RSA private/public key pair. +This can be done using openssl: + +```shell +# Generate the private key +openssl genpkey -algorithm RSA -out rsa_private.pem -pkeyopt rsa_keygen_bits:2048 + +# Extract the public key +openssl rsa -pubout -in rsa_private.pem -out rsa_public.pem +``` + +#### Sign and verify + +To sign and verify a message: + +```shell +# Sign the commit hash (assuming it's in $COMMIT_HASH) +SIG=$(updater signing sign -k rsa_private.pem "$COMMIT_HASH") + +# Verify the commit hash (should produce: "Signature is valid") +updater signing verify -k rsa_public.pem "$COMMIT_HASH" "$SIG" +``` \ No newline at end of file diff --git a/tools/updater/cmd/main.go b/tools/updater/cmd/main.go index b3d935037..1c8920cf3 100644 --- a/tools/updater/cmd/main.go +++ b/tools/updater/cmd/main.go @@ -3,6 +3,7 @@ package main // cspell: words afero alecthomas cuectx existingdir existingfile Timoni nolint import ( + "encoding/base64" "encoding/json" "fmt" "io" @@ -16,8 +17,14 @@ import ( ) var cli struct { - Scan scanCmd `cmd:"" help:"Scans a directory for deployment files."` - Update updateCmd `cmd:"" help:"Overrides a target path in a CUE file with the given value."` + Scan scanCmd `cmd:"" help:"Scans a directory for deployment files."` + Signing signingCmd `cmd:"" help:"Commands for signing and verifying deployments."` + Update updateCmd `cmd:"" help:"Overrides a target path in a CUE file with the given value."` +} + +type signingCmd struct { + Sign signCmd `cmd:"" help:"Signs a message using a private key."` + Verify verifyCmd `cmd:"" help:"Verifies a message using a public key."` } type updateCmd struct { @@ -57,6 +64,54 @@ func (c *scanCmd) Run() error { return nil } +type signCmd struct { + PrivateKey string `short:"k" help:"The path to the private key file." required:"true"` + Message string `arg:"" help:"The message to sign." required:"true"` +} + +func (c *signCmd) Run() error { + keyContent, err := os.ReadFile(c.PrivateKey) + if err != nil { + return fmt.Errorf("failed to read private key file %q: %v", c.PrivateKey, err) + } + + signature, err := pkg.Sign(keyContent, c.Message) + if err != nil { + return fmt.Errorf("failed to sign message: %v", err) + } + + encodedSignature := base64.StdEncoding.EncodeToString(signature) + fmt.Println(encodedSignature) + + return nil +} + +type verifyCmd struct { + PublicKey string `short:"k" help:"The path to the public key file." required:"true"` + Message string `arg:"" help:"The message to verify." required:"true"` + Signature string `arg:"" help:"The signature to verify." required:"true"` +} + +func (c *verifyCmd) Run() error { + keyContent, err := os.ReadFile(c.PublicKey) + if err != nil { + return fmt.Errorf("failed to read public key file %q: %v", c.PublicKey, err) + } + + signature, err := base64.StdEncoding.DecodeString(c.Signature) + if err != nil { + return fmt.Errorf("failed to decode signature: %v", err) + } + + if err := pkg.Verify(keyContent, c.Message, signature); err != nil { + return fmt.Errorf("failed to verify signature: %v", err) + } + + fmt.Println("Signature is valid") + + return nil +} + type updateBundleCmd struct { BundleFile string `type:"existingfile" short:"f" help:"Path to the bundle file to update." required:"true"` Instance string `short:"i" help:"The instance to update." required:"true"` diff --git a/tools/updater/pkg/signing.go b/tools/updater/pkg/signing.go new file mode 100644 index 000000000..d11b88076 --- /dev/null +++ b/tools/updater/pkg/signing.go @@ -0,0 +1,59 @@ +package pkg + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "fmt" +) + +// Sign signs a message using a private key. +func Sign(privateKey []byte, message string) ([]byte, error) { + block, _ := pem.Decode(privateKey) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block containing private key") + } + + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %v", err) + } + + hasher := sha256.New() + hasher.Write([]byte(message)) + hashed := hasher.Sum(nil) + + signature, err := rsa.SignPKCS1v15(rand.Reader, key.(*rsa.PrivateKey), crypto.SHA256, hashed) + if err != nil { + return nil, fmt.Errorf("failed to sign message: %v", err) + } + + return signature, nil +} + +// Verify verifies a message using a public key and a signature. +func Verify(publicKey []byte, message string, signature []byte) error { + block, _ := pem.Decode(publicKey) + if block == nil { + return fmt.Errorf("failed to decode PEM block containing public key") + } + + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse public key: %v", err) + } + + hasher := sha256.New() + hasher.Write([]byte(message)) + hashed := hasher.Sum(nil) + + err = rsa.VerifyPKCS1v15(key.(*rsa.PublicKey), crypto.SHA256, hashed, signature) + if err != nil { + return fmt.Errorf("failed to verify signature: %v", err) + } + + return nil +} diff --git a/tools/updater/pkg/signing_test.go b/tools/updater/pkg/signing_test.go new file mode 100644 index 000000000..d3f7266f6 --- /dev/null +++ b/tools/updater/pkg/signing_test.go @@ -0,0 +1,96 @@ +package pkg_test + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + + "github.com/input-output-hk/catalyst-ci/tools/updater/pkg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Signing", func() { + var privKey []byte + var pubKey []byte + + BeforeEach(func() { + privateKeyRaw, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).ToNot(HaveOccurred()) + + privateKeyDer, err := x509.MarshalPKCS8PrivateKey(privateKeyRaw) + Expect(err).ToNot(HaveOccurred()) + privateKeyBlock := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privateKeyDer, + } + privKey = pem.EncodeToMemory(privateKeyBlock) + + publicKey := &privateKeyRaw.PublicKey + publicKeyDer, err := x509.MarshalPKIXPublicKey(publicKey) + Expect(err).ToNot(HaveOccurred()) + + publicKeyBlock := &pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyDer, + } + pubKey = pem.EncodeToMemory(publicKeyBlock) + }) + + Describe("Sign", func() { + When("signing a message", func() { + When("The private key is valid", func() { + It("returns a signature", func() { + signature, err := pkg.Sign(privKey, "message") + Expect(err).ToNot(HaveOccurred()) + Expect(signature).ToNot(BeNil()) + }) + }) + + When("The private key is invalid", func() { + It("returns an error", func() { + signature, err := pkg.Sign([]byte("invalid"), "message") + Expect(err).To(HaveOccurred()) + Expect(signature).To(BeNil()) + }) + }) + }) + }) + + Describe("Verify", func() { + When("verifying a message", func() { + var message string + var signature []byte + + BeforeEach(func() { + message = "message" + + var err error + signature, err = pkg.Sign(privKey, message) + Expect(err).ToNot(HaveOccurred()) + }) + + When("The public key and signature are valid", func() { + It("returns nil", func() { + err := pkg.Verify(pubKey, message, signature) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + When("The public key is invalid", func() { + It("returns an error", func() { + err := pkg.Verify([]byte("invalid"), message, signature) + Expect(err).To(HaveOccurred()) + }) + }) + + When("The signature is invalid", func() { + It("returns an error", func() { + err := pkg.Verify(pubKey, message, []byte("invalid")) + Expect(err).To(HaveOccurred()) + }) + }) + }) + }) +})