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
+78
View File
@@ -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()