Repository navigation
Home
SylvaCrypt (formerly ShadowCrypt) is an open-source, end-to-end encrypted messaging web application built on a zero-knowledge relay. All cryptographic operations happen entirely in the browser — the server routes opaque ciphertext and never holds keys or plaintext.
Version: 8.5.0 · License: AGPL-3.0 · Repo: github.com/Forestritium/SylvaCrypt
SylvaCrypt is a zero-knowledge messaging platform. Its core design principle is that the relay (Supabase backend) is treated as fully untrusted — it only ever sees sealed, opaque ciphertext. No server-side secret is ever used to encrypt or decrypt user messages.
Key properties at a glance:
| Property | Value |
|---|---|
| Message encryption | Double Ratchet (X25519 + HKDF-SHA256 + AES-256-GCM) |
| Forward secrecy | Full — keys ratchet after every single message |
| Break-in recovery | DH ratchet regenerates fresh key material on every reply |
| Post-quantum layer | ML-KEM-768 (CRYSTALS-Kyber, FIPS 203), hybrid with X3DH |
| Key exchange | Full X3DH (IK + SPK + OPK prekeys) |
| Vault KDF | Argon2id (64 MB / 3 iterations) |
| Sender anonymity | Sealed-sender box (ephemeral X25519 + AES-256-GCM) |
| Header privacy | Envelope headers AES-256-GCM encrypted |
| At-rest encryption | All DB columns vault-wrapped with AES-256-GCM |
| Password recovery | BIP-39 12-word mnemonic (128-bit entropy); hash stored server-side only |
| Relay behaviour | Messages deleted after delivery; 30-day hard cap via pg_cron |
| Feature | Details |
|---|---|
| End-to-End Encryption | Double Ratchet — per-message unique keys, full forward secrecy |
| X3DH Key Exchange | Full X3DH: IK + SPK + OPK; fresh shared secret per conversation |
| Post-Quantum Encryption | ML-KEM-768 hybrid layer — classical + PQ shared secret via HKDF |
| Sealed Sender | Relay cannot identify the sender of any message |
| Header Encryption | Relay cannot read ratchet metadata (message number, chain counters) |
| Vault Encryption | All local data encrypted with Argon2id-derived key + AES-256-GCM |
| Real-time Messaging | Supabase Realtime relay — ciphertext only, auto-deleted after delivery |
| Image Sharing | AES-256-GCM per-image key, private bucket, signed URLs; 10 images/day cap |
| Voice Messages | AES-256-GCM encrypted Opus/WebM audio; key inside ratchet; 10 min/day cap |
| Reply & Quote | Thread-aware reply with quoted message preview |
| Contact Requests | Accept/decline incoming requests; full block list management |
| Multi-Device | Linked devices with independent E2E sessions |
| Recovery Phrase | BIP-39 12-word mnemonic for password reset without email or server trust |
| Safety Numbers | 60-digit fingerprint to verify a contact's public key out-of-band |
| Push Notifications | Anonymous — never reveals sender identity or message content |
| Themes | Public user-contributed UI themes with ratings and comments |
| PWA | Installable as a Progressive Web App on desktop and mobile |
| Dark / Light Mode | System-aware with manual override |
What SylvaCrypt protects against:
| Threat | Mitigation |
|---|---|
| Server compromise | Server stores only AES-256-GCM ciphertext; has no decryption keys |
| Database leak | All message content and image/voice keys are vault-wrapped before DB write |
| MITM on relay | Messages are E2E-encrypted before reaching the relay |
| Retrospective decryption | Double Ratchet forward secrecy — one compromised key exposes nothing past or future |
| Ratchet header metadata | Headers AES-256-GCM encrypted with NHK, rotated on every DH ratchet step |
| Weak passwords | Argon2id with 64 MB memory — GPU/ASIC cracking is extremely expensive |
| Email-less recovery | BIP-39 mnemonic hash only; plaintext phrase never sent to server |
| Encrypted image exposure | Key travels inside ratchet ciphertext; private bucket with signed URLs only |
| Encrypted voice exposure | Same model as images; plaintext audio never touches the server |
| Sender identification | Sealed-sender box hides sender identity from relay on every message |
What SylvaCrypt does NOT protect against:
| Threat | Explanation |
|---|---|
| Compromised device / malware | Attacker can read decrypted messages from memory |
| Session hijacking | Stolen JWT allows receiving future ciphertext but not decrypting it |
| Malicious browser extensions | Extensions with page access can intercept plaintext before encryption |
| Physical access | Vault key held in sessionStorage during active session |
| Traffic metadata | Who is talking to whom and how often is not hidden |
| OS-level screen capture | No web app can block OS screenshots; deterrence only |
| Denial of service | No DDoS mitigations at the application level |
| Primitive | Algorithm | Usage |
|---|---|---|
| Key exchange | X25519 (via @noble/curves) |
X3DH, Double Ratchet DH steps |
| Post-quantum KEM | ML-KEM-768 / CRYSTALS-Kyber (via @noble/post-quantum) |
Hybrid X3DH setup |
| Symmetric encryption | AES-256-GCM (Web Crypto API) | Messages, images, voice, vault, headers |
| Key derivation (messages) | HKDF-SHA256 (Web Crypto API) | Root key, chain keys, message keys |
| Key derivation (vault) | Argon2id (via hash-wasm WASM) |
Password → vault key (v1, current) |
| Key derivation (legacy) | PBKDF2-SHA256 @ 310k iterations | Vault key for v0 accounts (migrated on login) |
| Password recovery | BIP-39 12-word mnemonic (via @scure/bip39) |
Account recovery without email |
| Password strength | zxcvbn (via @zxcvbn-ts/core) |
Registration UX |
| Identity verification | Ed25519 signatures | Prekey bundle signing |
-
sessionStorage vault key — The vault key is written to
sessionStoragefor tab-reload usability. Users on shared machines should log out and close the tab. -
Stored messages share one vault key — Forward secrecy applies to the relay, not to IndexedDB. All locally stored messages are protected by a single vault key; vault key compromise exposes the full local history.
-
ML-KEM-768 secret key in JavaScript memory — The X25519 identity key is a non-extractable Web Crypto
CryptoKey. The ML-KEM-768 secret key (2400 bytes) has no Web Crypto native support, so it lives as a base64 string in vault-encrypted IndexedDB. The effective security difference from the attacker's perspective is minimal (arbitrary JS execution compromises both), but the asymmetry is noted. Future mitigation: WASM memory isolation when the W3C WebCryptoAPI adds native ML-KEM support. -
Daily rate limits are not security boundaries — The 10-image/day and 10-min/day voice caps are abuse mitigations, not cryptographic guarantees.
-
Self-hosted deployments — Self-hosters are responsible for securing their Supabase project, applying all migrations, and keeping dependencies updated.
┌─────────────────────────────────────────────────────────────────┐
│ Browser (Client) │
│ │
│ React + TypeScript + Vite │
│ │
│ AuthContext ──► session.ts ──► localStore.ts (IndexedDB) │
│ │ │ │
│ ▼ ▼ │
│ AuthPage.tsx crypto.ts (Web Crypto API + hash-wasm) │
│ ChatPage.tsx │ │
│ SettingsPage.tsx ▼ │
│ doubleRatchet.ts ──► relay.ts │
│ │ │
└──────────────────────────────────────────┼────────────────────────┘
│ HTTPS / WSS
│ (ciphertext only)
┌──────────────▼──────────────┐
│ Supabase │
│ │
│ PostgreSQL (DB) │
│ Realtime (change feed) │
│ Storage (private buckets) │
│ Edge Functions │
└──────────────────────────────┘
The frontend is a React 18 + TypeScript single-page application built with Vite.
Layer responsibilities:
| Layer | Path | Responsibility |
|---|---|---|
| Pages | src/pages/ |
Route-level views: Auth, Chat, Settings, Docs, Safety Number, etc. |
| Contexts | src/contexts/ |
Global state: AuthContext (auth/session/vault), ThemeContext
|
| Components | src/components/ |
Chat panels, dialogs, emoji pickers, file bubbles, shadcn/ui primitives |
| Lib | src/lib/ |
Pure logic: crypto.ts, x3dh.ts, doubleRatchet.ts, relay.ts, localStore.ts, session.ts, mnemonic.ts
|
| DB | src/db/ |
Supabase client singleton |
| Hooks | src/hooks/ |
Reusable React hooks (PWA install, etc.) |
| Types | src/types/ |
Shared TypeScript interfaces |
State management: No external state library. State lives in React Context, component useState/useReducer, and in-memory singletons in localStore.ts.
Routing (React Router v7):
| Path | Component | Guard |
|---|---|---|
/auth |
AuthPage |
Public |
/home, /chat
|
ChatPage |
Authenticated |
/add-contact |
AddContactPage |
Authenticated |
/settings |
SettingsPage |
Authenticated |
/linked-devices |
LinkedDevicesPage |
Authenticated |
/settings/notifications |
NotificationSettingsPage |
Authenticated |
/settings/themes |
ThemesPage |
Authenticated |
/settings/waveform |
WaveformSettingsPage |
Authenticated |
/safety-number/:contactId |
SafetyNumberPage |
Authenticated |
/docs |
DocsHomePage |
Public |
/docs/security-whitepaper |
SecurityWhitepaperPage |
Public |
/docs/troubleshooting |
TroubleshootingPage |
Public |
/privacy |
PrivacyPolicyPage |
Public |
RouteGuard redirects unauthenticated users to /auth.
Supabase is used as a zero-knowledge relay: it provides auth, a PostgreSQL database, Realtime subscriptions, private object storage, and Edge Functions. Supabase is never given decryption keys; a full Supabase compromise reveals only ciphertext.
Row-Level Security (RLS) is enforced on all tables. Key rules:
-
profiles— readable only by the user themselves and their contacts. -
messages/relay_messages— only the addressed recipient can read; only the sender can insert. -
contacts— users read and modify only their own contact list. -
contact_requests— sender and recipient can each read their own side.
| Table | Purpose |
|---|---|
profiles |
Username, public key, bio, avatar URL, password version, mnemonic hash, KDF version, vault backup |
user_signed_prekeys |
Public prekeys with Ed25519 signatures for X3DH |
user_one_time_prekeys |
Single-use OPKs for X3DH — consumed per conversation |
user_devices |
Linked device endpoints for multi-device sync |
contacts |
Owner → contact mapping; denormalised username + public key for offline access |
messages |
Internal relay storage (temporary) |
relay_messages |
Sealed-sender ciphertext routed between users; auto-deleted after 30 days |
voice_send_durations |
Per-user per-day voice seconds counter (10 min/day cap) |
contact_requests |
Pending / accepted / declined add-contact requests |
blocked_users |
Bidirectional block records |
conversation_pins |
Saved conversation pointers |
personal_pins |
Personal notes and pointers |
push_subscriptions |
Web Push subscription endpoints |
public_themes |
Globally shared UI themes |
theme_comments |
Discussion on public themes |
Migrations are tracked as ordered SQL files in supabase/migrations/ (00000 → 00119 at time of writing), applied via supabase db push.
| Function | Trigger | Description |
|---|---|---|
delete-account |
Authenticated client call | Verifies JWT, uses service-role key to call admin.deleteUser(). FK constraints cascade-delete all user data. |
reset-password |
Unauthenticated client call | Verifies BIP-39 mnemonic hash, uses service-role key to call admin.updateUserById() with new password. |
User password
│
▼
Argon2id (mem=64 MB, iter=3, par=1) ← KDF v1 (all new accounts)
│
▼
32-byte raw key
│
▼
AES-256-GCM CryptoKey (non-extractable during session)
All data in IndexedDB is stored as base64(IV[12] + AES-256-GCM-ciphertext). The vault key is kept in memory during the session and also written to sessionStorage (raw bytes) so page reloads don't require the user to re-enter their password.
Legacy v0 accounts used PBKDF2-SHA256 (310,000 iterations) and are migrated to Argon2id v1 on first login.
Before the first message in any conversation, the sender runs the full Extended Triple Diffie-Hellman protocol:
- Fetch recipient's prekey bundle from
user_signed_prekeysanduser_one_time_prekeys(IK, SPK, OPK, KEM public key). - Perform four classical DH operations: IK–SPK, Ephemeral–IK, Ephemeral–SPK, Ephemeral–OPK.
- ML-KEM-768 Encapsulate against the recipient's KEM public key.
- HKDF-combine all DH outputs + KEM shared secret → session secret → initial root key (RK) and sending chain key (CKs).
Every message advances the ratchet:
kdfCK(CKs) → (CKs', MK) ← symmetric ratchet step
AES-256-GCM(MK, plaintext) ← message encryption
AES-256-GCM(NHK, header) ← header encryption
After each reply, the DH ratchet also advances: a new X25519 ephemeral pair is generated, HKDF-combined with the root key to produce fresh chain keys. This provides break-in recovery — compromise of one message key exposes nothing past or future.
ML-KEM-768 (CRYSTALS-Kyber, FIPS 203) is combined with the classical X3DH output via HKDF-SHA256 to produce the initial root key. This means breaking the key exchange requires simultaneously defeating both:
- Classical X25519 ECDH (vulnerable to future quantum computers), and
- ML-KEM-768 (believed post-quantum secure).
Every outgoing message envelope is wrapped in a sealed-sender box:
ephemeral X25519 key pair generated per-message
ECDH(ephemeral_priv, recipient_pub) → shared secret
AES-256-GCM(shared_secret, envelope)
The relay receives only an opaque blob it cannot link to a sender identity. The recipient derives the shared secret using their private key and the ephemeral public key included in the box.
Ratchet envelope headers (sender's DH public key, message number, previous chain length) are encrypted with a Next Header Key (NHK) derived via HKDF("SylvaCrypt-InitV2") from the initial shared secret. The NHK rotates on every DH ratchet step, giving full forward secrecy for metadata.
Images:
- A random 256-bit AES key + 12-byte IV are generated in the browser.
-
IV || ciphertextis uploaded to the privatechat-imagesbucket. - The AES key is JSON-encoded into the ratchet plaintext and encrypted by the Double Ratchet before transit.
- Recipient decrypts the ratchet ciphertext, extracts the key, fetches a 1-hour signed URL, and decrypts the image blob client-side.
- Daily cap: 10 images per user per UTC day.
Voice:
- Audio recorded as Opus CVBR (32 kbps ceiling) in a WebM container.
- Same encryption scheme as images; stored in the private
chat-voicesbucket. - Plaintext audio never leaves the browser.
- Daily cap: 10 minutes per user per UTC day (enforced via
voice_send_durationstable).
Registration:
generateMnemonic() → 12-word BIP-39 phrase (128-bit entropy)
Argon2id(phrase) → mnemonic_hash (stored in profiles table)
AES-256-GCM(phrase) → encrypted blob (stored in vault IndexedDB)
Password reset:
User provides phrase
Edge Function: Argon2id(input) == profiles.mnemonic_hash?
Yes → admin.updateUserById(userId, newPassword)
No → 400 error
The plaintext mnemonic is never transmitted to the server. After a password reset, the local vault is cleared (message history lost).
1. User types message → ChatArea
2. relay.sendEncryptedMessage(conversationId, plaintext)
3. localStore.getRatchetSession(conversationId) → session
4. doubleRatchet.ratchetEncrypt(session, plaintext)
5. kdfCK(CKs) → (CKs', MK)
6. AES-256-GCM(MK, UTF8(plaintext)) → ciphertext + IV
7. Build EncryptedEnvelope { senderDHPub, Ns, PN, IV, ciphertext }
8. localStore.saveRatchetSession(conversationId, updatedSession)
9. supabase.from('messages').insert(envelope) ← relay
10. dbStore.saveMessageToDBFull(localMessage) ← local vault
11. Supabase Realtime pushes row to recipient
12. Recipient: relay.receiveAndDecryptMessage(envelope)
13. doubleRatchet.ratchetDecrypt(session, envelope)
14. Returns plaintext → stored in local vault + rendered in UI
Sender:
1. User taps mic → MediaRecorder starts (Opus CVBR / WebM, 250 ms chunks)
2. User taps stop → MediaRecorder.stop() → Blob (audio/webm)
3. relay.uploadVoiceMessage(userId, blob, duration, mimeType)
4. Rate-limit check: get_voice_send_duration(userId) + duration ≤ 600s?
5. encryptFileAESGCM(blob) → { ciphertextBlob, keyBase64 }
6. supabase.storage('chat-voices').upload(path, ciphertextBlob)
7. increment_voice_send_duration(userId, duration)
8. relay.sendEncryptedMessage(..., voiceAttachment)
- ratchetPlaintext = { v:3, t:'', voiceStoragePath, voiceKey, duration }
- Double Ratchet encrypts → voiceKey travels securely in ciphertext
9. dbStore.saveMessageToDBFull(localMessage) — key vault-wrapped before write
Recipient:
10. Receive ratchet-decrypted { v:3, voiceStoragePath, voiceKey, duration }
11. VoiceMessageBubble: on first play, fetchAndDecryptVoiceMessage(path, key)
12. createSignedUrl(path, 3600) → 1-hour signed URL
13. fetch(signedUrl) → ciphertextBlob
14. decryptBlobAESGCM(ciphertextBlob, key) → ArrayBuffer (plaintext audio)
15. URL.createObjectURL(new Blob([plainbuf], { type:'audio/webm' }))
16. <audio> element plays — plaintext audio never leaves the browser
SylvaCrypt/
├── src/
│ ├── App.tsx # Root: router, providers, PWA banner
│ ├── components/
│ │ ├── chat/ # ChatArea, Sidebar, dialogs, bubbles, emoji picker
│ │ ├── common/ # RouteGuard, PageMeta
│ │ └── ui/ # shadcn/ui primitives
│ ├── contexts/
│ │ ├── AuthContext.tsx # Auth state, vault unlock, mnemonic management
│ │ └── ThemeContext.tsx # Dark/light theme
│ ├── lib/
│ │ ├── crypto.ts # AES-GCM, X25519, HKDF, PBKDF2, Argon2id
│ │ ├── x3dh.ts # X3DH key agreement
│ │ ├── doubleRatchet.ts # Double Ratchet + NHK header key rotation
│ │ ├── relay.ts # [Supabase](https://supabase.com/) messaging relay & server sync
│ │ ├── localStore.ts # Encrypted IndexedDB vault
│ │ ├── deviceStore.ts # Multi-device session manager
│ │ ├── dbStore.ts # Supabase contacts & messages store
│ │ ├── session.ts # Session lifecycle, key derivation
│ │ ├── mnemonic.ts # BIP-39 utilities
│ │ └── zxcvbn.ts # Password strength estimation
│ ├── pages/
│ │ ├── AuthPage.tsx # Login, register, migrate, forgot password
│ │ ├── ChatPage.tsx # Main chat interface
│ │ ├── SettingsPage.tsx # Profile, bio, avatar, recovery phrase
│ │ ├── LinkedDevicesPage.tsx # Multi-device management
│ │ ├── SafetyNumberPage.tsx # Key fingerprint verification
│ │ ├── ThemesPage.tsx # Public themes browser
│ │ ├── DocsHomePage.tsx # In-app documentation hub
│ │ ├── SecurityWhitepaperPage.tsx
│ │ ├── TroubleshootingPage.tsx
│ │ └── PrivacyPolicyPage.tsx
│ └── types/types.ts # Shared TypeScript interfaces
├── supabase/
│ ├── functions/
│ │ ├── delete-account/ # Admin account deletion
│ │ └── reset-password/ # Mnemonic-verified password reset
│ └── migrations/ # 119 ordered SQL migration files
├── public/ # Static assets, service worker, PWA icons
├── .env.example # Environment variable template
├── Dockerfile # Container build
├── nginx.conf # Nginx config for self-hosted deployments
├── package.json # Dependencies and scripts
├── vite.config.ts # Vite build configuration
├── tailwind.config.js # Tailwind CSS configuration
├── biome.json # Biome linter configuration
├── ARCHITECTURE.md # Technical architecture (detailed)
├── SECURITY.md # Threat model and responsible disclosure
├── USAGE.md # End-user guide
├── CONTRIBUTING.md # Contribution guidelines
├── SELF_HOSTING.md # Self-hosting instructions
└── FAQ.md # Frequently asked questions
| Layer | Technology |
|---|---|
| Frontend framework | React 18, TypeScript 5, Vite (rolldown-vite) |
| Styling | Tailwind CSS 3, shadcn/ui (Radix UI primitives) |
| Routing | React Router v7 / v8 |
| Cryptography | Web Crypto API, hash-wasm (Argon2id WASM), @noble/curves (X25519), @noble/post-quantum (ML-KEM-768), @scure/bip39 |
| Backend | Supabase (PostgreSQL, Auth, Realtime, Storage, Edge Functions) |
| Local storage | IndexedDB via idb, encrypted vault in localStore.ts
|
| Password strength | @zxcvbn-ts/core |
| Linting | Biome 2.x |
| Build / test | Vite, Vitest |
| Containerisation | Docker + Nginx |
- Node.js ≥ 18
- pnpm ≥ 8
- A Supabase project (cloud or self-hosted)
- Supabase CLI (for running migrations and deploying functions)
# Clone
git clone https://github.com/Forestritium/SylvaCrypt.git
cd SylvaCrypt
# Install dependencies
pnpm install
# Start dev server (http://localhost:5173)
pnpm devLinting (must pass before any PR):
pnpm run lint
# Runs: tsgo type-check → biome lint → Tailwind CSS syntax check → Vite production buildTests:
pnpm test
# Runs Vitest in run modeCopy the template and fill in your Supabase project credentials:
cp .env.example .envVITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key# Apply all migrations to your Supabase project
supabase db push
# Or apply manually, in order, via the Supabase SQL Editor
# Files: supabase/migrations/00000_*.sql → 00119_*.sqlsupabase functions deploy delete-account
supabase functions deploy reset-passwordSylvaCrypt is fully self-hostable. A Dockerfile and nginx.conf are included.
- Create a Supabase project (cloud or self-hosted Supabase instance).
- Apply all migrations from
supabase/migrations/in sequential order. - Enable Email/Password authentication in Supabase Auth settings.
- Create private Storage buckets named
chat-imagesandchat-voices. - Set environment variables and build:
pnpm build
# Output: dist/- Serve the
dist/folder over HTTPS. The Web Crypto API requires a secure context (HTTPS or localhost).
Important: Self-hosters are responsible for securing their Supabase project, managing RLS policies, applying future migrations promptly, and keeping all dependencies up to date.
- Open SylvaCrypt in your browser and click Create Account.
- Enter a username (3–32 characters; letters, numbers, underscores only) and click Continue.
- Set a password meeting all requirements: 6–255 characters, at least one uppercase, lowercase, number, and special character. Aim for Strong or Very Strong on the strength meter.
- Click Create Account.
- Save your 12-word recovery phrase immediately — it appears once. Write it down or download the text file. It is the only way to recover your account if you forget your password.
Enter your username and password, then click Sign In. If your account was created before the current password system, you will be prompted for a one-time password migration on first login.
- Select a contact from the sidebar.
- Type in the message input and press Enter or click Send.
- To reply to a specific message, hover over it and click the Reply icon (or long-press on mobile). A preview appears above the input. Click × to cancel.
All messages are encrypted locally before transmission.
Images:
- Click the image icon in the chat input area.
- Select a file (max 10 MB), preview it, and click Send.
- Daily limit: 10 images per user, resetting at midnight UTC.
Voice messages:
- Click the microphone icon to start recording. A red indicator with an elapsed timer appears.
- Click the stop button to send, or the cancel button to discard.
- Daily limit: 10 minutes of voice per user, resetting at midnight UTC.
Adding a contact:
- Click the + button in the sidebar.
- Enter the username and click Send Request.
- The other person must accept before messaging is possible.
Incoming requests: A badge appears in the sidebar. Click Accept or Decline in the Requests panel.
Blocking a user: Open a conversation → ⋮ menu → Block User.
Unblocking: Go to Settings → Blocked Users and click Unblock.
Access via the gear icon or your avatar in the sidebar.
| Setting | Description |
|---|---|
| Avatar | Upload a profile picture (max 2 MB). Toggle avatar privacy to hide from non-contacts. |
| Bio | Short bio visible to contacts (max 160 characters). |
| Username | Changeable once every 30 days. |
| Recovery Phrase | View or regenerate your BIP-39 mnemonic. |
| Blocked Users | Manage your block list. |
| Linked Devices | Add or remove linked devices. |
| Notifications | Configure anonymous push notifications. |
| Themes | Browse and apply public themes. |
| Waveform | Customise voice message waveform display. |
| Delete Account | Permanently delete account and all data. |
Password requirements: 6–255 characters with uppercase, lowercase, number, and special character. No email is used for authentication.
Forgot password (with recovery phrase):
- Click Forgot Password on the login screen.
- Enter your username and your 12-word phrase (all lowercase, space-separated).
- Set a new password and click Reset Password.
After a password reset, your local vault is cleared — on-device message history is lost. Contacts are re-synced from the server.
Viewing or regenerating your phrase:
Go to Settings → Recovery Phrase and enter your current password to reveal it. You can regenerate it (old phrase immediately invalidated). Store the new phrase securely.
Go to Settings → Delete Account, enter your password, and confirm. This permanently removes your Supabase Auth account, profile, contacts, relay messages, and local vault. This action cannot be undone.
A safety number is a 60-digit fingerprint derived from your public key and your contact's public key. To verify you are not subject to a man-in-the-middle attack, compare your safety number with your contact over a separate channel (phone call, in person). Navigate to a conversation → contact name → Safety Number.
- Use a strong, unique password. Argon2id makes brute force very expensive, but a weak password undermines it.
- Save your recovery phrase offline (paper or password manager). Do not store it as plain text on the same device.
- Log out and close the tab on shared or untrusted machines.
- Verify contact safety numbers out-of-band before sharing sensitive information.
- Keep your browser updated — SylvaCrypt depends on modern Web Crypto APIs.
- Notifications are anonymous — they reveal neither the sender's identity nor message content.
# Fork the repo, then:
git clone https://github.com/YOUR_USERNAME/SylvaCrypt.git
cd SylvaCrypt
pnpm install
git checkout -b feat/my-feature
pnpm dev # Start dev server
pnpm run lint # Must pass before opening a PR
pnpm test # Run tests- 2-space indentation throughout.
- No default exports for utility functions — use named exports.
- TypeScript strict mode — no
anyor unchecked casts without an explanatory comment. - No raw Tailwind colour values (e.g.
bg-blue-500) — use semantic tokens (bg-primary,text-muted-foreground). - All crypto operations must go through
src/lib/crypto.ts. Do not introduce third-party crypto libraries without a security review. - Never write user message content, keys, or passwords to
localStorageor unencrypted DB columns. - Comments explain why, not what. Remove dead code instead of commenting it out.
Follow [Conventional Commits](https://www.conventionalcommits.org):
<type>(<scope>): <short summary>
| Type | When to use |
|---|---|
feat |
New user-visible feature |
fix |
Bug fix |
chore |
Maintenance, dependency updates, tooling |
docs |
Documentation only |
refactor |
Code restructuring without behaviour change |
perf |
Performance improvements |
style |
Formatting / whitespace (no logic changes) |
test |
Adding or updating tests |
build |
Build system or config changes |
ci |
CI/CD pipeline changes |
Examples:
feat(auth): add BIP-39 recovery phrase on registration
fix(relay): prevent duplicate message delivery on reconnect
chore: remove unused embla-carousel dependency
docs: add SECURITY.md and threat model
- Rebase your branch onto
mainbefore opening the PR. - Open the PR against
mainonForestritium/SylvaCrypt. - Fill in the PR template completely. Reference related issues (
Closes #123). - Ensure
pnpm run lintpasses with zero errors (type-check + biome + Tailwind + build). - A maintainer will review within 7 days. Address any requested changes promptly.
- Once approved and CI passes, a maintainer merges.
Reporting bugs: Search existing [issues](https://github.com/Forestritium/SylvaCrypt/issues) first; include steps to reproduce, expected vs. actual behaviour, browser/OS details, and any console errors.
Suggesting features: Open a [GitHub Discussion](https://github.com/Forestritium/SylvaCrypt/discussions) with the problem, proposed solution, alternatives considered, and whether you plan to implement it.
Do NOT open a public GitHub issue for security vulnerabilities.
- Email the maintainer at the contact listed on the GitHub profile of [A-Solo-Engineer](https://github.com/A-Solo-Engineer).
- Include: vulnerability description, affected components/versions, reproduction steps, impact assessment, and any suggested mitigations.
- If the report is sensitive, request a PGP key before sending.
Response timeline:
| Stage | Target |
|---|---|
| Acknowledgement | Within 72 hours |
| Initial assessment | Within 7 days |
| Fix or mitigation | ≤ 30 days (critical), ≤ 90 days (moderate) |
| Public disclosure | Coordinated with reporter after fix is deployed |
Reporters who follow responsible disclosure will be credited unless they prefer anonymity.
What is SylvaCrypt? SylvaCrypt (formerly ShadowCrypt) is an open-source, end-to-end encrypted messaging web app with a zero-knowledge relay. The server never holds decryption keys or plaintext.
Is my data really private? Yes. All messages are encrypted locally on your device before transmission. Supabase only ever routes opaque ciphertext; a full server compromise exposes only encrypted blobs.
What encryption does SylvaCrypt use? Double Ratchet (X25519 + HKDF-SHA256 + AES-256-GCM) combined with an ML-KEM-768 post-quantum layer for the initial key exchange. AES-256-GCM for symmetric encryption; Argon2id for vault key derivation.
What is a Safety Number? A 60-digit fingerprint derived from both your and your contact's public keys. Verifying it out-of-band (e.g. over a phone call) confirms your conversation has not been intercepted.
How often do keys rotate? After every single message. The symmetric ratchet advances with each send/receive; the DH ratchet advances on every reply.
Can I use multiple devices?
Yes. Each device maintains independent E2E sessions with your contacts via user_devices.
What if I lose my device? Log in from a new device using your credentials and recovery phrase. New messages will arrive; past message history is not recoverable unless you have a local backup (messages are deleted from the relay after delivery).
Why did my session reset? A Double Ratchet state mismatch (for example, after a long offline period where too many messages were missed) can trigger an unrecoverable state error. The app detects downgrade attacks and errors out. Re-initiate the conversation to establish a fresh session.
Is email required? No. SylvaCrypt uses username + password authentication. Password recovery uses the BIP-39 mnemonic, not email.
SylvaCrypt is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0).
Under the AGPL, if you modify SylvaCrypt and run it as a network service, you must release your modifications under the same license.
See [LICENSE](https://github.com/Forestritium/SylvaCrypt/blob/main/LICENSE) for the full text.