Compare commits

...
2 Commits
Author SHA1 Message Date
sneak 734a651be4 Add the README's required sections and clear stale TODO.md items (closes #46)
check / check (push) Failing after 2s
README gains Description, Getting Started, Rationale, Design, TODO and
License sections; its first sentence names the licence and author.
Installation and Quick Start become Getting Started; Core Architecture
becomes Design, whose two false version bullets (symlink switching,
unencrypted metadata) are corrected. README and AGENTS.md are wrapped
to prettier's settings.

TODO.md: Workflow and Next Step point at the 1.0.0 milestone and the
next branch, the old Next Step's four finished items move to Completed
Steps with their dates, and Future Steps loses the items already done.

Model: opus-5-5
2026-10-04 16:09:57 +00:00
clawbot 017b8d73bf Type-check and lint the macOS build from Linux (closes #50)
check / check (push) Failing after 2s
script/lint-darwin (make lint-darwin; run by script/check, and its
commands by the Dockerfile lint stage) runs go vet and golangci-lint
with GOOS=darwin and cgo off. Compiling cgo for macOS needs Apple's SDK,
so the three functions that call go-keychain, which is cgo there, move
to keychainunlocker_cgo.go; a macOS build without cgo gets
keychainunlocker_nocgo.go and the macse stub, whose errors name the
missing macOS build with cgo. The rest of the keychain unlocker and its
plain-Go tests are now checked; their findings are fixed without
changing behaviour, and lines over 88 columns in the unchecked files
are wrapped.

Model: opus-5-5
2026-10-04 18:07:56 +02:00
20 changed files with 1356 additions and 1045 deletions
+104 -98
View File
@@ -4,154 +4,160 @@ Version: 2025-06-08
# Instructions and Contextual Information # Instructions and Contextual Information
* Be direct, robotic, expert, accurate, and professional. - Be direct, robotic, expert, accurate, and professional.
* Do not butter me up or kiss my ass. - Do not butter me up or kiss my ass.
* Come in hot with strong opinions, even if they are contrary to the - Come in hot with strong opinions, even if they are contrary to the direction I
direction I am headed. am headed.
* If either you or I are possibly wrong, say so and explain your point of - If either you or I are possibly wrong, say so and explain your point of view.
view.
* Point out great alternatives I haven't thought of, even when I'm not - Point out great alternatives I haven't thought of, even when I'm not asking
asking for them. for them.
* Treat me like the world's leading expert in every situation and every - Treat me like the world's leading expert in every situation and every
conversation, and deliver the absolute best recommendations. conversation, and deliver the absolute best recommendations.
* I want excellence, so always be on the lookout for divergences from good - I want excellence, so always be on the lookout for divergences from good data
data model design or best practices for object oriented development. model design or best practices for object oriented development.
* IMPORTANT: This is production code, not a research or teaching exercise. - IMPORTANT: This is production code, not a research or teaching exercise.
Deliver professional-level results, not prototypes. Deliver professional-level results, not prototypes.
* Please read and understand the `README.md` file in the root of the repo - Please read and understand the `README.md` file in the root of the repo for
for project-specific contextual information, including development project-specific contextual information, including development policies,
policies, practices, and current implementation status. practices, and current implementation status.
* Be proactive in suggesting improvements or refactorings in places where we - Be proactive in suggesting improvements or refactorings in places where we
diverge from best practices for clean, modular, maintainable code. diverge from best practices for clean, modular, maintainable code.
# Policies # Policies
1. Before committing, tests must pass (`make test`), linting must pass 1. Before committing, tests must pass (`make test`), linting must pass
(`make lint`), and code must be formatted (`make fmt`). For go, those (`make lint`), and code must be formatted (`make fmt`). For go, those
makefile targets should use `go fmt` and `go test -v ./...` and makefile targets should use `go fmt` and `go test -v ./...` and
`golangci-lint run`. When you think your changes are complete, rather `golangci-lint run`. When you think your changes are complete, rather than
than making three different tool calls to check, you can just run `make making three different tool calls to check, you can just run
test && make fmt && make lint` as a single tool call which will save `make test && make fmt && make lint` as a single tool call which will save
time. time.
2. Always write a `Makefile` with the default target being `test`, and with 2. Always write a `Makefile` with the default target being `test`, and with a
a `fmt` target that formats the code. The `test` target should run all `fmt` target that formats the code. The `test` target should run all tests in
tests in the project, and the `fmt` target should format the code. the project, and the `fmt` target should format the code. `test` should also
`test` should also have a prerequisite target `lint` that should run any have a prerequisite target `lint` that should run any linters that are
linters that are configured for the project. configured for the project.
3. After each completed bugfix or feature, the code must be committed. Do 3. After each completed bugfix or feature, the code must be committed. Do all of
all of the pre-commit checks (test, lint, fmt) before committing, of the pre-commit checks (test, lint, fmt) before committing, of course.
course.
4. When creating a very simple test script for testing out a new feature, 4. When creating a very simple test script for testing out a new feature,
instead of making a throwaway to be deleted after verification, write an instead of making a throwaway to be deleted after verification, write an
actual test file into the test suite. It doesn't need to be very big or actual test file into the test suite. It doesn't need to be very big or
complex, but it should be a real test that can be run. complex, but it should be a real test that can be run.
5. When you are instructed to make the tests pass, DO NOT delete tests, skip 5. When you are instructed to make the tests pass, DO NOT delete tests, skip
tests, or change the tests specifically to make them pass (unless there tests, or change the tests specifically to make them pass (unless there is a
is a bug in the test). This is cheating, and it is bad. You should only bug in the test). This is cheating, and it is bad. You should only be
be modifying the test if it is incorrect or if the test is no longer modifying the test if it is incorrect or if the test is no longer relevant.
relevant. In almost all cases, you should be fixing the code that is In almost all cases, you should be fixing the code that is being tested, or
being tested, or updating the tests to match a refactored implementation. updating the tests to match a refactored implementation.
6. When dealing with dates and times or timestamps, always use, display, and 6. When dealing with dates and times or timestamps, always use, display, and
store UTC. Set the local timezone to UTC on startup. If the user needs store UTC. Set the local timezone to UTC on startup. If the user needs to see
to see the time in a different timezone, store the user's timezone in a the time in a different timezone, store the user's timezone in a separate
separate field and convert the UTC time to the user's timezone when field and convert the UTC time to the user's timezone when displaying it. For
displaying it. For internal use and internal applications and internal use and internal applications and administrative purposes, always
administrative purposes, always display UTC. display UTC.
7. Always write tests, even if they are extremely simple and just check for 7. Always write tests, even if they are extremely simple and just check for
correct syntax (ability to compile/import). If you are writing a new correct syntax (ability to compile/import). If you are writing a new feature,
feature, write a test for it. You don't need to target complete write a test for it. You don't need to target complete coverage, but you
coverage, but you should at least test any new functionality you add. If should at least test any new functionality you add. If you are fixing a bug,
you are fixing a bug, write a test first that reproduces the bug, and write a test first that reproduces the bug, and then fix the bug in the code.
then fix the bug in the code.
8. When implementing new features, be aware of potential side-effects (such 8. When implementing new features, be aware of potential side-effects (such as
as state files on disk, data in the database, etc.) and ensure that it is state files on disk, data in the database, etc.) and ensure that it is
possible to mock or stub these side-effects in tests. possible to mock or stub these side-effects in tests.
9. Always use structured logging. Log any relevant state/context with the 9. Always use structured logging. Log any relevant state/context with the
messages (but do not log secrets). If stdout is not a terminal, output messages (but do not log secrets). If stdout is not a terminal, output the
the structured logs in jsonl format. structured logs in jsonl format.
10. Avoid using bare strings or numbers in code, especially if they appear 10. Avoid using bare strings or numbers in code, especially if they appear
anywhere more than once. Always define a constant (usually at the top anywhere more than once. Always define a constant (usually at the top of the
of the file) and give it a descriptive name, then use that constant in file) and give it a descriptive name, then use that constant in the code
the code instead of the bare string or number. instead of the bare string or number.
11. You do not need to summarize your changes in the chat after making them. 11. You do not need to summarize your changes in the chat after making them.
Making the changes and committing them is sufficient. If anything out Making the changes and committing them is sufficient. If anything out of the
of the ordinary happened, please explain it, but in the normal case ordinary happened, please explain it, but in the normal case where you found
where you found and fixed the bug, or implemented the feature, there is and fixed the bug, or implemented the feature, there is no need for the
no need for the end-of-change summary. end-of-change summary.
12. Do not create additional files in the root directory of the project 12. Do not create additional files in the root directory of the project without
without asking permission first. Configuration files, documentation, and asking permission first. Configuration files, documentation, and build files
build files are acceptable in the root, but source code and other files are acceptable in the root, but source code and other files should be
should be organized in appropriate subdirectories. organized in appropriate subdirectories.
## Python-Specific Guidelines ## Python-Specific Guidelines
1. **Type Annotations (UP006)**: Use built-in collection types directly for type annotations instead of importing from `typing`. This avoids the UP006 linter error. 1. **Type Annotations (UP006)**: Use built-in collection types directly for type
annotations instead of importing from `typing`. This avoids the UP006 linter
**Good (modern Python 3.9+):** error.
```python
def process_items(items: list[str]) -> dict[str, int]: **Good (modern Python 3.9+):**
counts: dict[str, int] = {}
return counts ```python
``` def process_items(items: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
**Avoid (triggers UP006):** return counts
```python ```
from typing import List, Dict
**Avoid (triggers UP006):**
def process_items(items: List[str]) -> Dict[str, int]:
counts: Dict[str, int] = {} ```python
return counts from typing import List, Dict
```
def process_items(items: List[str]) -> Dict[str, int]:
For optional types, use the `|` operator instead of `Union`: counts: Dict[str, int] = {}
```python return counts
# Good ```
def get_value(key: str) -> str | None:
return None For optional types, use the `|` operator instead of `Union`:
# Avoid ```python
from typing import Optional, Union # Good
def get_value(key: str) -> Optional[str]: def get_value(key: str) -> str | None:
return None return None
```
# Avoid
from typing import Optional, Union
def get_value(key: str) -> Optional[str]:
return None
```
2. **Import Organization**: Follow the standard Python import order: 2. **Import Organization**: Follow the standard Python import order:
- Standard library imports - Standard library imports
- Third-party imports - Third-party imports
- Local application imports - Local application imports
Each group should be separated by a blank line. Each group should be separated by a blank line.
## Go-Specific Guidelines ## Go-Specific Guidelines
1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate errors via return values. 1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate
errors via return values.
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle errors, not crash. 2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle
errors, not crash.
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability. 3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix the code. 4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix
the code.
5. **All PRs must pass `make check` with zero failures.** No exceptions, no "pre-existing issue" excuses. 5. **All PRs must pass `make check` with zero failures.** No exceptions, no
"pre-existing issue" excuses.
6. **Pin external dependencies by commit hash**, not mutable tags. 6. **Pin external dependencies by commit hash**, not mutable tags.
+4 -1
View File
@@ -14,8 +14,11 @@ ARG CHECK_EPOCH
COPY . . COPY . .
RUN make fmt-check RUN make fmt-check
# Not make lint: script/lint is a docker build, which cannot run in here. # Not make lint or make lint-darwin: script/lint and script/lint-darwin are
# docker builds, which cannot run in here. These are their commands.
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
# Build stage — tests and compilation # Build stage — tests and compilation
# golang 1.24.13-alpine (2026-03-10) # golang 1.24.13-alpine (2026-03-10)
+13 -3
View File
@@ -1,6 +1,6 @@
# Lint image, built by script/lint: golangci-lint runs as a build step, so a # Lint image, built by script/lint and script/lint-darwin: golangci-lint runs
# successful build is a clean lint. Works where the docker daemon is remote # as a build step, so a successful build is a clean lint. Works where the
# and bind mounts are impossible. # docker daemon is remote and bind mounts are impossible.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
@@ -17,3 +17,13 @@ FROM deps AS lint
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# script/lint-darwin rebuilds this stage on every run, by this name. It
# checks the code as a macOS build compiles it, but with cgo off, which
# leaves out the files that need cgo on macOS (see script/lint-darwin).
FROM deps AS lint-darwin
COPY . .
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
+6 -2
View File
@@ -1,7 +1,7 @@
export CGO_ENABLED=1 export CGO_ENABLED=1
.PHONY: default bootstrap setup build test lint fmt fmt-check check docker \ .PHONY: default bootstrap setup build test lint lint-darwin fmt fmt-check \
docker-run clean install hooks check docker docker-run clean install hooks
default: check default: check
@@ -24,6 +24,10 @@ fmt:
lint: lint:
@script/lint @script/lint
# Type-check and lint the macOS build from Linux (see script/lint-darwin)
lint-darwin:
@script/lint-darwin
check: check:
@script/check @script/check
+186 -140
View File
@@ -1,72 +1,69 @@
# secret - Local Secret Manager # secret - Local Secret Manager
secret is a command-line local secret manager that implements a hierarchical ## Description
key architecture for storing and managing sensitive data. It supports
multiple vaults, various unlock mechanisms, and provides secure storage
using the `age` encryption library.
It could be used as password manager, but was not designed as such. I `secret` is a WTFPL-licensed Go command-line local secret manager by
created it to scratch an itch for a secure key/value store for replacing a [@sneak](https://sneak.berlin) that implements a hierarchical key architecture
bunch of pgp-encrypted files in a directory structure. for storing and managing sensitive data. It supports multiple vaults, various
unlock mechanisms, and provides secure storage using the `age` encryption
library.
## Core Architecture ## Getting Started
Build from source, then install the binary as `~/bin/secret`:
```bash
git clone https://git.eeqj.de/sneak/secret.git
cd secret
make build # writes the binary to ./secret
make install # builds it and copies it to ~/bin/secret
```
Generate a mnemonic, create the default vault, then store and read a secret:
```bash
secret generate mnemonic # prints a new BIP39 mnemonic; write it down
secret init # asks for that mnemonic and an unlocker passphrase
echo "my-password" | secret add myservice/password
secret get myservice/password
```
## Rationale
I created `secret` to scratch an itch: I wanted a secure key/value store to
replace a bunch of PGP-encrypted files in a directory structure. It could be
used as a password manager, but was not designed as one.
## Design
### Three-Layer Key Hierarchy ### Three-Layer Key Hierarchy
Secret implements a three-layer key architecture: Secret implements a three-layer key architecture:
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide 1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the
the foundation for all encryption foundation for all encryption
2. **Unlockers**: Short-term keys that encrypt the long-term keys, 2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting
supporting multiple authentication methods multiple authentication methods
3. **Version-specific Keys**: Per-version keys that encrypt individual 3. **Version-specific Keys**: Per-version keys that encrypt individual secret
secret values values
### Version Management ### Version Management
Each secret maintains a history of versions, with each version having: Each secret maintains a history of versions, with each version having:
- Its own encryption key pair - Its own encryption key pair
- Metadata (unencrypted) including creation time and validity period - Metadata including creation time and validity period, encrypted to the
version's key pair
- Immutable value storage - Immutable value storage
- Atomic version switching via symlink updates
The secret's `current` file names its current version. Switching versions
replaces that file in one rename, so it is never half-written.
### Vault System ### Vault System
Vaults provide logical separation of secrets, each with its own long-term Vaults provide logical separation of secrets, each with its own long-term key
key and unlocker set. This allows for complete isolation between different and unlocker set. This allows for complete isolation between different contexts
contexts (work, personal, projects). (work, personal, projects).
## Installation
Build from source:
```bash
git clone <repository>
cd secret
make build
```
## Quick Start
1. **Initialize the secret manager**:
```bash
secret init
```
This creates the default vault and prompts for a BIP39 mnemonic phrase.
2. **Generate a mnemonic** (if needed):
```bash
secret generate mnemonic
```
3. **Add a secret**:
```bash
echo "my-password" | secret add myservice/password
```
4. **Retrieve a secret**:
```bash
secret get myservice/password
```
## Commands Reference ## Commands Reference
@@ -74,10 +71,10 @@ make build
`secret rm`, `secret version rm`, `secret vault remove` and `secret rm`, `secret version rm`, `secret vault remove` and
`secret unlocker remove` destroy data that exists nowhere else. On a terminal `secret unlocker remove` destroy data that exists nowhere else. On a terminal
each one first asks `[y/N]`, naming exactly what it is about to remove, and each one first asks `[y/N]`, naming exactly what it is about to remove, and goes
goes ahead only on `y` or `yes`; any other answer, a bare Enter included, ahead only on `y` or `yes`; any other answer, a bare Enter included, cancels and
cancels and removes nothing. The question is asked only after the command's removes nothing. The question is asked only after the command's checks have
checks have passed, and before it changes anything. passed, and before it changes anything.
Whether to ask is decided by stdin, where the answer is read from, so Whether to ask is decided by stdin, where the answer is read from, so
`secret rm foo | tee log` still asks. When stdin is not a terminal, as in a `secret rm foo | tee log` still asks. When stdin is not a terminal, as in a
@@ -96,6 +93,7 @@ Initializes the secret manager with a default vault. Prompts for a BIP39
mnemonic phrase and creates the initial directory structure. mnemonic phrase and creates the initial directory structure.
**Environment Variables:** **Environment Variables:**
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase - `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase - `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase
@@ -118,8 +116,8 @@ Switches to the specified vault for subsequent operations.
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑 #### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
**DANGER**: Permanently removes a vault and all its secrets. It first asks **DANGER**: Permanently removes a vault and all its secrets. It first asks for
for confirmation, naming the vault and how many secrets it holds (see confirmation, naming the vault and how many secrets it holds (see
[Confirmation Before Removal](#confirmation-before-removal)). The last vault [Confirmation Before Removal](#confirmation-before-removal)). The last vault
cannot be removed. Removing the current vault makes another vault the current cannot be removed. Removing the current vault makes another vault the current
one. one.
@@ -132,58 +130,65 @@ one.
#### `secret add <secret-name> [--force]` #### `secret add <secret-name> [--force]`
Adds a secret to the current vault. Reads the secret value from stdin. Adds a secret to the current vault. Reads the secret value from stdin.
- `--force, -f`: Overwrite existing secret - `--force, -f`: Overwrite existing secret
**Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/` **Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/` are
are allowed, and a name must not be empty, start with `.` or `/`, end with allowed, and a name must not be empty, start with `.` or `/`, end with `/`,
`/`, contain `//`, or have `..` as a path segment. contain `//`, or have `..` as a path segment.
- Forward slashes (`/`) are converted to percent signs (`%`) for storage - Forward slashes (`/`) are converted to percent signs (`%`) for storage
- Examples: `database/password`, `api.key`, `ssh_private_key` - Examples: `database/password`, `api.key`, `ssh_private_key`
#### `secret get <secret-name> [--version <version>]` #### `secret get <secret-name> [--version <version>]`
Retrieves and outputs a secret value to stdout. Retrieves and outputs a secret value to stdout.
- `--version, -v`: Get a specific version (default: current) - `--version, -v`: Get a specific version (default: current)
#### `secret list [filter] [--json]` / `secret ls` #### `secret list [filter] [--json]` / `secret ls`
Lists all secrets in the current vault. Optional filter for substring Lists all secrets in the current vault. Optional filter for substring matching.
matching.
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑 #### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
**DANGER**: Permanently removes a secret and ALL its versions. It first asks **DANGER**: Permanently removes a secret and ALL its versions. It first asks for
for confirmation, naming the secret, its vault and how many versions it has confirmation, naming the secret, its vault and how many versions it has (see
(see [Confirmation Before Removal](#confirmation-before-removal)). [Confirmation Before Removal](#confirmation-before-removal)).
- `--force, -f`: Remove without asking - `--force, -f`: Remove without asking
- **NO RECOVERY**: Once removed, the secret cannot be recovered - **NO RECOVERY**: Once removed, the secret cannot be recovered
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted - **ALL VERSIONS DELETED**: Every version of the secret will be permanently
deleted
#### `secret move <source> <destination>` / `secret mv` / `secret rename` #### `secret move <source> <destination>` / `secret mv` / `secret rename`
Moves or renames a secret within the current vault. Moves or renames a secret within the current vault.
- Fails if the destination already exists - Fails if the destination already exists
- Fails if the destination is the source under another name, such as `foo` - Fails if the destination is the source under another name, such as `foo` for
for `Foo` on a case-insensitive filesystem (the macOS default); there, to `Foo` on a case-insensitive filesystem (the macOS default); there, to change
change only the case of a name, move the secret to a third name first only the case of a name, move the secret to a third name first
- Preserves all versions and metadata - Preserves all versions and metadata
### Version Management ### Version Management
#### `secret version list <secret-name>` / `secret version ls` #### `secret version list <secret-name>` / `secret version ls`
Lists all versions of a secret showing creation time, status, and validity period. Lists all versions of a secret showing creation time, status, and validity
period.
#### `secret version promote <secret-name> <version>` #### `secret version promote <secret-name> <version>`
Promotes a specific version to current by updating the symlink. Does not Promotes a specific version to current by updating the symlink. Does not modify
modify any timestamps, allowing for rollback scenarios. any timestamps, allowing for rollback scenarios.
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑 #### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
**DANGER**: Permanently removes a specific version of a secret. It first asks **DANGER**: Permanently removes a specific version of a secret. It first asks
for confirmation, naming the version, the secret and its vault (see for confirmation, naming the version, the secret and its vault (see
[Confirmation Before Removal](#confirmation-before-removal)). [Confirmation Before Removal](#confirmation-before-removal)).
- `--force, -f`: Remove without asking - `--force, -f`: Remove without asking
- **NO RECOVERY**: Once removed, this version cannot be recovered - **NO RECOVERY**: Once removed, this version cannot be recovered
- Cannot remove the current version (must promote another version first) - Cannot remove the current version (must promote another version first)
@@ -197,6 +202,7 @@ Generates a cryptographically secure BIP39 mnemonic phrase.
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]` #### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
Generates and stores a random secret. Generates and stores a random secret.
- `--length, -l`: Length of generated secret (default: 16) - `--length, -l`: Length of generated secret (default: 16)
- `--type, -t`: Type of secret (`base58`, `alnum`) - `--type, -t`: Type of secret (`base58`, `alnum`)
- `--force, -f`: Overwrite existing secret - `--force, -f`: Overwrite existing secret
@@ -212,16 +218,19 @@ Lists all unlockers in the current vault with their metadata.
Creates a new unlocker of the specified type: Creates a new unlocker of the specified type:
**Types:** **Types:**
- `passphrase`: Traditional passphrase-protected unlocker - `passphrase`: Traditional passphrase-protected unlocker
- `pgp`: Uses an existing GPG key for encryption/decryption - `pgp`: Uses an existing GPG key for encryption/decryption
- `keychain`: macOS Keychain integration (macOS only) - `keychain`: macOS Keychain integration (macOS only)
- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only) - `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only)
**Options:** **Options:**
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
A vault has one passphrase unlocker: adding one replaces the one the vault - `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not
has, which is removed only once the new one is the current unlocker. specified)
A vault has one passphrase unlocker: adding one replaces the one the vault has,
which is removed only once the new one is the current unlocker.
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑 #### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
@@ -230,9 +239,9 @@ naming the unlocker and its vault and saying whether it is the vault's last
unlocker; for the last one it says how many secrets the vault holds and warns unlocker; for the last one it says how many secrets the vault holds and warns
that the vault then opens only with its mnemonic (see that the vault then opens only with its mnemonic (see
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker [Confirmation Before Removal](#confirmation-before-removal)). An unlocker
directory that `secret unlocker list` skips with a warning, because its directory that `secret unlocker list` skips with a warning, because its metadata
metadata cannot be read or parsed, is removed by the directory name the cannot be read or parsed, is removed by the directory name the warning gives.
warning gives.
- `--force, -f`: Remove without asking, even the last unlocker - `--force, -f`: Remove without asking, even the last unlocker
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase, - **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
vault data will be PERMANENTLY INACCESSIBLE vault data will be PERMANENTLY INACCESSIBLE
@@ -247,7 +256,8 @@ Selects an unlocker as the current default for operations.
#### `secret import <secret-name> --source <filename>` #### `secret import <secret-name> --source <filename>`
Imports a secret from a file and stores it in the current vault under the given name. Imports a secret from a file and stores it in the current vault under the given
name.
#### `secret vault import [vault-name]` #### `secret vault import [vault-name]`
@@ -257,7 +267,8 @@ Imports a mnemonic phrase into the specified vault (defaults to "default").
#### `secret encrypt <secret-name> [--input=file] [--output=file]` #### `secret encrypt <secret-name> [--input=file] [--output=file]`
Encrypts data using an Age key stored as a secret. If the secret doesn't exist, generates a new Age key. Encrypts data using an Age key stored as a secret. If the secret doesn't exist,
generates a new Age key.
#### `secret decrypt <secret-name> [--input=file] [--output=file]` #### `secret decrypt <secret-name> [--input=file] [--output=file]`
@@ -302,37 +313,43 @@ Decrypts data using an Age key stored as a secret.
### Key Management and Encryption Flow ### Key Management and Encryption Flow
#### 1: Long-term Keys #### 1: Long-term Keys
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical deterministic (HD) key derivation
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical
deterministic (HD) key derivation
- **Purpose**: Master keys for each vault, used to encrypt secret-specific keys - **Purpose**: Master keys for each vault, used to encrypt secret-specific keys
- **Storage**: Public key stored as `pub.age`, private key encrypted by unlockers - **Storage**: Public key stored as `pub.age`, private key encrypted by
unlockers
#### 2: Unlockers #### 2: Unlockers
Unlockers provide different authentication methods to access the long-term keys: Unlockers provide different authentication methods to access the long-term keys:
1. **Passphrase Unlockers**: 1. **Passphrase Unlockers**:
- Encrypted with user-provided passphrase - Encrypted with user-provided passphrase
- Stored as encrypted Age keys - Stored as encrypted Age keys
- Cross-platform compatible - Cross-platform compatible
2. **PGP Unlockers**: 2. **PGP Unlockers**:
- Uses existing GPG key infrastructure - Uses existing GPG key infrastructure
- Leverages existing key management workflows - Leverages existing key management workflows
- Strong authentication through GPG - Strong authentication through GPG
3. **Keychain Unlockers** (macOS only): 3. **Keychain Unlockers** (macOS only):
- Stores unlock keys in macOS Keychain - Stores unlock keys in macOS Keychain
- Protected by system authentication (Touch ID, password) - Protected by system authentication (Touch ID, password)
- Automatic unlocking when Keychain is unlocked - Automatic unlocking when Keychain is unlocked
- Cross-application integration - Cross-application integration
4. **Secure Enclave Unlockers** (macOS): 4. **Secure Enclave Unlockers** (macOS):
- Hardware-backed key storage using Apple Secure Enclave - Hardware-backed key storage using Apple Secure Enclave
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer Program required) - Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
- ECIES encryption: vault long-term key encrypted directly by SE hardware Program required)
- Protected by biometric authentication (Touch ID) or system password - ECIES encryption: vault long-term key encrypted directly by SE hardware
- Protected by biometric authentication (Touch ID) or system password
Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets. Each vault maintains its own set of unlockers and one long-term key. The
long-term key is encrypted to each unlocker, allowing any authorized unlocker to
access vault secrets.
#### 3: Secret-specific Keys #### 3: Secret-specific Keys
@@ -352,18 +369,19 @@ they hold. Other processes running as the same user can read a process's
environment (on Linux, from `/proc/<pid>/environ`). Every child process of the environment (on Linux, from `/proc/<pid>/environ`). Every child process of the
shell or script that sets them inherits them, `gpg` included. Set on a command shell or script that sets them inherits them, `gpg` included. Set on a command
line or in a CI job, they end up in shell history and CI logs. `secret` unsets line or in a CI job, they end up in shell history and CI logs. `secret` unsets
each one as soon as it has read it, so that the programs it runs itself, such each one as soon as it has read it, so that the programs it runs itself, such as
as `gpg`, do not inherit it, but that erases nothing: the environment the `gpg`, do not inherit it, but that erases nothing: the environment the process
process started with, and its memory, still hold the value. The interactive started with, and its memory, still hold the value. The interactive prompt,
prompt, which every command except `secret vault import` offers when the which every command except `secret vault import` offers when the variable is not
variable is not set, is the safer default; `secret vault import` has no prompt set, is the safer default; `secret vault import` has no prompt and needs both
and needs both variables. variables.
## Security Features ## Security Features
### Encryption ### Encryption
- Uses the [age encryption library](https://age-encryption.org/) with X25519 keys - Uses the [age encryption library](https://age-encryption.org/) with X25519
keys
- All private keys are encrypted at rest - All private keys are encrypted at rest
- No plaintext secrets stored on disk - No plaintext secrets stored on disk
@@ -382,7 +400,8 @@ and needs both variables.
- Hardware token support via PGP/GPG integration - Hardware token support via PGP/GPG integration
- macOS Keychain integration for system-level security - macOS Keychain integration for system-level security
- Secure Enclave integration for hardware-backed key protection (macOS, via `sc_auth` / CryptoTokenKit) - Secure Enclave integration for hardware-backed key protection (macOS, via
`sc_auth` / CryptoTokenKit)
## Examples ## Examples
@@ -431,6 +450,7 @@ secret vault remove personal --force
``` ```
### Advanced Authentication ### Advanced Authentication
```bash ```bash
# Add multiple unlock methods # Add multiple unlock methods
secret unlocker add passphrase # Password-based secret unlocker add passphrase # Password-based
@@ -477,27 +497,37 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
## Technical Details ## Technical Details
### Cryptographic Primitives ### Cryptographic Primitives
- **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation - **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation
- **Encryption**: Age (X25519 + ChaCha20-Poly1305) - **Encryption**: Age (X25519 + ChaCha20-Poly1305)
- **Authentication**: Poly1305 MAC - **Authentication**: Poly1305 MAC
- **Hashing**: Double SHA-256 for public key identification - **Hashing**: Double SHA-256 for public key identification
### File Formats ### File Formats
- **age Files**: Standard age encryption format (.age extension) - **age Files**: Standard age encryption format (.age extension)
- **Metadata**: Unencrypted JSON format with timestamps and type information - **Metadata**: Unencrypted JSON format with timestamps and type information
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash - **Vault Metadata**: JSON containing vault name, creation time, derivation
index, and public key hash
### Vault Management ### Vault Management
- **Derivation Index**: Each vault uses a unique derivation index from the mnemonic, and thus a unique key pair - **Derivation Index**: Each vault uses a unique derivation index from the
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies vaults from the same mnemonic mnemonic, and thus a unique key pair
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived - **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies
vaults from the same mnemonic
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are
automatically derived
### Cross-Platform Support ### Cross-Platform Support
- **macOS**: Full support including Keychain and Secure Enclave integration - **macOS**: Full support including Keychain and Secure Enclave integration
- **Linux**: Full support (excluding macOS-specific features) - **Linux**: Full support (excluding macOS-specific features)
The keychain and Secure Enclave unlockers need a macOS build with cgo. A macOS
build without cgo, such as one cross-compiled from Linux, offers them but fails
to add or use them.
## Security Considerations ## Security Considerations
### Threat Model ### Threat Model
@@ -523,6 +553,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
## Development ## Development
### Building ### Building
```bash ```bash
make build # Build binary make build # Build binary
make test # Run tests make test # Run tests
@@ -530,7 +561,9 @@ make lint # Run linter
``` ```
### Testing ### Testing
The project includes comprehensive tests: The project includes comprehensive tests:
```bash ```bash
make test # Run all tests make test # Run all tests
go test ./... # Unit tests go test ./... # Unit tests
@@ -542,55 +575,68 @@ go test -tags=integration -v ./internal/cli # Integration tests
This repository adheres to the This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call development workflow, and the Makefile targets are thin shims that call them. We
them. We provide: provide:
- `script/bootstrap` — install all dependencies (Go, Go module - `script/bootstrap` — install all dependencies (Go, Go module download),
download), idempotently; golangci-lint is not installed, it runs in idempotently; golangci-lint is not installed, it runs in docker
docker
- `script/setup` — make a fresh clone ready for development: runs - `script/setup` — make a fresh clone ready for development: runs
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (`secret`); used by - `script/projectname` — output the project name (`secret`); used by other
other scripts such as `script/docker` scripts such as `script/docker`
- `script/build` — build the `secret` binary into the repo root, stamping - `script/build` — build the `secret` binary into the repo root, stamping the
the version (`VERSION` from the environment, else `git describe`) and version (`VERSION` from the environment, else `git describe`) and the git
the git commit commit
- `script/test` — run `go vet` and the test suite (verbose rerun on - `script/test` — run `go vet` and the test suite (verbose rerun on failure)
failure) - `script/lint` — run `golangci-lint` in docker only: builds `Dockerfile.lint`,
- `script/lint` — run `golangci-lint` in docker only: builds where the linter is a build step that runs on every call, also on an unchanged
`Dockerfile.lint`, where the linter is a build step that runs on every tree
call, also on an unchanged tree - `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on the code
as a macOS build compiles it (`GOOS=darwin`), which a Linux build never
compiles; cgo is off, so the keychain unlocker's calls into the keychain
(`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`)
and the Secure Enclave bindings (`internal/macse`) are not checked
- `script/fmt` — format all Go code (writes) - `script/fmt` — format all Go code (writes)
- `script/fmt-check` — check formatting without writing - `script/fmt-check` — check formatting without writing
- `script/check` — run `script/test`, `script/lint`, and - `script/check` — run `script/test`, `script/lint`, `script/lint-darwin`, and
`script/fmt-check` `script/fmt-check`
- `script/docker` — build the Docker image tagged with the project name - `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — CI entrypoint: `docker build --ulimit - `script/cibuild` — CI entrypoint: `docker build --ulimit memlock=-1:-1 .`
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the (memguard needs mlock; the Dockerfile runs the checks), with a new
checks), with a new `CHECK_EPOCH` build argument on every run so the `CHECK_EPOCH` build argument on every run so the checks run again on an
checks run again on an unchanged tree unchanged tree
- `script/precommit` — pre-commit checks: `go mod tidy` verification, - `script/precommit` — pre-commit checks: `go mod tidy` verification, then
then `script/check` `script/check`
- `script/install-precommit` — install the git pre-commit hook that - `script/install-precommit` — install the git pre-commit hook that runs
runs `script/precommit` `script/precommit`
## Features ## Features
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, and Secure Enclave unlockers - **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain,
and Secure Enclave unlockers
- **Vault Isolation**: Complete separation between different vaults - **Vault Isolation**: Complete separation between different vaults
- **Per-Secret Encryption**: Each secret has its own encryption key - **Per-Secret Encryption**: Each secret has its own encryption key
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases - **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems - **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
# Author ## TODO
Made with love and lots of expensive SOTA AI by Open work is tracked on the
[sneak](https://sneak.berlin) in Berlin in the summer of 2025. [issue tracker](https://git.eeqj.de/sneak/secret/issues), which is
authoritative. The work to be done before 1.0 is the
[`1.0.0` milestone](https://git.eeqj.de/sneak/secret/milestone/12). `TODO.md`
records the steps completed so far.
Released as a free software gift to the world, no strings attached, under ## License
the [WTFPL](https://www.wtfpl.net/) license.
Released as a free software gift to the world, no strings attached, under the
[WTFPL](https://www.wtfpl.net/) license; see [`LICENSE`](LICENSE).
## Author
Made with love and lots of expensive SOTA AI by [@sneak](https://sneak.berlin)
in Berlin in the summer of 2025.
Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin) Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin)
[https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2) [https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2)
+51 -45
View File
@@ -1,30 +1,44 @@
# Workflow # Workflow
* branch (from `main`) - branch from `next`
* do the work in Next Step - do the Next Step: the next open issue in the `1.0.0` milestone
* move Next Step to the top of Completed Steps - log it at the top of Completed Steps
* move the top item of Future Steps into Next Step - commit (`TODO.md` changes in the same commit as the work)
* commit (`TODO.md` changes in the same commit as the work) - push, and open a PR against `next`
* merge to `main` if the branch is not protected, otherwise open a PR
* push
# Status # Status
pre-1.0. No git tags. TODO.md carries open 1.0 security blockers. Work in pre-1.0. No git tags. Open work is tracked on the issue tracker, which is
flight on branch secure-enclave-unlocker (clean tree as of 2026-07-06). authoritative.
# Next Step # Next Step
Bring the repo into policy compliance in one commit: Take the next open issue in the `1.0.0` milestone:
https://git.eeqj.de/sneak/secret/milestone/12
- Add fmt-check and hooks targets to the Makefile (test/lint/fmt/check/
docker already exist).
- Add REPO_POLICIES.md and .editorconfig.
- Add .gitea/workflows/check.yml running make check.
- Verify Dockerfile base images are pinned by sha256.
# Completed Steps # Completed Steps
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
`golangci-lint` in docker on the code as a macOS build compiles it
(`GOOS=darwin`), with cgo off
(https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it, and
the `Dockerfile` lint stage runs its commands, so `script/cibuild` does too.
Before, CI on Linux never compiled the files built only for macOS. Compiling
cgo code for macOS needs Apple's SDK headers, and both `internal/macse` and
`github.com/keybase/go-keychain` are cgo on macOS. So the three functions
that call `go-keychain` moved from `keychainunlocker.go` to
`keychainunlocker_cgo.go`, built only with cgo on macOS like
`macse_darwin.go`. A macOS build without cgo, which before did not compile,
gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose errors
say the keychain or Secure Enclave needs a macOS build with cgo. The check
covers the rest of the keychain unlocker, the Secure Enclave unlocker and
the macOS-only tests other than `keychainunlocker_test.go`, whose lint
findings are fixed. For the length and complexity limits, parts of
`GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved
into functions of their own, and the Secure Enclave unlocker derives the
long-term key from the mnemonic through the same function as the keychain
unlocker instead of a copy of it. Lines over 88 columns in the files the
check cannot see are wrapped.
- 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and - 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and
`secret unlocker remove` ask `[y/N]` before removing anything `secret unlocker remove` ask `[y/N]` before removing anything
(https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the (https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the
@@ -266,8 +280,15 @@ Bring the repo into policy compliance in one commit:
`findUnlockerIDByMetadata` now returns an error so `unlocker list` `findUnlockerIDByMetadata` now returns an error so `unlocker list`
skips an unreadable `unlockers.d` entry with a warning instead of skips an unreadable `unlockers.d` entry with a warning instead of
emitting a fabricated fallback ID. emitting a fabricated fallback ID.
- 2026-08-07: Added `.editorconfig`
(https://git.eeqj.de/sneak/secret/issues/27).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section Makefile shims, README Entrypoints section
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
`.gitea/workflows/check.yml` now runs `script/cibuild`.
- 2026-03-30: Added the `make fmt-check` target and
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the
`Dockerfile` base images are pinned by sha256.
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret - 2026-03-11: Secure Enclave unlocker for hardware-backed secret
protection, plus review fixes (stub panics, derivation index, tests, protection, plus review fixes (stub panics, derivation index, tests,
README) on branch secure-enclave-unlocker. README) on branch secure-enclave-unlocker.
@@ -289,47 +310,32 @@ Bring the repo into policy compliance in one commit:
# Future Steps # Future Steps
- Compliance (after Next Step lands): keep main green under the new
.gitea workflow; run make check before every merge.
- Implement version-number shell completion for the second arg of - Implement version-number shell completion for the second arg of
`secret version promote` and `secret version rm` `secret version promote` and `secret version rm`
(`internal/cli/version.go`; was an in-code TODO removed for godox). (`internal/cli/version.go`; was an in-code TODO removed for godox).
- Cover mnemonic-vs-xprv identity consistency in - Cover mnemonic-vs-xprv identity consistency in
`pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an `pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an
in-code FIXME removed for godox). in-code FIXME removed for godox).
- Darwin-gated files (`internal/secret/keychainunlocker.go`, - CI does not compile, lint or test the files built only with cgo on
`seunlocker_darwin.go`, `internal/macse/macse_darwin.go`, related macOS, since compiling them needs Apple's SDK:
tests) are not linted on the Linux CI runner and still contain lines `internal/secret/keychainunlocker_cgo.go` (the three functions that call
over the new 88-column limit; they will surface if lint ever runs on `go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
macOS. (`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has
- Merge secure-enclave-unlocker to main once review is done. never run on them, so it would likely find more there than the line
lengths. No macOS test runs in CI. A macOS runner would cover all of it
(asked on https://git.eeqj.de/sneak/secret/issues/50).
- 1.0 critical security blockers (from repo TODO.md): - 1.0 critical security blockers (from repo TODO.md):
- Command injection: GPG key IDs passed unescaped to exec.Command - Memory security: age identity .String() creates unprotected copies of
(pgpunlocker.go:323-327); data.String() passed unescaped to the private keys; the call sites are listed in
security command (keychainunlocker.go:472-476). https://git.eeqj.de/sneak/secret/issues/38.
- Memory security: age identity .String() creates unprotected
copies (keychainunlocker.go:356, pgpunlocker.go:256,
version.go:155); age secret key held in a plain string in
cli/crypto.go:86,91,113; private keys exposed via buffer.Bytes()
to GPGEncryptFunc and EncryptWithPassphrase.
- Input validation: no maximum secret size (DoS).
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
209-216); non-constant-time public key compare (vault.go:95-100).
- High priority:
- Secure temporary file handling and cleanup.
- Initialize a default unlock key at vault creation.
- Add secret rm and vault deletion commands.
- Medium priority: - Medium priority:
- Standardize error messages; stop leaking internals. - Standardize error messages; stop leaking internals.
- Graceful handling of corrupted or missing key files with recovery - Graceful handling of corrupted or missing key files with recovery
suggestions. suggestions.
- Validate GPG key existence before creating PGP unlock keys.
- Split oversized CLI functions. - Split oversized CLI functions.
- mlock/munlock for sensitive allocations.
- Cleanups: read statedir from environment or default instead of - Cleanups: read statedir from environment or default instead of
passing it around. passing it around.
- Enhancements: help examples, shell completion, colored output, - Enhancements: help examples, colored output, --quiet flag, name suggestions on
--quiet flag, name suggestions on miss, audit logging, hardware miss, audit logging, hardware integration tests (Keychain, GPG), naming
integration tests (Keychain, GPG), naming consistency, vault consistency, vault export/import, batch operations, search, secret metadata
export/import, batch operations, search, secret metadata
(descriptions, tags). (descriptions, tags).
+8 -4
View File
@@ -38,7 +38,8 @@ const (
) )
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth. // CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
// Returns the uncompressed public key bytes (65 bytes) and the identity hash (for deletion). // Returns the uncompressed public key bytes (65 bytes) and the identity hash
// (for deletion).
func CreateKey(label string) (publicKey []byte, hash string, err error) { func CreateKey(label string) (publicKey []byte, hash string, err error) {
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize) pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
pubKeyLen := C.int(p256UncompressedKeySize) pubKeyLen := C.int(p256UncompressedKeySize)
@@ -57,7 +58,8 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
} }
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) //nolint:nlreturn // CGo result extraction //nolint:nlreturn // CGo result extraction
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
h := C.GoString(&hashBuf[0]) h := C.GoString(&hashBuf[0])
return pk, h, nil return pk, h, nil
@@ -83,7 +85,8 @@ func Encrypt(label string, plaintext []byte) ([]byte, error) {
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
} }
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen) //nolint:nlreturn // CGo result extraction //nolint:nlreturn // CGo result extraction
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen)
return out, nil return out, nil
} }
@@ -107,7 +110,8 @@ func Decrypt(label string, ciphertext []byte) ([]byte, error) {
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
} }
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen) //nolint:nlreturn // CGo result extraction //nolint:nlreturn // CGo result extraction
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen)
return out, nil return out, nil
} }
+6 -6
View File
@@ -1,28 +1,28 @@
//go:build !darwin //go:build !darwin || !cgo
// Package macse provides Go bindings for macOS Secure Enclave operations. // Package macse provides Go bindings for macOS Secure Enclave operations.
package macse package macse
import "errors" import "errors"
var errNotSupported = errors.New("secure enclave is only supported on macOS") var errNotSupported = errors.New("secure enclave needs a macOS build with cgo")
// CreateKey is not supported on non-darwin platforms. // CreateKey fails: the Secure Enclave needs a macOS build with cgo.
func CreateKey(_ string) ([]byte, string, error) { func CreateKey(_ string) ([]byte, string, error) {
return nil, "", errNotSupported return nil, "", errNotSupported
} }
// Encrypt is not supported on non-darwin platforms. // Encrypt fails: the Secure Enclave needs a macOS build with cgo.
func Encrypt(_ string, _ []byte) ([]byte, error) { func Encrypt(_ string, _ []byte) ([]byte, error) {
return nil, errNotSupported return nil, errNotSupported
} }
// Decrypt is not supported on non-darwin platforms. // Decrypt fails: the Secure Enclave needs a macOS build with cgo.
func Decrypt(_ string, _ []byte) ([]byte, error) { func Decrypt(_ string, _ []byte) ([]byte, error) {
return nil, errNotSupported return nil, errNotSupported
} }
// DeleteKey is not supported on non-darwin platforms. // DeleteKey fails: the Secure Enclave needs a macOS build with cgo.
func DeleteKey(_ string) error { func DeleteKey(_ string) error {
return errNotSupported return errNotSupported
} }
+5 -4
View File
@@ -1,5 +1,4 @@
//go:build darwin //go:build darwin && cgo
// +build darwin
package macse package macse
@@ -45,7 +44,8 @@ func TestCreateAndDeleteKey(t *testing.T) {
// Verify valid uncompressed P-256 public key // Verify valid uncompressed P-256 public key
if len(pubKey) != p256UncompressedKeySize { if len(pubKey) != p256UncompressedKeySize {
t.Fatalf("expected public key length %d, got %d", p256UncompressedKeySize, len(pubKey)) t.Fatalf("expected public key length %d, got %d",
p256UncompressedKeySize, len(pubKey))
} }
if pubKey[0] != 0x04 { if pubKey[0] != 0x04 {
@@ -83,7 +83,8 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
}() }()
// Test data simulating an age private key // Test data simulating an age private key
plaintext := []byte("AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ") plaintext := []byte("AGE-SECRET-KEY-1" +
"QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
// Encrypt // Encrypt
ciphertext, err := Encrypt(testKeyLabel, plaintext) ciphertext, err := Encrypt(testKeyLabel, plaintext)
+37 -10
View File
@@ -1,5 +1,6 @@
//go:build darwin //go:build darwin
//nolint:testpackage // white-box test of unexported getLongTermPrivateKey
package secret package secret
import ( import (
@@ -27,22 +28,44 @@ type realVault struct {
func (v *realVault) GetDirectory() (string, error) { func (v *realVault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.name), nil return filepath.Join(v.stateDir, "vaults.d", v.name), nil
} }
func (v *realVault) GetName() string { return v.name } func (v *realVault) GetName() string { return v.name }
//nolint:ireturn // implements VaultInterface
func (v *realVault) GetFilesystem() afero.Fs { return v.fs } func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
// Unused by getLongTermPrivateKey — these satisfy VaultInterface. // Unused by getLongTermPrivateKey — these satisfy VaultInterface.
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") } func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error {
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") } panic("not used")
func (v *realVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) { panic("not used") } }
func (v *realVault) SetMnemonic(*memguard.LockedBuffer) { panic("not used") }
func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) { panic("not used") } //nolint:ireturn // implements VaultInterface
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) { func (v *realVault) GetCurrentUnlocker() (Unlocker, error) {
panic("not used")
}
func (v *realVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
panic("not used")
}
func (v *realVault) SetMnemonic(*memguard.LockedBuffer) {
panic("not used")
}
func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) {
panic("not used")
}
func (v *realVault) CreatePassphraseUnlocker(
*memguard.LockedBuffer,
) (*PassphraseUnlocker, error) {
panic("not used") panic("not used")
} }
// createRealVault sets up a complete vault directory structure on an in-memory // createRealVault sets up a complete vault directory structure on an in-memory
// filesystem, identical to what vault.CreateVault produces. // filesystem, identical to what vault.CreateVault produces.
func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32) *realVault { func createRealVault(
t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32,
) *realVault {
t.Helper() t.Helper()
vaultDir := filepath.Join(stateDir, "vaults.d", name) vaultDir := filepath.Join(stateDir, "vaults.d", name)
@@ -55,7 +78,8 @@ func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivatio
} }
metaBytes, err := json.Marshal(metadata) metaBytes, err := json.Marshal(metadata)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms)) require.NoError(t, afero.WriteFile(fs,
filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
return &realVault{name: name, stateDir: stateDir, fs: fs} return &realVault{name: name, stateDir: stateDir, fs: fs}
} }
@@ -63,7 +87,9 @@ func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivatio
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) { func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
t.Parallel() t.Parallel()
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" //nolint:dupword // BIP39 test mnemonic repeats words by design
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
// Derive expected keys at two different indices to prove they differ. // Derive expected keys at two different indices to prove they differ.
key0, err := agehd.DeriveIdentity(testMnemonic, 0) key0, err := agehd.DeriveIdentity(testMnemonic, 0)
@@ -82,6 +108,7 @@ func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil) result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
require.NoError(t, err) require.NoError(t, err)
defer result.Destroy() defer result.Destroy()
assert.Equal(t, key5.String(), string(result.Bytes()), assert.Equal(t, key5.String(), string(result.Bytes()),
+203 -203
View File
@@ -1,11 +1,11 @@
//go:build darwin //go:build darwin
// +build darwin
package secret package secret
import ( import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@@ -17,23 +17,40 @@ import (
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
keychain "github.com/keybase/go-keychain"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
const ( const (
agePrivKeyPassphraseLength = 64 agePrivKeyPassphraseLength = 64
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items // KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret" //nolint:revive // ALL_CAPS is intentional for this constant //
//nolint:revive // ALL_CAPS is intentional for this constant
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret"
// keychainUnlockerType is the metadata type string for keychain unlockers.
keychainUnlockerType = "keychain"
// macOSFlag is the unlocker metadata flag of the macOS-only unlockers.
macOSFlag = "macos"
) )
// keychainItemNameRegex validates keychain item names // keychainItemNameRegex validates keychain item names
// Allows alphanumeric characters, dots, hyphens, and underscores only // Allows alphanumeric characters, dots, hyphens, and underscores only
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
var (
errNotMacOS = errors.New(
"keychain unlockers are only supported on macOS")
errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty")
errInvalidKeychainItemName = errors.New("invalid keychain item name format")
errUnsupportedCurrentUnlocker = errors.New(
"unsupported current unlocker type for keychain unlocker creation")
)
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data // KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
type KeychainUnlockerMetadata struct { type KeychainUnlockerMetadata struct {
UnlockerMetadata UnlockerMetadata
// Keychain item name // Keychain item name
KeychainItemName string `json:"keychainItemName"` KeychainItemName string `json:"keychainItemName"`
} }
@@ -45,6 +62,17 @@ type KeychainUnlocker struct {
fs afero.Fs fs afero.Fs
} }
// NewKeychainUnlocker creates a new KeychainUnlocker instance
func NewKeychainUnlocker(
fs afero.Fs, directory string, metadata UnlockerMetadata,
) *KeychainUnlocker {
return &KeychainUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity implements Unlocker interface for Keychain-based unlockers // GetIdentity implements Unlocker interface for Keychain-based unlockers
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) { func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting keychain unlocker identity", DebugWith("Getting keychain unlocker identity",
@@ -52,50 +80,20 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
slog.String("unlocker_type", k.GetType()), slog.String("unlocker_type", k.GetType()),
) )
// Step 1: Get keychain item name keychainData, err := k.readKeychainData()
keychainItemName, err := k.GetKeychainItemName()
if err != nil { if err != nil {
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID()) return nil, err
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
}
// Step 2: Retrieve data from keychain
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
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)
}
DebugWith("Retrieved data from keychain",
slog.String("unlocker_id", k.GetID()),
slog.Int("data_length", len(keychainDataBytes)),
)
// Move the keychain data into locked memory; this wipes keychainDataBytes
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
defer keychainDataBuffer.Destroy()
// Step 3: Parse keychain data
keychainData, err := decodeKeychainData(keychainDataBuffer)
if 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)
} }
defer keychainData.AgePrivKeyPassphrase.Destroy() defer keychainData.AgePrivKeyPassphrase.Destroy()
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
// Step 4: Read the encrypted age private key from filesystem // Step 4: Read the encrypted age private key from filesystem
agePrivKeyPath := filepath.Join(k.Directory, "priv.age") agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
Debug("Reading encrypted age private key", "path", agePrivKeyPath) Debug("Reading encrypted age private key", "path", agePrivKeyPath)
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath) encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
if err != nil { if err != nil {
Debug("Failed to read encrypted age private key", "error", err, "path", agePrivKeyPath) 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) return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
} }
@@ -106,12 +104,17 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
) )
// Step 5: Decrypt the age private key using the passphrase from keychain // Step 5: Decrypt the age private key using the passphrase from keychain
Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID()) Debug("Decrypting age private key with keychain passphrase",
agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase) "unlocker_id", k.GetID())
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) agePrivKeyBuffer, err := DecryptWithPassphrase(
encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
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)
} }
defer agePrivKeyBuffer.Destroy() defer agePrivKeyBuffer.Destroy()
@@ -140,7 +143,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
// GetType implements Unlocker interface // GetType implements Unlocker interface
func (k *KeychainUnlocker) GetType() string { func (k *KeychainUnlocker) GetType() string {
return "keychain" return keychainUnlockerType
} }
// GetMetadata implements Unlocker interface // GetMetadata implements Unlocker interface
@@ -174,58 +177,105 @@ func (k *KeychainUnlocker) Remove() error {
// Step 1: Get keychain item name // Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName() keychainItemName, err := k.GetKeychainItemName()
if err != nil { if err != nil {
Debug("Failed to get keychain item name during removal", "error", err, "unlocker_id", k.GetID()) 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) return fmt.Errorf("failed to get keychain item name: %w", err)
} }
// Step 2: Remove from keychain // Step 2: Remove from keychain
Debug("Removing keychain item", "keychain_item", keychainItemName) Debug("Removing keychain item", "keychain_item", keychainItemName)
if err := deleteFromKeychain(keychainItemName); err != nil {
Debug("Failed to remove keychain item", "error", err, "keychain_item", keychainItemName) err = deleteFromKeychain(keychainItemName)
if err != nil {
Debug("Failed to remove keychain item",
"error", err, "keychain_item", keychainItemName)
return fmt.Errorf("failed to remove keychain item: %w", err) return fmt.Errorf("failed to remove keychain item: %w", err)
} }
// Step 3: Remove directory // Step 3: Remove directory
Debug("Removing keychain unlocker directory", "directory", k.Directory) Debug("Removing keychain unlocker directory", "directory", k.Directory)
if err := RemoveDirAtomic(k.fs, k.Directory); err != nil {
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory) err = RemoveDirAtomic(k.fs, k.Directory)
if 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) return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
} }
Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName) Debug("Successfully removed keychain unlocker",
"unlocker_id", k.GetID(), "keychain_item", keychainItemName)
return nil return nil
} }
// NewKeychainUnlocker creates a new KeychainUnlocker instance
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
return &KeychainUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetKeychainItemName returns the keychain item name from metadata // GetKeychainItemName returns the keychain item name from metadata
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) { func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
// Load the metadata // Load the metadata
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json") metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(k.fs, metadataPath) metadataData, err := afero.ReadFile(k.fs, metadataPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read keychain metadata: %w", err) return "", fmt.Errorf("failed to read keychain metadata: %w", err)
} }
var keychainMetadata KeychainUnlockerMetadata var keychainMetadata KeychainUnlockerMetadata
if err := json.Unmarshal(metadataData, &keychainMetadata); err != nil {
err = json.Unmarshal(metadataData, &keychainMetadata)
if err != nil {
return "", fmt.Errorf("failed to parse keychain metadata: %w", err) return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
} }
return keychainMetadata.KeychainItemName, nil return keychainMetadata.KeychainItemName, nil
} }
// readKeychainData reads and parses the data this unlocker keeps in the
// keychain (steps 1 to 3 of GetIdentity). The caller must destroy the
// returned AgePrivKeyPassphrase.
func (k *KeychainUnlocker) readKeychainData() (*KeychainData, error) {
// Step 1: Get keychain item name
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)
}
// Step 2: Retrieve data from keychain
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
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)
}
DebugWith("Retrieved data from keychain",
slog.String("unlocker_id", k.GetID()),
slog.Int("data_length", len(keychainDataBytes)),
)
// Move the keychain data into locked memory; this wipes keychainDataBytes
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
defer keychainDataBuffer.Destroy()
// Step 3: Parse keychain data
keychainData, err := decodeKeychainData(keychainDataBuffer)
if 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)
}
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
return keychainData, nil
}
// generateKeychainUnlockerName generates a unique name for the keychain unlocker // generateKeychainUnlockerName generates a unique name for the keychain unlocker
func generateKeychainUnlockerName(vaultName string) (string, error) { func generateKeychainUnlockerName(vaultName string) (string, error) {
hostname, err := os.Hostname() hostname, err := os.Hostname()
@@ -247,31 +297,7 @@ func getLongTermPrivateKey(
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer, fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) { ) (*memguard.LockedBuffer, error) {
if mnemonic != nil { if mnemonic != nil {
// Read vault metadata to get the correct derivation index return deriveLongTermPrivateKey(fs, vault, mnemonic)
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
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 // Get the vault to access current unlocker
@@ -292,34 +318,43 @@ func getLongTermPrivateKey(
// Get encrypted long-term key from current unlocker, handling different types // Get encrypted long-term key from current unlocker, handling different types
var encryptedLtPrivKey []byte var encryptedLtPrivKey []byte
switch currentUnlocker := currentUnlocker.(type) { switch currentUnlocker := currentUnlocker.(type) {
case *PassphraseUnlocker: case *PassphraseUnlocker:
// Read the encrypted long-term private key from passphrase unlocker // Read the encrypted long-term private key from passphrase unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age")) encryptedLtPrivKey, err = afero.ReadFile(fs,
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err) return nil, fmt.Errorf("failed to read encrypted long-term key "+
"from current passphrase unlocker: %w", err)
} }
case *PGPUnlocker: case *PGPUnlocker:
// Read the encrypted long-term private key from PGP unlocker // Read the encrypted long-term private key from PGP unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age")) encryptedLtPrivKey, err = afero.ReadFile(fs,
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err) return nil, fmt.Errorf("failed to read encrypted long-term key "+
"from current PGP unlocker: %w", err)
} }
case *KeychainUnlocker: case *KeychainUnlocker:
// Read the encrypted long-term private key from another keychain unlocker // Read the encrypted long-term private key from another keychain
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age")) // unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs,
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlocker: %w", err) return nil, fmt.Errorf("failed to read encrypted long-term key "+
"from current keychain unlocker: %w", err)
} }
default: default:
return nil, fmt.Errorf("unsupported current unlocker type for keychain unlocker creation") return nil, errUnsupportedCurrentUnlocker
} }
// Decrypt long-term private key using current unlocker // Decrypt long-term private key using current unlocker
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity) ltPrivKeyBuffer, err := DecryptWithIdentity(
encryptedLtPrivKey, currentUnlockerIdentity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
} }
@@ -328,6 +363,43 @@ func getLongTermPrivateKey(
return ltPrivKeyBuffer, nil return ltPrivKeyBuffer, nil
} }
// deriveLongTermPrivateKey derives the long-term private key from mnemonic at
// the vault's derivation index, for getLongTermPrivateKey and
// getLongTermKeyForSE.
func deriveLongTermPrivateKey(
fs afero.Fs, vault VaultInterface, mnemonic *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
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
}
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the // CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
// vault. The long-term key comes from mnemonic when it is not nil, else from // vault. The long-term key comes from mnemonic when it is not nil, else from
// the current unlocker, as getLongTermPrivateKey describes. // the current unlocker, as getLongTermPrivateKey describes.
@@ -335,7 +407,8 @@ func CreateKeychainUnlocker(
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer, fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
) (*KeychainUnlocker, error) { ) (*KeychainUnlocker, error) {
// Check if we're on macOS // Check if we're on macOS
if err := checkMacOSAvailable(); err != nil { err := checkMacOSAvailable()
if err != nil {
return nil, err return nil, err
} }
@@ -377,10 +450,12 @@ func CreateKeychainUnlocker(
// Step 3: Encrypt age private key with the generated passphrase // Step 3: Encrypt age private key with the generated passphrase
// Create a secure buffer for the private key // Create a secure buffer for the private key
agePrivKeyStr := ageIdentity.String() agePrivKeyStr := ageIdentity.String()
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr)) agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
defer agePrivKeyBuffer.Destroy() defer agePrivKeyBuffer.Destroy()
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, agePrivKeyPassphrase) encryptedAgePrivKey, err := EncryptWithPassphrase(
agePrivKeyBuffer, agePrivKeyPassphrase)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err) return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
} }
@@ -393,9 +468,11 @@ func CreateKeychainUnlocker(
defer ltPrivKeyData.Destroy() defer ltPrivKeyData.Destroy()
// Step 5: Encrypt long-term private key to the new age unlocker // Step 5: Encrypt long-term private key to the new age unlocker
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient()) encryptedLtPrivKeyToAge, err := EncryptToRecipient(
ltPrivKeyData, ageIdentity.Recipient())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err) return nil, fmt.Errorf(
"failed to encrypt long-term private key to age unlocker: %w", err)
} }
// Step 6: Prepare keychain data // Step 6: Prepare keychain data
@@ -411,12 +488,23 @@ func CreateKeychainUnlocker(
} }
defer keychainDataBuffer.Destroy() defer keychainDataBuffer.Destroy()
return writeKeychainUnlocker(fs, unlockerDir, keychainItemName, ageRecipient,
encryptedAgePrivKey, encryptedLtPrivKeyToAge, keychainDataBuffer)
}
// writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and
// stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker).
func writeKeychainUnlocker(
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
encryptedAgePrivKey, encryptedLtPrivKey []byte,
keychainDataBuffer *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
// Step 7: Prepare enhanced metadata // Step 7: Prepare enhanced metadata
keychainMetadata := KeychainUnlockerMetadata{ keychainMetadata := KeychainUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{ UnlockerMetadata: UnlockerMetadata{
Type: "keychain", Type: keychainUnlockerType,
CreatedAt: time.Now(), CreatedAt: time.Now(),
Flags: []string{"keychain", "macos"}, Flags: []string{keychainUnlockerType, macOSFlag},
}, },
KeychainItemName: keychainItemName, KeychainItemName: keychainItemName,
} }
@@ -429,27 +517,29 @@ func CreateKeychainUnlocker(
// Step 8: Write the unlocker's files and store the data in the keychain, // Step 8: Write the unlocker's files and store the data in the keychain,
// the metadata last // the metadata last
err = WriteDir(fs, unlockerDir, func(dir string) error { err = WriteDir(fs, unlockerDir, func(dir string) error {
pubPath := filepath.Join(dir, "pub.txt") err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
if err := WriteFileAtomic(fs, pubPath, []byte(ageRecipient)); err != nil { if err != nil {
return fmt.Errorf("failed to write age recipient: %w", err) return fmt.Errorf("failed to write age recipient: %w", err)
} }
privPath := filepath.Join(dir, "priv.age") err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age"), encryptedAgePrivKey)
if err := WriteFileAtomic(fs, privPath, encryptedAgePrivKey); err != nil { if err != nil {
return fmt.Errorf("failed to write encrypted age private key: %w", err) return fmt.Errorf("failed to write encrypted age private key: %w", err)
} }
ltKeyPath := filepath.Join(dir, "longterm.age") err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtPrivKeyToAge); err != nil { if err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err) return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
} }
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil { err = storeInKeychain(keychainItemName, keychainDataBuffer)
if err != nil {
return fmt.Errorf("failed to store data in keychain: %w", err) return fmt.Errorf("failed to store data in keychain: %w", err)
} }
metadataPath := filepath.Join(dir, "unlocker-metadata.json") err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil { metadataBytes)
if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err) return fmt.Errorf("failed to write unlocker metadata: %w", err)
} }
@@ -469,111 +559,21 @@ func CreateKeychainUnlocker(
// checkMacOSAvailable verifies that we're running on macOS // checkMacOSAvailable verifies that we're running on macOS
func checkMacOSAvailable() error { func checkMacOSAvailable() error {
if runtime.GOOS != "darwin" { if runtime.GOOS != "darwin" {
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS) return fmt.Errorf("%w, current OS: %s", errNotMacOS, runtime.GOOS)
} }
return nil return nil
} }
// validateKeychainItemName validates that a keychain item name is safe for command execution // validateKeychainItemName validates that a keychain item name is safe for
// command execution
func validateKeychainItemName(itemName string) error { func validateKeychainItemName(itemName string) error {
if itemName == "" { if itemName == "" {
return fmt.Errorf("keychain item name cannot be empty") return errKeychainItemNameEmpty
} }
if !keychainItemNameRegex.MatchString(itemName) { if !keychainItemNameRegex.MatchString(itemName) {
return fmt.Errorf("invalid keychain item name format: %s", itemName) return fmt.Errorf("%w: %s", errInvalidKeychainItemName, itemName)
}
return nil
}
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
if data == nil {
return fmt.Errorf("data buffer is nil")
}
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
item.SetDescription("Secret vault keychain data")
item.SetData(data.Bytes())
item.SetSynchronizable(keychain.SynchronizableNo)
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
// First try to delete any existing item
deleteItem := keychain.NewItem()
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
deleteItem.SetAccount(itemName)
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
// Add the new item
if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("failed to store item in keychain: %w", err)
}
return nil
}
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
func retrieveFromKeychain(itemName string) ([]byte, error) {
if err := validateKeychainItemName(itemName); err != nil {
return nil, fmt.Errorf("invalid keychain item name: %w", err)
}
query := keychain.NewItem()
query.SetSecClass(keychain.SecClassGenericPassword)
query.SetService(KEYCHAIN_APP_IDENTIFIER)
query.SetAccount(itemName)
query.SetMatchLimit(keychain.MatchLimitOne)
query.SetReturnData(true)
results, err := keychain.QueryItem(query)
if err != nil {
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("keychain item not found: %s", itemName)
}
return results[0].Data, nil
}
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
// If the item doesn't exist, this function returns nil (not an error) since the goal
// is to ensure the item is gone, and it already being gone satisfies that goal.
func deleteFromKeychain(itemName string) error {
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
if err := keychain.DeleteItem(item); err != nil {
// If the item doesn't exist, that's not an error - the goal is to ensure
// the item is gone, and it already being gone satisfies that goal.
// This is important for cleaning up unlocker directories when the keychain
// item has already been removed (e.g., manually by user, or synced vault
// from a different machine).
if err == keychain.ErrorItemNotFound {
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
return nil
}
return fmt.Errorf("failed to delete item from keychain: %w", err)
} }
return nil return nil
+104
View File
@@ -0,0 +1,104 @@
//go:build darwin && cgo
package secret
import (
"fmt"
"github.com/awnumar/memguard"
keychain "github.com/keybase/go-keychain"
)
// The keychain unlocker's only calls into go-keychain, which is cgo on macOS.
// A macOS build without cgo gets keychainunlocker_nocgo.go instead.
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
if data == nil {
return fmt.Errorf("data buffer is nil")
}
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
item.SetDescription("Secret vault keychain data")
item.SetData(data.Bytes())
item.SetSynchronizable(keychain.SynchronizableNo)
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
// First try to delete any existing item
deleteItem := keychain.NewItem()
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
deleteItem.SetAccount(itemName)
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
// Add the new item
if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("failed to store item in keychain: %w", err)
}
return nil
}
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
func retrieveFromKeychain(itemName string) ([]byte, error) {
if err := validateKeychainItemName(itemName); err != nil {
return nil, fmt.Errorf("invalid keychain item name: %w", err)
}
query := keychain.NewItem()
query.SetSecClass(keychain.SecClassGenericPassword)
query.SetService(KEYCHAIN_APP_IDENTIFIER)
query.SetAccount(itemName)
query.SetMatchLimit(keychain.MatchLimitOne)
query.SetReturnData(true)
results, err := keychain.QueryItem(query)
if err != nil {
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("keychain item not found: %s", itemName)
}
return results[0].Data, nil
}
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
// If the item doesn't exist, this function returns nil (not an error) since the goal
// is to ensure the item is gone, and it already being gone satisfies that goal.
func deleteFromKeychain(itemName string) error {
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
if err := keychain.DeleteItem(item); err != nil {
// If the item doesn't exist, that's not an error - the goal is to ensure
// the item is gone, and it already being gone satisfies that goal.
// This is important for cleaning up unlocker directories when the keychain
// item has already been removed (e.g., manually by user, or synced vault
// from a different machine).
if err == keychain.ErrorItemNotFound {
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
return nil
}
return fmt.Errorf("failed to delete item from keychain: %w", err)
}
return nil
}
+30
View File
@@ -0,0 +1,30 @@
//go:build darwin && !cgo
package secret
import (
"errors"
"github.com/awnumar/memguard"
)
// In a macOS build without cgo, these take the place of the functions in
// keychainunlocker_cgo.go: go-keychain is cgo on macOS, so they can only fail.
var errKeychainNotSupported = errors.New(
"keychain unlockers need a macOS build with cgo")
// storeInKeychain fails: the keychain needs a macOS build with cgo.
func storeInKeychain(_ string, _ *memguard.LockedBuffer) error {
return errKeychainNotSupported
}
// retrieveFromKeychain fails: the keychain needs a macOS build with cgo.
func retrieveFromKeychain(_ string) ([]byte, error) {
return nil, errKeychainNotSupported
}
// deleteFromKeychain fails: the keychain needs a macOS build with cgo.
func deleteFromKeychain(_ string) error {
return errKeychainNotSupported
}
+9 -6
View File
@@ -1,5 +1,4 @@
//go:build darwin //go:build darwin && cgo
// +build darwin
package secret package secret
@@ -35,7 +34,8 @@ func TestKeychainStoreRetrieveDelete(t *testing.T) {
// Test 2: Retrieve data from keychain // Test 2: Retrieve data from keychain
retrievedData, err := retrieveFromKeychain(testItemName) retrievedData, err := retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve data from keychain") require.NoError(t, err, "Failed to retrieve data from keychain")
assert.Equal(t, testData, string(retrievedData), "Retrieved data doesn't match stored data") assert.Equal(t, testData, string(retrievedData),
"Retrieved data doesn't match stored data")
// Test 3: Update existing item (store again with different data) // Test 3: Update existing item (store again with different data)
newTestData := "updated-test-data-67890" newTestData := "updated-test-data-67890"
@@ -48,7 +48,8 @@ func TestKeychainStoreRetrieveDelete(t *testing.T) {
// Verify updated data // Verify updated data
retrievedData, err = retrieveFromKeychain(testItemName) retrievedData, err = retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve updated data from keychain") require.NoError(t, err, "Failed to retrieve updated data from keychain")
assert.Equal(t, newTestData, string(retrievedData), "Retrieved data doesn't match updated data") assert.Equal(t, newTestData, string(retrievedData),
"Retrieved data doesn't match updated data")
// Test 4: Delete from keychain // Test 4: Delete from keychain
err = deleteFromKeychain(testItemName) err = deleteFromKeychain(testItemName)
@@ -93,7 +94,8 @@ func TestKeychainInvalidItemName(t *testing.T) {
for _, name := range invalidNames { for _, name := range invalidNames {
err := storeInKeychain(name, testData) err := storeInKeychain(name, testData)
assert.Error(t, err, "Expected error for invalid name: %s", name) assert.Error(t, err, "Expected error for invalid name: %s", name)
assert.Contains(t, err.Error(), "invalid keychain item name", "Error should mention invalid name for: %s", name) assert.Contains(t, err.Error(), "invalid keychain item name",
"Error should mention invalid name for: %s", name)
} }
// Test valid names (should not error on validation) // Test valid names (should not error on validation)
@@ -180,5 +182,6 @@ func TestDeleteNonExistentKeychainItem(t *testing.T) {
// This is important for cleaning up unlocker directories when the keychain item // This is important for cleaning up unlocker directories when the keychain item
// has already been removed (e.g., manually by user, or on a different machine) // has already been removed (e.g., manually by user, or on a different machine)
err := deleteFromKeychain(testItemName) err := deleteFromKeychain(testItemName)
assert.NoError(t, err, "Deleting non-existent keychain item should not return an error") assert.NoError(t, err,
"Deleting non-existent keychain item should not return an error")
} }
+455 -325
View File
@@ -4,7 +4,9 @@ package secret_test
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -22,23 +24,24 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// Register vault with secret package for testing // pgpUnlockerType is the type of a PGP unlocker.
func init() { const pgpUnlockerType = "pgp"
// Register the vault.GetCurrentVault function with the secret package
secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) { var errNilDataBuffer = errors.New("data buffer is nil")
return vault.GetCurrentVault(fs, stateDir)
})
}
// setupNonInteractiveGPG creates a custom GPG environment for testing // setupNonInteractiveGPG creates a custom GPG environment for testing
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) { func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
t.Helper()
// Create GPG config file for non-interactive operation // Create GPG config file for non-interactive operation
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf") gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
gpgConfContent := `batch gpgConfContent := `batch
no-tty no-tty
pinentry-mode loopback pinentry-mode loopback
` `
if err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600); err != nil {
err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600)
if err != nil {
t.Fatalf("Failed to write GPG config file: %v", err) t.Fatalf("Failed to write GPG config file: %v", err)
} }
@@ -47,11 +50,15 @@ pinentry-mode loopback
origDecryptFunc := secret.GPGDecryptFunc origDecryptFunc := secret.GPGDecryptFunc
// Set custom GPG functions for this test // Set custom GPG functions for this test
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, keyID string) ([]byte, error) { secret.GPGEncryptFunc = func(
data *memguard.LockedBuffer, keyID string,
) ([]byte, error) {
if data == nil { if data == nil {
return nil, fmt.Errorf("data buffer is nil") return nil, errNilDataBuffer
} }
cmd := exec.Command("gpg",
//nolint:gosec // G204: test runs gpg with test-controlled arguments
cmd := exec.CommandContext(t.Context(), "gpg",
"--homedir", gnupgHomeDir, "--homedir", gnupgHomeDir,
"--batch", "--batch",
"--yes", "--yes",
@@ -63,11 +70,13 @@ pinentry-mode loopback
"-r", keyID) "-r", keyID)
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
cmd.Stdin = bytes.NewReader(data.Bytes()) cmd.Stdin = bytes.NewReader(data.Bytes())
if err := cmd.Run(); err != nil { err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String()) return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
} }
@@ -75,7 +84,8 @@ pinentry-mode loopback
} }
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) { secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
cmd := exec.Command("gpg", //nolint:gosec // G204: test runs gpg with test-controlled arguments
cmd := exec.CommandContext(t.Context(), "gpg",
"--homedir", gnupgHomeDir, "--homedir", gnupgHomeDir,
"--batch", "--batch",
"--yes", "--yes",
@@ -85,11 +95,13 @@ pinentry-mode loopback
"--decrypt") "--decrypt")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
cmd.Stdin = bytes.NewReader(encryptedData) cmd.Stdin = bytes.NewReader(encryptedData)
if err := cmd.Run(); err != nil { err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String()) return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
} }
@@ -105,20 +117,24 @@ pinentry-mode loopback
} }
// runGPGWithPassphrase executes a GPG command with the specified passphrase // runGPGWithPassphrase executes a GPG command with the specified passphrase
func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.Reader) ([]byte, error) { func runGPGWithPassphrase(
cmdArgs := []string{ ctx context.Context,
gnupgHome, passphrase string, args []string, input io.Reader,
) ([]byte, error) {
cmdArgs := append([]string{
"--homedir=" + gnupgHome, "--homedir=" + gnupgHome,
"--batch", "--batch",
"--yes", "--yes",
"--pinentry-mode", "loopback", "--pinentry-mode", "loopback",
"--passphrase", passphrase, "--passphrase", passphrase,
} }, args...)
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command("gpg", cmdArgs...) //nolint:gosec // G204: test runs gpg with test-controlled arguments
cmd := exec.CommandContext(ctx, "gpg", cmdArgs...)
cmd.Stdin = input cmd.Stdin = input
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
@@ -130,14 +146,96 @@ func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.
return stdout.Bytes(), nil return stdout.Bytes(), nil
} }
// generateTestGPGKey generates a GPG key protected by passphrase in
// gnupgHomeDir and returns its key ID and fingerprint.
func generateTestGPGKey(
t *testing.T, tempDir, gnupgHomeDir, passphrase string,
) (string, string) {
t.Helper()
// Create GPG batch file for key generation
batchFile := filepath.Join(tempDir, "gen-key-batch")
batchContent := `%echo Generating a test key
Key-Type: RSA
Key-Length: 2048
Name-Real: Test User
Name-Email: test@example.com
Expire-Date: 0
Passphrase: ` + passphrase + `
%commit
%echo Key generation completed
`
err := os.WriteFile(batchFile, []byte(batchContent), 0o600)
if err != nil {
t.Fatalf("Failed to write batch file: %v", err)
}
// Generate GPG key with batch mode
t.Log("Generating GPG key...")
_, err = runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
[]string{"--gen-key", batchFile}, nil)
if err != nil {
t.Fatalf("Failed to generate GPG key: %v", err)
}
t.Log("GPG key generated successfully")
// Get the key ID and fingerprint
output, err := runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
[]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 and fingerprint
var keyID, fingerprint string
for line := range strings.SplitSeq(string(output), "\n") {
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
}
}
}
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)
return keyID, fingerprint
}
//nolint:paralleltest // t.Setenv forbids parallel subtests
func TestPGPUnlockerWithRealFS(t *testing.T) { func TestPGPUnlockerWithRealFS(t *testing.T) {
// Check if gpg is available // Check if gpg is available
if _, err := exec.LookPath("gpg"); err != nil { _, err := exec.LookPath("gpg")
if err != nil {
t.Log("GPG not available, PGP unlock key tests may not fully function") t.Log("GPG not available, PGP unlock key tests may not fully function")
// Continue anyway to test what we can // Continue anyway to test what we can
} }
// Create a temporary directory for our tests // Create a temporary directory for our tests. Not t.TempDir: its longer
// path would put gpg-agent's socket in GNUPGHOME past the 104-byte limit
// macOS sets on socket paths.
//
//nolint:usetesting // see the comment above
tempDir, err := os.MkdirTemp("", "secret-pgp-test-") tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
@@ -146,7 +244,9 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
// Create a temporary GNUPGHOME // Create a temporary GNUPGHOME
gnupgHomeDir := filepath.Join(tempDir, "gnupg") gnupgHomeDir := filepath.Join(tempDir, "gnupg")
if err := os.MkdirAll(gnupgHomeDir, 0o700); err != nil {
err = os.MkdirAll(gnupgHomeDir, 0o700)
if err != nil {
t.Fatalf("Failed to create GNUPGHOME: %v", err) t.Fatalf("Failed to create GNUPGHOME: %v", err)
} }
@@ -159,64 +259,7 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
// Setup non-interactive GPG with custom functions // Setup non-interactive GPG with custom functions
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir) setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
// Create GPG batch file for key generation keyID, fingerprint := generateTestGPGKey(t, tempDir, gnupgHomeDir, testPassphrase)
batchFile := filepath.Join(tempDir, "gen-key-batch")
batchContent := `%echo Generating a test key
Key-Type: RSA
Key-Length: 2048
Name-Real: Test User
Name-Email: test@example.com
Expire-Date: 0
Passphrase: ` + testPassphrase + `
%commit
%echo Key generation completed
`
if err := os.WriteFile(batchFile, []byte(batchContent), 0o600); err != nil {
t.Fatalf("Failed to write batch file: %v", err)
}
// Generate GPG key with batch mode
t.Log("Generating GPG key...")
_, err = runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
[]string{"--gen-key", batchFile}, nil)
if err != nil {
t.Fatalf("Failed to generate GPG key: %v", err)
}
t.Log("GPG key generated successfully")
// Get the key ID and fingerprint
output, err := runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
[]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 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
}
}
}
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 // Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
t.Setenv("GPG_AGENT_INFO", "") t.Setenv("GPG_AGENT_INFO", "")
@@ -224,12 +267,6 @@ Passphrase: ` + testPassphrase + `
// Use the real filesystem // Use the real filesystem
fs := afero.NewOsFs() fs := afero.NewOsFs()
// Test data
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
// Set test environment variables // Set test environment variables
t.Setenv(secret.EnvGPGKeyID, keyID) t.Setenv(secret.EnvGPGKeyID, keyID)
@@ -239,162 +276,20 @@ Passphrase: ` + testPassphrase + `
// Test creation of a PGP unlock key through a vault // Test creation of a PGP unlock key through a vault
t.Run("CreatePGPUnlocker", func(t *testing.T) { t.Run("CreatePGPUnlocker", func(t *testing.T) {
// Set a limited test timeout to avoid hanging testCreatePGPUnlocker(t, fs, stateDir, vaultName, keyID, fingerprint)
timer := time.AfterFunc(30*time.Second, func() {
t.Fatalf("Test timed out after 30 seconds")
})
defer timer.Stop()
// Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Set the current vault
err = vault.SelectVault(fs, stateDir, vaultName)
if err != nil {
t.Fatalf("Failed to select vault: %v", err)
}
// Derive long-term key from mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err)
}
// Get the vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
// Write long-term public key
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), secret.FilePerms); err != nil {
t.Fatalf("Failed to write long-term public key: %v", err)
}
// Unlock the vault
vlt.Unlock(ltIdentity)
// Create a passphrase unlocker first (to have current unlocker)
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)
}
// Verify passphrase unlocker was created
if passUnlocker == nil {
t.Fatal("Passphrase unlocker is nil")
}
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID, fingerprint, mnemonic, nil)
if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err)
}
// Verify the PGP unlock key was created
if pgpUnlocker == nil {
t.Fatal("PGP unlock key is nil")
}
// Check if the key has the correct type
if pgpUnlocker.GetType() != "pgp" {
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
}
// 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
unlockerDir := pgpUnlocker.GetDirectory()
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
}
if !keyExists {
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
}
// Check if required files exist
recipientPath := filepath.Join(unlockerDir, "pub.txt")
recipientExists, err := afero.Exists(fs, recipientPath)
if err != nil {
t.Fatalf("Failed to check if recipient file exists: %v", err)
}
if !recipientExists {
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
}
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
privKeyExists, err := afero.Exists(fs, privKeyPath)
if err != nil {
t.Fatalf("Failed to check if private key file exists: %v", err)
}
if !privKeyExists {
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
}
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
metadataExists, err := afero.Exists(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to check if metadata file exists: %v", err)
}
if !metadataExists {
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
}
longtermPath := filepath.Join(unlockerDir, "longterm.age")
longtermExists, err := afero.Exists(fs, longtermPath)
if err != nil {
t.Fatalf("Failed to check if longterm key file exists: %v", err)
}
if !longtermExists {
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
}
// Read and verify metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to read metadata: %v", err)
}
var metadata struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags"`
GPGKeyID string `json:"gpgKeyId"`
}
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
t.Fatalf("Failed to parse metadata: %v", err)
}
if metadata.Type != "pgp" {
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
}
if metadata.GPGKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
}
}) })
// Set up key directory for individual tests // Set up key directory for individual tests
unlockerDir := filepath.Join(tempDir, "unlocker") unlockerDir := filepath.Join(tempDir, "unlocker")
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
err = os.MkdirAll(unlockerDir, secret.DirPerms)
if err != nil {
t.Fatalf("Failed to create unlocker directory: %v", err) t.Fatalf("Failed to create unlocker directory: %v", err)
} }
// Set up test metadata // Set up test metadata
metadata := secret.UnlockerMetadata{ metadata := secret.UnlockerMetadata{
Type: "pgp", Type: pgpUnlockerType,
CreatedAt: time.Now(), CreatedAt: time.Now(),
Flags: []string{"gpg", "encrypted"}, Flags: []string{"gpg", "encrypted"},
} }
@@ -404,105 +299,340 @@ Passphrase: ` + testPassphrase + `
// Test getting GPG key ID // Test getting GPG key ID
t.Run("GetGPGKeyID", func(t *testing.T) { t.Run("GetGPGKeyID", func(t *testing.T) {
// Create PGP metadata with GPG key ID testGetGPGKeyID(t, fs, unlocker, unlockerDir, metadata, fingerprint)
type PGPUnlockerMetadata struct {
secret.UnlockerMetadata
GPGKeyID string `json:"gpgKeyId"`
}
pgpMetadata := PGPUnlockerMetadata{
UnlockerMetadata: metadata,
GPGKeyID: fingerprint,
}
// Write metadata file
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil {
t.Fatalf("Failed to marshal metadata: %v", err)
}
if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
t.Fatalf("Failed to write metadata: %v", err)
}
// Get GPG key ID
retrievedKeyID, err := unlocker.GetGPGKeyID()
if err != nil {
t.Fatalf("Failed to get GPG key ID: %v", err)
}
// Verify key ID (should be the fingerprint)
if retrievedKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
}
}) })
// Test getting identity from PGP unlocker // Test getting identity from PGP unlocker
t.Run("GetIdentity", func(t *testing.T) { t.Run("GetIdentity", func(t *testing.T) {
// Generate an age identity for testing testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("Failed to generate age identity: %v", err)
}
// Write the recipient
recipientPath := filepath.Join(unlockerDir, "pub.txt")
if err := afero.WriteFile(fs, recipientPath, []byte(ageIdentity.Recipient().String()), secret.FilePerms); err != nil {
t.Fatalf("Failed to write recipient: %v", err)
}
// GPG encrypt the private key using our custom encrypt function
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer privKeyBuffer.Destroy()
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
if err != nil {
t.Fatalf("Failed to encrypt with GPG: %v", err)
}
// Write the encrypted data to a file
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
if err := afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms); err != nil {
t.Fatalf("Failed to write encrypted private key: %v", err)
}
// Now try to get the identity - this will use our custom GPGDecryptFunc
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity: %v", err)
}
// Verify the identity matches
expectedPubKey := ageIdentity.Recipient().String()
actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
}
}) })
// Test removing the unlocker // Test removing the unlocker
t.Run("RemoveUnlocker", func(t *testing.T) { t.Run("RemoveUnlocker", func(t *testing.T) {
// Ensure unlocker directory exists before removal testRemovePGPUnlocker(t, fs, unlocker, unlockerDir)
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if !keyExists {
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
}
// Remove unlocker
err = unlocker.Remove()
if err != nil {
t.Fatalf("Failed to remove unlocker: %v", err)
}
// Verify directory is gone
keyExists, err = afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if keyExists {
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
}
}) })
} }
// testCreatePGPUnlocker creates a vault with a passphrase unlocker, then a
// PGP unlocker for the GPG key keyID, and checks the PGP unlocker's files
// and metadata.
func testCreatePGPUnlocker(
t *testing.T, fs afero.Fs, stateDir, vaultName, keyID, fingerprint string,
) {
t.Helper()
// Set a limited test timeout to avoid hanging
timer := time.AfterFunc(30*time.Second, func() {
t.Fatalf("Test timed out after 30 seconds")
})
defer timer.Stop()
mnemonic := testMnemonicBuffer(t)
// Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Set the current vault
err = vault.SelectVault(fs, stateDir, vaultName)
if err != nil {
t.Fatalf("Failed to select vault: %v", err)
}
// Derive long-term key from mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err)
}
// Get the vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
// Write long-term public key
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath,
[]byte(ltIdentity.Recipient().String()), secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write long-term public key: %v", err)
}
// Unlock the vault
vlt.Unlock(ltIdentity)
// Create a passphrase unlocker first (to have current unlocker)
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)
}
// Verify passphrase unlocker was created
if passUnlocker == nil {
t.Fatal("Passphrase unlocker is nil")
}
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpUnlocker, err := secret.CreatePGPUnlocker(
fs, stateDir, keyID, fingerprint, mnemonic, nil)
if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err)
}
// Verify the PGP unlock key was created
if pgpUnlocker == nil {
t.Fatal("PGP unlock key is nil")
}
// Check if the key has the correct type
if pgpUnlocker.GetType() != pgpUnlockerType {
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
}
// 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)
}
checkPGPUnlockerFiles(t, fs, pgpUnlocker.GetDirectory())
checkPGPUnlockerMetadata(t, fs, pgpUnlocker.GetDirectory(), fingerprint)
}
// checkPGPUnlockerFiles checks that the PGP unlocker in unlockerDir has all
// its files.
func checkPGPUnlockerFiles(t *testing.T, fs afero.Fs, unlockerDir string) {
t.Helper()
// Check if the key directory exists
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
}
if !keyExists {
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
}
// Check if required files exist
recipientPath := filepath.Join(unlockerDir, "pub.txt")
recipientExists, err := afero.Exists(fs, recipientPath)
if err != nil {
t.Fatalf("Failed to check if recipient file exists: %v", err)
}
if !recipientExists {
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
}
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
privKeyExists, err := afero.Exists(fs, privKeyPath)
if err != nil {
t.Fatalf("Failed to check if private key file exists: %v", err)
}
if !privKeyExists {
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
}
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
metadataExists, err := afero.Exists(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to check if metadata file exists: %v", err)
}
if !metadataExists {
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
}
longtermPath := filepath.Join(unlockerDir, "longterm.age")
longtermExists, err := afero.Exists(fs, longtermPath)
if err != nil {
t.Fatalf("Failed to check if longterm key file exists: %v", err)
}
if !longtermExists {
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
}
}
// checkPGPUnlockerMetadata checks that the metadata of the PGP unlocker in
// unlockerDir names its type and the GPG key by fingerprint.
func checkPGPUnlockerMetadata(
t *testing.T, fs afero.Fs, unlockerDir, fingerprint string,
) {
t.Helper()
// Read and verify metadata
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to read metadata: %v", err)
}
var metadata struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags"`
GPGKeyID string `json:"gpgKeyId"`
}
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
t.Fatalf("Failed to parse metadata: %v", err)
}
if metadata.Type != pgpUnlockerType {
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
}
if metadata.GPGKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
}
}
// testGetGPGKeyID writes PGP unlocker metadata holding the GPG fingerprint
// into unlockerDir and checks that unlocker reads it back.
func testGetGPGKeyID(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
unlockerDir string, metadata secret.UnlockerMetadata, fingerprint string,
) {
t.Helper()
// Create PGP metadata with GPG key ID
type PGPUnlockerMetadata struct {
secret.UnlockerMetadata
GPGKeyID string `json:"gpgKeyId"`
}
pgpMetadata := PGPUnlockerMetadata{
UnlockerMetadata: metadata,
GPGKeyID: fingerprint,
}
// Write metadata file
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil {
t.Fatalf("Failed to marshal metadata: %v", err)
}
err = afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write metadata: %v", err)
}
// Get GPG key ID
retrievedKeyID, err := unlocker.GetGPGKeyID()
if err != nil {
t.Fatalf("Failed to get GPG key ID: %v", err)
}
// Verify key ID (should be the fingerprint)
if retrievedKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
}
}
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
// keyID into unlockerDir and checks that unlocker decrypts it.
func testPGPUnlockerGetIdentity(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
unlockerDir, keyID string,
) {
t.Helper()
// Generate an age identity for testing
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("Failed to generate age identity: %v", err)
}
// Write the recipient
recipientPath := filepath.Join(unlockerDir, "pub.txt")
err = afero.WriteFile(fs, recipientPath,
[]byte(ageIdentity.Recipient().String()), secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write recipient: %v", err)
}
// GPG encrypt the private key using our custom encrypt function
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer privKeyBuffer.Destroy()
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
if err != nil {
t.Fatalf("Failed to encrypt with GPG: %v", err)
}
// Write the encrypted data to a file
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
err = afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write encrypted private key: %v", err)
}
// Now try to get the identity - this will use our custom GPGDecryptFunc
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity: %v", err)
}
// Verify the identity matches
expectedPubKey := ageIdentity.Recipient().String()
actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
}
}
// testRemovePGPUnlocker removes unlocker and checks that unlockerDir is gone.
func testRemovePGPUnlocker(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker, unlockerDir string,
) {
t.Helper()
// Ensure unlocker directory exists before removal
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if !keyExists {
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
}
// Remove unlocker
err = unlocker.Remove()
if err != nil {
t.Fatalf("Failed to remove unlocker: %v", err)
}
// Verify directory is gone
keyExists, err = afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if keyExists {
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
}
}
+57 -67
View File
@@ -1,5 +1,4 @@
//go:build darwin //go:build darwin
// +build darwin
package secret package secret
@@ -13,7 +12,6 @@ import (
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/macse" "git.eeqj.de/sneak/secret/internal/macse"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -32,6 +30,7 @@ const (
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data. // SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
type SecureEnclaveUnlockerMetadata struct { type SecureEnclaveUnlockerMetadata struct {
UnlockerMetadata UnlockerMetadata
SEKeyLabel string `json:"seKeyLabel"` SEKeyLabel string `json:"seKeyLabel"`
SEKeyHash string `json:"seKeyHash"` SEKeyHash string `json:"seKeyHash"`
} }
@@ -43,6 +42,19 @@ type SecureEnclaveUnlocker struct {
fs afero.Fs fs afero.Fs
} }
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity implements Unlocker interface for SE-based unlockers. // GetIdentity implements Unlocker interface for SE-based unlockers.
// Decrypts the vault's long-term private key directly using the Secure Enclave. // Decrypts the vault's long-term private key directly using the Secure Enclave.
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) { func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
@@ -58,6 +70,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
// Read ECIES-encrypted long-term private key from disk // Read ECIES-encrypted long-term private key from disk
encryptedPath := filepath.Join(s.Directory, seLongtermFilename) encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
encryptedData, err := afero.ReadFile(s.fs, encryptedPath) encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -140,7 +153,9 @@ func (s *SecureEnclaveUnlocker) Remove() error {
if seKeyHash != "" { if seKeyHash != "" {
Debug("Deleting SE key", "hash", seKeyHash) Debug("Deleting SE key", "hash", seKeyHash)
if err := macse.DeleteKey(seKeyHash); err != nil {
err = macse.DeleteKey(seKeyHash)
if err != nil {
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash) Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
return fmt.Errorf("failed to delete SE key: %w", err) return fmt.Errorf("failed to delete SE key: %w", err)
@@ -148,7 +163,9 @@ func (s *SecureEnclaveUnlocker) Remove() error {
} }
Debug("Removing SE unlocker directory", "directory", s.Directory) Debug("Removing SE unlocker directory", "directory", s.Directory)
if err := RemoveDirAtomic(s.fs, s.Directory); err != nil {
err = RemoveDirAtomic(s.fs, s.Directory)
if err != nil {
return fmt.Errorf("failed to remove SE unlocker directory: %w", err) return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
} }
@@ -158,34 +175,24 @@ func (s *SecureEnclaveUnlocker) Remove() error {
} }
// getSEKeyInfo reads the SE key label and hash from metadata. // getSEKeyInfo reads the SE key label and hash from metadata.
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err error) { func (s *SecureEnclaveUnlocker) getSEKeyInfo() (string, string, error) {
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json") metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(s.fs, metadataPath) metadataData, err := afero.ReadFile(s.fs, metadataPath)
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to read SE metadata: %w", err) return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
} }
var seMetadata SecureEnclaveUnlockerMetadata var seMetadata SecureEnclaveUnlockerMetadata
if err := json.Unmarshal(metadataData, &seMetadata); err != nil {
err = json.Unmarshal(metadataData, &seMetadata)
if err != nil {
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err) return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
} }
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
} }
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// generateSEKeyLabel generates a unique label for the SE CTK identity. // generateSEKeyLabel generates a unique label for the SE CTK identity.
func generateSEKeyLabel(vaultName string) (string, error) { func generateSEKeyLabel(vaultName string) (string, error) {
hostname, err := os.Hostname() hostname, err := os.Hostname()
@@ -214,7 +221,8 @@ func CreateSecureEnclaveUnlocker(
stateDir string, stateDir string,
mnemonic, passphrase *memguard.LockedBuffer, mnemonic, passphrase *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) { ) (*SecureEnclaveUnlocker, error) {
if err := checkMacOSAvailable(); err != nil { err := checkMacOSAvailable()
if err != nil {
return nil, err return nil, err
} }
@@ -231,6 +239,7 @@ func CreateSecureEnclaveUnlocker(
// Step 1: Create P-256 key in the Secure Enclave via sc_auth // Step 1: Create P-256 key in the Secure Enclave via sc_auth
Debug("Creating Secure Enclave key", "label", seKeyLabel) Debug("Creating Secure Enclave key", "label", seKeyLabel)
_, seKeyHash, err := macse.CreateKey(seKeyLabel) _, seKeyHash, err := macse.CreateKey(seKeyLabel)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create SE key: %w", err) return nil, fmt.Errorf("failed to create SE key: %w", err)
@@ -263,14 +272,14 @@ func CreateSecureEnclaveUnlocker(
return nil, fmt.Errorf("failed to get vault directory: %w", err) return nil, fmt.Errorf("failed to get vault directory: %w", err)
} }
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel)) unlockerDirName := "se-" + filepath.Base(seKeyLabel)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName) unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
seMetadata := SecureEnclaveUnlockerMetadata{ seMetadata := SecureEnclaveUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{ UnlockerMetadata: UnlockerMetadata{
Type: seUnlockerType, Type: seUnlockerType,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
Flags: []string{seUnlockerType, "macos"}, Flags: []string{seUnlockerType, macOSFlag},
}, },
SEKeyLabel: seKeyLabel, SEKeyLabel: seKeyLabel,
SEKeyHash: seKeyHash, SEKeyHash: seKeyHash,
@@ -283,20 +292,7 @@ func CreateSecureEnclaveUnlocker(
// Step 5: Write the SE-encrypted long-term key, then the metadata // Step 5: Write the SE-encrypted long-term key, then the metadata
err = WriteDir(fs, unlockerDir, func(dir string) error { err = WriteDir(fs, unlockerDir, func(dir string) error {
ltKeyPath := filepath.Join(dir, seLongtermFilename) return writeSEUnlockerFiles(fs, dir, encryptedLtKey, metadataBytes)
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtKey); err != nil {
return fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
metadataPath := filepath.Join(dir, "unlocker-metadata.json")
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
return nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -309,6 +305,29 @@ func CreateSecureEnclaveUnlocker(
}, nil }, nil
} }
// writeSEUnlockerFiles writes the files of a new SE unlocker into dir: the
// SE-encrypted long-term key, then the metadata.
func writeSEUnlockerFiles(
fs afero.Fs, dir string, encryptedLtKey, metadataBytes []byte,
) error {
err := WriteFileAtomic(fs, filepath.Join(dir, seLongtermFilename),
encryptedLtKey)
if err != nil {
return fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
err = WriteFileAtomic(fs,
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
if err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
return nil
}
// getLongTermKeyForSE retrieves the vault's long-term private key, derived // getLongTermKeyForSE retrieves the vault's long-term private key, derived
// from mnemonic when it is not nil, else through the current unlocker, which // from mnemonic when it is not nil, else through the current unlocker, which
// is given passphrase when it is a passphrase unlocker. // is given passphrase when it is a passphrase unlocker.
@@ -318,37 +337,7 @@ func getLongTermKeyForSE(
mnemonic, passphrase *memguard.LockedBuffer, mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) { ) (*memguard.LockedBuffer, error) {
if mnemonic != nil { if mnemonic != nil {
// Read vault metadata to get the correct derivation index return deriveLongTermPrivateKey(fs, vault, mnemonic)
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(
mnemonic.String(),
metadata.DerivationIndex,
)
if err != nil {
return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w",
err,
)
}
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
} }
currentUnlocker, err := vault.GetCurrentUnlocker() currentUnlocker, err := vault.GetCurrentUnlocker()
@@ -373,6 +362,7 @@ func getLongTermKeyForSE(
currentUnlocker.GetDirectory(), currentUnlocker.GetDirectory(),
"longterm.age", "longterm.age",
) )
encryptedLtKey, err := afero.ReadFile(fs, longtermPath) encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
+20 -8
View File
@@ -1,6 +1,6 @@
//go:build darwin //go:build darwin
// +build darwin
//nolint:testpackage // white-box test of unexported Secure Enclave helpers
package secret package secret
import ( import (
@@ -13,12 +13,14 @@ import (
) )
func TestNewSecureEnclaveUnlocker(t *testing.T) { func TestNewSecureEnclaveUnlocker(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker" dir := "/tmp/test-se-unlocker"
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: "secure-enclave", Type: seUnlockerType,
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC), CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
Flags: []string{"secure-enclave", "macos"}, Flags: []string{seUnlockerType, "macos"},
} }
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata) unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
@@ -35,9 +37,11 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
} }
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) { func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: "secure-enclave", Type: seUnlockerType,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
} }
@@ -48,9 +52,11 @@ func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
} }
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) { func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: "secure-enclave", Type: seUnlockerType,
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC), CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
} }
@@ -63,6 +69,8 @@ func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
} }
func TestGenerateSEKeyLabel(t *testing.T) { func TestGenerateSEKeyLabel(t *testing.T) {
t.Parallel()
label, err := generateSEKeyLabel("test-vault") label, err := generateSEKeyLabel("test-vault")
require.NoError(t, err) require.NoError(t, err)
@@ -72,6 +80,8 @@ func TestGenerateSEKeyLabel(t *testing.T) {
} }
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) { func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker-missing" dir := "/tmp/test-se-unlocker-missing"
@@ -84,10 +94,12 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
"seKeyLabel": "berlin.sneak.app.secret.se.test", "seKeyLabel": "berlin.sneak.app.secret.se.test",
"seKeyHash": "abc123" "seKeyHash": "abc123"
}` }`
require.NoError(t, afero.WriteFile(fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms)) require.NoError(t, afero.WriteFile(
fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms,
))
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: "secure-enclave", Type: seUnlockerType,
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC), CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
} }
@@ -96,6 +108,6 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
// GetIdentity should fail because the encrypted longterm key file is missing // GetIdentity should fail because the encrypted longterm key file is missing
identity, err := unlocker.GetIdentity() identity, err := unlocker.GetIdentity()
assert.Nil(t, identity) assert.Nil(t, identity)
assert.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key") assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key")
} }
+29 -120
View File
@@ -1,5 +1,6 @@
//go:build darwin //go:build darwin
//nolint:testpackage // white-box test of unexported validateKeychainItemName
package secret package secret
import ( import (
@@ -7,138 +8,46 @@ import (
) )
func TestValidateKeychainItemName(t *testing.T) { func TestValidateKeychainItemName(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
itemName string itemName string
wantErr bool wantErr bool
}{ }{
// Valid cases // Valid cases
{ {name: "valid simple name", itemName: "my-secret-key", wantErr: false},
name: "valid simple name", {name: "valid name with dots", itemName: "com.example.app.key", wantErr: false},
itemName: "my-secret-key", {name: "valid name with underscores", itemName: "my_secret_key_123", wantErr: false},
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},
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 // Invalid cases
{ {name: "empty item name", itemName: "", wantErr: true},
name: "empty item name", {name: "item name with spaces", itemName: "my secret key", wantErr: true},
itemName: "", {name: "item name with semicolon", itemName: "key;rm -rf /", wantErr: true},
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 spaces", {name: "item name with quotes", itemName: "key\"name", wantErr: true},
itemName: "my secret key", {name: "item name with single quotes", itemName: "key'name", wantErr: true},
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 semicolon", {name: "item name with ampersand", itemName: "key&echo test", wantErr: true},
itemName: "key;rm -rf /", {name: "item name with redirect", itemName: "key>/tmp/test", wantErr: true},
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 pipe", {name: "item name with asterisk", itemName: "key*", wantErr: true},
itemName: "key|cat /etc/passwd", {name: "item name with question mark", itemName: "key?", wantErr: true},
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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateKeychainItemName(tt.itemName) err := validateKeychainItemName(tt.itemName)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
+3 -3
View File
@@ -1,7 +1,6 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint, lint-darwin, fmt-check). Our
# extension to scripts-to-rule-them-all. Must not modify any files. # own extension to scripts-to-rule-them-all. Must not modify any files.
# Generic: usually needs no adaptation.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -9,6 +8,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/lint-darwin"
"$SCRIPT_DIR/fmt-check" "$SCRIPT_DIR/fmt-check"
} }
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# script/lint-darwin: type-check (go vet) and lint the code as a macOS
# build compiles it, from Linux, in docker only. CI runs on Linux, which
# never compiles the files built only for macOS. Builds the lint-darwin
# stage of Dockerfile.lint, rebuilt on every run as script/lint does.
#
# Cgo is off: compiling cgo code for macOS needs Apple's SDK headers. That
# leaves out the files built only with cgo on macOS: the keychain unlocker's
# calls into the keychain (keychainunlocker_cgo.go, and
# keychainunlocker_test.go) and the Secure Enclave bindings (internal/macse).
# Nothing on Linux checks those.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build \
--progress=plain \
--target lint-darwin \
--no-cache-filter=lint-darwin \
--output=type=cacheonly \
-f Dockerfile.lint .
}
main "$@"