Parse the age identity key once and accept every identity in it (closes #165)
check / check (pull_request) Successful in 2m29s

Restore and verify --deep now parse the configured age secret key a
single time through a new internal helper that uses age.ParseIdentities
and hands every identity to age.Decrypt. A key file with several
identities (a whole age-keygen file) is fully accepted, so a blob
encrypted to any of its recipients decrypts, not just the first.

The helper is the first step of both commands, so a missing or
unparseable key now fails before anything is downloaded. Its error names
the configuration source (VAULTIK_AGE_SECRET_KEY or age_secret_key) and
never echoes the key value. config.extractAgeSecretKey and its silent
fallback are removed; the key is stored raw and parsed only where
decryption happens, so backup, list and prune are unaffected.

README, the restore help, and the missing-key error now show the key
read from a file with $(cat ...) rather than typed literally, keeping it
out of shell history, and say the variable may hold the whole key file.

Model: opus-4-8
This commit is contained in:
2026-09-22 13:24:03 +00:00
parent bd9656dbd4
commit 65d4bc572d
11 changed files with 329 additions and 116 deletions
+20 -43
View File
@@ -1,4 +1,4 @@
package config //nolint:testpackage // exercises unexported extractAgeSecretKey
package config //nolint:testpackage // exercises unexported source constants
import (
"errors"
@@ -298,53 +298,31 @@ func TestValidateAgeRecipients(t *testing.T) {
}
}
// TestExtractAgeSecretKey tests extraction of AGE-SECRET-KEY from various inputs
func TestExtractAgeSecretKey(t *testing.T) {
// TestAgeSecretKeySourceName checks the name reported for the configured
// age secret key: the recorded source when Load set one, and the
// config-file field name for a Config built directly (as in tests).
func TestAgeSecretKeySourceName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expected string
name string
source string
want string
}{
{
name: "plain key",
input: testIntegrationAgePrivateKey,
expected: testIntegrationAgePrivateKey,
name: "unset defaults to config field",
source: "",
want: ageSecretKeySourceConfig,
},
{
name: "key with trailing newline",
input: testIntegrationAgePrivateKey + "\n",
expected: testIntegrationAgePrivateKey,
name: "environment source",
source: ageSecretKeySourceEnv,
want: ageSecretKeySourceEnv,
},
{
name: "full age-keygen output",
input: "# created: 2025-01-14T12:00:00Z\n" +
"# public key: " + testIntegrationAgePublicKey + "\n" +
testIntegrationAgePrivateKey + "\n",
expected: testIntegrationAgePrivateKey,
},
{
name: "age-keygen output with extra blank lines",
input: "# created: 2025-01-14T12:00:00Z\n" +
"# public key: " + testIntegrationAgePublicKey + "\n\n" +
testIntegrationAgePrivateKey + "\n\n",
expected: testIntegrationAgePrivateKey,
},
{
name: "key with leading whitespace",
input: " " + testIntegrationAgePrivateKey + " ",
expected: testIntegrationAgePrivateKey,
},
{
name: "empty input",
input: "",
expected: "",
},
{
name: "only comments",
input: "# this is a comment\n# another comment",
expected: "# this is a comment\n# another comment",
name: "config-file source",
source: ageSecretKeySourceConfig,
want: ageSecretKeySourceConfig,
},
}
@@ -352,10 +330,9 @@ func TestExtractAgeSecretKey(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := extractAgeSecretKey(tt.input)
if result != tt.expected {
t.Errorf("extractAgeSecretKey(%q) = %q, want %q",
tt.input, result, tt.expected)
cfg := &Config{AgeSecretKeySource: tt.source}
if got := cfg.AgeSecretKeySourceName(); got != tt.want {
t.Errorf("AgeSecretKeySourceName() = %q, want %q", got, tt.want)
}
})
}