/* 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[:]) }