Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/cryptanalysis-tips.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ length; quadgrams see across the scaling). The genuine matrix wins by a wide mar

```python
from buttcrack.ciphers._hill_recover import recover

recs = recover(ct, scorer, alphabet="KRYPTOS", q_values=(1, 2), pair_brute=True)
# recs[0].decrypt_matrix / .offsets / .plaintext ; or just `butt crack --cipher hill`
```
Expand All @@ -622,8 +623,9 @@ that decodes the rest as English.

```python
from buttcrack.hill_kpa import solve_mod26, recover_matrix, recover_affine
recover_matrix(known_pt, ct, n=3, alphabet="STD", offset=0) # crib may start mid-message
recover_affine(known_pt, ct, n=3, q=2, alphabet="KRYPTOS") # matrix + period-q additive

recover_matrix(known_pt, ct, n=3, alphabet="STD", offset=0) # crib may start mid-message
recover_affine(known_pt, ct, n=3, q=2, alphabet="KRYPTOS") # matrix + period-q additive
```

## 17. The coset-preserving transposition: an honest, provable blind wall
Expand Down
115 changes: 115 additions & 0 deletions src/buttcrack/crib_anchor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Crib-anchored scoring for register-resistant cracks.

When a keyed cipher's plaintext is terse, name-heavy, numeric, or otherwise **not
flowing prose**, a pure n-gram objective loses its grip: the true key's decode
scores only middling, and an SA / hill-climb ranks it *below* over-fit junk. This
is the "register hole" — every fluency-based attack walks past the answer.

If you can guess a **contiguous crib** — a word or phrase almost certain to appear
(a recurring proper noun, a stock diary opener, a set phrase, a spelled year) —
you can anchor the search on it instead of on fluency. This module scores the
best-over-position appearance of the crib in a *decoded candidate*, to be **added
to** (and weighted to dominate) the n-gram term as the SA objective. For the true
key the crib lands in full and the score leaps clear of the plateau even when the
plaintext is unfluent; wrong keys cannot place the whole crib.

The primitive is decoder-agnostic: hand it any candidate plaintext string, from a
Bifid square-climb, a Quagmire solve, a columnar undo — whatever produced it.

Design rules (measured empirically on a short fractionation cipher whose plaintext
defeated n-gram scoring):

* **One contiguous crib, not scattered fragments.** A multi-anchor objective
(place word A *and* word B *and* word C) is too rugged — the true key's basin
is tiny and the climb almost never finds it, even at large restart budgets.
* **Length ~16-20 letters is the sweet spot.** Shorter (< ~13) *floods*: a short
crib is placeable somewhere in junk by many wrong keys, so it fails to separate
the true key (e.g. a 10-letter name is out-ranked by ~10^5 junk keys). Longer
(> ~24) *hurts convergence*: the climber cannot place all of it at a modest
restart budget, and the true key sinks in the ranking. See :data:`SWEET_SPOT`.
* **Weight the crib term to dominate.** Choose ``weight`` so a full placement
outweighs the n-gram spread between the true and junk keys; the n-gram term then
only breaks ties among keys that place the crib equally.
"""

from __future__ import annotations

from .scoring import NgramScorer, get_scorer
from .text import only_letters

#: Contiguous-crib length window that both separates (long enough that junk keys
#: cannot place it by chance) and converges (short enough for a hill-climb to
#: place in full at a modest restart budget).
SWEET_SPOT = (16, 20)


def best_position_match(text: str, crib: str) -> tuple[int, int]:
"""Best-over-position letter match of ``crib`` against ``text``.

Slides ``crib`` across ``text`` and returns ``(matches, position)``: the
maximum number of coincident letters over all offsets, and the offset that
achieves it. Both strings are reduced to A-Z first. Returns ``(0, 0)`` when
the crib is empty or longer than the text.
"""
t = only_letters(text.upper())
c = only_letters(crib.upper())
n = len(c)
if n == 0 or n > len(t):
return 0, 0
best, best_pos = -1, 0
for p in range(len(t) - n + 1):
m = sum(t[p + j] == c[j] for j in range(n))
if m > best:
best, best_pos = m, p
return best, best_pos


def crib_bonus(text: str, crib: str, *, weight: float = 1.0) -> float:
"""``weight`` times the best-over-position match count of ``crib`` in ``text``."""
return weight * best_position_match(text, crib)[0]


def crib_length_advice(crib: str) -> str:
"""Human-readable verdict on a crib's length for anchored SA (see module docs)."""
n = len(only_letters(crib.upper()))
if n < 13:
return "too short: will flood (a short crib is placeable in junk by many keys)"
if n > 24:
return "too long: hurts hill-climb convergence at a modest restart budget"
if SWEET_SPOT[0] <= n <= SWEET_SPOT[1]:
return "ideal"
return "usable"


class CribAnchoredScorer:
"""SA objective = n-gram score + ``weight`` * best crib placement.

Wrap the n-gram scorer a solver already uses and feed it candidate *decoded
plaintexts*; use :meth:`score` as the climb objective and :meth:`placement`
to gate / report survivors. See the module docstring for how to choose the
crib and ``weight``.
"""

def __init__(
self,
crib: str,
*,
weight: float = 30.0,
scorer: NgramScorer | None = None,
) -> None:
self.crib = only_letters(crib.upper())
self.weight = float(weight)
self.scorer = scorer or get_scorer()

def score(self, text: str) -> float:
"""n-gram fitness of ``text`` plus the weighted crib placement bonus."""
return self.scorer.score(text) + crib_bonus(text, self.crib, weight=self.weight)

def placement(self, text: str) -> tuple[int, int]:
"""``(matches, position)`` of the crib in ``text`` — for gating/reporting."""
return best_position_match(text, self.crib)

@property
def full(self) -> int:
"""Match count that constitutes a *full* placement of the crib."""
return len(self.crib)
78 changes: 70 additions & 8 deletions src/buttcrack/fractionation.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@

import random
import time
from functools import partial

from .scoring import index_of_coincidence, resolve_scorer

Expand Down Expand Up @@ -133,30 +134,84 @@ def _bifid6_di(alpha: str):
return fwd, inv


def bifid6_encode(pt: str, alpha: str, period: int) -> str:
#: Bifid seriation read-order variants: ``(half_order, reverse_rows, reverse_cols)``.
#: A Bifid gathers each block's row- and column-coordinates into one sequence, then
#: re-pairs it. Implementations differ in *how* they gather — rows-then-columns
#: (``"RC"``) vs columns-then-rows (``"CR"``), each optionally reversing the row and/or
#: column run. Several of these are statistically indistinguishable from one another,
#: so a blind solver that assumes only the standard order silently misses ciphertext
#: built with a different one. ``"std"`` is the classic row-major bifid (backward
#: compatible default).
GATHER_VARIANTS: dict[str, tuple[str, bool, bool]] = {
"std": ("RC", False, False),
"rc_ft": ("RC", False, True),
"rc_tf": ("RC", True, False),
"rc_tt": ("RC", True, True),
"cr_ff": ("CR", False, False),
"cr_ft": ("CR", False, True),
"cr_tf": ("CR", True, False),
"cr_tt": ("CR", True, True),
}


def _resolve_gather(gather: str | tuple[str, bool, bool]) -> tuple[str, bool, bool]:
return GATHER_VARIANTS[gather] if isinstance(gather, str) else tuple(gather) # type: ignore[return-value]


def _gather(rows: list[int], cols: list[int], spec: tuple[str, bool, bool]) -> list[int]:
half, rev_r, rev_c = spec
r = rows[::-1] if rev_r else list(rows)
c = cols[::-1] if rev_c else list(cols)
return (r + c) if half == "RC" else (c + r)


def _ungather(seq: list[int], p: int, spec: tuple[str, bool, bool]) -> tuple[list[int], list[int]]:
half, rev_r, rev_c = spec
if half == "RC":
r, c = seq[:p], seq[p:]
else:
c, r = seq[:p], seq[p:]
if rev_r:
r = r[::-1]
if rev_c:
c = c[::-1]
return r, c


def bifid6_encode(
pt: str, alpha: str, period: int, gather: str | tuple[str, bool, bool] = "std"
) -> str:
spec = _resolve_gather(gather)
fwd, inv = _bifid6_di(alpha)
out: list[str] = []
for i in range(0, len(pt), period):
block = pt[i : i + period]
p = len(block)
rows = [fwd[c][0] for c in block]
cols = [fwd[c][1] for c in block]
digits = "".join(str(x) for x in (rows + cols))
seq = _gather(rows, cols, spec)
for j in range(0, 2 * p, 2):
out.append(inv[(int(digits[j]), int(digits[j + 1]))])
out.append(inv[(seq[j], seq[j + 1])])
return "".join(out)


def bifid6_decode(ct: str, alpha: str, period: int) -> str:
def bifid6_decode(
ct: str, alpha: str, period: int, gather: str | tuple[str, bool, bool] = "std"
) -> str:
spec = _resolve_gather(gather)
fwd, inv = _bifid6_di(alpha)
out: list[str] = []
for i in range(0, len(ct), period):
block = ct[i : i + period]
p = len(block)
digits = "".join(f"{fwd[c][0]}{fwd[c][1]}" for c in block)
rows, cols = digits[0:p], digits[p : 2 * p]
seq: list[int] = []
for c in block:
r, cc = fwd[c]
seq.append(r)
seq.append(cc)
rows, cols = _ungather(seq, p, spec)
for j in range(p):
out.append(inv[(int(rows[j]), int(cols[j]))])
out.append(inv[(rows[j], cols[j])])
return "".join(out)


Expand Down Expand Up @@ -412,17 +467,24 @@ def solve_bifid6(
seconds_per_start: float = 4.0,
probe_seconds: float = 2.0,
seed: int = 0,
gather: str | tuple[str, bool, bool] = "std",
) -> dict:
"""Blind 6x6 Bifid/Polybius crack: detect period, greedy square climb.

Returns ``dict(score, plaintext, key, period, ioc, period_ranking)`` where
``key`` is the recovered 36-cell alphabet (26 letters + 10 digits).

``gather`` selects the seriation read-order (see :data:`GATHER_VARIANTS`); the
variants are statistically near-indistinguishable, so sweep them all — e.g.
``best = max((solve_bifid6(ct, gather=g) for g in GATHER_VARIANTS), key=lambda r: r["score"])``
— when the standard order does not read out.
"""
decode = partial(bifid6_decode, gather=gather)
return _blind_solve(
ct,
STANDARD + DIGITS,
"",
bifid6_decode,
decode,
periods=periods,
restarts=restarts,
seconds_per_start=seconds_per_start,
Expand Down
49 changes: 49 additions & 0 deletions tests/test_bifid_gather.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Tests for Bifid seriation gather-variant coverage (generalized from the
fractionation campaign: several read-orders are statistically indistinguishable,
so a blind solver must be able to try each)."""

from __future__ import annotations

from buttcrack.fractionation import (
GATHER_VARIANTS,
bifid6_alphabet,
bifid6_decode,
bifid6_encode,
)

PT = (
"THEQUICKBROWNFOXIUMPSOVERTHELAZYDOGWHILETHEOLDCLOCKINTHEHALLSTRUCK"
"MIDNIGHTANDTHEWINDCARRIEDTHESCENTOFRAINACROSSTHEQUIETFIELDS"
)


def test_all_gather_variants_round_trip() -> None:
alpha = bifid6_alphabet("KRYPTOS")
for name in GATHER_VARIANTS:
for period in (5, 7, 11, 13):
ct = bifid6_encode(PT, alpha, period, gather=name)
back = bifid6_decode(ct, alpha, period, gather=name)
assert back == PT, f"round-trip failed for gather={name} period={period}"


def test_std_is_backward_compatible() -> None:
# the default and "std" must equal the classic row-major behaviour
alpha = bifid6_alphabet("KRYPTOS")
for period in (5, 7, 12):
default = bifid6_encode(PT, alpha, period)
named = bifid6_encode(PT, alpha, period, gather="std")
spec = bifid6_encode(PT, alpha, period, gather=("RC", False, False))
assert default == named == spec


def test_variants_are_actually_distinct() -> None:
# different gathers must generally produce different ciphertext (else the
# "coverage" would be vacuous)
alpha = bifid6_alphabet("KRYPTOS")
cts = {name: bifid6_encode(PT, alpha, 7, gather=name) for name in GATHER_VARIANTS}
assert len(set(cts.values())) >= 6 # at least most of the 8 are distinct


def test_eight_variants_present() -> None:
assert len(GATHER_VARIANTS) == 8
assert GATHER_VARIANTS["std"] == ("RC", False, False)
51 changes: 51 additions & 0 deletions tests/test_crib_anchor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Tests for crib_anchor: crib-anchored scoring for register-resistant cracks
(anchor an SA on a guessed crib when the plaintext register defeats n-gram fitness)."""

from __future__ import annotations

from buttcrack.crib_anchor import (
SWEET_SPOT,
CribAnchoredScorer,
best_position_match,
crib_bonus,
crib_length_advice,
)
from buttcrack.scoring import get_scorer


def test_best_position_match_finds_planted_crib() -> None:
m, pos = best_position_match("XXXXXATTACKATDAWNYYYYY", "ATTACKATDAWN")
assert (m, pos) == (12, 5)


def test_best_position_match_partial_and_nonletters_ignored() -> None:
# one substituted letter (DAWN -> DOWN) -> 11 of 12; punctuation/case ignored
m, pos = best_position_match("aaaa attack-at-down aaaa", "ATTACKATDAWN")
assert m == 11


def test_best_position_match_edge_cases() -> None:
assert best_position_match("SHORT", "AVERYLONGCRIB") == (0, 0)
assert best_position_match("ANYTHING", "") == (0, 0)


def test_crib_bonus_scales_with_weight() -> None:
assert crib_bonus("XXXATTACKATDAWNXXX", "ATTACKATDAWN", weight=30.0) == 12 * 30.0


def test_anchored_scorer_prefers_the_placement() -> None:
scorer = get_scorer()
anchored = CribAnchoredScorer("ATTACKATDAWN", weight=30.0, scorer=scorer)
with_crib = "WEWILLATTACKATDAWNTOMORROW"
without = "THEQUICKBROWNFOXJUMPEDOVERALAZYDOG"
# placement dominates fluency: the crib-bearing decode wins despite `without`
# being at least as fluent
assert anchored.score(with_crib) > anchored.score(without)
assert anchored.placement(with_crib)[0] == anchored.full == 12


def test_length_advice_encodes_the_calibration() -> None:
assert crib_length_advice("BATTLESHIP").startswith("too short") # 10
assert crib_length_advice("ATTACKATDAWNSHARP") == "ideal" # 17, in SWEET_SPOT
assert crib_length_advice("A" * 30).startswith("too long")
assert SWEET_SPOT == (16, 20)
Loading