Compare commits
	
		
			6 Commits
		
	
	
		
			816f53f819
			...
			8e3530a510
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8e3530a510 | |||
| e5d7407c79 | |||
| 377b51f2db | |||
| a09fa89f30 | |||
| 7af1e6efa8 | |||
| 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": []
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										21
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								.dockerignore
									
									
									
									
									
										Normal 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
 | 
				
			||||||
@ -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
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										50
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								Dockerfile
									
									
									
									
									
										Normal 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"]
 | 
				
			||||||
							
								
								
									
										11
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								Makefile
									
									
									
									
									
								
							@ -1,3 +1,6 @@
 | 
				
			|||||||
 | 
					export CGO_ENABLED=1
 | 
				
			||||||
 | 
					export DOCKER_HOST := ssh://root@ber1app1.local
 | 
				
			||||||
 | 
					
 | 
				
			||||||
default: check
 | 
					default: check
 | 
				
			||||||
 | 
					
 | 
				
			||||||
build: ./secret
 | 
					build: ./secret
 | 
				
			||||||
@ -21,6 +24,14 @@ lint:
 | 
				
			|||||||
# Check all code quality (build + vet + lint + unit tests)
 | 
					# Check all code quality (build + vet + lint + unit tests)
 | 
				
			||||||
check: ./secret vet lint test
 | 
					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 build artifacts
 | 
				
			||||||
clean:
 | 
					clean:
 | 
				
			||||||
	rm -f ./secret
 | 
						rm -f ./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
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										102
									
								
								coverage.out
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								coverage.out
									
									
									
									
									
										Normal 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
 | 
				
			||||||
@ -2,21 +2,11 @@
 | 
				
			|||||||
package cli
 | 
					package cli
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"bufio"
 | 
					 | 
				
			||||||
	"fmt"
 | 
					 | 
				
			||||||
	"os"
 | 
					 | 
				
			||||||
	"strings"
 | 
					 | 
				
			||||||
	"syscall"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
						"git.eeqj.de/sneak/secret/internal/secret"
 | 
				
			||||||
	"github.com/spf13/afero"
 | 
						"github.com/spf13/afero"
 | 
				
			||||||
	"github.com/spf13/cobra"
 | 
						"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
 | 
					// Instance encapsulates all CLI functionality and state
 | 
				
			||||||
type Instance struct {
 | 
					type Instance struct {
 | 
				
			||||||
	fs       afero.Fs
 | 
						fs       afero.Fs
 | 
				
			||||||
@ -67,33 +57,3 @@ func (cli *Instance) SetStateDir(stateDir string) {
 | 
				
			|||||||
func (cli *Instance) GetStateDir() string {
 | 
					func (cli *Instance) GetStateDir() string {
 | 
				
			||||||
	return cli.stateDir
 | 
						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
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -60,14 +60,17 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
 | 
				
			|||||||
		mnemonicStr = envMnemonic
 | 
							mnemonicStr = envMnemonic
 | 
				
			||||||
	} else {
 | 
						} else {
 | 
				
			||||||
		secret.Debug("Prompting user for mnemonic phrase")
 | 
							secret.Debug("Prompting user for mnemonic phrase")
 | 
				
			||||||
		// Read mnemonic from stdin using shared line reader
 | 
							// Read mnemonic securely without echo
 | 
				
			||||||
		var err error
 | 
							mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
 | 
				
			||||||
		mnemonicStr, err = readLineFromStdin("Enter your BIP39 mnemonic phrase: ")
 | 
					 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			secret.Debug("Failed to read mnemonic from stdin", "error", err)
 | 
								secret.Debug("Failed to read mnemonic from stdin", "error", err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			return fmt.Errorf("failed to read mnemonic: %w", 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 == "" {
 | 
						if mnemonicStr == "" {
 | 
				
			||||||
@ -202,20 +205,26 @@ func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	defer passphraseBuffer1.Destroy()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Read confirmation passphrase
 | 
						// Read confirmation passphrase
 | 
				
			||||||
	passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
 | 
						passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
 | 
							passphraseBuffer1.Destroy()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		return nil, fmt.Errorf("failed to read passphrase confirmation: %w", err)
 | 
							return nil, fmt.Errorf("failed to read passphrase confirmation: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	defer passphraseBuffer2.Destroy()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Compare passphrases
 | 
						// Compare passphrases
 | 
				
			||||||
	if passphraseBuffer1.String() != passphraseBuffer2.String() {
 | 
						if passphraseBuffer1.String() != passphraseBuffer2.String() {
 | 
				
			||||||
 | 
							passphraseBuffer1.Destroy()
 | 
				
			||||||
 | 
							passphraseBuffer2.Destroy()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		return nil, fmt.Errorf("passphrases do not match")
 | 
							return nil, fmt.Errorf("passphrases do not match")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Create a new buffer with the confirmed passphrase
 | 
						// Clean up the second buffer, we'll return the first
 | 
				
			||||||
	return memguard.NewBufferFromBytes(passphraseBuffer1.Bytes()), nil
 | 
						passphraseBuffer2.Destroy()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Return the first buffer (caller is responsible for destroying it)
 | 
				
			||||||
 | 
						return passphraseBuffer1, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,9 @@ import (
 | 
				
			|||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"os"
 | 
						"os"
 | 
				
			||||||
 | 
						"os/exec"
 | 
				
			||||||
	"path/filepath"
 | 
						"path/filepath"
 | 
				
			||||||
 | 
						"runtime"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -15,9 +17,44 @@ import (
 | 
				
			|||||||
	"github.com/spf13/cobra"
 | 
						"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 {
 | 
					func newUnlockersCmd() *cobra.Command {
 | 
				
			||||||
	cmd := &cobra.Command{
 | 
						cmd := &cobra.Command{
 | 
				
			||||||
@ -28,15 +65,16 @@ 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
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func newUnlockersListCmd() *cobra.Command {
 | 
					func newUnlockersListCmd() *cobra.Command {
 | 
				
			||||||
	cmd := &cobra.Command{
 | 
						cmd := &cobra.Command{
 | 
				
			||||||
		Use:   "list",
 | 
							Use:     "list",
 | 
				
			||||||
		Short: "List unlockers in the current vault",
 | 
							Aliases: []string{"ls"},
 | 
				
			||||||
 | 
							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")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -53,14 +91,26 @@ func newUnlockersListCmd() *cobra.Command {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func newUnlockersAddCmd() *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{
 | 
						cmd := &cobra.Command{
 | 
				
			||||||
		Use:   "add <type>",
 | 
							Use:   "add <type> [keyid]",
 | 
				
			||||||
		Short: "Add a new unlocker",
 | 
							Short: "Add a new unlocker",
 | 
				
			||||||
		Long:  `Add a new unlocker of the specified type (passphrase, keychain, pgp).`,
 | 
							Long:  fmt.Sprintf(`Add a new unlocker of the specified type (%s).`, supportedTypes),
 | 
				
			||||||
		Args:  cobra.ExactArgs(1),
 | 
							Args:  cobra.RangeArgs(1, 2), //nolint:mnd // Command accepts 1 or 2 arguments
 | 
				
			||||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
							RunE: func(cmd *cobra.Command, args []string) error {
 | 
				
			||||||
			cli := NewCLIInstance()
 | 
								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)
 | 
								return cli.UnlockersAdd(args[0], cmd)
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@ -70,17 +120,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>",
 | 
				
			||||||
		Short: "Remove an unlocker",
 | 
							Aliases: []string{"rm"},
 | 
				
			||||||
		Args:  cobra.ExactArgs(1),
 | 
							Short:   "Remove an unlocker",
 | 
				
			||||||
		RunE: func(_ *cobra.Command, args []string) error {
 | 
							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()
 | 
								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 {
 | 
				
			||||||
@ -243,6 +302,12 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// UnlockersAdd adds a new unlocker
 | 
					// UnlockersAdd adds a new unlocker
 | 
				
			||||||
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
 | 
					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 {
 | 
						switch unlockerType {
 | 
				
			||||||
	case "passphrase":
 | 
						case "passphrase":
 | 
				
			||||||
		// Get current vault
 | 
							// Get current vault
 | 
				
			||||||
@ -277,6 +342,10 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
 | 
				
			|||||||
		return nil
 | 
							return nil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	case "keychain":
 | 
						case "keychain":
 | 
				
			||||||
 | 
							if runtime.GOOS != "darwin" {
 | 
				
			||||||
 | 
								return fmt.Errorf("keychain unlockers are only supported on macOS")
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
 | 
							keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
 | 
								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
 | 
							return nil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	case "pgp":
 | 
						case "pgp":
 | 
				
			||||||
		// Get GPG key ID from flag or environment variable
 | 
							// Get GPG key ID from flag, environment, or default key
 | 
				
			||||||
		var gpgKeyID string
 | 
							var gpgKeyID string
 | 
				
			||||||
		if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
 | 
							if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
 | 
				
			||||||
			gpgKeyID = flagKeyID
 | 
								gpgKeyID = flagKeyID
 | 
				
			||||||
		} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
 | 
							} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
 | 
				
			||||||
			gpgKeyID = envKeyID
 | 
								gpgKeyID = envKeyID
 | 
				
			||||||
		} else {
 | 
							} 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)
 | 
							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
 | 
							return nil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	default:
 | 
						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
 | 
					// 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
 | 
				
			||||||
@ -336,3 +463,69 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	return vlt.SelectUnlocker(unlockerID)
 | 
						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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -4,6 +4,7 @@ import (
 | 
				
			|||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"os"
 | 
						"os"
 | 
				
			||||||
 | 
						"path/filepath"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -27,14 +28,16 @@ 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
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func newVaultListCmd() *cobra.Command {
 | 
					func newVaultListCmd() *cobra.Command {
 | 
				
			||||||
	cmd := &cobra.Command{
 | 
						cmd := &cobra.Command{
 | 
				
			||||||
		Use:   "list",
 | 
							Use:     "list",
 | 
				
			||||||
		Short: "List available vaults",
 | 
							Aliases: []string{"ls"},
 | 
				
			||||||
 | 
							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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -33,9 +33,10 @@ 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>",
 | 
				
			||||||
		Short: "List all versions of a secret",
 | 
							Aliases: []string{"ls"},
 | 
				
			||||||
		Args:  cobra.ExactArgs(1),
 | 
							Short:   "List all versions of a secret",
 | 
				
			||||||
 | 
							Args:    cobra.ExactArgs(1),
 | 
				
			||||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
							RunE: func(cmd *cobra.Command, args []string) error {
 | 
				
			||||||
			return cli.ListVersions(cmd, args[0])
 | 
								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
 | 
						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")
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,3 +1,6 @@
 | 
				
			|||||||
 | 
					//go:build darwin
 | 
				
			||||||
 | 
					// +build darwin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
package secret
 | 
					package secret
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
@ -20,7 +23,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 +449,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 +481,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 +491,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 {
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										69
									
								
								internal/secret/keychainunlocker_stub.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								internal/secret/keychainunlocker_stub.go
									
									
									
									
									
										Normal 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")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -70,24 +70,24 @@ func TestKeychainInvalidItemName(t *testing.T) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Test invalid item names
 | 
						// Test invalid item names
 | 
				
			||||||
	invalidNames := []string{
 | 
						invalidNames := []string{
 | 
				
			||||||
		"",                    // Empty name
 | 
							"",                  // Empty name
 | 
				
			||||||
		"test space",          // Contains space
 | 
							"test space",        // Contains space
 | 
				
			||||||
		"test/slash",          // Contains slash
 | 
							"test/slash",        // Contains slash
 | 
				
			||||||
		"test\\backslash",     // Contains backslash
 | 
							"test\\backslash",   // Contains backslash
 | 
				
			||||||
		"test:colon",          // Contains colon
 | 
							"test:colon",        // Contains colon
 | 
				
			||||||
		"test;semicolon",      // Contains semicolon
 | 
							"test;semicolon",    // Contains semicolon
 | 
				
			||||||
		"test|pipe",           // Contains pipe
 | 
							"test|pipe",         // Contains pipe
 | 
				
			||||||
		"test@at",             // Contains @
 | 
							"test@at",           // Contains @
 | 
				
			||||||
		"test#hash",           // Contains #
 | 
							"test#hash",         // Contains #
 | 
				
			||||||
		"test$dollar",         // Contains $
 | 
							"test$dollar",       // Contains $
 | 
				
			||||||
		"test&ersand",      // Contains &
 | 
							"test&ersand",    // Contains &
 | 
				
			||||||
		"test*asterisk",       // Contains *
 | 
							"test*asterisk",     // Contains *
 | 
				
			||||||
		"test?question",       // Contains ?
 | 
							"test?question",     // Contains ?
 | 
				
			||||||
		"test!exclamation",    // Contains !
 | 
							"test!exclamation",  // Contains !
 | 
				
			||||||
		"test'quote",          // Contains single quote
 | 
							"test'quote",        // Contains single quote
 | 
				
			||||||
		"test\"doublequote",   // Contains double quote
 | 
							"test\"doublequote", // Contains double quote
 | 
				
			||||||
		"test(paren",          // Contains parenthesis
 | 
							"test(paren",        // Contains parenthesis
 | 
				
			||||||
		"test[bracket",        // Contains bracket
 | 
							"test[bracket",      // Contains bracket
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	for _, name := range invalidNames {
 | 
						for _, name := range invalidNames {
 | 
				
			||||||
@ -138,10 +138,10 @@ func TestKeychainLargeData(t *testing.T) {
 | 
				
			|||||||
	for i := range largeData {
 | 
						for i := range largeData {
 | 
				
			||||||
		largeData[i] = byte(i % 256)
 | 
							largeData[i] = byte(i % 256)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
					
 | 
				
			||||||
	// Convert to hex string for storage
 | 
						// Convert to hex string for storage
 | 
				
			||||||
	hexData := hex.EncodeToString(largeData)
 | 
						hexData := hex.EncodeToString(largeData)
 | 
				
			||||||
	
 | 
					
 | 
				
			||||||
	testItemName := "test-large-data"
 | 
						testItemName := "test-large-data"
 | 
				
			||||||
	testBuffer := memguard.NewBufferFromBytes([]byte(hexData))
 | 
						testBuffer := memguard.NewBufferFromBytes([]byte(hexData))
 | 
				
			||||||
	defer testBuffer.Destroy()
 | 
						defer testBuffer.Destroy()
 | 
				
			||||||
@ -156,7 +156,7 @@ func TestKeychainLargeData(t *testing.T) {
 | 
				
			|||||||
	// Retrieve and verify
 | 
						// Retrieve and verify
 | 
				
			||||||
	retrievedData, err := retrieveFromKeychain(testItemName)
 | 
						retrievedData, err := retrieveFromKeychain(testItemName)
 | 
				
			||||||
	require.NoError(t, err, "Failed to retrieve large data")
 | 
						require.NoError(t, err, "Failed to retrieve large data")
 | 
				
			||||||
	
 | 
					
 | 
				
			||||||
	// Decode hex and compare
 | 
						// Decode hex and compare
 | 
				
			||||||
	decodedData, err := hex.DecodeString(string(retrievedData))
 | 
						decodedData, err := hex.DecodeString(string(retrievedData))
 | 
				
			||||||
	require.NoError(t, err, "Failed to decode hex data")
 | 
						require.NoError(t, err, "Failed to decode hex data")
 | 
				
			||||||
@ -164,4 +164,4 @@ func TestKeychainLargeData(t *testing.T) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Clean up
 | 
						// Clean up
 | 
				
			||||||
	_ = deleteFromKeychain(testItemName)
 | 
						_ = deleteFromKeychain(testItemName)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -128,7 +128,7 @@ func (p *PGPUnlocker) GetDirectory() string {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// GetID implements Unlocker interface - generates ID from GPG key ID
 | 
					// GetID implements Unlocker interface - generates ID from GPG key ID
 | 
				
			||||||
func (p *PGPUnlocker) GetID() string {
 | 
					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()
 | 
						gpgKeyID, err := p.GetGPGKeyID()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		// The vault metadata is corrupt - this is a fatal error
 | 
							// 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))
 | 
							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
 | 
					// 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
 | 
						// Step 9: Resolve the GPG key ID to its full fingerprint
 | 
				
			||||||
	fingerprint, err := resolveGPGKeyFingerprint(gpgKeyID)
 | 
						fingerprint, err := ResolveGPGKeyFingerprint(gpgKeyID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
 | 
							return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@ -313,8 +313,8 @@ func validateGPGKeyID(keyID string) error {
 | 
				
			|||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// resolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
 | 
					// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
 | 
				
			||||||
func resolveGPGKeyFingerprint(keyID string) (string, error) {
 | 
					func ResolveGPGKeyFingerprint(keyID string) (string, error) {
 | 
				
			||||||
	if err := validateGPGKeyID(keyID); err != nil {
 | 
						if err := validateGPGKeyID(keyID); err != nil {
 | 
				
			||||||
		return "", fmt.Errorf("invalid GPG key ID: %w", err)
 | 
							return "", fmt.Errorf("invalid GPG key ID: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
				
			|||||||
@ -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