* feat(keys): manual key registration backend + scan-time exposure linking The ledger's second door: POST /api/keys/register accepts one pasted key, classifies it in-memory with the scanner's own patterns, and stores only a masked preview plus salted hash (source 'manual', rotation clock from registration via foundAt — no rotation-policy change needed). - lib/scanner/classify.ts: pure classification + hash matching, testable without prisma; static error messages the route returns verbatim - lib/manual-keys.ts: register, duplicate refusal (manual-vs-manual only, the honest limit is documented in the FAQ), tracked-candidate loading, and exposure attachment when a scan finds a registered key - scanner core verifies raw matches against manual-key hashes in the one scope that still holds the value; both persistence paths turn a linked finding into an exposure event (git-blame date when available, earlier-is-worse, human attestation never overwritten) instead of a triage row, with activity + one-shot alert - new Resend pattern (the key that exposed this gap couldn't classify) - rate-limited 10/min/user; tests: classification, extraction identity, hash matching, static messages, manual-key rotation anchoring Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(keys): register-a-key drawer, two-door empty state, registered-vs-found language The UI half of manual key registration, from Nilson's single-key-workflow handoff (section C): - RegisterKeyDrawer: two steps (register -> confirm), live client-side classification on every keystroke (classify.ts split browser-safe, hash matching moved to scanner/tracked.ts), masked-preview typo check, env-line extraction with name prefill, single POST on confirm. 409 renders the existing key as a pointer with a View key action; 429 keeps the paste, counts down Retry-After, re-enables itself. Name state resets when the extracted secret changes. - Keys page: empty state gains the second door + tracked-since-birth hint; toolbar Register button; inline landed bar showing the server's rotationDueAt verbatim; manual rows wear a dashed 'manual - you' chip and 'registered Nd ago' (scanned rows keep 'found Nd ago'). - Language pass (task 3.2): drawer says Registered/by you, 'window: N days from registration', exposure 'none recorded'; timeline emits a 'Registered - tracked since birth' event, no exposure window until evidence exists; radius no longer invents an exposure site from the synthetic 'registered by paste' location; scan-source exposure labeled 'found in a scan'. - Key drawer gains Delete for manual records (the no-edit-value promise needs a working delete); admin-gated server-side, 403 surfaced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * spec: check off UI + security-battery tasks for manual-key-registration Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|---|---|---|
| .claude | ||
| .github/workflows | ||
| app | ||
| components | ||
| docs | ||
| lib | ||
| openspec | ||
| prisma | ||
| public | ||
| scripts | ||
| types | ||
| .dockerignore | ||
| .env.example | ||
| .gitguardian.yaml | ||
| .gitignore | ||
| .npmrc | ||
| ai-declaration.md | ||
| CLAUDE.md | ||
| DEPLOYMENT.md | ||
| docker-compose.yml | ||
| docker-entrypoint.sh | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| LICENSE | ||
| middleware.ts | ||
| next.config.ts | ||
| package-lock.json | ||
| package.json | ||
| postcss.config.mjs | ||
| README.md | ||
| tailwind.config.ts | ||
| tsconfig.json | ||
| vercel.json | ||
Keystrok
Find exposed API keys in your code, track them, and rotate them safely. Keystrok is a self-hostable security tool for developers who want to get on top of leaked secrets without handing their keys to a third party.
Docs: keystrok.dev/docs (also readable in-repo at docs/guide/).
Advisory, never automated. Keystrok ranks what actually needs attention and walks you through each rotation step, but it never rotates or revokes a key on its own. The irreversible actions stay in your hands. It also never claims to know a key's real age: every deadline is anchored to when a key was discovered, not a guessed creation date.
What it does
- Discovery: scan a GitHub repo (via a GitHub App) or a local folder for exposed secrets (AWS, Stripe, GitHub, observability platforms, and more). Findings are stored hashed/masked, never as plaintext.
- Ledger: promote real findings to a tracked inventory. Each key gets a rotation deadline from its severity, counted from discovery.
- Guided rotation: step-by-step, operator-gated walkthroughs. The revoke step is irreversible by confirmation.
- Platform validation: connect a platform (Datadog, Grafana, Stripe, GitHub, …) to check whether a leaked key is still live. A dead key is far less urgent than a working one.
- Bring-your-own AI assistant (optional): a chat that reasons over your key metadata (never the secret values) to answer "what should I rotate first?". Works with Anthropic, any OpenAI-compatible endpoint, or a local model via Ollama.
- Teams: self-hosted, so one instance is one team. Invite members, gate the irreversible actions to admins, and keep a shared, attributed workspace.
Screenshots
Stack
- Next.js 15 (App Router) + React 19, TypeScript
- NextAuth v5: magic-link (email) authentication
- Prisma ORM on PostgreSQL
- Tailwind CSS + a square, terminal-inspired design system (
docs/DESIGN_SYSTEM.md) - Secrets encrypted at rest (AES-256-GCM); SSRF-guarded outbound calls
Quick start (self-host)
The fastest path is the bundled Docker stack, with app + Postgres + a local mail catcher, no external services:
cp .env.example .env
echo "NEXTAUTH_SECRET=$(openssl rand -base64 32)" >> .env
echo "ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env
echo "ALLOWED_EMAILS=you@example.com" >> .env # invite-only
docker compose up --build
# App: http://localhost:3001 Magic-link inbox: http://localhost:8025
Prefer a prebuilt image? docker compose pull && docker compose up uses the published ghcr.io/rootzreggae/keystrok instead of building. See DEPLOYMENT.md for the bring-your-own-infra path (managed Postgres + SMTP).
Note on email: the bundled mail catcher traps everything locally (that's the inbox at
:8025), so solo use works with zero email setup, but team invites will never reach a real inbox. To invite teammates, setEMAIL_SERVER_*in.envto a real SMTP server (orRESEND_API_KEY); the Team page shows where invites are currently being delivered.
Local development
npm install
cp .env.example .env.local
npx prisma generate
npx prisma db push
npm run dev -- -p 3001 # http://localhost:3001
Key environment variables
| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
NEXTAUTH_URL / NEXTAUTH_SECRET |
App URL + session signing secret |
ENCRYPTION_KEY |
AES-256-GCM key for encrypting stored credentials (openssl rand -base64 32) |
EMAIL_SERVER_* / EMAIL_FROM |
SMTP for magic-link and invite email (a real server, or the bundled catcher) |
ALLOWED_EMAILS |
Invite-only sign-in allowlist |
CRON_SECRET |
Secret for the scheduled-job endpoints (repo re-scans, liveness checks) |
Project layout
app/: Next.js App Router ((authenticated)/routes,api/handlers,auth/pages)lib/: auth, crypto, the scanner, rotation policy, integrationscomponents/: the UI component systemprisma/schema.prisma: database schema (source of truth)docs/: design & API references
On how this is made
I'm a product designer who has spent years on developer and observability tools (among them Grafana's Frontend Observability and APM). I build Keystrok with AI as my engineering partner. The product, what it does, what it deliberately won't do (it never rotates a key on its own), and how it should feel to use, is mine. The implementation is a collaboration, written and reviewed with AI in the loop.
I mention the AI part plainly, not defensively. Judge the work, not the method: the self-host path is verified end to end, secrets are encrypted at rest, rotation is advisory and operator-gated, and the AI assistant only ever sees key metadata, never the secret values. Find a bug or a bad call? Open an issue. I'd rather fix it than defend it.
License
MIT: see LICENSE.





