Skip to content

Commit

Permalink
Allow apps to declare secret values (#129)
Browse files Browse the repository at this point in the history
* Allow apps to declare secret values

Add support for a `secrets` dictionary in `Schema`. Apps can declare
secrets by providing an encrypted value that can only be decrypted by a
private key.

Each `runtime.Applet` now accepts a `SecretDecryptionKey`. If a key is
provided, the runtime will use it to decrypt all of an app's secrets
when loading the Starlark script.

The decrypted values are provided to Starlark via the `config` object.
During development, a value can be provided via `pixlet render`, or a
querystring when using `pixlet serve`.

* Add `pixlet encrypt` command

The encrypt command encrypts secret values so that they can only be
decrypted by a specific app running on the Tidbyt community servers.
  • Loading branch information
rohansingh authored Jan 28, 2022
1 parent 7bfb818 commit dfcc403
Show file tree
Hide file tree
Showing 11 changed files with 424 additions and 80 deletions.
60 changes: 60 additions & 0 deletions encrypt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package main

import (
"fmt"
"log"

"github.com/spf13/cobra"
"go.starlark.net/starlark"

"tidbyt.dev/pixlet/runtime"
)

const PublicKeysetJSON = `{
"primaryKeyId": 1589560679,
"key": [
{
"keyData": {
"typeUrl": "type.googleapis.com/google.crypto.tink.EciesAeadHkdfPublicKey",
"value": "ElwKBAgCEAMSUhJQCjh0eXBlLmdvb2dsZWFwaXMuY29tL2dvb2dsZS5jcnlwdG8udGluay5BZXNDdHJIbWFjQWVhZEtleRISCgYKAggQEBASCAoECAMQEBAgGAEYARogLGtas20og5yP8/g9mCNLNCWTDeLUdcHH7o9fbzouOQoiIBIth4hdVF5A2sztwfW+hNoZ0ht/HNH3dDTEBPW3GXA2",
"keyMaterialType": "ASYMMETRIC_PUBLIC"
},
"status": "ENABLED",
"keyId": 1589560679,
"outputPrefixType": "TINK"
}
]
}`

func init() {
rootCmd.AddCommand(encryptCmd)
}

var encryptCmd = &cobra.Command{
Use: "encrypt [app name] [secret value]...",
Short: "Encrypts secrets for use in an app that will be submitted to the Tidbyt community repo",
Example: "encrypt weather my-top-secretweather-api-key-123456",
Args: cobra.MinimumNArgs(2),
Run: encrypt,
}

func encrypt(cmd *cobra.Command, args []string) {
sek := &runtime.SecretEncryptionKey{
PublicKeysetJSON: []byte(PublicKeysetJSON),
}

appName := args[0]
encrypted := make([]string, len(args)-1)

for i, val := range args[1:] {
var err error
encrypted[i], err = sek.Encrypt(appName, val)
if err != nil {
log.Fatalf("encrypting value: %v", err)
}
}

for _, val := range encrypted {
fmt.Println(starlark.String(val).String())
}
}
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ require (
github.com/antchfx/xpath v1.2.0 // indirect
github.com/fogleman/gg v1.3.0
github.com/fsnotify/fsnotify v1.5.1
github.com/go-playground/validator/v10 v10.10.0 // indirect
github.com/go-playground/validator/v10 v10.10.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/tink/go v1.6.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2
github.com/harukasan/go-libwebp v0.0.0-20190703060927-68562c9c99af
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/nathan-osman/go-sunrise v1.0.0 // indirect
github.com/nathan-osman/go-sunrise v1.0.0
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/pkg/errors v0.9.1
github.com/qri-io/starlib v0.5.1-0.20211102160121-ae835e29cd41
Expand Down
48 changes: 48 additions & 0 deletions go.sum

Large diffs are not rendered by default.

68 changes: 47 additions & 21 deletions runtime/runtime.go → runtime/applet.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package runtime
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"

"github.com/pkg/errors"
Expand Down Expand Up @@ -40,15 +41,19 @@ func init() {
}

type Applet struct {
Filename string
Id string
Globals starlark.StringDict
src []byte
loader ModuleLoader
predeclared starlark.StringDict
main *starlark.Function
schema string
schemaHandler map[string]schema.SchemaHandler
Filename string
Id string
Globals starlark.StringDict
SecretDecryptionKey *SecretDecryptionKey

src []byte
loader ModuleLoader
predeclared starlark.StringDict
main *starlark.Function

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

func (a *Applet) thread(initializers ...ThreadInitializer) *starlark.Thread {
Expand Down Expand Up @@ -104,23 +109,30 @@ func (a *Applet) Load(filename string, src []byte, loader ModuleLoader) (err err
}
a.main = main

var s string
var handlers map[string]schema.SchemaHandler
schemaFun, _ := a.Globals[schema.SchemaFunctionName].(*starlark.Function)
if schemaFun != nil {
schemaVal, err := a.Call(schemaFun, nil)
if err != nil {
return errors.Wrap(err, "calling schema function")
return errors.Wrapf(err, "calling schema function for %s", a.Filename)
}

s, handlers, err = schema.EncodeSchema(schemaVal, a.Globals)
a.schema, err = schema.FromStarlark(schemaVal, a.Globals)
if err != nil {
return errors.Wrap(err, "encode schema")
return errors.Wrapf(err, "parsing schema for %s", a.Filename)
}

a.schemaJSON, err = json.Marshal(a.schema)
if err != nil {
return errors.Wrapf(err, "serializing schema to JSON for %s", a.Filename)
}
}

a.schema = s
a.schemaHandler = handlers
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 @@ -130,7 +142,15 @@ 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 {
starlarkConfig := AppletConfig(config)
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)
args = starlark.Tuple{starlarkConfig}
}

Expand Down Expand Up @@ -169,7 +189,7 @@ func (a *Applet) Run(config map[string]string, initializers ...ThreadInitializer
// CallSchemaHandler calls the schema handler for a field, passing it a single
// string parameter and returning a single string value.
func (app *Applet) CallSchemaHandler(ctx context.Context, fieldId, parameter string) (result string, err error) {
handler, found := app.schemaHandler[fieldId]
handler, found := app.schema.Handlers[fieldId]
if !found {
return "", fmt.Errorf("no handler exported for field id %s", fieldId)
}
Expand All @@ -192,11 +212,17 @@ func (app *Applet) CallSchemaHandler(ctx context.Context, fieldId, parameter str
return options, nil

case schema.ReturnSchema:
schema, _, err := schema.EncodeSchema(resultVal, app.Globals)
sch, err := schema.FromStarlark(resultVal, app.Globals)
if err != nil {
return "", err
}
return schema, nil

s, err := json.Marshal(sch)
if err != nil {
return "", errors.Wrap(err, "serializing schema to JSON")
}

return string(s), nil

case schema.ReturnString:
str, ok := starlark.AsString(resultVal)
Expand All @@ -214,7 +240,7 @@ func (app *Applet) CallSchemaHandler(ctx context.Context, fieldId, parameter str

// GetSchema returns the config for the applet.
func (app *Applet) GetSchema() string {
return app.schema
return string(app.schemaJSON)
}

func attachContext(ctx context.Context) ThreadInitializer {
Expand Down
File renamed without changes.
89 changes: 89 additions & 0 deletions runtime/secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package runtime

import (
"bytes"
"encoding/base64"
"strings"

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

// SecretDecryptionKey is a key that can be used to decrypt secrets.
type SecretDecryptionKey struct {
// EncryptedKeysetJSON is the encrypted JSON representation of a Tink keyset.
EncryptedKeysetJSON []byte

// KeyEncryptionKey is a Tink key that can be used to decrypt the keyset.
KeyEncryptionKey tink.AEAD
}

// SecretEncryptionKey is a key that can be used to encrypt secrets,
// but not decrypt them.
type SecretEncryptionKey struct {
// PublicKeysetJSON is the serialized JSON representation of a Tink keyset.
PublicKeysetJSON []byte
}

func (sdk *SecretDecryptionKey) decrypt(a *Applet) error {
if a.schema == nil || len(a.schema.Secrets) == 0 {
// nothing to do
return nil
}

r := bytes.NewReader(sdk.EncryptedKeysetJSON)
kh, err := keyset.Read(keyset.NewJSONReader(r), sdk.KeyEncryptionKey)
if err != nil {
return errors.Wrap(err, "reading keyset JSON")
}

dec, err := hybrid.NewHybridDecrypt(kh)
if err != nil {
return 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)
if err != nil {
return errors.Wrapf(err, "base64 decoding of secret '%s'", k)
}

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

a.decryptedSecrets[k] = string(cleartext)
}

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
}
78 changes: 78 additions & 0 deletions runtime/secret_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package runtime

import (
"bytes"
"fmt"
"testing"

"github.com/google/tink/go/hybrid"
"github.com/google/tink/go/keyset"
"github.com/google/tink/go/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

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

// make a test decryption key
dummyKEK := &testutil.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)

decryptionKey := &SecretDecryptionKey{
EncryptedKeysetJSON: privJSON.Bytes(),
KeyEncryptionKey: dummyKEK,
}

// 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")
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")
return render.Root(child=render.Box())
def get_schema():
return schema.Schema(
version = "1",
secrets = {
"top_secret": "%s",
},
)
`, plaintext, encrypted)

app := &Applet{
SecretDecryptionKey: decryptionKey,
}

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 dfcc403

Please sign in to comment.