Murmur is a secure message relay server using Ed25519 public key cryptography for authentication and message signing. It acts as a message broker, forwarding signed encrypted blobs between users without ever decrypting them.
- Users are identified by their Ed25519 public keys (NaCl signing keys)
- No passwords or traditional accounts
- Authentication uses cryptographic signatures
- JWT tokens are issued after signature verification using privacy-kit
- Tokens include access tokens (configurable TTL, default 24h) and refresh tokens
- Automatic token refresh without re-authentication
All messages and profile updates are:
- Created as JSON blobs (encrypted by client)
- Signed with the sender's private key
- Verified by the server before storage
- Forwarded as-is to recipients
The server never decrypts message contents, only verifies signatures.
Each user has two key pairs:
- Identity Key: Ed25519 signing key (permanent identifier)
- Profile Key: Separate key for profile encryption
The profile key is:
- Signed by the identity key (proves ownership)
- Used to encrypt the user's profile
- Can be rotated at any time
This separation allows profile key rotation without changing identity.
The EventBus provides broadcast event distribution using Redis Streams:
Architecture:
- Redis Streams for durable event delivery (not pub/sub)
- Broadcast reads: each node reads the stream independently
- No consumer groups or acknowledgments required
- Channel-based routing for future sharding capabilities
Message Identification:
- Messages identified by cuid2 IDs provided by sender
- No sequence numbers needed (distributed ID generation)
- Repeat protection via unique message IDs
- Format validation ensures only valid cuid2 IDs accepted
Delivery Model:
- Each node receives all events and filters by channel
- Messages are stored in PostgreSQL; events are realtime hints
- Inbox messages are deleted via
/v1/messages/ack(database)
Channel-Based Routing:
- Events published to channels (e.g., "user:userId", "global")
- Allows future sharding: route specific channels to specific servers
- Currently all channels processed globally
- Easy migration path to horizontal scaling
Authentication Flow:
- Client signs request with identity private key
- Server verifies signature against identity public key
- JWT issued for subsequent requests
- JWT validated on authenticated routes
Message Flow:
- Sender signs message blob
- Server verifies signature
- Message stored with 30-day expiration
- Event published to recipient's EventBus channel
- SSE notification sent if recipient connected
- Recipient fetches from inbox
Real-time message delivery using SSE:
- Clients open
/v1/messages/streamendpoint - Connection managed per user
- Events pushed when messages arrive
- Automatic reconnection on disconnect
- Heartbeat every 30 seconds
Why SSE over WebSocket:
- Simpler protocol (one-way)
- Automatic reconnection in browsers
- Works over HTTP/2
- Lower overhead for notifications
Background job that:
- Runs hourly
- Deletes messages with
expiresAt < now - Ensures 30-day auto-delete guarantee
Public keys are stored internally as standard base64 with padding, and API inputs/outputs use the same base64 encoding.
id: string (Ed25519 public key, base64)
createdAt: DateTime
profilePublicKey: string (NaCl public key for profile)
profileKeySignature: Bytes (signature of profilePublicKey by id)
encryptedProfile: Bytes (encrypted profile data)
profileUpdatedAt: DateTime
id: string (cuid2 provided by sender)
createdAt: DateTime
expiresAt: DateTime (createdAt + 30 days)
deliveredAt: DateTime | null
senderId: string (User.id)
recipientId: string (User.id)
blob: Bytes (encrypted message)
signature: Bytes (NaCl signature of blob bytes + messageId bytes)
All critical operations require signature verification:
- Registration: Request signed by identity key
- Login: Timestamp signed by identity key
- Profile update: Request signed by identity key
- Message send: Blob + messageId signed by sender's identity key
- Profile key: Signed by identity key
Message IDs must be cuid2 format:
- Provided by sender (not auto-generated)
- Validated with isCuid() check
- Included in signature to prevent tampering
- Repeat protection via unique constraint
- Distributed ID generation prevents conflicts
To prevent replay attacks:
- Requests include timestamp
- Server checks timestamp is within 5 minutes
- Prevents old signatures from being reused
Messages auto-delete after 30 days:
- Enforced at DB level with
expiresAtfield - Cleanup worker ensures timely deletion
- Prevents unlimited data accumulation
Server never decrypts:
- Message blobs (end-to-end encrypted by clients)
- Profile data (encrypted with profile key)
Server only:
- Verifies signatures
- Stores and forwards blobs
- Manages message lifecycle
Multiple server instances can run simultaneously:
- EventBus coordinates via Redis Streams broadcast reads
- Each instance has own DB connection pool
- SSE connections distributed across instances
- Message IDs remain unique via cuid2 distributed generation
- Channel-based routing enables future sharding
PostgreSQL chosen for:
- ACID guarantees for message and profile writes
- Efficient indexing for message queries
- Reliable retention for encrypted blobs
Used for: -- Redis Streams for event distribution -- Cross-node realtime notification delivery
Configured with:
- AOF persistence (append-only file)
- Ensures messages survive restarts
- Stream entries are trimmed by
EVENT_STREAM_MAXLEN
Three services:
- PostgreSQL: Persistent data storage
- Redis: Event bus and caching
- Murmur: Application server
Each service:
- Has health checks
- Persists data to volumes
- Restarts automatically
Required:
DATABASE_URL: PostgreSQL connectionREDIS_URL: Redis connectionJWT_SEED: Seed for privacy-kit JWT token generationPORT: HTTP port (default 3000) Optional:ACCESS_TOKEN_TTL_MS: Access token TTL in millisecondsEVENT_STREAM_MAXLEN: Redis stream trim lengthMETRICS_PORT: Metrics server portLOG_LEVEL: Logging level
Generate a JWT seed with: yarn tsx scripts/generateKeys.ts (only JWT_SEED is used by the server)
Potential improvements:
- WebSocket support alongside SSE
- Message read receipts
- Multi-recipient messages (groups)
- Message forwarding/routing
- Admin API for user management