TweetNaCl in Production: Building E2E Encrypted Messaging
Styrby uses TweetNaCl for end-to-end encryption of coding session data. This article covers why we chose TweetNaCl, how key management works in practice, the performance characteristics we measured, and the mistakes we made along the way.
Why TweetNaCl
The requirements were specific: authenticated encryption that works in Node.js (CLI), React Native (mobile), and the browser (web dashboard). The library needed to be small, well-audited, and have zero dependencies.
We evaluated three options:
| Library | Size (min) | Dependencies | Audit Status | Platforms |
|---|---|---|---|---|
| tweetnacl | ~7KB | 0 | Formally verified | Node, browser, RN |
| libsodium.js | ~180KB | 0 | Audited | Node, browser, RN (with effort) |
| WebCrypto API | Native | 0 | Platform-dependent | Browser, Node 15+, no RN |
TweetNaCl won on three criteria: smallest size (important for CLI install speed), zero dependencies (supply chain security), and cross-platform compatibility without polyfills.
The formal verification is a meaningful advantage. TweetNaCl is a JavaScript port of the NaCl library by Daniel J. Bernstein, and the JavaScript implementation has been verified against the reference implementation. This is a stronger guarantee than "we wrote tests."
Key Management Architecture
Each device (CLI workstation and mobile phone) generates a Curve25519 key pair on first setup:
import nacl from "tweetnacl";
// Generate once, store securely
const keyPair = nacl.box.keyPair();
// Public key: 32 bytes, uploaded to server
// Secret key: 32 bytes, stored in system keychain ONLYKey Storage
- macOS CLI: Keychain Services via
securityCLI - Linux CLI: Secret Service API (libsecret) or encrypted file in
~/.config/styrby/ - iOS: Expo SecureStore (backed by Keychain)
- Web: IndexedDB with non-exportable CryptoKey wrapping (browser-session scoped)
Key Exchange
When the CLI and mobile app establish a session, they exchange public keys through the Styrby server. The server facilitates the exchange but never sees the secret keys. The shared secret is computed independently on each device:
// Both sides compute the same shared key
// CLI side:
const shared = nacl.box.before(mobilePublicKey, cliSecretKey);
// Mobile side:
const shared = nacl.box.before(cliPublicKey, mobileSecretKey);
// Diffie-Hellman: both produce identical 32-byte shared keysEncryption and Decryption
We use nacl.box.after (the precomputed variant) for message encryption. This avoids recomputing the shared secret for every message:
import nacl from "tweetnacl";
import { encodeUTF8, decodeUTF8 } from "tweetnacl-util";
function encrypt(message: string, sharedKey: Uint8Array): {
nonce: Uint8Array;
ciphertext: Uint8Array;
} {
const nonce = nacl.randomBytes(nacl.box.nonceLength); // 24 bytes
const messageBytes = decodeUTF8(message);
const ciphertext = nacl.box.after(messageBytes, nonce, sharedKey);
if (!ciphertext) throw new Error("Encryption failed");
return { nonce, ciphertext };
}
function decrypt(
ciphertext: Uint8Array,
nonce: Uint8Array,
sharedKey: Uint8Array
): string {
const decrypted = nacl.box.open.after(ciphertext, nonce, sharedKey);
if (!decrypted) throw new Error("Decryption failed: invalid key or tampered data");
return encodeUTF8(decrypted);
}Performance Characteristics
We benchmarked TweetNaCl on the three target platforms:
| Operation | Message Size | Node.js (M2 Mac) | React Native (iPhone 15) | Chrome (M2 Mac) |
|---|---|---|---|---|
| Key generation | N/A | 0.02ms | 0.1ms | 0.03ms |
| Encrypt | 1KB | 0.05ms | 0.3ms | 0.08ms |
| Encrypt | 100KB | 2.1ms | 8.5ms | 3.2ms |
| Encrypt | 1MB | 18ms | 72ms | 28ms |
| Decrypt | 1KB | 0.04ms | 0.25ms | 0.07ms |
| Decrypt | 100KB | 1.8ms | 7.2ms | 2.8ms |
For typical session messages (1-10KB), encryption adds less than 1ms on any platform. Even large messages (code files with full context) encrypt in under 20ms on the CLI. The performance overhead is negligible compared to network latency.
Lessons Learned
1. Nonce Management Is Critical
Reusing a nonce with the same key breaks the security of XSalsa20. We use nacl.randomBytes(24) for every message. With a 24-byte nonce space, the probability of collision is negligible even at billions of messages.
2. Error Messages Must Not Leak Information
Our initial implementation returned different error messages for "wrong key" and "tampered data." This is an oracle that helps attackers distinguish failure modes. Both cases now return the same generic error.
3. Key Rotation Is Hard
We planned for key rotation but have not implemented it yet. Rotating keys requires re-encrypting historical messages or accepting that old messages become inaccessible with new keys. For now, keys persist for the lifetime of the device registration. This is a known limitation.
4. React Native Gotchas
TweetNaCl works in React Native but relies on crypto.getRandomValues for secure random number generation. Expo provides this, but some bare React Native setups require a polyfill. We document this in the setup guide.
Open Questions
We are still evaluating forward secrecy. The current design uses long-lived key pairs. A Double Ratchet protocol (like Signal uses) would provide forward secrecy but adds significant complexity. For coding sessions where the threat model is server compromise rather than active MITM, the current approach provides adequate security. We may revisit this for the enterprise tier.
Ready to manage your AI agents from one place?
Styrby gives you cost tracking, remote permissions, and session replay across five agents.