High-level explanation of how Keycast works based on database schema and code structure.
Keycast is a remote signing service for Nostr that provides:
- Email/password authentication → Nostr keypair management
- NIP-46 remote signing via bunker URLs
- Team-based key management with granular permissions
- Multi-tenant isolation (different deployments/domains)
Purpose: Isolate data by deployment domain
tenants
├─ id, domain (login.divine.video, localhost, etc.)
├─ settings (JSON: relay URLs, email config)
└─ All other tables have tenant_id FK
Flow:
- HTTP request arrives with Host header
- Extract domain from Host
- Auto-provision tenant if doesn't exist
- All queries scoped by tenant_id
Example:
login.divine.video→ tenant_id: 1keycast.example.com→ tenant_id: 2 (auto-created)- Same email can exist in both with different keys
Purpose: Email/password authentication for individual users
users
├─ public_key (Nostr pubkey, PRIMARY)
├─ email, password_hash
├─ email_verified, verification tokens
└─ tenant_id
personal_keys
├─ user_public_key → users(public_key)
├─ encrypted_secret_key (AES-256-GCM)
├─ bunker_secret (for NIP-46 connection)
└─ tenant_id
Registration Flow:
- User provides email + password (+ optional nsec to import)
- Create key pair (or parse provided nsec)
- Hash password with bcrypt
- Encrypt private key with master key/KMS
- Insert into
usersandpersonal_keys - Create OAuth authorization for "keycast-login" app
- Return JWT token
Login Flow:
- User provides email + password
- Lookup user by email + tenant_id
- Verify password hash
- Generate JWT token (contains user pubkey)
- Return token + pubkey
Purpose: Organizations can manage shared Nostr keys
teams
├─ id, name, description
└─ tenant_id
team_users
├─ team_id → teams(id)
├─ user_public_key → users(public_key)
├─ role (admin/member)
└─ tenant_id
stored_keys
├─ team_id, name
├─ public_key, encrypted_secret_key
└─ tenant_id
Use Case:
- Company creates team "Marketing"
- Adds users as admins/members
- Creates shared Nostr key "marketing_account"
- Team members can authorize apps to sign with team key
- Permissions control what each member can do
Purpose: Third-party apps request authorization to sign on user's behalf
oauth_applications
├─ client_id (e.g., "myapp")
├─ client_secret, name, redirect_uris
├─ policy_id (default permissions)
└─ tenant_id
oauth_codes (short-lived)
├─ code (one-time use)
├─ user_public_key, application_id
├─ expires_at
└─ tenant_id
oauth_authorizations (long-lived)
├─ user_public_key, application_id
├─ bunker_public_key, bunker_secret (encrypted keypair for this auth)
├─ secret (NIP-46 connection secret)
├─ relays (JSON array)
├─ policy_id (permissions for this authorization)
└─ tenant_id
OAuth Flow (Third-Party Apps):
- App redirects to
/api/oauth/authorize?client_id=myapp - User logs in and approves
- Keycast creates
oauth_code - Redirects back to app with code
- App exchanges code for bunker URL at
/api/oauth/token - Creates
oauth_authorizationwith unique bunker keypair - Returns bunker URL:
bunker://pubkey?relay=...&secret=...
ROPC Flow (First-Party Apps like Peek):
- Peek collects email + password directly
- Calls
/api/auth/registerwith optional nsec - Creates
oauth_authorizationfor "keycast-login" app - Uses user's key as bunker key (dogfooding pattern)
- Calls
/api/user/bunkerto get bunker URL - Returns bunker URL with user's pubkey
Key Difference:
- OAuth: Separate bunker keypair per authorization
- Personal (keycast-login): User's key IS the bunker key
Purpose: Team-managed bunker authorizations
authorizations
├─ bunker_public_key, bunker_secret (separate keypair)
├─ signing_key_id → stored_keys(id) (team's key)
├─ secret (NIP-46 connection)
├─ policy_id
└─ tenant_id
user_authorizations
├─ authorization_id → authorizations(id)
├─ user_public_key (who can redeem this)
├─ redeemed (claimed flag)
└─ tenant_id
Team Authorization Flow:
- Team admin creates authorization for team key
- Assigns to specific user
- User redeems authorization (first NIP-46 connect)
- Bunker uses team's key to sign, not user's key
Purpose: Fine-grained control over what authorizations can do
permissions (global templates)
├─ identifier (e.g., "allowed_kinds_social")
└─ config (JSON: {"allowed_kinds": [0, 1, 3, 7]})
policies (per-tenant)
├─ name (e.g., "Standard Social (Default)")
├─ team_id (NULL for global policies)
└─ tenant_id
policy_permissions (many-to-many)
├─ policy_id → policies(id)
└─ permission_id → permissions(id)
How It Works:
- Permissions are reusable templates (no tenant_id)
- Policies are per-tenant, link to permissions
- Authorizations reference a policy
- When signing request comes via NIP-46:
- Signer checks authorization's policy
- Evaluates all linked permissions
- Allows/denies based on rules
Example:
- Permission:
allowed_kinds_social= [0, 1, 3, 7] - Policy: "Standard Social" links to social + messaging permissions
- Authorization has policy_id = Standard Social
- Can sign kinds 0, 1, 3, 4, 7, 44 (social + messaging)
- Cannot sign kind 9734 (zaps) ❌
Purpose: Audit log of all signing operations
signing_activity
├─ user_public_key, application_id
├─ bunker_secret (which authorization)
├─ event_kind, event_content (truncated)
├─ event_id (signed event)
├─ client_public_key (NIP-46 client)
└─ tenant_id, created_at
Logged When:
- Any NIP-46 sign_event request
- Fast-path HTTP signing (/api/user/sign)
- Shows who signed what, when
Purpose: Traditional web account features
email_verification_tokens
└─ Standard verification flow
password_reset_tokens
└─ Forgot password flow
Not enforced - users can use service without verifying email.
1. Peek: POST /api/auth/register
Body: {email, password, nsec: "user's local key"}
2. Keycast creates:
users:
- public_key: "abc123..."
- email: "user@example.com"
- password_hash: bcrypt(password)
- tenant_id: 2 (localhost)
personal_keys:
- encrypted_secret_key: AES(nsec)
- bunker_secret: AES(nsec) ← Same key!
oauth_authorizations:
- application_id: keycast-login app
- bunker_public_key: "abc123..." ← User's pubkey
- secret: random 48 chars
- policy_id: Standard Social
3. Returns: JWT token
4. Peek: GET /api/user/bunker
Header: Bearer <JWT>
5. Returns: bunker://abc123...?relay=wss://relay.damus.io&secret=xyz...
6. Peek stores bunker URL, uses for NIP-46 signing
1. ThirdApp: Redirect to /api/oauth/authorize?client_id=thirdapp
2. User logs in, sees consent screen
3. Keycast creates:
oauth_codes:
- code: random
- user_public_key: "abc123..."
- application_id: thirdapp
- expires_at: +10 minutes
4. Redirects: https://thirdapp.com/callback?code=xyz
5. ThirdApp: POST /api/oauth/token
Body: {code: "xyz"}
6. Keycast creates:
oauth_authorizations:
- bunker_public_key: "def456..." ← NEW separate keypair
- bunker_secret: AES(new bunker secret key)
- user signs with "abc123..." original key
- policy_id: from application default
7. Returns: bunker://def456...?relay=...&secret=...
8. ThirdApp connects via NIP-46, signs as "abc123..." (user's key)
but bunker pubkey is "def456..." (ephemeral)
1. Company creates team "Marketing"
teams: {name: "Marketing", tenant_id: 1}
2. Add key to team
stored_keys:
- team_id: marketing team
- public_key: "team123..."
- encrypted_secret_key: AES(team private key)
3. Create authorization for team key
authorizations:
- bunker_public_key: "bunker789..." ← Separate
- signing_key_id: team123 stored key
- policy_id: Team permissions
user_authorizations:
- user_public_key: "member_abc..."
- redeemed: false
4. Team member redeems (first NIP-46 connect)
- Mark redeemed: true
- Bunker signs with team's key, not member's key
Personal OAuth (oauth_authorizations for keycast-login):
bunker_pubkey=user_pubkey(same key)- Simplifies: one keypair for everything
- Used for: ROPC flow with first-party apps
Third-Party OAuth (oauth_authorizations for other apps):
bunker_pubkey≠user_pubkey(separate ephemeral key)- Security: can revoke without changing user's key
- Client sees
bunker_pubkeybut signs asuser_pubkey
Permissions: Reusable templates (no tenant_id)
allowed_kinds_socialallowed_kinds_messagingencrypt_to_self
Policies: Per-tenant collections of permissions
- "Standard Social (Default)" for tenant 1
- "Standard Social (Default)" for tenant 2
- Different tenants can customize same-named policies
At Rest:
- Private keys: AES-256-GCM encrypted in DB
- Master key: File-based or GCP KMS
In Transit:
- NIP-46: NIP-44 encryption over relay
- HTTP: TLS
In Memory:
- Signer daemon keeps decrypted keys cached
- API shares handlers for fast-path signing
Fast Path (unified binary only):
- HTTP request → Cached handler → Sign → Return
- ~10ms (no DB/KMS hit)
Slow Path:
- NIP-46 request → Query DB → Decrypt with KMS → Sign → Return
- ~50-100ms
NIP-46 Path:
- Request on relay → Signer daemon → Decrypt → Sign → Reply on relay
- Variable latency (relay dependent)
tenants (1)
└─→ users (many)
├─→ personal_keys (1) [encrypted user key]
├─→ user_profiles (1) [display name, etc.]
├─→ oauth_authorizations (many) [bunker URLs for user]
└─→ team_users (many)
└─→ teams (many)
└─→ stored_keys (many) [team keys]
└─→ authorizations (many) [team bunker URLs]
└─→ user_authorizations (many) [who can use]
policies (per-tenant)
├─→ policy_permissions (many)
│ └─→ permissions (global templates)
└─← authorizations.policy_id
└─← oauth_authorizations.policy_id
oauth_applications (per-tenant)
└─→ oauth_authorizations (many)
├─ For personal: bunker_pubkey = user_pubkey
└─ For third-party: bunker_pubkey ≠ user_pubkey
signing_activity (audit log)
← All signing operations logged here
- Anonymous → Peek auto-generates local nsec
- Secure Account → Peek calls keycast registration with nsec
- Account Created →
users+personal_keys+oauth_authorization - Get Bunker URL →
/api/user/bunkerreturns bunker URL - Use Everywhere → Paste bunker URL into any NIP-46 client
- Sign Events → Signer daemon handles NIP-46 requests
- App Integration → Developer registers OAuth app
- User Connects → OAuth authorization flow
- Authorization Created → Separate bunker keypair generated
- Bunker URL → App gets bunker URL, stores it
- Sign Events → App sends NIP-46 requests via relay
- Permissions → Signer checks policy before signing
- Create Team → Organization makes team
- Add Key → Import or generate team Nostr key
- Create Authorization → Make bunker URL for team key
- Assign to Users → Team members can use authorization
- Redeem → User claims authorization (NIP-46 connect)
- Sign as Team → Signs with team's key, not personal key
Private Keys:
- Encrypted at rest: AES-256-GCM
- Decrypted only when signing
- Master key: File or GCP KMS
Connection Secrets:
- Random per authorization
- Required for NIP-46 authentication
- 32-48 character alphanumeric
Personal:
- Email + password → JWT token
- JWT contains user pubkey
- Token expires in 24 hours
- No refresh tokens (must re-login)
NIP-46:
- Client proves knowledge of connection secret
- First
connectrequest validates secret - Subsequent requests encrypted with NIP-44
Policy-Based:
- Each authorization has a policy
- Policy contains multiple permissions
- Permissions checked before signing
- Can restrict: event kinds, content patterns, encryption targets
Example Policy:
{
"Standard Social": [
{"allowed_kinds": [0, 1, 3, 7]}, // Social
{"allowed_kinds": [4, 44, 1059]} // Messaging
]
}Request to sign kind 9734 (zap) → ❌ Denied
Tenant Scoping:
- All queries filter by
tenant_id - Email uniqueness: per-tenant
- OAuth client_id uniqueness: per-tenant
- Policies: per-tenant (can customize)
Auto-Provisioning:
- New domain → Auto-create tenant
- Seed default policies
- Ready for first user
Isolation Guarantees:
- Tenant A cannot see Tenant B's users
- Same email can exist in multiple tenants
- Different keys, different authorizations
- Complete data isolation
-
One User, Many Bunker URLs:
- Personal auth: bunker URL with user's pubkey
- OAuth apps: separate bunker URL per app
- Team keys: bunker URLs for team keypairs
-
Policy Reuse:
- Permissions are global templates
- Policies combine permissions per-tenant
- Authorizations reference policies
- Change policy → affects all using it
-
Unified Binary Performance:
- Signer and API share process
- Decrypted keys cached in memory
- Fast-path signing without DB hits
- ~5-10x faster than separate processes
-
Two Authentication Models:
- ROPC: First-party apps (peek) get user's key directly
- OAuth: Third-party apps get ephemeral bunker key
- Both use same NIP-46 protocol
-
Multi-Tenancy Enables:
- SaaS deployment (many customers, one instance)
- Data isolation by domain
- Per-tenant customization
- Different policies/relays per tenant
Register User:
users ← INSERT
personal_keys ← INSERT (encrypted key)
oauth_applications ← ENSURE "keycast-login" exists
oauth_authorizations ← INSERT (for keycast-login)
Get Bunker URL:
oauth_authorizations ← SELECT WHERE user_pubkey AND app="keycast-login"
Format: bunker://{bunker_pubkey}?relay={relay}&secret={secret}
Sign Event (NIP-46):
NIP-46 request → Signer daemon
oauth_authorizations ← SELECT WHERE bunker_pubkey
Decrypt bunker_secret
Check policy permissions
Sign with user_keys
signing_activity ← INSERT (audit)
NIP-46 response → Relay
Auto-Provision Tenant:
tenants ← INSERT
policies ← INSERT × 3 (Standard Social, Read Only, Wallet Only)
policy_permissions ← INSERT (link to global permissions)
This schema supports both simple personal use (peek) and complex team/organization scenarios with fine-grained access control.