Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25cc2cbe52 | ||
|
|
1a23fd3125 | ||
|
|
23dcea83f9 |
@@ -180,8 +180,8 @@ period.
|
|||||||
|
|
||||||
#### `secret version promote <secret-name> <version>`
|
#### `secret version promote <secret-name> <version>`
|
||||||
|
|
||||||
Promotes a specific version to current by updating the symlink. Does not modify
|
Promotes a specific version to current by rewriting the secret's `current` file
|
||||||
any timestamps, allowing for rollback scenarios.
|
to name it. Does not modify any timestamps, allowing for rollback scenarios.
|
||||||
|
|
||||||
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
||||||
|
|
||||||
@@ -280,8 +280,13 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
|
|
||||||
### Directory Structure
|
### Directory Structure
|
||||||
|
|
||||||
|
The state directory is `berlin.sneak.pkg.secret` in the user's configuration
|
||||||
|
directory: on Linux `$XDG_CONFIG_HOME`, or `~/.config` when that is unset; on
|
||||||
|
macOS `~/Library/Application Support`. When `SB_SECRET_STATE_DIR` is set, it is
|
||||||
|
the state directory instead. On Linux:
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.local/share/secret/
|
~/.config/berlin.sneak.pkg.secret/
|
||||||
├── vaults.d/
|
├── vaults.d/
|
||||||
│ ├── default/
|
│ ├── default/
|
||||||
│ │ ├── unlockers.d/
|
│ │ ├── unlockers.d/
|
||||||
@@ -294,12 +299,12 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
│ │ │ │ │ │ ├── pub.age # Version public key
|
│ │ │ │ │ │ ├── pub.age # Version public key
|
||||||
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
||||||
│ │ │ │ │ │ ├── value.age # Encrypted value
|
│ │ │ │ │ │ ├── value.age # Encrypted value
|
||||||
│ │ │ │ │ │ └── metadata.json # Unencrypted metadata
|
│ │ │ │ │ │ └── metadata.age # Encrypted metadata
|
||||||
│ │ │ │ │ └── 20231216.001/ # Another version
|
│ │ │ │ │ └── 20231216.001/ # Another version
|
||||||
│ │ │ │ └── current -> versions/20231216.001
|
│ │ │ │ └── current # Current version's name: 20231216.001
|
||||||
│ │ │ └── database%password/ # Secret: database/password
|
│ │ │ └── database%password/ # Secret: database/password
|
||||||
│ │ │ ├── versions/
|
│ │ │ ├── versions/
|
||||||
│ │ │ └── current -> versions/20231215.001
|
│ │ │ └── current # Current version's name: 20231215.001
|
||||||
│ │ ├── vault-metadata.json # Vault metadata
|
│ │ ├── vault-metadata.json # Vault metadata
|
||||||
│ │ ├── pub.age # Long-term public key
|
│ │ ├── pub.age # Long-term public key
|
||||||
│ │ └── current-unlocker # Current unlocker's directory name
|
│ │ └── current-unlocker # Current unlocker's directory name
|
||||||
@@ -309,9 +314,13 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
│ ├── vault-metadata.json
|
│ ├── vault-metadata.json
|
||||||
│ ├── pub.age
|
│ ├── pub.age
|
||||||
│ └── current-unlocker
|
│ └── current-unlocker
|
||||||
└── currentvault -> vaults.d/default
|
├── currentvault # Current vault's name: default
|
||||||
|
└── lock # Locked by each command that changes anything
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`current`, `currentvault` and `current-unlocker` are plain files that each hold
|
||||||
|
one name. Changing one replaces it in one rename, so it is never half-written.
|
||||||
|
|
||||||
### Key Management and Encryption Flow
|
### Key Management and Encryption Flow
|
||||||
|
|
||||||
#### 1: Long-term Keys
|
#### 1: Long-term Keys
|
||||||
@@ -338,7 +347,7 @@ Unlockers provide different authentication methods to access the long-term keys:
|
|||||||
|
|
||||||
3. **Keychain Unlockers** (macOS only):
|
3. **Keychain Unlockers** (macOS only):
|
||||||
- Stores unlock keys in macOS Keychain
|
- Stores unlock keys in macOS Keychain
|
||||||
- Protected by system authentication (Touch ID, password)
|
- Kept on this Mac only: the keychain item is never synced to other devices
|
||||||
- Automatic unlocking when Keychain is unlocked
|
- Automatic unlocking when Keychain is unlocked
|
||||||
- Cross-application integration
|
- Cross-application integration
|
||||||
|
|
||||||
@@ -346,8 +355,10 @@ Unlockers provide different authentication methods to access the long-term keys:
|
|||||||
- Hardware-backed key storage using Apple Secure Enclave
|
- Hardware-backed key storage using Apple Secure Enclave
|
||||||
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
|
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
|
||||||
Program required)
|
Program required)
|
||||||
- ECIES encryption: vault long-term key encrypted directly by SE hardware
|
- ECIES encryption: the vault long-term key is encrypted directly to the SE
|
||||||
- Protected by biometric authentication (Touch ID) or system password
|
key, and only the SE can decrypt it
|
||||||
|
- The SE key cannot leave this Mac; using it asks for no Touch ID or
|
||||||
|
password
|
||||||
|
|
||||||
Each vault maintains its own set of unlockers and one long-term key. The
|
Each vault maintains its own set of unlockers and one long-term key. The
|
||||||
long-term key is encrypted to each unlocker, allowing any authorized unlocker to
|
long-term key is encrypted to each unlocker, allowing any authorized unlocker to
|
||||||
@@ -357,7 +368,7 @@ access vault secrets.
|
|||||||
|
|
||||||
- Each secret version has its own encryption key pair
|
- Each secret version has its own encryption key pair
|
||||||
- Private key encrypted to the vault's long-term key
|
- Private key encrypted to the vault's long-term key
|
||||||
- Provides forward secrecy and granular access control
|
- A version's private key decrypts only that version's value and metadata
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
@@ -507,17 +518,21 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
|
|
||||||
### File Formats
|
### File Formats
|
||||||
|
|
||||||
- **age Files**: Standard age encryption format (.age extension)
|
- **age Files**: Standard age encryption format (.age extension), except
|
||||||
- **Metadata**: Unencrypted JSON format with timestamps and type information
|
`pub.age`, which holds an age public key as text
|
||||||
- **Vault Metadata**: JSON containing vault name, creation time, derivation
|
- **Metadata**: `vault-metadata.json` and `unlocker-metadata.json` are
|
||||||
index, and public key hash
|
unencrypted JSON with a creation time, and `unlocker-metadata.json` also
|
||||||
|
records the unlocker's type; a version's `metadata.age` is JSON encrypted to
|
||||||
|
the version's public key
|
||||||
|
- **Vault Metadata**: JSON containing creation time, derivation index, and the
|
||||||
|
public key hashes described below
|
||||||
|
|
||||||
### Vault Management
|
### Vault Management
|
||||||
|
|
||||||
- **Derivation Index**: Each vault uses a unique derivation index from the
|
- **Derivation Index**: Each vault uses a unique derivation index from the
|
||||||
mnemonic, and thus a unique key pair
|
mnemonic, and thus a unique key pair
|
||||||
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies
|
- **Public Key Hash**: Double SHA-256 hash of the vault's public key; the same
|
||||||
vaults from the same mnemonic
|
hash of the index-0 public key identifies vaults from the same mnemonic
|
||||||
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are
|
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are
|
||||||
automatically derived
|
automatically derived
|
||||||
|
|
||||||
@@ -568,8 +583,6 @@ The project includes comprehensive tests:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test # Run all tests
|
make test # Run all tests
|
||||||
go test ./... # Unit tests
|
|
||||||
go test -tags=integration -v ./internal/cli # Integration tests
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|||||||
@@ -27,9 +27,39 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
|||||||
selection `unlocker add` makes acted on the older one. A PGP unlocker's ID
|
selection `unlocker add` makes acted on the older one. A PGP unlocker's ID
|
||||||
was `pgp-` and its key's fingerprint; a second PGP unlocker for a key is
|
was `pgp-` and its key's fingerprint; a second PGP unlocker for a key is
|
||||||
still refused, now by comparing the fingerprint in the other unlockers'
|
still refused, now by comparing the fingerprint in the other unlockers'
|
||||||
metadata. The keychain and Secure Enclave code was type-checked by
|
metadata. `unlocker list` and the shell completion of `unlocker select` and
|
||||||
`script/lint-darwin`, never run; a test on Linux lists, selects and removes
|
`unlocker remove` take each ID from the directory the unlocker was read
|
||||||
each of two passphrase unlockers created in one minute by its own ID.
|
from, no longer by matching metadata, so two unlockers with the same
|
||||||
|
metadata are listed apart; an unlocker of an unknown type is listed under
|
||||||
|
its directory name, and completion now offers Secure Enclave unlockers too.
|
||||||
|
The keychain and Secure Enclave code was type-checked by
|
||||||
|
`script/lint-darwin`, never run; a test on Linux lists, completes, selects
|
||||||
|
and removes each of two passphrase unlockers with the same metadata by its
|
||||||
|
own ID.
|
||||||
|
- 2026-10-04: README's Storage Architecture, `secret version promote`,
|
||||||
|
Technical Details and Testing text matches the code
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/102). `current` and
|
||||||
|
`currentvault` are plain files holding a name, not symbolic links; a
|
||||||
|
version's metadata is the encrypted `metadata.age`; the state directory is
|
||||||
|
`berlin.sneak.pkg.secret` in the user's configuration directory, not
|
||||||
|
`~/.local/share/secret`, and holds the `lock` file. Also corrected: the
|
||||||
|
code sets up no Touch ID for the keychain or Secure Enclave unlocker, and
|
||||||
|
the Secure Enclave only decrypts; per-version keys give no forward
|
||||||
|
secrecy; `pub.age` is not age-encrypted; vault metadata holds no vault
|
||||||
|
name. Testing lists only `make test`.
|
||||||
|
- 2026-10-04: `secret init` and `secret vault create` create a vault whole or
|
||||||
|
not at all (https://git.eeqj.de/sneak/secret/issues/105).
|
||||||
|
`vault.CreateVault` now takes the unlocker passphrase too, writes the vault
|
||||||
|
directory with its metadata, long-term public key and passphrase unlocker,
|
||||||
|
`longterm.age` included, into a temporary directory, renames that into
|
||||||
|
`vaults.d` once it is complete, and only then makes the vault current.
|
||||||
|
Before, either command killed after the passphrase prompt but before the
|
||||||
|
unlocker was written left a vault with no unlocker, which `vault create` had
|
||||||
|
already made current and which neither command would create again. Killed
|
||||||
|
part-way now, it leaves no vault, and the next command that takes the lock
|
||||||
|
deletes the temporary directory; or, killed between the rename and making
|
||||||
|
the vault current, a complete vault that is not current, which
|
||||||
|
`secret vault select` makes current.
|
||||||
- 2026-10-04: A failed `secret unlocker add keychain` or
|
- 2026-10-04: A failed `secret unlocker add keychain` or
|
||||||
`secret unlocker add secure-enclave` no longer leaves its keychain item or
|
`secret unlocker add secure-enclave` no longer leaves its keychain item or
|
||||||
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
|
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
|
||||||
@@ -270,11 +300,7 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
|||||||
`current-unlocker` never go missing. New versions, new secrets and
|
`current-unlocker` never go missing. New versions, new secrets and
|
||||||
cross-vault copies are built in a temporary directory and renamed
|
cross-vault copies are built in a temporary directory and renamed
|
||||||
into place, and removals rename out of the way first, so a version
|
into place, and removals rename out of the way first, so a version
|
||||||
or secret is never half-added and never half-removed. An
|
or secret is never half-added and never half-removed.
|
||||||
interrupted command can still leave, from `init` or `vault create`
|
|
||||||
killed after the passphrase prompt but before the unlocker is
|
|
||||||
written, a vault with no unlocker, which `vault create` has already
|
|
||||||
made the current vault.
|
|
||||||
- 2026-10-03: The checks run before changing a vault now stop with an
|
- 2026-10-03: The checks run before changing a vault now stop with an
|
||||||
error naming the path and cause when they cannot read what they
|
error naming the path and cause when they cannot read what they
|
||||||
inspect, instead of reading the failure as "nothing there": the
|
inspect, instead of reading the failure as "nothing there": the
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"maps"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -44,7 +44,7 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getUnlockerIDsCompletionFunc returns a completion function that provides
|
// getUnlockerIDsCompletionFunc returns a completion function that provides
|
||||||
// unlocker IDs
|
// unlocker IDs, the names of the unlockers' directories in unlockers.d
|
||||||
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
@@ -57,38 +57,15 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get unlocker metadata list
|
unlockerMetadata, err := vlt.ListUnlockers()
|
||||||
unlockerMetadataList, err := vlt.ListUnlockers()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get vault directory
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect unlocker IDs
|
|
||||||
var completions []string
|
var completions []string
|
||||||
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
for _, id := range slices.Sorted(maps.Keys(unlockerMetadata)) {
|
||||||
|
if strings.HasPrefix(id, toComplete) {
|
||||||
for _, metadata := range unlockerMetadataList {
|
|
||||||
// Get the actual unlocker ID by creating the unlocker instance
|
|
||||||
id, err := findUnlockerIDByMetadata(
|
|
||||||
fs, unlockersDir, metadata, false,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn(
|
|
||||||
"Could not read unlockers directory during completion, "+
|
|
||||||
"skipping unlocker",
|
|
||||||
"unlockers_dir", unlockersDir, "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if id != "" && strings.HasPrefix(id, toComplete) {
|
|
||||||
completions = append(completions, id)
|
completions = append(completions, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,10 +63,10 @@ func newConfirmTestVaults(
|
|||||||
fs := &afero.MemMapFs{}
|
fs := &afero.MemMapFs{}
|
||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
|
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
|
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("older"), false)
|
addTestSecret(t, vlt, []byte("older"), false)
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package cli_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
@@ -155,7 +159,7 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
|
|||||||
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
|
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
|
||||||
|
|
||||||
withDefault := afero.NewMemMapFs()
|
withDefault := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic)
|
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
cmd := &cobra.Command{}
|
||||||
@@ -193,3 +197,192 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault
|
||||||
|
// create` killed after the passphrase prompt but before the unlocker was
|
||||||
|
// written left a vault with no unlocker, which neither command would then
|
||||||
|
// create again. After the prompt, each command changes the state directory
|
||||||
|
// only through vault.CreateVault. The test makes that call as the command
|
||||||
|
// does and records the state directory before each change it makes, and once
|
||||||
|
// after it returns: what a stop at that point leaves. Each must hold either
|
||||||
|
// no vault, and not name it current, or exactly the finished vault, which
|
||||||
|
// opens with the passphrase through its current unlocker. The command run
|
||||||
|
// again after a stop first takes the lock, which must delete what the stop
|
||||||
|
// left under a temporary name. Running the command is slow, so it runs once
|
||||||
|
// on each different state the lock leaves, and must create the vault there,
|
||||||
|
// or refuse the one there.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // commands on the in-memory filesystem share one lock
|
||||||
|
func TestStopDuringCreateLeavesWholeVaultOrNone(t *testing.T) {
|
||||||
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
t.Cleanup(passphrase.Destroy)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
|
||||||
|
t.Run("init", func(t *testing.T) {
|
||||||
|
// From an empty state directory
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll(testStateDir, secret.DirPerms))
|
||||||
|
|
||||||
|
requireStopsLeaveWholeVaultOrNone(t, fs, "default",
|
||||||
|
"failed to create default vault: vault default already exists",
|
||||||
|
mnemonic, passphrase,
|
||||||
|
func(c *cli.Instance) error { return c.Init(cmd) })
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("vault create work", func(t *testing.T) {
|
||||||
|
// From a state directory holding the vault "default"
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
requireStopsLeaveWholeVaultOrNone(t, fs, "work", "vault work already exists",
|
||||||
|
mnemonic, passphrase,
|
||||||
|
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireStopsLeaveWholeVaultOrNone checks, as
|
||||||
|
// TestStopDuringCreateLeavesWholeVaultOrNone describes, the stops of the
|
||||||
|
// command run, creating the vault name on fs with mnemonic and passphrase.
|
||||||
|
// Run again where the vault is there, the command must fail with exists.
|
||||||
|
func requireStopsLeaveWholeVaultOrNone(
|
||||||
|
t *testing.T, fs afero.Fs, name, exists string,
|
||||||
|
mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
|
run func(c *cli.Instance) error,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var stops []map[string]string
|
||||||
|
|
||||||
|
record := func() { stops = append(stops, snapshotStateDir(t, fs)) }
|
||||||
|
|
||||||
|
_, err := vault.CreateVault(hookFs{Fs: fs, before: record},
|
||||||
|
testStateDir, name, mnemonic, passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
record()
|
||||||
|
|
||||||
|
vaultDir := testStateDir + "/vaults.d/" + name
|
||||||
|
require.NotContains(t, stops[0], vaultDir+"/", "no stop before the vault")
|
||||||
|
|
||||||
|
finished := entriesUnder(stops[len(stops)-1], vaultDir)
|
||||||
|
|
||||||
|
opener := vault.NewVault(fs, testStateDir, name)
|
||||||
|
opener.UnlockPassphrase = passphrase
|
||||||
|
|
||||||
|
key, err := opener.UnlockVault()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, finished[vaultDir+"/pub.age"], key.Recipient().String())
|
||||||
|
|
||||||
|
// Each different state the command run again finds once it holds the lock
|
||||||
|
var locked []map[string]string
|
||||||
|
|
||||||
|
for i, stop := range stops {
|
||||||
|
if _, there := stop[vaultDir+"/"]; there {
|
||||||
|
require.Equal(t, finished, entriesUnder(stop, vaultDir),
|
||||||
|
"stop %d left a partial vault", i)
|
||||||
|
} else {
|
||||||
|
require.NotEqual(t, name, stop[testStateDir+"/currentvault"],
|
||||||
|
"stop %d made a missing vault current", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopped := newFsFromSnapshot(t, stop)
|
||||||
|
release, err := vault.LockStateDir(stopped, testStateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
release()
|
||||||
|
|
||||||
|
state := snapshotStateDir(t, stopped)
|
||||||
|
for path := range state {
|
||||||
|
require.NotContains(t, path, ".tmp-", "stop %d", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.ContainsFunc(locked, func(s map[string]string) bool {
|
||||||
|
return maps.Equal(s, state)
|
||||||
|
}) {
|
||||||
|
locked = append(locked, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, state := range locked {
|
||||||
|
c := cli.NewCLIInstanceWithStateDir(newFsFromSnapshot(t, state), testStateDir)
|
||||||
|
c.Mnemonic = mnemonic
|
||||||
|
c.UnlockPassphrase = passphrase
|
||||||
|
|
||||||
|
if _, there := state[vaultDir+"/"]; there {
|
||||||
|
require.EqualError(t, run(c), exists)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, run(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// entriesUnder returns the entries of a tree recorded by snapshotStateDir
|
||||||
|
// that are under dir.
|
||||||
|
func entriesUnder(tree map[string]string, dir string) map[string]string {
|
||||||
|
entries := map[string]string{}
|
||||||
|
|
||||||
|
for path, content := range tree {
|
||||||
|
if strings.HasPrefix(path, dir+"/") {
|
||||||
|
entries[path] = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// hookFs passes every call through to Fs, but first calls before for each
|
||||||
|
// call that can change the filesystem.
|
||||||
|
type hookFs struct {
|
||||||
|
afero.Fs
|
||||||
|
|
||||||
|
before func()
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // implements afero.Fs
|
||||||
|
func (h hookFs) Create(name string) (afero.File, error) {
|
||||||
|
h.before()
|
||||||
|
|
||||||
|
return h.Fs.Create(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // implements afero.Fs
|
||||||
|
func (h hookFs) OpenFile(
|
||||||
|
name string, flag int, perm os.FileMode,
|
||||||
|
) (afero.File, error) {
|
||||||
|
h.before()
|
||||||
|
|
||||||
|
return h.Fs.OpenFile(name, flag, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
|
||||||
|
h.before()
|
||||||
|
|
||||||
|
return h.Fs.Mkdir(name, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
|
||||||
|
h.before()
|
||||||
|
|
||||||
|
return h.Fs.MkdirAll(path, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h hookFs) Remove(name string) error {
|
||||||
|
h.before()
|
||||||
|
|
||||||
|
return h.Fs.Remove(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h hookFs) RemoveAll(path string) error {
|
||||||
|
h.before()
|
||||||
|
|
||||||
|
return h.Fs.RemoveAll(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h hookFs) Rename(oldname, newname string) error {
|
||||||
|
h.before()
|
||||||
|
|
||||||
|
return h.Fs.Rename(oldname, newname)
|
||||||
|
}
|
||||||
|
|||||||
+19
-62
@@ -6,13 +6,10 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/tyler-smith/go-bip39"
|
"github.com/tyler-smith/go-bip39"
|
||||||
@@ -70,43 +67,6 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
|
|||||||
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
|
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupDefaultVault creates the default vault and derives its long-term
|
|
||||||
// identity from the mnemonic
|
|
||||||
func (cli *Instance) setupDefaultVault(
|
|
||||||
stateDir string, mnemonic *memguard.LockedBuffer,
|
|
||||||
) (*vault.Vault, *age.X25519Identity, error) {
|
|
||||||
// Create the default vault - it will handle key derivation internally
|
|
||||||
secret.Debug("Creating default vault")
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default", mnemonic)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to create default vault", "error", err)
|
|
||||||
|
|
||||||
return nil, nil, fmt.Errorf("failed to create default vault: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the vault metadata to retrieve the derivation index
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
|
|
||||||
|
|
||||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to load vault metadata", "error", err)
|
|
||||||
|
|
||||||
return nil, nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive the long-term key using the same index that CreateVault used
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to derive long-term key", "error", err)
|
|
||||||
|
|
||||||
return nil, nil, fmt.Errorf(
|
|
||||||
"failed to derive long-term key from mnemonic: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return vlt, ltIdentity, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init initializes the secret manager, holding the state directory lock
|
// Init initializes the secret manager, holding the state directory lock
|
||||||
// while initialize runs
|
// while initialize runs
|
||||||
func (cli *Instance) Init(cmd *cobra.Command) error {
|
func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||||
@@ -173,34 +133,31 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
|
|||||||
}
|
}
|
||||||
defer cleanupPassphrase()
|
defer cleanupPassphrase()
|
||||||
|
|
||||||
// Create the default vault and derive its long-term key
|
// Create the default vault with its passphrase unlocker
|
||||||
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonic)
|
secret.Debug("Creating default vault")
|
||||||
|
|
||||||
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default",
|
||||||
|
mnemonic, passphraseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to create default vault", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to create default vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlocker, err := vlt.GetCurrentUnlocker()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ltPubKey := ltIdentity.Recipient().String()
|
|
||||||
|
|
||||||
// Unlock the vault with the derived long-term key
|
|
||||||
vlt.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
// Create passphrase-protected unlocker
|
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to create unlocker", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: CreatePassphraseUnlocker already encrypts and writes the long-term
|
|
||||||
// private key to longterm.age, so no need to do it again here.
|
|
||||||
|
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
cmd.Printf("\nDefault vault created and configured\n")
|
cmd.Printf("\nDefault vault created and configured\n")
|
||||||
cmd.Printf("Long-term public key: %s\n", ltPubKey)
|
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
||||||
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
|
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
|
||||||
cmd.Println("\nYour secret manager is ready to use!")
|
cmd.Println("\nYour secret manager is ready to use!")
|
||||||
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
|
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
|
||||||
cmd.Println("unlockers are not required for secret operations.")
|
cmd.Println("unlockers are not required for secret operations.")
|
||||||
|
|||||||
@@ -2563,7 +2563,7 @@ func secretRmCommand(ctx context.Context, t *testing.T) (*exec.Cmd, string) {
|
|||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||||
defer mnemonic.Destroy()
|
defer mnemonic.Destroy()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", mnemonic)
|
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("value"))
|
value := memguard.NewBufferFromBytes([]byte("value"))
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestLeftoversRemovedByNextChangingCommand(t *testing.T) {
|
|||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
fs := newTwoVaultFs(t)
|
||||||
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, ".tmp-0", nil)
|
_, err := vault.CreateVault(fs, testStateDir, ".tmp-0", nil, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, vault.SelectVault(fs, testStateDir, "default"))
|
require.NoError(t, vault.SelectVault(fs, testStateDir, "default"))
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
|||||||
{"real", afero.NewOsFs(), t.TempDir()},
|
{"real", afero.NewOsFs(), t.TempDir()},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic)
|
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// One add creates the secret; the others find that it exists
|
// One add creates the secret; the others find that it exists
|
||||||
@@ -185,7 +185,7 @@ func (r *readNotifier) Read(p []byte) (int, error) {
|
|||||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||||
func TestEncryptPipedIntoAdd(t *testing.T) {
|
func TestEncryptPipedIntoAdd(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
|
||||||
|
|
||||||
@@ -292,14 +292,14 @@ func setupEveryCommand(
|
|||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
|
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
otherDir, err := other.GetDirectory()
|
otherDir, err := other.GetDirectory()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
|
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
|
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("older"), false)
|
addTestSecret(t, vlt, []byte("older"), false)
|
||||||
@@ -487,7 +487,7 @@ func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
|
|||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic)
|
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
||||||
|
|
||||||
@@ -525,7 +525,7 @@ func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
|
|||||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||||
func TestEncryptStreamsUnlocked(t *testing.T) {
|
func TestEncryptStreamsUnlocked(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
|
||||||
|
|
||||||
|
|||||||
@@ -156,10 +156,10 @@ func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
|
|||||||
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
||||||
|
|
||||||
// "default" is created last, so it is the current vault.
|
// "default" is created last, so it is the current vault.
|
||||||
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
|
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
||||||
@@ -205,7 +205,7 @@ func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
stateDir := t.TempDir()
|
stateDir := t.TempDir()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
|
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false)
|
err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func newTwoVaultFs(t *testing.T) afero.Fs {
|
|||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
for _, name := range []string{"work", "default"} {
|
for _, name := range []string{"work", "default"} {
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic)
|
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
_, err := vault.CreateVault(fs, testStateDir, testVaultName, testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Set current vault
|
// Set current vault
|
||||||
|
|||||||
+11
-134
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -313,91 +314,8 @@ func newUnlockerSelectCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlockerIDFromDir constructs an unlocker of the given metadata type
|
// UnlockersList lists unlockers in the current vault, each under its ID,
|
||||||
// rooted at unlockerDir and returns its ID. Returns "" for unknown types
|
// the name of its directory in unlockers.d
|
||||||
// and, when includeSecureEnclave is false, for secure enclave unlockers.
|
|
||||||
func unlockerIDFromDir(
|
|
||||||
fs afero.Fs, unlockerDir string, metadata secret.UnlockerMetadata,
|
|
||||||
includeSecureEnclave bool,
|
|
||||||
) string {
|
|
||||||
// Create the appropriate unlocker instance
|
|
||||||
var unlocker secret.Unlocker
|
|
||||||
|
|
||||||
switch metadata.Type {
|
|
||||||
case unlockerTypePassphrase:
|
|
||||||
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
|
|
||||||
case unlockerTypeKeychain:
|
|
||||||
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, metadata)
|
|
||||||
case unlockerTypePGP:
|
|
||||||
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
|
||||||
case unlockerTypeSecureEnclave:
|
|
||||||
if includeSecureEnclave {
|
|
||||||
unlocker = secret.NewSecureEnclaveUnlocker(fs, unlockerDir, metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if unlocker == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return unlocker.GetID()
|
|
||||||
}
|
|
||||||
|
|
||||||
// findUnlockerIDByMetadata scans unlockersDir for the directory whose
|
|
||||||
// stored metadata matches the given type and creation time and returns
|
|
||||||
// the matching unlocker's ID. It returns ("", nil) when the directory is
|
|
||||||
// readable but holds no match, and a non-nil error when the directory
|
|
||||||
// itself cannot be read. Callers must distinguish the two: an unreadable
|
|
||||||
// directory means the unlocker's real ID is unknowable, so the entry has
|
|
||||||
// to be skipped rather than reported under a synthesized ID.
|
|
||||||
//
|
|
||||||
// A metadata file that cannot be read or parsed is skipped without a
|
|
||||||
// warning: every caller gets metadata from vault.ListUnlockers first,
|
|
||||||
// which has already warned about that directory.
|
|
||||||
func findUnlockerIDByMetadata(
|
|
||||||
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
|
|
||||||
includeSecureEnclave bool,
|
|
||||||
) (string, error) {
|
|
||||||
files, err := afero.ReadDir(fs, unlockersDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"failed to read unlockers directory %s: %w", unlockersDir, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
if !file.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
|
||||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
||||||
|
|
||||||
// Check if this is the right unlocker by comparing metadata
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var diskMetadata secret.UnlockerMetadata
|
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &diskMetadata)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match by type and creation time
|
|
||||||
if diskMetadata.Type == metadata.Type &&
|
|
||||||
diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
|
||||||
return unlockerIDFromDir(fs, unlockerDir, diskMetadata,
|
|
||||||
includeSecureEnclave), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnlockersList lists unlockers in the current vault
|
|
||||||
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
@@ -413,58 +331,23 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
currentUnlockerID = currentUnlocker.GetID()
|
currentUnlockerID = currentUnlocker.GetID()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the metadata first
|
unlockerMetadata, err := vlt.ListUnlockers()
|
||||||
unlockerMetadataList, err := vlt.ListUnlockers()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load actual unlocker objects to get the proper IDs
|
|
||||||
var unlockers []UnlockerInfo
|
var unlockers []UnlockerInfo
|
||||||
|
|
||||||
for _, metadata := range unlockerMetadataList {
|
for _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) {
|
||||||
// Create unlocker instance to get the proper ID
|
metadata := unlockerMetadata[unlockerID]
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Could not get vault directory while listing unlockers",
|
|
||||||
"error", err)
|
|
||||||
|
|
||||||
continue
|
unlockers = append(unlockers, UnlockerInfo{
|
||||||
}
|
ID: unlockerID,
|
||||||
|
|
||||||
// Find the unlocker directory by type and created time
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
||||||
|
|
||||||
unlockerID, err := findUnlockerIDByMetadata(
|
|
||||||
cli.fs, unlockersDir, metadata, true,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Could not read unlockers directory, skipping unlocker",
|
|
||||||
"unlockers_dir", unlockersDir, "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the proper ID using the unlocker's ID() method
|
|
||||||
var properID string
|
|
||||||
if unlockerID != "" {
|
|
||||||
properID = unlockerID
|
|
||||||
} else {
|
|
||||||
// Generate ID as fallback
|
|
||||||
properID = fmt.Sprintf("%s-%s",
|
|
||||||
metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
|
||||||
secret.Warn("Could not create unlocker instance, using fallback ID",
|
|
||||||
"fallback_id", properID, "type", metadata.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerInfo := UnlockerInfo{
|
|
||||||
ID: properID,
|
|
||||||
Type: metadata.Type,
|
Type: metadata.Type,
|
||||||
CreatedAt: metadata.CreatedAt,
|
CreatedAt: metadata.CreatedAt,
|
||||||
Flags: metadata.Flags,
|
Flags: metadata.Flags,
|
||||||
IsCurrent: properID == currentUnlockerID,
|
IsCurrent: unlockerID == currentUnlockerID,
|
||||||
}
|
})
|
||||||
unlockers = append(unlockers, unlockerInfo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
@@ -802,13 +685,7 @@ func (cli *Instance) findUnlockerToRemove(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(unlockers) == 1 {
|
if len(unlockers) == 1 {
|
||||||
lastID, err := findUnlockerIDByMetadata(
|
_, found.last = unlockers[unlockerID]
|
||||||
cli.fs, unlockersDir, unlockers[0], true)
|
|
||||||
if err != nil {
|
|
||||||
return unlockerToRemove{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
found.last = lastID == unlockerID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlockerID may instead name a directory left out of the list. If its
|
// unlockerID may instead name a directory left out of the list. If its
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func TestAddPGPUnlocker(t *testing.T) {
|
|||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
||||||
testMnemonicBuffer(t))
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = vlt.AddSecret(addTestSecretName,
|
err = vlt.AddSecret(addTestSecretName,
|
||||||
|
|||||||
@@ -14,34 +14,35 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSameMinuteUnlockersHaveTheirOwnIDs writes two passphrase unlockers
|
// TestSameMetadataUnlockersHaveTheirOwnIDs writes two passphrase unlockers
|
||||||
// created in the same minute side by side, as an `unlocker add passphrase`
|
// side by side whose metadata is the same, creation time included, as
|
||||||
// that fails before removing the old one leaves them, and asserts that
|
// copying an unlocker directory leaves them. It asserts that `unlocker
|
||||||
// `unlocker list` gives each its own ID, and that each is selected and
|
// list` and the shell completion of `unlocker select` and `unlocker remove`
|
||||||
// removed by its ID alone. Keychain and Secure Enclave unlockers, which
|
// give each its own ID, and that each is selected and removed by its ID
|
||||||
// only macOS can add, get their IDs the same way.
|
// alone. Keychain and Secure Enclave unlockers, which only macOS can add,
|
||||||
func TestSameMinuteUnlockersHaveTheirOwnIDs(t *testing.T) {
|
// get their IDs the same way.
|
||||||
|
func TestSameMetadataUnlockersHaveTheirOwnIDs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
_, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
||||||
testMnemonicBuffer(t))
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vaultDir := testVaultDir(listTestVaultName)
|
vaultDir := testVaultDir(listTestVaultName)
|
||||||
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
||||||
dirNames := []string{
|
dirNames := []string{
|
||||||
"passphrase-2026-10-04.12.30.00.000000000",
|
"passphrase-2026-10-04.12.30.00.000000000",
|
||||||
"passphrase-2026-10-04.12.30.30.000000000",
|
"passphrase-2026-10-04.12.30.00.000000000-copy",
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, dirName := range dirNames {
|
metadata, err := json.Marshal(secret.UnlockerMetadata{
|
||||||
metadata, err := json.Marshal(secret.UnlockerMetadata{
|
Type: unlockerTypePassphrase,
|
||||||
Type: unlockerTypePassphrase,
|
CreatedAt: time.Date(2026, time.October, 4, 12, 30, 0, 0, time.UTC),
|
||||||
CreatedAt: time.Date(2026, time.October, 4, 12, 30, 30*i, 0, time.UTC),
|
})
|
||||||
})
|
require.NoError(t, err)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
|
for _, dirName := range dirNames {
|
||||||
dir := filepath.Join(unlockersDir, dirName)
|
dir := filepath.Join(unlockersDir, dirName)
|
||||||
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
|
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
require.NoError(t, afero.WriteFile(fs,
|
||||||
@@ -52,6 +53,10 @@ func TestSameMinuteUnlockersHaveTheirOwnIDs(t *testing.T) {
|
|||||||
listed := listUnlockersJSON(t, fs)
|
listed := listUnlockersJSON(t, fs)
|
||||||
require.Len(t, listed, len(dirNames))
|
require.Len(t, listed, len(dirNames))
|
||||||
|
|
||||||
|
completed, _ := getUnlockerIDsCompletionFunc(fs, listTestStateDir)(
|
||||||
|
nil, nil, "")
|
||||||
|
assert.Equal(t, dirNames, completed)
|
||||||
|
|
||||||
instance, cmd := newTestInstance(fs)
|
instance, cmd := newTestInstance(fs)
|
||||||
|
|
||||||
for i, unlocker := range listed {
|
for i, unlocker := range listed {
|
||||||
@@ -65,7 +70,7 @@ func TestSameMinuteUnlockersHaveTheirOwnIDs(t *testing.T) {
|
|||||||
assert.Equal(t, dirNames[i], string(current))
|
assert.Equal(t, dirNames[i], string(current))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The newer one first: an ID both shared would remove the older one
|
// The second one first: an ID both shared would remove the first one
|
||||||
require.NoError(t, instance.UnlockersRemove(listed[1].ID, true, cmd))
|
require.NoError(t, instance.UnlockersRemove(listed[1].ID, true, cmd))
|
||||||
assertDirEntries(t, fs, unlockersDir, dirNames[0])
|
assertDirEntries(t, fs, unlockersDir, dirNames[0])
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,13 @@
|
|||||||
// Unlocker List Tests
|
// Unlocker List Tests
|
||||||
//
|
//
|
||||||
// Tests for `secret unlocker list` behavior when the unlockers.d directory,
|
// Tests for `secret unlocker list` behavior when an unlocker's metadata
|
||||||
// or an unlocker's metadata in it, cannot be read while the listing is
|
// cannot be read or used:
|
||||||
// being rendered:
|
|
||||||
//
|
//
|
||||||
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
|
|
||||||
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
|
|
||||||
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
|
|
||||||
// still listed, with its real ID and its current-unlocker marker,
|
|
||||||
// when a later entry's scan fails.
|
|
||||||
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
|
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
|
||||||
// metadata does not stop the others from being listed.
|
// metadata does not stop the others from being listed.
|
||||||
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
||||||
// file cannot be checked for or read is left out, and the other is
|
// file cannot be checked for or read is left out, and the other is
|
||||||
// still listed.
|
// still listed.
|
||||||
//
|
|
||||||
// The listing resolves each unlocker's real ID by rescanning unlockers.d
|
|
||||||
// after the vault has already enumerated it. If that rescan fails the ID
|
|
||||||
// is unknowable, so the entry must be skipped: a synthesized ID matches
|
|
||||||
// no `unlocker remove` or `unlocker select` argument and would also
|
|
||||||
// suppress the current-unlocker marker.
|
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported internals
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
package cli
|
package cli
|
||||||
@@ -57,8 +45,7 @@ const (
|
|||||||
listTestUnlockerDirOne = "host-pgp-2026-08-09"
|
listTestUnlockerDirOne = "host-pgp-2026-08-09"
|
||||||
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
|
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
|
||||||
|
|
||||||
// listTestUnlockersDirName is the directory the listing rescans to
|
// listTestUnlockersDirName is the directory holding the unlockers.
|
||||||
// resolve unlocker IDs.
|
|
||||||
listTestUnlockersDirName = "unlockers.d"
|
listTestUnlockersDirName = "unlockers.d"
|
||||||
|
|
||||||
// listTestMetadataFileName is the per-unlocker metadata file name.
|
// listTestMetadataFileName is the per-unlocker metadata file name.
|
||||||
@@ -73,25 +60,16 @@ const (
|
|||||||
// a successful open of unlockers.d.
|
// a successful open of unlockers.d.
|
||||||
var errUnlockersDirUnreadable = errors.New("permission denied")
|
var errUnlockersDirUnreadable = errors.New("permission denied")
|
||||||
|
|
||||||
// unlockersDirFailFs makes unlockers.d unreadable once it has been opened
|
// unlockersDirFailFs fails every open of unlockers.d, as when the
|
||||||
// successfully openBudget times. This reproduces the directory becoming
|
// directory cannot be read.
|
||||||
// unreadable (permission change, partially restored backup, EIO) between
|
|
||||||
// the vault's own enumeration and the per-entry rescan that resolves
|
|
||||||
// unlocker IDs.
|
|
||||||
type unlockersDirFailFs struct {
|
type unlockersDirFailFs struct {
|
||||||
afero.Fs
|
afero.Fs
|
||||||
|
|
||||||
openBudget int
|
|
||||||
opens int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||||
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
||||||
if filepath.Base(name) == listTestUnlockersDirName {
|
if filepath.Base(name) == listTestUnlockersDirName {
|
||||||
f.opens++
|
return nil, errUnlockersDirUnreadable
|
||||||
if f.opens > f.openBudget {
|
|
||||||
return nil, errUnlockersDirUnreadable
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
||||||
@@ -223,44 +201,6 @@ func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
|
|||||||
return decoded.Unlockers
|
return decoded.Unlockers
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUnlockersListSkipsUnreadableUnlockersDir asserts that an unlockers.d
|
|
||||||
// which becomes unreadable after the vault enumerated it produces no rows,
|
|
||||||
// rather than rows carrying fabricated fallback IDs.
|
|
||||||
func TestUnlockersListSkipsUnreadableUnlockersDir(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
// Budget of one: the vault's own ListUnlockers scan succeeds, the
|
|
||||||
// per-entry rescan that resolves the ID fails.
|
|
||||||
fs := &unlockersDirFailFs{Fs: base, openBudget: 1}
|
|
||||||
|
|
||||||
unlockers := listUnlockersJSON(t, fs)
|
|
||||||
|
|
||||||
assert.Empty(t, unlockers,
|
|
||||||
"an unreadable unlockers.d must yield no rows, not fabricated IDs")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockersListSkipsOnlyUnreadableEntries asserts that a readable
|
|
||||||
// entry survives with its real ID and current-unlocker marker when a later
|
|
||||||
// entry's rescan fails.
|
|
||||||
func TestUnlockersListSkipsOnlyUnreadableEntries(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 2)
|
|
||||||
// Budget of two: ListUnlockers plus the first entry's rescan succeed,
|
|
||||||
// the second entry's rescan fails.
|
|
||||||
fs := &unlockersDirFailFs{Fs: base, openBudget: 2}
|
|
||||||
|
|
||||||
unlockers := listUnlockersJSON(t, fs)
|
|
||||||
|
|
||||||
require.Len(t, unlockers, 1,
|
|
||||||
"only the entry whose directory was readable may be listed")
|
|
||||||
assert.Equal(t, listTestUnlockerDirOne, unlockers[0].ID,
|
|
||||||
"the surviving row must carry the real unlocker ID")
|
|
||||||
assert.True(t, unlockers[0].IsCurrent,
|
|
||||||
"the current-unlocker marker must survive the skip")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockersListReadableEntriesAreListed is the control case: with a
|
// TestUnlockersListReadableEntriesAreListed is the control case: with a
|
||||||
// fully readable unlockers.d every entry is listed with its real ID.
|
// fully readable unlockers.d every entry is listed with its real ID.
|
||||||
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
||||||
@@ -279,9 +219,9 @@ func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
|||||||
|
|
||||||
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
|
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
|
||||||
// corrupt metadata does not stop the listing. Metadata that is not JSON
|
// corrupt metadata does not stop the listing. Metadata that is not JSON
|
||||||
// leaves that unlocker out; PGP metadata without a usable GPG key ID is
|
// leaves that unlocker out; PGP metadata without a usable GPG key ID, and
|
||||||
// still listed, under its directory name like any other. The healthy
|
// metadata of an unknown type, are still listed, under the directory name
|
||||||
// unlocker is listed with its real ID.
|
// like any other. The healthy unlocker is listed with its real ID.
|
||||||
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -307,6 +247,11 @@ func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
|||||||
metadata: `{"type": "pgp"}`,
|
metadata: `{"type": "pgp"}`,
|
||||||
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "unknown type",
|
||||||
|
metadata: `{"type": "unknown"}`,
|
||||||
|
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
+8
-22
@@ -293,40 +293,26 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
}
|
}
|
||||||
defer cleanupPassphrase()
|
defer cleanupPassphrase()
|
||||||
|
|
||||||
// Create the vault - it will handle key derivation internally
|
// Create the vault with its passphrase unlocker
|
||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name, mnemonic)
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name,
|
||||||
|
mnemonic, passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the vault metadata to retrieve the derivation index
|
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||||
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
|
|
||||||
|
|
||||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load vault metadata: %w", err)
|
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive the long-term key using the same index that CreateVault used
|
unlocker, err := vlt.GetCurrentUnlocker()
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock the vault with the derived long-term key
|
|
||||||
vlt.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
// Create passphrase-protected unlocker
|
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Created vault '%s'\n", vlt.GetName())
|
cmd.Printf("Created vault '%s'\n", vlt.GetName())
|
||||||
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
||||||
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
|
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ func setupTestVault(t *testing.T, fs afero.Fs) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
|
vlt, err := vault.CreateVault(fs, testStateDir, "default",
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ func newVaultWithSecret(
|
|||||||
) *vault.Vault {
|
) *vault.Vault {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
buffer := memguard.NewBufferFromBytes([]byte(value))
|
buffer := memguard.NewBufferFromBytes([]byte(value))
|
||||||
@@ -353,7 +353,7 @@ func TestLongestNames(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
name := strings.Repeat("a", longestName)
|
name := strings.Repeat("a", longestName)
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t))
|
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("long"))
|
value := memguard.NewBufferFromBytes([]byte("long"))
|
||||||
@@ -647,7 +647,7 @@ func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
|
|||||||
|
|
||||||
// No mnemonic, and no current unlocker to get the key from
|
// No mnemonic, and no current unlocker to get the key from
|
||||||
base := afero.NewMemMapFs()
|
base := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
|
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(_, path string) error {
|
fs := hookFs{Fs: base, before: func(_, path string) error {
|
||||||
@@ -679,7 +679,7 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
|
|||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
base, stateDir := tfs.open(t)
|
||||||
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
||||||
testMnemonicBuffer(t))
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
@@ -728,7 +728,7 @@ func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
|
|||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
base, stateDir := tfs.open(t)
|
||||||
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
||||||
testMnemonicBuffer(t))
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||||
@@ -911,7 +911,7 @@ func TestSecureEnclaveUnlockerFailureDeletesKey(t *testing.T) {
|
|||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
base := afero.NewMemMapFs()
|
base := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic)
|
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// The unlocker's directory is named se-<label of its Secure Enclave key>
|
// The unlocker's directory is named se-<label of its Secure Enclave key>
|
||||||
|
|||||||
@@ -297,11 +297,6 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|||||||
// Create a PGP unlocker for the remaining tests
|
// Create a PGP unlocker for the remaining tests
|
||||||
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
||||||
|
|
||||||
// Test getting GPG key ID
|
|
||||||
t.Run("GetGPGKeyID", func(t *testing.T) {
|
|
||||||
testGetGPGKeyID(t, fs, unlocker, unlockerDir, metadata, fingerprint)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test getting identity from PGP unlocker
|
// Test getting identity from PGP unlocker
|
||||||
t.Run("GetIdentity", func(t *testing.T) {
|
t.Run("GetIdentity", func(t *testing.T) {
|
||||||
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
|
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
|
||||||
@@ -330,7 +325,7 @@ func testCreatePGPUnlocker(
|
|||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
// Create a test vault directory structure
|
// Create a test vault directory structure
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
|
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -504,51 +499,6 @@ func checkPGPUnlockerMetadata(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// testGetGPGKeyID writes PGP unlocker metadata holding the GPG fingerprint
|
|
||||||
// into unlockerDir and checks that unlocker reads it back.
|
|
||||||
func testGetGPGKeyID(
|
|
||||||
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
|
|
||||||
unlockerDir string, metadata secret.UnlockerMetadata, fingerprint string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Create PGP metadata with GPG key ID
|
|
||||||
type PGPUnlockerMetadata struct {
|
|
||||||
secret.UnlockerMetadata
|
|
||||||
|
|
||||||
GPGKeyID string `json:"gpgKeyId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
pgpMetadata := PGPUnlockerMetadata{
|
|
||||||
UnlockerMetadata: metadata,
|
|
||||||
GPGKeyID: fingerprint,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write metadata file
|
|
||||||
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
|
|
||||||
|
|
||||||
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to write metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get GPG key ID
|
|
||||||
retrievedKeyID, err := unlocker.GetGPGKeyID()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get GPG key ID: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify key ID (should be the fingerprint)
|
|
||||||
if retrievedKeyID != fingerprint {
|
|
||||||
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
|
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
|
||||||
// keyID into unlockerDir and checks that unlocker decrypts it.
|
// keyID into unlockerDir and checks that unlocker decrypts it.
|
||||||
func testPGPUnlockerGetIdentity(
|
func testPGPUnlockerGetIdentity(
|
||||||
|
|||||||
@@ -172,30 +172,6 @@ func (p *PGPUnlocker) Remove() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetGPGKeyID returns the GPG key ID from metadata
|
|
||||||
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
|
||||||
// Load the metadata
|
|
||||||
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
|
|
||||||
|
|
||||||
metadataData, err := afero.ReadFile(p.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pgpMetadata PGPUnlockerMetadata
|
|
||||||
|
|
||||||
err = json.Unmarshal(metadataData, &pgpMetadata)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pgpMetadata.GPGKeyID == "" {
|
|
||||||
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pgpMetadata.GPGKeyID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
||||||
// based on hostname and time
|
// based on hostname and time
|
||||||
func generatePGPUnlockerName() (string, error) {
|
func generatePGPUnlockerName() (string, error) {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
|
|||||||
installFakeGPG(t)
|
installFakeGPG(t)
|
||||||
|
|
||||||
base := afero.NewMemMapFs()
|
base := afero.NewMemMapFs()
|
||||||
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
|
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(_, path string) error {
|
fs := hookFs{Fs: base, before: func(_, path string) error {
|
||||||
@@ -87,7 +87,7 @@ func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic)
|
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
first, err := secret.CreatePGPUnlocker(
|
first, err := secret.CreatePGPUnlocker(
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ var (
|
|||||||
// Composed as "vault <name> already exists".
|
// Composed as "vault <name> already exists".
|
||||||
ErrVaultExists = errors.New("already exists")
|
ErrVaultExists = errors.New("already exists")
|
||||||
|
|
||||||
|
// ErrUnlockerWithoutMnemonic indicates that CreateVault was given a
|
||||||
|
// passphrase for an unlocker but no mnemonic to derive the long-term key
|
||||||
|
// it unlocks. Composed as "vault <name> needs a mnemonic for an unlocker".
|
||||||
|
ErrUnlockerWithoutMnemonic = errors.New("needs a mnemonic for an unlocker")
|
||||||
|
|
||||||
// ErrNilValueBuffer indicates a nil value buffer was supplied.
|
// ErrNilValueBuffer indicates a nil value buffer was supplied.
|
||||||
ErrNilValueBuffer = errors.New("value buffer is nil")
|
ErrNilValueBuffer = errors.New("value buffer is nil")
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
|
|
||||||
// Create a test vault
|
// Create a test vault
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
||||||
testMnemonicBuffer(t))
|
testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
// Create a test vault - CreateVault writes the public key derived from
|
// Create a test vault - CreateVault writes the public key derived from
|
||||||
// the mnemonic
|
// the mnemonic
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
||||||
testMnemonicBuffer(t))
|
testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
// Create a test vault - CreateVault writes the public key derived from
|
// Create a test vault - CreateVault writes the public key derived from
|
||||||
// the mnemonic
|
// the mnemonic
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
||||||
testMnemonicBuffer(t))
|
testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -324,7 +324,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range validNames {
|
for _, name := range validNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
||||||
}
|
}
|
||||||
@@ -340,7 +340,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range invalidNames {
|
for _, name := range invalidNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("Expected error creating vault with invalid name %q, "+
|
t.Errorf("Expected error creating vault with invalid name %q, "+
|
||||||
"but got none", name)
|
"but got none", name)
|
||||||
@@ -361,7 +361,7 @@ func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
// Create three vaults
|
// Create three vaults
|
||||||
vaultNames := []string{"vault1", "vault2", "vault3"}
|
vaultNames := []string{"vault1", "vault2", "vault3"}
|
||||||
for _, name := range vaultNames {
|
for _, name := range vaultNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault %s: %v", name, err)
|
t.Fatalf("Failed to create vault %s: %v", name, err)
|
||||||
}
|
}
|
||||||
@@ -411,12 +411,12 @@ func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
|
|
||||||
// Create two vaults - CreateVault writes the public key derived from
|
// Create two vaults - CreateVault writes the public key derived from
|
||||||
// the mnemonic
|
// the mnemonic
|
||||||
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t))
|
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault1: %v", err)
|
t.Fatalf("Failed to create vault1: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t))
|
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault2: %v", err)
|
t.Fatalf("Failed to create vault2: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault without a long-term key, which is set up below
|
// Create vault without a long-term key, which is set up below
|
||||||
vault, err := CreateVault(fs, testStateDir, "test", nil)
|
vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -152,16 +153,17 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// processMnemonicForVault handles mnemonic processing for vault creation.
|
// processMnemonicForVault handles mnemonic processing for vault creation.
|
||||||
// It returns the derivation index, public key hash, and family hash.
|
// It returns the long-term key, nil when there is no mnemonic, and the
|
||||||
|
// derivation index, public key hash, and family hash.
|
||||||
func processMnemonicForVault(
|
func processMnemonicForVault(
|
||||||
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
||||||
mnemonicBuffer *memguard.LockedBuffer,
|
mnemonicBuffer *memguard.LockedBuffer,
|
||||||
) (uint32, string, string, error) {
|
) (*age.X25519Identity, uint32, string, string, error) {
|
||||||
if mnemonicBuffer == nil {
|
if mnemonicBuffer == nil {
|
||||||
secret.Debug("No mnemonic given, vault created without long-term key",
|
secret.Debug("No mnemonic given, vault created without long-term key",
|
||||||
"vault", vaultName)
|
"vault", vaultName)
|
||||||
// Use 0 for derivation index when no mnemonic is provided
|
// Use 0 for derivation index when no mnemonic is provided
|
||||||
return 0, "", "", nil
|
return nil, 0, "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
mnemonic := mnemonicBuffer.String()
|
mnemonic := mnemonicBuffer.String()
|
||||||
@@ -171,13 +173,14 @@ func processMnemonicForVault(
|
|||||||
// Get the next available derivation index for this mnemonic
|
// Get the next available derivation index for this mnemonic
|
||||||
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
|
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
|
return nil, 0, "", "",
|
||||||
|
fmt.Errorf("failed to get next derivation index: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive the long-term key using the actual derivation index
|
// Derive the long-term key using the actual derivation index
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
|
return nil, 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the public key
|
// Write the public key
|
||||||
@@ -187,7 +190,8 @@ func processMnemonicForVault(
|
|||||||
|
|
||||||
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
|
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
|
return nil, 0, "", "",
|
||||||
|
fmt.Errorf("failed to write long-term public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
||||||
@@ -199,24 +203,33 @@ func processMnemonicForVault(
|
|||||||
// This is used to identify which vaults belong to the same mnemonic family
|
// This is used to identify which vaults belong to the same mnemonic family
|
||||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
|
return nil, 0, "", "",
|
||||||
|
fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||||
|
|
||||||
return derivationIndex, publicKeyHash, familyHash, nil
|
return ltIdentity, derivationIndex, publicKeyHash, familyHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateVault creates a new vault and selects it as the current vault. When
|
// CreateVault creates a new vault and selects it as the current vault. When
|
||||||
// mnemonic is not nil, the vault's long-term key is derived from it, and the
|
// mnemonic is not nil, the vault's long-term key is derived from it, and the
|
||||||
// returned vault has it as its Mnemonic; when it is nil, the vault has no
|
// returned vault has it as its Mnemonic; when it is nil, the vault has no
|
||||||
// long-term key until one is imported. It refuses a vault that already
|
// long-term key until one is imported. When passphrase is not nil, the vault
|
||||||
// exists before writing anything: creating it again would replace its keys,
|
// gets a passphrase unlocker protected by it, as its current unlocker; that
|
||||||
// and its secrets could no longer be decrypted. The commands that call it
|
// needs a mnemonic. It refuses a vault that already exists before writing
|
||||||
// hold the state directory lock, so no other command can create the vault
|
// anything: creating it again would replace its keys, and its secrets could
|
||||||
// between the check and the writes.
|
// no longer be decrypted. The commands that call it hold the state directory
|
||||||
|
// lock, so no other command can create the vault between the check and the
|
||||||
|
// writes.
|
||||||
|
//
|
||||||
|
// The vault is written whole into a temporary directory, which is renamed
|
||||||
|
// into vaults.d only once complete, and only then selected: a crash at any
|
||||||
|
// point leaves either no vault or a complete one. The next command that
|
||||||
|
// takes the lock deletes what the crash left under a temporary name.
|
||||||
func CreateVault(
|
func CreateVault(
|
||||||
fs afero.Fs, stateDir string, name string, mnemonic *memguard.LockedBuffer,
|
fs afero.Fs, stateDir string, name string,
|
||||||
|
mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
) (*Vault, error) {
|
) (*Vault, error) {
|
||||||
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
||||||
|
|
||||||
@@ -240,51 +253,19 @@ func CreateVault(
|
|||||||
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
|
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create vault directory structure
|
if passphrase != nil && mnemonic == nil {
|
||||||
|
return nil, fmt.Errorf("vault %s %w", name, ErrUnlockerWithoutMnemonic)
|
||||||
|
}
|
||||||
|
|
||||||
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
||||||
|
|
||||||
// Create main vault directory
|
err = secret.WriteDir(fs, vaultDir, func(dir string) error {
|
||||||
err = fs.MkdirAll(vaultDir, secret.DirPerms)
|
return writeVaultFiles(fs, stateDir, dir, name, mnemonic, passphrase)
|
||||||
if err != nil {
|
})
|
||||||
return nil, fmt.Errorf("failed to create vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create secrets directory
|
|
||||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
|
||||||
|
|
||||||
err = fs.MkdirAll(secretsDir, secret.DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create unlockers directory
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
||||||
|
|
||||||
err = fs.MkdirAll(unlockersDir, secret.DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process mnemonic if available
|
|
||||||
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
|
|
||||||
fs, stateDir, vaultDir, name, mnemonic)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save vault metadata
|
|
||||||
metadata := &Metadata{
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
DerivationIndex: derivationIndex,
|
|
||||||
PublicKeyHash: publicKeyHash,
|
|
||||||
MnemonicFamilyHash: familyHash,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = SaveVaultMetadata(fs, vaultDir, metadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select the newly created vault as current
|
// Select the newly created vault as current
|
||||||
secret.Debug("Selecting newly created vault as current", "name", name)
|
secret.Debug("Selecting newly created vault as current", "name", name)
|
||||||
|
|
||||||
@@ -302,6 +283,47 @@ func CreateVault(
|
|||||||
return vlt, nil
|
return vlt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeVaultFiles writes the files of the new vault name into vaultDir: its
|
||||||
|
// secrets and unlockers directories, its long-term public key and metadata,
|
||||||
|
// and, when passphrase is not nil, a passphrase unlocker as its current one.
|
||||||
|
func writeVaultFiles(
|
||||||
|
fs afero.Fs, stateDir, vaultDir, name string,
|
||||||
|
mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
|
) error {
|
||||||
|
for _, subdir := range []string{"secrets.d", "unlockers.d"} {
|
||||||
|
err := fs.MkdirAll(filepath.Join(vaultDir, subdir), secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create %s directory: %w", subdir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ltIdentity, derivationIndex, publicKeyHash, familyHash, err :=
|
||||||
|
processMnemonicForVault(fs, stateDir, vaultDir, name, mnemonic)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &Metadata{
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
DerivationIndex: derivationIndex,
|
||||||
|
PublicKeyHash: publicKeyHash,
|
||||||
|
MnemonicFamilyHash: familyHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = SaveVaultMetadata(fs, vaultDir, metadata)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if passphrase == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = writePassphraseUnlocker(fs, vaultDir, ltIdentity, passphrase)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// SelectVault selects the given vault as the current vault
|
// SelectVault selects the given vault as the current vault
|
||||||
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||||
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
// Test Case 1: Create vault WITH mnemonic (like init command)
|
// Test Case 1: Create vault WITH mnemonic (like init command)
|
||||||
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t))
|
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
||||||
}
|
}
|
||||||
@@ -321,7 +321,7 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
||||||
|
|
||||||
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
||||||
_, err = vault.CreateVault(fs, tempDir, "work", nil)
|
_, err = vault.CreateVault(fs, tempDir, "work", nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
testMnemonicBuffer(t))
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Add a legitimate secret so the vault is set up
|
// Add a legitimate secret so the vault is set up
|
||||||
@@ -57,7 +57,7 @@ func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
testMnemonicBuffer(t))
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = vlt.GetSecret("../../../etc/passwd")
|
_, err = vlt.GetSecret("../../../etc/passwd")
|
||||||
@@ -73,7 +73,7 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
testMnemonicBuffer(t))
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
maliciousNames := []string{
|
maliciousNames := []string{
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Create vault without a long-term key, which is set up below
|
// Create vault without a long-term key, which is set up below
|
||||||
vault, err := CreateVault(fs, testStateDir, "test", nil)
|
vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
|
|||||||
+40
-23
@@ -188,8 +188,9 @@ func (v *Vault) findUnlockerByID(
|
|||||||
return nil, skippedDirPath, nil
|
return nil, skippedDirPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUnlockers returns a list of available unlockers for this vault
|
// ListUnlockers returns the metadata of each unlocker of this vault, keyed
|
||||||
func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
// by the unlocker's ID, the name of its directory in unlockers.d
|
||||||
|
func (v *Vault) ListUnlockers() (map[string]UnlockerMetadata, error) {
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -204,7 +205,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return []UnlockerMetadata{}, nil
|
return map[string]UnlockerMetadata{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List directories in unlockers.d
|
// List directories in unlockers.d
|
||||||
@@ -213,7 +214,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
|
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var unlockers []UnlockerMetadata
|
unlockers := map[string]UnlockerMetadata{}
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if !file.IsDir() {
|
if !file.IsDir() {
|
||||||
@@ -222,7 +223,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
|
|
||||||
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
||||||
if ok {
|
if ok {
|
||||||
unlockers = append(unlockers, metadata)
|
unlockers[file.Name()] = metadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,8 +391,31 @@ func (v *Vault) CreatePassphraseUnlocker(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unlocker, err := writePassphraseUnlocker(v.fs, vaultDir, ltIdentity, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, oldDir := range oldDirs {
|
||||||
|
err = secret.RemoveDirAtomic(v.fs, oldDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"created and selected the new passphrase unlocker: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unlocker, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePassphraseUnlocker writes a new passphrase unlocker of the long-term
|
||||||
|
// key ltIdentity into the vault directory vaultDir, in a directory of its own,
|
||||||
|
// and makes it the vault's current unlocker.
|
||||||
|
func writePassphraseUnlocker(
|
||||||
|
fs afero.Fs, vaultDir string, ltIdentity *age.X25519Identity,
|
||||||
|
passphrase *memguard.LockedBuffer,
|
||||||
|
) (*secret.PassphraseUnlocker, error) {
|
||||||
createdAt := time.Now()
|
createdAt := time.Now()
|
||||||
unlockerDir := filepath.Join(unlockersDir, unlockerTypePassphrase+"-"+
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase+"-"+
|
||||||
createdAt.UTC().Format(secret.UnlockerTimeFormat))
|
createdAt.UTC().Format(secret.UnlockerTimeFormat))
|
||||||
|
|
||||||
// Generate new age keypair for unlocker
|
// Generate new age keypair for unlocker
|
||||||
@@ -422,8 +446,8 @@ func (v *Vault) CreatePassphraseUnlocker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write the unlocker's files, the metadata last
|
// Write the unlocker's files, the metadata last
|
||||||
err = secret.WriteDir(v.fs, unlockerDir, func(dir string) error {
|
err = secret.WriteDir(fs, unlockerDir, func(dir string) error {
|
||||||
return v.writeUnlockerFiles(dir, unlockerIdentity, passphrase,
|
return writeUnlockerFiles(fs, dir, unlockerIdentity, passphrase,
|
||||||
encryptedLtPrivKey, metadataBytes)
|
encryptedLtPrivKey, metadataBytes)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -433,21 +457,13 @@ func (v *Vault) CreatePassphraseUnlocker(
|
|||||||
// Make the new unlocker the current one
|
// Make the new unlocker the current one
|
||||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath,
|
err = secret.WriteFileAtomic(fs, currentUnlockerPath,
|
||||||
[]byte(filepath.Base(unlockerDir)))
|
[]byte(filepath.Base(unlockerDir)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
|
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, oldDir := range oldDirs {
|
return secret.NewPassphraseUnlocker(fs, unlockerDir, metadata), nil
|
||||||
err = secret.RemoveDirAtomic(v.fs, oldDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"created and selected the new passphrase unlocker: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// passphraseUnlockerDirs returns the directories in unlockersDir that hold
|
// passphraseUnlockerDirs returns the directories in unlockersDir that hold
|
||||||
@@ -513,7 +529,8 @@ func (v *Vault) readUnlockerMetadata(unlockerDir string) (UnlockerMetadata, erro
|
|||||||
// writeUnlockerFiles writes the files of a passphrase unlocker into
|
// writeUnlockerFiles writes the files of a passphrase unlocker into
|
||||||
// unlockerDir: its public key, its passphrase-encrypted private key, the
|
// unlockerDir: its public key, its passphrase-encrypted private key, the
|
||||||
// long-term private key encrypted to it, and its metadata, last.
|
// long-term private key encrypted to it, and its metadata, last.
|
||||||
func (v *Vault) writeUnlockerFiles(
|
func writeUnlockerFiles(
|
||||||
|
fs afero.Fs,
|
||||||
unlockerDir string,
|
unlockerDir string,
|
||||||
unlockerIdentity *age.X25519Identity,
|
unlockerIdentity *age.X25519Identity,
|
||||||
passphrase *memguard.LockedBuffer,
|
passphrase *memguard.LockedBuffer,
|
||||||
@@ -522,7 +539,7 @@ func (v *Vault) writeUnlockerFiles(
|
|||||||
// Write public key
|
// Write public key
|
||||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||||
|
|
||||||
err := secret.WriteFileAtomic(v.fs, pubKeyPath,
|
err := secret.WriteFileAtomic(fs, pubKeyPath,
|
||||||
[]byte(unlockerIdentity.Recipient().String()))
|
[]byte(unlockerIdentity.Recipient().String()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write unlocker public key: %w", err)
|
return fmt.Errorf("failed to write unlocker public key: %w", err)
|
||||||
@@ -540,18 +557,18 @@ func (v *Vault) writeUnlockerFiles(
|
|||||||
// Write encrypted private key
|
// Write encrypted private key
|
||||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs, privKeyPath, encryptedPrivKey)
|
err = secret.WriteFileAtomic(fs, privKeyPath, encryptedPrivKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs,
|
err = secret.WriteFileAtomic(fs,
|
||||||
filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey)
|
filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs,
|
err = secret.WriteFileAtomic(fs,
|
||||||
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes)
|
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package vault_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -72,7 +73,7 @@ func testCreateVault(t *testing.T, fs afero.Fs) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
testMnemonicBuffer(t))
|
testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -298,7 +299,7 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
|||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
testMnemonicBuffer(t))
|
testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -344,3 +345,29 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreateVaultUnlockerNeedsMnemonic checks that CreateVault, given a
|
||||||
|
// passphrase for an unlocker but no mnemonic to derive the long-term key from,
|
||||||
|
// fails without writing anything.
|
||||||
|
func TestCreateVaultUnlockerNeedsMnemonic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
defer passphrase.Destroy()
|
||||||
|
|
||||||
|
_, err := vault.CreateVault(fs, testStateDir, testVaultName, nil, passphrase)
|
||||||
|
if !errors.Is(err, vault.ErrUnlockerWithoutMnemonic) {
|
||||||
|
t.Fatalf("Expected ErrUnlockerWithoutMnemonic, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := afero.Exists(fs, testStateDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to check for the state directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
t.Errorf("CreateVault wrote the state directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user