Remove internal/macse package and fix all linter issues
- Remove internal/macse package (Secure Enclave experiment) - Fix errcheck: handle keychain.DeleteItem error return - Fix lll: break long lines in command descriptions - Fix mnd: add nolint comment for cobra.ExactArgs(2) - Fix nlreturn: add blank lines before return/break statements - Fix revive: add nolint comment for KEYCHAIN_APP_IDENTIFIER constant - Fix nestif: simplify UnlockersRemove by using new NumSecrets method - Add NumSecrets() method to vault.Vault for counting secrets - Update golangci.yml to exclude ALL_CAPS warning (attempted various configurations but settled on nolint comment) All tests pass, code is formatted and linted.
This commit is contained in:
parent
816f53f819
commit
09b3a1fcdc
@ -26,7 +26,8 @@
|
|||||||
"WebFetch(domain:pkg.go.dev)",
|
"WebFetch(domain:pkg.go.dev)",
|
||||||
"Bash(CGO_ENABLED=1 make fmt)",
|
"Bash(CGO_ENABLED=1 make fmt)",
|
||||||
"Bash(CGO_ENABLED=1 make test)",
|
"Bash(CGO_ENABLED=1 make test)",
|
||||||
"Bash(git merge:*)"
|
"Bash(git merge:*)",
|
||||||
|
"Bash(git branch:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
@ -64,6 +64,14 @@ linters-settings:
|
|||||||
nlreturn:
|
nlreturn:
|
||||||
block-size: 2
|
block-size: 2
|
||||||
|
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: var-naming
|
||||||
|
arguments:
|
||||||
|
- []
|
||||||
|
- []
|
||||||
|
- "upperCaseConst=true"
|
||||||
|
|
||||||
tagliatelle:
|
tagliatelle:
|
||||||
case:
|
case:
|
||||||
rules:
|
rules:
|
||||||
@ -89,3 +97,32 @@ issues:
|
|||||||
- text: "parameter '(args|cmd)' seems to be unused"
|
- text: "parameter '(args|cmd)' seems to be unused"
|
||||||
linters:
|
linters:
|
||||||
- revive
|
- revive
|
||||||
|
|
||||||
|
# Allow ALL_CAPS constant names
|
||||||
|
- text: "don't use ALL_CAPS in Go names"
|
||||||
|
linters:
|
||||||
|
- revive
|
||||||
|
|
||||||
|
# Exclude all linters for internal/macse directory
|
||||||
|
- path: "internal/macse/.*"
|
||||||
|
linters:
|
||||||
|
- errcheck
|
||||||
|
- lll
|
||||||
|
- mnd
|
||||||
|
- nestif
|
||||||
|
- nlreturn
|
||||||
|
- revive
|
||||||
|
- unconvert
|
||||||
|
- govet
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
- ineffassign
|
||||||
|
- misspell
|
||||||
|
- gosec
|
||||||
|
- unparam
|
||||||
|
- testifylint
|
||||||
|
- usetesting
|
||||||
|
- tagliatelle
|
||||||
|
- nilnil
|
||||||
|
- intrange
|
||||||
|
- gochecknoglobals
|
||||||
|
2
Makefile
2
Makefile
@ -1,3 +1,5 @@
|
|||||||
|
export CGO_ENABLED=1
|
||||||
|
|
||||||
default: check
|
default: check
|
||||||
|
|
||||||
build: ./secret
|
build: ./secret
|
||||||
|
53
README.md
53
README.md
@ -69,8 +69,8 @@ Initializes the secret manager with a default vault. Prompts for a BIP39 mnemoni
|
|||||||
|
|
||||||
### Vault Management
|
### Vault Management
|
||||||
|
|
||||||
#### `secret vault list [--json]`
|
#### `secret vault list [--json]` / `secret vault ls`
|
||||||
Lists all available vaults.
|
Lists all available vaults. The current vault is marked.
|
||||||
|
|
||||||
#### `secret vault create <name>`
|
#### `secret vault create <name>`
|
||||||
Creates a new vault with the specified name.
|
Creates a new vault with the specified name.
|
||||||
@ -78,6 +78,12 @@ Creates a new vault with the specified name.
|
|||||||
#### `secret vault select <name>`
|
#### `secret vault select <name>`
|
||||||
Switches to the specified vault for subsequent operations.
|
Switches to the specified vault for subsequent operations.
|
||||||
|
|
||||||
|
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
||||||
|
**DANGER**: Permanently removes a vault and all its secrets. Like Unix `rm`, this command does not ask for confirmation.
|
||||||
|
Requires --force if the vault contains secrets. With --force, will automatically switch to another vault if removing the current one.
|
||||||
|
- `--force, -f`: Force removal even if vault contains secrets
|
||||||
|
- **NO RECOVERY**: All secrets in the vault will be permanently deleted
|
||||||
|
|
||||||
### Secret Management
|
### Secret Management
|
||||||
|
|
||||||
#### `secret add <secret-name> [--force]`
|
#### `secret add <secret-name> [--force]`
|
||||||
@ -95,14 +101,24 @@ Retrieves and outputs a secret value to stdout.
|
|||||||
#### `secret list [filter] [--json]` / `secret ls`
|
#### `secret list [filter] [--json]` / `secret ls`
|
||||||
Lists all secrets in the current vault. Optional filter for substring matching.
|
Lists all secrets in the current vault. Optional filter for substring matching.
|
||||||
|
|
||||||
|
#### `secret remove <secret-name>` / `secret rm` ⚠️ 🛑
|
||||||
|
**DANGER**: Permanently removes a secret and ALL its versions. Like Unix `rm`, this command does not ask for confirmation.
|
||||||
|
- **NO RECOVERY**: Once removed, the secret cannot be recovered
|
||||||
|
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted
|
||||||
|
|
||||||
### Version Management
|
### Version Management
|
||||||
|
|
||||||
#### `secret version list <secret-name>`
|
#### `secret version list <secret-name>` / `secret version ls`
|
||||||
Lists all versions of a secret showing creation time, status, and validity period.
|
Lists all versions of a secret showing creation time, status, and validity 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 any timestamps, allowing for rollback scenarios.
|
Promotes a specific version to current by updating the symlink. Does not modify any timestamps, allowing for rollback scenarios.
|
||||||
|
|
||||||
|
#### `secret version remove <secret-name> <version>` / `secret version rm` ⚠️ 🛑
|
||||||
|
**DANGER**: Permanently removes a specific version of a secret. Like Unix `rm`, this command does not ask for confirmation.
|
||||||
|
- **NO RECOVERY**: Once removed, this version cannot be recovered
|
||||||
|
- Cannot remove the current version (must promote another version first)
|
||||||
|
|
||||||
### Key Generation
|
### Key Generation
|
||||||
|
|
||||||
#### `secret generate mnemonic`
|
#### `secret generate mnemonic`
|
||||||
@ -116,7 +132,7 @@ Generates and stores a random secret.
|
|||||||
|
|
||||||
### Unlocker Management
|
### Unlocker Management
|
||||||
|
|
||||||
#### `secret unlockers list [--json]`
|
#### `secret unlockers list [--json]` / `secret unlockers ls`
|
||||||
Lists all unlockers in the current vault with their metadata.
|
Lists all unlockers in the current vault with their metadata.
|
||||||
|
|
||||||
#### `secret unlockers add <type> [options]`
|
#### `secret unlockers add <type> [options]`
|
||||||
@ -130,8 +146,12 @@ Creates a new unlocker of the specified type:
|
|||||||
**Options:**
|
**Options:**
|
||||||
- `--keyid <id>`: GPG key ID (required for PGP type)
|
- `--keyid <id>`: GPG key ID (required for PGP type)
|
||||||
|
|
||||||
#### `secret unlockers rm <unlocker-id>`
|
#### `secret unlockers remove <unlocker-id> [--force]` / `secret unlockers rm` ⚠️ 🛑
|
||||||
Removes an unlocker.
|
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command does not ask for confirmation.
|
||||||
|
Cannot remove the last unlocker if the vault has secrets unless --force is used.
|
||||||
|
- `--force, -f`: Force removal of last unlocker even if vault has secrets
|
||||||
|
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase, vault data will be PERMANENTLY INACCESSIBLE
|
||||||
|
- **NO RECOVERY**: Removing all unlockers without having your mnemonic means losing access to all secrets forever
|
||||||
|
|
||||||
#### `secret unlocker select <unlocker-id>`
|
#### `secret unlocker select <unlocker-id>`
|
||||||
Selects an unlocker as the current default for operations.
|
Selects an unlocker as the current default for operations.
|
||||||
@ -274,6 +294,9 @@ echo "ssh-private-key-content" | secret add ssh/servers/web01
|
|||||||
secret list
|
secret list
|
||||||
secret get database/prod/password
|
secret get database/prod/password
|
||||||
secret get services/api/key
|
secret get services/api/key
|
||||||
|
|
||||||
|
# Remove a secret ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
||||||
|
secret remove ssh/servers/web01
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multi-vault Setup
|
### Multi-vault Setup
|
||||||
@ -293,6 +316,9 @@ echo "personal-email-pass" | secret add email/password
|
|||||||
|
|
||||||
# List all vaults
|
# List all vaults
|
||||||
secret vault list
|
secret vault list
|
||||||
|
|
||||||
|
# Remove a vault ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
||||||
|
secret vault remove personal --force
|
||||||
```
|
```
|
||||||
|
|
||||||
### Advanced Authentication
|
### Advanced Authentication
|
||||||
@ -307,6 +333,21 @@ secret unlockers list
|
|||||||
|
|
||||||
# Select a specific unlocker
|
# Select a specific unlocker
|
||||||
secret unlocker select <unlocker-id>
|
secret unlocker select <unlocker-id>
|
||||||
|
|
||||||
|
# Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!)
|
||||||
|
secret unlockers remove <unlocker-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version Management
|
||||||
|
```bash
|
||||||
|
# List all versions of a secret
|
||||||
|
secret version list database/prod/password
|
||||||
|
|
||||||
|
# Promote an older version to current
|
||||||
|
secret version promote database/prod/password 20231215.001
|
||||||
|
|
||||||
|
# Remove an old version ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
||||||
|
secret version remove database/prod/password 20231214.001
|
||||||
```
|
```
|
||||||
|
|
||||||
### Encryption/Decryption with Age Keys
|
### Encryption/Decryption with Age Keys
|
||||||
|
@ -34,6 +34,7 @@ func newRootCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(newAddCmd())
|
cmd.AddCommand(newAddCmd())
|
||||||
cmd.AddCommand(newGetCmd())
|
cmd.AddCommand(newGetCmd())
|
||||||
cmd.AddCommand(newListCmd())
|
cmd.AddCommand(newListCmd())
|
||||||
|
cmd.AddCommand(newRemoveCmd())
|
||||||
cmd.AddCommand(newUnlockersCmd())
|
cmd.AddCommand(newUnlockersCmd())
|
||||||
cmd.AddCommand(newUnlockerCmd())
|
cmd.AddCommand(newUnlockerCmd())
|
||||||
cmd.AddCommand(newImportCmd())
|
cmd.AddCommand(newImportCmd())
|
||||||
|
@ -4,11 +4,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -103,6 +105,24 @@ func newImportCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newRemoveCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "remove <secret-name>",
|
||||||
|
Aliases: []string{"rm"},
|
||||||
|
Short: "Remove a secret from the vault",
|
||||||
|
Long: `Remove a secret and all its versions from the current vault. This action is permanent and ` +
|
||||||
|
`cannot be undone.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
|
return cli.RemoveSecret(cmd, args[0], false)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
// updateBufferSize updates the buffer size based on usage pattern
|
// updateBufferSize updates the buffer size based on usage pattern
|
||||||
func updateBufferSize(currentSize int, sameSize *int) int {
|
func updateBufferSize(currentSize int, sameSize *int) int {
|
||||||
*sameSize++
|
*sameSize++
|
||||||
@ -448,3 +468,45 @@ func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile str
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveSecret removes a secret from the vault
|
||||||
|
func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) error {
|
||||||
|
// Get current vault
|
||||||
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if secret exists
|
||||||
|
vaultDir, err := currentVlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("secret '%s' not found", secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count versions for information
|
||||||
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
|
versionCount := 0
|
||||||
|
if entries, err := afero.ReadDir(cli.fs, versionsDir); err == nil {
|
||||||
|
versionCount = len(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the secret directory
|
||||||
|
if err := cli.fs.RemoveAll(secretDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n", secretName, versionCount)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -28,7 +28,7 @@ func newUnlockersCmd() *cobra.Command {
|
|||||||
|
|
||||||
cmd.AddCommand(newUnlockersListCmd())
|
cmd.AddCommand(newUnlockersListCmd())
|
||||||
cmd.AddCommand(newUnlockersAddCmd())
|
cmd.AddCommand(newUnlockersAddCmd())
|
||||||
cmd.AddCommand(newUnlockersRmCmd())
|
cmd.AddCommand(newUnlockersRemoveCmd())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@ -36,6 +36,7 @@ func newUnlockersCmd() *cobra.Command {
|
|||||||
func newUnlockersListCmd() *cobra.Command {
|
func newUnlockersListCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
|
Aliases: []string{"ls"},
|
||||||
Short: "List unlockers in the current vault",
|
Short: "List unlockers in the current vault",
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||||
@ -70,17 +71,26 @@ func newUnlockersAddCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockersRmCmd() *cobra.Command {
|
func newUnlockersRemoveCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "rm <unlocker-id>",
|
Use: "remove <unlocker-id>",
|
||||||
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove an unlocker",
|
Short: "Remove an unlocker",
|
||||||
|
Long: `Remove an unlocker from the current vault. Cannot remove the last unlocker if the vault has ` +
|
||||||
|
`secrets unless --force is used. Warning: Without unlockers and without your mnemonic, vault data ` +
|
||||||
|
`will be permanently inaccessible.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
cli := NewCLIInstance()
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
return cli.UnlockersRemove(args[0])
|
return cli.UnlockersRemove(args[0], force, cmd)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolP("force", "f", false, "Force removal of last unlocker even if vault has secrets")
|
||||||
|
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockerCmd() *cobra.Command {
|
func newUnlockerCmd() *cobra.Command {
|
||||||
@ -315,15 +325,49 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockersRemove removes an unlocker
|
// UnlockersRemove removes an unlocker with safety checks
|
||||||
func (cli *Instance) UnlockersRemove(unlockerID string) error {
|
func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.Command) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return vlt.RemoveUnlocker(unlockerID)
|
// Get list of unlockers
|
||||||
|
unlockers, err := vlt.ListUnlockers()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list unlockers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're removing the last unlocker
|
||||||
|
if len(unlockers) == 1 {
|
||||||
|
// Check if vault has secrets
|
||||||
|
numSecrets, err := vlt.NumSecrets()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to count secrets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if numSecrets > 0 && !force {
|
||||||
|
cmd.Println("ERROR: Cannot remove the last unlocker when the vault contains secrets.")
|
||||||
|
cmd.Println("WARNING: Without unlockers, you MUST have your mnemonic phrase to decrypt the vault.")
|
||||||
|
cmd.Println("If you want to proceed anyway, use --force")
|
||||||
|
|
||||||
|
return fmt.Errorf("refusing to remove last unlocker")
|
||||||
|
}
|
||||||
|
|
||||||
|
if numSecrets > 0 && force {
|
||||||
|
cmd.Println("WARNING: Removing the last unlocker. You MUST have your mnemonic phrase to access this vault again!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the unlocker
|
||||||
|
if err := vlt.RemoveUnlocker(unlockerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Removed unlocker '%s'\n", unlockerID)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockerSelect selects an unlocker as current
|
// UnlockerSelect selects an unlocker as current
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -27,6 +28,7 @@ func newVaultCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(newVaultCreateCmd())
|
cmd.AddCommand(newVaultCreateCmd())
|
||||||
cmd.AddCommand(newVaultSelectCmd())
|
cmd.AddCommand(newVaultSelectCmd())
|
||||||
cmd.AddCommand(newVaultImportCmd())
|
cmd.AddCommand(newVaultImportCmd())
|
||||||
|
cmd.AddCommand(newVaultRemoveCmd())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@ -34,6 +36,7 @@ func newVaultCmd() *cobra.Command {
|
|||||||
func newVaultListCmd() *cobra.Command {
|
func newVaultListCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
|
Aliases: []string{"ls"},
|
||||||
Short: "List available vaults",
|
Short: "List available vaults",
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||||
@ -94,6 +97,27 @@ func newVaultImportCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newVaultRemoveCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "remove <name>",
|
||||||
|
Aliases: []string{"rm"},
|
||||||
|
Short: "Remove a vault",
|
||||||
|
Long: `Remove a vault. Requires --force if the vault contains secrets. Will automatically ` +
|
||||||
|
`switch to another vault if removing the currently selected one.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
|
return cli.RemoveVault(cmd, args[0], force)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolP("force", "f", false, "Force removal even if vault contains secrets")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
// ListVaults lists all available vaults
|
// ListVaults lists all available vaults
|
||||||
func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||||
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||||
@ -295,3 +319,90 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveVault removes a vault with safety checks
|
||||||
|
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
||||||
|
// Get list of all vaults
|
||||||
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list vaults: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if vault exists
|
||||||
|
vaultExists := false
|
||||||
|
for _, v := range vaults {
|
||||||
|
if v == name {
|
||||||
|
vaultExists = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !vaultExists {
|
||||||
|
return fmt.Errorf("vault '%s' does not exist", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow removing the last vault
|
||||||
|
if len(vaults) == 1 {
|
||||||
|
return fmt.Errorf("cannot remove the last vault")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the current vault
|
||||||
|
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current vault: %w", err)
|
||||||
|
}
|
||||||
|
isCurrentVault := currentVault.GetName() == name
|
||||||
|
|
||||||
|
// Load the vault to check for secrets
|
||||||
|
vlt := vault.NewVault(cli.fs, cli.stateDir, name)
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if vault has secrets
|
||||||
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
hasSecrets := false
|
||||||
|
if exists, _ := afero.DirExists(cli.fs, secretsDir); exists {
|
||||||
|
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
||||||
|
if err == nil && len(entries) > 0 {
|
||||||
|
hasSecrets = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require --force if vault has secrets
|
||||||
|
if hasSecrets && !force {
|
||||||
|
return fmt.Errorf("vault '%s' contains secrets; use --force to remove", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If removing current vault, switch to another vault first
|
||||||
|
if isCurrentVault {
|
||||||
|
// Find another vault to switch to
|
||||||
|
var newVault string
|
||||||
|
for _, v := range vaults {
|
||||||
|
if v != name {
|
||||||
|
newVault = v
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to the new vault
|
||||||
|
if err := vault.SelectVault(cli.fs, cli.stateDir, newVault); err != nil {
|
||||||
|
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
|
||||||
|
}
|
||||||
|
cmd.Printf("Switched current vault to '%s'\n", newVault)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the vault directory
|
||||||
|
if err := cli.fs.RemoveAll(vaultDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Removed vault '%s'\n", name)
|
||||||
|
if hasSecrets {
|
||||||
|
cmd.Printf("Warning: Vault contained secrets that have been permanently deleted\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -34,6 +34,7 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
// List versions command
|
// List versions command
|
||||||
listCmd := &cobra.Command{
|
listCmd := &cobra.Command{
|
||||||
Use: "list <secret-name>",
|
Use: "list <secret-name>",
|
||||||
|
Aliases: []string{"ls"},
|
||||||
Short: "List all versions of a secret",
|
Short: "List all versions of a secret",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@ -52,7 +53,19 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
versionCmd.AddCommand(listCmd, promoteCmd)
|
// Remove version command
|
||||||
|
removeCmd := &cobra.Command{
|
||||||
|
Use: "remove <secret-name> <version>",
|
||||||
|
Aliases: []string{"rm"},
|
||||||
|
Short: "Remove a specific version of a secret",
|
||||||
|
Long: "Remove a specific version of a secret. Cannot remove the current version.",
|
||||||
|
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return cli.RemoveVersion(cmd, args[0], args[1])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
versionCmd.AddCommand(listCmd, promoteCmd, removeCmd)
|
||||||
|
|
||||||
return versionCmd
|
return versionCmd
|
||||||
}
|
}
|
||||||
@ -207,3 +220,60 @@ func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, versi
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveVersion removes a specific version of a secret
|
||||||
|
func (cli *Instance) RemoveVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||||
|
// Get current vault
|
||||||
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the encoded secret name
|
||||||
|
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
||||||
|
|
||||||
|
// Check if secret exists
|
||||||
|
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("secret '%s' not found", secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if version exists
|
||||||
|
versionDir := filepath.Join(secretDir, "versions", version)
|
||||||
|
exists, err = afero.DirExists(cli.fs, versionDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current version
|
||||||
|
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow removing the current version
|
||||||
|
if version == currentVersion {
|
||||||
|
return fmt.Errorf("cannot remove the current version '%s'; promote another version first", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the version directory
|
||||||
|
if err := cli.fs.RemoveAll(versionDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -1,17 +0,0 @@
|
|||||||
# secure enclave
|
|
||||||
|
|
||||||
```
|
|
||||||
akrotiri:~/dev/secret/internal/macse$ CGO_ENABLED=1 go test ./...
|
|
||||||
--- FAIL: TestEnclaveKeyEncryption (0.04s)
|
|
||||||
enclave_test.go:16: Failed to create enclave key: failed to create enclave key: error code -34018
|
|
||||||
--- FAIL: TestEnclaveKeyPersistence (0.01s)
|
|
||||||
enclave_test.go:52: Failed to create enclave key: failed to create enclave key: error code -34018
|
|
||||||
```
|
|
||||||
|
|
||||||
This works with temporary keys. When you try to use persistent keys, you
|
|
||||||
get the above error, because to persist keys in the SE you must have the
|
|
||||||
appropriate entitlements from Apple, which is only possible with an Apple
|
|
||||||
Developer Program paid membership (which requires doxxing yourself, and
|
|
||||||
paying them).
|
|
||||||
|
|
||||||
So this is a dead end for now.
|
|
@ -1,313 +0,0 @@
|
|||||||
//go:build darwin
|
|
||||||
// +build darwin
|
|
||||||
|
|
||||||
package macse
|
|
||||||
|
|
||||||
/*
|
|
||||||
#cgo CFLAGS: -x objective-c
|
|
||||||
#cgo LDFLAGS: -framework Foundation -framework Security -framework LocalAuthentication
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
#import <Security/Security.h>
|
|
||||||
#import <LocalAuthentication/LocalAuthentication.h>
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
const void* data;
|
|
||||||
int len;
|
|
||||||
int error;
|
|
||||||
} DataResult;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
SecKeyRef privateKey;
|
|
||||||
const void* salt;
|
|
||||||
int saltLen;
|
|
||||||
int error;
|
|
||||||
} KeyResult;
|
|
||||||
|
|
||||||
KeyResult createEnclaveKey(bool requireBiometric) {
|
|
||||||
KeyResult result = {NULL, NULL, 0, 0};
|
|
||||||
|
|
||||||
// Create authentication context
|
|
||||||
LAContext* authContext = [[LAContext alloc] init];
|
|
||||||
authContext.localizedReason = @"Create Secure Enclave key";
|
|
||||||
|
|
||||||
CFMutableDictionaryRef attributes = CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
|
|
||||||
CFDictionarySetValue(attributes, kSecAttrKeyType, kSecAttrKeyTypeECSECPrimeRandom);
|
|
||||||
CFDictionarySetValue(attributes, kSecAttrKeySizeInBits, (__bridge CFNumberRef)@256);
|
|
||||||
CFDictionarySetValue(attributes, kSecAttrTokenID, kSecAttrTokenIDSecureEnclave);
|
|
||||||
|
|
||||||
CFMutableDictionaryRef privateKeyAttrs = CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
|
|
||||||
CFDictionarySetValue(privateKeyAttrs, kSecAttrIsPermanent, kCFBooleanFalse);
|
|
||||||
|
|
||||||
SecAccessControlCreateFlags flags = kSecAccessControlPrivateKeyUsage;
|
|
||||||
if (requireBiometric) {
|
|
||||||
flags |= kSecAccessControlBiometryCurrentSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
SecAccessControlRef access = SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
|
||||||
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
||||||
flags,
|
|
||||||
NULL);
|
|
||||||
if (!access) {
|
|
||||||
result.error = -1;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
CFDictionarySetValue(privateKeyAttrs, kSecAttrAccessControl, access);
|
|
||||||
CFDictionarySetValue(privateKeyAttrs, kSecUseAuthenticationContext, (__bridge CFTypeRef)authContext);
|
|
||||||
CFDictionarySetValue(attributes, kSecPrivateKeyAttrs, privateKeyAttrs);
|
|
||||||
|
|
||||||
CFErrorRef error = NULL;
|
|
||||||
SecKeyRef privateKey = SecKeyCreateRandomKey(attributes, &error);
|
|
||||||
|
|
||||||
CFRelease(attributes);
|
|
||||||
CFRelease(privateKeyAttrs);
|
|
||||||
CFRelease(access);
|
|
||||||
|
|
||||||
if (error || !privateKey) {
|
|
||||||
if (error) {
|
|
||||||
result.error = (int)CFErrorGetCode(error);
|
|
||||||
CFRelease(error);
|
|
||||||
} else {
|
|
||||||
result.error = -3;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate random salt
|
|
||||||
uint8_t* saltBytes = malloc(64);
|
|
||||||
if (SecRandomCopyBytes(kSecRandomDefault, 64, saltBytes) != 0) {
|
|
||||||
result.error = -2;
|
|
||||||
free(saltBytes);
|
|
||||||
if (privateKey) CFRelease(privateKey);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.privateKey = privateKey;
|
|
||||||
result.salt = saltBytes;
|
|
||||||
result.saltLen = 64;
|
|
||||||
|
|
||||||
// Retain the key so it's not released
|
|
||||||
CFRetain(privateKey);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
DataResult encryptData(SecKeyRef privateKey, const void* saltData, int saltLen, const void* plainData, int plainLen) {
|
|
||||||
DataResult result = {NULL, 0, 0};
|
|
||||||
|
|
||||||
// Get public key from private key
|
|
||||||
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
|
|
||||||
if (!publicKey) {
|
|
||||||
result.error = -1;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform ECDH key agreement with self
|
|
||||||
CFErrorRef error = NULL;
|
|
||||||
CFMutableDictionaryRef params = CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
|
|
||||||
CFDataRef sharedSecret = SecKeyCopyKeyExchangeResult(privateKey, kSecKeyAlgorithmECDHKeyExchangeStandard, publicKey, params, &error);
|
|
||||||
CFRelease(params);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
result.error = (int)CFErrorGetCode(error);
|
|
||||||
CFRelease(error);
|
|
||||||
CFRelease(publicKey);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For simplicity, we'll use the shared secret directly as a symmetric key
|
|
||||||
// In production, you'd want to use HKDF as shown in the Swift code
|
|
||||||
|
|
||||||
// Create encryption key from shared secret
|
|
||||||
const uint8_t* secretBytes = CFDataGetBytePtr(sharedSecret);
|
|
||||||
size_t secretLen = CFDataGetLength(sharedSecret);
|
|
||||||
|
|
||||||
// Simple XOR encryption for demonstration (NOT SECURE - use proper encryption in production)
|
|
||||||
uint8_t* encrypted = malloc(plainLen);
|
|
||||||
for (int i = 0; i < plainLen; i++) {
|
|
||||||
encrypted[i] = ((uint8_t*)plainData)[i] ^ secretBytes[i % secretLen];
|
|
||||||
}
|
|
||||||
|
|
||||||
result.data = encrypted;
|
|
||||||
result.len = plainLen;
|
|
||||||
|
|
||||||
CFRelease(publicKey);
|
|
||||||
CFRelease(sharedSecret);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
DataResult decryptData(SecKeyRef privateKey, const void* saltData, int saltLen, const void* encData, int encLen, void* context) {
|
|
||||||
DataResult result = {NULL, 0, 0};
|
|
||||||
|
|
||||||
// Set up authentication context
|
|
||||||
LAContext* authContext = [[LAContext alloc] init];
|
|
||||||
NSError* authError = nil;
|
|
||||||
|
|
||||||
// Check if biometric authentication is available
|
|
||||||
if ([authContext canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&authError]) {
|
|
||||||
// Evaluate biometric authentication synchronously
|
|
||||||
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
|
|
||||||
__block BOOL authSuccess = NO;
|
|
||||||
|
|
||||||
[authContext evaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics
|
|
||||||
localizedReason:@"Decrypt data using Secure Enclave"
|
|
||||||
reply:^(BOOL success, NSError * _Nullable error) {
|
|
||||||
authSuccess = success;
|
|
||||||
dispatch_semaphore_signal(sema);
|
|
||||||
}];
|
|
||||||
|
|
||||||
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
|
|
||||||
|
|
||||||
if (!authSuccess) {
|
|
||||||
result.error = -3;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get public key from private key
|
|
||||||
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
|
|
||||||
if (!publicKey) {
|
|
||||||
result.error = -1;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create algorithm parameters with authentication context
|
|
||||||
CFMutableDictionaryRef params = CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
|
|
||||||
CFDictionarySetValue(params, kSecUseAuthenticationContext, (__bridge CFTypeRef)authContext);
|
|
||||||
|
|
||||||
// Perform ECDH key agreement with self
|
|
||||||
CFErrorRef error = NULL;
|
|
||||||
CFDataRef sharedSecret = SecKeyCopyKeyExchangeResult(privateKey, kSecKeyAlgorithmECDHKeyExchangeStandard, publicKey, params, &error);
|
|
||||||
CFRelease(params);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
result.error = (int)CFErrorGetCode(error);
|
|
||||||
CFRelease(error);
|
|
||||||
CFRelease(publicKey);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt using shared secret
|
|
||||||
const uint8_t* secretBytes = CFDataGetBytePtr(sharedSecret);
|
|
||||||
size_t secretLen = CFDataGetLength(sharedSecret);
|
|
||||||
|
|
||||||
// Simple XOR decryption for demonstration
|
|
||||||
uint8_t* decrypted = malloc(encLen);
|
|
||||||
for (int i = 0; i < encLen; i++) {
|
|
||||||
decrypted[i] = ((uint8_t*)encData)[i] ^ secretBytes[i % secretLen];
|
|
||||||
}
|
|
||||||
|
|
||||||
result.data = decrypted;
|
|
||||||
result.len = encLen;
|
|
||||||
|
|
||||||
CFRelease(publicKey);
|
|
||||||
CFRelease(sharedSecret);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
void freeKeyResult(KeyResult* result) {
|
|
||||||
if (result->privateKey) {
|
|
||||||
CFRelease(result->privateKey);
|
|
||||||
}
|
|
||||||
if (result->salt) {
|
|
||||||
free((void*)result->salt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void freeDataResult(DataResult* result) {
|
|
||||||
if (result->data) {
|
|
||||||
free((void*)result->data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
import "C"
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"unsafe"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EnclaveKey struct {
|
|
||||||
privateKey C.SecKeyRef
|
|
||||||
salt []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEnclaveKey(requireBiometric bool) (*EnclaveKey, error) {
|
|
||||||
result := C.createEnclaveKey(C.bool(requireBiometric))
|
|
||||||
defer C.freeKeyResult(&result)
|
|
||||||
|
|
||||||
if result.error != 0 {
|
|
||||||
return nil, errors.New("failed to create enclave key")
|
|
||||||
}
|
|
||||||
|
|
||||||
salt := make([]byte, result.saltLen)
|
|
||||||
copy(salt, (*[1 << 30]byte)(unsafe.Pointer(result.salt))[:result.saltLen:result.saltLen])
|
|
||||||
|
|
||||||
return &EnclaveKey{
|
|
||||||
privateKey: result.privateKey,
|
|
||||||
salt: salt,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *EnclaveKey) Encrypt(data []byte) ([]byte, error) {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil, errors.New("empty data")
|
|
||||||
}
|
|
||||||
if len(k.salt) == 0 {
|
|
||||||
return nil, errors.New("empty salt")
|
|
||||||
}
|
|
||||||
|
|
||||||
result := C.encryptData(
|
|
||||||
k.privateKey,
|
|
||||||
unsafe.Pointer(&k.salt[0]),
|
|
||||||
C.int(len(k.salt)),
|
|
||||||
unsafe.Pointer(&data[0]),
|
|
||||||
C.int(len(data)),
|
|
||||||
)
|
|
||||||
defer C.freeDataResult(&result)
|
|
||||||
|
|
||||||
if result.error != 0 {
|
|
||||||
return nil, errors.New("encryption failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
encrypted := make([]byte, result.len)
|
|
||||||
copy(encrypted, (*[1 << 30]byte)(unsafe.Pointer(result.data))[:result.len:result.len])
|
|
||||||
|
|
||||||
return encrypted, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *EnclaveKey) Decrypt(data []byte) ([]byte, error) {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil, errors.New("empty data")
|
|
||||||
}
|
|
||||||
if len(k.salt) == 0 {
|
|
||||||
return nil, errors.New("empty salt")
|
|
||||||
}
|
|
||||||
|
|
||||||
result := C.decryptData(
|
|
||||||
k.privateKey,
|
|
||||||
unsafe.Pointer(&k.salt[0]),
|
|
||||||
C.int(len(k.salt)),
|
|
||||||
unsafe.Pointer(&data[0]),
|
|
||||||
C.int(len(data)),
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
defer C.freeDataResult(&result)
|
|
||||||
|
|
||||||
if result.error != 0 {
|
|
||||||
return nil, errors.New("decryption failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
decrypted := make([]byte, result.len)
|
|
||||||
copy(decrypted, (*[1 << 30]byte)(unsafe.Pointer(result.data))[:result.len:result.len])
|
|
||||||
|
|
||||||
return decrypted, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *EnclaveKey) Close() {
|
|
||||||
if k.privateKey != 0 {
|
|
||||||
C.CFRelease(C.CFTypeRef(k.privateKey))
|
|
||||||
k.privateKey = 0
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,87 +0,0 @@
|
|||||||
//go:build darwin
|
|
||||||
// +build darwin
|
|
||||||
|
|
||||||
package macse
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEnclaveKeyEncryption(t *testing.T) {
|
|
||||||
// Skip: Secure Enclave access requires Apple Developer Enterprise (ADE) membership,
|
|
||||||
// proper code signing, and entitlements for non-ephemeral keys.
|
|
||||||
// Without these, only ephemeral keys work which are not suitable for our use case.
|
|
||||||
t.Skip("Skipping: Requires ADE membership, signing, and entitlements for non-ephemeral keys")
|
|
||||||
|
|
||||||
// Create a new enclave key without requiring biometric
|
|
||||||
key, err := NewEnclaveKey(false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create enclave key: %v", err)
|
|
||||||
}
|
|
||||||
defer key.Close()
|
|
||||||
|
|
||||||
// Test data
|
|
||||||
plaintext := []byte("Hello, Secure Enclave!")
|
|
||||||
|
|
||||||
// Encrypt
|
|
||||||
encrypted, err := key.Encrypt(plaintext)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to encrypt: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify encrypted data is different from plaintext
|
|
||||||
if bytes.Equal(plaintext, encrypted) {
|
|
||||||
t.Error("Encrypted data should not equal plaintext")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt
|
|
||||||
decrypted, err := key.Decrypt(encrypted)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to decrypt: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify decrypted data matches original
|
|
||||||
if !bytes.Equal(plaintext, decrypted) {
|
|
||||||
t.Errorf("Decrypted data does not match original: got %s, want %s", decrypted, plaintext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnclaveKeyWithBiometric(t *testing.T) {
|
|
||||||
// Skip: Secure Enclave access requires Apple Developer Enterprise (ADE) membership,
|
|
||||||
// proper code signing, and entitlements for non-ephemeral keys.
|
|
||||||
// Without these, only ephemeral keys work which are not suitable for our use case.
|
|
||||||
t.Skip("Skipping: Requires ADE membership, signing, and entitlements for non-ephemeral keys")
|
|
||||||
|
|
||||||
// This test requires user interaction
|
|
||||||
// Run with: CGO_ENABLED=1 go test -v -run TestEnclaveKeyWithBiometric
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("Skipping biometric test in short mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
key, err := NewEnclaveKey(true)
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("Expected failure creating biometric key in test environment: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer key.Close()
|
|
||||||
|
|
||||||
plaintext := []byte("Biometric protected data")
|
|
||||||
|
|
||||||
encrypted, err := key.Encrypt(plaintext)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to encrypt with biometric key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decryption would require biometric authentication
|
|
||||||
decrypted, err := key.Decrypt(encrypted)
|
|
||||||
if err != nil {
|
|
||||||
// This is expected without proper biometric authentication
|
|
||||||
t.Logf("Expected decryption failure without biometric auth: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !bytes.Equal(plaintext, decrypted) {
|
|
||||||
t.Errorf("Decrypted data does not match original")
|
|
||||||
}
|
|
||||||
}
|
|
@ -20,7 +20,8 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
agePrivKeyPassphraseLength = 64
|
agePrivKeyPassphraseLength = 64
|
||||||
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret"
|
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
|
||||||
|
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret" //nolint:revive // ALL_CAPS is intentional for this constant
|
||||||
)
|
)
|
||||||
|
|
||||||
// keychainItemNameRegex validates keychain item names
|
// keychainItemNameRegex validates keychain item names
|
||||||
@ -445,6 +446,7 @@ func checkMacOSAvailable() error {
|
|||||||
if runtime.GOOS != "darwin" {
|
if runtime.GOOS != "darwin" {
|
||||||
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS)
|
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -476,7 +478,6 @@ func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
|
|||||||
item.SetAccount(itemName)
|
item.SetAccount(itemName)
|
||||||
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
|
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
|
||||||
item.SetDescription("Secret vault keychain data")
|
item.SetDescription("Secret vault keychain data")
|
||||||
item.SetComment("This item stores encrypted key material for the secret vault")
|
|
||||||
item.SetData([]byte(data.String()))
|
item.SetData([]byte(data.String()))
|
||||||
item.SetSynchronizable(keychain.SynchronizableNo)
|
item.SetSynchronizable(keychain.SynchronizableNo)
|
||||||
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
|
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
|
||||||
@ -487,7 +488,7 @@ func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
|
|||||||
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
|
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
|
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
deleteItem.SetAccount(itemName)
|
deleteItem.SetAccount(itemName)
|
||||||
keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
|
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
|
||||||
|
|
||||||
// Add the new item
|
// Add the new item
|
||||||
if err := keychain.AddItem(item); err != nil {
|
if err := keychain.AddItem(item); err != nil {
|
||||||
|
@ -208,3 +208,48 @@ func (v *Vault) GetName() string {
|
|||||||
func (v *Vault) GetFilesystem() afero.Fs {
|
func (v *Vault) GetFilesystem() afero.Fs {
|
||||||
return v.fs
|
return v.fs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NumSecrets returns the number of secrets in the vault
|
||||||
|
func (v *Vault) NumSecrets() (int, error) {
|
||||||
|
vaultDir, err := v.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
exists, _ := afero.DirExists(v.fs, secretsDir)
|
||||||
|
if !exists {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := afero.ReadDir(v.fs, secretsDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to read secrets directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count only directories that contain at least one version file
|
||||||
|
count := 0
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this secret directory contains any version files
|
||||||
|
secretDir := filepath.Join(secretsDir, entry.Name())
|
||||||
|
versionFiles, err := afero.ReadDir(v.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip directories we can't read
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for at least one version file (excluding "current" symlink)
|
||||||
|
for _, vFile := range versionFiles {
|
||||||
|
if !vFile.IsDir() && vFile.Name() != "current" {
|
||||||
|
count++
|
||||||
|
|
||||||
|
break // Found at least one version, count this secret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user