Files
metacrypt/clients/go/metacrypt.go
Kyle Isom 64d921827e Add MEK rotation, per-engine DEKs, and v2 ciphertext format (audit #6, #22)
Implement a two-level key hierarchy: the MEK now wraps per-engine DEKs
stored in a new barrier_keys table, rather than encrypting all barrier
entries directly. A v2 ciphertext format (0x02) embeds the key ID so the
barrier can resolve which DEK to use on decryption. v1 ciphertext remains
supported for backward compatibility.

Key changes:
- crypto: EncryptV2/DecryptV2/ExtractKeyID for v2 ciphertext with key IDs
- barrier: key registry (CreateKey, RotateKey, ListKeys, MigrateToV2, ReWrapKeys)
- seal: RotateMEK re-wraps DEKs without re-encrypting data
- engine: Mount auto-creates per-engine DEK
- REST + gRPC: barrier/keys, barrier/rotate-mek, barrier/rotate-key, barrier/migrate
- proto: BarrierService (v1 + v2) with ListKeys, RotateMEK, RotateKey, Migrate
- db: migration v2 adds barrier_keys table

Also includes: security audit report, CSRF protection, engine design specs
(sshca, transit, user), path-bound AAD migration tool, policy engine
enhancements, and ARCHITECTURE.md updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:27:44 -07:00

168 lines
4.8 KiB
Go

package metacryptclient
import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"time"
)
// loginMCIAS authenticates against the MCIAS server and returns a bearer token.
func loginMCIAS(ctx context.Context, client *http.Client, mciasURL, username, password, totpCode string) (token string, exp time.Time, err error) {
body, _ := json.Marshal(map[string]string{
"username": username,
"password": password,
"totp_code": totpCode,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, mciasURL+"/v1/auth/login", bytes.NewReader(body))
if err != nil {
return "", time.Time{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", time.Time{}, fmt.Errorf("MCIAS login request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", time.Time{}, fmt.Errorf("MCIAS login: unexpected status %d", resp.StatusCode)
}
var result struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", time.Time{}, fmt.Errorf("MCIAS login: decode response: %w", err)
}
if result.Token == "" {
return "", time.Time{}, fmt.Errorf("MCIAS login: empty token in response")
}
var expTime time.Time
if result.ExpiresAt != "" {
expTime, _ = time.Parse(time.RFC3339, result.ExpiresAt)
}
return result.Token, expTime, nil
}
// fetchEAB calls POST /v1/acme/{mount}/eab on Metacrypt and returns the EAB kid and HMAC key.
func fetchEAB(ctx context.Context, client *http.Client, metacryptURL, mount, token string) (kid string, hmacKey []byte, err error) {
url := metacryptURL + "/v1/acme/" + mount + "/eab"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, http.NoBody)
if err != nil {
return "", nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", nil, fmt.Errorf("fetch EAB: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return "", nil, fmt.Errorf("fetch EAB: unexpected status %d: %s", resp.StatusCode, body)
}
var result struct {
KID string `json:"kid"`
HMACKey []byte `json:"hmac_key"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", nil, fmt.Errorf("fetch EAB: decode response: %w", err)
}
return result.KID, result.HMACKey, nil
}
// buildHTTPClient creates an HTTP client that optionally trusts a custom CA.
func buildHTTPClient(caCertPath string) *http.Client {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS13}
if caCertPath != "" {
pool := x509.NewCertPool()
if data, err := os.ReadFile(caCertPath); err == nil {
pool.AppendCertsFromPEM(data)
tlsCfg.RootCAs = pool
}
}
return &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsCfg},
Timeout: 30 * time.Second,
}
}
// generateKey generates a certificate key of the requested type.
// Supported types: "EC256" (default), "EC384", "RSA2048", "RSA4096".
func generateKey(keyType string) (crypto.Signer, error) {
switch keyType {
case "", "EC256":
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
case "EC384":
return ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
case "RSA2048":
return rsa.GenerateKey(rand.Reader, 2048)
case "RSA4096":
return rsa.GenerateKey(rand.Reader, 4096)
default:
return nil, fmt.Errorf("unsupported key type: %s", keyType)
}
}
// buildCSR creates a DER-encoded certificate signing request.
func buildCSR(key crypto.Signer, domains []string) ([]byte, error) {
if len(domains) == 0 {
return nil, fmt.Errorf("at least one domain required")
}
template := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: domains[0]},
DNSNames: domains,
}
return x509.CreateCertificateRequest(rand.Reader, template, key)
}
// encodeCertChainPEM encodes a chain of DER-encoded certificates to PEM.
func encodeCertChainPEM(chain [][]byte) []byte {
var buf bytes.Buffer
for _, der := range chain {
pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der})
}
return buf.Bytes()
}
// encodeKeyPEM encodes a private key to PEM (PKCS8 format).
func encodeKeyPEM(key crypto.Signer) ([]byte, error) {
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil
}
// parseCertExpiry parses the expiry time from a DER-encoded certificate.
func parseCertExpiry(der []byte) (time.Time, error) {
cert, err := x509.ParseCertificate(der)
if err != nil {
return time.Time{}, err
}
return cert.NotAfter, nil
}