From 577b060b8a6d8ca589059d470b48a7b31056300d Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 10:55:54 +0000 Subject: [PATCH] feat: Extract AES-GCM into shared/cryptobox services/crypto.go keeps its function names and its KEY_ENCRYPTION_KEY lookup and delegates the cipher, so vantagectl's verify probe can decrypt with the same implementation rather than a second copy. --- server/internal/services/crypto.go | 48 +++--------------- shared/cryptobox/cryptobox.go | 67 +++++++++++++++++++++++++ shared/cryptobox/cryptobox_test.go | 79 ++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 40 deletions(-) create mode 100644 shared/cryptobox/cryptobox.go create mode 100644 shared/cryptobox/cryptobox_test.go diff --git a/server/internal/services/crypto.go b/server/internal/services/crypto.go index 64f82e1..dff6e7d 100644 --- a/server/internal/services/crypto.go +++ b/server/internal/services/crypto.go @@ -1,22 +1,23 @@ package services import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" "encoding/hex" "fmt" - "io" "os" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/cryptobox" ) +// encryptionKey reads KEY_ENCRYPTION_KEY. The cipher itself lives in +// shared/cryptobox so vantagectl's verify probe uses the same implementation +// rather than a second copy that can drift. func encryptionKey() ([]byte, error) { raw := os.Getenv("KEY_ENCRYPTION_KEY") if raw == "" { return nil, fmt.Errorf("KEY_ENCRYPTION_KEY is not set") } key, err := hex.DecodeString(raw) - if err != nil || len(key) != 32 { + if err != nil || len(key) != cryptobox.KeySize { return nil, fmt.Errorf("KEY_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)") } return key, nil @@ -27,20 +28,7 @@ func encryptString(plaintext string) (string, error) { if err != nil { return "", err } - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", err - } - sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil) - return hex.EncodeToString(sealed), nil + return cryptobox.Seal(key, plaintext) } func decryptString(ciphertextHex string) (string, error) { @@ -48,27 +36,7 @@ func decryptString(ciphertextHex string) (string, error) { if err != nil { return "", err } - data, err := hex.DecodeString(ciphertextHex) - if err != nil { - return "", fmt.Errorf("invalid ciphertext encoding") - } - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - nonceSize := gcm.NonceSize() - if len(data) < nonceSize { - return "", fmt.Errorf("ciphertext too short") - } - plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil) - if err != nil { - return "", fmt.Errorf("decryption failed") - } - return string(plaintext), nil + return cryptobox.Open(key, ciphertextHex) } func encryptPrivateKey(plaintext string) (string, error) { return encryptString(plaintext) } diff --git a/shared/cryptobox/cryptobox.go b/shared/cryptobox/cryptobox.go new file mode 100644 index 0000000..b7edb8b --- /dev/null +++ b/shared/cryptobox/cryptobox.go @@ -0,0 +1,67 @@ +// Package cryptobox is the AES-256-GCM primitive used for everything Vantage +// encrypts at rest: SSH private keys, key passphrases, vault secrets, OIDC +// client secrets and console credentials. +// +// It takes a raw key and reads no environment. Key sourcing belongs to the +// caller, because the two callers source it differently: the server reads +// KEY_ENCRYPTION_KEY at the point of use, while vantagectl is handed one. +package cryptobox + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "fmt" + "io" +) + +// KeySize is the only key length accepted. AES-256 by construction. +const KeySize = 32 + +func gcmFor(key []byte) (cipher.AEAD, error) { + if len(key) != KeySize { + return nil, fmt.Errorf("key must be %d bytes, got %d", KeySize, len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +// Seal encrypts plaintext and returns nonce||ciphertext, hex encoded. +func Seal(key []byte, plaintext string) (string, error) { + gcm, err := gcmFor(key) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + return hex.EncodeToString(gcm.Seal(nonce, nonce, []byte(plaintext), nil)), nil +} + +// Open reverses Seal. Every failure mode returns an error that does not +// distinguish a wrong key from corrupt data, because the caller cannot act on +// the difference and an oracle is worth avoiding for free. +func Open(key []byte, ciphertextHex string) (string, error) { + gcm, err := gcmFor(key) + if err != nil { + return "", err + } + data, err := hex.DecodeString(ciphertextHex) + if err != nil { + return "", fmt.Errorf("invalid ciphertext encoding") + } + n := gcm.NonceSize() + if len(data) < n { + return "", fmt.Errorf("ciphertext too short") + } + plaintext, err := gcm.Open(nil, data[:n], data[n:], nil) + if err != nil { + return "", fmt.Errorf("decryption failed") + } + return string(plaintext), nil +} diff --git a/shared/cryptobox/cryptobox_test.go b/shared/cryptobox/cryptobox_test.go new file mode 100644 index 0000000..a67e1ec --- /dev/null +++ b/shared/cryptobox/cryptobox_test.go @@ -0,0 +1,79 @@ +package cryptobox + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "testing" +) + +func testKey(t *testing.T) []byte { + t.Helper() + k := make([]byte, KeySize) + if _, err := rand.Read(k); err != nil { + t.Fatalf("rand: %v", err) + } + return k +} + +func TestSealOpenRoundTrip(t *testing.T) { + key := testKey(t) + sealed, err := Seal(key, "hunter2") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := hex.DecodeString(sealed); err != nil { + t.Fatalf("Seal output is not hex: %v", err) + } + if bytes.Contains([]byte(sealed), []byte("hunter2")) { + t.Fatal("plaintext appears in ciphertext") + } + got, err := Open(key, sealed) + if err != nil { + t.Fatalf("Open: %v", err) + } + if got != "hunter2" { + t.Fatalf("got %q, want %q", got, "hunter2") + } +} + +func TestSealIsNonDeterministic(t *testing.T) { + key := testKey(t) + a, err := Seal(key, "same") + if err != nil { + t.Fatalf("Seal: %v", err) + } + b, err := Seal(key, "same") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if a == b { + t.Fatal("two seals of the same plaintext are identical; nonce is not random") + } +} + +func TestOpenWrongKeyFails(t *testing.T) { + sealed, err := Seal(testKey(t), "secret") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := Open(testKey(t), sealed); err == nil { + t.Fatal("Open with the wrong key succeeded") + } +} + +func TestOpenRejectsBadInput(t *testing.T) { + key := testKey(t) + if _, err := Open(key, "not-hex"); err == nil { + t.Fatal("Open accepted non-hex input") + } + if _, err := Open(key, "abcd"); err == nil { + t.Fatal("Open accepted a ciphertext shorter than the nonce") + } +} + +func TestWrongKeySizeRejected(t *testing.T) { + if _, err := Seal(make([]byte, 16), "x"); err == nil { + t.Fatal("Seal accepted a 16-byte key") + } +}