Vigenère cipher reference

Build a Vigenère Cipher in Python: Correct Key Alignment and Round-Trip Tests

A Vigenère implementation can produce convincing ciphertext and still be wrong.

The arithmetic is only one part of the job.

A correct program also has to decide:

  • which characters are part of the cipher alphabet;
  • whether spaces and punctuation advance the key;
  • whether letter case is preserved;
  • what happens when the key is longer than the message;
  • how invalid keys are rejected;
  • how decryption handles negative intermediate values;
  • how correctness is tested against results that were not generated by the same code.

Those decisions are where many small implementations become incompatible with one another.

This article builds two explicit Python profiles:

Strict A-Z profile
→ ASCII letters only
→ output normalized to uppercase
→ every message character consumes one key position

Formatting-preserving profile
→ encrypt ASCII letters only
→ preserve uppercase/lowercase
→ copy all other characters unchanged
→ nonletters do not consume key positions

Both use the same Vigenère arithmetic.

They differ only in text policy.

The code is tested against fixed known-answer vectors, edge cases, and round-trip properties. The final suite contains 20 passing tests.

If you simply want to transform text without writing code, use the Vigenère Cipher Decoder and Encoder. This article is about implementing a predictable, testable Python version whose behavior is defined before the first character is encrypted.

Start by defining the cipher profile

The mathematical Vigenère rule is compact.

Using:

A = 0
B = 1
...
Z = 25

encryption is:

Cᵢ = (Pᵢ + Kᵢ) mod 26

and decryption is:

Pᵢ = (Cᵢ - Kᵢ) mod 26

That does not tell us what to do with:

Meet at 9!

Several implementations are possible.

One program may strip everything except letters:

MEETAT

Another may preserve formatting but count the spaces as key positions.

Another may preserve formatting and advance the key only over letters.

All three can use the same formula and still produce different ciphertext.

So the text policy is part of the implementation contract.

The site’s verified Vigenère test vectors make the same distinction between cipher arithmetic and formatting behavior. The Python code below keeps those policies separate rather than hiding them in one ambiguous function.

Profile 1: strict A-Z behavior

The strict functions accept ASCII letters only.

Input may be uppercase or lowercase, but output is normalized to uppercase.

Examples:

ATTACKATDAWN
attackatdawn

are both valid.

This is not:

ATTACK AT DAWN

because the space lies outside the strict alphabet.

The advantage of this profile is that key alignment is trivial.

For message position i:

key position = i mod key_length

There are no skipped characters.

That makes the strict profile ideal for:

  • textbook examples;
  • known-answer vectors;
  • cryptanalysis fixtures;
  • machine-readable tests.

Profile 2: preserve formatting without consuming key positions

Human-facing text often needs spaces, punctuation, digits, and case to survive.

For this article, the preserve profile follows this rule:

ASCII A-Z or a-z:
encrypt/decrypt and advance the key

everything else:
copy unchanged and do not advance the key

So:

Meet at 9!

with key:

KEY

uses the letter sequence:

M e e t a t
K E Y K E Y

The space, digit, and punctuation never consume key letters.

The verified ciphertext is:

Wicd er 9!

This alignment rule must be identical during encryption and decryption.

Why the implementation does not use str.isalpha()

Python’s definition of “alphabetic” is broader than ASCII A-Z.

Characters outside the English alphabet can return True from str.isalpha().

That is useful for general Unicode text processing, but it is not the same thing as the classical 26-letter Vigenère alphabet.

The implementation therefore checks explicitly:

"A" <= ch <= "Z"

or:

"a" <= ch <= "z"

for the formatting-preserving profile.

The Python documentation notes that str.isalpha() includes Unicode letter categories, not only ASCII letters: Python built-in string methods.

This is a small implementation choice with a large effect on key alignment.

Consider:

AéB

If é consumes a key position in one program but not another, every later letter becomes misaligned.

The tested profile in this article produces:

plaintext:  AéB
key:        BC
ciphertext: BéD

The é is copied unchanged and does not advance the key.

Validate the key once

A Vigenère key for these profiles must be:

non-empty
ASCII letters only

Lowercase keys are accepted and normalized logically to uppercase shifts.

Valid:

KEY
lemon
Computer

Invalid:

""
BAD KEY
KEY-2
CAFÉ

The helper function converts the key into numeric shifts:

def _key_shifts(key):
    if not key:
        raise ValueError("Key must not be empty.")

    if any(ch not in ASCII_LETTERS for ch in key):
        raise ValueError("Key must contain ASCII letters A-Z only.")

    return [ord(ch.upper()) - ord("A") for ch in key]

So:

ORBIT

becomes:

14, 17, 1, 8, 19

Validation happens before transformation.

That prevents later failures such as indexing an empty key or silently introducing an undefined symbol mapping.

Use one arithmetic core for encryption and decryption

Encryption and decryption differ only by the sign of the key shift.

This implementation represents that with:

direction = +1

for encryption and:

direction = -1

for decryption.

The strict transform uses:

result.append(
    chr(
        (value + direction * shift) % 26
        + ord("A")
    )
)

Using the same core reduces duplicated logic.

It also makes it harder for encryption and decryption to accidentally adopt different alignment rules.

For integer arithmetic with positive divisor 26, Python’s modulo behavior keeps the result in the desired residue class, so a decryption expression such as:

0 - 1

becomes:

-1 % 26 = 25

which maps back to:

Z

No separate:

if value < 0:
    value += 26

branch is required.

The complete implementation

This is the exact module used by the test suite.

import string

ASCII_UPPER = string.ascii_uppercase
ASCII_LOWER = string.ascii_lowercase
ASCII_LETTERS = ASCII_UPPER + ASCII_LOWER


def _key_shifts(key):
    """Validate an ASCII-letter key and return numeric shifts 0..25."""
    if not key:
        raise ValueError("Key must not be empty.")

    if any(ch not in ASCII_LETTERS for ch in key):
        raise ValueError("Key must contain ASCII letters A-Z only.")

    return [ord(ch.upper()) - ord("A") for ch in key]


def _transform_az(text, key, direction):
    """
    Transform an A-Z-only message.

    The message may use lowercase ASCII letters, but output is normalized
    to uppercase. Spaces, punctuation, digits, and non-ASCII letters are
    rejected.
    """
    if any(ch not in ASCII_LETTERS for ch in text):
        raise ValueError("Strict text must contain ASCII letters A-Z only.")

    shifts = _key_shifts(key)
    result = []

    for index, ch in enumerate(text.upper()):
        value = ord(ch) - ord("A")
        shift = shifts[index % len(shifts)]
        result.append(chr((value + direction * shift) % 26 + ord("A")))

    return "".join(result)


def encrypt_az(text, key):
    """Encrypt an ASCII-letter message and return uppercase A-Z ciphertext."""
    return _transform_az(text, key, +1)


def decrypt_az(text, key):
    """Decrypt an ASCII-letter ciphertext and return uppercase A-Z plaintext."""
    return _transform_az(text, key, -1)


def _transform_preserve(text, key, direction):
    """
    Transform ASCII letters while preserving case and copying every
    non-ASCII-letter character unchanged.

    Nonletters do not consume key positions.
    """
    shifts = _key_shifts(key)
    result = []
    key_index = 0

    for ch in text:
        if "A" <= ch <= "Z":
            base = ord("A")
        elif "a" <= ch <= "z":
            base = ord("a")
        else:
            result.append(ch)
            continue

        value = ord(ch) - base
        shift = shifts[key_index % len(shifts)]
        result.append(chr((value + direction * shift) % 26 + base))
        key_index += 1

    return "".join(result)


def encrypt_preserve(text, key):
    """
    Encrypt ASCII letters, preserve their case, copy other characters,
    and advance the key only when an ASCII letter is transformed.
    """
    return _transform_preserve(text, key, +1)


def decrypt_preserve(text, key):
    """
    Decrypt ASCII letters, preserve their case, copy other characters,
    and advance the key only when an ASCII letter is transformed.
    """
    return _transform_preserve(text, key, -1)

There are four public functions:

encrypt_az()
decrypt_az()

encrypt_preserve()
decrypt_preserve()

That is intentional.

A caller has to choose a text policy explicitly.

There is no hidden “smart” mode that guesses whether punctuation should affect the key.

How strict key alignment works

Consider:

plaintext: ATTACKATDAWN
key:       LEMON

Repeat the key over the message:

ATTACKATDAWN
LEMONLEMONLE

The first positions are:

Position Plain P Key K (P + K) mod 26 Cipher
1 A 0 L 11 11 L
2 T 19 E 4 23 X
3 T 19 M 12 5 F
4 A 0 O 14 14 O
5 C 2 N 13 15 P

The complete known-answer result is:

LXFOPVEFRNHR

The test suite does not derive this expected string by calling the encryption function.

It stores the expected ciphertext independently.

That distinction is essential.

Why round-trip tests alone are not enough

A common test is:

assert decrypt(encrypt(text, key), key) == text

That is useful.

It is not sufficient.

Suppose both encryption and decryption incorrectly advance the key over punctuation.

They may still reverse each other perfectly.

The round trip passes even though the implementation is incompatible with the intended profile.

That is why the test suite contains both:

known-answer tests
and
round-trip tests

Known-answer tests ask:

Did the implementation produce a predetermined external result?

Round-trip tests ask:

Are the two directions inverses of one another under the same policy?

You need both.

Fixed known-answer tests

The strict suite checks several different failure modes.

Classic repeated-key vector

plaintext:  ATTACKATDAWN
key:        LEMON
ciphertext: LXFOPVEFRNHR

This tests ordinary repeated-key alignment.

Longer plaintext and longer key

plaintext:  MEETMEATTHESTORE
key:        COMPUTER
ciphertext: OSQIGXEKVVQHNHVV

This exercises a different period and several key repetitions.

One-letter key

plaintext:  HELLOWORLD
key:        H
ciphertext: OLSSVDVYSK

A one-letter Vigenère key is a Caesar shift.

This catches code that assumes the key must have length greater than one.

Wraparound

plaintext:  ZZZZZ
key:        BBBBB
ciphertext: AAAAA

Every position crosses the end of the alphabet.

This is a direct test of modulo behavior.

Key longer than plaintext

plaintext:  CODE
key:        ORANGE
ciphertext: QFDR

Only the first four key letters are used:

ORAN

A correct implementation does not require the plaintext to reach the end of the key.

Formatting tests need fixed answers too

For the preserve profile:

plaintext:  Meet at 9!
key:        KEY
ciphertext: Wicd er 9!

The test verifies all of these at once:

uppercase remains uppercase
lowercase remains lowercase
spaces are copied
digit is copied
punctuation is copied
nonletters do not consume the key

Another alignment fixture is:

plaintext:  AB-CD
key:        KEY
ciphertext: KF-AN

The letter stream is:

A B C D
K E Y K

not:

A B - C D
K E Y K E

The hyphen does not move the key forward.

The exact test suite

The following is the test file used for the final audit.

import unittest

from vigenere_cipher import (
    decrypt_az,
    decrypt_preserve,
    encrypt_az,
    encrypt_preserve,
)


class StrictAZKnownAnswerTests(unittest.TestCase):
    def test_classic_attack_at_dawn(self):
        self.assertEqual(
            encrypt_az("ATTACKATDAWN", "LEMON"),
            "LXFOPVEFRNHR",
        )

    def test_decrypt_classic_vector(self):
        self.assertEqual(
            decrypt_az("LXFOPVEFRNHR", "LEMON"),
            "ATTACKATDAWN",
        )

    def test_computer_vector(self):
        self.assertEqual(
            encrypt_az("MEETMEATTHESTORE", "COMPUTER"),
            "OSQIGXEKVVQHNHVV",
        )

    def test_single_shift_vector(self):
        self.assertEqual(
            encrypt_az("HELLOWORLD", "H"),
            "OLSSVDVYSK",
        )

    def test_wraparound_vector(self):
        self.assertEqual(
            encrypt_az("ZZZZZ", "BBBBB"),
            "AAAAA",
        )

    def test_key_longer_than_plaintext_uses_prefix(self):
        self.assertEqual(
            encrypt_az("CODE", "ORANGE"),
            "QFDR",
        )

    def test_lowercase_ascii_is_normalized_in_strict_profile(self):
        self.assertEqual(
            encrypt_az("attackatdawn", "lemon"),
            "LXFOPVEFRNHR",
        )


class PreserveFormattingTests(unittest.TestCase):
    def test_spaces_punctuation_and_digit_do_not_consume_key(self):
        self.assertEqual(
            encrypt_preserve("Meet at 9!", "KEY"),
            "Wicd er 9!",
        )

    def test_preserve_round_trip(self):
        text = "Meet at 9!"
        key = "KEY"
        self.assertEqual(
            decrypt_preserve(encrypt_preserve(text, key), key),
            text,
        )

    def test_case_is_preserved(self):
        self.assertEqual(
            encrypt_preserve("Attack At Dawn", "LEMON"),
            "Lxfopv Ef Rnhr",
        )

    def test_punctuation_alignment(self):
        self.assertEqual(
            encrypt_preserve("AB-CD", "KEY"),
            "KF-AN",
        )

    def test_only_nonletters_returns_unchanged_text(self):
        self.assertEqual(
            encrypt_preserve("1234!? —", "KEY"),
            "1234!? —",
        )

    def test_non_ascii_letter_is_preserved_and_does_not_consume_key(self):
        self.assertEqual(
            encrypt_preserve("AéB", "BC"),
            "BéD",
        )


class ValidationTests(unittest.TestCase):
    def test_empty_key_is_rejected(self):
        with self.assertRaises(ValueError):
            encrypt_az("HELLO", "")

    def test_key_with_space_is_rejected(self):
        with self.assertRaises(ValueError):
            encrypt_az("HELLO", "BAD KEY")

    def test_key_with_non_ascii_letter_is_rejected(self):
        with self.assertRaises(ValueError):
            encrypt_az("HELLO", "CAFÉ")

    def test_strict_profile_rejects_spaces(self):
        with self.assertRaises(ValueError):
            encrypt_az("HELLO WORLD", "KEY")

    def test_strict_profile_rejects_non_ascii_letters(self):
        with self.assertRaises(ValueError):
            encrypt_az("CAFÉ", "KEY")


class RoundTripPropertyTests(unittest.TestCase):
    def test_strict_round_trip_cases(self):
        cases = [
            ("A", "A"),
            ("Z", "B"),
            ("HELLOWORLD", "KEY"),
            ("MEETMEATTHESTORE", "COMPUTER"),
            ("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "ORBIT"),
        ]
        for text, key in cases:
            with self.subTest(text=text, key=key):
                cipher = encrypt_az(text, key)
                self.assertEqual(decrypt_az(cipher, key), text.upper())

    def test_preserve_round_trip_cases(self):
        cases = [
            ("Hello, World!", "KEY"),
            ("Meet at 9!", "ORBIT"),
            ("Case-Sensitive Text 123", "LEMON"),
            ("AéB — C", "BC"),
            ("", "KEY"),
        ]
        for text, key in cases:
            with self.subTest(text=text, key=key):
                cipher = encrypt_preserve(text, key)
                self.assertEqual(decrypt_preserve(cipher, key), text)


if __name__ == "__main__":
    unittest.main(verbosity=2)

Run it with:

python -m unittest -v test_vigenere_cipher.py

The verified result is:

test_case_is_preserved ... ok
test_non_ascii_letter_is_preserved_and_does_not_consume_key ... ok
test_only_nonletters_returns_unchanged_text ... ok
test_preserve_round_trip ... ok
test_punctuation_alignment ... ok
test_spaces_punctuation_and_digit_do_not_consume_key ... ok
test_preserve_round_trip_cases ... ok
test_strict_round_trip_cases ... ok
test_classic_attack_at_dawn ... ok
test_computer_vector ... ok
test_decrypt_classic_vector ... ok
test_key_longer_than_plaintext_uses_prefix ... ok
test_lowercase_ascii_is_normalized_in_strict_profile ... ok
test_single_shift_vector ... ok
test_wraparound_vector ... ok
test_empty_key_is_rejected ... ok
test_key_with_non_ascii_letter_is_rejected ... ok
test_key_with_space_is_rejected ... ok
test_strict_profile_rejects_non_ascii_letters ... ok
test_strict_profile_rejects_spaces ... ok

Ran 20 tests
OK

All 20 tests pass.

What the round-trip properties check

The strict property is:

decrypt_az(
    encrypt_az(plaintext, key),
    key
)
=
plaintext.upper()

for valid ASCII-letter messages.

The preserve property is:

decrypt_preserve(
    encrypt_preserve(text, key),
    key
)
=
text

including formatting.

The suite tests cases such as:

Hello, World!
Meet at 9!
Case-Sensitive Text 123
AéB — C
empty plaintext

The empty plaintext case is worth keeping.

A valid key plus empty text should produce:

""

without error.

The invalid case is the reverse:

non-empty or empty text
+
empty key

The key is undefined and is rejected.

Case preservation needs separate bases

ASCII uppercase and lowercase letters occupy different code-point ranges.

The preserve function therefore selects:

base = ord("A")

for uppercase and:

base = ord("a")

for lowercase.

The letter’s offset is calculated relative to that base, transformed modulo 26, and returned to the same case range.

That is why:

Attack At Dawn

with LEMON becomes:

Lxfopv Ef Rnhr

rather than forcing the entire result to uppercase.

The key itself remains case-insensitive.

A formatting character must not increment key_index

This is the most important line of the preserve loop:

else:
    result.append(ch)
    continue

Because continue is reached before:

key_index += 1

a nonletter is copied and the same key position remains waiting for the next ASCII letter.

The loop therefore tracks:

message index

and:

key index

as different concepts.

Using:

for index, ch in enumerate(text):
    shift = key[index % len(key)]

would be wrong for this profile because index includes spaces, punctuation, and digits.

Why unsupported characters are preserved rather than rejected in one profile

There is no universal correct policy for every application.

The strict profile rejects unsupported characters because the whole input is defined as cipher symbols.

The preserve profile copies them because its purpose is readable formatted text.

The important requirement is not choosing one policy forever.

It is documenting the policy and testing it.

For:

AéB

the preserve profile returns:

BéD

under key BC.

The non-ASCII é is not encrypted.

A different application might reject it instead.

That would be a different profile, not necessarily a mathematical error.

Common Python Vigenère mistakes

Advancing the key with every string index

This breaks formatted text as soon as punctuation appears.

Keep a separate key counter.

Using isalpha() for an A-Z cipher

Unicode letters can enter the alignment unexpectedly.

Use an explicit ASCII policy.

Forgetting to validate an empty key

Eventually the code will attempt modulo by the key length or index into an empty list.

Reject it at the boundary.

Testing only encryption

A cipher implementation needs a verified inverse.

Test decryption against a known ciphertext too.

Testing only round trips

The same bug can exist symmetrically in both directions.

Include independent known-answer vectors.

Stripping punctuation in one direction but preserving it in the other

Encryption and decryption must share the same profile.

Incrementing the key before knowing whether a character is transformable

Decide whether the current symbol belongs to the cipher alphabet first.

Then advance the key only if the profile says it should.

Treating the written key length as something the message must reach

A key longer than the plaintext is valid.

The unused suffix simply never enters the keystream.

Known-answer testing and property testing solve different problems

A fixed vector is narrow but authoritative.

For example:

ATTACKATDAWN + LEMON
=
LXFOPVEFRNHR

If the output differs, the implementation is wrong for that profile.

A property test covers many inputs but can be less independent:

decrypt(encrypt(P, K), K) = P

Together they provide much stronger coverage.

The site’s Vigenère practice problems are useful for checking hand calculations, while the test-vector reference is designed specifically for implementation conformance.

The Python suite uses the same philosophy:

fixed answers
+
inverse properties
+
edge cases

This implementation is intentionally not a cracker

The code in this article knows the key.

That boundary keeps it separate from Vigenère Cryptanalysis in Python, which starts from ciphertext and attempts to recover an unknown period and key.

Here the questions are different:

Is the transformation correct?
Is the key aligned correctly?
Are formatting rules deterministic?
Does decryption invert encryption?
Do known vectors match?

Those questions should be settled before the same cipher functions are used inside a cryptanalysis program.

A broken encrypt/decrypt primitive can contaminate every later benchmark.

A compact correctness checklist

Before treating a Python Vigenère implementation as finished, verify:

alphabet mapping
A=0 through Z=25

encryption
(P + K) mod 26

decryption
(C - K) mod 26

key validation
non-empty ASCII letters

text profile
strict or preserve behavior documented

key advancement
explicitly tested around punctuation

case policy
normalized or preserved deliberately

wraparound
tested at Z

known-answer vectors
not generated by the code being tested

round trips
tested across multiple inputs

unsupported symbols
rejected or preserved by explicit policy

If any one of those is undefined, two “Vigenère” programs can produce different results from the same visible input while each appears internally consistent.

The practical implementation rule

The Vigenère formula is short.

The contract around it is what makes code interoperable.

For a strict cryptographic fixture, treat the input as A-Z and make every character consume the next key position.

For formatted user text, decide exactly which symbols are encrypted and increment the key only for those symbols.

Then test the implementation in two directions:

known input
→ known expected output

and:

plaintext
→ encrypt
→ decrypt
→ original plaintext

The first catches compatibility errors.

The second catches inverse-function errors.

Together, they turn a small Python cipher from a demonstration into a reliable implementation.


Written by Marcus Hale

Editorially reviewed by VigenereDecoder.com editorial team

Written by Marcus Hale

Editorially reviewed by VigenereDecoder.com editorial team

Similar Posts