Whispr

Docs

Security model

Your identity is a 32-byte seed generated in the browser. HKDF derives an Ed25519 signing key (your messaging ID) and an X25519 encryption key. The encryption key is signed by the ID key, so a directory record is self-certifying: our server cannot substitute a different key for an ID without the client noticing.

The weak point is handles: @alice → ID is a mapping we serve. Compare safety numbers out of band and press Verify. Whispr pins every contact’s key on first use and warns loudly if it ever changes.

Messages use a static recipient key, so there is no forward secrecy. A stolen seed decrypts everything sent to or from it. Treat your recovery key like a password manager’s master key.

Envelope format

Implemented with audited libraries (@noble/curves, @noble/ciphers, @noble/hashes). The sender and recipient are not written on-chain — only inside the ciphertext.

byte  0        version (1)
bytes 1–32     ephemeral X25519 public key
bytes 33–80    content key wrapped for recipient  (XChaCha20-Poly1305, 32 + 16 tag)
bytes 81–128   content key wrapped for sender
bytes 129–152  nonce (24)
bytes 153–     XChaCha20-Poly1305( sender_id ‖ timestamp ‖ ed25519_sig ‖ text ), AAD = header

memo = "hush1:" + base64url(envelope)            short messages, fully on-chain
memo = "hush1r:" + base64url(sha256) + ":" + id  long messages, ciphertext off-chain

Sponsored transactions

The browser sends the sealed envelope with an Ed25519-signed request and a solved proof-of-work challenge. The server checks the signature, replay window, PoW, per-identity and per-IP quotas, the daily lamport budget and the sponsor’s minimum balance, then builds a single Memo-program instruction, signs it with the sponsor key and broadcasts it.

Each send records the blockhash’s lastValidBlockHeight. A pending message is only marked failed — and only becomes retryable — once the chain has passed that height without the signature landing, so a retry can never produce a duplicate message.

Environment variables

# Database (provided by the Neon integration)
DATABASE_URL=postgres://...

# Solana
SOLANA_NETWORK=mainnet-beta           # default; set devnet only for testing
SOLANA_RPC_URL=                        # optional; a paid RPC is strongly recommended on mainnet
SPONSOR_SECRET_KEY=                    # base58 secret key or JSON byte array of the fee-payer wallet

# Spending guards
SPONSOR_DAILY_LAMPORTS=50000000        # 0.05 SOL/day ≈ 10,000 messages
SPONSOR_MIN_BALANCE_LAMPORTS=10000000  # pause relaying below 0.01 SOL

# Abuse protection
POW_SECRET=                            # ≥16 random chars, signs proof-of-work challenges
POW_BITS=18                            # difficulty (~0.2–1s in a browser)
MESSAGES_PER_IDENTITY_PER_DAY=60
MESSAGES_PER_IP_PER_HOUR=30

Database schema

identities(id text pk, box_pub text, box_sig text, handle text unique, created_at)
messages(id uuid pk, client_id text unique, sender_id, recipient_id, network,
         storage 'onchain'|'offchain', envelope text, content_hash text,
         memo text, signature text unique, last_valid_block_height bigint,
         status 'queued'|'pending'|'confirmed'|'failed', error, attempts,
         fee_lamports, slot, block_time, hidden_by_sender, hidden_by_recipient,
         ip_hash, created_at, confirmed_at)
pow_spent(challenge text pk, created_at)

Deploying

  1. Connect a Neon database and run the schema above.
  2. Create a dedicated fee-payer wallet (never reuse a personal one) and set SPONSOR_SECRET_KEY.
  3. Send real SOL to the fee-payer address — the relay runs on Solana mainnet and pays every fee, so users never need a wallet. About 0.000005 SOL per message; a dedicated RPC is recommended at volume.
  4. Set POW_SECRET and tune the spending caps. Watch the relay badge in the messenger for the live budget.
  5. Publish to Vercel.