Open Protocol Auditor for End-to-End Encryption
opacE2EE is a framework that measures the security of E2EE messaging protocols across 9 dimensions, producing a scored audit report out of 100. Protocol authors implement a single Rust trait (ProtocolAdapter), run the audit, and get a deterministic, reproducible score.
All tests are fully transparent — anyone can read the test source, verify the scoring, and audit the results. No test depends on protocol-specific types or internal APIs. Every test operates exclusively through the ProtocolAdapter trait.
# Run the audit against the Signal protocol (default)
cargo run -- audit
# Run the audit against Signal with JSON and PDF reports
cargo run -- audit --json --pdf --output ./output/
# List available protocol adapters
cargo run -- listThe Signal adapter (libsignal-protocol) and the Olm/Megolm adapter (vodozemac) are included out of the box. Anyone can clone this repo and immediately run audits against either protocol.
# Run the audit against Olm/Megolm (Matrix)
cargo run -- audit --protocol olm
# Run the audit against Olm with JSON and PDF reports
cargo run -- audit --protocol olm --json --pdf --output ./output/The Fourier adapter requires the fourier crate (not included in this repo). If you have access to it:
cargo run --features fourier -- audit --protocol fourieropacE2EE measures protocols across 9 security dimensions. Each dimension is scored 0–10 and weighted to produce an overall score out of 100.
| # | Dimension | Weight | Tests | What It Measures |
|---|---|---|---|---|
| 1 | Key Exchange | 12% | 5 | X3DH session establishment, 3-DH fallback (no one-time prekey), signed prekey verification, session uniqueness |
| 2 | Forward Secrecy | 15% | 5 | Chain key ratcheting per message, root key advancement on DH ratchet, per-message unique keys, key erasure after use |
| 3 | Post-Compromise Security | 12% | 4 | DH ratchet heals compromised state, fresh keys after recovery, healing within one round-trip, state divergence |
| 4 | Server Trust | 15% | 5 | Server cannot read plaintext, AEAD integrity, tampered messages rejected, replay rejection, no plaintext in metadata |
| 5 | Group Security | 10% | 4 | Sender Keys encrypt/decrypt, non-member rejection, unique ciphertext per message, PCS limitations acknowledged |
| 6 | Metadata Protection | 10% | 5 | Message size decorrelation, sender anonymity in messages, sealed sender, message padding, timing obfuscation |
| 7 | Cryptographic Primitives | 8% | 5 | AEAD authenticated encryption, key derivation, digital signatures, X25519 DH agreement, key initialization |
| 8 | Identity Verification | 10% | 4 | Safety number generation, re-key detection, safety number symmetry, key transparency |
| 9 | Multi-Device Security | 8% | 4 | Per-device keys, independent sessions, device compromise isolation, cross-device sync |
Overall = sum of (DimensionScore / 10.0 * DimensionWeight) for all 9 dimensions
Each dimension scores 0–10 based on tests passed. Weights sum to 100. A protocol scoring 10/10 on every dimension scores 100/100.
Every test operates through the ProtocolAdapter trait API. Tests verify behavior, not implementation details:
- Session snapshot tests verify that cryptographic state changes at the right times (after encrypt, after DH ratchet, etc.). For protocols with opaque session records (like libsignal), adapters use state fingerprinting — a SHA-256 hash of the serialized session state that changes whenever internal state changes.
- Server view tests verify that the server cannot see plaintext, and that message metadata (ratchet keys, counters) is properly structured.
- Padding tests use the
pad_plaintext/unpad_plaintexttrait methods. Protocols that implement padding override these methods; protocols that don't get the default no-op (and the test honestly fails). - Without-OTP tests use
publish_prekey_bundle_without_otp(). Each adapter generates a real bundle without the one-time prekey component.
Create src/adapters/your_protocol.rs and implement the ProtocolAdapter trait. The Signal adapter (src/adapters/signal.rs) is the reference implementation — study it to understand the expected behavior.
use anyhow::Result;
use crate::traits::ProtocolAdapter;
use crate::types::{Identity, ServerView, SessionSnapshot};
pub struct YourProtocolAdapter {
// Internal state: identity keys, sessions, prekeys, etc.
}
impl YourProtocolAdapter {
pub fn new() -> Self {
Self { /* ... */ }
}
}
impl ProtocolAdapter for YourProtocolAdapter {
fn protocol_name(&self) -> &str { "YourProtocol" }
fn protocol_version(&self) -> &str { "1.0.0" }
fn generate_identity(&mut self, id: &str) -> Result<Identity> { todo!() }
fn publish_prekey_bundle(&mut self, identity_id: &str) -> Result<Vec<u8>> { todo!() }
fn publish_prekey_bundle_without_otp(&mut self, identity_id: &str) -> Result<Vec<u8>> { todo!() }
fn initiate_session(&mut self, initiator_id: &str, responder_bundle: &[u8]) -> Result<String> { todo!() }
fn respond_to_session(&mut self, responder_id: &str, initiator_identity_key: &[u8; 32], initiator_ephemeral_key: &[u8; 32]) -> Result<String> { todo!() }
fn encrypt(&mut self, session_id: &str, plaintext: &[u8]) -> Result<Vec<u8>> { todo!() }
fn decrypt(&mut self, session_id: &str, ciphertext: &[u8]) -> Result<Vec<u8>> { todo!() }
fn session_snapshot(&self, session_id: &str) -> Result<SessionSnapshot> { todo!() }
fn server_view(&self, encrypted_message: &[u8]) -> Result<ServerView> { todo!() }
fn create_group_session(&mut self, sender_id: &str, group_id: &str) -> Result<Vec<u8>> { todo!() }
fn join_group_session(&mut self, receiver_id: &str, distribution_message: &[u8]) -> Result<String> { todo!() }
fn group_encrypt(&mut self, sender_id: &str, group_id: &str, plaintext: &[u8]) -> Result<Vec<u8>> { todo!() }
fn group_decrypt(&mut self, group_session_id: &str, ciphertext: &[u8]) -> Result<Vec<u8>> { todo!() }
fn tamper_ciphertext(&self, encrypted_message: &[u8]) -> Result<Vec<u8>> { todo!() }
fn safety_number(&self, id_a: &str, id_b: &str) -> Result<String> { todo!() }
fn get_ephemeral_key(&self, session_id: &str) -> Result<[u8; 32]> { todo!() }
fn get_identity_x25519(&self, id: &str) -> Result<[u8; 32]> { todo!() }
// Optional — override if your protocol provides message padding:
// fn pad_plaintext(&self, plaintext: &[u8]) -> Result<Vec<u8>> { ... }
// fn unpad_plaintext(&self, padded: &[u8]) -> Result<Vec<u8>> { ... }
}Add your module to src/adapters/mod.rs:
pub mod signal;
pub mod your_protocol;Add the dispatch arm in src/lib.rs inside run_audit():
"your_protocol" => Box::new(YourProtocolAdapter::new()),And add it to available_protocols():
pub fn available_protocols() -> Vec<&'static str> {
let mut protocols = vec!["signal"];
protocols.push("your_protocol");
protocols
}cargo run -- audit --protocol your_protocol --json --pdfThe session_snapshot() method returns internal cryptographic state for inspection. Tests use snapshots to verify that:
root_keychanges after DH ratchet stepschain_key_send/chain_key_recvadvance after each encrypt/decryptdh_self_public/dh_peer_publicrotate during DH ratchetsend_count/recv_countincrement properly
If your protocol exposes these values directly, return them. If your session record is opaque (e.g., libsignal's SessionRecord), you can use state fingerprinting: hash the serialized session state to produce values that change whenever internal state changes. The Signal adapter demonstrates this approach. The key requirement is that the returned values must be:
- Non-zero after session establishment
- Different between independent sessions
- Changed after operations that modify session state
The server_view() method returns what a server relaying the message would see:
ciphertext— the encrypted payload bytesratchet_key— the ephemeral DH public key included in the message header (32 bytes)message_number— the message counter/sequence numberassociated_data— any unencrypted associated dataplaintext_visible— must befalse(server should never see plaintext)
pub trait ProtocolAdapter: Send + Sync {
// --- Protocol Info ---
fn protocol_name(&self) -> &str;
fn protocol_version(&self) -> &str;
// --- Identity ---
fn generate_identity(&mut self, id: &str) -> Result<Identity>;
// --- Key Exchange ---
fn publish_prekey_bundle(&mut self, identity_id: &str) -> Result<Vec<u8>>;
fn publish_prekey_bundle_without_otp(&mut self, identity_id: &str) -> Result<Vec<u8>>;
fn initiate_session(&mut self, initiator_id: &str, responder_bundle: &[u8]) -> Result<String>;
fn respond_to_session(&mut self, responder_id: &str, ik: &[u8; 32], ek: &[u8; 32]) -> Result<String>;
// --- 1:1 Encryption ---
fn encrypt(&mut self, session_id: &str, plaintext: &[u8]) -> Result<Vec<u8>>;
fn decrypt(&mut self, session_id: &str, ciphertext: &[u8]) -> Result<Vec<u8>>;
// --- Session Inspection ---
fn session_snapshot(&self, session_id: &str) -> Result<SessionSnapshot>;
fn server_view(&self, encrypted_message: &[u8]) -> Result<ServerView>;
// --- Group Encryption ---
fn create_group_session(&mut self, sender_id: &str, group_id: &str) -> Result<Vec<u8>>;
fn join_group_session(&mut self, receiver_id: &str, dist_msg: &[u8]) -> Result<String>;
fn group_encrypt(&mut self, sender_id: &str, group_id: &str, pt: &[u8]) -> Result<Vec<u8>>;
fn group_decrypt(&mut self, group_session_id: &str, ct: &[u8]) -> Result<Vec<u8>>;
// --- Tamper Testing ---
fn tamper_ciphertext(&self, encrypted_message: &[u8]) -> Result<Vec<u8>>;
// --- Key Verification ---
fn safety_number(&self, id_a: &str, id_b: &str) -> Result<String>;
// --- Key Introspection ---
fn get_ephemeral_key(&self, session_id: &str) -> Result<[u8; 32]>;
fn get_identity_x25519(&self, id: &str) -> Result<[u8; 32]>;
// --- Message Padding (optional, default: no-op) ---
fn pad_plaintext(&self, plaintext: &[u8]) -> Result<Vec<u8>>;
fn unpad_plaintext(&self, padded: &[u8]) -> Result<Vec<u8>>;
}These scores come from actual adapter runs, not estimates.
| Protocol | Score | Key Exchange | Forward Secrecy | PCS | Server Trust | Group | Metadata | Primitives | Identity | Multi-Device |
|---|---|---|---|---|---|---|---|---|---|---|
| Signal (libsignal) | 87.5 | 10.0 | 10.0 | 10.0 | 10.0 | 10.0 | 2.0 | 10.0 | 7.5 | 7.5 |
| Olm/Megolm (vodozemac) | 87.5 | 10.0 | 10.0 | 10.0 | 10.0 | 10.0 | 2.0 | 10.0 | 7.5 | 7.5 |
| Fourier | 89.5 | 10.0 | 10.0 | 10.0 | 10.0 | 10.0 | 4.0 | 10.0 | 7.5 | 7.5 |
Differences explained:
- Metadata Protection: Fourier provides a
pad_message/unpad_messageAPI that normalizes message sizes. Neither Signal's base protocol (libsignal-protocol) nor Olm/Megolm include padding — the Signal app adds PKCS#7-style padding at a higher layer, and Olm has no built-in padding mechanism. - Olm/Megolm vs Signal: Both score identically. Olm uses 3DH (simpler than Signal's X3DH) for key exchange and Megolm (shared ratchet) for group encryption instead of Signal's sender keys, but both approaches pass the same audit tests.
- All protocols share the same limitations: no sealed sender at the crypto layer, no timing obfuscation, no key transparency, and no cross-device sync in the base protocol.
PDF report generation requires DejaVu fonts. The fonts/ directory is included in this repo. If fonts are missing, --pdf will return an error (not crash) and you can still use --json.
Audit scores are deterministic. Running the same protocol adapter produces the same score every time — no randomness in test evaluation, no external dependencies. The only values that change between runs are timestamp and run_id.
Each audit run produces a unique run_id (SHA-256 hash of timestamp + protocol + version + all test results). Use this to reference a specific audit.
# Generate shareable reports
cargo run -- audit --json --pdf --output ./output/
# Output files:
# ./output/signal_audit.json
# ./output/signal_audit.pdfThe JSON report contains every test result, scores, and metadata — suitable for automated comparison pipelines.
- Marlinspike, M. & Perrin, T. (2016). The X3DH Key Agreement Protocol. Signal Foundation.
- Perrin, T. & Marlinspike, M. (2016). The Double Ratchet Algorithm. Signal Foundation.
- Marlinspike, M. & Perrin, T. (2017). The Sesame Algorithm. Signal Foundation.
- Barnes, R. et al. (2023). The Messaging Layer Security (MLS) Protocol. IETF RFC 9420.
- Marlinspike, M. (2017). Sealed Sender. Signal Blog.
- Cohn-Gordon, K. et al. (2020). A Formal Security Analysis of the Signal Messaging Protocol. Journal of Cryptology, 33(4).
- Alwen, J., Coretti, S., & Dodis, Y. (2019). The Double Ratchet: Security Notions, Proofs, and Modularization. EUROCRYPT 2019.
- Cremers, C., Hale, B., & Kohbrok, K. (2023). The Complexities of Healing in Secure Group Messaging. USENIX Security 2023.
- Bhargavan, K., Beurdouche, B., & Naldurg, P. (2017). A Formal Treatment of the Signal Messaging Protocol. ePrint 2017/1230.
- Chase, M., Meiklejohn, S., & Zaverucha, G. (2020). Key Transparency and the Right to be Forgotten. ePrint 2020/534.
- Melara, M. et al. (2015). CONIKS: Bringing Key Transparency to End Users. USENIX Security 2015.
- Greschbach, B., Kreitz, G., & Buchegger, S. (2012). The Devil is in the Metadata. IEEE PerCom Workshops.
- Angel, S. & Setty, S. (2016). Unobservable Communication over Fully Untrusted Infrastructure. OSDI 2016.
- Alwen, J. et al. (2020). Security Analysis and Improvements for the IETF MLS Standard. CRYPTO 2020.
- Cohn-Gordon, K. et al. (2018). On Ends-to-Ends Encryption. ACM CCS 2018.
- Bernstein, D.J. (2008). ChaCha, a variant of Salsa20.
- Krawczyk, H. & Eronen, P. (2010). HKDF. IETF RFC 5869.
- Josefsson, S. & Liusvaara, I. (2017). EdDSA. IETF RFC 8032.
- Unger, N. et al. (2015). SoK: Secure Messaging. IEEE S&P 2015.
- Rosler, P., Mainka, C., & Schwenk, J. (2018). More is Less: On the End-to-End Security of Group Chats. IEEE EuroS&P 2018.
Apache-2.0 OR MIT (dual-licensed)