From 4c32694a52625252175a95aca70aa703f8e6d09e Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 17 Mar 2026 06:16:23 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20vault=20service=20=E2=80=94=20Argon2id?= =?UTF-8?q?=20key=20derivation=20+=20AES-256-GCM=20encrypt/decrypt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 6 +- go.sum | 20 +++---- internal/vault/service.go | 95 ++++++++++++++++++++++++++++++ internal/vault/service_test.go | 104 +++++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 internal/vault/service.go create mode 100644 internal/vault/service_test.go diff --git a/go.mod b/go.mod index 6c8d4a2..f5268a9 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.1 require ( github.com/wailsapp/wails/v3 v3.0.0-alpha.74 + golang.org/x/crypto v0.49.0 modernc.org/sqlite v1.46.2 ) @@ -45,10 +46,9 @@ require ( github.com/skeema/knownhosts v1.3.2 // indirect github.com/wailsapp/go-webview2 v1.0.23 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/text v0.35.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index cbb9d35..235d6ba 100644 --- a/go.sum +++ b/go.sum @@ -122,17 +122,17 @@ github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyG github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -145,11 +145,11 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/internal/vault/service.go b/internal/vault/service.go new file mode 100644 index 0000000..b82f89d --- /dev/null +++ b/internal/vault/service.go @@ -0,0 +1,95 @@ +package vault + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argonTime = 3 + argonMemory = 64 * 1024 + argonThreads = 4 + argonKeyLen = 32 +) + +func DeriveKey(password string, salt []byte) []byte { + return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) +} + +func GenerateSalt() ([]byte, error) { + salt := make([]byte, 32) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("generate salt: %w", err) + } + return salt, nil +} + +type VaultService struct { + key []byte +} + +func NewVaultService(key []byte) *VaultService { + return &VaultService{key: key} +} + +func (v *VaultService) Encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(v.key) + if err != nil { + return "", fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create GCM: %w", err) + } + + iv := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(iv); err != nil { + return "", fmt.Errorf("generate IV: %w", err) + } + + sealed := gcm.Seal(nil, iv, []byte(plaintext), nil) + + return fmt.Sprintf("v1:%s:%s", hex.EncodeToString(iv), hex.EncodeToString(sealed)), nil +} + +func (v *VaultService) Decrypt(encrypted string) (string, error) { + parts := strings.SplitN(encrypted, ":", 3) + if len(parts) != 3 || parts[0] != "v1" { + return "", errors.New("invalid encrypted format: expected v1:{iv}:{sealed}") + } + + iv, err := hex.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("decode IV: %w", err) + } + + sealed, err := hex.DecodeString(parts[2]) + if err != nil { + return "", fmt.Errorf("decode sealed data: %w", err) + } + + block, err := aes.NewCipher(v.key) + if err != nil { + return "", fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create GCM: %w", err) + } + + plaintext, err := gcm.Open(nil, iv, sealed, nil) + if err != nil { + return "", fmt.Errorf("decrypt: %w", err) + } + + return string(plaintext), nil +} diff --git a/internal/vault/service_test.go b/internal/vault/service_test.go new file mode 100644 index 0000000..5558048 --- /dev/null +++ b/internal/vault/service_test.go @@ -0,0 +1,104 @@ +package vault + +import ( + "strings" + "testing" +) + +func TestDeriveKeyConsistent(t *testing.T) { + salt := []byte("test-salt-exactly-32-bytes-long!") + key1 := DeriveKey("mypassword", salt) + key2 := DeriveKey("mypassword", salt) + + if len(key1) != 32 { + t.Errorf("key length = %d, want 32", len(key1)) + } + if string(key1) != string(key2) { + t.Error("same password+salt produced different keys") + } +} + +func TestDeriveKeyDifferentPasswords(t *testing.T) { + salt := []byte("test-salt-exactly-32-bytes-long!") + key1 := DeriveKey("password1", salt) + key2 := DeriveKey("password2", salt) + + if string(key1) == string(key2) { + t.Error("different passwords produced same key") + } +} + +func TestEncryptDecryptRoundTrip(t *testing.T) { + key := DeriveKey("testpassword", []byte("test-salt-exactly-32-bytes-long!")) + vs := NewVaultService(key) + + plaintext := "super-secret-ssh-key-data" + encrypted, err := vs.Encrypt(plaintext) + if err != nil { + t.Fatalf("Encrypt() error: %v", err) + } + + if !strings.HasPrefix(encrypted, "v1:") { + t.Errorf("encrypted does not start with v1: prefix: %q", encrypted[:10]) + } + + decrypted, err := vs.Decrypt(encrypted) + if err != nil { + t.Fatalf("Decrypt() error: %v", err) + } + + if decrypted != plaintext { + t.Errorf("Decrypt() = %q, want %q", decrypted, plaintext) + } +} + +func TestEncryptProducesDifferentCiphertexts(t *testing.T) { + key := DeriveKey("testpassword", []byte("test-salt-exactly-32-bytes-long!")) + vs := NewVaultService(key) + + enc1, _ := vs.Encrypt("same-data") + enc2, _ := vs.Encrypt("same-data") + + if enc1 == enc2 { + t.Error("two encryptions of same data produced identical ciphertext (IV reuse)") + } +} + +func TestDecryptWrongKey(t *testing.T) { + key1 := DeriveKey("password1", []byte("test-salt-exactly-32-bytes-long!")) + key2 := DeriveKey("password2", []byte("test-salt-exactly-32-bytes-long!")) + + vs1 := NewVaultService(key1) + vs2 := NewVaultService(key2) + + encrypted, _ := vs1.Encrypt("secret") + _, err := vs2.Decrypt(encrypted) + if err == nil { + t.Error("Decrypt() with wrong key should return error") + } +} + +func TestDecryptInvalidFormat(t *testing.T) { + key := DeriveKey("test", []byte("test-salt-exactly-32-bytes-long!")) + vs := NewVaultService(key) + + _, err := vs.Decrypt("not-valid-format") + if err == nil { + t.Error("Decrypt() with invalid format should return error") + } +} + +func TestGenerateSalt(t *testing.T) { + salt1, err := GenerateSalt() + if err != nil { + t.Fatalf("GenerateSalt() error: %v", err) + } + if len(salt1) != 32 { + t.Errorf("salt length = %d, want 32", len(salt1)) + } + + salt2, _ := GenerateSalt() + if string(salt1) == string(salt2) { + t.Error("two calls to GenerateSalt produced identical salt") + } +}