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