Problem 59: XOR Decryption

View on Project Euler

Project Euler Problem 59 Solution

EulerSolve provides an optimized solution for Project Euler Problem 59, XOR Decryption, with C++, Python, Java, and a step-by-step mathematical explanation.

Problem Summary The ciphertext is given as a comma-separated list of ASCII codes obtained by XOR-encrypting an English plaintext with a repeating key of length 3. Each key character is a lowercase English letter, so the key belongs to a set with only \(26^3\) possibilities. The task is to recover the intended plaintext and then compute the sum of its ASCII values. The decisive facts are that XOR is its own inverse, the key repeats every three positions, and the implementations do not use a separate cryptanalytic shortcut. They test every admissible key and rank the resulting plaintexts with an explicit English-text score. Mathematical Approach Let the ciphertext be \(e_0,e_1,\dots,e_{n-1}\). For a candidate key \(k=(k_0,k_1,k_2)\) with each \(k_r\in\{97,98,\dots,122\}\), the decrypted byte at position \(i\) is $$p_i(k)=e_i \oplus k_{i \bmod 3}.$$ This turns the problem into a finite optimization problem over the key set \(K=\{97,\dots,122\}^3\). XOR as an involution The basic invariant is $$x \oplus y \oplus y = x.$$ If a plaintext byte \(p_i\) was encrypted as \(e_i=p_i \oplus k_{i \bmod 3}\), then applying the same key byte again restores the original symbol: $$e_i \oplus k_{i \bmod 3}=(p_i \oplus k_{i \bmod 3}) \oplus k_{i \bmod 3}=p_i.$$ No recurrence is needed....

Detailed mathematical approach

Problem Summary

The ciphertext is given as a comma-separated list of ASCII codes obtained by XOR-encrypting an English plaintext with a repeating key of length 3. Each key character is a lowercase English letter, so the key belongs to a set with only \(26^3\) possibilities. The task is to recover the intended plaintext and then compute the sum of its ASCII values.

The decisive facts are that XOR is its own inverse, the key repeats every three positions, and the implementations do not use a separate cryptanalytic shortcut. They test every admissible key and rank the resulting plaintexts with an explicit English-text score.

Mathematical Approach

Let the ciphertext be \(e_0,e_1,\dots,e_{n-1}\). For a candidate key \(k=(k_0,k_1,k_2)\) with each \(k_r\in\{97,98,\dots,122\}\), the decrypted byte at position \(i\) is

$$p_i(k)=e_i \oplus k_{i \bmod 3}.$$

This turns the problem into a finite optimization problem over the key set \(K=\{97,\dots,122\}^3\).

XOR as an involution

The basic invariant is

$$x \oplus y \oplus y = x.$$

If a plaintext byte \(p_i\) was encrypted as \(e_i=p_i \oplus k_{i \bmod 3}\), then applying the same key byte again restores the original symbol:

$$e_i \oplus k_{i \bmod 3}=(p_i \oplus k_{i \bmod 3}) \oplus k_{i \bmod 3}=p_i.$$

No recurrence is needed. Once the key is fixed, every plaintext byte is determined independently by this identity, and the period 3 tells us exactly which key byte is used at each index.

The key space is small enough to exhaust

Because the key has length 3 and each character must be a lowercase ASCII letter,

$$|K|=26^3=17{,}576.$$

That is small enough for direct exhaustive search. For each key, decryption costs one XOR per ciphertext position, so the entire search is completely practical. This is why the solution does not need a more elaborate derivation from residue classes or classical frequency tables.

The method is exact relative to its scoring rule: it does not estimate the best key, it evaluates every candidate key and keeps the best-scoring plaintext.

Scoring English-looking plaintexts

The implementations use a hard validity filter followed by a weighted English-text score. If any decrypted character lies outside the ASCII interval from 9 through 126, the candidate is rejected by assigning a very large negative score. Otherwise the score is

$$S(p)=3N_{\text{space}}(p)+2N_{\text{letter}}(p)+40W_{\text{the}}(p)+25W_{\text{and}}(p)+20W_{\text{of}}(p)+20W_{\text{to}}(p),$$

where \(N_{\text{space}}\) counts spaces, \(N_{\text{letter}}\) counts uppercase and lowercase English letters, and \(W_{\text{word}}\) counts occurrences of the indicated word written with surrounding spaces.

So the selected key is

$$k^\star=\operatorname*{arg\,max}_{k\in K} S(p(k)).$$

This is the real mathematical object used by the code. The search is performed on fully decrypted texts, not by solving each key position separately.

Worked Example: a short XOR round trip

The implementations include a small checkpoint with the plaintext “hello world” and the key “abc”. Writing the key bytes as \(97,98,99\), the first encrypted values are

$$104\oplus97=9,\qquad 101\oplus98=7,\qquad 108\oplus99=15,$$

and continuing with the same period-3 pattern gives the ciphertext prefix \([9,7,15,13,13,67,\dots]\). Decrypting with the same repeating key reverses the process:

$$9\oplus97=104,\qquad 7\oplus98=101,\qquad 15\oplus99=108.$$

The original text is recovered exactly. A wrong key may still produce printable symbols, but it usually scores much worse because it contains fewer letters, fewer spaces, and few or no common English words.

Recovering the required answer

Once the maximizing plaintext \(p(k^\star)\) has been found, the required result is simply

$$A=\sum_{i=0}^{n-1} p_i(k^\star).$$

The search phase identifies the plaintext, and the final summation converts that plaintext into the requested integer.

How the Code Works

Parsing the ciphertext

The C++, Python, and Java implementations read the ciphertext as raw text and scan it character by character. Consecutive decimal digits are collected into one token, converted to an integer, and appended to the ciphertext array. Commas and line breaks act only as separators.

Enumerating candidate keys and decrypting

Next, the implementation loops through all triples of lowercase letters. For each triple, it decrypts the entire ciphertext with the repeating period-3 XOR rule and materializes the candidate plaintext as a string.

Scoring, selecting, and summing

Each candidate plaintext is checked against the ASCII-range filter and then scored with the weighted English-text formula. Whenever a candidate beats the best score seen so far, the implementation stores it as the current winner. After the full \(26^3\) search finishes, it sums the ASCII codes of the winning plaintext. Before the main search, the implementations also verify the XOR round-trip identity on a short known example.

Complexity Analysis

If \(n\) is the number of ciphertext bytes, parsing costs \(O(n)\). The exhaustive search tries \(26^3\) keys, and each key requires \(O(n)\) work to decrypt and score the text, so the dominant running time is

$$O(26^3 n).$$

Because \(26^3=17{,}576\) is a fixed constant, the runtime is linear in the message length with a moderate constant factor. The extra word-count passes in the score are over four fixed short words, so they do not change the asymptotic bound.

The memory usage is \(O(n)\): the implementations store the ciphertext, one current plaintext, and a constant amount of bookkeeping for the best score and the key enumeration.

Footnotes and References

  1. Problem page: https://projecteuler.net/problem=59
  2. XOR cipher: Wikipedia - XOR cipher
  3. ASCII: Wikipedia - ASCII
  4. Frequency analysis and language statistics: Wikipedia - Frequency analysis

Problem 59 source code

C++

#include <algorithm>
#include <cstdint>
#include <fstream>
#include <iostream>
#include <limits>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>

namespace {

using i64 = std::int64_t;

struct Options {
    std::string file = "resources/documents/0059_cipher.txt";
    bool run_checkpoints = true;
};

bool parse_string_after_prefix(const std::string& arg,
                               const std::string& prefix,
                               std::string& value) {
    if (arg.rfind(prefix, 0U) != 0U) {
        return false;
    }
    const std::string tail = arg.substr(prefix.size());
    if (tail.empty()) {
        return false;
    }
    value = tail;
    return true;
}

bool parse_arguments(int argc, char** argv, Options& options) {
    for (int i = 1; i < argc; ++i) {
        const std::string arg(argv[i]);
        if (arg == "--skip-checkpoints") {
            options.run_checkpoints = false;
            continue;
        }
        if (parse_string_after_prefix(arg, "--file=", options.file)) {
            continue;
        }

        std::cerr << "Unknown argument: " << arg << '\n';
        return false;
    }
    return true;
}

std::vector<int> parse_cipher_csv(const std::string& payload) {
    std::vector<int> values;
    std::string token;

    for (char c : payload) {
        if ((c >= '0' && c <= '9')) {
            token.push_back(c);
        } else if (!token.empty()) {
            values.push_back(std::stoi(token));
            token.clear();
        }
    }
    if (!token.empty()) {
        values.push_back(std::stoi(token));
    }

    return values;
}

std::string decrypt_with_key(const std::vector<int>& cipher, const std::string& key) {
    std::string out;
    out.resize(cipher.size());

    for (std::size_t i = 0; i < cipher.size(); ++i) {
        out[i] = static_cast<char>(cipher[i] ^ key[i % key.size()]);
    }

    return out;
}

int text_score(const std::string& text) {
    int score = 0;

    for (char c : text) {
        const unsigned char uc = static_cast<unsigned char>(c);
        if (uc < 9 || uc > 126) {
            return -1000000;
        }
        if (c == ' ') {
            score += 3;
        }
        if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
            score += 2;
        }
    }

    auto count_occurrences = [&](const std::string& needle) {
        int cnt = 0;
        std::size_t pos = 0;
        while (true) {
            pos = text.find(needle, pos);
            if (pos == std::string::npos) {
                break;
            }
            ++cnt;
            pos += needle.size();
        }
        return cnt;
    };

    score += 40 * count_occurrences(" the ");
    score += 25 * count_occurrences(" and ");
    score += 20 * count_occurrences(" of ");
    score += 20 * count_occurrences(" to ");
    return score;
}

i64 ascii_sum(const std::string& text) {
    i64 total = 0;
    for (char c : text) {
        total += static_cast<unsigned char>(c);
    }
    return total;
}

i64 solve(const std::string& file_path) {
    std::ifstream input(file_path);
    if (!input) {
        throw std::runtime_error("Could not open cipher file: " + file_path);
    }

    std::ostringstream buffer;
    buffer << input.rdbuf();
    const std::vector<int> cipher = parse_cipher_csv(buffer.str());

    int best_score = std::numeric_limits<int>::min();
    std::string best_text;

    for (char a = 'a'; a <= 'z'; ++a) {
        for (char b = 'a'; b <= 'z'; ++b) {
            for (char c = 'a'; c <= 'z'; ++c) {
                const std::string key = {a, b, c};
                const std::string text = decrypt_with_key(cipher, key);
                const int score = text_score(text);
                if (score > best_score) {
                    best_score = score;
                    best_text = text;
                }
            }
        }
    }

    return ascii_sum(best_text);
}

bool run_checkpoints() {
    const std::string key = "abc";
    const std::string plain = "hello world";
    std::vector<int> cipher;
    cipher.reserve(plain.size());
    for (std::size_t i = 0; i < plain.size(); ++i) {
        cipher.push_back(static_cast<unsigned char>(plain[i]) ^ key[i % key.size()]);
    }

    if (decrypt_with_key(cipher, key) != plain) {
        std::cerr << "Checkpoint failed for XOR roundtrip" << '\n';
        return false;
    }

    return true;
}

}  // namespace

int main(int argc, char** argv) {
    Options options;
    if (!parse_arguments(argc, argv, options)) {
        return 1;
    }

    if (options.run_checkpoints && !run_checkpoints()) {
        return 2;
    }

    try {
        std::cout << solve(options.file) << '\n';
    } catch (const std::exception& ex) {
        std::cerr << ex.what() << '\n';
        return 3;
    }

    return 0;
}

Python

import sys

def parse_cipher_csv(payload):
    values = []
    token = ""
    
    for c in payload:
        if '0' <= c <= '9':
            token += c
        elif token:
            values.append(int(token))
            token = ""
    
    if token:
        values.append(int(token))
    
    return values

def decrypt_with_key(cipher, key):
    out = []
    for i in range(len(cipher)):
        decrypted_char = cipher[i] ^ ord(key[i % len(key)])
        out.append(chr(decrypted_char))
    return ''.join(out)

def text_score(text):
    score = 0
    
    for c in text:
        uc = ord(c)
        if uc < 9 or uc > 126:
            return -1000000
        if c == ' ':
            score += 3
        if ('a' <= c <= 'z') or ('A' <= c <= 'Z'):
            score += 2
    
    def count_occurrences(needle):
        cnt = 0
        pos = 0
        while True:
            pos = text.find(needle, pos)
            if pos == -1:
                break
            cnt += 1
            pos += len(needle)
        return cnt
    
    score += 40 * count_occurrences(" the ")
    score += 25 * count_occurrences(" and ")
    score += 20 * count_occurrences(" of ")
    score += 20 * count_occurrences(" to ")
    
    return score

def ascii_sum(text):
    total = 0
    for c in text:
        total += ord(c)
    return total

def solve(file_path):
    try:
        with open(file_path, 'r') as input_file:
            cipher_data = input_file.read()
    except Exception:
        raise RuntimeError(f"Could not open cipher file: {file_path}")
    
    cipher = parse_cipher_csv(cipher_data)
    
    best_score = float('-inf')
    best_text = ""
    
    for a in range(ord('a'), ord('z') + 1):
        for b in range(ord('a'), ord('z') + 1):
            for c in range(ord('a'), ord('z') + 1):
                key = chr(a) + chr(b) + chr(c)
                text = decrypt_with_key(cipher, key)
                score = text_score(text)
                if score > best_score:
                    best_score = score
                    best_text = text
    
    return ascii_sum(best_text)

def run_checkpoints():
    key = "abc"
    plain = "hello world"
    cipher = []
    
    for i in range(len(plain)):
        cipher.append(ord(plain[i]) ^ ord(key[i % len(key)]))
    
    if decrypt_with_key(cipher, key) != plain:
        return False
    
    return True

def main():
    file_path = "resources/documents/0059_cipher.txt"
    run_checkpoints_flag = True
    
    # Parse command line arguments
    args = sys.argv[1:]
    for arg in args:
        if arg == "--skip-checkpoints":
            run_checkpoints_flag = False
        elif arg.startswith("--file="):
            file_path = arg[7:]
    
    if run_checkpoints_flag and not run_checkpoints():
        sys.exit(2)
    
    try:
        result = solve(file_path)
        print(result)
    except Exception as e:
        sys.stderr.write(str(e) + "\n")
        sys.exit(3)

if __name__ == "__main__":
    main()

Java

import java.io.*;
import java.nio.file.*;
import java.util.*;

public class Euler59 {

    private static class Options {
        String file = "resources/documents/0059_cipher.txt";
        boolean runCheckpoints = true;
    }

    private static boolean parseStringAfterPrefix(String arg, String prefix, StringBuilder value) {
        if (!arg.startsWith(prefix)) {
            return false;
        }
        String tail = arg.substring(prefix.length());
        if (tail.isEmpty()) {
            return false;
        }
        value.setLength(0);
        value.append(tail);
        return true;
    }

    private static boolean parseArguments(String[] args, Options options) {
        for (String arg : args) {
            if ("--skip-checkpoints".equals(arg)) {
                options.runCheckpoints = false;
                continue;
            }
            StringBuilder value = new StringBuilder();
            if (parseStringAfterPrefix(arg, "--file=", value)) {
                options.file = value.toString();
                continue;
            }
            System.err.println("Unknown argument: " + arg);
            return false;
        }
        return true;
    }

    private static List<Integer> parseCipherCsv(String payload) {
        List<Integer> values = new ArrayList<>();
        StringBuilder token = new StringBuilder();

        for (char c : payload.toCharArray()) {
            if (c >= '0' && c <= '9') {
                token.append(c);
            } else if (token.length() > 0) {
                values.add(Integer.parseInt(token.toString()));
                token.setLength(0);
            }
        }
        if (token.length() > 0) {
            values.add(Integer.parseInt(token.toString()));
        }

        return values;
    }

    private static String decryptWithKey(List<Integer> cipher, String key) {
        char[] out = new char[cipher.size()];
        for (int i = 0; i < cipher.size(); i++) {
            out[i] = (char)(cipher.get(i) ^ key.charAt(i % key.length()));
        }
        return new String(out);
    }

    private static int textScore(String text) {
        int score = 0;

        for (char c : text.toCharArray()) {
            int uc = (int)c;
            if (uc < 9 || uc > 126) {
                return -1000000;
            }
            if (c == ' ') {
                score += 3;
            }
            if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
                score += 2;
            }
        }

        int theCount = countOccurrences(text, " the ");
        int andCount = countOccurrences(text, " and ");
        int ofCount = countOccurrences(text, " of ");
        int toCount = countOccurrences(text, " to ");

        score += 40 * theCount;
        score += 25 * andCount;
        score += 20 * ofCount;
        score += 20 * toCount;

        return score;
    }

    private static int countOccurrences(String text, String needle) {
        int count = 0;
        int pos = 0;
        while ((pos = text.indexOf(needle, pos)) != -1) {
            count++;
            pos += needle.length();
        }
        return count;
    }

    private static long asciiSum(String text) {
        long total = 0;
        for (char c : text.toCharArray()) {
            total += (int)c;
        }
        return total;
    }

    private static long solve(String filePath) throws IOException {
        String content = Files.readString(Paths.get(filePath));
        List<Integer> cipher = parseCipherCsv(content);

        int bestScore = Integer.MIN_VALUE;
        String bestText = "";

        for (char a = 'a'; a <= 'z'; a++) {
            for (char b = 'a'; b <= 'z'; b++) {
                for (char c = 'a'; c <= 'z'; c++) {
                    String key = "" + a + b + c;
                    String text = decryptWithKey(cipher, key);
                    int score = textScore(text);
                    if (score > bestScore) {
                        bestScore = score;
                        bestText = text;
                    }
                }
            }
        }

        return asciiSum(bestText);
    }

    private static boolean runCheckpoints() {
        String key = "abc";
        String plain = "hello world";
        List<Integer> cipher = new ArrayList<>();
        for (int i = 0; i < plain.length(); i++) {
            cipher.add((int)plain.charAt(i) ^ key.charAt(i % key.length()));
        }

        if (!decryptWithKey(cipher, key).equals(plain)) {
            System.err.println("Checkpoint failed for XOR roundtrip");
            return false;
        }

        return true;
    }

    public static void main(String[] args) {
        Options options = new Options();
        if (!parseArguments(args, options)) {
            System.exit(1);
        }

        if (options.runCheckpoints && !runCheckpoints()) {
            System.exit(2);
        }

        try {
            System.out.println(solve(options.file));
        } catch (Exception ex) {
            System.err.println(ex.getMessage());
            System.exit(3);
        }
    }
}