This project implements a two-ciphertext cryptanalysis system inspired by the 2006 paper "A Natural Language Approach to Automated Cryptanalysis of Two-time Pads" by Mason, Watkins, Eisner, and Stubblefield.
The system demonstrates a practical attack on keystream reuse vulnerabilities, automatically recovering plaintexts from two ciphertexts encrypted with the same keystream when their text domain is known. The email workflow is the most developed path today; HTML and book/document corpus support is still preliminary.
The core code solves the strict two-time pad case: two ciphertexts, one reused keystream, and one XOR stream x = c1 ⊕ c2 = p ⊕ q. It does not currently implement the easier 3+ ciphertext variant where additional XOR constraints reduce stream switching.
Current strengths:
- Character-level n-gram language model with Witten-Bell backoff.
- Beam-search decoder that keeps the XOR invariant at every recovered byte pair.
- Text-aware candidate alphabet with printable ASCII, common whitespace, and trained vocabulary bytes.
- Switch-aware alignment refinement that can repair local stream swaps after decoding.
- Optional separate models for the two streams, useful for mixed document types.
- Separate evaluation of strict byte accuracy, global-swap-aware byte accuracy, pair accuracy, printable accuracy, word accuracy, and stream switching.
- Repeatable local tests for the model, decoder, evaluator, and training pipeline.
Main limitations:
- Accuracy is strongly limited by the stream-switching problem when both plaintexts come from the same domain and use the same language model.
- Beam search is approximate. Wider beams can help but increase runtime.
- The decoder is tuned for text-like payloads, not arbitrary binary plaintext.
- If both plaintexts use the same model, a full global stream swap is often mathematically indistinguishable. Pair accuracy and best-orientation byte accuracy are more honest than strict byte accuracy alone.
- The README result summaries are based on local experiment artifacts committed in
results/; rerunpython -m src.analysisto refresh them after model or decoder changes.
When a keystream k is reused to encrypt two plaintexts p and q, an attacker can compute:
c₁ = p ⊕ k (ciphertext 1)
c₂ = q ⊕ k (ciphertext 2)
x = c₁ ⊕ c₂ = (p ⊕ k) ⊕ (q ⊕ k) = p ⊕ q
The challenge is to recover p and q from x = p ⊕ q alone. This has 2ⁿ solutions for n-bit messages, but when plaintexts follow known statistical patterns (natural language), the solution becomes tractable.
The paper's key insight: treat plaintext recovery as a decoding problem using statistical language models. Given x = p ⊕ q and language models for p and q's document types, find the most probable (p, q) pair:
argmax_(p,q) Pr₁(p) · Pr₂(q) subject to p ⊕ q = x
Where Pr₁ and Pr₂ are character-level n-gram language models trained on domain-specific corpora.
A character n-gram model is a probability distribution over the next character given the previous n-1 characters. For n=7 (used in this implementation), the model predicts the 7th character based on the preceding 6 characters.
Example: After seeing "hobnob", the model might predict:
- 's' with probability 0.52 (forming "hobnobs")
- space with probability 0.35 (forming "hobnob ")
- 'b' with probability 0.13 (forming "hobnobb")
For a plaintext string p = (p₁, p₂, ..., pₗ), the 7-gram model defines:
Pr₁(p) = ∏ᵢ₌₁ˡ Pr₁(pᵢ | pᵢ₋₆, ..., pᵢ₋₁)
Each factor represents the probability of character pᵢ given the previous 6 characters, assuming a Markov property - each character depends only on the immediate history.
The model is trained by sliding a 7-character window through training texts and counting occurrences:
def train(self, text: bytes):
# Add BOM (beginning-of-message) padding for proper context
prefix = bytes([BOM]) * 6 # 6 BOM characters
suffix = bytes([EOM]) # 1 EOM character
data = prefix + text + suffix
# Count 7-grams: (context of 6 chars) → (next char)
for i in range(0, len(data) - 7 + 1):
context = data[i:i+6] # 6-byte context
next_char = data[i+6] # Character to predict
self.ngram_counts[context][next_char] += 1Example: Training on "hobnobs" with BOM padding:
Training window: "h o b n o b s"
Context: "hobnob" (6 chars)
Next char: 's'
Result: ngram_counts[b"hobnob"]['s'] += 1
With 6-character contexts, there are 256⁶ ≈ 2.8×10¹⁴ possible contexts. Even with billions of training characters, most contexts are never seen. Naive frequency estimates give zero probability for unseen sequences, making decoding impossible.
Witten-Bell smoothing interpolates between:
- Maximum Likelihood Estimate (MLE) for observed n-grams
- Backoff estimate using shorter contexts
For a context c:
N(c)= total timescappearedT(c)= distinct characters followingc- Backoff weight:
λ(c) = T(c) / (T(c) + N(c))
Probability calculation:
P_WB(x|c) = (1 - λ(c)) × P_ML(x|c) + λ(c) × P_backoff(x|c')
Where c' = c[1:] (context with oldest character removed)
When computing P('s' | "hobnob"):
- Try 7-gram:
P('s' | "hobnob") - If insufficient data, backoff to 6-gram:
P('s' | "obnob") - Continue backing off until 1-gram:
P('s') - Base case: uniform over vocabulary
This ensures no probability is ever zero, allowing the decoder to explore all possibilities.
Given x = p ⊕ q and language models for p and q, find:
argmax_(p,q) ∏ᵢ Pr₁(pᵢ | pᵢ₋₆...pᵢ₋₁) × Pr₂(qᵢ | qᵢ₋₆...qᵢ₋₁)
subject to pᵢ ⊕ qᵢ = xᵢ for all i
class BeamState:
__slots__ = ['ctx1', 'ctx2', 'log_prob', 'p1_path', 'p2_path']
def __init__(self, ctx1: bytes, ctx2: bytes, log_prob: float,
p1_path: bytearray, p2_path: bytearray):
self.ctx1 = ctx1 # Last 6 chars of plaintext1
self.ctx2 = ctx2 # Last 6 chars of plaintext2
self.log_prob = log_prob # Cumulative log probability
self.p1_path = p1_path # Reconstructed plaintext1 so far
self.p2_path = p2_path # Reconstructed plaintext2 so fardef decode(self, xor_stream: List[int]) -> Tuple[bytes, bytes]:
# Initialize with BOM contexts
beam = [BeamState(bytes([BOM]*6), bytes([BOM]*6), 0.0, bytearray(), bytearray())]
for i, xor_byte in enumerate(xor_stream):
candidates = []
for state in beam:
# Generate candidate byte pairs from the text alphabet
for p1_byte in candidate_alphabet:
p2_byte = p1_byte ^ xor_byte
if p2_byte in candidate_alphabet:
# Score using language models
lp1 = model1.log_prob(p1_byte, state.ctx1)
lp2 = model2.log_prob(p2_byte, state.ctx2)
new_prob = state.log_prob + lp1 + lp2
# Update contexts
new_ctx1 = (state.ctx1 + bytes([p1_byte]))[-6:]
new_ctx2 = (state.ctx2 + bytes([p2_byte]))[-6:]
candidates.append(BeamState(new_ctx1, new_ctx2, new_prob,
state.p1_path + [p1_byte],
state.p2_path + [p2_byte]))
# Keep only top-k states (beam width = 100)
beam = sorted(candidates, key=lambda s: s.log_prob, reverse=True)[:100]
# Return best candidate
best = max(beam, key=lambda s: s.log_prob)
return bytes(best.p1_path), bytes(best.p2_path)The decoder now runs an optional post-processing pass after beam search. Beam search often recovers the right unordered byte pair but assigns a segment to the wrong stream. The refinement pass treats every recovered pair as two possible orientations:
position i: keep = (rec1[i], rec2[i])
position i: swap = (rec2[i], rec1[i])
It then runs a second, much smaller beam/Viterbi pass over those keep/swap choices. Each orientation is scored with the same two language models and a configurable switch penalty. This keeps the XOR invariant intact because swapping a recovered pair does not change rec1[i] ⊕ rec2[i].
This improves local stream assignment when the model evidence is strong enough. It cannot reliably solve a full global swap when both streams use the same language model, because (p, q) and (q, p) are equally plausible without outside identity information.
- Full search space: O(256²ˡ) for length
l(intractable) - With n-gram constraint: O(l × 256⁷) (still massive)
- With beam search: O(l × beam_width × 256) (practical)
- Alignment refinement: O(l × alignment_beam_width × 2), because it only chooses keep vs swap for recovered pairs.
Performance: runtime scales roughly with message_length × beam_width × candidate_alphabet_size, plus the smaller alignment pass. Exact throughput depends on model size, corpus domain, and beam width.
Local experiments train Enron email models and evaluate recovery on held-out email pairs. Similar two-time-pad tools often show either crib-dragging examples or partial plaintext output, while Mason et al. report plaintext accuracy and runtime. For this project, the most useful presentation is a table with metric definitions, corpus size, model settings, and recovery scores.
- Strict byte accuracy: recovered bytes match the original file order exactly.
- Best-orientation byte accuracy: best score after allowing one global stream swap. This is important for same-domain two-time pads because
(p, q)and(q, p)may be indistinguishable. - Pair accuracy: unordered byte pair is correct at a position, even if assigned to the wrong stream.
- Switch rate: correct byte pairs that are assigned to the opposite stream.
- Success rate: percentage of tests with pair accuracy above 70%.
These larger runs are the committed result artifacts under results/. They predate the new best-orientation metric, but they are still useful for tracking pair recovery as training data grows.
| Total Emails | Train Emails | Strict Byte Accuracy | Pair Accuracy | Success Rate |
|---|---|---|---|---|
| 1,250 | 1,000 | 29.6% | 58.6% | 40.4% |
| 6,250 | 5,000 | 36.9% | 75.2% | 64.4% |
| 12,500 | 10,000 | 38.1% | 80.9% | 71.2% |
This compact benchmark was run against the current decoder after adding switch-aware alignment refinement:
- Data:
res/20k_processed_emails - Training: 240 emails
- Test set: 5 held-out email pairs
- Plaintext length: 70 bytes per pair
- Model: 4-gram character model
- Beam width: 80
- Seed: 23
| Decoder Variant | Strict Byte Accuracy | Best-Orientation Byte Accuracy | Pair Accuracy | Switch Rate |
|---|---|---|---|---|
| Beam search only | 39.1% | 81.7% | 96.9% | 57.7% |
| Beam + alignment refinement | 40.6% | 81.7% | 96.9% | 56.3% |
The refinement improves local stream assignment without changing pair recovery. The large gap between strict byte accuracy and best-orientation byte accuracy shows that many same-domain failures are stream-labeling failures rather than missing-character failures.
-
Pair Accuracy Is the Core Deciphering Metric: When pair accuracy is high, the system has often recovered the plaintext characters but may not know which recovered stream belongs to which original ciphertext.
-
Strict Byte Accuracy Measures Stream Identity: This is useful when model 1 and model 2 represent distinct document types. It can look unfairly low when both models are identical.
-
Best-Orientation Byte Accuracy Separates Global Swaps from Real Errors: A globally swapped recovery is still useful to a human analyst, and this metric shows that.
-
Training Data Helps Pair Recovery: The larger local summaries show pair accuracy improving with more email data.
-
Domain Separation Should Improve Stream Assignment: Different document-type models, such as HTML versus email, should reduce switching more than simply adding more same-domain email.
After decoding errors, the algorithm loses track of which byte belongs to which text. Correct bytes continue to be recovered but are assigned to the wrong plaintext until another correction occurs.
Example:
Original: p = "Hello world", q = "Secret message"
Recovered: p' = "Secret Hello", q' = "world message"
Pair Accuracy: 100% (all bytes present)
Byte Accuracy: 50% (bytes in wrong positions)
Character n-gram models only look at the last 6 characters. When the decoder makes an error, the contexts become corrupted. Once it "gets back on track," it has no memory of which stream the correct bytes belong to.
When p is HTML and q is email, their language models differ significantly:
- HTML: High probability for
<,>, tags - Email: High probability for
@,:in headers The probability gap makes swapped assignments highly improbable.
Implemented in TwoTimePadDecoder.refine_stream_alignment. The decoder scores each recovered byte pair in both orientations and keeps the highest-scoring stream assignment, with a small penalty for changing orientation too often.
for i in range(len(rec1)):
keep_score = model1.log_prob(rec1[i], ctx1) + model2.log_prob(rec2[i], ctx2)
swap_score = model1.log_prob(rec2[i], ctx1) + model2.log_prob(rec1[i], ctx2)
# A beam/Viterbi pass chooses the best sequence of keep/swap decisions.Implemented in the decoder: the candidate alphabet includes printable ASCII, common whitespace, and trained vocabulary bytes. The remaining accuracy work is mostly systematic tuning of beam width, n-gram length, switch penalty, and corpus/domain-specific alphabets.
git clone https://github.com/Radoslaw-Wolnik/TwoTimePadCracking.git
cd TwoTimePadCracking
pip install -r requirements.txt# Download and process Enron emails
python -m src.main --setup --type email
# For HTML documents
python -m src.main --setup --type html
# Books are accepted by the CLI, but corpus setup is currently a placeholder
python -m src.main --setup --type books# Train a new email model
python -m src.main --train --type email --model-name email_model --new
# Continue training on existing model
python -m src.main --train --type email --model-name email_model --open-model-name email_model# Decode two ciphertexts encrypted with same keystream
python -m src.main --decoding \
--model-name email_model \
--doc-type email \
--file1path enc1.bin \
--file2path enc2.bin
# Decode with two different stream models
python -m src.main --decoding \
--model1-name email_model \
--model2-name html_model \
--doc-type email \
--file1path enc_email.bin \
--file2path enc_html.bin
# Tune decoder search/refinement
python -m src.main --decoding \
--model-name email_model \
--doc-type email \
--beam-width 200 \
--switch-penalty 1.0 \
--file1path enc1.bin \
--file2path enc2.binAlignment refinement is enabled by default. Add --no-refine-alignment to inspect the raw beam-search output.
# Analyze with different training sizes
python -m src.analysis --num-emails 1000 --num-runs 3 --num-tests 10
python -m src.analysis --num-emails 5000 --num-runs 3 --num-tests 10
python -m src.analysis --num-emails 10000 --num-runs 3 --num-tests 10
# With custom parameters
python -m src.analysis --num-emails 5000 --n 7 --beam-width 200 --num-runs 5# All tests
python -m pytest src/tests/ -v
# Specific test suites
python -m pytest src/tests/test_char_language_model.py -v
python -m pytest src/tests/test_decoder_integration.py -v
# Slow tests (requires data)
python -m pytest src/tests/ --runslow -v
python -m pytest src/tests/ --runveryslow -v- Models: Saved to
res/models/directory as binary files - Analysis Results: JSON, plots, and summaries in
res/analysis_results/ - Decoded Text: Recovered plaintexts printed to console and saved to files
-
Primary Reference: Mason, J., Watkins, K., Eisner, J., & Stubblefield, A. (2006). A Natural Language Approach to Automated Cryptanalysis of Two-time Pads. Proceedings of the 13th ACM Conference on Computer and Communications Security (CCS'06).
-
Language Modeling: Witten, I. H., & Bell, T. C. (1991). The zero-frequency problem: Estimating the probabilities of novel events in adaptive text compression. IEEE Transactions on Information Theory.
-
Earlier Work: Dawson, E., & Nielsen, L. (1996). Automated cryptanalysis of XOR plaintext strings. Cryptologia.
-
Historical Context: Kahn, D. (1996). The Codebreakers. Comprehensive history of cryptography including two-time pad vulnerabilities.
-
Dataset: Enron Email Dataset, available at https://www.cs.cmu.edu/~enron/
-
Related Tooling: SpiderLabs
cribdrag, an interactive crib-dragging tool for reused or predictable stream cipher keys: https://github.com/spiderlabs/cribdrag -
Related Tooling:
multi_time_pad_breaker, a heuristic same-key OTP breaker that demonstrates space/XOR reasoning and partial plaintext recovery: https://github.com/dszyszek/multi_time_pad_breaker -
Metric Presentation: Recent cryptanalysis benchmarks commonly report exact-match and normalized text-recovery metrics; this project uses byte/pair/switch metrics because two-time-pad recovery has stream-orientation ambiguity: https://arxiv.org/html/2505.24621v1
This project is for educational and research purposes only. Use responsibly and in accordance with applicable laws and regulations.
The code is provided as-is under the MIT License. See LICENSE file for details.
This project demonstrates a known cryptographic vulnerability (keystream reuse). Use this knowledge responsibly:
- Only test on systems you own or have explicit permission to test
- Never use for unauthorized access to systems or data
- Report security vulnerabilities to vendors through proper channels
Author: Radoslaw Wolnik
Contact: radoslaw.m.wolnik@gmail.com
Repository: GitHub Link
Based on: Mason et al. (2006), CCS'06