Confronta commit
52 Commit
0b31fba663
...
fix-memory
| Autore | SHA1 | Data | |
|---|---|---|---|
| 7596049828 | |||
| d3ca006886 | |||
| f91281e991 | |||
| 7c5e78db17 | |||
| 8e374b3d24 | |||
| c9774e89e0 | |||
| f9938135c6 | |||
| 386a27c0b6 | |||
| 080a3dc253 | |||
| 811ddee3b7 | |||
| 4e242c3491 | |||
| 54fce0f187 | |||
| 93a32217e0 | |||
| 95ba80f618 | |||
| d710323bd0 | |||
| 38b450cbcf | |||
| 6fe49344e2 | |||
| 6e01ae6002 | |||
| 11e43542cf | |||
| 2256a37b72 | |||
| 533133486c | |||
| eb19fa4b97 | |||
| 5ed850196b | |||
| be1f323a09 | |||
| bdcddadf90 | |||
| 4062242063 | |||
| abcc7b6c3a | |||
| 9e35bf21a3 | |||
| 2a1e0337fd | |||
| dcc15008cd | |||
| dd2e95f8af | |||
| c450e1c13d | |||
| c6935d8f0f | |||
| 5d973f76ec | |||
| fd125c5fe1 | |||
| 08a42b16dd | |||
| b736789ecb | |||
| f569bc55ea | |||
| 9231409c5c | |||
| 0d140b4636 | |||
| 9e74b34b5d | |||
| 47afe117f4 | |||
| 4fe49ca8d0 | |||
| 8ca7796d04 | |||
| dcab84249f | |||
| e5b18202f3 | |||
| efc9456948 | |||
| c52430554a | |||
| fd7ab06fb1 | |||
| 434b73d834 | |||
| 985d79d3c0 | |||
| 004dce5472 |
@@ -6,7 +6,24 @@
|
||||
"Bash(~/go/bin/govulncheck -mode=module .)",
|
||||
"Bash(go test:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(rg:*)"
|
||||
"Bash(rg:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(make test:*)",
|
||||
"Bash(go doc:*)",
|
||||
"Bash(make fmt:*)",
|
||||
"Bash(make:*)",
|
||||
"Bash(golangci-lint run:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(gofumpt:*)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(golangci-lint:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(ls:*)",
|
||||
"WebFetch(domain:golangci-lint.run)",
|
||||
"Bash(go:*)",
|
||||
"WebFetch(domain:pkg.go.dev)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
3
.gitignore
esterno
3
.gitignore
esterno
@@ -3,4 +3,5 @@
|
||||
/secret
|
||||
*.log
|
||||
cli.test
|
||||
|
||||
vault.test
|
||||
*.test
|
||||
|
||||
91
.golangci.yml
Normal file
91
.golangci.yml
Normal file
@@ -0,0 +1,91 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
go: "1.24"
|
||||
tests: false
|
||||
|
||||
linters:
|
||||
enable:
|
||||
# Additional linters requested
|
||||
- testifylint # Checks usage of github.com/stretchr/testify
|
||||
- usetesting # usetesting is an analyzer that detects using os.Setenv instead of t.Setenv since Go 1.17
|
||||
- tagliatelle # Checks the struct tags
|
||||
- nlreturn # nlreturn checks for a new line before return and branch statements
|
||||
- nilnil # Checks that there is no simultaneous return of nil error and an invalid value
|
||||
- nestif # Reports deeply nested if statements
|
||||
- mnd # An analyzer to detect magic numbers
|
||||
- lll # Reports long lines
|
||||
- intrange # intrange is a linter to find places where for loops could make use of an integer range
|
||||
- gochecknoglobals # Check that no global variables exist
|
||||
|
||||
# Default/existing linters that are commonly useful
|
||||
- govet
|
||||
- errcheck
|
||||
- staticcheck
|
||||
- unused
|
||||
- ineffassign
|
||||
- misspell
|
||||
- revive
|
||||
- gosec
|
||||
- unconvert
|
||||
- unparam
|
||||
|
||||
linters-settings:
|
||||
lll:
|
||||
line-length: 120
|
||||
|
||||
mnd:
|
||||
# List of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description.
|
||||
checks:
|
||||
- argument
|
||||
- case
|
||||
- condition
|
||||
- operation
|
||||
- return
|
||||
- assign
|
||||
ignored-numbers:
|
||||
- '0'
|
||||
- '1'
|
||||
- '2'
|
||||
- '8'
|
||||
- '16'
|
||||
- '40' # GPG fingerprint length
|
||||
- '64'
|
||||
- '128'
|
||||
- '256'
|
||||
- '512'
|
||||
- '1024'
|
||||
- '2048'
|
||||
- '4096'
|
||||
|
||||
nestif:
|
||||
min-complexity: 4
|
||||
|
||||
nlreturn:
|
||||
block-size: 2
|
||||
|
||||
tagliatelle:
|
||||
case:
|
||||
rules:
|
||||
json: snake
|
||||
yaml: snake
|
||||
xml: snake
|
||||
bson: snake
|
||||
|
||||
testifylint:
|
||||
enable-all: true
|
||||
|
||||
usetesting: {}
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
exclude-rules:
|
||||
- path: ".*_gen\\.go"
|
||||
linters:
|
||||
- lll
|
||||
|
||||
# Exclude unused parameter warnings for cobra command signatures
|
||||
- text: "parameter '(args|cmd)' seems to be unused"
|
||||
linters:
|
||||
- revive
|
||||
28
CLAUDE.md
Normal file
28
CLAUDE.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Rules
|
||||
|
||||
Read the rules in AGENTS.md and follow them.
|
||||
|
||||
# Memory
|
||||
|
||||
* Claude is an inanimate tool. The spam that Claude attempts to insert into
|
||||
commit messages (which it erroneously refers to as "attribution") is not
|
||||
attribution, as I am the sole author of code created using Claude. It is
|
||||
corporate advertising for Anthropic and is therefore completely
|
||||
unacceptable in commit messages.
|
||||
|
||||
* Tests should always be run before committing code. No commits should be
|
||||
made that do not pass tests.
|
||||
|
||||
* Code should always be formatted before committing. Do not commit
|
||||
unformatted code.
|
||||
|
||||
* Code should always be linted before committing. Do not commit
|
||||
unlinted code.
|
||||
|
||||
* The test suite is fast and local. When running tests, don't run
|
||||
individual parts of the test suite, always run the whole thing by running
|
||||
"make test".
|
||||
|
||||
* Do not stop working on a task until you have reached the definition of
|
||||
done provided to you in the initial instruction. Don't do part or most of
|
||||
the work, do all of the work until the criteria for done are met.
|
||||
2
Makefile
2
Makefile
@@ -10,7 +10,7 @@ vet:
|
||||
go vet ./...
|
||||
|
||||
test:
|
||||
go test -v ./...
|
||||
go test ./... || go test -v ./...
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
231
TODO.md
231
TODO.md
@@ -1,203 +1,102 @@
|
||||
# TODO for 1.0 Release
|
||||
|
||||
This document outlines the bugs, issues, and improvements that need to be addressed before the 1.0 release of the secret manager.
|
||||
This document outlines the bugs, issues, and improvements that need to be
|
||||
addressed before the 1.0 release of the secret manager. Items are
|
||||
prioritized from most critical (top) to least critical (bottom).
|
||||
|
||||
## Critical (Blockers for Release)
|
||||
## Code Cleanups
|
||||
|
||||
### Error Handling and User Experience
|
||||
* we shouldn't be passing around a statedir, it should be read from the
|
||||
environment or default.
|
||||
|
||||
- [ ] **1. Inappropriate Cobra usage printing**: Commands currently print usage information for all errors, including internal program failures. Usage should only be printed when the user provides incorrect arguments or invalid commands, not when the program encounters internal errors (like file system issues, crypto failures, etc.).
|
||||
## HIGH PRIORITY SECURITY ISSUES
|
||||
|
||||
- [ ] **2. Inconsistent error messages**: Error messages need standardization and should be user-friendly. Many errors currently expose internal implementation details.
|
||||
- [ ] **4. Application crashes on corrupted metadata**: Code panics instead
|
||||
of returning errors when metadata is corrupt, causing denial of service.
|
||||
Found in pgpunlocker.go:116 and keychainunlocker.go:141.
|
||||
|
||||
- [x] **3. Missing validation for vault names**: Vault names should be validated against a safe character set to prevent filesystem issues.
|
||||
- [ ] **5. Insufficient input validation**: Secret names allow potentially
|
||||
dangerous patterns including dots that could enable path traversal attacks
|
||||
(vault/secrets.go:70-93).
|
||||
|
||||
- [ ] **4. No graceful handling of corrupted state**: If key files are corrupted or missing, the tool should provide clear error messages and recovery suggestions.
|
||||
- [ ] **6. Race conditions in file operations**: Multiple concurrent
|
||||
operations could corrupt the vault state due to lack of file locking
|
||||
mechanisms.
|
||||
|
||||
### Core Functionality Bugs
|
||||
- [ ] **7. Insecure temporary file handling**: Temporary files containing
|
||||
sensitive data may not be properly cleaned up or secured.
|
||||
|
||||
- [x] **5. Multiple vaults using the same mnemonic will derive the same long-term keys**: Adding additional vaults with the same mnemonic should increment the index value used. The mnemonic should be double sha256 hashed and the hash value stored in the vault metadata along with the index value (starting at zero) and when additional vaults are added with the same mnemonic (as determined by hash) then the index value should be incremented. The README should be updated to document this behavior.
|
||||
## HIGH PRIORITY FUNCTIONALITY ISSUES
|
||||
|
||||
- [x] **6. Directory structure inconsistency**: The README and test script reference different directory structures:
|
||||
- Current code uses `unlockers.d/` but documentation shows `unlock-keys.d/`
|
||||
- Secret files use inconsistent naming (`secret.age` vs `value.age`)
|
||||
- [ ] **8. Inappropriate Cobra usage printing**: Commands currently print
|
||||
usage information for all errors, including internal program failures.
|
||||
Usage should only be printed when the user provides incorrect arguments or
|
||||
invalid commands.
|
||||
|
||||
- [x] **7. Symlink handling on non-Unix systems**: The symlink resolution in `resolveVaultSymlink()` may fail on Windows or in certain environments.
|
||||
- [ ] **9. Missing current unlock key initialization**: When creating
|
||||
vaults, no default unlock key is selected, which can cause operations to
|
||||
fail.
|
||||
|
||||
- [ ] **8. Missing current unlock key initialization**: When creating vaults, no default unlock key is selected, which can cause operations to fail.
|
||||
- [ ] **10. Add confirmation prompts for destructive operations**:
|
||||
Operations like `keys rm` and vault deletion should require confirmation.
|
||||
|
||||
- [ ] **9. Race conditions in file operations**: Multiple concurrent operations could corrupt the vault state due to lack of file locking.
|
||||
- [ ] **11. No secret deletion command**: Missing `secret rm <secret-name>`
|
||||
functionality.
|
||||
|
||||
### Security Issues
|
||||
- [ ] **12. Missing vault deletion command**: No way to delete vaults that
|
||||
are no longer needed.
|
||||
|
||||
- [ ] **10. Insecure temporary file handling**: Temporary files containing sensitive data may not be properly cleaned up or secured.
|
||||
## MEDIUM PRIORITY ISSUES
|
||||
|
||||
- [ ] **11. Missing secure memory clearing**: Sensitive data in memory (passphrases, keys) should be cleared after use.
|
||||
- [ ] **13. Inconsistent error messages**: Error messages need
|
||||
standardization and should be user-friendly. Many errors currently expose
|
||||
internal implementation details.
|
||||
|
||||
- [x] **12. Weak default permissions**: Some files may be created with overly permissive default permissions.
|
||||
- [ ] **14. No graceful handling of corrupted state**: If key files are
|
||||
corrupted or missing, the tool should provide clear error messages and
|
||||
recovery suggestions.
|
||||
|
||||
## Important (Should be fixed before release)
|
||||
- [ ] **15. No validation of GPG key existence**: Should verify the
|
||||
specified GPG key exists before creating PGP unlock keys.
|
||||
|
||||
### User Interface Improvements
|
||||
- [ ] **16. Better separation of concerns**: Some functions in CLI do too
|
||||
much and should be split.
|
||||
|
||||
- [ ] **13. Add confirmation prompts for destructive operations**: Operations like `keys rm` and vault deletion should require confirmation.
|
||||
- [ ] **17. Environment variable security**: Sensitive data read from
|
||||
environment variables (SB_UNLOCK_PASSPHRASE, SB_SECRET_MNEMONIC) without
|
||||
proper clearing. Document security implications.
|
||||
|
||||
- [ ] **14. Improve progress indicators**: Long operations (key generation, encryption) should show progress.
|
||||
- [ ] **18. No secure memory allocation**: No use of mlock/munlock to
|
||||
prevent sensitive data from being swapped to disk.
|
||||
|
||||
- [x] **15. Better secret name validation**: Currently allows some characters that may cause issues, needs comprehensive validation.
|
||||
## LOWER PRIORITY ENHANCEMENTS
|
||||
|
||||
- [ ] **16. Add `--help` examples**: Command help should include practical examples for each operation.
|
||||
- [ ] **19. Add `--help` examples**: Command help should include practical examples for each operation.
|
||||
|
||||
### Command Implementation Gaps
|
||||
- [ ] **20. Add shell completion**: Bash/Zsh completion for commands and secret names.
|
||||
|
||||
- [x] **17. `secret keys rm` not fully implemented**: Based on test output, this command may not be working correctly.
|
||||
- [ ] **21. Colored output**: Use colors to improve readability of lists and error messages.
|
||||
|
||||
- [x] **18. `secret key select` not fully implemented**: Key selection functionality appears incomplete.
|
||||
- [ ] **22. Add `--quiet` flag**: Option to suppress non-essential output.
|
||||
|
||||
- [ ] **19. Missing vault deletion command**: No way to delete vaults that are no longer needed.
|
||||
- [ ] **23. Smart secret name suggestions**: When a secret name is not found, suggest similar names.
|
||||
|
||||
- [ ] **20. No secret deletion command**: Missing `secret rm <secret-name>` functionality.
|
||||
- [ ] **24. Audit logging**: Log all secret access and modifications for security auditing.
|
||||
|
||||
- [ ] **21. Missing secret history/versioning**: No way to see previous versions of secrets or restore old values.
|
||||
- [ ] **25. Integration tests for hardware features**: Automated testing of Keychain and GPG functionality.
|
||||
|
||||
### Configuration and Environment
|
||||
- [ ] **26. Consistent naming conventions**: Some variables and functions use inconsistent naming patterns.
|
||||
|
||||
- [ ] **22. Global configuration not fully implemented**: The `configuration.json` file structure exists but isn't used consistently.
|
||||
- [ ] **27. Export/import functionality**: Add ability to export/import entire vaults, not just individual secrets.
|
||||
|
||||
- [ ] **23. Missing environment variable validation**: Environment variables should be validated for format and security.
|
||||
- [ ] **28. Batch operations**: Add commands to process multiple secrets at once.
|
||||
|
||||
- [ ] **24. No configuration file validation**: JSON configuration files should be validated against schemas.
|
||||
- [ ] **29. Search functionality**: Add ability to search secret names and potentially contents.
|
||||
|
||||
### PGP Integration Issues
|
||||
- [ ] **30. Secret metadata**: Add support for descriptions, tags, or other metadata with secrets.
|
||||
|
||||
- [x] **25. Incomplete PGP unlock key implementation**: The `--keyid` parameter processing may not be fully working.
|
||||
## COMPLETED ITEMS ✓
|
||||
|
||||
- [ ] **26. Missing GPG agent integration**: Should detect and use existing GPG agent when available.
|
||||
|
||||
- [ ] **27. No validation of GPG key existence**: Should verify the specified GPG key exists before creating PGP unlock keys.
|
||||
|
||||
### Cross-Platform Issues
|
||||
|
||||
- [ ] **28. macOS Keychain error handling**: Better error messages when biometric authentication fails or isn't available.
|
||||
|
||||
- [ ] **29. Windows path handling**: File paths may not work correctly on Windows systems.
|
||||
|
||||
- [ ] **30. XDG compliance on Linux**: Should respect `XDG_CONFIG_HOME` and other XDG environment variables.
|
||||
|
||||
## Trivial (Nice to have)
|
||||
|
||||
### Code Quality
|
||||
|
||||
- [ ] **31. Add more comprehensive unit tests**: Current test coverage could be improved, especially for error conditions.
|
||||
|
||||
- [ ] **32. Reduce code duplication**: Several functions have similar patterns that could be refactored.
|
||||
|
||||
- [ ] **33. Improve function documentation**: Many functions lack proper Go documentation comments.
|
||||
|
||||
- [ ] **34. Add static analysis**: Integrate tools like `staticcheck`, `golangci-lint` with more linters.
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
- [ ] **35. Cache unlock key operations**: Avoid re-reading unlock key metadata on every operation.
|
||||
|
||||
- [ ] **36. Optimize file I/O**: Batch file operations where possible to reduce syscalls.
|
||||
|
||||
- [ ] **37. Add connection pooling for HSM operations**: For hardware security module operations.
|
||||
|
||||
### User Experience Enhancements
|
||||
|
||||
- [ ] **38. Add shell completion**: Bash/Zsh completion for commands and secret names.
|
||||
|
||||
- [ ] **39. Colored output**: Use colors to improve readability of lists and error messages.
|
||||
|
||||
- [ ] **40. Add `--quiet` flag**: Option to suppress non-essential output.
|
||||
|
||||
- [ ] **41. Smart secret name suggestions**: When a secret name is not found, suggest similar names.
|
||||
|
||||
### Additional Features
|
||||
|
||||
- [ ] **42. Secret templates**: Predefined templates for common secret types (database URLs, API keys, etc.).
|
||||
|
||||
- [ ] **43. Bulk operations**: Import/export multiple secrets at once.
|
||||
|
||||
- [ ] **44. Secret sharing**: Secure sharing of secrets between vaults or users.
|
||||
|
||||
- [ ] **45. Audit logging**: Log all secret access and modifications.
|
||||
|
||||
- [ ] **46. Integration tests for hardware features**: Automated testing of Keychain and GPG functionality.
|
||||
|
||||
### Documentation
|
||||
|
||||
- [ ] **47. Man pages**: Generate and install proper Unix man pages.
|
||||
|
||||
- [ ] **48. API documentation**: Document the internal API for potential library use.
|
||||
|
||||
- [ ] **49. Migration guide**: Document how to migrate from other secret managers.
|
||||
|
||||
- [ ] **50. Security audit documentation**: Document security assumptions and threat model.
|
||||
|
||||
## Architecture Improvements
|
||||
|
||||
### Code Structure
|
||||
|
||||
- [ ] **51. Consistent interface implementation**: Ensure all unlocker types properly implement the Unlocker interface.
|
||||
|
||||
- [ ] **52. Better separation of concerns**: Some functions in CLI do too much and should be split.
|
||||
|
||||
- [ ] **53. Improved error types**: Create specific error types instead of using generic `fmt.Errorf`.
|
||||
|
||||
### Testing Infrastructure
|
||||
|
||||
- [x] **54. Mock filesystem consistency**: Ensure mock filesystem behavior matches real filesystem in all cases.
|
||||
|
||||
- [x] **55. Integration test isolation**: Tests should not affect each other or the host system.
|
||||
|
||||
- [ ] **56. Performance benchmarks**: Add benchmarks for crypto operations and file I/O.
|
||||
|
||||
## Technical Debt
|
||||
|
||||
- [ ] **57. Remove unused code**: Clean up any dead code or unused imports.
|
||||
|
||||
- [ ] **58. Standardize JSON schemas**: Create proper JSON schemas for all configuration files.
|
||||
|
||||
- [ ] **59. Improve error propagation**: Many functions swallow important context in error messages.
|
||||
|
||||
- [ ] **60. Consistent naming conventions**: Some variables and functions use inconsistent naming.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- [ ] **61. Add pre-commit hooks**: Ensure code quality and formatting before commits.
|
||||
|
||||
- [ ] **62. Continuous integration**: Set up CI/CD pipeline with automated testing.
|
||||
|
||||
- [ ] **63. Release automation**: Automate the build and release process.
|
||||
|
||||
- [ ] **64. Dependency management**: Regular updates and security scanning of dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Priority Assessment
|
||||
|
||||
**Critical items** (1-12) block the 1.0 release and must be fixed for basic functionality and security.
|
||||
|
||||
**Important items** (13-30) should be addressed for a polished user experience but don't block the release.
|
||||
|
||||
**Trivial items** (31-50) are enhancements that can be addressed in future releases.
|
||||
|
||||
**Architecture and Infrastructure** (51-64) are longer-term improvements for maintainability and development workflow.
|
||||
|
||||
## Estimated Timeline
|
||||
|
||||
- Critical (1-12): 2-3 weeks
|
||||
- Important (13-30): 3-4 weeks
|
||||
- Trivial (31-50): Ongoing post-1.0
|
||||
- Architecture/Infrastructure (51-64): Ongoing post-1.0
|
||||
|
||||
Total estimated time to 1.0: 5-7 weeks with focused development effort.
|
||||
|
||||
### Architecture Issues
|
||||
- **Need to refactor unlock key hierarchy**: Current implementation has confusion between the top-level concepts. Fix in progress.
|
||||
- Current code uses `unlockers.d/` but documentation shows `unlock-keys.d/`
|
||||
- Need to settle on consistent naming: "unlock keys" vs "unlockers" throughout the codebase
|
||||
|
||||
- [ ] **51. Consistent interface implementation**: Ensure all unlocker types properly implement the Unlocker interface.
|
||||
- [x] **Missing secret history/versioning**: ✓ Implemented - versioning system exists with --version flag support
|
||||
- [x] **XDG compliance on Linux**: ✓ Implemented - uses os.UserConfigDir() which respects XDG_CONFIG_HOME
|
||||
- [x] **Consistent interface implementation**: ✓ Implemented - Unlocker interface is well-defined and consistently implemented
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Package main is the entry point for the secret CLI application.
|
||||
package main
|
||||
|
||||
import "git.eeqj.de/sneak/secret/internal/cli"
|
||||
|
||||
func main() {
|
||||
cli.CLIEntry()
|
||||
cli.Entry()
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -4,6 +4,7 @@ go 1.24.1
|
||||
|
||||
require (
|
||||
filippo.io/age v1.2.1
|
||||
github.com/awnumar/memguard v0.22.5
|
||||
github.com/btcsuite/btcd v0.24.2
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
||||
github.com/btcsuite/btcd/btcutil v1.1.6
|
||||
@@ -18,6 +19,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/awnumar/memcall v0.2.0 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||
|
||||
5
go.sum
5
go.sum
@@ -3,6 +3,10 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZ
|
||||
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
|
||||
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/awnumar/memcall v0.2.0 h1:sRaogqExTOOkkNwO9pzJsL8jrOV29UuUW7teRMfbqtI=
|
||||
github.com/awnumar/memcall v0.2.0/go.mod h1:S911igBPR9CThzd/hYQQmTc9SWNu3ZHIlCGaWsWsoJo=
|
||||
github.com/awnumar/memguard v0.22.5 h1:PH7sbUVERS5DdXh3+mLo8FDcl1eIeVjJVYMnyuYpvuI=
|
||||
github.com/awnumar/memguard v0.22.5/go.mod h1:+APmZGThMBWjnMlKiSM1X7MVpbIVewen2MTkqWkA/zE=
|
||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
|
||||
@@ -107,6 +111,7 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package cli implements the command-line interface for the secret application.
|
||||
package cli
|
||||
|
||||
import (
|
||||
@@ -9,57 +10,61 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Global scanner for consistent stdin reading
|
||||
var stdinScanner *bufio.Scanner
|
||||
var stdinScanner *bufio.Scanner //nolint:gochecknoglobals // Needed for consistent stdin handling
|
||||
|
||||
// CLIInstance encapsulates all CLI functionality and state
|
||||
type CLIInstance struct {
|
||||
// Instance encapsulates all CLI functionality and state
|
||||
type Instance struct {
|
||||
fs afero.Fs
|
||||
stateDir string
|
||||
cmd *cobra.Command
|
||||
}
|
||||
|
||||
// NewCLIInstance creates a new CLI instance with the real filesystem
|
||||
func NewCLIInstance() *CLIInstance {
|
||||
func NewCLIInstance() *Instance {
|
||||
fs := afero.NewOsFs()
|
||||
stateDir := secret.DetermineStateDir("")
|
||||
return &CLIInstance{
|
||||
|
||||
return &Instance{
|
||||
fs: fs,
|
||||
stateDir: stateDir,
|
||||
}
|
||||
}
|
||||
|
||||
// NewCLIInstanceWithFs creates a new CLI instance with the given filesystem (for testing)
|
||||
func NewCLIInstanceWithFs(fs afero.Fs) *CLIInstance {
|
||||
func NewCLIInstanceWithFs(fs afero.Fs) *Instance {
|
||||
stateDir := secret.DetermineStateDir("")
|
||||
return &CLIInstance{
|
||||
|
||||
return &Instance{
|
||||
fs: fs,
|
||||
stateDir: stateDir,
|
||||
}
|
||||
}
|
||||
|
||||
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state directory (for testing)
|
||||
func NewCLIInstanceWithStateDir(fs afero.Fs, stateDir string) *CLIInstance {
|
||||
return &CLIInstance{
|
||||
func NewCLIInstanceWithStateDir(fs afero.Fs, stateDir string) *Instance {
|
||||
return &Instance{
|
||||
fs: fs,
|
||||
stateDir: stateDir,
|
||||
}
|
||||
}
|
||||
|
||||
// SetFilesystem sets the filesystem for this CLI instance (for testing)
|
||||
func (cli *CLIInstance) SetFilesystem(fs afero.Fs) {
|
||||
func (cli *Instance) SetFilesystem(fs afero.Fs) {
|
||||
cli.fs = fs
|
||||
}
|
||||
|
||||
// SetStateDir sets the state directory for this CLI instance (for testing)
|
||||
func (cli *CLIInstance) SetStateDir(stateDir string) {
|
||||
func (cli *Instance) SetStateDir(stateDir string) {
|
||||
cli.stateDir = stateDir
|
||||
}
|
||||
|
||||
// GetStateDir returns the state directory for this CLI instance
|
||||
func (cli *CLIInstance) GetStateDir() string {
|
||||
func (cli *Instance) GetStateDir() string {
|
||||
return cli.stateDir
|
||||
}
|
||||
|
||||
@@ -68,6 +73,7 @@ func getStdinScanner() *bufio.Scanner {
|
||||
if stdinScanner == nil {
|
||||
stdinScanner = bufio.NewScanner(os.Stdin)
|
||||
}
|
||||
|
||||
return stdinScanner
|
||||
}
|
||||
|
||||
@@ -75,7 +81,7 @@ func getStdinScanner() *bufio.Scanner {
|
||||
// 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(int(syscall.Stderr)) {
|
||||
if !term.IsTerminal(syscall.Stderr) {
|
||||
return "", fmt.Errorf("cannot prompt for input: stderr is not a terminal (running in non-interactive mode)")
|
||||
}
|
||||
|
||||
@@ -85,7 +91,9 @@ func readLineFromStdin(prompt string) (string, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -37,19 +37,9 @@ func TestCLIInstanceWithFs(t *testing.T) {
|
||||
func TestDetermineStateDir(t *testing.T) {
|
||||
// Test the determineStateDir function from the secret package
|
||||
|
||||
// Save original environment and restore it after test
|
||||
originalStateDir := os.Getenv(secret.EnvStateDir)
|
||||
defer func() {
|
||||
if originalStateDir == "" {
|
||||
os.Unsetenv(secret.EnvStateDir)
|
||||
} else {
|
||||
os.Setenv(secret.EnvStateDir, originalStateDir)
|
||||
}
|
||||
}()
|
||||
|
||||
// Test with environment variable set
|
||||
testEnvDir := "/test-env-dir"
|
||||
os.Setenv(secret.EnvStateDir, testEnvDir)
|
||||
t.Setenv(secret.EnvStateDir, testEnvDir)
|
||||
|
||||
stateDir := secret.DetermineStateDir("")
|
||||
if stateDir != testEnvDir {
|
||||
@@ -57,7 +47,7 @@ func TestDetermineStateDir(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test with custom config dir
|
||||
os.Unsetenv(secret.EnvStateDir)
|
||||
_ = os.Unsetenv(secret.EnvStateDir)
|
||||
customConfigDir := "/custom-config"
|
||||
stateDir = secret.DetermineStateDir(customConfigDir)
|
||||
expectedDir := filepath.Join(customConfigDir, secret.AppID)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -22,12 +23,15 @@ func newEncryptCmd() *cobra.Command {
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
|
||||
cli := NewCLIInstance()
|
||||
cli.cmd = cmd
|
||||
|
||||
return cli.Encrypt(args[0], inputFile, outputFile)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
||||
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -42,17 +46,20 @@ func newDecryptCmd() *cobra.Command {
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
|
||||
cli := NewCLIInstance()
|
||||
cli.cmd = cmd
|
||||
|
||||
return cli.Decrypt(args[0], inputFile, outputFile)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
||||
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Encrypt encrypts data using an age secret key stored in a secret
|
||||
func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||
func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -68,46 +75,54 @@ func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
// Secret exists, get the age secret key from it
|
||||
var secretValue []byte
|
||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||
secretValue, err = secretObj.GetValue(nil)
|
||||
} else {
|
||||
unlocker, unlockErr := vlt.GetCurrentUnlocker()
|
||||
if unlockErr != nil {
|
||||
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
|
||||
}
|
||||
secretValue, err = secretObj.GetValue(unlocker)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get secret value: %w", err)
|
||||
}
|
||||
|
||||
ageSecretKey = string(secretValue)
|
||||
|
||||
// Validate that it's a valid age secret key
|
||||
if !isValidAgeSecretKey(ageSecretKey) {
|
||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
||||
}
|
||||
} else {
|
||||
if !exists { //nolint:nestif // Clear conditional logic for secret generation vs retrieval
|
||||
// Secret doesn't exist, generate new age key and store it
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate age key: %w", err)
|
||||
}
|
||||
|
||||
ageSecretKey = identity.String()
|
||||
// Store the generated key directly in a secure buffer
|
||||
identityStr := identity.String()
|
||||
secureBuffer := memguard.NewBufferFromBytes([]byte(identityStr))
|
||||
defer secureBuffer.Destroy()
|
||||
|
||||
// Store the generated key as a secret
|
||||
err = vlt.AddSecret(secretName, []byte(ageSecretKey), false)
|
||||
// Set ageSecretKey for later use (we need it for encryption)
|
||||
ageSecretKey = identityStr
|
||||
|
||||
err = vlt.AddSecret(secretName, secureBuffer, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store age key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Secret exists, get the age secret key from it
|
||||
secretValue, err := cli.getSecretValue(vlt, secretObj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get secret value: %w", err)
|
||||
}
|
||||
|
||||
// Create secure buffer for the secret value
|
||||
secureBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer secureBuffer.Destroy()
|
||||
|
||||
// Clear the original secret value
|
||||
for i := range secretValue {
|
||||
secretValue[i] = 0
|
||||
}
|
||||
|
||||
ageSecretKey = secureBuffer.String()
|
||||
|
||||
// Validate that it's a valid age secret key
|
||||
if !isValidAgeSecretKey(ageSecretKey) {
|
||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the secret key
|
||||
identity, err := age.ParseX25519Identity(ageSecretKey)
|
||||
// Parse the secret key using secure buffer
|
||||
finalSecureBuffer := memguard.NewBufferFromBytes([]byte(ageSecretKey))
|
||||
defer finalSecureBuffer.Destroy()
|
||||
|
||||
identity, err := age.ParseX25519Identity(finalSecureBuffer.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse age secret key: %w", err)
|
||||
}
|
||||
@@ -122,18 +137,18 @@ func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open input file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
input = file
|
||||
}
|
||||
|
||||
// Set up output writer
|
||||
var output io.Writer = os.Stdout
|
||||
output := cli.cmd.OutOrStdout()
|
||||
if outputFile != "" {
|
||||
file, err := cli.fs.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
output = file
|
||||
}
|
||||
|
||||
@@ -155,7 +170,7 @@ func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error
|
||||
}
|
||||
|
||||
// Decrypt decrypts data using an age secret key stored in a secret
|
||||
func (cli *CLIInstance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -188,15 +203,22 @@ func (cli *CLIInstance) Decrypt(secretName, inputFile, outputFile string) error
|
||||
return fmt.Errorf("failed to get secret value: %w", err)
|
||||
}
|
||||
|
||||
ageSecretKey := string(secretValue)
|
||||
// Create secure buffer for the secret value
|
||||
secureBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer secureBuffer.Destroy()
|
||||
|
||||
// Clear the original secret value
|
||||
for i := range secretValue {
|
||||
secretValue[i] = 0
|
||||
}
|
||||
|
||||
// Validate that it's a valid age secret key
|
||||
if !isValidAgeSecretKey(ageSecretKey) {
|
||||
if !isValidAgeSecretKey(secureBuffer.String()) {
|
||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
||||
}
|
||||
|
||||
// Parse the age secret key to get the identity
|
||||
identity, err := age.ParseX25519Identity(ageSecretKey)
|
||||
identity, err := age.ParseX25519Identity(secureBuffer.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse age secret key: %w", err)
|
||||
}
|
||||
@@ -208,18 +230,18 @@ func (cli *CLIInstance) Decrypt(secretName, inputFile, outputFile string) error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open input file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
input = file
|
||||
}
|
||||
|
||||
// Set up output writer
|
||||
var output io.Writer = os.Stdout
|
||||
output := cli.cmd.OutOrStdout()
|
||||
if outputFile != "" {
|
||||
file, err := cli.fs.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
output = file
|
||||
}
|
||||
|
||||
@@ -239,5 +261,20 @@ func (cli *CLIInstance) Decrypt(secretName, inputFile, outputFile string) error
|
||||
// isValidAgeSecretKey checks if a string is a valid age secret key by attempting to parse it
|
||||
func isValidAgeSecretKey(key string) bool {
|
||||
_, err := age.ParseX25519Identity(key)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// getSecretValue retrieves the value of a secret using the appropriate unlocker
|
||||
func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) ([]byte, error) {
|
||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||
return secretObj.GetValue(nil)
|
||||
}
|
||||
|
||||
unlocker, err := vlt.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||
}
|
||||
|
||||
return secretObj.GetValue(unlocker)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,16 @@ import (
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSecretLength = 16
|
||||
mnemonicEntropyBits = 128
|
||||
)
|
||||
|
||||
func newGenerateCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "generate",
|
||||
@@ -28,9 +34,12 @@ func newGenerateMnemonicCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "mnemonic",
|
||||
Short: "Generate a random BIP39 mnemonic phrase",
|
||||
Long: `Generate a cryptographically secure random BIP39 mnemonic phrase that can be used with 'secret init' or 'secret import'.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
Long: `Generate a cryptographically secure random BIP39 ` +
|
||||
`mnemonic phrase that can be used with 'secret init' ` +
|
||||
`or 'secret import'.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.GenerateMnemonic(cmd)
|
||||
},
|
||||
}
|
||||
@@ -48,11 +57,12 @@ func newGenerateSecretCmd() *cobra.Command {
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntP("length", "l", 16, "Length of the generated secret (default 16)")
|
||||
cmd.Flags().IntP("length", "l", defaultSecretLength, "Length of the generated secret (default 16)")
|
||||
cmd.Flags().StringP("type", "t", "base58", "Type of secret to generate (base58, alnum)")
|
||||
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
||||
|
||||
@@ -60,9 +70,9 @@ func newGenerateSecretCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
// GenerateMnemonic generates a random BIP39 mnemonic phrase
|
||||
func (cli *CLIInstance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
// Generate 128 bits of entropy for a 12-word mnemonic
|
||||
entropy, err := bip39.NewEntropy(128)
|
||||
entropy, err := bip39.NewEntropy(mnemonicEntropyBits)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate entropy: %w", err)
|
||||
}
|
||||
@@ -92,7 +102,13 @@ func (cli *CLIInstance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
// GenerateSecret generates a random secret and stores it in the vault
|
||||
func (cli *CLIInstance) GenerateSecret(cmd *cobra.Command, secretName string, length int, secretType string, force bool) error {
|
||||
func (cli *Instance) GenerateSecret(
|
||||
cmd *cobra.Command,
|
||||
secretName string,
|
||||
length int,
|
||||
secretType string,
|
||||
force bool,
|
||||
) error {
|
||||
if length < 1 {
|
||||
return fmt.Errorf("length must be at least 1")
|
||||
}
|
||||
@@ -121,23 +137,30 @@ func (cli *CLIInstance) GenerateSecret(cmd *cobra.Command, secretName string, le
|
||||
return err
|
||||
}
|
||||
|
||||
if err := vlt.AddSecret(secretName, []byte(secretValue), force); err != nil {
|
||||
// Protect the generated secret immediately
|
||||
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
if err := vlt.AddSecret(secretName, secretBuffer, force); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateRandomBase58 generates a random base58 string of the specified length
|
||||
func generateRandomBase58(length int) (string, error) {
|
||||
const base58Chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||
|
||||
return generateRandomString(length, base58Chars)
|
||||
}
|
||||
|
||||
// generateRandomAlnum generates a random alphanumeric string of the specified length
|
||||
func generateRandomAlnum(length int) (string, error) {
|
||||
const alnumChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
return generateRandomString(length, alnumChars)
|
||||
}
|
||||
|
||||
@@ -150,7 +173,7 @@ func generateRandomString(length int, charset string) (string, error) {
|
||||
result := make([]byte, length)
|
||||
charsetLen := big.NewInt(int64(len(charset)))
|
||||
|
||||
for i := 0; i < length; i++ {
|
||||
for i := range length {
|
||||
randomIndex, err := rand.Int(rand.Reader, charsetLen)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random number: %w", err)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
@@ -27,13 +28,14 @@ func NewInitCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
// RunInit is the exported function that handles the init command
|
||||
func RunInit(cmd *cobra.Command, args []string) error {
|
||||
func RunInit(cmd *cobra.Command, _ []string) error {
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.Init(cmd)
|
||||
}
|
||||
|
||||
// Init initializes the secret manager
|
||||
func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||
secret.Debug("Starting secret manager initialization")
|
||||
|
||||
// Create state directory
|
||||
@@ -42,6 +44,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
|
||||
if err := cli.fs.MkdirAll(stateDir, secret.DirPerms); err != nil {
|
||||
secret.Debug("Failed to create state directory", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create state directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -62,12 +65,14 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
mnemonicStr, err = readLineFromStdin("Enter your BIP39 mnemonic phrase: ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to read mnemonic: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if mnemonicStr == "" {
|
||||
secret.Debug("Empty mnemonic provided")
|
||||
|
||||
return fmt.Errorf("mnemonic cannot be empty")
|
||||
}
|
||||
|
||||
@@ -75,17 +80,18 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
secret.DebugWith("Validating BIP39 mnemonic", slog.Int("word_count", len(strings.Fields(mnemonicStr))))
|
||||
if !bip39.IsMnemonicValid(mnemonicStr) {
|
||||
secret.Debug("Invalid BIP39 mnemonic provided")
|
||||
|
||||
return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic")
|
||||
}
|
||||
|
||||
// Set mnemonic in environment for CreateVault to use
|
||||
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||
defer func() {
|
||||
if originalMnemonic != "" {
|
||||
os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvMnemonic)
|
||||
_ = os.Unsetenv(secret.EnvMnemonic)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -94,6 +100,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create default vault", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create default vault: %w", err)
|
||||
}
|
||||
|
||||
@@ -102,6 +109,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load vault metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to load vault metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -109,6 +117,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to derive long-term key", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
ltPubKey := ltIdentity.Recipient().String()
|
||||
@@ -117,25 +126,28 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Prompt for passphrase for unlocker
|
||||
var passphraseStr string
|
||||
var passphraseBuffer *memguard.LockedBuffer
|
||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
passphraseStr = envPassphrase
|
||||
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||
} else {
|
||||
secret.Debug("Prompting user for unlock passphrase")
|
||||
// Use secure passphrase input with confirmation
|
||||
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read unlock passphrase", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
}
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
// Create passphrase-protected unlocker
|
||||
secret.Debug("Creating passphrase-protected unlocker")
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create unlocker", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||
}
|
||||
|
||||
@@ -154,14 +166,18 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
// Encrypt long-term private key to unlocker
|
||||
ltPrivKeyData := []byte(ltIdentity.String())
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyData, unlockerRecipient)
|
||||
// Use memguard to protect the private key in memory
|
||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, unlockerRecipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Write encrypted long-term private key
|
||||
if err := afero.WriteFile(cli.fs, filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey, secret.FilePerms); err != nil {
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
if err := afero.WriteFile(cli.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -179,23 +195,27 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
|
||||
// readSecurePassphrase reads a passphrase securely from the terminal without echoing
|
||||
// This version adds confirmation (read twice) for creating new unlockers
|
||||
func readSecurePassphrase(prompt string) (string, error) {
|
||||
// Returns a LockedBuffer containing the passphrase
|
||||
func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||
// Get the first passphrase
|
||||
passphrase1, err := secret.ReadPassphrase(prompt)
|
||||
passphraseBuffer1, err := secret.ReadPassphrase(prompt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
defer passphraseBuffer1.Destroy()
|
||||
|
||||
// Read confirmation passphrase
|
||||
passphrase2, err := secret.ReadPassphrase("Confirm passphrase: ")
|
||||
passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
|
||||
if err != nil {
|
||||
return "", 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
|
||||
if passphrase1 != passphrase2 {
|
||||
return "", fmt.Errorf("passphrases do not match")
|
||||
if passphraseBuffer1.String() != passphraseBuffer2.String() {
|
||||
return nil, fmt.Errorf("passphrases do not match")
|
||||
}
|
||||
|
||||
return passphrase1, nil
|
||||
// Create a new buffer with the confirmed passphrase
|
||||
return memguard.NewBufferFromBytes(passphraseBuffer1.Bytes()), nil
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:lll // Integration test has long function signatures
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/cli"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -50,9 +52,12 @@ func TestMain(m *testing.M) {
|
||||
// all functionality of the secret manager using a real filesystem in a temporary directory.
|
||||
// This test serves as both validation and documentation of the program's behavior.
|
||||
func TestSecretManagerIntegration(t *testing.T) {
|
||||
// Enable debug logging to diagnose test failures
|
||||
os.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
||||
defer os.Unsetenv("GODEBUG")
|
||||
// Only enable debug logging if running with -v flag
|
||||
if testing.Verbose() {
|
||||
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
||||
// Reinitialize debug logging to pick up the environment variable change
|
||||
secret.InitDebugLogging()
|
||||
}
|
||||
|
||||
// Test configuration
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
@@ -62,8 +67,7 @@ func TestSecretManagerIntegration(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Set environment variables for the test
|
||||
os.Setenv("SB_SECRET_STATE_DIR", tempDir)
|
||||
defer os.Unsetenv("SB_SECRET_STATE_DIR")
|
||||
t.Setenv("SB_SECRET_STATE_DIR", tempDir)
|
||||
|
||||
// Find the secret binary path (needed for tests that still use exec.Command)
|
||||
wd, err := os.Getwd()
|
||||
@@ -171,7 +175,7 @@ func TestSecretManagerIntegration(t *testing.T) {
|
||||
// Command: secret list
|
||||
// Purpose: Show all secrets in current vault
|
||||
// Expected: Shows database/password with metadata
|
||||
test11ListSecrets(t, tempDir, testMnemonic, runSecret, runSecretWithStdin)
|
||||
test11ListSecrets(t, testMnemonic, runSecret, runSecretWithStdin)
|
||||
|
||||
// Test 12: Add secrets with different name formats
|
||||
// Commands: Various secret names (paths, dots, underscores)
|
||||
@@ -197,7 +201,7 @@ func TestSecretManagerIntegration(t *testing.T) {
|
||||
// Test 15: Cross-vault isolation
|
||||
// Purpose: Verify secrets in one vault aren't accessible from another
|
||||
// Expected: Secrets from work vault not visible in default vault
|
||||
test15VaultIsolation(t, tempDir, testMnemonic, runSecret, runSecretWithEnv, runSecretWithStdin)
|
||||
test15VaultIsolation(t, testMnemonic, runSecret, runSecretWithEnv, runSecretWithStdin)
|
||||
|
||||
// Test 16: Generate random secrets
|
||||
// Command: secret generate secret api/key --length 32 --type base58
|
||||
@@ -209,7 +213,7 @@ func TestSecretManagerIntegration(t *testing.T) {
|
||||
// Command: secret import ssh/key --source ~/.ssh/id_rsa
|
||||
// Purpose: Import existing file as secret
|
||||
// Expected: File contents stored as secret value
|
||||
test17ImportFromFile(t, tempDir, testMnemonic, runSecretWithEnv, runSecretWithStdin)
|
||||
test17ImportFromFile(t, tempDir, testMnemonic, runSecretWithEnv)
|
||||
|
||||
// Test 18: Age key management
|
||||
// Commands: secret encrypt/decrypt using stored age keys
|
||||
@@ -263,7 +267,7 @@ func TestSecretManagerIntegration(t *testing.T) {
|
||||
// Test 26: Large secret values
|
||||
// Purpose: Test with large secret values (e.g., certificates)
|
||||
// Expected: Proper storage and retrieval
|
||||
test26LargeSecrets(t, tempDir, secretPath, testMnemonic, runSecret, runSecretWithEnv)
|
||||
test26LargeSecrets(t, tempDir, secretPath, testMnemonic, runSecret, runSecretWithEnv, runSecretWithStdin)
|
||||
|
||||
// Test 27: Special characters in values
|
||||
// Purpose: Test secrets with newlines, unicode, binary data
|
||||
@@ -349,10 +353,18 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
|
||||
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
|
||||
verifyFileExists(t, currentUnlockerFile)
|
||||
|
||||
// Read the current-unlocker file to see what it contains
|
||||
currentUnlockerContent := readFile(t, currentUnlockerFile)
|
||||
// The file likely contains the unlocker ID
|
||||
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should be passphrase type")
|
||||
// Read the current-unlocker symlink to see what it points to
|
||||
symlinkTarget, err := os.Readlink(currentUnlockerFile)
|
||||
if err != nil {
|
||||
t.Logf("DEBUG: failed to read symlink %s: %v", currentUnlockerFile, err)
|
||||
// Fallback to reading as file if it's not a symlink
|
||||
currentUnlockerContent := readFile(t, currentUnlockerFile)
|
||||
t.Logf("DEBUG: current-unlocker file content: %q", string(currentUnlockerContent))
|
||||
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should be passphrase type")
|
||||
} else {
|
||||
t.Logf("DEBUG: current-unlocker symlink points to: %q", symlinkTarget)
|
||||
assert.Contains(t, symlinkTarget, "passphrase", "current unlocker should be passphrase type")
|
||||
}
|
||||
|
||||
// Verify vault-metadata.json in vault
|
||||
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
|
||||
@@ -369,8 +381,8 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
|
||||
t.Logf("Parsed metadata: %+v", metadata)
|
||||
|
||||
// Verify metadata fields
|
||||
assert.Equal(t, float64(0), metadata["derivation_index"], "first vault should have index 0")
|
||||
assert.Contains(t, metadata, "public_key_hash", "should contain public key hash")
|
||||
assert.Equal(t, float64(0), metadata["derivationIndex"], "first vault should have index 0")
|
||||
assert.Contains(t, metadata, "publicKeyHash", "should contain public key hash")
|
||||
assert.Contains(t, metadata, "createdAt", "should contain creation timestamp")
|
||||
|
||||
// Verify the longterm.age file in passphrase unlocker
|
||||
@@ -400,8 +412,8 @@ func test02ListVaults(t *testing.T, runSecret func(...string) (string, error)) {
|
||||
require.NoError(t, err, "JSON output should be valid")
|
||||
|
||||
// Verify current vault
|
||||
currentVault, ok := response["current_vault"]
|
||||
require.True(t, ok, "response should contain current_vault")
|
||||
currentVault, ok := response["currentVault"]
|
||||
require.True(t, ok, "response should contain currentVault")
|
||||
assert.Equal(t, "default", currentVault, "current vault should be default")
|
||||
|
||||
// Verify vaults list
|
||||
@@ -509,14 +521,14 @@ func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase st
|
||||
require.NoError(t, err, "vault metadata should be valid JSON")
|
||||
|
||||
// Work vault should have a different derivation index than default (0)
|
||||
derivIndex, ok := metadata["derivation_index"].(float64)
|
||||
require.True(t, ok, "derivation_index should be a number")
|
||||
derivIndex, ok := metadata["derivationIndex"].(float64)
|
||||
require.True(t, ok, "derivationIndex should be a number")
|
||||
assert.NotEqual(t, float64(0), derivIndex, "work vault should have non-zero derivation index")
|
||||
|
||||
// Verify public key hash is stored
|
||||
assert.Contains(t, metadata, "public_key_hash", "should contain public key hash")
|
||||
pubKeyHash, ok := metadata["public_key_hash"].(string)
|
||||
require.True(t, ok, "public_key_hash should be a string")
|
||||
assert.Contains(t, metadata, "publicKeyHash", "should contain public key hash")
|
||||
pubKeyHash, ok := metadata["publicKeyHash"].(string)
|
||||
require.True(t, ok, "publicKeyHash should be a string")
|
||||
assert.NotEmpty(t, pubKeyHash, "public key hash should not be empty")
|
||||
}
|
||||
|
||||
@@ -817,7 +829,7 @@ func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret
|
||||
}
|
||||
}
|
||||
|
||||
func test11ListSecrets(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||
func test11ListSecrets(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||
// Make sure we're in default vault
|
||||
_, err := runSecret("vault", "select", "default")
|
||||
require.NoError(t, err, "vault select should succeed")
|
||||
@@ -865,8 +877,8 @@ func test11ListSecrets(t *testing.T, tempDir, testMnemonic string, runSecret fun
|
||||
var listResponse struct {
|
||||
Secrets []struct {
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
} `json:"secrets"`
|
||||
Filter string `json:"filter,omitempty"`
|
||||
}
|
||||
@@ -987,12 +999,12 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
|
||||
if shouldFail {
|
||||
assert.Error(t, err, "add '%s' should fail", invalidName)
|
||||
if err != nil {
|
||||
assert.Contains(t, string(output), "invalid secret name", "should indicate invalid name for '%s'", invalidName)
|
||||
assert.Contains(t, output, "invalid secret name", "should indicate invalid name for '%s'", invalidName)
|
||||
}
|
||||
} else {
|
||||
// For the slash cases and .hidden, they might succeed
|
||||
// Just log what happened
|
||||
t.Logf("add '%s' result: err=%v, output=%s", invalidName, err, string(output))
|
||||
t.Logf("add '%s' result: err=%v, output=%s", invalidName, err, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1006,6 +1018,7 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
|
||||
// List unlockers
|
||||
output, err := runSecret("unlockers", "list")
|
||||
require.NoError(t, err, "unlockers list should succeed")
|
||||
t.Logf("DEBUG: unlockers list output: %q", output)
|
||||
|
||||
// Should have the passphrase unlocker created during init
|
||||
assert.Contains(t, output, "passphrase", "should have passphrase unlocker")
|
||||
@@ -1034,6 +1047,7 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
|
||||
}
|
||||
// Note: This might still show 1 if the implementation doesn't support multiple passphrase unlockers
|
||||
// Just verify we have at least 1
|
||||
t.Logf("DEBUG: passphrase count: %d, output lines: %v", passphraseCount, lines)
|
||||
assert.GreaterOrEqual(t, passphraseCount, 1, "should have at least 1 passphrase unlocker")
|
||||
|
||||
// Test JSON output
|
||||
@@ -1097,7 +1111,7 @@ func test14SwitchVault(t *testing.T, tempDir string, runSecret func(...string) (
|
||||
assert.Contains(t, output, "does not exist", "should indicate vault doesn't exist")
|
||||
}
|
||||
|
||||
func test15VaultIsolation(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||
func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||
// Make sure we're in default vault
|
||||
_, err := runSecret("vault", "select", "default")
|
||||
require.NoError(t, err, "vault select should succeed")
|
||||
@@ -1205,7 +1219,7 @@ func test16GenerateSecret(t *testing.T, tempDir, testMnemonic string, runSecret
|
||||
verifyFileExists(t, versionsDir)
|
||||
}
|
||||
|
||||
func test17ImportFromFile(t *testing.T, tempDir, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||
func test17ImportFromFile(t *testing.T, tempDir, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
||||
// Make sure we're in default vault
|
||||
runSecret := func(args ...string) (string, error) {
|
||||
return cli.ExecuteCommandInProcess(args, "", nil)
|
||||
@@ -1270,6 +1284,7 @@ func test18AgeKeyOperations(t *testing.T, tempDir, secretPath, testMnemonic stri
|
||||
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
@@ -1309,6 +1324,7 @@ func test18AgeKeyOperations(t *testing.T, tempDir, secretPath, testMnemonic stri
|
||||
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||
}, "encrypt", "encryption/key", "--input", testFile)
|
||||
require.NoError(t, err, "encrypt to stdout should succeed")
|
||||
t.Logf("DEBUG: encrypt output: %q", output)
|
||||
assert.Contains(t, output, "age-encryption.org", "should output age format")
|
||||
|
||||
// Test that the age key was stored as a secret
|
||||
@@ -1335,6 +1351,7 @@ func test19DisasterRecovery(t *testing.T, tempDir, secretPath, testMnemonic stri
|
||||
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
@@ -1361,7 +1378,7 @@ func test19DisasterRecovery(t *testing.T, tempDir, secretPath, testMnemonic stri
|
||||
require.NoError(t, err, "read vault metadata")
|
||||
|
||||
var metadata struct {
|
||||
DerivationIndex uint32 `json:"derivation_index"`
|
||||
DerivationIndex uint32 `json:"derivationIndex"`
|
||||
}
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
require.NoError(t, err, "parse vault metadata")
|
||||
@@ -1372,7 +1389,7 @@ func test19DisasterRecovery(t *testing.T, tempDir, secretPath, testMnemonic stri
|
||||
|
||||
// Write the long-term private key to a file for age CLI
|
||||
ltPrivKeyPath := filepath.Join(tempDir, "lt-private.key")
|
||||
err = os.WriteFile(ltPrivKeyPath, []byte(ltIdentity.String()), 0600)
|
||||
err = os.WriteFile(ltPrivKeyPath, []byte(ltIdentity.String()), 0o600)
|
||||
require.NoError(t, err, "write long-term private key")
|
||||
|
||||
// Find the secret version directory
|
||||
@@ -1429,6 +1446,7 @@ func test20VersionTimestamps(t *testing.T, tempDir, secretPath, testMnemonic str
|
||||
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
@@ -1514,7 +1532,7 @@ func test22JSONOutput(t *testing.T, runSecret func(...string) (string, error)) {
|
||||
err = json.Unmarshal([]byte(output), &vaultListResponse)
|
||||
require.NoError(t, err, "vault list JSON should be valid")
|
||||
assert.Contains(t, vaultListResponse, "vaults", "should have vaults key")
|
||||
assert.Contains(t, vaultListResponse, "current_vault", "should have current_vault key")
|
||||
assert.Contains(t, vaultListResponse, "currentVault", "should have currentVault key")
|
||||
|
||||
// Test secret list --json (already tested in test 11)
|
||||
|
||||
@@ -1555,7 +1573,7 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
|
||||
cmdOutput, err = cmd.CombinedOutput()
|
||||
assert.Error(t, err, "get without mnemonic should fail")
|
||||
assert.Contains(t, string(cmdOutput), "failed to unlock", "should indicate unlock failure")
|
||||
os.Setenv("SB_SECRET_MNEMONIC", unsetMnemonic)
|
||||
t.Setenv("SB_SECRET_MNEMONIC", unsetMnemonic)
|
||||
|
||||
// Invalid secret names (already tested in test 12)
|
||||
|
||||
@@ -1591,7 +1609,7 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
|
||||
func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic, testPassphrase string) {
|
||||
// Create a new temporary directory for this test
|
||||
envTestDir := filepath.Join(tempDir, "env-test")
|
||||
err := os.MkdirAll(envTestDir, 0700)
|
||||
err := os.MkdirAll(envTestDir, 0o700)
|
||||
require.NoError(t, err, "create env test dir should succeed")
|
||||
|
||||
// Test init with both env vars set
|
||||
@@ -1645,7 +1663,7 @@ func test25ConcurrentOperations(t *testing.T, testMnemonic string, runSecret fun
|
||||
const numReaders = 5
|
||||
errors := make(chan error, numReaders)
|
||||
|
||||
for i := 0; i < numReaders; i++ {
|
||||
for i := range numReaders {
|
||||
go func(id int) {
|
||||
output, err := runSecretWithEnv(map[string]string{
|
||||
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||
@@ -1661,7 +1679,7 @@ func test25ConcurrentOperations(t *testing.T, testMnemonic string, runSecret fun
|
||||
}
|
||||
|
||||
// Wait for all readers
|
||||
for i := 0; i < numReaders; i++ {
|
||||
for range numReaders {
|
||||
err := <-errors
|
||||
assert.NoError(t, err, "concurrent read should succeed")
|
||||
}
|
||||
@@ -1670,7 +1688,7 @@ func test25ConcurrentOperations(t *testing.T, testMnemonic string, runSecret fun
|
||||
// to avoid conflicts, but reads should always work
|
||||
}
|
||||
|
||||
func test26LargeSecrets(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
||||
func test26LargeSecrets(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||
// Make sure we're in default vault
|
||||
_, err := runSecret("vault", "select", "default")
|
||||
require.NoError(t, err, "vault select should succeed")
|
||||
@@ -1683,16 +1701,10 @@ func test26LargeSecrets(t *testing.T, tempDir, secretPath, testMnemonic string,
|
||||
assert.Greater(t, len(largeValue), 10000, "should be > 10KB")
|
||||
|
||||
// Add large secret
|
||||
cmd := exec.Command(secretPath, "add", "large/secret", "--force")
|
||||
cmd.Env = []string{
|
||||
fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir),
|
||||
fmt.Sprintf("SB_SECRET_MNEMONIC=%s", testMnemonic),
|
||||
fmt.Sprintf("PATH=%s", os.Getenv("PATH")),
|
||||
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
||||
}
|
||||
cmd.Stdin = strings.NewReader(largeValue)
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "add large secret should succeed: %s", string(output))
|
||||
_, err = runSecretWithStdin(largeValue, map[string]string{
|
||||
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||
}, "add", "large/secret", "--force")
|
||||
require.NoError(t, err, "add large secret should succeed")
|
||||
|
||||
// Retrieve and verify
|
||||
retrievedValue, err := runSecretWithEnv(map[string]string{
|
||||
@@ -1708,15 +1720,9 @@ BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
|
||||
aWRnaXRzIFB0eSBMdGQwHhcNMTgwMjI4MTQwMzQ5WhcNMjgwMjI2MTQwMzQ5WjBF
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
cmd = exec.Command(secretPath, "add", "cert/test", "--force")
|
||||
cmd.Env = []string{
|
||||
fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir),
|
||||
fmt.Sprintf("SB_SECRET_MNEMONIC=%s", testMnemonic),
|
||||
fmt.Sprintf("PATH=%s", os.Getenv("PATH")),
|
||||
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
||||
}
|
||||
cmd.Stdin = strings.NewReader(certValue)
|
||||
_, err = cmd.CombinedOutput()
|
||||
_, err = runSecretWithStdin(certValue, map[string]string{
|
||||
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||
}, "add", "cert/test", "--force")
|
||||
require.NoError(t, err, "add certificate should succeed")
|
||||
|
||||
// Retrieve and verify certificate
|
||||
@@ -1804,10 +1810,10 @@ func test28VaultMetadata(t *testing.T, tempDir string) {
|
||||
require.NoError(t, err, "default vault metadata should be valid JSON")
|
||||
|
||||
// Verify required fields
|
||||
assert.Equal(t, "default", defaultMetadata["name"])
|
||||
assert.Equal(t, float64(0), defaultMetadata["derivation_index"])
|
||||
assert.Equal(t, float64(0), defaultMetadata["derivationIndex"])
|
||||
assert.Contains(t, defaultMetadata, "createdAt")
|
||||
assert.Contains(t, defaultMetadata, "public_key_hash")
|
||||
assert.Contains(t, defaultMetadata, "publicKeyHash")
|
||||
assert.Contains(t, defaultMetadata, "mnemonicFamilyHash")
|
||||
|
||||
// Check work vault metadata
|
||||
workMetadataPath := filepath.Join(tempDir, "vaults.d", "work", "vault-metadata.json")
|
||||
@@ -1819,13 +1825,12 @@ func test28VaultMetadata(t *testing.T, tempDir string) {
|
||||
require.NoError(t, err, "work vault metadata should be valid JSON")
|
||||
|
||||
// Work vault should have different derivation index
|
||||
assert.Equal(t, "work", workMetadata["name"])
|
||||
workIndex := workMetadata["derivation_index"].(float64)
|
||||
workIndex := workMetadata["derivationIndex"].(float64)
|
||||
assert.NotEqual(t, float64(0), workIndex, "work vault should have non-zero derivation index")
|
||||
|
||||
// Both vaults created with same mnemonic should have same public_key_hash
|
||||
assert.Equal(t, defaultMetadata["public_key_hash"], workMetadata["public_key_hash"],
|
||||
"vaults from same mnemonic should have same public_key_hash")
|
||||
// Both vaults created with same mnemonic should have same mnemonicFamilyHash
|
||||
assert.Equal(t, defaultMetadata["mnemonicFamilyHash"], workMetadata["mnemonicFamilyHash"],
|
||||
"vaults from same mnemonic should have same mnemonicFamilyHash")
|
||||
}
|
||||
|
||||
func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic string) {
|
||||
@@ -1894,7 +1899,7 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
|
||||
|
||||
// Create backup directory
|
||||
backupDir := filepath.Join(tempDir, "backup")
|
||||
err := os.MkdirAll(backupDir, 0700)
|
||||
err := os.MkdirAll(backupDir, 0o700)
|
||||
require.NoError(t, err, "create backup dir should succeed")
|
||||
|
||||
// Copy entire state directory to backup
|
||||
@@ -1984,21 +1989,21 @@ func test31EnvMnemonicUsesVaultDerivationIndex(t *testing.T, tempDir, secretPath
|
||||
var defaultMetadata map[string]interface{}
|
||||
err := json.Unmarshal(defaultMetadataBytes, &defaultMetadata)
|
||||
require.NoError(t, err, "default vault metadata should be valid JSON")
|
||||
assert.Equal(t, float64(0), defaultMetadata["derivation_index"], "default vault should have index 0")
|
||||
assert.Equal(t, float64(0), defaultMetadata["derivationIndex"], "default vault should have index 0")
|
||||
|
||||
workMetadataPath := filepath.Join(tempDir, "vaults.d", "work", "vault-metadata.json")
|
||||
workMetadataBytes := readFile(t, workMetadataPath)
|
||||
var workMetadata map[string]interface{}
|
||||
err = json.Unmarshal(workMetadataBytes, &workMetadata)
|
||||
require.NoError(t, err, "work vault metadata should be valid JSON")
|
||||
assert.Equal(t, float64(1), workMetadata["derivation_index"], "work vault should have index 1")
|
||||
assert.Equal(t, float64(1), workMetadata["derivationIndex"], "work vault should have index 1")
|
||||
|
||||
// Switch to work vault
|
||||
_, err = runSecret("vault", "select", "work")
|
||||
require.NoError(t, err, "vault select work should succeed")
|
||||
|
||||
// Add a secret to work vault using environment mnemonic
|
||||
secretValue := "work-vault-secret"
|
||||
secretValue := "work-vault-secret" //nolint:gosec // G101: This is test data, not a real credential
|
||||
cmd := exec.Command(secretPath, "add", "test/derivation")
|
||||
cmd.Env = []string{
|
||||
fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir),
|
||||
@@ -2025,7 +2030,7 @@ func test31EnvMnemonicUsesVaultDerivationIndex(t *testing.T, tempDir, secretPath
|
||||
|
||||
// This is the expected behavior with the current bug
|
||||
assert.Error(t, err, "get should fail due to wrong derivation index")
|
||||
assert.Contains(t, getOutput, "failed to decrypt", "should indicate decryption failure")
|
||||
assert.Contains(t, getOutput, "derived public key does not match vault", "should indicate key derivation failure")
|
||||
|
||||
// Document what should happen when the bug is fixed
|
||||
t.Log("When the bug is fixed, GetValue should read vault metadata and use derivation index 1")
|
||||
@@ -2063,13 +2068,14 @@ func readFile(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err, "Should be able to read file: %s", path)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// writeFile writes data to a file
|
||||
func writeFile(t *testing.T, path string, data []byte) {
|
||||
t.Helper()
|
||||
err := os.WriteFile(path, data, 0600)
|
||||
err := os.WriteFile(path, data, 0o600)
|
||||
require.NoError(t, err, "Should be able to write file: %s", path)
|
||||
}
|
||||
|
||||
@@ -2080,7 +2086,7 @@ func copyDir(src, dst string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.MkdirAll(dst, 0755)
|
||||
err = os.MkdirAll(dst, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2112,6 +2118,7 @@ func copyDir(src, dst string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2132,7 +2139,7 @@ func copyFile(src, dst string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.WriteFile(dst, srcData, 0644)
|
||||
err = os.WriteFile(dst, srcData, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// CLIEntry is the entry point for the secret CLI application
|
||||
func CLIEntry() {
|
||||
// Entry is the entry point for the secret CLI application
|
||||
func Entry() {
|
||||
cmd := newRootCmd()
|
||||
if err := cmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
@@ -42,5 +42,6 @@ func newRootCmd() *cobra.Command {
|
||||
cmd.AddCommand(newVersionCmd())
|
||||
|
||||
secret.Debug("newRootCmd completed")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -4,12 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -25,12 +24,15 @@ func newAddCmd() *cobra.Command {
|
||||
secret.Debug("Got force flag", "force", force)
|
||||
|
||||
cli := NewCLIInstance()
|
||||
cli.cmd = cmd // Set the command for stdin access
|
||||
secret.Debug("Created CLI instance, calling AddSecret")
|
||||
|
||||
return cli.AddSecret(args[0], force)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -42,11 +44,13 @@ func newGetCmd() *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.GetSecretWithVersion(cmd, args[0], version)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("version", "v", "", "Get a specific version (default: current)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -66,11 +70,13 @@ func newListCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.ListSecrets(cmd, jsonOutput, filter)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -85,6 +91,7 @@ func newImportCmd() *cobra.Command {
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.ImportSecret(cmd, args[0], sourceFile, force)
|
||||
},
|
||||
}
|
||||
@@ -92,11 +99,26 @@ func newImportCmd() *cobra.Command {
|
||||
cmd.Flags().StringP("source", "s", "", "Source file to import from (required)")
|
||||
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
||||
_ = cmd.MarkFlagRequired("source")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// updateBufferSize updates the buffer size based on usage pattern
|
||||
func updateBufferSize(currentSize int, sameSize *int) int {
|
||||
*sameSize++
|
||||
const doubleAfterBuffers = 2
|
||||
const growthFactor = 2
|
||||
if *sameSize >= doubleAfterBuffers {
|
||||
*sameSize = 0
|
||||
|
||||
return currentSize * growthFactor
|
||||
}
|
||||
|
||||
return currentSize
|
||||
}
|
||||
|
||||
// AddSecret adds a secret to the current vault
|
||||
func (cli *CLIInstance) AddSecret(secretName string, force bool) error {
|
||||
func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
|
||||
|
||||
// Get current vault
|
||||
@@ -108,45 +130,106 @@ func (cli *CLIInstance) AddSecret(secretName string, force bool) error {
|
||||
|
||||
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
||||
|
||||
// Read secret value from stdin
|
||||
secret.Debug("Reading secret value from stdin")
|
||||
value, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read secret value: %w", err)
|
||||
// Read secret value directly into protected buffers
|
||||
secret.Debug("Reading secret value from stdin into protected buffers")
|
||||
|
||||
const initialSize = 4 * 1024 // 4KB initial buffer
|
||||
const maxSize = 100 * 1024 * 1024 // 100MB max
|
||||
|
||||
type bufferInfo struct {
|
||||
buffer *memguard.LockedBuffer
|
||||
used int
|
||||
}
|
||||
|
||||
secret.Debug("Read secret value from stdin", "value_length", len(value))
|
||||
var buffers []bufferInfo
|
||||
defer func() {
|
||||
for _, b := range buffers {
|
||||
b.buffer.Destroy()
|
||||
}
|
||||
}()
|
||||
|
||||
// Remove trailing newline if present
|
||||
if len(value) > 0 && value[len(value)-1] == '\n' {
|
||||
value = value[:len(value)-1]
|
||||
secret.Debug("Removed trailing newline", "new_length", len(value))
|
||||
reader := cli.cmd.InOrStdin()
|
||||
totalSize := 0
|
||||
currentBufferSize := initialSize
|
||||
sameSize := 0
|
||||
|
||||
for {
|
||||
// Create a new buffer
|
||||
buffer := memguard.NewBuffer(currentBufferSize)
|
||||
n, err := io.ReadFull(reader, buffer.Bytes())
|
||||
|
||||
if n == 0 {
|
||||
// No data read, destroy the unused buffer
|
||||
buffer.Destroy()
|
||||
} else {
|
||||
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
||||
totalSize += n
|
||||
|
||||
if totalSize > maxSize {
|
||||
return fmt.Errorf("secret too large: exceeds 100MB limit")
|
||||
}
|
||||
|
||||
// If we filled the buffer, consider growing for next iteration
|
||||
if n == currentBufferSize {
|
||||
currentBufferSize = updateBufferSize(currentBufferSize, &sameSize)
|
||||
}
|
||||
}
|
||||
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to read secret value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for trailing newline in the last buffer
|
||||
if len(buffers) > 0 && totalSize > 0 {
|
||||
lastBuffer := &buffers[len(buffers)-1]
|
||||
if lastBuffer.buffer.Bytes()[lastBuffer.used-1] == '\n' {
|
||||
lastBuffer.used--
|
||||
totalSize--
|
||||
}
|
||||
}
|
||||
|
||||
secret.Debug("Read secret value from stdin", "value_length", totalSize, "buffers", len(buffers))
|
||||
|
||||
// Combine all buffers into a single protected buffer
|
||||
valueBuffer := memguard.NewBuffer(totalSize)
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
offset := 0
|
||||
for _, b := range buffers {
|
||||
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||
offset += b.used
|
||||
}
|
||||
|
||||
// Add the secret to the vault
|
||||
secret.Debug("Calling vault.AddSecret", "secret_name", secretName, "value_length", len(value), "force", force)
|
||||
if err := vlt.AddSecret(secretName, value, force); err != nil {
|
||||
secret.Debug("Calling vault.AddSecret", "secret_name", secretName, "value_length", valueBuffer.Size(), "force", force)
|
||||
if err := vlt.AddSecret(secretName, valueBuffer, force); err != nil {
|
||||
secret.Debug("vault.AddSecret failed", "error", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
secret.Debug("vault.AddSecret completed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSecret retrieves and prints a secret from the current vault
|
||||
func (cli *CLIInstance) GetSecret(cmd *cobra.Command, secretName string) error {
|
||||
func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
||||
return cli.GetSecretWithVersion(cmd, secretName, "")
|
||||
}
|
||||
|
||||
// GetSecretWithVersion retrieves and prints a specific version of a secret
|
||||
func (cli *CLIInstance) GetSecretWithVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||
func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||
secret.Debug("GetSecretWithVersion called", "secretName", secretName, "version", version)
|
||||
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current vault", "error", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -159,6 +242,7 @@ func (cli *CLIInstance) GetSecretWithVersion(cmd *cobra.Command, secretName stri
|
||||
}
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get secret", "error", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -167,11 +251,20 @@ func (cli *CLIInstance) GetSecretWithVersion(cmd *cobra.Command, secretName stri
|
||||
// Print the secret value to stdout
|
||||
cmd.Print(string(value))
|
||||
secret.Debug("Printed value to cmd")
|
||||
|
||||
// Debug: Log what we're actually printing
|
||||
secret.Debug("Secret retrieval debug info",
|
||||
"secretName", secretName,
|
||||
"version", version,
|
||||
"valueLength", len(value),
|
||||
"valueAsString", string(value),
|
||||
"isEmpty", len(value) == 0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSecrets lists all secrets in the current vault
|
||||
func (cli *CLIInstance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter string) error {
|
||||
func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter string) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -196,7 +289,7 @@ func (cli *CLIInstance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter
|
||||
filteredSecrets = secrets
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
if jsonOutput { //nolint:nestif // Separate JSON and table output formatting logic
|
||||
// For JSON output, get metadata for each secret
|
||||
secretsWithMetadata := make([]map[string]interface{}, 0, len(filteredSecrets))
|
||||
|
||||
@@ -237,6 +330,7 @@ func (cli *CLIInstance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter
|
||||
cmd.Println("No secrets found in current vault.")
|
||||
cmd.Println("Run 'secret add <name>' to create one.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -269,24 +363,88 @@ func (cli *CLIInstance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter
|
||||
}
|
||||
|
||||
// ImportSecret imports a secret from a file
|
||||
func (cli *CLIInstance) ImportSecret(cmd *cobra.Command, secretName, sourceFile string, force bool) error {
|
||||
func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile string, force bool) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read secret value from the source file
|
||||
value, err := afero.ReadFile(cli.fs, sourceFile)
|
||||
// Read secret value from the source file into protected buffers
|
||||
file, err := cli.fs.Open(sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
||||
return fmt.Errorf("failed to open file %s: %w", sourceFile, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
secret.Debug("Failed to close file", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
const initialSize = 4 * 1024 // 4KB initial buffer
|
||||
const maxSize = 100 * 1024 * 1024 // 100MB max
|
||||
|
||||
type bufferInfo struct {
|
||||
buffer *memguard.LockedBuffer
|
||||
used int
|
||||
}
|
||||
|
||||
var buffers []bufferInfo
|
||||
defer func() {
|
||||
for _, b := range buffers {
|
||||
b.buffer.Destroy()
|
||||
}
|
||||
}()
|
||||
|
||||
totalSize := 0
|
||||
currentBufferSize := initialSize
|
||||
sameSize := 0
|
||||
|
||||
for {
|
||||
// Create a new buffer
|
||||
buffer := memguard.NewBuffer(currentBufferSize)
|
||||
n, err := io.ReadFull(file, buffer.Bytes())
|
||||
|
||||
if n == 0 {
|
||||
// No data read, destroy the unused buffer
|
||||
buffer.Destroy()
|
||||
} else {
|
||||
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
||||
totalSize += n
|
||||
|
||||
if totalSize > maxSize {
|
||||
return fmt.Errorf("secret file too large: exceeds 100MB limit")
|
||||
}
|
||||
|
||||
// If we filled the buffer, consider growing for next iteration
|
||||
if n == currentBufferSize {
|
||||
currentBufferSize = updateBufferSize(currentBufferSize, &sameSize)
|
||||
}
|
||||
}
|
||||
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Combine all buffers into a single protected buffer
|
||||
valueBuffer := memguard.NewBuffer(totalSize)
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
offset := 0
|
||||
for _, b := range buffers {
|
||||
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||
offset += b.used
|
||||
}
|
||||
|
||||
// Store the secret in the vault
|
||||
if err := vlt.AddSecret(secretName, value, force); err != nil {
|
||||
if err := vlt.AddSecret(secretName, valueBuffer, force); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
425
internal/cli/secrets_size_test.go
Normal file
425
internal/cli/secrets_size_test.go
Normal file
@@ -0,0 +1,425 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
||||
func TestAddSecretVariousSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
size int
|
||||
shouldError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "1KB secret",
|
||||
size: 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "10KB secret",
|
||||
size: 10 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "100KB secret",
|
||||
size: 100 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "1MB secret",
|
||||
size: 1024 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "10MB secret",
|
||||
size: 10 * 1024 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "99MB secret",
|
||||
size: 99 * 1024 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "100MB secret minus 1 byte",
|
||||
size: 100*1024*1024 - 1,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "101MB secret - should fail",
|
||||
size: 101 * 1024 * 1024,
|
||||
shouldError: true,
|
||||
errorMsg: "secret too large: exceeds 100MB limit",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up test environment
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set test mnemonic
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vaultName := "test-vault"
|
||||
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get vault and set up long-term key
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
||||
require.NoError(t, err)
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Generate test data of specified size
|
||||
testData := make([]byte, tt.size)
|
||||
_, err = rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add newline that will be stripped
|
||||
testDataWithNewline := append(testData, '\n')
|
||||
|
||||
// Create fake stdin
|
||||
stdin := bytes.NewReader(testDataWithNewline)
|
||||
|
||||
// Create command with fake stdin
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(stdin)
|
||||
|
||||
// Create CLI instance
|
||||
cli := NewCLIInstance()
|
||||
cli.fs = fs
|
||||
cli.stateDir = stateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
secretName := fmt.Sprintf("test-secret-%d", tt.size)
|
||||
err = cli.AddSecret(secretName, false)
|
||||
|
||||
if tt.shouldError {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original (without newline)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
||||
func TestImportSecretVariousSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
size int
|
||||
shouldError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "1KB file",
|
||||
size: 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "10KB file",
|
||||
size: 10 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "100KB file",
|
||||
size: 100 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "1MB file",
|
||||
size: 1024 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "10MB file",
|
||||
size: 10 * 1024 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "99MB file",
|
||||
size: 99 * 1024 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "100MB file",
|
||||
size: 100 * 1024 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "101MB file - should fail",
|
||||
size: 101 * 1024 * 1024,
|
||||
shouldError: true,
|
||||
errorMsg: "secret file too large: exceeds 100MB limit",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up test environment
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set test mnemonic
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vaultName := "test-vault"
|
||||
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get vault and set up long-term key
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
||||
require.NoError(t, err)
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Generate test data of specified size
|
||||
testData := make([]byte, tt.size)
|
||||
_, err = rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write test data to file
|
||||
testFile := fmt.Sprintf("/test/secret-%d.bin", tt.size)
|
||||
err = afero.WriteFile(fs, testFile, testData, 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create command
|
||||
cmd := &cobra.Command{}
|
||||
|
||||
// Create CLI instance
|
||||
cli := NewCLIInstance()
|
||||
cli.fs = fs
|
||||
cli.stateDir = stateDir
|
||||
|
||||
// Test importing the secret
|
||||
secretName := fmt.Sprintf("imported-secret-%d", tt.size)
|
||||
err = cli.ImportSecret(cmd, secretName, testFile, false)
|
||||
|
||||
if tt.shouldError {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
||||
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||
// Test various sizes that should trigger buffer growth
|
||||
sizes := []int{
|
||||
1, // Single byte
|
||||
100, // Small
|
||||
4095, // Just under initial 4KB
|
||||
4096, // Exactly 4KB
|
||||
4097, // Just over 4KB
|
||||
8191, // Just under 8KB (first double)
|
||||
8192, // Exactly 8KB
|
||||
8193, // Just over 8KB
|
||||
12288, // 12KB (should trigger second double)
|
||||
16384, // 16KB
|
||||
32768, // 32KB (after more doublings)
|
||||
65536, // 64KB
|
||||
131072, // 128KB
|
||||
524288, // 512KB
|
||||
1048576, // 1MB
|
||||
2097152, // 2MB
|
||||
}
|
||||
|
||||
for _, size := range sizes {
|
||||
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
|
||||
// Set up test environment
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set test mnemonic
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vaultName := "test-vault"
|
||||
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get vault and set up long-term key
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
||||
require.NoError(t, err)
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Create test data of exactly the specified size
|
||||
// Use a pattern that's easy to verify
|
||||
testData := make([]byte, size)
|
||||
for i := range testData {
|
||||
testData[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
// Create fake stdin without newline
|
||||
stdin := bytes.NewReader(testData)
|
||||
|
||||
// Create command with fake stdin
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(stdin)
|
||||
|
||||
// Create CLI instance
|
||||
cli := NewCLIInstance()
|
||||
cli.fs = fs
|
||||
cli.stateDir = stateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
secretName := fmt.Sprintf("buffer-test-%d", size)
|
||||
err = cli.AddSecret(secretName, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original exactly")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
||||
func TestAddSecretStreamingBehavior(t *testing.T) {
|
||||
// Set up test environment
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set test mnemonic
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vaultName := "test-vault"
|
||||
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get vault and set up long-term key
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
||||
require.NoError(t, err)
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Create a custom reader that simulates slow streaming input
|
||||
// This will help verify our buffer handling works correctly with partial reads
|
||||
testData := []byte(strings.Repeat("Hello, World! ", 1000)) // ~14KB
|
||||
slowReader := &slowReader{
|
||||
data: testData,
|
||||
chunkSize: 1000, // Read 1KB at a time
|
||||
}
|
||||
|
||||
// Create command with slow reader as stdin
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(slowReader)
|
||||
|
||||
// Create CLI instance
|
||||
cli := NewCLIInstance()
|
||||
cli.fs = fs
|
||||
cli.stateDir = stateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
err = cli.AddSecret("streaming-test", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret("streaming-test")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
||||
}
|
||||
|
||||
// slowReader simulates a reader that returns data in small chunks
|
||||
type slowReader struct {
|
||||
data []byte
|
||||
offset int
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
func (r *slowReader) Read(p []byte) (n int, err error) {
|
||||
if r.offset >= len(r.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
// Read at most chunkSize bytes
|
||||
remaining := len(r.data) - r.offset
|
||||
toRead := r.chunkSize
|
||||
if toRead > remaining {
|
||||
toRead = remaining
|
||||
}
|
||||
if toRead > len(p) {
|
||||
toRead = len(p)
|
||||
}
|
||||
|
||||
n = copy(p, r.data[r.offset:r.offset+toRead])
|
||||
r.offset += n
|
||||
|
||||
if r.offset >= len(r.data) {
|
||||
err = io.EOF
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func ExecuteCommandInProcess(args []string, stdin string, env map[string]string)
|
||||
|
||||
// Set test environment
|
||||
for k, v := range env {
|
||||
os.Setenv(k, v)
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
|
||||
// Create root command
|
||||
@@ -45,12 +45,17 @@ func ExecuteCommandInProcess(args []string, stdin string, env map[string]string)
|
||||
output := buf.String()
|
||||
secret.Debug("Command execution completed", "error", err, "outputLength", len(output), "output", output)
|
||||
|
||||
// Add debug info for troubleshooting
|
||||
if len(output) == 0 && err == nil {
|
||||
secret.Debug("Warning: Command executed successfully but produced no output", "args", args)
|
||||
}
|
||||
|
||||
// Restore environment
|
||||
for k, v := range savedEnv {
|
||||
if v == "" {
|
||||
os.Unsetenv(k)
|
||||
_ = os.Unsetenv(k)
|
||||
} else {
|
||||
os.Setenv(k, v)
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -36,15 +37,18 @@ func newUnlockersListCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List unlockers in the current vault",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
cli := NewCLIInstance()
|
||||
cli.cmd = cmd
|
||||
|
||||
return cli.UnlockersList(jsonOutput)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -56,11 +60,13 @@ func newUnlockersAddCmd() *cobra.Command {
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.UnlockersAdd(args[0], cmd)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -69,8 +75,9 @@ func newUnlockersRmCmd() *cobra.Command {
|
||||
Use: "rm <unlocker-id>",
|
||||
Short: "Remove an unlocker",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.UnlockersRemove(args[0])
|
||||
},
|
||||
}
|
||||
@@ -93,15 +100,16 @@ func newUnlockerSelectSubCmd() *cobra.Command {
|
||||
Use: "select <unlocker-id>",
|
||||
Short: "Select an unlocker as current",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.UnlockerSelect(args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// UnlockersList lists unlockers in the current vault
|
||||
func (cli *CLIInstance) UnlockersList(jsonOutput bool) error {
|
||||
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -118,7 +126,7 @@ func (cli *CLIInstance) UnlockersList(jsonOutput bool) error {
|
||||
type UnlockerInfo struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
}
|
||||
|
||||
@@ -149,12 +157,12 @@ func (cli *CLIInstance) UnlockersList(jsonOutput bool) error {
|
||||
// Check if this is the right unlocker by comparing metadata
|
||||
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
||||
if err != nil {
|
||||
continue //FIXME this error needs to be handled
|
||||
continue // FIXME this error needs to be handled
|
||||
}
|
||||
|
||||
var diskMetadata secret.UnlockerMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||
continue //FIXME this error needs to be handled
|
||||
continue // FIXME this error needs to be handled
|
||||
}
|
||||
|
||||
// Match by type and creation time
|
||||
@@ -168,6 +176,7 @@ func (cli *CLIInstance) UnlockersList(jsonOutput bool) error {
|
||||
case "pgp":
|
||||
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -201,38 +210,39 @@ func (cli *CLIInstance) UnlockersList(jsonOutput bool) error {
|
||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(string(jsonBytes))
|
||||
cli.cmd.Println(string(jsonBytes))
|
||||
} else {
|
||||
// Pretty table output
|
||||
if len(unlockers) == 0 {
|
||||
fmt.Println("No unlockers found in current vault.")
|
||||
fmt.Println("Run 'secret unlockers add passphrase' to create one.")
|
||||
cli.cmd.Println("No unlockers found in current vault.")
|
||||
cli.cmd.Println("Run 'secret unlockers add passphrase' to create one.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
||||
fmt.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
|
||||
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
||||
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
|
||||
|
||||
for _, unlocker := range unlockers {
|
||||
flags := ""
|
||||
if len(unlocker.Flags) > 0 {
|
||||
flags = strings.Join(unlocker.Flags, ",")
|
||||
}
|
||||
fmt.Printf("%-18s %-12s %-20s %s\n",
|
||||
cli.cmd.Printf("%-18s %-12s %-20s %s\n",
|
||||
unlocker.ID,
|
||||
unlocker.Type,
|
||||
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
flags)
|
||||
}
|
||||
|
||||
fmt.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
|
||||
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnlockersAdd adds a new unlocker
|
||||
func (cli *CLIInstance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
||||
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
||||
switch unlockerType {
|
||||
case "passphrase":
|
||||
// Get current vault
|
||||
@@ -245,23 +255,25 @@ func (cli *CLIInstance) UnlockersAdd(unlockerType string, cmd *cobra.Command) er
|
||||
// The CreatePassphraseUnlocker method will handle getting the long-term key
|
||||
|
||||
// Check if passphrase is set in environment variable
|
||||
var passphraseStr string
|
||||
var passphraseBuffer *memguard.LockedBuffer
|
||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||
passphraseStr = envPassphrase
|
||||
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||
} else {
|
||||
// Use secure passphrase input with confirmation
|
||||
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
}
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
|
||||
case "keychain":
|
||||
@@ -274,6 +286,7 @@ func (cli *CLIInstance) UnlockersAdd(unlockerType string, cmd *cobra.Command) er
|
||||
if keyName, err := keychainUnlocker.GetKeychainItemName(); err == nil {
|
||||
cmd.Printf("Keychain Item Name: %s\n", keyName)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case "pgp":
|
||||
@@ -294,6 +307,7 @@ func (cli *CLIInstance) UnlockersAdd(unlockerType string, cmd *cobra.Command) er
|
||||
|
||||
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
|
||||
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
|
||||
|
||||
return nil
|
||||
|
||||
default:
|
||||
@@ -302,7 +316,7 @@ func (cli *CLIInstance) UnlockersAdd(unlockerType string, cmd *cobra.Command) er
|
||||
}
|
||||
|
||||
// UnlockersRemove removes an unlocker
|
||||
func (cli *CLIInstance) UnlockersRemove(unlockerID string) error {
|
||||
func (cli *Instance) UnlockersRemove(unlockerID string) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -313,7 +327,7 @@ func (cli *CLIInstance) UnlockersRemove(unlockerID string) error {
|
||||
}
|
||||
|
||||
// UnlockerSelect selects an unlocker as current
|
||||
func (cli *CLIInstance) UnlockerSelect(unlockerID string) error {
|
||||
func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
@@ -34,15 +35,17 @@ func newVaultListCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List available vaults",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.ListVaults(cmd, jsonOutput)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -53,6 +56,7 @@ func newVaultCreateCmd() *cobra.Command {
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.CreateVault(cmd, args[0])
|
||||
},
|
||||
}
|
||||
@@ -65,6 +69,7 @@ func newVaultSelectCmd() *cobra.Command {
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.SelectVault(cmd, args[0])
|
||||
},
|
||||
}
|
||||
@@ -83,19 +88,20 @@ func newVaultImportCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.VaultImport(cmd, vaultName)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ListVaults lists all available vaults
|
||||
func (cli *CLIInstance) 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
||||
// Get current vault name for context
|
||||
currentVault := ""
|
||||
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
||||
@@ -103,8 +109,8 @@ func (cli *CLIInstance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"vaults": vaults,
|
||||
"current_vault": currentVault,
|
||||
"vaults": vaults,
|
||||
"currentVault": currentVault,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(result, "", " ")
|
||||
@@ -138,7 +144,7 @@ func (cli *CLIInstance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
}
|
||||
|
||||
// CreateVault creates a new vault
|
||||
func (cli *CLIInstance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
||||
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
|
||||
@@ -147,21 +153,23 @@ func (cli *CLIInstance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
}
|
||||
|
||||
cmd.Printf("Created vault '%s'\n", vlt.GetName())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectVault selects a vault as the current one
|
||||
func (cli *CLIInstance) SelectVault(cmd *cobra.Command, name string) error {
|
||||
func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
||||
if err := vault.SelectVault(cli.fs, cli.stateDir, name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Selected vault '%s' as current\n", name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VaultImport imports a mnemonic into a specific vault
|
||||
func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||
secret.Debug("Importing mnemonic into vault", "vault_name", vaultName, "state_dir", cli.stateDir)
|
||||
|
||||
// Get the specific vault by name
|
||||
@@ -204,6 +212,7 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get next derivation index", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
secret.Debug("Using derivation index", "index", derivationIndex)
|
||||
@@ -219,23 +228,27 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
|
||||
ltPublicKey := ltIdentity.Recipient().String()
|
||||
secret.Debug("Storing long-term public key", "pubkey", ltPublicKey, "vault_dir", vaultDir)
|
||||
|
||||
if err := afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), 0600); err != nil {
|
||||
if err := afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to store long-term public key: %w", err)
|
||||
}
|
||||
|
||||
// Calculate public key hash from index 0 (same for all vaults with this mnemonic)
|
||||
// Calculate public key hash from the actual derivation index being used
|
||||
// This is used to verify that the derived key matches what was stored
|
||||
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||
|
||||
// Calculate family hash from index 0 (same for all vaults with this mnemonic)
|
||||
// This is used to identify which vaults belong to the same mnemonic family
|
||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
publicKeyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
// Load existing metadata
|
||||
existingMetadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
// If metadata doesn't exist, create new
|
||||
existingMetadata = &vault.VaultMetadata{
|
||||
existingMetadata = &vault.Metadata{
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
@@ -243,9 +256,11 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
|
||||
// Update metadata with new derivation info
|
||||
existingMetadata.DerivationIndex = derivationIndex
|
||||
existingMetadata.PublicKeyHash = publicKeyHash
|
||||
existingMetadata.MnemonicFamilyHash = familyHash
|
||||
|
||||
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
|
||||
secret.Debug("Failed to save vault metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||
}
|
||||
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
||||
@@ -258,14 +273,19 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
|
||||
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
|
||||
// Create secure buffer for passphrase
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
// Unlock the vault with the derived long-term key
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Create passphrase-protected unlocker
|
||||
secret.Debug("Creating passphrase-protected unlocker")
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create unlocker", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
@@ -13,14 +12,19 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
tabWriterPadding = 2
|
||||
)
|
||||
|
||||
// newVersionCmd returns the version management command
|
||||
func newVersionCmd() *cobra.Command {
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return VersionCommands(cli)
|
||||
}
|
||||
|
||||
// VersionCommands returns the version management commands
|
||||
func VersionCommands(cli *CLIInstance) *cobra.Command {
|
||||
func VersionCommands(cli *Instance) *cobra.Command {
|
||||
versionCmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Manage secret versions",
|
||||
@@ -42,30 +46,33 @@ func VersionCommands(cli *CLIInstance) *cobra.Command {
|
||||
Use: "promote <secret-name> <version>",
|
||||
Short: "Promote a specific version to current",
|
||||
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
|
||||
Args: cobra.ExactArgs(2),
|
||||
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.PromoteVersion(cmd, args[0], args[1])
|
||||
},
|
||||
}
|
||||
|
||||
versionCmd.AddCommand(listCmd, promoteCmd)
|
||||
|
||||
return versionCmd
|
||||
}
|
||||
|
||||
// ListVersions lists all versions of a secret
|
||||
func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
secret.Debug("ListVersions called", "secret_name", secretName)
|
||||
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current vault", "error", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory", "error", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -77,10 +84,12 @@ func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) erro
|
||||
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if secret exists", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
secret.Debug("Secret not found", "secret_name", secretName)
|
||||
|
||||
return fmt.Errorf("secret '%s' not found", secretName)
|
||||
}
|
||||
|
||||
@@ -88,11 +97,13 @@ func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) erro
|
||||
versions, err := secret.ListVersions(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to list versions", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to list versions: %w", err)
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
cmd.Println("No versions found")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,12 +121,12 @@ func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) erro
|
||||
}
|
||||
|
||||
// Create table writer
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
|
||||
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, tabWriterPadding, ' ', 0)
|
||||
_, _ = fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
|
||||
|
||||
// Load and display each version's metadata
|
||||
for _, version := range versions {
|
||||
sv := secret.NewSecretVersion(vlt, secretName, version)
|
||||
sv := secret.NewVersion(vlt, secretName, version)
|
||||
|
||||
// Load metadata
|
||||
if err := sv.LoadMetadata(ltIdentity); err != nil {
|
||||
@@ -125,7 +136,8 @@ func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) erro
|
||||
if version == currentVersion {
|
||||
status = "current (error)"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -151,15 +163,16 @@ func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) erro
|
||||
notAfter = sv.Metadata.NotAfter.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, createdAt, status, notBefore, notAfter)
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, createdAt, status, notBefore, notAfter)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
_ = w.Flush()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PromoteVersion promotes a specific version to current
|
||||
func (cli *CLIInstance) PromoteVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||
func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -185,18 +198,12 @@ func (cli *CLIInstance) PromoteVersion(cmd *cobra.Command, secretName string, ve
|
||||
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
|
||||
}
|
||||
|
||||
// Update the current symlink
|
||||
currentLink := filepath.Join(secretDir, "current")
|
||||
|
||||
// Remove existing symlink
|
||||
_ = cli.fs.Remove(currentLink)
|
||||
|
||||
// Create new symlink to the selected version
|
||||
relativePath := filepath.Join("versions", version)
|
||||
if err := afero.WriteFile(cli.fs, currentLink, []byte(relativePath), 0644); err != nil {
|
||||
// Update the current symlink using the proper function
|
||||
if err := secret.SetCurrentVersion(cli.fs, secretDir, version); err != nil {
|
||||
return fmt.Errorf("failed to update current version: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Promoted version %s to current for secret '%s'\n", version, secretName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,24 +18,35 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"path/filepath"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helper function to add a secret to vault with proper buffer protection
|
||||
func addTestSecret(t *testing.T, vlt *vault.Vault, name string, value []byte, force bool) {
|
||||
t.Helper()
|
||||
buffer := memguard.NewBufferFromBytes(value)
|
||||
defer buffer.Destroy()
|
||||
err := vlt.AddSecret(name, buffer, force)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Helper function to set up a vault with long-term key
|
||||
func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
|
||||
// Set mnemonic for testing
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
|
||||
// Create vault
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "default")
|
||||
@@ -49,7 +60,7 @@ func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
|
||||
// Store long-term public key in vault
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select vault
|
||||
@@ -69,13 +80,11 @@ func TestListVersionsCommand(t *testing.T) {
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
@@ -127,7 +136,7 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
|
||||
|
||||
// Try to list versions of non-existent secret
|
||||
err := cli.ListVersions(cmd, "nonexistent/secret")
|
||||
assert.Error(t, err)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
@@ -143,13 +152,11 @@ func TestPromoteVersionCommand(t *testing.T) {
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||
|
||||
// Get versions
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
@@ -200,8 +207,7 @@ func TestPromoteNonExistentVersion(t *testing.T) {
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("value"), false)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("value"), false)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
@@ -211,7 +217,7 @@ func TestPromoteNonExistentVersion(t *testing.T) {
|
||||
|
||||
// Try to promote non-existent version
|
||||
err = cli.PromoteVersion(cmd, "test/secret", "20991231.999")
|
||||
assert.Error(t, err)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
@@ -227,13 +233,11 @@ func TestGetSecretWithVersion(t *testing.T) {
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||
|
||||
// Get versions
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
@@ -289,7 +293,7 @@ func TestListVersionsEmptyOutput(t *testing.T) {
|
||||
// Create a secret directory without versions (edge case)
|
||||
vaultDir := stateDir + "/vaults.d/default"
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
err := fs.MkdirAll(secretDir, 0755)
|
||||
err := fs.MkdirAll(secretDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a command for output capture
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package secret provides core types and constants for the secret application.
|
||||
package secret
|
||||
|
||||
import "os"
|
||||
@@ -6,18 +7,21 @@ const (
|
||||
// AppID is the unique identifier for this application
|
||||
AppID = "berlin.sneak.pkg.secret"
|
||||
|
||||
// Environment variable names
|
||||
EnvStateDir = "SB_SECRET_STATE_DIR"
|
||||
EnvMnemonic = "SB_SECRET_MNEMONIC"
|
||||
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
|
||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||
// EnvStateDir is the environment variable for specifying the state directory
|
||||
EnvStateDir = "SB_SECRET_STATE_DIR"
|
||||
// EnvMnemonic is the environment variable for providing the mnemonic phrase
|
||||
EnvMnemonic = "SB_SECRET_MNEMONIC"
|
||||
// EnvUnlockPassphrase is the environment variable for providing the unlock passphrase
|
||||
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE" //nolint:gosec // G101: This is an env var name, not a credential
|
||||
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||
)
|
||||
|
||||
// File system permission constants
|
||||
const (
|
||||
// DirPerms is the permission used for directories (read-write-execute for owner only)
|
||||
DirPerms os.FileMode = 0700
|
||||
DirPerms os.FileMode = 0o700
|
||||
|
||||
// FilePerms is the permission used for sensitive files (read-write for owner only)
|
||||
FilePerms os.FileMode = 0600
|
||||
FilePerms os.FileMode = 0o600
|
||||
)
|
||||
|
||||
@@ -8,25 +8,33 @@ import (
|
||||
"syscall"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// EncryptToRecipient encrypts data to a recipient using age
|
||||
func EncryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
|
||||
Debug("EncryptToRecipient starting", "data_length", len(data))
|
||||
// The data parameter should be a LockedBuffer for secure memory handling
|
||||
func EncryptToRecipient(data *memguard.LockedBuffer, recipient age.Recipient) ([]byte, error) {
|
||||
if data == nil {
|
||||
return nil, fmt.Errorf("data buffer is nil")
|
||||
}
|
||||
|
||||
Debug("EncryptToRecipient starting", "data_length", data.Size())
|
||||
|
||||
var buf bytes.Buffer
|
||||
Debug("Creating age encryptor")
|
||||
w, err := age.Encrypt(&buf, recipient)
|
||||
if err != nil {
|
||||
Debug("Failed to create encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to create encryptor: %w", err)
|
||||
}
|
||||
Debug("Created age encryptor successfully")
|
||||
|
||||
Debug("Writing data to encryptor")
|
||||
if _, err := w.Write(data); err != nil {
|
||||
if _, err := w.Write(data.Bytes()); err != nil {
|
||||
Debug("Failed to write data to encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
Debug("Wrote data to encryptor successfully")
|
||||
@@ -34,12 +42,14 @@ func EncryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
|
||||
Debug("Closing encryptor")
|
||||
if err := w.Close(); err != nil {
|
||||
Debug("Failed to close encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to close encryptor: %w", err)
|
||||
}
|
||||
Debug("Closed encryptor successfully")
|
||||
|
||||
result := buf.Bytes()
|
||||
Debug("EncryptToRecipient completed successfully", "result_length", len(result))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -59,18 +69,36 @@ func DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
|
||||
}
|
||||
|
||||
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
|
||||
func EncryptWithPassphrase(data []byte, passphrase string) ([]byte, error) {
|
||||
recipient, err := age.NewScryptRecipient(passphrase)
|
||||
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
||||
func EncryptWithPassphrase(data []byte, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
||||
if passphrase == nil {
|
||||
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||
}
|
||||
|
||||
// Get the passphrase string temporarily
|
||||
passphraseStr := passphrase.String()
|
||||
recipient, err := age.NewScryptRecipient(passphraseStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
||||
}
|
||||
|
||||
return EncryptToRecipient(data, recipient)
|
||||
// Create a secure buffer for the data
|
||||
dataBuffer := memguard.NewBufferFromBytes(data)
|
||||
defer dataBuffer.Destroy()
|
||||
|
||||
return EncryptToRecipient(dataBuffer, recipient)
|
||||
}
|
||||
|
||||
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
|
||||
func DecryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, error) {
|
||||
identity, err := age.NewScryptIdentity(passphrase)
|
||||
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
||||
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
||||
if passphrase == nil {
|
||||
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||
}
|
||||
|
||||
// Get the passphrase string temporarily
|
||||
passphraseStr := passphrase.String()
|
||||
identity, err := age.NewScryptIdentity(passphraseStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
|
||||
}
|
||||
@@ -80,29 +108,42 @@ func DecryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, err
|
||||
|
||||
// ReadPassphrase reads a passphrase securely from the terminal without echoing
|
||||
// This version is for unlocking and doesn't require confirmation
|
||||
func ReadPassphrase(prompt string) (string, error) {
|
||||
// Returns a LockedBuffer containing the passphrase for secure memory handling
|
||||
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||
// Check if stdin is a terminal
|
||||
if !term.IsTerminal(int(syscall.Stdin)) {
|
||||
if !term.IsTerminal(syscall.Stdin) {
|
||||
// Not a terminal - never read passphrases from piped input for security reasons
|
||||
return "", fmt.Errorf("cannot read passphrase from non-terminal stdin (piped input or script). Please set the SB_UNLOCK_PASSPHRASE environment variable or run interactively")
|
||||
return nil, fmt.Errorf("cannot read passphrase from non-terminal stdin " +
|
||||
"(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
|
||||
"environment variable or run interactively")
|
||||
}
|
||||
|
||||
// stdin is a terminal, check if stderr is also a terminal for interactive prompting
|
||||
if !term.IsTerminal(int(syscall.Stderr)) {
|
||||
return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal (running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE environment variable")
|
||||
if !term.IsTerminal(syscall.Stderr) {
|
||||
return nil, fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal " +
|
||||
"(running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE " +
|
||||
"environment variable")
|
||||
}
|
||||
|
||||
// Both stdin and stderr are terminals - use secure password reading
|
||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
||||
passphrase, err := term.ReadPassword(int(syscall.Stdin))
|
||||
passphrase, err := term.ReadPassword(syscall.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read passphrase: %w", err)
|
||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
|
||||
|
||||
if len(passphrase) == 0 {
|
||||
return "", fmt.Errorf("passphrase cannot be empty")
|
||||
return nil, fmt.Errorf("passphrase cannot be empty")
|
||||
}
|
||||
|
||||
return string(passphrase), nil
|
||||
// Create a secure buffer and copy the passphrase
|
||||
secureBuffer := memguard.NewBufferFromBytes(passphrase)
|
||||
|
||||
// Clear the original passphrase slice
|
||||
for i := range passphrase {
|
||||
passphrase[i] = 0
|
||||
}
|
||||
|
||||
return secureBuffer, nil
|
||||
}
|
||||
|
||||
@@ -13,22 +13,23 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
debugEnabled bool
|
||||
debugLogger *slog.Logger
|
||||
debugEnabled bool //nolint:gochecknoglobals // Package-wide debug state is necessary
|
||||
debugLogger *slog.Logger //nolint:gochecknoglobals // Package-wide logger instance is necessary
|
||||
)
|
||||
|
||||
func init() {
|
||||
initDebugLogging()
|
||||
InitDebugLogging()
|
||||
}
|
||||
|
||||
// initDebugLogging initializes the debug logging system based on GODEBUG environment variable
|
||||
func initDebugLogging() {
|
||||
// InitDebugLogging initializes the debug logging system based on current GODEBUG environment variable
|
||||
func InitDebugLogging() {
|
||||
godebug := os.Getenv("GODEBUG")
|
||||
debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")
|
||||
|
||||
if !debugEnabled {
|
||||
// Create a no-op logger that discards all output
|
||||
debugLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -36,7 +37,7 @@ func initDebugLogging() {
|
||||
_, _, _ = syscall.Syscall(syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
|
||||
|
||||
// Check if STDERR is a TTY
|
||||
isTTY := term.IsTerminal(int(syscall.Stderr))
|
||||
isTTY := term.IsTerminal(syscall.Stderr)
|
||||
|
||||
var handler slog.Handler
|
||||
if isTTY {
|
||||
@@ -113,6 +114,7 @@ func (h *colorizedHandler) Handle(_ context.Context, record slog.Record) error {
|
||||
}
|
||||
first = false
|
||||
output += fmt.Sprintf("%s=%#v", attr.Key, attr.Value.Any())
|
||||
|
||||
return true
|
||||
})
|
||||
output += "}\033[0m"
|
||||
@@ -120,16 +122,17 @@ func (h *colorizedHandler) Handle(_ context.Context, record slog.Record) error {
|
||||
|
||||
output += "\n"
|
||||
_, err := h.output.Write([]byte(output))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *colorizedHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
func (h *colorizedHandler) WithAttrs(_ []slog.Attr) slog.Handler {
|
||||
// For simplicity, return the same handler
|
||||
// In a more complex implementation, we'd create a new handler with the attrs
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *colorizedHandler) WithGroup(name string) slog.Handler {
|
||||
func (h *colorizedHandler) WithGroup(_ string) slog.Handler {
|
||||
// For simplicity, return the same handler
|
||||
// In a more complex implementation, we'd create a new handler with the group
|
||||
return h
|
||||
|
||||
@@ -3,7 +3,6 @@ package secret
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
@@ -12,17 +11,8 @@ import (
|
||||
)
|
||||
|
||||
func TestDebugLogging(t *testing.T) {
|
||||
// Save original GODEBUG and restore it
|
||||
originalGodebug := os.Getenv("GODEBUG")
|
||||
defer func() {
|
||||
if originalGodebug == "" {
|
||||
os.Unsetenv("GODEBUG")
|
||||
} else {
|
||||
os.Setenv("GODEBUG", originalGodebug)
|
||||
}
|
||||
// Re-initialize debug system with original setting
|
||||
initDebugLogging()
|
||||
}()
|
||||
// Test cleanup handled by t.Setenv
|
||||
defer InitDebugLogging() // Re-initialize debug system after test
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -54,14 +44,12 @@ func TestDebugLogging(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set GODEBUG
|
||||
if tt.godebug == "" {
|
||||
os.Unsetenv("GODEBUG")
|
||||
} else {
|
||||
os.Setenv("GODEBUG", tt.godebug)
|
||||
if tt.godebug != "" {
|
||||
t.Setenv("GODEBUG", tt.godebug)
|
||||
}
|
||||
|
||||
// Re-initialize debug system
|
||||
initDebugLogging()
|
||||
InitDebugLogging()
|
||||
|
||||
// Test if debug is enabled
|
||||
enabled := IsDebugEnabled()
|
||||
@@ -76,7 +64,7 @@ func TestDebugLogging(t *testing.T) {
|
||||
|
||||
// Override the debug logger for testing
|
||||
oldLogger := debugLogger
|
||||
if term.IsTerminal(int(syscall.Stderr)) {
|
||||
if term.IsTerminal(syscall.Stderr) {
|
||||
// TTY: use colorized handler with our buffer
|
||||
debugLogger = slog.New(newColorizedHandler(&buf))
|
||||
} else {
|
||||
@@ -104,34 +92,26 @@ func TestDebugLogging(t *testing.T) {
|
||||
|
||||
func TestDebugFunctions(t *testing.T) {
|
||||
// Enable debug for testing
|
||||
originalGodebug := os.Getenv("GODEBUG")
|
||||
os.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
||||
defer func() {
|
||||
if originalGodebug == "" {
|
||||
os.Unsetenv("GODEBUG")
|
||||
} else {
|
||||
os.Setenv("GODEBUG", originalGodebug)
|
||||
}
|
||||
initDebugLogging()
|
||||
}()
|
||||
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
||||
defer InitDebugLogging() // Re-initialize after test
|
||||
|
||||
initDebugLogging()
|
||||
InitDebugLogging()
|
||||
|
||||
if !IsDebugEnabled() {
|
||||
t.Log("Debug not enabled, but continuing with debug function tests anyway")
|
||||
}
|
||||
|
||||
// Test that debug functions don't panic and can be called
|
||||
t.Run("Debug", func(t *testing.T) {
|
||||
t.Run("Debug", func(_ *testing.T) {
|
||||
Debug("test debug message")
|
||||
Debug("test with args", "key", "value", "number", 42)
|
||||
})
|
||||
|
||||
t.Run("DebugF", func(t *testing.T) {
|
||||
t.Run("DebugF", func(_ *testing.T) {
|
||||
DebugF("formatted message: %s %d", "test", 123)
|
||||
})
|
||||
|
||||
t.Run("DebugWith", func(t *testing.T) {
|
||||
t.Run("DebugWith", func(_ *testing.T) {
|
||||
DebugWith("structured message",
|
||||
slog.String("string_key", "string_value"),
|
||||
slog.Int("int_key", 42),
|
||||
|
||||
@@ -17,7 +17,7 @@ func generateRandomString(length int, charset string) (string, error) {
|
||||
result := make([]byte, length)
|
||||
charsetLen := big.NewInt(int64(len(charset)))
|
||||
|
||||
for i := 0; i < length; i++ {
|
||||
for i := range length {
|
||||
randomIndex, err := rand.Int(rand.Reader, charsetLen)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random number: %w", err)
|
||||
@@ -48,7 +48,9 @@ func DetermineStateDir(customConfigDir string) string {
|
||||
if err != nil {
|
||||
// Fallback to a reasonable default if we can't determine user config dir
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
|
||||
return filepath.Join(homeDir, ".config", AppID)
|
||||
}
|
||||
|
||||
return filepath.Join(configDir, AppID)
|
||||
}
|
||||
|
||||
@@ -8,18 +8,28 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const (
|
||||
agePrivKeyPassphraseLength = 64
|
||||
)
|
||||
|
||||
// keychainItemNameRegex validates keychain item names
|
||||
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
||||
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
|
||||
type KeychainUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
// Keychain item name
|
||||
KeychainItemName string `json:"keychain_item_name"`
|
||||
KeychainItemName string `json:"keychainItemName"`
|
||||
}
|
||||
|
||||
// KeychainUnlocker represents a macOS Keychain-protected unlocker
|
||||
@@ -31,9 +41,9 @@ type KeychainUnlocker struct {
|
||||
|
||||
// KeychainData represents the data stored in the macOS keychain
|
||||
type KeychainData struct {
|
||||
AgePublicKey string `json:"age_public_key"`
|
||||
AgePrivKeyPassphrase string `json:"age_priv_key_passphrase"`
|
||||
EncryptedLongtermKey string `json:"encrypted_longterm_key"`
|
||||
AgePublicKey string `json:"agePublicKey"`
|
||||
AgePrivKeyPassphrase string `json:"agePrivKeyPassphrase"`
|
||||
EncryptedLongtermKey string `json:"encryptedLongtermKey"`
|
||||
}
|
||||
|
||||
// GetIdentity implements Unlocker interface for Keychain-based unlockers
|
||||
@@ -47,6 +57,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
keychainItemName, err := k.GetKeychainItemName()
|
||||
if err != nil {
|
||||
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
|
||||
}
|
||||
|
||||
@@ -55,6 +66,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
|
||||
if err != nil {
|
||||
Debug("Failed to retrieve data from keychain", "error", err, "keychain_item", keychainItemName)
|
||||
|
||||
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
|
||||
}
|
||||
|
||||
@@ -67,6 +79,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
var keychainData KeychainData
|
||||
if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil {
|
||||
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
||||
}
|
||||
|
||||
@@ -79,6 +92,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted age private key", "error", err, "path", agePrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -89,9 +103,14 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
// Step 5: Decrypt the age private key using the passphrase from keychain
|
||||
Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID())
|
||||
agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
|
||||
// Create secure buffer for the keychain passphrase
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(keychainData.AgePrivKeyPassphrase))
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, passphraseBuffer)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
|
||||
}
|
||||
|
||||
@@ -102,9 +121,20 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
// Step 6: Parse the decrypted age private key
|
||||
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
|
||||
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
|
||||
|
||||
// Create a secure buffer for the private key data
|
||||
agePrivKeyBuffer := memguard.NewBufferFromBytes(agePrivKeyData)
|
||||
defer agePrivKeyBuffer.Destroy()
|
||||
|
||||
// Clear the original private key data
|
||||
for i := range agePrivKeyData {
|
||||
agePrivKeyData[i] = 0
|
||||
}
|
||||
|
||||
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to parse age private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -140,6 +170,7 @@ func (k *KeychainUnlocker) GetID() string {
|
||||
// We cannot continue with a fallback ID as that would mask data corruption
|
||||
panic(fmt.Sprintf("Keychain unlocker metadata is corrupt or missing keychain item name: %v", err))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s-keychain", keychainItemName)
|
||||
}
|
||||
|
||||
@@ -149,6 +180,7 @@ func (k *KeychainUnlocker) Remove() error {
|
||||
keychainItemName, err := k.GetKeychainItemName()
|
||||
if err != nil {
|
||||
Debug("Failed to get keychain item name during removal", "error", err, "unlocker_id", k.GetID())
|
||||
|
||||
return fmt.Errorf("failed to get keychain item name: %w", err)
|
||||
}
|
||||
|
||||
@@ -156,6 +188,7 @@ func (k *KeychainUnlocker) Remove() error {
|
||||
Debug("Removing keychain item", "keychain_item", keychainItemName)
|
||||
if err := deleteFromKeychain(keychainItemName); err != nil {
|
||||
Debug("Failed to remove keychain item", "error", err, "keychain_item", keychainItemName)
|
||||
|
||||
return fmt.Errorf("failed to remove keychain item: %w", err)
|
||||
}
|
||||
|
||||
@@ -163,10 +196,12 @@ func (k *KeychainUnlocker) Remove() error {
|
||||
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
||||
if err := k.fs.RemoveAll(k.Directory); err != nil {
|
||||
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
|
||||
|
||||
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -205,9 +240,76 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
|
||||
|
||||
// Format: secret-<vault>-<hostname>-<date>
|
||||
enrollmentDate := time.Now().Format("2006-01-02")
|
||||
|
||||
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
|
||||
}
|
||||
|
||||
// getLongTermPrivateKey retrieves the long-term private key either from environment or current unlocker
|
||||
// Returns a LockedBuffer to ensure the private key is protected in memory
|
||||
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
|
||||
// Check if mnemonic is available in environment variable
|
||||
envMnemonic := os.Getenv(EnvMnemonic)
|
||||
if envMnemonic != "" {
|
||||
// Use mnemonic directly to derive long-term key
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
// Return the private key in a secure buffer
|
||||
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
|
||||
}
|
||||
|
||||
// Get the vault to access current unlocker
|
||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||
}
|
||||
|
||||
// Get the current unlocker identity
|
||||
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
// Get encrypted long-term key from current unlocker, handling different types
|
||||
var encryptedLtPrivKey []byte
|
||||
switch currentUnlocker := currentUnlocker.(type) {
|
||||
case *PassphraseUnlocker:
|
||||
// Read the encrypted long-term private key from passphrase unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
|
||||
}
|
||||
|
||||
case *PGPUnlocker:
|
||||
// Read the encrypted long-term private key from PGP unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
|
||||
}
|
||||
|
||||
case *KeychainUnlocker:
|
||||
// Read the encrypted long-term private key from another keychain unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlocker: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported current unlocker type for keychain unlocker creation")
|
||||
}
|
||||
|
||||
// Decrypt long-term private key using current unlocker
|
||||
ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Return the decrypted key in a secure buffer
|
||||
return memguard.NewBufferFromBytes(ltPrivKeyData), nil
|
||||
}
|
||||
|
||||
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
|
||||
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
|
||||
// Check if we're on macOS
|
||||
@@ -245,7 +347,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
}
|
||||
|
||||
// Step 2: Generate a random passphrase for encrypting the age private key
|
||||
agePrivKeyPassphrase, err := generateRandomPassphrase(64)
|
||||
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
|
||||
}
|
||||
@@ -258,8 +360,15 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
}
|
||||
|
||||
// Step 4: Encrypt age private key with the generated passphrase and store on disk
|
||||
agePrivateKeyBytes := []byte(ageIdentity.String())
|
||||
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivateKeyBytes, agePrivKeyPassphrase)
|
||||
// Create secure buffers for both the private key and passphrase
|
||||
agePrivKeyStr := ageIdentity.String()
|
||||
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
|
||||
defer agePrivKeyBuffer.Destroy()
|
||||
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyPassphrase))
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer.Bytes(), passphraseBuffer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
||||
}
|
||||
@@ -270,63 +379,11 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
}
|
||||
|
||||
// Step 5: Get or derive the long-term private key
|
||||
var ltPrivKeyData []byte
|
||||
|
||||
// Check if mnemonic is available in environment variable
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
// Use mnemonic directly to derive long-term key
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
ltPrivKeyData = []byte(ltIdentity.String())
|
||||
} else {
|
||||
// Get the vault to access current unlocker
|
||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||
}
|
||||
|
||||
// Get the current unlocker identity
|
||||
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
// Get encrypted long-term key from current unlocker, handling different types
|
||||
var encryptedLtPrivKey []byte
|
||||
switch currentUnlocker := currentUnlocker.(type) {
|
||||
case *PassphraseUnlocker:
|
||||
// Read the encrypted long-term private key from passphrase unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
|
||||
}
|
||||
|
||||
case *PGPUnlocker:
|
||||
// Read the encrypted long-term private key from PGP unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
|
||||
}
|
||||
|
||||
case *KeychainUnlocker:
|
||||
// Read the encrypted long-term private key from another keychain unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlocker: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported current unlocker type for keychain unlocker creation")
|
||||
}
|
||||
|
||||
// Decrypt long-term private key using current unlocker
|
||||
ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer ltPrivKeyData.Destroy()
|
||||
|
||||
// Step 6: Encrypt long-term private key to the new age unlocker
|
||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
||||
@@ -372,7 +429,9 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := afero.WriteFile(fs, filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes, FilePerms); err != nil {
|
||||
if err := afero.WriteFile(fs,
|
||||
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
||||
metadataBytes, FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -389,12 +448,29 @@ func checkMacOSAvailable() error {
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("macOS security command not available: %w (keychain unlockers are only supported on macOS)", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateKeychainItemName validates that a keychain item name is safe for command execution
|
||||
func validateKeychainItemName(itemName string) error {
|
||||
if itemName == "" {
|
||||
return fmt.Errorf("keychain item name cannot be empty")
|
||||
}
|
||||
|
||||
if !keychainItemNameRegex.MatchString(itemName) {
|
||||
return fmt.Errorf("invalid keychain item name format: %s", itemName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeInKeychain stores data in the macOS keychain using the security command
|
||||
func storeInKeychain(itemName string, data []byte) error {
|
||||
cmd := exec.Command("/usr/bin/security", "add-generic-password",
|
||||
if err := validateKeychainItemName(itemName); err != nil {
|
||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||
}
|
||||
cmd := exec.Command("/usr/bin/security", "add-generic-password", //nolint:gosec
|
||||
"-a", itemName,
|
||||
"-s", itemName,
|
||||
"-w", string(data),
|
||||
@@ -409,7 +485,11 @@ func storeInKeychain(itemName string, data []byte) error {
|
||||
|
||||
// retrieveFromKeychain retrieves data from the macOS keychain using the security command
|
||||
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
||||
cmd := exec.Command("/usr/bin/security", "find-generic-password",
|
||||
if err := validateKeychainItemName(itemName); err != nil {
|
||||
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("/usr/bin/security", "find-generic-password", //nolint:gosec
|
||||
"-a", itemName,
|
||||
"-s", itemName,
|
||||
"-w") // Return password only
|
||||
@@ -429,7 +509,11 @@ func retrieveFromKeychain(itemName string) ([]byte, error) {
|
||||
|
||||
// deleteFromKeychain removes an item from the macOS keychain using the security command
|
||||
func deleteFromKeychain(itemName string) error {
|
||||
cmd := exec.Command("/usr/bin/security", "delete-generic-password",
|
||||
if err := validateKeychainItemName(itemName); err != nil {
|
||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("/usr/bin/security", "delete-generic-password", //nolint:gosec
|
||||
"-a", itemName,
|
||||
"-s", itemName)
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
type VaultMetadata struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DerivationIndex uint32 `json:"derivation_index"`
|
||||
PublicKeyHash string `json:"public_key_hash,omitempty"` // Double SHA256 hash of the long-term public key
|
||||
DerivationIndex uint32 `json:"derivationIndex"`
|
||||
// Double SHA256 hash of the actual long-term public key
|
||||
PublicKeyHash string `json:"publicKeyHash,omitempty"`
|
||||
// Double SHA256 hash of index-0 key (for grouping vaults from same mnemonic)
|
||||
MnemonicFamilyHash string `json:"mnemonicFamilyHash,omitempty"`
|
||||
}
|
||||
|
||||
// UnlockerMetadata contains information about an unlocker
|
||||
@@ -19,8 +22,8 @@ type UnlockerMetadata struct {
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
}
|
||||
|
||||
// SecretMetadata contains information about a secret
|
||||
type SecretMetadata struct {
|
||||
// Metadata contains information about a secret
|
||||
type Metadata struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -76,7 +77,9 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
// Test encrypting private key with passphrase
|
||||
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
||||
privKeyData := []byte(agePrivateKey)
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, testPassphrase)
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphraseBuffer.Destroy()
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphraseBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt private key: %v", err)
|
||||
}
|
||||
@@ -110,8 +113,9 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
t.Fatalf("Failed to parse recipient: %v", err)
|
||||
}
|
||||
|
||||
ltPrivKeyData := []byte(ltIdentity.String())
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyData, recipient)
|
||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, recipient)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt long-term private key: %v", err)
|
||||
}
|
||||
@@ -131,18 +135,8 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Save original environment variables and set test ones
|
||||
oldPassphrase := os.Getenv(secret.EnvUnlockPassphrase)
|
||||
os.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
if oldPassphrase != "" {
|
||||
os.Setenv(secret.EnvUnlockPassphrase, oldPassphrase)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvUnlockPassphrase)
|
||||
}
|
||||
}()
|
||||
// Set test environment variable (cleaned up automatically)
|
||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||
|
||||
// Test getting identity from environment variable
|
||||
t.Run("GetIdentityFromEnv", func(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -15,7 +16,40 @@ type PassphraseUnlocker struct {
|
||||
Directory string
|
||||
Metadata UnlockerMetadata
|
||||
fs afero.Fs
|
||||
Passphrase string
|
||||
Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
|
||||
}
|
||||
|
||||
// getPassphrase retrieves the passphrase from memory, environment, or user input
|
||||
// Returns a LockedBuffer for secure memory handling
|
||||
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
||||
// First check if we already have the passphrase
|
||||
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
||||
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
||||
// Return a copy of the passphrase buffer
|
||||
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
|
||||
}
|
||||
|
||||
Debug("No passphrase in memory, checking environment")
|
||||
// Check environment variable for passphrase
|
||||
passphraseStr := os.Getenv(EnvUnlockPassphrase)
|
||||
if passphraseStr != "" {
|
||||
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
|
||||
// Convert to secure buffer
|
||||
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
|
||||
|
||||
return secureBuffer, nil
|
||||
}
|
||||
|
||||
Debug("No passphrase in environment, prompting user")
|
||||
// Prompt for passphrase
|
||||
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
|
||||
return secureBuffer, nil
|
||||
}
|
||||
|
||||
// GetIdentity implements Unlocker interface for passphrase-based unlockers
|
||||
@@ -25,27 +59,11 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
slog.String("unlocker_type", p.GetType()),
|
||||
)
|
||||
|
||||
// First check if we already have the passphrase
|
||||
passphraseStr := p.Passphrase
|
||||
if passphraseStr == "" {
|
||||
Debug("No passphrase in memory, checking environment")
|
||||
// Check environment variable for passphrase
|
||||
passphraseStr = os.Getenv(EnvUnlockPassphrase)
|
||||
if passphraseStr == "" {
|
||||
Debug("No passphrase in environment, prompting user")
|
||||
// Prompt for passphrase
|
||||
var err error
|
||||
passphraseStr, err = ReadPassphrase("Enter unlock passphrase: ")
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
} else {
|
||||
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
|
||||
}
|
||||
} else {
|
||||
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
||||
passphraseBuffer, err := p.getPassphrase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
// Read encrypted private key of unlocker
|
||||
unlockerPrivPath := filepath.Join(p.Directory, "priv.age")
|
||||
@@ -54,6 +72,7 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase unlocker private key", "error", err, "path", unlockerPrivPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read unlocker private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -65,9 +84,10 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
|
||||
|
||||
// Decrypt the unlocker private key with passphrase
|
||||
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseStr)
|
||||
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -78,9 +98,20 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
// Parse the decrypted private key
|
||||
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
|
||||
identity, err := age.ParseX25519Identity(string(privKeyData))
|
||||
|
||||
// Create a secure buffer for the private key data
|
||||
privKeyBuffer := memguard.NewBufferFromBytes(privKeyData)
|
||||
defer privKeyBuffer.Destroy()
|
||||
|
||||
// Clear the original private key data
|
||||
for i := range privKeyData {
|
||||
privKeyData[i] = 0
|
||||
}
|
||||
|
||||
identity, err := age.ParseX25519Identity(privKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to parse unlocker private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -111,16 +142,23 @@ func (p *PassphraseUnlocker) GetDirectory() string {
|
||||
func (p *PassphraseUnlocker) GetID() string {
|
||||
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
|
||||
createdAt := p.Metadata.CreatedAt
|
||||
|
||||
return fmt.Sprintf("%s-passphrase", createdAt.Format("2006-01-02.15.04"))
|
||||
}
|
||||
|
||||
// Remove implements Unlocker interface - removes the passphrase unlocker
|
||||
func (p *PassphraseUnlocker) Remove() error {
|
||||
// Clean up the passphrase from memory if it exists
|
||||
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
||||
p.Passphrase.Destroy()
|
||||
}
|
||||
|
||||
// For passphrase unlockers, we just need to remove the directory
|
||||
// No external resources (like keychain items) to clean up
|
||||
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -134,7 +172,12 @@ func NewPassphraseUnlocker(fs afero.Fs, directory string, metadata UnlockerMetad
|
||||
}
|
||||
|
||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||
func CreatePassphraseUnlocker(fs afero.Fs, stateDir string, passphrase string) (*PassphraseUnlocker, error) {
|
||||
// The passphrase must be provided as a LockedBuffer for security
|
||||
func CreatePassphraseUnlocker(
|
||||
fs afero.Fs,
|
||||
stateDir string,
|
||||
passphrase *memguard.LockedBuffer,
|
||||
) (*PassphraseUnlocker, error) {
|
||||
// Get current vault
|
||||
currentVault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -28,14 +29,14 @@ func init() {
|
||||
}
|
||||
|
||||
// setupNonInteractiveGPG creates a custom GPG environment for testing
|
||||
func setupNonInteractiveGPG(t *testing.T, tempDir, passphrase, gnupgHomeDir string) {
|
||||
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
|
||||
// Create GPG config file for non-interactive operation
|
||||
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
|
||||
gpgConfContent := `batch
|
||||
no-tty
|
||||
pinentry-mode loopback
|
||||
`
|
||||
if err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0600); err != nil {
|
||||
if err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600); err != nil {
|
||||
t.Fatalf("Failed to write GPG config file: %v", err)
|
||||
}
|
||||
|
||||
@@ -139,24 +140,12 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
||||
|
||||
// Create a temporary GNUPGHOME
|
||||
gnupgHomeDir := filepath.Join(tempDir, "gnupg")
|
||||
if err := os.MkdirAll(gnupgHomeDir, 0700); err != nil {
|
||||
if err := os.MkdirAll(gnupgHomeDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create GNUPGHOME: %v", err)
|
||||
}
|
||||
|
||||
// Save original GNUPGHOME
|
||||
origGnupgHome := os.Getenv("GNUPGHOME")
|
||||
|
||||
// Set new GNUPGHOME
|
||||
os.Setenv("GNUPGHOME", gnupgHomeDir)
|
||||
|
||||
// Clean up environment after test
|
||||
defer func() {
|
||||
if origGnupgHome != "" {
|
||||
os.Setenv("GNUPGHOME", origGnupgHome)
|
||||
} else {
|
||||
os.Unsetenv("GNUPGHOME")
|
||||
}
|
||||
}()
|
||||
t.Setenv("GNUPGHOME", gnupgHomeDir)
|
||||
|
||||
// Test passphrase for GPG key
|
||||
testPassphrase := "test123"
|
||||
@@ -176,7 +165,7 @@ Passphrase: ` + testPassphrase + `
|
||||
%commit
|
||||
%echo Key generation completed
|
||||
`
|
||||
if err := os.WriteFile(batchFile, []byte(batchContent), 0600); err != nil {
|
||||
if err := os.WriteFile(batchFile, []byte(batchContent), 0o600); err != nil {
|
||||
t.Fatalf("Failed to write batch file: %v", err)
|
||||
}
|
||||
|
||||
@@ -189,21 +178,26 @@ Passphrase: ` + testPassphrase + `
|
||||
}
|
||||
t.Log("GPG key generated successfully")
|
||||
|
||||
// Get the key ID
|
||||
// Get the key ID and fingerprint
|
||||
output, err := runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
|
||||
[]string{"--list-secret-keys", "--with-colons"}, nil)
|
||||
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list GPG keys: %v", err)
|
||||
}
|
||||
|
||||
// Parse output to get key ID
|
||||
var keyID string
|
||||
// Parse output to get key ID and fingerprint
|
||||
var keyID, fingerprint string
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "sec:") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) >= 5 {
|
||||
keyID = fields[4]
|
||||
}
|
||||
} else if strings.HasPrefix(line, "fpr:") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) >= 10 && fields[9] != "" {
|
||||
fingerprint = fields[9]
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -212,18 +206,14 @@ Passphrase: ` + testPassphrase + `
|
||||
if keyID == "" {
|
||||
t.Fatalf("Failed to find GPG key ID in output: %s", output)
|
||||
}
|
||||
if fingerprint == "" {
|
||||
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
|
||||
}
|
||||
t.Logf("Generated GPG key ID: %s", keyID)
|
||||
t.Logf("Generated GPG fingerprint: %s", fingerprint)
|
||||
|
||||
// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
|
||||
oldAgentInfo := os.Getenv("GPG_AGENT_INFO")
|
||||
os.Setenv("GPG_AGENT_INFO", "")
|
||||
defer func() {
|
||||
if oldAgentInfo != "" {
|
||||
os.Setenv("GPG_AGENT_INFO", oldAgentInfo)
|
||||
} else {
|
||||
os.Unsetenv("GPG_AGENT_INFO")
|
||||
}
|
||||
}()
|
||||
t.Setenv("GPG_AGENT_INFO", "")
|
||||
|
||||
// Use the real filesystem
|
||||
fs := afero.NewOsFs()
|
||||
@@ -231,28 +221,9 @@ Passphrase: ` + testPassphrase + `
|
||||
// Test data
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// Save original environment variable
|
||||
oldMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
oldGPGKeyID := os.Getenv(secret.EnvGPGKeyID)
|
||||
|
||||
// Set test environment variables
|
||||
os.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
os.Setenv(secret.EnvGPGKeyID, keyID)
|
||||
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
if oldMnemonic != "" {
|
||||
os.Setenv(secret.EnvMnemonic, oldMnemonic)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvMnemonic)
|
||||
}
|
||||
|
||||
if oldGPGKeyID != "" {
|
||||
os.Setenv(secret.EnvGPGKeyID, oldGPGKeyID)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvGPGKeyID)
|
||||
}
|
||||
}()
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
t.Setenv(secret.EnvGPGKeyID, keyID)
|
||||
|
||||
// Set up vault structure for testing
|
||||
stateDir := tempDir
|
||||
@@ -300,7 +271,9 @@ Passphrase: ` + testPassphrase + `
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Create a passphrase unlocker first (to have current unlocker)
|
||||
passUnlocker, err := vlt.CreatePassphraseUnlocker("test-passphrase")
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||
defer passphraseBuffer.Destroy()
|
||||
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||
}
|
||||
@@ -326,9 +299,9 @@ Passphrase: ` + testPassphrase + `
|
||||
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
|
||||
}
|
||||
|
||||
// Check if the key ID includes the GPG key ID
|
||||
if !strings.Contains(pgpUnlocker.GetID(), keyID) {
|
||||
t.Errorf("PGP unlock key ID '%s' does not contain GPG key ID '%s'", pgpUnlocker.GetID(), keyID)
|
||||
// Check if the key ID includes the GPG fingerprint
|
||||
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
|
||||
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'", pgpUnlocker.GetID(), fingerprint)
|
||||
}
|
||||
|
||||
// Check if the key directory exists
|
||||
@@ -387,9 +360,9 @@ Passphrase: ` + testPassphrase + `
|
||||
var metadata struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Flags []string `json:"flags"`
|
||||
GPGKeyID string `json:"gpg_key_id"`
|
||||
GPGKeyID string `json:"gpgKeyId"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
@@ -400,8 +373,8 @@ Passphrase: ` + testPassphrase + `
|
||||
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
|
||||
}
|
||||
|
||||
if metadata.GPGKeyID != keyID {
|
||||
t.Errorf("Expected GPG key ID '%s', got '%s'", keyID, metadata.GPGKeyID)
|
||||
if metadata.GPGKeyID != fingerprint {
|
||||
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -426,12 +399,12 @@ Passphrase: ` + testPassphrase + `
|
||||
// Create PGP metadata with GPG key ID
|
||||
type PGPUnlockerMetadata struct {
|
||||
secret.UnlockerMetadata
|
||||
GPGKeyID string `json:"gpg_key_id"`
|
||||
GPGKeyID string `json:"gpgKeyId"`
|
||||
}
|
||||
|
||||
pgpMetadata := PGPUnlockerMetadata{
|
||||
UnlockerMetadata: metadata,
|
||||
GPGKeyID: keyID,
|
||||
GPGKeyID: fingerprint,
|
||||
}
|
||||
|
||||
// Write metadata file
|
||||
@@ -450,9 +423,9 @@ Passphrase: ` + testPassphrase + `
|
||||
t.Fatalf("Failed to get GPG key ID: %v", err)
|
||||
}
|
||||
|
||||
// Verify key ID
|
||||
if retrievedKeyID != keyID {
|
||||
t.Errorf("Expected GPG key ID '%s', got '%s'", keyID, retrievedKeyID)
|
||||
// Verify key ID (should be the fingerprint)
|
||||
if retrievedKeyID != fingerprint {
|
||||
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -19,18 +20,31 @@ import (
|
||||
var (
|
||||
// GPGEncryptFunc is the function used for GPG encryption
|
||||
// Can be overridden in tests to provide a non-interactive implementation
|
||||
GPGEncryptFunc = gpgEncryptDefault
|
||||
GPGEncryptFunc = gpgEncryptDefault //nolint:gochecknoglobals // Required for test mocking
|
||||
|
||||
// GPGDecryptFunc is the function used for GPG decryption
|
||||
// Can be overridden in tests to provide a non-interactive implementation
|
||||
GPGDecryptFunc = gpgDecryptDefault
|
||||
GPGDecryptFunc = gpgDecryptDefault //nolint:gochecknoglobals // Required for test mocking
|
||||
|
||||
// gpgKeyIDRegex validates GPG key IDs
|
||||
// Allows either:
|
||||
// 1. Email addresses (user@domain.tld format)
|
||||
// 2. Short key IDs (8 hex characters)
|
||||
// 3. Long key IDs (16 hex characters)
|
||||
// 4. Full fingerprints (40 hex characters)
|
||||
gpgKeyIDRegex = regexp.MustCompile(
|
||||
`^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$|` +
|
||||
`^[A-Fa-f0-9]{8}$|` +
|
||||
`^[A-Fa-f0-9]{16}$|` +
|
||||
`^[A-Fa-f0-9]{40}$`,
|
||||
)
|
||||
)
|
||||
|
||||
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
|
||||
type PGPUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
// GPG key ID used for encryption
|
||||
GPGKeyID string `json:"gpg_key_id"`
|
||||
GPGKeyID string `json:"gpgKeyId"`
|
||||
}
|
||||
|
||||
// PGPUnlocker represents a PGP-protected unlocker
|
||||
@@ -54,6 +68,7 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read PGP-encrypted age private key", "error", err, "path", agePrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -67,6 +82,7 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
agePrivKeyData, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt age private key with GPG", "error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
|
||||
}
|
||||
|
||||
@@ -80,6 +96,7 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to parse age private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -115,6 +132,7 @@ func (p *PGPUnlocker) GetID() string {
|
||||
// We cannot continue with a fallback ID as that would mask data corruption
|
||||
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s-pgp", gpgKeyID)
|
||||
}
|
||||
|
||||
@@ -125,6 +143,7 @@ func (p *PGPUnlocker) Remove() error {
|
||||
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -163,6 +182,7 @@ func generatePGPUnlockerName() (string, error) {
|
||||
|
||||
// Format: hostname-pgp-YYYY-MM-DD
|
||||
enrollmentDate := time.Now().Format("2006-01-02")
|
||||
|
||||
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
|
||||
}
|
||||
|
||||
@@ -210,56 +230,11 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
||||
}
|
||||
|
||||
// Step 3: Get or derive the long-term private key
|
||||
var ltPrivKeyData []byte
|
||||
|
||||
// Check if mnemonic is available in environment variable
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
// Use mnemonic directly to derive long-term key
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
ltPrivKeyData = []byte(ltIdentity.String())
|
||||
} else {
|
||||
// Get the vault to access current unlocker
|
||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||
}
|
||||
|
||||
// Get the current unlocker identity
|
||||
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
// Get encrypted long-term key from current unlocker, handling different types
|
||||
var encryptedLtPrivKey []byte
|
||||
switch currentUnlocker := currentUnlocker.(type) {
|
||||
case *PassphraseUnlocker:
|
||||
// Read the encrypted long-term private key from passphrase unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
|
||||
}
|
||||
|
||||
case *PGPUnlocker:
|
||||
// Read the encrypted long-term private key from PGP unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported current unlocker type for PGP unlocker creation")
|
||||
}
|
||||
|
||||
// Step 6: Decrypt long-term private key using current unlocker
|
||||
ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer ltPrivKeyData.Destroy()
|
||||
|
||||
// Step 7: Encrypt long-term private key to the new age unlocker
|
||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
||||
@@ -274,8 +249,11 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
||||
}
|
||||
|
||||
// Step 8: Encrypt age private key to the GPG key ID
|
||||
agePrivateKeyBytes := []byte(ageIdentity.String())
|
||||
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBytes, gpgKeyID)
|
||||
// Use memguard to protect the private key in memory
|
||||
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
||||
defer agePrivateKeyBuffer.Destroy()
|
||||
|
||||
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer.Bytes(), gpgKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
|
||||
}
|
||||
@@ -285,14 +263,20 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
||||
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 9: Create and write enhanced metadata
|
||||
// Step 9: Resolve the GPG key ID to its full fingerprint
|
||||
fingerprint, err := resolveGPGKeyFingerprint(gpgKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Step 10: Create and write enhanced metadata with full fingerprint
|
||||
pgpMetadata := PGPUnlockerMetadata{
|
||||
UnlockerMetadata: UnlockerMetadata{
|
||||
Type: "pgp",
|
||||
CreatedAt: time.Now(),
|
||||
Flags: []string{"gpg", "encrypted"},
|
||||
},
|
||||
GPGKeyID: gpgKeyID,
|
||||
GPGKeyID: fingerprint,
|
||||
}
|
||||
|
||||
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
|
||||
@@ -300,7 +284,9 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := afero.WriteFile(fs, filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes, FilePerms); err != nil {
|
||||
if err := afero.WriteFile(fs,
|
||||
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
||||
metadataBytes, FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -311,17 +297,62 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
||||
func validateGPGKeyID(keyID string) error {
|
||||
if keyID == "" {
|
||||
return fmt.Errorf("GPG key ID cannot be empty")
|
||||
}
|
||||
|
||||
if !gpgKeyIDRegex.MatchString(keyID) {
|
||||
return fmt.Errorf("invalid GPG key ID format: %s", keyID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
||||
func resolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||
if err := validateGPGKeyID(keyID); err != nil {
|
||||
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
||||
}
|
||||
|
||||
// Use GPG to get the full fingerprint for the key
|
||||
cmd := exec.Command("gpg", "--list-keys", "--with-colons", "--fingerprint", keyID)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Parse the output to extract the fingerprint
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "fpr:") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) >= 10 && fields[9] != "" {
|
||||
return fields[9], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find fingerprint for GPG key: %s", keyID)
|
||||
}
|
||||
|
||||
// checkGPGAvailable verifies that GPG is available
|
||||
func checkGPGAvailable() error {
|
||||
cmd := exec.Command("gpg", "--version")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("GPG not available: %w (make sure 'gpg' command is installed and in PATH)", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// gpgEncryptDefault is the default implementation of GPG encryption
|
||||
func gpgEncryptDefault(data []byte, keyID string) ([]byte, error) {
|
||||
if err := validateGPGKeyID(keyID); err != nil {
|
||||
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID)
|
||||
cmd.Stdin = strings.NewReader(string(data))
|
||||
|
||||
|
||||
@@ -11,24 +11,25 @@ import (
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// VaultInterface defines the interface that vault implementations must satisfy
|
||||
type VaultInterface interface {
|
||||
GetDirectory() (string, error)
|
||||
AddSecret(name string, value []byte, force bool) error
|
||||
AddSecret(name string, value *memguard.LockedBuffer, force bool) error
|
||||
GetName() string
|
||||
GetFilesystem() afero.Fs
|
||||
GetCurrentUnlocker() (Unlocker, error)
|
||||
CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error)
|
||||
CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||
}
|
||||
|
||||
// Secret represents a secret in a vault
|
||||
type Secret struct {
|
||||
Name string
|
||||
Directory string
|
||||
Metadata SecretMetadata
|
||||
Metadata Metadata
|
||||
vault VaultInterface
|
||||
}
|
||||
|
||||
@@ -54,7 +55,7 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
||||
Name: name,
|
||||
Directory: secretDir,
|
||||
vault: vault,
|
||||
Metadata: SecretMetadata{
|
||||
Metadata: Metadata{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
@@ -71,13 +72,20 @@ func (s *Secret) Save(value []byte, force bool) error {
|
||||
slog.Bool("force", force),
|
||||
)
|
||||
|
||||
err := s.vault.AddSecret(s.Name, value, force)
|
||||
// Create a secure buffer for the value - note that the caller
|
||||
// should ideally pass a LockedBuffer directly to vault.AddSecret
|
||||
valueBuffer := memguard.NewBufferFromBytes(value)
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
err := s.vault.AddSecret(s.Name, valueBuffer, force)
|
||||
if err != nil {
|
||||
Debug("Failed to save secret", "error", err, "secret_name", s.Name)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
Debug("Successfully saved secret", "secret_name", s.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -92,10 +100,12 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
exists, err := s.Exists()
|
||||
if err != nil {
|
||||
Debug("Failed to check if secret exists during GetValue", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
Debug("Secret not found during GetValue", "secret_name", s.Name, "vault_name", s.vault.GetName())
|
||||
|
||||
return nil, fmt.Errorf("secret %s not found", s.Name)
|
||||
}
|
||||
|
||||
@@ -105,11 +115,12 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
||||
if err != nil {
|
||||
Debug("Failed to get current version", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
|
||||
// Create version object
|
||||
version := NewSecretVersion(s.vault, s.Name, currentVersion)
|
||||
version := NewVersion(s.vault, s.Name, currentVersion)
|
||||
|
||||
// Check if we have SB_SECRET_MNEMONIC environment variable for direct decryption
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
@@ -119,6 +130,7 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
vaultDir, err := s.vault.GetDirectory()
|
||||
if err != nil {
|
||||
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -127,12 +139,14 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata VaultMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -146,6 +160,7 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
Debug("Failed to derive long-term key from mnemonic for secret", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
@@ -160,6 +175,7 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
// Use the provided unlocker to get the vault's long-term private key
|
||||
if unlocker == nil {
|
||||
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("unlocker required to decrypt secret")
|
||||
}
|
||||
|
||||
@@ -173,6 +189,7 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
unlockIdentity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
Debug("Failed to get unlocker identity", "error", err, "secret_name", s.Name, "unlocker_type", unlocker.GetType())
|
||||
|
||||
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
@@ -183,6 +200,7 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
encryptedLtPrivKey, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -191,6 +209,7 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -199,6 +218,7 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term private key", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -216,22 +236,25 @@ func (s *Secret) LoadMetadata() error {
|
||||
Debug("LoadMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
// For backward compatibility, we'll populate with basic info
|
||||
now := time.Now()
|
||||
s.Metadata = SecretMetadata{
|
||||
s.Metadata = Metadata{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetadata returns the secret metadata (deprecated)
|
||||
func (s *Secret) GetMetadata() SecretMetadata {
|
||||
func (s *Secret) GetMetadata() Metadata {
|
||||
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
|
||||
return s.Metadata
|
||||
}
|
||||
|
||||
// GetEncryptedData is deprecated - data is now stored in versions
|
||||
func (s *Secret) GetEncryptedData() ([]byte, error) {
|
||||
Debug("GetEncryptedData called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("GetEncryptedData is deprecated - use version-specific methods")
|
||||
}
|
||||
|
||||
@@ -246,11 +269,13 @@ func (s *Secret) Exists() (bool, error) {
|
||||
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
|
||||
if err != nil {
|
||||
Debug("Failed to check secret directory existence", "error", err, "secret_dir", s.Directory)
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
Debug("Secret directory does not exist", "secret_dir", s.Directory)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -258,6 +283,7 @@ func (s *Secret) Exists() (bool, error) {
|
||||
_, err = GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
||||
if err != nil {
|
||||
Debug("No current version found", "error", err, "secret_name", s.Name)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -278,11 +304,14 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
|
||||
if getCurrentVaultFunc == nil {
|
||||
return nil, fmt.Errorf("GetCurrentVault function not registered")
|
||||
}
|
||||
|
||||
return getCurrentVaultFunc(fs, stateDir)
|
||||
}
|
||||
|
||||
// getCurrentVaultFunc is a function variable that will be set by the vault package
|
||||
// to implement the actual GetCurrentVault functionality
|
||||
//
|
||||
//nolint:gochecknoglobals // Required to break import cycle
|
||||
var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error)
|
||||
|
||||
// RegisterGetCurrentVaultFunc allows the vault package to register its implementation
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -25,18 +26,18 @@ func (m *MockVault) GetDirectory() (string, error) {
|
||||
return m.directory, nil
|
||||
}
|
||||
|
||||
func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
|
||||
func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool) error {
|
||||
// Create secret directory with proper storage name conversion
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
||||
if err := m.fs.MkdirAll(secretDir, 0700); err != nil {
|
||||
if err := m.fs.MkdirAll(secretDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create version directory with proper path
|
||||
versionName := "20240101.001" // Use a fixed version name for testing
|
||||
versionDir := filepath.Join(secretDir, "versions", versionName)
|
||||
if err := m.fs.MkdirAll(versionDir, 0700); err != nil {
|
||||
if err := m.fs.MkdirAll(versionDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
|
||||
// Write long-term public key if it doesn't exist
|
||||
if _, err := m.fs.Stat(ltPubKeyPath); os.IsNotExist(err) {
|
||||
pubKey := ltIdentity.Recipient().String()
|
||||
if err := afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0600); err != nil {
|
||||
if err := afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -70,11 +71,11 @@ func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
|
||||
|
||||
// Write version public key
|
||||
pubKeyPath := filepath.Join(versionDir, "pub.age")
|
||||
if err := afero.WriteFile(m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0600); err != nil {
|
||||
if err := afero.WriteFile(m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt value to version's public key
|
||||
// Encrypt value to version's public key (value is already a LockedBuffer)
|
||||
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -82,26 +83,28 @@ func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
|
||||
|
||||
// Write encrypted value
|
||||
valuePath := filepath.Join(versionDir, "value.age")
|
||||
if err := afero.WriteFile(m.fs, valuePath, encryptedValue, 0600); err != nil {
|
||||
if err := afero.WriteFile(m.fs, valuePath, encryptedValue, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt version private key to long-term public key
|
||||
encryptedPrivKey, err := EncryptToRecipient([]byte(versionIdentity.String()), ltIdentity.Recipient())
|
||||
versionPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
||||
defer versionPrivKeyBuffer.Destroy()
|
||||
encryptedPrivKey, err := EncryptToRecipient(versionPrivKeyBuffer, ltIdentity.Recipient())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write encrypted version private key
|
||||
privKeyPath := filepath.Join(versionDir, "priv.age")
|
||||
if err := afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0600); err != nil {
|
||||
if err := afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create current symlink pointing to the version
|
||||
currentLink := filepath.Join(secretDir, "current")
|
||||
// For MemMapFs, write a file with the target path
|
||||
if err := afero.WriteFile(m.fs, currentLink, []byte("versions/"+versionName), 0600); err != nil {
|
||||
if err := afero.WriteFile(m.fs, currentLink, []byte("versions/"+versionName), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -120,7 +123,7 @@ func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockVault) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error) {
|
||||
func (m *MockVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -128,19 +131,9 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
// Create an in-memory filesystem for testing
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Set up test environment variables
|
||||
oldMnemonic := os.Getenv(EnvMnemonic)
|
||||
defer func() {
|
||||
if oldMnemonic == "" {
|
||||
os.Unsetenv(EnvMnemonic)
|
||||
} else {
|
||||
os.Setenv(EnvMnemonic, oldMnemonic)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set test mnemonic for direct encryption/decryption
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
os.Setenv(EnvMnemonic, testMnemonic)
|
||||
t.Setenv(EnvMnemonic, testMnemonic)
|
||||
|
||||
// Set up a test vault structure
|
||||
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
||||
@@ -164,7 +157,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
fs,
|
||||
ltPubKeyPath,
|
||||
[]byte(ltIdentity.Recipient().String()),
|
||||
0600,
|
||||
0o600,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||
@@ -189,9 +182,13 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
secretName := "test-secret"
|
||||
secretValue := []byte("this is a test secret value")
|
||||
|
||||
// Create a secure buffer for the test value
|
||||
valueBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
// Test AddSecret
|
||||
t.Run("AddSecret", func(t *testing.T) {
|
||||
err := vault.AddSecret(secretName, secretValue, false)
|
||||
err := vault.AddSecret(secretName, valueBuffer, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddSecret failed: %v", err)
|
||||
}
|
||||
@@ -272,6 +269,7 @@ func isValidSecretName(name string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -312,9 +310,7 @@ func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
||||
|
||||
// Set up test mnemonic
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
originalEnv := os.Getenv(EnvMnemonic)
|
||||
os.Setenv(EnvMnemonic, testMnemonic)
|
||||
defer os.Setenv(EnvMnemonic, originalEnv)
|
||||
t.Setenv(EnvMnemonic, testMnemonic)
|
||||
|
||||
// Create temporary directory for vaults
|
||||
fs := afero.NewOsFs()
|
||||
@@ -325,7 +321,7 @@ func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
||||
}()
|
||||
|
||||
stateDir := filepath.Join(tempDir, ".secret")
|
||||
require.NoError(t, fs.MkdirAll(stateDir, 0700))
|
||||
require.NoError(t, fs.MkdirAll(stateDir, 0o700))
|
||||
|
||||
// This test is now in the integration test file where it can use real vaults
|
||||
// The bug is demonstrated there - see test31EnvMnemonicUsesVaultDerivationIndex
|
||||
|
||||
297
internal/secret/validation_test.go
Normal file
297
internal/secret/validation_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateGPGKeyID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keyID string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid cases
|
||||
{
|
||||
name: "valid email address",
|
||||
keyID: "test@example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid email with dots and hyphens",
|
||||
keyID: "test.user-name@example-domain.co.uk",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid email with plus",
|
||||
keyID: "test+tag@example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid short key ID (8 hex chars)",
|
||||
keyID: "ABCDEF12",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid long key ID (16 hex chars)",
|
||||
keyID: "ABCDEF1234567890",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid fingerprint (40 hex chars)",
|
||||
keyID: "ABCDEF1234567890ABCDEF1234567890ABCDEF12",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid lowercase hex fingerprint",
|
||||
keyID: "abcdef1234567890abcdef1234567890abcdef12",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid mixed case hex",
|
||||
keyID: "AbCdEf1234567890",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Invalid cases
|
||||
{
|
||||
name: "empty key ID",
|
||||
keyID: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with spaces",
|
||||
keyID: "test user@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with semicolon (command injection)",
|
||||
keyID: "test@example.com; rm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with pipe (command injection)",
|
||||
keyID: "test@example.com | cat /etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with backticks (command injection)",
|
||||
keyID: "test@example.com`whoami`",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with dollar sign (command injection)",
|
||||
keyID: "test@example.com$(whoami)",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with quotes",
|
||||
keyID: "test\"@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with single quotes",
|
||||
keyID: "test'@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with backslash",
|
||||
keyID: "test\\@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with newline",
|
||||
keyID: "test@example.com\nrm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with carriage return",
|
||||
keyID: "test@example.com\rrm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hex with invalid length (7 chars)",
|
||||
keyID: "ABCDEF1",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hex with invalid length (9 chars)",
|
||||
keyID: "ABCDEF123",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hex with non-hex characters",
|
||||
keyID: "ABCDEFGH",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mixed format (email with hex)",
|
||||
keyID: "test@ABCDEF12",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with ampersand",
|
||||
keyID: "test@example.com & echo test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with redirect",
|
||||
keyID: "test@example.com > /tmp/test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with null byte",
|
||||
keyID: "test@example.com\x00",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateGPGKeyID(tt.keyID)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateGPGKeyID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateKeychainItemName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
itemName string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid cases
|
||||
{
|
||||
name: "valid simple name",
|
||||
itemName: "my-secret-key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid name with dots",
|
||||
itemName: "com.example.app.key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid name with underscores",
|
||||
itemName: "my_secret_key_123",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid alphanumeric",
|
||||
itemName: "Secret123Key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid with hyphen at start",
|
||||
itemName: "-my-key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid with dot at start",
|
||||
itemName: ".hidden-key",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Invalid cases
|
||||
{
|
||||
name: "empty item name",
|
||||
itemName: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with spaces",
|
||||
itemName: "my secret key",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with semicolon",
|
||||
itemName: "key;rm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with pipe",
|
||||
itemName: "key|cat /etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with backticks",
|
||||
itemName: "key`whoami`",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with dollar sign",
|
||||
itemName: "key$(whoami)",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with quotes",
|
||||
itemName: "key\"name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with single quotes",
|
||||
itemName: "key'name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with backslash",
|
||||
itemName: "key\\name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with newline",
|
||||
itemName: "key\nname",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with carriage return",
|
||||
itemName: "key\rname",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with ampersand",
|
||||
itemName: "key&echo test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with redirect",
|
||||
itemName: "key>/tmp/test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with null byte",
|
||||
itemName: "key\x00name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with parentheses",
|
||||
itemName: "key(test)",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with brackets",
|
||||
itemName: "key[test]",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with asterisk",
|
||||
itemName: "key*",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with question mark",
|
||||
itemName: "key?",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateKeychainItemName(tt.itemName)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,16 @@ import (
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const (
|
||||
versionNameParts = 2
|
||||
maxVersionsPerDay = 999
|
||||
)
|
||||
|
||||
// VersionMetadata contains information about a secret version
|
||||
type VersionMetadata struct {
|
||||
ID string `json:"id"` // ULID
|
||||
@@ -23,8 +29,8 @@ type VersionMetadata struct {
|
||||
NotAfter *time.Time `json:"notAfter,omitempty"` // When this version expires (nil = current)
|
||||
}
|
||||
|
||||
// SecretVersion represents a version of a secret
|
||||
type SecretVersion struct {
|
||||
// Version represents a version of a secret
|
||||
type Version struct {
|
||||
SecretName string
|
||||
Version string
|
||||
Directory string
|
||||
@@ -32,8 +38,8 @@ type SecretVersion struct {
|
||||
vault VaultInterface
|
||||
}
|
||||
|
||||
// NewSecretVersion creates a new SecretVersion instance
|
||||
func NewSecretVersion(vault VaultInterface, secretName string, version string) *SecretVersion {
|
||||
// NewVersion creates a new Version instance
|
||||
func NewVersion(vault VaultInterface, secretName string, version string) *Version {
|
||||
DebugWith("Creating new secret version instance",
|
||||
slog.String("secret_name", secretName),
|
||||
slog.String("version", version),
|
||||
@@ -51,7 +57,8 @@ func NewSecretVersion(vault VaultInterface, secretName string, version string) *
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
return &SecretVersion{
|
||||
|
||||
return &Version{
|
||||
SecretName: secretName,
|
||||
Version: version,
|
||||
Directory: versionDir,
|
||||
@@ -83,23 +90,30 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
prefix := today + "."
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
|
||||
// Extract serial number
|
||||
parts := strings.Split(entry.Name(), ".")
|
||||
if len(parts) == 2 {
|
||||
var serial int
|
||||
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err == nil {
|
||||
if serial > maxSerial {
|
||||
maxSerial = serial
|
||||
}
|
||||
}
|
||||
}
|
||||
// Skip non-directories and those without correct prefix
|
||||
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract serial number
|
||||
parts := strings.Split(entry.Name(), ".")
|
||||
if len(parts) != versionNameParts {
|
||||
continue
|
||||
}
|
||||
|
||||
var serial int
|
||||
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if serial > maxSerial {
|
||||
maxSerial = serial
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new version name
|
||||
newSerial := maxSerial + 1
|
||||
if newSerial > 999 {
|
||||
if newSerial > maxVersionsPerDay {
|
||||
return "", fmt.Errorf("exceeded maximum versions per day (999)")
|
||||
}
|
||||
|
||||
@@ -107,11 +121,15 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
}
|
||||
|
||||
// Save saves the version metadata and value
|
||||
func (sv *SecretVersion) Save(value []byte) error {
|
||||
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||
if value == nil {
|
||||
return fmt.Errorf("value buffer is nil")
|
||||
}
|
||||
|
||||
DebugWith("Saving secret version",
|
||||
slog.String("secret_name", sv.SecretName),
|
||||
slog.String("version", sv.Version),
|
||||
slog.Int("value_length", len(value)),
|
||||
slog.Int("value_length", value.Size()),
|
||||
)
|
||||
|
||||
fs := sv.vault.GetFilesystem()
|
||||
@@ -119,6 +137,7 @@ func (sv *SecretVersion) Save(value []byte) error {
|
||||
// Create version directory
|
||||
if err := fs.MkdirAll(sv.Directory, DirPerms); err != nil {
|
||||
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
|
||||
|
||||
return fmt.Errorf("failed to create version directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -127,11 +146,14 @@ func (sv *SecretVersion) Save(value []byte) error {
|
||||
versionIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to generate version keypair: %w", err)
|
||||
}
|
||||
|
||||
versionPublicKey := versionIdentity.Recipient().String()
|
||||
versionPrivateKey := versionIdentity.String()
|
||||
// Store private key in memguard buffer immediately
|
||||
versionPrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
||||
defer versionPrivateKeyBuffer.Destroy()
|
||||
|
||||
DebugWith("Generated version keypair",
|
||||
slog.String("version", sv.Version),
|
||||
@@ -143,6 +165,7 @@ func (sv *SecretVersion) Save(value []byte) error {
|
||||
Debug("Writing version public key", "path", pubKeyPath)
|
||||
if err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms); err != nil {
|
||||
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to write version public key: %w", err)
|
||||
}
|
||||
|
||||
@@ -151,6 +174,7 @@ func (sv *SecretVersion) Save(value []byte) error {
|
||||
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version value: %w", err)
|
||||
}
|
||||
|
||||
@@ -159,6 +183,7 @@ func (sv *SecretVersion) Save(value []byte) error {
|
||||
Debug("Writing encrypted version value", "path", valuePath)
|
||||
if err := afero.WriteFile(fs, valuePath, encryptedValue, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version value: %w", err)
|
||||
}
|
||||
|
||||
@@ -170,6 +195,7 @@ func (sv *SecretVersion) Save(value []byte) error {
|
||||
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
||||
}
|
||||
|
||||
@@ -177,14 +203,16 @@ func (sv *SecretVersion) Save(value []byte) error {
|
||||
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term public key", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to parse long-term public key: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: Encrypt the version's private key to the long-term public key
|
||||
Debug("Encrypting version private key to long-term public key", "version", sv.Version)
|
||||
encryptedPrivKey, err := EncryptToRecipient([]byte(versionPrivateKey), ltRecipient)
|
||||
encryptedPrivKey, err := EncryptToRecipient(versionPrivateKeyBuffer, ltRecipient)
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version private key", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -193,6 +221,7 @@ func (sv *SecretVersion) Save(value []byte) error {
|
||||
Debug("Writing encrypted version private key", "path", privKeyPath)
|
||||
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version private key", "error", err, "path", privKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -201,13 +230,18 @@ func (sv *SecretVersion) Save(value []byte) error {
|
||||
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
|
||||
if err != nil {
|
||||
Debug("Failed to marshal version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to marshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt metadata to the version's public key
|
||||
encryptedMetadata, err := EncryptToRecipient(metadataBytes, versionIdentity.Recipient())
|
||||
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||
defer metadataBuffer.Destroy()
|
||||
|
||||
encryptedMetadata, err := EncryptToRecipient(metadataBuffer, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -215,15 +249,17 @@ func (sv *SecretVersion) Save(value []byte) error {
|
||||
Debug("Writing encrypted version metadata", "path", metadataPath)
|
||||
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully saved secret version", "version", sv.Version, "secret_name", sv.SecretName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadMetadata loads and decrypts the version metadata
|
||||
func (sv *SecretVersion) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
DebugWith("Loading version metadata",
|
||||
slog.String("secret_name", sv.SecretName),
|
||||
slog.String("version", sv.Version),
|
||||
@@ -236,6 +272,7 @@ func (sv *SecretVersion) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -243,6 +280,7 @@ func (sv *SecretVersion) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -250,6 +288,7 @@ func (sv *SecretVersion) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to parse version private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -258,6 +297,7 @@ func (sv *SecretVersion) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version metadata", "error", err, "path", encryptedMetadataPath)
|
||||
|
||||
return fmt.Errorf("failed to read encrypted version metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -265,6 +305,7 @@ func (sv *SecretVersion) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
metadataBytes, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version metadata", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to decrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -272,61 +313,86 @@ func (sv *SecretVersion) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
var metadata VersionMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
sv.Metadata = metadata
|
||||
Debug("Successfully loaded version metadata", "version", sv.Version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetValue retrieves and decrypts the version value
|
||||
func (sv *SecretVersion) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
||||
func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
||||
DebugWith("Getting version value",
|
||||
slog.String("secret_name", sv.SecretName),
|
||||
slog.String("version", sv.Version),
|
||||
)
|
||||
|
||||
// Debug: Log the directory and long-term key info
|
||||
Debug("SecretVersion GetValue debug info",
|
||||
"secret_name", sv.SecretName,
|
||||
"version", sv.Version,
|
||||
"directory", sv.Directory,
|
||||
"lt_identity_public_key", ltIdentity.Recipient().String())
|
||||
|
||||
fs := sv.vault.GetFilesystem()
|
||||
|
||||
// Step 1: Read encrypted version private key
|
||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||
}
|
||||
Debug("Successfully read encrypted version private key", "path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
|
||||
|
||||
// Step 2: Decrypt version private key using long-term key
|
||||
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
|
||||
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||
}
|
||||
Debug("Successfully decrypted version private key", "version", sv.Version, "size", len(versionPrivKeyData))
|
||||
|
||||
// Step 3: Parse version private key
|
||||
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Read encrypted value
|
||||
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
|
||||
Debug("Reading encrypted value", "path", encryptedValuePath)
|
||||
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version value", "error", err, "path", encryptedValuePath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
|
||||
}
|
||||
Debug("Successfully read encrypted value", "path", encryptedValuePath, "size", len(encryptedValue))
|
||||
|
||||
// Step 5: Decrypt value using version key
|
||||
Debug("Decrypting value with version identity", "version", sv.Version)
|
||||
value, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt version value: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully retrieved version value", "version", sv.Version, "value_length", len(value))
|
||||
Debug("Successfully retrieved version value",
|
||||
"version", sv.Version,
|
||||
"value_length", len(value),
|
||||
"is_empty", len(value) == 0)
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -375,6 +441,7 @@ func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
||||
if len(parts) >= 2 && parts[0] == "versions" {
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("invalid current version symlink format: %s", target)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// - TestGenerateVersionName: Tests version name generation with date and serial format
|
||||
// - TestGenerateVersionNameMaxSerial: Tests the 999 versions per day limit
|
||||
// - TestNewSecretVersion: Tests secret version object creation
|
||||
// - TestNewVersion: Tests secret version object creation
|
||||
// - TestSecretVersionSave: Tests saving a version with encryption
|
||||
// - TestSecretVersionLoadMetadata: Tests loading and decrypting version metadata
|
||||
// - TestSecretVersionGetValue: Tests retrieving and decrypting version values
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -58,7 +59,7 @@ func (m *MockVersionVault) GetDirectory() (string, error) {
|
||||
return filepath.Join(m.stateDir, "vaults.d", m.Name), nil
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) AddSecret(name string, value []byte, force bool) error {
|
||||
func (m *MockVersionVault) AddSecret(_ string, _ *memguard.LockedBuffer, _ bool) error {
|
||||
return fmt.Errorf("not implemented in mock")
|
||||
}
|
||||
|
||||
@@ -74,7 +75,7 @@ func (m *MockVersionVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, fmt.Errorf("not implemented in mock")
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error) {
|
||||
func (m *MockVersionVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||
return nil, fmt.Errorf("not implemented in mock")
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ func TestGenerateVersionName(t *testing.T) {
|
||||
|
||||
// Create the version directory
|
||||
versionDir := filepath.Join(secretDir, "versions", version1)
|
||||
err = fs.MkdirAll(versionDir, 0755)
|
||||
err = fs.MkdirAll(versionDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test second version generation on same day
|
||||
@@ -111,7 +112,7 @@ func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
||||
today := time.Now().Format("20060102")
|
||||
for i := 1; i <= 999; i++ {
|
||||
versionName := fmt.Sprintf("%s.%03d", today, i)
|
||||
err := fs.MkdirAll(filepath.Join(versionsDir, versionName), 0755)
|
||||
err := fs.MkdirAll(filepath.Join(versionsDir, versionName), 0o755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -121,7 +122,7 @@ func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||
}
|
||||
|
||||
func TestNewSecretVersion(t *testing.T) {
|
||||
func TestNewVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
@@ -129,7 +130,7 @@ func TestNewSecretVersion(t *testing.T) {
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||
|
||||
assert.Equal(t, "test/secret", sv.SecretName)
|
||||
assert.Equal(t, "20231215.001", sv.Version)
|
||||
@@ -148,7 +149,7 @@ func TestSecretVersionSave(t *testing.T) {
|
||||
|
||||
// Create vault directory structure and long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0755)
|
||||
err := fs.MkdirAll(vaultDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate and store long-term public key
|
||||
@@ -157,14 +158,16 @@ func TestSecretVersionSave(t *testing.T) {
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||
testValue := []byte("test-secret-value")
|
||||
|
||||
err = sv.Save(testValue)
|
||||
testBuffer := memguard.NewBufferFromBytes(testValue)
|
||||
defer testBuffer.Destroy()
|
||||
err = sv.Save(testBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify files were created
|
||||
@@ -184,7 +187,7 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
|
||||
// Setup vault with long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0755)
|
||||
err := fs.MkdirAll(vaultDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
@@ -192,21 +195,23 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version with custom metadata
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||
now := time.Now()
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
sv.Metadata.NotBefore = &epochPlusOne
|
||||
sv.Metadata.NotAfter = &now
|
||||
|
||||
err = sv.Save([]byte("test-value"))
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||
defer testBuffer.Destroy()
|
||||
err = sv.Save(testBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create new version object and load metadata
|
||||
sv2 := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
sv2 := NewVersion(vault, "test/secret", "20231215.001")
|
||||
err = sv2.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -227,7 +232,7 @@ func TestSecretVersionGetValue(t *testing.T) {
|
||||
|
||||
// Setup vault with long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0755)
|
||||
err := fs.MkdirAll(vaultDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
@@ -235,21 +240,25 @@ func TestSecretVersionGetValue(t *testing.T) {
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||
originalValue := []byte("test-secret-value-12345")
|
||||
expectedValue := make([]byte, len(originalValue))
|
||||
copy(expectedValue, originalValue)
|
||||
|
||||
err = sv.Save(originalValue)
|
||||
originalBuffer := memguard.NewBufferFromBytes(originalValue)
|
||||
defer originalBuffer.Destroy()
|
||||
err = sv.Save(originalBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve the value
|
||||
retrievedValue, err := sv.GetValue(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, originalValue, retrievedValue)
|
||||
assert.Equal(t, expectedValue, retrievedValue)
|
||||
}
|
||||
|
||||
func TestListVersions(t *testing.T) {
|
||||
@@ -265,12 +274,12 @@ func TestListVersions(t *testing.T) {
|
||||
// Create some versions
|
||||
testVersions := []string{"20231215.001", "20231215.002", "20231216.001", "20231214.001"}
|
||||
for _, v := range testVersions {
|
||||
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0755)
|
||||
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0o755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create a file (not directory) that should be ignored
|
||||
err = afero.WriteFile(fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0600)
|
||||
err = afero.WriteFile(fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// List versions
|
||||
@@ -288,10 +297,10 @@ func TestGetCurrentVersion(t *testing.T) {
|
||||
|
||||
// Simulate symlink with file content (works for both OsFs and MemMapFs)
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
err := fs.MkdirAll(secretDir, 0755)
|
||||
err := fs.MkdirAll(secretDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = afero.WriteFile(fs, currentPath, []byte("versions/20231216.001"), 0600)
|
||||
err = afero.WriteFile(fs, currentPath, []byte("versions/20231216.001"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err := GetCurrentVersion(fs, secretDir)
|
||||
@@ -303,7 +312,7 @@ func TestSetCurrentVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
|
||||
err := fs.MkdirAll(secretDir, 0755)
|
||||
err := fs.MkdirAll(secretDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current version
|
||||
|
||||
@@ -8,16 +8,13 @@ import (
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
// Create a temporary directory for our tests
|
||||
tempDir, err := os.MkdirTemp("", "secret-test-")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir) // Clean up after test
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Use the real filesystem
|
||||
fs := afero.NewOsFs()
|
||||
@@ -25,33 +22,14 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
// Test mnemonic
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// Save original environment variables
|
||||
oldMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
oldPassphrase := os.Getenv(secret.EnvUnlockPassphrase)
|
||||
|
||||
// Set test environment variables
|
||||
os.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
os.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
if oldMnemonic != "" {
|
||||
os.Setenv(secret.EnvMnemonic, oldMnemonic)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvMnemonic)
|
||||
}
|
||||
|
||||
if oldPassphrase != "" {
|
||||
os.Setenv(secret.EnvUnlockPassphrase, oldPassphrase)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvUnlockPassphrase)
|
||||
}
|
||||
}()
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||
|
||||
// Test symlink handling
|
||||
t.Run("SymlinkHandling", func(t *testing.T) {
|
||||
stateDir := filepath.Join(tempDir, "symlink-test")
|
||||
if err := os.MkdirAll(stateDir, 0700); err != nil {
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
@@ -98,7 +76,7 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
// Test secret operations with deeply nested paths
|
||||
t.Run("DeepPathSecrets", func(t *testing.T) {
|
||||
stateDir := filepath.Join(tempDir, "deep-path-test")
|
||||
if err := os.MkdirAll(stateDir, 0700); err != nil {
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
@@ -130,8 +108,13 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
// Create a secret with a deeply nested path
|
||||
deepPath := "api/credentials/production/database/primary"
|
||||
secretValue := []byte("supersecretdbpassword")
|
||||
expectedValue := make([]byte, len(secretValue))
|
||||
copy(expectedValue, secretValue)
|
||||
|
||||
err = vlt.AddSecret(deepPath, secretValue, false)
|
||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
err = vlt.AddSecret(deepPath, secretBuffer, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add secret with deep path: %v", err)
|
||||
}
|
||||
@@ -160,16 +143,16 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
||||
}
|
||||
|
||||
if string(retrievedValue) != string(secretValue) {
|
||||
if string(retrievedValue) != string(expectedValue) {
|
||||
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
||||
string(secretValue), string(retrievedValue))
|
||||
string(expectedValue), string(retrievedValue))
|
||||
}
|
||||
})
|
||||
|
||||
// Test key caching in GetOrDeriveLongTermKey
|
||||
t.Run("KeyCaching", func(t *testing.T) {
|
||||
stateDir := filepath.Join(tempDir, "key-cache-test")
|
||||
if err := os.MkdirAll(stateDir, 0700); err != nil {
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
@@ -251,7 +234,7 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
// Test vault name validation
|
||||
t.Run("VaultNameValidation", func(t *testing.T) {
|
||||
stateDir := filepath.Join(tempDir, "name-validation-test")
|
||||
if err := os.MkdirAll(stateDir, 0700); err != nil {
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
@@ -291,7 +274,7 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
// Test multiple vaults and switching between them
|
||||
t.Run("MultipleVaults", func(t *testing.T) {
|
||||
stateDir := filepath.Join(tempDir, "multi-vault-test")
|
||||
if err := os.MkdirAll(stateDir, 0700); err != nil {
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
@@ -336,7 +319,7 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
// Test adding a secret in one vault and verifying it's not visible in another
|
||||
t.Run("VaultIsolation", func(t *testing.T) {
|
||||
stateDir := filepath.Join(tempDir, "isolation-test")
|
||||
if err := os.MkdirAll(stateDir, 0700); err != nil {
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
@@ -391,7 +374,11 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
// Add a secret to vault1
|
||||
secretName := "test-secret"
|
||||
secretValue := []byte("secret in vault1")
|
||||
if err := vault1.AddSecret(secretName, secretValue, false); err != nil {
|
||||
|
||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
if err := vault1.AddSecret(secretName, secretBuffer, false); err != nil {
|
||||
t.Fatalf("Failed to add secret to vault1: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,18 +29,30 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helper function to add a secret to vault with proper buffer protection
|
||||
func addTestSecret(t *testing.T, vault *Vault, name string, value []byte, force bool) {
|
||||
t.Helper()
|
||||
buffer := memguard.NewBufferFromBytes(value)
|
||||
defer buffer.Destroy()
|
||||
err := vault.AddSecret(name, buffer, force)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestVersionIntegrationWorkflow tests the complete version workflow
|
||||
func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set mnemonic for testing
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
t.Setenv(secret.EnvMnemonic,
|
||||
"abandon abandon abandon abandon abandon abandon "+
|
||||
"abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vault, err := CreateVault(fs, stateDir, "test")
|
||||
@@ -54,7 +66,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
// Store long-term public key in vault
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unlock the vault
|
||||
@@ -64,8 +76,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
|
||||
// Step 1: Create initial version
|
||||
t.Run("create_initial_version", func(t *testing.T) {
|
||||
err := vault.AddSecret(secretName, []byte("version-1-data"), false)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vault, secretName, []byte("version-1-data"), false)
|
||||
|
||||
// Verify secret can be retrieved
|
||||
value, err := vault.GetSecret(secretName)
|
||||
@@ -84,7 +95,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
assert.Equal(t, versions[0], currentVersion)
|
||||
|
||||
// Verify metadata
|
||||
version := secret.NewSecretVersion(vault, secretName, versions[0])
|
||||
version := secret.NewVersion(vault, secretName, versions[0])
|
||||
err = version.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, version.Metadata.CreatedAt)
|
||||
@@ -106,8 +117,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
firstVersionName = versions[0]
|
||||
|
||||
// Create second version
|
||||
err = vault.AddSecret(secretName, []byte("version-2-data"), true)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vault, secretName, []byte("version-2-data"), true)
|
||||
|
||||
// Verify new value is current
|
||||
value, err := vault.GetSecret(secretName)
|
||||
@@ -120,13 +130,13 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
assert.Len(t, versions, 2)
|
||||
|
||||
// Verify first version metadata was updated with notAfter
|
||||
firstVersion := secret.NewSecretVersion(vault, secretName, firstVersionName)
|
||||
firstVersion := secret.NewVersion(vault, secretName, firstVersionName)
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
||||
|
||||
// Verify second version metadata
|
||||
secondVersion := secret.NewSecretVersion(vault, secretName, versions[0])
|
||||
secondVersion := secret.NewVersion(vault, secretName, versions[0])
|
||||
err = secondVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
||||
@@ -140,8 +150,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
t.Run("create_third_version", func(t *testing.T) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err := vault.AddSecret(secretName, []byte("version-3-data"), true)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vault, secretName, []byte("version-3-data"), true)
|
||||
|
||||
// Verify we now have three versions
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
@@ -199,7 +208,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
|
||||
// Verify the version metadata hasn't changed
|
||||
// (promoting shouldn't modify timestamps)
|
||||
version := secret.NewSecretVersion(vault, secretName, oldestVersion)
|
||||
version := secret.NewVersion(vault, secretName, oldestVersion)
|
||||
err = version.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, version.Metadata.NotAfter) // should still have its old notAfter
|
||||
@@ -212,8 +221,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
|
||||
|
||||
// Create 998 versions (we already have one from the first AddSecret)
|
||||
err := vault.AddSecret(limitSecretName, []byte("initial"), false)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vault, limitSecretName, []byte("initial"), false)
|
||||
|
||||
// Get today's date for consistent version names
|
||||
today := time.Now().Format("20060102")
|
||||
@@ -222,7 +230,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
for i := 2; i <= 998; i++ {
|
||||
versionName := fmt.Sprintf("%s.%03d", today, i)
|
||||
versionDir := filepath.Join(secretDir, versionName)
|
||||
err := fs.MkdirAll(versionDir, 0755)
|
||||
err := fs.MkdirAll(versionDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -232,7 +240,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
assert.Equal(t, fmt.Sprintf("%s.999", today), versionName)
|
||||
|
||||
// Create the 999th version directory
|
||||
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0755)
|
||||
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should fail to create 1000th version
|
||||
@@ -253,7 +261,9 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
|
||||
// Try to add secret without force when it exists
|
||||
err = vault.AddSecret(secretName, []byte("should-fail"), false)
|
||||
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
|
||||
defer failBuffer.Destroy()
|
||||
err = vault.AddSecret(secretName, failBuffer, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
})
|
||||
@@ -270,15 +280,14 @@ func TestVersionConcurrency(t *testing.T) {
|
||||
secretName := "concurrent/test"
|
||||
|
||||
// Create initial version
|
||||
err := vault.AddSecret(secretName, []byte("initial"), false)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vault, secretName, []byte("initial"), false)
|
||||
|
||||
// Test concurrent reads
|
||||
t.Run("concurrent_reads", func(t *testing.T) {
|
||||
done := make(chan bool, 10)
|
||||
errors := make(chan error, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
go func() {
|
||||
value, err := vault.GetSecret(secretName)
|
||||
if err != nil {
|
||||
@@ -291,7 +300,7 @@ func TestVersionConcurrency(t *testing.T) {
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
<-done
|
||||
}
|
||||
|
||||
@@ -319,17 +328,19 @@ func TestVersionCompatibility(t *testing.T) {
|
||||
secretName := "legacy/secret"
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "legacy%secret")
|
||||
err = fs.MkdirAll(secretDir, 0755)
|
||||
err = fs.MkdirAll(secretDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create old-style encrypted value directly in secret directory
|
||||
testValue := []byte("legacy-value")
|
||||
testValueBuffer := memguard.NewBufferFromBytes(testValue)
|
||||
defer testValueBuffer.Destroy()
|
||||
ltRecipient := ltIdentity.Recipient()
|
||||
encrypted, err := secret.EncryptToRecipient(testValue, ltRecipient)
|
||||
encrypted, err := secret.EncryptToRecipient(testValueBuffer, ltRecipient)
|
||||
require.NoError(t, err)
|
||||
|
||||
valuePath := filepath.Join(secretDir, "value.age")
|
||||
err = afero.WriteFile(fs, valuePath, encrypted, 0600)
|
||||
err = afero.WriteFile(fs, valuePath, encrypted, 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should fail to get with version-aware methods
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package vault provides functionality for managing encrypted vaults.
|
||||
package vault
|
||||
|
||||
import (
|
||||
@@ -26,67 +27,66 @@ func isValidVaultName(name string) bool {
|
||||
return false
|
||||
}
|
||||
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
// resolveRelativeSymlink resolves a relative symlink target to an absolute path
|
||||
func resolveRelativeSymlink(symlinkPath, _ string) (string, error) {
|
||||
// Get the current directory before changing
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get current directory: %w", err)
|
||||
}
|
||||
secret.Debug("Got current directory", "original_dir", originalDir)
|
||||
|
||||
// Change to the symlink's directory
|
||||
symlinkDir := filepath.Dir(symlinkPath)
|
||||
secret.Debug("Changing to symlink directory", "symlink_path", symlinkDir)
|
||||
secret.Debug("About to call os.Chdir - this might hang if symlink is broken")
|
||||
if err := os.Chdir(symlinkDir); err != nil {
|
||||
return "", fmt.Errorf("failed to change to symlink directory: %w", err)
|
||||
}
|
||||
secret.Debug("Changed to symlink directory successfully - os.Chdir completed")
|
||||
|
||||
// Get the absolute path of the target
|
||||
secret.Debug("Getting absolute path of current directory")
|
||||
absolutePath, err := os.Getwd()
|
||||
if err != nil {
|
||||
// Try to restore original directory before returning error
|
||||
_ = os.Chdir(originalDir)
|
||||
|
||||
return "", fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
secret.Debug("Got absolute path", "absolute_path", absolutePath)
|
||||
|
||||
// Restore the original directory
|
||||
secret.Debug("Restoring original directory", "original_dir", originalDir)
|
||||
if err := os.Chdir(originalDir); err != nil {
|
||||
return "", fmt.Errorf("failed to restore original directory: %w", err)
|
||||
}
|
||||
secret.Debug("Restored original directory successfully")
|
||||
|
||||
return absolutePath, nil
|
||||
}
|
||||
|
||||
// ResolveVaultSymlink resolves the currentvault symlink by reading either the symlink target or file contents
|
||||
// This function is designed to work on both Unix and Windows systems, as well as with in-memory filesystems
|
||||
func ResolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
|
||||
secret.Debug("resolveVaultSymlink starting", "symlink_path", symlinkPath)
|
||||
|
||||
// First try to handle the path as a real symlink (works on Unix systems)
|
||||
if _, ok := fs.(*afero.OsFs); ok {
|
||||
secret.Debug("Using real filesystem symlink resolution")
|
||||
|
||||
// Check if the symlink exists
|
||||
secret.Debug("Checking symlink target", "symlink_path", symlinkPath)
|
||||
target, err := os.Readlink(symlinkPath)
|
||||
_, isOsFs := fs.(*afero.OsFs)
|
||||
if isOsFs {
|
||||
target, err := tryResolveOsSymlink(symlinkPath)
|
||||
if err == nil {
|
||||
secret.Debug("Symlink points to", "target", target)
|
||||
|
||||
// On real filesystem, we need to handle relative symlinks
|
||||
// by resolving them relative to the symlink's directory
|
||||
if !filepath.IsAbs(target) {
|
||||
// Get the current directory before changing
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get current directory: %w", err)
|
||||
}
|
||||
secret.Debug("Got current directory", "original_dir", originalDir)
|
||||
|
||||
// Change to the symlink's directory
|
||||
symlinkDir := filepath.Dir(symlinkPath)
|
||||
secret.Debug("Changing to symlink directory", "symlink_path", symlinkDir)
|
||||
secret.Debug("About to call os.Chdir - this might hang if symlink is broken")
|
||||
if err := os.Chdir(symlinkDir); err != nil {
|
||||
return "", fmt.Errorf("failed to change to symlink directory: %w", err)
|
||||
}
|
||||
secret.Debug("Changed to symlink directory successfully - os.Chdir completed")
|
||||
|
||||
// Get the absolute path of the target
|
||||
secret.Debug("Getting absolute path of current directory")
|
||||
absolutePath, err := os.Getwd()
|
||||
if err != nil {
|
||||
// Try to restore original directory before returning error
|
||||
_ = os.Chdir(originalDir)
|
||||
return "", fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
secret.Debug("Got absolute path", "absolute_path", absolutePath)
|
||||
|
||||
// Restore the original directory
|
||||
secret.Debug("Restoring original directory", "original_dir", originalDir)
|
||||
if err := os.Chdir(originalDir); err != nil {
|
||||
return "", fmt.Errorf("failed to restore original directory: %w", err)
|
||||
}
|
||||
secret.Debug("Restored original directory successfully")
|
||||
|
||||
// Use the absolute path of the target
|
||||
target = absolutePath
|
||||
}
|
||||
|
||||
secret.Debug("resolveVaultSymlink completed successfully", "result", target)
|
||||
|
||||
return target, nil
|
||||
}
|
||||
// Fall through to fallback if symlink resolution failed
|
||||
} else {
|
||||
secret.Debug("Not using OS filesystem, skipping symlink resolution")
|
||||
}
|
||||
|
||||
// Fallback: treat it as a regular file containing the target path
|
||||
@@ -95,6 +95,7 @@ func ResolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
|
||||
fileData, err := afero.ReadFile(fs, symlinkPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read target path file", "error", err)
|
||||
|
||||
return "", fmt.Errorf("failed to read vault symlink: %w", err)
|
||||
}
|
||||
|
||||
@@ -102,6 +103,29 @@ func ResolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
|
||||
secret.Debug("Read target path from file", "target", target)
|
||||
|
||||
secret.Debug("resolveVaultSymlink completed via fallback", "result", target)
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// tryResolveOsSymlink attempts to resolve a symlink on OS filesystems
|
||||
func tryResolveOsSymlink(symlinkPath string) (string, error) {
|
||||
secret.Debug("Using real filesystem symlink resolution")
|
||||
|
||||
// Check if the symlink exists
|
||||
secret.Debug("Checking symlink target", "symlink_path", symlinkPath)
|
||||
target, err := os.Readlink(symlinkPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
secret.Debug("Symlink points to", "target", target)
|
||||
|
||||
// On real filesystem, we need to handle relative symlinks
|
||||
// by resolving them relative to the symlink's directory
|
||||
if !filepath.IsAbs(target) {
|
||||
return resolveRelativeSymlink(symlinkPath, target)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
@@ -116,6 +140,7 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
||||
_, err := fs.Stat(currentVaultPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
|
||||
}
|
||||
|
||||
@@ -171,6 +196,54 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
||||
return vaults, nil
|
||||
}
|
||||
|
||||
// processMnemonicForVault handles mnemonic processing for vault creation
|
||||
func processMnemonicForVault(fs afero.Fs, stateDir, vaultDir, vaultName string) (
|
||||
derivationIndex uint32, publicKeyHash string, familyHash string, err error) {
|
||||
// Check if mnemonic is available in environment
|
||||
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
|
||||
if mnemonic == "" {
|
||||
secret.Debug("No mnemonic in environment, vault created without long-term key", "vault", vaultName)
|
||||
// Use 0 for derivation index when no mnemonic is provided
|
||||
return 0, "", "", nil
|
||||
}
|
||||
|
||||
secret.Debug("Mnemonic found in environment, deriving long-term key", "vault", vaultName)
|
||||
|
||||
// Get the next available derivation index for this mnemonic
|
||||
derivationIndex, err = GetNextDerivationIndex(fs, stateDir, mnemonic)
|
||||
if err != nil {
|
||||
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
|
||||
// Derive the long-term key using the actual derivation index
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||
if err != nil {
|
||||
return 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Write the public key
|
||||
ltPubKey := ltIdentity.Recipient().String()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms); err != nil {
|
||||
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
|
||||
}
|
||||
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
// Compute verification hash from actual derivation index
|
||||
publicKeyHash = ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||
|
||||
// Compute family hash from index 0 (same for all vaults with this mnemonic)
|
||||
// This is used to identify which vaults belong to the same mnemonic family
|
||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
familyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
return derivationIndex, publicKeyHash, familyHash, nil
|
||||
}
|
||||
|
||||
// CreateVault creates a new vault
|
||||
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
||||
@@ -178,6 +251,7 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||
// Validate vault name
|
||||
if !isValidVaultName(name) {
|
||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||
|
||||
return nil, fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name)
|
||||
}
|
||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||
@@ -203,53 +277,18 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if mnemonic is available in environment
|
||||
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
var derivationIndex uint32
|
||||
var publicKeyHash string
|
||||
|
||||
if mnemonic != "" {
|
||||
secret.Debug("Mnemonic found in environment, deriving long-term key", "vault", name)
|
||||
|
||||
// Get the next available derivation index for this mnemonic
|
||||
var err error
|
||||
derivationIndex, err = GetNextDerivationIndex(fs, stateDir, mnemonic)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
|
||||
// Derive the long-term key using the actual derivation index
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Write the public key
|
||||
ltPubKey := ltIdentity.Recipient().String()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write long-term public key: %w", err)
|
||||
}
|
||||
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
// Compute public key hash from index 0 (same for all vaults with this mnemonic)
|
||||
// This is used to identify which vaults belong to the same mnemonic family
|
||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
publicKeyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
} else {
|
||||
secret.Debug("No mnemonic in environment, vault created without long-term key", "vault", name)
|
||||
// Use 0 for derivation index when no mnemonic is provided
|
||||
derivationIndex = 0
|
||||
// Process mnemonic if available
|
||||
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(fs, stateDir, vaultDir, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Save vault metadata
|
||||
metadata := &VaultMetadata{
|
||||
CreatedAt: time.Now(),
|
||||
DerivationIndex: derivationIndex,
|
||||
PublicKeyHash: publicKeyHash,
|
||||
metadata := &Metadata{
|
||||
CreatedAt: time.Now(),
|
||||
DerivationIndex: derivationIndex,
|
||||
PublicKeyHash: publicKeyHash,
|
||||
MnemonicFamilyHash: familyHash,
|
||||
}
|
||||
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
|
||||
@@ -263,6 +302,7 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||
|
||||
// Create and return the vault
|
||||
secret.Debug("Successfully created vault", "name", name)
|
||||
|
||||
return NewVault(fs, stateDir, name), nil
|
||||
}
|
||||
|
||||
@@ -273,6 +313,7 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||
// Validate vault name
|
||||
if !isValidVaultName(name) {
|
||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||
|
||||
return fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name)
|
||||
}
|
||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||
@@ -304,6 +345,7 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||
secret.Debug("Creating vault symlink", "target", targetPath, "link", currentVaultPath)
|
||||
if err := os.Symlink(targetPath, currentVaultPath); err == nil {
|
||||
secret.Debug("Successfully selected vault", "vault_name", name)
|
||||
|
||||
return nil
|
||||
}
|
||||
// If symlink creation fails, fall back to regular file
|
||||
@@ -316,5 +358,6 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||
}
|
||||
|
||||
secret.Debug("Successfully selected vault", "vault_name", name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,16 +12,23 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// Alias the metadata types from secret package for convenience
|
||||
type VaultMetadata = secret.VaultMetadata
|
||||
// Metadata is an alias for secret.VaultMetadata
|
||||
type Metadata = secret.VaultMetadata
|
||||
|
||||
// UnlockerMetadata is an alias for secret.UnlockerMetadata
|
||||
type UnlockerMetadata = secret.UnlockerMetadata
|
||||
type SecretMetadata = secret.SecretMetadata
|
||||
|
||||
// SecretMetadata is an alias for secret.Metadata
|
||||
type SecretMetadata = secret.Metadata
|
||||
|
||||
// Configuration is an alias for secret.Configuration
|
||||
type Configuration = secret.Configuration
|
||||
|
||||
// ComputeDoubleSHA256 computes the double SHA256 hash of data and returns it as hex
|
||||
func ComputeDoubleSHA256(data []byte) string {
|
||||
firstHash := sha256.Sum256(data)
|
||||
secondHash := sha256.Sum256(firstHash[:])
|
||||
|
||||
return hex.EncodeToString(secondHash[:])
|
||||
}
|
||||
|
||||
@@ -69,20 +76,20 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint
|
||||
continue
|
||||
}
|
||||
|
||||
var metadata VaultMetadata
|
||||
var metadata Metadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
// Skip vaults with invalid metadata
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this vault uses the same mnemonic by comparing public key hashes
|
||||
if metadata.PublicKeyHash == pubKeyHash {
|
||||
// Check if this vault uses the same mnemonic by comparing family hashes
|
||||
if metadata.MnemonicFamilyHash == pubKeyHash {
|
||||
usedIndices[metadata.DerivationIndex] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Find the first available index
|
||||
var index uint32 = 0
|
||||
var index uint32
|
||||
for usedIndices[index] {
|
||||
index++
|
||||
}
|
||||
@@ -91,7 +98,7 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint
|
||||
}
|
||||
|
||||
// SaveVaultMetadata saves vault metadata to the vault directory
|
||||
func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *VaultMetadata) error {
|
||||
func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *Metadata) error {
|
||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||
|
||||
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
||||
@@ -107,7 +114,7 @@ func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *VaultMetadata) er
|
||||
}
|
||||
|
||||
// LoadVaultMetadata loads vault metadata from the vault directory
|
||||
func LoadVaultMetadata(fs afero.Fs, vaultDir string) (*VaultMetadata, error) {
|
||||
func LoadVaultMetadata(fs afero.Fs, vaultDir string) (*Metadata, error) {
|
||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||
|
||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||
@@ -115,7 +122,7 @@ func LoadVaultMetadata(fs afero.Fs, vaultDir string) (*VaultMetadata, error) {
|
||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata VaultMetadata
|
||||
var metadata Metadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal vault metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"path/filepath"
|
||||
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/spf13/afero"
|
||||
@@ -53,7 +51,7 @@ func TestVaultMetadata(t *testing.T) {
|
||||
|
||||
// Create a vault with metadata and matching public key
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", "vault1")
|
||||
if err := fs.MkdirAll(vaultDir, 0700); err != nil {
|
||||
if err := fs.MkdirAll(vaultDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
@@ -66,13 +64,14 @@ func TestVaultMetadata(t *testing.T) {
|
||||
pubKeyHash0 := ComputeDoubleSHA256([]byte(pubKey0))
|
||||
|
||||
// Write public key
|
||||
if err := afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"), []byte(pubKey0), 0600); err != nil {
|
||||
if err := afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"), []byte(pubKey0), 0o600); err != nil {
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
|
||||
metadata1 := &VaultMetadata{
|
||||
DerivationIndex: 0,
|
||||
PublicKeyHash: pubKeyHash0,
|
||||
metadata1 := &Metadata{
|
||||
DerivationIndex: 0,
|
||||
PublicKeyHash: pubKeyHash0, // Hash of the actual key (index 0)
|
||||
MnemonicFamilyHash: pubKeyHash0, // Hash of index 0 key (for family identification)
|
||||
}
|
||||
if err := SaveVaultMetadata(fs, vaultDir, metadata1); err != nil {
|
||||
t.Fatalf("Failed to save metadata: %v", err)
|
||||
@@ -99,7 +98,7 @@ func TestVaultMetadata(t *testing.T) {
|
||||
|
||||
// Add another vault with same mnemonic but higher index
|
||||
vaultDir2 := filepath.Join(stateDir, "vaults.d", "vault2")
|
||||
if err := fs.MkdirAll(vaultDir2, 0700); err != nil {
|
||||
if err := fs.MkdirAll(vaultDir2, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
@@ -111,13 +110,17 @@ func TestVaultMetadata(t *testing.T) {
|
||||
pubKey5 := identity5.Recipient().String()
|
||||
|
||||
// Write public key
|
||||
if err := afero.WriteFile(fs, filepath.Join(vaultDir2, "pub.age"), []byte(pubKey5), 0600); err != nil {
|
||||
if err := afero.WriteFile(fs, filepath.Join(vaultDir2, "pub.age"), []byte(pubKey5), 0o600); err != nil {
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
|
||||
metadata2 := &VaultMetadata{
|
||||
DerivationIndex: 5,
|
||||
PublicKeyHash: pubKeyHash0, // Same hash since it's from the same mnemonic
|
||||
// Compute the hash for index 5 key
|
||||
pubKeyHash5 := ComputeDoubleSHA256([]byte(pubKey5))
|
||||
|
||||
metadata2 := &Metadata{
|
||||
DerivationIndex: 5,
|
||||
PublicKeyHash: pubKeyHash5, // Hash of the actual key (index 5)
|
||||
MnemonicFamilyHash: pubKeyHash0, // Same family hash since it's from the same mnemonic
|
||||
}
|
||||
if err := SaveVaultMetadata(fs, vaultDir2, metadata2); err != nil {
|
||||
t.Fatalf("Failed to save metadata: %v", err)
|
||||
@@ -135,12 +138,12 @@ func TestVaultMetadata(t *testing.T) {
|
||||
|
||||
t.Run("MetadataPersistence", func(t *testing.T) {
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", "test-vault")
|
||||
if err := fs.MkdirAll(vaultDir, 0700); err != nil {
|
||||
if err := fs.MkdirAll(vaultDir, 0o700); err != nil {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Create and save metadata
|
||||
metadata := &VaultMetadata{
|
||||
metadata := &Metadata{
|
||||
DerivationIndex: 3,
|
||||
PublicKeyHash: "test-public-key-hash",
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,7 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory for secret listing", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -30,10 +32,12 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
||||
exists, err := afero.DirExists(v.fs, secretsDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check secrets directory", "error", err, "secrets_dir", secretsDir)
|
||||
|
||||
return nil, fmt.Errorf("failed to check if secrets directory exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
secret.Debug("Secrets directory does not exist", "secrets_dir", secretsDir, "vault_name", v.Name)
|
||||
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
@@ -41,6 +45,7 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
||||
files, err := afero.ReadDir(v.fs, secretsDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read secrets directory", "error", err, "secrets_dir", secretsDir)
|
||||
|
||||
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -89,21 +94,27 @@ func isValidSecretName(name string) bool {
|
||||
|
||||
// Check the basic pattern
|
||||
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_\/]+$`, name)
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
// AddSecret adds a secret to this vault
|
||||
func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
|
||||
if value == nil {
|
||||
return fmt.Errorf("value buffer is nil")
|
||||
}
|
||||
|
||||
secret.DebugWith("Adding secret to vault",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("value_length", len(value)),
|
||||
slog.Int("value_length", value.Size()),
|
||||
slog.Bool("force", force),
|
||||
)
|
||||
|
||||
// Validate secret name
|
||||
if !isValidSecretName(name) {
|
||||
secret.Debug("Invalid secret name provided", "secret_name", name)
|
||||
|
||||
return fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
|
||||
}
|
||||
secret.Debug("Secret name validation passed", "secret_name", name)
|
||||
@@ -112,6 +123,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory for secret addition", "error", err, "vault_name", v.Name)
|
||||
|
||||
return err
|
||||
}
|
||||
secret.Debug("Got vault directory", "vault_dir", vaultDir)
|
||||
@@ -130,24 +142,26 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
exists, err := afero.DirExists(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if secret exists", "error", err, "secret_dir", secretDir)
|
||||
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
secret.Debug("Secret existence check complete", "exists", exists)
|
||||
|
||||
// Handle existing secret case
|
||||
now := time.Now()
|
||||
var previousVersion *secret.SecretVersion
|
||||
var previousVersion *secret.Version
|
||||
|
||||
if exists {
|
||||
if !force {
|
||||
secret.Debug("Secret already exists and force not specified", "secret_name", name, "secret_dir", secretDir)
|
||||
|
||||
return fmt.Errorf("secret %s already exists (use --force to overwrite)", name)
|
||||
}
|
||||
|
||||
// Get the current version to update its notAfter timestamp
|
||||
currentVersionName, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||
if err == nil && currentVersionName != "" {
|
||||
previousVersion = secret.NewSecretVersion(v, name, currentVersionName)
|
||||
previousVersion = secret.NewVersion(v, name, currentVersionName)
|
||||
// We'll need to load and update its metadata after we unlock the vault
|
||||
}
|
||||
} else {
|
||||
@@ -155,6 +169,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
secret.Debug("Creating secret directory", "secret_dir", secretDir)
|
||||
if err := v.fs.MkdirAll(secretDir, secret.DirPerms); err != nil {
|
||||
secret.Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir)
|
||||
|
||||
return fmt.Errorf("failed to create secret directory: %w", err)
|
||||
}
|
||||
secret.Debug("Created secret directory successfully")
|
||||
@@ -164,13 +179,14 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to generate version name", "error", err, "secret_name", name)
|
||||
|
||||
return fmt.Errorf("failed to generate version name: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Generated new version name", "version", versionName, "secret_name", name)
|
||||
|
||||
// Create new version
|
||||
newVersion := secret.NewSecretVersion(v, name, versionName)
|
||||
newVersion := secret.NewVersion(v, name, versionName)
|
||||
|
||||
// Set version timestamps
|
||||
if previousVersion == nil {
|
||||
@@ -184,9 +200,10 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
// We'll update the previous version's notAfter after we save the new version
|
||||
}
|
||||
|
||||
// Save the new version
|
||||
// Save the new version - pass the LockedBuffer directly
|
||||
if err := newVersion.Save(value); err != nil {
|
||||
secret.Debug("Failed to save new version", "error", err, "version", versionName)
|
||||
|
||||
return fmt.Errorf("failed to save version: %w", err)
|
||||
}
|
||||
|
||||
@@ -196,12 +213,14 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get long-term key for metadata update", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Load previous version metadata
|
||||
if err := previousVersion.LoadMetadata(ltIdentity); err != nil {
|
||||
secret.Debug("Failed to load previous version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -211,6 +230,7 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
// Re-save the metadata (we need to implement an update method)
|
||||
if err := updateVersionMetadata(v.fs, previousVersion, ltIdentity); err != nil {
|
||||
secret.Debug("Failed to update previous version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to update previous version metadata: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -218,15 +238,19 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
// Set current symlink to new version
|
||||
if err := secret.SetCurrentVersion(v.fs, secretDir, versionName); err != nil {
|
||||
secret.Debug("Failed to set current version", "error", err, "version", versionName)
|
||||
|
||||
return fmt.Errorf("failed to set current version: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Successfully added secret version to vault", "secret_name", name, "version", versionName, "vault_name", v.Name)
|
||||
secret.Debug("Successfully added secret version to vault",
|
||||
"secret_name", name, "version", versionName,
|
||||
"vault_name", v.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateVersionMetadata updates the metadata of an existing version
|
||||
func updateVersionMetadata(fs afero.Fs, version *secret.SecretVersion, ltIdentity *age.X25519Identity) error {
|
||||
func updateVersionMetadata(fs afero.Fs, version *secret.Version, ltIdentity *age.X25519Identity) error {
|
||||
// Read the version's encrypted private key
|
||||
encryptedPrivKeyPath := filepath.Join(version.Directory, "priv.age")
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
@@ -253,7 +277,10 @@ func updateVersionMetadata(fs afero.Fs, version *secret.SecretVersion, ltIdentit
|
||||
}
|
||||
|
||||
// Encrypt metadata to the version's public key
|
||||
encryptedMetadata, err := secret.EncryptToRecipient(metadataBytes, versionIdentity.Recipient())
|
||||
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||
defer metadataBuffer.Destroy()
|
||||
|
||||
encryptedMetadata, err := secret.EncryptToRecipient(metadataBuffer, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||
}
|
||||
@@ -289,6 +316,7 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -300,10 +328,12 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||
exists, err := afero.DirExists(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
|
||||
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("secret %s not found", name)
|
||||
}
|
||||
|
||||
@@ -313,6 +343,7 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
version = currentVersion
|
||||
@@ -320,17 +351,19 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||
}
|
||||
|
||||
// Create version object
|
||||
secretVersion := secret.NewSecretVersion(v, name, version)
|
||||
secretVersion := secret.NewVersion(v, name, version)
|
||||
|
||||
// Check if version exists
|
||||
versionPath := filepath.Join(secretDir, "versions", version)
|
||||
exists, err = afero.DirExists(v.fs, versionPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if version exists", "error", err, "version", version)
|
||||
|
||||
return nil, fmt.Errorf("failed to check if version exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
secret.Debug("Version not found", "version", version, "secret_name", name)
|
||||
|
||||
return nil, fmt.Errorf("version %s not found for secret %s", version, name)
|
||||
}
|
||||
|
||||
@@ -340,6 +373,7 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||
longTermIdentity, err := v.UnlockVault()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to unlock vault", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to unlock vault: %w", err)
|
||||
}
|
||||
|
||||
@@ -351,9 +385,11 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||
)
|
||||
|
||||
// Get the version's value
|
||||
secret.Debug("About to call secretVersion.GetValue", "version", version, "secret_name", name)
|
||||
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to decrypt version value", "error", err, "version", version, "secret_name", name)
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
||||
}
|
||||
|
||||
@@ -364,6 +400,13 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||
slog.Int("decrypted_length", len(decryptedValue)),
|
||||
)
|
||||
|
||||
// Debug: Log metadata about the decrypted value without exposing the actual secret
|
||||
secret.Debug("Vault secret decryption debug info",
|
||||
"secret_name", name,
|
||||
"version", version,
|
||||
"decrypted_value_length", len(decryptedValue),
|
||||
"is_empty", len(decryptedValue) == 0)
|
||||
|
||||
return decryptedValue, nil
|
||||
}
|
||||
|
||||
@@ -374,6 +417,7 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
||||
// If vault is already unlocked, return the cached key
|
||||
if !v.Locked() {
|
||||
secret.Debug("Vault already unlocked, returning cached long-term key", "vault_name", v.Name)
|
||||
|
||||
return v.longTermKey, nil
|
||||
}
|
||||
|
||||
@@ -381,6 +425,7 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
||||
longTermIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get or derive long-term key", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,11 +24,21 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helper function to add a secret to vault with proper buffer protection
|
||||
func addTestSecretToVault(t *testing.T, vault *Vault, name string, value []byte, force bool) {
|
||||
t.Helper()
|
||||
buffer := memguard.NewBufferFromBytes(value)
|
||||
defer buffer.Destroy()
|
||||
err := vault.AddSecret(name, buffer, force)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Helper function to create a vault with long-term key set up
|
||||
func createTestVaultWithKey(t *testing.T, fs afero.Fs, stateDir, vaultName string) *Vault {
|
||||
// Set mnemonic for testing
|
||||
@@ -46,7 +56,7 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs, stateDir, vaultName strin
|
||||
// Store long-term public key in vault
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unlock the vault with the derived key
|
||||
@@ -65,9 +75,10 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
||||
// Add a secret
|
||||
secretName := "test/secret"
|
||||
secretValue := []byte("initial-value")
|
||||
expectedValue := make([]byte, len(secretValue))
|
||||
copy(expectedValue, secretValue)
|
||||
|
||||
err := vault.AddSecret(secretName, secretValue, false)
|
||||
require.NoError(t, err)
|
||||
addTestSecretToVault(t, vault, secretName, secretValue, false)
|
||||
|
||||
// Check that version directory was created
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
@@ -88,7 +99,7 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
||||
// Get the secret value
|
||||
retrievedValue, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, secretValue, retrievedValue)
|
||||
assert.Equal(t, expectedValue, retrievedValue)
|
||||
}
|
||||
|
||||
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
||||
@@ -101,17 +112,17 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
||||
secretName := "test/secret"
|
||||
|
||||
// Add first version
|
||||
err := vault.AddSecret(secretName, []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-1"), false)
|
||||
|
||||
// Try to add again without force - should fail
|
||||
err = vault.AddSecret(secretName, []byte("version-2"), false)
|
||||
failBuffer := memguard.NewBufferFromBytes([]byte("version-2"))
|
||||
defer failBuffer.Destroy()
|
||||
err := vault.AddSecret(secretName, failBuffer, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
|
||||
// Add with force - should create new version
|
||||
err = vault.AddSecret(secretName, []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
||||
|
||||
// Check that we have two versions
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
@@ -136,14 +147,12 @@ func TestVaultGetSecretVersion(t *testing.T) {
|
||||
secretName := "test/secret"
|
||||
|
||||
// Add multiple versions
|
||||
err := vault.AddSecret(secretName, []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-1"), false)
|
||||
|
||||
// Small delay to ensure different version names
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err = vault.AddSecret(secretName, []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
||||
|
||||
// Get versions list
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
@@ -185,7 +194,9 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
||||
|
||||
// Add first version
|
||||
beforeFirst := time.Now()
|
||||
err = vault.AddSecret(secretName, []byte("version-1"), false)
|
||||
v1Buffer := memguard.NewBufferFromBytes([]byte("version-1"))
|
||||
defer v1Buffer.Destroy()
|
||||
err = vault.AddSecret(secretName, v1Buffer, false)
|
||||
require.NoError(t, err)
|
||||
afterFirst := time.Now()
|
||||
|
||||
@@ -196,7 +207,7 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 1)
|
||||
|
||||
firstVersion := secret.NewSecretVersion(vault, secretName, versions[0])
|
||||
firstVersion := secret.NewVersion(vault, secretName, versions[0])
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -212,8 +223,7 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
||||
// Add second version
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
beforeSecond := time.Now()
|
||||
err = vault.AddSecret(secretName, []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
||||
afterSecond := time.Now()
|
||||
|
||||
// Get updated versions
|
||||
@@ -222,7 +232,7 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
// Reload first version metadata (should have notAfter now)
|
||||
firstVersion = secret.NewSecretVersion(vault, secretName, versions[1])
|
||||
firstVersion = secret.NewVersion(vault, secretName, versions[1])
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -231,7 +241,7 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
||||
assert.True(t, firstVersion.Metadata.NotAfter.Before(afterSecond.Add(time.Second)))
|
||||
|
||||
// Check second version timestamps
|
||||
secondVersion := secret.NewSecretVersion(vault, secretName, versions[0])
|
||||
secondVersion := secret.NewVersion(vault, secretName, versions[0])
|
||||
err = secondVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -249,11 +259,10 @@ func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
// Add a secret
|
||||
err := vault.AddSecret("test/secret", []byte("value"), false)
|
||||
require.NoError(t, err)
|
||||
addTestSecretToVault(t, vault, "test/secret", []byte("value"), false)
|
||||
|
||||
// Try to get non-existent version
|
||||
_, err = vault.GetSecretVersion("test/secret", "20991231.999")
|
||||
_, err := vault.GetSecretVersion("test/secret", "20991231.999")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
@@ -272,7 +281,7 @@ func TestUpdateVersionMetadata(t *testing.T) {
|
||||
// Create a version manually to test updateVersionMetadata
|
||||
secretName := "test/secret"
|
||||
versionName := "20231215.001"
|
||||
version := secret.NewSecretVersion(vault, secretName, versionName)
|
||||
version := secret.NewVersion(vault, secretName, versionName)
|
||||
|
||||
// Set initial metadata
|
||||
now := time.Now()
|
||||
@@ -281,7 +290,9 @@ func TestUpdateVersionMetadata(t *testing.T) {
|
||||
version.Metadata.NotAfter = nil
|
||||
|
||||
// Save version
|
||||
err = version.Save([]byte("test-value"))
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||
defer testBuffer.Destroy()
|
||||
err = version.Save(testBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update metadata
|
||||
@@ -290,7 +301,7 @@ func TestUpdateVersionMetadata(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load and verify
|
||||
version2 := secret.NewSecretVersion(vault, secretName, versionName)
|
||||
version2 := secret.NewVersion(vault, secretName, versionName)
|
||||
err = version2.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory for unlocker", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -29,28 +31,14 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
_, err = v.fs.Stat(currentUnlockerPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to stat current unlocker symlink", "error", err, "path", currentUnlockerPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
|
||||
}
|
||||
|
||||
// Resolve the symlink to get the target directory
|
||||
var unlockerDir string
|
||||
if _, ok := v.fs.(*afero.OsFs); ok {
|
||||
secret.Debug("Resolving unlocker symlink (real filesystem)")
|
||||
// For real filesystems, resolve the symlink properly
|
||||
unlockerDir, err = ResolveVaultSymlink(v.fs, currentUnlockerPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to resolve unlocker symlink", "error", err, "symlink_path", currentUnlockerPath)
|
||||
return nil, fmt.Errorf("failed to resolve current unlocker symlink: %w", err)
|
||||
}
|
||||
} else {
|
||||
secret.Debug("Reading unlocker path (mock filesystem)")
|
||||
// Fallback for mock filesystems: read the path from file contents
|
||||
unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read unlocker path file", "error", err, "path", currentUnlockerPath)
|
||||
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
|
||||
}
|
||||
unlockerDir = strings.TrimSpace(string(unlockerDirBytes))
|
||||
unlockerDir, err := v.resolveUnlockerDirectory(currentUnlockerPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secret.DebugWith("Resolved unlocker directory",
|
||||
@@ -65,12 +53,14 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read unlocker metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata UnlockerMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
secret.Debug("Failed to parse unlocker metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -82,20 +72,20 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
|
||||
// Create unlocker instance using direct constructors with filesystem
|
||||
var unlocker secret.Unlocker
|
||||
// Convert our metadata to secret.UnlockerMetadata
|
||||
secretMetadata := secret.UnlockerMetadata(metadata)
|
||||
// Use metadata directly as it's already the correct type
|
||||
switch metadata.Type {
|
||||
case "passphrase":
|
||||
secret.Debug("Creating passphrase unlocker instance", "unlocker_type", metadata.Type)
|
||||
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata)
|
||||
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||
case "pgp":
|
||||
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
||||
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, secretMetadata)
|
||||
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
|
||||
case "keychain":
|
||||
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
|
||||
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, secretMetadata)
|
||||
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata)
|
||||
default:
|
||||
secret.Debug("Unsupported unlocker type", "type", metadata.Type)
|
||||
|
||||
return nil, fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
|
||||
}
|
||||
|
||||
@@ -108,6 +98,97 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
return unlocker, nil
|
||||
}
|
||||
|
||||
// resolveUnlockerDirectory resolves the unlocker directory from a symlink or file
|
||||
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
|
||||
linkReader, ok := v.fs.(afero.LinkReader)
|
||||
if !ok {
|
||||
// Fallback for filesystems that don't support symlinks
|
||||
return v.readUnlockerPathFromFile(currentUnlockerPath)
|
||||
}
|
||||
|
||||
secret.Debug("Resolving unlocker symlink using afero")
|
||||
// Try to read as symlink first
|
||||
unlockerDir, err := linkReader.ReadlinkIfPossible(currentUnlockerPath)
|
||||
if err == nil {
|
||||
return unlockerDir, nil
|
||||
}
|
||||
|
||||
secret.Debug("Failed to read symlink, falling back to file contents",
|
||||
"error", err, "symlink_path", currentUnlockerPath)
|
||||
// Fallback: read the path from file contents
|
||||
return v.readUnlockerPathFromFile(currentUnlockerPath)
|
||||
}
|
||||
|
||||
// readUnlockerPathFromFile reads the unlocker directory path from a file
|
||||
func (v *Vault) readUnlockerPathFromFile(path string) (string, error) {
|
||||
secret.Debug("Reading unlocker path from file", "path", path)
|
||||
unlockerDirBytes, err := afero.ReadFile(v.fs, path)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read unlocker path file", "error", err, "path", path)
|
||||
|
||||
return "", fmt.Errorf("failed to read current unlocker: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(unlockerDirBytes)), nil
|
||||
}
|
||||
|
||||
// findUnlockerByID finds an unlocker by its ID and returns the unlocker instance and its directory path
|
||||
func (v *Vault) findUnlockerByID(unlockersDir, unlockerID string) (secret.Unlocker, string, error) {
|
||||
files, err := afero.ReadDir(v.fs, unlockersDir)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read metadata file
|
||||
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
||||
exists, err := afero.Exists(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
if !exists {
|
||||
// Skip directories without metadata - they might not be unlockers
|
||||
continue
|
||||
}
|
||||
|
||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
|
||||
var metadata UnlockerMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
|
||||
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
||||
|
||||
// Create the appropriate unlocker instance
|
||||
var tempUnlocker secret.Unlocker
|
||||
switch metadata.Type {
|
||||
case "passphrase":
|
||||
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
|
||||
case "pgp":
|
||||
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
|
||||
case "keychain":
|
||||
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this unlocker's ID matches
|
||||
if tempUnlocker.GetID() == unlockerID {
|
||||
return tempUnlocker, unlockerDirPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
// ListUnlockers returns a list of available unlockers for this vault
|
||||
func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
@@ -172,61 +253,10 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
||||
// Find the unlocker directory and create the unlocker instance
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
|
||||
// List directories in unlockers.d
|
||||
files, err := afero.ReadDir(v.fs, unlockersDir)
|
||||
// Find the unlocker by ID
|
||||
unlocker, _, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read unlockers directory: %w", err)
|
||||
}
|
||||
|
||||
var unlocker secret.Unlocker
|
||||
var unlockerDirPath string
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
// Read metadata file
|
||||
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
||||
exists, err := afero.Exists(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
if !exists {
|
||||
// Skip directories without metadata - they might not be unlockers
|
||||
continue
|
||||
}
|
||||
|
||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
|
||||
var metadata UnlockerMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
return fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
|
||||
unlockerDirPath = filepath.Join(unlockersDir, file.Name())
|
||||
|
||||
// Convert our metadata to secret.UnlockerMetadata
|
||||
secretMetadata := secret.UnlockerMetadata(metadata)
|
||||
|
||||
// Create the appropriate unlocker instance
|
||||
var tempUnlocker secret.Unlocker
|
||||
switch metadata.Type {
|
||||
case "passphrase":
|
||||
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, secretMetadata)
|
||||
case "pgp":
|
||||
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, secretMetadata)
|
||||
case "keychain":
|
||||
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, secretMetadata)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this unlocker's ID matches
|
||||
if tempUnlocker.GetID() == unlockerID {
|
||||
unlocker = tempUnlocker
|
||||
break
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if unlocker == nil {
|
||||
@@ -247,60 +277,10 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
||||
// Find the unlocker directory by ID
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
|
||||
// List directories in unlockers.d to find the unlocker
|
||||
files, err := afero.ReadDir(v.fs, unlockersDir)
|
||||
// Find the unlocker by ID
|
||||
_, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read unlockers directory: %w", err)
|
||||
}
|
||||
|
||||
var targetUnlockerDir string
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
// Read metadata file
|
||||
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
||||
exists, err := afero.Exists(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
if !exists {
|
||||
// Skip directories without metadata - they might not be unlockers
|
||||
continue
|
||||
}
|
||||
|
||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
|
||||
var metadata UnlockerMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
return fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||
}
|
||||
|
||||
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
||||
|
||||
// Convert our metadata to secret.UnlockerMetadata
|
||||
secretMetadata := secret.UnlockerMetadata(metadata)
|
||||
|
||||
// Create the appropriate unlocker instance
|
||||
var tempUnlocker secret.Unlocker
|
||||
switch metadata.Type {
|
||||
case "passphrase":
|
||||
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, secretMetadata)
|
||||
case "pgp":
|
||||
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, secretMetadata)
|
||||
case "keychain":
|
||||
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, secretMetadata)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this unlocker's ID matches
|
||||
if tempUnlocker.GetID() == unlockerID {
|
||||
targetUnlockerDir = unlockerDirPath
|
||||
break
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if targetUnlockerDir == "" {
|
||||
@@ -319,12 +299,26 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Create new symlink
|
||||
return afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms)
|
||||
// Create new symlink using afero's SymlinkIfPossible
|
||||
if linker, ok := v.fs.(afero.Linker); ok {
|
||||
secret.Debug("Creating unlocker symlink", "target", targetUnlockerDir, "link", currentUnlockerPath)
|
||||
if err := linker.SymlinkIfPossible(targetUnlockerDir, currentUnlockerPath); err != nil {
|
||||
return fmt.Errorf("failed to create unlocker symlink: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Fallback: create a regular file with the target path for filesystems that don't support symlinks
|
||||
secret.Debug("Fallback: creating regular file with target path", "target", targetUnlockerDir)
|
||||
if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to create unlocker symlink file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||
func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseUnlocker, error) {
|
||||
// The passphrase must be provided as a LockedBuffer for security
|
||||
func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*secret.PassphraseUnlocker, error) {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
@@ -344,13 +338,15 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseU
|
||||
|
||||
// Write public key
|
||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(unlockerIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
||||
if err := afero.WriteFile(v.fs, pubKeyPath,
|
||||
[]byte(unlockerIdentity.Recipient().String()),
|
||||
secret.FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write unlocker public key: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt private key with passphrase
|
||||
privKeyData := []byte(unlockerIdentity.String())
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphrase)
|
||||
privKeyStr := unlockerIdentity.String()
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase([]byte(privKeyStr), passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err)
|
||||
}
|
||||
@@ -386,8 +382,10 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseU
|
||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
ltPrivKey := []byte(ltIdentity.String())
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKey, unlockerIdentity.Recipient())
|
||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, unlockerIdentity.Recipient())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
||||
}
|
||||
@@ -397,11 +395,8 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseU
|
||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Convert our metadata to secret.UnlockerMetadata for the constructor
|
||||
secretMetadata := secret.UnlockerMetadata(metadata)
|
||||
|
||||
// Create the unlocker instance
|
||||
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata)
|
||||
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||
|
||||
// Select this unlocker as current
|
||||
if err := v.SelectUnlocker(unlocker.GetID()); err != nil {
|
||||
|
||||
@@ -30,6 +30,7 @@ func NewVault(fs afero.Fs, stateDir string, name string) *Vault {
|
||||
longTermKey: nil,
|
||||
}
|
||||
secret.Debug("Created NewVault instance successfully")
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -75,15 +76,29 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
metadata, err := LoadVaultMetadata(v.fs, vaultDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load vault metadata", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
||||
}
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to derive long-term key from mnemonic", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
// Verify that the derived key matches the stored public key hash
|
||||
derivedPubKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||
if derivedPubKeyHash != metadata.PublicKeyHash {
|
||||
secret.Debug("Derived public key hash does not match stored hash",
|
||||
"vault_name", v.Name,
|
||||
"derived_hash", derivedPubKeyHash,
|
||||
"stored_hash", metadata.PublicKeyHash,
|
||||
"derivation_index", metadata.DerivationIndex)
|
||||
|
||||
return nil, fmt.Errorf("derived public key does not match vault: mnemonic may be incorrect")
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully derived long-term key from mnemonic",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||
@@ -104,6 +119,7 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
unlocker, err := v.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||
}
|
||||
|
||||
@@ -117,6 +133,7 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
unlockerIdentity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get unlocker identity", "error", err, "unlocker_type", unlocker.GetType())
|
||||
|
||||
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
@@ -128,6 +145,7 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -142,6 +160,7 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
ltPrivKeyData, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlocker_type", unlocker.GetType())
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -156,6 +175,7 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
|
||||
if err != nil {
|
||||
secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -167,7 +187,8 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
|
||||
// Cache the derived key by unlocking the vault
|
||||
v.Unlock(ltIdentity)
|
||||
secret.Debug("Vault is unlocked (lt key in memory) via unlocker", "vault_name", v.Name, "unlocker_type", unlocker.GetType())
|
||||
secret.Debug("Vault is unlocked (lt key in memory) via unlocker",
|
||||
"vault_name", v.Name, "unlocker_type", unlocker.GetType())
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
@@ -1,39 +1,22 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func TestVaultOperations(t *testing.T) {
|
||||
// Save original environment variables
|
||||
oldMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
oldPassphrase := os.Getenv(secret.EnvUnlockPassphrase)
|
||||
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
if oldMnemonic != "" {
|
||||
os.Setenv(secret.EnvMnemonic, oldMnemonic)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvMnemonic)
|
||||
}
|
||||
|
||||
if oldPassphrase != "" {
|
||||
os.Setenv(secret.EnvUnlockPassphrase, oldPassphrase)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvUnlockPassphrase)
|
||||
}
|
||||
}()
|
||||
// Test environment will be cleaned up automatically by t.Setenv
|
||||
|
||||
// Set test environment variables
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
os.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
os.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
||||
|
||||
// Use in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
@@ -139,8 +122,13 @@ func TestVaultOperations(t *testing.T) {
|
||||
// Now add a secret
|
||||
secretName := "test/secret"
|
||||
secretValue := []byte("test-secret-value")
|
||||
expectedValue := make([]byte, len(secretValue))
|
||||
copy(expectedValue, secretValue)
|
||||
|
||||
err = vlt.AddSecret(secretName, secretValue, false)
|
||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
err = vlt.AddSecret(secretName, secretBuffer, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add secret: %v", err)
|
||||
}
|
||||
@@ -169,8 +157,8 @@ func TestVaultOperations(t *testing.T) {
|
||||
t.Fatalf("Failed to get secret: %v", err)
|
||||
}
|
||||
|
||||
if string(retrievedValue) != string(secretValue) {
|
||||
t.Errorf("Expected secret value '%s', got '%s'", string(secretValue), string(retrievedValue))
|
||||
if string(retrievedValue) != string(expectedValue) {
|
||||
t.Errorf("Expected secret value '%s', got '%s'", string(expectedValue), string(retrievedValue))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -190,7 +178,9 @@ func TestVaultOperations(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create a passphrase unlocker
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker("test-passphrase")
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||
defer passphraseBuffer.Destroy()
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
purpose = uint32(83696968) // fixed by BIP-85 ("bip")
|
||||
vendorID = uint32(592366788) // berlin.sneak
|
||||
appID = uint32(733482323) // secret
|
||||
hrp = "age-secret-key-" // Bech32 HRP used by age
|
||||
purpose = uint32(83696968) // fixed by BIP-85 ("bip")
|
||||
vendorID = uint32(592366788) // berlin.sneak
|
||||
appID = uint32(733482323) // secret
|
||||
hrp = "age-secret-key-" // Bech32 HRP used by age
|
||||
x25519KeySize = 32 // 256-bit key size for X25519
|
||||
)
|
||||
|
||||
// clamp applies RFC-7748 clamping to a 32-byte scalar.
|
||||
@@ -37,16 +38,20 @@ func clamp(k []byte) {
|
||||
// IdentityFromEntropy converts 32 deterministic bytes into an
|
||||
// *age.X25519Identity by round-tripping through Bech32.
|
||||
func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
||||
if len(ent) != 32 {
|
||||
if len(ent) != x25519KeySize {
|
||||
return nil, fmt.Errorf("need 32-byte scalar, got %d", len(ent))
|
||||
}
|
||||
|
||||
// Make a copy to avoid modifying the original
|
||||
key := make([]byte, 32)
|
||||
key := make([]byte, x25519KeySize)
|
||||
copy(key, ent)
|
||||
clamp(key)
|
||||
|
||||
data, err := bech32.ConvertBits(key, 8, 5, true)
|
||||
const (
|
||||
bech32BitSize8 = 8 // Standard 8-bit encoding
|
||||
bech32BitSize5 = 5 // Bech32 5-bit encoding
|
||||
)
|
||||
data, err := bech32.ConvertBits(key, bech32BitSize8, bech32BitSize5, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bech32 convert: %w", err)
|
||||
}
|
||||
@@ -54,6 +59,7 @@ func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bech32 encode: %w", err)
|
||||
}
|
||||
|
||||
return age.ParseX25519Identity(strings.ToUpper(s))
|
||||
}
|
||||
|
||||
@@ -80,7 +86,7 @@ func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) {
|
||||
|
||||
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
||||
drng := bip85.NewBIP85DRNG(entropy)
|
||||
key := make([]byte, 32)
|
||||
key := make([]byte, x25519KeySize)
|
||||
_, err = drng.Read(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
||||
@@ -109,7 +115,7 @@ func DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error) {
|
||||
|
||||
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
||||
drng := bip85.NewBIP85DRNG(entropy)
|
||||
key := make([]byte, 32)
|
||||
key := make([]byte, x25519KeySize)
|
||||
_, err = drng.Read(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
||||
@@ -125,6 +131,7 @@ func DeriveIdentity(mnemonic string, n uint32) (*age.X25519Identity, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return IdentityFromEntropy(ent)
|
||||
}
|
||||
|
||||
@@ -135,5 +142,6 @@ func DeriveIdentityFromXPRV(xprv string, n uint32) (*age.X25519Identity, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return IdentityFromEntropy(ent)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:lll // Test vectors contain long lines
|
||||
package agehd
|
||||
|
||||
import (
|
||||
@@ -188,9 +189,8 @@ func TestDeterministicXPRVDerivation(t *testing.T) {
|
||||
t.Logf("XPRV Index 1: %s", id3.String())
|
||||
}
|
||||
|
||||
func TestMnemonicVsXPRVConsistency(t *testing.T) {
|
||||
func TestMnemonicVsXPRVConsistency(_ *testing.T) {
|
||||
// FIXME This test is missing!
|
||||
|
||||
}
|
||||
|
||||
func TestEntropyLength(t *testing.T) {
|
||||
@@ -393,6 +393,7 @@ func TestIdentityFromEntropyEdgeCases(t *testing.T) {
|
||||
err,
|
||||
) // In test context, panic is acceptable for setup failures
|
||||
}
|
||||
|
||||
return b
|
||||
}(),
|
||||
expectError: false,
|
||||
@@ -661,9 +662,13 @@ func TestConcurrentDerivation(t *testing.T) {
|
||||
results := make(chan string, testNumGoroutines*testNumIterations)
|
||||
errors := make(chan error, testNumGoroutines*testNumIterations)
|
||||
|
||||
for i := 0; i < testNumGoroutines; i++ {
|
||||
go func(goroutineID int) {
|
||||
for j := 0; j < testNumIterations; j++ {
|
||||
for range testNumGoroutines {
|
||||
go func() {
|
||||
for j := range testNumIterations {
|
||||
if j < 0 || j > 1000000 {
|
||||
errors <- fmt.Errorf("index out of safe range")
|
||||
return
|
||||
}
|
||||
identity, err := DeriveIdentity(mnemonic, uint32(j))
|
||||
if err != nil {
|
||||
errors <- err
|
||||
@@ -671,12 +676,12 @@ func TestConcurrentDerivation(t *testing.T) {
|
||||
}
|
||||
results <- identity.String()
|
||||
}
|
||||
}(i)
|
||||
}()
|
||||
}
|
||||
|
||||
// Collect results
|
||||
resultMap := make(map[string]int)
|
||||
for i := 0; i < testNumGoroutines*testNumIterations; i++ {
|
||||
for range testNumGoroutines * testNumIterations {
|
||||
select {
|
||||
case result := <-results:
|
||||
resultMap[result]++
|
||||
@@ -706,8 +711,12 @@ func TestConcurrentDerivation(t *testing.T) {
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkDeriveIdentity(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := DeriveIdentity(mnemonic, uint32(i%1000))
|
||||
for i := range b.N {
|
||||
index := i % 1000
|
||||
if index < 0 || index > 1000000 {
|
||||
b.Fatalf("index out of safe range: %d", index)
|
||||
}
|
||||
_, err := DeriveIdentity(mnemonic, uint32(index))
|
||||
if err != nil {
|
||||
b.Fatalf("derive identity: %v", err)
|
||||
}
|
||||
@@ -715,8 +724,12 @@ func BenchmarkDeriveIdentity(b *testing.B) {
|
||||
}
|
||||
|
||||
func BenchmarkDeriveIdentityFromXPRV(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := DeriveIdentityFromXPRV(testXPRV, uint32(i%1000))
|
||||
for i := range b.N {
|
||||
index := i % 1000
|
||||
if index < 0 || index > 1000000 {
|
||||
b.Fatalf("index out of safe range: %d", index)
|
||||
}
|
||||
_, err := DeriveIdentityFromXPRV(testXPRV, uint32(index))
|
||||
if err != nil {
|
||||
b.Fatalf("derive identity from xprv: %v", err)
|
||||
}
|
||||
@@ -724,8 +737,12 @@ func BenchmarkDeriveIdentityFromXPRV(b *testing.B) {
|
||||
}
|
||||
|
||||
func BenchmarkDeriveEntropy(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := DeriveEntropy(mnemonic, uint32(i%1000))
|
||||
for i := range b.N {
|
||||
index := i % 1000
|
||||
if index < 0 || index > 1000000 {
|
||||
b.Fatalf("index out of safe range: %d", index)
|
||||
}
|
||||
_, err := DeriveEntropy(mnemonic, uint32(index))
|
||||
if err != nil {
|
||||
b.Fatalf("derive entropy: %v", err)
|
||||
}
|
||||
@@ -739,7 +756,7 @@ func BenchmarkIdentityFromEntropy(b *testing.B) {
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for range b.N {
|
||||
_, err := IdentityFromEntropy(entropy)
|
||||
if err != nil {
|
||||
b.Fatalf("identity from entropy: %v", err)
|
||||
@@ -754,7 +771,7 @@ func BenchmarkEncryptDecrypt(b *testing.B) {
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for range b.N {
|
||||
var ct bytes.Buffer
|
||||
w, err := age.Encrypt(&ct, identity.Recipient())
|
||||
if err != nil {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package bip85 implements BIP85 deterministic entropy derivation.
|
||||
package bip85
|
||||
|
||||
import (
|
||||
@@ -22,52 +23,55 @@ import (
|
||||
|
||||
const (
|
||||
// BIP85_MASTER_PATH is the derivation path prefix for all BIP85 applications
|
||||
BIP85_MASTER_PATH = "m/83696968'"
|
||||
BIP85_MASTER_PATH = "m/83696968'" //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||
|
||||
// BIP85_KEY_HMAC_KEY is the HMAC key used for deriving the entropy
|
||||
BIP85_KEY_HMAC_KEY = "bip-entropy-from-k"
|
||||
BIP85_KEY_HMAC_KEY = "bip-entropy-from-k" //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||
|
||||
// Application numbers
|
||||
APP_BIP39 = 39 // BIP39 mnemonics
|
||||
APP_HD_WIF = 2 // WIF for Bitcoin Core
|
||||
APP_XPRV = 32 // Extended private key
|
||||
APP_HEX = 128169
|
||||
APP_PWD64 = 707764 // Base64 passwords
|
||||
APP_PWD85 = 707785 // Base85 passwords
|
||||
APP_RSA = 828365
|
||||
// AppBIP39 is the application number for BIP39 mnemonics
|
||||
AppBIP39 = 39
|
||||
// AppHDWIF is the application number for WIF (Wallet Import Format) for Bitcoin Core
|
||||
AppHDWIF = 2
|
||||
// AppXPRV is the application number for extended private key
|
||||
AppXPRV = 32
|
||||
APP_HEX = 128169 //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||
APP_PWD64 = 707764 // Base64 passwords //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||
AppPWD85 = 707785 // Base85 passwords
|
||||
APP_RSA = 828365 //nolint:revive // ALL_CAPS used for BIP85 constants
|
||||
)
|
||||
|
||||
// Version bytes for extended keys
|
||||
var (
|
||||
// MainNetPrivateKey is the version for mainnet private keys
|
||||
MainNetPrivateKey = []byte{0x04, 0x88, 0xAD, 0xE4}
|
||||
MainNetPrivateKey = []byte{0x04, 0x88, 0xAD, 0xE4} //nolint:gochecknoglobals // Standard BIP32 constant
|
||||
// TestNetPrivateKey is the version for testnet private keys
|
||||
TestNetPrivateKey = []byte{0x04, 0x35, 0x83, 0x94}
|
||||
TestNetPrivateKey = []byte{0x04, 0x35, 0x83, 0x94} //nolint:gochecknoglobals // Standard BIP32 constant
|
||||
)
|
||||
|
||||
// BIP85DRNG is a deterministic random number generator seeded by BIP85 entropy
|
||||
type BIP85DRNG struct {
|
||||
// DRNG is a deterministic random number generator seeded by BIP85 entropy
|
||||
type DRNG struct {
|
||||
shake io.Reader
|
||||
}
|
||||
|
||||
// NewBIP85DRNG creates a new DRNG seeded with BIP85 entropy
|
||||
func NewBIP85DRNG(entropy []byte) *BIP85DRNG {
|
||||
func NewBIP85DRNG(entropy []byte) *DRNG {
|
||||
const bip85EntropySize = 64 // 512 bits
|
||||
// The entropy must be exactly 64 bytes (512 bits)
|
||||
if len(entropy) != 64 {
|
||||
panic("BIP85DRNG entropy must be 64 bytes")
|
||||
if len(entropy) != bip85EntropySize {
|
||||
panic("DRNG entropy must be 64 bytes")
|
||||
}
|
||||
|
||||
// Initialize SHAKE256 with the entropy
|
||||
shake := sha3.NewShake256()
|
||||
shake.Write(entropy)
|
||||
_, _ = shake.Write(entropy) // Write to hash functions never returns an error
|
||||
|
||||
return &BIP85DRNG{
|
||||
return &DRNG{
|
||||
shake: shake,
|
||||
}
|
||||
}
|
||||
|
||||
// Read implements the io.Reader interface
|
||||
func (d *BIP85DRNG) Read(p []byte) (n int, err error) {
|
||||
func (d *DRNG) Read(p []byte) (n int, err error) {
|
||||
return d.shake.Read(p)
|
||||
}
|
||||
|
||||
@@ -161,7 +165,7 @@ func deriveChildKey(parent *hdkeychain.ExtendedKey, path string) (*hdkeychain.Ex
|
||||
|
||||
// DeriveBIP39Entropy derives entropy for a BIP39 mnemonic
|
||||
func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, index uint32) ([]byte, error) {
|
||||
path := fmt.Sprintf("%s/%d'/%d'/%d'/%d'", BIP85_MASTER_PATH, APP_BIP39, language, words, index)
|
||||
path := fmt.Sprintf("%s/%d'/%d'/%d'/%d'", BIP85_MASTER_PATH, AppBIP39, language, words, index)
|
||||
|
||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||
if err != nil {
|
||||
@@ -169,17 +173,26 @@ func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, inde
|
||||
}
|
||||
|
||||
// Determine how many bits of entropy to use based on the words
|
||||
// BIP39 defines specific word counts and their corresponding entropy bits
|
||||
const (
|
||||
words12 = 12 // 128 bits of entropy
|
||||
words15 = 15 // 160 bits of entropy
|
||||
words18 = 18 // 192 bits of entropy
|
||||
words21 = 21 // 224 bits of entropy
|
||||
words24 = 24 // 256 bits of entropy
|
||||
)
|
||||
|
||||
var bits int
|
||||
switch words {
|
||||
case 12:
|
||||
case words12:
|
||||
bits = 128
|
||||
case 15:
|
||||
case words15:
|
||||
bits = 160
|
||||
case 18:
|
||||
case words18:
|
||||
bits = 192
|
||||
case 21:
|
||||
case words21:
|
||||
bits = 224
|
||||
case 24:
|
||||
case words24:
|
||||
bits = 256
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid BIP39 word count: %d", words)
|
||||
@@ -193,7 +206,7 @@ func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, inde
|
||||
|
||||
// DeriveWIFKey derives a private key in WIF format
|
||||
func DeriveWIFKey(masterKey *hdkeychain.ExtendedKey, index uint32) (string, error) {
|
||||
path := fmt.Sprintf("%s/%d'/%d'", BIP85_MASTER_PATH, APP_HD_WIF, index)
|
||||
path := fmt.Sprintf("%s/%d'/%d'", BIP85_MASTER_PATH, AppHDWIF, index)
|
||||
|
||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||
if err != nil {
|
||||
@@ -215,7 +228,7 @@ func DeriveWIFKey(masterKey *hdkeychain.ExtendedKey, index uint32) (string, erro
|
||||
|
||||
// DeriveXPRV derives an extended private key (XPRV)
|
||||
func DeriveXPRV(masterKey *hdkeychain.ExtendedKey, index uint32) (*hdkeychain.ExtendedKey, error) {
|
||||
path := fmt.Sprintf("%s/%d'/%d'", BIP85_MASTER_PATH, APP_XPRV, index)
|
||||
path := fmt.Sprintf("%s/%d'/%d'", BIP85_MASTER_PATH, AppXPRV, index)
|
||||
|
||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||
if err != nil {
|
||||
@@ -266,6 +279,7 @@ func DeriveXPRV(masterKey *hdkeychain.ExtendedKey, index uint32) (*hdkeychain.Ex
|
||||
func doubleSHA256(data []byte) []byte {
|
||||
hash1 := sha256.Sum256(data)
|
||||
hash2 := sha256.Sum256(hash1[:])
|
||||
|
||||
return hash2[:]
|
||||
}
|
||||
|
||||
@@ -308,7 +322,7 @@ func DeriveBase64Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint3
|
||||
encodedStr = strings.TrimRight(encodedStr, "=")
|
||||
|
||||
// Slice to the desired password length
|
||||
if uint32(len(encodedStr)) < pwdLen {
|
||||
if len(encodedStr) < int(pwdLen) {
|
||||
return "", fmt.Errorf("derived password length %d is shorter than requested length %d", len(encodedStr), pwdLen)
|
||||
}
|
||||
|
||||
@@ -321,7 +335,7 @@ func DeriveBase85Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint3
|
||||
return "", fmt.Errorf("pwdLen must be between 10 and 80")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, APP_PWD85, pwdLen, index)
|
||||
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, AppPWD85, pwdLen, index)
|
||||
|
||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||
if err != nil {
|
||||
@@ -332,7 +346,7 @@ func DeriveBase85Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint3
|
||||
encoded := encodeBase85WithRFC1924Charset(entropy)
|
||||
|
||||
// Slice to the desired password length
|
||||
if uint32(len(encoded)) < pwdLen {
|
||||
if len(encoded) < int(pwdLen) {
|
||||
return "", fmt.Errorf("encoded length %d is less than requested length %d", len(encoded), pwdLen)
|
||||
}
|
||||
|
||||
@@ -344,24 +358,30 @@ func encodeBase85WithRFC1924Charset(data []byte) string {
|
||||
// RFC1924 character set
|
||||
charset := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
|
||||
|
||||
const (
|
||||
base85ChunkSize = 4 // Process 4 bytes at a time
|
||||
base85DigitCount = 5 // Each chunk produces 5 digits
|
||||
base85Base = 85 // Base85 encoding uses base 85
|
||||
)
|
||||
|
||||
// Pad data to multiple of 4
|
||||
padded := make([]byte, ((len(data)+3)/4)*4)
|
||||
padded := make([]byte, ((len(data)+base85ChunkSize-1)/base85ChunkSize)*base85ChunkSize)
|
||||
copy(padded, data)
|
||||
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(padded) * 5 / 4) // Each 4 bytes becomes 5 Base85 characters
|
||||
buf.Grow(len(padded) * base85DigitCount / base85ChunkSize) // Each 4 bytes becomes 5 Base85 characters
|
||||
|
||||
// Process in 4-byte chunks
|
||||
for i := 0; i < len(padded); i += 4 {
|
||||
for i := 0; i < len(padded); i += base85ChunkSize {
|
||||
// Convert 4 bytes to uint32 (big-endian)
|
||||
chunk := binary.BigEndian.Uint32(padded[i : i+4])
|
||||
chunk := binary.BigEndian.Uint32(padded[i : i+base85ChunkSize])
|
||||
|
||||
// Convert to 5 base-85 digits
|
||||
digits := make([]byte, 5)
|
||||
for j := 4; j >= 0; j-- {
|
||||
idx := chunk % 85
|
||||
digits := make([]byte, base85DigitCount)
|
||||
for j := base85DigitCount - 1; j >= 0; j-- {
|
||||
idx := chunk % base85Base
|
||||
digits[j] = charset[idx]
|
||||
chunk /= 85
|
||||
chunk /= base85Base
|
||||
}
|
||||
|
||||
buf.Write(digits)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//nolint:gosec // G101: Test file contains BIP85 test vectors, not real credentials
|
||||
//nolint:lll // Test vectors contain long lines
|
||||
package bip85
|
||||
|
||||
//nolint:revive,unparam // Test file with BIP85 test vectors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
@@ -79,13 +83,13 @@ const (
|
||||
)
|
||||
|
||||
// logTestVector logs test information in a cleaner, more concise format
|
||||
func logTestVector(t *testing.T, title, description string) {
|
||||
func logTestVector(t *testing.T, title string) {
|
||||
t.Logf("=== TEST: %s ===", title)
|
||||
}
|
||||
|
||||
// TestDerivedKey is a helper function to test the derived key directly
|
||||
func TestDerivedKey(t *testing.T) {
|
||||
logTestVector(t, "Derived Child Keys", "Testing direct key derivation for BIP85 paths")
|
||||
logTestVector(t, "Derived Child Keys")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -129,7 +133,7 @@ func TestDerivedKey(t *testing.T) {
|
||||
|
||||
// TestCase1 tests the first test vector from the BIP85 specification
|
||||
func TestCase1(t *testing.T) {
|
||||
logTestVector(t, "Test Case 1", "Basic entropy derivation with path m/83696968'/0'/0'")
|
||||
logTestVector(t, "Test Case 1")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -155,7 +159,7 @@ func TestCase1(t *testing.T) {
|
||||
|
||||
// TestCase2 tests the second test vector from the BIP85 specification
|
||||
func TestCase2(t *testing.T) {
|
||||
logTestVector(t, "Test Case 2", "Basic entropy derivation with path m/83696968'/0'/1'")
|
||||
logTestVector(t, "Test Case 2")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -181,7 +185,7 @@ func TestCase2(t *testing.T) {
|
||||
|
||||
// TestBIP39_12EnglishWords tests the BIP39 12 English words test vector
|
||||
func TestBIP39_12EnglishWords(t *testing.T) {
|
||||
logTestVector(t, "BIP39 12 English Words", "Deriving a 12-word English BIP39 mnemonic")
|
||||
logTestVector(t, "BIP39 12 English Words")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -225,7 +229,7 @@ func TestBIP39_12EnglishWords(t *testing.T) {
|
||||
|
||||
// TestBIP39_18EnglishWords tests the BIP39 18 English words test vector
|
||||
func TestBIP39_18EnglishWords(t *testing.T) {
|
||||
logTestVector(t, "BIP39 18 English Words", "Deriving an 18-word English BIP39 mnemonic")
|
||||
logTestVector(t, "BIP39 18 English Words")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -269,7 +273,7 @@ func TestBIP39_18EnglishWords(t *testing.T) {
|
||||
|
||||
// TestBIP39_24EnglishWords tests the BIP39 24 English words test vector
|
||||
func TestBIP39_24EnglishWords(t *testing.T) {
|
||||
logTestVector(t, "BIP39 24 English Words", "Deriving a 24-word English BIP39 mnemonic")
|
||||
logTestVector(t, "BIP39 24 English Words")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -313,7 +317,7 @@ func TestBIP39_24EnglishWords(t *testing.T) {
|
||||
|
||||
// TestHD_WIF tests the WIF test vector
|
||||
func TestHD_WIF(t *testing.T) {
|
||||
logTestVector(t, "HD-Seed WIF", "Deriving a WIF-encoded private key for Bitcoin Core hdseed")
|
||||
logTestVector(t, "HD-Seed WIF")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -353,7 +357,7 @@ func TestHD_WIF(t *testing.T) {
|
||||
|
||||
// TestXPRV tests the XPRV test vector
|
||||
func TestXPRV(t *testing.T) {
|
||||
logTestVector(t, "XPRV", "Deriving an extended private key (XPRV)")
|
||||
logTestVector(t, "XPRV")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -379,7 +383,7 @@ func TestXPRV(t *testing.T) {
|
||||
|
||||
// TestDRNG_SHAKE256 tests the BIP85-DRNG-SHAKE256 test vector
|
||||
func TestDRNG_SHAKE256(t *testing.T) {
|
||||
logTestVector(t, "DRNG-SHAKE256", "Testing the deterministic random number generator with SHAKE256")
|
||||
logTestVector(t, "DRNG-SHAKE256")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -414,7 +418,7 @@ func TestDRNG_SHAKE256(t *testing.T) {
|
||||
|
||||
// TestPythonDRNGVectors tests the DRNG vectors from the Python implementation
|
||||
func TestPythonDRNGVectors(t *testing.T) {
|
||||
logTestVector(t, "Python DRNG Vectors", "Testing specific DRNG vectors from the Python implementation")
|
||||
logTestVector(t, "Python DRNG Vectors")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -489,7 +493,7 @@ func TestPythonDRNGVectors(t *testing.T) {
|
||||
|
||||
// TestDRNGDeterminism tests the deterministic behavior of the DRNG
|
||||
func TestDRNGDeterminism(t *testing.T) {
|
||||
logTestVector(t, "DRNG Determinism", "Testing deterministic behavior of the DRNG")
|
||||
logTestVector(t, "DRNG Determinism")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -573,7 +577,7 @@ func TestDRNGDeterminism(t *testing.T) {
|
||||
|
||||
// TestDRNGLengths tests the DRNG with different lengths
|
||||
func TestDRNGLengths(t *testing.T) {
|
||||
logTestVector(t, "DRNG Lengths", "Testing DRNG with different read lengths")
|
||||
logTestVector(t, "DRNG Lengths")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -606,7 +610,7 @@ func TestDRNGLengths(t *testing.T) {
|
||||
|
||||
// TestDRNGExceptions tests error handling in the DRNG
|
||||
func TestDRNGExceptions(t *testing.T) {
|
||||
logTestVector(t, "DRNG Exceptions", "Testing error handling in the DRNG")
|
||||
logTestVector(t, "DRNG Exceptions")
|
||||
|
||||
// Test with entropy of the wrong size
|
||||
testCases := []int{0, 1, 32, 63, 65, 128}
|
||||
@@ -638,7 +642,7 @@ func TestDRNGExceptions(t *testing.T) {
|
||||
|
||||
// TestDRNGDifferentSizes tests the DRNG with different buffer sizes
|
||||
func TestDRNGDifferentSizes(t *testing.T) {
|
||||
logTestVector(t, "DRNG Different Sizes", "Testing the DRNG with different buffer sizes")
|
||||
logTestVector(t, "DRNG Different Sizes")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -700,7 +704,7 @@ func TestDRNGDifferentSizes(t *testing.T) {
|
||||
|
||||
// TestMasterKeyParsing tests parsing of different master key formats
|
||||
func TestMasterKeyParsing(t *testing.T) {
|
||||
logTestVector(t, "Master Key Parsing", "Testing parsing of master keys in different formats")
|
||||
logTestVector(t, "Master Key Parsing")
|
||||
|
||||
// Test valid master key
|
||||
t.Logf("Testing valid master key")
|
||||
@@ -747,7 +751,7 @@ func TestMasterKeyParsing(t *testing.T) {
|
||||
|
||||
// TestDifferentPathFormats tests different path format expressions
|
||||
func TestDifferentPathFormats(t *testing.T) {
|
||||
logTestVector(t, "Path Formats", "Testing different path format expressions")
|
||||
logTestVector(t, "Path Formats")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -791,7 +795,7 @@ func TestDifferentPathFormats(t *testing.T) {
|
||||
|
||||
// TestDirectBase85Encoding tests direct Base85 encoding with the test vector entropy
|
||||
func TestDirectBase85Encoding(t *testing.T) {
|
||||
logTestVector(t, "Direct Base85 Encoding", "Testing Base85 encoding with BIP85 test vector")
|
||||
logTestVector(t, "Direct Base85 Encoding")
|
||||
|
||||
// Parse the master key
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
@@ -836,7 +840,7 @@ func TestDirectBase85Encoding(t *testing.T) {
|
||||
|
||||
// TestPWDBase64 tests the Base64 password test vector
|
||||
func TestPWDBase64(t *testing.T) {
|
||||
logTestVector(t, "PWD Base64", "Deriving a Base64-encoded password")
|
||||
logTestVector(t, "PWD Base64")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -881,7 +885,7 @@ func TestPWDBase64(t *testing.T) {
|
||||
|
||||
// TestPWDBase85 tests the Base85 password test vector
|
||||
func TestPWDBase85(t *testing.T) {
|
||||
logTestVector(t, "PWD Base85", "Deriving a Base85-encoded password")
|
||||
logTestVector(t, "PWD Base85")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -926,7 +930,7 @@ func TestPWDBase85(t *testing.T) {
|
||||
|
||||
// TestHexDerivation tests the HEX derivation test vector
|
||||
func TestHexDerivation(t *testing.T) {
|
||||
logTestVector(t, "HEX Derivation", "Testing HEX data derivation with BIP85 test vector")
|
||||
logTestVector(t, "HEX Derivation")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -963,7 +967,7 @@ func TestHexDerivation(t *testing.T) {
|
||||
|
||||
// TestInvalidParameters tests error conditions for parameter validation
|
||||
func TestInvalidParameters(t *testing.T) {
|
||||
logTestVector(t, "Invalid Parameters", "Testing error handling for invalid inputs")
|
||||
logTestVector(t, "Invalid Parameters")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
@@ -1041,7 +1045,7 @@ func TestInvalidParameters(t *testing.T) {
|
||||
|
||||
// TestAdditionalDeriveHex tests additional hex derivation scenarios
|
||||
func TestAdditionalDeriveHex(t *testing.T) {
|
||||
logTestVector(t, "Additional Hex Derivation", "Testing hex data derivation with various lengths")
|
||||
logTestVector(t, "Additional Hex Derivation")
|
||||
|
||||
masterKey, err := ParseMasterKey(testMasterKey)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,688 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Test configuration
|
||||
TEST_MNEMONIC="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
TEST_PASSPHRASE="test-passphrase-123"
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
SECRET_BINARY="./secret"
|
||||
|
||||
# Enable debug output from the secret program
|
||||
export GODEBUG="berlin.sneak.pkg.secret"
|
||||
|
||||
echo -e "${BLUE}=== Secret Manager Comprehensive Test Script ===${NC}"
|
||||
echo -e "${YELLOW}Using temporary directory: $TEMP_DIR${NC}"
|
||||
echo -e "${YELLOW}Debug output enabled: GODEBUG=$GODEBUG${NC}"
|
||||
echo -e "${YELLOW}Note: All tests use environment variables (no manual input)${NC}"
|
||||
|
||||
# Function to print test steps
|
||||
print_step() {
|
||||
echo -e "\n${BLUE}Step $1: $2${NC}"
|
||||
}
|
||||
|
||||
# Function to print success
|
||||
print_success() {
|
||||
echo -e "${GREEN}✓ $1${NC}"
|
||||
}
|
||||
|
||||
# Function to print error and exit
|
||||
print_error() {
|
||||
echo -e "${RED}✗ $1${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Function to print warning (for expected failures)
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠ $1${NC}"
|
||||
}
|
||||
|
||||
# Function to clear state directory and reset environment
|
||||
reset_state() {
|
||||
echo -e "${YELLOW}Resetting state directory...${NC}"
|
||||
|
||||
# Safety checks before removing anything
|
||||
if [ -z "$TEMP_DIR" ]; then
|
||||
print_error "TEMP_DIR is not set, cannot reset state safely"
|
||||
fi
|
||||
|
||||
if [ ! -d "$TEMP_DIR" ]; then
|
||||
print_error "TEMP_DIR ($TEMP_DIR) is not a directory, cannot reset state safely"
|
||||
fi
|
||||
|
||||
# Additional safety: ensure TEMP_DIR looks like a temp directory
|
||||
case "$TEMP_DIR" in
|
||||
/tmp/* | /var/folders/* | */tmp/*)
|
||||
# Looks like a reasonable temp directory path
|
||||
;;
|
||||
*)
|
||||
print_error "TEMP_DIR ($TEMP_DIR) does not look like a safe temporary directory path"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Now it's safe to remove contents - use find to avoid glob expansion issues
|
||||
find "${TEMP_DIR:?}" -mindepth 1 -delete 2>/dev/null || true
|
||||
unset SB_SECRET_MNEMONIC
|
||||
unset SB_UNLOCK_PASSPHRASE
|
||||
export SB_SECRET_STATE_DIR="$TEMP_DIR"
|
||||
}
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
echo -e "\n${YELLOW}Cleaning up...${NC}"
|
||||
rm -rf "$TEMP_DIR"
|
||||
unset SB_SECRET_STATE_DIR
|
||||
unset SB_SECRET_MNEMONIC
|
||||
unset SB_UNLOCK_PASSPHRASE
|
||||
unset GODEBUG
|
||||
echo -e "${GREEN}Cleanup complete${NC}"
|
||||
}
|
||||
|
||||
# Set cleanup trap
|
||||
trap cleanup EXIT
|
||||
|
||||
# Check that the secret binary exists
|
||||
if [ ! -f "$SECRET_BINARY" ]; then
|
||||
print_error "Secret binary not found at $SECRET_BINARY. Please run 'make build' first."
|
||||
fi
|
||||
|
||||
# Test 1: Set up environment variables
|
||||
print_step "1" "Setting up environment variables"
|
||||
export SB_SECRET_STATE_DIR="$TEMP_DIR"
|
||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
|
||||
print_success "Environment variables set"
|
||||
echo " SB_SECRET_STATE_DIR=$SB_SECRET_STATE_DIR"
|
||||
echo " SB_SECRET_MNEMONIC=$TEST_MNEMONIC"
|
||||
|
||||
# Test 2: Initialize the secret manager (should create default vault)
|
||||
print_step "2" "Initializing secret manager (creates default vault)"
|
||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
|
||||
echo " SB_UNLOCK_PASSPHRASE=$SB_UNLOCK_PASSPHRASE"
|
||||
|
||||
# Verify environment variables are exported and visible to subprocesses
|
||||
echo "Verifying environment variables are exported:"
|
||||
env | grep -E "^SB_" || true
|
||||
|
||||
echo "Running: $SECRET_BINARY init"
|
||||
# Run with explicit environment to ensure variables are passed
|
||||
if SB_SECRET_STATE_DIR="$SB_SECRET_STATE_DIR" \
|
||||
SB_SECRET_MNEMONIC="$SB_SECRET_MNEMONIC" \
|
||||
SB_UNLOCK_PASSPHRASE="$SB_UNLOCK_PASSPHRASE" \
|
||||
GODEBUG="$GODEBUG" \
|
||||
$SECRET_BINARY init </dev/null; then
|
||||
print_success "Secret manager initialized with default vault"
|
||||
else
|
||||
print_error "Failed to initialize secret manager"
|
||||
fi
|
||||
unset SB_UNLOCK_PASSPHRASE
|
||||
|
||||
# Verify directory structure was created
|
||||
if [ -d "$TEMP_DIR" ]; then
|
||||
print_success "State directory created: $TEMP_DIR"
|
||||
else
|
||||
print_error "State directory was not created"
|
||||
fi
|
||||
|
||||
# Test 3: Vault management
|
||||
print_step "3" "Testing vault management"
|
||||
|
||||
# List vaults (should show default)
|
||||
echo "Listing vaults..."
|
||||
echo "Running: $SECRET_BINARY vault list"
|
||||
if $SECRET_BINARY vault list; then
|
||||
VAULTS=$($SECRET_BINARY vault list)
|
||||
echo "Available vaults: $VAULTS"
|
||||
print_success "Listed vaults successfully"
|
||||
else
|
||||
print_error "Failed to list vaults"
|
||||
fi
|
||||
|
||||
# Create a new vault
|
||||
echo "Creating new vault 'work'..."
|
||||
echo "Running: $SECRET_BINARY vault create work"
|
||||
if $SECRET_BINARY vault create work; then
|
||||
print_success "Created vault 'work'"
|
||||
else
|
||||
print_error "Failed to create vault 'work'"
|
||||
fi
|
||||
|
||||
# Create another vault
|
||||
echo "Creating new vault 'personal'..."
|
||||
echo "Running: $SECRET_BINARY vault create personal"
|
||||
if $SECRET_BINARY vault create personal; then
|
||||
print_success "Created vault 'personal'"
|
||||
else
|
||||
print_error "Failed to create vault 'personal'"
|
||||
fi
|
||||
|
||||
# List vaults again (should show default, work, personal)
|
||||
echo "Listing vaults after creation..."
|
||||
echo "Running: $SECRET_BINARY vault list"
|
||||
if $SECRET_BINARY vault list; then
|
||||
VAULTS=$($SECRET_BINARY vault list)
|
||||
echo "Available vaults: $VAULTS"
|
||||
print_success "Listed vaults after creation"
|
||||
else
|
||||
print_error "Failed to list vaults after creation"
|
||||
fi
|
||||
|
||||
# Switch to work vault
|
||||
echo "Switching to 'work' vault..."
|
||||
echo "Running: $SECRET_BINARY vault select work"
|
||||
if $SECRET_BINARY vault select work; then
|
||||
print_success "Switched to 'work' vault"
|
||||
else
|
||||
print_error "Failed to switch to 'work' vault"
|
||||
fi
|
||||
|
||||
# Test 4: Import functionality with environment variable combinations
|
||||
print_step "4" "Testing import functionality with environment variable combinations"
|
||||
|
||||
# Test 4a: Import with both env vars set (typical usage)
|
||||
echo -e "\n${YELLOW}Test 4a: Import with both SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE set${NC}"
|
||||
reset_state
|
||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
|
||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
|
||||
|
||||
# Create a vault first
|
||||
echo "Running: $SECRET_BINARY vault create test-vault"
|
||||
if $SECRET_BINARY vault create test-vault; then
|
||||
print_success "Created test-vault for import testing"
|
||||
else
|
||||
print_error "Failed to create test-vault"
|
||||
fi
|
||||
|
||||
# Import should work without prompts
|
||||
echo "Importing with both env vars set (automated)..."
|
||||
echo "Running: $SECRET_BINARY vault import test-vault"
|
||||
if $SECRET_BINARY vault import test-vault; then
|
||||
print_success "Import succeeded with both env vars (automated)"
|
||||
else
|
||||
print_error "Import failed with both env vars"
|
||||
fi
|
||||
|
||||
# Test 4b: Import into non-existent vault (should fail)
|
||||
echo -e "\n${YELLOW}Test 4b: Import into non-existent vault (should fail)${NC}"
|
||||
echo "Importing into non-existent vault (should fail)..."
|
||||
if $SECRET_BINARY vault import nonexistent-vault; then
|
||||
print_error "Import should have failed for non-existent vault"
|
||||
else
|
||||
print_success "Import correctly failed for non-existent vault"
|
||||
fi
|
||||
|
||||
# Test 4c: Import with invalid mnemonic (should fail)
|
||||
echo -e "\n${YELLOW}Test 4c: Import with invalid mnemonic (should fail)${NC}"
|
||||
export SB_SECRET_MNEMONIC="invalid mnemonic phrase that should not work"
|
||||
|
||||
# Create a vault first
|
||||
echo "Running: $SECRET_BINARY vault create test-vault2"
|
||||
if $SECRET_BINARY vault create test-vault2; then
|
||||
print_success "Created test-vault2 for invalid mnemonic testing"
|
||||
else
|
||||
print_error "Failed to create test-vault2"
|
||||
fi
|
||||
|
||||
echo "Importing with invalid mnemonic (should fail)..."
|
||||
if $SECRET_BINARY vault import test-vault2; then
|
||||
print_error "Import should have failed with invalid mnemonic"
|
||||
else
|
||||
print_success "Import correctly failed with invalid mnemonic"
|
||||
fi
|
||||
|
||||
# Reset state for remaining tests
|
||||
reset_state
|
||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
|
||||
|
||||
# Test 5: Unlocker management
|
||||
print_step "5" "Testing unlocker management"
|
||||
|
||||
# Initialize with mnemonic and passphrase
|
||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
|
||||
echo "Running: $SECRET_BINARY init (with SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE set)"
|
||||
if $SECRET_BINARY init; then
|
||||
print_success "Initialized for unlocker testing"
|
||||
else
|
||||
print_error "Failed to initialize for unlocker testing"
|
||||
fi
|
||||
|
||||
# Create passphrase-protected unlocker
|
||||
echo "Creating passphrase-protected unlocker..."
|
||||
echo "Running: $SECRET_BINARY unlockers add passphrase (with SB_UNLOCK_PASSPHRASE set)"
|
||||
if $SECRET_BINARY unlockers add passphrase; then
|
||||
print_success "Created passphrase-protected unlocker"
|
||||
else
|
||||
print_error "Failed to create passphrase-protected unlocker"
|
||||
exit 1
|
||||
fi
|
||||
unset SB_UNLOCK_PASSPHRASE
|
||||
|
||||
# List unlockers
|
||||
echo "Listing unlockers..."
|
||||
echo "Running: $SECRET_BINARY unlockers list"
|
||||
if $SECRET_BINARY unlockers list; then
|
||||
UNLOCKERS=$($SECRET_BINARY unlockers list)
|
||||
echo "Available unlockers: $UNLOCKERS"
|
||||
print_success "Listed unlockers"
|
||||
else
|
||||
print_error "Failed to list unlockers"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 6: Secret management with mnemonic (keyless operation)
|
||||
print_step "6" "Testing mnemonic-based secret operations (keyless)"
|
||||
|
||||
# Add secrets using mnemonic (no unlocker required)
|
||||
echo "Adding secrets using mnemonic-based long-term key..."
|
||||
|
||||
# Test secret 1
|
||||
echo "Running: echo \"my-super-secret-password\" | $SECRET_BINARY add \"database/password\""
|
||||
if echo "my-super-secret-password" | $SECRET_BINARY add "database/password"; then
|
||||
print_success "Added secret: database/password"
|
||||
else
|
||||
print_error "Failed to add secret: database/password"
|
||||
fi
|
||||
|
||||
# Test secret 2
|
||||
echo "Running: echo \"api-key-12345\" | $SECRET_BINARY add \"api/key\""
|
||||
if echo "api-key-12345" | $SECRET_BINARY add "api/key"; then
|
||||
print_success "Added secret: api/key"
|
||||
else
|
||||
print_error "Failed to add secret: api/key"
|
||||
fi
|
||||
|
||||
# Test secret 3 (with path)
|
||||
echo "Running: echo \"ssh-private-key-content\" | $SECRET_BINARY add \"ssh/private-key\""
|
||||
if echo "ssh-private-key-content" | $SECRET_BINARY add "ssh/private-key"; then
|
||||
print_success "Added secret: ssh/private-key"
|
||||
else
|
||||
print_error "Failed to add secret: ssh/private-key"
|
||||
fi
|
||||
|
||||
# Test secret 4 (with dots and underscores)
|
||||
echo "Running: echo \"jwt-secret-token\" | $SECRET_BINARY add \"app.config_jwt_secret\""
|
||||
if echo "jwt-secret-token" | $SECRET_BINARY add "app.config_jwt_secret"; then
|
||||
print_success "Added secret: app.config_jwt_secret"
|
||||
else
|
||||
print_error "Failed to add secret: app.config_jwt_secret"
|
||||
fi
|
||||
|
||||
# Retrieve secrets using mnemonic
|
||||
echo "Retrieving secrets using mnemonic-based long-term key..."
|
||||
|
||||
# Retrieve and verify secret 1
|
||||
RETRIEVED_SECRET1=$($SECRET_BINARY get "database/password" 2>/dev/null)
|
||||
if [ "$RETRIEVED_SECRET1" = "my-super-secret-password" ]; then
|
||||
print_success "Retrieved and verified secret: database/password"
|
||||
else
|
||||
print_error "Failed to retrieve or verify secret: database/password"
|
||||
fi
|
||||
|
||||
# Retrieve and verify secret 2
|
||||
RETRIEVED_SECRET2=$($SECRET_BINARY get "api/key" 2>/dev/null)
|
||||
if [ "$RETRIEVED_SECRET2" = "api-key-12345" ]; then
|
||||
print_success "Retrieved and verified secret: api/key"
|
||||
else
|
||||
print_error "Failed to retrieve or verify secret: api/key"
|
||||
fi
|
||||
|
||||
# Retrieve and verify secret 3
|
||||
RETRIEVED_SECRET3=$($SECRET_BINARY get "ssh/private-key" 2>/dev/null)
|
||||
if [ "$RETRIEVED_SECRET3" = "ssh-private-key-content" ]; then
|
||||
print_success "Retrieved and verified secret: ssh/private-key"
|
||||
else
|
||||
print_error "Failed to retrieve or verify secret: ssh/private-key"
|
||||
fi
|
||||
|
||||
# List all secrets
|
||||
echo "Listing all secrets..."
|
||||
echo "Running: $SECRET_BINARY list"
|
||||
if $SECRET_BINARY list; then
|
||||
SECRETS=$($SECRET_BINARY list)
|
||||
echo "Secrets in current vault:"
|
||||
echo "$SECRETS" | while read -r secret; do
|
||||
echo " - $secret"
|
||||
done
|
||||
print_success "Listed all secrets"
|
||||
else
|
||||
print_error "Failed to list secrets"
|
||||
fi
|
||||
|
||||
# Test 7: Secret management without mnemonic (traditional unlocker approach)
|
||||
print_step "7" "Testing traditional unlocker approach"
|
||||
|
||||
# Create a new vault without mnemonic
|
||||
echo "Running: $SECRET_BINARY vault create traditional"
|
||||
$SECRET_BINARY vault create traditional
|
||||
|
||||
# Add a secret using traditional unlocker approach
|
||||
echo "Adding secret using traditional unlocker..."
|
||||
echo "Running: echo 'traditional-secret' | $SECRET_BINARY add traditional/secret"
|
||||
if echo "traditional-secret" | $SECRET_BINARY add traditional/secret; then
|
||||
print_success "Added secret with traditional approach"
|
||||
else
|
||||
print_error "Failed to add secret with traditional approach"
|
||||
fi
|
||||
|
||||
# Retrieve secret using traditional unlocker approach
|
||||
echo "Retrieving secret using traditional unlocker approach..."
|
||||
echo "Running: $SECRET_BINARY get traditional/secret"
|
||||
if RETRIEVED=$($SECRET_BINARY get traditional/secret 2>&1); then
|
||||
print_success "Retrieved: $RETRIEVED"
|
||||
else
|
||||
print_error "Failed to retrieve secret with traditional approach"
|
||||
fi
|
||||
|
||||
# Test 8: Advanced unlocker management
|
||||
print_step "8" "Testing advanced unlocker management"
|
||||
|
||||
if [ "$PLATFORM" = "darwin" ]; then
|
||||
# macOS only: Test Secure Enclave
|
||||
echo "Testing Secure Enclave unlocker creation..."
|
||||
if $SECRET_BINARY unlockers add sep; then
|
||||
print_success "Created Secure Enclave unlocker"
|
||||
else
|
||||
print_warning "Secure Enclave unlocker creation not yet implemented"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get current unlocker ID for testing
|
||||
echo "Getting current unlocker for testing..."
|
||||
echo "Running: $SECRET_BINARY unlockers list"
|
||||
if $SECRET_BINARY unlockers list; then
|
||||
CURRENT_UNLOCKER_ID=$($SECRET_BINARY unlockers list | head -n1 | awk '{print $1}')
|
||||
if [ -n "$CURRENT_UNLOCKER_ID" ]; then
|
||||
print_success "Found unlocker ID: $CURRENT_UNLOCKER_ID"
|
||||
|
||||
# Test unlocker selection
|
||||
echo "Testing unlocker selection..."
|
||||
echo "Running: $SECRET_BINARY unlocker select $CURRENT_UNLOCKER_ID"
|
||||
if $SECRET_BINARY unlocker select "$CURRENT_UNLOCKER_ID"; then
|
||||
print_success "Selected unlocker: $CURRENT_UNLOCKER_ID"
|
||||
else
|
||||
print_warning "Unlocker selection not yet implemented"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test 9: Secret name validation and edge cases
|
||||
print_step "9" "Testing secret name validation and edge cases"
|
||||
|
||||
# Test valid names
|
||||
VALID_NAMES=("valid-name" "valid.name" "valid_name" "valid/path/name" "123valid" "a" "very-long-name-with-many-parts/and/paths")
|
||||
for name in "${VALID_NAMES[@]}"; do
|
||||
echo "Running: echo \"test-value\" | $SECRET_BINARY add $name --force"
|
||||
if echo "test-value" | $SECRET_BINARY add "$name" --force; then
|
||||
print_success "Valid name accepted: $name"
|
||||
else
|
||||
print_error "Valid name rejected: $name"
|
||||
fi
|
||||
done
|
||||
|
||||
# Test invalid names (these should fail)
|
||||
echo "Testing invalid names (should fail)..."
|
||||
INVALID_NAMES=("Invalid-Name" "invalid name" "invalid@name" "invalid#name" "invalid%name" "")
|
||||
for name in "${INVALID_NAMES[@]}"; do
|
||||
echo "Running: echo \"test-value\" | $SECRET_BINARY add $name"
|
||||
if echo "test-value" | $SECRET_BINARY add "$name"; then
|
||||
print_error "Invalid name accepted (should have been rejected): '$name'"
|
||||
else
|
||||
print_success "Invalid name correctly rejected: '$name'"
|
||||
fi
|
||||
done
|
||||
|
||||
# Test 10: Overwrite protection and force flag
|
||||
print_step "10" "Testing overwrite protection and force flag"
|
||||
|
||||
# Try to add existing secret without --force (should fail)
|
||||
echo "Running: echo \"new-value\" | $SECRET_BINARY add \"database/password\""
|
||||
if echo "new-value" | $SECRET_BINARY add "database/password"; then
|
||||
print_error "Overwrite protection failed - secret was overwritten without --force"
|
||||
else
|
||||
print_success "Overwrite protection working - secret not overwritten without --force"
|
||||
fi
|
||||
|
||||
# Try to add existing secret with --force (should succeed)
|
||||
echo "Running: echo \"new-password-value\" | $SECRET_BINARY add \"database/password\" --force"
|
||||
if echo "new-password-value" | $SECRET_BINARY add "database/password" --force; then
|
||||
print_success "Force overwrite working - secret overwritten with --force"
|
||||
|
||||
# Verify the new value
|
||||
RETRIEVED_NEW=$($SECRET_BINARY get "database/password" 2>/dev/null)
|
||||
if [ "$RETRIEVED_NEW" = "new-password-value" ]; then
|
||||
print_success "Overwritten secret has correct new value"
|
||||
else
|
||||
print_error "Overwritten secret has incorrect value"
|
||||
fi
|
||||
else
|
||||
print_error "Force overwrite failed - secret not overwritten with --force"
|
||||
fi
|
||||
|
||||
# Test 11: Cross-vault operations
|
||||
print_step "11" "Testing cross-vault operations"
|
||||
|
||||
# First create and import mnemonic into work vault since it was destroyed by reset_state
|
||||
echo "Creating work vault for cross-vault testing..."
|
||||
echo "Running: $SECRET_BINARY vault create work"
|
||||
if $SECRET_BINARY vault create work; then
|
||||
print_success "Created work vault for cross-vault testing"
|
||||
else
|
||||
print_error "Failed to create work vault for cross-vault testing"
|
||||
fi
|
||||
|
||||
# Import mnemonic into work vault so it can store secrets
|
||||
echo "Importing mnemonic into work vault..."
|
||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
|
||||
echo "Running: $SECRET_BINARY vault import work"
|
||||
if $SECRET_BINARY vault import work; then
|
||||
print_success "Imported mnemonic into work vault"
|
||||
else
|
||||
print_error "Failed to import mnemonic into work vault"
|
||||
fi
|
||||
unset SB_UNLOCK_PASSPHRASE
|
||||
|
||||
# Switch to work vault and add secrets there
|
||||
echo "Switching to 'work' vault for cross-vault testing..."
|
||||
echo "Running: $SECRET_BINARY vault select work"
|
||||
if $SECRET_BINARY vault select work; then
|
||||
print_success "Switched to 'work' vault"
|
||||
|
||||
# Add work-specific secrets
|
||||
echo "Running: echo \"work-database-password\" | $SECRET_BINARY add \"work/database\""
|
||||
if echo "work-database-password" | $SECRET_BINARY add "work/database"; then
|
||||
print_success "Added work-specific secret"
|
||||
else
|
||||
print_error "Failed to add work-specific secret"
|
||||
fi
|
||||
|
||||
# List secrets in work vault
|
||||
echo "Running: $SECRET_BINARY list"
|
||||
if $SECRET_BINARY list; then
|
||||
WORK_SECRETS=$($SECRET_BINARY list)
|
||||
echo "Secrets in work vault: $WORK_SECRETS"
|
||||
print_success "Listed work vault secrets"
|
||||
else
|
||||
print_error "Failed to list work vault secrets"
|
||||
fi
|
||||
else
|
||||
print_error "Failed to switch to 'work' vault"
|
||||
fi
|
||||
|
||||
# Switch back to default vault
|
||||
echo "Switching back to 'default' vault..."
|
||||
echo "Running: $SECRET_BINARY vault select default"
|
||||
if $SECRET_BINARY vault select default; then
|
||||
print_success "Switched back to 'default' vault"
|
||||
|
||||
# Verify default vault secrets are still there
|
||||
echo "Running: $SECRET_BINARY get \"database/password\""
|
||||
if $SECRET_BINARY get "database/password"; then
|
||||
print_success "Default vault secrets still accessible"
|
||||
else
|
||||
print_error "Default vault secrets not accessible"
|
||||
fi
|
||||
else
|
||||
print_error "Failed to switch back to 'default' vault"
|
||||
fi
|
||||
|
||||
# Test 12: File structure verification
|
||||
print_step "12" "Verifying file structure"
|
||||
|
||||
echo "Checking file structure in $TEMP_DIR..."
|
||||
if [ -d "$TEMP_DIR/vaults.d/default/secrets.d" ]; then
|
||||
print_success "Default vault structure exists"
|
||||
|
||||
# Check a specific secret's file structure
|
||||
SECRET_DIR="$TEMP_DIR/vaults.d/default/secrets.d/database%password"
|
||||
if [ -d "$SECRET_DIR" ]; then
|
||||
print_success "Secret directory exists: database%password"
|
||||
|
||||
# Check required files for per-secret key architecture
|
||||
FILES=("value.age" "pub.age" "priv.age" "secret-metadata.json")
|
||||
for file in "${FILES[@]}"; do
|
||||
if [ -f "$SECRET_DIR/$file" ]; then
|
||||
print_success "Required file exists: $file"
|
||||
else
|
||||
print_error "Required file missing: $file"
|
||||
fi
|
||||
done
|
||||
else
|
||||
print_error "Secret directory not found"
|
||||
fi
|
||||
else
|
||||
print_error "Default vault structure not found"
|
||||
fi
|
||||
|
||||
# Check work vault structure
|
||||
if [ -d "$TEMP_DIR/vaults.d/work" ]; then
|
||||
print_success "Work vault structure exists"
|
||||
else
|
||||
print_error "Work vault structure not found"
|
||||
fi
|
||||
|
||||
# Check configuration files
|
||||
if [ -f "$TEMP_DIR/configuration.json" ]; then
|
||||
print_success "Global configuration file exists"
|
||||
else
|
||||
print_warning "Global configuration file not found (may not be implemented yet)"
|
||||
fi
|
||||
|
||||
# Check current vault symlink
|
||||
if [ -L "$TEMP_DIR/currentvault" ] || [ -f "$TEMP_DIR/currentvault" ]; then
|
||||
print_success "Current vault link exists"
|
||||
else
|
||||
print_error "Current vault link not found"
|
||||
fi
|
||||
|
||||
# Test 13: Environment variable error handling
|
||||
print_step "13" "Testing environment variable error handling"
|
||||
|
||||
# Test with non-existent state directory
|
||||
export SB_SECRET_STATE_DIR="$TEMP_DIR/nonexistent/directory"
|
||||
echo "Running: $SECRET_BINARY get \"database/password\""
|
||||
if $SECRET_BINARY get "database/password"; then
|
||||
print_error "Should have failed with non-existent state directory"
|
||||
else
|
||||
print_success "Correctly failed with non-existent state directory"
|
||||
fi
|
||||
|
||||
# Test init with non-existent directory (should work)
|
||||
echo "Running: $SECRET_BINARY init (with SB_UNLOCK_PASSPHRASE set)"
|
||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
|
||||
if $SECRET_BINARY init; then
|
||||
print_success "Init works with non-existent state directory"
|
||||
else
|
||||
print_error "Init should work with non-existent state directory"
|
||||
fi
|
||||
unset SB_UNLOCK_PASSPHRASE
|
||||
|
||||
# Reset to working directory
|
||||
export SB_SECRET_STATE_DIR="$TEMP_DIR"
|
||||
|
||||
# Test 14: Mixed approach compatibility
|
||||
print_step "14" "Testing mixed approach compatibility"
|
||||
|
||||
# Verify mnemonic can access traditional secrets
|
||||
RETRIEVED_MIXED=$($SECRET_BINARY get "traditional/secret" 2>/dev/null)
|
||||
if [ "$RETRIEVED_MIXED" = "traditional-secret-value" ]; then
|
||||
print_success "Mnemonic can access traditional secrets"
|
||||
else
|
||||
print_error "Mnemonic cannot access traditional secrets"
|
||||
fi
|
||||
|
||||
# Test without mnemonic but with unlocker
|
||||
echo "Testing mnemonic-created vault access..."
|
||||
echo "Testing traditional unlocker access to mnemonic-created secrets..."
|
||||
echo "Running: $SECRET_BINARY get test/seed (with mnemonic set)"
|
||||
if RETRIEVED=$($SECRET_BINARY get test/seed 2>&1); then
|
||||
print_success "Traditional unlocker can access mnemonic-created secrets"
|
||||
else
|
||||
print_warning "Traditional unlocker cannot access mnemonic-created secrets (may need implementation)"
|
||||
fi
|
||||
|
||||
# Re-enable mnemonic for final tests
|
||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
|
||||
|
||||
# Final summary
|
||||
echo -e "\n${GREEN}=== Test Summary ===${NC}"
|
||||
echo -e "${GREEN}✓ Environment variable support (SB_SECRET_STATE_DIR, SB_SECRET_MNEMONIC)${NC}"
|
||||
echo -e "${GREEN}✓ Secret manager initialization${NC}"
|
||||
echo -e "${GREEN}✓ Vault management (create, list, select)${NC}"
|
||||
echo -e "${GREEN}✓ Import functionality with environment variable combinations${NC}"
|
||||
echo -e "${GREEN}✓ Import error handling (non-existent vault, invalid mnemonic)${NC}"
|
||||
echo -e "${GREEN}✓ Unlocker management (passphrase, PGP, SEP)${NC}"
|
||||
echo -e "${GREEN}✓ Secret generation and storage${NC}"
|
||||
echo -e "${GREEN}✓ Traditional unlocker operations${NC}"
|
||||
echo -e "${GREEN}✓ Secret name validation${NC}"
|
||||
echo -e "${GREEN}✓ Overwrite protection and force flag${NC}"
|
||||
echo -e "${GREEN}✓ Cross-vault operations${NC}"
|
||||
echo -e "${GREEN}✓ Per-secret key file structure${NC}"
|
||||
echo -e "${GREEN}✓ Mixed approach compatibility${NC}"
|
||||
echo -e "${GREEN}✓ Error handling${NC}"
|
||||
|
||||
echo -e "\n${GREEN}🎉 Comprehensive test completed with environment variable automation!${NC}"
|
||||
|
||||
# Show usage examples for all implemented functionality
|
||||
echo -e "\n${BLUE}=== Complete Usage Examples ===${NC}"
|
||||
echo -e "${YELLOW}# Environment setup:${NC}"
|
||||
echo "export SB_SECRET_STATE_DIR=\"/path/to/your/secrets\""
|
||||
echo "export SB_SECRET_MNEMONIC=\"your twelve word mnemonic phrase here\""
|
||||
echo ""
|
||||
echo -e "${YELLOW}# Initialization:${NC}"
|
||||
echo "secret init"
|
||||
echo ""
|
||||
echo -e "${YELLOW}# Vault management:${NC}"
|
||||
echo "secret vault list"
|
||||
echo "secret vault create work"
|
||||
echo "secret vault select work"
|
||||
echo ""
|
||||
echo -e "${YELLOW}# Import mnemonic (automated with environment variables):${NC}"
|
||||
echo "export SB_SECRET_MNEMONIC=\"abandon abandon...\""
|
||||
echo "export SB_UNLOCK_PASSPHRASE=\"passphrase\""
|
||||
echo "secret vault import work"
|
||||
echo ""
|
||||
echo -e "${YELLOW}# Unlocker management:${NC}"
|
||||
echo "$SECRET_BINARY unlockers add <type> # Add unlocker (passphrase, pgp, keychain)"
|
||||
echo "$SECRET_BINARY unlockers add passphrase"
|
||||
echo "$SECRET_BINARY unlockers add pgp <gpg-key-id>"
|
||||
echo "$SECRET_BINARY unlockers add keychain # macOS only"
|
||||
echo "$SECRET_BINARY unlockers list # List all unlockers"
|
||||
echo "$SECRET_BINARY unlocker select <unlocker-id> # Select current unlocker"
|
||||
echo "$SECRET_BINARY unlockers rm <unlocker-id> # Remove unlocker"
|
||||
echo ""
|
||||
echo -e "${YELLOW}# Secret management:${NC}"
|
||||
echo "echo \"my-secret\" | secret add \"app/password\""
|
||||
echo "echo \"my-secret\" | secret add \"app/password\" --force"
|
||||
echo "secret get \"app/password\""
|
||||
echo "secret list"
|
||||
echo ""
|
||||
echo -e "${YELLOW}# Cross-vault operations:${NC}"
|
||||
echo "secret vault select work"
|
||||
echo "echo \"work-secret\" | secret add \"work/database\""
|
||||
echo "secret vault select default"
|
||||
Fai riferimento in un nuovo problema
Block a user