macvault
v0.1.0
Published
macOS Keychain-backed secrets over a local Vault-like API and CLI.
Maintainers
Readme
MacVault
MacVault is a macOS-only proof of concept that exposes selected Keychain items through a tiny Vault-like API, a remote-friendly CLI, and a local React console. Keychain remains the only source of truth.
Threat Model
MacVault is designed for a single operator with a trusted Mac and a remote VPS connected over Tailscale.
- Transport security is delegated to Tailscale. Do not expose this service to the public internet.
- Every
/v1/*endpoint requiresAuthorization: Bearer <KCV_TOKEN>. - Bearer token checks compare SHA-256 digests with
crypto.timingSafeEqualso unequal-length and unequal-byte cases share the same hash-then-compare path. - The server refuses
0.0.0.0/::binds and refuses any hostname that resolves outside127.0.0.1,::1, the Tailscale CGNAT range100.64.0.0/10, orfd7a:115c:a1e0::/48. SetKCV_ALLOW_UNSAFE_HOST=1to override (do not). - Incoming requests are rejected if their
Hostheader is not in the allowlist (loopback + the configured bind + anyKCV_HOST_ALIASESentries). This kills DNS rebinding attacks against127.0.0.1. - Responses ship strict
Content-Security-Policy,X-Frame-Options: DENY,Referrer-Policy: no-referrer, andX-Content-Type-Options: nosniff. - Request logs include method, path, status, and duration only. Secret values, bearer tokens, and request bodies are never logged. Secret names in
/v1/secrets/:namepaths are replaced with a 12-char SHA-256 prefix in logs. - The server keeps an internal index (
macvault-meta.indexKeychain entry) and never callsdump-keychain, so it has no read scope over unrelated Keychain items. - macOS Keychain ACLs still gate access by the
securityprocess and may prompt on first read.
TLS is intentionally out of scope for this POC because Tailscale is the transport boundary.
Install On The Mac
./scripts/install.shThe installer builds the React console, compiles the Bun binary, installs a LaunchAgent, generates or reuses a 48-byte bearer token in Keychain, and prints a local curl test.
The LaunchAgent does NOT store the bearer token in its plist. It sets KCV_TOKEN_FROM_KEYCHAIN=1, and the binary loads the token from the macvault-meta.token Keychain entry at startup. The plist is also installed mode 0600. Retrieve the token at any time with:
security find-generic-password -s macvault-meta.token -a default -wInstall With npm
The npm package installs the macvault command and requires Bun on the target Mac because the CLI entrypoint runs TypeScript through Bun.
npm install -g macvault
macvault --helpFor a manual local server run:
export KCV_TOKEN="$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=')"
macvault serveThe npm package includes the built React console under dist/client. Run bun run build:package before packing or publishing so those assets are fresh.
CLI On A VPS
Copy the compiled binary to the VPS:
scp dist/macvault user@vps:/usr/local/bin/macvaultCreate ~/.config/macvault/config.toml on the VPS:
url = "http://your-mac.tailnet-name.ts.net:7331"
token = "paste-token-from-installer"Then use stdin-first writes so secrets do not land in shell history:
echo "sk-..." | macvault set openai_key
macvault get openai_key --raw
macvault list
macvault rm openai_keyCLI config resolution order is flags, then MACVAULT_URL / MACVAULT_TOKEN, then ~/.config/macvault/config.toml.
API
All endpoints except /health require Authorization: Bearer <KCV_TOKEN>.
GET /health
GET /v1/secrets
GET /v1/secrets/:name
PUT /v1/secrets/:name { "value": "secret", "account": "optional" }
DELETE /v1/secrets/:nameBodies are validated with strict Zod schemas, so unknown fields are rejected.
React Console
After install, open:
http://127.0.0.1:7331By default the console holds the bearer token in memory only — it is lost on reload. Enable "Keep token for this tab only" to persist it in sessionStorage (per-tab, never localStorage); closing the tab purges it.
Binding To Tailscale
Keep the default loopback bind for local-only operation:
KCV_HOST=127.0.0.1 macvault serveTo listen directly on your Tailscale address, set KCV_HOST to that specific interface IP or DNS-resolved address. Never use 0.0.0.0. The server validates that the resolved address falls inside loopback or the Tailscale CGNAT range and refuses to start otherwise.
KCV_HOST=100.x.y.z KCV_TOKEN="..." macvault serveIf you bind to a Tailscale interface, also set KCV_HOST_ALIASES to the additional Host: headers clients will send (typically your tailnet DNS name). Requests with any other Host header are rejected with 403 forbidden_host:
KCV_HOST=100.x.y.z \
KCV_HOST_ALIASES=your-mac.tailnet-name.ts.net \
KCV_TOKEN_FROM_KEYCHAIN=1 \
macvault serveFirst-Run Keychain ACL Gotcha
macOS may prompt the first time the launchd-spawned binary reads each Keychain item. Either run macvault serve interactively once and click "Always Allow", or add the installed binary to each item's ACL with the security CLI -T option.
Development
bun install
bun run dev:server
bun run dev
bun test
bun run typecheck
bun run buildBuild output:
- UI:
dist/client - Binary:
dist/macvault
Npm publishing smoke check:
bun test
bun run typecheck
bun run build:package
npm run pack:dry-run