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
334 lines
11 KiB
Go
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.
|