Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a530061843 | ||
|
|
4c47b233bb | ||
|
|
fac2753819 | ||
|
|
9c4968c768 | ||
|
|
93351cbd36 | ||
|
|
03416d417c | ||
|
|
42f4961937 | ||
|
|
f0bf3b24a6 | ||
|
|
67c2829f58 | ||
|
|
5922de32df | ||
|
|
6bc95f7c69 | ||
|
|
fdfe2c92f6 | ||
|
|
00e34b93ec | ||
|
|
0ffffd5e3f | ||
|
|
438c36e7a9 | ||
|
|
220213abfe | ||
|
|
bfb13af302 | ||
|
|
acb4b9cb6a | ||
|
|
13c7721bad | ||
|
|
b8842b6184 | ||
|
|
faf46d12ff | ||
|
|
6d95d96477 | ||
|
|
72cd6c30a1 | ||
|
|
46c395e045 | ||
|
|
fe94e99801 | ||
|
|
0630a32167 | ||
|
|
5371382cb4 | ||
|
|
fd6c5df1a8 | ||
|
|
2d6675a452 |
@@ -13,3 +13,6 @@ indent_style = tab
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
# This repository's own sections, such as one for another language it
|
||||
# uses, go below this comment, and a re-vendor keeps them.
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
name: check
|
||||
on: [push]
|
||||
# Free the shared runner: a new push cancels only the same branch's older run.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
# Free the shared runner from a hung build.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-22
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
# script/cibuild needs no token, so none is left in .git/config.
|
||||
with:
|
||||
persist-credentials: false
|
||||
# All history and tags, so git describe finds the version tag.
|
||||
fetch-depth: 0
|
||||
- run: script/cibuild
|
||||
|
||||
+5
-1
@@ -27,7 +27,7 @@ node_modules/
|
||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||
# convention. Only the templates `example.env` and `sample.env` are
|
||||
# re-included below. A repository that commits any other template adds
|
||||
# its own negation after these lines, for example `!.env.example`.
|
||||
# its own negation at the end of this file, for example `!.env.example`.
|
||||
*.[eE][nN][vV]
|
||||
.[eE][nN][vV].*
|
||||
.[eE][nN][vV][rR][cC]
|
||||
@@ -46,6 +46,10 @@ node_modules/
|
||||
[iI][dD]_[eE][dD]25519
|
||||
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||
|
||||
# This repository's own entries, such as its build outputs, go below
|
||||
# this comment, and a re-vendor keeps them. Anchor a binary built at the
|
||||
# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`.
|
||||
|
||||
# Go. /secret is the built binary, anchored so that it does not also match
|
||||
# the internal/secret/ package directory.
|
||||
*.log
|
||||
|
||||
@@ -25,6 +25,8 @@ linters:
|
||||
# silenced by disabling that name, not by enabling the successor.
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||
# Misses findings at random in v2.14.0; back once a pinned release fixes it
|
||||
- canonicalheader
|
||||
settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
|
||||
+22
-15
@@ -1,36 +1,40 @@
|
||||
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
||||
# on every build, which reruns every RUN step after it. The steps that
|
||||
# compile mount Go's build cache, which survives from build to build.
|
||||
|
||||
# Lint phase. The linter is invoked directly rather than through `make
|
||||
# lint` or `script/lint`, which are themselves a docker build.
|
||||
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
|
||||
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||
ARG CACHEBUST
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go vet ./...
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go vet ./...
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
# The same checks on the code as a macOS build compiles it, which a Linux
|
||||
# build never compiles. Cgo is off, because 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.
|
||||
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
|
||||
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
|
||||
# needs Apple's SDK headers.
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
GOOS=darwin CGO_ENABLED=0 go vet ./...
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||
# image ships and the alpine one does not.
|
||||
# image ships and the alpine one does not. -count=1 because the mounted
|
||||
# cache also holds test results, which go test would otherwise replay.
|
||||
# golang:1.24.13-trixie, 2026-02-04
|
||||
FROM golang@sha256:5835f052b784aa39f2fe9070def3568605c8bc3fcd810f10402066348b61e716 AS test
|
||||
ARG CACHEBUST
|
||||
ENV CGO_ENABLED=1
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
# Go's build cache goes in a cache mount, not the image layer, which would
|
||||
# take seconds longer to export. --no-cache, on every build in script/,
|
||||
# starts the mount empty; -count=1 keeps a build without it from taking
|
||||
# test results from there.
|
||||
RUN --mount=type=cache,id=sneak/secret/go-build-test,target=/root/.cache/go-build \
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go test -count=1 -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||
@@ -40,6 +44,7 @@ RUN --mount=type=cache,id=sneak/secret/go-build-test,target=/root/.cache/go-buil
|
||||
# lint and test passed.
|
||||
# golang 1.24.13-alpine, 2026-03-10
|
||||
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
||||
ARG CACHEBUST
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=test /src/go.sum /dev/null
|
||||
# script/build compiles with cgo, so it needs a C compiler too.
|
||||
@@ -56,7 +61,8 @@ COPY . .
|
||||
# .git present, a version that is still empty, dev or unknown fails the
|
||||
# build: git is missing or could not read the checkout.
|
||||
ARG VERSION
|
||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
if [ -e .git ]; then \
|
||||
case "$VERSION" in ""|dev|unknown) \
|
||||
echo "version is '$VERSION' although .git is present" >&2; \
|
||||
@@ -68,6 +74,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
# Runtime stage, and the last one
|
||||
# alpine 3.23, 2026-03-10
|
||||
FROM alpine@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
||||
ARG CACHEBUST
|
||||
|
||||
RUN apk add --no-cache ca-certificates gnupg
|
||||
|
||||
|
||||
@@ -67,6 +67,10 @@ and unlocker set. This allows for complete isolation between different contexts
|
||||
|
||||
## Commands Reference
|
||||
|
||||
Every time `secret` prints, in a table or in `--json` output, is in UTC,
|
||||
whatever `TZ` or the system's time zone is set to. The date in a version's name
|
||||
is the UTC date too.
|
||||
|
||||
### Confirmation Before Removal
|
||||
|
||||
`secret rm`, `secret version rm`, `secret vault remove` and
|
||||
@@ -90,7 +94,8 @@ remove things pass `--force`.
|
||||
#### `secret init`
|
||||
|
||||
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. When the vault
|
||||
`default` already exists, it fails before asking for the mnemonic or passphrase.
|
||||
|
||||
**Environment Variables:**
|
||||
|
||||
@@ -105,14 +110,19 @@ Lists all available vaults. The current vault is marked.
|
||||
|
||||
#### `secret vault create <name>`
|
||||
|
||||
Creates a new vault with the specified name.
|
||||
Creates a new vault with the specified name. When a vault of that name already
|
||||
exists, it fails before asking for the mnemonic or passphrase.
|
||||
|
||||
**Vault Name Format:** only lowercase ASCII letters, digits, `.`, `-` and `_`
|
||||
are allowed, and a name must not be empty, `.` or `..`.
|
||||
|
||||
#### `secret vault select <name>`
|
||||
|
||||
Switches to the specified vault for subsequent operations.
|
||||
Switches to the specified vault for subsequent operations. When `currentvault`
|
||||
is empty or names a vault that does not exist, every command that uses the
|
||||
current vault fails with an error that names the file and says to run
|
||||
`secret vault select <name>`. `secret vault list` still lists the vaults, and
|
||||
`secret info` still runs; both show no vault as current.
|
||||
|
||||
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
||||
|
||||
@@ -127,9 +137,24 @@ one.
|
||||
|
||||
### Secret Management
|
||||
|
||||
#### Secret Size
|
||||
|
||||
While `secret` works on a secret's value it keeps the value in locked memory, so
|
||||
the value counts against the locked-memory limit (`ulimit -l`). `secret` raises
|
||||
that limit to the hard limit when it starts. `secret add`, `secret import` and
|
||||
`secret generate secret` refuse a value that does not fit in the limit with 8
|
||||
memory pages to spare (32 KiB with 4 KiB pages), with an error that names the
|
||||
limit. Linux's usual limit of 8 MiB allows a value of nearly 8 MiB; a larger one
|
||||
needs a higher hard limit, such as `memlock` in `/etc/security/limits.conf`. No
|
||||
value may be over 100 MiB, whatever the limit.
|
||||
|
||||
#### `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. When the
|
||||
value ends in a newline, that one newline is dropped, so
|
||||
`echo value | secret add name` stores `value`; from `printf 'a\n\n'` it stores
|
||||
`a` and one newline. To keep a file's final newline, as in a PEM key, use
|
||||
`secret import`, which stores the file's bytes unchanged.
|
||||
|
||||
- `--force, -f`: Overwrite existing secret
|
||||
|
||||
@@ -142,7 +167,13 @@ contain `//`, or have `..` as a path segment.
|
||||
|
||||
#### `secret get <secret-name> [--version <version>]`
|
||||
|
||||
Retrieves and outputs a secret value to stdout.
|
||||
Writes the secret's value to stdout exactly as stored, with no newline added
|
||||
after it. A failed write of the value, such as to a file on a full disk, prints
|
||||
the error and exits 1, with two exceptions. If the program reading from a pipe
|
||||
has already exited, as in `secret get s | true`, the broken-pipe signal
|
||||
(SIGPIPE) ends `secret` with no message and the shell reports exit status 141.
|
||||
If the shell closed stdout before `secret` started (`secret get s >&-`), the
|
||||
value is written to `/dev/null` and `secret get` exits 0.
|
||||
|
||||
- `--version, -v`: Get a specific version (default: current)
|
||||
|
||||
@@ -150,6 +181,13 @@ Retrieves and outputs a secret value to stdout.
|
||||
|
||||
Lists all secrets in the current vault. Optional filter for substring matching.
|
||||
|
||||
The `LAST UPDATED` column, and `updated_at` in the `--json` output, give the
|
||||
time the secret's `current` file was last written: every command that adds a
|
||||
version replaces it, and so does `secret version promote`. `created_at` gives
|
||||
the time the `value.age` of the secret's oldest version was written. Both are
|
||||
the files' modification times, so listing asks for no passphrase. Moving a
|
||||
secret to another vault keeps both times.
|
||||
|
||||
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
|
||||
|
||||
**DANGER**: Permanently removes a secret and ALL its versions. It first asks for
|
||||
@@ -178,10 +216,18 @@ Moves or renames a secret within the current vault.
|
||||
Lists all versions of a secret showing creation time, status, and validity
|
||||
period.
|
||||
|
||||
`STATUS` is `current` for the secret's current version, `expired` for another
|
||||
version whose `NOT_AFTER` has passed, and `inactive` for any other version.
|
||||
Adding a version sets `NOT_AFTER` on the version that was current;
|
||||
`secret version promote` sets no times. A version that is not current therefore
|
||||
shows `inactive` when no version was added while it was current.
|
||||
|
||||
#### `secret version promote <secret-name> <version>`
|
||||
|
||||
Promotes a specific version to current by rewriting the secret's `current` file
|
||||
to name it. Does not modify any timestamps, allowing for rollback scenarios.
|
||||
to name it. The versions' own times are left unchanged, allowing for rollback
|
||||
scenarios; `secret list` then gives the time of the promote as the secret's last
|
||||
update.
|
||||
|
||||
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
||||
|
||||
@@ -197,7 +243,8 @@ for confirmation, naming the version, the secret and its vault (see
|
||||
|
||||
#### `secret generate mnemonic`
|
||||
|
||||
Generates a cryptographically secure BIP39 mnemonic phrase.
|
||||
Generates a cryptographically secure BIP39 mnemonic phrase and prints it to
|
||||
stdout. The warning to keep it safe goes to stderr.
|
||||
|
||||
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
||||
|
||||
@@ -223,8 +270,6 @@ Creates a new unlocker of the specified type:
|
||||
|
||||
- `passphrase`: Traditional passphrase-protected unlocker
|
||||
- `pgp`: Uses an existing GPG key for encryption/decryption
|
||||
- `keychain`: macOS Keychain integration (macOS only)
|
||||
- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only)
|
||||
|
||||
**Options:**
|
||||
|
||||
@@ -234,6 +279,12 @@ Creates a new unlocker of the specified type:
|
||||
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 add passphrase` asks on the terminal for the new passphrase
|
||||
even when `SB_UNLOCK_PASSPHRASE` is set, and uses the variable to unlock the
|
||||
vault. With no controlling terminal, as under cron or in a CI job, the new
|
||||
unlocker takes its passphrase from `SB_UNLOCK_PASSPHRASE`, and the command says
|
||||
so.
|
||||
|
||||
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
||||
|
||||
**DANGER**: Permanently removes an unlocker. It first asks for confirmation,
|
||||
@@ -243,6 +294,14 @@ that the vault then opens only with its mnemonic (see
|
||||
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker
|
||||
directory that `secret unlocker list` skips with a warning, because its metadata
|
||||
cannot be read or parsed, is removed by the directory name the warning gives.
|
||||
Removing the current unlocker makes the first of the vault's other unlockers
|
||||
that `secret unlocker select` accepts, in the order `secret unlocker list` shows
|
||||
them, the current one, and says which; when there is none, the vault is left
|
||||
without a current unlocker. `secret unlocker select` refuses an unlocker whose
|
||||
type is not `passphrase` or `pgp`. When `current-unlocker` names an unlocker
|
||||
that does not exist and the vault has another that `secret unlocker select`
|
||||
accepts, opening the vault fails with an error that says to select one with
|
||||
`secret unlocker select`.
|
||||
|
||||
- `--force, -f`: Remove without asking, even the last unlocker
|
||||
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
||||
@@ -259,14 +318,16 @@ Selects an unlocker as the current default for operations.
|
||||
#### `secret import <secret-name> --source <filename>`
|
||||
|
||||
Imports a secret from a file and stores it in the current vault under the given
|
||||
name.
|
||||
|
||||
#### `secret vault import [vault-name]`
|
||||
|
||||
Imports a mnemonic phrase into the specified vault (defaults to "default").
|
||||
name. The file's bytes are stored unchanged, a final newline included.
|
||||
|
||||
### Encryption Operations
|
||||
|
||||
With `--output`, `secret encrypt` and `secret decrypt` hold the result in memory
|
||||
and write it to `--output` only once encryption or decryption has succeeded, so
|
||||
a failure to encrypt or decrypt leaves `--output` untouched. A failure while
|
||||
writing `--output`, such as a full disk, can leave it empty or partly written.
|
||||
The command can need up to about four times as much memory as the result.
|
||||
|
||||
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
|
||||
|
||||
Encrypts data using an Age key stored as a secret. If the secret doesn't exist,
|
||||
@@ -345,21 +406,6 @@ Unlockers provide different authentication methods to access the long-term keys:
|
||||
- Leverages existing key management workflows
|
||||
- Strong authentication through GPG
|
||||
|
||||
3. **Keychain Unlockers** (macOS only):
|
||||
- Stores unlock keys in macOS Keychain
|
||||
- Kept on this Mac only: the keychain item is never synced to other devices
|
||||
- Automatic unlocking when Keychain is unlocked
|
||||
- Cross-application integration
|
||||
|
||||
4. **Secure Enclave Unlockers** (macOS):
|
||||
- Hardware-backed key storage using Apple Secure Enclave
|
||||
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
|
||||
Program required)
|
||||
- ECIES encryption: the vault long-term key is encrypted directly to the SE
|
||||
key, and only the SE can decrypt it
|
||||
- The SE key cannot leave this Mac; using it asks for no Touch ID or
|
||||
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.
|
||||
@@ -385,9 +431,20 @@ 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 as
|
||||
`gpg`, do not inherit it, but that erases nothing: the environment the process
|
||||
started with, and its memory, still hold the value. The interactive prompt,
|
||||
which every command except `secret vault import` offers when the variable is not
|
||||
set, is the safer default; `secret vault import` has no prompt and needs both
|
||||
variables.
|
||||
which every command offers when the variable is not set, is the safer default.
|
||||
|
||||
The prompt asks on the terminal the command runs in, not on stdin, so it also
|
||||
asks when stdin is a pipe, as in `echo value | secret add name --force` or
|
||||
`cat file | secret encrypt key`. With no controlling terminal, as under cron or
|
||||
in a CI job, the command fails and names the variable to set.
|
||||
|
||||
A command that changes anything locks the state directory, through its file
|
||||
`lock`, and another such command waits until the lock is released.
|
||||
`secret init`, `secret vault create`, `secret unlocker add`, `secret mv` from
|
||||
one vault to another, and `secret add`, `secret import` and
|
||||
`secret generate secret` with `--force`, ask for the mnemonic or passphrase
|
||||
before they take the lock, so a prompt of theirs left open in one terminal holds
|
||||
up no command in another.
|
||||
|
||||
## Security Features
|
||||
|
||||
@@ -412,9 +469,6 @@ variables.
|
||||
### Hardware Integration
|
||||
|
||||
- Hardware token support via PGP/GPG integration
|
||||
- macOS Keychain integration for system-level security
|
||||
- Secure Enclave integration for hardware-backed key protection (macOS, via
|
||||
`sc_auth` / CryptoTokenKit)
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -468,8 +522,6 @@ secret vault remove personal --force
|
||||
# Add multiple unlock methods
|
||||
secret unlocker add passphrase # Password-based
|
||||
secret unlocker add pgp --keyid ABCD1234 # GPG key
|
||||
secret unlocker add keychain # macOS Keychain (macOS only)
|
||||
secret unlocker add secure-enclave # macOS Secure Enclave (macOS only)
|
||||
|
||||
# List unlockers
|
||||
secret unlocker list
|
||||
@@ -530,7 +582,11 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
||||
### Vault Management
|
||||
|
||||
- **Derivation Index**: Each vault uses a unique derivation index from the
|
||||
mnemonic, and thus a unique key pair
|
||||
mnemonic, and thus a unique key pair; creating a vault (`secret init` and
|
||||
`secret vault create`) fails, with an error naming the file, when any vault's
|
||||
`vault-metadata.json` exists but cannot be read or parsed; a vault directory
|
||||
with no `vault-metadata.json` is skipped, so the index it uses can be given to
|
||||
a new vault
|
||||
- **Public Key Hash**: Double SHA-256 hash of the vault's public key; the same
|
||||
hash of the index-0 public key identifies vaults from the same mnemonic
|
||||
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are
|
||||
@@ -538,12 +594,8 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
||||
|
||||
### Cross-Platform Support
|
||||
|
||||
- **macOS**: Full support including Keychain and Secure Enclave integration
|
||||
- **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.
|
||||
- **macOS**: Full support
|
||||
- **Linux**: Full support
|
||||
|
||||
## Security Considerations
|
||||
|
||||
@@ -556,7 +608,7 @@ to add or use them.
|
||||
### Best Practices
|
||||
|
||||
1. Use strong, unique passphrases for unlockers
|
||||
2. Enable hardware authentication (Keychain, hardware tokens) when available
|
||||
2. Enable hardware authentication (hardware tokens) when available
|
||||
3. Regularly audit unlockers and remove unused ones
|
||||
4. Keep mnemonic phrases securely backed up offline
|
||||
5. Use separate vaults for different security contexts
|
||||
@@ -565,7 +617,6 @@ to add or use them.
|
||||
|
||||
- Requires access to unlockers for secret retrieval
|
||||
- Mnemonic phrases must be securely stored and backed up
|
||||
- Hardware features limited to supported platforms
|
||||
|
||||
## Development
|
||||
|
||||
@@ -610,10 +661,7 @@ provide:
|
||||
rerun passes
|
||||
- `script/lint` — build the `lint` phase of the `Dockerfile`, which runs
|
||||
`go vet` and `golangci-lint`, then both again on the code as a macOS build
|
||||
compiles it (`GOOS=darwin`), which a Linux build never compiles; cgo is off
|
||||
there, 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
|
||||
compiles it (`GOOS=darwin`, with cgo off), which a Linux build never compiles
|
||||
- `script/fmt` — format all Go code with `go fmt` and every markdown file with
|
||||
prettier (4-space tabs, `proseWrap: always`) (writes)
|
||||
- `script/fmt-check` — check the same formatting without writing, on the host
|
||||
@@ -627,10 +675,13 @@ provide:
|
||||
- `script/install-precommit` — install the git pre-commit hook that runs
|
||||
`script/precommit`
|
||||
|
||||
Every stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after
|
||||
`FROM`. The `script/` builds pass a new `CACHEBUST` value on every build, which
|
||||
reruns every `RUN` step and keeps Go's build cache; `--no-cache` would empty it.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain,
|
||||
and Secure Enclave unlockers
|
||||
- **Multiple Authentication Methods**: Supports passphrase and PGP unlockers
|
||||
- **Vault Isolation**: Complete separation between different vaults
|
||||
- **Per-Secret Encryption**: Each secret has its own encryption key
|
||||
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
|
||||
|
||||
+156
-79
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-10-04
|
||||
last_modified: 2026-10-15
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
@@ -54,34 +54,39 @@ style conventions are in separate documents:
|
||||
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||
(detected in that order; apt runs noninteractive). For node it uses the
|
||||
installed node if present; otherwise it installs a PINNED node version via
|
||||
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
||||
release archive (never `curl | sh`), with bash installed as an explicit
|
||||
prerequisite since nvm requires bash. yarn is then pinned via
|
||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||
installed node only when its major version is the pinned one; otherwise (node
|
||||
missing, or another major, such as the node 24 the CI runner image ships) it
|
||||
installs the PINNED node version via nvm and installs yarn and the packages
|
||||
under it. nvm itself is installed first if missing, from a hash-verified
|
||||
GitHub release archive (never `curl | sh`), with bash installed as an explicit
|
||||
prerequisite since nvm requires bash. Only the major version is compared
|
||||
because the `Dockerfile` stages start from a node image whose exact version is
|
||||
not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned
|
||||
via `corepack prepare yarn@<version> --activate`. Never install "latest" or
|
||||
"lts"; always exact versions. `script/cibuild` runs the CI build: it changes
|
||||
to the repo root, runs `script/bootstrap`, runs `script/check`, and builds the
|
||||
image with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||
pristine checkout with nothing installed the run dies there, after the
|
||||
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||
source nvm for the pinned node version before invoking it, exactly as
|
||||
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
||||
docker and git then gets through `script/check`. Four further scripts are our
|
||||
own extensions to the standard: `script/check` runs `script/test`,
|
||||
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
|
||||
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
|
||||
installs the git pre-commit hook (the `make hooks` target shims to it); and
|
||||
`script/projectname` (literally that filename) simply outputs the project's
|
||||
name. Scripts that need the name call `script/projectname` — e.g.
|
||||
`script/docker` assembles its image tag from it — so those scripts stay
|
||||
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
|
||||
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
|
||||
the hook itself. Model scripts are at
|
||||
containerised gates have passed. **The bootstrap alone is not enough**: when
|
||||
`script/bootstrap` installs node and yarn under nvm it leaves neither on the
|
||||
`PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs
|
||||
under another node. The host entrypoints that need yarn — `script/fmt` and
|
||||
`script/fmt-check` — therefore use the same test as `script/bootstrap`: when
|
||||
the node on `PATH` has the pinned major version they run the `yarn` on `PATH`,
|
||||
and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the
|
||||
pinned node. A runner carrying nothing but docker and git then gets through
|
||||
`script/check`. Four further scripts are our own extensions to the standard:
|
||||
`script/check` runs `script/test`, `script/lint` and `script/fmt-check`;
|
||||
`script/precommit` is what the git pre-commit hook runs, and it calls
|
||||
`script/check`; `script/install-precommit` installs the git pre-commit hook
|
||||
(the `make hooks` target shims to it); and `script/projectname` (literally
|
||||
that filename) simply outputs the project's name. Scripts that need the name
|
||||
call `script/projectname` — e.g. `script/docker` assembles its image tag from
|
||||
it — so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
||||
`script/precommit`, not in the hook itself. Model scripts are at
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||
must document the provided scripts in an **Entrypoints** section (see the
|
||||
README requirements below).
|
||||
@@ -118,8 +123,10 @@ style conventions are in separate documents:
|
||||
and nothing else:
|
||||
|
||||
```sh
|
||||
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
|
||||
docker build --no-cache --target test -t "$(script/projectname)-test" .
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
--target lint --output type=cacheonly .
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
--target test --output type=cacheonly .
|
||||
```
|
||||
|
||||
**A stage that is not the last one in the file is built only when the final
|
||||
@@ -129,10 +136,15 @@ style conventions are in separate documents:
|
||||
plain `docker build .` builds the last stage alone and exits 0 having linted
|
||||
and tested nothing.
|
||||
|
||||
**Every `docker build` in `script/` is tagged**, here and in
|
||||
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
|
||||
image behind on every invocation, on every developer host and every CI
|
||||
runner; a tagged one replaces the previous image.
|
||||
**The gate builds write no image.** With `--output type=cacheonly` the phase
|
||||
runs and a failing step fails the build, but the result is not exported.
|
||||
Nothing uses those images, and writing one out is slow: a Go test phase's
|
||||
image holds the toolchain. A build given neither `--output` nor `-t` writes
|
||||
an untagged image and leaves it dangling, on every developer host and every
|
||||
CI runner. `script/cibuild` and `script/docker` build the image that ships
|
||||
and tag it, so each build replaces the previous image; each assigns the tag
|
||||
on its own line before the build, so `set -e` stops it where
|
||||
`script/projectname` fails.
|
||||
|
||||
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
||||
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
||||
@@ -147,22 +159,33 @@ style conventions are in separate documents:
|
||||
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
||||
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
||||
cannot tell from real findings. Both have produced wrong verdicts in this
|
||||
org, in both directions. A container has its own cache, its own `TMPDIR` and
|
||||
a digest-pinned binary, so neither is reachable.
|
||||
org, in both directions. A container has its own golangci-lint cache, its
|
||||
own `TMPDIR` and a digest-pinned binary, so neither is reachable.
|
||||
|
||||
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
|
||||
a `COPY` layer only when the copied content changes, so on an unchanged tree
|
||||
the check `RUN` is served from cache, nothing executes, and the build still
|
||||
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
|
||||
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
|
||||
four, and there is no fifth — `script/check` runs the two gate phases and
|
||||
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
|
||||
not evidence that anything ran: a sub-second build reporting success is a
|
||||
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
|
||||
and friends destroy a build cache shared with every other build on the host.
|
||||
When a check is added or changed, prove it works by planting a defect it must
|
||||
catch and watching the run fail on it, then revert the defect. A green run
|
||||
alone shows neither that the check ran nor that it covers what it should.
|
||||
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker
|
||||
invalidates a `COPY` layer only when the copied content changes, so on an
|
||||
unchanged tree the check `RUN` is served from cache, nothing executes, and the
|
||||
build still exits 0. Every `docker build` in `script/` therefore passes
|
||||
`--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process
|
||||
id, so no two builds share a value, not even the three in one
|
||||
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
|
||||
`script/docker` are the four, and there is no fifth — `script/check` runs the
|
||||
two gate phases and `script/fmt-check`, and builds no image of its own. Every
|
||||
stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after
|
||||
`FROM`, and a new value reruns every `RUN` step after that line; `COPY` steps
|
||||
are compared by content and need nothing. A stage without the `ARG` line is
|
||||
served from cache on an unchanged tree, so a repository re-vendors the scripts
|
||||
and adds the `ARG` lines in one commit. The scripts do not pass `--no-cache`:
|
||||
it reruns every step too, but it also empties every cache mount, and Go's
|
||||
build cache, which the canonical Go `Dockerfile` below mounts so that compiled
|
||||
packages survive between check builds, would start empty every time. A bare
|
||||
`docker build .` is not evidence that anything ran: a sub-second build
|
||||
reporting success is a cache hit, not a result. Never invalidate by pruning —
|
||||
`docker builder prune` and friends destroy a build cache shared with every
|
||||
other build on the host. When a check is added or changed, prove it works by
|
||||
planting a defect it must catch and watching the run fail on it, then revert
|
||||
the defect. A green run alone shows neither that the check ran nor that it
|
||||
covers what it should.
|
||||
|
||||
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||
@@ -171,32 +194,42 @@ style conventions are in separate documents:
|
||||
`Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
||||
# on every build, which reruns every RUN step after it. The steps that
|
||||
# compile mount Go's build cache, which survives from build to build.
|
||||
|
||||
# Lint phase
|
||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||
FROM golangci/golangci-lint@sha256:... AS lint
|
||||
ARG CACHEBUST
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||
# image ships and the alpine one does not.
|
||||
# image ships and the alpine one does not. -count=1 because the mounted
|
||||
# cache also holds test results, which go test would otherwise replay.
|
||||
# golang:1.x, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS test
|
||||
ARG CACHEBUST
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go test -timeout 90s -race -cover ./... || \
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go test -count=1 -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -timeout 90s -race -v ./...; exit 1; }
|
||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||
|
||||
# Build stage. Nothing is wanted from either phase above; the copies
|
||||
# are what make BuildKit build them first, so this stage cannot run
|
||||
# unless lint and test passed.
|
||||
# golang:1.x-alpine, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS builder
|
||||
ARG CACHEBUST
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=test /src/go.sum /dev/null
|
||||
RUN apk add --no-cache git
|
||||
@@ -212,7 +245,8 @@ style conventions are in separate documents:
|
||||
# .git present, a version that is still empty, dev or unknown fails the
|
||||
# build: git is missing or could not read the checkout.
|
||||
ARG VERSION
|
||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
if [ -e .git ]; then \
|
||||
case "$VERSION" in ""|dev|unknown) \
|
||||
echo "version is '$VERSION' although .git is present" >&2; \
|
||||
@@ -225,6 +259,7 @@ style conventions are in separate documents:
|
||||
|
||||
# Runtime stage, and the last one
|
||||
FROM alpine@sha256:...
|
||||
ARG CACHEBUST
|
||||
COPY --from=builder /app /usr/local/bin/app
|
||||
ENTRYPOINT ["app"]
|
||||
```
|
||||
@@ -239,6 +274,16 @@ style conventions are in separate documents:
|
||||
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||
same two copies. A plain `docker build .` builds the last stage's chain
|
||||
and nothing else.
|
||||
- Only Go's build cache is mounted (`/root/.cache/go-build`, where Go keeps
|
||||
it for root), and only on the steps that compile: `golangci-lint run`,
|
||||
both `go test` runs and `go build`. It keeps compiled packages between
|
||||
check builds, and Go reuses one only when all its inputs are unchanged, so
|
||||
it saves the compile without skipping a check. Nothing else is kept:
|
||||
golangci-lint's own cache would replay stored findings in place of a lint
|
||||
run, and Go builds from whatever files a kept module cache holds without
|
||||
checking them again (only `go mod verify` does), so a file changed there
|
||||
would go unnoticed. Go's build cache also holds test results, which is why
|
||||
both `go test` runs carry `-count=1`.
|
||||
- If the project uses `//go:embed` directives that reference build artifacts
|
||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||
create placeholder files so the embed directives resolve. Example:
|
||||
@@ -278,20 +323,39 @@ style conventions are in separate documents:
|
||||
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
||||
`docker build .` with no build arguments must succeed; a Dockerfile that
|
||||
refuses an empty build argument drops that refusal and keeps the argument.
|
||||
A checkout whose `.git` is a file (a linked worktree, or a repository
|
||||
checked out as a submodule) is the exception: that file points to a git
|
||||
directory outside the build context, so the build cannot read the version
|
||||
and a plain `docker build .` fails; pass the version with
|
||||
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
|
||||
do.
|
||||
|
||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||
That script bootstraps, runs the gate phases, and then builds the image, so a
|
||||
successful run means every check passed; a bare `docker build .` does not
|
||||
runs `script/cibuild` on push, and checks out the repo as its only other step,
|
||||
with `persist-credentials: false`: `script/cibuild` needs no token, and
|
||||
without it the checkout leaves the job's token in `.git/config` for every
|
||||
later step. The checkout step also sets `fetch-depth: 0`, which fetches the
|
||||
tags `git describe` needs: by default it clones shallow with no tags, and a
|
||||
tagged repository's CI build would stamp a bare short commit id. The
|
||||
workflow's `concurrency` block groups runs by workflow and branch
|
||||
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`,
|
||||
so a new push cancels the older run on the same branch, queued or running, and
|
||||
no other: runs for replaced commits do not hold up the shared runner.
|
||||
`script/cibuild` bootstraps, runs the gate phases, and then builds the image,
|
||||
so a successful run means every check passed; a bare `docker build .` does not
|
||||
carry the same guarantee, because its gate phases may come from the cache. The
|
||||
image build is uncached and so runs the gate phases a second time. That is the
|
||||
price of the rule above, and it is worth paying: the image that ships is built
|
||||
from a run of its own gates rather than from a cache entry. A separate
|
||||
workflow limited to `main` by a `branches` list under `on: push` cannot be
|
||||
checked by review: to try a change to it, add the feature branch to that list
|
||||
and push, then remove the branch from the list again before merging. Keep any
|
||||
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
|
||||
from the feature branch publishes nothing.
|
||||
image build gets a `CACHEBUST` value of its own and so runs the gate phases a
|
||||
second time. That is the price of the rule above, and it is worth paying: the
|
||||
image that ships is built from a run of its own gates rather than from a cache
|
||||
entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the
|
||||
shared runner after 20 minutes. That allows for the three Docker builds
|
||||
described above (the test phase, the lint phase, then the image), each held to
|
||||
the 5-minute Docker build limit below, plus the bootstrap. A separate workflow
|
||||
limited to `main` by a `branches` list under `on: push` cannot be checked by
|
||||
review: to try a change to it, add the feature branch to that list and push,
|
||||
then remove the branch from the list again before merging. Keep any job in it
|
||||
that publishes behind `if: github.ref_name == 'main'`, so the run from the
|
||||
feature branch publishes nothing.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
@@ -351,12 +415,11 @@ style conventions are in separate documents:
|
||||
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
||||
passing result in its cache directory (`GOCACHE`), and when the same tests
|
||||
run again on unchanged code it prints that result, marked `(cached)`,
|
||||
without running them. That matters on a developer's machine, where this
|
||||
target runs and the directory lasts from one run to the next. The `test`
|
||||
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
|
||||
result for this repo's tests and nothing before its `go test` step runs a
|
||||
test, so there is nothing to replay. `--no-cache` (above) is what makes that
|
||||
step run on an unchanged tree.
|
||||
without running them. The directory lasts from one run to the next on a
|
||||
developer's machine, where this target runs, and in the `test` phase of the
|
||||
`Dockerfile`, which mounts it to keep compiled packages between check
|
||||
builds, so the phase's `go test` runs carry `-count=1` too. A new
|
||||
`CACHEBUST` value (above) is what makes that step run on an unchanged tree.
|
||||
|
||||
Python example:
|
||||
|
||||
@@ -384,9 +447,12 @@ style conventions are in separate documents:
|
||||
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
||||
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
||||
setting up a new repo. These patterns are written to `.gitignore`'s own
|
||||
`node_modules/`, and the repo's own build outputs. Fetch the standard
|
||||
`.gitignore` from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||
a new repo. A repo's `.gitignore` is the standard file followed by the repo's
|
||||
own entries, such as its binaries; a re-vendor replaces the standard part and
|
||||
keeps those entries. These patterns are written to `.gitignore`'s own
|
||||
semantics, in which an unanchored pattern already matches at every depth; they
|
||||
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
||||
|
||||
@@ -434,13 +500,15 @@ style conventions are in separate documents:
|
||||
byte-identically across repos:
|
||||
|
||||
```sh
|
||||
# Own line: a failing command substitution inside an argument does not
|
||||
# trip `set -e`, so the inline form degrades to an empty constant.
|
||||
# The version and the tag each get their own line: a failing command
|
||||
# substitution inside an argument does not trip `set -e`, so the inline
|
||||
# form degrades to an empty constant.
|
||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||
[ -n "$version" ] || version="unknown"
|
||||
docker build --no-cache \
|
||||
tag="$(script/projectname)"
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
--build-arg VERSION="$version" \
|
||||
-t "$(script/projectname)" .
|
||||
-t "$tag" .
|
||||
```
|
||||
|
||||
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
||||
@@ -452,8 +520,8 @@ style conventions are in separate documents:
|
||||
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
||||
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
|
||||
the scripts stay byte-identical. One consequence for CI: the standard
|
||||
checkout action clones shallow and fetches no tags, so a repo that embeds a
|
||||
tag-derived version must set `fetch-depth: 0` on its checkout step.
|
||||
checkout action clones shallow and fetches no tags, so the canonical
|
||||
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step.
|
||||
|
||||
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
||||
patterns.** Plant files at the root _and_ at least two directories deep, build
|
||||
@@ -520,6 +588,11 @@ style conventions are in separate documents:
|
||||
function defined and never invoked has the same exit status and the same
|
||||
empty output as one that worked.
|
||||
|
||||
Node is the exception to the whole-token comparison: the canonical
|
||||
`script/bootstrap` compares only its major version, because the node 22
|
||||
alpine image the `Dockerfile` stages start from is not the exact pin, and
|
||||
nvm cannot install a prebuilt node on alpine.
|
||||
|
||||
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||
|
||||
A Go tool a repo needs on the host is installed with `go install` pinned to
|
||||
@@ -636,7 +709,11 @@ style conventions are in separate documents:
|
||||
Never edit existing migrations after release.
|
||||
|
||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||
settings.
|
||||
settings: the standard file from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, which sets
|
||||
tabs for `Makefile` and Go files, followed by the repo's own sections, such as
|
||||
one for another language it uses. A re-vendor replaces the standard part and
|
||||
keeps those sections.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||
|
||||
@@ -18,6 +18,80 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-15: The canonical files are re-vendored from `sneak/prompts` commit
|
||||
`e3f79c4` (https://git.eeqj.de/sneak/secret/issues/129), so Go's build cache
|
||||
survives between check builds and `make test` no longer compiles the standard
|
||||
library and every dependency with `-race` on every run. `script/lint`,
|
||||
`script/test`, `script/cibuild` and `script/docker` pass a new `CACHEBUST`
|
||||
value instead of `--no-cache`, and every stage of the `Dockerfile` declares
|
||||
`ARG CACHEBUST` first, so every `RUN` step still reruns on an unchanged tree.
|
||||
The steps that compile mount Go's build cache, and both `go test` runs keep
|
||||
`-count=1`, so no test result is reused. `script/lint` and `script/test` write
|
||||
no image. The CI workflow cancels a branch's older run, stops after 20
|
||||
minutes, fetches the tags and leaves no token behind. `script/bootstrap` uses
|
||||
the installed node only when its major version is the pinned one.
|
||||
- 2026-10-14: A wrong passphrase, from `SB_UNLOCK_PASSPHRASE` or typed at the
|
||||
prompt, gives the error `wrong passphrase for unlocker <id>`
|
||||
(https://git.eeqj.de/sneak/secret/issues/157). It used to be age's "no
|
||||
identity matched any of the recipients", followed by advice to give the vault
|
||||
a new unlocker from its mnemonic, which would have replaced an unlocker that
|
||||
works. The error now wraps `secret.ErrWrongPassphrase` and carries no advice
|
||||
to use the mnemonic. A `priv.age` that is not an age file still gets that
|
||||
advice.
|
||||
- 2026-10-14: The passphrase and mnemonic prompts ask on the controlling
|
||||
terminal (`/dev/tty`) instead of stdin, so `echo v | secret add s1 --force`,
|
||||
`cat file | secret encrypt key` and `cat file.age | secret decrypt key` ask
|
||||
for the passphrase at a terminal instead of failing
|
||||
(https://git.eeqj.de/sneak/secret/issues/144). Only a process with no
|
||||
controlling terminal gets the error that names the variable to set. On SIGINT
|
||||
or SIGTERM, `Entry()` restores the settings of that terminal, not of stdin,
|
||||
since that is where the prompt turns echo off. `secret init` and
|
||||
`secret vault create` no longer write a newline to stderr after the mnemonic
|
||||
is typed, since the prompt ends that line on the terminal.
|
||||
- 2026-10-14: `secret generate mnemonic`, `secret vault list` and
|
||||
`secret unlocker list` print their output to stdout, as `secret list` does, so
|
||||
it can be piped or captured (https://git.eeqj.de/sneak/secret/issues/138).
|
||||
They printed with cobra's `cmd.Println` and `cmd.Printf`, which write to
|
||||
stderr when no output writer is set, so `secret vault list --json | jq`
|
||||
received nothing. "No versions found" from `secret version list` moved to
|
||||
stdout too. Messages such as "Created vault", and the warning after the
|
||||
mnemonic, stay on stderr.
|
||||
- 2026-10-14: Removing the current unlocker no longer leaves the vault unable to
|
||||
open through the unlockers it still has
|
||||
(https://git.eeqj.de/sneak/secret/issues/137). `current-unlocker` went on
|
||||
naming the removed unlocker, so every command that opened the vault failed,
|
||||
with advice to use the mnemonic. `secret unlocker remove` of the current
|
||||
unlocker now makes the first of the vault's other unlockers that
|
||||
`secret unlocker select` accepts, in the order `secret unlocker list` shows,
|
||||
current before removing it, and says so; when there is none, it also removes
|
||||
`current-unlocker`. `vault.RemoveUnlocker` returns the ID of the unlocker it
|
||||
made current. When `current-unlocker` names an unlocker that does not exist
|
||||
and the vault has another that `secret unlocker select` accepts, the error
|
||||
wraps `vault.ErrUnlockerNotFound` and says to run `secret unlocker select`,
|
||||
without the advice to use the mnemonic.
|
||||
- 2026-10-14: A `secret add --force` that cannot unlock the vault, given a wrong
|
||||
passphrase or mnemonic, leaves the secret as it was
|
||||
(https://git.eeqj.de/sneak/secret/issues/136). The new version used to be
|
||||
written before the vault was unlocked to set `notAfter` on the version it
|
||||
replaces, so each failed add left one more version that `secret version list`
|
||||
showed. The vault is now unlocked, and that version's metadata read, before
|
||||
anything is written. `generate secret --force` and `import --force` take the
|
||||
same path.
|
||||
- 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the
|
||||
same long-term key as its words separated by single spaces
|
||||
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
|
||||
text exactly as typed, so a vault made with a double or trailing space did not
|
||||
open with the same words typed normally. `agehd.DeriveEntropy`, which every
|
||||
use of the mnemonic goes through, now joins the words with single spaces and
|
||||
puts them in Unicode NFKD, as BIP39 defines, before making the seed.
|
||||
- 2026-10-12: The keychain and Secure Enclave unlockers are gone from the code
|
||||
and the documents (https://git.eeqj.de/sneak/secret/issues/132). They were
|
||||
research: neither works without macOS code signing and entitlements, which
|
||||
`secret` does not have. The last commit that has them is `ef0ae90`, tagged
|
||||
`v1.0.0-rc1`. `secret unlocker add` takes only `passphrase` and `pgp` on every
|
||||
platform, `internal/macse` and those unlockers' files in `internal/secret` are
|
||||
deleted, and `github.com/keybase/go-keychain` left `go.mod` and `go.sum`.
|
||||
`secret` still builds and runs on macOS.
|
||||
- 2026-10-07: The part of `github.com/tyler-smith/go-bip39` v1.1.0 that `secret`
|
||||
uses is copied into `internal/bip39`, with upstream's `LICENSE` beside it,
|
||||
because its repository no longer exists
|
||||
|
||||
@@ -12,7 +12,6 @@ require (
|
||||
github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8 // v1.1.24's Open can return another pty's terminal
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/spf13/afero v1.14.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
@@ -20,6 +19,7 @@ require (
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/term v0.32.0
|
||||
golang.org/x/text v0.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -32,6 +32,5 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -69,8 +69,6 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1 h1:yi1W8qcFJ2plmaGJFN1npm0KQviWPMCtQOYuwDT6Swk=
|
||||
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1/go.mod h1:qDHUvIjGZJUtdPtuP4WMu5/U4aVWbFw1MhlkJqCGmCQ=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
@@ -140,6 +141,25 @@ func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
|
||||
run("vault", "create", "work")
|
||||
}
|
||||
|
||||
// TestNoVaultImportCommand is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/139: `secret vault import`
|
||||
// refused every vault that `secret vault create` makes, because each has a
|
||||
// long-term key. The command is gone, so it fails as any subcommand of
|
||||
// `secret vault` that does not exist does.
|
||||
func TestNoVaultImportCommand(t *testing.T) {
|
||||
t.Setenv(secret.EnvStateDir, t.TempDir())
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||
|
||||
_, err := cli.ExecuteCommandInProcess(
|
||||
strings.Fields("vault create work"), "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cli.ExecuteCommandInProcess(
|
||||
strings.Fields("vault import work"), "", nil)
|
||||
require.EqualError(t, err, `unknown command "import" for "secret vault"`)
|
||||
}
|
||||
|
||||
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the
|
||||
// review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or
|
||||
// `secret vault create` stopped at the passphrase prompt left a vault with
|
||||
@@ -197,10 +217,10 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
|
||||
|
||||
// TestMnemonicNotReadNamesOnlyMnemonic is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/115: `secret init` without
|
||||
// SB_SECRET_MNEMONIC and with a stdin that is not a terminal said "failed to
|
||||
// read mnemonic: failed to read passphrase: ...". The error must wrap
|
||||
// SB_SECRET_MNEMONIC and with no terminal said "failed to read mnemonic:
|
||||
// failed to read passphrase: ...". The error must wrap
|
||||
// secret.ErrMnemonicNotRead and name the mnemonic only. The message is
|
||||
// pinned on the built binary, whose stdin is surely not a terminal.
|
||||
// pinned on the built binary, run in a new session, which has no terminal.
|
||||
func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -216,14 +236,54 @@ func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.Error(t, err)
|
||||
|
||||
require.Equal(t, "Initialized secrets manager at: "+stateDir+"\n"+
|
||||
"Error: failed to read mnemonic: stdin is not a terminal (piped input "+
|
||||
"or script). Please set the SB_SECRET_MNEMONIC environment variable "+
|
||||
"or run interactively\n", string(output))
|
||||
require.Equal(t, "Error: failed to read mnemonic: there is no terminal to "+
|
||||
"ask on. Please set the SB_SECRET_MNEMONIC environment variable or run "+
|
||||
"interactively\n", string(output))
|
||||
}
|
||||
|
||||
// TestCreateExistingVaultAsksForNothing is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/151: `secret init` and `secret
|
||||
// vault create` asked for the mnemonic and the unlocker passphrase before
|
||||
// refusing a vault that already exists, and `secret init` printed
|
||||
// "Initialized secrets manager at" first. Each runs here as the built binary,
|
||||
// in a new session, which has no terminal, and with neither
|
||||
// SB_SECRET_MNEMONIC nor SB_UNLOCK_PASSPHRASE set, so asking for either
|
||||
// fails. Each must fail saying only that the vault exists.
|
||||
func TestCreateExistingVaultAsksForNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
_, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// run runs the built binary with args, which must fail, and returns what
|
||||
// it printed
|
||||
run := func(args ...string) string {
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), args...)
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.Error(t, err, "secret %v", args)
|
||||
|
||||
return string(output)
|
||||
}
|
||||
|
||||
require.Equal(t,
|
||||
"Error: failed to create default vault: vault default already exists\n",
|
||||
run("init"))
|
||||
require.Equal(t, "Error: vault default already exists\n",
|
||||
run("vault", "create", "default"))
|
||||
}
|
||||
|
||||
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
|
||||
|
||||
+50
-19
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -107,8 +108,8 @@ func (cli *Instance) storeNewEncryptionKey(
|
||||
// does not exist. The caller must destroy the returned buffer. Only storing
|
||||
// a new key takes the state directory lock, so that reading an existing key
|
||||
// works on a read-only state directory and keeps no other command waiting
|
||||
// at the passphrase prompt, and Encrypt streams its input and output
|
||||
// unlocked.
|
||||
// at the passphrase prompt, and Encrypt reads its input and writes its
|
||||
// output without the lock.
|
||||
func (cli *Instance) resolveEncryptionKey(
|
||||
vlt *vault.Vault, secretName string,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
@@ -188,17 +189,17 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||
input = file
|
||||
}
|
||||
|
||||
// Set up output writer
|
||||
// Set up output writer. With --output the result is held in memory
|
||||
// and written only once encryption has succeeded, so a failure to
|
||||
// encrypt leaves --output untouched and --input can be the same file.
|
||||
// A failure while writing --output, such as a full disk, can leave it
|
||||
// empty or partly written.
|
||||
output := cli.cmd.OutOrStdout()
|
||||
|
||||
if outputFile != "" {
|
||||
file, err := cli.fs.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
var result bytes.Buffer
|
||||
|
||||
output = file
|
||||
if outputFile != "" {
|
||||
output = &result
|
||||
}
|
||||
|
||||
// Encrypt the data
|
||||
@@ -217,7 +218,11 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||
return fmt.Errorf("failed to finalize encryption: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
if outputFile == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cli.writeOutputFile(outputFile, result.Bytes())
|
||||
}
|
||||
|
||||
// Decrypt decrypts data using an age secret key stored in a secret
|
||||
@@ -278,17 +283,17 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
input = file
|
||||
}
|
||||
|
||||
// Set up output writer
|
||||
// Set up output writer. With --output the result is held in memory
|
||||
// and written only once decryption has succeeded, so a failure to
|
||||
// decrypt leaves --output untouched and --input can be the same file.
|
||||
// A failure while writing --output, such as a full disk, can leave it
|
||||
// empty or partly written.
|
||||
output := cli.cmd.OutOrStdout()
|
||||
|
||||
if outputFile != "" {
|
||||
file, err := cli.fs.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
var result bytes.Buffer
|
||||
|
||||
output = file
|
||||
if outputFile != "" {
|
||||
output = &result
|
||||
}
|
||||
|
||||
// Decrypt the data
|
||||
@@ -302,6 +307,32 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
return fmt.Errorf("failed to decrypt data: %w", err)
|
||||
}
|
||||
|
||||
if outputFile == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cli.writeOutputFile(outputFile, result.Bytes())
|
||||
}
|
||||
|
||||
// writeOutputFile creates outputFile, or empties it if it exists, and
|
||||
// writes result into it.
|
||||
func (cli *Instance) writeOutputFile(outputFile string, result []byte) error {
|
||||
file, err := cli.fs.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
_, err = file.Write(result)
|
||||
|
||||
closeErr := file.Close()
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write output file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//nolint:testpackage // sets the unexported fields of Instance
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// plainText is the content of plain.txt in the directory newCryptoTest makes.
|
||||
const plainText = "document"
|
||||
|
||||
// newCryptoTest returns an Instance whose current vault, in a new state
|
||||
// directory on the real filesystem, holds the age key secret "key", and a
|
||||
// new directory holding only plain.txt and plain.txt.age, its encryption
|
||||
// with that key.
|
||||
func newCryptoTest(t *testing.T) (*Instance, string) {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
stateDir := t.TempDir()
|
||||
|
||||
_, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
cli.Mnemonic = testMnemonicBuffer(t)
|
||||
cli.cmd = &cobra.Command{}
|
||||
|
||||
dir := t.TempDir()
|
||||
plain := filepath.Join(dir, "plain.txt")
|
||||
require.NoError(t, afero.WriteFile(fs, plain, []byte(plainText), 0o600))
|
||||
require.NoError(t, cli.Encrypt("key", plain, plain+".age"))
|
||||
|
||||
return cli, dir
|
||||
}
|
||||
|
||||
// TestFailedCryptoLeavesOutputUnchanged is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/143, where secret encrypt and
|
||||
// secret decrypt emptied the file named by --output before they failed.
|
||||
// A failure to encrypt or decrypt must leave an existing file as it was and
|
||||
// create none, and give the same error as without --output.
|
||||
func TestFailedCryptoLeavesOutputUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(cli *Instance, dir, output string) error
|
||||
}{
|
||||
// Reading a directory fails once encryption has begun
|
||||
{"encrypt", func(cli *Instance, dir, output string) error {
|
||||
return cli.Encrypt("key", dir, output)
|
||||
}},
|
||||
// plain.txt is not an age file
|
||||
{"decrypt", func(cli *Instance, dir, output string) error {
|
||||
return cli.Decrypt("key", filepath.Join(dir, "plain.txt"), output)
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cli, dir := newCryptoTest(t)
|
||||
existing := filepath.Join(dir, "existing.txt")
|
||||
require.NoError(t, afero.WriteFile(cli.fs, existing, []byte("precious"), 0o600))
|
||||
|
||||
cli.cmd.SetOut(io.Discard)
|
||||
toStdout := tt.run(cli, dir, "")
|
||||
require.Error(t, toStdout)
|
||||
|
||||
require.EqualError(t, tt.run(cli, dir, existing), toStdout.Error())
|
||||
require.EqualError(t, tt.run(cli, dir, filepath.Join(dir, "new.txt")),
|
||||
toStdout.Error())
|
||||
|
||||
content, err := afero.ReadFile(cli.fs, existing)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "precious", string(content))
|
||||
|
||||
exists, err := afero.Exists(cli.fs, filepath.Join(dir, "new.txt"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, exists, "a failed %s created --output", tt.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCryptoInPlace checks that secret encrypt and secret decrypt given
|
||||
// one file as both --input and --output replace it with the right result.
|
||||
// Before https://git.eeqj.de/sneak/secret/issues/143 they emptied the file
|
||||
// before reading it.
|
||||
func TestCryptoInPlace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cli, dir := newCryptoTest(t)
|
||||
file := filepath.Join(dir, "plain.txt")
|
||||
|
||||
require.NoError(t, cli.Encrypt("key", file, file))
|
||||
require.NoError(t, cli.Decrypt("key", file, file))
|
||||
|
||||
content, err := afero.ReadFile(cli.fs, file)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, plainText, string(content))
|
||||
}
|
||||
|
||||
// errCloseFailed is returned by the files closeFailFs creates in place of
|
||||
// a successful Close, as NFS reports a full disk only when the file is
|
||||
// closed.
|
||||
var errCloseFailed = errors.New("no space left on device")
|
||||
|
||||
// closeFailFs creates files whose Close fails with errCloseFailed.
|
||||
type closeFailFs struct {
|
||||
afero.Fs
|
||||
}
|
||||
|
||||
// closeFailFile is a file created through closeFailFs.
|
||||
type closeFailFile struct {
|
||||
afero.File
|
||||
}
|
||||
|
||||
func (f closeFailFile) Close() error {
|
||||
_ = f.File.Close()
|
||||
|
||||
return errCloseFailed
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||
func (f closeFailFs) Create(name string) (afero.File, error) {
|
||||
file, err := f.Fs.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return closeFailFile{File: file}, nil
|
||||
}
|
||||
|
||||
// TestDecryptFailsWhenClosingOutputFails checks that secret decrypt fails
|
||||
// when closing --output fails, since some filesystems report a failed
|
||||
// write only then.
|
||||
func TestDecryptFailsWhenClosingOutputFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cli, dir := newCryptoTest(t)
|
||||
cli.fs = closeFailFs{Fs: cli.fs}
|
||||
|
||||
err := cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"),
|
||||
filepath.Join(dir, "out.txt"))
|
||||
require.ErrorIs(t, err, errCloseFailed)
|
||||
}
|
||||
|
||||
// TestDecryptToDescriptorOfDeletedFile checks that secret decrypt given
|
||||
// /dev/fd/N as --output writes through that descriptor. A program may give
|
||||
// the command a temporary file it has already deleted as stdout, pass
|
||||
// /dev/stdout as --output and read the result back from that file.
|
||||
func TestDecryptToDescriptorOfDeletedFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cli, dir := newCryptoTest(t)
|
||||
|
||||
file, err := os.CreateTemp(t.TempDir(), "out")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
require.NoError(t, os.Remove(file.Name()))
|
||||
|
||||
output := fmt.Sprintf("/dev/fd/%d", file.Fd())
|
||||
require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), output))
|
||||
|
||||
// On macOS the command's open of /dev/fd/N shares this descriptor's
|
||||
// position, which its write leaves at the end.
|
||||
_, err = file.Seek(0, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := io.ReadAll(file)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, plainText, string(content))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestListFailsWhenCurrentVaultDoesNotExist is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/156, where a currentvault file
|
||||
// that was empty, held an invalid vault name, or named a vault that does
|
||||
// not exist, made `secret list` report an empty vault. Listing must fail
|
||||
// with an error that names the file and says to run `secret vault select`,
|
||||
// `secret vault list` must still list the vaults, and selecting a vault
|
||||
// must make listing work again. The name "." would make vaults.d itself the
|
||||
// vault, and "../vaults.d/work" would reach the vault work by a path.
|
||||
func TestListFailsWhenCurrentVaultDoesNotExist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, name := range []string{"", ".", "../vaults.d/work", "gone"} {
|
||||
t.Run(strconv.Quote(name), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
||||
require.NoError(t, afero.WriteFile(fs, currentVaultPath,
|
||||
[]byte(name), secret.FilePerms))
|
||||
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&out)
|
||||
|
||||
err := c.ListSecrets(cmd, false, true, "")
|
||||
require.ErrorIs(t, err, vault.ErrVaultNotFound)
|
||||
require.ErrorContains(t, err, currentVaultPath)
|
||||
require.ErrorContains(t, err, "secret vault select")
|
||||
assert.Empty(t, out.String())
|
||||
|
||||
require.NoError(t, c.ListVaults(cmd, false))
|
||||
assert.Equal(t, "Available vaults:\n default\n work\n", out.String())
|
||||
|
||||
require.NoError(t, c.SelectVault(cmd, "default"))
|
||||
out.Reset()
|
||||
require.NoError(t, c.ListSecrets(cmd, false, true, ""))
|
||||
assert.Equal(t, "x\n", out.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
+29
-15
@@ -29,6 +29,12 @@ func newGenerateCmd() *cobra.Command {
|
||||
Use: "generate",
|
||||
Short: "Generate random data",
|
||||
Long: `Generate various types of random data including mnemonics and secrets.`,
|
||||
// Cobra checks Args only on a command that has a RunE; without
|
||||
// one, an unknown subcommand prints this help and exits 0.
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newGenerateMnemonicCmd())
|
||||
@@ -43,7 +49,7 @@ func newGenerateMnemonicCmd() *cobra.Command {
|
||||
Short: "Generate a random BIP39 mnemonic phrase",
|
||||
Long: `Generate a cryptographically secure random BIP39 ` +
|
||||
`mnemonic phrase that can be used with 'secret init' ` +
|
||||
`or 'secret import'.`,
|
||||
`or 'secret vault create'.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
@@ -72,6 +78,12 @@ func newGenerateSecretCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
// Before readSecretEnv, which makes the first locked allocation
|
||||
err = checkLockedMemoryLimit(0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
destroySecrets := cli.readSecretEnv()
|
||||
defer destroySecrets()
|
||||
|
||||
@@ -103,7 +115,7 @@ func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
// Output mnemonic to stdout
|
||||
cmd.Println(mnemonic)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), mnemonic)
|
||||
|
||||
// Output helpful information to stderr
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
@@ -115,8 +127,10 @@ func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
" • If you lose this phrase, your secrets cannot be recovered")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
|
||||
fmt.Fprintln(os.Stderr, " secret init (to initialize a new secret manager)")
|
||||
fmt.Fprintln(os.Stderr, " secret import (to import into an existing vault)")
|
||||
fmt.Fprintln(os.Stderr,
|
||||
" secret init (to initialize a new secret manager)")
|
||||
fmt.Fprintln(os.Stderr,
|
||||
" secret vault create <name> (to create another vault)")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -133,10 +147,16 @@ func (cli *Instance) GenerateSecret(
|
||||
return errLengthTooSmall
|
||||
}
|
||||
|
||||
var (
|
||||
secretValue string
|
||||
err error
|
||||
)
|
||||
if length > maxSecretSize {
|
||||
return errSecretTooLarge
|
||||
}
|
||||
|
||||
err := checkLockedMemoryLimit(length)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var secretValue string
|
||||
|
||||
switch secretType {
|
||||
case "base58":
|
||||
@@ -155,12 +175,6 @@ func (cli *Instance) GenerateSecret(
|
||||
return fmt.Errorf("failed to generate random secret: %w", err)
|
||||
}
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Store the secret in the vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -173,7 +187,7 @@ func (cli *Instance) GenerateSecret(
|
||||
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
err = vlt.AddSecret(secretName, secretBuffer, force)
|
||||
err = cli.storeSecret(vlt, secretName, secretBuffer, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+12
-13
@@ -18,24 +18,23 @@ type vaultStats struct {
|
||||
|
||||
// addVersion accumulates size and timestamp info for one version directory
|
||||
func (s *vaultStats) addVersion(fs afero.Fs, versionPath string) {
|
||||
// Add size of encrypted data
|
||||
dataPath := filepath.Join(versionPath, "data.age")
|
||||
|
||||
stat, err := fs.Stat(dataPath)
|
||||
if err == nil {
|
||||
s.totalSize += stat.Size()
|
||||
// Add the size of every file in the version directory: its value,
|
||||
// metadata and keys. A directory that cannot be read adds nothing and
|
||||
// gets a warning; its timestamp is still tracked below.
|
||||
files, err := afero.ReadDir(fs, versionPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read version directory",
|
||||
"directory", versionPath, "error", err)
|
||||
}
|
||||
|
||||
// Add size of metadata
|
||||
metaPath := filepath.Join(versionPath, "metadata.age")
|
||||
|
||||
stat, err = fs.Stat(metaPath)
|
||||
if err == nil {
|
||||
s.totalSize += stat.Size()
|
||||
for _, file := range files {
|
||||
if !file.IsDir() {
|
||||
s.totalSize += file.Size()
|
||||
}
|
||||
}
|
||||
|
||||
// Track timestamps
|
||||
stat, err = fs.Stat(versionPath)
|
||||
stat, err := fs.Stat(versionPath)
|
||||
if err == nil {
|
||||
modTime := stat.ModTime()
|
||||
if s.oldestTime.IsZero() || modTime.Before(s.oldestTime) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAddVersionWarnsWhenVersionDirUnreadable asserts that a version
|
||||
// directory that cannot be listed, so that its files are left out of the
|
||||
// total size, gets a warning naming it and the error, and that its
|
||||
// timestamp is still tracked.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Stderr, where secret.Warn writes
|
||||
func TestAddVersionWarnsWhenVersionDirUnreadable(t *testing.T) {
|
||||
versionDir := filepath.Join(testVaultDir(listTestVaultName),
|
||||
unreadableTestSecretsDirName, unreadableTestSecretName,
|
||||
"versions", "20260809.001")
|
||||
|
||||
base := afero.NewMemMapFs()
|
||||
require.NoError(t, base.MkdirAll(versionDir, listTestDirPerm))
|
||||
|
||||
stderrReader, stderrWriter, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = stderrReader.Close() }()
|
||||
|
||||
realStderr := os.Stderr
|
||||
os.Stderr = stderrWriter
|
||||
|
||||
var stats vaultStats
|
||||
stats.addVersion(&openFailFs{Fs: base, path: versionDir}, versionDir)
|
||||
|
||||
os.Stderr = realStderr
|
||||
|
||||
require.NoError(t, stderrWriter.Close())
|
||||
|
||||
warning, err := io.ReadAll(stderrReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, string(warning), versionDir)
|
||||
assert.Contains(t, string(warning), errOpenFailed.Error())
|
||||
assert.False(t, stats.latestTime.IsZero(),
|
||||
"the timestamp must still be tracked")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestInfoTotalSizeCountsSecretValues is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/147, where the total size shown by
|
||||
// `secret info` left out every secret's value. Adding a secret must make it
|
||||
// grow by at least the size of the value.
|
||||
func TestInfoTotalSizeCountsSecretValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const valueSize = 10000
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, "default",
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
before := infoTotalSize(t, fs)
|
||||
|
||||
valueBytes := make([]byte, valueSize)
|
||||
_, err = rand.Read(valueBytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
value := memguard.NewBufferFromBytes(valueBytes)
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("s1", value, false))
|
||||
|
||||
after := infoTotalSize(t, fs)
|
||||
require.GreaterOrEqual(t, after-before, int64(valueSize))
|
||||
}
|
||||
|
||||
// infoTotalSize returns the total size that `secret info --json` reports for
|
||||
// the state directory on fs.
|
||||
func infoTotalSize(t *testing.T, fs afero.Fs) int64 {
|
||||
t.Helper()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&out)
|
||||
|
||||
err := cli.NewCLIInstanceWithStateDir(fs, testStateDir).Info(cmd, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
var info cli.InfoOutput
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &info))
|
||||
|
||||
return info.TotalSize
|
||||
}
|
||||
+41
-32
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
@@ -18,6 +17,9 @@ import (
|
||||
// errPassphraseMismatch is returned when passphrase confirmation fails
|
||||
var errPassphraseMismatch = errors.New("passphrases do not match")
|
||||
|
||||
// defaultVaultName is the name of the vault `secret init` creates
|
||||
const defaultVaultName = "default"
|
||||
|
||||
// NewInitCmd creates the init command
|
||||
func NewInitCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
@@ -57,46 +59,25 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
|
||||
// Read mnemonic securely without echo
|
||||
mnemonicBuffer, err := secret.ReadMnemonic("Enter your BIP39 mnemonic phrase: ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||
secret.Debug("Failed to read mnemonic", "error", err)
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||
|
||||
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
|
||||
}
|
||||
|
||||
// Init initializes the secret manager, holding the state directory lock
|
||||
// while initialize runs
|
||||
// Init initializes the secret manager. It asks for the mnemonic and the
|
||||
// passphrase before it takes the state directory lock, so that no other
|
||||
// command waits while the user answers, and holds the lock while
|
||||
// initialize runs.
|
||||
func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
return cli.initialize(cmd)
|
||||
}
|
||||
|
||||
// initialize creates the state directory, the default vault and its first
|
||||
// unlocker
|
||||
func (cli *Instance) initialize(cmd *cobra.Command) error {
|
||||
secret.Debug("Starting secret manager initialization")
|
||||
|
||||
// Create state directory
|
||||
stateDir := cli.GetStateDir()
|
||||
secret.DebugWith("Creating state directory", slog.String("path", stateDir))
|
||||
|
||||
err := cli.fs.MkdirAll(stateDir, secret.DirPerms)
|
||||
// Refuse an existing default vault before asking for anything
|
||||
err := vault.CheckNewVaultName(cli.fs, cli.stateDir, defaultVaultName)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create state directory", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create state directory: %w", err)
|
||||
}
|
||||
|
||||
if cmd != nil {
|
||||
cmd.Printf("Initialized secrets manager at: %s\n", stateDir)
|
||||
return fmt.Errorf("failed to create default vault: %w", err)
|
||||
}
|
||||
|
||||
// Prompt for mnemonic
|
||||
@@ -133,11 +114,38 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
|
||||
}
|
||||
defer cleanupPassphrase()
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
return cli.initialize(cmd, mnemonic, passphraseBuffer)
|
||||
}
|
||||
|
||||
// initialize creates the state directory, and the default vault from
|
||||
// mnemonic with a passphrase unlocker protected by passphrase. Another
|
||||
// command may have created the vault since Init checked: vault.CreateVault
|
||||
// checks again.
|
||||
func (cli *Instance) initialize(
|
||||
cmd *cobra.Command, mnemonic, passphrase *memguard.LockedBuffer,
|
||||
) error {
|
||||
// Create state directory
|
||||
stateDir := cli.GetStateDir()
|
||||
secret.DebugWith("Creating state directory", slog.String("path", stateDir))
|
||||
|
||||
err := cli.fs.MkdirAll(stateDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create state directory", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create state directory: %w", err)
|
||||
}
|
||||
|
||||
// Create the default vault with its passphrase unlocker
|
||||
secret.Debug("Creating default vault")
|
||||
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default",
|
||||
mnemonic, passphraseBuffer)
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, defaultVaultName,
|
||||
mnemonic, passphrase)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create default vault", "error", err)
|
||||
|
||||
@@ -155,6 +163,7 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
if cmd != nil {
|
||||
cmd.Printf("Initialized secrets manager at: %s\n", stateDir)
|
||||
cmd.Printf("\nDefault vault created and configured\n")
|
||||
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
||||
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
|
||||
|
||||
@@ -7,12 +7,10 @@ import (
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestInvalidMnemonicError checks that every command that takes a mnemonic
|
||||
// returns errInvalidMnemonicPhrase for one that is not valid BIP39. The vault
|
||||
// "other" has no long-term key, as vault import needs.
|
||||
// returns errInvalidMnemonicPhrase for one that is not valid BIP39.
|
||||
func TestInvalidMnemonicError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -24,20 +22,13 @@ func TestInvalidMnemonicError(t *testing.T) {
|
||||
{"secret vault create work", func(c *Instance) error {
|
||||
return c.CreateVault(c.cmd, "work")
|
||||
}},
|
||||
{"secret vault import other", func(c *Instance) error {
|
||||
return c.VaultImport(c.cmd, "other")
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(fs, listTestStateDir, "other", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
instance, _ := newTestInstance(fs)
|
||||
instance, _ := newTestInstance(afero.NewMemMapFs())
|
||||
instance.Mnemonic = memguard.NewBufferFromBytes([]byte("not a mnemonic"))
|
||||
t.Cleanup(instance.Mnemonic.Destroy)
|
||||
|
||||
|
||||
@@ -140,16 +140,6 @@ func TestSecretManagerIntegration(t *testing.T) {
|
||||
// - currentvault symlink updated to point to work
|
||||
test03CreateVault(t, tempDir, runSecret)
|
||||
|
||||
// Test 4: Import mnemonic into work vault
|
||||
// Command: secret vault import work
|
||||
// Purpose: Import mnemonic with passphrase unlocker
|
||||
// Expected filesystem:
|
||||
// - work vault has pub.age file
|
||||
// - work vault has unlockers.d/passphrase directory
|
||||
// - Unlocker metadata and encrypted keys present
|
||||
// NOTE: Skipped because vault creation now includes mnemonic import
|
||||
// test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv)
|
||||
|
||||
// Test 5: Add secrets with versioning
|
||||
// Command: echo "password123" | secret add database/password
|
||||
// Purpose: Add a secret and verify versioned storage
|
||||
@@ -535,65 +525,6 @@ func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (
|
||||
assert.Contains(t, output, "work", "should list work vault")
|
||||
}
|
||||
|
||||
//nolint:unused // TODO: re-enable when vault import is implemented
|
||||
func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase string, runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
||||
t.Helper()
|
||||
|
||||
// Import mnemonic into work vault
|
||||
output, err := runSecretWithEnv(map[string]string{
|
||||
secret.EnvMnemonic: testMnemonic,
|
||||
secret.EnvUnlockPassphrase: testPassphrase,
|
||||
}, "vault", "import", "work")
|
||||
|
||||
require.NoError(t, err, "vault import should succeed")
|
||||
assert.Contains(t, output, "Successfully imported mnemonic into vault 'work'", "should confirm import")
|
||||
|
||||
// Verify work vault now has long-term key
|
||||
workVaultDir := filepath.Join(tempDir, "vaults.d", "work")
|
||||
pubKeyFile := filepath.Join(workVaultDir, "pub.age")
|
||||
verifyFileExists(t, pubKeyFile)
|
||||
|
||||
// Verify passphrase unlocker was created
|
||||
currentUnlocker := readFile(t, filepath.Join(workVaultDir, "current-unlocker"))
|
||||
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", string(currentUnlocker))
|
||||
verifyFileExists(t, passphraseUnlockerDir)
|
||||
|
||||
// Check unlocker files
|
||||
unlockerMetadata := filepath.Join(passphraseUnlockerDir, "unlocker-metadata.json")
|
||||
verifyFileExists(t, unlockerMetadata)
|
||||
|
||||
encryptedLTPrivKey := filepath.Join(passphraseUnlockerDir, "priv.age")
|
||||
verifyFileExists(t, encryptedLTPrivKey)
|
||||
|
||||
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
|
||||
verifyFileExists(t, encryptedLTPubKey)
|
||||
|
||||
longtermKeyFile := filepath.Join(passphraseUnlockerDir, "longterm.age")
|
||||
verifyFileExists(t, longtermKeyFile)
|
||||
|
||||
// Verify vault metadata was created with derivation index
|
||||
vaultMetadata := filepath.Join(workVaultDir, "vault-metadata.json")
|
||||
verifyFileExists(t, vaultMetadata)
|
||||
|
||||
metadataBytes := readFile(t, vaultMetadata)
|
||||
|
||||
var metadata map[string]any
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
require.NoError(t, err, "vault metadata should be valid JSON")
|
||||
|
||||
// Work vault should have a different derivation index than default (0)
|
||||
derivIndex, ok := metadata["derivationIndex"].(float64)
|
||||
require.True(t, ok, "derivationIndex should be a number")
|
||||
assert.NotEqual(t, float64(0), derivIndex, "work vault should have non-zero derivation index")
|
||||
|
||||
// Verify public key hash is stored
|
||||
assert.Contains(t, metadata, "publicKeyHash", "should contain public key hash")
|
||||
pubKeyHash, ok := metadata["publicKeyHash"].(string)
|
||||
require.True(t, ok, "publicKeyHash should be a string")
|
||||
assert.NotEmpty(t, pubKeyHash, "public key hash should not be empty")
|
||||
}
|
||||
|
||||
func test05AddSecret(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1939,14 +1870,6 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
|
||||
_, err = runSecret("vault", "select", "nonexistent")
|
||||
require.ErrorIs(t, err, vault.ErrVaultNotFound, "select non-existent vault should fail")
|
||||
|
||||
// Import to non-existent vault with test passphrase
|
||||
testPassphrase := "test-passphrase-123" // Define testPassphrase locally
|
||||
_, err = runSecretWithEnv(map[string]string{
|
||||
secret.EnvMnemonic: testMnemonic,
|
||||
secret.EnvUnlockPassphrase: testPassphrase,
|
||||
}, "vault", "import", "nonexistent")
|
||||
require.ErrorIs(t, err, vault.ErrVaultNotFound, "import to non-existent vault should fail")
|
||||
|
||||
// Get specific version that doesn't exist
|
||||
_, err = runSecretWithEnv(map[string]string{
|
||||
secret.EnvMnemonic: testMnemonic,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
// listClockStep is how long TestListShowsWhenSecretWasWritten waits
|
||||
// between its steps, so that a time read in one step cannot be the
|
||||
// time read in the next.
|
||||
listClockStep = 200 * time.Millisecond
|
||||
|
||||
// fileTimeSlack allows for the clock the kernel stamps files with,
|
||||
// which advances only every few milliseconds: a file's modification
|
||||
// time can be slightly earlier than a time.Now() read before the file
|
||||
// was written.
|
||||
fileTimeSlack = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// TestListShowsWhenSecretWasWritten is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/142, where `secret list` gave
|
||||
// the time of the listing as every secret's creation and last-update time.
|
||||
// Two listings with a clock step between must be the same, and give the
|
||||
// time of the add; after a forced add, the creation time is unchanged and
|
||||
// the last update is the time of that add. It runs on a real filesystem,
|
||||
// where writing a file into a directory also changes the directory's time.
|
||||
func TestListShowsWhenSecretWasWritten(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
stateDir := t.TempDir()
|
||||
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
beforeAdd := time.Now()
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("one")), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
afterAdd := time.Now()
|
||||
|
||||
firstListing := listSecrets(t, c, true)
|
||||
|
||||
time.Sleep(listClockStep)
|
||||
require.Equal(t, firstListing, listSecrets(t, c, true))
|
||||
|
||||
added := listedTimes(t, firstListing)
|
||||
assert.WithinRange(t, added.CreatedAt, beforeAdd.Add(-fileTimeSlack), afterAdd)
|
||||
assert.WithinRange(t, added.UpdatedAt, beforeAdd.Add(-fileTimeSlack), afterAdd)
|
||||
|
||||
time.Sleep(listClockStep)
|
||||
|
||||
beforeUpdate := time.Now()
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("two")), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
afterUpdate := time.Now()
|
||||
|
||||
updated := listedTimes(t, listSecrets(t, c, true))
|
||||
assert.True(t, added.CreatedAt.Equal(updated.CreatedAt),
|
||||
"created %s, then %s", added.CreatedAt, updated.CreatedAt)
|
||||
assert.WithinRange(t, updated.UpdatedAt,
|
||||
beforeUpdate.Add(-fileTimeSlack), afterUpdate)
|
||||
|
||||
// The table's LAST UPDATED column gives the same time, to the minute
|
||||
assert.Contains(t, listSecrets(t, c, false),
|
||||
updated.UpdatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
// TestMovedSecretKeepsItsTimes checks that a secret moved to another vault
|
||||
// is listed there with the times it was listed with before the move, and
|
||||
// not with the time of the move.
|
||||
func TestMovedSecretKeepsItsTimes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
stateDir := t.TempDir()
|
||||
|
||||
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// "default" is created last, so it is the current vault
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
// Two versions written apart, so that the creation and the last update
|
||||
// are different times
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("one")), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(listClockStep)
|
||||
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("two")), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
c.Mnemonic = testMnemonicBuffer(t)
|
||||
beforeMove := listedTimes(t, listSecrets(t, c, true))
|
||||
|
||||
time.Sleep(listClockStep)
|
||||
require.NoError(t, c.MoveSecret(&cobra.Command{}, "default:x", "other", false))
|
||||
require.NoError(t, vault.SelectVault(fs, stateDir, "other"))
|
||||
|
||||
afterMove := listedTimes(t, listSecrets(t, c, true))
|
||||
assert.True(t, beforeMove.CreatedAt.Equal(afterMove.CreatedAt),
|
||||
"created %s, then %s", beforeMove.CreatedAt, afterMove.CreatedAt)
|
||||
assert.True(t, beforeMove.UpdatedAt.Equal(afterMove.UpdatedAt),
|
||||
"updated %s, then %s", beforeMove.UpdatedAt, afterMove.UpdatedAt)
|
||||
}
|
||||
|
||||
// TestListedTimesAreUTC is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/169, where times were printed
|
||||
// in the local time zone. It runs `secret list` on the built binary with
|
||||
// TZ set to New York, where the secret's last update, 03:04:05 UTC on 2
|
||||
// January, is 22:04:05 the day before.
|
||||
func TestListedTimesAreUTC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
vlt, err := vault.CreateVault(
|
||||
afero.NewOsFs(), stateDir, "default", testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("one")), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
current := filepath.Join(stateDir, "vaults.d", "default", "secrets.d", "x",
|
||||
"current")
|
||||
require.NoError(t, os.Chtimes(current, updated, updated))
|
||||
|
||||
tests := []struct {
|
||||
flags []string
|
||||
want string
|
||||
}{
|
||||
{flags: nil, want: "2026-01-02 03:04"},
|
||||
{
|
||||
flags: []string{"--json"},
|
||||
want: `"updated_at": "2026-01-02T03:04:05Z"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
args := append([]string{"list"}, tt.flags...)
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), args...)
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
"TZ=America/New_York",
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "%v: %s", args, output)
|
||||
assert.Contains(t, string(output), tt.want, "%v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// listSecrets returns the output of `secret list`, or of
|
||||
// `secret list --json` when jsonOutput is set.
|
||||
func listSecrets(t *testing.T, c *cli.Instance, jsonOutput bool) string {
|
||||
t.Helper()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&out)
|
||||
require.NoError(t, c.ListSecrets(cmd, jsonOutput, false, ""))
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// listedTimes returns the times that the output of `secret list --json`
|
||||
// gives for the one secret in the vault.
|
||||
func listedTimes(t *testing.T, listing string) secret.Metadata {
|
||||
t.Helper()
|
||||
|
||||
var parsed struct {
|
||||
Secrets []map[string]string `json:"secrets"`
|
||||
}
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(listing), &parsed))
|
||||
require.Len(t, parsed.Secrets, 1)
|
||||
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, parsed.Secrets[0]["created_at"])
|
||||
require.NoError(t, err)
|
||||
|
||||
updatedAt, err := time.Parse(time.RFC3339Nano, parsed.Secrets[0]["updated_at"])
|
||||
require.NoError(t, err)
|
||||
|
||||
return secret.Metadata{CreatedAt: createdAt, UpdatedAt: updatedAt}
|
||||
}
|
||||
+332
-26
@@ -280,13 +280,12 @@ func stateDirModTimes(t *testing.T, fs afero.Fs) map[string]int64 {
|
||||
return modTimes
|
||||
}
|
||||
|
||||
// setupEveryCommand makes what each command in
|
||||
// TestChangingCommandsWaitForLock needs: the current vault "work" with two
|
||||
// versions of "test/secret", the vault "other" without a long-term key, for
|
||||
// vault import, and the file testInput. There is no vault "default", which
|
||||
// init creates. If withUnlocker is set, it also gives "work" a passphrase
|
||||
// unlocker, which is slow. It returns the older version and the unlocker's
|
||||
// ID.
|
||||
// setupEveryCommand makes what the commands that the lock tests below run
|
||||
// need: the current vault "work" with two versions of "test/secret", the
|
||||
// vault "other", and the file testInput.
|
||||
// There is no vault "default", which init creates. If withUnlocker is set,
|
||||
// it also gives "work" a passphrase unlocker, which is slow. It returns the
|
||||
// older version and the unlocker's ID.
|
||||
func setupEveryCommand(
|
||||
t *testing.T, fs afero.Fs, withUnlocker bool,
|
||||
) (string, string) {
|
||||
@@ -294,13 +293,9 @@ func setupEveryCommand(
|
||||
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
||||
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
otherDir, err := other.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
|
||||
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -351,6 +346,25 @@ func waitingForLock() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// requireWaitingForLock returns once a command waits for the state
|
||||
// directory lock. It fails the test if the command finishes first, sending
|
||||
// its error on done, or never waits.
|
||||
func requireWaitingForLock(t *testing.T, done <-chan error) {
|
||||
t.Helper()
|
||||
|
||||
timeout := time.After(lockWait)
|
||||
|
||||
for !waitingForLock() {
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("finished while the lock was held, with error %v", err)
|
||||
case <-timeout:
|
||||
t.Fatal("never waited for the lock")
|
||||
case <-time.After(time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// requireWaitsForLock runs a command, given what setupEveryCommand made,
|
||||
// while holding the state directory lock. The command must neither finish
|
||||
// nor change anything before it waits for the lock, and must succeed once
|
||||
@@ -390,17 +404,7 @@ func requireWaitsForLock(
|
||||
|
||||
go func() { done <- run(cli, olderVersion, unlockerID) }()
|
||||
|
||||
timeout := time.After(lockWait)
|
||||
|
||||
for !waitingForLock() {
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("finished while the lock was held, with error %v", err)
|
||||
case <-timeout:
|
||||
t.Fatal("never waited for the lock")
|
||||
case <-time.After(time.Millisecond):
|
||||
}
|
||||
}
|
||||
requireWaitingForLock(t, done)
|
||||
|
||||
assert.Equal(t, before, stateDirModTimes(t, fs),
|
||||
"changed the state directory before waiting for the lock")
|
||||
@@ -455,9 +459,6 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
|
||||
{"vault select", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.SelectVault(cli.cmd, "other")
|
||||
}},
|
||||
{"vault import", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.VaultImport(cli.cmd, "other")
|
||||
}},
|
||||
{"vault rm --force", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.RemoveVault(cli.cmd, "other", true)
|
||||
}},
|
||||
@@ -480,6 +481,311 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandsAskBeforeLocking is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/152: these commands took the
|
||||
// state directory lock before asking for the mnemonic or a passphrase, so
|
||||
// while one waited at its prompt, every other command that changes anything
|
||||
// waited for it. Each runs here while the test holds the lock, given what
|
||||
// takes it to the last step at which it can ask: the passphrase prompt of
|
||||
// init and vault create, and the unlock of the vault, which asks for the
|
||||
// passphrase of its passphrase unlocker, for the others. The tests have no
|
||||
// terminal, so a prompt fails at once. Each command must get there, and
|
||||
// fail, without waiting for the lock.
|
||||
//
|
||||
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
|
||||
func TestCommandsAskBeforeLocking(t *testing.T) {
|
||||
newTestGPGKey(t)
|
||||
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
wrongPassphrase := memguard.NewBufferFromBytes([]byte("wrong-passphrase"))
|
||||
t.Cleanup(wrongPassphrase.Destroy)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
run func(cli *Instance) error
|
||||
// want is wrapped in the error the command fails with
|
||||
want error
|
||||
}{
|
||||
{"init", func(cli *Instance) error {
|
||||
cli.Mnemonic = mnemonic
|
||||
|
||||
return cli.Init(cli.cmd)
|
||||
}, secret.ErrNoTerminal},
|
||||
{"vault create", func(cli *Instance) error {
|
||||
cli.Mnemonic = mnemonic
|
||||
|
||||
return cli.CreateVault(cli.cmd, "created")
|
||||
}, secret.ErrNoTerminal},
|
||||
// With no terminal to ask for the new passphrase on, the new
|
||||
// unlocker takes the one in SB_UNLOCK_PASSPHRASE, which then fails
|
||||
// to unlock the vault
|
||||
{"unlocker add passphrase", func(cli *Instance) error {
|
||||
cli.UnlockPassphrase = wrongPassphrase
|
||||
|
||||
return cli.UnlockersAdd("passphrase", cli.cmd)
|
||||
}, secret.ErrWrongPassphrase},
|
||||
{"unlocker add pgp", func(cli *Instance) error {
|
||||
cli.cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
||||
|
||||
return cli.UnlockersAdd("pgp", cli.cmd)
|
||||
}, secret.ErrNoTerminal},
|
||||
{"add --force", func(cli *Instance) error {
|
||||
return cli.AddSecret("test/secret", true)
|
||||
}, secret.ErrNoTerminal},
|
||||
{"import --force", func(cli *Instance) error {
|
||||
return cli.ImportSecret(cli.cmd, "test/secret", testInput, true)
|
||||
}, secret.ErrNoTerminal},
|
||||
{"generate secret --force", func(cli *Instance) error {
|
||||
return cli.GenerateSecret(cli.cmd, "test/secret", 16, "base58", true)
|
||||
}, secret.ErrNoTerminal},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
requireAsksBeforeLocking(t, tc.run, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// startWhileLocked makes what setupEveryCommand makes, with the passphrase
|
||||
// unlocker, on fs, an empty in-memory filesystem, takes the state directory
|
||||
// lock there, and starts run, given a CLI instance on fs with "value" on
|
||||
// stdin. It returns the function that releases the lock, which does so only
|
||||
// the first time it is called, and the channel that run's error comes on.
|
||||
// The lock is also released when the test ends, so that later tests can
|
||||
// take it.
|
||||
func startWhileLocked(
|
||||
t *testing.T, fs afero.Fs, run func(cli *Instance) error,
|
||||
) (func(), <-chan error) {
|
||||
t.Helper()
|
||||
|
||||
setupEveryCommand(t, fs, true)
|
||||
|
||||
release, err := vault.LockStateDir(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
release = sync.OnceFunc(release)
|
||||
t.Cleanup(release)
|
||||
|
||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
cli.cmd = &cobra.Command{}
|
||||
cli.cmd.SetIn(strings.NewReader("value"))
|
||||
cli.cmd.SetOut(io.Discard)
|
||||
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() { done <- run(cli) }()
|
||||
|
||||
return release, done
|
||||
}
|
||||
|
||||
// requireAsksBeforeLocking starts run while holding the state directory
|
||||
// lock, as startWhileLocked does. run must fail, with an error wrapping
|
||||
// want, without waiting for the lock.
|
||||
func requireAsksBeforeLocking(
|
||||
t *testing.T, run func(cli *Instance) error, want error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
release, done := startWhileLocked(t, afero.NewMemMapFs(), run)
|
||||
|
||||
timeout := time.After(lockWait)
|
||||
|
||||
for !waitingForLock() {
|
||||
select {
|
||||
case err := <-done:
|
||||
require.ErrorIs(t, err, want)
|
||||
|
||||
return
|
||||
case <-timeout:
|
||||
t.Fatal("neither failed nor waited for the lock")
|
||||
case <-time.After(time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// Let the command go on, so that it releases the lock
|
||||
release()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(lockWait):
|
||||
}
|
||||
|
||||
t.Fatal("waited for the lock before asking")
|
||||
}
|
||||
|
||||
// TestCommandsCheckAgainOnceLocked checks that commands which check what
|
||||
// they change before they ask for anything, and so before they take the
|
||||
// state directory lock, check again once they hold it: another command may
|
||||
// have changed it in the meantime. Each runs while the test holds the lock,
|
||||
// given what it needs to ask for nothing. Once it waits for the lock, the
|
||||
// test makes the change another command could have made, then releases the
|
||||
// lock. The command must fail, and change nothing.
|
||||
//
|
||||
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
|
||||
func TestCommandsCheckAgainOnceLocked(t *testing.T) {
|
||||
fingerprint := newTestGPGKey(t)
|
||||
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
otherMnemonic := memguard.NewBufferFromBytes([]byte("legal winner thank " +
|
||||
"year wave sausage worth useful legal winner thank yellow"))
|
||||
t.Cleanup(otherMnemonic.Destroy)
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(passphrase.Destroy)
|
||||
|
||||
// work returns the vault "work" on fs, opened with the mnemonic
|
||||
work := func(fs afero.Fs) *vault.Vault {
|
||||
vlt := vault.NewVault(fs, testStateDir, "work")
|
||||
vlt.Mnemonic = mnemonic
|
||||
|
||||
return vlt
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
run func(cli *Instance) error
|
||||
// change is what another command changes while run waits
|
||||
change func(fs afero.Fs) error
|
||||
// want is wrapped in the error the command fails with
|
||||
want error
|
||||
}{
|
||||
{
|
||||
"unlocker add, vault removed",
|
||||
func(cli *Instance) error {
|
||||
cli.Mnemonic, cli.UnlockPassphrase = mnemonic, passphrase
|
||||
|
||||
return cli.UnlockersAdd("passphrase", cli.cmd)
|
||||
},
|
||||
func(fs afero.Fs) error {
|
||||
return fs.RemoveAll(testStateDir + "/vaults.d/work")
|
||||
},
|
||||
vault.ErrVaultNotFound,
|
||||
},
|
||||
{
|
||||
"unlocker add, vault created again from another mnemonic",
|
||||
func(cli *Instance) error {
|
||||
cli.Mnemonic, cli.UnlockPassphrase = mnemonic, passphrase
|
||||
|
||||
return cli.UnlockersAdd("passphrase", cli.cmd)
|
||||
},
|
||||
func(fs afero.Fs) error {
|
||||
err := fs.RemoveAll(testStateDir + "/vaults.d/work")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = vault.CreateVault(fs, testStateDir, "work",
|
||||
otherMnemonic, passphrase)
|
||||
|
||||
return err
|
||||
},
|
||||
errVaultCreatedAgain,
|
||||
},
|
||||
{
|
||||
"unlocker add pgp, key added",
|
||||
func(cli *Instance) error {
|
||||
cli.Mnemonic = mnemonic
|
||||
cli.cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
||||
|
||||
return cli.UnlockersAdd("pgp", cli.cmd)
|
||||
},
|
||||
func(fs afero.Fs) error {
|
||||
_, err := secret.CreatePGPUnlocker(work(fs),
|
||||
unreadableTestGPGUserID, fingerprint)
|
||||
|
||||
return err
|
||||
},
|
||||
errGPGKeyAlreadyUnlocker,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
requireChecksAgainOnceLocked(t, tc.run, tc.change, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// requireChecksAgainOnceLocked starts run while holding the state directory
|
||||
// lock, as startWhileLocked does. Once run waits for the lock, change makes
|
||||
// the change that another command could have made in the meantime, and the
|
||||
// lock is released. run must then fail, with an error wrapping want, and
|
||||
// change nothing.
|
||||
func requireChecksAgainOnceLocked(
|
||||
t *testing.T, run func(cli *Instance) error,
|
||||
change func(fs afero.Fs) error, want error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
release, done := startWhileLocked(t, fs, run)
|
||||
|
||||
requireWaitingForLock(t, done)
|
||||
require.NoError(t, change(fs))
|
||||
|
||||
// Left out: taking and releasing the lock writes the lock file
|
||||
lockFile := testStateDir + "/lock"
|
||||
|
||||
before := stateDirModTimes(t, fs)
|
||||
delete(before, lockFile)
|
||||
|
||||
release()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.ErrorIs(t, err, want)
|
||||
case <-time.After(lockWait):
|
||||
t.Fatal("did not finish once the lock was released")
|
||||
}
|
||||
|
||||
after := stateDirModTimes(t, fs)
|
||||
delete(after, lockFile)
|
||||
|
||||
assert.Equal(t, before, after, "changed the state directory")
|
||||
}
|
||||
|
||||
// TestForcedAddReplacesSecretAddedMeanwhile runs a forced add of a secret
|
||||
// that does not exist yet while the test holds the state directory lock.
|
||||
// Once it waits for the lock, the test adds the secret, as another command
|
||||
// could have, and releases the lock. The forced add must replace that
|
||||
// secret, as if it had run after the other command.
|
||||
//
|
||||
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
|
||||
func TestForcedAddReplacesSecretAddedMeanwhile(t *testing.T) {
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
release, done := startWhileLocked(t, fs, func(cli *Instance) error {
|
||||
cli.Mnemonic = mnemonic
|
||||
|
||||
return cli.AddSecret("added", true)
|
||||
})
|
||||
|
||||
requireWaitingForLock(t, done)
|
||||
|
||||
work := vault.NewVault(fs, testStateDir, "work")
|
||||
work.Mnemonic = mnemonic
|
||||
|
||||
other := memguard.NewBufferFromBytes([]byte("other"))
|
||||
t.Cleanup(other.Destroy)
|
||||
|
||||
require.NoError(t, work.AddSecret("added", other, false))
|
||||
|
||||
release()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(lockWait):
|
||||
t.Fatal("did not finish once the lock was released")
|
||||
}
|
||||
|
||||
value, err := work.GetSecret("added")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(value.Destroy)
|
||||
|
||||
assert.Equal(t, "value", string(value.Bytes()))
|
||||
}
|
||||
|
||||
// TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a
|
||||
// key that already exists, which only reads the state directory, finishes
|
||||
// while another command holds the state directory lock.
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// errPermissionDenied is returned by renameFailFs and removeAllFailFs in
|
||||
// place of a rename or removal that succeeds.
|
||||
var errPermissionDenied = errors.New("permission denied")
|
||||
|
||||
// renameFailFs fails every Rename of one path, as a read-only parent
|
||||
// directory does.
|
||||
type renameFailFs struct {
|
||||
afero.Fs
|
||||
|
||||
path string
|
||||
}
|
||||
|
||||
func (f *renameFailFs) Rename(oldname, newname string) error {
|
||||
if oldname == f.path {
|
||||
return errPermissionDenied
|
||||
}
|
||||
|
||||
return f.Fs.Rename(oldname, newname)
|
||||
}
|
||||
|
||||
// removeAllFailFs fails every RemoveAll of an entry of the directory dir,
|
||||
// as a file in it that cannot be deleted does, and records that entry's
|
||||
// path in failed.
|
||||
type removeAllFailFs struct {
|
||||
afero.Fs
|
||||
|
||||
dir string
|
||||
failed string
|
||||
}
|
||||
|
||||
func (f *removeAllFailFs) RemoveAll(path string) error {
|
||||
if filepath.Dir(path) == f.dir {
|
||||
f.failed = path
|
||||
|
||||
return errPermissionDenied
|
||||
}
|
||||
|
||||
return f.Fs.RemoveAll(path)
|
||||
}
|
||||
|
||||
// createCrossVaults creates in fs the vaults "work" and "default", where
|
||||
// "default" holds the secret "x" with the value "value".
|
||||
func createCrossVaults(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
_, err := vault.CreateVault(fs, testStateDir, "work",
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
source, err := vault.CreateVault(fs, testStateDir, "default",
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = source.AddSecret("x",
|
||||
memguard.NewBufferFromBytes([]byte("value")), false)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// requireValue requires that the vault vaultName in fs holds secretName
|
||||
// with the value createCrossVaults stored.
|
||||
func requireValue(t *testing.T, fs afero.Fs, vaultName, secretName string) {
|
||||
t.Helper()
|
||||
|
||||
vlt := vault.NewVault(fs, testStateDir, vaultName)
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
value, err := vlt.GetSecret(secretName)
|
||||
require.NoError(t, err, vaultName+":"+secretName)
|
||||
|
||||
defer value.Destroy()
|
||||
|
||||
require.Equal(t, []byte("value"), value.Bytes())
|
||||
}
|
||||
|
||||
// TestCrossVaultMoveFailsWhenSourceRemains is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/159, where a move between two
|
||||
// vaults whose source could not be removed reported the secret moved and
|
||||
// succeeded. It must fail, and the copy and the source must both be there.
|
||||
// The state directory lock refuses the wrapped filesystem, so the test
|
||||
// calls copySecretAndRemoveSource, which a move between vaults runs once it
|
||||
// holds the lock.
|
||||
func TestCrossVaultMoveFailsWhenSourceRemains(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := afero.NewMemMapFs()
|
||||
createCrossVaults(t, base)
|
||||
|
||||
instance := NewCLIInstanceWithStateDir(&renameFailFs{
|
||||
Fs: base,
|
||||
path: testStateDir + "/vaults.d/default/secrets.d/x",
|
||||
}, testStateDir)
|
||||
|
||||
source := vault.NewVault(instance.fs, testStateDir, "default")
|
||||
source.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
err := instance.copySecretAndRemoveSource(&cobra.Command{}, source, "x",
|
||||
vault.NewVault(instance.fs, testStateDir, "work"), "y", false)
|
||||
require.ErrorIs(t, err, errPermissionDenied)
|
||||
require.ErrorContains(t, err, "secret 'default:x' is copied with all "+
|
||||
"1 version(s) to 'work:y', but the source remains")
|
||||
|
||||
requireValue(t, base, "default", "x")
|
||||
requireValue(t, base, "work", "y")
|
||||
}
|
||||
|
||||
// TestCrossVaultMoveFailsWhenOldFilesRemain makes the removal of the source
|
||||
// fail after the source has been moved out of its vault, when only the
|
||||
// temporary directory it was moved into cannot be deleted. The move must
|
||||
// fail, say that the secret is moved, and name that directory.
|
||||
func TestCrossVaultMoveFailsWhenOldFilesRemain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := afero.NewMemMapFs()
|
||||
createCrossVaults(t, base)
|
||||
|
||||
sourceVaultDir := testStateDir + "/vaults.d/default"
|
||||
fs := &removeAllFailFs{Fs: base, dir: sourceVaultDir}
|
||||
|
||||
instance := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
|
||||
source := vault.NewVault(fs, testStateDir, "default")
|
||||
source.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
err := instance.copySecretAndRemoveSource(&cobra.Command{}, source, "x",
|
||||
vault.NewVault(fs, testStateDir, "work"), "y", false)
|
||||
require.ErrorIs(t, err, errPermissionDenied)
|
||||
require.ErrorContains(t, err, "secret 'default:x' is moved with all "+
|
||||
"1 version(s) to 'work:y', but its old files could not all be deleted")
|
||||
|
||||
// The error names the directory that holds the old files
|
||||
require.NotEmpty(t, fs.failed)
|
||||
require.ErrorContains(t, err, fs.failed)
|
||||
|
||||
oldFiles, err := afero.DirExists(base, filepath.Join(fs.failed, "x"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, oldFiles)
|
||||
|
||||
// The source vault no longer holds the secret
|
||||
remains, err := afero.DirExists(base, sourceVaultDir+"/secrets.d/x")
|
||||
require.NoError(t, err)
|
||||
require.False(t, remains)
|
||||
|
||||
requireValue(t, base, "work", "y")
|
||||
}
|
||||
|
||||
// TestCrossVaultMoveUnlocksBeforeLocking is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/189: a move between two vaults
|
||||
// unlocked them while holding the state directory lock, and unlocking a
|
||||
// vault can ask for its passphrase, so every other command that changes
|
||||
// anything waited while the user answered. Here each vault has a passphrase
|
||||
// unlocker whose passphrase is the vault's name, and the move is given the
|
||||
// passphrase of only one of them. Run while the test holds the lock, it
|
||||
// must fail to unlock the other vault without waiting for the lock.
|
||||
//
|
||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||
func TestCrossVaultMoveUnlocksBeforeLocking(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
createCrossVaults(t, fs)
|
||||
|
||||
vaults, err := vault.ListVaults(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, name := range vaults {
|
||||
vlt := vault.NewVault(fs, testStateDir, name)
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(name))
|
||||
_, err = vlt.CreatePassphraseUnlocker(passphrase)
|
||||
passphrase.Destroy()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// The move is from "default" to "work"
|
||||
for _, tc := range []struct {
|
||||
// failing is the vault that the move cannot unlock, given the
|
||||
// passphrase of the vault named given
|
||||
failing, given string
|
||||
}{
|
||||
{"source", "work"},
|
||||
{"destination", "default"},
|
||||
} {
|
||||
t.Run(tc.failing, func(t *testing.T) {
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(tc.given))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
instance := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
instance.UnlockPassphrase = passphrase
|
||||
|
||||
release, err := vault.LockStateDir(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
// Also frees a waiting move if the test fails, so that it
|
||||
// releases the lock the other tests use
|
||||
defer release()
|
||||
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
done <- instance.MoveSecret(&cobra.Command{},
|
||||
"default:x", "work:y", false)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.ErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||
require.ErrorContains(t, err,
|
||||
"failed to unlock "+tc.failing+" vault")
|
||||
case <-time.After(lockWait):
|
||||
t.Fatal("waited for the lock before unlocking the " +
|
||||
tc.failing + " vault")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossVaultMoveChecksAgainOnceLocked runs a move between vaults, given
|
||||
// the mnemonic, as TestCommandsCheckAgainOnceLocked runs the commands in its
|
||||
// table: once the move waits for the state directory lock, the test removes
|
||||
// the source secret or the destination vault, or creates the destination
|
||||
// vault again from another mnemonic, and the move must then fail and change
|
||||
// nothing.
|
||||
//
|
||||
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
|
||||
func TestCrossVaultMoveChecksAgainOnceLocked(t *testing.T) {
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
otherMnemonic := memguard.NewBufferFromBytes([]byte("legal winner thank " +
|
||||
"year wave sausage worth useful legal winner thank yellow"))
|
||||
t.Cleanup(otherMnemonic.Destroy)
|
||||
|
||||
move := func(cli *Instance) error {
|
||||
cli.Mnemonic = mnemonic
|
||||
|
||||
return cli.MoveSecret(cli.cmd, "work:test/secret", "other:moved", false)
|
||||
}
|
||||
|
||||
t.Run("source removed", func(t *testing.T) {
|
||||
requireChecksAgainOnceLocked(t, move, func(fs afero.Fs) error {
|
||||
return fs.RemoveAll(testStateDir + "/vaults.d/work/secrets.d/test%secret")
|
||||
}, vault.ErrSecretNotFound)
|
||||
})
|
||||
|
||||
t.Run("destination vault removed", func(t *testing.T) {
|
||||
requireChecksAgainOnceLocked(t, move, func(fs afero.Fs) error {
|
||||
return fs.RemoveAll(testStateDir + "/vaults.d/other")
|
||||
}, vault.ErrVaultNotFound)
|
||||
})
|
||||
|
||||
t.Run("destination vault created again from another mnemonic", func(t *testing.T) {
|
||||
// Set by the move, which has finished once
|
||||
// requireChecksAgainOnceLocked returns
|
||||
var moveErr error
|
||||
|
||||
requireChecksAgainOnceLocked(t, func(cli *Instance) error {
|
||||
moveErr = move(cli)
|
||||
|
||||
return moveErr
|
||||
}, func(fs afero.Fs) error {
|
||||
err := fs.RemoveAll(testStateDir + "/vaults.d/other")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = vault.CreateVault(fs, testStateDir, "other", otherMnemonic, nil)
|
||||
|
||||
return err
|
||||
}, errVaultCreatedAgain)
|
||||
|
||||
// secret unlocker add fails with the same error, so its message
|
||||
// must fit a move too
|
||||
require.EqualError(t, moveErr, "vault 'other' was removed and "+
|
||||
"created again with another key while it was being unlocked; "+
|
||||
"nothing was changed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossVaultMoveRefusesExistingSecretBeforeUnlocking checks that a move
|
||||
// between vaults onto a secret that exists, without --force, is refused
|
||||
// before either vault is unlocked, since unlocking can ask for a
|
||||
// passphrase. The vaults have no unlockers and the move is given no
|
||||
// mnemonic, so unlocking either vault fails.
|
||||
func TestCrossVaultMoveRefusesExistingSecretBeforeUnlocking(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
createCrossVaults(t, fs)
|
||||
|
||||
work := vault.NewVault(fs, testStateDir, "work")
|
||||
work.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
err := work.AddSecret("y",
|
||||
memguard.NewBufferFromBytes([]byte("other")), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
instance := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
|
||||
err = instance.MoveSecret(&cobra.Command{}, "default:x", "work:y", false)
|
||||
require.ErrorIs(t, err, vault.ErrSecretExists)
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/creack/pty"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestPassphraseAskedAtTerminalWithPipedStdin is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/144: run at a terminal with stdin
|
||||
// a pipe, as in `echo v2 | secret add s1 --force`, a command that needed
|
||||
// the passphrase failed instead of asking for it. Each command here must ask
|
||||
// on the terminal and succeed once the passphrase is typed there.
|
||||
func TestPassphraseAskedAtTerminalWithPipedStdin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default",
|
||||
testMnemonicBuffer(t), passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("v1"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("s1", value, false))
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
key := secret.IdentityToLockedBuffer(identity)
|
||||
defer key.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("mykey", key, false))
|
||||
|
||||
runAtTerminal(t, stateDir, "v2\n", "add", "s1", "--force")
|
||||
|
||||
vlt.UnlockPassphrase = passphrase
|
||||
|
||||
added, err := vlt.GetSecret("s1")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer added.Destroy()
|
||||
|
||||
assert.Equal(t, "v2", added.String())
|
||||
|
||||
encrypted := runAtTerminal(t, stateDir, "a document\n", "encrypt", "mykey")
|
||||
decrypted := runAtTerminal(t, stateDir, encrypted, "decrypt", "mykey")
|
||||
assert.Equal(t, "a document\n", decrypted)
|
||||
}
|
||||
|
||||
// TestInitWritesNoBlankLineAfterMnemonic runs `secret init 2>log` at a
|
||||
// terminal: the terminal is its controlling terminal and none of stdin,
|
||||
// stdout and stderr. The mnemonic is typed on the terminal and the prompt
|
||||
// ends its line there, so stderr must not get a newline for it as well, a
|
||||
// blank line in the log. pty.Open is described above
|
||||
// TestRemoveIgnoresTerminalOnStdout.
|
||||
func TestInitWritesNoBlankLineAfterMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, secretBinaryPath(t), "init")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
secret.EnvUnlockPassphrase + "=" + testPassphrase,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
|
||||
ptmx, tty, err := pty.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = ptmx.Close() }()
|
||||
|
||||
deadline, _ := ctx.Deadline()
|
||||
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
||||
|
||||
var stderr strings.Builder
|
||||
|
||||
cmd.Stderr = &stderr
|
||||
cmd.ExtraFiles = []*os.File{tty}
|
||||
// Ctty is a descriptor in the new process: 3, the first of ExtraFiles.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 3}
|
||||
|
||||
require.NoError(t, cmd.Start())
|
||||
|
||||
_ = tty.Close()
|
||||
|
||||
var (
|
||||
shown []byte
|
||||
char byte
|
||||
)
|
||||
|
||||
terminal := bufio.NewReader(ptmx)
|
||||
for !bytes.HasSuffix(shown, []byte("Enter your BIP39 mnemonic phrase: ")) {
|
||||
char, err = terminal.ReadByte()
|
||||
require.NoError(t, err, "secret init did not ask on the terminal: %s",
|
||||
shown)
|
||||
|
||||
shown = append(shown, char)
|
||||
}
|
||||
|
||||
_, err = ptmx.WriteString(testMnemonic + "\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// The read ends once the command has exited and so closed the terminal.
|
||||
rest, _ := io.ReadAll(terminal)
|
||||
|
||||
err = cmd.Wait()
|
||||
require.NoError(t, err, "secret init: %s%s%s", shown, rest, stderr.String())
|
||||
|
||||
assert.True(t, strings.HasPrefix(stderr.String(),
|
||||
"Initialized secrets manager at: "+stateDir+"\n\n"+
|
||||
"Default vault created and configured\n"),
|
||||
"stderr: %q", stderr.String())
|
||||
}
|
||||
|
||||
// TestUnlockerAddAsksForNewPassphrase is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/154: with SB_UNLOCK_PASSPHRASE set,
|
||||
// `secret unlocker add passphrase` gave the new unlocker the passphrase in
|
||||
// the variable without asking. At a terminal it must ask for the new
|
||||
// passphrase there, twice, and unlock the vault with the variable, so that
|
||||
// `secret get` then opens the vault with the passphrase typed. pty.Open is
|
||||
// described above TestRemoveIgnoresTerminalOnStdout.
|
||||
func TestUnlockerAddAsksForNewPassphrase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const newPassphrase = "new-passphrase"
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default",
|
||||
testMnemonicBuffer(t), passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("v1"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("s1", value, false))
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, secretBinaryPath(t),
|
||||
"unlocker", "add", "passphrase")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
secret.EnvUnlockPassphrase + "=" + testPassphrase,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
|
||||
ptmx, tty, err := pty.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = ptmx.Close() }()
|
||||
|
||||
deadline, _ := ctx.Deadline()
|
||||
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
||||
|
||||
cmd.Stderr = tty
|
||||
// Ctty is a descriptor in the new process: 2, its stderr.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 2}
|
||||
|
||||
require.NoError(t, cmd.Start())
|
||||
|
||||
_ = tty.Close()
|
||||
|
||||
var (
|
||||
shown []byte
|
||||
char byte
|
||||
)
|
||||
|
||||
terminal := bufio.NewReader(ptmx)
|
||||
|
||||
for _, prompt := range []string{
|
||||
"Enter passphrase for unlocker: ", "Confirm passphrase: ",
|
||||
} {
|
||||
for !bytes.HasSuffix(shown, []byte(prompt)) {
|
||||
char, err = terminal.ReadByte()
|
||||
require.NoError(t, err,
|
||||
"secret unlocker add passphrase did not ask on the terminal: %s",
|
||||
shown)
|
||||
|
||||
shown = append(shown, char)
|
||||
}
|
||||
|
||||
_, err = ptmx.WriteString(newPassphrase + "\n")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// The read ends once the command has exited and so closed the terminal.
|
||||
rest, _ := io.ReadAll(terminal)
|
||||
|
||||
require.NoError(t, cmd.Wait(),
|
||||
"secret unlocker add passphrase: %s%s", shown, rest)
|
||||
|
||||
// The binary, not this test process, opens the vault: the new unlocker
|
||||
// has age's full scrypt work factor, which is slow under the race
|
||||
// detector.
|
||||
assert.Equal(t, "v1",
|
||||
runWithoutTerminal(t, stateDir, newPassphrase, "get", "s1"))
|
||||
}
|
||||
|
||||
// TestUnlockerAddWithoutTerminalSaysPassphraseIsVariable is a regression
|
||||
// test for https://git.eeqj.de/sneak/secret/issues/154: with no terminal to
|
||||
// ask on, `secret unlocker add passphrase` gives the new unlocker the
|
||||
// passphrase in SB_UNLOCK_PASSPHRASE, and its output must say so.
|
||||
func TestUnlockerAddWithoutTerminalSaysPassphraseIsVariable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
_, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default",
|
||||
testMnemonicBuffer(t), passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := runWithoutTerminal(t, stateDir, testPassphrase,
|
||||
"unlocker", "add", "passphrase")
|
||||
|
||||
assert.Contains(t, output, "The new unlocker takes its passphrase "+
|
||||
"from SB_UNLOCK_PASSPHRASE, since there is no terminal to ask for a "+
|
||||
"new one on\n")
|
||||
}
|
||||
|
||||
// runWithoutTerminal runs the secret binary with args and SB_UNLOCK_PASSPHRASE
|
||||
// set to passphrase, in a new session, which has no terminal, and returns
|
||||
// what it printed once it has succeeded.
|
||||
func runWithoutTerminal(
|
||||
t *testing.T, stateDir, passphrase string, args ...string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, secretBinaryPath(t), args...)
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
secret.EnvUnlockPassphrase + "=" + passphrase,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "secret %v: %s", args, output)
|
||||
|
||||
return string(output)
|
||||
}
|
||||
|
||||
// runAtTerminal runs the secret binary with args and input on stdin, in a
|
||||
// new session whose controlling terminal is a new terminal, which is also
|
||||
// its stderr: a command typed at a terminal with its input piped. pty.Open
|
||||
// is described above TestRemoveIgnoresTerminalOnStdout. runAtTerminal waits
|
||||
// for the passphrase prompt on the terminal, types the passphrase there,
|
||||
// and returns what the command wrote to stdout once it has succeeded.
|
||||
func runAtTerminal(t *testing.T, stateDir, input string, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, secretBinaryPath(t), args...)
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
|
||||
ptmx, tty, err := pty.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = ptmx.Close() }()
|
||||
|
||||
deadline, _ := ctx.Deadline()
|
||||
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
||||
|
||||
var stdout strings.Builder
|
||||
|
||||
cmd.Stdin = strings.NewReader(input)
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = tty
|
||||
// Ctty is a descriptor in the new process: 2, its stderr.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 2}
|
||||
|
||||
require.NoError(t, cmd.Start())
|
||||
|
||||
_ = tty.Close()
|
||||
|
||||
var (
|
||||
shown []byte
|
||||
char byte
|
||||
)
|
||||
|
||||
terminal := bufio.NewReader(ptmx)
|
||||
for !bytes.HasSuffix(shown, []byte("Enter unlock passphrase: ")) {
|
||||
char, err = terminal.ReadByte()
|
||||
require.NoError(t, err, "secret %v did not ask on the terminal: %s",
|
||||
args, shown)
|
||||
|
||||
shown = append(shown, char)
|
||||
}
|
||||
|
||||
_, err = ptmx.WriteString(testPassphrase + "\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// The read ends once the command has exited and so closed the terminal.
|
||||
rest, _ := io.ReadAll(terminal)
|
||||
|
||||
require.NoError(t, cmd.Wait(), "secret %v: %s%s", args, shown, rest)
|
||||
|
||||
return stdout.String()
|
||||
}
|
||||
@@ -306,8 +306,7 @@ func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
|
||||
|
||||
// TestInvalidVaultNameLeavesStateUnchanged is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/68, where
|
||||
// `secret vault import ..` wrote a long-term key and an unlocker into the
|
||||
// state directory itself, and `secret vault select ..` made it the current
|
||||
// `secret vault select ..` made the state directory itself the current
|
||||
// vault. Each command that takes a vault name must reject an invalid one
|
||||
// before building a path from it. The instance is given the mnemonic and
|
||||
// the passphrase, and moves and removals use --force, so that only the name
|
||||
@@ -331,9 +330,6 @@ func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
|
||||
{"vault create %q", func(c *cli.Instance, name string) error {
|
||||
return c.CreateVault(cmd, name)
|
||||
}},
|
||||
{"vault import %q", func(c *cli.Instance, name string) error {
|
||||
return c.VaultImport(cmd, name)
|
||||
}},
|
||||
{"vault select %q", func(c *cli.Instance, name string) error {
|
||||
return c.SelectVault(cmd, name)
|
||||
}},
|
||||
|
||||
+41
-8
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -14,17 +15,36 @@ import (
|
||||
// every memguard buffer before it returns, so the caller must do nothing
|
||||
// but exit with the code.
|
||||
func Entry() int {
|
||||
// Every time secret prints or writes is in UTC, whatever TZ or the
|
||||
// system's time zone is set to.
|
||||
time.Local = time.UTC //nolint:gosmopolitan // this makes local time UTC
|
||||
|
||||
raiseLockedMemoryLimit()
|
||||
|
||||
// On SIGINT or SIGTERM memguard runs this function, wipes every buffer
|
||||
// and exits with status 1. The passphrase prompt turns terminal echo
|
||||
// off until the read finishes, so a signal there would leave echo off.
|
||||
// Only a process in the terminal's foreground process group may reset
|
||||
// it: one in the background that tries is stopped instead of exiting.
|
||||
terminalState, terminalErr := term.GetState(unix.Stdin)
|
||||
// and exits with status 1. The passphrase prompt turns echo off on the
|
||||
// controlling terminal until the read finishes, so a signal there would
|
||||
// leave echo off. Only a process in the terminal's foreground process
|
||||
// group may reset it: one in the background that tries is stopped
|
||||
// instead of exiting.
|
||||
terminal, terminalErr := os.Open(secret.TerminalPath)
|
||||
|
||||
var terminalState *term.State
|
||||
|
||||
if terminalErr == nil {
|
||||
defer func() { _ = terminal.Close() }()
|
||||
|
||||
terminalState, terminalErr = term.GetState(int(terminal.Fd()))
|
||||
}
|
||||
|
||||
memguard.CatchSignal(func(os.Signal) {
|
||||
foreground, err := unix.IoctlGetInt(unix.Stdin, unix.TIOCGPGRP)
|
||||
if terminalErr == nil && err == nil && foreground == unix.Getpgrp() {
|
||||
_ = term.Restore(unix.Stdin, terminalState)
|
||||
if terminalErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
foreground, err := unix.IoctlGetInt(int(terminal.Fd()), unix.TIOCGPGRP)
|
||||
if err == nil && foreground == unix.Getpgrp() {
|
||||
_ = term.Restore(int(terminal.Fd()), terminalState)
|
||||
}
|
||||
}, os.Interrupt, unix.SIGTERM)
|
||||
|
||||
@@ -38,6 +58,19 @@ func Entry() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// raiseLockedMemoryLimit raises this process's limit on locked memory, which
|
||||
// holds every secret value, to the hard limit, the most a process may set
|
||||
// without privileges. When that fails the limit stays as it was, and
|
||||
// checkLockedMemoryLimit names it in its error.
|
||||
func raiseLockedMemoryLimit() {
|
||||
var limit unix.Rlimit
|
||||
|
||||
if unix.Getrlimit(unix.RLIMIT_MEMLOCK, &limit) == nil {
|
||||
limit.Cur = limit.Max
|
||||
_ = unix.Setrlimit(unix.RLIMIT_MEMLOCK, &limit)
|
||||
}
|
||||
}
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
secret.Debug("newRootCmd starting")
|
||||
|
||||
|
||||
+360
-176
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/sys/unix"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
@@ -24,26 +25,30 @@ const (
|
||||
// vaultSecretParts is the number of parts when splitting vault:secret
|
||||
vaultSecretParts = 2
|
||||
|
||||
// initialBufferSize is the starting size for secret read buffers (4KB)
|
||||
initialBufferSize = 4 * 1024
|
||||
// maxSecretSize is the maximum allowed size of a secret (100MB)
|
||||
maxSecretSize = 100 * 1024 * 1024
|
||||
|
||||
// lockedMemoryReservePages is the locked memory, in pages, that `secret
|
||||
// add`, `secret import` and `secret generate secret` may need besides
|
||||
// the value they store, for keys and the read buffer: memguard locks
|
||||
// whole pages, at least one for each buffer. `secret add` was measured
|
||||
// to need 7 with both SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE set,
|
||||
// and 6 with one; the eighth is to spare.
|
||||
lockedMemoryReservePages = 8
|
||||
// kibibyte is the unit of the locked-memory limit in `ulimit -l`.
|
||||
kibibyte = 1024
|
||||
)
|
||||
|
||||
// Sentinel errors for secret operations
|
||||
var (
|
||||
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
|
||||
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
|
||||
errLockedMemoryLimit = errors.New(
|
||||
"secret too large for the locked-memory limit")
|
||||
errCrossVaultSourceUnqualified = errors.New(
|
||||
"source must specify vault (e.g., vault:secret) for cross-vault move")
|
||||
errMoveOntoItself = errors.New("cannot be moved onto itself")
|
||||
)
|
||||
|
||||
// bufferInfo tracks a protected buffer and the number of bytes used in it
|
||||
type bufferInfo struct {
|
||||
buffer *memguard.LockedBuffer
|
||||
used int
|
||||
}
|
||||
|
||||
// ParseVaultSecretRef parses a "vault:secret" or just "secret" reference
|
||||
// Returns (vaultName, secretName, isQualified)
|
||||
// If no vault is specified, returns empty vaultName and isQualified=false
|
||||
@@ -75,6 +80,12 @@ func newAddCmd() *cobra.Command {
|
||||
|
||||
cli.cmd = cmd // Set the command for stdin access
|
||||
|
||||
// Before readSecretEnv, which makes the first locked allocation
|
||||
err = checkLockedMemoryLimit(0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
destroySecrets := cli.readSecretEnv()
|
||||
defer destroySecrets()
|
||||
|
||||
@@ -174,6 +185,12 @@ func newImportCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
// Before readSecretEnv, which makes the first locked allocation
|
||||
err = checkLockedMemoryLimit(0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
destroySecrets := cli.readSecretEnv()
|
||||
defer destroySecrets()
|
||||
|
||||
@@ -270,88 +287,105 @@ The source secret is deleted after successful copy.`,
|
||||
return cmd
|
||||
}
|
||||
|
||||
// updateBufferSize updates the buffer size based on usage pattern
|
||||
func updateBufferSize(currentSize int, sameSize *int) int {
|
||||
const (
|
||||
doubleAfterBuffers = 2
|
||||
growthFactor = 2
|
||||
)
|
||||
// checkLockedMemoryLimit returns an error naming the locked-memory limit
|
||||
// (`ulimit -l`) when a value of size bytes does not fit in it with
|
||||
// lockedMemoryReservePages. memguard, which holds secret values in locked
|
||||
// memory, crashes or hangs the process when it cannot lock more, so a
|
||||
// command calls this with size 0 before its first locked allocation, and
|
||||
// again with the value's size before a buffer for the value is allocated.
|
||||
func checkLockedMemoryLimit(size int) error {
|
||||
var limit unix.Rlimit
|
||||
|
||||
*sameSize++
|
||||
if *sameSize >= doubleAfterBuffers {
|
||||
*sameSize = 0
|
||||
|
||||
return currentSize * growthFactor
|
||||
err := unix.Getrlimit(unix.RLIMIT_MEMLOCK, &limit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read the locked-memory limit: %w", err)
|
||||
}
|
||||
|
||||
return currentSize
|
||||
}
|
||||
// memguard locks whole pages, so the value is counted in whole pages.
|
||||
pageSize := os.Getpagesize()
|
||||
valuePages := (size + pageSize - 1) / pageSize
|
||||
|
||||
// destroyBuffers destroys every buffer in the list
|
||||
func destroyBuffers(buffers []bufferInfo) {
|
||||
for _, b := range buffers {
|
||||
b.buffer.Destroy()
|
||||
//nolint:gosec // G115: a page count and a page size are never negative
|
||||
needed := uint64((valuePages + lockedMemoryReservePages) * pageSize)
|
||||
if needed > limit.Cur {
|
||||
return fmt.Errorf("%w of %d KiB (ulimit -l)",
|
||||
errLockedMemoryLimit, limit.Cur/kibibyte)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readSecretFromReader reads all data from reader into protected buffers,
|
||||
// enforcing the maximum secret size. On failure the accumulated buffers
|
||||
// are destroyed; on success the caller must destroy them.
|
||||
func readSecretFromReader(reader io.Reader) ([]bufferInfo, int, error) {
|
||||
var buffers []bufferInfo
|
||||
// readSecretFromReader reads all of reader into a new locked buffer, which
|
||||
// the caller must destroy, leaving out one trailing newline when trimNewline
|
||||
// is set. It reads a page at a time into a memguard.Stream, which keeps what
|
||||
// it is given encrypted, so that the value is in locked memory only once, in
|
||||
// the buffer returned, and both size checks come before that buffer is
|
||||
// allocated. It takes the value back out with Stream.Next, not Stream.Read:
|
||||
// Read puts back the part of a piece that does not fit, and memguard hangs
|
||||
// when it runs out of locked memory doing that.
|
||||
func readSecretFromReader(
|
||||
reader io.Reader, trimNewline bool,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
stream := memguard.NewStream()
|
||||
|
||||
totalSize := 0
|
||||
currentBufferSize := initialBufferSize
|
||||
sameSize := 0
|
||||
chunk := memguard.NewBuffer(os.Getpagesize())
|
||||
defer chunk.Destroy()
|
||||
|
||||
size := 0
|
||||
endsWithNewline := false
|
||||
|
||||
for {
|
||||
// Create a new buffer
|
||||
buffer := memguard.NewBuffer(currentBufferSize)
|
||||
n, err := io.ReadFull(reader, buffer.Bytes())
|
||||
|
||||
if n == 0 {
|
||||
// No data read, destroy the unused buffer
|
||||
buffer.Destroy()
|
||||
} else {
|
||||
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
||||
totalSize += n
|
||||
|
||||
if totalSize > maxSecretSize {
|
||||
destroyBuffers(buffers)
|
||||
|
||||
return nil, 0, errSecretTooLarge
|
||||
n, err := io.ReadFull(reader, chunk.Bytes())
|
||||
if n > 0 {
|
||||
size += n
|
||||
if size > maxSecretSize {
|
||||
return nil, errSecretTooLarge
|
||||
}
|
||||
|
||||
// If we filled the buffer, consider growing for next iteration
|
||||
if n == currentBufferSize {
|
||||
currentBufferSize = updateBufferSize(currentBufferSize, &sameSize)
|
||||
}
|
||||
endsWithNewline = chunk.Bytes()[n-1] == '\n'
|
||||
|
||||
// Write encrypts the bytes and wipes them from chunk.
|
||||
_, _ = stream.Write(chunk.Bytes()[:n])
|
||||
}
|
||||
|
||||
if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
break
|
||||
} else if err != nil {
|
||||
destroyBuffers(buffers)
|
||||
}
|
||||
|
||||
return nil, 0, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return buffers, totalSize, nil
|
||||
}
|
||||
// Freed before the value's buffer is allocated, so that the two are
|
||||
// never locked at once.
|
||||
chunk.Destroy()
|
||||
|
||||
// combineBuffers copies the used portions of buffers into a single
|
||||
// protected buffer of totalSize bytes
|
||||
func combineBuffers(buffers []bufferInfo, totalSize int) *memguard.LockedBuffer {
|
||||
valueBuffer := memguard.NewBuffer(totalSize)
|
||||
|
||||
offset := 0
|
||||
for _, b := range buffers {
|
||||
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||
offset += b.used
|
||||
if trimNewline && endsWithNewline {
|
||||
size--
|
||||
}
|
||||
|
||||
return valueBuffer
|
||||
err := checkLockedMemoryLimit(size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
value := memguard.NewBuffer(size)
|
||||
|
||||
// copy stops at the value's size, so a trimmed newline is not copied.
|
||||
for offset := 0; offset < size; {
|
||||
decrypted, err := stream.Next()
|
||||
if err != nil {
|
||||
value.Destroy()
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt the value read: %w", err)
|
||||
}
|
||||
|
||||
offset += copy(value.Bytes()[offset:], decrypted.Bytes())
|
||||
decrypted.Destroy()
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// AddSecret adds a secret to the current vault
|
||||
@@ -370,58 +404,110 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
|
||||
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
||||
|
||||
// Read secret value directly into protected buffers
|
||||
secret.Debug("Reading secret value from stdin into protected buffers")
|
||||
// Read the secret value, less a trailing newline, into locked memory
|
||||
secret.Debug("Reading secret value from stdin into a protected buffer")
|
||||
|
||||
buffers, totalSize, err := readSecretFromReader(cli.cmd.InOrStdin())
|
||||
valueBuffer, err := readSecretFromReader(cli.cmd.InOrStdin(), true)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSecretTooLarge) {
|
||||
if errors.Is(err, errSecretTooLarge) || errors.Is(err, errLockedMemoryLimit) {
|
||||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to read secret value: %w", err)
|
||||
}
|
||||
defer destroyBuffers(buffers)
|
||||
|
||||
// Check for trailing newline in the last buffer
|
||||
if len(buffers) > 0 && totalSize > 0 {
|
||||
lastBuffer := &buffers[len(buffers)-1]
|
||||
if lastBuffer.buffer.Bytes()[lastBuffer.used-1] == '\n' {
|
||||
lastBuffer.used--
|
||||
totalSize--
|
||||
}
|
||||
}
|
||||
|
||||
secret.Debug("Read secret value from stdin",
|
||||
"value_length", totalSize, "buffers", len(buffers))
|
||||
|
||||
// Combine all buffers into a single protected buffer
|
||||
valueBuffer := combineBuffers(buffers, totalSize)
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
secret.Debug("Read secret value from stdin", "value_length", valueBuffer.Size())
|
||||
|
||||
// Locked only now that stdin has been read: in `secret encrypt key |
|
||||
// secret add name`, holding the lock while reading would leave each
|
||||
// command waiting for the other.
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
return cli.storeSecret(vlt, secretName, valueBuffer, force)
|
||||
}
|
||||
|
||||
// storeSecret adds the secret name with value to vlt, as vault.AddSecret
|
||||
// does, holding the state directory lock while it does. With force, adding
|
||||
// an existing secret replaces its current version, which needs the vault's
|
||||
// long-term key. Getting the key can ask for the passphrase, so storeSecret
|
||||
// gets it before it takes the lock, and no other command waits while the
|
||||
// user answers. If another command adds the secret while this one waits for
|
||||
// the lock, storeSecret lets go of the lock, gets the key, and takes the
|
||||
// lock again.
|
||||
func (cli *Instance) storeSecret(
|
||||
vlt *vault.Vault, name string, value *memguard.LockedBuffer, force bool,
|
||||
) error {
|
||||
err := vault.ValidateSecretName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
replacing, err := replacesVersion(vlt, name, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if replacing {
|
||||
_, err = vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
needsKey, err := cli.addWithoutAsking(vlt, name, value, force)
|
||||
if !needsKey {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// With the key in memory, this adds the secret
|
||||
_, err = cli.addWithoutAsking(vlt, name, value, force)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// addWithoutAsking takes the state directory lock and adds the secret name
|
||||
// with value to vlt, as vault.AddSecret does. If that would replace the
|
||||
// secret's current version while the vault's long-term key is not in
|
||||
// memory, getting the key could ask for the passphrase with the lock held:
|
||||
// addWithoutAsking then adds nothing and returns true.
|
||||
func (cli *Instance) addWithoutAsking(
|
||||
vlt *vault.Vault, name string, value *memguard.LockedBuffer, force bool,
|
||||
) (bool, error) {
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Add the secret to the vault
|
||||
secret.Debug("Calling vault.AddSecret", "secret_name", secretName,
|
||||
"value_length", valueBuffer.Size(), "force", force)
|
||||
|
||||
err = vlt.AddSecret(secretName, valueBuffer, force)
|
||||
replacing, err := replacesVersion(vlt, name, force)
|
||||
if err != nil {
|
||||
secret.Debug("vault.AddSecret failed", "error", err)
|
||||
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
secret.Debug("vault.AddSecret completed successfully")
|
||||
if replacing && vlt.Locked() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
return false, vlt.AddSecret(name, value, force)
|
||||
}
|
||||
|
||||
// replacesVersion reports whether adding the secret name to vlt replaces
|
||||
// the secret's current version: whether force is set and the secret has one.
|
||||
func replacesVersion(vlt *vault.Vault, name string, force bool) (bool, error) {
|
||||
if !force {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
exists, err := secret.NewSecret(vlt, name).Exists()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// GetSecret retrieves and prints the current version of a secret
|
||||
@@ -443,7 +529,10 @@ func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
||||
defer value.Destroy()
|
||||
|
||||
// Write the value straight from locked memory, with no trailing newline
|
||||
_, _ = cmd.OutOrStdout().Write(value.Bytes())
|
||||
_, err = cmd.OutOrStdout().Write(value.Bytes())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write secret value: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -478,7 +567,10 @@ func (cli *Instance) GetSecretWithVersion(
|
||||
secret.Debug("Got secret value", "valueLength", value.Size())
|
||||
|
||||
// Write the value straight from locked memory, with no trailing newline
|
||||
_, _ = cmd.OutOrStdout().Write(value.Bytes())
|
||||
_, err = cmd.OutOrStdout().Write(value.Bytes())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write secret value: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Printed value to stdout")
|
||||
|
||||
@@ -541,8 +633,12 @@ func printSecretsJSON(
|
||||
"name": secretName,
|
||||
}
|
||||
|
||||
// Try to get metadata using GetSecretObject
|
||||
// A secret whose times cannot be read is listed without them
|
||||
secretObj, err := vlt.GetSecretObject(secretName)
|
||||
if err == nil {
|
||||
err = secretObj.LoadMetadata()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
secretInfo["created_at"] = metadata.CreatedAt
|
||||
@@ -618,6 +714,10 @@ func printSecretsTable(
|
||||
lastUpdated := "unknown"
|
||||
|
||||
secretObj, err := vlt.GetSecretObject(secretName)
|
||||
if err == nil {
|
||||
err = secretObj.LoadMetadata()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
||||
@@ -653,7 +753,7 @@ func (cli *Instance) ImportSecret(
|
||||
|
||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
// Read secret value from the source file into protected buffers
|
||||
// Read secret value from the source file into a protected buffer
|
||||
file, err := cli.fs.Open(sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file %s: %w", sourceFile, err)
|
||||
@@ -665,26 +765,15 @@ func (cli *Instance) ImportSecret(
|
||||
}
|
||||
}()
|
||||
|
||||
buffers, totalSize, err := readSecretFromReader(file)
|
||||
valueBuffer, err := readSecretFromReader(file, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
||||
}
|
||||
defer destroyBuffers(buffers)
|
||||
|
||||
// Combine all buffers into a single protected buffer
|
||||
valueBuffer := combineBuffers(buffers, totalSize)
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
// Locked only now that the file has been read, as in AddSecret: the
|
||||
// file may be a pipe written by another secret command.
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Store the secret in the vault
|
||||
err = vlt.AddSecret(secretName, valueBuffer, force)
|
||||
err = cli.storeSecret(vlt, secretName, valueBuffer, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -786,24 +875,12 @@ func (cli *Instance) findSecretToRemove(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MoveSecret moves or renames a secret (within or across vaults), holding
|
||||
// the state directory lock.
|
||||
// MoveSecret moves or renames a secret (within or across vaults). It
|
||||
// leaves taking the state directory lock to moveSecretWithinVault and
|
||||
// moveSecretCrossVault, since a move between vaults unlocks both vaults
|
||||
// before it takes the lock.
|
||||
func (cli *Instance) MoveSecret(
|
||||
cmd *cobra.Command, source, dest string, force bool,
|
||||
) error {
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
return cli.moveSecret(cmd, source, dest, force)
|
||||
}
|
||||
|
||||
// moveSecret does the work of MoveSecret. The caller holds the state
|
||||
// directory lock.
|
||||
func (cli *Instance) moveSecret(
|
||||
cmd *cobra.Command, source, dest string, force bool,
|
||||
) error {
|
||||
// Parse source and destination
|
||||
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
|
||||
@@ -903,8 +980,9 @@ func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
|
||||
return vault.NewVault(cli.fs, cli.stateDir, name), nil
|
||||
}
|
||||
|
||||
// moveSecretWithinVault renames a secret within the vault vlt. Its caller,
|
||||
// MoveSecret, has already checked both secret names.
|
||||
// moveSecretWithinVault renames a secret within the vault vlt, holding the
|
||||
// state directory lock. Its caller, MoveSecret, has already checked both
|
||||
// secret names.
|
||||
func (cli *Instance) moveSecretWithinVault(
|
||||
cmd *cobra.Command, vlt *vault.Vault, source, dest string, force bool,
|
||||
) error {
|
||||
@@ -914,6 +992,12 @@ func (cli *Instance) moveSecretWithinVault(
|
||||
return fmt.Errorf("secret '%s' %w", source, errMoveOntoItself)
|
||||
}
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1000,59 +1084,75 @@ func (cli *Instance) sameDirectory(dir, other string) (bool, error) {
|
||||
|
||||
// moveSecretCrossVault handles moving between two different vaults. Its
|
||||
// caller, MoveSecret, has already checked both secret names and that both
|
||||
// vaults exist.
|
||||
// vaults exist. The copy needs the source vault's long-term key and is
|
||||
// encrypted to the destination vault's long-term public key. The
|
||||
// destination vault is unlocked to check that the copy will open with what
|
||||
// the user gave, and, once the state directory lock is held, that it still
|
||||
// has the key unlocked. Either unlock can ask for a passphrase, so both
|
||||
// happen before the lock is taken, and no other command waits while the
|
||||
// user answers.
|
||||
func (cli *Instance) moveSecretCrossVault(
|
||||
cmd *cobra.Command,
|
||||
srcVault *vault.Vault, srcSecretName string,
|
||||
destVault *vault.Vault, destSecretName string,
|
||||
force bool,
|
||||
) error {
|
||||
srcVaultDir, err := srcVault.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get source vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Verify source secret exists
|
||||
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
|
||||
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
|
||||
|
||||
exists, err := afero.DirExists(cli.fs, srcSecretDir)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("secret '%s' %w in vault '%s'",
|
||||
srcSecretName, vault.ErrSecretNotFound, srcVault.Name)
|
||||
}
|
||||
|
||||
// The source is removed after the copy, so a destination that is the
|
||||
// source under another name would be lost with it.
|
||||
destVaultDir, err := destVault.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||
}
|
||||
|
||||
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
||||
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
||||
|
||||
same, err := cli.sameDirectory(srcSecretDir, destSecretDir)
|
||||
// Before unlocking, check that the source secret exists, that the
|
||||
// destination is not the source under another name, and, without
|
||||
// --force, that the destination secret does not exist
|
||||
_, err := cli.checkCrossVaultMove(
|
||||
srcVault, srcSecretName, destVault, destSecretName, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if same {
|
||||
return fmt.Errorf("secret '%s:%s' %w: '%s:%s' is the same secret on "+
|
||||
"this filesystem", srcVault.Name, srcSecretName, errMoveOntoItself,
|
||||
destVault.Name, destSecretName)
|
||||
}
|
||||
|
||||
// Copying needs the long-term keys of both vaults
|
||||
srcVault.Mnemonic, srcVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
destVault.Mnemonic, destVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
||||
_, err = destVault.GetOrDeriveLongTermKey()
|
||||
destIdentity, err := destVault.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unlock destination vault '%s': %w", destVault.Name, err)
|
||||
}
|
||||
|
||||
_, err = srcVault.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unlock source vault '%s': %w",
|
||||
srcVault.Name, err)
|
||||
}
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
err = cli.checkLongTermKey(destVault, destIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return cli.copySecretAndRemoveSource(
|
||||
cmd, srcVault, srcSecretName, destVault, destSecretName, force)
|
||||
}
|
||||
|
||||
// copySecretAndRemoveSource copies the secret srcSecretName of srcVault,
|
||||
// with all its versions, to destSecretName in destVault, and removes the
|
||||
// source. moveSecretCrossVault calls it holding the state directory lock.
|
||||
// Another command may have changed either vault since moveSecretCrossVault
|
||||
// checked them, so it checks them again first.
|
||||
func (cli *Instance) copySecretAndRemoveSource(
|
||||
cmd *cobra.Command,
|
||||
srcVault *vault.Vault, srcSecretName string,
|
||||
destVault *vault.Vault, destSecretName string,
|
||||
force bool,
|
||||
) error {
|
||||
srcSecretDir, err := cli.checkCrossVaultMove(
|
||||
srcVault, srcSecretName, destVault, destSecretName, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Count versions for user feedback
|
||||
versions, _ := secret.ListVersions(cli.fs, srcSecretDir)
|
||||
versionCount := len(versions)
|
||||
@@ -1067,12 +1167,9 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
// Delete source secret
|
||||
err = secret.RemoveDirAtomic(cli.fs, srcSecretDir)
|
||||
if err != nil {
|
||||
// Copy succeeded but delete failed - warn but don't fail
|
||||
cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err)
|
||||
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
||||
srcVault.Name, srcSecretName, destVault.Name, destSecretName, versionCount)
|
||||
|
||||
return nil
|
||||
return cli.sourceRemovalError(err, srcSecretDir,
|
||||
srcVault.Name+":"+srcSecretName, destVault.Name+":"+destSecretName,
|
||||
versionCount)
|
||||
}
|
||||
|
||||
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
||||
@@ -1080,3 +1177,90 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCrossVaultMove checks that the secret srcSecretName exists in
|
||||
// srcVault, that destSecretName in destVault is not the source under
|
||||
// another name, and, unless force is set, that destSecretName does not
|
||||
// exist in destVault. It returns the source's directory.
|
||||
func (cli *Instance) checkCrossVaultMove(
|
||||
srcVault *vault.Vault, srcSecretName string,
|
||||
destVault *vault.Vault, destSecretName string,
|
||||
force bool,
|
||||
) (string, error) {
|
||||
srcVaultDir, err := srcVault.GetDirectory()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get source vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Verify source secret exists
|
||||
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
|
||||
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
|
||||
|
||||
exists, err := afero.DirExists(cli.fs, srcSecretDir)
|
||||
if err != nil || !exists {
|
||||
return "", fmt.Errorf("secret '%s' %w in vault '%s'",
|
||||
srcSecretName, vault.ErrSecretNotFound, srcVault.Name)
|
||||
}
|
||||
|
||||
// The source is removed after the copy, so a destination that is the
|
||||
// source under another name would be lost with it.
|
||||
destVaultDir, err := destVault.GetDirectory()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||
}
|
||||
|
||||
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
||||
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
||||
|
||||
same, err := cli.sameDirectory(srcSecretDir, destSecretDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if same {
|
||||
return "", fmt.Errorf("secret '%s:%s' %w: '%s:%s' is the same secret "+
|
||||
"on this filesystem", srcVault.Name, srcSecretName, errMoveOntoItself,
|
||||
destVault.Name, destSecretName)
|
||||
}
|
||||
|
||||
exists, err = afero.DirExists(cli.fs, destSecretDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check if destination secret exists: %w", err)
|
||||
}
|
||||
|
||||
if exists && !force {
|
||||
return "", fmt.Errorf(
|
||||
"secret '%s' %w in vault '%s' (use --force to overwrite)",
|
||||
destSecretName, vault.ErrSecretExists, destVault.Name)
|
||||
}
|
||||
|
||||
return srcSecretDir, nil
|
||||
}
|
||||
|
||||
// sourceRemovalError returns the error of a move between vaults that
|
||||
// copied all versionCount versions of the secret srcName to destName, and
|
||||
// then failed with removeErr to remove srcSecretDir, the directory of
|
||||
// srcName. The removal can fail after the source has left its vault, when
|
||||
// only the temporary directory it was moved into cannot be deleted, so the
|
||||
// error says the source remains only if srcSecretDir is still there.
|
||||
func (cli *Instance) sourceRemovalError(
|
||||
removeErr error, srcSecretDir, srcName, destName string, versionCount int,
|
||||
) error {
|
||||
remains, err := afero.DirExists(cli.fs, srcSecretDir)
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("secret '%s' is copied with all %d version(s) "+
|
||||
"to '%s', but removing the source failed and whether it "+
|
||||
"remains is unknown: %w",
|
||||
srcName, versionCount, destName, errors.Join(removeErr, err))
|
||||
case remains:
|
||||
return fmt.Errorf("secret '%s' is copied with all %d version(s) "+
|
||||
"to '%s', but the source remains: %w",
|
||||
srcName, versionCount, destName, removeErr)
|
||||
default:
|
||||
return fmt.Errorf("secret '%s' is moved with all %d version(s) "+
|
||||
"to '%s', but its old files could not all be deleted: %w",
|
||||
srcName, versionCount, destName, removeErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,23 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
@@ -30,17 +36,11 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create vault
|
||||
// Create vault, which selects it as the current vault
|
||||
_, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
||||
vaultPath := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get vault and set up long-term key
|
||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
@@ -208,23 +208,25 @@ func TestImportSecretVariousSizes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
||||
// TestAddSecretAroundPageSize tests that secret add, which reads its input
|
||||
// one memory page (usually 4 KiB) at a time, stores values just under, at and
|
||||
// just over one and two pages unchanged.
|
||||
//
|
||||
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
|
||||
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||
// Test various sizes that should trigger buffer growth
|
||||
func TestAddSecretAroundPageSize(t *testing.T) {
|
||||
// Sizes around one and two pages of 4 KiB, then larger ones up to 1 MiB
|
||||
sizes := []int{
|
||||
1, // Single byte
|
||||
100, // Small
|
||||
4095, // Just under initial 4KB
|
||||
4095, // Just under 4KB
|
||||
4096, // Exactly 4KB
|
||||
4097, // Just over 4KB
|
||||
8191, // Just under 8KB (first double)
|
||||
8191, // Just under 8KB
|
||||
8192, // Exactly 8KB
|
||||
8193, // Just over 8KB
|
||||
12288, // 12KB (should trigger second double)
|
||||
12288, // 12KB
|
||||
16384, // 16KB
|
||||
32768, // 32KB (after more doublings)
|
||||
32768, // 32KB
|
||||
65536, // 64KB
|
||||
131072, // 128KB
|
||||
524288, // 512KB
|
||||
@@ -315,6 +317,144 @@ func TestAddSecretStreamingBehavior(t *testing.T) {
|
||||
"Retrieved secret should match original")
|
||||
}
|
||||
|
||||
// newBinaryTestStateDir returns a new state directory holding a vault made
|
||||
// from the test mnemonic, for the secret binary to use.
|
||||
func newBinaryTestStateDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
_, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default",
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
return stateDir
|
||||
}
|
||||
|
||||
// runAtLockedMemoryLimit runs the secret binary with args, stdin, the state
|
||||
// directory stateDir and the test mnemonic, its locked-memory limit (ulimit
|
||||
// -l) lowered to limitKiB, and returns what it printed.
|
||||
func runAtLockedMemoryLimit(
|
||||
t *testing.T, stateDir string, limitKiB int, stdin io.Reader,
|
||||
args ...string,
|
||||
) (string, error) {
|
||||
t.Helper()
|
||||
|
||||
const commandWait = 10 * time.Second
|
||||
|
||||
secretPath, err := filepath.Abs("../../secret")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
// The shell lowers the limit for itself and the binary only.
|
||||
script := fmt.Sprintf(`ulimit -l %d && exec "$0" "$@"`, limitKiB)
|
||||
|
||||
//nolint:gosec // G204: runs the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, "sh",
|
||||
append([]string{"-c", script, secretPath}, args...)...)
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
}
|
||||
cmd.Stdin = stdin
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
// requireRefusedAtLockedMemoryLimit runs the secret binary as
|
||||
// runAtLockedMemoryLimit does, and requires it to exit with status 1 and
|
||||
// print wantError, with no panic.
|
||||
func requireRefusedAtLockedMemoryLimit(
|
||||
t *testing.T, stateDir string, limitKiB int, wantError string,
|
||||
stdin io.Reader, args ...string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
output, err := runAtLockedMemoryLimit(t, stateDir, limitKiB, stdin, args...)
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
|
||||
require.ErrorAs(t, err, &exitErr, "%v: %s", args, output)
|
||||
assert.Equal(t, 1, exitErr.ExitCode(), "%v: %s", args, output)
|
||||
assert.Contains(t, output, wantError, args)
|
||||
assert.NotContains(t, output, "panic", args)
|
||||
}
|
||||
|
||||
// TestSecretsAtLockedMemoryLimit runs the secret binary with the
|
||||
// locked-memory limit (ulimit -l) lowered to 1 MiB, where memguard would crash
|
||||
// the process with a panic instead of returning an error. add must store the
|
||||
// largest value that fits, which takes holding it in locked memory only once.
|
||||
// add, import and generate secret must refuse a value one byte larger with
|
||||
// exit status 1 and an error saying why.
|
||||
func TestSecretsAtLockedMemoryLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limitKiB = 1024
|
||||
|
||||
fits := limitKiB*kibibyte - lockedMemoryReservePages*os.Getpagesize()
|
||||
limitError := fmt.Sprintf("locked-memory limit of %d KiB", limitKiB)
|
||||
stateDir := newBinaryTestStateDir(t)
|
||||
|
||||
tooBigFile := filepath.Join(t.TempDir(), "too-big")
|
||||
require.NoError(t, os.WriteFile(tooBigFile, make([]byte, fits+1), 0o600))
|
||||
|
||||
output, err := runAtLockedMemoryLimit(t, stateDir, limitKiB,
|
||||
bytes.NewReader(make([]byte, fits)), "add", "fits")
|
||||
require.NoError(t, err, "the largest value that fits: %s", output)
|
||||
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, limitKiB, limitError,
|
||||
bytes.NewReader(make([]byte, fits+1)), "add", "too-big")
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, limitKiB, limitError,
|
||||
nil, "import", "too-big", "--source", tooBigFile)
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, limitKiB, limitError,
|
||||
nil, "generate", "secret", "too-big", "--length", strconv.Itoa(fits+1))
|
||||
}
|
||||
|
||||
// TestSecretsUnderSmallLockedMemoryLimits runs add, import and generate
|
||||
// secret with a short value under two small locked-memory limits (ulimit
|
||||
// -l). Under 20 KiB, too little for the buffers they need besides the value,
|
||||
// each must refuse it with exit status 1 and an error naming the limit, where
|
||||
// memguard would panic or hang. Under 64 KiB, the default of older Linux
|
||||
// kernels, each must store it.
|
||||
func TestSecretsUnderSmallLockedMemoryLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
tooSmallLimitKiB = 20
|
||||
oldDefaultLimitKiB = 64
|
||||
shortValue = "hunter2"
|
||||
)
|
||||
|
||||
tooSmallLimitError := fmt.Sprintf("locked-memory limit of %d KiB",
|
||||
tooSmallLimitKiB)
|
||||
stateDir := newBinaryTestStateDir(t)
|
||||
|
||||
shortFile := filepath.Join(t.TempDir(), "short")
|
||||
require.NoError(t, os.WriteFile(shortFile, []byte(shortValue), 0o600))
|
||||
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, tooSmallLimitKiB,
|
||||
tooSmallLimitError, strings.NewReader(shortValue), "add", "added")
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, tooSmallLimitKiB,
|
||||
tooSmallLimitError, nil, "import", "imported", "--source", shortFile)
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, tooSmallLimitKiB,
|
||||
tooSmallLimitError, nil, "generate", "secret", "generated")
|
||||
|
||||
output, err := runAtLockedMemoryLimit(t, stateDir, oldDefaultLimitKiB,
|
||||
strings.NewReader(shortValue), "add", "added")
|
||||
require.NoError(t, err, "add: %s", output)
|
||||
|
||||
output, err = runAtLockedMemoryLimit(t, stateDir, oldDefaultLimitKiB,
|
||||
nil, "import", "imported", "--source", shortFile)
|
||||
require.NoError(t, err, "import: %s", output)
|
||||
|
||||
output, err = runAtLockedMemoryLimit(t, stateDir, oldDefaultLimitKiB,
|
||||
nil, "generate", "secret", "generated")
|
||||
require.NoError(t, err, "generate secret: %s", output)
|
||||
}
|
||||
|
||||
// slowReader simulates a reader that returns data in small chunks
|
||||
type slowReader struct {
|
||||
data []byte
|
||||
|
||||
@@ -2,6 +2,7 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/bip39"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
@@ -90,3 +92,66 @@ func TestGetCommandOutputsToStdout(t *testing.T) {
|
||||
// Nothing should be in stderr
|
||||
assert.Empty(t, stderr.String(), "stderr should be empty")
|
||||
}
|
||||
|
||||
// TestMnemonicAndListingsOutputToStdout tests that 'secret generate
|
||||
// mnemonic', 'secret vault list' and 'secret unlocker list' print their
|
||||
// output to stdout, so that it can be piped
|
||||
func TestMnemonicAndListingsOutputToStdout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
secretPath := secretBinaryPath(t)
|
||||
|
||||
// run runs the secret binary and returns what it wrote to stdout and to
|
||||
// stderr
|
||||
run := func(args ...string) (string, string) {
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(t.Context(), secretPath, args...)
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
secret.EnvUnlockPassphrase + "=test-passphrase",
|
||||
"PATH=/usr/bin:/bin",
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
require.NoError(t, err, "%v should succeed: %s", args, stderr.String())
|
||||
|
||||
return stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
stdout, stderr := run("generate", "mnemonic")
|
||||
assert.True(t, bip39.IsMnemonicValid(strings.TrimSpace(stdout)),
|
||||
"stdout should hold the mnemonic, not %q", stdout)
|
||||
assert.Contains(t, stderr, "Save this mnemonic phrase securely",
|
||||
"the warning should be on stderr")
|
||||
|
||||
run("init")
|
||||
|
||||
stdout, _ = run("vault", "list", "--json")
|
||||
|
||||
var vaults map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &vaults),
|
||||
"stdout should hold the vaults as JSON, not %q", stdout)
|
||||
assert.Equal(t, "default", vaults["currentVault"])
|
||||
|
||||
stdout, _ = run("unlocker", "list", "--json")
|
||||
|
||||
var unlockers map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &unlockers),
|
||||
"stdout should hold the unlockers as JSON, not %q", stdout)
|
||||
assert.Len(t, unlockers["unlockers"], 1)
|
||||
|
||||
stdout, _ = run("vault", "list")
|
||||
assert.Contains(t, stdout, "default (current)")
|
||||
|
||||
stdout, _ = run("unlocker", "list")
|
||||
assert.Contains(t, stdout, "Total: 1 unlocker(s)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAddDropsOneTrailingNewlineImportKeepsIt pins the rule the README
|
||||
// gives: `secret add` drops one trailing newline from stdin, and
|
||||
// `secret import` stores the file's bytes unchanged.
|
||||
func TestAddDropsOneTrailingNewlineImportKeepsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs, vlt := newSizeTestVault(t)
|
||||
value := []byte("a\n\n")
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(bytes.NewReader(value))
|
||||
|
||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
cli.cmd = cmd
|
||||
|
||||
require.NoError(t, cli.AddSecret("added", false))
|
||||
|
||||
sourceFile := "/test/value"
|
||||
require.NoError(t, afero.WriteFile(fs, sourceFile, value, 0o600))
|
||||
require.NoError(t, cli.ImportSecret(cmd, "imported", sourceFile, false))
|
||||
|
||||
added, err := vlt.GetSecret("added")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer added.Destroy()
|
||||
|
||||
imported, err := vlt.GetSecret("imported")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer imported.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("a\n"), added.Bytes())
|
||||
assert.Equal(t, []byte("a\n\n"), imported.Bytes())
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// TestUnknownSubcommandFails is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/183: given a subcommand it does not
|
||||
// have, `secret vault`, `secret unlocker`, `secret version` and `secret
|
||||
// generate` printed their help and exited 0, so a script could not tell a
|
||||
// mistyped command from a successful one. Each runs here as the built binary
|
||||
// and must exit 1 naming the subcommand, as an unknown command does at the
|
||||
// top level, and must still print its help and exit 0 when given nothing.
|
||||
func TestUnknownSubcommandFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
// run runs the built binary with args and returns what it printed and
|
||||
// its exit code
|
||||
run := func(args ...string) (string, int) {
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), args...)
|
||||
cmd.Env = []string{secret.EnvStateDir + "=" + stateDir}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
require.ErrorAs(t, err, &exitErr, "secret %v did not run", args)
|
||||
}
|
||||
|
||||
return string(output), cmd.ProcessState.ExitCode()
|
||||
}
|
||||
|
||||
for _, call := range []string{
|
||||
"vault improt", "unlocker foo", "version foo", "generate foo",
|
||||
} {
|
||||
parent, unknown, _ := strings.Cut(call, " ")
|
||||
|
||||
output, exitCode := run(parent, unknown)
|
||||
assert.Equal(t, 1, exitCode, "secret %s should fail:\n%s", call, output)
|
||||
assert.Contains(t, output, fmt.Sprintf(
|
||||
"unknown command %q for \"secret %s\"", unknown, parent))
|
||||
|
||||
output, exitCode = run(parent)
|
||||
assert.Equal(t, 0, exitCode,
|
||||
"secret %s should succeed:\n%s", parent, output)
|
||||
assert.Contains(t, output, usageHeading,
|
||||
"secret %s should print its help", parent)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
// Unlock Failure Tests
|
||||
//
|
||||
// When a vault cannot be opened through its current unlocker, because a
|
||||
// file the unlocker needs is missing or the passphrase is wrong, the error
|
||||
// keeps its cause and ends by saying that the mnemonic still opens that
|
||||
// vault, but only for a vault that the mnemonic does open, and not when the
|
||||
// passphrase could not be read at all. When a secret's current file is
|
||||
// missing, the error says how to make a version current again. Each test
|
||||
// that pins such advice also follows it.
|
||||
// file the unlocker needs is missing, the error keeps its cause and ends by
|
||||
// saying that the mnemonic still opens that vault, but only for a vault that
|
||||
// the mnemonic does open, and not when the passphrase could not be read at
|
||||
// all. A wrong passphrase gives an error that says so, without that advice.
|
||||
// When the current unlocker does not exist and the vault has another that
|
||||
// `secret unlocker select` accepts, the error says to select one instead.
|
||||
// When a secret's current file is missing, the error says how to make a
|
||||
// version current again. Each test that pins such advice also follows it.
|
||||
|
||||
package cli_test
|
||||
|
||||
@@ -16,9 +18,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -162,26 +164,48 @@ func TestUnlockFailureNamesMnemonic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrongPassphraseNamesMnemonic checks the error of `secret get` given a
|
||||
// passphrase that does not decrypt the passphrase unlocker: it keeps age's
|
||||
// error and ends with the advice that the mnemonic still opens the vault.
|
||||
func TestWrongPassphraseNamesMnemonic(t *testing.T) {
|
||||
// TestWrongPassphraseNamesPassphrase checks the error of `secret get` given
|
||||
// a passphrase that does not decrypt the passphrase unlocker: it says that
|
||||
// the passphrase is wrong, without the advice to use the mnemonic, since the
|
||||
// unlocker still opens the vault with the right passphrase. The test then
|
||||
// gives the right passphrase.
|
||||
func TestWrongPassphraseNamesPassphrase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, c := newUnlockTestCLI(t, newTwoVaultFs(t))
|
||||
unlockerDir, c := newUnlockTestCLI(t, newTwoVaultFs(t))
|
||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
|
||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
var noMatch *age.NoIdentityMatchError
|
||||
|
||||
require.ErrorAs(t, err, &noMatch)
|
||||
|
||||
require.ErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||
assert.NotContains(t, err.Error(), mnemonicAdvice)
|
||||
require.EqualError(t, err, "failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get unlocker identity: "+
|
||||
"failed to decrypt unlocker private key: failed to create decryptor: "+
|
||||
noMatch.Error()+mnemonicAdvice)
|
||||
"wrong passphrase for unlocker "+filepath.Base(unlockerDir))
|
||||
|
||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||
}
|
||||
|
||||
// TestDamagedUnlockerNamesMnemonic checks the error of `secret get` when the
|
||||
// passphrase unlocker's priv.age is not an age file: the passphrase is not
|
||||
// blamed, and the error ends with the advice that the mnemonic still opens
|
||||
// the vault.
|
||||
func TestDamagedUnlockerNamesMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(unlockerDir, "priv.age"),
|
||||
[]byte("damaged"), secret.FilePerms))
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
require.NotErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||
require.ErrorContains(t, err, mnemonicAdvice)
|
||||
}
|
||||
|
||||
// TestMoveUnlockFailureNamesVault checks the error of `secret move` into
|
||||
@@ -222,8 +246,71 @@ func TestMoveUnlockFailureNamesVault(t *testing.T) {
|
||||
assert.Equal(t, "value", getSecret(t, c, "y"))
|
||||
}
|
||||
|
||||
// TestMissingCurrentUnlockerNamesSelect checks the error of `secret get`
|
||||
// when current-unlocker names an unlocker that does not exist and the vault
|
||||
// has another: it names the missing unlocker and says to select another
|
||||
// one, without the advice to use the mnemonic. The test then follows that
|
||||
// advice.
|
||||
func TestMissingCurrentUnlockerNamesSelect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(unlockTestVaultDir, "current-unlocker"),
|
||||
[]byte("missing"), secret.FilePerms))
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
require.ErrorIs(t, err, vault.ErrUnlockerNotFound)
|
||||
require.EqualError(t, err, "failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get current unlocker: "+
|
||||
"unlocker with ID missing not found; the vault 'default' has other "+
|
||||
"unlockers: run 'secret unlocker select' with the ID of one of them "+
|
||||
"to make it the current unlocker")
|
||||
|
||||
require.NoError(t, c.UnlockerSelect(filepath.Base(unlockerDir)))
|
||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||
}
|
||||
|
||||
// TestMissingCurrentUnlockerNamesMnemonic checks the error of `secret get`
|
||||
// when the unlocker that current-unlocker names does not exist and the
|
||||
// vault's only other unlocker is the directory "odd", whose metadata names a
|
||||
// type that `secret unlocker select` refuses: the error ends with the advice
|
||||
// that the mnemonic still opens the vault, not with advice to select another
|
||||
// unlocker. The test then follows that advice.
|
||||
func TestMissingCurrentUnlockerNamesMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
require.NoError(t, fs.RemoveAll(unlockerDir))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(unlockTestVaultDir, "unlockers.d", "odd",
|
||||
"unlocker-metadata.json"),
|
||||
[]byte(`{"type": "odd"}`), secret.FilePerms))
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
var cause *os.PathError
|
||||
|
||||
require.ErrorAs(t, err, &cause)
|
||||
require.NotErrorIs(t, err, vault.ErrUnlockerNotFound)
|
||||
require.EqualError(t, err, "failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get current unlocker: "+
|
||||
"failed to read unlocker metadata: "+cause.Error()+mnemonicAdvice)
|
||||
|
||||
c.Mnemonic = testMnemonicBuffer(t)
|
||||
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
|
||||
|
||||
c.Mnemonic = nil
|
||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||
}
|
||||
|
||||
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
|
||||
// binary without SB_UNLOCK_PASSPHRASE and with a stdin that is not a
|
||||
// binary without SB_UNLOCK_PASSPHRASE and in a new session, which has no
|
||||
// terminal, so the passphrase cannot be read. The unlocker was not tried,
|
||||
// and adding one would need a passphrase read the same way, so the error
|
||||
// is the cause alone, without the advice to use the mnemonic.
|
||||
@@ -254,15 +341,16 @@ func TestPassphraseNotReadNamesNoMnemonic(t *testing.T) {
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.Error(t, err)
|
||||
|
||||
assert.Equal(t, "Error: failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get unlocker identity: "+
|
||||
"failed to read passphrase: stdin is not a terminal (piped input or "+
|
||||
"script). Please set the SB_UNLOCK_PASSPHRASE environment variable or "+
|
||||
"run interactively\n", string(output))
|
||||
"failed to read passphrase: there is no terminal to ask on. Please "+
|
||||
"set the SB_UNLOCK_PASSPHRASE environment variable or run "+
|
||||
"interactively\n", string(output))
|
||||
}
|
||||
|
||||
// TestCryptoUnlockFailureNamesMnemonic checks that `secret encrypt` and
|
||||
|
||||
+188
-148
@@ -7,28 +7,25 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// Unlocker type names and platform identifiers shared across the CLI
|
||||
// Unlocker type names shared across the CLI
|
||||
const (
|
||||
unlockerTypePassphrase = "passphrase"
|
||||
unlockerTypeKeychain = "keychain"
|
||||
unlockerTypePGP = "pgp"
|
||||
unlockerTypeSecureEnclave = "secure-enclave"
|
||||
|
||||
platformDarwin = "darwin"
|
||||
unlockerTypePassphrase = "passphrase"
|
||||
unlockerTypePGP = "pgp"
|
||||
|
||||
cmdUseList = "list"
|
||||
)
|
||||
@@ -43,6 +40,10 @@ var (
|
||||
// composes "GPG key <id> is already added as an unlocker".
|
||||
errGPGKeyAlreadyUnlocker = errors.New(
|
||||
"is already added as an unlocker")
|
||||
// errVaultCreatedAgain carries only the message tail; the caller
|
||||
// composes "vault '<name>' was removed and created again ...".
|
||||
errVaultCreatedAgain = errors.New("was removed and created again " +
|
||||
"with another key while it was being unlocked; nothing was changed")
|
||||
)
|
||||
|
||||
// UnlockerInfo represents unlocker information for display
|
||||
@@ -71,12 +72,13 @@ func getDefaultGPGKey() (string, error) {
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
||||
// The default key is in field 10 (index 9)
|
||||
return fields[9], nil
|
||||
}
|
||||
defaultKey, parseErr := parseDefaultGPGKey(string(output))
|
||||
if parseErr != nil {
|
||||
return "", parseErr
|
||||
}
|
||||
|
||||
if defaultKey != "" {
|
||||
return defaultKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,11 +105,38 @@ func getDefaultGPGKey() (string, error) {
|
||||
return "", errNoGPGSecretKeys
|
||||
}
|
||||
|
||||
// parseDefaultGPGKey returns the default-key value in the output of
|
||||
// `gpgconf --list-options gpg`, or "" when gpg.conf sets none. gpgconf(1)
|
||||
// prints a string value with a leading `"` and percent-escaped.
|
||||
func parseDefaultGPGKey(gpgconfOutput string) (string, error) {
|
||||
for line := range strings.SplitSeq(gpgconfOutput, "\n") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
||||
// The default key is in field 10 (index 9)
|
||||
keyID, err := url.PathUnescape(strings.TrimPrefix(fields[9], `"`))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode gpgconf default-key %s: %w",
|
||||
fields[9], err)
|
||||
}
|
||||
|
||||
return keyID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func newUnlockerCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "unlocker",
|
||||
Short: "Manage unlockers",
|
||||
Long: `Create, list, and remove unlockers for the current vault.`,
|
||||
// Cobra checks Args only on a command that has a RunE; without
|
||||
// one, an unknown subcommand prints this help and exits 0.
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newUnlockerListCmd())
|
||||
@@ -143,9 +172,8 @@ func newUnlockerListCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
// unlockerAddHelp returns the supported unlocker types list and their
|
||||
// descriptions for the current platform
|
||||
// descriptions
|
||||
func unlockerAddHelp() (string, string) {
|
||||
// Build the supported types list based on platform
|
||||
supportedTypes := "passphrase, pgp"
|
||||
typeDescriptions := "Available unlocker types:\n" +
|
||||
"\n" +
|
||||
@@ -162,43 +190,6 @@ func unlockerAddHelp() (string, string) {
|
||||
" Use --keyid to specify a particular key, " +
|
||||
"otherwise uses your default GPG key."
|
||||
|
||||
if runtime.GOOS == platformDarwin {
|
||||
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
||||
typeDescriptions = "Available unlocker types:\n" +
|
||||
"\n" +
|
||||
" passphrase - Traditional password-based encryption\n" +
|
||||
" Prompts for a passphrase that will be " +
|
||||
"used to encrypt/decrypt the vault's master key.\n" +
|
||||
" The passphrase is never stored in " +
|
||||
"plaintext.\n" +
|
||||
"\n" +
|
||||
" keychain - macOS Keychain integration (macOS only)\n" +
|
||||
" Stores the vault's master key in the " +
|
||||
"macOS Keychain, protected by your login password.\n" +
|
||||
" Automatically unlocks when your Keychain " +
|
||||
"is unlocked (e.g., after login).\n" +
|
||||
" Provides seamless integration with macOS " +
|
||||
"security features like Touch ID.\n" +
|
||||
"\n" +
|
||||
" pgp - GNU Privacy Guard (GPG) key-based " +
|
||||
"encryption\n" +
|
||||
" Uses your existing GPG key to " +
|
||||
"encrypt/decrypt the vault's master key.\n" +
|
||||
" Requires gpg to be installed and " +
|
||||
"configured with at least one secret key.\n" +
|
||||
" Use --keyid to specify a particular key, " +
|
||||
"otherwise uses your default GPG key.\n" +
|
||||
"\n" +
|
||||
" secure-enclave - Apple Secure Enclave hardware protection " +
|
||||
"(macOS only)\n" +
|
||||
" Stores the vault's master key encrypted " +
|
||||
"by a non-exportable P-256 key\n" +
|
||||
" held in the Secure Enclave. The key " +
|
||||
"never leaves the hardware.\n" +
|
||||
" Uses ECIES encryption; decryption is " +
|
||||
"performed inside the SE."
|
||||
}
|
||||
|
||||
return supportedTypes, typeDescriptions
|
||||
}
|
||||
|
||||
@@ -265,8 +256,11 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
||||
Long: `Remove an unlocker from the current vault. Asks for ` +
|
||||
`confirmation first, saying whether it is the vault's last ` +
|
||||
`unlocker; when stdin is not a terminal, fails unless --force ` +
|
||||
`is given. Warning: Without unlockers and without your ` +
|
||||
`mnemonic, vault data will be permanently inaccessible.`,
|
||||
`is given. Removing the current unlocker makes another of the ` +
|
||||
`vault's unlockers current, if it has one that ` +
|
||||
`'secret unlocker select' accepts. Warning: Without ` +
|
||||
`unlockers and without your mnemonic, vault data will be ` +
|
||||
`permanently inaccessible.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -331,7 +325,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var unlockers []UnlockerInfo
|
||||
// Not nil, so that a vault with no unlockers prints [] in JSON, not null
|
||||
unlockers := make([]UnlockerInfo, 0, len(unlockerMetadata))
|
||||
|
||||
for _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) {
|
||||
metadata := unlockerMetadata[unlockerID]
|
||||
@@ -366,22 +361,25 @@ func (cli *Instance) printUnlockersJSON(
|
||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||
}
|
||||
|
||||
cli.cmd.Println(string(jsonBytes))
|
||||
_, _ = fmt.Fprintln(cli.cmd.OutOrStdout(), string(jsonBytes))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printUnlockersTable prints unlockers in a formatted table
|
||||
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
out := cli.cmd.OutOrStdout()
|
||||
|
||||
if len(unlockers) == 0 {
|
||||
cli.cmd.Println("No unlockers found in current vault.")
|
||||
cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.")
|
||||
_, _ = fmt.Fprintln(out, "No unlockers found in current vault.")
|
||||
_, _ = fmt.Fprintln(out, "Run 'secret unlocker add passphrase' to create one.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.cmd.Printf(" %-40s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
||||
cli.cmd.Printf(" %-40s %-12s %-20s %s\n",
|
||||
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
|
||||
"UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
||||
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
|
||||
strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth),
|
||||
strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth))
|
||||
|
||||
@@ -396,7 +394,7 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
prefix = "* "
|
||||
}
|
||||
|
||||
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
|
||||
_, _ = fmt.Fprintf(out, "%s%-40s %-12s %-20s %s\n",
|
||||
prefix,
|
||||
unlocker.ID,
|
||||
unlocker.Type,
|
||||
@@ -404,40 +402,84 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
flags)
|
||||
}
|
||||
|
||||
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
|
||||
_, _ = fmt.Fprintf(out, "\nTotal: %d unlocker(s)\n", len(unlockers))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnlockersAdd adds a new unlocker
|
||||
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
switch unlockerType {
|
||||
case unlockerTypePassphrase:
|
||||
return cli.addPassphraseUnlocker(cmd)
|
||||
case unlockerTypeKeychain:
|
||||
return cli.addKeychainUnlocker(cmd)
|
||||
case unlockerTypeSecureEnclave:
|
||||
return cli.addSecureEnclaveUnlocker(cmd)
|
||||
case unlockerTypePGP:
|
||||
return cli.addPGPUnlocker(cmd)
|
||||
default:
|
||||
// Build the supported types list based on platform
|
||||
supportedTypes := "passphrase, pgp"
|
||||
if runtime.GOOS == platformDarwin {
|
||||
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %s (supported: %s)",
|
||||
errInvalidUnlockerType, unlockerType, supportedTypes)
|
||||
}
|
||||
}
|
||||
|
||||
// unlockThenLock gets the long-term key of vlt, the vault an unlocker is
|
||||
// added to, then takes the state directory lock and returns the function
|
||||
// that releases it. Getting the key can ask for a passphrase, so it comes
|
||||
// before the lock, as the question of askThenLock does, and no other
|
||||
// command waits while the user answers. Once the lock is held, it fails if
|
||||
// another command has removed the vault in the meantime, since writing the
|
||||
// unlocker would create the vault's directory again, holding nothing else.
|
||||
// It also fails if the vault was removed and created again with another
|
||||
// long-term key, which the unlocker would then hold instead of the new
|
||||
// vault's own.
|
||||
func (cli *Instance) unlockThenLock(vlt *vault.Vault) (func(), error) {
|
||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = cli.checkLongTermKey(vlt, ltIdentity)
|
||||
if err != nil {
|
||||
release()
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// checkLongTermKey checks, for the vault vlt unlocked as ltIdentity before
|
||||
// the state directory lock was taken, that vlt still exists and that its
|
||||
// long-term public key is still that of ltIdentity. Another command may have
|
||||
// removed the vault in the meantime, or removed it and created it again with
|
||||
// another long-term key. The caller holds the lock.
|
||||
func (cli *Instance) checkLongTermKey(
|
||||
vlt *vault.Vault, ltIdentity *age.X25519Identity,
|
||||
) error {
|
||||
_, err := cli.existingVault(vlt.GetName())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
|
||||
ltPubKey, err := afero.ReadFile(cli.fs, filepath.Join(vaultDir, "pub.age"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
||||
}
|
||||
|
||||
if string(ltPubKey) != ltIdentity.Recipient().String() {
|
||||
return fmt.Errorf("vault '%s' %w", vlt.GetName(), errVaultCreatedAgain)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// autoSelectUnlocker selects the newly created unlocker as current,
|
||||
// printing a warning if selection fails
|
||||
func autoSelectUnlocker(cmd *cobra.Command, vlt *vault.Vault, unlockerID string) {
|
||||
@@ -457,23 +499,31 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// For passphrase unlockers, we don't need the vault to be unlocked
|
||||
// The CreatePassphraseUnlocker method will handle getting the
|
||||
// long-term key
|
||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
// The new unlocker gets the passphrase from the environment, which also
|
||||
// unlocks the current passphrase unlocker, else the one entered here
|
||||
passphraseBuffer := cli.UnlockPassphrase
|
||||
if passphraseBuffer == nil {
|
||||
// Use secure passphrase input with confirmation
|
||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The new passphrase is asked for even when SB_UNLOCK_PASSPHRASE is
|
||||
// set, and the variable unlocks the vault. With no terminal to ask on,
|
||||
// the new unlocker takes the passphrase in the variable.
|
||||
fromVariable := false
|
||||
|
||||
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
|
||||
switch {
|
||||
case errors.Is(err, secret.ErrNoTerminal) && cli.UnlockPassphrase != nil:
|
||||
passphraseBuffer = cli.UnlockPassphrase
|
||||
fromVariable = true
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
defer passphraseBuffer.Destroy()
|
||||
}
|
||||
|
||||
release, err := cli.unlockThenLock(vlt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -481,59 +531,18 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
||||
|
||||
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
||||
|
||||
if fromVariable {
|
||||
cmd.Printf("The new unlocker takes its passphrase from %s, since "+
|
||||
"there is no terminal to ask for a new one on\n",
|
||||
secret.EnvUnlockPassphrase)
|
||||
}
|
||||
|
||||
// CreatePassphraseUnlocker has already made it the current unlocker
|
||||
cmd.Printf("Automatically selected as current unlocker\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addKeychainUnlocker creates a macOS Keychain unlocker in the current vault
|
||||
func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
|
||||
keychainUnlocker, err := secret.CreateKeychainUnlocker(
|
||||
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID())
|
||||
|
||||
keyName, err := keychainUnlocker.GetKeychainItemName()
|
||||
if err == nil {
|
||||
cmd.Printf("Keychain Item Name: %s\n", keyName)
|
||||
}
|
||||
|
||||
// Auto-select the newly created unlocker
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
autoSelectUnlocker(cmd, vlt, keychainUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the
|
||||
// current vault
|
||||
func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
|
||||
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(
|
||||
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Created Secure Enclave unlocker: %s\n", seUnlocker.GetID())
|
||||
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
autoSelectUnlocker(cmd, vlt, seUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPGPUnlocker creates a PGP unlocker in the current vault
|
||||
func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
||||
// Get GPG key ID from flag, environment, or default key
|
||||
@@ -553,12 +562,13 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
||||
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
|
||||
}
|
||||
|
||||
// Check if this key is already added as an unlocker
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
// Resolve the GPG key ID to its fingerprint, once: the duplicate check
|
||||
// and the new unlocker's metadata both use this result
|
||||
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
||||
@@ -566,21 +576,26 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
||||
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Check if this GPG key is already added
|
||||
exists, err := cli.pgpUnlockerExists(vlt, fingerprint)
|
||||
// Refuse a key that is already an unlocker before asking for anything,
|
||||
// and again once the lock is held, since another command may have added
|
||||
// it in the meantime
|
||||
err = cli.checkPGPKeyNotAdded(vlt, gpgKeyID, fingerprint)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not check whether GPG key %s is already an unlocker: %w",
|
||||
gpgKeyID, err,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
|
||||
release, err := cli.unlockThenLock(vlt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
err = cli.checkPGPKeyNotAdded(vlt, gpgKeyID, fingerprint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir,
|
||||
gpgKeyID, fingerprint, cli.Mnemonic, cli.UnlockPassphrase)
|
||||
pgpUnlocker, err := secret.CreatePGPUnlocker(vlt, gpgKeyID, fingerprint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -710,13 +725,17 @@ func (cli *Instance) removeUnlocker(
|
||||
"have your mnemonic phrase to access this vault again!")
|
||||
}
|
||||
|
||||
err := found.vlt.RemoveUnlocker(unlockerID)
|
||||
newCurrentID, err := found.vlt.RemoveUnlocker(unlockerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Removed unlocker '%s'\n", unlockerID)
|
||||
|
||||
if newCurrentID != "" {
|
||||
cmd.Printf("Switched current unlocker to '%s'\n", newCurrentID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -751,6 +770,27 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
||||
return vlt.SelectUnlocker(unlockerID)
|
||||
}
|
||||
|
||||
// checkPGPKeyNotAdded fails when vlt already has a PGP unlocker for the GPG
|
||||
// key gpgKeyID, whose fingerprint is fingerprint, or when that cannot be
|
||||
// checked.
|
||||
func (cli *Instance) checkPGPKeyNotAdded(
|
||||
vlt *vault.Vault, gpgKeyID, fingerprint string,
|
||||
) error {
|
||||
exists, err := cli.pgpUnlockerExists(vlt, fingerprint)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not check whether GPG key %s is already an unlocker: %w",
|
||||
gpgKeyID, err,
|
||||
)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pgpUnlockerExists reports whether the vault already has a PGP unlocker
|
||||
// for the GPG key with the given fingerprint. It returns an error, and no
|
||||
// answer, when unlockers.d or an unlocker's metadata file cannot be read;
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
@@ -82,6 +85,77 @@ func TestAddPGPUnlocker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddPGPUnlockerDefaultKey is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/141: with default-key set in
|
||||
// gpg.conf and no --keyid, `secret unlocker add pgp` uses that key.
|
||||
// gpg.conf names the key by its user ID, and the printed key ID must be that
|
||||
// user ID. The first secret key, which is used when gpg.conf sets no
|
||||
// default-key, prints as a hex key ID.
|
||||
func TestAddPGPUnlockerDefaultKey(t *testing.T) {
|
||||
newTestGPGKey(t)
|
||||
// The key comes from neither --keyid nor the environment.
|
||||
t.Setenv(secret.EnvGPGKeyID, "")
|
||||
|
||||
gpgConf := filepath.Join(os.Getenv("GNUPGHOME"), "gpg.conf")
|
||||
//nolint:gosec // G703: GNUPGHOME is the directory newTestGPGKey made
|
||||
require.NoError(t, os.WriteFile(gpgConf,
|
||||
[]byte("default-key "+unreadableTestGPGUserID+"\n"), 0o600))
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
instance, cmd := newTestInstance(fs)
|
||||
instance.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
|
||||
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
||||
assert.Contains(t, output.String(),
|
||||
"Using default GPG key: "+unreadableTestGPGUserID+"\n")
|
||||
}
|
||||
|
||||
// TestParseDefaultGPGKey is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/141: gpgconf prints the
|
||||
// default-key value with a leading `"` and percent-escaped, and the key ID
|
||||
// is that value decoded.
|
||||
func TestParseDefaultGPGKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// gpgconfLine is the default-key line of `gpgconf --list-options gpg`
|
||||
// up to the value, its last field.
|
||||
const gpgconfLine = "default-key:0:0:use NAME as default secret key:" +
|
||||
"1:1:NAME:::"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"fingerprint", `"56163D7F3FAFF709608855BEC1A360216D007F41`,
|
||||
"56163D7F3FAFF709608855BEC1A360216D007F41",
|
||||
},
|
||||
{
|
||||
"user ID with a comma", `"Doe%2c Alice <alice@example.com>`,
|
||||
"Doe, Alice <alice@example.com>",
|
||||
},
|
||||
{"no default-key in gpg.conf", "", ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keyID, err := parseDefaultGPGKey(gpgconfLine + test.value + "\n")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.want, keyID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key
|
||||
// the keyring does not hold fails at looking up the key's fingerprint and
|
||||
// leaves no new unlocker directory. The error must come from the lookup: a
|
||||
@@ -104,3 +178,40 @@ func TestAddPGPUnlockerUnknownKey(t *testing.T) {
|
||||
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName),
|
||||
listTestUnlockerDirOne)
|
||||
}
|
||||
|
||||
// TestUnlockerAddKnowsOnlyPassphraseAndPGP is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/132: `secret unlocker add` takes
|
||||
// only passphrase and pgp, on every platform. keychain and secure-enclave
|
||||
// fail as unknown types, and neither --help nor shell completion offers
|
||||
// them.
|
||||
//
|
||||
//nolint:paralleltest // executes the CLI in-process and sets the environment
|
||||
func TestUnlockerAddKnowsOnlyPassphraseAndPGP(t *testing.T) {
|
||||
env := map[string]string{secret.EnvStateDir: t.TempDir()}
|
||||
removedTypes := []string{"keychain", "secure-enclave"}
|
||||
|
||||
for _, unlockerType := range removedTypes {
|
||||
_, err := ExecuteCommandInProcess(
|
||||
[]string{"unlocker", "add", unlockerType}, "", env)
|
||||
require.ErrorIs(t, err, errInvalidUnlockerType)
|
||||
|
||||
instance, cmd := newTestInstance(newListTestVault(t, 1))
|
||||
require.ErrorIs(t, instance.UnlockersAdd(unlockerType, cmd),
|
||||
errInvalidUnlockerType)
|
||||
}
|
||||
|
||||
// --help prints the command's Long text, and shell completion offers
|
||||
// its ValidArgs.
|
||||
addCmd := newUnlockerAddCmd()
|
||||
help := addCmd.Long
|
||||
completions := strings.Join(addCmd.ValidArgs, " ")
|
||||
|
||||
for _, offered := range []string{help, completions} {
|
||||
assert.Contains(t, offered, unlockerTypePassphrase)
|
||||
assert.Contains(t, offered, unlockerTypePGP)
|
||||
|
||||
for _, unlockerType := range removedTypes {
|
||||
assert.NotContains(t, offered, unlockerType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ import (
|
||||
// copying an unlocker directory leaves them. It asserts that `unlocker
|
||||
// list` and the shell completion of `unlocker select` and `unlocker remove`
|
||||
// give each its own ID, and that each is selected and removed by its ID
|
||||
// alone. Keychain and Secure Enclave unlockers, which only macOS can add,
|
||||
// get their IDs the same way.
|
||||
// alone.
|
||||
func TestSameMetadataUnlockersHaveTheirOwnIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// Unlocker List Tests
|
||||
//
|
||||
// Tests for `secret unlocker list` behavior when an unlocker's metadata
|
||||
// cannot be read or used:
|
||||
// Tests for `secret unlocker list`:
|
||||
//
|
||||
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
|
||||
// metadata does not stop the others from being listed.
|
||||
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
||||
// file cannot be checked for or read is left out, and the other is
|
||||
// still listed.
|
||||
// - TestUnlockersListEmptyJSON: a vault with no unlockers lists them as
|
||||
// an empty JSON list.
|
||||
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
@@ -321,3 +322,36 @@ func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnlockersListEmptyJSON is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/148: listing the unlockers of a
|
||||
// vault that has none, as after its last unlocker is removed, printed
|
||||
// "unlockers": null in JSON form instead of an empty list.
|
||||
func TestUnlockersListEmptyJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vaultDir := filepath.Join(listTestStateDir, "vaults.d", listTestVaultName)
|
||||
|
||||
require.NoError(t, afero.WriteFile(
|
||||
fs, filepath.Join(listTestStateDir, "currentvault"),
|
||||
[]byte(listTestVaultName), listTestFilePerm,
|
||||
))
|
||||
require.NoError(t, fs.MkdirAll(
|
||||
filepath.Join(vaultDir, listTestUnlockersDirName), listTestDirPerm,
|
||||
))
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&buf)
|
||||
|
||||
instance := &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}
|
||||
require.NoError(t, instance.UnlockersList(true))
|
||||
|
||||
var decoded map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
|
||||
assert.Equal(t, []any{}, decoded["unlockers"],
|
||||
"no unlockers must be printed as [], not %s", buf.String())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestUnlockerRemoveCurrent is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/137. A vault has a passphrase
|
||||
// unlocker and a PGP unlocker, the current one. Removing the PGP unlocker
|
||||
// makes the passphrase unlocker current and says so, and the vault then
|
||||
// opens through it. Removing the passphrase unlocker too removes
|
||||
// current-unlocker.
|
||||
//
|
||||
// The second case also gives the vault the unlocker directory "odd", whose
|
||||
// metadata names a type that `secret unlocker select` refuses, and which
|
||||
// sorts before the passphrase unlocker: neither removal makes it current.
|
||||
//
|
||||
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
||||
func TestUnlockerRemoveCurrent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
withOdd bool
|
||||
}{
|
||||
{name: "two unlockers", withOdd: false},
|
||||
{name: "and the directory odd", withOdd: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
newTestGPGKey(t)
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(passphrase.Destroy)
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vlt, err := vault.CreateVault(fs, listTestStateDir,
|
||||
listTestVaultName, testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.withOdd {
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(testVaultDir(listTestVaultName), "unlockers.d",
|
||||
"odd", "unlocker-metadata.json"),
|
||||
[]byte(`{"type": "odd"}`), secret.FilePerms))
|
||||
}
|
||||
|
||||
instance, cmd := newTestInstance(fs)
|
||||
instance.UnlockPassphrase = passphrase
|
||||
|
||||
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
||||
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
||||
|
||||
pgpUnlocker, err := vlt.GetCurrentUnlocker()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unlockerTypePGP, pgpUnlocker.GetType())
|
||||
|
||||
var output bytes.Buffer
|
||||
|
||||
cmd.SetOut(&output)
|
||||
require.NoError(t,
|
||||
instance.UnlockersRemove(pgpUnlocker.GetID(), true, cmd))
|
||||
assert.Contains(t, output.String(),
|
||||
"Switched current unlocker to '"+passphraseUnlocker.GetID()+"'")
|
||||
|
||||
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
|
||||
reopened.UnlockPassphrase = passphrase
|
||||
|
||||
_, err = reopened.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t,
|
||||
instance.UnlockersRemove(passphraseUnlocker.GetID(), true, cmd))
|
||||
|
||||
exists, err := afero.Exists(fs, filepath.Join(
|
||||
testVaultDir(listTestVaultName), "current-unlocker"))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists,
|
||||
"current-unlocker is left after the passphrase unlocker")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,12 @@
|
||||
// "nothing there" and go ahead.
|
||||
//
|
||||
// The tests make the look fail with a wrapper around the in-memory
|
||||
// filesystem, which the state directory lock refuses. So they call the
|
||||
// function each command runs once it holds the lock, such as addPGPUnlocker
|
||||
// for UnlockersAdd, or, for a removal, the function that makes its checks,
|
||||
// such as findVaultToRemove for RemoveVault, which runs again under the
|
||||
// lock before anything is removed, with --force or without.
|
||||
// filesystem, which the state directory lock refuses. So they call a
|
||||
// function that makes the look before any lock is taken: addPGPUnlocker for
|
||||
// UnlockersAdd, which looks again once it holds the lock, or, for a removal,
|
||||
// the function that makes its checks, such as findVaultToRemove for
|
||||
// RemoveVault, which runs again under the lock before anything is removed,
|
||||
// with --force or without.
|
||||
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
@@ -371,19 +372,3 @@ func TestRemoveSecretAbortsWhenVersionsUnreadable(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "the secret must not be removed")
|
||||
}
|
||||
|
||||
// TestVaultImportAbortsWhenPubKeyUnreadable asserts that a mnemonic import
|
||||
// stops when whether the vault already has a long-term key cannot be
|
||||
// determined.
|
||||
func TestVaultImportAbortsWhenPubKeyUnreadable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := newListTestVault(t, 1)
|
||||
instance, cmd := newTestInstance(&statFailFs{
|
||||
Fs: base, path: filepath.Join(testVaultDir(listTestVaultName), "pub.age"),
|
||||
})
|
||||
|
||||
err := instance.importMnemonic(cmd, listTestVaultName)
|
||||
|
||||
require.ErrorIs(t, err, errStatFailed)
|
||||
}
|
||||
|
||||
+26
-240
@@ -8,7 +8,6 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
@@ -16,19 +15,12 @@ import (
|
||||
"sneak.berlin/go/secret/internal/bip39"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// Sentinel errors for vault operations
|
||||
var (
|
||||
errMnemonicEmpty = errors.New("mnemonic cannot be empty")
|
||||
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
|
||||
errVaultHasLongTermKey = errors.New(
|
||||
"already has a long-term key configured")
|
||||
errMnemonicEnvNotSet = errors.New(
|
||||
"SB_SECRET_MNEMONIC environment variable not set")
|
||||
errPassphraseEnvNotSet = errors.New(
|
||||
"SB_UNLOCK_PASSPHRASE environment variable not set")
|
||||
errCannotRemoveLastVault = errors.New("cannot remove the last vault")
|
||||
)
|
||||
|
||||
@@ -37,12 +29,17 @@ func newVaultCmd() *cobra.Command {
|
||||
Use: "vault",
|
||||
Short: "Manage vaults",
|
||||
Long: `Create, list, and select vaults for organizing secrets.`,
|
||||
// Cobra checks Args only on a command that has a RunE; without
|
||||
// one, an unknown subcommand prints this help and exits 0.
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newVaultListCmd())
|
||||
cmd.AddCommand(newVaultCreateCmd())
|
||||
cmd.AddCommand(newVaultSelectCmd())
|
||||
cmd.AddCommand(newVaultImportCmd())
|
||||
cmd.AddCommand(newVaultRemoveCmd())
|
||||
|
||||
return cmd
|
||||
@@ -111,38 +108,6 @@ func newVaultSelectCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func newVaultImportCmd() *cobra.Command {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
return &cobra.Command{
|
||||
Use: "import <vault-name>",
|
||||
Short: "Import a mnemonic into a vault",
|
||||
Long: `Import a BIP39 mnemonic phrase into the specified vault ` +
|
||||
`(default if not specified).`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
vaultName := "default"
|
||||
if len(args) > 0 {
|
||||
vaultName = args[0]
|
||||
}
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
destroySecrets := cli.readSecretEnv()
|
||||
defer destroySecrets()
|
||||
|
||||
return cli.VaultImport(cmd, vaultName)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVaultRemoveCmd() *cobra.Command {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
@@ -186,6 +151,8 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
|
||||
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
||||
// Get current vault name for context
|
||||
currentVault := ""
|
||||
@@ -205,13 +172,13 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Println(string(jsonBytes))
|
||||
_, _ = fmt.Fprintln(out, string(jsonBytes))
|
||||
} else {
|
||||
// Text output
|
||||
cmd.Println("Available vaults:")
|
||||
_, _ = fmt.Fprintln(out, "Available vaults:")
|
||||
|
||||
if len(vaults) == 0 {
|
||||
cmd.Println(" (none)")
|
||||
_, _ = fmt.Fprintln(out, " (none)")
|
||||
} else {
|
||||
// Try to get current vault for marking
|
||||
currentVault := ""
|
||||
@@ -223,9 +190,9 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
|
||||
for _, vaultName := range vaults {
|
||||
if vaultName == currentVault {
|
||||
cmd.Printf(" %s (current)\n", vaultName)
|
||||
_, _ = fmt.Fprintf(out, " %s (current)\n", vaultName)
|
||||
} else {
|
||||
cmd.Printf(" %s\n", vaultName)
|
||||
_, _ = fmt.Fprintf(out, " %s\n", vaultName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,15 +222,17 @@ func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error)
|
||||
return passphraseBuffer, passphraseBuffer.Destroy, nil
|
||||
}
|
||||
|
||||
// CreateVault creates a new vault
|
||||
// CreateVault creates a new vault. It asks for the mnemonic and the
|
||||
// passphrase before it takes the state directory lock, so that no other
|
||||
// command waits while the user answers.
|
||||
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
// Refuse an invalid name or an existing vault before asking for anything
|
||||
err := vault.CheckNewVaultName(cli.fs, cli.stateDir, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
|
||||
if err != nil {
|
||||
@@ -292,7 +261,14 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
}
|
||||
defer cleanupPassphrase()
|
||||
|
||||
// Create the vault with its passphrase unlocker
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Create the vault with its passphrase unlocker. Another command may
|
||||
// have created it since the check above: vault.CreateVault checks again.
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name,
|
||||
mnemonic, passphraseBuffer)
|
||||
if err != nil {
|
||||
@@ -334,196 +310,6 @@ func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// vaultImportPreflight verifies the vault exists without a long-term key
|
||||
// and returns the vault directory, public key path, and validated mnemonic
|
||||
func (cli *Instance) vaultImportPreflight(
|
||||
vlt *vault.Vault, vaultName string,
|
||||
) (string, string, string, error) {
|
||||
// Check if vault exists
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
exists, err := afero.DirExists(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||
vaultName, vault.ErrVaultNotFound)
|
||||
}
|
||||
|
||||
// Check if vault already has a public key
|
||||
pubKeyPath := vaultDir + "/pub.age"
|
||||
|
||||
exists, err = afero.Exists(cli.fs, pubKeyPath)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to check %s: %w", pubKeyPath, err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||
vaultName, errVaultHasLongTermKey)
|
||||
}
|
||||
|
||||
// Get mnemonic from environment
|
||||
if cli.Mnemonic == nil {
|
||||
return "", "", "", errMnemonicEnvNotSet
|
||||
}
|
||||
|
||||
mnemonic := cli.Mnemonic.String()
|
||||
|
||||
// Validate the mnemonic
|
||||
mnemonicWords := strings.Fields(mnemonic)
|
||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||
|
||||
if !bip39.IsMnemonicValid(mnemonic) {
|
||||
return "", "", "", errInvalidMnemonicPhrase
|
||||
}
|
||||
|
||||
return vaultDir, pubKeyPath, mnemonic, nil
|
||||
}
|
||||
|
||||
// updateVaultImportMetadata stores the derivation info in vault metadata
|
||||
func updateVaultImportMetadata(
|
||||
fs afero.Fs, vaultDir string, derivationIndex uint32,
|
||||
publicKeyHash, familyHash string,
|
||||
) error {
|
||||
// Load existing metadata
|
||||
existingMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
// If metadata doesn't exist, create new
|
||||
existingMetadata = &vault.Metadata{
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Update metadata with new derivation info
|
||||
existingMetadata.DerivationIndex = derivationIndex
|
||||
existingMetadata.PublicKeyHash = publicKeyHash
|
||||
existingMetadata.MnemonicFamilyHash = familyHash
|
||||
|
||||
err = vault.SaveVaultMetadata(fs, vaultDir, existingMetadata)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to save vault metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VaultImport imports a mnemonic into a specific vault, holding the state
|
||||
// directory lock while importMnemonic runs
|
||||
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||
err := vault.ValidateVaultName(vaultName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
return cli.importMnemonic(cmd, vaultName)
|
||||
}
|
||||
|
||||
// importMnemonic gives the vault a long-term key derived from the mnemonic
|
||||
// and a passphrase unlocker
|
||||
func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error {
|
||||
secret.Debug("Importing mnemonic into vault",
|
||||
"vault_name", vaultName, "state_dir", cli.stateDir)
|
||||
|
||||
// Get the specific vault by name
|
||||
vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName)
|
||||
|
||||
vaultDir, pubKeyPath, mnemonic, err := cli.vaultImportPreflight(vlt, vaultName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the next available derivation index for this mnemonic
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get next derivation index", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Using derivation index", "index", derivationIndex)
|
||||
|
||||
// Derive long-term key from mnemonic with the appropriate index
|
||||
secret.Debug("Deriving long-term key from mnemonic", "index", derivationIndex)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Store long-term public key in vault
|
||||
ltPublicKey := ltIdentity.Recipient().String()
|
||||
secret.Debug("Storing long-term public key",
|
||||
"pubkey", ltPublicKey, "vault_dir", vaultDir)
|
||||
|
||||
err = secret.WriteFileAtomic(cli.fs, pubKeyPath, []byte(ltPublicKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store long-term public key: %w", err)
|
||||
}
|
||||
|
||||
// Calculate public key hash from the actual derivation index being used
|
||||
// This is used to verify that the derived key matches what was stored
|
||||
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||
|
||||
// Calculate family hash from index 0 (same for all vaults with this
|
||||
// mnemonic). This is used to identify which vaults belong to the same
|
||||
// mnemonic family.
|
||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
|
||||
familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
err = updateVaultImportMetadata(
|
||||
cli.fs, vaultDir, derivationIndex, publicKeyHash, familyHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get passphrase from environment variable
|
||||
passphraseBuffer := cli.UnlockPassphrase
|
||||
if passphraseBuffer == nil {
|
||||
return errPassphraseEnvNotSet
|
||||
}
|
||||
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
|
||||
// Unlock the vault with the derived long-term key
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Create passphrase-protected unlocker
|
||||
secret.Debug("Creating passphrase-protected unlocker")
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create unlocker", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName)
|
||||
cmd.Printf("Long-term public key: %s\n", ltPublicKey)
|
||||
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// countVaultSecrets returns the number of secrets in the vault directory
|
||||
func (cli *Instance) countVaultSecrets(vaultDir string) (int, error) {
|
||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// TestVaultListEmptyJSON is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/148: listing the vaults when
|
||||
// vaults.d holds none printed "vaults": null in JSON form instead of an
|
||||
// empty list.
|
||||
func TestVaultListEmptyJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, fs.MkdirAll(
|
||||
filepath.Join(testStateDir, "vaults.d"), secret.DirPerms,
|
||||
))
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&buf)
|
||||
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
require.NoError(t, c.ListVaults(cmd, true))
|
||||
|
||||
var decoded map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
|
||||
assert.Equal(t, []any{}, decoded["vaults"],
|
||||
"no vaults must be printed as [], not %s", buf.String())
|
||||
}
|
||||
+18
-6
@@ -43,6 +43,12 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
||||
Short: "Manage secret versions",
|
||||
Long: "Commands for managing secret versions including listing, " +
|
||||
"promoting, and retrieving specific versions",
|
||||
// Cobra checks Args only on a command that has a RunE; without
|
||||
// one, an unknown subcommand prints this help and exits 0.
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
// List versions command
|
||||
@@ -64,8 +70,10 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
||||
promoteCmd := &cobra.Command{
|
||||
Use: "promote <secret-name> <version>",
|
||||
Short: "Promote a specific version to current",
|
||||
Long: "Updates the current symlink to point to the specified " +
|
||||
"version without modifying timestamps",
|
||||
Long: "Rewrites the secret's current file to name the specified " +
|
||||
"version. The versions' own times are left unchanged; secret " +
|
||||
"list then gives the time of the promote as the secret's last " +
|
||||
"update.",
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
@@ -167,7 +175,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
cmd.Println("No versions found")
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No versions found")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -227,9 +235,13 @@ func printVersionRow(
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := "expired"
|
||||
if version == currentVersion {
|
||||
status := "inactive"
|
||||
|
||||
switch {
|
||||
case version == currentVersion:
|
||||
status = "current"
|
||||
case sv.Metadata.NotAfter != nil && sv.Metadata.NotAfter.Before(time.Now()):
|
||||
status = "expired"
|
||||
}
|
||||
|
||||
// Format timestamps
|
||||
@@ -291,7 +303,7 @@ func (cli *Instance) PromoteVersion(
|
||||
version, vault.ErrVersionNotFound, secretName)
|
||||
}
|
||||
|
||||
// Update the current symlink using the proper function
|
||||
// Rewrite the secret's current file to name the version
|
||||
err = secret.SetCurrentVersion(cli.fs, secretDir, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update current version: %w", err)
|
||||
|
||||
@@ -5,9 +5,14 @@
|
||||
// - TestListVersionsCommand: Tests `secret version list` command output
|
||||
// - TestListVersionsNonExistentSecret: Tests error handling for missing secrets
|
||||
// - TestPromoteVersionCommand: Tests `secret version promote` command
|
||||
// - TestListVersionsStatusAfterPromote: Tests each version's status after
|
||||
// promoting an older version
|
||||
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
|
||||
// - TestPromoteHelpSaysCurrentFileIsRewritten: Tests `secret version promote
|
||||
// --help` text
|
||||
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
|
||||
// - TestGetSecretWritesBinaryValue: Tests `secret get` output of binary values
|
||||
// - TestGetSecretReturnsWriteError: Tests `secret get` when stdout fails
|
||||
// - TestVersionCommandStructure: Tests command structure and help text
|
||||
// - TestListVersionsEmptyOutput: Tests edge case with no versions
|
||||
//
|
||||
@@ -20,6 +25,7 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -240,6 +246,66 @@ func TestPromoteVersionCommand(t *testing.T) {
|
||||
assert.Equal(t, []byte("version-1"), promoted.Bytes())
|
||||
}
|
||||
|
||||
func TestListVersionsStatusAfterPromote(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
cli.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
setupTestVault(t, fs)
|
||||
|
||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt.Mnemonic = cli.Mnemonic
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||
addTestSecret(t, vlt, []byte("version-3"), true)
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Newest first
|
||||
versions, err := secret.ListVersions(fs,
|
||||
filepath.Join(vaultDir, "secrets.d", "test%secret"))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 3)
|
||||
|
||||
newest, middle, oldest := versions[0], versions[1], versions[2]
|
||||
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
|
||||
err = cli.PromoteVersion(cmd, "test/secret", oldest)
|
||||
require.NoError(t, err)
|
||||
|
||||
buf.Reset()
|
||||
|
||||
err = cli.ListVersions(cmd, "test/secret")
|
||||
require.NoError(t, err)
|
||||
|
||||
// STATUS is the fourth field of a row, since CREATED is a date and a time
|
||||
statuses := map[string]string{}
|
||||
|
||||
for line := range strings.SplitSeq(buf.String(), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 3 {
|
||||
statuses[fields[0]] = fields[3]
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, "current", statuses[oldest])
|
||||
// Its NOT_AFTER was set when the newest version was added
|
||||
assert.Equal(t, "expired", statuses[middle])
|
||||
// It has no NOT_AFTER
|
||||
assert.Equal(t, "inactive", statuses[newest])
|
||||
}
|
||||
|
||||
func TestPromoteNonExistentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -268,6 +334,23 @@ func TestPromoteNonExistentVersion(t *testing.T) {
|
||||
require.ErrorIs(t, err, vault.ErrVersionNotFound)
|
||||
}
|
||||
|
||||
// TestPromoteHelpSaysCurrentFileIsRewritten is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/153: `secret version promote
|
||||
// --help` said it updates a symlink, but promote rewrites the secret's
|
||||
// `current` file, which is a plain file.
|
||||
//
|
||||
//nolint:paralleltest // executes the CLI in-process and sets the environment
|
||||
func TestPromoteHelpSaysCurrentFileIsRewritten(t *testing.T) {
|
||||
env := map[string]string{secret.EnvStateDir: t.TempDir()}
|
||||
|
||||
output, err := ExecuteCommandInProcess(
|
||||
[]string{"version", "promote", "--help"}, "", env)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotContains(t, output, "symlink")
|
||||
assert.Contains(t, output, "current file")
|
||||
}
|
||||
|
||||
func TestGetSecretWithVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -360,6 +443,45 @@ func TestGetSecretWritesBinaryValue(t *testing.T) {
|
||||
assert.Equal(t, value, buf.Bytes())
|
||||
}
|
||||
|
||||
// TestGetSecretReturnsWriteError checks that `secret get`, with and without
|
||||
// a version, returns the error of a failed write of the value.
|
||||
func TestGetSecretReturnsWriteError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
cli.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
setupTestVault(t, fs)
|
||||
|
||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecret(t, vlt, []byte("value"), false)
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
versions, err := secret.ListVersions(fs,
|
||||
filepath.Join(vaultDir, "secrets.d", "test%secret"))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 1)
|
||||
|
||||
// Stdout is a file closed before the value is written to it
|
||||
out, err := os.Create(filepath.Join(t.TempDir(), "stdout"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, out.Close())
|
||||
|
||||
cmd := newRootCmd()
|
||||
cmd.SetOut(out)
|
||||
|
||||
err = cli.GetSecret(cmd, "test/secret")
|
||||
require.ErrorIs(t, err, os.ErrClosed)
|
||||
|
||||
err = cli.GetSecretWithVersion(cmd, "test/secret", versions[0])
|
||||
require.ErrorIs(t, err, os.ErrClosed)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // reads process environment to determine the state dir
|
||||
func TestVersionCommandStructure(t *testing.T) {
|
||||
// Test that version commands are properly structured
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
// Package macse provides Go bindings for macOS Secure Enclave operations
|
||||
// using CryptoTokenKit identities created via sc_auth.
|
||||
// Key creation and deletion shell out to sc_auth (which has SE entitlements).
|
||||
// Encrypt/decrypt use Security.framework ECIES directly (works unsigned).
|
||||
package macse
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c -fobjc-arc
|
||||
#cgo LDFLAGS: -framework Security -framework Foundation -framework CoreFoundation
|
||||
#include <stdlib.h>
|
||||
#include "secure_enclave.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// p256UncompressedKeySize is the size of an uncompressed P-256 public key.
|
||||
p256UncompressedKeySize = 65
|
||||
|
||||
// errorBufferSize is the size of the C error message buffer.
|
||||
errorBufferSize = 512
|
||||
|
||||
// hashBufferSize is the size of the hash output buffer.
|
||||
hashBufferSize = 128
|
||||
|
||||
// maxCiphertextSize is the max buffer for ECIES ciphertext.
|
||||
// ECIES overhead for P-256: 65 (ephemeral pub) + 16 (GCM tag) + 16 (IV) + plaintext.
|
||||
maxCiphertextSize = 8192
|
||||
|
||||
// maxPlaintextSize is the max buffer for decrypted plaintext.
|
||||
maxPlaintextSize = 8192
|
||||
)
|
||||
|
||||
// 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). If getting the public key fails, CreateKey deletes the key
|
||||
// again; a failure to delete is returned along with the first error.
|
||||
func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
||||
var hashBuf [hashBufferSize]C.char
|
||||
var errBuf [errorBufferSize]C.char
|
||||
|
||||
cLabel := C.CString(label)
|
||||
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||
|
||||
result := C.se_create_key(cLabel,
|
||||
&hashBuf[0], C.int(hashBufferSize),
|
||||
&errBuf[0], C.int(errorBufferSize))
|
||||
|
||||
if result != 0 {
|
||||
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||
}
|
||||
|
||||
h := C.GoString(&hashBuf[0])
|
||||
|
||||
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
|
||||
pubKeyLen := C.int(p256UncompressedKeySize)
|
||||
|
||||
result = C.se_copy_public_key(cLabel,
|
||||
&pubKeyBuf[0], &pubKeyLen,
|
||||
&errBuf[0], C.int(errorBufferSize))
|
||||
|
||||
if result != 0 {
|
||||
err = fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||
|
||||
deleteErr := DeleteKey(h)
|
||||
if deleteErr != nil {
|
||||
err = errors.Join(err,
|
||||
fmt.Errorf("failed to delete key %s: %w", label, deleteErr))
|
||||
}
|
||||
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
//nolint:nlreturn // CGo result extraction
|
||||
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
|
||||
|
||||
return pk, h, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext using the SE-backed public key via ECIES
|
||||
// (eciesEncryptionStandardVariableIVX963SHA256AESGCM).
|
||||
// Encryption uses only the public key; no SE interaction required.
|
||||
func Encrypt(label string, plaintext []byte) ([]byte, error) {
|
||||
ciphertextBuf := make([]C.uint8_t, maxCiphertextSize)
|
||||
ciphertextLen := C.int(maxCiphertextSize)
|
||||
var errBuf [errorBufferSize]C.char
|
||||
|
||||
cLabel := C.CString(label)
|
||||
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||
|
||||
result := C.se_encrypt(cLabel,
|
||||
(*C.uint8_t)(unsafe.Pointer(&plaintext[0])), C.int(len(plaintext)),
|
||||
&ciphertextBuf[0], &ciphertextLen,
|
||||
&errBuf[0], C.int(errorBufferSize))
|
||||
|
||||
if result != 0 {
|
||||
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||
}
|
||||
|
||||
//nolint:nlreturn // CGo result extraction
|
||||
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts ECIES ciphertext using the SE-backed private key.
|
||||
// The ECDH portion of decryption is performed inside the Secure Enclave.
|
||||
func Decrypt(label string, ciphertext []byte) ([]byte, error) {
|
||||
plaintextBuf := make([]C.uint8_t, maxPlaintextSize)
|
||||
plaintextLen := C.int(maxPlaintextSize)
|
||||
var errBuf [errorBufferSize]C.char
|
||||
|
||||
cLabel := C.CString(label)
|
||||
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||
|
||||
result := C.se_decrypt(cLabel,
|
||||
(*C.uint8_t)(unsafe.Pointer(&ciphertext[0])), C.int(len(ciphertext)),
|
||||
&plaintextBuf[0], &plaintextLen,
|
||||
&errBuf[0], C.int(errorBufferSize))
|
||||
|
||||
if result != 0 {
|
||||
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||
}
|
||||
|
||||
//nolint:nlreturn // CGo result extraction
|
||||
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteKey removes a CTK identity from the Secure Enclave via sc_auth.
|
||||
func DeleteKey(hash string) error {
|
||||
var errBuf [errorBufferSize]C.char
|
||||
|
||||
cHash := C.CString(hash)
|
||||
defer C.free(unsafe.Pointer(cHash)) //nolint:nlreturn // CGo free pattern
|
||||
|
||||
result := C.se_delete_key(cHash, &errBuf[0], C.int(errorBufferSize))
|
||||
|
||||
if result != 0 {
|
||||
return fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//go:build !darwin || !cgo
|
||||
|
||||
// Package macse provides Go bindings for macOS Secure Enclave operations.
|
||||
package macse
|
||||
|
||||
import "errors"
|
||||
|
||||
var errNotSupported = errors.New("secure enclave needs a macOS build with cgo")
|
||||
|
||||
// CreateKey fails: the Secure Enclave needs a macOS build with cgo.
|
||||
func CreateKey(_ string) ([]byte, string, error) {
|
||||
return nil, "", errNotSupported
|
||||
}
|
||||
|
||||
// Encrypt fails: the Secure Enclave needs a macOS build with cgo.
|
||||
func Encrypt(_ string, _ []byte) ([]byte, error) {
|
||||
return nil, errNotSupported
|
||||
}
|
||||
|
||||
// Decrypt fails: the Secure Enclave needs a macOS build with cgo.
|
||||
func Decrypt(_ string, _ []byte) ([]byte, error) {
|
||||
return nil, errNotSupported
|
||||
}
|
||||
|
||||
// DeleteKey fails: the Secure Enclave needs a macOS build with cgo.
|
||||
func DeleteKey(_ string) error {
|
||||
return errNotSupported
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package macse
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testKeyLabel = "berlin.sneak.app.secret.test.se-key"
|
||||
|
||||
// testKeyHash stores the hash of the created test key for cleanup.
|
||||
var testKeyHash string //nolint:gochecknoglobals
|
||||
|
||||
// skipIfNoSecureEnclave skips the test if SE access is unavailable.
|
||||
func skipIfNoSecureEnclave(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
probeLabel := "berlin.sneak.app.secret.test.se-probe"
|
||||
_, hash, err := CreateKey(probeLabel)
|
||||
if err != nil {
|
||||
t.Skipf("Secure Enclave unavailable (skipping): %v", err)
|
||||
}
|
||||
|
||||
if hash != "" {
|
||||
_ = DeleteKey(hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndDeleteKey(t *testing.T) {
|
||||
skipIfNoSecureEnclave(t)
|
||||
|
||||
if testKeyHash != "" {
|
||||
_ = DeleteKey(testKeyHash)
|
||||
}
|
||||
|
||||
pubKey, hash, err := CreateKey(testKeyLabel)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateKey failed: %v", err)
|
||||
}
|
||||
|
||||
testKeyHash = hash
|
||||
t.Logf("Created key with hash: %s", hash)
|
||||
|
||||
// Verify valid uncompressed P-256 public key
|
||||
if len(pubKey) != p256UncompressedKeySize {
|
||||
t.Fatalf("expected public key length %d, got %d",
|
||||
p256UncompressedKeySize, len(pubKey))
|
||||
}
|
||||
|
||||
if pubKey[0] != 0x04 {
|
||||
t.Fatalf("expected uncompressed point prefix 0x04, got 0x%02x", pubKey[0])
|
||||
}
|
||||
|
||||
if hash == "" {
|
||||
t.Fatal("expected non-empty hash")
|
||||
}
|
||||
|
||||
// Delete the key
|
||||
if err := DeleteKey(hash); err != nil {
|
||||
t.Fatalf("DeleteKey failed: %v", err)
|
||||
}
|
||||
|
||||
testKeyHash = ""
|
||||
t.Log("Key created, verified, and deleted successfully")
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
skipIfNoSecureEnclave(t)
|
||||
|
||||
_, hash, err := CreateKey(testKeyLabel)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateKey failed: %v", err)
|
||||
}
|
||||
|
||||
testKeyHash = hash
|
||||
|
||||
defer func() {
|
||||
if testKeyHash != "" {
|
||||
_ = DeleteKey(testKeyHash)
|
||||
testKeyHash = ""
|
||||
}
|
||||
}()
|
||||
|
||||
// Test data simulating an age private key
|
||||
plaintext := []byte("AGE-SECRET-KEY-1" +
|
||||
"QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
|
||||
|
||||
// Encrypt
|
||||
ciphertext, err := Encrypt(testKeyLabel, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Plaintext: %d bytes, Ciphertext: %d bytes", len(plaintext), len(ciphertext))
|
||||
|
||||
if bytes.Equal(ciphertext, plaintext) {
|
||||
t.Fatal("ciphertext should differ from plaintext")
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := Decrypt(testKeyLabel, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(decrypted, plaintext) {
|
||||
t.Fatalf("decrypted data does not match original plaintext")
|
||||
}
|
||||
|
||||
t.Log("ECIES encrypt/decrypt round-trip successful")
|
||||
}
|
||||
|
||||
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
||||
skipIfNoSecureEnclave(t)
|
||||
|
||||
_, hash, err := CreateKey(testKeyLabel)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateKey failed: %v", err)
|
||||
}
|
||||
|
||||
testKeyHash = hash
|
||||
|
||||
defer func() {
|
||||
if testKeyHash != "" {
|
||||
_ = DeleteKey(testKeyHash)
|
||||
testKeyHash = ""
|
||||
}
|
||||
}()
|
||||
|
||||
plaintext := []byte("test-secret-data")
|
||||
|
||||
ct1, err := Encrypt(testKeyLabel, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("first Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
ct2, err := Encrypt(testKeyLabel, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("second Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
// ECIES uses a random ephemeral key each time, so ciphertexts should differ
|
||||
if bytes.Equal(ct1, ct2) {
|
||||
t.Fatal("two encryptions of same plaintext should produce different ciphertexts")
|
||||
}
|
||||
|
||||
// Both should decrypt to the same plaintext
|
||||
dec1, err := Decrypt(testKeyLabel, ct1)
|
||||
if err != nil {
|
||||
t.Fatalf("first Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
dec2, err := Decrypt(testKeyLabel, ct2)
|
||||
if err != nil {
|
||||
t.Fatalf("second Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(dec1, plaintext) || !bytes.Equal(dec2, plaintext) {
|
||||
t.Fatal("both ciphertexts should decrypt to original plaintext")
|
||||
}
|
||||
|
||||
t.Log("ECIES correctly produces different ciphertexts that decrypt to same plaintext")
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
#ifndef SECURE_ENCLAVE_H
|
||||
#define SECURE_ENCLAVE_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth and
|
||||
// finds its identity hash. If the hash cannot be found, the key exists but
|
||||
// se_create_key fails, with an error naming the label.
|
||||
// label: unique identifier for the CTK identity (UTF-8 C string)
|
||||
// hash_out: output buffer for the identity hash (for deletion)
|
||||
// hash_out_len: size of hash_out buffer
|
||||
// error_out: output buffer for error message
|
||||
// error_out_len: size of error_out buffer
|
||||
// Returns 0 on success, -1 on failure.
|
||||
int se_create_key(const char *label,
|
||||
char *hash_out, int hash_out_len,
|
||||
char *error_out, int error_out_len);
|
||||
|
||||
// se_copy_public_key copies the public key of a CTK identity.
|
||||
// label: label of the CTK identity
|
||||
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
|
||||
// pub_key_len: on input, size of pub_key_out; on output, actual size written
|
||||
// error_out: output buffer for error message
|
||||
// error_out_len: size of error_out buffer
|
||||
// Returns 0 on success, -1 on failure.
|
||||
int se_copy_public_key(const char *label,
|
||||
uint8_t *pub_key_out, int *pub_key_len,
|
||||
char *error_out, int error_out_len);
|
||||
|
||||
// se_encrypt encrypts data using the SE-backed public key (ECIES).
|
||||
// label: label of the CTK identity whose public key to use
|
||||
// plaintext: data to encrypt
|
||||
// plaintext_len: length of plaintext
|
||||
// ciphertext_out: output buffer for the ECIES ciphertext
|
||||
// ciphertext_len: on input, size of buffer; on output, actual size written
|
||||
// error_out: output buffer for error message
|
||||
// error_out_len: size of error_out buffer
|
||||
// Returns 0 on success, -1 on failure.
|
||||
int se_encrypt(const char *label,
|
||||
const uint8_t *plaintext, int plaintext_len,
|
||||
uint8_t *ciphertext_out, int *ciphertext_len,
|
||||
char *error_out, int error_out_len);
|
||||
|
||||
// se_decrypt decrypts ECIES ciphertext using the SE-backed private key.
|
||||
// The ECDH portion of decryption is performed inside the Secure Enclave.
|
||||
// label: label of the CTK identity whose private key to use
|
||||
// ciphertext: ECIES ciphertext produced by se_encrypt
|
||||
// ciphertext_len: length of ciphertext
|
||||
// plaintext_out: output buffer for decrypted data
|
||||
// plaintext_len: on input, size of buffer; on output, actual size written
|
||||
// error_out: output buffer for error message
|
||||
// error_out_len: size of error_out buffer
|
||||
// Returns 0 on success, -1 on failure.
|
||||
int se_decrypt(const char *label,
|
||||
const uint8_t *ciphertext, int ciphertext_len,
|
||||
uint8_t *plaintext_out, int *plaintext_len,
|
||||
char *error_out, int error_out_len);
|
||||
|
||||
// se_delete_key removes a CTK identity from the Secure Enclave via sc_auth.
|
||||
// hash: the identity hash returned by se_create_key
|
||||
// error_out: output buffer for error message
|
||||
// error_out_len: size of error_out buffer
|
||||
// Returns 0 on success, -1 on failure.
|
||||
int se_delete_key(const char *hash,
|
||||
char *error_out, int error_out_len);
|
||||
|
||||
#endif // SECURE_ENCLAVE_H
|
||||
@@ -1,317 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <Security/Security.h>
|
||||
#include "secure_enclave.h"
|
||||
#include <string.h>
|
||||
|
||||
// snprintf_error writes an error message string to the output buffer.
|
||||
static void snprintf_error(char *error_out, int error_out_len, NSString *msg) {
|
||||
if (error_out && error_out_len > 0) {
|
||||
snprintf(error_out, error_out_len, "%s", msg.UTF8String);
|
||||
}
|
||||
}
|
||||
|
||||
// lookup_ctk_identity finds a CTK identity by label and returns the private key.
|
||||
static SecKeyRef lookup_ctk_private_key(const char *label, char *error_out, int error_out_len) {
|
||||
NSDictionary *query = @{
|
||||
(id)kSecClass: (id)kSecClassIdentity,
|
||||
(id)kSecAttrLabel: [NSString stringWithUTF8String:label],
|
||||
(id)kSecMatchLimit: (id)kSecMatchLimitOne,
|
||||
(id)kSecReturnRef: @YES,
|
||||
};
|
||||
|
||||
SecIdentityRef identity = NULL;
|
||||
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&identity);
|
||||
|
||||
if (status != errSecSuccess || !identity) {
|
||||
NSString *msg = [NSString stringWithFormat:@"CTK identity '%s' not found: OSStatus %d",
|
||||
label, (int)status];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
SecKeyRef privateKey = NULL;
|
||||
status = SecIdentityCopyPrivateKey(identity, &privateKey);
|
||||
CFRelease(identity);
|
||||
|
||||
if (status != errSecSuccess || !privateKey) {
|
||||
NSString *msg = [NSString stringWithFormat:
|
||||
@"failed to get private key from CTK identity '%s': OSStatus %d",
|
||||
label, (int)status];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
int se_create_key(const char *label,
|
||||
char *hash_out, int hash_out_len,
|
||||
char *error_out, int error_out_len) {
|
||||
@autoreleasepool {
|
||||
NSString *labelStr = [NSString stringWithUTF8String:label];
|
||||
|
||||
// Shell out to sc_auth (which has SE entitlements) to create the key
|
||||
NSTask *task = [[NSTask alloc] init];
|
||||
task.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
||||
task.arguments = @[
|
||||
@"create-ctk-identity",
|
||||
@"-k", @"p-256-ne",
|
||||
@"-t", @"none",
|
||||
@"-l", labelStr,
|
||||
];
|
||||
|
||||
NSPipe *stderrPipe = [NSPipe pipe];
|
||||
task.standardOutput = [NSPipe pipe];
|
||||
task.standardError = stderrPipe;
|
||||
|
||||
NSError *nsError = nil;
|
||||
if (![task launchAndReturnError:&nsError]) {
|
||||
NSString *msg = [NSString stringWithFormat:@"failed to launch sc_auth: %@",
|
||||
nsError.localizedDescription];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
[task waitUntilExit];
|
||||
|
||||
if (task.terminationStatus != 0) {
|
||||
NSData *stderrData = [stderrPipe.fileHandleForReading readDataToEndOfFile];
|
||||
NSString *stderrStr = [[NSString alloc] initWithData:stderrData
|
||||
encoding:NSUTF8StringEncoding];
|
||||
NSString *msg = [NSString stringWithFormat:@"sc_auth failed: %@",
|
||||
stderrStr ?: @"unknown error"];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Get the identity hash, which deleting the key needs, by parsing
|
||||
// sc_auth list output
|
||||
hash_out[0] = '\0';
|
||||
NSTask *listTask = [[NSTask alloc] init];
|
||||
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
||||
listTask.arguments = @[@"list-ctk-identities"];
|
||||
|
||||
NSPipe *listPipe = [NSPipe pipe];
|
||||
listTask.standardOutput = listPipe;
|
||||
listTask.standardError = [NSPipe pipe];
|
||||
|
||||
if ([listTask launchAndReturnError:&nsError]) {
|
||||
[listTask waitUntilExit];
|
||||
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
|
||||
NSString *listStr = [[NSString alloc] initWithData:listData
|
||||
encoding:NSUTF8StringEncoding];
|
||||
|
||||
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
|
||||
if ([line containsString:labelStr]) {
|
||||
NSMutableArray *tokens = [NSMutableArray array];
|
||||
for (NSString *part in [line componentsSeparatedByCharactersInSet:
|
||||
[NSCharacterSet whitespaceCharacterSet]]) {
|
||||
if (part.length > 0) {
|
||||
[tokens addObject:part];
|
||||
}
|
||||
}
|
||||
if (tokens.count > 1) {
|
||||
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hash_out[0] == '\0') {
|
||||
NSString *msg = [NSString stringWithFormat:
|
||||
@"created key '%s' but found no hash for it in sc_auth list-ctk-identities",
|
||||
label];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int se_copy_public_key(const char *label,
|
||||
uint8_t *pub_key_out, int *pub_key_len,
|
||||
char *error_out, int error_out_len) {
|
||||
@autoreleasepool {
|
||||
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||
if (!privateKey) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
|
||||
CFRelease(privateKey);
|
||||
|
||||
if (!publicKey) {
|
||||
snprintf_error(error_out, error_out_len, @"failed to get public key");
|
||||
return -1;
|
||||
}
|
||||
|
||||
CFErrorRef cfError = NULL;
|
||||
CFDataRef pubKeyData = SecKeyCopyExternalRepresentation(publicKey, &cfError);
|
||||
CFRelease(publicKey);
|
||||
|
||||
if (!pubKeyData) {
|
||||
NSError *err = (__bridge_transfer NSError *)cfError;
|
||||
NSString *msg = [NSString stringWithFormat:@"failed to export public key: %@",
|
||||
err.localizedDescription];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const UInt8 *bytes = CFDataGetBytePtr(pubKeyData);
|
||||
CFIndex length = CFDataGetLength(pubKeyData);
|
||||
|
||||
if (length > *pub_key_len) {
|
||||
CFRelease(pubKeyData);
|
||||
snprintf_error(error_out, error_out_len, @"public key buffer too small");
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(pub_key_out, bytes, length);
|
||||
*pub_key_len = (int)length;
|
||||
CFRelease(pubKeyData);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int se_encrypt(const char *label,
|
||||
const uint8_t *plaintext, int plaintext_len,
|
||||
uint8_t *ciphertext_out, int *ciphertext_len,
|
||||
char *error_out, int error_out_len) {
|
||||
@autoreleasepool {
|
||||
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||
if (!privateKey) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
|
||||
CFRelease(privateKey);
|
||||
|
||||
if (!publicKey) {
|
||||
snprintf_error(error_out, error_out_len, @"failed to get public key for encryption");
|
||||
return -1;
|
||||
}
|
||||
|
||||
NSData *plaintextData = [NSData dataWithBytes:plaintext length:plaintext_len];
|
||||
|
||||
CFErrorRef cfError = NULL;
|
||||
CFDataRef encrypted = SecKeyCreateEncryptedData(
|
||||
publicKey,
|
||||
kSecKeyAlgorithmECIESEncryptionStandardVariableIVX963SHA256AESGCM,
|
||||
(__bridge CFDataRef)plaintextData,
|
||||
&cfError
|
||||
);
|
||||
CFRelease(publicKey);
|
||||
|
||||
if (!encrypted) {
|
||||
NSError *nsError = (__bridge_transfer NSError *)cfError;
|
||||
NSString *msg = [NSString stringWithFormat:@"ECIES encryption failed: %@",
|
||||
nsError.localizedDescription];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const UInt8 *encBytes = CFDataGetBytePtr(encrypted);
|
||||
CFIndex encLength = CFDataGetLength(encrypted);
|
||||
|
||||
if (encLength > *ciphertext_len) {
|
||||
CFRelease(encrypted);
|
||||
snprintf_error(error_out, error_out_len, @"ciphertext buffer too small");
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(ciphertext_out, encBytes, encLength);
|
||||
*ciphertext_len = (int)encLength;
|
||||
CFRelease(encrypted);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int se_decrypt(const char *label,
|
||||
const uint8_t *ciphertext, int ciphertext_len,
|
||||
uint8_t *plaintext_out, int *plaintext_len,
|
||||
char *error_out, int error_out_len) {
|
||||
@autoreleasepool {
|
||||
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||
if (!privateKey) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
NSData *ciphertextData = [NSData dataWithBytes:ciphertext length:ciphertext_len];
|
||||
|
||||
CFErrorRef cfError = NULL;
|
||||
CFDataRef decrypted = SecKeyCreateDecryptedData(
|
||||
privateKey,
|
||||
kSecKeyAlgorithmECIESEncryptionStandardVariableIVX963SHA256AESGCM,
|
||||
(__bridge CFDataRef)ciphertextData,
|
||||
&cfError
|
||||
);
|
||||
CFRelease(privateKey);
|
||||
|
||||
if (!decrypted) {
|
||||
NSError *nsError = (__bridge_transfer NSError *)cfError;
|
||||
NSString *msg = [NSString stringWithFormat:@"ECIES decryption failed: %@",
|
||||
nsError.localizedDescription];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const UInt8 *decBytes = CFDataGetBytePtr(decrypted);
|
||||
CFIndex decLength = CFDataGetLength(decrypted);
|
||||
|
||||
if (decLength > *plaintext_len) {
|
||||
CFRelease(decrypted);
|
||||
snprintf_error(error_out, error_out_len, @"plaintext buffer too small");
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(plaintext_out, decBytes, decLength);
|
||||
*plaintext_len = (int)decLength;
|
||||
CFRelease(decrypted);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int se_delete_key(const char *hash,
|
||||
char *error_out, int error_out_len) {
|
||||
@autoreleasepool {
|
||||
NSTask *task = [[NSTask alloc] init];
|
||||
task.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
||||
task.arguments = @[
|
||||
@"delete-ctk-identity",
|
||||
@"-h", [NSString stringWithUTF8String:hash],
|
||||
];
|
||||
|
||||
NSPipe *stderrPipe = [NSPipe pipe];
|
||||
task.standardOutput = [NSPipe pipe];
|
||||
task.standardError = stderrPipe;
|
||||
|
||||
NSError *nsError = nil;
|
||||
if (![task launchAndReturnError:&nsError]) {
|
||||
NSString *msg = [NSString stringWithFormat:@"failed to launch sc_auth: %@",
|
||||
nsError.localizedDescription];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
[task waitUntilExit];
|
||||
|
||||
if (task.terminationStatus != 0) {
|
||||
NSData *stderrData = [stderrPipe.fileHandleForReading readDataToEndOfFile];
|
||||
NSString *stderrStr = [[NSString alloc] initWithData:stderrData
|
||||
encoding:NSUTF8StringEncoding];
|
||||
NSString *msg = [NSString stringWithFormat:@"sc_auth delete failed: %@",
|
||||
stderrStr ?: @"unknown error"];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,9 @@ func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
|
||||
|
||||
// RemoveDirAtomic deletes the directory dir so that it disappears in one
|
||||
// rename: dir is moved into a new directory from TempDirFor, which is then
|
||||
// deleted. A crash part-way leaves only that temporary directory behind.
|
||||
// deleted. A crash part-way leaves only that temporary directory behind. If
|
||||
// only deleting the temporary directory fails, dir is already gone, and the
|
||||
// error names the temporary directory, which holds what is left of it.
|
||||
func RemoveDirAtomic(fs afero.Fs, dir string) error {
|
||||
tmp, err := TempDirFor(fs, dir)
|
||||
if err != nil {
|
||||
@@ -165,7 +167,8 @@ func RemoveDirAtomic(fs afero.Fs, dir string) error {
|
||||
|
||||
err = fs.RemoveAll(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove %s: %w", dir, err)
|
||||
return fmt.Errorf("failed to remove %s after moving %s into it: %w",
|
||||
tmp, dir, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/macse"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
@@ -900,42 +899,3 @@ func TestWriteDirRefusesExistingDir(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecureEnclaveUnlockerFailureDeletesKey makes moving a new Secure
|
||||
// Enclave unlocker into place fail after its Secure Enclave key is created:
|
||||
// the key must be deleted again. Skipped when the add fails before that, as
|
||||
// it does everywhere but in a macOS build with cgo on a Mac with a Secure
|
||||
// Enclave.
|
||||
func TestSecureEnclaveUnlockerFailureDeletesKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
base := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The unlocker's directory is named se-<label of its Secure Enclave key>
|
||||
var seKeyLabel string
|
||||
|
||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||
if op == opRename && filepath.Base(filepath.Dir(path)) == "unlockers.d" {
|
||||
seKeyLabel = strings.TrimPrefix(filepath.Base(path), "se-")
|
||||
|
||||
return errInjected
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
_, err = secret.CreateSecureEnclaveUnlocker(fs, testVaultStateDir, mnemonic,
|
||||
nil)
|
||||
|
||||
if seKeyLabel == "" {
|
||||
t.Skipf("the add failed before moving the unlocker into place: %v", err)
|
||||
}
|
||||
|
||||
require.ErrorIs(t, err, errInjected)
|
||||
|
||||
_, err = macse.Encrypt(seKeyLabel, []byte("test"))
|
||||
assert.Error(t, err, "Secure Enclave key left behind")
|
||||
}
|
||||
|
||||
@@ -17,10 +17,14 @@ const (
|
||||
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||
|
||||
// TerminalPath opens the controlling terminal of the process that opens
|
||||
// it, where the passphrase and mnemonic prompts ask.
|
||||
TerminalPath = "/dev/tty"
|
||||
|
||||
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
|
||||
// new unlocker's directory, keychain item and Secure Enclave key. It runs
|
||||
// to the nanosecond, so that every new unlocker, even one added right
|
||||
// after another, gets a directory of its own.
|
||||
// new unlocker's directory. It runs to the nanosecond, so that every new
|
||||
// unlocker, even one added right after another, gets a directory of its
|
||||
// own.
|
||||
UnlockerTimeFormat = "2006-01-02.15.04.05.000000000"
|
||||
)
|
||||
|
||||
|
||||
+20
-27
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"filippo.io/age"
|
||||
@@ -16,14 +15,13 @@ import (
|
||||
|
||||
var (
|
||||
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
||||
errStdinNotTerminal = errors.New(
|
||||
"stdin is not a terminal (piped input or script)")
|
||||
errStderrNotTerminal = errors.New(
|
||||
"stderr is not a terminal (running in non-interactive mode)")
|
||||
errNothingEntered = errors.New("nothing was entered")
|
||||
errEmptyPassphrase = errors.New("passphrase cannot be empty")
|
||||
errNothingEntered = errors.New("nothing was entered")
|
||||
)
|
||||
|
||||
// ErrNoTerminal is wrapped in the error of ReadPassphrase and ReadMnemonic
|
||||
// when the process has no controlling terminal to ask on.
|
||||
var ErrNoTerminal = errors.New("there is no terminal to ask on")
|
||||
|
||||
// ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no
|
||||
// terminal to read the mnemonic from, reading it failed, or it was empty.
|
||||
var ErrMnemonicNotRead = errors.New("failed to read mnemonic")
|
||||
@@ -194,38 +192,33 @@ func ReadMnemonic(prompt string) (*memguard.LockedBuffer, error) {
|
||||
return readFromTerminal(prompt, ErrMnemonicNotRead, EnvMnemonic)
|
||||
}
|
||||
|
||||
// readFromTerminal reads input from the terminal without echoing it. Every
|
||||
// error it returns wraps notRead; without a terminal, the error says to set
|
||||
// envVar instead.
|
||||
// readFromTerminal asks on the controlling terminal and reads the answer
|
||||
// there without echoing it, whatever stdin is: in
|
||||
// `echo value | secret add name --force`, stdin is a pipe. Every error it
|
||||
// returns wraps notRead; without a terminal, the error says to set envVar
|
||||
// instead.
|
||||
func readFromTerminal(
|
||||
prompt string, notRead error, envVar string,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
// Check if stdin is a terminal
|
||||
if !term.IsTerminal(syscall.Stdin) {
|
||||
// Not a terminal - never read secrets from piped input
|
||||
// for security reasons
|
||||
terminal, err := os.OpenFile(TerminalPath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
Debug("Failed to open the controlling terminal", "error", err)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"%w: %w. Please set the %s environment variable or run interactively",
|
||||
notRead, errStdinNotTerminal, envVar)
|
||||
notRead, ErrNoTerminal, envVar)
|
||||
}
|
||||
defer func() { _ = terminal.Close() }()
|
||||
|
||||
// stdin is a terminal, check if stderr is also a terminal for
|
||||
// interactive prompting
|
||||
if !term.IsTerminal(syscall.Stderr) {
|
||||
return nil, fmt.Errorf("%w: %w. Please set the %s environment variable",
|
||||
notRead, errStderrNotTerminal, envVar)
|
||||
}
|
||||
_, _ = fmt.Fprint(terminal, prompt)
|
||||
|
||||
// Both stdin and stderr are terminals - use secure password reading
|
||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
||||
|
||||
input, err := term.ReadPassword(syscall.Stdin)
|
||||
input, err := term.ReadPassword(int(terminal.Fd()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", notRead, err)
|
||||
}
|
||||
|
||||
// Print newline to stderr since ReadPassword doesn't echo
|
||||
fmt.Fprintln(os.Stderr)
|
||||
// The newline typed after the input, which ReadPassword did not echo
|
||||
_, _ = fmt.Fprintln(terminal)
|
||||
|
||||
if len(input) == 0 {
|
||||
return nil, fmt.Errorf("%w: %w", notRead, errNothingEntered)
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
//nolint:testpackage // white-box test of unexported getLongTermPrivateKey
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// realVault is a minimal VaultInterface backed by a real afero filesystem,
|
||||
// using the same directory layout as vault.Vault.
|
||||
type realVault struct {
|
||||
name string
|
||||
stateDir string
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
func (v *realVault) GetDirectory() (string, error) {
|
||||
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
|
||||
}
|
||||
func (v *realVault) GetName() string { return v.name }
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
|
||||
|
||||
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
||||
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error {
|
||||
panic("not used")
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
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")
|
||||
}
|
||||
|
||||
// createRealVault sets up a complete vault directory structure on an in-memory
|
||||
// filesystem, identical to what vault.CreateVault produces.
|
||||
func createRealVault(
|
||||
t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32,
|
||||
) *realVault {
|
||||
t.Helper()
|
||||
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms))
|
||||
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "unlockers.d"), DirPerms))
|
||||
|
||||
metadata := VaultMetadata{
|
||||
CreatedAt: time.Now(),
|
||||
DerivationIndex: derivationIndex,
|
||||
}
|
||||
metaBytes, err := json.Marshal(metadata)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
|
||||
|
||||
return &realVault{name: name, stateDir: stateDir, fs: fs}
|
||||
}
|
||||
|
||||
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
//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.
|
||||
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
key5, err := agehd.DeriveIdentity(testMnemonic, 5)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, key0.String(), key5.String(),
|
||||
"sanity check: different derivation indices must produce different keys")
|
||||
|
||||
// Build a real vault with DerivationIndex=5 on an in-memory filesystem.
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := createRealVault(t, fs, "/state", "test-vault", 5)
|
||||
|
||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||
defer mnemonic.Destroy()
|
||||
|
||||
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer result.Destroy()
|
||||
|
||||
assert.Equal(t, key5.String(), string(result.Bytes()),
|
||||
"getLongTermPrivateKey should derive at vault's DerivationIndex (5)")
|
||||
assert.NotEqual(t, key0.String(), string(result.Bytes()),
|
||||
"getLongTermPrivateKey must not use hardcoded index 0")
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
)
|
||||
|
||||
var (
|
||||
errPassphraseLength = errors.New(
|
||||
"passphrase length must be a positive even number")
|
||||
errPassphraseNotHex = errors.New(
|
||||
"keychain passphrase must be lowercase hex")
|
||||
errNoKeychainPassphrase = errors.New(
|
||||
"keychain data has no agePrivKeyPassphrase string")
|
||||
)
|
||||
|
||||
// KeychainData is what a keychain unlocker stores in the macOS keychain.
|
||||
// It is stored as JSON, but encode and decodeKeychainData keep the
|
||||
// passphrase out of encoding/json, which would leave copies of it in
|
||||
// ordinary memory.
|
||||
type KeychainData struct {
|
||||
AgePublicKey string
|
||||
AgePrivKeyPassphrase *memguard.LockedBuffer
|
||||
EncryptedLongtermKey string
|
||||
}
|
||||
|
||||
// generateRandomPassphrase returns length random lowercase hex characters
|
||||
// in a locked buffer. The caller must destroy it.
|
||||
func generateRandomPassphrase(length int) (*memguard.LockedBuffer, error) {
|
||||
// Each random byte becomes two hex characters.
|
||||
randomBytes := hex.DecodedLen(length)
|
||||
if length <= 0 || hex.EncodedLen(randomBytes) != length {
|
||||
return nil, errPassphraseLength
|
||||
}
|
||||
|
||||
random := memguard.NewBufferRandom(randomBytes)
|
||||
defer random.Destroy()
|
||||
|
||||
passphrase := memguard.NewBuffer(length)
|
||||
hex.Encode(passphrase.Bytes(), random.Bytes())
|
||||
passphrase.Freeze()
|
||||
|
||||
return passphrase, nil
|
||||
}
|
||||
|
||||
// encode returns d as JSON in a locked buffer:
|
||||
// {"agePublicKey":"...","agePrivKeyPassphrase":"...","encryptedLongtermKey":"..."}.
|
||||
// The passphrase is copied straight into the buffer, so it must be hex,
|
||||
// which JSON does not escape. The caller must destroy the returned buffer.
|
||||
func (d *KeychainData) encode() (*memguard.LockedBuffer, error) {
|
||||
if d.AgePrivKeyPassphrase == nil {
|
||||
return nil, errNilPassphraseBuffer
|
||||
}
|
||||
|
||||
if d.AgePrivKeyPassphrase.Size() == 0 {
|
||||
return nil, errEmptyPassphrase
|
||||
}
|
||||
|
||||
for _, c := range d.AgePrivKeyPassphrase.Bytes() {
|
||||
if strings.IndexByte("0123456789abcdef", c) < 0 {
|
||||
return nil, errPassphraseNotHex
|
||||
}
|
||||
}
|
||||
|
||||
publicKey, err := json.Marshal(d.AgePublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode age public key: %w", err)
|
||||
}
|
||||
|
||||
longtermKey, err := json.Marshal(d.EncryptedLongtermKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode long-term key: %w", err)
|
||||
}
|
||||
|
||||
parts := [][]byte{
|
||||
[]byte(`{"agePublicKey":`), publicKey,
|
||||
[]byte(`,"agePrivKeyPassphrase":"`), d.AgePrivKeyPassphrase.Bytes(),
|
||||
[]byte(`","encryptedLongtermKey":`), longtermKey,
|
||||
[]byte(`}`),
|
||||
}
|
||||
|
||||
size := 0
|
||||
for _, part := range parts {
|
||||
size += len(part)
|
||||
}
|
||||
|
||||
encoded := memguard.NewBuffer(size)
|
||||
|
||||
written := 0
|
||||
for _, part := range parts {
|
||||
written += copy(encoded.Bytes()[written:], part)
|
||||
}
|
||||
|
||||
encoded.Freeze()
|
||||
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// decodeKeychainData parses keychain data written by encode. The caller
|
||||
// must destroy the returned AgePrivKeyPassphrase.
|
||||
func decodeKeychainData(data *memguard.LockedBuffer) (*KeychainData, error) {
|
||||
if data == nil {
|
||||
return nil, errNilDataBuffer
|
||||
}
|
||||
|
||||
// json.Unmarshal gives a json.RawMessage field the field's JSON text
|
||||
// unchanged, in the one copy RawMessage makes; it is wiped on return.
|
||||
var fields struct {
|
||||
AgePublicKey string `json:"agePublicKey"`
|
||||
AgePrivKeyPassphrase json.RawMessage `json:"agePrivKeyPassphrase"`
|
||||
EncryptedLongtermKey string `json:"encryptedLongtermKey"`
|
||||
}
|
||||
|
||||
defer func() { memguard.WipeBytes(fields.AgePrivKeyPassphrase) }()
|
||||
|
||||
err := json.Unmarshal(data.Bytes(), &fields)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
||||
}
|
||||
|
||||
// json.Unmarshal accepted the JSON, so text that starts with a quote is
|
||||
// a whole string. The passphrase is hex, so it is the text between the
|
||||
// quotes.
|
||||
quoted := fields.AgePrivKeyPassphrase
|
||||
if !bytes.HasPrefix(quoted, []byte(`"`)) {
|
||||
return nil, errNoKeychainPassphrase
|
||||
}
|
||||
|
||||
return &KeychainData{
|
||||
AgePublicKey: fields.AgePublicKey,
|
||||
// NewBufferFromBytes wipes the bytes it copies.
|
||||
AgePrivKeyPassphrase: memguard.NewBufferFromBytes(
|
||||
quoted[1 : len(quoted)-1]),
|
||||
EncryptedLongtermKey: fields.EncryptedLongtermKey,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateRandomPassphrase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := generateRandomPassphrase(64)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer first.Destroy()
|
||||
|
||||
second, err := generateRandomPassphrase(64)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer second.Destroy()
|
||||
|
||||
assert.Regexp(t, `^[0-9a-f]{64}$`, first.String())
|
||||
assert.NotEqual(t, first.String(), second.String())
|
||||
assert.False(t, first.IsMutable())
|
||||
|
||||
for _, length := range []int{0, -2, 63} {
|
||||
_, err := generateRandomPassphrase(length)
|
||||
require.ErrorIs(t, err, errPassphraseLength, "length %d", length)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeychainDataEncodeDecode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte("0a1b2c3d"))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
data := KeychainData{
|
||||
AgePublicKey: "age1example",
|
||||
AgePrivKeyPassphrase: passphrase,
|
||||
EncryptedLongtermKey: "beef",
|
||||
}
|
||||
|
||||
encoded, err := data.encode()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer encoded.Destroy()
|
||||
|
||||
assert.JSONEq(t,
|
||||
`{"agePublicKey":"age1example",`+
|
||||
`"agePrivKeyPassphrase":"0a1b2c3d",`+
|
||||
`"encryptedLongtermKey":"beef"}`,
|
||||
encoded.String())
|
||||
assert.False(t, encoded.IsMutable())
|
||||
|
||||
decoded, err := decodeKeychainData(encoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer decoded.AgePrivKeyPassphrase.Destroy()
|
||||
|
||||
assert.Equal(t, "age1example", decoded.AgePublicKey)
|
||||
assert.Equal(t, "0a1b2c3d", decoded.AgePrivKeyPassphrase.String())
|
||||
assert.Equal(t, "beef", decoded.EncryptedLongtermKey)
|
||||
}
|
||||
|
||||
func TestKeychainDataEncodeRejectsBadPassphrase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
passphrase *memguard.LockedBuffer
|
||||
wantErr error
|
||||
}{
|
||||
{"nil", nil, errNilPassphraseBuffer},
|
||||
{"empty", memguard.NewBuffer(0), errEmptyPassphrase},
|
||||
{
|
||||
"not hex",
|
||||
memguard.NewBufferFromBytes([]byte(`abc"def`)),
|
||||
errPassphraseNotHex,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := KeychainData{AgePrivKeyPassphrase: tt.passphrase}
|
||||
_, err := data.encode()
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeKeychainDataRejectsBadData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, text := range []string{
|
||||
`{"agePublicKey":"age1example"}`,
|
||||
`{"agePrivKeyPassphrase":42}`,
|
||||
} {
|
||||
data := memguard.NewBufferFromBytes([]byte(text))
|
||||
_, err := decodeKeychainData(data)
|
||||
data.Destroy()
|
||||
require.ErrorIs(t, err, errNoKeychainPassphrase, text)
|
||||
}
|
||||
|
||||
notJSON := memguard.NewBufferFromBytes([]byte(`{"agePrivKeyPassphrase":`))
|
||||
defer notJSON.Destroy()
|
||||
|
||||
_, err := decodeKeychainData(notJSON)
|
||||
|
||||
var syntaxError *json.SyntaxError
|
||||
require.ErrorAs(t, err, &syntaxError)
|
||||
}
|
||||
@@ -1,561 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
const (
|
||||
agePrivKeyPassphraseLength = 64
|
||||
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
|
||||
//
|
||||
//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
|
||||
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
||||
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
var (
|
||||
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
|
||||
type KeychainUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
// Keychain item name
|
||||
KeychainItemName string `json:"keychainItemName"`
|
||||
}
|
||||
|
||||
// KeychainUnlocker represents a macOS Keychain-protected unlocker
|
||||
type KeychainUnlocker struct {
|
||||
Directory string
|
||||
Metadata UnlockerMetadata
|
||||
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
|
||||
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
DebugWith("Getting keychain unlocker identity",
|
||||
slog.String("unlocker_id", k.GetID()),
|
||||
slog.String("unlocker_type", k.GetType()),
|
||||
)
|
||||
|
||||
keychainData, err := k.readKeychainData()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer keychainData.AgePrivKeyPassphrase.Destroy()
|
||||
|
||||
// Step 4: Read the encrypted age private key from filesystem
|
||||
agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
|
||||
Debug("Reading encrypted age private key", "path", agePrivKeyPath)
|
||||
|
||||
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted age private key",
|
||||
"error", err, "path", agePrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read encrypted age private key",
|
||||
slog.String("unlocker_id", k.GetID()),
|
||||
slog.Int("encrypted_length", len(encryptedAgePrivKeyData)),
|
||||
)
|
||||
|
||||
// Step 5: Decrypt the age private key using the passphrase from keychain
|
||||
Debug("Decrypting age private key with keychain passphrase",
|
||||
"unlocker_id", k.GetID())
|
||||
|
||||
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()
|
||||
|
||||
DebugWith("Successfully decrypted age private key with keychain passphrase",
|
||||
slog.String("unlocker_id", k.GetID()),
|
||||
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
|
||||
)
|
||||
|
||||
// Step 6: Parse the decrypted age private key
|
||||
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
|
||||
|
||||
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to parse age private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully parsed keychain age identity",
|
||||
slog.String("unlocker_id", k.GetID()),
|
||||
slog.String("public_key", ageIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
return ageIdentity, nil
|
||||
}
|
||||
|
||||
// GetType implements Unlocker interface
|
||||
func (k *KeychainUnlocker) GetType() string {
|
||||
return keychainUnlockerType
|
||||
}
|
||||
|
||||
// GetMetadata implements Unlocker interface
|
||||
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
|
||||
return k.Metadata
|
||||
}
|
||||
|
||||
// GetDirectory implements Unlocker interface
|
||||
func (k *KeychainUnlocker) GetDirectory() string {
|
||||
return k.Directory
|
||||
}
|
||||
|
||||
// GetID implements Unlocker interface: the name of the unlocker's directory
|
||||
func (k *KeychainUnlocker) GetID() string {
|
||||
return filepath.Base(k.Directory)
|
||||
}
|
||||
|
||||
// Remove implements Unlocker interface - removes the keychain unlocker
|
||||
func (k *KeychainUnlocker) Remove() error {
|
||||
// Step 1: Get keychain item name
|
||||
keychainItemName, err := k.GetKeychainItemName()
|
||||
if err != nil {
|
||||
Debug("Failed to get keychain item name during removal",
|
||||
"error", err, "unlocker_id", k.GetID())
|
||||
|
||||
return fmt.Errorf("failed to get keychain item name: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Remove from keychain
|
||||
Debug("Removing keychain item", "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)
|
||||
}
|
||||
|
||||
// Step 3: Remove directory
|
||||
Debug("Removing keychain unlocker directory", "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)
|
||||
}
|
||||
|
||||
Debug("Successfully removed keychain unlocker",
|
||||
"unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKeychainItemName returns the keychain item name from metadata
|
||||
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
||||
// Load the metadata
|
||||
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
|
||||
|
||||
metadataData, err := afero.ReadFile(k.fs, metadataPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
|
||||
}
|
||||
|
||||
var keychainMetadata KeychainUnlockerMetadata
|
||||
|
||||
err = json.Unmarshal(metadataData, &keychainMetadata)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
func generateKeychainUnlockerName(vaultName string) (string, error) {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||
}
|
||||
|
||||
// Format: secret-<vault>-<hostname>-<time>
|
||||
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
||||
|
||||
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil
|
||||
}
|
||||
|
||||
// getLongTermPrivateKey derives the long-term private key from mnemonic when
|
||||
// it is not nil, else gets it through the current unlocker, which is given
|
||||
// passphrase when it is a passphrase unlocker.
|
||||
// Returns a LockedBuffer to ensure the private key is protected in memory
|
||||
func getLongTermPrivateKey(
|
||||
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
if mnemonic != nil {
|
||||
return deriveLongTermPrivateKey(fs, vault, mnemonic)
|
||||
}
|
||||
|
||||
// Get the vault to access current unlocker
|
||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||
}
|
||||
|
||||
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
|
||||
passphraseUnlocker.Passphrase = passphrase
|
||||
}
|
||||
|
||||
// Get the current unlocker identity
|
||||
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
// Get encrypted long-term key from current unlocker, handling different types
|
||||
var encryptedLtPrivKey []byte
|
||||
|
||||
switch currentUnlocker := currentUnlocker.(type) {
|
||||
case *PassphraseUnlocker:
|
||||
// Read the encrypted long-term private key from passphrase unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
||||
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
||||
"from current passphrase unlocker: %w", err)
|
||||
}
|
||||
|
||||
case *PGPUnlocker:
|
||||
// Read the encrypted long-term private key from PGP unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
||||
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
||||
"from current PGP unlocker: %w", err)
|
||||
}
|
||||
|
||||
case *KeychainUnlocker:
|
||||
// Read the encrypted long-term private key from another keychain
|
||||
// unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
||||
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
||||
"from current keychain unlocker: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, errUnsupportedCurrentUnlocker
|
||||
}
|
||||
|
||||
// Decrypt long-term private key using current unlocker
|
||||
ltPrivKeyBuffer, err := DecryptWithIdentity(
|
||||
encryptedLtPrivKey, currentUnlockerIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Return the decrypted key buffer
|
||||
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 IdentityToLockedBuffer(ltIdentity), nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// the current unlocker, as getLongTermPrivateKey describes.
|
||||
func CreateKeychainUnlocker(
|
||||
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
|
||||
) (*KeychainUnlocker, error) {
|
||||
// Get current vault using the GetCurrentVault function from the same package
|
||||
vault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Generate the keychain item name
|
||||
keychainItemName, err := generateKeychainUnlockerName(vault.GetName())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
|
||||
}
|
||||
|
||||
// The unlocker directory is named after the keychain item
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
|
||||
|
||||
// Step 1: Generate a new age keypair for the keychain unlocker
|
||||
ageIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
||||
}
|
||||
|
||||
ageRecipient := ageIdentity.Recipient().String()
|
||||
|
||||
// Step 2: Generate a random passphrase for encrypting the age private key
|
||||
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
|
||||
}
|
||||
defer agePrivKeyPassphrase.Destroy()
|
||||
|
||||
// Step 3: Encrypt age private key with the generated passphrase
|
||||
agePrivKeyBuffer := IdentityToLockedBuffer(ageIdentity)
|
||||
defer agePrivKeyBuffer.Destroy()
|
||||
|
||||
encryptedAgePrivKey, err := EncryptWithPassphrase(
|
||||
agePrivKeyBuffer, agePrivKeyPassphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Get or derive the long-term private key
|
||||
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault, mnemonic, passphrase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer ltPrivKeyData.Destroy()
|
||||
|
||||
// Step 5: Encrypt long-term private key to the new age unlocker
|
||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(
|
||||
ltPrivKeyData, ageIdentity.Recipient())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to encrypt long-term private key to age unlocker: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: Prepare keychain data
|
||||
keychainData := KeychainData{
|
||||
AgePublicKey: ageRecipient,
|
||||
AgePrivKeyPassphrase: agePrivKeyPassphrase,
|
||||
EncryptedLongtermKey: hex.EncodeToString(encryptedLtPrivKeyToAge),
|
||||
}
|
||||
|
||||
keychainDataBuffer, err := keychainData.encode()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode keychain data: %w", err)
|
||||
}
|
||||
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).
|
||||
// The data is stored after the unlocker's files are written, and the keychain
|
||||
// item is deleted again if moving the unlocker into place then fails.
|
||||
func writeKeychainUnlocker(
|
||||
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
|
||||
encryptedAgePrivKey, encryptedLtPrivKey []byte,
|
||||
keychainDataBuffer *memguard.LockedBuffer,
|
||||
) (*KeychainUnlocker, error) {
|
||||
// Step 7: Prepare enhanced metadata
|
||||
keychainMetadata := KeychainUnlockerMetadata{
|
||||
UnlockerMetadata: UnlockerMetadata{
|
||||
Type: keychainUnlockerType,
|
||||
CreatedAt: time.Now(),
|
||||
Flags: []string{keychainUnlockerType, macOSFlag},
|
||||
},
|
||||
KeychainItemName: keychainItemName,
|
||||
}
|
||||
|
||||
metadataBytes, err := json.MarshalIndent(keychainMetadata, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
// Step 8: Write the unlocker's files, the metadata last, then store the
|
||||
// data in the keychain
|
||||
stored := false
|
||||
|
||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
||||
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write age recipient: %w", err)
|
||||
}
|
||||
|
||||
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age"), encryptedAgePrivKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
|
||||
metadataBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
err = storeInKeychain(keychainItemName, keychainDataBuffer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store data in keychain: %w", err)
|
||||
}
|
||||
|
||||
stored = true
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil && stored {
|
||||
deleteErr := deleteFromKeychain(keychainItemName)
|
||||
if deleteErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf(
|
||||
"failed to delete keychain item %s: %w", keychainItemName, deleteErr))
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &KeychainUnlocker{
|
||||
Directory: unlockerDir,
|
||||
Metadata: keychainMetadata.UnlockerMetadata,
|
||||
fs: fs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateKeychainItemName validates that a keychain item name is safe for
|
||||
// command execution
|
||||
func validateKeychainItemName(itemName string) error {
|
||||
if itemName == "" {
|
||||
return errKeychainItemNameEmpty
|
||||
}
|
||||
|
||||
if !keychainItemNameRegex.MatchString(itemName) {
|
||||
return fmt.Errorf("%w: %s", errInvalidKeychainItemName, itemName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
//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 errNilDataBuffer
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//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
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//go:build !darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// KeychainUnlockerMetadata is a stub for non-Darwin platforms
|
||||
type KeychainUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
KeychainItemName string `json:"keychainItemName"`
|
||||
}
|
||||
|
||||
// KeychainUnlocker is a stub for non-Darwin platforms
|
||||
type KeychainUnlocker struct {
|
||||
Directory string
|
||||
Metadata UnlockerMetadata
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
var errKeychainNotSupported = errors.New(
|
||||
"keychain unlockers are only supported on macOS")
|
||||
|
||||
// NewKeychainUnlocker creates a stub KeychainUnlocker on non-Darwin
|
||||
// platforms. The returned instance's methods that require macOS
|
||||
// functionality will return errors.
|
||||
func NewKeychainUnlocker(
|
||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
||||
) *KeychainUnlocker {
|
||||
return &KeychainUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity returns an error on non-Darwin platforms
|
||||
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
return nil, errKeychainNotSupported
|
||||
}
|
||||
|
||||
// GetType returns the unlocker type
|
||||
func (k *KeychainUnlocker) GetType() string {
|
||||
return "keychain"
|
||||
}
|
||||
|
||||
// GetMetadata returns the unlocker metadata
|
||||
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
|
||||
return k.Metadata
|
||||
}
|
||||
|
||||
// GetDirectory returns the unlocker directory
|
||||
func (k *KeychainUnlocker) GetDirectory() string {
|
||||
return k.Directory
|
||||
}
|
||||
|
||||
// GetID returns the unlocker ID, the name of the unlocker's directory
|
||||
func (k *KeychainUnlocker) GetID() string {
|
||||
return filepath.Base(k.Directory)
|
||||
}
|
||||
|
||||
// GetKeychainItemName returns an error on non-Darwin platforms
|
||||
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
||||
return "", errKeychainNotSupported
|
||||
}
|
||||
|
||||
// Remove returns an error on non-Darwin platforms
|
||||
func (k *KeychainUnlocker) Remove() error {
|
||||
return errKeychainNotSupported
|
||||
}
|
||||
|
||||
// CreateKeychainUnlocker returns an error on non-Darwin platforms
|
||||
func CreateKeychainUnlocker(
|
||||
_ afero.Fs, _ string, _, _ *memguard.LockedBuffer,
|
||||
) (*KeychainUnlocker, error) {
|
||||
return nil, errKeychainNotSupported
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKeychainStoreRetrieveDelete(t *testing.T) {
|
||||
// Skip test if not on macOS
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("Keychain tests only run on macOS")
|
||||
}
|
||||
|
||||
// Test data
|
||||
testItemName := "test-secret-keychain-item"
|
||||
testData := "test-secret-data-12345"
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte(testData))
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
// Clean up any existing item first
|
||||
_ = deleteFromKeychain(testItemName)
|
||||
|
||||
// Test 1: Store data in keychain
|
||||
err := storeInKeychain(testItemName, testBuffer)
|
||||
require.NoError(t, err, "Failed to store data in keychain")
|
||||
|
||||
// Test 2: Retrieve data from keychain
|
||||
retrievedData, err := retrieveFromKeychain(testItemName)
|
||||
require.NoError(t, err, "Failed to retrieve data from keychain")
|
||||
assert.Equal(t, testData, string(retrievedData),
|
||||
"Retrieved data doesn't match stored data")
|
||||
|
||||
// Test 3: Update existing item (store again with different data)
|
||||
newTestData := "updated-test-data-67890"
|
||||
newTestBuffer := memguard.NewBufferFromBytes([]byte(newTestData))
|
||||
defer newTestBuffer.Destroy()
|
||||
|
||||
err = storeInKeychain(testItemName, newTestBuffer)
|
||||
require.NoError(t, err, "Failed to update data in keychain")
|
||||
|
||||
// Verify updated data
|
||||
retrievedData, err = retrieveFromKeychain(testItemName)
|
||||
require.NoError(t, err, "Failed to retrieve updated data from keychain")
|
||||
assert.Equal(t, newTestData, string(retrievedData),
|
||||
"Retrieved data doesn't match updated data")
|
||||
|
||||
// Test 4: Delete from keychain
|
||||
err = deleteFromKeychain(testItemName)
|
||||
require.NoError(t, err, "Failed to delete data from keychain")
|
||||
|
||||
// Test 5: Verify item is deleted (should fail to retrieve)
|
||||
_, err = retrieveFromKeychain(testItemName)
|
||||
assert.Error(t, err, "Expected error when retrieving deleted item")
|
||||
}
|
||||
|
||||
func TestKeychainInvalidItemName(t *testing.T) {
|
||||
// Skip test if not on macOS
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("Keychain tests only run on macOS")
|
||||
}
|
||||
|
||||
testData := memguard.NewBufferFromBytes([]byte("test"))
|
||||
defer testData.Destroy()
|
||||
|
||||
// Test an empty item name
|
||||
err := storeInKeychain("", testData)
|
||||
require.ErrorIs(t, err, errKeychainItemNameEmpty)
|
||||
|
||||
// Test invalid item names
|
||||
invalidNames := []string{
|
||||
"test space", // Contains space
|
||||
"test/slash", // Contains slash
|
||||
"test\\backslash", // Contains backslash
|
||||
"test:colon", // Contains colon
|
||||
"test;semicolon", // Contains semicolon
|
||||
"test|pipe", // Contains pipe
|
||||
"test@at", // Contains @
|
||||
"test#hash", // Contains #
|
||||
"test$dollar", // Contains $
|
||||
"test&ersand", // Contains &
|
||||
"test*asterisk", // Contains *
|
||||
"test?question", // Contains ?
|
||||
"test!exclamation", // Contains !
|
||||
"test'quote", // Contains single quote
|
||||
"test\"doublequote", // Contains double quote
|
||||
"test(paren", // Contains parenthesis
|
||||
"test[bracket", // Contains bracket
|
||||
}
|
||||
|
||||
for _, name := range invalidNames {
|
||||
err := storeInKeychain(name, testData)
|
||||
require.ErrorIs(t, err, errInvalidKeychainItemName,
|
||||
"Expected error for invalid name: %s", name)
|
||||
}
|
||||
|
||||
// Test valid names (should not error on validation)
|
||||
validNames := []string{
|
||||
"test-name",
|
||||
"test_name",
|
||||
"test.name",
|
||||
"TestName123",
|
||||
"TEST_NAME_123",
|
||||
"com.example.test",
|
||||
"secret-vault-hostname-2024-01-01",
|
||||
}
|
||||
|
||||
for _, name := range validNames {
|
||||
err := validateKeychainItemName(name)
|
||||
assert.NoError(t, err, "Expected no error for valid name: %s", name)
|
||||
// Clean up
|
||||
_ = deleteFromKeychain(name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeychainNilData(t *testing.T) {
|
||||
// Skip test if not on macOS
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("Keychain tests only run on macOS")
|
||||
}
|
||||
|
||||
// Test storing nil data
|
||||
err := storeInKeychain("test-item", nil)
|
||||
require.ErrorIs(t, err, errNilDataBuffer)
|
||||
}
|
||||
|
||||
func TestKeychainLargeData(t *testing.T) {
|
||||
// Skip test if not on macOS
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("Keychain tests only run on macOS")
|
||||
}
|
||||
|
||||
// Test with larger hex-encoded data (512 bytes of binary data = 1KB hex)
|
||||
largeData := make([]byte, 512)
|
||||
for i := range largeData {
|
||||
largeData[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
// Convert to hex string for storage
|
||||
hexData := hex.EncodeToString(largeData)
|
||||
|
||||
testItemName := "test-large-data"
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte(hexData))
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
// Clean up first
|
||||
_ = deleteFromKeychain(testItemName)
|
||||
|
||||
// Store hex data
|
||||
err := storeInKeychain(testItemName, testBuffer)
|
||||
require.NoError(t, err, "Failed to store large data")
|
||||
|
||||
// Retrieve and verify
|
||||
retrievedData, err := retrieveFromKeychain(testItemName)
|
||||
require.NoError(t, err, "Failed to retrieve large data")
|
||||
|
||||
// Decode hex and compare
|
||||
decodedData, err := hex.DecodeString(string(retrievedData))
|
||||
require.NoError(t, err, "Failed to decode hex data")
|
||||
assert.Equal(t, largeData, decodedData, "Large data mismatch")
|
||||
|
||||
// Clean up
|
||||
_ = deleteFromKeychain(testItemName)
|
||||
}
|
||||
|
||||
func TestDeleteNonExistentKeychainItem(t *testing.T) {
|
||||
// Skip test if not on macOS
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("Keychain tests only run on macOS")
|
||||
}
|
||||
|
||||
// Ensure item doesn't exist
|
||||
testItemName := "test-nonexistent-keychain-item-12345"
|
||||
_ = deleteFromKeychain(testItemName)
|
||||
|
||||
// Deleting a non-existent item should NOT return an error
|
||||
// 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)
|
||||
err := deleteFromKeychain(testItemName)
|
||||
assert.NoError(t, err,
|
||||
"Deleting non-existent keychain item should not return an error")
|
||||
}
|
||||
|
||||
// TestWriteKeychainUnlockerFailureDeletesItem makes moving a new keychain
|
||||
// unlocker into place fail after its data is stored in the keychain: the
|
||||
// keychain item must be deleted again.
|
||||
func TestWriteKeychainUnlockerFailureDeletesItem(t *testing.T) {
|
||||
testItemName := "test-secret-keychain-unlocker-cleanup"
|
||||
_ = deleteFromKeychain(testItemName)
|
||||
|
||||
// Moving the unlocker into a read-only directory fails
|
||||
unlockersDir := filepath.Join(t.TempDir(), "unlockers.d")
|
||||
require.NoError(t, os.Mkdir(unlockersDir, 0o500))
|
||||
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-keychain-data"))
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
_, err := writeKeychainUnlocker(afero.NewOsFs(),
|
||||
filepath.Join(unlockersDir, testItemName), testItemName, "age1test",
|
||||
[]byte("test-priv"), []byte("test-longterm"), testBuffer)
|
||||
require.ErrorIs(t, err, os.ErrPermission,
|
||||
"moving the unlocker into place should fail")
|
||||
|
||||
_, err = retrieveFromKeychain(testItemName)
|
||||
assert.Error(t, err, "keychain item left behind")
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type VaultMetadata struct {
|
||||
|
||||
// UnlockerMetadata contains information about an unlocker
|
||||
type UnlockerMetadata struct {
|
||||
Type string `json:"type"` // passphrase, pgp, keychain
|
||||
Type string `json:"type"` // passphrase or pgp
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ import (
|
||||
// empty. A passphrase unlocker that fails with it was not tried.
|
||||
var ErrPassphraseNotRead = errors.New("failed to read passphrase")
|
||||
|
||||
// ErrWrongPassphrase is wrapped in the error of GetIdentity when the
|
||||
// passphrase does not decrypt the unlocker's private key.
|
||||
var ErrWrongPassphrase = errors.New("wrong passphrase")
|
||||
|
||||
// PassphraseUnlocker represents a passphrase-protected unlocker
|
||||
type PassphraseUnlocker struct {
|
||||
Directory string
|
||||
@@ -73,6 +77,12 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
Debug("Failed to decrypt unlocker private key",
|
||||
"error", err, "unlocker_id", p.GetID())
|
||||
|
||||
// age says that no identity matched when the passphrase is wrong
|
||||
var noMatch *age.NoIdentityMatchError
|
||||
if errors.As(err, &noMatch) {
|
||||
return nil, fmt.Errorf("%w for unlocker %s", ErrWrongPassphrase, p.GetID())
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
|
||||
}
|
||||
defer privKeyBuffer.Destroy()
|
||||
@@ -128,7 +138,6 @@ func (p *PassphraseUnlocker) Remove() error {
|
||||
}
|
||||
|
||||
// For passphrase unlockers, we just need to remove the directory
|
||||
// No external resources (like keychain items) to clean up
|
||||
err := RemoveDirAtomic(p.fs, p.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
||||
|
||||
@@ -375,8 +375,7 @@ func testCreatePGPUnlocker(
|
||||
}
|
||||
|
||||
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
|
||||
pgpUnlocker, err := secret.CreatePGPUnlocker(
|
||||
fs, stateDir, keyID, fingerprint, mnemonic, nil)
|
||||
pgpUnlocker, err := secret.CreatePGPUnlocker(vlt, keyID, fingerprint)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create PGP unlock key: %v", err)
|
||||
}
|
||||
|
||||
@@ -171,7 +171,6 @@ func (p *PGPUnlocker) GetID() string {
|
||||
// Remove implements Unlocker interface - removes the PGP unlocker
|
||||
func (p *PGPUnlocker) Remove() error {
|
||||
// For PGP unlockers, we just need to remove the directory
|
||||
// No external resources (like keychain items) to clean up
|
||||
err := RemoveDirAtomic(p.fs, p.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
||||
@@ -193,58 +192,45 @@ func generatePGPUnlockerName() (string, error) {
|
||||
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
|
||||
}
|
||||
|
||||
// pgpUnlockerDir returns the current vault and the directory in it for a
|
||||
// new PGP unlocker, named after the host and the time.
|
||||
//
|
||||
//nolint:ireturn // the vault is only available behind VaultInterface
|
||||
func pgpUnlockerDir(
|
||||
fs afero.Fs, stateDir string,
|
||||
) (VaultInterface, string, error) {
|
||||
// Get current vault
|
||||
vault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// pgpUnlockerDir returns the directory in vault for a new PGP unlocker,
|
||||
// named after the host and the time.
|
||||
func pgpUnlockerDir(vault VaultInterface) (string, error) {
|
||||
// Generate the unlocker name based on hostname and time
|
||||
unlockerName, err := generatePGPUnlockerName()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
||||
return "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
||||
}
|
||||
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
|
||||
return "", fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
return vault, filepath.Join(vaultDir, "unlockers.d", unlockerName), nil
|
||||
return filepath.Join(vaultDir, "unlockers.d", unlockerName), nil
|
||||
}
|
||||
|
||||
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault.
|
||||
// CreatePGPUnlocker creates a new PGP unlocker and stores it in vault.
|
||||
// It encrypts to the GPG key gpgKeyID and records fingerprint, that key's
|
||||
// fingerprint as ResolveGPGKeyFingerprint returns it, in the metadata.
|
||||
// Everything that can fail short of writing a file is done before anything
|
||||
// is written, and the files are written through WriteDir, so a failure
|
||||
// leaves no partial unlocker. The long-term key comes from mnemonic when it
|
||||
// is not nil, else from the current unlocker, which is given passphrase when
|
||||
// it is a passphrase unlocker.
|
||||
// leaves no partial unlocker. The long-term key comes from vault's
|
||||
// GetOrDeriveLongTermKey: the key it holds when it is unlocked, else the
|
||||
// key from its mnemonic or its current unlocker.
|
||||
func CreatePGPUnlocker(
|
||||
fs afero.Fs, stateDir, gpgKeyID, fingerprint string,
|
||||
mnemonic, passphrase *memguard.LockedBuffer,
|
||||
vault VaultInterface, gpgKeyID, fingerprint string,
|
||||
) (*PGPUnlocker, error) {
|
||||
err := checkGPGAvailable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vault, unlockerDir, err := pgpUnlockerDir(fs, stateDir)
|
||||
unlockerDir, err := pgpUnlockerDir(vault)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The vault's GetOrDeriveLongTermKey, in step 2, uses both
|
||||
vault.SetMnemonic(mnemonic)
|
||||
vault.SetUnlockPassphrase(passphrase)
|
||||
fs := vault.GetFilesystem()
|
||||
|
||||
// Step 1: Generate a new age keypair for the PGP unlocker
|
||||
ageIdentity, err := age.GenerateX25519Identity()
|
||||
@@ -296,7 +282,7 @@ func CreatePGPUnlocker(
|
||||
func encryptPGPUnlockerKeys(
|
||||
vault VaultInterface, ageIdentity *age.X25519Identity, gpgKeyID string,
|
||||
) ([]byte, []byte, error) {
|
||||
// From the mnemonic or the current unlocker, as for a passphrase unlocker
|
||||
// The key vault holds, or else from its mnemonic or current unlocker
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||
@@ -389,7 +375,8 @@ func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||
return "", fmt.Errorf("%w: %s", ErrGPGKeyNotFound, keyID)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
return "", fmt.Errorf(
|
||||
"failed to resolve GPG key fingerprint: %w", withGPGStderr(err))
|
||||
}
|
||||
|
||||
// Parse the output to extract the fingerprint
|
||||
@@ -419,6 +406,24 @@ func checkGPGAvailable() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// withGPGStderr appends gpg's stderr, which says why gpg failed, to err
|
||||
// from cmd.Output. cmd.Output keeps that stderr in the *exec.ExitError.
|
||||
// err is returned unchanged when gpg wrote nothing to stderr.
|
||||
func withGPGStderr(err error) error {
|
||||
var exitErr *exec.ExitError
|
||||
|
||||
if !errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
|
||||
stderr := strings.TrimSpace(string(exitErr.Stderr))
|
||||
if stderr == "" {
|
||||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %s", err, stderr)
|
||||
}
|
||||
|
||||
// gpgEncryptDefault is the default implementation of GPG encryption
|
||||
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||
if data == nil {
|
||||
@@ -438,7 +443,7 @@ func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GPG encryption failed: %w", err)
|
||||
return nil, fmt.Errorf("GPG encryption failed: %w", withGPGStderr(err))
|
||||
}
|
||||
|
||||
return output, nil
|
||||
@@ -451,7 +456,7 @@ func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GPG decryption failed: %w", err)
|
||||
return nil, fmt.Errorf("GPG decryption failed: %w", withGPGStderr(err))
|
||||
}
|
||||
|
||||
// Create a secure buffer for the decrypted data
|
||||
|
||||
@@ -58,7 +58,8 @@ func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
|
||||
}}
|
||||
|
||||
_, err = secret.CreatePGPUnlocker(
|
||||
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, nil, nil)
|
||||
vault.NewVault(fs, testVaultStateDir, testVaultName),
|
||||
testGPGKeyID, testGPGFingerprint)
|
||||
require.Error(t, err)
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
@@ -87,18 +88,40 @@ func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic, nil)
|
||||
vlt, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
first, err := secret.CreatePGPUnlocker(
|
||||
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
|
||||
first, err := secret.CreatePGPUnlocker(vlt, testGPGKeyID, testGPGFingerprint)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstFiles := dirFiles(t, fs, first.GetDirectory())
|
||||
|
||||
second, err := secret.CreatePGPUnlocker(
|
||||
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
|
||||
second, err := secret.CreatePGPUnlocker(vlt, testGPGKeyID, testGPGFingerprint)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, first.GetDirectory(), second.GetDirectory())
|
||||
assert.Equal(t, firstFiles, dirFiles(t, fs, first.GetDirectory()))
|
||||
}
|
||||
|
||||
// TestGPGDecryptErrorHasGPGReason runs the real gpg on data that is not an
|
||||
// OpenPGP message and checks that the error carries the reason gpg writes
|
||||
// to stderr.
|
||||
func TestGPGDecryptErrorHasGPGReason(t *testing.T) {
|
||||
// The empty GNUPGHOME keeps gpg away from the user's own keyring;
|
||||
// LC_ALL=C keeps gpg's message in English.
|
||||
t.Setenv("GNUPGHOME", t.TempDir())
|
||||
t.Setenv("LC_ALL", "C")
|
||||
|
||||
_, err := secret.GPGDecryptFunc([]byte("not an OpenPGP message"))
|
||||
require.ErrorContains(t, err, "gpg: no valid OpenPGP data found")
|
||||
}
|
||||
|
||||
// TestGPGDecryptErrorWithoutGPGStderr checks that when gpg fails without
|
||||
// writing to stderr, the error ends with the exit status and nothing after it.
|
||||
//
|
||||
//nolint:paralleltest // installFakeGPG uses t.Setenv
|
||||
func TestGPGDecryptErrorWithoutGPGStderr(t *testing.T) {
|
||||
installFakeGPG(t)
|
||||
|
||||
_, err := secret.GPGDecryptFunc([]byte("not an OpenPGP message"))
|
||||
require.EqualError(t, err, "GPG decryption failed: exit status 1")
|
||||
}
|
||||
|
||||
+38
-20
@@ -2,10 +2,11 @@ package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
@@ -27,11 +28,6 @@ type VaultInterface interface {
|
||||
GetFilesystem() afero.Fs
|
||||
GetCurrentUnlocker() (Unlocker, error)
|
||||
GetOrDeriveLongTermKey() (*age.X25519Identity, error)
|
||||
// SetMnemonic and SetUnlockPassphrase give GetOrDeriveLongTermKey the
|
||||
// mnemonic to derive the long-term key from, and the passphrase for a
|
||||
// current passphrase unlocker; nil for none.
|
||||
SetMnemonic(mnemonic *memguard.LockedBuffer)
|
||||
SetUnlockPassphrase(passphrase *memguard.LockedBuffer)
|
||||
CreatePassphraseUnlocker(
|
||||
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||
}
|
||||
@@ -66,31 +62,53 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
||||
Name: name,
|
||||
Directory: secretDir,
|
||||
vault: vault,
|
||||
Metadata: Metadata{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||
// LoadMetadata sets the secret's times from the modification times of its
|
||||
// files, which can be read without unlocking the vault. UpdatedAt is when
|
||||
// the secret's current file was last written, by an add or a promote.
|
||||
// CreatedAt is when its oldest version's value.age was written. That file
|
||||
// is never rewritten; the version's directory is no use, as its time
|
||||
// changes when the next add rewrites the version's metadata.age.
|
||||
func (s *Secret) LoadMetadata() error {
|
||||
Debug("LoadMetadata called but is deprecated in versioned model",
|
||||
"secret_name", s.Name)
|
||||
// For backward compatibility, we'll populate with basic info
|
||||
now := time.Now()
|
||||
fs := s.vault.GetFilesystem()
|
||||
|
||||
current, err := fs.Stat(filepath.Join(s.Directory, "current"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read when secret %s was updated: %w",
|
||||
s.Name, err)
|
||||
}
|
||||
|
||||
versions, err := ListVersions(fs, s.Directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
return fmt.Errorf("secret %s has no versions: %w", s.Name, os.ErrNotExist)
|
||||
}
|
||||
|
||||
// ListVersions lists the newest version first
|
||||
oldest := versions[len(versions)-1]
|
||||
|
||||
value, err := fs.Stat(
|
||||
filepath.Join(s.Directory, "versions", oldest, "value.age"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read when secret %s was created: %w",
|
||||
s.Name, err)
|
||||
}
|
||||
|
||||
s.Metadata = Metadata{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CreatedAt: value.ModTime(),
|
||||
UpdatedAt: current.ModTime(),
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetadata returns the secret metadata (deprecated)
|
||||
// GetMetadata returns the times LoadMetadata read
|
||||
func (s *Secret) GetMetadata() Metadata {
|
||||
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
|
||||
return s.Metadata
|
||||
}
|
||||
|
||||
|
||||
@@ -118,12 +118,6 @@ func (m *MockVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
func (m *MockVault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
|
||||
m.mnemonic = mnemonic
|
||||
}
|
||||
|
||||
func (m *MockVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
||||
|
||||
func (m *MockVault) CreatePassphraseUnlocker(
|
||||
_ *memguard.LockedBuffer,
|
||||
) (*PassphraseUnlocker, error) {
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/macse"
|
||||
)
|
||||
|
||||
const (
|
||||
// seKeyLabelPrefix is the prefix for Secure Enclave CTK identity labels.
|
||||
seKeyLabelPrefix = "berlin.sneak.app.secret.se"
|
||||
|
||||
// seUnlockerType is the metadata type string for Secure Enclave unlockers.
|
||||
seUnlockerType = "secure-enclave"
|
||||
|
||||
// seLongtermFilename is the filename for the SE-encrypted vault long-term private key.
|
||||
seLongtermFilename = "longterm.age.se"
|
||||
)
|
||||
|
||||
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
|
||||
type SecureEnclaveUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
SEKeyLabel string `json:"seKeyLabel"`
|
||||
SEKeyHash string `json:"seKeyHash"`
|
||||
}
|
||||
|
||||
// SecureEnclaveUnlocker represents a Secure Enclave-protected unlocker.
|
||||
type SecureEnclaveUnlocker struct {
|
||||
Directory string
|
||||
Metadata UnlockerMetadata
|
||||
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.
|
||||
// Decrypts the vault's long-term private key directly using the Secure Enclave.
|
||||
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
DebugWith("Getting SE unlocker identity",
|
||||
slog.String("unlocker_id", s.GetID()),
|
||||
)
|
||||
|
||||
// Get SE key label from metadata
|
||||
seKeyLabel, _, err := s.getSEKeyInfo()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get SE key info: %w", err)
|
||||
}
|
||||
|
||||
// Read ECIES-encrypted long-term private key from disk
|
||||
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
|
||||
|
||||
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to read SE-encrypted long-term key: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
DebugWith("Read SE-encrypted long-term key",
|
||||
slog.Int("encrypted_length", len(encryptedData)),
|
||||
)
|
||||
|
||||
// Decrypt using the Secure Enclave (ECDH happens inside SE hardware)
|
||||
decryptedData, err := macse.Decrypt(seKeyLabel, encryptedData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to decrypt long-term key with SE: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
// Parse the decrypted long-term private key
|
||||
ltIdentity, err := age.ParseX25519Identity(string(decryptedData))
|
||||
|
||||
// Clear sensitive data immediately
|
||||
for i := range decryptedData {
|
||||
decryptedData[i] = 0
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to parse long-term private key: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted long-term key via SE",
|
||||
slog.String("unlocker_id", s.GetID()),
|
||||
)
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// GetType implements Unlocker interface.
|
||||
func (s *SecureEnclaveUnlocker) GetType() string {
|
||||
return seUnlockerType
|
||||
}
|
||||
|
||||
// GetMetadata implements Unlocker interface.
|
||||
func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata {
|
||||
return s.Metadata
|
||||
}
|
||||
|
||||
// GetDirectory implements Unlocker interface.
|
||||
func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
||||
return s.Directory
|
||||
}
|
||||
|
||||
// GetID implements Unlocker interface: the name of the unlocker's directory.
|
||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||
return filepath.Base(s.Directory)
|
||||
}
|
||||
|
||||
// Remove implements Unlocker interface.
|
||||
func (s *SecureEnclaveUnlocker) Remove() error {
|
||||
_, seKeyHash, err := s.getSEKeyInfo()
|
||||
if err != nil {
|
||||
Debug("Failed to get SE key info during removal", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get SE key info: %w", err)
|
||||
}
|
||||
|
||||
if seKeyHash != "" {
|
||||
Debug("Deleting SE key", "hash", seKeyHash)
|
||||
|
||||
err = macse.DeleteKey(seKeyHash)
|
||||
if err != nil {
|
||||
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
|
||||
|
||||
return fmt.Errorf("failed to delete SE key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
Debug("Removing SE unlocker directory", "directory", s.Directory)
|
||||
|
||||
err = RemoveDirAtomic(s.fs, s.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully removed SE unlocker", "unlocker_id", s.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getSEKeyInfo reads the SE key label and hash from metadata.
|
||||
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (string, string, error) {
|
||||
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
|
||||
|
||||
metadataData, err := afero.ReadFile(s.fs, metadataPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
|
||||
}
|
||||
|
||||
var seMetadata SecureEnclaveUnlockerMetadata
|
||||
|
||||
err = json.Unmarshal(metadataData, &seMetadata)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
|
||||
}
|
||||
|
||||
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
|
||||
}
|
||||
|
||||
// generateSEKeyLabel generates a unique label for the SE CTK identity.
|
||||
func generateSEKeyLabel(vaultName string) (string, error) {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||
}
|
||||
|
||||
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s.%s-%s-%s",
|
||||
seKeyLabelPrefix,
|
||||
vaultName,
|
||||
hostname,
|
||||
enrollmentTime,
|
||||
), nil
|
||||
}
|
||||
|
||||
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
|
||||
// The vault's long-term private key is encrypted directly by the Secure Enclave
|
||||
// using ECIES. No intermediate age keypair is used.
|
||||
// The long-term key comes from mnemonic when it is not nil, else from the
|
||||
// current unlocker, as getLongTermKeyForSE describes.
|
||||
// The SE key is created once the long-term key is in hand and the unlocker's
|
||||
// path is known, and is deleted again if a later step fails.
|
||||
func CreateSecureEnclaveUnlocker(
|
||||
fs afero.Fs,
|
||||
stateDir string,
|
||||
mnemonic, passphrase *memguard.LockedBuffer,
|
||||
) (*SecureEnclaveUnlocker, error) {
|
||||
vault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Generate SE key label
|
||||
seKeyLabel, err := generateSEKeyLabel(vault.GetName())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
|
||||
}
|
||||
|
||||
// Step 1: Get the vault's long-term private key
|
||||
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to get long-term private key: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
defer ltPrivKeyData.Destroy()
|
||||
|
||||
// Step 2: Prepare the unlocker directory's path
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
unlockerDirName := "se-" + filepath.Base(seKeyLabel)
|
||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
||||
|
||||
// Step 3: Create P-256 key in the Secure Enclave via sc_auth
|
||||
Debug("Creating Secure Enclave key", "label", seKeyLabel)
|
||||
|
||||
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SE key: %w", err)
|
||||
}
|
||||
|
||||
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
|
||||
|
||||
// Steps 4 and 5: Write the unlocker, or delete the SE key if that fails
|
||||
unlocker, err := writeSEUnlocker(fs, unlockerDir, seKeyLabel, seKeyHash,
|
||||
ltPrivKeyData)
|
||||
if err != nil {
|
||||
deleteErr := macse.DeleteKey(seKeyHash)
|
||||
if deleteErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf(
|
||||
"failed to delete SE key %s: %w", seKeyLabel, deleteErr))
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return unlocker, nil
|
||||
}
|
||||
|
||||
// writeSEUnlocker encrypts the long-term key with the SE key and writes the
|
||||
// new unlocker into unlockerDir (steps 4 and 5 of
|
||||
// CreateSecureEnclaveUnlocker).
|
||||
func writeSEUnlocker(
|
||||
fs afero.Fs, unlockerDir, seKeyLabel, seKeyHash string,
|
||||
ltPrivKeyData *memguard.LockedBuffer,
|
||||
) (*SecureEnclaveUnlocker, error) {
|
||||
// Step 4: Encrypt the long-term key directly with the SE (ECIES), and
|
||||
// prepare the metadata
|
||||
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to encrypt long-term key with SE: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
seMetadata := SecureEnclaveUnlockerMetadata{
|
||||
UnlockerMetadata: UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Flags: []string{seUnlockerType, macOSFlag},
|
||||
},
|
||||
SEKeyLabel: seKeyLabel,
|
||||
SEKeyHash: seKeyHash,
|
||||
}
|
||||
|
||||
metadataBytes, err := json.MarshalIndent(seMetadata, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Write the SE-encrypted long-term key, then the metadata
|
||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
||||
return writeSEUnlockerFiles(fs, dir, encryptedLtKey, metadataBytes)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SecureEnclaveUnlocker{
|
||||
Directory: unlockerDir,
|
||||
Metadata: seMetadata.UnlockerMetadata,
|
||||
fs: fs,
|
||||
}, 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
|
||||
// from mnemonic when it is not nil, else through the current unlocker, which
|
||||
// is given passphrase when it is a passphrase unlocker.
|
||||
func getLongTermKeyForSE(
|
||||
fs afero.Fs,
|
||||
vault VaultInterface,
|
||||
mnemonic, passphrase *memguard.LockedBuffer,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
if mnemonic != nil {
|
||||
return deriveLongTermPrivateKey(fs, vault, mnemonic)
|
||||
}
|
||||
|
||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||
}
|
||||
|
||||
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
|
||||
passphraseUnlocker.Passphrase = passphrase
|
||||
}
|
||||
|
||||
currentIdentity, err := currentUnlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to get current unlocker identity: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
// All unlocker types store longterm.age in their directory
|
||||
longtermPath := filepath.Join(
|
||||
currentUnlocker.GetDirectory(),
|
||||
"longterm.age",
|
||||
)
|
||||
|
||||
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to read encrypted long-term key: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
ltPrivKeyBuffer, err := DecryptWithIdentity(
|
||||
encryptedLtKey,
|
||||
currentIdentity,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term key: %w", err)
|
||||
}
|
||||
|
||||
return ltPrivKeyBuffer, nil
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
//go:build !darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// seUnlockerType is the type string for Secure Enclave unlockers.
|
||||
const seUnlockerType = "secure-enclave"
|
||||
|
||||
var errSENotSupported = errors.New(
|
||||
"secure enclave unlockers are only supported on macOS",
|
||||
)
|
||||
|
||||
// SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms.
|
||||
type SecureEnclaveUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
SEKeyLabel string `json:"seKeyLabel"`
|
||||
SEKeyHash string `json:"seKeyHash"`
|
||||
}
|
||||
|
||||
// SecureEnclaveUnlocker is a stub for non-Darwin platforms.
|
||||
type SecureEnclaveUnlocker struct {
|
||||
Directory string
|
||||
Metadata UnlockerMetadata
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewSecureEnclaveUnlocker creates a stub SecureEnclaveUnlocker on
|
||||
// non-Darwin platforms. The returned instance's methods that require
|
||||
// macOS functionality will return errors.
|
||||
func NewSecureEnclaveUnlocker(
|
||||
fs afero.Fs,
|
||||
directory string,
|
||||
metadata UnlockerMetadata,
|
||||
) *SecureEnclaveUnlocker {
|
||||
return &SecureEnclaveUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity returns an error on non-Darwin platforms.
|
||||
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
return nil, errSENotSupported
|
||||
}
|
||||
|
||||
// GetType returns the unlocker type.
|
||||
func (s *SecureEnclaveUnlocker) GetType() string {
|
||||
return seUnlockerType
|
||||
}
|
||||
|
||||
// GetMetadata returns the unlocker metadata.
|
||||
func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata {
|
||||
return s.Metadata
|
||||
}
|
||||
|
||||
// GetDirectory returns the unlocker directory.
|
||||
func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
||||
return s.Directory
|
||||
}
|
||||
|
||||
// GetID returns the unlocker ID, the name of the unlocker's directory.
|
||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||
return filepath.Base(s.Directory)
|
||||
}
|
||||
|
||||
// Remove returns an error on non-Darwin platforms.
|
||||
func (s *SecureEnclaveUnlocker) Remove() error {
|
||||
return errSENotSupported
|
||||
}
|
||||
|
||||
// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms.
|
||||
func CreateSecureEnclaveUnlocker(
|
||||
_ afero.Fs,
|
||||
_ string,
|
||||
_, _ *memguard.LockedBuffer,
|
||||
) (*SecureEnclaveUnlocker, error) {
|
||||
return nil, errSENotSupported
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
//go:build !darwin
|
||||
|
||||
//nolint:testpackage // white-box test asserting unexported sentinel errors
|
||||
package secret
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
dir := "/tmp/test-se-unlocker"
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||
Flags: []string{seUnlockerType, "macos"},
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
|
||||
|
||||
// Test GetType returns correct type
|
||||
assert.Equal(t, seUnlockerType, unlocker.GetType())
|
||||
|
||||
// Test GetMetadata returns the metadata we passed in
|
||||
assert.Equal(t, metadata, unlocker.GetMetadata())
|
||||
|
||||
// Test GetDirectory returns the directory we passed in
|
||||
assert.Equal(t, dir, unlocker.GetDirectory())
|
||||
|
||||
// Test GetID returns the name of the unlocker's directory
|
||||
assert.Equal(t, "test-se-unlocker", unlocker.GetID())
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||
|
||||
identity, err := unlocker.GetIdentity()
|
||||
assert.Nil(t, identity)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errSENotSupported)
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||
|
||||
err := unlocker.Remove()
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errSENotSupported)
|
||||
}
|
||||
|
||||
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test", nil, nil)
|
||||
assert.Nil(t, unlocker)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errSENotSupported)
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||
|
||||
// Verify the stub implements the Unlocker interface
|
||||
var _ Unlocker = unlocker
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
//nolint:testpackage // white-box test of unexported Secure Enclave helpers
|
||||
package secret
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
dir := "/tmp/test-se-unlocker"
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||
Flags: []string{seUnlockerType, "macos"},
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
|
||||
|
||||
// Test GetType returns correct type
|
||||
assert.Equal(t, seUnlockerType, unlocker.GetType())
|
||||
|
||||
// Test GetMetadata returns the metadata we passed in
|
||||
assert.Equal(t, metadata, unlocker.GetMetadata())
|
||||
|
||||
// Test GetDirectory returns the directory we passed in
|
||||
assert.Equal(t, dir, unlocker.GetDirectory())
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||
|
||||
// Verify the darwin implementation implements the Unlocker interface
|
||||
var _ Unlocker = unlocker
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||
|
||||
// The ID is the name of the unlocker's directory
|
||||
assert.Equal(t, "test", unlocker.GetID())
|
||||
}
|
||||
|
||||
func TestGenerateSEKeyLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
label, err := generateSEKeyLabel("test-vault")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Label should contain the prefix and vault name
|
||||
assert.Contains(t, label, seKeyLabelPrefix)
|
||||
assert.Contains(t, label, "test-vault")
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
dir := "/tmp/test-se-unlocker-missing"
|
||||
|
||||
// Create unlocker directory with metadata but no encrypted key file
|
||||
require.NoError(t, fs.MkdirAll(dir, DirPerms))
|
||||
|
||||
metadataJSON := `{
|
||||
"type": "secure-enclave",
|
||||
"createdAt": "2026-01-15T10:30:00Z",
|
||||
"seKeyLabel": "berlin.sneak.app.secret.se.test",
|
||||
"seKeyHash": "abc123"
|
||||
}`
|
||||
require.NoError(t, afero.WriteFile(
|
||||
fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms,
|
||||
))
|
||||
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||
|
||||
// GetIdentity should fail because the encrypted longterm key file is missing
|
||||
identity, err := unlocker.GetIdentity()
|
||||
assert.Nil(t, identity)
|
||||
|
||||
var cause *os.PathError
|
||||
|
||||
require.ErrorAs(t, err, &cause)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
assert.Equal(t, filepath.Join(dir, seLongtermFilename), cause.Path)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
//nolint:testpackage // white-box test of unexported validateKeychainItemName
|
||||
package secret
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateKeychainItemName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
itemName string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid cases
|
||||
{name: "valid simple name", itemName: "my-secret-key", wantErr: false},
|
||||
{name: "valid name with dots", itemName: "com.example.app.key", wantErr: false},
|
||||
{name: "valid name with underscores", itemName: "my_secret_key_123", wantErr: false},
|
||||
{name: "valid alphanumeric", itemName: "Secret123Key", wantErr: false},
|
||||
{name: "valid with hyphen at start", itemName: "-my-key", wantErr: false},
|
||||
{name: "valid with dot at start", itemName: ".hidden-key", wantErr: false},
|
||||
|
||||
// Invalid cases
|
||||
{name: "empty item name", itemName: "", wantErr: true},
|
||||
{name: "item name with spaces", itemName: "my secret key", wantErr: true},
|
||||
{name: "item name with semicolon", itemName: "key;rm -rf /", wantErr: true},
|
||||
{name: "item name with pipe", itemName: "key|cat /etc/passwd", wantErr: true},
|
||||
{name: "item name with backticks", itemName: "key`whoami`", wantErr: true},
|
||||
{name: "item name with dollar sign", itemName: "key$(whoami)", wantErr: true},
|
||||
{name: "item name with quotes", itemName: "key\"name", wantErr: true},
|
||||
{name: "item name with single quotes", itemName: "key'name", wantErr: true},
|
||||
{name: "item name with backslash", itemName: "key\\name", wantErr: true},
|
||||
{name: "item name with newline", itemName: "key\nname", wantErr: true},
|
||||
{name: "item name with carriage return", itemName: "key\rname", wantErr: true},
|
||||
{name: "item name with ampersand", itemName: "key&echo test", wantErr: true},
|
||||
{name: "item name with redirect", itemName: "key>/tmp/test", wantErr: true},
|
||||
{name: "item name with null byte", itemName: "key\x00name", wantErr: true},
|
||||
{name: "item name with parentheses", itemName: "key(test)", wantErr: true},
|
||||
{name: "item name with brackets", itemName: "key[test]", wantErr: true},
|
||||
{name: "item name with asterisk", itemName: "key*", wantErr: true},
|
||||
{name: "item name with question mark", itemName: "key?", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateKeychainItemName(tt.itemName)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,8 @@ type VersionMetadata struct {
|
||||
ID string `json:"id"` // ULID
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
|
||||
NotBefore *time.Time `json:"notBefore,omitempty"` // When this version becomes active
|
||||
NotAfter *time.Time `json:"notAfter,omitempty"` // Expiry (nil = current)
|
||||
// NotAfter is set when another version is added while this one is current
|
||||
NotAfter *time.Time `json:"notAfter,omitempty"`
|
||||
}
|
||||
|
||||
// Version represents a version of a secret
|
||||
|
||||
@@ -90,10 +90,6 @@ func (m *MockVersionVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error)
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) SetMnemonic(_ *memguard.LockedBuffer) {}
|
||||
|
||||
func (m *MockVersionVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
||||
|
||||
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
||||
_ *memguard.LockedBuffer,
|
||||
) (*secret.PassphraseUnlocker, error) {
|
||||
|
||||
@@ -81,7 +81,9 @@ func TestVaultErrors(t *testing.T) {
|
||||
return vlt.CopySecretAllVersions(vlt, versionless, "copy", false)
|
||||
}, vault.ErrNoVersions},
|
||||
{"remove a missing unlocker", func(vlt *vault.Vault) error {
|
||||
return vlt.RemoveUnlocker(missingName)
|
||||
_, err := vlt.RemoveUnlocker(missingName)
|
||||
|
||||
return err
|
||||
}, vault.ErrUnlockerNotFound},
|
||||
{"select a missing unlocker", func(vlt *vault.Vault) error {
|
||||
return vlt.SelectUnlocker(missingName)
|
||||
@@ -130,7 +132,7 @@ func TestGetSecretWithWrongMnemonic(t *testing.T) {
|
||||
t.Cleanup(mnemonic.Destroy)
|
||||
|
||||
vlt := vault.NewVault(created.GetFilesystem(), testStateDir, testVaultName)
|
||||
vlt.SetMnemonic(mnemonic)
|
||||
vlt.Mnemonic = mnemonic
|
||||
|
||||
_, err := vlt.GetSecret(testSecretName)
|
||||
require.ErrorIs(t, err, vault.ErrMnemonicMismatch)
|
||||
|
||||
@@ -55,7 +55,10 @@ func ValidateVaultName(name string) error {
|
||||
}
|
||||
|
||||
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
||||
// The file contains just the vault name (e.g., "default")
|
||||
// The file contains just the vault name (e.g., "default"). An empty or
|
||||
// invalid name, or the name of a vault that does not exist, is an error
|
||||
// wrapping ErrVaultNotFound that names the file and says to run
|
||||
// 'secret vault select <name>'.
|
||||
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
||||
secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath)
|
||||
|
||||
@@ -74,6 +77,21 @@ func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
||||
stateDir := filepath.Dir(currentVaultPath)
|
||||
absolutePath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||
|
||||
// An empty name would make vaults.d itself the vault's directory
|
||||
exists := false
|
||||
if isValidVaultName(vaultName) {
|
||||
exists, err = afero.DirExists(fs, absolutePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return "", fmt.Errorf("vault '%s' named in %s %w; run "+
|
||||
"'secret vault select <name>' to select a vault",
|
||||
vaultName, currentVaultPath, ErrVaultNotFound)
|
||||
}
|
||||
|
||||
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
|
||||
|
||||
return absolutePath, nil
|
||||
@@ -140,8 +158,9 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
||||
return nil, fmt.Errorf("failed to read vaults directory: %w", err)
|
||||
}
|
||||
|
||||
// Extract vault names
|
||||
var vaults []string
|
||||
// Extract vault names. Not nil, so that no vaults prints [] in JSON,
|
||||
// not null.
|
||||
vaults := make([]string, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
@@ -212,16 +231,40 @@ func processMnemonicForVault(
|
||||
return ltIdentity, derivationIndex, publicKeyHash, familyHash, nil
|
||||
}
|
||||
|
||||
// CheckNewVaultName returns an error when name cannot be the name of a new
|
||||
// vault: one wrapping ErrInvalidVaultName when it is not a valid vault name,
|
||||
// and one wrapping ErrVaultExists when that vault already exists. Creating an
|
||||
// existing vault again would replace its keys, and its secrets could no
|
||||
// longer be decrypted. The commands that create a vault check it before they
|
||||
// ask for the mnemonic or passphrase, and CreateVault checks it again, under
|
||||
// the state directory lock, before writing anything.
|
||||
func CheckNewVaultName(fs afero.Fs, stateDir string, name string) error {
|
||||
err := ValidateVaultName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
exists, err := afero.DirExists(fs, filepath.Join(stateDir, "vaults.d", name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return fmt.Errorf("vault %s %w", name, ErrVaultExists)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateVault creates a new vault and selects it as the current vault. When
|
||||
// mnemonic is not nil, the vault's long-term key is derived from it, and the
|
||||
// returned vault has it as its Mnemonic; when it is nil, the vault has no
|
||||
// long-term key until one is imported. When passphrase is not nil, the vault
|
||||
// gets a passphrase unlocker protected by it, as its current unlocker; that
|
||||
// needs a mnemonic. It refuses a vault that already exists before writing
|
||||
// anything: creating it again would replace its keys, and its secrets could
|
||||
// no longer be decrypted. The commands that call it hold the state directory
|
||||
// lock, so no other command can create the vault between the check and the
|
||||
// writes.
|
||||
// long-term key. When passphrase is not nil, the vault gets a passphrase
|
||||
// unlocker protected by it, as its current unlocker; that needs a mnemonic.
|
||||
// It refuses an invalid name or a vault that already exists, as
|
||||
// CheckNewVaultName does, before writing anything. The commands that call it
|
||||
// hold the state directory lock, so no other command can create the vault
|
||||
// between the check and the writes.
|
||||
//
|
||||
// The vault is written whole into a temporary directory, which is renamed
|
||||
// into vaults.d only once complete, and only then selected: a crash at any
|
||||
@@ -233,30 +276,17 @@ func CreateVault(
|
||||
) (*Vault, error) {
|
||||
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
||||
|
||||
err := ValidateVaultName(name)
|
||||
err := CheckNewVaultName(fs, stateDir, name)
|
||||
if err != nil {
|
||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||
|
||||
exists, err := afero.DirExists(fs, vaultDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
|
||||
}
|
||||
|
||||
if passphrase != nil && mnemonic == nil {
|
||||
return nil, fmt.Errorf("vault %s %w", name, ErrUnlockerWithoutMnemonic)
|
||||
}
|
||||
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||
|
||||
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
||||
|
||||
err = secret.WriteDir(fs, vaultDir, func(dir string) error {
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
@@ -33,7 +35,10 @@ func ComputeDoubleSHA256(data []byte) string {
|
||||
}
|
||||
|
||||
// GetNextDerivationIndex finds the next available derivation index for a given mnemonic
|
||||
// by deriving the public key for index 0 and using its hash to identify related vaults
|
||||
// by deriving the public key for index 0 and using its hash to identify related vaults.
|
||||
// It fails when a vault's metadata file exists but cannot be read or parsed: the
|
||||
// index that vault uses is then unknown, and handing it out again would give a
|
||||
// second vault the same long-term key.
|
||||
func GetNextDerivationIndex(
|
||||
fs afero.Fs, stateDir string, mnemonic string,
|
||||
) (uint32, error) {
|
||||
@@ -72,21 +77,23 @@ func GetNextDerivationIndex(
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to read vault metadata
|
||||
metadataPath := filepath.Join(vaultsDir, entry.Name(), "vault-metadata.json")
|
||||
|
||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||
if err != nil {
|
||||
// Skip vaults without metadata
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// Skip directories without metadata
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read vault metadata %s: %w", metadataPath, err)
|
||||
}
|
||||
|
||||
var metadata Metadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
// Skip vaults with invalid metadata
|
||||
continue
|
||||
return 0, fmt.Errorf("failed to parse vault metadata %s: %w", metadataPath, err)
|
||||
}
|
||||
|
||||
// Check if this vault uses the same mnemonic by comparing family hashes
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package vault_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
@@ -163,6 +167,88 @@ func testGetNextDerivationIndex(t *testing.T, fs afero.Fs) {
|
||||
}
|
||||
}
|
||||
|
||||
// errMetadataUnreadable is returned by the test filesystem in place of a
|
||||
// successful open of one vault's metadata file.
|
||||
var errMetadataUnreadable = errors.New("input/output error")
|
||||
|
||||
// metadataReadFailFs fails every open of the file at unreadablePath. The
|
||||
// file still exists, so checking for it succeeds and only reading it fails.
|
||||
type metadataReadFailFs struct {
|
||||
afero.Fs
|
||||
|
||||
unreadablePath string
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||
func (f *metadataReadFailFs) Open(name string) (afero.File, error) {
|
||||
if name == f.unreadablePath {
|
||||
return nil, errMetadataUnreadable
|
||||
}
|
||||
|
||||
return f.Fs.Open(name)
|
||||
}
|
||||
|
||||
// TestCreateVaultWithCorruptMetadataFails checks that creating a vault from
|
||||
// a mnemonic fails, naming the file and leaving no new vault, when another
|
||||
// vault's metadata file exists but cannot be parsed or read. That vault's
|
||||
// derivation index is then unknown, so the new vault could get the same one
|
||||
// and the same key.
|
||||
func TestCreateVaultWithCorruptMetadataFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
vaultsDir := filepath.Join(testStateDir, "vaults.d")
|
||||
metadataPath := filepath.Join(vaultsDir, "work", "vault-metadata.json")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// breakMetadata makes the work vault's metadata file unusable on
|
||||
// base and returns the filesystem to create the second vault on.
|
||||
breakMetadata func(t *testing.T, base afero.Fs) afero.Fs
|
||||
}{
|
||||
{
|
||||
name: "parsing the file fails",
|
||||
breakMetadata: func(t *testing.T, base afero.Fs) afero.Fs {
|
||||
t.Helper()
|
||||
|
||||
err := afero.WriteFile(base, metadataPath, []byte("{"),
|
||||
secret.FilePerms)
|
||||
require.NoError(t, err)
|
||||
|
||||
return base
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reading the file fails",
|
||||
breakMetadata: func(_ *testing.T, base afero.Fs) afero.Fs {
|
||||
return &metadataReadFailFs{Fs: base, unreadablePath: metadataPath}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := afero.NewMemMapFs()
|
||||
|
||||
_, err := vault.CreateVault(base, testStateDir, "work",
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
fs := tt.breakMetadata(t, base)
|
||||
|
||||
_, err = vault.CreateVault(fs, testStateDir, "other",
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.ErrorContains(t, err, metadataPath)
|
||||
|
||||
entries, err := afero.ReadDir(base, vaultsDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "work", entries[0].Name())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testMetadataPersistence(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
@@ -296,91 +382,6 @@ func TestSampleHashCalculation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
// Test Case 1: Create vault WITH mnemonic (like init command)
|
||||
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
||||
}
|
||||
|
||||
// Load metadata for vault1
|
||||
vault1Dir := filepath.Join(tempDir, "vaults.d", "default")
|
||||
|
||||
metadata1, err := vault.LoadVaultMetadata(fs, vault1Dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault1 metadata: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Vault1 (with mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
||||
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
||||
|
||||
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
||||
_, err = vault.CreateVault(fs, tempDir, "work", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
||||
}
|
||||
|
||||
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
|
||||
|
||||
// Simulate the vault import process: get the next available derivation
|
||||
// index for this mnemonic
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get next derivation index: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Next derivation index for import: %d", derivationIndex)
|
||||
|
||||
// Calculate public key hash from index 0 (same as in VaultImport)
|
||||
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity for index 0: %v", err)
|
||||
}
|
||||
|
||||
publicKeyHash := vault.ComputeDoubleSHA256(
|
||||
[]byte(identity0.Recipient().String()))
|
||||
|
||||
// Load existing metadata and update it (same as in VaultImport)
|
||||
existingMetadata, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load existing metadata: %v", err)
|
||||
}
|
||||
|
||||
// Update metadata with new derivation info
|
||||
existingMetadata.DerivationIndex = derivationIndex
|
||||
existingMetadata.PublicKeyHash = publicKeyHash
|
||||
|
||||
err = vault.SaveVaultMetadata(fs, vault2Dir, existingMetadata)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save vault metadata: %v", err)
|
||||
}
|
||||
|
||||
// Load updated metadata for vault2
|
||||
metadata2, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault2 metadata: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Vault2 (imported mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
||||
metadata2.DerivationIndex, metadata2.PublicKeyHash)
|
||||
|
||||
// Verify that both vaults have the same public key hash
|
||||
if metadata1.PublicKeyHash != metadata2.PublicKeyHash {
|
||||
t.Errorf("Public key hashes don't match!")
|
||||
t.Logf("Vault1 hash: %s", metadata1.PublicKeyHash)
|
||||
t.Logf("Vault2 hash: %s", metadata2.PublicKeyHash)
|
||||
} else {
|
||||
t.Logf("SUCCESS: Both vaults have the same public key hash: %s",
|
||||
metadata1.PublicKeyHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseEngineerHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+83
-27
@@ -219,6 +219,15 @@ func (v *Vault) addVersion(
|
||||
name, secretDir string, value *memguard.LockedBuffer,
|
||||
previousVersion *secret.Version,
|
||||
) error {
|
||||
// Unlock the vault before writing anything, so that a wrong passphrase
|
||||
// or mnemonic leaves the secret as it was
|
||||
err := v.loadPreviousVersionMetadata(previousVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Taken after the unlock, which can wait for the user to type the
|
||||
// passphrase, so that the new version starts when it is created
|
||||
now := time.Now()
|
||||
|
||||
// Create the new version and save the encrypted value
|
||||
@@ -418,7 +427,8 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
||||
return longTermIdentity, nil
|
||||
}
|
||||
|
||||
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
||||
// GetSecretObject retrieves a Secret object from this vault. Its times are
|
||||
// zero until its LoadMetadata reads them.
|
||||
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||
err := ValidateSecretName(name)
|
||||
if err != nil {
|
||||
@@ -445,16 +455,7 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||
return nil, fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
||||
}
|
||||
|
||||
// Create a Secret object
|
||||
secretObj := secret.NewSecret(v, name)
|
||||
|
||||
// Load the metadata from disk
|
||||
err = secretObj.LoadMetadata()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return secretObj, nil
|
||||
return secret.NewSecret(v, name), nil
|
||||
}
|
||||
|
||||
// CopySecretVersion copies a single version from source into destSecretDir
|
||||
@@ -572,8 +573,8 @@ func (v *Vault) CopySecretAllVersions(
|
||||
}
|
||||
|
||||
// Copy each version and the current pointer, then move the copy into place
|
||||
err = v.copyVersions(srcVault, srcIdentity,
|
||||
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
|
||||
err = v.copyVersions(srcVault, srcIdentity, srcSecretName, srcSecretDir,
|
||||
destSecretName, destSecretDir, versions, currentVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -632,8 +633,35 @@ func (v *Vault) checkExistingSecret(
|
||||
return true, previousVersion, nil
|
||||
}
|
||||
|
||||
// loadPreviousVersionMetadata unlocks the vault and loads the metadata of
|
||||
// the version being superseded. It is a no-op when previousVersion is nil.
|
||||
func (v *Vault) loadPreviousVersionMetadata(
|
||||
previousVersion *secret.Version,
|
||||
) error {
|
||||
if previousVersion == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get long-term key for previous version", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
err = previousVersion.LoadMetadata(ltIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load previous version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updatePreviousVersion sets the notAfter timestamp on the version being
|
||||
// superseded. It is a no-op when previousVersion is nil.
|
||||
// superseded, whose metadata loadPreviousVersionMetadata has loaded. It is
|
||||
// a no-op when previousVersion is nil.
|
||||
func (v *Vault) updatePreviousVersion(
|
||||
previousVersion *secret.Version, now *time.Time,
|
||||
) error {
|
||||
@@ -641,22 +669,13 @@ func (v *Vault) updatePreviousVersion(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get long-term key to decrypt/encrypt metadata
|
||||
// loadPreviousVersionMetadata unlocked the vault, so this returns the
|
||||
// key it keeps in memory
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get long-term key for metadata update", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Load previous version metadata
|
||||
err = previousVersion.LoadMetadata(ltIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load previous version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Update notAfter timestamp
|
||||
previousVersion.Metadata.NotAfter = now
|
||||
|
||||
@@ -774,10 +793,13 @@ func (v *Vault) createAndSaveVersion(
|
||||
// pointer into a temporary directory, then moves that directory to
|
||||
// destSecretDir, replacing a secret already there. Nothing in this vault
|
||||
// changes until the copy is complete, so an interrupted copy leaves only a
|
||||
// temporary directory behind.
|
||||
// temporary directory behind. Each copied value.age and current file gets
|
||||
// the modification time of the file it was copied from, so `secret list`,
|
||||
// which reads a secret's times from them, lists the copy with the times of
|
||||
// the source.
|
||||
func (v *Vault) copyVersions(
|
||||
srcVault *Vault, srcIdentity *age.X25519Identity,
|
||||
srcSecretName, destSecretName, destSecretDir string,
|
||||
srcSecretName, srcSecretDir, destSecretName, destSecretDir string,
|
||||
versions []string, currentVersion string,
|
||||
) error {
|
||||
buildDir, err := secret.TempDirFor(v.fs, destSecretDir)
|
||||
@@ -796,6 +818,14 @@ func (v *Vault) copyVersions(
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
|
||||
}
|
||||
|
||||
valuePath := filepath.Join("versions", versionName, "value.age")
|
||||
|
||||
err = copyModTime(srcVault.fs, filepath.Join(srcSecretDir, valuePath),
|
||||
v.fs, filepath.Join(buildDir, valuePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = secret.SetCurrentVersion(v.fs, buildDir, currentVersion)
|
||||
@@ -803,6 +833,12 @@ func (v *Vault) copyVersions(
|
||||
return fmt.Errorf("failed to set current version: %w", err)
|
||||
}
|
||||
|
||||
err = copyModTime(srcVault.fs, filepath.Join(srcSecretDir, "current"),
|
||||
v.fs, filepath.Join(buildDir, "current"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// With --force, the secret being replaced goes only now that its
|
||||
// replacement is complete
|
||||
exists, err := afero.DirExists(v.fs, destSecretDir)
|
||||
@@ -827,6 +863,26 @@ func (v *Vault) copyVersions(
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyModTime gives destPath in destFs the modification time of srcPath in
|
||||
// srcFs.
|
||||
func copyModTime(
|
||||
srcFs afero.Fs, srcPath string, destFs afero.Fs, destPath string,
|
||||
) error {
|
||||
info, err := srcFs.Stat(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read modification time of %s: %w",
|
||||
srcPath, err)
|
||||
}
|
||||
|
||||
err = destFs.Chtimes(destPath, info.ModTime(), info.ModTime())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set modification time of %s: %w",
|
||||
destPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCopyDestination refuses to copy over an existing secret unless force
|
||||
// is set. A secret being replaced is removed by copyVersions, once its
|
||||
// replacement is complete.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
// version
|
||||
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
||||
// across versions
|
||||
// - TestVaultVersionTimestampsAfterUnlock: Tests that those timestamps are
|
||||
// taken after AddSecret unlocks the vault
|
||||
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
||||
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
||||
//
|
||||
@@ -295,6 +297,70 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
||||
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
||||
}
|
||||
|
||||
// longTermKeyReadFs records when longterm.age, the last file an unlock
|
||||
// reads, was last opened.
|
||||
type longTermKeyReadFs struct {
|
||||
afero.Fs
|
||||
|
||||
readAt time.Time
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||
func (f *longTermKeyReadFs) Open(name string) (afero.File, error) {
|
||||
if filepath.Base(name) == "longterm.age" {
|
||||
f.readAt = time.Now()
|
||||
}
|
||||
|
||||
return f.Fs.Open(name)
|
||||
}
|
||||
|
||||
// TestVaultVersionTimestampsAfterUnlock checks that when AddSecret has to
|
||||
// unlock the vault to replace a version, the new version's notBefore and the
|
||||
// replaced version's notAfter are taken after the unlock, which can wait for
|
||||
// the user to type the passphrase.
|
||||
func TestVaultVersionTimestampsAfterUnlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := &longTermKeyReadFs{Fs: afero.NewMemMapFs()}
|
||||
|
||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||
defer mnemonic.Destroy()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
created, err := CreateVault(fs, testStateDir, "test", mnemonic, passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecretToVault(t, created, testSecretPath, []byte("version-1"), false)
|
||||
|
||||
// A new instance is locked; AddSecret unlocks it with the passphrase
|
||||
vault := NewVault(fs, testStateDir, "test")
|
||||
vault.UnlockPassphrase = passphrase
|
||||
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||
require.False(t, fs.readAt.IsZero(), "AddSecret did not unlock the vault")
|
||||
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "test%secret")
|
||||
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
newVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
||||
require.NoError(t, newVersion.LoadMetadata(ltIdentity))
|
||||
|
||||
replacedVersion := secret.NewVersion(vault, testSecretPath, versions[1])
|
||||
require.NoError(t, replacedVersion.LoadMetadata(ltIdentity))
|
||||
|
||||
assert.False(t, newVersion.Metadata.NotBefore.Before(fs.readAt))
|
||||
assert.False(t, replacedVersion.Metadata.NotAfter.Before(fs.readAt))
|
||||
}
|
||||
|
||||
func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+127
-24
@@ -5,8 +5,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,8 +20,8 @@ import (
|
||||
|
||||
// Unlocker metadata type strings.
|
||||
const (
|
||||
unlockerTypePassphrase = "passphrase"
|
||||
unlockerTypeSecureEnclave = "secure-enclave"
|
||||
unlockerTypePassphrase = "passphrase"
|
||||
unlockerTypePGP = "pgp"
|
||||
)
|
||||
|
||||
// GetCurrentUnlocker returns the current unlocker for this vault
|
||||
@@ -53,6 +55,11 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = v.checkCurrentUnlockerExists(unlockerDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secret.DebugWith("Resolved unlocker directory",
|
||||
slog.String("unlocker_dir", unlockerDir),
|
||||
slog.String("vault_name", v.Name),
|
||||
@@ -75,19 +82,10 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
passphraseUnlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||
passphraseUnlocker.Passphrase = v.UnlockPassphrase
|
||||
unlocker = passphraseUnlocker
|
||||
case "pgp":
|
||||
case unlockerTypePGP:
|
||||
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
||||
|
||||
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
|
||||
case "keychain":
|
||||
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
|
||||
|
||||
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata)
|
||||
case unlockerTypeSecureEnclave:
|
||||
secret.Debug("Creating secure enclave unlocker instance",
|
||||
"unlocker_type", metadata.Type)
|
||||
|
||||
unlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDir, metadata)
|
||||
default:
|
||||
secret.Debug("Unsupported unlocker type", "type", metadata.Type)
|
||||
|
||||
@@ -129,6 +127,37 @@ func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, er
|
||||
return absolutePath, nil
|
||||
}
|
||||
|
||||
// checkCurrentUnlockerExists fails with ErrUnlockerNotFound, saying to
|
||||
// select another unlocker, when unlockerDir, the unlocker directory that
|
||||
// current-unlocker names, does not exist and the vault has another unlocker
|
||||
// that SelectUnlocker accepts. When it has none, it returns nil: reading the
|
||||
// missing unlocker then fails, with the advice that the mnemonic still opens
|
||||
// the vault.
|
||||
func (v *Vault) checkCurrentUnlockerExists(unlockerDir string) error {
|
||||
exists, err := afero.DirExists(v.fs, unlockerDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for the current unlocker: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
otherID, err := v.firstOtherUnlockerID(filepath.Base(unlockerDir))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if otherID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unlocker with ID %s %w; the vault '%s' has other "+
|
||||
"unlockers: run %s with the ID of one of them to make it the current "+
|
||||
"unlocker", filepath.Base(unlockerDir), ErrUnlockerNotFound, v.Name,
|
||||
v.withVaultSelect("'secret unlocker select'"))
|
||||
}
|
||||
|
||||
// findUnlockerByID finds an unlocker by its ID and returns the unlocker
|
||||
// instance and its directory path. A directory that ListUnlockers skips is
|
||||
// skipped here too, with the same warning. Such a directory has no ID: if
|
||||
@@ -169,12 +198,8 @@ func (v *Vault) findUnlockerByID(
|
||||
switch metadata.Type {
|
||||
case unlockerTypePassphrase:
|
||||
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
|
||||
case "pgp":
|
||||
case unlockerTypePGP:
|
||||
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
|
||||
case "keychain":
|
||||
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata)
|
||||
case unlockerTypeSecureEnclave:
|
||||
tempUnlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDirPath, metadata)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
@@ -296,10 +321,15 @@ func (v *Vault) HasUnlocker(unlockerID string) (bool, error) {
|
||||
// RemoveUnlocker removes an unlocker from this vault. An unlocker
|
||||
// directory that ListUnlockers skips is removed by its directory name; its
|
||||
// type is unknown, so only the directory is removed.
|
||||
func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
||||
//
|
||||
// It returns the ID of the unlocker it made current, or "" when it made none
|
||||
// current. If the removed unlocker is the current one, the unlocker
|
||||
// firstOtherUnlockerID returns is made current before the removal; with
|
||||
// none, current-unlocker is removed after it.
|
||||
func (v *Vault) RemoveUnlocker(unlockerID string) (string, error) {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Find the unlocker directory and create the unlocker instance
|
||||
@@ -308,19 +338,92 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
||||
// Find the unlocker by ID
|
||||
unlocker, unlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
if unlockerDir == "" {
|
||||
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||
return "", fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||
}
|
||||
|
||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||
|
||||
currentDir, err := v.resolveUnlockerDirectory(currentUnlockerPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
isCurrent := currentDir == unlockerDir
|
||||
newCurrentID := ""
|
||||
|
||||
if isCurrent {
|
||||
newCurrentID, err = v.selectOtherUnlocker(unlockerID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if unlocker == nil {
|
||||
return secret.RemoveDirAtomic(v.fs, unlockerDir)
|
||||
err = secret.RemoveDirAtomic(v.fs, unlockerDir)
|
||||
} else {
|
||||
err = unlocker.Remove()
|
||||
}
|
||||
|
||||
// Use the unlocker's Remove method
|
||||
return unlocker.Remove()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if isCurrent && newCurrentID == "" {
|
||||
err = v.fs.Remove(currentUnlockerPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to remove current-unlocker file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return newCurrentID, nil
|
||||
}
|
||||
|
||||
// selectOtherUnlocker makes the unlocker firstOtherUnlockerID returns for
|
||||
// unlockerID the current unlocker and returns its ID. It returns "" when
|
||||
// there is no such unlocker.
|
||||
func (v *Vault) selectOtherUnlocker(unlockerID string) (string, error) {
|
||||
otherID, err := v.firstOtherUnlockerID(unlockerID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if otherID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
err = v.SelectUnlocker(otherID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return otherID, nil
|
||||
}
|
||||
|
||||
// firstOtherUnlockerID returns the ID of the vault's first unlocker other
|
||||
// than unlockerID, in ID order, the order `secret unlocker list` shows, that
|
||||
// SelectUnlocker accepts, or "" when there is none. SelectUnlocker refuses
|
||||
// an unlocker whose metadata names a type other than passphrase or pgp,
|
||||
// which ListUnlockers still returns.
|
||||
func (v *Vault) firstOtherUnlockerID(unlockerID string) (string, error) {
|
||||
unlockers, err := v.ListUnlockers()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, id := range slices.Sorted(maps.Keys(unlockers)) {
|
||||
unlockerType := unlockers[id].Type
|
||||
|
||||
if id != unlockerID && (unlockerType == unlockerTypePassphrase ||
|
||||
unlockerType == unlockerTypePGP) {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// SelectUnlocker selects an unlocker as current for this vault
|
||||
|
||||
+25
-40
@@ -64,18 +64,6 @@ func (v *Vault) ClearLongTermKey() {
|
||||
v.longTermKey = nil
|
||||
}
|
||||
|
||||
// SetMnemonic sets v.Mnemonic, for code that has v only as a
|
||||
// secret.VaultInterface.
|
||||
func (v *Vault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
|
||||
v.Mnemonic = mnemonic
|
||||
}
|
||||
|
||||
// SetUnlockPassphrase sets v.UnlockPassphrase, for code that has v only as
|
||||
// a secret.VaultInterface.
|
||||
func (v *Vault) SetUnlockPassphrase(passphrase *memguard.LockedBuffer) {
|
||||
v.UnlockPassphrase = passphrase
|
||||
}
|
||||
|
||||
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it
|
||||
// from available sources
|
||||
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
@@ -109,9 +97,8 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
slog.String("unlocker_id", unlocker.GetID()),
|
||||
)
|
||||
|
||||
// Get the long-term key via the unlocker.
|
||||
// SE unlockers return the long-term key directly from GetIdentity().
|
||||
// Other unlockers return their own identity, used to decrypt longterm.age.
|
||||
// Get the long-term key via the unlocker: its identity decrypts
|
||||
// longterm.age.
|
||||
ltIdentity, err := v.unlockLongTermKey(unlocker)
|
||||
if err != nil {
|
||||
return nil, v.withMnemonicAdvice(err)
|
||||
@@ -254,23 +241,10 @@ func (v *Vault) deriveLongTermKeyFromMnemonic(
|
||||
}
|
||||
|
||||
// unlockLongTermKey extracts the vault's long-term key using the given
|
||||
// unlocker. SE unlockers decrypt the long-term key directly; other unlockers
|
||||
// use an intermediate identity.
|
||||
// unlocker's identity.
|
||||
func (v *Vault) unlockLongTermKey(
|
||||
unlocker secret.Unlocker,
|
||||
) (*age.X25519Identity, error) {
|
||||
if unlocker.GetType() == unlockerTypeSecureEnclave {
|
||||
secret.Debug("SE unlocker: decrypting long-term key directly via Secure Enclave")
|
||||
|
||||
ltIdentity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term key via SE: %w", err)
|
||||
}
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// Standard unlockers: get unlocker identity, then decrypt longterm.age
|
||||
unlockerIdentity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
||||
@@ -305,9 +279,14 @@ func (v *Vault) unlockLongTermKey(
|
||||
// without a mnemonic records none, and without its metadata the key cannot
|
||||
// be derived. It is not added when the passphrase could not be read: the
|
||||
// unlocker was not tried, and adding one would need a passphrase read the
|
||||
// same way.
|
||||
// same way. Nor is it added when the passphrase is wrong: the unlocker still
|
||||
// opens the vault with the right one. Nor is it added when the current
|
||||
// unlocker does not exist and the vault has others: the error already says
|
||||
// to select one of them.
|
||||
func (v *Vault) withMnemonicAdvice(err error) error {
|
||||
if errors.Is(err, secret.ErrPassphraseNotRead) {
|
||||
if errors.Is(err, secret.ErrPassphraseNotRead) ||
|
||||
errors.Is(err, secret.ErrWrongPassphrase) ||
|
||||
errors.Is(err, ErrUnlockerNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -318,15 +297,21 @@ func (v *Vault) withMnemonicAdvice(err error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 'secret unlocker add' acts on the current vault only.
|
||||
steps := "'secret unlocker add passphrase'"
|
||||
|
||||
current, currentErr := GetCurrentVault(v.fs, v.stateDir)
|
||||
if currentErr != nil || current.Name != v.Name {
|
||||
steps = fmt.Sprintf("'secret vault select %s', then %s", v.Name, steps)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w; the vault '%s' still opens with its mnemonic: run "+
|
||||
"%s with %s set to the mnemonic to give it a new unlocker",
|
||||
err, v.Name, steps, secret.EnvMnemonic)
|
||||
err, v.Name, v.withVaultSelect("'secret unlocker add passphrase'"),
|
||||
secret.EnvMnemonic)
|
||||
}
|
||||
|
||||
// withVaultSelect returns command, a quoted 'secret unlocker' command, for
|
||||
// advice about this vault. Such a command acts on the current vault only,
|
||||
// so when this vault is not the current one, the command that selects it
|
||||
// comes first.
|
||||
func (v *Vault) withVaultSelect(command string) string {
|
||||
current, err := GetCurrentVault(v.fs, v.stateDir)
|
||||
if err != nil || current.Name != v.Name {
|
||||
return fmt.Sprintf("'secret vault select %s', then %s", v.Name, command)
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
@@ -103,3 +103,76 @@ func TestAddSecretCleansUpOnFailure(t *testing.T) {
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "pub.age", entries[0].Name())
|
||||
}
|
||||
|
||||
// TestAddSecretForceWithWrongPassphraseChangesNothing checks that adding a
|
||||
// version to an existing secret, with a passphrase that does not open the
|
||||
// vault, fails and leaves the secret's directory as it was.
|
||||
func TestAddSecretForceWithWrongPassphraseChangesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(passphrase.Destroy)
|
||||
|
||||
created, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||
testMnemonicBuffer(t), passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
first := memguard.NewBufferFromBytes([]byte("first"))
|
||||
t.Cleanup(first.Destroy)
|
||||
|
||||
require.NoError(t, created.AddSecret(testSecretName, first, false))
|
||||
|
||||
wrong := memguard.NewBufferFromBytes([]byte("wrong passphrase"))
|
||||
t.Cleanup(wrong.Destroy)
|
||||
|
||||
vlt := vault.NewVault(fs, testStateDir, testVaultName)
|
||||
vlt.UnlockPassphrase = wrong
|
||||
|
||||
secretDir := filepath.Join(testStateDir, "vaults.d", testVaultName,
|
||||
"secrets.d", testSecretName)
|
||||
before := dirContents(t, fs, secretDir)
|
||||
|
||||
second := memguard.NewBufferFromBytes([]byte("second"))
|
||||
t.Cleanup(second.Destroy)
|
||||
|
||||
err = vlt.AddSecret(testSecretName, second, true)
|
||||
|
||||
require.ErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||
assert.Equal(t, before, dirContents(t, fs, secretDir))
|
||||
}
|
||||
|
||||
// dirContents maps every file under dir to its contents, and every
|
||||
// directory, written with a trailing "/", to "".
|
||||
func dirContents(t *testing.T, fs afero.Fs, dir string) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
contents := map[string]string{}
|
||||
|
||||
err := afero.Walk(fs, dir, func(
|
||||
path string, info os.FileInfo, err error,
|
||||
) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
contents[path+"/"] = ""
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := afero.ReadFile(fs, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contents[path] = string(content)
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return contents
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1"
|
||||
}
|
||||
|
||||
+7
-2
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/btcsuite/btcutil/bech32"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"sneak.berlin/go/secret/internal/bip39"
|
||||
"sneak.berlin/go/secret/pkg/bip85"
|
||||
)
|
||||
@@ -73,8 +74,12 @@ func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
||||
// DeriveEntropy derives 32 bytes of application-scoped entropy from the
|
||||
// supplied BIP-39 mnemonic and index n using BIP85.
|
||||
func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) {
|
||||
// Convert mnemonic to seed
|
||||
seed := bip39.NewSeed(mnemonic, "")
|
||||
// BIP-39 makes the seed from the words joined by single spaces, in
|
||||
// Unicode NFKD. NewSeed hashes its input as given, so the mnemonic is
|
||||
// brought to that form first: the same words, however they were
|
||||
// typed, then give the same seed.
|
||||
sentence := norm.NFKD.String(strings.Join(strings.Fields(mnemonic), " "))
|
||||
seed := bip39.NewSeed(sentence, "")
|
||||
|
||||
// Create master key from seed
|
||||
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
|
||||
|
||||
@@ -175,6 +175,34 @@ func TestDeterministicDerivation(t *testing.T) {
|
||||
t.Logf("Index 1: %s", id3.String())
|
||||
}
|
||||
|
||||
// TestDeriveIdentityIgnoresExtraWhitespace checks that the same words give
|
||||
// the same identity however they are spaced, as when a mnemonic is typed or
|
||||
// pasted with a double space or a trailing newline.
|
||||
func TestDeriveIdentityIgnoresExtraWhitespace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want, err := DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("derive: %v", err)
|
||||
}
|
||||
|
||||
typedMnemonics := []string{
|
||||
strings.Replace(mnemonic, " ", " ", 1) + " ",
|
||||
"\t" + strings.ReplaceAll(mnemonic, " ", "\n") + "\n",
|
||||
}
|
||||
|
||||
for _, typed := range typedMnemonics {
|
||||
got, err := DeriveIdentity(typed, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("derive from %q: %v", typed, err)
|
||||
}
|
||||
|
||||
if got.String() != want.String() {
|
||||
t.Errorf("%q derives a different identity from %q", typed, mnemonic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterministicXPRVDerivation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+46
-26
@@ -2,18 +2,18 @@
|
||||
# script/bootstrap: install all dependencies needed to build and develop
|
||||
# this repo. Idempotent: every install is guarded by a check so already
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||
# make, node, yarn, go, or python). Node is used directly if installed;
|
||||
# otherwise a pinned version is installed via nvm (installing nvm
|
||||
# itself first, from a hash-verified release archive, never curl | sh).
|
||||
# or apk (detected in that order); assumes nothing is present. The
|
||||
# installed node is used only when its major version is the pinned one;
|
||||
# otherwise (node missing, or another major) the pinned version is
|
||||
# installed via nvm (installing nvm itself first, from a hash-verified
|
||||
# release archive, never curl | sh), and yarn and the packages are
|
||||
# installed under it.
|
||||
# golangci-lint is never installed: script/lint runs it in docker.
|
||||
#
|
||||
# Uncomment the language sections in main() that apply to this repo.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Pinned versions, 2026-07-06. Never "latest" or "lts"; exact versions.
|
||||
# Pinned versions, 2026-07-06
|
||||
NODE_VERSION="22.17.0"
|
||||
NVM_VERSION="0.40.3"
|
||||
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
|
||||
@@ -22,6 +22,7 @@ YARN_VERSION="1.22.22"
|
||||
|
||||
PKGMGR=""
|
||||
SUDO=""
|
||||
APT_UPDATED=""
|
||||
|
||||
detect_pkgmgr() {
|
||||
[ -n "$PKGMGR" ] && return 0
|
||||
@@ -50,7 +51,14 @@ pkg_install() {
|
||||
detect_pkgmgr
|
||||
case "$PKGMGR" in
|
||||
nix) nix-env -iA "nixpkgs.$1" ;;
|
||||
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
|
||||
apt)
|
||||
# Package lists may be empty (fresh images); refresh once per run.
|
||||
if [ -z "$APT_UPDATED" ]; then
|
||||
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
APT_UPDATED=1
|
||||
fi
|
||||
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
|
||||
;;
|
||||
brew) brew install "$3" ;;
|
||||
apk) apk add --no-cache "$4" ;;
|
||||
esac
|
||||
@@ -82,7 +90,7 @@ nvm_sh() {
|
||||
|
||||
ensure_nvm() {
|
||||
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
|
||||
# nvm prerequisites; nvm itself requires bash, so install it too
|
||||
# nvm prerequisites; nvm itself requires bash
|
||||
if missing bash; then pkg_install bash bash bash bash; fi
|
||||
if missing curl; then pkg_install curl curl curl curl; fi
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
@@ -95,56 +103,68 @@ ensure_nvm() {
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
# True when the node on PATH has the pinned major version. Only the
|
||||
# major is compared: the Dockerfile stages start from a node 22 alpine
|
||||
# image whose exact version is not the pin, and nvm cannot install a
|
||||
# prebuilt node on alpine. Another major is not used: the pinned yarn 1
|
||||
# prints a deprecation warning under node 24. script/fmt and
|
||||
# script/fmt-check carry this function unchanged, to run the yarn
|
||||
# installed here.
|
||||
node_is_pinned_major() {
|
||||
if ! command -v node >/dev/null 2>&1; then return 1; fi
|
||||
installed="$(node --version)"
|
||||
installed="${installed#v}"
|
||||
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
|
||||
}
|
||||
|
||||
ensure_node() {
|
||||
if ! missing node; then return 0; fi
|
||||
if node_is_pinned_major; then
|
||||
echo "bootstrap: using node $(node --version)"
|
||||
return 0
|
||||
fi
|
||||
ensure_nvm
|
||||
nvm_sh "nvm install $NODE_VERSION"
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && \
|
||||
echo \"bootstrap: using node \$(node --version)\""
|
||||
}
|
||||
|
||||
ensure_yarn() {
|
||||
if ! node_is_pinned_major; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
|
||||
corepack prepare yarn@$YARN_VERSION --activate"
|
||||
return 0
|
||||
fi
|
||||
if ! missing yarn; then return 0; fi
|
||||
if ! missing corepack; then
|
||||
corepack enable
|
||||
corepack prepare "yarn@$YARN_VERSION" --activate
|
||||
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
|
||||
corepack prepare yarn@$YARN_VERSION --activate"
|
||||
else
|
||||
npm install -g "yarn@$YARN_VERSION"
|
||||
fi
|
||||
}
|
||||
|
||||
install_js_deps() {
|
||||
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
if node_is_pinned_major; then
|
||||
yarn install --frozen-lockfile
|
||||
else
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
|
||||
yarn install --frozen-lockfile"
|
||||
else
|
||||
yarn install --frozen-lockfile
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
# Base tooling (every repo)
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
if missing make; then pkg_install gnumake make make make; fi
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
|
||||
# ---- JS / docs repos ----
|
||||
# prettier, pinned in package.json and yarn.lock, formats the markdown
|
||||
ensure_node
|
||||
ensure_yarn
|
||||
install_js_deps
|
||||
|
||||
# ---- Go repos ----
|
||||
if missing go; then pkg_install go golang go go; fi
|
||||
go mod download
|
||||
|
||||
# ---- Python repos ----
|
||||
# if missing python3; then pkg_install python3 python3 python3 python3; fi
|
||||
# python3 -m venv .venv
|
||||
# ./.venv/bin/pip install -e '.[dev]'
|
||||
|
||||
echo "bootstrap complete"
|
||||
}
|
||||
|
||||
|
||||
+13
-9
@@ -2,9 +2,11 @@
|
||||
# script/cibuild: run the CI build. It bootstraps first: a CI runner
|
||||
# checks out and runs this and nothing else, and script/fmt-check runs
|
||||
# the formatter on the host, which a pristine checkout cannot do.
|
||||
# --no-cache for the same reason as script/docker: the gate phases the
|
||||
# final stage depends on are RUN steps, and a cached one is a check that
|
||||
# did not run.
|
||||
# A new CACHEBUST value reruns every RUN step, since a cached gate phase
|
||||
# is a check that did not run; unlike --no-cache, it keeps Go's build cache.
|
||||
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
|
||||
# first; a stage without that line is served from cache on an unchanged
|
||||
# tree, with no warning.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -14,15 +16,17 @@ main() {
|
||||
cd "$ROOT"
|
||||
"$SCRIPT_DIR/bootstrap"
|
||||
"$SCRIPT_DIR/check"
|
||||
# Own line: a failing command substitution inside an argument does
|
||||
# not trip `set -e`, so the inline form degrades silently to an
|
||||
# empty constant. The VERSION build argument takes precedence over
|
||||
# the version a build stage derives from the .git in the context.
|
||||
# The version and the tag each get their own line: a failing
|
||||
# command substitution inside an argument does not trip `set -e`,
|
||||
# so the inline form degrades silently to an empty constant. The
|
||||
# VERSION build argument takes precedence over the version a build
|
||||
# stage derives from the .git in the context.
|
||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||
[ -n "$version" ] || version="unknown"
|
||||
docker build --no-cache \
|
||||
tag="$("$SCRIPT_DIR/projectname")"
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
--build-arg VERSION="$version" \
|
||||
-t "$("$SCRIPT_DIR/projectname")" .
|
||||
-t "$tag" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+13
-8
@@ -1,8 +1,11 @@
|
||||
#!/bin/sh
|
||||
# script/docker: build the Docker image tagged with the project name.
|
||||
# Identical in all repos; the tag comes from script/projectname.
|
||||
# --no-cache because the gate phases the final stage depends on are RUN
|
||||
# steps, and a cached one is a check that did not run.
|
||||
# A new CACHEBUST value reruns every RUN step, since a cached gate phase
|
||||
# is a check that did not run; unlike --no-cache, it keeps Go's build cache.
|
||||
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
|
||||
# first; a stage without that line is served from cache on an unchanged
|
||||
# tree, with no warning.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -10,15 +13,17 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
# Own line: a failing command substitution inside an argument does
|
||||
# not trip `set -e`, so the inline form degrades silently to an
|
||||
# empty constant. The VERSION build argument takes precedence over
|
||||
# the version a build stage derives from the .git in the context.
|
||||
# The version and the tag each get their own line: a failing
|
||||
# command substitution inside an argument does not trip `set -e`,
|
||||
# so the inline form degrades silently to an empty constant. The
|
||||
# VERSION build argument takes precedence over the version a build
|
||||
# stage derives from the .git in the context.
|
||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||
[ -n "$version" ] || version="unknown"
|
||||
docker build --no-cache \
|
||||
tag="$("$SCRIPT_DIR/projectname")"
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
--build-arg VERSION="$version" \
|
||||
-t "$("$SCRIPT_DIR/projectname")" .
|
||||
-t "$tag" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+16
-6
@@ -8,15 +8,25 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
# Must match the pin in script/bootstrap.
|
||||
NODE_VERSION="22.17.0"
|
||||
|
||||
# script/bootstrap installs node and yarn under nvm and leaves neither
|
||||
# on the PATH of the shell that called it, so resolve the pinned
|
||||
# toolchain here the way bootstrap's own install step does. nvm is a
|
||||
# bash script, hence the subshell.
|
||||
# True when the node on PATH has the pinned major version: the same
|
||||
# function as in script/bootstrap, which uses it to decide where it
|
||||
# installs yarn.
|
||||
node_is_pinned_major() {
|
||||
if ! command -v node >/dev/null 2>&1; then return 1; fi
|
||||
installed="$(node --version)"
|
||||
installed="${installed#v}"
|
||||
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
|
||||
}
|
||||
|
||||
# Run the yarn script/bootstrap installed: the yarn on PATH when the
|
||||
# node on PATH has the pinned major version, and otherwise yarn under
|
||||
# the pinned node in nvm, which bootstrap leaves off the PATH of the
|
||||
# shell that called it. nvm is a bash script, hence the subshell.
|
||||
run_yarn() {
|
||||
if command -v yarn >/dev/null 2>&1; then
|
||||
if node_is_pinned_major && command -v yarn >/dev/null 2>&1; then
|
||||
exec yarn "$@"
|
||||
fi
|
||||
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
echo "fmt: no yarn; run script/bootstrap first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+16
-6
@@ -8,15 +8,25 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
# Must match the pin in script/bootstrap.
|
||||
NODE_VERSION="22.17.0"
|
||||
|
||||
# script/bootstrap installs node and yarn under nvm and leaves neither
|
||||
# on the PATH of the shell that called it, so resolve the pinned
|
||||
# toolchain here the way bootstrap's own install step does. nvm is a
|
||||
# bash script, hence the subshell.
|
||||
# True when the node on PATH has the pinned major version: the same
|
||||
# function as in script/bootstrap, which uses it to decide where it
|
||||
# installs yarn.
|
||||
node_is_pinned_major() {
|
||||
if ! command -v node >/dev/null 2>&1; then return 1; fi
|
||||
installed="$(node --version)"
|
||||
installed="${installed#v}"
|
||||
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
|
||||
}
|
||||
|
||||
# Run the yarn script/bootstrap installed: the yarn on PATH when the
|
||||
# node on PATH has the pinned major version, and otherwise yarn under
|
||||
# the pinned node in nvm, which bootstrap leaves off the PATH of the
|
||||
# shell that called it. nvm is a bash script, hence the subshell.
|
||||
run_yarn() {
|
||||
if command -v yarn >/dev/null 2>&1; then
|
||||
if node_is_pinned_major && command -v yarn >/dev/null 2>&1; then
|
||||
exec yarn "$@"
|
||||
fi
|
||||
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
echo "fmt-check: no yarn; run script/bootstrap first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+8
-5
@@ -5,9 +5,12 @@
|
||||
# make its answer untrustworthy.
|
||||
#
|
||||
# The phase is not the last stage in the file, so it is built only when
|
||||
# --target names it. --no-cache because a cached lint layer is a lint
|
||||
# that did not run. The tag makes each build replace the previous image
|
||||
# instead of leaving a dangling one behind.
|
||||
# --target names it. A new CACHEBUST value reruns every RUN step, since
|
||||
# a cached lint layer is a lint that did not run; unlike --no-cache, it
|
||||
# keeps Go's build cache. This relies on every stage of the Dockerfile
|
||||
# declaring ARG CACHEBUST first; a stage without that line is served
|
||||
# from cache on an unchanged tree, with no warning.
|
||||
# --output type=cacheonly writes no image, since nothing uses one.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -15,9 +18,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build --no-cache \
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
--target lint \
|
||||
-t "$("$SCRIPT_DIR/projectname")-lint" .
|
||||
--output type=cacheonly .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+8
-4
@@ -2,8 +2,12 @@
|
||||
# script/test: run the test suite. Testing is a phase of the Dockerfile
|
||||
# and this builds that phase alone, on the same terms as script/lint:
|
||||
# --target because a phase that is not the last stage is built only when
|
||||
# named, --no-cache because a cached test layer is a test that did not
|
||||
# run, and a tag so each build replaces the previous image.
|
||||
# named, and a new CACHEBUST value because a cached test layer is a test
|
||||
# that did not run; unlike --no-cache, it keeps Go's build cache. This
|
||||
# relies on every stage of the Dockerfile declaring ARG CACHEBUST first;
|
||||
# a stage without that line is served from cache on an unchanged tree,
|
||||
# with no warning. --output type=cacheonly writes no image, since
|
||||
# nothing uses one.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -11,9 +15,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build --no-cache \
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
--target test \
|
||||
-t "$("$SCRIPT_DIR/projectname")-test" .
|
||||
--output type=cacheonly .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user