diff --git a/README.md b/README.md index 29cf82d..c067731 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,14 @@ The functionality in this package should work on Linux, MacOS and the BSDs. - add a mechanism for root interactions - add Windows support + +## encoding/transfer + +This package provides an interface to encode and decode values that will get stored +in a data store. This is required to ensure that values are consistently stored safely, +but also doesn't enforce an encoding that users must use when storing secrets or outputs. +The default algorithm is base64 and all encoded strings generated will be prefixed with "base64:". +If there is no encoding prefix, there is `NoEncodingType` that will just return the original +value unencoded/decoded. + +Help can be found by running `go doc -all github.com/puppetlabs/horsehead/encoding/transfer`. diff --git a/encoding/transfer/doc.go b/encoding/transfer/doc.go new file mode 100644 index 0000000..5acde08 --- /dev/null +++ b/encoding/transfer/doc.go @@ -0,0 +1,23 @@ +/* +Package transfer provides an interface for encoding and decoding values +for storage. The utility in this package is transparent to the user +and it is used to maintain byte integrity on values used in workflows. + +Basic use when encoding a value: + encoder := transfer.Encoders[transfer.DefaultEncodingType]() + + result, err := encoder.EncodeForTransfer([]byte("super secret token")) + if err != nil { + // handle error + } + +Basic use when decoding a value: + encodingType, value := transfer.ParseEncodedValue("base64:c3VwZXIgc2VjcmV0IHRva2Vu") + encoder := transfer.Encoders[encoderType]() + + result, err := encoder.DecodeFromTransfer(value) + if err != nil { + // handle error + } +*/ +package transfer diff --git a/encoding/transfer/encoding.go b/encoding/transfer/encoding.go new file mode 100644 index 0000000..6aa231b --- /dev/null +++ b/encoding/transfer/encoding.go @@ -0,0 +1,110 @@ +package transfer + +import ( + "encoding/base64" + "fmt" + "strings" +) + +type encodingType string + +func (p encodingType) String() string { + return string(p) +} + +const ( + Base64EncodingType encodingType = "base64" + NoEncodingType encodingType = "" +) + +// DefaultEncodingType is the default encodingType. This makes it easier to use this +// package as the caller won't need to make any desisions around what encoder to use +// unless they really need to. +const DefaultEncodingType = Base64EncodingType + +// encodingTypeMap is an internal map used to get the encodingType type from a string +var encodingTypeMap = map[string]encodingType{ + "base64": Base64EncodingType, +} + +// ParseEncodedValue will attempt to split on : and extract an encoding identifer +// from the prefix of the string. It then returns the discovered encodingType and the +// value without the encodingType prefixed. +func ParseEncodedValue(value string) (encodingType, string) { + parts := strings.SplitN(value, ":", 2) + + if len(parts) < 2 { + return NoEncodingType, value + } + + t, ok := encodingTypeMap[parts[0]] + if !ok { + return NoEncodingType, value + } + + return t, parts[1] +} + +// Encoders maps encoding algorithms to their respective EncodeDecoder types. +// Example: +// +// ed := transfer.Encoders[Base64EncodingType]() +// encodedValue, err := ed.EncodeForTransfer("my super secret value") +var Encoders = map[encodingType]func() EncodeDecoder{ + Base64EncodingType: func() EncodeDecoder { + return Base64Encoding{} + }, + NoEncodingType: func() EncodeDecoder { + return NoEncoding{} + }, +} + +// Base64Encoding handles the encoding and decoding of values using base64. +// All encoded values will be prefixed with "base64:" +type Base64Encoding struct{} + +// EncodeForTransfer takes a byte slice and returns it encoded as a base64 string. +// No error is ever returned. +func (e Base64Encoding) EncodeForTransfer(value []byte) (string, error) { + s := base64.StdEncoding.EncodeToString(value) + + return fmt.Sprintf("%s:%s", Base64EncodingType, s), nil +} + +// DecodeFromTransfer takes a string and attempts to decode using a base64 decoder. +// If an error is returned, it will originate from the Go encoding/base64 package. +func (e Base64Encoding) DecodeFromTransfer(value string) ([]byte, error) { + return base64.StdEncoding.DecodeString(value) +} + +// NoEncoding just returns the values without encoding them. This is used when there +// is no encoding type algorithm prefix on the value. +type NoEncoding struct{} + +// EncodeForTransfer takes a byte slice and casts it to a string. No error is ever +// returned. +func (e NoEncoding) EncodeForTransfer(value []byte) (string, error) { + return string(value), nil +} + +// DecodeFromTransfer takes a string and casts it to a byte slice. No error is ever +// returned. +func (e NoEncoding) DecodeFromTransfer(value string) ([]byte, error) { + return []byte(value), nil +} + +// EncodeForTransfer uses the DefaultEncodingType to encode value. +func EncodeForTransfer(value []byte) (string, error) { + encoder := Encoders[DefaultEncodingType]() + + return encoder.EncodeForTransfer(value) +} + +// DecodeFromTransfer uses ParseEncodedValue to find the right encoder then +// decodes value with it. +func DecodeFromTransfer(value string) ([]byte, error) { + t, val := ParseEncodedValue(value) + encoder := Encoders[t]() + + return encoder.DecodeFromTransfer(val) +} diff --git a/encoding/transfer/encoding_test.go b/encoding/transfer/encoding_test.go new file mode 100644 index 0000000..b89e2c4 --- /dev/null +++ b/encoding/transfer/encoding_test.go @@ -0,0 +1,133 @@ +package transfer + +import ( + "encoding/base64" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEncoding(t *testing.T) { + var cases = []struct { + description string + value string + encodingType encodingType + expected string + customResultTest func(t *testing.T, encoded string, decoded []byte) + }{ + { + description: "base64 encoding succeeds", + value: "super secret token", + encodingType: Base64EncodingType, + expected: "base64:c3VwZXIgc2VjcmV0IHRva2Vu", + }, + { + description: "no encoding succeeds", + value: "super secret token", + encodingType: NoEncodingType, + expected: "super secret token", + }, + { + description: "base64 complex values don't loose integrity", + value: "super: secret token:12:49:wheel", + encodingType: Base64EncodingType, + expected: "base64:c3VwZXI6IHNlY3JldCB0b2tlbjoxMjo0OTp3aGVlbA==", + }, + { + description: "no encoding complex values don't loose integrity", + value: "super: secret token:12:49:wheel", + encodingType: NoEncodingType, + expected: "super: secret token:12:49:wheel", + }, + { + description: "begins with :", + value: ":fun time at the park", + encodingType: NoEncodingType, + expected: ":fun time at the park", + }, + { + description: "user encoded base64", + value: "c3VwZXIgc2VjcmV0IHRva2Vu", + encodingType: NoEncodingType, + expected: "c3VwZXIgc2VjcmV0IHRva2Vu", + }, + { + description: "user encoded base64 wrapped with our base64 encoder", + // "super secret token" encoded as base64 + value: "c3VwZXIgc2VjcmV0IHRva2Vu", + encodingType: Base64EncodingType, + expected: "base64:YzNWd1pYSWdjMlZqY21WMElIUnZhMlZ1", + customResultTest: func(t *testing.T, encoded string, decoded []byte) { + // tests that a user can encode their own values, have our system wrap it in our own + // encoding, then when they try to unwrap their encoding, they get the expected value. + result, err := base64.StdEncoding.DecodeString(string(decoded)) + require.NoError(t, err) + + require.Equal(t, "super secret token", string(result)) + }, + }, + } + + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + ed := Encoders[c.encodingType]() + + encoded, err := ed.EncodeForTransfer([]byte(c.value)) + require.NoError(t, err) + require.Equal(t, c.expected, encoded, fmt.Sprintf("encoding result was malformed: %s", encoded)) + + typ, value := ParseEncodedValue(encoded) + require.Equal(t, c.encodingType, typ) + + newED := Encoders[typ]() + + var decoded []byte + + decoded, err = newED.DecodeFromTransfer(value) + require.NoError(t, err) + require.Equal(t, c.value, string(decoded)) + + if c.customResultTest != nil { + t.Run("custom result test", func(t *testing.T) { + c.customResultTest(t, encoded, decoded) + }) + } + }) + } +} + +func TestHelperFuncs(t *testing.T) { + var cases = []struct { + description string + value string + expected string + }{ + { + description: "base64 encoding succeeds", + value: "super secret token", + expected: "base64:c3VwZXIgc2VjcmV0IHRva2Vu", + }, + { + description: "user encoded base64 wrapped with our base64 encoder", + // "super secret token" encoded as base64 + value: "c3VwZXIgc2VjcmV0IHRva2Vu", + expected: "base64:YzNWd1pYSWdjMlZqY21WMElIUnZhMlZ1", + }, + } + + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + encoded, err := EncodeForTransfer([]byte(c.value)) + require.NoError(t, err) + + require.Equal(t, c.expected, encoded) + + var decoded []byte + + decoded, err = DecodeFromTransfer(encoded) + require.NoError(t, err) + require.Equal(t, c.value, string(decoded)) + }) + } +} diff --git a/encoding/transfer/interface.go b/encoding/transfer/interface.go new file mode 100644 index 0000000..8bebdbc --- /dev/null +++ b/encoding/transfer/interface.go @@ -0,0 +1,17 @@ +package transfer + +// Encoder encodes a byte slice and returns a string with the encoding type prefixed +type Encoder interface { + EncodeForTransfer([]byte) (string, error) +} + +// Decoder takes a string and decodes it, returning a byte slice or an error +type Decoder interface { + DecodeFromTransfer(string) ([]byte, error) +} + +// EncodeDecoder groups Encoder and Decoder to form a type that can both encode and decode values. +type EncodeDecoder interface { + Encoder + Decoder +}