Files
volt/pkg/cdn/encrypted_client.go
Karl Clinger 81ad0b597c 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 00:31:12 -05:00

197 lines
6.8 KiB
Go

/*
Encrypted CDN Client — Transparent AGE encryption layer over CDN operations.
Wraps the standard CDN Client to encrypt blobs before upload and decrypt
on download. The encryption is transparent to callers — they push/pull
plaintext and the encryption happens automatically.
Architecture:
- PushBlob: plaintext → AGE encrypt → upload ciphertext
- PullBlob: download ciphertext → AGE decrypt → return plaintext
- Hash verification: hash is of PLAINTEXT (preserves CAS dedup)
- Manifests are NOT encrypted (they contain only hashes, no sensitive data)
Copyright (c) Armored Gates LLC. All rights reserved.
*/
package cdn
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
"github.com/armoredgate/volt/pkg/encryption"
)
// ── Encrypted Client ─────────────────────────────────────────────────────────
// EncryptedClient wraps a CDN Client with transparent AGE encryption.
type EncryptedClient struct {
// Inner is the underlying CDN client that handles HTTP operations.
Inner *Client
// Recipients are the AGE public keys to encrypt to.
// Populated from encryption.BuildRecipients() on creation.
Recipients []string
// IdentityPath is the path to the AGE private key for decryption.
IdentityPath string
}
// NewEncryptedClient creates a CDN client with transparent encryption.
// It reads encryption keys from the standard locations.
func NewEncryptedClient() (*EncryptedClient, error) {
inner, err := NewClient()
if err != nil {
return nil, fmt.Errorf("encrypted cdn client: %w", err)
}
return NewEncryptedClientFromInner(inner)
}
// NewEncryptedClientFromInner wraps an existing CDN client with encryption.
func NewEncryptedClientFromInner(inner *Client) (*EncryptedClient, error) {
recipients, err := encryption.BuildRecipients()
if err != nil {
return nil, fmt.Errorf("encrypted cdn client: %w", err)
}
return &EncryptedClient{
Inner: inner,
Recipients: recipients,
IdentityPath: encryption.CDNIdentityPath(),
}, nil
}
// ── Encrypted Push/Pull ──────────────────────────────────────────────────────
// PushBlob encrypts plaintext data and uploads the ciphertext to the CDN.
// The hash parameter is the SHA-256 of the PLAINTEXT (for CAS addressing).
// The CDN stores the ciphertext keyed by the plaintext hash.
func (ec *EncryptedClient) PushBlob(hash string, plaintext []byte) error {
// Verify plaintext hash matches
actualHash := encSha256Hex(plaintext)
if actualHash != hash {
return fmt.Errorf("encrypted push: hash mismatch (expected %s, got %s)", hash[:12], actualHash[:12])
}
// Encrypt
ciphertext, err := encryption.Encrypt(plaintext, ec.Recipients)
if err != nil {
return fmt.Errorf("encrypted push %s: %w", hash[:12], err)
}
// Upload ciphertext — we bypass the inner client's hash check since the
// ciphertext hash won't match the plaintext hash. We use the raw HTTP upload.
return ec.pushRawBlob(hash, ciphertext)
}
// PullBlob downloads ciphertext from the CDN, decrypts it, and returns plaintext.
// The hash is verified against the decrypted plaintext.
func (ec *EncryptedClient) PullBlob(hash string) ([]byte, error) {
// Download raw (skip inner client's integrity check since it's ciphertext)
ciphertext, err := ec.pullRawBlob(hash)
if err != nil {
return nil, err
}
// Decrypt
plaintext, err := encryption.Decrypt(ciphertext, ec.IdentityPath)
if err != nil {
return nil, fmt.Errorf("encrypted pull %s: %w", hash[:12], err)
}
// Verify plaintext integrity
actualHash := encSha256Hex(plaintext)
if actualHash != hash {
return nil, fmt.Errorf("encrypted pull %s: plaintext integrity check failed (got %s)", hash[:12], actualHash[:12])
}
return plaintext, nil
}
// BlobExists checks if a blob exists on the CDN (delegates to inner client).
func (ec *EncryptedClient) BlobExists(hash string) (bool, error) {
return ec.Inner.BlobExists(hash)
}
// PullManifest downloads a manifest (NOT encrypted — manifests contain only hashes).
func (ec *EncryptedClient) PullManifest(name string) (*Manifest, error) {
return ec.Inner.PullManifest(name)
}
// PushManifest uploads a manifest (NOT encrypted).
func (ec *EncryptedClient) PushManifest(name string, manifest *Manifest) error {
return ec.Inner.PushManifest(name, manifest)
}
// ── Raw HTTP Operations ──────────────────────────────────────────────────────
// pushRawBlob uploads raw bytes to the CDN without hash verification.
// Used for ciphertext upload where the hash is of the plaintext.
func (ec *EncryptedClient) pushRawBlob(hash string, data []byte) error {
if ec.Inner.StorageAPIKey == "" {
return fmt.Errorf("cdn push blob: StorageAPIKey not configured")
}
if ec.Inner.StorageZoneName == "" {
return fmt.Errorf("cdn push blob: StorageZoneName not configured")
}
url := fmt.Sprintf("https://%s.storage.bunnycdn.com/%s/sha256:%s",
ec.Inner.Region, ec.Inner.StorageZoneName, hash)
req, err := http.NewRequest(http.MethodPut, url, strings.NewReader(string(data)))
if err != nil {
return fmt.Errorf("cdn push blob %s: create request: %w", hash[:12], err)
}
req.Header.Set("AccessKey", ec.Inner.StorageAPIKey)
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = int64(len(data))
resp, err := ec.Inner.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("cdn push blob %s: %w", hash[:12], err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("cdn push blob %s: HTTP %d: %s", hash[:12], resp.StatusCode, string(body))
}
return nil
}
// pullRawBlob downloads raw bytes from the CDN without hash verification.
// Used for ciphertext download where the hash is of the plaintext.
func (ec *EncryptedClient) pullRawBlob(hash string) ([]byte, error) {
url := fmt.Sprintf("%s/sha256:%s", ec.Inner.BlobsBaseURL, hash)
resp, err := ec.Inner.HTTPClient.Get(url)
if err != nil {
return nil, fmt.Errorf("cdn pull blob %s: %w", hash[:12], err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cdn pull blob %s: HTTP %d", hash[:12], resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cdn pull blob %s: read body: %w", hash[:12], err)
}
return data, nil
}
// ── Helpers ──────────────────────────────────────────────────────────────────
func encSha256Hex(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}