Compare commits

..

3 Commits

Author SHA1 Message Date
b9aaf8ddac Merge branch 'main' into secure-enclave-unlocker 2026-03-11 02:34:45 +01:00
e10b4cec82 Fix nlreturn lint errors in macse CGo bindings 2026-03-11 08:34:24 +07:00
4adeeae1db Add Secure Enclave unlocker for hardware-backed secret protection
Adds a new "secure-enclave" unlocker type that stores the vault's
long-term private key encrypted by a non-exportable P-256 key held
in the Secure Enclave hardware. Decryption (ECDH) is performed
inside the SE; the key never leaves the hardware.

Uses CryptoTokenKit identities created via sc_auth, which allows
SE access from unsigned binaries without Apple Developer Program
membership. ECIES (X963SHA256 + AES-GCM) handles encryption and
decryption through Security.framework.

New package internal/macse/ provides the CGo bridge to
Security.framework for SE key creation, ECIES encrypt/decrypt,
and key deletion. The SE unlocker directly encrypts the vault
long-term key (no intermediate age keypair).
2026-03-11 06:17:34 +07:00
40 changed files with 408 additions and 1446 deletions

View File

@@ -17,4 +17,5 @@ coverage.out
.claude/ .claude/
# Local settings # Local settings
.golangci.yml
.claude/settings.local.json .claude/settings.local.json

View File

@@ -1,9 +0,0 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-28
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild

View File

@@ -1,46 +1,50 @@
# Lint stage — fast feedback on formatting and lint issues # Build stage
# golangci/golangci-lint v2.1.6 (2026-03-10) FROM golang:1.24-alpine AS builder
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
WORKDIR /src # Install build dependencies
COPY go.mod go.sum ./ RUN apk add --no-cache \
RUN go mod download gcc \
musl-dev \
COPY . . make \
git
RUN make fmt-check
RUN make lint
# Build stage — tests and compilation
# golang 1.24.13-alpine (2026-03-10)
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
# Force BuildKit to run the lint stage
COPY --from=lint /src/go.sum /dev/null
RUN apk add --no-cache gcc musl-dev make git gnupg
# Set working directory
WORKDIR /build WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./ COPY go.mod go.sum ./
# Download dependencies
RUN go mod download RUN go mod download
# Copy source code
COPY . . COPY . .
RUN make test # Build the binary
RUN make build RUN CGO_ENABLED=1 go build -v -o secret cmd/secret/main.go
# Runtime stage # Runtime stage
# alpine 3.23 (2026-03-10) FROM alpine:latest
FROM alpine@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
RUN apk add --no-cache ca-certificates gnupg # Install runtime dependencies
RUN apk add --no-cache \
ca-certificates \
gnupg
# Create non-root user
RUN adduser -D -s /bin/sh secret RUN adduser -D -s /bin/sh secret
# Copy binary from builder
COPY --from=builder /build/secret /usr/local/bin/secret COPY --from=builder /build/secret /usr/local/bin/secret
# Ensure binary is executable
RUN chmod +x /usr/local/bin/secret RUN chmod +x /usr/local/bin/secret
# Switch to non-root user
USER secret USER secret
# Set working directory
WORKDIR /home/secret WORKDIR /home/secret
ENTRYPOINT ["secret"] # Set entrypoint
ENTRYPOINT ["secret"]

View File

@@ -7,16 +7,8 @@ GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
LDFLAGS := -X 'git.eeqj.de/sneak/secret/internal/cli.Version=$(VERSION)' \ LDFLAGS := -X 'git.eeqj.de/sneak/secret/internal/cli.Version=$(VERSION)' \
-X 'git.eeqj.de/sneak/secret/internal/cli.GitCommit=$(GIT_COMMIT)' -X 'git.eeqj.de/sneak/secret/internal/cli.GitCommit=$(GIT_COMMIT)'
.PHONY: default bootstrap setup test lint fmt fmt-check check docker hooks vet
default: check default: check
bootstrap:
@script/bootstrap
setup:
@script/setup
build: ./secret build: ./secret
./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.* ./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.*
@@ -25,25 +17,24 @@ build: ./secret
vet: vet:
go vet ./... go vet ./...
test: test: lint vet
@script/test go test ./... || go test -v ./...
fmt: fmt:
@script/fmt go fmt ./...
lint: lint:
@script/lint golangci-lint run --timeout 5m
check: check: build test
@script/check
# Build Docker container # Build Docker container
docker: docker:
@script/docker docker build -t sneak/secret .
# Run Docker container interactively # Run Docker container interactively
docker-run: docker-run:
docker run --rm -it "$$(./script/projectname)" docker run --rm -it sneak/secret
# Clean build artifacts # Clean build artifacts
clean: clean:
@@ -51,9 +42,3 @@ clean:
install: ./secret install: ./secret
cp ./secret $(HOME)/bin/secret cp ./secret $(HOME)/bin/secret
fmt-check:
@script/fmt-check
hooks:
@script/install-precommit

View File

@@ -184,7 +184,6 @@ Creates a new unlocker of the specified type:
- `passphrase`: Traditional passphrase-protected unlocker - `passphrase`: Traditional passphrase-protected unlocker
- `pgp`: Uses an existing GPG key for encryption/decryption - `pgp`: Uses an existing GPG key for encryption/decryption
- `keychain`: macOS Keychain integration (macOS only) - `keychain`: macOS Keychain integration (macOS only)
- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only)
**Options:** **Options:**
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified) - `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
@@ -287,11 +286,11 @@ Unlockers provide different authentication methods to access the long-term keys:
- Automatic unlocking when Keychain is unlocked - Automatic unlocking when Keychain is unlocked
- Cross-application integration - Cross-application integration
4. **Secure Enclave Unlockers** (macOS): 4. **Secure Enclave Unlockers** (macOS - planned):
- Hardware-backed key storage using Apple Secure Enclave - Hardware-backed key storage using Apple Secure Enclave
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer Program required) - Currently partially implemented but non-functional
- ECIES encryption: vault long-term key encrypted directly by SE hardware - Requires Apple Developer Program membership and code signing entitlements
- Protected by biometric authentication (Touch ID) or system password - Full implementation blocked by entitlement requirements
Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets. Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets.
@@ -331,7 +330,8 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
- Hardware token support via PGP/GPG integration - Hardware token support via PGP/GPG integration
- macOS Keychain integration for system-level security - macOS Keychain integration for system-level security
- Secure Enclave integration for hardware-backed key protection (macOS, via `sc_auth` / CryptoTokenKit) - Secure Enclave support planned (requires paid Apple Developer Program for
signed entitlements to access the SEP and doxxing myself to Apple)
## Examples ## Examples
@@ -385,7 +385,6 @@ secret vault remove personal --force
secret unlocker add passphrase # Password-based secret unlocker add passphrase # Password-based
secret unlocker add pgp --keyid ABCD1234 # GPG key secret unlocker add pgp --keyid ABCD1234 # GPG key
secret unlocker add keychain # macOS Keychain (macOS only) secret unlocker add keychain # macOS Keychain (macOS only)
secret unlocker add secure-enclave # macOS Secure Enclave (macOS only)
# List unlockers # List unlockers
secret unlocker list secret unlocker list
@@ -444,7 +443,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
### Cross-Platform Support ### Cross-Platform Support
- **macOS**: Full support including Keychain and Secure Enclave integration - **macOS**: Full support including Keychain and planned Secure Enclave integration
- **Linux**: Full support (excluding macOS-specific features) - **Linux**: Full support (excluding macOS-specific features)
## Security Considerations ## Security Considerations
@@ -486,39 +485,9 @@ go test ./... # Unit tests
go test -tags=integration -v ./internal/cli # Integration tests go test -tags=integration -v ./internal/cli # Integration tests
``` ```
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call
them. We provide:
- `script/bootstrap` — install all dependencies (Go, golangci-lint, Go
module download), idempotently
- `script/setup` — make a fresh clone ready for development: runs
`script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (`secret`); used by
other scripts such as `script/docker`
- `script/test` — run `go vet` and the test suite (verbose rerun on
failure)
- `script/lint` — run `golangci-lint`
- `script/fmt` — format all Go code (writes)
- `script/fmt-check` — check formatting without writing
- `script/check` — run `script/test`, `script/lint`, and
`script/fmt-check`
- `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — CI entrypoint: `docker build --ulimit
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
checks)
- `script/precommit` — pre-commit checks: `go mod tidy` verification,
then `script/check`
- `script/install-precommit` — install the git pre-commit hook that
runs `script/precommit`
## Features ## Features
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, and Secure Enclave unlockers - **Multiple Authentication Methods**: Supports passphrase, PGP, and macOS Keychain unlockers
- **Vault Isolation**: Complete separation between different vaults - **Vault Isolation**: Complete separation between different vaults
- **Per-Secret Encryption**: Each secret has its own encryption key - **Per-Secret Encryption**: Each secret has its own encryption key
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases - **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases

View File

@@ -1,408 +0,0 @@
---
title: Repository Policies
last_modified: 2026-07-06
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`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 and runs `docker build .`; the Gitea workflow calls it. 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).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), 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.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

222
TODO.md
View File

@@ -1,95 +1,147 @@
# Workflow # TODO for 1.0 Release
* branch (from `main`) This document outlines the bugs, issues, and improvements that need to be
* do the work in Next Step addressed before the 1.0 release of the secret manager. Items are
* move Next Step to the top of Completed Steps prioritized from most critical (top) to least critical (bottom).
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
# Status ## CRITICAL BLOCKERS FOR 1.0 RELEASE
pre-1.0. No git tags. TODO.md carries open 1.0 security blockers. Work in ### Command Injection Vulnerabilities
flight on branch secure-enclave-unlocker (clean tree as of 2026-07-06). - [ ] **1. PGP command injection risk**: `internal/secret/pgpunlocker.go:323-327` - GPG key IDs passed directly to exec.Command without proper escaping
- [ ] **2. Keychain command injection risk**: `internal/secret/keychainunlocker.go:472-476` - data.String() passed to security command without escaping
# Next Step ### Memory Security Critical Issues
- [ ] **3. Plain text passphrase in memory**: `internal/secret/keychainunlocker.go:342,393-396` - KeychainData struct stores AgePrivKeyPassphrase as unprotected string
- [ ] **4. Sensitive string conversions**: `internal/secret/keychainunlocker.go:356`, `internal/secret/pgpunlocker.go:256`, `internal/secret/version.go:155` - Age identity .String() creates unprotected copies
Bring the repo into policy compliance in one commit: ### Race Conditions (Data Corruption Risk)
- [ ] **5. No file locking mechanism**: `internal/vault/secrets.go:142-176` - Multiple concurrent operations can corrupt vault state
- [ ] **6. Non-atomic file operations**: Various locations - Interrupted writes leave vault inconsistent
- Add fmt-check and hooks targets to the Makefile (test/lint/fmt/check/ ### Input Validation Vulnerabilities
docker already exist). - [ ] **7. Path traversal risk**: `internal/vault/secrets.go:75-99` - Secret names allow dots which could enable traversal attacks with encoding
- Add REPO_POLICIES.md and .editorconfig. - [ ] **8. Missing size limits**: `internal/vault/secrets.go:102` - No maximum secret size allows DoS via memory exhaustion
- Add .gitea/workflows/check.yml running make check.
- Verify Dockerfile base images are pinned by sha256.
# Completed Steps ### Timing Attack Vulnerabilities
- [ ] **9. Non-constant-time passphrase comparison**: `internal/cli/init.go:209-216` - bytes.Equal() vulnerable to timing attacks
- [ ] **10. Non-constant-time key validation**: `internal/vault/vault.go:95-100` - Public key comparison leaks timing information
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, ## CRITICAL MEMORY SECURITY ISSUES
Makefile shims, README Entrypoints section
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
protection, plus review fixes (stub panics, derivation index, tests,
README) on branch secure-enclave-unlocker.
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with
missing metadata, allow uppercase secret names, fix hardcoded
derivation index, validate names in GetSecretVersion against path
traversal, return errors instead of panicking, add Warn() on silent
anomalies.
- Memory security hardening: LockedBuffer used through encrypt/decrypt
paths (Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated
bare-[]byte APIs removed.
- Per-secret keypair architecture, vault package refactor, versioning
with --version, comprehensive test suite with in-memory filesystem.
- Debug logging system (slog, GODEBUG flag, TTY-aware output).
- Renamed SEP unlocker to Keychain, reorganized import commands.
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic,
CLI).
# Future Steps ### Functions accepting bare []byte for sensitive data
- [x] **1. Secret.Save accepts unprotected data**: `internal/secret/secret.go:67` - `Save(value []byte, force bool)` - ✓ REMOVED - deprecated function deleted
- [x] **2. EncryptWithPassphrase accepts unprotected data**: `internal/secret/crypto.go:73` - `EncryptWithPassphrase(data []byte, passphrase *memguard.LockedBuffer)` - ✓ FIXED - now accepts LockedBuffer for data
- [x] **3. storeInKeychain accepts unprotected data**: `internal/secret/keychainunlocker.go:469` - `storeInKeychain(itemName string, data []byte)` - ✓ FIXED - now accepts LockedBuffer for data
- [x] **4. gpgEncryptDefault accepts unprotected data**: `internal/secret/pgpunlocker.go:351` - `gpgEncryptDefault(data []byte, keyID string)` - ✓ FIXED - now accepts LockedBuffer for data
- Compliance (after Next Step lands): keep main green under the new ### Functions returning unprotected secrets
.gitea workflow; run make check before every merge. - [x] **5. GetValue returns unprotected secret**: `internal/secret/secret.go:93` - `GetValue(unlocker Unlocker) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer internally
- Merge secure-enclave-unlocker to main once review is done. - [x] **6. DecryptWithIdentity returns unprotected data**: `internal/secret/crypto.go:57` - `DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
- 1.0 critical security blockers (from repo TODO.md): - [x] **7. DecryptWithPassphrase returns unprotected data**: `internal/secret/crypto.go:94` - `DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
- Command injection: GPG key IDs passed unescaped to exec.Command - [x] **8. gpgDecryptDefault returns unprotected data**: `internal/secret/pgpunlocker.go:368` - `gpgDecryptDefault(encryptedData []byte) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
(pgpunlocker.go:323-327); data.String() passed unescaped to the - [x] **9. getSecretValue returns unprotected data**: `internal/cli/crypto.go:269` - `getSecretValue()` returns bare []byte - ✓ ALREADY FIXED - returns LockedBuffer
security command (keychainunlocker.go:472-476).
- Memory security: KeychainData stores AgePrivKeyPassphrase as a ### Intermediate string variables for passphrases
plain string (keychainunlocker.go:342,393-396); age identity - [x] **10. Passphrase extracted to string**: `internal/secret/crypto.go:79,100` - `passphraseStr := passphrase.String()` - ✓ UNAVOIDABLE - age library requires string parameter
.String() creates unprotected copies (keychainunlocker.go:356, - [ ] **11. Age secret key in plain string**: `internal/cli/crypto.go:86,91,113` - Age secret key stored in plain string variable before conversion back to secure buffer
pgpunlocker.go:256, version.go:155); age secret key held in a
plain string in cli/crypto.go:86,91,113; private keys exposed via ### Unprotected buffer.Bytes() usage
buffer.Bytes() to GPGEncryptFunc and EncryptWithPassphrase. - [ ] **12. GPG encrypt exposes private key**: `internal/secret/pgpunlocker.go:256` - `GPGEncryptFunc(agePrivateKeyBuffer.Bytes(), gpgKeyID)` - private key exposed to external function
- Race conditions: no file locking in vault/secrets.go:142-176; - [ ] **13. Keychain encrypt exposes private key**: `internal/secret/keychainunlocker.go:371` - `EncryptWithPassphrase(agePrivKeyBuffer.Bytes(), passphraseBuffer)` - private key passed as bare bytes
non-atomic writes can leave the vault inconsistent.
- Input validation: dots in secret names risk path traversal ## Code Cleanups
(vault/secrets.go:75-99); no maximum secret size (DoS).
- Timing attacks: bytes.Equal passphrase compare (cli/init.go: * we shouldn't be passing around a statedir, it should be read from the
209-216); non-constant-time public key compare (vault.go:95-100). environment or default.
- High priority:
- Return errors instead of panicking on corrupted metadata ## HIGH PRIORITY SECURITY ISSUES
(pgpunlocker.go:116, keychainunlocker.go:141).
- Secure temporary file handling and cleanup. - [ ] **4. Application crashes on corrupted metadata**: Code panics instead
- Print cobra usage only for argument errors, not internal of returning errors when metadata is corrupt, causing denial of service.
failures. Found in pgpunlocker.go:116 and keychainunlocker.go:141.
- Initialize a default unlock key at vault creation.
- Confirmation prompts for destructive operations (keys rm, vault - [ ] **5. Insufficient input validation**: Secret names allow potentially
deletion). dangerous patterns including dots that could enable path traversal attacks
- Add secret rm and vault deletion commands. (vault/secrets.go:70-93).
- Medium priority:
- Standardize error messages; stop leaking internals. - [ ] **6. Race conditions in file operations**: Multiple concurrent
- Graceful handling of corrupted or missing key files with recovery operations could corrupt the vault state due to lack of file locking
suggestions. mechanisms.
- Validate GPG key existence before creating PGP unlock keys.
- Split oversized CLI functions. - [ ] **7. Insecure temporary file handling**: Temporary files containing
- Document env var security (SB_UNLOCK_PASSPHRASE, sensitive data may not be properly cleaned up or secured.
SB_SECRET_MNEMONIC); clear after use.
- mlock/munlock for sensitive allocations. ## HIGH PRIORITY FUNCTIONALITY ISSUES
- Cleanups: read statedir from environment or default instead of
passing it around. - [ ] **8. Inappropriate Cobra usage printing**: Commands currently print
- Enhancements: help examples, shell completion, colored output, usage information for all errors, including internal program failures.
--quiet flag, name suggestions on miss, audit logging, hardware Usage should only be printed when the user provides incorrect arguments or
integration tests (Keychain, GPG), naming consistency, vault invalid commands.
export/import, batch operations, search, secret metadata
(descriptions, tags). - [ ] **9. Missing current unlock key initialization**: When creating
vaults, no default unlock key is selected, which can cause operations to
fail.
- [ ] **10. Add confirmation prompts for destructive operations**:
Operations like `keys rm` and vault deletion should require confirmation.
- [ ] **11. No secret deletion command**: Missing `secret rm <secret-name>`
functionality.
- [ ] **12. Missing vault deletion command**: No way to delete vaults that
are no longer needed.
## MEDIUM PRIORITY ISSUES
- [ ] **13. Inconsistent error messages**: Error messages need
standardization and should be user-friendly. Many errors currently expose
internal implementation details.
- [ ] **14. No graceful handling of corrupted state**: If key files are
corrupted or missing, the tool should provide clear error messages and
recovery suggestions.
- [ ] **15. No validation of GPG key existence**: Should verify the
specified GPG key exists before creating PGP unlock keys.
- [ ] **16. Better separation of concerns**: Some functions in CLI do too
much and should be split.
- [ ] **17. Environment variable security**: Sensitive data read from
environment variables (SB_UNLOCK_PASSPHRASE, SB_SECRET_MNEMONIC) without
proper clearing. Document security implications.
- [ ] **18. No secure memory allocation**: No use of mlock/munlock to
prevent sensitive data from being swapped to disk.
## LOWER PRIORITY ENHANCEMENTS
- [ ] **19. Add `--help` examples**: Command help should include practical examples for each operation.
- [ ] **20. Add shell completion**: Bash/Zsh completion for commands and secret names.
- [ ] **21. Colored output**: Use colors to improve readability of lists and error messages.
- [ ] **22. Add `--quiet` flag**: Option to suppress non-essential output.
- [ ] **23. Smart secret name suggestions**: When a secret name is not found, suggest similar names.
- [ ] **24. Audit logging**: Log all secret access and modifications for security auditing.
- [ ] **25. Integration tests for hardware features**: Automated testing of Keychain and GPG functionality.
- [ ] **26. Consistent naming conventions**: Some variables and functions use inconsistent naming patterns.
- [ ] **27. Export/import functionality**: Add ability to export/import entire vaults, not just individual secrets.
- [ ] **28. Batch operations**: Add commands to process multiple secrets at once.
- [ ] **29. Search functionality**: Add ability to search secret names and potentially contents.
- [ ] **30. Secret metadata**: Add support for descriptions, tags, or other metadata with secrets.
## COMPLETED ITEMS ✓
- [x] **Missing secret history/versioning**: ✓ Implemented - versioning system exists with --version flag support
- [x] **XDG compliance on Linux**: ✓ Implemented - uses os.UserConfigDir() which respects XDG_CONFIG_HOME
- [x] **Consistent interface implementation**: ✓ Implemented - Unlocker interface is well-defined and consistently implemented

View File

@@ -1,10 +1,10 @@
package cli package cli
import ( import (
"log"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"

View File

@@ -1,8 +1,8 @@
package cli package cli
import ( import (
"fmt"
"log" "log"
"fmt"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"

View File

@@ -48,7 +48,7 @@ func TestMain(m *testing.M) {
code := m.Run() code := m.Run()
// Clean up the binary // Clean up the binary
_ = os.Remove(filepath.Join(projectRoot, "secret")) os.Remove(filepath.Join(projectRoot, "secret"))
os.Exit(code) os.Exit(code)
} }
@@ -450,10 +450,10 @@ func test02ListVaults(t *testing.T, runSecret func(...string) (string, error)) {
func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (string, error)) { func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (string, error)) {
// Set environment variables for vault creation // Set environment variables for vault creation
_ = os.Setenv("SB_SECRET_MNEMONIC", testMnemonic) os.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
_ = os.Setenv("SB_UNLOCK_PASSPHRASE", "test-passphrase") os.Setenv("SB_UNLOCK_PASSPHRASE", "test-passphrase")
defer func() { _ = os.Unsetenv("SB_SECRET_MNEMONIC") }() defer os.Unsetenv("SB_SECRET_MNEMONIC")
defer func() { _ = os.Unsetenv("SB_UNLOCK_PASSPHRASE") }() defer os.Unsetenv("SB_UNLOCK_PASSPHRASE")
// Create work vault // Create work vault
output, err := runSecret("vault", "create", "work") output, err := runSecret("vault", "create", "work")
@@ -489,7 +489,6 @@ func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (
assert.Contains(t, output, "work", "should list work vault") 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)) { func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase string, runSecretWithEnv func(map[string]string, ...string) (string, error)) {
// Import mnemonic into work vault // Import mnemonic into work vault
output, err := runSecretWithEnv(map[string]string{ output, err := runSecretWithEnv(map[string]string{
@@ -1668,9 +1667,9 @@ func test19DisasterRecovery(t *testing.T, tempDir, secretPath, testMnemonic stri
assert.Equal(t, testSecretValue, strings.TrimSpace(toolOutput), "tool output should match original") assert.Equal(t, testSecretValue, strings.TrimSpace(toolOutput), "tool output should match original")
// Clean up temporary files // Clean up temporary files
_ = os.Remove(ltPrivKeyPath) os.Remove(ltPrivKeyPath)
_ = os.Remove(versionPrivKeyPath) os.Remove(versionPrivKeyPath)
_ = os.Remove(decryptedValuePath) os.Remove(decryptedValuePath)
} }
func test20VersionTimestamps(t *testing.T, tempDir, secretPath, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) { func test20VersionTimestamps(t *testing.T, tempDir, secretPath, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) {
@@ -1789,7 +1788,7 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
// Add secret without mnemonic or unlocker // Add secret without mnemonic or unlocker
unsetMnemonic := os.Getenv("SB_SECRET_MNEMONIC") unsetMnemonic := os.Getenv("SB_SECRET_MNEMONIC")
_ = os.Unsetenv("SB_SECRET_MNEMONIC") os.Unsetenv("SB_SECRET_MNEMONIC")
cmd := exec.Command(secretPath, "add", "test/nomnemonic") cmd := exec.Command(secretPath, "add", "test/nomnemonic")
cmd.Env = []string{ cmd.Env = []string{
fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir),
@@ -2129,7 +2128,7 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
versionsPath := filepath.Join(secretPath, "versions") versionsPath := filepath.Join(secretPath, "versions")
if _, err := os.Stat(versionsPath); os.IsNotExist(err) { if _, err := os.Stat(versionsPath); os.IsNotExist(err) {
// This is a malformed secret directory, remove it // This is a malformed secret directory, remove it
_ = os.RemoveAll(secretPath) os.RemoveAll(secretPath)
} }
} }
} }
@@ -2179,7 +2178,7 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
require.NoError(t, err, "restore vaults should succeed") require.NoError(t, err, "restore vaults should succeed")
// Restore currentvault // Restore currentvault
_ = os.Remove(currentVaultSrc) os.Remove(currentVaultSrc)
restoredData := readFile(t, currentVaultDst) restoredData := readFile(t, currentVaultDst)
writeFile(t, currentVaultSrc, restoredData) writeFile(t, currentVaultSrc, restoredData)
@@ -2285,8 +2284,6 @@ func verifyFileExists(t *testing.T, path string) {
} }
// verifyFileNotExists checks if a file does not exist at the given path // verifyFileNotExists checks if a file does not exist at the given path
//
//nolint:unused // kept for future use
func verifyFileNotExists(t *testing.T, path string) { func verifyFileNotExists(t *testing.T, path string) {
t.Helper() t.Helper()
_, err := os.Stat(path) _, err := os.Stat(path)

View File

@@ -1,10 +1,10 @@
package cli package cli
import ( import (
"log"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"path/filepath" "path/filepath"
"strings" "strings"

View File

@@ -1,9 +1,9 @@
package cli package cli
import ( import (
"log"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"

View File

@@ -1,9 +1,9 @@
package cli package cli
import ( import (
"log"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"

View File

@@ -1,8 +1,8 @@
package cli package cli
import ( import (
"fmt"
"log" "log"
"fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"text/tabwriter" "text/tabwriter"

View File

@@ -1,5 +1,3 @@
//go:build darwin
#ifndef SECURE_ENCLAVE_H #ifndef SECURE_ENCLAVE_H
#define SECURE_ENCLAVE_H #define SECURE_ENCLAVE_H

View File

@@ -1,5 +1,3 @@
//go:build darwin
#import <Foundation/Foundation.h> #import <Foundation/Foundation.h>
#import <Security/Security.h> #import <Security/Security.h>
#include "secure_enclave.h" #include "secure_enclave.h"

View File

@@ -1,5 +1,3 @@
//go:build darwin
package secret package secret
import ( import (
@@ -26,12 +24,12 @@ type realVault struct {
func (v *realVault) GetDirectory() (string, error) { func (v *realVault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.name), nil return filepath.Join(v.stateDir, "vaults.d", v.name), nil
} }
func (v *realVault) GetName() string { return v.name } func (v *realVault) GetName() string { return v.name }
func (v *realVault) GetFilesystem() afero.Fs { return v.fs } func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
// Unused by getLongTermPrivateKey — these satisfy VaultInterface. // Unused by getLongTermPrivateKey — these satisfy VaultInterface.
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") } func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") } func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) { func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
panic("not used") panic("not used")
} }

View File

@@ -1,11 +1,33 @@
package secret package secret
import ( import (
"crypto/rand"
"fmt" "fmt"
"math/big"
"os" "os"
"path/filepath" "path/filepath"
) )
// generateRandomString generates a random string of the specified length using the given character set
func generateRandomString(length int, charset string) (string, error) {
if length <= 0 {
return "", fmt.Errorf("length must be positive")
}
result := make([]byte, length)
charsetLen := big.NewInt(int64(len(charset)))
for i := range length {
randomIndex, err := rand.Int(rand.Reader, charsetLen)
if err != nil {
return "", fmt.Errorf("failed to generate random number: %w", err)
}
result[i] = charset[randomIndex.Int64()]
}
return string(result), nil
}
// DetermineStateDir determines the state directory based on environment variables and OS. // DetermineStateDir determines the state directory based on environment variables and OS.
// It returns an error if no usable directory can be determined. // It returns an error if no usable directory can be determined.
func DetermineStateDir(customConfigDir string) (string, error) { func DetermineStateDir(customConfigDir string) (string, error) {

View File

@@ -1,29 +0,0 @@
//go:build darwin
package secret
import (
"crypto/rand"
"fmt"
"math/big"
)
// generateRandomString generates a random string of the specified length using the given character set
func generateRandomString(length int, charset string) (string, error) {
if length <= 0 {
return "", fmt.Errorf("length must be positive")
}
result := make([]byte, length)
charsetLen := big.NewInt(int64(len(charset)))
for i := range length {
randomIndex, err := rand.Int(rand.Reader, charsetLen)
if err != nil {
return "", fmt.Errorf("failed to generate random number: %w", err)
}
result[i] = charset[randomIndex.Int64()]
}
return string(result), nil
}

View File

@@ -24,7 +24,7 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
} }
defer func() { _ = os.RemoveAll(tempDir) }() // Clean up after test defer os.RemoveAll(tempDir) // Clean up after test
// Use the real filesystem // Use the real filesystem
fs := afero.NewOsFs() fs := afero.NewOsFs()
@@ -155,7 +155,7 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
}) })
// Unset the environment variable to test interactive prompt // Unset the environment variable to test interactive prompt
_ = os.Unsetenv(secret.EnvUnlockPassphrase) os.Unsetenv(secret.EnvUnlockPassphrase)
// Test getting identity from prompt (this would require mocking the prompt) // Test getting identity from prompt (this would require mocking the prompt)
// For real integration tests, we'd need to provide a way to mock the passphrase input // For real integration tests, we'd need to provide a way to mock the passphrase input

View File

@@ -1,5 +1,3 @@
//go:build darwin
package secret_test package secret_test
import ( import (
@@ -142,7 +140,7 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
} }
defer func() { _ = os.RemoveAll(tempDir) }() // Clean up after test defer os.RemoveAll(tempDir) // Clean up after test
// Create a temporary GNUPGHOME // Create a temporary GNUPGHOME
gnupgHomeDir := filepath.Join(tempDir, "gnupg") gnupgHomeDir := filepath.Join(tempDir, "gnupg")

View File

@@ -284,11 +284,11 @@ func TestSecretNameValidation(t *testing.T) {
{"valid/path/name", true}, {"valid/path/name", true},
{"123valid", true}, {"123valid", true},
{"", false}, {"", false},
{"Valid-Upper-Name", true}, // uppercase allowed {"Valid-Upper-Name", true}, // uppercase allowed
{"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true}, // real-world uppercase key ID {"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true}, // real-world uppercase key ID
{"MixedCase/Path/Name", true}, // mixed case with path {"MixedCase/Path/Name", true}, // mixed case with path
{"invalid name", false}, // space not allowed {"invalid name", false}, // space not allowed
{"invalid@name", false}, // @ not allowed {"invalid@name", false}, // @ not allowed
} }
for _, test := range tests { for _, test := range tests {

View File

@@ -60,10 +60,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
encryptedPath := filepath.Join(s.Directory, seLongtermFilename) encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
encryptedData, err := afero.ReadFile(s.fs, encryptedPath) encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to read SE-encrypted long-term key: %w", err)
"failed to read SE-encrypted long-term key: %w",
err,
)
} }
DebugWith("Read SE-encrypted long-term key", DebugWith("Read SE-encrypted long-term key",
@@ -73,10 +70,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
// Decrypt using the Secure Enclave (ECDH happens inside SE hardware) // Decrypt using the Secure Enclave (ECDH happens inside SE hardware)
decryptedData, err := macse.Decrypt(seKeyLabel, encryptedData) decryptedData, err := macse.Decrypt(seKeyLabel, encryptedData)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to decrypt long-term key with SE: %w", err)
"failed to decrypt long-term key with SE: %w",
err,
)
} }
// Parse the decrypted long-term private key // Parse the decrypted long-term private key
@@ -88,10 +82,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
} }
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
"failed to parse long-term private key: %w",
err,
)
} }
DebugWith("Successfully decrypted long-term key via SE", DebugWith("Successfully decrypted long-term key via SE",
@@ -174,11 +165,7 @@ func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err e
} }
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance. // NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker( func NewSecureEnclaveUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *SecureEnclaveUnlocker {
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{ return &SecureEnclaveUnlocker{
Directory: directory, Directory: directory,
Metadata: metadata, Metadata: metadata,
@@ -195,22 +182,13 @@ func generateSEKeyLabel(vaultName string) (string, error) {
enrollmentDate := time.Now().UTC().Format("2006-01-02") enrollmentDate := time.Now().UTC().Format("2006-01-02")
return fmt.Sprintf( return fmt.Sprintf("%s.%s-%s-%s", seKeyLabelPrefix, vaultName, hostname, enrollmentDate), nil
"%s.%s-%s-%s",
seKeyLabelPrefix,
vaultName,
hostname,
enrollmentDate,
), nil
} }
// CreateSecureEnclaveUnlocker creates a new SE unlocker. // CreateSecureEnclaveUnlocker creates a new SE unlocker.
// The vault's long-term private key is encrypted directly by the Secure Enclave // The vault's long-term private key is encrypted directly by the Secure Enclave
// using ECIES. No intermediate age keypair is used. // using ECIES. No intermediate age keypair is used.
func CreateSecureEnclaveUnlocker( func CreateSecureEnclaveUnlocker(fs afero.Fs, stateDir string) (*SecureEnclaveUnlocker, error) {
fs afero.Fs,
stateDir string,
) (*SecureEnclaveUnlocker, error) {
if err := checkMacOSAvailable(); err != nil { if err := checkMacOSAvailable(); err != nil {
return nil, err return nil, err
} }
@@ -238,20 +216,14 @@ func CreateSecureEnclaveUnlocker(
// Step 2: Get the vault's long-term private key // Step 2: Get the vault's long-term private key
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault) ltPrivKeyData, err := getLongTermKeyForSE(fs, vault)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to get long-term private key: %w", err)
"failed to get long-term private key: %w",
err,
)
} }
defer ltPrivKeyData.Destroy() defer ltPrivKeyData.Destroy()
// Step 3: Encrypt the long-term key directly with the SE (ECIES) // Step 3: Encrypt the long-term key directly with the SE (ECIES)
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes()) encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to encrypt long-term key with SE: %w", err)
"failed to encrypt long-term key with SE: %w",
err,
)
} }
// Step 4: Create unlocker directory and write files // Step 4: Create unlocker directory and write files
@@ -263,19 +235,13 @@ func CreateSecureEnclaveUnlocker(
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel)) unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName) unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil { if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
"failed to create unlocker directory: %w",
err,
)
} }
// Write SE-encrypted long-term key // Write SE-encrypted long-term key
ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename) ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename)
if err := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); err != nil { if err := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to write SE-encrypted long-term key: %w", err)
"failed to write SE-encrypted long-term key: %w",
err,
)
} }
// Write metadata // Write metadata
@@ -308,40 +274,12 @@ func CreateSecureEnclaveUnlocker(
// getLongTermKeyForSE retrieves the vault's long-term private key // getLongTermKeyForSE retrieves the vault's long-term private key
// either from the mnemonic env var or by unlocking via the current unlocker. // either from the mnemonic env var or by unlocking via the current unlocker.
func getLongTermKeyForSE( func getLongTermKeyForSE(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
fs afero.Fs,
vault VaultInterface,
) (*memguard.LockedBuffer, error) {
envMnemonic := os.Getenv(EnvMnemonic) envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" { if envMnemonic != "" {
// Read vault metadata to get the correct derivation index ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
vaultDir, err := vault.GetDirectory()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err) return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(
envMnemonic,
metadata.DerivationIndex,
)
if err != nil {
return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w",
err,
)
} }
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
@@ -354,29 +292,17 @@ func getLongTermKeyForSE(
currentIdentity, err := currentUnlocker.GetIdentity() currentIdentity, err := currentUnlocker.GetIdentity()
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
"failed to get current unlocker identity: %w",
err,
)
} }
// All unlocker types store longterm.age in their directory // All unlocker types store longterm.age in their directory
longtermPath := filepath.Join( longtermPath := filepath.Join(currentUnlocker.GetDirectory(), "longterm.age")
currentUnlocker.GetDirectory(),
"longterm.age",
)
encryptedLtKey, err := afero.ReadFile(fs, longtermPath) encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to read encrypted long-term key: %w", err)
"failed to read encrypted long-term key: %w",
err,
)
} }
ltPrivKeyBuffer, err := DecryptWithIdentity( ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtKey, currentIdentity)
encryptedLtKey,
currentIdentity,
)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term key: %w", err) return nil, fmt.Errorf("failed to decrypt long-term key: %w", err)
} }

View File

@@ -4,16 +4,10 @@
package secret package secret
import ( import (
"fmt"
"filippo.io/age" "filippo.io/age"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
var errSENotSupported = fmt.Errorf(
"secure enclave unlockers are only supported on macOS",
)
// SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms. // SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms.
type SecureEnclaveUnlockerMetadata struct { type SecureEnclaveUnlockerMetadata struct {
UnlockerMetadata UnlockerMetadata
@@ -28,57 +22,42 @@ type SecureEnclaveUnlocker struct {
fs afero.Fs fs afero.Fs
} }
// GetIdentity returns an error on non-Darwin platforms. // GetIdentity panics on non-Darwin platforms.
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) { func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
return nil, errSENotSupported panic("secure enclave unlockers are only supported on macOS")
} }
// GetType returns the unlocker type. // GetType panics on non-Darwin platforms.
func (s *SecureEnclaveUnlocker) GetType() string { func (s *SecureEnclaveUnlocker) GetType() string {
return "secure-enclave" panic("secure enclave unlockers are only supported on macOS")
} }
// GetMetadata returns the unlocker metadata. // GetMetadata panics on non-Darwin platforms.
func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata { func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata {
return s.Metadata panic("secure enclave unlockers are only supported on macOS")
} }
// GetDirectory returns the unlocker directory. // GetDirectory panics on non-Darwin platforms.
func (s *SecureEnclaveUnlocker) GetDirectory() string { func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory panic("secure enclave unlockers are only supported on macOS")
} }
// GetID returns the unlocker ID. // GetID panics on non-Darwin platforms.
func (s *SecureEnclaveUnlocker) GetID() string { func (s *SecureEnclaveUnlocker) GetID() string {
return fmt.Sprintf( panic("secure enclave unlockers are only supported on macOS")
"%s-secure-enclave",
s.Metadata.CreatedAt.Format("2006-01-02.15.04"),
)
} }
// Remove returns an error on non-Darwin platforms. // Remove panics on non-Darwin platforms.
func (s *SecureEnclaveUnlocker) Remove() error { func (s *SecureEnclaveUnlocker) Remove() error {
return errSENotSupported panic("secure enclave unlockers are only supported on macOS")
} }
// NewSecureEnclaveUnlocker creates a stub SecureEnclaveUnlocker on non-Darwin platforms. // NewSecureEnclaveUnlocker panics on non-Darwin platforms.
// The returned instance's methods that require macOS functionality will return errors. func NewSecureEnclaveUnlocker(_ afero.Fs, _ string, _ UnlockerMetadata) *SecureEnclaveUnlocker {
func NewSecureEnclaveUnlocker( panic("secure enclave unlockers are only supported on macOS")
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
} }
// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms. // CreateSecureEnclaveUnlocker panics on non-Darwin platforms.
func CreateSecureEnclaveUnlocker( func CreateSecureEnclaveUnlocker(_ afero.Fs, _ string) (*SecureEnclaveUnlocker, error) {
_ afero.Fs, panic("secure enclave unlockers are only supported on macOS")
_ string,
) (*SecureEnclaveUnlocker, error) {
return nil, errSENotSupported
} }

View File

@@ -1,90 +0,0 @@
//go:build !darwin
// +build !darwin
package secret
import (
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSecureEnclaveUnlocker(t *testing.T) {
fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker"
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
Flags: []string{"secure-enclave", "macos"},
}
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
// Test GetType returns correct type
assert.Equal(t, "secure-enclave", 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 a formatted string with the creation timestamp
expectedID := "2026-01-15.10.30-secure-enclave"
assert.Equal(t, expectedID, unlocker.GetID())
}
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
identity, err := unlocker.GetIdentity()
assert.Nil(t, identity)
assert.Error(t, err)
assert.ErrorIs(t, err, errSENotSupported)
}
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
err := unlocker.Remove()
assert.Error(t, err)
assert.ErrorIs(t, err, errSENotSupported)
}
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
fs := afero.NewMemMapFs()
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
assert.Nil(t, unlocker)
assert.Error(t, err)
assert.ErrorIs(t, err, errSENotSupported)
}
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
// Verify the stub implements the Unlocker interface
var _ Unlocker = unlocker
}

View File

@@ -1,101 +0,0 @@
//go:build darwin
// +build darwin
package secret
import (
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSecureEnclaveUnlocker(t *testing.T) {
fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker"
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
Flags: []string{"secure-enclave", "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) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
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) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
id := unlocker.GetID()
// ID should contain the timestamp and "secure-enclave" type
assert.Contains(t, id, "2026-03-10.14.30")
assert.Contains(t, id, seUnlockerType)
}
func TestGenerateSEKeyLabel(t *testing.T) {
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) {
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: "secure-enclave",
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)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key")
}

View File

@@ -1,148 +0,0 @@
//go:build darwin
package secret
import (
"testing"
)
func TestValidateKeychainItemName(t *testing.T) {
tests := []struct {
name string
itemName string
wantErr bool
}{
// Valid cases
{
name: "valid simple name",
itemName: "my-secret-key",
wantErr: false,
},
{
name: "valid name with dots",
itemName: "com.example.app.key",
wantErr: false,
},
{
name: "valid name with underscores",
itemName: "my_secret_key_123",
wantErr: false,
},
{
name: "valid alphanumeric",
itemName: "Secret123Key",
wantErr: false,
},
{
name: "valid with hyphen at start",
itemName: "-my-key",
wantErr: false,
},
{
name: "valid with dot at start",
itemName: ".hidden-key",
wantErr: false,
},
// Invalid cases
{
name: "empty item name",
itemName: "",
wantErr: true,
},
{
name: "item name with spaces",
itemName: "my secret key",
wantErr: true,
},
{
name: "item name with semicolon",
itemName: "key;rm -rf /",
wantErr: true,
},
{
name: "item name with pipe",
itemName: "key|cat /etc/passwd",
wantErr: true,
},
{
name: "item name with backticks",
itemName: "key`whoami`",
wantErr: true,
},
{
name: "item name with dollar sign",
itemName: "key$(whoami)",
wantErr: true,
},
{
name: "item name with quotes",
itemName: "key\"name",
wantErr: true,
},
{
name: "item name with single quotes",
itemName: "key'name",
wantErr: true,
},
{
name: "item name with backslash",
itemName: "key\\name",
wantErr: true,
},
{
name: "item name with newline",
itemName: "key\nname",
wantErr: true,
},
{
name: "item name with carriage return",
itemName: "key\rname",
wantErr: true,
},
{
name: "item name with ampersand",
itemName: "key&echo test",
wantErr: true,
},
{
name: "item name with redirect",
itemName: "key>/tmp/test",
wantErr: true,
},
{
name: "item name with null byte",
itemName: "key\x00name",
wantErr: true,
},
{
name: "item name with parentheses",
itemName: "key(test)",
wantErr: true,
},
{
name: "item name with brackets",
itemName: "key[test]",
wantErr: true,
},
{
name: "item name with asterisk",
itemName: "key*",
wantErr: true,
},
{
name: "item name with question mark",
itemName: "key?",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateKeychainItemName(tt.itemName)
if (err != nil) != tt.wantErr {
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@@ -154,3 +154,144 @@ func TestValidateGPGKeyID(t *testing.T) {
}) })
} }
} }
func TestValidateKeychainItemName(t *testing.T) {
tests := []struct {
name string
itemName string
wantErr bool
}{
// Valid cases
{
name: "valid simple name",
itemName: "my-secret-key",
wantErr: false,
},
{
name: "valid name with dots",
itemName: "com.example.app.key",
wantErr: false,
},
{
name: "valid name with underscores",
itemName: "my_secret_key_123",
wantErr: false,
},
{
name: "valid alphanumeric",
itemName: "Secret123Key",
wantErr: false,
},
{
name: "valid with hyphen at start",
itemName: "-my-key",
wantErr: false,
},
{
name: "valid with dot at start",
itemName: ".hidden-key",
wantErr: false,
},
// Invalid cases
{
name: "empty item name",
itemName: "",
wantErr: true,
},
{
name: "item name with spaces",
itemName: "my secret key",
wantErr: true,
},
{
name: "item name with semicolon",
itemName: "key;rm -rf /",
wantErr: true,
},
{
name: "item name with pipe",
itemName: "key|cat /etc/passwd",
wantErr: true,
},
{
name: "item name with backticks",
itemName: "key`whoami`",
wantErr: true,
},
{
name: "item name with dollar sign",
itemName: "key$(whoami)",
wantErr: true,
},
{
name: "item name with quotes",
itemName: "key\"name",
wantErr: true,
},
{
name: "item name with single quotes",
itemName: "key'name",
wantErr: true,
},
{
name: "item name with backslash",
itemName: "key\\name",
wantErr: true,
},
{
name: "item name with newline",
itemName: "key\nname",
wantErr: true,
},
{
name: "item name with carriage return",
itemName: "key\rname",
wantErr: true,
},
{
name: "item name with ampersand",
itemName: "key&echo test",
wantErr: true,
},
{
name: "item name with redirect",
itemName: "key>/tmp/test",
wantErr: true,
},
{
name: "item name with null byte",
itemName: "key\x00name",
wantErr: true,
},
{
name: "item name with parentheses",
itemName: "key(test)",
wantErr: true,
},
{
name: "item name with brackets",
itemName: "key[test]",
wantErr: true,
},
{
name: "item name with asterisk",
itemName: "key*",
wantErr: true,
},
{
name: "item name with question mark",
itemName: "key?",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateKeychainItemName(tt.itemName)
if (err != nil) != tt.wantErr {
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@@ -1,155 +0,0 @@
#!/bin/sh
# 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).
#
# 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.
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
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
PKGMGR=""
SUDO=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# nvm is a bash script; run a command in a bash with nvm loaded
nvm_sh() {
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
}
ensure_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash, so install it too
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
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nvm.tar.gz" \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
ensure_node() {
if ! missing node; then return 0; fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
}
ensure_yarn() {
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
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
# ---- JS / docs repos ----
# ensure_node
# ensure_yarn
# install_js_deps
# ---- Go repos ----
if missing go; then pkg_install go golang go go; fi
# golangci-lint: packaged in nix, brew, and apk. On apt there is no
# package: download a specific release archive from GitHub and
# verify its hash (verify_sha256), never curl | sh.
if missing golangci-lint; then
pkg_install golangci-lint golangci-lint golangci-lint golangci-lint
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"
}
main "$@"

View File

@@ -1,15 +0,0 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
# Generic: usually needs no adaptation.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

View File

@@ -1,15 +0,0 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check
# (via make check), so a successful build implies all checks pass.
# The Gitea workflow runs this on push. The memlock ulimit is required
# because the test suite uses memguard, which mlocks memory.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --ulimit memlock=-1:-1 .
}
main "$@"

View File

@@ -1,15 +0,0 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# Generic: needs no adaptation.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
go fmt ./...
}
main "$@"

View File

@@ -1,17 +0,0 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
if [ -n "$(gofmt -l .)" ]; then
echo "Files need formatting:"
gofmt -l .
exit 1
fi
}
main "$@"

View File

@@ -1,17 +0,0 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
# Generic: needs no adaptation.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

View File

@@ -1,14 +0,0 @@
#!/bin/sh
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
# CGO is required (Makefile exports this too)
export CGO_ENABLED=1
golangci-lint run --timeout 5m
}
main "$@"

View File

@@ -1,20 +0,0 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. Go repo
# extras run first: go mod tidy and go fmt, failing the commit if they
# change go.mod or go.sum.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go mod tidy
go fmt ./...
git diff --exit-code -- go.mod go.sum ||
{ echo "go mod tidy changed files; stage and retry" >&2; exit 1; }
"$SCRIPT_DIR/check"
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "secret"
}
main "$@"

View File

@@ -1,14 +0,0 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies (script/bootstrap) and the git pre-commit hook.
# Add any repo-specific initialization (db init, .env template) here.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

View File

@@ -1,15 +0,0 @@
#!/bin/sh
# script/test: run the test suite (vet first, verbose rerun on failure).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
# CGO is required (Makefile exports this too)
export CGO_ENABLED=1
go vet ./...
go test ./... || go test -v ./...
}
main "$@"