Skip to content
quarry Artifact [85deac2148]
Login

Artifact [85deac2148]

Artifact 85deac2148fd5c1393e79fcb0118ea191def457911654b8e5eac73a84b210b96:


// 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
}