// Package cisecret seals CI secrets for storage (RFC 0019 §storage).
//
// The key comes from the forge's environment (QUARRY_CI_SECRETS_KEY,
// 32 bytes base64), never from the database it protects, so a copied
// database or backup carries no usable secret. Each value is sealed
// with AES-256-GCM and bound to where it lives (repository, environment,
// name) as associated data, so a sealed value copied onto another
// secret's row does not open.
package cisecret
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strings"
)
// EnvKey names the environment variable the key is read from.
const EnvKey = "QUARRY_CI_SECRETS_KEY"
// ErrNoKey is a forge started without a key: secrets can be neither
// stored nor delivered, and saying so beats storing them in the clear.
var ErrNoKey = errors.New("CI secrets are not configured: the forge has no " + EnvKey)
// Box seals and opens values under one key. The zero Box has no key.
type Box struct{ aead cipher.AEAD }
// New reads a base64 key; empty text is a Box with no key.
func New(b64 string) (Box, error) {
b64 = strings.TrimSpace(b64)
if b64 == "" {
return Box{}, nil
}
key, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return Box{}, fmt.Errorf("%s is not base64: %v", EnvKey, err)
}
if len(key) != 32 {
return Box{}, fmt.Errorf("%s is %d bytes; want 32", EnvKey, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return Box{}, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return Box{}, err
}
return Box{aead: aead}, nil
}
// Ready reports whether the Box has a key.
func (b Box) Ready() bool { return b.aead != nil }
func bound(repo, environment, name string) []byte {
return []byte(repo + "\x00" + environment + "\x00" + name)
}
// Seal returns nonce || ciphertext for value, bound to its place.
func (b Box) Seal(repo, environment, name, value string) ([]byte, error) {
if b.aead == nil {
return nil, ErrNoKey
}
nonce := make([]byte, b.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return b.aead.Seal(nonce, nonce, []byte(value), bound(repo, environment, name)), nil
}
// Open reverses Seal for the same place.
func (b Box) Open(repo, environment, name string, sealed []byte) (string, error) {
if b.aead == nil {
return "", ErrNoKey
}
n := b.aead.NonceSize()
if len(sealed) < n {
return "", errors.New("cisecret: sealed value is too short")
}
out, err := b.aead.Open(nil, sealed[:n], sealed[n:], bound(repo, environment, name))
if err != nil {
return "", errors.New("cisecret: the value does not open with this key, or was moved from another secret")
}
return string(out), nil
}