E2EE Implementation - nself-org/nchat GitHub Wiki
nself-chat v0.4.0 implements Signal Protocol-based end-to-end encryption (E2EE) providing the same level of security used by Signal, WhatsApp, and other leading secure messaging platforms.
Version: 0.4.0
Protocol: Signal Protocol (X3DH + Double Ratchet)
Library: @signalapp/libsignal-client (official implementation)
Status: Production-ready
- Architecture
- Security Properties
- Database Schema
- Key Management
- Message Flow
- API Reference
- React Hooks
- UI Components
- Integration Guide
- Security Best Practices
- Troubleshooting
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Application Layer β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β React Components β Hooks β API Routes β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β E2EE Manager β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ β
β β Key Manager β βSession Mgr β βMessage Enc β β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Signal Protocol Client Wrapper β
β (X3DH Key Exchange + Double Ratchet Algorithm) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Cryptographic Primitives Layer β
β (@noble/curves, @noble/hashes, Web Crypto API) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β PostgreSQL Database β
β (Identity Keys, Prekeys, Sessions, Safety Numbers) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
src/
βββ lib/e2ee/
β βββ index.ts # E2EE Manager (main entry point)
β βββ crypto.ts # Crypto primitives
β βββ signal-client.ts # Signal Protocol wrapper
β βββ key-manager.ts # Key generation & storage
β βββ session-manager.ts # Session lifecycle management
β βββ message-encryption.ts # Message integration helpers
βββ hooks/
β βββ use-e2ee.ts # Main E2EE hook
β βββ use-safety-numbers.ts # Safety number verification
βββ components/e2ee/
β βββ E2EESetup.tsx # Setup wizard
β βββ SafetyNumberDisplay.tsx # Identity verification
β βββ E2EEStatus.tsx # Encryption status indicator
βββ app/api/e2ee/
β βββ initialize/route.ts # E2EE initialization
β βββ recover/route.ts # Recovery via recovery code
β βββ safety-number/route.ts # Safety number operations
β βββ keys/replenish/route.ts # Prekey replenishment
βββ graphql/
βββ e2ee.ts # GraphQL queries & mutations
.backend/migrations/
βββ 014_e2ee_system.sql # Database schema
β End-to-End Encryption: Only sender and recipient can read messages β Perfect Forward Secrecy: Past messages remain secure if keys are compromised β Future Secrecy: Future messages remain secure after key compromise β Authentication: Cryptographic verification of sender identity β Deniability: No cryptographic proof of who sent a message β Zero-Knowledge Server: Server cannot decrypt any messages
- Key Exchange: X3DH (Extended Triple Diffie-Hellman)
- Ratcheting: Double Ratchet Algorithm
- Curve: Curve25519 (ECDH)
- Signing: Ed25519
- Symmetric Encryption: AES-256-GCM
- Key Derivation: PBKDF2-SHA256 (100,000 iterations)
- Hashing: SHA-256, SHA-512
Stores user's master key info (password-derived).
- salt: bytea -- 32-byte random salt
- key_hash: bytea -- SHA-256 hash for verification
- iterations: int -- PBKDF2 iterations (100,000)
- master_key_backup_encrypted: bytea -- Encrypted with recovery key
- recovery_code_hash: bytea -- Hash of recovery codeLong-term identity keys (one per device).
- device_id: varchar -- Unique device identifier
- identity_key_public: bytea -- Public identity key (32 bytes)
- identity_key_private_encrypted: bytea -- Encrypted private key
- registration_id: int -- 14-bit random numberSigned prekeys (rotated weekly).
- key_id: int -- Signed prekey ID
- public_key: bytea -- Public key (32 bytes)
- private_key_encrypted: bytea -- Encrypted private key
- signature: bytea -- Ed25519 signature (64 bytes)
- expires_at: timestamptz -- Expiration (7 days)One-time prekeys (consumed once, 100 per device).
- key_id: int -- Prekey ID
- public_key: bytea -- Public key (32 bytes)
- private_key_encrypted: bytea -- Encrypted private key
- is_consumed: boolean -- Consumed flag
- consumed_at: timestamptz -- Consumption timestamp
- consumed_by: uuid -- Consumer user IDDouble Ratchet session state.
- peer_user_id: uuid -- Conversation partner
- peer_device_id: varchar -- Partner's device
- session_state_encrypted: bytea -- Encrypted session state
- root_key_hash: bytea -- Root key hash (non-sensitive)
- chain_key_hash: bytea -- Chain key hash
- send_counter: int -- Message send counter
- receive_counter: int -- Message receive counter
- is_initiator: boolean -- Session initiator flag
- expires_at: timestamptz -- Expiration (30 days)Safety numbers for identity verification.
- peer_user_id: uuid -- Peer user
- safety_number: varchar(60) -- 60-digit number
- is_verified: boolean -- Verification status
- verified_at: timestamptz -- Verification timestamp
- user_identity_fingerprint: varchar(64)
- peer_identity_fingerprint: varchar(64)Sender keys for efficient group encryption.
- channel_id: uuid -- Group channel
- sender_user_id: uuid -- Sender
- distribution_id: uuid -- Distribution identifier
- sender_key_public: bytea -- Public sender key
- signature_key_public: bytea -- Signature keyAudit log for E2EE events (non-sensitive metadata only).
- event_type: varchar -- Event type
- event_data: jsonb -- Event metadata
- ip_address: inet -- IP address
- user_agent: text -- User agent
- created_at: timestamptz -- Timestamp-
Master Key
- Derived from user's password using PBKDF2 (100k iterations)
- Used to encrypt all private keys
- Never stored in plaintext
- Backed up encrypted with recovery key
-
Identity Key Pair (IK)
- Long-term Curve25519 key pair
- One per device
- Identifies the device cryptographically
-
Signed Prekey (SPK)
- Medium-term key signed by identity key
- Rotated weekly
- Provides forward secrecy
-
One-Time Prekeys (OPK)
- Single-use keys (100 per device)
- Consumed during X3DH key exchange
- Replenished automatically when low
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. User Setup β
β - Generate master key from password β
β - Generate recovery code β
β - Store encrypted master key backup β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 2. Device Registration β
β - Generate identity key pair β
β - Generate signed prekey (expires in 7 days) β
β - Generate 100 one-time prekeys β
β - Encrypt private keys with master key β
β - Upload public keys to server β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 3. Ongoing Maintenance β
β - Rotate signed prekey weekly β
β - Replenish one-time prekeys when < 20 remain β
β - Expire inactive sessions after 30 days β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. Alice wants to send encrypted message to Bob β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 2. Check if session exists β
β - Query nchat_signal_sessions table β
β - If no session: initiate X3DH key exchange β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 3. X3DH Key Exchange (if no session) β
β - Fetch Bob's prekey bundle from server β
β β’ Identity key (IK_B) β
β β’ Signed prekey (SPK_B) β
β β’ One-time prekey (OPK_B) [optional] β
β - Perform 3 or 4 Diffie-Hellman calculations β
β - Derive shared secret β
β - Initialize Double Ratchet β
β - Mark Bob's one-time prekey as consumed β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 4. Encrypt Message β
β - Use Double Ratchet to derive message key β
β - Encrypt plaintext with AES-256-GCM β
β - Include ratchet public key (for first message) β
β - Update session state (ratchet forward) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 5. Store & Send β
β - Save encrypted payload to database β
β - Update session metadata β
β - Send notification to Bob β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. Bob receives encrypted message β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 2. Load Session β
β - Query session with Alice from database β
β - Decrypt session state with master key β
β - Deserialize Signal session β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 3. Decrypt Message β
β - If PreKeyMessage: process X3DH, create session β
β - If NormalMessage: use existing session β
β - Derive message key from Double Ratchet β
β - Decrypt ciphertext with AES-256-GCM β
β - Update session state (ratchet forward) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 4. Display & Update β
β - Show decrypted plaintext to Bob β
β - Save updated session state to database β
β - Update session metadata β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Initialize E2EE for the current user.
Request:
{
"password": "strong-password",
"deviceId": "optional-device-id"
}Response:
{
"success": true,
"status": {
"initialized": true,
"masterKeyInitialized": true,
"deviceKeysGenerated": true,
"deviceId": "abc123..."
},
"recoveryCode": "alpha-bravo-charlie-...",
"message": "E2EE initialized successfully"
}Recover E2EE using recovery code.
Request:
{
"recoveryCode": "alpha-bravo-charlie-...",
"deviceId": "optional-device-id"
}Response:
{
"success": true,
"status": {
"initialized": true,
"masterKeyInitialized": true,
"deviceKeysGenerated": true
},
"message": "E2EE recovered successfully"
}Generate or verify safety numbers.
Request (Generate):
{
"action": "generate",
"localUserId": "uuid",
"peerUserId": "uuid",
"peerDeviceId": "device-id"
}Response:
{
"success": true,
"safetyNumber": "123456789012...",
"formattedSafetyNumber": "12345 67890 12345 ...",
"qrCodeData": "v1:userId1:userId2:safetyNumber"
}Replenish one-time prekeys.
Request:
{
"count": 50
}Response:
{
"success": true,
"message": "Successfully replenished 50 one-time prekeys",
"count": 50
}Main hook for E2EE functionality.
import { useE2EE } from '@/hooks/use-e2ee';
function MyComponent() {
const {
status,
isInitialized,
isLoading,
error,
initialize,
recover,
destroy,
encryptMessage,
decryptMessage,
rotateSignedPreKey,
replenishOneTimePreKeys,
getRecoveryCode,
clearRecoveryCode,
hasSession,
} = useE2EE();
// Initialize E2EE
const handleSetup = async () => {
await initialize('my-password', 'optional-device-id');
const recoveryCode = getRecoveryCode();
console.log('Recovery code:', recoveryCode);
};
// Encrypt a message
const handleSendMessage = async () => {
const encrypted = await encryptMessage(
'Hello, World!',
'recipient-user-id',
'recipient-device-id'
);
// Send encrypted to server
};
return <div>{isInitialized ? 'E2EE Enabled' : 'E2EE Disabled'}</div>;
}Hook for safety number operations.
import { useSafetyNumbers } from '@/hooks/use-safety-numbers';
function SafetyNumberView() {
const {
safetyNumber,
isLoading,
generateSafetyNumber,
verifySafetyNumber,
loadSafetyNumber,
compareSafetyNumbers,
} = useSafetyNumbers();
useEffect(() => {
generateSafetyNumber(
'local-user-id',
'peer-user-id',
'peer-device-id'
);
}, []);
return (
<div>
{safetyNumber && (
<div>
<p>{safetyNumber.formattedSafetyNumber}</p>
<button onClick={() => verifySafetyNumber('peer-user-id')}>
Verify
</button>
</div>
)}
</div>
);
}Setup wizard component.
import { E2EESetup } from '@/components/e2ee/E2EESetup'
;<E2EESetup
onComplete={() => console.log('Setup complete')}
onCancel={() => console.log('Setup cancelled')}
/>Display and verify safety numbers.
import { SafetyNumberDisplay } from '@/components/e2ee/SafetyNumberDisplay'
;<SafetyNumberDisplay
localUserId="user-1"
peerUserId="user-2"
peerDeviceId="device-abc"
peerName="Alice"
onVerified={() => console.log('Verified!')}
/>Show encryption status badge/icon.
import { E2EEStatus } from '@/components/e2ee/E2EEStatus';
<E2EEStatus isEncrypted={true} isVerified={true} variant="badge" />
<E2EEStatus isEncrypted={true} variant="icon" />
<E2EEStatus isEncrypted={false} variant="inline" />// src/app/auth/signin/page.tsx
import { useE2EE } from '@/hooks/use-e2ee';
export default function SignInPage() {
const { initialize } = useE2EE();
const [showE2EESetup, setShowE2EESetup] = useState(false);
const handleSignIn = async (email: string, password: string) => {
// Sign in with auth provider
await signIn(email, password);
// Prompt for E2EE setup
setShowE2EESetup(true);
};
return (
<div>
{showE2EESetup ? (
<E2EESetup
onComplete={() => router.push('/chat')}
onCancel={() => router.push('/chat')}
/>
) : (
<SignInForm onSubmit={handleSignIn} />
)}
</div>
);
}// src/components/chat/message-input.tsx
import { useE2EE } from '@/hooks/use-e2ee';
import { encryptMessageForSending, prepareMessageForStorage } from '@/lib/e2ee/message-encryption';
export function MessageInput({ channelId, recipientUserId }: Props) {
const apolloClient = useApolloClient();
const { isInitialized } = useE2EE();
const handleSend = async (plaintext: string) => {
// Encrypt message
const payload = await encryptMessageForSending(
plaintext,
{
recipientUserId,
isDirectMessage: true,
},
apolloClient
);
// Prepare for storage
const messageData = prepareMessageForStorage(payload);
// Insert into database
await apolloClient.mutate({
mutation: INSERT_MESSAGE,
variables: {
channel_id: channelId,
...messageData,
},
});
};
return <input onSubmit={handleSend} />;
}// src/components/chat/message-list.tsx
import { extractMessageContent } from '@/lib/e2ee/message-encryption';
export function MessageList({ messages }: Props) {
const apolloClient = useApolloClient();
const [decryptedMessages, setDecryptedMessages] = useState<Map<string, string>>(new Map());
useEffect(() => {
const decryptAll = async () => {
for (const msg of messages) {
if (msg.is_encrypted) {
const plaintext = await extractMessageContent(msg, apolloClient);
setDecryptedMessages(prev => new Map(prev).set(msg.id, plaintext));
}
}
};
decryptAll();
}, [messages]);
return (
<div>
{messages.map(msg => (
<div key={msg.id}>
<E2EEStatus isEncrypted={msg.is_encrypted} variant="icon" />
<p>{msg.is_encrypted ? decryptedMessages.get(msg.id) : msg.content}</p>
</div>
))}
</div>
);
}// src/components/chat/user-profile.tsx
import { SafetyNumberDisplay } from '@/components/e2ee/SafetyNumberDisplay'
export function UserProfile({ userId, deviceId, name }: Props) {
const { user } = useAuth()
return (
<div>
<h2>{name}</h2>
<SafetyNumberDisplay
localUserId={user.id}
peerUserId={userId}
peerDeviceId={deviceId}
peerName={name}
onVerified={() => toast('Identity verified!')}
/>
</div>
)
}- Strong Password: Use a unique, strong password for E2EE
- Recovery Code: Store recovery code in a secure, offline location
- Verify Identity: Always verify safety numbers with contacts
- Device Security: Keep devices physically secure and locked
- Update Regularly: Keep the app updated for security patches
- Key Rotation: Rotate signed prekeys weekly (automated)
- Prekey Monitoring: Replenish one-time prekeys when < 20 remain
- Session Expiry: Expire inactive sessions after 30 days
- Audit Logging: Log all E2EE events (metadata only, no keys)
- Error Handling: Never expose keys or sensitive data in errors
- Secure Storage: All private keys encrypted with master key
- Zero Trust: Server should never have access to plaintext
- Master key derived with PBKDF2 (100k iterations)
- All private keys encrypted at rest
- Recovery code stored securely offline
- Safety numbers verified with contacts
- Signed prekeys rotated weekly
- One-time prekeys replenished automatically
- Inactive sessions expired after 30 days
- E2EE audit log enabled
- Error messages don't leak sensitive data
- Server cannot access plaintext messages
Cause: User hasn't set up E2EE yet.
Solution:
const { initialize } = useE2EE()
await initialize('password')Causes:
- Session not established
- Out-of-order message delivery
- Corrupted session state
- Wrong device ID
Solutions:
- Check if session exists:
await hasSession(userId, deviceId) - Recreate session: Delete old session, initiate new X3DH
- Verify sender device ID is correct
Cause: Recipient hasn't uploaded keys yet.
Solution: Recipient must initialize E2EE first.
Cause: Wrong recovery code entered.
Solution: Double-check recovery code spelling and format.
Cause: < 20 one-time prekeys remaining.
Solution: Automatic replenishment will trigger. Manual:
const { replenishOneTimePreKeys } = useE2EE()
await replenishOneTimePreKeys(50)- Batch Decryption: Decrypt multiple messages in parallel
- Session Caching: Cache session objects in memory
- Lazy Loading: Only decrypt visible messages
- Worker Threads: Offload crypto to Web Workers (future)
-
Materialized Views: Use
nchat_prekey_bundlesfor fast lookups
- Key generation: ~100ms
- X3DH key exchange: ~50ms
- Message encryption: ~5ms
- Message decryption: ~5ms
- Safety number generation: ~10ms
Q: Is this as secure as Signal? A: Yes, we use the same protocol and official Signal library.
Q: Can the server read my messages? A: No, the server only stores encrypted payloads it cannot decrypt.
Q: What if I lose my password? A: Use your recovery code to restore access. Without it, messages are unrecoverable.
Q: Can I use E2EE in group chats? A: Yes, using sender keys for efficiency (to be implemented).
Q: How do I verify someone's identity? A: Compare safety numbers in person or via video call.
Q: What happens if I change devices? A: Each device has its own keys. You can have multiple devices per account.
Q: Is metadata encrypted? A: No, only message content is encrypted. Metadata (who, when) is visible to server.
- Signal Protocol Specification
- The Double Ratchet Algorithm
- The X3DH Key Agreement Protocol
- @signalapp/libsignal-client
Last Updated: 2026-01-30 Version: 0.4.0 Status: Production Ready β