Found by the security review #73. Severity: high (a private key can reach stderr, logs and cron mail after one plausible operator mistake).
What is wrong
Config.Validate only checks that age_recipients is non-empty (internal/config/config.go:296). The first real parse is in blobgen.NewWriter (internal/blobgen/writer.go:61-63), reached when the packer starts its first blob, after the snapshot row exists and the source tree has been walked.
The error text carries the whole entry twice: vaultik's own %s, and age's error, which quotes its input in every failure branch. If someone pastes an AGE-SECRET-KEY-1... string into age_recipients, the private key is printed to stderr and the log (internal/cli/snapshot.go:98-99), once per file under --skip-errors (internal/snapshot/scanner.go:1308-1311). The same %s pattern sits in internal/crypto/encryption.go:42 and :148.
Lesser effects of the late parse: the placeholder written by config init (age1REPLACE_WITH_YOUR_PUBLIC_KEY, internal/cli/config.go:53) passes config load; ssh and plugin recipients are rejected only mid-run; a typo in a working config is found at the next scheduled backup.
It fails closed: nothing is ever written unencrypted.
Acceptable
Config.Validate parses every entry with age.ParseX25519Recipient. On failure the error names the position (age_recipients[1]), never the value, and does not wrap age's error. It says only X25519 age1... recipients are supported.
An entry starting with AGE-SECRET-KEY- (compare case-insensitively) gets its own message: a secret key was given where a public key belongs.
The value and the wrapped age error are removed from the remaining parse sites too (writer.go:63, encryption.go:42, :148), since callers that skip config.Load still reach them.
test/config.yaml:3 holds a placeholder second recipient (age1otherpubkey...); replace it with a valid X25519 public key and keep the two-recipient assertion in internal/config/config_test.go:49.
Definition of done
Tests: the config init placeholder, an ssh-ed25519 recipient, a truncated age1 string and a valid two-recipient list through Validate.
Test: a secret key passed as a recipient to Validate, blobgen.NewWriter and crypto.NewEncryptor; the key string does not appear in any returned error. (Skip the internal/crypto sites if that package has been removed by then.)
No existing assertion weakened; make check green.
Optional and separate: NewScanner returns nil after logging when the packer cannot be built (scanner.go:161-163) and its caller does not check; unreachable today.
Line numbers are as of next at 6fcd8e1.
model: fable-5-1
Found by the security review https://git.eeqj.de/sneak/vaultik/issues/73. Severity: **high** (a private key can reach stderr, logs and cron mail after one plausible operator mistake).
## What is wrong
`Config.Validate` only checks that `age_recipients` is non-empty (`internal/config/config.go:296`). The first real parse is in `blobgen.NewWriter` (`internal/blobgen/writer.go:61-63`), reached when the packer starts its first blob, after the snapshot row exists and the source tree has been walked.
The error text carries the whole entry twice: vaultik's own `%s`, and age's error, which quotes its input in every failure branch. If someone pastes an `AGE-SECRET-KEY-1...` string into `age_recipients`, the private key is printed to stderr and the log (`internal/cli/snapshot.go:98-99`), once per file under `--skip-errors` (`internal/snapshot/scanner.go:1308-1311`). The same `%s` pattern sits in `internal/crypto/encryption.go:42` and `:148`.
Lesser effects of the late parse: the placeholder written by `config init` (`age1REPLACE_WITH_YOUR_PUBLIC_KEY`, `internal/cli/config.go:53`) passes config load; ssh and plugin recipients are rejected only mid-run; a typo in a working config is found at the next scheduled backup.
It fails closed: nothing is ever written unencrypted.
## Acceptable
- `Config.Validate` parses every entry with `age.ParseX25519Recipient`. On failure the error names the position (`age_recipients[1]`), never the value, and does not wrap age's error. It says only X25519 `age1...` recipients are supported.
- An entry starting with `AGE-SECRET-KEY-` (compare case-insensitively) gets its own message: a secret key was given where a public key belongs.
- The value and the wrapped age error are removed from the remaining parse sites too (`writer.go:63`, `encryption.go:42`, `:148`), since callers that skip `config.Load` still reach them.
- `test/config.yaml:3` holds a placeholder second recipient (`age1otherpubkey...`); replace it with a valid X25519 public key and keep the two-recipient assertion in `internal/config/config_test.go:49`.
## Definition of done
1. Tests: the `config init` placeholder, an `ssh-ed25519` recipient, a truncated `age1` string and a valid two-recipient list through `Validate`.
2. Test: a secret key passed as a recipient to `Validate`, `blobgen.NewWriter` and `crypto.NewEncryptor`; the key string does not appear in any returned error. (Skip the `internal/crypto` sites if that package has been removed by then.)
3. No existing assertion weakened; `make check` green.
Optional and separate: `NewScanner` returns nil after logging when the packer cannot be built (`scanner.go:161-163`) and its caller does not check; unreachable today.
Line numbers are as of `next` at `6fcd8e1`.
model: fable-5-1
Config.Validate now parses each age_recipients entry, so a bad recipient fails at config load rather than mid-backup. Errors name the position (age_recipients[N]), never the value, and do not wrap age's error; an entry beginning with AGE-SECRET-KEY- (case-insensitive) gets its own message. The value and wrapped age error are removed from the other parse sites too (blobgen.NewWriter, crypto.NewEncryptor/UpdateRecipients). test/config.yaml's placeholder second recipient is replaced with a valid X25519 key.
Tests cover the config init placeholder, an ssh-ed25519 recipient, a truncated age1 string, a valid two-recipient list, and that a secret key passed to Validate, blobgen.NewWriter, and crypto.NewEncryptor never appears in the returned error. make check is green.
Model: opus-4-8
Implemented in https://git.eeqj.de/sneak/vaultik/pulls/187.
`Config.Validate` now parses each `age_recipients` entry, so a bad recipient fails at config load rather than mid-backup. Errors name the position (`age_recipients[N]`), never the value, and do not wrap age's error; an entry beginning with `AGE-SECRET-KEY-` (case-insensitive) gets its own message. The value and wrapped age error are removed from the other parse sites too (`blobgen.NewWriter`, `crypto.NewEncryptor`/`UpdateRecipients`). `test/config.yaml`'s placeholder second recipient is replaced with a valid X25519 key.
Tests cover the `config init` placeholder, an `ssh-ed25519` recipient, a truncated `age1` string, a valid two-recipient list, and that a secret key passed to `Validate`, `blobgen.NewWriter`, and `crypto.NewEncryptor` never appears in the returned error. `make check` is green.
Model: opus-4-8
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Found by the security review #73. Severity: high (a private key can reach stderr, logs and cron mail after one plausible operator mistake).
What is wrong
Config.Validateonly checks thatage_recipientsis non-empty (internal/config/config.go:296). The first real parse is inblobgen.NewWriter(internal/blobgen/writer.go:61-63), reached when the packer starts its first blob, after the snapshot row exists and the source tree has been walked.The error text carries the whole entry twice: vaultik's own
%s, and age's error, which quotes its input in every failure branch. If someone pastes anAGE-SECRET-KEY-1...string intoage_recipients, the private key is printed to stderr and the log (internal/cli/snapshot.go:98-99), once per file under--skip-errors(internal/snapshot/scanner.go:1308-1311). The same%spattern sits ininternal/crypto/encryption.go:42and:148.Lesser effects of the late parse: the placeholder written by
config init(age1REPLACE_WITH_YOUR_PUBLIC_KEY,internal/cli/config.go:53) passes config load; ssh and plugin recipients are rejected only mid-run; a typo in a working config is found at the next scheduled backup.It fails closed: nothing is ever written unencrypted.
Acceptable
Config.Validateparses every entry withage.ParseX25519Recipient. On failure the error names the position (age_recipients[1]), never the value, and does not wrap age's error. It says only X25519age1...recipients are supported.AGE-SECRET-KEY-(compare case-insensitively) gets its own message: a secret key was given where a public key belongs.writer.go:63,encryption.go:42,:148), since callers that skipconfig.Loadstill reach them.test/config.yaml:3holds a placeholder second recipient (age1otherpubkey...); replace it with a valid X25519 public key and keep the two-recipient assertion ininternal/config/config_test.go:49.Definition of done
config initplaceholder, anssh-ed25519recipient, a truncatedage1string and a valid two-recipient list throughValidate.Validate,blobgen.NewWriterandcrypto.NewEncryptor; the key string does not appear in any returned error. (Skip theinternal/cryptosites if that package has been removed by then.)make checkgreen.Optional and separate:
NewScannerreturns nil after logging when the packer cannot be built (scanner.go:161-163) and its caller does not check; unreachable today.Line numbers are as of
nextat6fcd8e1.model: fable-5-1
Implemented in #187.
Config.Validatenow parses eachage_recipientsentry, so a bad recipient fails at config load rather than mid-backup. Errors name the position (age_recipients[N]), never the value, and do not wrap age's error; an entry beginning withAGE-SECRET-KEY-(case-insensitive) gets its own message. The value and wrapped age error are removed from the other parse sites too (blobgen.NewWriter,crypto.NewEncryptor/UpdateRecipients).test/config.yaml's placeholder second recipient is replaced with a valid X25519 key.Tests cover the
config initplaceholder, anssh-ed25519recipient, a truncatedage1string, a valid two-recipient list, and that a secret key passed toValidate,blobgen.NewWriter, andcrypto.NewEncryptornever appears in the returned error.make checkis green.Model: opus-4-8