Files
volt/pkg/encryption/keys.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

334 lines
11 KiB
Go

/*
AGE Key Management — Generate, store, and manage AGE encryption keys for Volt.
Key Hierarchy:
1. Platform CDN Key — per-node key for CDN blob encryption
- Private: /etc/volt/encryption/cdn.key (AGE-SECRET-KEY-...)
- Public: /etc/volt/encryption/cdn.pub (age1...)
2. Master Recovery Key — platform-wide recovery key (public only on nodes)
- Public: /etc/volt/encryption/master-recovery.pub (age1...)
- Private: held by platform operator (offline/HSM)
3. User BYOK Key — optional user-provided public key (Pro tier)
- Public: /etc/volt/encryption/user.pub (age1...)
- Private: held by the user
Encryption Recipients:
- Community: platform key + master recovery key (dual-recipient)
- Pro/BYOK: user key + platform key + master recovery key (tri-recipient)
Copyright (c) Armored Gates LLC. All rights reserved.
*/
package encryption
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ── Paths ────────────────────────────────────────────────────────────────────
const (
// EncryptionDir is the base directory for encryption keys.
EncryptionDir = "/etc/volt/encryption"
// CDNKeyFile is the AGE private key for CDN blob encryption.
CDNKeyFile = "/etc/volt/encryption/cdn.key"
// CDNPubFile is the AGE public key for CDN blob encryption.
CDNPubFile = "/etc/volt/encryption/cdn.pub"
// MasterRecoveryPubFile is the platform master recovery public key.
MasterRecoveryPubFile = "/etc/volt/encryption/master-recovery.pub"
// UserBYOKPubFile is the user-provided BYOK public key (Pro tier).
UserBYOKPubFile = "/etc/volt/encryption/user.pub"
)
// ── Key Info ─────────────────────────────────────────────────────────────────
// KeyInfo describes a configured encryption key.
type KeyInfo struct {
Name string // "cdn", "master-recovery", "user-byok"
Type string // "identity" (private+public) or "recipient" (public only)
PublicKey string // The age1... public key
Path string // File path
Present bool // Whether the key file exists
}
// ── Key Generation ───────────────────────────────────────────────────────────
// GenerateCDNKey generates a new AGE keypair for CDN blob encryption.
// Stores the private key at CDNKeyFile and extracts the public key to CDNPubFile.
// Returns the public key string.
func GenerateCDNKey() (string, error) {
if err := os.MkdirAll(EncryptionDir, 0700); err != nil {
return "", fmt.Errorf("create encryption dir: %w", err)
}
keygenBin, err := findAgeKeygenBinary()
if err != nil {
return "", err
}
// Generate key to file
keyFile, err := os.OpenFile(CDNKeyFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return "", fmt.Errorf("create cdn key file: %w", err)
}
defer keyFile.Close()
cmd := exec.Command(keygenBin)
cmd.Stdout = keyFile
var stderrBuf strings.Builder
cmd.Stderr = &stderrBuf
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("age-keygen: %s: %w", stderrBuf.String(), err)
}
// age-keygen prints the public key to stderr: "Public key: age1..."
pubKey := extractPublicKeyFromStderr(stderrBuf.String())
if pubKey == "" {
// Try extracting from the key file itself
pubKey, err = extractPublicKeyFromKeyFile(CDNKeyFile)
if err != nil {
return "", fmt.Errorf("extract public key: %w", err)
}
}
// Write public key to separate file for easy sharing
if err := os.WriteFile(CDNPubFile, []byte(pubKey+"\n"), 0644); err != nil {
return "", fmt.Errorf("write cdn pub file: %w", err)
}
return pubKey, nil
}
// ── Key Loading ──────────────────────────────────────────────────────────────
// LoadCDNPublicKey reads the CDN public key from disk.
func LoadCDNPublicKey() (string, error) {
return readKeyFile(CDNPubFile)
}
// LoadMasterRecoveryKey reads the master recovery public key from disk.
func LoadMasterRecoveryKey() (string, error) {
return readKeyFile(MasterRecoveryPubFile)
}
// LoadUserBYOKKey reads the user's BYOK public key from disk.
func LoadUserBYOKKey() (string, error) {
return readKeyFile(UserBYOKPubFile)
}
// CDNKeyExists checks if the CDN encryption key has been generated.
func CDNKeyExists() bool {
_, err := os.Stat(CDNKeyFile)
return err == nil
}
// CDNIdentityPath returns the path to the CDN private key for decryption.
func CDNIdentityPath() string {
return CDNKeyFile
}
// ── BYOK Key Import ─────────────────────────────────────────────────────────
// ImportUserKey imports a user-provided AGE public key for BYOK encryption.
// The key must be a valid AGE public key (age1...).
func ImportUserKey(pubKeyPath string) error {
data, err := os.ReadFile(pubKeyPath)
if err != nil {
return fmt.Errorf("read user key file: %w", err)
}
pubKey := strings.TrimSpace(string(data))
// Validate it looks like an AGE public key
if !strings.HasPrefix(pubKey, "age1") {
return fmt.Errorf("invalid AGE public key: must start with 'age1' (got %q)", truncate(pubKey, 20))
}
if err := os.MkdirAll(EncryptionDir, 0700); err != nil {
return fmt.Errorf("create encryption dir: %w", err)
}
// Write the user's public key
if err := os.WriteFile(UserBYOKPubFile, []byte(pubKey+"\n"), 0644); err != nil {
return fmt.Errorf("write user key: %w", err)
}
return nil
}
// ImportUserKeyFromString imports a user-provided AGE public key from a string.
func ImportUserKeyFromString(pubKey string) error {
pubKey = strings.TrimSpace(pubKey)
if !strings.HasPrefix(pubKey, "age1") {
return fmt.Errorf("invalid AGE public key: must start with 'age1'")
}
if err := os.MkdirAll(EncryptionDir, 0700); err != nil {
return fmt.Errorf("create encryption dir: %w", err)
}
return os.WriteFile(UserBYOKPubFile, []byte(pubKey+"\n"), 0644)
}
// SetMasterRecoveryKey sets the platform master recovery public key.
func SetMasterRecoveryKey(pubKey string) error {
pubKey = strings.TrimSpace(pubKey)
if !strings.HasPrefix(pubKey, "age1") {
return fmt.Errorf("invalid AGE public key for master recovery: must start with 'age1'")
}
if err := os.MkdirAll(EncryptionDir, 0700); err != nil {
return fmt.Errorf("create encryption dir: %w", err)
}
return os.WriteFile(MasterRecoveryPubFile, []byte(pubKey+"\n"), 0644)
}
// ── Recipients Builder ───────────────────────────────────────────────────────
// BuildRecipients returns the list of AGE public keys that blobs should be
// encrypted to, based on what keys are configured.
// - Always includes the CDN key (if present)
// - Always includes the master recovery key (if present)
// - Includes the BYOK user key (if present and BYOK is enabled)
func BuildRecipients() ([]string, error) {
var recipients []string
// CDN key (required)
cdnPub, err := LoadCDNPublicKey()
if err != nil {
return nil, fmt.Errorf("CDN encryption key not initialized. Run: volt security keys init")
}
recipients = append(recipients, cdnPub)
// Master recovery key (optional but strongly recommended)
if masterPub, err := LoadMasterRecoveryKey(); err == nil {
recipients = append(recipients, masterPub)
}
// User BYOK key (optional, Pro tier)
if userPub, err := LoadUserBYOKKey(); err == nil {
recipients = append(recipients, userPub)
}
return recipients, nil
}
// ── Key Status ───────────────────────────────────────────────────────────────
// ListKeys returns information about all configured encryption keys.
func ListKeys() []KeyInfo {
keys := []KeyInfo{
{
Name: "cdn",
Type: "identity",
Path: CDNKeyFile,
Present: fileExists(CDNKeyFile),
},
{
Name: "master-recovery",
Type: "recipient",
Path: MasterRecoveryPubFile,
Present: fileExists(MasterRecoveryPubFile),
},
{
Name: "user-byok",
Type: "recipient",
Path: UserBYOKPubFile,
Present: fileExists(UserBYOKPubFile),
},
}
// Load public keys where available
for i := range keys {
if keys[i].Present {
switch keys[i].Name {
case "cdn":
if pub, err := readKeyFile(CDNPubFile); err == nil {
keys[i].PublicKey = pub
}
case "master-recovery":
if pub, err := readKeyFile(MasterRecoveryPubFile); err == nil {
keys[i].PublicKey = pub
}
case "user-byok":
if pub, err := readKeyFile(UserBYOKPubFile); err == nil {
keys[i].PublicKey = pub
}
}
}
}
return keys
}
// ── Helpers ──────────────────────────────────────────────────────────────────
// readKeyFile reads a single key line from a file.
func readKeyFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read key %s: %w", filepath.Base(path), err)
}
key := strings.TrimSpace(string(data))
if key == "" {
return "", fmt.Errorf("key file %s is empty", filepath.Base(path))
}
return key, nil
}
// extractPublicKeyFromStderr parses age-keygen stderr output for the public key.
// age-keygen outputs: "Public key: age1..."
func extractPublicKeyFromStderr(stderr string) string {
for _, line := range strings.Split(stderr, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Public key:") {
return strings.TrimSpace(strings.TrimPrefix(line, "Public key:"))
}
}
return ""
}
// extractPublicKeyFromKeyFile reads an AGE key file and extracts the public
// key from the comment line (# public key: age1...).
func extractPublicKeyFromKeyFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "# public key:") {
return strings.TrimSpace(strings.TrimPrefix(line, "# public key:")), nil
}
}
return "", fmt.Errorf("no public key comment found in key file")
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// exec.Command is used directly for simplicity.