diff --git a/README.md b/README.md index a93f3f2..0789d50 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Reef -A shared library for Hammerhead, Blacktip, and Bamboo +A shared library for Hammerhead, Blacktip, Bamboo and Sandbar Shared types and building blocks for the sharks stack: [Hammerhead](https://git.veillax.com/Veillax/hammerhead) (control plane), [Blacktip](https://git.veillax.com/Veillax/blacktip) (edge) and [Bamboo](https://git.veillax.com/Veillax/bamboo) (site agent). @@ -15,6 +15,7 @@ Reef holds the code all three programs agree on, so each can be built and releas | `netguard` | Refuses internal addresses for anything that connects out on a user's behalf (webhooks, mail delivery), and dials the address it checked | | `docker` | Lists running containers through the Docker Engine API, with no SDK | | `dotenv` | Loads a `.env` file into the environment without overriding real variables | +| `secretbox` | AES-256-GCM sealing of small secrets (`enc1:` values) before they are written to a database | ## Using it diff --git a/secretbox/secretbox.go b/secretbox/secretbox.go new file mode 100644 index 0000000..159c7df --- /dev/null +++ b/secretbox/secretbox.go @@ -0,0 +1,87 @@ +// Package secretbox encrypts small secrets (tokens, passwords, private keys) +// before they are written to the database, using AES-256-GCM. +// +// Sealed values look like "enc1:". Opening a value that isn't sealed +// returns it unchanged, so data written before encryption was turned on is +// still readable and gets sealed the next time it is saved. +package secretbox + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "strings" +) + +const prefix = "enc1:" + +type Box struct{ aead cipher.AEAD } + +// GenerateKey returns a new random key in the form New accepts. +func GenerateKey() string { + b := make([]byte, 32) + rand.Read(b) + return base64.StdEncoding.EncodeToString(b) +} + +// New builds a Box from a key given as base64 or hex of 32 bytes. Any other +// text is treated as a passphrase and stretched with SHA-256. +func New(key string) (*Box, error) { + key = strings.TrimSpace(key) + if key == "" { + return nil, errors.New("empty key") + } + raw := decodeKey(key) + block, err := aes.NewCipher(raw) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &Box{aead: aead}, nil +} + +func decodeKey(key string) []byte { + if b, err := base64.StdEncoding.DecodeString(key); err == nil && len(b) == 32 { + return b + } + if b, err := hex.DecodeString(key); err == nil && len(b) == 32 { + return b + } + sum := sha256.Sum256([]byte(key)) + return sum[:] +} + +// Seal encrypts s. The empty string stays empty so unset secrets stay visibly unset. +func (b *Box) Seal(s string) string { + if s == "" || strings.HasPrefix(s, prefix) { + return s + } + nonce := make([]byte, b.aead.NonceSize()) + rand.Read(nonce) + return prefix + base64.StdEncoding.EncodeToString(b.aead.Seal(nonce, nonce, []byte(s), nil)) +} + +// Open decrypts a sealed value. A wrong key or damaged data is an error, so a +// bad key can never silently turn secrets into garbage. +func (b *Box) Open(s string) (string, error) { + if !strings.HasPrefix(s, prefix) { + return s, nil + } + raw, err := base64.StdEncoding.DecodeString(s[len(prefix):]) + if err != nil || len(raw) < b.aead.NonceSize() { + return "", errors.New("damaged secret") + } + n := b.aead.NonceSize() + plain, err := b.aead.Open(nil, raw[:n], raw[n:], nil) + if err != nil { + return "", errors.New("can't decrypt a stored secret: is HAMMERHEAD_MASTER_KEY the key it was saved with?") + } + return string(plain), nil +} diff --git a/secretbox/secretbox_test.go b/secretbox/secretbox_test.go new file mode 100644 index 0000000..c8f2822 --- /dev/null +++ b/secretbox/secretbox_test.go @@ -0,0 +1,51 @@ +package secretbox + +import ( + "strings" + "testing" +) + +func TestSealOpenRoundTripAndKeys(t *testing.T) { + key := GenerateKey() + b, err := New(key) + if err != nil { + t.Fatal(err) + } + sealed := b.Seal("hunter2") + if !strings.HasPrefix(sealed, "enc1:") || strings.Contains(sealed, "hunter2") { + t.Fatalf("not sealed: %q", sealed) + } + if again := b.Seal("hunter2"); again == sealed { + t.Error("two seals of the same value must differ (random nonce)") + } + if b.Seal(sealed) != sealed { + t.Error("sealing an already sealed value must not double-encrypt") + } + if got, err := b.Open(sealed); err != nil || got != "hunter2" { + t.Errorf("Open = %q, %v", got, err) + } + if got, _ := b.Open("legacy plaintext"); got != "legacy plaintext" { + t.Errorf("unsealed legacy data should pass through, got %q", got) + } + if b.Seal("") != "" { + t.Error("empty stays empty") + } + + other, _ := New(GenerateKey()) + if _, err := other.Open(sealed); err == nil { + t.Error("the wrong key must fail loudly, not return garbage") + } + if _, err := b.Open(sealed[:len(sealed)-4] + "AAAA"); err == nil { + t.Error("tampered data must fail") + } + + // a passphrase works as a key, and is stable + p1, _ := New("correct horse battery staple") + p2, _ := New("correct horse battery staple") + if got, err := p2.Open(p1.Seal("x")); err != nil || got != "x" { + t.Errorf("passphrase keys must be reproducible: %q %v", got, err) + } + if _, err := New(" "); err == nil { + t.Error("an empty key must be rejected") + } +}