/* AGE Encryption — Core encrypt/decrypt operations using AGE (x25519 + ChaCha20-Poly1305). AGE is the encryption standard for Volt CDN blob storage. All blobs are encrypted before upload to BunnyCDN and decrypted on download. This ensures zero-knowledge storage — the CDN operator cannot read blob contents. AGE uses x25519 for key agreement and ChaCha20-Poly1305 for symmetric encryption. This works on edge hardware without AES-NI instructions, making it ideal for ARM/RISC-V edge nodes. Architecture: - Encrypt to multiple recipients (platform key + master recovery key + optional BYOK) - Identity (private key) stored on the node for decryption - Uses the `age` CLI tool (filippo.io/age) as subprocess — no CGO, no heavy deps Copyright (c) Armored Gates LLC. All rights reserved. */ package encryption import ( "bytes" "fmt" "io" "os" "os/exec" "strings" ) // ── Constants ──────────────────────────────────────────────────────────────── const ( // AgeBinary is the path to the age encryption tool. AgeBinary = "age" // AgeKeygenBinary is the path to the age-keygen tool. AgeKeygenBinary = "age-keygen" ) // ── Core Operations ────────────────────────────────────────────────────────── // Encrypt encrypts plaintext data to one or more AGE recipients (public keys). // Returns the AGE-encrypted ciphertext (binary armor). // Recipients are AGE public keys (age1...). func Encrypt(plaintext []byte, recipients []string) ([]byte, error) { if len(recipients) == 0 { return nil, fmt.Errorf("encrypt: at least one recipient required") } ageBin, err := findAgeBinary() if err != nil { return nil, err } // Build args: age -e -r -r ... args := []string{"-e"} for _, r := range recipients { r = strings.TrimSpace(r) if r == "" { continue } args = append(args, "-r", r) } cmd := exec.Command(ageBin, args...) cmd.Stdin = bytes.NewReader(plaintext) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("age encrypt: %s: %w", strings.TrimSpace(stderr.String()), err) } return stdout.Bytes(), nil } // Decrypt decrypts AGE-encrypted ciphertext using a private key (identity) file. // The identity file is the AGE secret key file (contains AGE-SECRET-KEY-...). func Decrypt(ciphertext []byte, identityPath string) ([]byte, error) { if _, err := os.Stat(identityPath); err != nil { return nil, fmt.Errorf("decrypt: identity file not found: %s", identityPath) } ageBin, err := findAgeBinary() if err != nil { return nil, err } cmd := exec.Command(ageBin, "-d", "-i", identityPath) cmd.Stdin = bytes.NewReader(ciphertext) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("age decrypt: %s: %w", strings.TrimSpace(stderr.String()), err) } return stdout.Bytes(), nil } // EncryptToFile encrypts plaintext and writes the ciphertext to a file. func EncryptToFile(plaintext []byte, recipients []string, outputPath string) error { ciphertext, err := Encrypt(plaintext, recipients) if err != nil { return err } return os.WriteFile(outputPath, ciphertext, 0600) } // DecryptFile reads an encrypted file and decrypts it. func DecryptFile(encryptedPath, identityPath string) ([]byte, error) { ciphertext, err := os.ReadFile(encryptedPath) if err != nil { return nil, fmt.Errorf("decrypt file: %w", err) } return Decrypt(ciphertext, identityPath) } // EncryptStream encrypts data from a reader to a writer for multiple recipients. func EncryptStream(r io.Reader, w io.Writer, recipients []string) error { if len(recipients) == 0 { return fmt.Errorf("encrypt stream: at least one recipient required") } ageBin, err := findAgeBinary() if err != nil { return err } args := []string{"-e"} for _, rec := range recipients { rec = strings.TrimSpace(rec) if rec == "" { continue } args = append(args, "-r", rec) } cmd := exec.Command(ageBin, args...) cmd.Stdin = r cmd.Stdout = w var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("age encrypt stream: %s: %w", strings.TrimSpace(stderr.String()), err) } return nil } // DecryptStream decrypts data from a reader to a writer using an identity file. func DecryptStream(r io.Reader, w io.Writer, identityPath string) error { ageBin, err := findAgeBinary() if err != nil { return err } cmd := exec.Command(ageBin, "-d", "-i", identityPath) cmd.Stdin = r cmd.Stdout = w var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("age decrypt stream: %s: %w", strings.TrimSpace(stderr.String()), err) } return nil } // ── AGE Binary Discovery ───────────────────────────────────────────────────── // findAgeBinary locates the age binary on the system. func findAgeBinary() (string, error) { // Try PATH first if path, err := exec.LookPath(AgeBinary); err == nil { return path, nil } // Check common locations for _, candidate := range []string{ "/usr/bin/age", "/usr/local/bin/age", "/snap/bin/age", } { if _, err := os.Stat(candidate); err == nil { return candidate, nil } } return "", fmt.Errorf("age binary not found. Install with: apt install age") } // findAgeKeygenBinary locates the age-keygen binary. func findAgeKeygenBinary() (string, error) { if path, err := exec.LookPath(AgeKeygenBinary); err == nil { return path, nil } for _, candidate := range []string{ "/usr/bin/age-keygen", "/usr/local/bin/age-keygen", "/snap/bin/age-keygen", } { if _, err := os.Stat(candidate); err == nil { return candidate, nil } } return "", fmt.Errorf("age-keygen binary not found. Install with: apt install age") } // IsAgeAvailable checks if the age binary is installed and working. func IsAgeAvailable() bool { _, err := findAgeBinary() return err == nil } // AgeVersion returns the installed age version string. func AgeVersion() (string, error) { ageBin, err := findAgeBinary() if err != nil { return "", err } cmd := exec.Command(ageBin, "--version") var stdout bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stdout if err := cmd.Run(); err != nil { return "", fmt.Errorf("age version: %w", err) } return strings.TrimSpace(stdout.String()), nil }