Leave no partial unlocker directory when adding an unlocker fails (closes #48)
check / check (push) Waiting to run

CreatePGPUnlocker looked up the GPG key's fingerprint, and the keychain
unlocker got the long-term key, only after writing part of the unlocker,
so a failure there left a directory with no metadata. Both now do every
step that can fail before writing anything. `secret unlocker add pgp`
looks the fingerprint up once, for its duplicate check, and passes it to
CreatePGPUnlocker to record. All four unlocker types write their files
through the new secret.WriteDir, which builds a new directory in a
temporary directory, renames it into place when complete and removes it
on a failure. A directory that already exists, as when an unlocker
replaces one of the same name, is written in place and never removed.

Model: opus-5-5
This commit was merged in pull request #90.
This commit is contained in:
2026-10-04 12:58:50 +02:00
parent 24d99819a3
commit 596b978cb1
11 changed files with 460 additions and 214 deletions
+23 -28
View File
@@ -357,26 +357,14 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, fmt.Errorf("failed to get long-term key: %w", err)
}
// Create unlocker directory
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
err = v.fs.MkdirAll(unlockerDir, secret.DirPerms)
if err != nil {
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
}
// Generate new age keypair for unlocker
unlockerIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
}
// Write the unlocker keypair (public and passphrase-encrypted private)
err = v.writeUnlockerKeypair(unlockerDir, unlockerIdentity, passphrase)
if err != nil {
return nil, err
}
// Encrypt long-term private key to this unlocker
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
defer ltPrivKeyBuffer.Destroy()
@@ -387,15 +375,6 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
}
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
err = secret.WriteFileAtomic(v.fs, ltPrivKeyPath, encryptedLtPrivKey)
if err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
// Write the metadata last: readers skip an unlocker directory without
// it, so an unlocker interrupted before this point is never used.
metadata := UnlockerMetadata{
Type: unlockerTypePassphrase,
CreatedAt: time.Now(),
@@ -407,11 +386,13 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
err = secret.WriteFileAtomic(v.fs, metadataPath, metadataBytes)
// Write the unlocker's files, the metadata last
err = secret.WriteDir(v.fs, unlockerDir, func(dir string) error {
return v.writeUnlockerFiles(dir, unlockerIdentity, passphrase,
encryptedLtPrivKey, metadataBytes)
})
if err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
return nil, err
}
// Create the unlocker instance
@@ -457,12 +438,14 @@ func (v *Vault) readUnlockerMetadata(unlockerDir string) (UnlockerMetadata, erro
return metadata, nil
}
// writeUnlockerKeypair writes the unlocker's public key and its
// passphrase-encrypted private key into the unlocker directory.
func (v *Vault) writeUnlockerKeypair(
// writeUnlockerFiles writes the files of a passphrase unlocker into
// unlockerDir: its public key, its passphrase-encrypted private key, the
// long-term private key encrypted to it, and its metadata, last.
func (v *Vault) writeUnlockerFiles(
unlockerDir string,
unlockerIdentity *age.X25519Identity,
passphrase *memguard.LockedBuffer,
encryptedLtPrivKey, metadataBytes []byte,
) error {
// Write public key
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
@@ -492,5 +475,17 @@ func (v *Vault) writeUnlockerKeypair(
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
}
err = secret.WriteFileAtomic(v.fs,
filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
err = secret.WriteFileAtomic(v.fs,
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes)
if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err)
}
return nil
}