diff --git a/docs/cryptanalysis-tips.md b/docs/cryptanalysis-tips.md index 735b05a..6212a4d 100644 --- a/docs/cryptanalysis-tips.md +++ b/docs/cryptanalysis-tips.md @@ -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` ``` @@ -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 diff --git a/src/buttcrack/crib_anchor.py b/src/buttcrack/crib_anchor.py new file mode 100644 index 0000000..77efdc1 --- /dev/null +++ b/src/buttcrack/crib_anchor.py @@ -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) diff --git a/src/buttcrack/fractionation.py b/src/buttcrack/fractionation.py index 2c0dd02..0f767a1 100644 --- a/src/buttcrack/fractionation.py +++ b/src/buttcrack/fractionation.py @@ -43,6 +43,7 @@ import random import time +from functools import partial from .scoring import index_of_coincidence, resolve_scorer @@ -133,7 +134,54 @@ 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): @@ -141,22 +189,29 @@ def bifid6_encode(pt: str, alpha: str, period: int) -> str: 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) @@ -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, diff --git a/tests/test_bifid_gather.py b/tests/test_bifid_gather.py new file mode 100644 index 0000000..9f14e9d --- /dev/null +++ b/tests/test_bifid_gather.py @@ -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) diff --git a/tests/test_crib_anchor.py b/tests/test_crib_anchor.py new file mode 100644 index 0000000..b0d9bdc --- /dev/null +++ b/tests/test_crib_anchor.py @@ -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)