Parse the age identity key once and accept every identity in it (closes #165)
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:
+31
-19
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user