mirror of
https://github.com/hanzoai/llm-toolchain.git
synced 2026-10-30 18:29:08 +00:00
154 lines
5.1 KiB
Go
154 lines
5.1 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
)
|
|
|
|
// SecretBox encrypts LLM provider credentials at rest. It is the SAME
|
|
// AES-256-GCM + HMAC key-ladder the Base vault plugin uses (UserShard.Encrypt /
|
|
// deriveOrgKEK / deriveUserDEK), lifted here as a VALUE rather than coupled to
|
|
// the vault plugin's unexported *plugin type: the secret-at-rest primitive is
|
|
// "AES-256-GCM under an org-derived DEK", and that primitive lives wherever it
|
|
// is needed. One construction, one place.
|
|
//
|
|
// Master KEK (from KMS in prod; env/ephemeral in dev)
|
|
// └── Org KEK = HMAC-SHA256(master, "vault:org:" + orgID)
|
|
// └── DEK = HMAC-SHA256(orgKEK, "vault:llmtc:secret")
|
|
// └── per-secret AES-256-GCM, random 12-byte nonce (prepended)
|
|
//
|
|
// The DEK label is service-scoped ("vault:llmtc:secret") so the same master KEK
|
|
// yields a key distinct from any other Base vault consumer for the same org —
|
|
// compromise containment without a separate master.
|
|
type SecretBox struct {
|
|
dek []byte // 32-byte data encryption key (AES-256)
|
|
}
|
|
|
|
// NewSecretBox derives the LlmToolchain secret DEK from the master KEK and org.
|
|
// masterKey MUST be 32 bytes — the same invariant the vault plugin enforces
|
|
// (vault.Config.validate). Returns an error rather than panicking so the caller
|
|
// (main) fails loudly at boot with a clear message.
|
|
func NewSecretBox(masterKey []byte, orgID string) (*SecretBox, error) {
|
|
if len(masterKey) != 32 {
|
|
return nil, fmt.Errorf("secretbox: masterKey must be 32 bytes, got %d", len(masterKey))
|
|
}
|
|
if orgID == "" {
|
|
return nil, fmt.Errorf("secretbox: orgID is required")
|
|
}
|
|
orgKEK := hmacSum(masterKey, "vault:org:"+orgID)
|
|
dek := hmacSum(orgKEK, "vault:llmtc:secret")
|
|
return &SecretBox{dek: dek}, nil
|
|
}
|
|
|
|
// Seal encrypts plaintext and returns a base64-std string suitable for storage
|
|
// in a SQLite text column. Output = base64(nonce || AES-256-GCM ciphertext),
|
|
// byte-identical in scheme to vault UserShard.Encrypt.
|
|
func (s *SecretBox) Seal(plaintext string) (string, error) {
|
|
gcm, err := s.gcm()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return "", err
|
|
}
|
|
ct := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
|
return base64.StdEncoding.EncodeToString(ct), nil
|
|
}
|
|
|
|
// Open reverses Seal: base64-decode, split nonce, AES-256-GCM open. Used only
|
|
// server-side (e.g. deriving the Bedrock auth-method enum) — the plaintext
|
|
// secret is never written back onto the wire.
|
|
func (s *SecretBox) Open(sealed string) (string, error) {
|
|
raw, err := base64.StdEncoding.DecodeString(sealed)
|
|
if err != nil {
|
|
return "", fmt.Errorf("secretbox: bad base64: %w", err)
|
|
}
|
|
gcm, err := s.gcm()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
ns := gcm.NonceSize()
|
|
if len(raw) < ns {
|
|
return "", fmt.Errorf("secretbox: ciphertext too short")
|
|
}
|
|
pt, err := gcm.Open(nil, raw[:ns], raw[ns:], nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("secretbox: open: %w", err)
|
|
}
|
|
return string(pt), nil
|
|
}
|
|
|
|
func (s *SecretBox) gcm() (cipher.AEAD, error) {
|
|
block, err := aes.NewCipher(s.dek)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cipher.NewGCM(block)
|
|
}
|
|
|
|
func hmacSum(key []byte, label string) []byte {
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write([]byte(label))
|
|
return mac.Sum(nil)
|
|
}
|
|
|
|
// displaySecretKey returns the masked tail the tRPC model stored alongside the
|
|
// encrypted secret (getDisplaySecretKey in the console router): "...abcd", or a
|
|
// "...ab" trailing-quote variant for JSON-credential providers (Bedrock/Vertex
|
|
// stored a `{...}` blob). Sentinels for default cloud credentials map to a
|
|
// human label exactly as the console did.
|
|
func displaySecretKey(secretKey string) string {
|
|
switch secretKey {
|
|
case bedrockUseDefaultCredentials:
|
|
return "Default AWS credentials"
|
|
case vertexUseDefaultCredentials:
|
|
return "Default GCP credentials (ADC)"
|
|
}
|
|
n := len(secretKey)
|
|
if n >= 2 && secretKey[n-2:] == `"}` {
|
|
if n >= 6 {
|
|
return "..." + secretKey[n-6:n-2]
|
|
}
|
|
return "..." + secretKey
|
|
}
|
|
if n >= 4 {
|
|
return "..." + secretKey[n-4:]
|
|
}
|
|
return "..." + secretKey
|
|
}
|
|
|
|
// bedrockAuthMethodFromSecret derives the SafeLlmApiKey authMethod enum exactly
|
|
// as the console did (getBedrockAuthMethod): only Bedrock keys carry it; the
|
|
// decrypted secret is inspected for shape, never returned. Empty string means
|
|
// "no auth method" (non-Bedrock, or unparseable).
|
|
func bedrockAuthMethodFromSecret(secret string) string {
|
|
switch secret {
|
|
case bedrockUseDefaultCredentials:
|
|
return "default-credentials"
|
|
}
|
|
var m map[string]any
|
|
if json.Unmarshal([]byte(secret), &m) == nil {
|
|
if _, hasAPIKey := m["apiKey"]; hasAPIKey {
|
|
return "api-key"
|
|
}
|
|
return "access-keys"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Credential sentinels (mirrors @hanzo/shared BEDROCK_USE_DEFAULT_CREDENTIALS /
|
|
// VERTEXAI_USE_DEFAULT_CREDENTIALS): a stored secret equal to one of these
|
|
// means "use the platform's default cloud credentials" rather than a literal key.
|
|
const (
|
|
bedrockUseDefaultCredentials = "__BEDROCK_USE_DEFAULT_CREDENTIALS__"
|
|
vertexUseDefaultCredentials = "__VERTEXAI_USE_DEFAULT_CREDENTIALS__"
|
|
)
|