Merge branch 'add-list-remove-commands'

This commit is contained in:
Jeffrey Paul 2025-07-22 12:47:20 +02:00
commit 7ed3e287ea
22 changed files with 898 additions and 528 deletions

View File

@ -26,7 +26,8 @@
"WebFetch(domain:pkg.go.dev)",
"Bash(CGO_ENABLED=1 make fmt)",
"Bash(CGO_ENABLED=1 make test)",
"Bash(git merge:*)"
"Bash(git merge:*)",
"Bash(git branch:*)"
],
"deny": []
}

21
.dockerignore Normal file
View File

@ -0,0 +1,21 @@
# Build artifacts
secret
coverage.out
*.test
# IDE and editor files
.vscode
.idea
*.swp
*.swo
*~
# macOS
.DS_Store
# Claude files
.claude/
# Local settings
.golangci.yml
.claude/settings.local.json

View File

@ -64,6 +64,14 @@ linters-settings:
nlreturn:
block-size: 2
revive:
rules:
- name: var-naming
arguments:
- []
- []
- "upperCaseConst=true"
tagliatelle:
case:
rules:
@ -89,3 +97,32 @@ issues:
- text: "parameter '(args|cmd)' seems to be unused"
linters:
- 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

50
Dockerfile Normal file
View File

@ -0,0 +1,50 @@
# Build stage
FROM golang:1.24-alpine AS builder
# Install build dependencies
RUN apk add --no-cache \
gcc \
musl-dev \
make \
git
# Set working directory
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the binary
RUN CGO_ENABLED=1 go build -v -o secret cmd/secret/main.go
# Runtime stage
FROM alpine:latest
# Install runtime dependencies
RUN apk add --no-cache \
ca-certificates \
gnupg
# Create non-root user
RUN adduser -D -s /bin/sh secret
# Copy binary from builder
COPY --from=builder /build/secret /usr/local/bin/secret
# Ensure binary is executable
RUN chmod +x /usr/local/bin/secret
# Switch to non-root user
USER secret
# Set working directory
WORKDIR /home/secret
# Set entrypoint
ENTRYPOINT ["secret"]

View File

@ -1,3 +1,6 @@
export CGO_ENABLED=1
export DOCKER_HOST := ssh://root@ber1app1.local
default: check
build: ./secret
@ -21,6 +24,14 @@ lint:
# Check all code quality (build + vet + lint + unit tests)
check: ./secret vet lint test
# Build Docker container
docker:
docker build -t sneak/secret .
# Run Docker container interactively
docker-run:
docker run --rm -it sneak/secret
# Clean build artifacts
clean:
rm -f ./secret

View File

@ -69,8 +69,8 @@ Initializes the secret manager with a default vault. Prompts for a BIP39 mnemoni
### Vault Management
#### `secret vault list [--json]`
Lists all available vaults.
#### `secret vault list [--json]` / `secret vault ls`
Lists all available vaults. The current vault is marked.
#### `secret vault create <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>`
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 add <secret-name> [--force]`
@ -95,14 +101,24 @@ Retrieves and outputs a secret value to stdout.
#### `secret list [filter] [--json]` / `secret ls`
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
#### `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.
#### `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.
#### `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
#### `secret generate mnemonic`
@ -116,7 +132,7 @@ Generates and stores a random secret.
### Unlocker Management
#### `secret unlockers list [--json]`
#### `secret unlockers list [--json]` / `secret unlockers ls`
Lists all unlockers in the current vault with their metadata.
#### `secret unlockers add <type> [options]`
@ -130,8 +146,12 @@ Creates a new unlocker of the specified type:
**Options:**
- `--keyid <id>`: GPG key ID (required for PGP type)
#### `secret unlockers rm <unlocker-id>`
Removes an unlocker.
#### `secret unlockers remove <unlocker-id> [--force]` / `secret unlockers rm` ⚠️ 🛑
**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>`
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 get database/prod/password
secret get services/api/key
# Remove a secret ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
secret remove ssh/servers/web01
```
### Multi-vault Setup
@ -293,6 +316,9 @@ echo "personal-email-pass" | secret add email/password
# List all vaults
secret vault list
# Remove a vault ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
secret vault remove personal --force
```
### Advanced Authentication
@ -307,6 +333,21 @@ secret unlockers list
# Select a specific unlocker
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

102
coverage.out Normal file
View File

@ -0,0 +1,102 @@
mode: set
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:57.41,60.38 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:60.38,61.41 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:65.2,70.3 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:74.50,76.2 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:79.85,81.28 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:81.28,83.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:86.2,87.16 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:87.16,89.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:92.2,93.16 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:93.16,95.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:98.2,98.35 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:102.89,105.16 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:105.16,107.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:110.2,114.21 4 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:118.99,119.46 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:119.46,121.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:124.2,134.39 5 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:134.39,137.15 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:137.15,140.4 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:143.3,145.17 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:145.17,147.4 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:150.3,150.15 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:150.15,152.4 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:155.3,156.17 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:156.17,158.4 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:160.3,160.14 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:163.2,163.17 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:167.107,171.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:171.16,173.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:177.2,186.15 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:187.15,188.13 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:189.15,190.13 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:191.15,192.13 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:193.15,194.13 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:195.15,196.13 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:197.10,198.64 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:202.2,204.21 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:208.84,212.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:212.16,214.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:217.2,222.16 4 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:222.16,224.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:226.2,226.26 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:230.99,234.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:234.16,236.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:239.2,251.45 6 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:251.45,253.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:256.2,275.45 12 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:279.39,284.2 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:287.91,288.36 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:288.36,290.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:292.2,295.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:295.16,297.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:300.2,302.41 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:306.100,307.32 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:307.32,309.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:311.2,314.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:314.16,316.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:319.2,325.35 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:325.35,327.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:329.2,329.33 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:333.100,334.32 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:334.32,336.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:338.2,341.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:341.16,343.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:346.2,349.32 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:349.32,351.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:353.2,353.30 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:357.57,375.52 7 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:375.52,381.46 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:381.46,385.4 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:387.3,387.20 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:390.2,390.21 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:394.67,396.2 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:32.22,36.2 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:40.67,41.31 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:41.31,43.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:46.2,55.16 6 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:55.16,57.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:58.2,59.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:59.16,61.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:63.2,63.52 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:68.63,74.16 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:74.16,76.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:79.2,83.16 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:83.16,85.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:88.2,91.16 4 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:91.16,93.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:95.2,95.17 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:100.67,103.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:103.16,105.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:108.2,112.16 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:112.16,114.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:117.2,120.16 4 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:120.16,122.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:124.2,124.17 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:129.77,131.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:131.16,133.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:135.2,135.33 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:140.81,142.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:142.16,144.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:146.2,146.33 1 1

View File

@ -2,21 +2,11 @@
package cli
import (
"bufio"
"fmt"
"os"
"strings"
"syscall"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// Global scanner for consistent stdin reading
var stdinScanner *bufio.Scanner //nolint:gochecknoglobals // Needed for consistent stdin handling
// Instance encapsulates all CLI functionality and state
type Instance struct {
fs afero.Fs
@ -67,33 +57,3 @@ func (cli *Instance) SetStateDir(stateDir string) {
func (cli *Instance) GetStateDir() string {
return cli.stateDir
}
// getStdinScanner returns a shared scanner for stdin to avoid buffering issues
func getStdinScanner() *bufio.Scanner {
if stdinScanner == nil {
stdinScanner = bufio.NewScanner(os.Stdin)
}
return stdinScanner
}
// readLineFromStdin reads a single line from stdin with a prompt
// Uses a shared scanner to avoid buffering issues between multiple calls
func readLineFromStdin(prompt string) (string, error) {
// Check if stderr is a terminal - if not, we can't prompt interactively
if !term.IsTerminal(syscall.Stderr) {
return "", fmt.Errorf("cannot prompt for input: stderr is not a terminal (running in non-interactive mode)")
}
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
scanner := getStdinScanner()
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("failed to read from stdin: %w", err)
}
return "", fmt.Errorf("failed to read from stdin: EOF")
}
return strings.TrimSpace(scanner.Text()), nil
}

View File

@ -60,14 +60,17 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
mnemonicStr = envMnemonic
} else {
secret.Debug("Prompting user for mnemonic phrase")
// Read mnemonic from stdin using shared line reader
var err error
mnemonicStr, err = readLineFromStdin("Enter your BIP39 mnemonic phrase: ")
// Read mnemonic securely without echo
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err)
return fmt.Errorf("failed to read mnemonic: %w", err)
}
defer mnemonicBuffer.Destroy()
mnemonicStr = mnemonicBuffer.String()
fmt.Fprintln(os.Stderr) // Add newline after hidden input
}
if mnemonicStr == "" {
@ -202,20 +205,26 @@ func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
if err != nil {
return nil, err
}
defer passphraseBuffer1.Destroy()
// Read confirmation passphrase
passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
if err != nil {
passphraseBuffer1.Destroy()
return nil, fmt.Errorf("failed to read passphrase confirmation: %w", err)
}
defer passphraseBuffer2.Destroy()
// Compare passphrases
if passphraseBuffer1.String() != passphraseBuffer2.String() {
passphraseBuffer1.Destroy()
passphraseBuffer2.Destroy()
return nil, fmt.Errorf("passphrases do not match")
}
// Create a new buffer with the confirmed passphrase
return memguard.NewBufferFromBytes(passphraseBuffer1.Bytes()), nil
// Clean up the second buffer, we'll return the first
passphraseBuffer2.Destroy()
// Return the first buffer (caller is responsible for destroying it)
return passphraseBuffer1, nil
}

View File

@ -34,6 +34,7 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newAddCmd())
cmd.AddCommand(newGetCmd())
cmd.AddCommand(newListCmd())
cmd.AddCommand(newRemoveCmd())
cmd.AddCommand(newUnlockersCmd())
cmd.AddCommand(newUnlockerCmd())
cmd.AddCommand(newImportCmd())

View File

@ -4,11 +4,13 @@ import (
"encoding/json"
"fmt"
"io"
"path/filepath"
"strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
@ -103,6 +105,24 @@ func newImportCmd() *cobra.Command {
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
func updateBufferSize(currentSize int, sameSize *int) int {
*sameSize++
@ -448,3 +468,45 @@ func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile str
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
}

View File

@ -4,7 +4,9 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
@ -15,9 +17,44 @@ import (
"github.com/spf13/cobra"
)
// Import from init.go
// getDefaultGPGKey returns the default GPG key ID if available
func getDefaultGPGKey() (string, error) {
// First try to get the configured default key using gpgconf
cmd := exec.Command("gpgconf", "--list-options", "gpg")
output, err := cmd.Output()
if err == nil {
lines := strings.Split(string(output), "\n")
for _, line := range lines {
fields := strings.Split(line, ":")
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
// The default key is in field 10 (index 9)
return fields[9], nil
}
}
}
// ... existing imports ...
// If no default key is configured, get the first secret key
cmd = exec.Command("gpg", "--list-secret-keys", "--with-colons")
output, err = cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to list GPG keys: %w", err)
}
// Parse output to find the first usable secret key
lines := strings.Split(string(output), "\n")
for _, line := range lines {
// sec line indicates a secret key
if strings.HasPrefix(line, "sec:") {
fields := strings.Split(line, ":")
// Field 5 contains the key ID
if len(fields) > 4 && fields[4] != "" {
return fields[4], nil
}
}
}
return "", fmt.Errorf("no GPG secret keys found")
}
func newUnlockersCmd() *cobra.Command {
cmd := &cobra.Command{
@ -28,15 +65,16 @@ func newUnlockersCmd() *cobra.Command {
cmd.AddCommand(newUnlockersListCmd())
cmd.AddCommand(newUnlockersAddCmd())
cmd.AddCommand(newUnlockersRmCmd())
cmd.AddCommand(newUnlockersRemoveCmd())
return cmd
}
func newUnlockersListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List unlockers in the current vault",
Use: "list",
Aliases: []string{"ls"},
Short: "List unlockers in the current vault",
RunE: func(cmd *cobra.Command, _ []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json")
@ -53,14 +91,26 @@ func newUnlockersListCmd() *cobra.Command {
}
func newUnlockersAddCmd() *cobra.Command {
// Build the supported types list based on platform
supportedTypes := "passphrase, pgp"
if runtime.GOOS == "darwin" {
supportedTypes = "passphrase, keychain, pgp"
}
cmd := &cobra.Command{
Use: "add <type>",
Use: "add <type> [keyid]",
Short: "Add a new unlocker",
Long: `Add a new unlocker of the specified type (passphrase, keychain, pgp).`,
Args: cobra.ExactArgs(1),
Long: fmt.Sprintf(`Add a new unlocker of the specified type (%s).`, supportedTypes),
Args: cobra.RangeArgs(1, 2), //nolint:mnd // Command accepts 1 or 2 arguments
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
// For PGP type, check if keyid is provided as positional argument
if args[0] == "pgp" && len(args) == 2 {
// Override any flag value with the positional argument
_ = cmd.Flags().Set("keyid", args[1])
}
return cli.UnlockersAdd(args[0], cmd)
},
}
@ -70,17 +120,26 @@ func newUnlockersAddCmd() *cobra.Command {
return cmd
}
func newUnlockersRmCmd() *cobra.Command {
return &cobra.Command{
Use: "rm <unlocker-id>",
Short: "Remove an unlocker",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
func newUnlockersRemoveCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "remove <unlocker-id>",
Aliases: []string{"rm"},
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),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
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 {
@ -243,6 +302,12 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
// UnlockersAdd adds a new unlocker
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
// Build the supported types list based on platform
supportedTypes := "passphrase, pgp"
if runtime.GOOS == "darwin" {
supportedTypes = "passphrase, keychain, pgp"
}
switch unlockerType {
case "passphrase":
// Get current vault
@ -277,6 +342,10 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
return nil
case "keychain":
if runtime.GOOS != "darwin" {
return fmt.Errorf("keychain unlockers are only supported on macOS")
}
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
@ -290,14 +359,38 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
return nil
case "pgp":
// Get GPG key ID from flag or environment variable
// Get GPG key ID from flag, environment, or default key
var gpgKeyID string
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
gpgKeyID = flagKeyID
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
gpgKeyID = envKeyID
} else {
return fmt.Errorf("GPG key ID required: use --keyid flag or set SB_GPG_KEY_ID environment variable")
// Try to get the default GPG key
defaultKeyID, err := getDefaultGPGKey()
if err != nil {
return fmt.Errorf("no GPG key specified and no default key found: %w", err)
}
gpgKeyID = defaultKeyID
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
}
// Check if this key is already added as an unlocker
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
// Resolve the GPG key ID to its fingerprint
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
if err != nil {
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
}
// Check if this GPG key is already added
expectedID := fmt.Sprintf("pgp-%s", fingerprint)
if err := cli.checkUnlockerExists(vlt, expectedID); err != nil {
return fmt.Errorf("GPG key %s is already added as an unlocker", gpgKeyID)
}
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
@ -311,19 +404,53 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
return nil
default:
return fmt.Errorf("unsupported unlocker type: %s (supported: passphrase, keychain, pgp)", unlockerType)
return fmt.Errorf("unsupported unlocker type: %s (supported: %s)", unlockerType, supportedTypes)
}
}
// UnlockersRemove removes an unlocker
func (cli *Instance) UnlockersRemove(unlockerID string) error {
// UnlockersRemove removes an unlocker with safety checks
func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.Command) error {
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
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
@ -336,3 +463,69 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
return vlt.SelectUnlocker(unlockerID)
}
// checkUnlockerExists checks if an unlocker with the given ID exists
func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) error {
// Get the list of unlockers and check if any match the ID
unlockers, err := vlt.ListUnlockers()
if err != nil {
return nil // If we can't list unlockers, assume it doesn't exist
}
// Get vault directory to construct unlocker instances
vaultDir, err := vlt.GetDirectory()
if err != nil {
return nil
}
// Check each unlocker's ID
for _, metadata := range unlockers {
// Construct the unlocker based on type to get its ID
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(cli.fs, unlockersDir)
if err != nil {
continue
}
for _, file := range files {
if !file.IsDir() {
continue
}
unlockerDir := filepath.Join(unlockersDir, file.Name())
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
// Check if this matches our metadata
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
if err != nil {
continue
}
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
continue
}
// Match by type and creation time
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
var unlocker secret.Unlocker
switch metadata.Type {
case "passphrase":
unlocker = secret.NewPassphraseUnlocker(cli.fs, unlockerDir, diskMetadata)
case "keychain":
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
case "pgp":
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
}
if unlocker != nil && unlocker.GetID() == unlockerID {
return fmt.Errorf("unlocker already exists")
}
break
}
}
}
return nil
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@ -27,14 +28,16 @@ func newVaultCmd() *cobra.Command {
cmd.AddCommand(newVaultCreateCmd())
cmd.AddCommand(newVaultSelectCmd())
cmd.AddCommand(newVaultImportCmd())
cmd.AddCommand(newVaultRemoveCmd())
return cmd
}
func newVaultListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List available vaults",
Use: "list",
Aliases: []string{"ls"},
Short: "List available vaults",
RunE: func(cmd *cobra.Command, _ []string) error {
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
func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
@ -295,3 +319,90 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
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
}

View File

@ -33,9 +33,10 @@ func VersionCommands(cli *Instance) *cobra.Command {
// List versions command
listCmd := &cobra.Command{
Use: "list <secret-name>",
Short: "List all versions of a secret",
Args: cobra.ExactArgs(1),
Use: "list <secret-name>",
Aliases: []string{"ls"},
Short: "List all versions of a secret",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return cli.ListVersions(cmd, args[0])
},
@ -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
}
@ -207,3 +220,60 @@ func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, versi
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
}

View File

@ -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.

View File

@ -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
}
}

View File

@ -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")
}
}

View File

@ -1,3 +1,6 @@
//go:build darwin
// +build darwin
package secret
import (
@ -20,7 +23,8 @@ import (
const (
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
@ -445,6 +449,7 @@ func checkMacOSAvailable() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS)
}
return nil
}
@ -476,7 +481,6 @@ func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
item.SetAccount(itemName)
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
item.SetDescription("Secret vault keychain data")
item.SetComment("This item stores encrypted key material for the secret vault")
item.SetData([]byte(data.String()))
item.SetSynchronizable(keychain.SynchronizableNo)
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
@ -487,7 +491,7 @@ func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
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
if err := keychain.AddItem(item); err != nil {

View File

@ -0,0 +1,69 @@
//go:build !darwin
// +build !darwin
package secret
import (
"fmt"
"filippo.io/age"
"github.com/spf13/afero"
)
// KeychainUnlockerMetadata is a stub for non-Darwin platforms
type KeychainUnlockerMetadata struct {
UnlockerMetadata
KeychainItemName string `json:"keychainItemName"`
}
// KeychainUnlocker is a stub for non-Darwin platforms
type KeychainUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
}
// GetIdentity panics on non-Darwin platforms
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
panic("keychain unlockers are only supported on macOS")
}
// GetType panics on non-Darwin platforms
func (k *KeychainUnlocker) GetType() string {
panic("keychain unlockers are only supported on macOS")
}
// GetMetadata panics on non-Darwin platforms
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
panic("keychain unlockers are only supported on macOS")
}
// GetDirectory panics on non-Darwin platforms
func (k *KeychainUnlocker) GetDirectory() string {
panic("keychain unlockers are only supported on macOS")
}
// GetID returns the unlocker ID
func (k *KeychainUnlocker) GetID() string {
panic("keychain unlockers are only supported on macOS")
}
// GetKeychainItemName panics on non-Darwin platforms
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
panic("keychain unlockers are only supported on macOS")
}
// Remove panics on non-Darwin platforms
func (k *KeychainUnlocker) Remove() error {
panic("keychain unlockers are only supported on macOS")
}
// NewKeychainUnlocker panics on non-Darwin platforms
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
panic("keychain unlockers are only supported on macOS")
}
// CreateKeychainUnlocker panics on non-Darwin platforms
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
panic("keychain unlockers are only supported on macOS")
}

View File

@ -70,24 +70,24 @@ func TestKeychainInvalidItemName(t *testing.T) {
// Test invalid item names
invalidNames := []string{
"", // Empty name
"test space", // Contains space
"test/slash", // Contains slash
"test\\backslash", // Contains backslash
"test:colon", // Contains colon
"test;semicolon", // Contains semicolon
"test|pipe", // Contains pipe
"test@at", // Contains @
"test#hash", // Contains #
"test$dollar", // Contains $
"test&ampersand", // Contains &
"test*asterisk", // Contains *
"test?question", // Contains ?
"test!exclamation", // Contains !
"test'quote", // Contains single quote
"test\"doublequote", // Contains double quote
"test(paren", // Contains parenthesis
"test[bracket", // Contains bracket
"", // Empty name
"test space", // Contains space
"test/slash", // Contains slash
"test\\backslash", // Contains backslash
"test:colon", // Contains colon
"test;semicolon", // Contains semicolon
"test|pipe", // Contains pipe
"test@at", // Contains @
"test#hash", // Contains #
"test$dollar", // Contains $
"test&ampersand", // Contains &
"test*asterisk", // Contains *
"test?question", // Contains ?
"test!exclamation", // Contains !
"test'quote", // Contains single quote
"test\"doublequote", // Contains double quote
"test(paren", // Contains parenthesis
"test[bracket", // Contains bracket
}
for _, name := range invalidNames {
@ -138,10 +138,10 @@ func TestKeychainLargeData(t *testing.T) {
for i := range largeData {
largeData[i] = byte(i % 256)
}
// Convert to hex string for storage
hexData := hex.EncodeToString(largeData)
testItemName := "test-large-data"
testBuffer := memguard.NewBufferFromBytes([]byte(hexData))
defer testBuffer.Destroy()
@ -156,7 +156,7 @@ func TestKeychainLargeData(t *testing.T) {
// Retrieve and verify
retrievedData, err := retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve large data")
// Decode hex and compare
decodedData, err := hex.DecodeString(string(retrievedData))
require.NoError(t, err, "Failed to decode hex data")
@ -164,4 +164,4 @@ func TestKeychainLargeData(t *testing.T) {
// Clean up
_ = deleteFromKeychain(testItemName)
}
}

View File

@ -128,7 +128,7 @@ func (p *PGPUnlocker) GetDirectory() string {
// GetID implements Unlocker interface - generates ID from GPG key ID
func (p *PGPUnlocker) GetID() string {
// Generate ID using GPG key ID: <keyid>-pgp
// Generate ID using GPG key ID: pgp-<keyid>
gpgKeyID, err := p.GetGPGKeyID()
if err != nil {
// The vault metadata is corrupt - this is a fatal error
@ -136,7 +136,7 @@ func (p *PGPUnlocker) GetID() string {
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
}
return fmt.Sprintf("%s-pgp", gpgKeyID)
return fmt.Sprintf("pgp-%s", gpgKeyID)
}
// Remove implements Unlocker interface - removes the PGP unlocker
@ -267,7 +267,7 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
}
// Step 9: Resolve the GPG key ID to its full fingerprint
fingerprint, err := resolveGPGKeyFingerprint(gpgKeyID)
fingerprint, err := ResolveGPGKeyFingerprint(gpgKeyID)
if err != nil {
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
}
@ -313,8 +313,8 @@ func validateGPGKeyID(keyID string) error {
return nil
}
// resolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
func resolveGPGKeyFingerprint(keyID string) (string, error) {
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
if err := validateGPGKeyID(keyID); err != nil {
return "", fmt.Errorf("invalid GPG key ID: %w", err)
}

View File

@ -208,3 +208,48 @@ func (v *Vault) GetName() string {
func (v *Vault) GetFilesystem() afero.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
}