Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add utility for generating signing and verification keys #2415

Merged
merged 5 commits into from
Oct 9, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions clicommand/commands.go
Original file line number Diff line number Diff line change
@@ -77,4 +77,11 @@ var BuildkiteAgentCommands = []cli.Command{
},
},
BootstrapCommand,
{
Name: "tool",
Usage: "Utility commands, intended for users and operators of the agent to run directly on their machines, and not as part of a Buildkite job",
Subcommands: []cli.Command{
KeygenCommand,
},
},
}
3 changes: 3 additions & 0 deletions clicommand/config_completeness_test.go
Original file line number Diff line number Diff line change
@@ -40,9 +40,12 @@ var commandConfigPairs = []configCommandPair{
{Config: PipelineUploadConfig{}, Command: PipelineUploadCommand},
{Config: StepGetConfig{}, Command: StepGetCommand},
{Config: StepUpdateConfig{}, Command: StepUpdateCommand},
{Config: KeygenConfig{}, Command: KeygenCommand},
}

func TestAllCommandConfigStructsHaveCorrespondingCLIFlags(t *testing.T) {
t.Parallel()

for _, pair := range commandConfigPairs {
flagNames := make(map[string]struct{}, len(pair.Command.Flags))
for _, flag := range pair.Command.Flags {
133 changes: 133 additions & 0 deletions clicommand/tool_keygen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package clicommand

import (
"context"
"encoding/json"
"fmt"
"os"

"github.com/buildkite/agent/v3/internal/jwkutil"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/urfave/cli"
"golang.org/x/exp/slices"
)

type KeygenConfig struct {
Alg string `cli:"alg" validate:"required"`
KeyID string `cli:"key-id" validate:"required"`
PrivateKeySetFilename string `cli:"private-keyset-filename" normalize:"filepath"`
PublicKeysetFilename string `cli:"public-keyset-filename" normalize:"filepath"`

NoColor bool `cli:"no-color"`
Debug bool `cli:"debug"`
LogLevel string `cli:"log-level"`
Experiments []string `cli:"experiment"`
Profile string `cli:"profile"`
}

// TODO: Add docs link when there is one.
var KeygenCommand = cli.Command{
Name: "keygen",
Usage: "Generate a new JWS key pair, used for signing and verifying jobs in Buildkite",
Description: `Usage:
buildkite-agent tool keygen [options...]
Description:
This (experimental!) command generates a new JWS key pair, used for signing and verifying jobs in Buildkite.
The key pair is written to two files, a private keyset and a public keyset. The private keyset should be used
as for signing, and the public for verification. The keysets are written in JWKS format.
For more information about JWS, see https://tools.ietf.org/html/rfc7515 and for information about JWKS, see https://tools.ietf.org/html/rfc7517`,
Flags: []cli.Flag{
cli.StringFlag{
Name: "alg",
EnvVar: "BUILDKITE_AGENT_KEYGEN_ALG",
Usage: fmt.Sprintf("The JWS signing algorithm to use for the key pair. Valid algorithms are: %v", ValidSigningAlgorithms),
},
cli.StringFlag{
Name: "key-id",
EnvVar: "BUILDKITE_AGENT_KEYGEN_KEY_ID",
Usage: "The ID to use for the keys generated",
},
cli.StringFlag{
Name: "private-keyset-filename",
EnvVar: "BUILDKITE_AGENT_KEYGEN_PRIVATE_KEY_FILENAME",
Usage: "The filename to write the private key to. Defaults to a name based on the key id in the current directory",
},
cli.StringFlag{
Name: "public-keyset-filename",
EnvVar: "BUILDKITE_AGENT_KEYGEN_PUBLIC_KEYSET_FILENAME",
Usage: "The filename to write the public keyset to. Defaults to a name based on the key id in the current directory",
},

// Global flags
NoColorFlag,
DebugFlag,
LogLevelFlag,
ExperimentsFlag,
ProfileFlag,
},
Action: func(c *cli.Context) {
_, cfg, l, _, done := setupLoggerAndConfig[KeygenConfig](context.Background(), c)
defer done()

l.Warn("Pipeline signing is experimental and the user interface might change! Also it might not work, it might sign the pipeline only partially, or it might eat your pet dog. You have been warned!")

sigAlg := jwa.SignatureAlgorithm(cfg.Alg)

if !slices.Contains(ValidSigningAlgorithms, sigAlg) {
l.Fatal("Invalid signing algorithm: %s. Valid signing algorithms are: %s", cfg.Alg, ValidSigningAlgorithms)
}

priv, pub, err := jwkutil.NewKeyPair(cfg.KeyID, sigAlg)
if err != nil {
l.Fatal("Failed to generate key pair: %v", err)
}

if cfg.PrivateKeySetFilename == "" {
cfg.PrivateKeySetFilename = fmt.Sprintf("./%s-%s-private.json", cfg.Alg, cfg.KeyID)
}

if cfg.PublicKeysetFilename == "" {
cfg.PublicKeysetFilename = fmt.Sprintf("./%s-%s-public.json", cfg.Alg, cfg.KeyID)
}

l.Info("Writing private key set to %s...", cfg.PrivateKeySetFilename)
pKey, err := json.Marshal(priv)
if err != nil {
l.Fatal("Failed to marshal private key: %v", err)
}

err = writeIfNotExists(cfg.PrivateKeySetFilename, pKey)
if err != nil {
l.Fatal("Failed to write private key file: %v", err)
}

l.Info("Writing public key set to %s...", cfg.PublicKeysetFilename)
pubKey, err := json.Marshal(pub)
if err != nil {
l.Fatal("Failed to marshal private key: %v", err)
}

err = writeIfNotExists(cfg.PublicKeysetFilename, pubKey)
if err != nil {
l.Fatal("Failed to write private key file: %v", err)
}

l.Info("Done! Enjoy your new keys ^_^")

if slices.Contains(ValidOctetAlgorithms, sigAlg) {
l.Info("Note: Because you're using the %s algorithm, which is symmetric, the public and private keys are identical", sigAlg)
}
},
}

func writeIfNotExists(filename string, data []byte) error {
if _, err := os.Stat(filename); err == nil {
return fmt.Errorf("file %s already exists", filename)
}

return os.WriteFile(filename, data, 0o600)
}
152 changes: 152 additions & 0 deletions internal/jwkutil/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package jwkutil

import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"fmt"

"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
)

const symmetricKeyLength = 2048

func NewKeyPair(keyID string, alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) {
switch alg {
case jwa.HS256, jwa.HS384, jwa.HS512:
key := make([]byte, symmetricKeyLength)
_, err := rand.Read(key)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate symmetric key: reading from crypto/rand: %w", err)
}

return newSymmetricKeyPair(keyID, key, alg)

case jwa.ES256, jwa.ES384, jwa.ES512:
// There's a helper function for this in jwx, jws.CurveForAlgorithm, but it requires a bunch of type asserting back and forth
// Not really worth the trouble for a single switch statement
var crv elliptic.Curve
switch alg {
case jwa.ES256:
crv = elliptic.P256()
case jwa.ES384:
crv = elliptic.P384()
case jwa.ES512:
crv = elliptic.P521()
default:
panic("unreachable")
}

return newECKeyPair(keyID, alg, crv)

case jwa.PS256, jwa.PS384, jwa.PS512:
return newRSAKeyPair(keyID, alg)

case jwa.EdDSA:
return newEdwardsKeyPair(keyID, alg)

default:
return nil, nil, fmt.Errorf("unsupported algorithm: %s", alg)
}
}

func NewSymmetricKeyPairFromString(id, key string, alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) {
return newSymmetricKeyPair(id, []byte(key), alg)
}

func newSymmetricKeyPair(id string, key []byte, alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) {
skey, err := jwk.FromRaw(key)
if err != nil {
return nil, nil, fmt.Errorf("failed to create symmetric key: %s", err)
}

err = setAll(skey, map[string]interface{}{
jwk.AlgorithmKey: alg,
jwk.KeyUsageKey: jwk.ForSignature,
jwk.KeyIDKey: id,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to set key attributes: %s", err)
}

set := jwk.NewSet()
if err := set.AddKey(skey); err != nil {
return nil, nil, fmt.Errorf("failed to add key to set: %s", err)
}

return set, set, err
}

func newRSAKeyPair(id string, alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate RSA private key: %s", err)
}

return newKeyPair(id, alg, priv)
}

func newECKeyPair(id string, alg jwa.SignatureAlgorithm, crv elliptic.Curve) (jwk.Set, jwk.Set, error) {

priv, err := ecdsa.GenerateKey(crv, rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate EC private key: %s", err)
}

return newKeyPair(id, alg, priv)
}

func newEdwardsKeyPair(id string, alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate Edwards private key: %s", err)
}

return newKeyPair(id, alg, priv)
}

func newKeyPair(id string, alg jwa.SignatureAlgorithm, privKey any) (jwk.Set, jwk.Set, error) {
privJWK, err := jwk.FromRaw(privKey)
if err != nil {
return nil, nil, fmt.Errorf("jwk.FromRaw(%v) error = %v", privKey, err)
}

err = setAll(privJWK, map[string]interface{}{
jwk.AlgorithmKey: alg,
jwk.KeyIDKey: id,
jwk.KeyUsageKey: jwk.ForSignature,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to set key attributes: %s", err)
}

pubJWK, err := jwk.PublicKeyOf(privJWK)
if err != nil {
return nil, nil, fmt.Errorf("jwk.PublicKeyOf(%v) error = %v", privJWK, err)
}

pubSet := jwk.NewSet()
if err := pubSet.AddKey(pubJWK); err != nil {
return nil, nil, fmt.Errorf("failed to add public key to set: %s", err)
}

privSet := jwk.NewSet()
if err := privSet.AddKey(privJWK); err != nil {
return nil, nil, fmt.Errorf("failed to add private key to set: %s", err)
}

return privSet, pubSet, nil
}

func setAll(key jwk.Key, values map[string]interface{}) error {
for k, v := range values {
if err := key.Set(k, v); err != nil {
return fmt.Errorf("failed to set %s: %s", k, err)
}
}

return nil
}
127 changes: 95 additions & 32 deletions internal/pipeline/sign_test.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
package pipeline

import (
"crypto/elliptic"
"errors"
"fmt"
"math/rand"
"strings"
"testing"

"github.com/buildkite/agent/v3/internal/jwkutil"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
)

const keyID = "chartreuse" // chosen by fair dice roll (unimportant what the value actually is)

func TestSignVerify(t *testing.T) {
step := &CommandStep{
Command: "llamas",
@@ -43,61 +45,67 @@ func TestSignVerify(t *testing.T) {

cases := []struct {
name string
generateSigner func(alg jwa.SignatureAlgorithm) (jwk.Key, jwk.Set)
generateSigner func(alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error)
alg jwa.SignatureAlgorithm
expectedDeterministicSignature string
}{
{
name: "HMAC-SHA256",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Key, jwk.Set) { return newSymmetricKeyPair(t, "alpacas", alg) },
name: "HMAC-SHA256",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) {
return jwkutil.NewSymmetricKeyPairFromString(keyID, "alpacas", alg)
},
alg: jwa.HS256,
expectedDeterministicSignature: "eyJhbGciOiJIUzI1NiIsImtpZCI6IlRlc3RTaWduVmVyaWZ5In0..Xd7udcMRc3Gg236JdiV2vggGrqxAfgfLZdCLUpgAN34",
expectedDeterministicSignature: "eyJhbGciOiJIUzI1NiIsImtpZCI6ImNoYXJ0cmV1c2UifQ..SHbGJSyZadUIr8M591h_63VS-o0hwZ0n63YBfLfFxzo",
},
{
name: "HMAC-SHA384",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Key, jwk.Set) { return newSymmetricKeyPair(t, "alpacas", alg) },
name: "HMAC-SHA384",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) {
return jwkutil.NewSymmetricKeyPairFromString(keyID, "alpacas", alg)
},
alg: jwa.HS384,
expectedDeterministicSignature: "eyJhbGciOiJIUzM4NCIsImtpZCI6IlRlc3RTaWduVmVyaWZ5In0..g-_B2RO6o_oZjPoM2UyCHDANbPeeqLBUexLRl_MoW7BdpLC7r6mLc0wgRIzJy6ih",
expectedDeterministicSignature: "eyJhbGciOiJIUzM4NCIsImtpZCI6ImNoYXJ0cmV1c2UifQ..i1cy6E6JfYtoHYmYxJXObV4zr3UD3fPTRLvhu9oi9nq3Shz2eSmLGkdqH8lkL9gQ",
},
{
name: "HMAC-SHA512",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Key, jwk.Set) { return newSymmetricKeyPair(t, "alpacas", alg) },
name: "HMAC-SHA512",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) {
return jwkutil.NewSymmetricKeyPairFromString(keyID, "alpacas", alg)
},
alg: jwa.HS512,
expectedDeterministicSignature: "eyJhbGciOiJIUzUxMiIsImtpZCI6IlRlc3RTaWduVmVyaWZ5In0..iW8eaMBrcK7Ehj41DRzgQp3haYBf70JgA_n0C4d_acRZCdVUm-GJv9pdxQ5O0pYd7gJC_wMmaNMkuj4TXqlPvg",
expectedDeterministicSignature: "eyJhbGciOiJIUzUxMiIsImtpZCI6ImNoYXJ0cmV1c2UifQ..QzsnwhNotMQHSHozrJfkohrpYa9usXPoGQGFUjNoD8kJBWa7zsRMEePo4MnP89P0kMfKOBds3HKR3xMc7X7ZyA",
},
{
name: "RSA-PSS 256",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Key, jwk.Set) { return newRSAKeyPair(t, alg) },
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) { return jwkutil.NewKeyPair(keyID, alg) },
alg: jwa.PS256,
},
{
name: "RSA-PSS 384",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Key, jwk.Set) { return newRSAKeyPair(t, alg) },
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) { return jwkutil.NewKeyPair(keyID, alg) },
alg: jwa.PS384,
},
{
name: "RSA-PSS 512",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Key, jwk.Set) { return newRSAKeyPair(t, alg) },
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) { return jwkutil.NewKeyPair(keyID, alg) },
alg: jwa.PS512,
},
{
name: "ECDSA P-256",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Key, jwk.Set) { return newECKeyPair(t, alg, elliptic.P256()) },
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) { return jwkutil.NewKeyPair(keyID, alg) },
alg: jwa.ES256,
},
{
name: "ECDSA P-384",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Key, jwk.Set) { return newECKeyPair(t, alg, elliptic.P384()) },
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) { return jwkutil.NewKeyPair(keyID, alg) },
alg: jwa.ES384,
},
{
name: "ECDSA P-512",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Key, jwk.Set) { return newECKeyPair(t, alg, elliptic.P521()) },
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) { return jwkutil.NewKeyPair(keyID, alg) },
alg: jwa.ES512,
},
{
name: "EdDSA Ed25519",
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Key, jwk.Set) { return newEdwardsKeyPair(t, alg) },
generateSigner: func(alg jwa.SignatureAlgorithm) (jwk.Set, jwk.Set, error) { return jwkutil.NewKeyPair(keyID, alg) },
alg: jwa.EdDSA,
},
}
@@ -106,9 +114,17 @@ func TestSignVerify(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
signer, verifier := tc.generateSigner(tc.alg)
signer, verifier, err := tc.generateSigner(tc.alg)
if err != nil {
t.Fatalf("generateSigner(%v) error = %v", tc.alg, err)
}

key, ok := signer.Key(0)
if !ok {
t.Fatalf("signer.Key(0) = _, false, want true")
}

sig, err := Sign(signEnv, step, signer)
sig, err := Sign(signEnv, step, key)
if err != nil {
t.Fatalf("Sign(CommandStep, signer) error = %v", err)
}
@@ -174,9 +190,18 @@ func TestSignConcatenatedFields(t *testing.T) {

sigs := make(map[string][]testFields)

signer, _ := newSymmetricKeyPair(t, "alpacas", jwa.HS256)
signer, _, err := jwkutil.NewSymmetricKeyPairFromString(keyID, "alpacas", jwa.HS256)
if err != nil {
t.Fatalf("NewSymmetricKeyPairFromString(alpacas) error = %v", err)
}

key, ok := signer.Key(0)
if !ok {
t.Fatalf("signer.Key(0) = _, false, want true")
}

for _, m := range maps {
sig, err := Sign(nil, m, signer)
sig, err := Sign(nil, m, key)
if err != nil {
t.Fatalf("Sign(%v, pts) error = %v", m, err)
}
@@ -196,10 +221,19 @@ func TestSignConcatenatedFields(t *testing.T) {
}

func TestUnknownAlgorithm(t *testing.T) {
signer, _ := newSymmetricKeyPair(t, "alpacas", jwa.HS256)
signer.Set(jwk.AlgorithmKey, "rot13")
signer, _, err := jwkutil.NewSymmetricKeyPairFromString(keyID, "alpacas", jwa.HS256)
if err != nil {
t.Fatalf("NewSymmetricKeyPairFromString(alpacas) error = %v", err)
}

key, ok := signer.Key(0)
if !ok {
t.Fatalf("signer.Key(0) = _, false, want true")
}

if _, err := Sign(nil, &CommandStep{Command: "llamas"}, signer); err == nil {
key.Set(jwk.AlgorithmKey, "rot13")

if _, err := Sign(nil, &CommandStep{Command: "llamas"}, key); err == nil {
t.Errorf("Sign(nil, CommandStep, signer) = %v, want non-nil error", err)
}
}
@@ -215,7 +249,11 @@ func TestVerifyBadSignature(t *testing.T) {
Value: "YWxwYWNhcw==", // base64("alpacas")
}

_, verifier := newSymmetricKeyPair(t, "alpacas", jwa.HS256)
_, verifier, err := jwkutil.NewSymmetricKeyPairFromString(keyID, "alpacas", jwa.HS256)
if err != nil {
t.Fatalf("NewSymmetricKeyPairFromString(alpacas) error = %v", err)
}

if err := sig.Verify(nil, cs, verifier); err == nil {
t.Errorf("sig.Verify(CommandStep, alpacas) = %v, want non-nil error", err)
}
@@ -228,8 +266,17 @@ func TestSignUnknownStep(t *testing.T) {
},
}

signer, _ := newSymmetricKeyPair(t, "alpacas", jwa.HS256)
if err := steps.sign(nil, signer); !errors.Is(err, errSigningRefusedUnknownStepType) {
signer, _, err := jwkutil.NewSymmetricKeyPairFromString(keyID, "alpacas", jwa.HS256)
if err != nil {
t.Fatalf("NewSymmetricKeyPairFromString(alpacas) error = %v", err)
}

key, ok := signer.Key(0)
if !ok {
t.Fatalf("signer.Key(0) = _, false, want true")
}

if err := steps.sign(nil, key); !errors.Is(err, errSigningRefusedUnknownStepType) {
t.Errorf("steps.sign(signer) = %v, want %v", err, errSigningRefusedUnknownStepType)
}
}
@@ -297,9 +344,17 @@ func TestSignVerifyEnv(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
signer, verifier := newSymmetricKeyPair(t, "alpacas", jwa.HS256)
signer, verifier, err := jwkutil.NewSymmetricKeyPairFromString(keyID, "alpacas", jwa.HS256)
if err != nil {
t.Fatalf("NewSymmetricKeyPairFromString(alpacas) error = %v", err)
}

key, ok := signer.Key(0)
if !ok {
t.Fatalf("signer.Key(0) = _, false, want true")
}

sig, err := Sign(tc.pipelineEnv, tc.step, signer)
sig, err := Sign(tc.pipelineEnv, tc.step, key)
if err != nil {
t.Fatalf("Sign(CommandStep, signer) error = %v", err)
}
@@ -340,9 +395,17 @@ func TestSignatureStability(t *testing.T) {
pluginSubCfg[fmt.Sprintf("key%08x", rand.Uint32())] = fmt.Sprintf("value%08x", rand.Uint32())
}

signer, verifier := newECKeyPair(t, jwa.ES256, elliptic.P256())
signer, verifier, err := jwkutil.NewKeyPair(keyID, jwa.ES256)
if err != nil {
t.Fatalf("NewKeyPair error = %v", err)
}

key, ok := signer.Key(0)
if !ok {
t.Fatalf("signer.Key(0) = _, false, want true")
}

sig, err := Sign(env, step, signer)
sig, err := Sign(env, step, key)
if err != nil {
t.Fatalf("Sign(env, CommandStep, signer) error = %v", err)
}
101 changes: 0 additions & 101 deletions internal/pipeline/sign_test_helpers_test.go

This file was deleted.