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
This commit is contained in:
243
pkg/encryption/age.go
Normal file
243
pkg/encryption/age.go
Normal file
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
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 <key1> -r <key2> ...
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user