Encryption in 325 Characters or Less with Bennett!
2024-01-23What encryption scheme is this?
def e(s,d,r=""):
p=lambda j,d:53>(k:=d.index(j))and d[:k]+[d[k+1],j]+d[k+2:]
or[d[0],j]+d[1:k];d=p(54,p(54,p(53,d)));l,h=sorted([d.index(53)
,d.index(54)]) d=d[h+1:]+d[l:h+1]+d[:l];b=min(d[-1],53);d=d[b:-1]
+d[:b]+[d[-1]];o=d[min(d[0],53)];return s and(53>o and e(s[1:],d,
r+chr(97+(ord(s[0])-97+o)%26))or e(s,d,r))or r
encrypt=e
See Bennett's post here!
Basically, I was proctoring an informatics tournament to get a free hoodie and some food, and I bumped into my friend Bennett from high school. Naturally, we decided to catch up and golf for nine hours straight1 a few days later :))
Anyways, the answer is Solitaire from the book Cryptonomicon by Neal Stephenson!
I'll give a quick rundown of the algorithm and then we can get to the interesting part: golfing, aka writing a program to do a task using the fewest characters.
Overview
The most important part of the algorithm is generating keystream values. The intention is for these values to be basically random. For each plaintext character, we add in a keystream value and mod by 26, resulting in a new encrypted character. For example, if our plaintext letter is "p" (16), and our keystream value is "k" (11), we would add these to get 27 and mod by 26 to get 1, which corresponds to "a", our new encrypted value.
So how do we go about making these key stream values? There are roughly 5 steps, and all you need is a deck of cards. Number the 52 regular cards 1 to 52. We'll also use the jokers, which we call A and B. You can think of them as cards 53 and 54. Let's walk through the algorithm as we condense this code. We roughly started with this code for generating keystream values2.
Moving the jokers
The first step is to swap the A joker with the card under it. If it's the last card, instead place it second from the top.
Representing the deck as a list of integers from 1 to 54, we have the following helpers:
# get the index of a card
index=lambda l, x: l.index(x)
# push the card at index i down normally
push=lambda l, i: l[:i] + [l[i + 1]] + [l[i]] + l[i + 2:]
Combining these two, we have the following to cycle the A joker:
aidx = index(deck, A)
deck = push(deck, aidx) if aidx != DECKSIZE - 1 else (
[deck[0], deck[aidx], *deck[1:aidx]])
The last line handles the edge case where the joker is already on the bottom. There are already some easy pickings: just shorten the variable names!
# aidx -> a, deck -> d, DECKSIZE - 1 -> 53
a = index(d, A)
d = push(d, a) if a != 53 else (
[d[0], d[a], *d[1:a]])
We can also eliminate some spaces, but let's keep them now for readability.
It's time for our first real optimization: better ternaries. This transformation saves one character per ternary:
x if cond else y
cond and x or y
Our final form for Step 1 will then be this for now:
a = index(d, A)
d = a != 53 and push(d, a) or [d[0], d[a], *d[1:a]]
The next step is to move the B joker in the same way twice, and this is where the fun really starts. We have something like:
index=lambda l, x: l.index(x)
push=lambda l, i: l[:i] + [l[i + 1]] + [l[i]] + l[i + 2:]
a = index(d, A)
d = a != 53 and push(d, a) or [d[0], d[a], *d[1:a]]
b = index(d, B)
d = b != 53 and push(d, b) or [d[0], d[b], *d[1:b]]
b = index(d, B)
d = b != 53 and push(d, b) or [d[0], d[b], *d[1:b]]
Since the motif [d[0], d[b], *d[1:b]]
is used so many times, let's put it
in a lambda
. Let's condense our helpers now:
index=lambda l,x:l.index(x)
push=lambda l,i:l[:i]+[l[i+1]]+[l[i]]+l[i+2:]
cycle=lambda l,i:[l[0],l[b],*l[1:b]]
These look pretty small, but all three will turn out to be suboptimal! Notice
in push
that we wrap two elements in []
to turn them into lists and then
concatenate them. This can be better done with a comma:
# vvv
push=lambda l,i:l[:i]+[l[i+1]]+[l[i]]+l[i+2:]
# v
push=lambda l,i:l[:i]+[l[i+1],l[i]]+l[i+2:]
Since these two elements are next to each other in the original list, we tried to
use slicing like: l[i-1:i+1:-1]
, but this was no better. Let's take a look
at cycle
now. Firstly, the *
is suboptimal and we can just use +
, while
creating a new list for the first two elements:
cycle=lambda l,i:[l[0],l[b],*l[1:b]]
# v v
cycle=lambda l,i:[l[0],l[b]]+l[1:b]
But it doesn't stop there! We actually can use slicing here. We can actually
slice with a step size of b-1
to get the 0
th element and the b
th. Since
b
is the last index, we won't get any extra elements.
cycle=lambda l,i:[l[0],l[b]]+l[1:b]
cycle=lambda l,i:l[0:b+1:b]+l[1:b]
But it gets even better! We can actually just omit the indices:
cycle=lambda l,i:[l[0],l[b],*l[1:b]]
cycle=lambda l,i:l[::b]+l[1:b]
And we've not eliminated a full 5 characters from cycle
! This is actually
pretty massive.
Notice how both cycle
and push
take an index? We can save some space by only having
that parameter occur once, if we fuse the two functions into one that takes the joker as
a parameter and moves it:
# vvvvvvvvv v
p=lambda j:53>(k:=i(j))and d[:k]+[d[k+1],j]+d[k+2:]or d[::k]+d[1:k]
We also introduce the walrus operator to cache the result of a computation
while inside an expression. (x:=expr)
evaluates to expr
, and also defines
the binding x
. Super useful, as we can now use i(j)
in multiple places without
writing it out. Also notice that we replace d[k]
with j
, since k
is by definition
the index of j
. Instead of using checking if the joker index is != 53
,
we check if it's < 53
, saving one character. Finally, we've also eliminated the
l
parameter, and just use the global variable d
which represents the deck.
Now, all it takes to move the jokers is:
i=lambda x:d.index(x)
p=lambda j:53>(k:=i(j))and d[:k]+[d[k+1],j]+d[k+2:]or d[::k]+d[1:k]
# A = 53, B = 54
d=p(A)
d=p(B)
d=p(B)
We were really happy with this push
function. It's just so compactified and *mwah*
chef's kiss.
Part Two: Cuts
The next step is to perform a triple cut. This is best explained visually. Suppose our deck looks like this (with some cards omitted): 1 2 3 A 4 5 6 B 7 8 9. We just swap the portions to the outsides of the jokers, so we get 7 8 9 A 4 5 6 B 1 2 3. Notice that the jokers don't move, nor do the cards between them.
We first to figure out which joker is to the left (low), and which one is to the
right (high)3. We started by just using python's sorted
function, like this:
l, h = sorted([i(A), i(B)])
d = [*d[h+1:], *d[l:h + 1], *d[:l]]
Since sorted
seems kinda long, we also tried some other methods for ordering the
two joker indices. One failed (but cool) attempt uses xor:
a=i(A)
b=i(B)
l=min(a,b)
h=l^a^b
but it's still quite a fair bit longer. Let's compress and improve the main triple cutting action now. Once again, we immediately ditch the stars:
d=[*d[h+1:],*d[l:h+1],*d[:l]]
d=d[h+1:]+d[l:h+1]+d[:l]
And honestly, that's about as short as we could get the triple cut.
The next cut is a count cut. Look at the bottom card of the deck, and move that many cards from the top to just above it. Count the jokers as having value 53. Here's an example
vvvvvvvvvvv last card is 5, so we move the top 5 cards
[2 4 6 8 1] 3 5 -> 3 [2 4 6 8 1] 5
The code to execute this cut is also pretty simple (b
means bottom):
b = min(d[-1], A)
d = [*d[b:-1], *d[:b], d[-1]]
We have to min
the bottom card with the value of the A joker, since
we also want to count the B joker as 53. Let's quickly eliminate the stars,
saving one character:
d=[*d[b:-1],*d[:b],d[-1]]
d=d[b:-1]+d[:b]+[d[-1]]]
Now Bennett was thinking, what if we don't take the min
, and pretend the deck is
53 cards long, then just add back the last card at the end? Doing this, we can
eliminate quite a few characters!
b=min(d[-1],A)
d=[*d[b:-1],*d[:b],d[-1]]
b=d[-1]
d=d[b:-1]+d[:-1][:b]+[b]
# ^^^
Since we now have b
defined as d[-1]
, we can replace the last term with b
!
Now both lines are shorter! This was pretty sick.
Extracting the Keystream Value
Finally, we just use the value of top card as an index. If it's not a joker, we return the deck at that index, otherwise, we recurse and execute the entire keystream procedure again. In other characters:
t=min(d[0],A)
return d[t]<A and d[t]or keystream()
Our program now looks like this (with some omissions for clarity):
i=lambda x:d.index(x)
p=lambda j:53>(k:=i(j))and d[:k]+[d[k+1],j]+d[k+2:]or d[::k]+d[1:k]
def keystream():
global d, A, B
d=p(A)
d=p(B)
d=p(B)
# triple cut
lo,hi=sorted([i(A),i(B)])
d=d[hi+1:]+d[lo:hi+1]+d[:lo]
# count end
b=min(d[-1],A)
d=d[b:-1]+d[:b]+[d[-1]]
# return value
t=min(d[0],A)
return d[t]<A and d[t]or keystream()
Quite a bit shorter!
Actual Encryption
To encrypt some text, we now have to combine the keystream and plain text. We started with:
for c in "<text>":
enc = 96 + ((ord(c) - 96 + keystream()) % 26 or 26)
result += chr(enc)
Note that 96 is 1 less than the ASCII value of 'a'. This snippet also has a bug!
If our plaintext + keystream is 0 mod 26, we'll get chr(96) = 'z'
. We can
fix this easily by using 97 instead of 96 as our offset. With that out of the
way, we can now define a nice encrypt
function:
encrypt=lambda s:"".join(chr(97+(ord(c)-97+k())%26)for c in s)
At this point, we were at just under 350 characters, IIRC, but we still wanted
to do better. There were a few small changes, like adding and deleting some
variables, but our biggest concerns were the return
and global
keywords used
in our keystream function.
Functional Programming
For some reason, a few days before, I had implemented this algorithm in Haskell. Since Haskell is purely functional, it has no mutation, so global variables are obviously out of style. Sort of vibing off of this, I thought maybe we could do some multiparameter tail-recursive fold stuff to update the state of the deck between calls to keystream. And it turns out you can! The basic idea is to produce the new inputs to the encrypt function while encrypting a character.
The Haskelly way to do things would be something like this
encrypt(string, deck) =
# v encrypt character v recursively encrypt the rest
combine(string[0], keystream(deck)) + encrypt(string[1:], newdeck)
# ^ combine the two parts
where the keystream function returns the new deck! We basically set up a function signature like this:
encrypt(string, deck, encrypted) -> string
and reverse engineered it. I'll just show what the result is:
def e(s,d,t=""):
# move jokers
d=p(54,p(54,p(53,d)))
# triple cut
l,h=sorted([d.index(53),d.index(54)])
d=d[h+1:]+d[l:h+1]+d[:l]
# count cut
b=d[-1]
d=d[b:-1]+d[:-1][:b]+[b]
# get the keystream value, or "offset"
o=d[d[0]%54or-1]
# nested ternary madness
return s and (
o<53 and e(s[1:],d,t+chr((ord(s[0])-97+o)%26+97))
or e(s,d,t)
) or t
encrypt=e
Firstly, we define e
so that we can recurse without having to pay the cost
of typing out encrypt
, then just "rename" it to e
at the end.
The parameter s
represents the input string, d
represents the deck, and
t
... I don't remember why it's called that ... is our encrypted text. I think
we wanted to call it e
for encryption but that wasn't an option.
Ok, so this ternary monster. First, we check if s
is empty. If it is, it's
falsy so we'll take the second branch and return t
, or the fully encrypted
string. Otherwise, we go into the second ternary.
Here, we check if the keystream value was a joker. It it was, we recurse with
the same s
and t
, since we haven't gotten a valid keystream value, but
notice that d
has changed. If we do have a valid keystream value, we recurse
on the string with the first character chopped off, our new deck, and an
encrypted string with one character freshly added.
Altogether this somehow saved 12 characters! I have no idea how, but it's elegant and arcane and completely inscrutable so I love it.
That's basically it! There were probably some other small optimizations we did that aren't worth covering here. Overall, it's so satisfying just seeing the code shrink and shrink. You should give golfing a try :)
Check out Bennett's blog for his writeup and GitHub for other insane code he's written.
And lastly, you can see the code in its full glory on GitHub and right here:
p=lambda j,d:53>(k:=d.index(j))and d[:k]+[d[k+1],j]+d[k+2:]or d[::k]+d[1:k]
def e(s,d,t=""):d=p(54,p(54,p(53,d)));l,h=sorted([d.index(53),d.index(54)]);d=d[h+1:]+d[l:h+1]+d[:l];b=d[-1];d=d[b:-1]+d[:-1][:b]+[b];o=d[d[0]%54or-1];return s and(o<53and e(s[1:],d,t+chr((ord(s[0])-97+o)%26+97))or e(s,d,t))or t
encrypt=e
Thanks for reading, and I'll see you next time!
1 Ok I lied; there was a small burrito break.
2 Starter code
index=lambda l, x: l.index(x)
push=lambda l, i: l[:i] + [l[i + 1]] + [l[i]] + l[i + 2:]
def keystream():
global deck, A, B, DECKSIZE
# cycle ajoker
aidx = index(deck, A)
deck = push(deck, aidx) if aidx != DECKSIZE - 1 else (
[deck[0], deck[aidx], *deck[1:aidx]])
# cycle bjoker
bidx = index(deck, B)
deck = push(deck, bidx) if bidx != DECKSIZE-1 else (
[deck[0], deck[bidx], *deck[1:bidx]])
bidx = index(deck, B)
deck = push(deck, bidx) if bidx != DECKSIZE-1 else (
[deck[0], deck[bidx], *deck[1:bidx]])
# triple cut
[lo, hi] = sorted([index(deck, A), index(deck, B)])
deck = [*deck[hi+1:], *deck[lo:hi + 1], *deck[:lo]]
# count end
bot = min(deck[-1], A)
deck = [*deck[bot:-1], *deck[:bot], deck[-1]]
# return value
top = min(deck[0], A)
return deck[top] if deck[top] < A else keystream()
3 I just realized this is contratry to our intuition about a face down deck. The first card is the highest.