feat: vault service — Argon2id key derivation + AES-256-GCM encrypt/decrypt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
62133d8966
commit
4c32694a52
6
go.mod
6
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
|
||||
|
||||
20
go.sum
20
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=
|
||||
|
||||
95
internal/vault/service.go
Normal file
95
internal/vault/service.go
Normal file
@ -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
|
||||
}
|
||||
104
internal/vault/service_test.go
Normal file
104
internal/vault/service_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user