// Package secrets provides envelope encryption for sensitive // at-rest values (WireGuard private keys, peer PSKs, possibly more // later). The master key lives in /var/lib/edgeguard/.master_key // (32 bytes, 0600 root-owned via postinst — but readable by the // edgeguard user via group), generated lazily on first use if // missing. // // On-disk format per ciphertext: nonce (12 byte) || aes-gcm ciphertext. // Plaintext is never logged or returned outside this package's // callers. package secrets import ( "crypto/aes" "crypto/cipher" "crypto/rand" "errors" "fmt" "io" "os" "path/filepath" "sync" ) const masterKeyLen = 32 // DefaultMasterKeyPath is the file that holds the box master key. // Override via the EDGEGUARD_MASTER_KEY env var for tests. const DefaultMasterKeyPath = "/var/lib/edgeguard/.master_key" // Box uses AES-256-GCM with a static master key to seal/unseal // values. Concurrency-safe; the cipher is initialised once. type Box struct { once sync.Once aead cipher.AEAD err error path string } // New returns a Box that loads / lazily creates the master key at // the given path. Pass empty string to use DefaultMasterKeyPath. func New(path string) *Box { if path == "" { path = DefaultMasterKeyPath } return &Box{path: path} } func (b *Box) init() { b.once.Do(func() { key, err := loadOrCreateMasterKey(b.path) if err != nil { b.err = fmt.Errorf("master key: %w", err) return } blk, err := aes.NewCipher(key) if err != nil { b.err = fmt.Errorf("aes cipher: %w", err) return } aead, err := cipher.NewGCM(blk) if err != nil { b.err = fmt.Errorf("gcm: %w", err) return } b.aead = aead }) } // Seal returns nonce||ciphertext. Empty input → empty output (so // callers can store NULL for "not set" without a special case). func (b *Box) Seal(plaintext []byte) ([]byte, error) { if len(plaintext) == 0 { return nil, nil } b.init() if b.err != nil { return nil, b.err } nonce := make([]byte, b.aead.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, fmt.Errorf("nonce: %w", err) } out := b.aead.Seal(nil, nonce, plaintext, nil) return append(nonce, out...), nil } // Open is the inverse of Seal. Empty input → empty output. func (b *Box) Open(blob []byte) ([]byte, error) { if len(blob) == 0 { return nil, nil } b.init() if b.err != nil { return nil, b.err } ns := b.aead.NonceSize() if len(blob) < ns+1 { return nil, errors.New("ciphertext too short") } nonce, ct := blob[:ns], blob[ns:] pt, err := b.aead.Open(nil, nonce, ct, nil) if err != nil { return nil, fmt.Errorf("decrypt: %w", err) } return pt, nil } func loadOrCreateMasterKey(path string) ([]byte, error) { if data, err := os.ReadFile(path); err == nil { if len(data) != masterKeyLen { return nil, fmt.Errorf("master key file has wrong length %d, want %d", len(data), masterKeyLen) } return data, nil } else if !errors.Is(err, os.ErrNotExist) { return nil, err } // Generate. Make sure the parent dir exists; postinst should // have created /var/lib/edgeguard already, but in dev (`go run` // without sudo) we create it best-effort. if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, fmt.Errorf("mkdir parent: %w", err) } key := make([]byte, masterKeyLen) if _, err := io.ReadFull(rand.Reader, key); err != nil { return nil, fmt.Errorf("rand: %w", err) } if err := os.WriteFile(path, key, 0o600); err != nil { return nil, fmt.Errorf("write: %w", err) } return key, nil }