Parse age_recipients at config load and never echo the entry (closes #153)
check / check (pull_request) Successful in 2m18s

Config.Validate now parses every age_recipients entry with
age.ParseX25519Recipient, so a bad recipient fails at config load instead
of deep in a backup after the snapshot row and tree walk. On failure the
error names the position (age_recipients[N]) and never the value: a
recipient string can itself be a secret key an operator pasted by mistake,
and age's own error quotes its input. An entry starting with
AGE-SECRET-KEY- (compared case-insensitively) gets a specific message.

The remaining parse sites (blobgen.NewWriter, crypto NewEncryptor and
UpdateRecipients), reachable by callers that skip config.Load, likewise
drop the value and age's wrapped error, naming only the position.

test/config.yaml's placeholder second recipient is replaced with a valid
X25519 key so it still loads.

Model: opus-4-8
This commit is contained in:
2026-09-22 10:41:59 +00:00
parent b4654f8e52
commit e0e43548b7
7 changed files with 176 additions and 8 deletions
+38 -1
View File
@@ -22,6 +22,13 @@ import (
const appName = "vaultik"
// secretKeyPrefix marks an age secret (private) key. It is compared
// case-insensitively so a recipient entry that is actually a private key is
// caught and never passed to age or echoed back.
//
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
const secretKeyPrefix = "AGE-SECRET-KEY-"
// Defaults and validation bounds for tunable settings.
const (
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
@@ -38,6 +45,10 @@ var (
errNoConfigPath = errors.New("config path not provided")
errNoAgeRecipients = errors.New(
"at least one age_recipient is required (generate with: age-keygen)")
errRecipientIsSecretKey = errors.New(
"an age secret key was given where a public key (age1...) belongs")
errRecipientNotX25519 = errors.New(
"not a valid recipient; only X25519 age1... public keys are supported")
errNoSnapshots = errors.New(
"at least one snapshot must be configured (see config.example.yml)")
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
@@ -290,7 +301,9 @@ func Load(path string) (*Config, error) {
// Validate checks if the configuration is valid and complete.
// It ensures all required fields are present and have valid values:
// - At least one age recipient must be specified
// - At least one age recipient must be specified, and every recipient must
// parse as an X25519 age1... public key (so a bad entry fails at load, not
// mid-backup); errors name the position, never the value
// - At least one snapshot must be configured with at least one path
// - Storage must be configured (either storage_url or s3.* fields)
// - Chunk size must be at least 1MB
@@ -305,6 +318,13 @@ func (c *Config) Validate() error {
return errNoAgeRecipients
}
for i, recipient := range c.AgeRecipients {
err := validateAgeRecipient(recipient)
if err != nil {
return fmt.Errorf("age_recipients[%d]: %w", i, err)
}
}
if len(c.Snapshots) == 0 {
return errNoSnapshots
}
@@ -342,6 +362,23 @@ func (c *Config) Validate() error {
return nil
}
// validateAgeRecipient parses one age_recipients entry with the age library
// and returns a value-free error on failure. A recipient string can be
// sensitive (an operator may paste a secret key by mistake), so neither the
// entry nor age's own error (which quotes its input) is ever included.
func validateAgeRecipient(recipient string) error {
if strings.HasPrefix(strings.ToUpper(recipient), secretKeyPrefix) {
return errRecipientIsSecretKey
}
_, err := age.ParseX25519Recipient(recipient)
if err != nil {
return errRecipientNotX25519
}
return nil
}
// validateStorage validates storage configuration.
// If StorageURL is set, it takes precedence. S3 URLs require credentials.
// File URLs don't require any S3 configuration.