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_messaging")
└─ config (JSON: {"allowed_kinds": [0, 1, 3, 4, 7, 44, 1059, 9735, 22242, 10011]})
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 a signing request arrives (NIP-46, HTTP RPC, or session signing):
- Signer checks authorization's policy
- Evaluates all linked permissions
- Allows only if every linked permission allows the event
Example:
- Permission:
allowed_kinds_social_messaging= [0, 1, 3, 4, 7, 44, 1059, 9735, 22242, 10011] - Policy: "Standard Social" links to that one permission
- Authorization has policy_id = Standard Social
- Can sign those kinds, including 10011 (linked accounts)
- Cannot sign kind 5 (deletions) ❌
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
- A
connectcarrying the secret binds the client's pubkey; nostr-login authorizations are bound to the approved client when they are created - For OAuth authorizations, only a binding made one of those two ways is trusted (it matches
verified_client_pubkey); any other binding counts as none - Until a client is bound, and afterwards for any other client, every request (
get_public_keyincluded) is refused POST /api/user/sessions/disconnectclears an OAuth binding, so the nextconnectwith the secret binds again; team bindings are cleared only by deleting the authorization- 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, 4, 7, 44, 1059, 9735, 22242, 10011]}
]
}Every permission must allow an event, so a policy's allowed kinds go in one
permission: two allowed_kinds permissions would only allow kinds on both lists.
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.
The dedicated /api/groups API reuses teams, stored keys, policies and ordinary
NIP-46 authorizations, while group_memberships and group_grants bind current
human admins and device installations to the identity. Every hosted request,
including cached signer handlers, rechecks durable group/version authority.
group_backups stores encrypted version-bound full-key or FROST participant
packages; hosted keys remain in stored_keys. Threshold coordination has separate
versioned sessions and permanent public commitment reservations and never uses the
complete-key parser for shares. Final published events remain ordinary Nostr.
See the client contract, recovery operations, and the optional threshold protocol. Independent clients may operate entirely without Keycast registration, authentication, signing or storage.