Parse the age identity key once and accept every identity in it (closes #165)
check / check (pull_request) Successful in 2m41s
check / check (push) Successful in 2m56s

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:
2026-09-22 15:45:27 +02:00
parent bd9656dbd4
commit d88ed64489
11 changed files with 329 additions and 116 deletions
+31 -19
View File
@@ -30,8 +30,13 @@ var (
errDecryptionKeyRequired = errors.New(
"decryption key required for restore\n\n" +
"Set the VAULTIK_AGE_SECRET_KEY environment variable to your " +
"age private key:\n" +
" export VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...'")
"age private key file:\n" +
" export VAULTIK_AGE_SECRET_KEY=\"$(cat vaultik_backup_private_key.txt)\"")
// errInvalidAgeSecretKey is returned when the configured key does not
// parse as any age identity. It names the source but never the value,
// which is secret, so the message is safe to print and log.
errInvalidAgeSecretKey = errors.New(
"configured age secret key holds no usable age identity")
errBlobMissingFromIndex = errors.New("blob hash missing from blob index")
errChunkNotInAnyBlob = errors.New("chunk not found in any blob")
errBlobIDNotInHashIndex = errors.New("blob id missing from hash index")
@@ -97,7 +102,7 @@ type RestoreResult struct {
func (v *Vaultik) Restore(opts *RestoreOptions) error {
startTime := time.Now()
identity, err := v.prepareRestoreIdentity()
identities, err := v.restoreIdentities()
if err != nil {
return err
}
@@ -111,7 +116,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
// Step 1: Download and decrypt the snapshot metadata database
log.Info("Downloading snapshot metadata...")
tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, identity)
tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, identities)
if err != nil {
return fmt.Errorf("downloading snapshot database: %w", err)
}
@@ -160,7 +165,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
}
// Step 5: Restore files
result, err := v.restoreAllFiles(files, repos, opts, identity, chunkToBlobMap)
result, err := v.restoreAllFiles(files, repos, opts, identities, chunkToBlobMap)
if err != nil {
return err
}
@@ -221,21 +226,28 @@ func (v *Vaultik) finishRestore(
return nil
}
// prepareRestoreIdentity validates that an age secret key is configured
// and parses it.
//
//nolint:ireturn // age.Identity is the decryption abstraction by design
func (v *Vaultik) prepareRestoreIdentity() (age.Identity, error) {
// restoreIdentities parses the configured age secret key once into every
// identity it contains. The value may be a single key line or a whole
// age-keygen file with several identities; all of them are returned so
// blobgen (via age.Decrypt) can read a blob encrypted to any of their
// recipients. This is the first step of both restore and deep verify, so
// a missing or unparseable key fails before anything is downloaded. The
// error names the configuration source but never the key value.
func (v *Vaultik) restoreIdentities() ([]age.Identity, error) {
if v.Config.AgeSecretKey == "" {
return nil, errDecryptionKeyRequired
}
identity, err := age.ParseX25519Identity(v.Config.AgeSecretKey)
// age.ParseIdentities skips comment and blank lines and rejects a
// malformed key. Its error can quote the offending line, so it is not
// wrapped here — that would leak the secret into the message.
identities, err := age.ParseIdentities(strings.NewReader(v.Config.AgeSecretKey))
if err != nil {
return nil, fmt.Errorf("parsing age secret key: %w", err)
return nil, fmt.Errorf("%w (source: %s)",
errInvalidAgeSecretKey, v.Config.AgeSecretKeySourceName())
}
return identity, nil
return identities, nil
}
// restoreAllFiles processes files in blob-locality order: drain every
@@ -248,7 +260,7 @@ func (v *Vaultik) restoreAllFiles(
files []*database.File,
repos *database.Repositories,
opts *RestoreOptions,
identity age.Identity,
identities []age.Identity,
chunkToBlobMap map[string]*database.BlobChunk,
) (*RestoreResult, error) {
result := &RestoreResult{}
@@ -302,7 +314,7 @@ func (v *Vaultik) restoreAllFiles(
ctx: v.ctx,
repos: repos,
opts: opts,
identity: identity,
identities: identities,
chunkToBlobMap: chunkToBlobMap,
blobByHash: blobByHash,
blobIDToHash: blobIDToHash,
@@ -616,7 +628,7 @@ func (v *Vaultik) handleRestoreVerification(
// for a remote-only snapshot) is used as-is, so a host with no local
// index can restore the snapshots it can only see on the store.
func (v *Vaultik) downloadSnapshotDB(
snapshotID string, identity age.Identity,
snapshotID string, identities []age.Identity,
) (*database.DB, string, error) {
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
if err != nil {
@@ -643,7 +655,7 @@ func (v *Vaultik) downloadSnapshotDB(
"size", ubytes(int64(len(encryptedData))))
// Decrypt and decompress using blobgen.Reader
blobReader, err := blobgen.NewReader(bytes.NewReader(encryptedData), identity)
blobReader, err := blobgen.NewReader(bytes.NewReader(encryptedData), identities...)
if err != nil {
return nil, "", fmt.Errorf("creating decryption reader: %w", err)
}
@@ -833,7 +845,7 @@ type restoreSession struct {
ctx context.Context //nolint:containedctx // per-restore state by design
repos *database.Repositories
opts *RestoreOptions
identity age.Identity
identities []age.Identity
chunkToBlobMap map[string]*database.BlobChunk
blobByHash map[string]*database.Blob
blobIDToHash map[string]string
@@ -1195,7 +1207,7 @@ func (s *restoreSession) downloadBlobToCache(
start := time.Now()
t0 := time.Now()
rc, err := s.v.FetchAndDecryptBlob(s.ctx, blobHash, expectedSize, s.identity)
rc, err := s.v.FetchAndDecryptBlob(s.ctx, blobHash, expectedSize, s.identities...)
fetchSetupDur := time.Since(t0)
if err != nil {