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
This commit is contained in:
369
pkg/secrets/store.go
Normal file
369
pkg/secrets/store.go
Normal file
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user