Skip to content

Commit

Permalink
feat: adds signing subcommands to updater
Browse files Browse the repository at this point in the history
  • Loading branch information
jmgilman committed Feb 1, 2024
1 parent f3f26ca commit 8cc56e7
Show file tree
Hide file tree
Showing 5 changed files with 264 additions and 7 deletions.
7 changes: 2 additions & 5 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ on:


jobs:
discover:
deploy:
runs-on: ubuntu-latest
outputs:
json: ${{ steps.discover.outputs.json }}
Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions tools/updater/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```
59 changes: 57 additions & 2 deletions tools/updater/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
// cspell: words afero alecthomas cuectx existingdir existingfile Timoni nolint

import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
Expand All @@ -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 {
Expand Down Expand Up @@ -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"`
Expand Down
59 changes: 59 additions & 0 deletions tools/updater/pkg/signing.go
Original file line number Diff line number Diff line change
@@ -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
}
96 changes: 96 additions & 0 deletions tools/updater/pkg/signing_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
})
})
})
})

0 comments on commit 8cc56e7

Please sign in to comment.