Parse the age identity key once and accept every identity in it (closes #165)
Restore and verify --deep now parse the configured age secret key a single time through a new 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 fails before anything is downloaded. Its error names the config source 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. README, the restore help, and the missing-key error now read the key from a file with \$(cat ...) rather than typed literally, keeping it out of shell history. Model: opus-4-8
This commit was merged in pull request #196.
This commit is contained in:
+43
-22
@@ -135,6 +135,26 @@ func (c *Config) SnapshotNames() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// Names of the two places the age secret key can be configured, used by
|
||||
// AgeSecretKeySourceName for error messages that must not echo the value.
|
||||
//
|
||||
//nolint:gosec // G101: these are the names of the config sources, not a key
|
||||
const (
|
||||
ageSecretKeySourceEnv = "VAULTIK_AGE_SECRET_KEY"
|
||||
ageSecretKeySourceConfig = "age_secret_key"
|
||||
)
|
||||
|
||||
// AgeSecretKeySourceName returns the human name of where AgeSecretKey was
|
||||
// configured. A Config built directly (as in tests) has no recorded
|
||||
// source, so it reports the config-file field name.
|
||||
func (c *Config) AgeSecretKeySourceName() string {
|
||||
if c.AgeSecretKeySource != "" {
|
||||
return c.AgeSecretKeySource
|
||||
}
|
||||
|
||||
return ageSecretKeySourceConfig
|
||||
}
|
||||
|
||||
// Config represents the application configuration for Vaultik.
|
||||
// It defines all settings for backup operations, including source directories,
|
||||
// encryption recipients, storage configuration, and performance tuning parameters.
|
||||
@@ -144,8 +164,13 @@ func (c *Config) SnapshotNames() []string {
|
||||
type Config struct {
|
||||
AgeRecipients []string `yaml:"age_recipients"`
|
||||
AgeSecretKey string `yaml:"age_secret_key"`
|
||||
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
||||
ChunkSize Size `yaml:"chunk_size"`
|
||||
// AgeSecretKeySource names where AgeSecretKey was configured
|
||||
// ("VAULTIK_AGE_SECRET_KEY" or "age_secret_key") so a later parse
|
||||
// failure can name the source without echoing the secret value. It is
|
||||
// set by Load and never read from or written to the config file.
|
||||
AgeSecretKeySource string `yaml:"-"`
|
||||
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
||||
ChunkSize Size `yaml:"chunk_size"`
|
||||
// Exclude holds global excludes applied to all snapshots.
|
||||
Exclude []string `yaml:"exclude"`
|
||||
Hostname string `yaml:"hostname"`
|
||||
@@ -254,10 +279,7 @@ func Load(path string) (*Config, error) {
|
||||
cfg.IndexPath = expandTilde(envIndexPath)
|
||||
}
|
||||
|
||||
// Check for environment variable override for AgeSecretKey
|
||||
if envAgeSecretKey := os.Getenv("VAULTIK_AGE_SECRET_KEY"); envAgeSecretKey != "" {
|
||||
cfg.AgeSecretKey = extractAgeSecretKey(envAgeSecretKey)
|
||||
}
|
||||
cfg.setAgeSecretKey()
|
||||
|
||||
// Get hostname if not set
|
||||
if cfg.Hostname == "" {
|
||||
@@ -379,6 +401,21 @@ func validateAgeRecipient(recipient string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAgeSecretKey records the age secret key and where it came from. The
|
||||
// value is stored raw and parsed only where decryption happens
|
||||
// (internal/vaultik), so backup, list and prune keep working whatever the
|
||||
// field holds. The environment variable overrides the config-file field.
|
||||
func (c *Config) setAgeSecretKey() {
|
||||
if c.AgeSecretKey != "" {
|
||||
c.AgeSecretKeySource = ageSecretKeySourceConfig
|
||||
}
|
||||
|
||||
if env := os.Getenv("VAULTIK_AGE_SECRET_KEY"); env != "" {
|
||||
c.AgeSecretKey = env
|
||||
c.AgeSecretKeySource = ageSecretKeySourceEnv
|
||||
}
|
||||
}
|
||||
|
||||
// validateStorage validates storage configuration.
|
||||
// If StorageURL is set, it takes precedence. S3 URLs require credentials.
|
||||
// File URLs don't require any S3 configuration.
|
||||
@@ -435,22 +472,6 @@ func (c *Config) validateStorageURL() error {
|
||||
}
|
||||
}
|
||||
|
||||
// extractAgeSecretKey extracts the AGE-SECRET-KEY from the input using
|
||||
// the age library's parser, which handles comments and whitespace.
|
||||
func extractAgeSecretKey(input string) string {
|
||||
identities, err := age.ParseIdentities(strings.NewReader(input))
|
||||
if err != nil || len(identities) == 0 {
|
||||
// Fall back to trimmed input if parsing fails
|
||||
return strings.TrimSpace(input)
|
||||
}
|
||||
// Return the string representation of the first identity
|
||||
if id, ok := identities[0].(*age.X25519Identity); ok {
|
||||
return id.String()
|
||||
}
|
||||
|
||||
return strings.TrimSpace(input)
|
||||
}
|
||||
|
||||
// Module exports the config module for fx dependency injection.
|
||||
// It provides the Config type to other modules in the application.
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user