solitare

A gleam implementation of Solitare encryption scheme
Log | Files | Refs | README

commit 17fd40322042797c15ced8c4e94d2f9b40601df1
Author: Martin Ashby <martin@ashbysoft.com>
Date:   Sat, 24 Oct 2026 23:43:13 +0100

Initial commit

Diffstat:
A.gitignore | 4++++
AREADME.md | 15+++++++++++++++
Agleam.toml | 20++++++++++++++++++++
Amanifest.toml | 18++++++++++++++++++
Asrc/solitare.gleam | 483+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/solitare_test.gleam | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 744 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/README.md b/README.md @@ -0,0 +1,15 @@ +# solitare + +A gleam implementation of Solitare encryption scheme + +https://www.schneier.com/academic/solitaire/ + +Done as a learning exercise for learning gleam + + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/gleam.toml b/gleam.toml @@ -0,0 +1,20 @@ +name = "solitare" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "", repo = "" } +# links = [{ title = "Website", href = "" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +argv = ">= 1.1.0 and < 2.0.0" + +[dev_dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml @@ -0,0 +1,18 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, + { name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, +] + +[requirements] +argv = { version = ">= 1.1.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/src/solitare.gleam b/src/solitare.gleam @@ -0,0 +1,482 @@ +import gleam/string +import gleam/list +import gleam/result +import gleam/option.{type Option,Some,None} +import gleam/io +import gleam/int +import argv + +pub type NewCardError { + InvalidCard +} +pub type Suit { + Clubs + Diamonds + Hearts + Spades + JokerA + JokerB +} +pub type Card { + Card(suit: Suit, value: Option(Int)) +} + +pub fn new_card(suit: Suit, value: Option(Int)) -> Result(Card, NewCardError) { + case suit { + Clubs | Diamonds | Hearts | Spades -> case value { + Some(value) -> case value { + x if x < 1 || x > 13 -> Error(InvalidCard) + y -> Ok(Card(suit, Some(y))) + } + None -> Error(InvalidCard) + } + JokerA | JokerB -> case value { + Some(_a) -> Error(InvalidCard) + None -> Ok(Card(suit, None)) + } + } +} + +pub fn unkeyed_deck() -> List(Card) { + let makesuit = fn (s: Suit) -> List(Result(Card,NewCardError)) { + list.reverse(int.range(1, 14, [], fn (lst, value) { + [new_card(s, Some(value)), ..lst] + })) + } + let assert Ok(ud) = result.all(list.flatten([ + makesuit(Clubs), + makesuit(Diamonds), + makesuit(Hearts), + makesuit(Spades), + [new_card(JokerA, None), new_card(JokerB, None)] + ])) + ud +} + +pub fn count_cut(deck: List(Card)) -> Result(List(Card),DeckError) { + let len = list.length(deck) + case len { + 0 -> Error(MalformedDeck) + _ -> { + let #(rest, b) = list.split(deck, len-1) + let assert Ok(bottom_card) = list.first(b) + let count = count_cut_value(bottom_card) + let #(top, bottom) = list.split(rest, count) + let res = list.append(bottom, top) + |> list.append([bottom_card]) + Ok(res) + } + } +} + +// (Use the bridge order of suits: clubs, diamonds, hearts, and spades. +// If the card is a club, it is the value shown. If the card is a diamond, +// it is the value plus 13. If it is a heart, it is the value plus 26. +// If it is a spade, it is the value plus 39. Either joker is a 53.) +pub fn count_cut_value(card: Card) -> Int { + case card.suit { + Clubs | Diamonds | Hearts | Spades -> { + let assert Some(value) = card.value + case card.suit { + Clubs -> value + Diamonds -> 13 + value + Hearts -> 26 + value + Spades -> 39 + value + JokerA | JokerB -> panic + } + } + JokerA | JokerB -> 53 + } +} + +pub type DeckError{ + MalformedDeck +} + +pub fn triple_cut(deck: List(Card)) -> Result(List(Card), DeckError) { + let jokers = list.index_map(deck, fn (card, ix) { + case is_joker(card) { + True -> [ix] + False -> [] + } + }) |> list.flatten + case list.length(jokers) { + 2 -> { + let assert Ok(j1) = list.first(jokers) + let assert Ok(j2) = list.last(jokers) + let #(top, rest) = list.split(deck, j1) + let #(mid, bottom) = list.split(rest, j2-j1+1) + Ok(list.append(list.append(bottom, mid), top)) + } + _ -> Error(MalformedDeck) + } +} + +fn is_joker(card: Card) -> Bool { + card.suit == JokerA || card.suit == JokerB +} + +pub fn move_jokers(deck: List(Card)) -> Result(List(Card),DeckError) { + move_joker_a(deck) + |> result.map(fn (d) {move_joker_b(d)}) + |> result.flatten() +} + +// joker A moves back one +pub fn move_joker_a(deck: List(Card)) -> Result(List(Card),DeckError) { + let #(pre, suffix) = list.split_while(deck, fn (card) { card.suit != JokerA }) + let #(jokera, post) = list.split(suffix, 1) + case list.length(jokera) { + 1 -> case list.length(post) { + 0 -> { + let #(a, b) = list.split(pre, 1) + let res = list.append(a, [Card(JokerA, None)]) + |> list.append(b) + Ok(res) + } + _ -> { + let #(a, b) = list.split(post, 1) + let res = list.append(pre, a) + |> list.append([Card(JokerA, None)]) + |> list.append(b) + Ok(res) + } + } + _ -> Error(MalformedDeck) + } +} + +// joker B moves back two +pub fn move_joker_b(deck: List(Card)) -> Result(List(Card),DeckError) { + let #(pre, suffix) = list.split_while(deck, fn (card) { card.suit != JokerB}) + let #(jokerb, post) = list.split(suffix, 1) + case list.length(jokerb) { + 1 -> case list.length(post) { + 0 -> { + let #(a, b) = list.split(pre, 2) + let res = list.append(list.append(a, [Card(JokerB, None)]), b) + Ok(res) + } + 1 -> { + let #(a, b) = list.split(pre, 1) + let res = list.append(a, [Card(JokerB, None)]) + |> list.append(b) + |> list.append(post) + Ok(res) + } + _ -> { + let #(a, b) = list.split(post, 2) + let res = list.append(pre, a) + |> list.append([Card(JokerB, None)]) + |> list.append(b) + Ok(res) + } + } + _ -> Error(MalformedDeck) + } +} + +// it might return None if the output is a joker card, in which case +// this output must be skipped +pub fn read_output(deck: List(Card)) -> Result(Option(Int), DeckError) { + case list.first(deck) { + Ok(first) -> { + let val = count_cut_value(first) + case at(deck, val) { + Some(out_card) -> Ok(output_value(out_card)) + None -> Error(MalformedDeck) + } + } + Error(_) -> Error(MalformedDeck) + } +} + +fn output_value(card: Card) -> Option(Int) { + option.map(card.value, fn (value) { + case card.suit { + Clubs | Hearts -> value + Diamonds | Spades -> 13 + value + _ -> panic // unreachable, other suits have no value + } + }) +} + + +pub fn generate_keystream(deck: List(Card), length: Int) -> Result(#(List(Card), List(Int)),DeckError) { + case generate_keystream_internal(deck, length, []) { + Ok(#(updated_deck, keystream)) -> Ok(#(updated_deck, list.reverse(keystream))) + Error(e) -> Error(e) + } +} + +// Keystream is returned _reversed_ because prepending is cheaper right now +fn generate_keystream_internal(deck: List(Card), length: Int, ks: List(Int)) -> Result(#(List(Card), List(Int)), DeckError) { + case length { + 0 -> Ok(#(deck, ks)) + _ -> { + let updated_deck = move_jokers(deck) + |> flat_map(triple_cut) + |> flat_map(count_cut) + case updated_deck { + Ok(ud) -> { + case read_output(ud) { + Ok(out) -> case out { + Some(value) -> generate_keystream_internal(ud, length-1, [value, ..ks]) + None -> generate_keystream_internal(ud, length, ks) + } + Error(e) -> Error(e) + } + } + Error(e) -> Error(e) + } + } + } + +} + +// TODO use a better data structure than List (which is a singly-linked list) +fn at(l: List(a), i: Int) -> Option(a) { + list.index_fold(l, None, fn (acc, e, ix) { + case acc { + Some(g) -> Some(g) + None -> case ix == i { + True -> Some(e) + False -> None + } + } + }) +} + + +fn flat_map(r: Result(a, b), f: fn (a) -> Result(c, b)) -> Result(c, b) { + case r { + Ok(x) -> case f(x) { + Ok(y) -> Ok(y) + Error(z) -> Error(z) + } + Error(z) -> Error(z) + } +} + + +pub type ToNumberError { + InvalidString +} + +fn encode(s: String) -> Result(Int, ToNumberError) { + case string.uppercase(s) { + "A" -> Ok(1) + "B" -> Ok(2) + "C" -> Ok(3) + "D" -> Ok(4) + "E" -> Ok(5) + "F" -> Ok(6) + "G" -> Ok(7) + "H" -> Ok(8) + "I" -> Ok(9) + "J" -> Ok(10) + "K" -> Ok(11) + "L" -> Ok(12) + "M" -> Ok(13) + "N" -> Ok(14) + "O" -> Ok(15) + "P" -> Ok(16) + "Q" -> Ok(17) + "R" -> Ok(18) + "S" -> Ok(19) + "T" -> Ok(20) + "U" -> Ok(21) + "V" -> Ok(22) + "W" -> Ok(23) + "X" -> Ok(24) + "Y" -> Ok(25) + "Z" -> Ok(26) + _ -> Error(InvalidString) + } +} +fn decode(i: Int) -> Result(String,ToNumberError) { + case i { + 1 -> Ok("A") + 2 -> Ok("B") + 3 -> Ok("C") + 4 -> Ok("D") + 5 -> Ok("E") + 6 -> Ok("F") + 7 -> Ok("G") + 8 -> Ok("H") + 9 -> Ok("I") + 10 -> Ok("J") + 11 -> Ok("K") + 12 -> Ok("L") + 13 -> Ok("M") + 14 -> Ok("N") + 15 -> Ok("O") + 16 -> Ok("P") + 17 -> Ok("Q") + 18 -> Ok("R") + 19 -> Ok("S") + 20 -> Ok("T") + 21 -> Ok("U") + 22 -> Ok("V") + 23 -> Ok("W") + 24 -> Ok("X") + 25 -> Ok("Y") + 26 -> Ok("Z") + _ -> Error(InvalidString) + } +} + +pub type EncryptError { + EEMalformedDeck + EEInvalidString + EEKeystreamError +} +fn map_de(de: DeckError) -> EncryptError { + case de { + MalformedDeck -> EEMalformedDeck + } +} +fn map_ne(ne: ToNumberError) -> EncryptError { + case ne { + InvalidString -> EEInvalidString + } +} + +// Encrypt with a pre-keyed deck +pub fn encrypt(deck: List(Card), plaintext: String) -> Result(String, EncryptError) { + crypt(deck, plaintext, enc) +} + +// Decrypt with a pre-keyed deck +pub fn decrypt(deck: List(Card), ciphertext: String) -> Result(String,EncryptError) { + crypt(deck, ciphertext, dec) +} + +fn crypt(deck: List(Card), text: String, f: fn (Int, Int) -> Int) -> Result(String,EncryptError) { + case string.to_graphemes(text) + |> pad("X", 5) + |> list.map(encode) + |> result.all() { + Ok(encoded) -> { + case generate_keystream(deck, list.length(encoded)) { + Ok(#(_updated_deck, keystream)) -> { + case list.strict_zip(encoded, keystream) { + Ok(zipped) -> { + case list.map(zipped, fn (zz) { + let #(e, k) = zz + f(e, k) + }) + |> list.map(decode) + |> result.all() { + Ok(decode_l) -> Ok(string.join(decode_l, "")) + Error(e) -> Error(map_ne(e)) + } + } + Error(_) -> Error(EEKeystreamError) + } + } + Error(e) -> Error(map_de(e)) + } + } + Error(e) -> Error(map_ne(e)) + } +} + +fn pad(lst: List(String), p: String, x: Int) -> List(String) { + let len = list.length(lst) + case {len % x} { + 0 -> lst + x -> list.append(lst, list.repeat(p, 5 - x)) + } +} + +fn enc(text: Int, key: Int) -> Int { + case text + key { + x if x > 26 -> x - 26 + x -> x + } +} +fn dec(text: Int, key: Int) -> Int { + case text - key { + x if x < 1 -> x + 26 + x -> x + } +} + +pub fn passphrase_key(passphrase: String) -> Result(List(Card), EncryptError) { + string.to_graphemes(passphrase) + |> passphrase_key_internal(unkeyed_deck()) +} + +fn passphrase_key_internal(passphrase_l: List(String), deck: List(Card)) -> Result(List(Card), EncryptError) { + case list.length(passphrase_l) { + 0 -> Ok(deck) + _ -> { + case list.first(passphrase_l) { + Ok(ch) -> { + case encode(ch) { + Ok(enc) -> { + case move_jokers(deck) + |> flat_map(triple_cut) + |> flat_map(count_cut) + |> flat_map(fn (d) {count_cut_x(d, enc)}) { + Ok(updated_deck) -> { + let #(_, rest) = list.split(passphrase_l, 1) + passphrase_key_internal(rest, updated_deck) + } + Error(e) -> Error(map_de(e)) + } + } + Error(e) -> Error(map_ne(e)) + } + } + Error(_) -> panic + } + } + } +} + +fn count_cut_x(deck: List(Card), x: Int) -> Result(List(Card),DeckError) { + let len = list.length(deck) + case len { + 0 -> Error(MalformedDeck) + _ -> { + let #(rest, b) = list.split(deck, len-1) + let assert Ok(bottom_card) = list.first(b) + let #(top, bottom) = list.split(rest, x) + let res = list.append(bottom, top) + |> list.append([bottom_card]) + Ok(res) + } + } +} + +pub fn encrypt_key(key: String, plaintext: String) -> Result(String,EncryptError) { + passphrase_key(key) + |> flat_map(fn (deck){ encrypt(deck, plaintext) }) +} + +pub fn decrypt_key(key: String, ciphertext: String) -> Result(String,EncryptError) { + passphrase_key(key) + |> flat_map(fn (deck){ decrypt(deck, ciphertext) }) +} + +pub fn main() -> Nil { + case argv.load().arguments { + ["encrypt", key, plaintext] -> { + case encrypt_key(key, plaintext) { + Ok(ciphertext) -> io.println(ciphertext) + Error(e) -> io.println_error("error encrypting: " <> string.inspect(e)) + } + } + ["decrypt", key, ciphertext] -> { + case decrypt_key(key, ciphertext) { + Ok(plaintext) -> io.println(plaintext) + Error(e) -> io.println_error("error decrypting: " <> string.inspect(e)) + } + } + _ -> io.println("usage: solitare (encrypt|decrypt) key (cipher|plain)text") + + } + Nil +} +\ No newline at end of file diff --git a/test/solitare_test.gleam b/test/solitare_test.gleam @@ -0,0 +1,204 @@ +import gleam/int +import gleam/list +import gleam/option.{Some,None} +import solitare.{type Card,Card,JokerA,JokerB,Clubs,Hearts,Diamonds,Spades,InvalidCard,new_card,unkeyed_deck,count_cut,count_cut_value,triple_cut,move_joker_a,move_joker_b,move_jokers,read_output,encrypt,decrypt,passphrase_key} +import gleeunit +import gleam/string + +pub fn main() -> Nil { + gleeunit.main() +} + +pub fn new_card_test() { + let cases = [ + #(new_card(JokerA, Some(1)), Error(InvalidCard)), + #(new_card(JokerB, Some(1)), Error(InvalidCard)), + #(new_card(JokerA, None), Ok(Card(JokerA, None))), + #(new_card(JokerB, None), Ok(Card(JokerB, None))), + #(new_card(Clubs, Some(1)), Ok(Card(Clubs, Some(1)))), + #(new_card(Clubs, Some(13)), Ok(Card(Clubs, Some(13)))), + #(new_card(Clubs, None), Error(InvalidCard)), + #(new_card(Clubs, Some(0)), Error(InvalidCard)), + #(new_card(Clubs, Some(14)), Error(InvalidCard)), + #(new_card(Clubs, Some(14)), Error(InvalidCard)), + #(new_card(Diamonds, Some(1)), Ok(Card(Diamonds, Some(1)))), + #(new_card(Hearts, Some(1)), Ok(Card(Hearts, Some(1)))), + #(new_card(Spades, Some(1)), Ok(Card(Spades, Some(1)))), + ] + list.each(cases, fn (tup) { + let actual = tup.0 + let expected = tup.1 + assert actual == expected + }) +} + +pub fn unkeyed_deck_test() { + let ud = unkeyed_deck() + assert list.first(ud) == Ok(Card(Clubs, Some(1))) + assert list.last(ud) == Ok(Card(JokerB, None)) +} + +pub fn count_cut_value_test() { + let cases = [ + #(count_cut_value(Card(JokerB,None)), 53), + #(count_cut_value(Card(JokerA,None)), 53), + #(count_cut_value(Card(Clubs,Some(12))), 12), + #(count_cut_value(Card(Diamonds,Some(5))), 18), + #(count_cut_value(Card(Hearts,Some(3))), 29), + #(count_cut_value(Card(Spades,Some(13))), 52) + ] + list.each(cases, fn (tup) { + assert tup.0 == tup.1 + }) +} + +pub fn count_cut_test() { + let cases = [ + #([1, 4, 2, 7, 6, 3], [7, 6, 1, 4, 2, 3]) + ] + list.each(cases, fn (cas) { + let in = make_clubs_deck(cas.0) + let exp = make_clubs_deck(cas.1) + let act = count_cut(in) + assert act == Ok(exp) + }) +} + +fn make_clubs_deck(lst: List(Int)) -> List(Card) { + list.map(lst, fn (i) { + Card(Clubs, Some(i)) + }) +} + +pub fn triple_cut_test() { + let cases = [ + #("B 5 8 7 1 A 3 9", "3 9 B 5 8 7 1 A"), + #("4 B 5 8 7 1 A 3 9", "3 9 B 5 8 7 1 A 4"), + #("B 5 8 7 1 A", "B 5 8 7 1 A"), + #("3 9 B 5 8 7 1 A", "B 5 8 7 1 A 3 9"), + ] + list.each(cases, fn (cas) { + let #(in_s, exp_s) = cas + let in = make_clubs_deck_s(in_s) + let exp = make_clubs_deck_s(exp_s) + let assert Ok(act) = triple_cut(in) + assert act == exp + }) +} + +fn make_clubs_deck_s(str: String) -> List(Card) { + string.split(str, " ") + |> list.map(fn (s) { + case int.parse(s) { + Ok(value) -> Card(Clubs, Some(value)) + _ -> { + case s { + "A" -> Card(JokerA, None) + "B" -> Card(JokerB, None) + _ -> panic + } + } + } + }) +} + +pub fn move_joker_a_test() { + let cases = [ + #("B 5 8 7 1 A 3 9", "B 5 8 7 1 3 A 9"), + #("B 5 8 7 1 3 A 9", "B 5 8 7 1 3 9 A"), + #("B 5 8 7 1 3 9 A", "B A 5 8 7 1 3 9"), + ] + list.each(cases, fn (cas) { + let #(in_s, exp_s) = cas + let in = make_clubs_deck_s(in_s) + let exp = make_clubs_deck_s(exp_s) + let act = move_joker_a(in) + assert act == Ok(exp) + }) +} + + +pub fn move_joker_b_test() { + let cases = [ + #("B 5 8 7 1 A 3 9", "5 8 B 7 1 A 3 9"), + #("2 5 8 7 1 B 3 9", "2 5 8 7 1 3 9 B"), + #("2 5 8 7 1 3 B 9", "2 B 5 8 7 1 3 9"), + #("2 5 8 7 1 3 9 B", "2 5 B 8 7 1 3 9"), + ] + list.each(cases, fn (cas) { + let #(in_s, exp_s) = cas + let in = make_clubs_deck_s(in_s) + let exp = make_clubs_deck_s(exp_s) + let act = move_joker_b(in) + assert act == Ok(exp) + }) +} + +pub fn move_jokers_test() { + let cases = [ + #("B 5 8 7 1 A 3 9", "5 8 B 7 1 3 A 9"), + #("3 A B 8 9 6", "3 A 8 B 9 6"), + ] + list.each(cases, fn (cas) { + let #(in_s, exp_s) = cas + let in = make_clubs_deck_s(in_s) + let exp = make_clubs_deck_s(exp_s) + let act = move_jokers(in) + assert act == Ok(exp) + }) +} + +pub fn read_output_test() { + let cases = [ + #("3 5 8 7 1 A 3 9", Some(7)), + #("3 4 A B 9 6", None), + ] + list.each(cases, fn (cas) { + let #(in_s, exp) = cas + let in = make_clubs_deck_s(in_s) + let act = read_output(in) + assert act == Ok(exp) + }) +} + +// test case in the doc is wrong as it isn't modulo 26 +//pub fn generate_keystream_test() { +// let deck = unkeyed_deck() +// let assert Ok(#(_updated_deck, keystream)) = generate_keystream(deck, 10) +// assert keystream == [4, 49, 10, 24, 8, 51, 44, 6, 4, 33] +//} + + +pub fn encrypt_test() { + let deck = unkeyed_deck() + let act = encrypt(deck, "AAAAAAAAAA") + assert act == Ok("EXKYIZSGEH") +} + +pub fn decrypt_test() { + let deck = unkeyed_deck() + let act = decrypt(deck, "EXKYIZSGEH") + assert act == Ok("AAAAAAAAAA") +} + +pub fn derive_key_test() { + let assert Ok(deck) = passphrase_key("FOO") + let act = encrypt(deck, "AAAAAAAAAAAAAAA") + assert act == Ok("ITHZUJIWGRFARMW") +} + +pub fn derive_key_2_test() { + let assert Ok(deck) = passphrase_key("CRYPTONOMICON") + let act = encrypt(deck, "SOLITAIRE") + assert act == Ok("KIRAKSFJAN") +} + + + + + + + + + +