Lock the state directory and write vault files atomically (closes #34)
check / check (push) Successful in 50s
check / check (push) Successful in 50s
Each command that changes the state directory holds one lock: flock(2) on `lock` in the state directory, dropped by the kernel if the process dies, or a process-wide mutex on the in-memory test filesystem. It covers the state directory, not each vault, because `currentvault`, `vault create` and cross-vault moves span vaults, and a lock file in a vault would be deleted by `vault remove` under a waiting command. Files go through `secret.WriteFileAtomic`; versions, new secrets and cross-vault copies are built in a temporary directory and renamed into place; removals rename out of the way first. An unlocker added under an existing unlocker's directory name is still rewritten file by file: #71. Model: opus-5-5
This commit is contained in:
+35
-48
@@ -310,30 +310,16 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
||||
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||
}
|
||||
|
||||
// Create/update current-unlocker file with just the unlocker name
|
||||
// Create or replace the current-unlocker file with just the unlocker
|
||||
// name. It is replaced in one rename, so it never goes missing.
|
||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||
|
||||
// Remove existing file if it exists
|
||||
exists, err := afero.Exists(v.fs, currentUnlockerPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if current-unlocker file exists: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
err = v.fs.Remove(currentUnlockerPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove existing current-unlocker file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get just the unlocker name (basename of the directory)
|
||||
unlockerName := filepath.Base(targetUnlockerDir)
|
||||
|
||||
// Write just the unlocker name to the file
|
||||
secret.Debug("Writing current-unlocker file", "unlocker_name", unlockerName)
|
||||
|
||||
err = afero.WriteFile(v.fs, currentUnlockerPath, []byte(unlockerName),
|
||||
secret.FilePerms)
|
||||
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath, []byte(unlockerName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create current-unlocker file: %w", err)
|
||||
}
|
||||
@@ -351,6 +337,14 @@ func (v *Vault) CreatePassphraseUnlocker(
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
// We need to get the long-term key (either from memory if unlocked, or
|
||||
// derive it). Getting it before anything is written means failing to
|
||||
// get it changes nothing, even when replacing the current unlocker.
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Create unlocker directory
|
||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
|
||||
|
||||
@@ -371,33 +365,7 @@ func (v *Vault) CreatePassphraseUnlocker(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create metadata
|
||||
metadata := UnlockerMetadata{
|
||||
Type: unlockerTypePassphrase,
|
||||
CreatedAt: time.Now(),
|
||||
Flags: []string{},
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||
|
||||
err = afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt long-term private key to this unlocker
|
||||
// We need to get the long-term key (either from memory if unlocked, or derive it)
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
@@ -409,11 +377,31 @@ func (v *Vault) CreatePassphraseUnlocker(
|
||||
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
|
||||
err = afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms)
|
||||
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(),
|
||||
Flags: []string{},
|
||||
}
|
||||
|
||||
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||
|
||||
err = secret.WriteFileAtomic(v.fs, metadataPath, metadataBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
// Create the unlocker instance
|
||||
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||
|
||||
@@ -467,9 +455,8 @@ func (v *Vault) writeUnlockerKeypair(
|
||||
// Write public key
|
||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||
|
||||
err := afero.WriteFile(v.fs, pubKeyPath,
|
||||
[]byte(unlockerIdentity.Recipient().String()),
|
||||
secret.FilePerms)
|
||||
err := secret.WriteFileAtomic(v.fs, pubKeyPath,
|
||||
[]byte(unlockerIdentity.Recipient().String()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write unlocker public key: %w", err)
|
||||
}
|
||||
@@ -488,7 +475,7 @@ func (v *Vault) writeUnlockerKeypair(
|
||||
// Write encrypted private key
|
||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||
|
||||
err = afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms)
|
||||
err = secret.WriteFileAtomic(v.fs, privKeyPath, encryptedPrivKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user