Parse age_recipients at config load and never echo the entry (closes #153)
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- 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. Model: opus-4-8
This commit was merged in pull request #187.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/chunker"
|
||||
@@ -178,6 +179,83 @@ func TestValidateBlobSizeLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgeRecipients checks that recipients are parsed at config load
|
||||
// (a bad entry fails immediately, not mid-backup) and that no invalid entry —
|
||||
// least of all a pasted secret key — is echoed in the error.
|
||||
func TestValidateAgeRecipients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseConfig := func(recipients []string) *Config {
|
||||
return &Config{
|
||||
AgeRecipients: recipients,
|
||||
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
|
||||
StorageURL: "file:///tmp/vaultik-test-store",
|
||||
ChunkSize: Size(10 * 1024 * 1024),
|
||||
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024),
|
||||
CompressionLevel: 3,
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
recipients []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "config init placeholder is rejected",
|
||||
recipients: []string{"age1REPLACE_WITH_YOUR_PUBLIC_KEY"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ssh-ed25519 recipient is rejected",
|
||||
recipients: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexamplekeydata"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "truncated age1 string is rejected",
|
||||
recipients: []string{"age1short"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "secret key passed as recipient is rejected",
|
||||
recipients: []string{testIntegrationAgePrivateKey},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "two valid recipients are accepted",
|
||||
recipients: []string{testSneakAgePublicKey, testIntegrationAgePublicKey},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := baseConfig(tt.recipients).Validate()
|
||||
if !tt.wantErr {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Validate() returned nil, want error")
|
||||
}
|
||||
|
||||
// The entry itself must never appear in the error, since a
|
||||
// recipient string can be a secret key.
|
||||
for _, recipient := range tt.recipients {
|
||||
if strings.Contains(err.Error(), recipient) {
|
||||
t.Fatalf("Validate() error echoed the recipient value: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractAgeSecretKey tests extraction of AGE-SECRET-KEY from various inputs
|
||||
func TestExtractAgeSecretKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
Reference in New Issue
Block a user