fix: unlock legacy vault kdf files

This commit is contained in:
mirivlad 2026-05-28 10:16:14 +08:00
parent 0be1e93bb7
commit 257ce79d42
2 changed files with 114 additions and 24 deletions

View File

@ -61,6 +61,11 @@ type secretRecord struct {
plaintext []byte plaintext []byte
} }
type derivedKey struct {
key []byte
legacy bool
}
type SecretMeta struct { type SecretMeta struct {
ID string ID string
Alias string Alias string
@ -164,22 +169,19 @@ func (v *Vault) Unlock(masterPassword string) error {
return fmt.Errorf("decode salt: %w", err) return fmt.Errorf("decode salt: %w", err)
} }
key := argon2.IDKey([]byte(masterPassword), salt, uint32(vf.KDF.Iterations), uint32(vf.KDF.MemoryKiB), uint8(vf.KDF.Parallelism), keyLen) candidate, err := deriveValidKey([]byte(masterPassword), salt, vf)
if err != nil {
return err
}
key := candidate.key
if vf.Verifier != nil { if len(vf.Records) == 0 && vf.Verifier == nil {
if err := verifyRecord(key, *vf.Verifier); err != nil {
return fmt.Errorf("invalid master password")
}
} else if len(vf.Records) > 0 {
if _, err := decryptRecord(key, vf.Records[0]); err != nil {
return fmt.Errorf("invalid master password")
}
} else {
return fmt.Errorf("vault cannot verify master password; recreate empty vault") return fmt.Errorf("vault cannot verify master password; recreate empty vault")
} }
v.masterKey = key v.masterKey = key
v.records = make(map[string]secretRecord) v.records = make(map[string]secretRecord)
v.modified = candidate.legacy
for _, rec := range vf.Records { for _, rec := range vf.Records {
plaintext, err := decryptRecord(key, rec) plaintext, err := decryptRecord(key, rec)
@ -531,6 +533,44 @@ func decryptRecord(key []byte, rec Record) ([]byte, error) {
return plaintext, nil return plaintext, nil
} }
func deriveValidKey(masterPassword, salt []byte, vf VaultFile) (derivedKey, error) {
key := deriveKey(masterPassword, salt, vf.KDF.MemoryKiB, vf.KDF.Iterations, vf.KDF.Parallelism)
if canDecryptVaultFile(key, vf) {
return derivedKey{key: key}, nil
}
clearBytes(key)
if shouldTryLegacyKDF(vf) {
legacyMemoryKiB := vf.KDF.MemoryKiB * 1024
key = deriveKey(masterPassword, salt, legacyMemoryKiB, vf.KDF.Iterations, vf.KDF.Parallelism)
if canDecryptVaultFile(key, vf) {
return derivedKey{key: key, legacy: true}, nil
}
clearBytes(key)
}
return derivedKey{}, fmt.Errorf("invalid master password")
}
func shouldTryLegacyKDF(vf VaultFile) bool {
return vf.Verifier == nil && vf.KDF.MemoryKiB > 0 && vf.KDF.MemoryKiB <= 4096
}
func deriveKey(masterPassword, salt []byte, memoryKiB, iterations, parallelism int) []byte {
return argon2.IDKey(masterPassword, salt, uint32(iterations), uint32(memoryKiB), uint8(parallelism), keyLen)
}
func canDecryptVaultFile(key []byte, vf VaultFile) bool {
if vf.Verifier != nil {
return verifyRecord(key, *vf.Verifier) == nil
}
if len(vf.Records) > 0 {
_, err := decryptRecord(key, vf.Records[0])
return err == nil
}
return false
}
func newVerifierRecord(key []byte) (Record, error) { func newVerifierRecord(key []byte) (Record, error) {
rec, err := encryptRecord(key, verifierID, []byte(verifierPlaintext)) rec, err := encryptRecord(key, verifierID, []byte(verifierPlaintext))
if err != nil { if err != nil {
@ -568,22 +608,22 @@ func VerifyPassword(path string, masterPassword string) (bool, error) {
return false, err return false, err
} }
key := argon2.IDKey([]byte(masterPassword), salt, uint32(vf.KDF.Iterations), uint32(vf.KDF.MemoryKiB), uint8(vf.KDF.Parallelism), keyLen) candidate, err := deriveValidKey([]byte(masterPassword), salt, vf)
defer func() { if err != nil {
for i := range key {
key[i] = 0
}
}()
if vf.Verifier != nil {
return verifyRecord(key, *vf.Verifier) == nil, nil
}
if len(vf.Records) == 0 {
return false, nil return false, nil
} }
_, err = decryptRecord(key, vf.Records[0]) key := candidate.key
return err == nil, nil defer func() {
clearBytes(key)
}()
return true, nil
}
func clearBytes(data []byte) {
for i := range data {
data[i] = 0
}
} }
// Constant-time comparison to prevent timing attacks // Constant-time comparison to prevent timing attacks

View File

@ -97,6 +97,56 @@ func TestLegacyVaultWithRecordsStillVerifiesByFirstRecord(t *testing.T) {
} }
} }
func TestLegacyVaultWithPreReductionKDFStillUnlocks(t *testing.T) {
path := filepath.Join(t.TempDir(), "vault.bin")
salt := []byte("12345678901234567890123456789012")
key := argon2.IDKey([]byte("correct horse"), salt, 2, 1024, 1, keyLen)
rec, err := encryptRecord(key, "server:test:ssh_password", []byte("secret"))
if err != nil {
t.Fatalf("encrypt legacy record: %v", err)
}
data, err := json.Marshal(VaultFile{
Version: currentVersion,
KDF: KDFMeta{
Name: "argon2id",
MemoryKiB: 1,
Iterations: 2,
Parallelism: 1,
Salt: base64.StdEncoding.EncodeToString(salt),
},
Records: []Record{rec},
})
if err != nil {
t.Fatalf("marshal legacy vault: %v", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write legacy vault: %v", err)
}
ok, err := VerifyPassword(path, "correct horse")
if err != nil {
t.Fatalf("verify legacy vault: %v", err)
}
if !ok {
t.Fatal("expected legacy vault using pre-reduction KDF to accept correct password")
}
v := New(path)
if err := v.Unlock("correct horse"); err != nil {
t.Fatalf("unlock legacy vault using pre-reduction KDF: %v", err)
}
secret, err := v.Get("server:test:ssh_password")
if err != nil {
t.Fatalf("get legacy secret: %v", err)
}
if string(secret) != "secret" {
t.Fatalf("unexpected legacy secret: %q", secret)
}
}
func TestLegacyEmptyVaultWithoutVerifierCannotUnlock(t *testing.T) { func TestLegacyEmptyVaultWithoutVerifierCannotUnlock(t *testing.T) {
path := filepath.Join(t.TempDir(), "vault.bin") path := filepath.Join(t.TempDir(), "vault.bin")
salt := []byte("12345678901234567890123456789012") salt := []byte("12345678901234567890123456789012")