/* 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/.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 }