| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236 |
- // Package nodetoken encrypts replayable per-node bearer tokens at rest with
- // row-bound AES-GCM and versioned key IDs.
- package nodetoken
- import (
- "crypto/aes"
- "crypto/cipher"
- "crypto/rand"
- "encoding/base64"
- "errors"
- "fmt"
- "strings"
- "sync"
- )
- // Mode is explicit so a missing key cannot silently downgrade encrypted
- // deployments to plaintext.
- type Mode int
- const (
- // ModeOff: legacy plaintext operation. Writes store plaintext; an encrypted
- // value cannot be interpreted (no key) and is rejected rather than guessed.
- ModeOff Mode = iota
- // ModeMigration: key required. Reads accept plaintext OR ciphertext; writes
- // always produce ciphertext. Used while migrating existing rows.
- ModeMigration
- // ModeRequired: key required (startup fails if it cannot load). Reads decrypt
- // ciphertext (error on failure) and accept any still-unmigrated plaintext;
- // writes always produce ciphertext.
- ModeRequired
- )
- const (
- encPrefix = "enc:"
- encScheme = "enc:v1:"
- keyLen = 32 // AES-256
- nonceLen = 12 // GCM standard nonce
- aadKeyFormat = "nodes/api_token/%d"
- )
- // ParseMode maps the NODE_TOKEN_ENCRYPTION env value to a Mode.
- func ParseMode(s string) (Mode, error) {
- switch strings.ToLower(strings.TrimSpace(s)) {
- case "", "off":
- return ModeOff, nil
- case "migration":
- return ModeMigration, nil
- case "required":
- return ModeRequired, nil
- default:
- return ModeOff, fmt.Errorf("nodetoken: unknown NODE_TOKEN_ENCRYPTION %q (want off|migration|required)", s)
- }
- }
- // Keyring holds the active write key and previous decryption keys.
- type Keyring struct {
- ActiveID string
- Keys map[string][keyLen]byte
- }
- func (kr *Keyring) active() ([keyLen]byte, error) {
- k, ok := kr.Keys[kr.ActiveID]
- if !ok {
- return [keyLen]byte{}, fmt.Errorf("nodetoken: active key %q not in keyring", kr.ActiveID)
- }
- return k, nil
- }
- // Codec encrypts/decrypts node tokens under a fixed policy and keyring.
- type Codec struct {
- mode Mode
- ring *Keyring // nil only in ModeOff
- }
- // NewCodec requires an active key outside ModeOff.
- func NewCodec(mode Mode, ring *Keyring) (*Codec, error) {
- if mode == ModeOff {
- return &Codec{mode: ModeOff}, nil
- }
- if ring == nil || len(ring.Keys) == 0 {
- return nil, errors.New("nodetoken: encryption mode requires a key, but none was loaded")
- }
- if _, err := ring.active(); err != nil {
- return nil, err
- }
- return &Codec{mode: mode, ring: ring}, nil
- }
- // Enabled reports whether the codec writes ciphertext (mode != off).
- func (c *Codec) Enabled() bool { return c.mode != ModeOff }
- func aad(nodeID int) []byte { return []byte(fmt.Sprintf(aadKeyFormat, nodeID)) }
- // IsEncrypted reports whether a stored value is in this package's ciphertext form.
- func IsEncrypted(stored string) bool { return strings.HasPrefix(stored, encPrefix) }
- // Encrypt returns plaintext in ModeOff or row-bound enc:v1 ciphertext otherwise.
- // Empty and already-valid encrypted values remain unchanged.
- func (c *Codec) Encrypt(nodeID int, plaintext string) (string, error) {
- if c.mode == ModeOff || plaintext == "" {
- return plaintext, nil
- }
- if IsEncrypted(plaintext) {
- // Validate it actually decrypts for this node; if so keep verbatim.
- if _, err := c.Decrypt(nodeID, plaintext); err != nil {
- return "", fmt.Errorf("nodetoken: refusing to store undecryptable ciphertext: %w", err)
- }
- return plaintext, nil
- }
- key, err := c.ring.active()
- if err != nil {
- return "", err
- }
- gcm, err := newGCM(key)
- if err != nil {
- return "", err
- }
- nonce := make([]byte, nonceLen)
- if _, err := rand.Read(nonce); err != nil {
- return "", err
- }
- ct := gcm.Seal(nil, nonce, []byte(plaintext), aad(nodeID))
- blob := append(nonce, ct...)
- return encScheme + c.ring.ActiveID + ":" + base64.RawURLEncoding.EncodeToString(blob), nil
- }
- // Decrypt passes legacy plaintext through; enc: values must authenticate and
- // are never reinterpreted as plaintext after an error.
- func (c *Codec) Decrypt(nodeID int, stored string) (string, error) {
- if c.mode == ModeOff {
- return stored, nil
- }
- if !IsEncrypted(stored) {
- return stored, nil
- }
- rest, ok := strings.CutPrefix(stored, encScheme)
- if !ok {
- return "", fmt.Errorf("nodetoken: unsupported ciphertext scheme in %q", firstN(stored, 12))
- }
- keyID, b64, ok := strings.Cut(rest, ":")
- if !ok || keyID == "" {
- return "", errors.New("nodetoken: malformed ciphertext (missing key id)")
- }
- if c.ring == nil {
- return "", errors.New("nodetoken: encrypted token encountered but encryption is disabled (no key)")
- }
- key, ok := c.ring.Keys[keyID]
- if !ok {
- return "", fmt.Errorf("nodetoken: no key %q in keyring to decrypt token", keyID)
- }
- blob, err := base64.RawURLEncoding.DecodeString(b64)
- if err != nil {
- return "", fmt.Errorf("nodetoken: base64 decode: %w", err)
- }
- if len(blob) < nonceLen {
- return "", errors.New("nodetoken: ciphertext too short")
- }
- gcm, err := newGCM(key)
- if err != nil {
- return "", err
- }
- pt, err := gcm.Open(nil, blob[:nonceLen], blob[nonceLen:], aad(nodeID))
- if err != nil {
- return "", fmt.Errorf("nodetoken: authentication failed for node %d: %w", nodeID, err)
- }
- return string(pt), nil
- }
- // ActiveKeyID returns the id new writes use (empty in ModeOff).
- func (c *Codec) ActiveKeyID() string {
- if c.ring == nil {
- return ""
- }
- return c.ring.ActiveID
- }
- // EncryptedWithActive reports whether migration can skip a ciphertext row.
- func (c *Codec) EncryptedWithActive(stored string) bool {
- if c.ring == nil || !IsEncrypted(stored) {
- return false
- }
- rest, ok := strings.CutPrefix(stored, encScheme)
- if !ok {
- return false
- }
- keyID, _, ok := strings.Cut(rest, ":")
- return ok && keyID == c.ring.ActiveID
- }
- func newGCM(key [keyLen]byte) (cipher.AEAD, error) {
- block, err := aes.NewCipher(key[:])
- if err != nil {
- return nil, err
- }
- return cipher.NewGCM(block)
- }
- func firstN(s string, n int) string {
- if len(s) <= n {
- return s
- }
- return s[:n]
- }
- // --- package singleton, initialized once at startup ---
- var (
- mu sync.RWMutex
- current *Codec
- )
- // Init installs the process-wide codec. Call once during startup after building
- // the keyring; in ModeOff a nil keyring is fine.
- func Init(c *Codec) {
- mu.Lock()
- defer mu.Unlock()
- current = c
- }
- // get returns the installed codec, or a permissive ModeOff codec if Init was
- // never called (e.g. unit tests / sqlite dev) so callers never nil-panic.
- func get() *Codec {
- mu.RLock()
- c := current
- mu.RUnlock()
- if c == nil {
- return &Codec{mode: ModeOff}
- }
- return c
- }
- // Encrypt/Decrypt/Enabled operate on the process-wide codec.
- func Encrypt(nodeID int, plaintext string) (string, error) { return get().Encrypt(nodeID, plaintext) }
- func Decrypt(nodeID int, stored string) (string, error) { return get().Decrypt(nodeID, stored) }
- func Enabled() bool { return get().Enabled() }
- func Active() *Codec { return get() }
|