Skip to content

Commit

Permalink
Move secrets from schema to separate module (#132)
Browse files Browse the repository at this point in the history
Instead of declaring secrets in `get_schema`, the new interface is to
load the `secret.star` module and call `secret.decrypt()`:

```starlark
load("secret.star", "secret")

API_KEY = secret.decrypt("<encrypted string goes here>")
```

This allows secrets to be used anywhere in an app, including in the
`get_schema` function itself.
  • Loading branch information
rohansingh authored Jan 31, 2022
1 parent 7fb96e5 commit 6009c28
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 100 deletions.
37 changes: 18 additions & 19 deletions runtime/applet.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ type Applet struct {
predeclared starlark.StringDict
main *starlark.Function

schema *schema.Schema
schemaJSON []byte
decryptedSecrets map[string]string
schema *schema.Schema
schemaJSON []byte
decrypter decrypter
}

func (a *Applet) thread(initializers ...ThreadInitializer) *starlark.Thread {
Expand All @@ -65,6 +65,10 @@ func (a *Applet) thread(initializers ...ThreadInitializer) *starlark.Thread {
},
}

if a.decrypter != nil {
a.decrypter.attachToThread(t)
}

for _, init := range initializers {
t = init(t)
}
Expand All @@ -89,6 +93,13 @@ func (a *Applet) Load(filename string, src []byte, loader ModuleLoader) (err err

a.Id = fmt.Sprintf("%s/%x", filename, md5.Sum(src))

if a.SecretDecryptionKey != nil {
a.decrypter, err = a.SecretDecryptionKey.decrypterForApp(a)
if err != nil {
return errors.Wrapf(err, "preparing secret key for %s", a.Filename)
}
}

a.predeclared = starlark.StringDict{
"struct": starlark.NewBuiltin("struct", starlarkstruct.Make),
}
Expand Down Expand Up @@ -127,13 +138,6 @@ func (a *Applet) Load(filename string, src []byte, loader ModuleLoader) (err err
}
}

if a.SecretDecryptionKey != nil {
err = a.SecretDecryptionKey.decrypt(a)
if err != nil {
return errors.Wrapf(err, "decrypting secrets for %s", a.Filename)
}
}

return nil
}

Expand All @@ -142,15 +146,7 @@ func (a *Applet) Load(filename string, src []byte, loader ModuleLoader) (err err
func (a *Applet) Run(config map[string]string, initializers ...ThreadInitializer) (roots []render.Root, err error) {
var args starlark.Tuple
if a.main.NumParams() > 0 {
mergedConfig := make(map[string]string)
for k, v := range a.decryptedSecrets {
mergedConfig[k] = v
}
for k, v := range config {
mergedConfig[k] = v
}

starlarkConfig := AppletConfig(mergedConfig)
starlarkConfig := AppletConfig(config)
args = starlark.Tuple{starlarkConfig}
}

Expand Down Expand Up @@ -294,6 +290,9 @@ func (a *Applet) loadModule(thread *starlark.Thread, module string) (starlark.St
case "cache.star":
return LoadCacheModule()

case "secret.star":
return LoadSecretModule()

case "xpath.star":
return LoadXPathModule()

Expand Down
116 changes: 86 additions & 30 deletions runtime/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@ package runtime
import (
"bytes"
"encoding/base64"
"fmt"
"strings"
"sync"

"github.com/google/tink/go/hybrid"
"github.com/google/tink/go/keyset"
"github.com/google/tink/go/tink"
"github.com/pkg/errors"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
)

const (
threadDecrypterKey = "tidbyt.dev/pixlet/runtime/decrypter"
)

// SecretDecryptionKey is a key that can be used to decrypt secrets.
Expand All @@ -27,63 +35,111 @@ type SecretEncryptionKey struct {
PublicKeysetJSON []byte
}

func (sdk *SecretDecryptionKey) decrypt(a *Applet) error {
if a.schema == nil || len(a.schema.Secrets) == 0 {
// nothing to do
return nil
// Encrypt encrypts a value for use as a secret in an app. Provide both a value
// and the name of the app the encrypted secret will be used in. The value will
// only be usable with the specified app.
func (sek *SecretEncryptionKey) Encrypt(appName, plaintext string) (string, error) {
r := bytes.NewReader(sek.PublicKeysetJSON)
kh, err := keyset.ReadWithNoSecrets(keyset.NewJSONReader(r))
if err != nil {
return "", errors.Wrap(err, "reading keyset JSON")
}

enc, err := hybrid.NewHybridEncrypt(kh)
if err != nil {
return "", errors.Wrap(err, "NewHybridEncrypt")
}

context := []byte(strings.TrimSuffix(appName, ".star"))
ciphertext, err := enc.Encrypt([]byte(plaintext), context)
if err != nil {
return "", errors.Wrap(err, "encrypting secret")
}

return base64.StdEncoding.EncodeToString(ciphertext), nil
}

var (
secretOnce sync.Once
secretModule starlark.StringDict
)

func LoadSecretModule() (starlark.StringDict, error) {
secretOnce.Do(func() {
secretModule = starlark.StringDict{
"secret": &starlarkstruct.Module{
Name: "secret",
Members: starlark.StringDict{
"decrypt": starlark.NewBuiltin("decrypt", secretDecrypt),
},
},
}
})

return secretModule, nil
}

type decrypter func(starlark.String) (starlark.String, error)

func (sdk *SecretDecryptionKey) decrypterForApp(a *Applet) (decrypter, error) {
r := bytes.NewReader(sdk.EncryptedKeysetJSON)
kh, err := keyset.Read(keyset.NewJSONReader(r), sdk.KeyEncryptionKey)
if err != nil {
return errors.Wrap(err, "reading keyset JSON")
return nil, errors.Wrap(err, "reading keyset JSON")
}

dec, err := hybrid.NewHybridDecrypt(kh)
if err != nil {
return errors.Wrap(err, "NewHybridDecrypt")
return nil, errors.Wrap(err, "NewHybridDecrypt")
}

context := []byte(strings.TrimSuffix(a.Filename, ".star"))

a.decryptedSecrets = make(map[string]string, len(a.schema.Secrets))
for k, v := range a.schema.Secrets {
ciphertext, err := base64.StdEncoding.DecodeString(v)
return func(s starlark.String) (starlark.String, error) {
ciphertext, err := base64.StdEncoding.DecodeString(s.GoString())
if err != nil {
return errors.Wrapf(err, "base64 decoding of secret '%s'", k)
return "", errors.Wrapf(err, "base64 decoding of secret: %s", s)
}

cleartext, err := dec.Decrypt(ciphertext, context)
if err != nil {
return errors.Wrapf(err, "decrypting secret '%s'", k)
return "", errors.Wrapf(err, "decrypting secret: %s", s)
}

a.decryptedSecrets[k] = string(cleartext)
}
return starlark.String(cleartext), nil
}, nil
}

return nil
func (d decrypter) attachToThread(t *starlark.Thread) {
t.SetLocal(threadDecrypterKey, d)
}

// Encrypt encrypts a value for use as a secret in an app. Provide both a value
// and the name of the app the encrypted secret will be used in. The value will
// only be usable with the specified app.
func (sek *SecretEncryptionKey) Encrypt(appName, plaintext string) (string, error) {
r := bytes.NewReader(sek.PublicKeysetJSON)
kh, err := keyset.ReadWithNoSecrets(keyset.NewJSONReader(r))
if err != nil {
return "", errors.Wrap(err, "reading keyset JSON")
func decrypterForThread(t *starlark.Thread) decrypter {
d, ok := t.Local(threadDecrypterKey).(decrypter)
if ok {
return d
} else {
return nil
}
}

enc, err := hybrid.NewHybridEncrypt(kh)
if err != nil {
return "", errors.Wrap(err, "NewHybridEncrypt")
func secretDecrypt(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var encryptedVal starlark.String

if err := starlark.UnpackPositionalArgs(
"decrypt",
args, kwargs,
0, &encryptedVal,
); err != nil {
return nil, fmt.Errorf("unpacking arguments for secret.decrypt: %v", err)
}

context := []byte(strings.TrimSuffix(appName, ".star"))
ciphertext, err := enc.Encrypt([]byte(plaintext), context)
if err != nil {
return "", errors.Wrap(err, "encrypting secret")
dec := decrypterForThread(thread)

if dec == nil {
// no decrypter configured
return starlark.None, nil
}

return base64.StdEncoding.EncodeToString(ciphertext), nil
return dec(encryptedVal)
}
74 changes: 64 additions & 10 deletions runtime/secret_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,22 +56,19 @@ func TestSecretDecrypt(t *testing.T) {
src := fmt.Sprintf(`
load("render.star", "render")
load("schema.star", "schema")
load("secret.star", "secret")
EXPECTED_PLAINTEXT = "%s"
ENCRYPTED = "%s"
DECRYPTED = secret.decrypt(ENCRYPTED)
def assert_eq(message, actual, expected):
if not expected == actual:
fail(message, "-", "expected", expected, "actual", actual)
def main(config):
assert_eq("secret value", config.get("top_secret"), "%s")
def main():
assert_eq("secret value", DECRYPTED, EXPECTED_PLAINTEXT)
return render.Root(child=render.Box())
def get_schema():
return schema.Schema(
version = "1",
secrets = {
"top_secret": "%s",
},
)
`, plaintext, encrypted)

app := &Applet{
Expand All @@ -85,3 +82,60 @@ def get_schema():
assert.NoError(t, err)
assert.Equal(t, 1, len(roots))
}

func TestSecretDoesntDecryptWithoutKey(t *testing.T) {
plaintext := "h4x0rrszZ!!"

// make a test decryption key
dummyKEK := &dummyAEAD{}
khPriv, err := keyset.NewHandle(hybrid.ECIESHKDFAES128CTRHMACSHA256KeyTemplate())
require.NoError(t, err)

privJSON := &bytes.Buffer{}
err = khPriv.Write(keyset.NewJSONWriter(privJSON), dummyKEK)
require.NoError(t, err)

// get the corresponding public key and serialize it
khPub, err := khPriv.Public()
require.NoError(t, err)

pubJSON := &bytes.Buffer{}
err = khPub.WriteWithNoSecrets(keyset.NewJSONWriter(pubJSON))
require.NoError(t, err)

// encrypt the secret
encrypted, err := (&SecretEncryptionKey{
PublicKeysetJSON: pubJSON.Bytes(),
}).Encrypt("test", plaintext)
require.NoError(t, err)
assert.NotEqual(t, encrypted, "")

src := fmt.Sprintf(`
load("render.star", "render")
load("schema.star", "schema")
load("secret.star", "secret")
EXPECTED_PLAINTEXT = "%s"
ENCRYPTED = "%s"
DECRYPTED = secret.decrypt(ENCRYPTED)
def assert_eq(message, actual, expected):
if not expected == actual:
fail(message, "-", "expected", expected, "actual", actual)
def main():
assert_eq("secret value", DECRYPTED, None)
return render.Root(child=render.Box())
`, plaintext, encrypted)

app := &Applet{
SecretDecryptionKey: nil,
}

err = app.Load("test.star", []byte(src), nil)
require.NoError(t, err)

roots, err := app.Run(nil)
assert.NoError(t, err)
assert.Equal(t, 1, len(roots))
}
Loading

0 comments on commit 6009c28

Please sign in to comment.