Files
volt/pkg/secrets/store.go
Karl Clinger 0ebe75b2ca Volt CLI: source-available under AGPSL v5.0
Complete infrastructure platform CLI:
- Container runtime (systemd-nspawn)
- VoltVisor VMs (Neutron Stardust / QEMU)
- Stellarium CAS (content-addressed storage)
- ORAS Registry
- GitOps integration
- Landlock LSM security
- Compose orchestration
- Mesh networking

Copyright (c) Armored Gates LLC. All rights reserved.
Licensed under AGPSL v5.0
2026-03-21 02:08:15 -05:00

370 lines
11 KiB
Go

/*
Secrets Store — Encrypted secrets management for Volt containers.
Secrets are stored AGE-encrypted on disk and can be injected into containers
at runtime as environment variables or file mounts.
Storage:
- Secrets directory: /etc/volt/secrets/
- Each secret: /etc/volt/secrets/<name>.age (AGE-encrypted)
- Metadata: /etc/volt/secrets/metadata.json (secret names + injection configs)
Encryption:
- Uses the node's CDN AGE key for encryption/decryption
- Secrets are encrypted at rest — only decrypted at injection time
Copyright (c) Armored Gates LLC. All rights reserved.
*/
package secrets
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/armoredgate/volt/pkg/encryption"
)
// ── Constants ────────────────────────────────────────────────────────────────
const (
// SecretsDir is the directory where encrypted secrets are stored.
SecretsDir = "/etc/volt/secrets"
// MetadataFile stores secret names and injection configurations.
MetadataFile = "/etc/volt/secrets/metadata.json"
)
// ── Types ────────────────────────────────────────────────────────────────────
// SecretMetadata tracks a secret's metadata (not its value).
type SecretMetadata struct {
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Size int `json:"size"` // plaintext size in bytes
}
// SecretInjection defines how a secret is injected into a container.
type SecretInjection struct {
SecretName string `json:"secret_name"`
ContainerName string `json:"container_name"`
Mode string `json:"mode"` // "env" or "file"
EnvVar string `json:"env_var,omitempty"` // for mode=env
FilePath string `json:"file_path,omitempty"` // for mode=file
}
// secretsMetadataFile is the on-disk metadata format.
type secretsMetadataFile struct {
Secrets []SecretMetadata `json:"secrets"`
Injections []SecretInjection `json:"injections"`
}
// Store manages encrypted secrets.
type Store struct {
dir string
}
// ── Constructor ──────────────────────────────────────────────────────────────
// NewStore creates a new secrets store at the default location.
func NewStore() *Store {
return &Store{dir: SecretsDir}
}
// NewStoreAt creates a secrets store at a custom location (for testing).
func NewStoreAt(dir string) *Store {
return &Store{dir: dir}
}
// ── Secret CRUD ──────────────────────────────────────────────────────────────
// Create stores a new secret (or updates an existing one).
// The value is encrypted using the node's AGE key before storage.
func (s *Store) Create(name string, value []byte) error {
if err := validateSecretName(name); err != nil {
return err
}
if err := os.MkdirAll(s.dir, 0700); err != nil {
return fmt.Errorf("create secrets dir: %w", err)
}
// Get encryption recipients
recipients, err := encryption.BuildRecipients()
if err != nil {
return fmt.Errorf("secret create: encryption keys not initialized. Run: volt security keys init")
}
// Encrypt the value
ciphertext, err := encryption.Encrypt(value, recipients)
if err != nil {
return fmt.Errorf("secret create %s: encrypt: %w", name, err)
}
// Write encrypted file
secretPath := filepath.Join(s.dir, name+".age")
if err := os.WriteFile(secretPath, ciphertext, 0600); err != nil {
return fmt.Errorf("secret create %s: write: %w", name, err)
}
// Update metadata
return s.updateMetadata(name, len(value))
}
// Get retrieves and decrypts a secret value.
func (s *Store) Get(name string) ([]byte, error) {
secretPath := filepath.Join(s.dir, name+".age")
ciphertext, err := os.ReadFile(secretPath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("secret %q not found", name)
}
return nil, fmt.Errorf("secret get %s: %w", name, err)
}
plaintext, err := encryption.Decrypt(ciphertext, encryption.CDNIdentityPath())
if err != nil {
return nil, fmt.Errorf("secret get %s: decrypt: %w", name, err)
}
return plaintext, nil
}
// Delete removes a secret and its metadata.
func (s *Store) Delete(name string) error {
secretPath := filepath.Join(s.dir, name+".age")
if err := os.Remove(secretPath); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("secret %q not found", name)
}
return fmt.Errorf("secret delete %s: %w", name, err)
}
// Remove from metadata
return s.removeFromMetadata(name)
}
// List returns metadata for all stored secrets.
func (s *Store) List() ([]SecretMetadata, error) {
md, err := s.loadMetadata()
if err != nil {
// No metadata file = no secrets
return nil, nil
}
return md.Secrets, nil
}
// Exists checks if a secret with the given name exists.
func (s *Store) Exists(name string) bool {
secretPath := filepath.Join(s.dir, name+".age")
_, err := os.Stat(secretPath)
return err == nil
}
// ── Injection ────────────────────────────────────────────────────────────────
// AddInjection configures a secret to be injected into a container.
func (s *Store) AddInjection(injection SecretInjection) error {
if !s.Exists(injection.SecretName) {
return fmt.Errorf("secret %q not found", injection.SecretName)
}
md, err := s.loadMetadata()
if err != nil {
md = &secretsMetadataFile{}
}
// Check for duplicate injection
for _, existing := range md.Injections {
if existing.SecretName == injection.SecretName &&
existing.ContainerName == injection.ContainerName &&
existing.EnvVar == injection.EnvVar &&
existing.FilePath == injection.FilePath {
return nil // Already configured
}
}
md.Injections = append(md.Injections, injection)
return s.saveMetadata(md)
}
// GetInjections returns all injection configurations for a container.
func (s *Store) GetInjections(containerName string) ([]SecretInjection, error) {
md, err := s.loadMetadata()
if err != nil {
return nil, nil
}
var injections []SecretInjection
for _, inj := range md.Injections {
if inj.ContainerName == containerName {
injections = append(injections, inj)
}
}
return injections, nil
}
// ResolveInjections decrypts and returns all secret values for a container's
// configured injections. Returns a map of env_var/file_path → decrypted value.
func (s *Store) ResolveInjections(containerName string) (envVars map[string]string, files map[string][]byte, err error) {
injections, err := s.GetInjections(containerName)
if err != nil {
return nil, nil, err
}
envVars = make(map[string]string)
files = make(map[string][]byte)
for _, inj := range injections {
value, err := s.Get(inj.SecretName)
if err != nil {
return nil, nil, fmt.Errorf("resolve injection %s for %s: %w",
inj.SecretName, containerName, err)
}
switch inj.Mode {
case "env":
envVars[inj.EnvVar] = string(value)
case "file":
files[inj.FilePath] = value
}
}
return envVars, files, nil
}
// RemoveInjection removes a specific injection configuration.
func (s *Store) RemoveInjection(secretName, containerName string) error {
md, err := s.loadMetadata()
if err != nil {
return nil
}
var filtered []SecretInjection
for _, inj := range md.Injections {
if !(inj.SecretName == secretName && inj.ContainerName == containerName) {
filtered = append(filtered, inj)
}
}
md.Injections = filtered
return s.saveMetadata(md)
}
// ── Metadata ─────────────────────────────────────────────────────────────────
func (s *Store) loadMetadata() (*secretsMetadataFile, error) {
mdPath := filepath.Join(s.dir, "metadata.json")
data, err := os.ReadFile(mdPath)
if err != nil {
return nil, err
}
var md secretsMetadataFile
if err := json.Unmarshal(data, &md); err != nil {
return nil, fmt.Errorf("parse secrets metadata: %w", err)
}
return &md, nil
}
func (s *Store) saveMetadata(md *secretsMetadataFile) error {
data, err := json.MarshalIndent(md, "", " ")
if err != nil {
return fmt.Errorf("marshal secrets metadata: %w", err)
}
mdPath := filepath.Join(s.dir, "metadata.json")
return os.WriteFile(mdPath, data, 0600)
}
func (s *Store) updateMetadata(name string, plainSize int) error {
md, err := s.loadMetadata()
if err != nil {
md = &secretsMetadataFile{}
}
now := time.Now()
found := false
for i := range md.Secrets {
if md.Secrets[i].Name == name {
md.Secrets[i].UpdatedAt = now
md.Secrets[i].Size = plainSize
found = true
break
}
}
if !found {
md.Secrets = append(md.Secrets, SecretMetadata{
Name: name,
CreatedAt: now,
UpdatedAt: now,
Size: plainSize,
})
}
// Sort by name
sort.Slice(md.Secrets, func(i, j int) bool {
return md.Secrets[i].Name < md.Secrets[j].Name
})
return s.saveMetadata(md)
}
func (s *Store) removeFromMetadata(name string) error {
md, err := s.loadMetadata()
if err != nil {
return nil // No metadata to clean up
}
// Remove secret entry
var filtered []SecretMetadata
for _, sec := range md.Secrets {
if sec.Name != name {
filtered = append(filtered, sec)
}
}
md.Secrets = filtered
// Remove all injections for this secret
var filteredInj []SecretInjection
for _, inj := range md.Injections {
if inj.SecretName != name {
filteredInj = append(filteredInj, inj)
}
}
md.Injections = filteredInj
return s.saveMetadata(md)
}
// ── Validation ───────────────────────────────────────────────────────────────
func validateSecretName(name string) error {
if name == "" {
return fmt.Errorf("secret name cannot be empty")
}
if len(name) > 253 {
return fmt.Errorf("secret name too long (max 253 characters)")
}
// Must be lowercase alphanumeric with hyphens/dots/underscores
for _, c := range name {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_') {
return fmt.Errorf("secret name %q contains invalid character %q (allowed: a-z, 0-9, -, ., _)", name, string(c))
}
}
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "-") {
return fmt.Errorf("secret name cannot start with '.' or '-'")
}
return nil
}