mirror of
https://github.com/TronoSfera/backupy-agent.git
synced 2026-05-18 18:13:30 +03:00
The .gitignore rule "state/" was unanchored, so git also ignored apps/agent/internal/state/ — the BoltDB-backed queue persistence package. CI build failed with: internal/queue/queue.go:13:2: no required module provides package github.com/backupy/backupy/apps/agent/internal/state Anchored the rule to repo root (/state/, /var/) so it only matches the runtime data directory, never a Go package.
96 lines
3.2 KiB
Go
96 lines
3.2 KiB
Go
// State-at-rest encryption helpers.
|
|
//
|
|
// All bucket *values* are wrapped with AES-256-GCM using a key derived from
|
|
// BACKUP_AGENT_KEY via HKDF-SHA256 (per docs/03-agent-spec.md →
|
|
// "Шифрование state опционально (key derived из BACKUP_AGENT_KEY)").
|
|
//
|
|
// Wire format on disk:
|
|
//
|
|
// [1 byte version=0x01] [12 bytes nonce] [ciphertext] [16 bytes GCM tag]
|
|
//
|
|
// Bucket *keys* (e.g. job ids) are stored in clear because BoltDB relies on
|
|
// the byte-ordering of keys for iteration and a deterministic encryption of
|
|
// keys would still leak ordering information without buying much.
|
|
package state
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
|
|
"golang.org/x/crypto/hkdf"
|
|
)
|
|
|
|
const (
|
|
stateKeyLen = 32 // AES-256
|
|
stateNonceLen = 12 // GCM standard
|
|
stateEnvVersion = byte(0x01) // bump on format change
|
|
hkdfInfo = "backupy-agent/state-v1"
|
|
hkdfSalt = "backupy-agent-state-salt"
|
|
)
|
|
|
|
// errCipher indicates corrupt or wrong-key state. Surfaced as-is so the
|
|
// agent can refuse to start instead of silently overwriting good data.
|
|
var errCipher = errors.New("state: cipher open failed (corrupt or wrong key)")
|
|
|
|
// deriveStateKey returns a 32-byte AES-256 key derived from the agent key.
|
|
// HKDF is overkill for a single derivation step, but it costs nothing and
|
|
// keeps us compliant with NIST SP 800-108 should we ever need to rotate.
|
|
func deriveStateKey(agentKey string) ([]byte, error) {
|
|
if agentKey == "" {
|
|
return nil, errors.New("state: empty agent key — cannot derive encryption key")
|
|
}
|
|
r := hkdf.New(sha256New, []byte(agentKey), []byte(hkdfSalt), []byte(hkdfInfo))
|
|
key := make([]byte, stateKeyLen)
|
|
if _, err := io.ReadFull(r, key); err != nil {
|
|
return nil, fmt.Errorf("state: hkdf read: %w", err)
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// newGCM constructs a fresh AEAD around a 32-byte AES key.
|
|
func newGCM(key []byte) (cipher.AEAD, error) {
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("state: aes init: %w", err)
|
|
}
|
|
aead, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("state: gcm init: %w", err)
|
|
}
|
|
return aead, nil
|
|
}
|
|
|
|
// seal returns version || nonce || ciphertext+tag.
|
|
func seal(aead cipher.AEAD, plaintext []byte) ([]byte, error) {
|
|
nonce := make([]byte, stateNonceLen)
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return nil, fmt.Errorf("state: rand: %w", err)
|
|
}
|
|
out := make([]byte, 0, 1+stateNonceLen+len(plaintext)+aead.Overhead())
|
|
out = append(out, stateEnvVersion)
|
|
out = append(out, nonce...)
|
|
out = aead.Seal(out, nonce, plaintext, nil)
|
|
return out, nil
|
|
}
|
|
|
|
// open reverses seal. Returns errCipher on any decryption failure so callers
|
|
// can distinguish "no such record" (bbolt nil) from "tampered/wrong key".
|
|
func open(aead cipher.AEAD, sealed []byte) ([]byte, error) {
|
|
if len(sealed) < 1+stateNonceLen+aead.Overhead() {
|
|
return nil, errCipher
|
|
}
|
|
if sealed[0] != stateEnvVersion {
|
|
return nil, fmt.Errorf("state: unsupported envelope version 0x%02x", sealed[0])
|
|
}
|
|
nonce := sealed[1 : 1+stateNonceLen]
|
|
ct := sealed[1+stateNonceLen:]
|
|
pt, err := aead.Open(nil, nonce, ct, nil)
|
|
if err != nil {
|
|
return nil, errCipher
|
|
}
|
|
return pt, nil
|
|
}
|