Secure remote signing and team-managed permissions for Nostr keys.
Keycast is an early-stage project. A May 2026 audit found and fixed several auth, permission, data integrity, and dependency issues, but this should still get a deployment review before it is trusted with real team keys on the public internet. See AUDIT.md for the current status.
Keycast lets a team:
- sign in with a Nostr key and manage team membership,
- store Nostr private keys encrypted at rest in SQLite,
- create policies and permissions for stored keys,
- generate NIP-46 bunker connection strings,
- run signer processes that approve or deny remote signing requests from those policies.
The project was written for teams rather than individual remote-signing workflows like nsec.app,
Knox, or Amber.
core/- shared Rust models, database helpers, encryption, and permission checks.api/- Axum API, SQLite setup, NIP-98 HTTP auth middleware, and team/key/policy routes.signer/- signer manager plus onesigner_daemonprocess per authorization.web/- SvelteKit app that asks a NIP-07 signer to sign NIP-98 auth events.database/migrations/- SQLite schema.scripts/- key generation, Docker entrypoint, health checks, and deployment init.
These commands were run after the May 14, 2026 hardening pass:
cargo check --workspacepasses with no warnings.cargo build --workspacepasses.cargo test --workspacepasses.cd web && bun testpasses.cd web && bun run checkpasses with no warnings.cd web && bun run buildpasses with no warnings.bun auditandcd web && bun auditreport no vulnerabilities.bun pm scanandcd web && bun pm scanrun through the configured Socket scanner and report no advisories in free mode.cargo auditexits quietly..cargo/audit.tomldocuments the ignored upstreaminstantadvisory, which is pulled into the lockfile bynostrfor wasm targets and is not used by the native runtime.bash -npasses forscripts/init.sh,scripts/upgrade_preflight.sh,scripts/docker-entrypoint.sh,scripts/healthcheck.sh, andscripts/generate_key.sh.- Migration
0002_normalize_allowed_kinds_permissions.sqlwas smoke-tested against a migrated SQLite database and converts the legacy{"sign":[...]}permission shape to{"allowed_kinds":[...]}. - Docker Compose config rendering passes with explicit
ALLOWED_PUBKEYS,KEYCAST_UID, andKEYCAST_GIDvalues. DOCKER_BUILDKIT=1 docker build -t keycast-runtime-check .passes with digest-pinned base images, frozen lockfiles, and production frontend dependencies.- The built API image starts under
--read-only, creates a fresh SQLite database on a writable bind mount, and applies migrations 1 and 2. - The built web image starts as UID/GID
10001, serves/healthunder--read-onlyplus/tmptmpfs, and emits the expected CSP, HSTS, frame, referrer, permissions, and content-type headers.
Requirements:
- Rust toolchain
- Bun
- SQLx CLI for database reset commands
cargo-watchfor the combined dev commandopensslor/dev/urandomformaster.keygeneration
Install dependencies:
bun install
cd web && bun installGenerate the local encryption key:
bun run key:generateThis creates master.key at the repo root. It is ignored by git and must never be committed. The
current FileKeyManager reads this root file directly.
Create .env from .env.example for Docker/deployment settings:
cp .env.example .envALLOWED_PUBKEYS is enforced by the API for NIP-98-authenticated requests. The API also exposes an
unauthenticated /api/config?pubkey=<hex> check so the browser can ask whether the current pubkey is
allowed without receiving the full server allowlist.
Run API, web, and signer together:
bun run devThe local dev scripts run the API on http://localhost:3100 and point the web app at that origin.
The Docker/runtime default API port remains 3000.
Run pieces separately:
bun run dev:api
bun run dev:web
bun run dev:signerReset the SQLite database:
bun run db:resetUseful validation commands:
cargo check --workspace
cargo build --workspace
cargo test --workspace
cd web && bun test
cd web && bun run check
cd web && bun run build
cargo audit
bun audit
cd web && bun audit
bun pm scan
cd web && bun pm scan
cd web && bun pm untrustedbun pm scan is Bun's pluggable package scanner. Bun does not include a scanner by default; it loads
an npm package named in bunfig.toml:
[install.security]
scanner = "@socketsecurity/bun-security-scanner"This repo now installs @socketsecurity/bun-security-scanner@1.1.2 exactly in both Bun projects. It
runs in Socket free mode without credentials. Set SOCKET_API_KEY only if you want scans to use a
Socket organization policy.
- The web app signs in through a selected external signer: NIP-07 browser extension, Amber/NIP-55
clipboard signing on Android, or a
bunker://NIP-46 remote signer. - API calls include a NIP-98 HTTP auth event in the
Authorizationheader. - The API verifies the event, extracts the pubkey, and checks team membership for team reads and admin rights for mutations or authorization-secret-bearing routes.
- Stored keys and per-authorization bunker keys are encrypted with the root
master.key. - The signer manager watches authorization rows and starts a
signer_daemonfor each one. - Each signer daemon decrypts the stored key and bunker key, listens on configured relays, and calls
Authorization::validate_policybefore approving NIP-46 requests.
The current hardening posture:
- NIP-98 auth now requires exactly one
utag and onemethodtag, rejects stale or far-future timestamps, validates request-bodypayloadhashes, and rejects oversized authenticated bodies. ALLOWED_PUBKEYSis parsed exactly and enforced server-side.- Allowed-kinds permission config now uses one Rust/TypeScript shape and rejects unknown fields.
- Empty policies default-deny sign/encrypt/decrypt requests, and policy creation rejects empty, unknown, or malformed permission configs.
- SQLite connections enable foreign-key enforcement, and team deletion no longer loses permission join data before cleanup.
- Server-side route protection now covers nested app routes such as
/teams/:id, not only exact top-level paths. - Web responses set CSP, frame, content-type, referrer, permissions, and HTTPS HSTS headers.
Keep future fixes narrow and test-backed. The code handles private key material, so correctness and access control matter more than cosmetic cleanup.
Docker deployment uses:
docker-compose.ymlfor local source builds of API, web, and signer containers,docker-compose.prod.ymlfor pulling the publishedghcr.io/marmot-protocol/keycastimage,master.keymounted into API and signer containers,- an external Docker network named
keycast, - Caddy labels for routing
/api/*to the API and the rest to the web app.
The compose defaults now require ALLOWED_PUBKEYS, expose the API only on the Docker network, build
with frozen Bun/Cargo lockfiles, and avoid copying master.key into the image. Runtime containers run
as a non-root UID/GID, use a read-only root filesystem, drop Linux capabilities, set
no-new-privileges, and get a writable /tmp tmpfs. The Caddy example uses a read-only Docker socket
and a digest-pinned caddy-docker-proxy image. .dockerignore excludes local build output, package
installs, databases, .env files, and master.key from the build context.
Initialize a server checkout:
bash scripts/init.sh --domain keycast.example.com --allowed-pubkeys "hexpubkey1,hexpubkey2"
sudo docker compose -f docker-compose.prod.yml pull
sudo docker compose -f docker-compose.prod.yml up -dscripts/init.sh writes KEYCAST_UID and KEYCAST_GID into .env, defaults them to the current
user, and tries to set matching ownership on database/ and master.key. Pass --uid and --gid
if the container user should be different.
Every push to master publishes one reusable image to GitHub Container Registry with master,
latest, and sha-<commit> tags. Set KEYCAST_IMAGE_TAG=sha-<commit> in .env when you want a
pin-and-roll-forward deployment instead of tracking the moving master tag. If GitHub creates the
first package as private, change the package visibility to public in the GitHub Packages settings.
For an existing deployment, do not use the blank-install flow blindly. Read UPGRADE.md,
back up database/keycast.db and master.key together, verify the host master.key matches the
currently running container, set ALLOWED_PUBKEYS, and run:
scripts/upgrade_preflight.sh
sudo scripts/upgrade_preflight.sh --fix-permissionsThe new SQL migration normalizes old allowed-kinds permission JSON during API or signer startup. It
does not rotate keys, change encrypted stored-key material, or intentionally invalidate existing
NIP-46 bunker connection strings. Keep database/migrations/ present on the server because the API
and signer read migrations from the bind-mounted database/ directory. If you roll back after the
migration has run, restore the database backup as well as the matching master.key.
Before using this for real keys, review the residual notes in AUDIT.md, configure the proxy headers used by NIP-98 URL validation, and rerun the validation commands above.
Permissions are implemented in core/src/custom_permissions. To add or change a permission, update
all of these places together:
- Rust permission implementation and config type in
core/src/custom_permissions/ AVAILABLE_PERMISSIONSincore/src/custom_permissions/mod.rsPermission::to_custom_permissionincore/src/types/permission.rs- web permission types in
web/src/lib/types.ts - web form rendering in
web/src/lib/components/PermissionForm.svelte - README or audit notes if the semantics affect signing approval
Do not rely on frontend-only validation for permission safety.