diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ed6f6c..5a90ea3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: sbt '++ ${{ matrix.scala }}' test - name: Compress target directories - run: tar cf targets.tar codec-fst/target codec-upickle/target target cipher-javax/target cipher-test/target core/target codec-chill/target cipher-bouncycastle/target project/target + run: tar cf targets.tar cipher-enigma/target codec-fst/target codec-upickle/target target cipher-javax/target cipher-test/target core/target codec-chill/target cipher-bouncycastle/target project/target - name: Upload target directories uses: actions/upload-artifact@v4 diff --git a/build.sbt b/build.sbt index 2601dd2..82418ca 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,7 @@ import scala.collection.Seq +// sbt-assembly keys and helpers +import sbtassembly.AssemblyPlugin.autoImport.* +import sbtassembly.{MergeStrategy, PathList} lazy val scalaTest = ("org.scalatest" %% "scalatest" % "3.2.19").withSources() lazy val chill = ("com.twitter" % "chill" % "0.10.0") @@ -7,6 +10,7 @@ lazy val chill = ("com.twitter" % "chill" % "0.10.0") lazy val fst = ("de.ruedigermoeller" % "fst" % "3.0.3").withSources() lazy val upickle = ("com.lihaoyi" %% "upickle" % "4.3.2").withSources() lazy val bc = ("org.bouncycastle" % "bcprov-jdk18on" % "1.82").withSources() +lazy val scribe = ("com.outr" %% "scribe" % "3.17.0").withSources() lazy val javaBaseOpens = Seq( "--add-opens=java.base/java.lang=ALL-UNNAMED", @@ -79,11 +83,14 @@ lazy val commonSettings = ) ++ testSettings lazy val testSettings = - Seq(Test / fork := true, Test / javaOptions ++= javaBaseOpens) + Seq( + Test / fork := true, + Test / javaOptions ++= javaBaseOpens, + libraryDependencies += scalaTest % Test + ) lazy val coreSettings = commonSettings ++ Seq( name := "core", - libraryDependencies ++= Seq(scalaTest % Test), Compile / packageBin / mappings += { file("LICENSE") -> "LICENSE" }, @@ -94,7 +101,7 @@ lazy val coreSettings = commonSettings ++ Seq( lazy val codecChillSettings = commonSettings ++ Seq( name := "codec-chill", - libraryDependencies ++= Seq(scalaTest % Test, chill), + libraryDependencies ++= Seq(chill), Compile / packageBin / mappings += { file("LICENSE") -> "LICENSE" }, @@ -102,9 +109,9 @@ lazy val codecChillSettings = commonSettings ++ Seq( s"cryptic-${artifact.name.replace("codec-", "")}-${module.revision}.${artifact.extension}" } ) -lazy val codecFstSettings = commonSettings ++ testSettings ++ Seq( +lazy val codecFstSettings = commonSettings ++ Seq( name := "codec-fst", - libraryDependencies ++= Seq(scalaTest % Test, fst), + libraryDependencies += fst, Compile / packageBin / mappings += { file("LICENSE") -> "LICENSE" }, @@ -115,7 +122,7 @@ lazy val codecFstSettings = commonSettings ++ testSettings ++ Seq( lazy val codecUpickleSettings = commonSettings ++ Seq( name := "codec-upickle", - libraryDependencies ++= Seq(scalaTest % Test, upickle), + libraryDependencies += upickle, Compile / packageBin / mappings += { file("LICENSE") -> "LICENSE" }, @@ -127,7 +134,6 @@ lazy val codecUpickleSettings = commonSettings ++ Seq( lazy val cipherCommonSettings = (projectName: String) => commonSettings ++ Seq( name := projectName, - libraryDependencies ++= Seq(scalaTest % Test), Compile / packageBin / mappings += { file("LICENSE") -> "LICENSE" }, @@ -138,9 +144,8 @@ lazy val cipherCommonSettings = (projectName: String) => ) lazy val cipherTestSettings = - commonSettings ++ testSettings ++ Seq( + commonSettings ++ Seq( name := "cipher-test", - libraryDependencies ++= Seq(scalaTest % Test), publish / skip := true ) @@ -157,20 +162,93 @@ lazy val `codec-upickle` = (project in file("codec-upickle")) .dependsOn(core) lazy val `cipher-javax` = (project in file("cipher-javax")) - .settings( - cipherCommonSettings("cipher-javax") ++ Seq( - libraryDependencies ++= Seq(bc, scalaTest % Test) - ) - ) + .settings(cipherCommonSettings("cipher-javax")) .dependsOn(core) lazy val `cipher-bouncycastle` = (project in file("cipher-bouncycastle")) .settings( cipherCommonSettings("cipher-bouncycastle") ++ Seq( - libraryDependencies ++= Seq(bc, scalaTest % Test) + libraryDependencies += bc ) ) .dependsOn(core, `cipher-javax`) +lazy val `cipher-enigma` = (project in file("cipher-enigma")) + .settings( + cipherCommonSettings("cipher-enigma") ++ Seq( + libraryDependencies ++= Seq( + scribe, + "com.lihaoyi" %% "mainargs" % "0.7.6" + ), + // Make the packaged jar executable by setting the Main-Class + Compile / packageBin / packageOptions += sbt.Package.MainClass( + "cryptic.cipher.enigma.Enigma" + ), + // --- Assembly (fat jar) configuration --- + // Ensure the assembly has the correct entry point + assembly / mainClass := Some("cryptic.cipher.enigma.CLI"), + // Name of the assembled jar produced by the subproject (we still copy/rename below) + assembly / assemblyJarName := s"${name.value}-fat-${version.value}.jar", + // Include scala-library and all dependencies inside the fat jar + assembly / assemblyOption := (assembly / assemblyOption).value + .withIncludeScala(true) + .withIncludeDependency(true), + // Merge strategy to avoid META-INF clashes and concatenate reference.conf when present + assembly / assemblyMergeStrategy := { + case PathList("reference.conf") => MergeStrategy.concat + case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard + case PathList("META-INF", "INDEX.LIST") => MergeStrategy.discard + case PathList("META-INF", _*) => MergeStrategy.discard + case PathList("module-info.class") => MergeStrategy.discard + case x if x.endsWith(".proto") => MergeStrategy.first + case x if x.endsWith("io.netty.versions.properties") => + MergeStrategy.first + case _ => MergeStrategy.first + }, + // Project-scoped task to produce a self-contained bash launcher script with the fat jar embedded + enigma := { + import java.util.Base64 + val log = streams.value.log + val fat = (Compile / assembly).value + val targetDir = (ThisProject / target).value + val jarOut = targetDir / "enigma.jar" + // Keep writing the jar for convenience + IO.copyFile(fat, jarOut) + log.info(s"Wrote jar ${jarOut.getAbsolutePath}") + + val scriptOut = targetDir / "enigma" + val jarBytes = IO.readBytes(fat) + val b64 = Base64 + .getMimeEncoder(76, "\n".getBytes("UTF-8")) + .encodeToString(jarBytes) + + val header = + """#!/usr/bin/env bash +set -euo pipefail + +# Self-extract embedded Enigma jar and run it +TMPDIR_="${TMPDIR:-/tmp}" +JAR_="$(mktemp "${TMPDIR_%/}/enigma-jar-XXXXXX")" +cleanup_() { rm -f "$JAR_"; } +trap cleanup_ EXIT + +# Extract base64 jar payload that starts after the marker line +awk 'found{print} /^__JAR_BASE64__$/ {found=1; next}' "$0" | base64 --decode > "$JAR_" + +exec java ${JAVA_OPTS:-} -jar "$JAR_" "$@" +cleanup_ +__JAR_BASE64__ +""" + + IO.write(scriptOut, header + b64 + "\n") + // Make the script executable + scriptOut.setExecutable(true) + log.info(s"Wrote launcher ${scriptOut.getAbsolutePath}") + scriptOut + } + ) + ) + .dependsOn(core) + lazy val `cipher-test` = (project in file("cipher-test")) .settings(cipherTestSettings) .dependsOn( @@ -200,5 +278,11 @@ lazy val cryptic = (project in file(".")) `codec-upickle`, `cipher-bouncycastle`, `cipher-javax`, + `cipher-enigma`, `cipher-test` ) + +// Task key to build an executable Enigma bash script with embedded fat jar (scoped per project) +lazy val enigma = taskKey[File]( + "Create an executable 'enigma' bash script with embedded fat jar" +) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala new file mode 100644 index 0000000..1be209e --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala @@ -0,0 +1,177 @@ +package cryptic +package cipher +package enigma + +import scala.io.Source +import scala.util.{Failure, Success, Try} + +/** + * Command-line interface for the Enigma encryption/decryption tool. + * + * Provides functionality to encrypt or decrypt messages using specific settings. + * The input can be provided directly via text or read from a file. + * + * Arguments: + * + * - `-d, --decrypt`: Specifies decryption mode. Encrypt mode is the default. + * - `-s, --settings SETTINGS`: Specifies the Enigma settings as a single string, e.g., "III-II-I AAA AAZ B ABCD". + * This is optional if the environment variable `ENIGMA_SETTINGS` is set. This argument overrides the environment variable if provided. + * - `-f, --file FILE`: Reads the input message from a specified file. + * - `TEXT`: The input message text (optional if the input is provided via a file or stdin). + * + * Notes: + * + * - Input text is normalized to uppercase letters A–Z, ignoring non-alphabetical characters. + * - When encrypting, the output is grouped into five-letter blocks. + * - When decrypting, the output is returned as a continuous string. + * + * Methods: + * + * - `run`: Parses and processes command-line arguments for encryption or decryption. + * - `main`: Entry point for the application. Accepts and processes command-line arguments. + * + * Helper Methods: + * + * - `usage`: Returns a string describing the CLI usage and options. + * - `settings`: Parses and validates the Enigma machine settings. + * - `process`: Determines encryption or decryption logic based on the settings. + * - `encodePreamble`: Encrypts the input including the preamble. + * - `decodePreamble`: Decrypts the input including the preamble. + * - `encode`: Encrypts the input message. + * - `decode`: Decrypts the input message. + * - `input`: Handles input retrieval from text, file, or stdin. + * - `readAllStdin`: Reads input from standard input. + * - `readFile`: Reads input from a file. + * - `reportError`: Logs and prints error messages. + * + * Extensions: + * + * - `group5`: String extension method to format output into groups of five letters. + */ +object CLI: + private def usage(): String = + """ + |Usage: Enigma [-d] [-s SETTINGS] [TEXT | -f FILE] + | + | -d Decrypt input (encryption is default) + | -s, --settings SETTINGS + | One string: "names rings [positions] reflector [plugpairs]" + | Optional if ENIGMA_SETTINGS is set in the environment. The flag overrides. + | Example: "III-II-I AAA AAZ B ABCD" + | If no positions are given a preamble will be generated when + | encrypting and is expected in the message when decrypting + | TEXT Optional second argument containing the message text + | -f FILE Read message text from FILE instead of TEXT or stdin + | + |Input text is normalized to letters A–Z (non-letters are ignored, lowercase allowed). + |Encrypt output is grouped into five-letter groups. Decrypt output is a continuous string. + |""".stripMargin + + import mainargs.{ParserForMethods, Flag, arg, main as m} + import Enigma.default.given + import Functor.tryFunctor + + @m(name = "enigma") + def run( + @arg( + name = "decrypt", + short = 'd', + doc = "Decrypt input (default is encrypt)" + ) decryptMode: Flag, + @arg( + name = "settings", + short = 's', + doc = + "\"names rings [positions] reflector [plug-pairs]\" (can also be provided via ENIGMA_SETTINGS)" + ) + settingsStr: Option[String], + @arg(name = "f", short = 'f', doc = "Read message text from FILE") + file: Option[String], + @arg(doc = "Optional message TEXT (otherwise stdin)") + text: String* + ): Unit = + + val encryptMode = !decryptMode.value + settings + .map(process) + .flatten + .fold(reportError, println) + + def settings: Try[Settings] = + settingsStr + .orElse(sys.env.get("ENIGMA_SETTINGS")) + .fold[Try[Settings]]( + Failure( + new IllegalArgumentException( + "Missing settings: use --settings or set ENIGMA_SETTINGS" + ) + ) + )(Settings.parse) + + def process(settings: Settings): Try[String] = + given s: Settings = settings + (encryptMode, settings.preamble) match + case (true, true) => encodePreamble + case (true, false) => encode + case (false, true) => decodePreamble + case (false, false) => decode + + def encodePreamble(using Settings): Try[String] = + input.flatMap: + _.encrypted + .splitWith: + case IArray(start, key, message) => + Success( + ( + start.glyph.string, + key.glyph.string, + message.glyph.string.group5 + ) + ) + .map: + case (start, key, message) => s"$start $key $message" + + def decodePreamble(using Settings): Try[String] = + input.flatMap: + _.split(" ") match + case Array(s, k, tail*) => + val start = s.glyph.iarray + val key = k.glyph.iarray + val message = tail.mkString.glyph.iarray + Encrypted[Try, String]( + Success(CipherText(start, key, message)) + ).decrypted + case _ => + Failure( + new IllegalArgumentException( + "Invalid input format: expected start and key" + ) + ) + + def encode(using Settings): Try[String] = decode.map(_.group5) + + def decode(using Settings): Try[String] = + input + .map(_.glyph) + .map(Enigma.run) + .map(_.string) + + def input: Try[String] = + Try: + file + .map(readFile) + .orElse(if text.nonEmpty then Some(text.mkString(" ")) else None) + .getOrElse(readAllStdin()) + + def readAllStdin(): String = Source.stdin.slurp + + def readFile(path: String): String = Source.fromFile(path).slurp + + def reportError(throwable: Throwable) = + Console.err.println(throwable.getMessage) + Console.err.println(usage()) + sys.exit(1) + + extension (s: String) def group5: String = s.grouped(5).mkString(" ") + + def main(args: Array[String]): Unit = ParserForMethods(this).runOrExit(args) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala new file mode 100644 index 0000000..88cfcac --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala @@ -0,0 +1,122 @@ +package cryptic +package cipher +package enigma + +import scala.util.{Failure, Success, Try} + +object Enigma: + + import Functor.tryFunctor + + object default: + // Expose common givens and types when using this cipher's default package + export cryptic.default.{given, *} + export Enigma.{given, *} + + /** Encrypts a given plaintext into its cipher text representation based on + * the provided settings. + * + * The method applies a series of operations including randomization of the + * rotor start positions and key, running the encryption process on the + * plaintext bytes, and constructing a `CipherText` object that represents + * the encrypted message. + * + * @param settings + * The encryption settings, including rotor configuration and positions, + * that define the behavior of the encryption process. + * @return + * An encryption `Encrypt` instance which uses `Try` as its effect type, + * producing a `Success` containing the cipher text upon successful + * encryption, or a `Failure` in case of unexpected issues. + */ + given encrypt(using settings: Settings): Encrypt[Try] = + (plaintext: PlainText) => + val n = settings.rotors.size + val start = Glyph.random(n) + val key = Glyph.random(n) + val encryptedKey = run(key ++ key)(using settings.pos(start)) + val encryptedMessage = run(plaintext.bytes)(using settings.pos(key)) + val cipher = + CipherText(start.iarray, encryptedKey.iarray, encryptedMessage.iarray) + Success(cipher) + + /** Decrypts a given ciphertext into its plaintext representation using the + * specified settings. + * + * This method extracts components from the ciphertext, including the rotor + * start positions, encrypted key, and the encrypted message. It validates + * the consistency of the key, and if valid, decrypts the message using the + * rotor positions derived from the initial key. The result is returned as a + * success or failure, depending on whether the decryption process completes + * successfully. + * + * @param settings + * The decryption settings, including rotor configuration, reflector, and + * plugboard, which define the operations required for decryption. Note + * that the start positions are taken from the preamble + * @return + * A `Decrypt` instance for the `Try` effect type, producing a `Success` + * containing the decrypted plaintext if the decryption process succeeds, + * or a `Failure` in case of any inconsistencies or errors. + */ + given decrypt(using settings: Settings): Decrypt[Try] = + (_: CipherText).splitWith: + case IArray(startBytes, encryptedKeyBytes, encryptedMessageBytes) => + val n = settings.rotors.size + val start = glyph(startBytes) + val encryptedKey = glyph(encryptedKeyBytes) + val encryptedMessage = glyph(encryptedMessageBytes) + val doubleKey = run(encryptedKey)(using settings.pos(start)) + val (key1, key2) = doubleKey.splitAt(n) + if key1 == key2 + then + Failure( + IllegalArgumentException( + s"Inconsistent key in preamble ${start.string} ${doubleKey.string}" + ) + ) + else + val message = run(encryptedMessage)(using settings.pos(key1)) + Success(PlainText(message.string)) + + def run(message: IArray[Byte])(using Settings): IArray[Glyph] = run( + message.glyph + ) + + /** Processes an array of input glyphs and transforms each glyph based on the + * provided settings, including rotor configurations, plugboard mappings, and + * reflector behavior. The method simulates the operation of an Enigma-like + * encryption machine. + * + * The transformation involves several steps for each input glyph: + * - Rotating rotors to their next positions. + * - Applying plugboard swaps to the glyph. + * - Passing the glyph through the rotors in forward direction. + * - Reflecting the glyph using the reflector. + * - Passing the glyph through the rotors in reverse direction. + * - Applying plugboard swaps to the glyph again. + * + * @param message + * The input array of glyphs to be processed by the encryption mechanism. + * @param settings + * The encryption or transformation settings, including rotor + * configurations, reflector, and plugboard mappings, provided as an + * implicit parameter. + * @return + * An array of glyphs resulting from the transformation of the input glyphs + * based on the specified settings. + */ + def run(message: IArray[Glyph])(using settings: Settings): IArray[Glyph] = + var rotors = settings.rotors + message + .map: g => + rotors = rotors.rotate + val swappedIn = settings.plugboard.swap(g) + val (forward, inTrace) = rotors.in(swappedIn) + val reflected = settings.reflector.reflect(forward) + val (output, outTrace) = rotors.out(reflected) + val swappedOut = settings.plugboard.swap(output) + scribe.trace( + s"encoded ${g.char} ${inTrace.string}-${outTrace.string} ${swappedOut.char}" + ) + swappedOut diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala new file mode 100644 index 0000000..6d8fea0 --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala @@ -0,0 +1,105 @@ +package cryptic +package cipher +package enigma + +import java.security.SecureRandom +import scala.util.{Failure, Success, Try} + +val secureRandom = SecureRandom() +opaque type Glyph = Int + +/** Represents the Glyph object, which provides operations related to the encoding and + * construction of glyphs for cryptographic purposes. + * + * Constants: + * - `mod`: A constant value indicating the modulus used for glyph calculations. + * - `base`: A constant value indicating the ASCII value offset for character conversions. + * - `one`: A predefined Glyph instance representing the glyph value of 1. + * + * Factory Methods: + * - `apply(i: Int): Glyph`: Constructs a Glyph instance from an integer by applying the + * modulus operation such that the resulting value is normalized between 0 and `mod - 1`. + * - `apply(c: Char): Glyph`: Constructs a Glyph instance from a character by subtracting + * the ASCII offset (`base`) and normalizing the result using the modulus operation. + * + * Utility Methods: + * - `random(n: Int): IArray[Glyph]`: Generates an immutable array containing `n` randomly + * generated Glyph instances. Each Glyph value is constrained by the modulus and generated + * securely using a random number generator. + * - `unsafe(c: Char): Try[Glyph]`: Attempts to construct a Glyph instance from a character + * input. Accepts letters A–Z or a–z, automatically converting lowercase to uppercase. + * Returns a `Success` containing the Glyph if valid, otherwise returns a `Failure` with + * an `IllegalArgumentException`. + * - `isValid(c: Char): Boolean`: Checks if a character can be converted to a valid Glyph + * representation following the same criteria as `unsafe`. Returns `true` if valid, otherwise + * `false`. + */ +object Glyph: + val mod = 26 + val base = 65 + val one: Glyph = Glyph(1) + def apply(i: Int): Glyph = (i + mod) % mod + def apply(c: Char): Glyph = apply(c.intValue - base) + def random(n: Int): IArray[Glyph] = + IArray.from((0 until n).map(_ => Glyph(secureRandom.nextInt(mod)))) + + /** Safely construct a Glyph from a Char. + * - Accepts A–Z or a–z; lower-case is converted to upper-case first. + * - Returns Failure(IllegalArgumentException) for any non A–Z/a–z + * character. + */ + def unsafe(c: Char): Try[Glyph] = + val upper = if c >= 'a' && c <= 'z' then c.toUpper else c + if upper >= 'A' && upper <= 'Z' then Success(apply(upper)) + else Failure(IllegalArgumentException(s"Invalid character for Glyph: '$c'")) + + /** Returns true if the provided character can be converted to a Glyph + * according to the same rules as `unsafe` (i.e., letters A–Z/a–z only). + */ + def isValid(c: Char): Boolean = Glyph.unsafe(c).isSuccess + +extension (g: Glyph) + def char: Char = (g + Glyph.base).toChar + def string: String = char.toString + def byte: Byte = (g + Glyph.base).byteValue + + /** Underlying 0-25 index value of this Glyph */ + def int: Int = g + def +(term: Glyph): Glyph = Glyph(g + term) + def -(term: Glyph): Glyph = Glyph(g - term) + def ++ : Glyph = Glyph(g + 1) + def -- : Glyph = Glyph(g - 1) + +extension (c: Char) + def glyph: Glyph = Glyph(c) + def isGlyph: Boolean = Glyph.isValid(c) + +extension (s: String) + /** Converts the characters of a string into an immutable array of Glyphs. + * Uses `Glyph.unsafe` for each character and ignores any failures, meaning + * only the safe alphabetic characters (A–Z/a–z) are included. Lower-case + * letters are treated as their upper-case equivalents by `unsafe`. + */ + def glyph: IArray[Glyph] = + val kept: Array[Glyph] = + s.toCharArray.flatMap(c => Glyph.unsafe(c).toOption) + IArray.from(kept) + +extension (ga: IArray[Glyph]) + /** Convert an immutable array of Glyphs to a String + */ + def string: String = ga.map(_.char).mkString + +extension (iter: Iterable[Glyph]) + /** Constructs a string by concatenating the `char` representation of elements + * in the sequence `seq`. + * + * @return + * A string composed of the characters obtained from the `char` property of + * each element in the sequence `seq`. + */ + def string: String = iter.map(_.char).mkString + def iarray: IArray[Byte] = IArray.from(iter.map(_.byte)) + +extension (iarray:IArray[Byte]) + def glyph:IArray[Glyph] = new String(iarray.mutable).glyph \ No newline at end of file diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala new file mode 100644 index 0000000..cd64f41 --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala @@ -0,0 +1,68 @@ +package cryptic +package cipher +package enigma + +/** Simple Enigma plugboard implementation. + * + * @param wiring + * Zero to ten disjoint pairs of unique `Glyph`s. Each `Glyph` may appear at + * most once across all pairs, and the two `Glyph`s within a pair must + * differ. + */ +case class PlugBoard(wiring: Seq[(Glyph, Glyph)]): + require(wiring.length <= 10, "PlugBoard may contain at most 10 pairs") + require( + wiring.forall((a, b) => a != b), + "PlugBoard pairs must map two different letters (no self-pairs)" + ) + private val allGlyphs: IArray[Glyph] = + IArray.from(wiring.flatMap((a, b) => Array(a, b))) + require( + allGlyphs.distinct.length == allGlyphs.length, + "PlugBoard letters must be unique across all pairs" + ) + + override def toString: String = wiring.map((f,t)=>s"${f.string}${t.string}").mkString + + /** Swap the provided `Glyph` using the configured wiring. If the `Glyph` is + * not present in any pair, it is returned unchanged. + */ + def swap(g: Glyph): Glyph = + wiring + .collectFirst: + case (a, b) if g == a => b + case (a, b) if g == b => a + .getOrElse(g) + +object PlugBoard: + val empty = PlugBoard(Seq.empty) + /** Construct a PlugBoard from a concatenated string of letter pairs with no + * spaces, e.g. "ABCD" -> pairs (A,B), (C,D). Lowercase letters are accepted + * and normalized to uppercase. The total number of pairs must be between 0 + * and 10 (i.e. length 0 to 20), and letters must be unique. + */ + def apply(pairs: String): PlugBoard = + val s = pairs.trim + if s.nonEmpty && !s.forall(_.isLetter) then + throw IllegalArgumentException( + "PlugBoard.apply requires only letters A–Z or a–z" + ) + val glyphs: IArray[Glyph] = s.glyph + if glyphs.length != s.length then + throw IllegalArgumentException( + "PlugBoard.apply contains invalid characters" + ) + if glyphs.length % 2 != 0 then + throw IllegalArgumentException( + "PlugBoard.apply requires an even number of letters (pairs)" + ) + val pairCount = glyphs.length / 2 + if pairCount > 10 then + throw IllegalArgumentException( + "PlugBoard may contain at most 10 pairs (20 letters)" + ) + + val wiring = IArray.tabulate(pairCount): i => + (glyphs(i * 2), glyphs(i * 2 + 1)) + + new PlugBoard(wiring) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Reflector.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Reflector.scala new file mode 100644 index 0000000..cb91a68 --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Reflector.scala @@ -0,0 +1,19 @@ +package cryptic +package cipher +package enigma + +/** Enigma reflectors A, B, C. + * + * Wiring is expressed in the domain type `Glyph` and the primary API operates + * on `Glyph`, with convenience overload for `Char`. + */ +enum Reflector(wiring: IArray[Glyph]): + /** Primary reflect method operating on Glyph domain values */ + def reflect(g: Glyph): Glyph = wiring(g.int) + + /** Convenience overload reflecting a Char */ + def reflect(c: Char): Char = reflect(c.glyph).char + + case A extends Reflector("EJMZALYXVBWFCRQUONTSPIKHGD".glyph) + case B extends Reflector("YRUHQSLDPXNGOKMIEBFZCWVJAT".glyph) + case C extends Reflector("FVPJIAOYEDRZXWGCTKUQSBNMHL".glyph) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala new file mode 100644 index 0000000..03de0de --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala @@ -0,0 +1,72 @@ +package cryptic +package cipher +package enigma + +/** Represents a rotor in an Enigma-like cryptographic device. A rotor handles + * letter substitution and position-based encoding/decoding as part of a series + * of wheels. + * + * @constructor + * Creates a Rotor with a specified wheel, ring setting, and initial + * position. + * @param wheel + * The wheel defining the rotor's wiring and notches for carry operations. + * @param ring + * The ring setting, which shifts the internal wiring relative to the outer + * casing. + * @param pos + * The current position of the rotor. + * @define operation + * Each operation (`in` or `out`) transposes values by accounting for the + * rotor's offset, allowing for directional encoding and decoding of inputs. + */ +case class Rotor(wheel: Wheel, ring: Glyph, pos: Glyph): + def rotate: Rotor = copy(pos = pos.++) + val offset: Glyph = pos - ring + def carry: Boolean = wheel.carry(pos) + def previousCarry: Boolean = wheel.carry(pos.--) + + def in(g: Glyph): Glyph = wheel.in(g + offset) - offset + def in(c: Char): Char = in(c.glyph).char + + def out(g: Glyph): Glyph = wheel.out(g + offset) - offset + def out(c: Char): Char = out(c.glyph).char + + override def toString: String = s"$wheel ${ring.string} ${pos.string}" + +object Rotor: + /** Convenience constructor for tests and callers that prefer simple types. + * Builds a `Rotor` from a rotor name, ring setting as a character (A–Z), and + * a character position. + */ + def apply(rotorName: String, ring: Char, pos: Char): Rotor = + Rotor(Wheel(rotorName), ring.glyph, pos.glyph) + + /** Convenience constructor from a single settings string with the format: + * "wheel ring pos" + * + * Examples: + * - "I A A" + * - "II a b" (lowercase letters are accepted for ring/pos) + * - Extra internal whitespace is tolerated: "III A Z" + * + * @throws IllegalArgumentException + * if the format is invalid, the wheel name is unknown, or ring/pos are not + * alphabetic characters. + */ + def apply(settings: String): Rotor = + val Settings = """^\s*([^\s]+)\s+([A-Za-z])\s+([A-Za-z])\s*$""".r + + settings match + case Settings(name, ringStr, posStr) => + val triedRotor = for + wheel <- Wheel.unsafe(name) + ring <- Glyph.unsafe(ringStr.head) + pos <- Glyph.unsafe(posStr.head) + yield Rotor(wheel, ring, pos) + + triedRotor.get + case _ => + throw IllegalArgumentException( + "Rotor.apply requires format \"wheel ring pos\" with ring/pos as letters A-Z or a-z" + ) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala new file mode 100644 index 0000000..87d3e00 --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala @@ -0,0 +1,190 @@ +package cryptic +package cipher +package enigma + +/** Represents a sequence of rotors in an Enigma-like cryptographic device. + * + * @param rotors + * An immutable array of Rotors representing the rotor sequence. + * The rightmost rotor should be placed at index 0. The sequence must be non-empty + * and must not contain duplicate rotors. + */ +case class Rotors(rotors: Seq[Rotor]): + require( + rotors.nonEmpty, + "Rotors must contain at least one rotor state (right-most at index 0)" + ) + require( + rotors.map(_.wheel.name).distinct.length == rotors.length, + "Rotors state must not contain duplicate rotors" + ) + + val size: Int = rotors.length + + /** Transforms a given `Glyph` through the sequence of rotors, recording the + * state of the `Glyph` at each step. + * + * @param g + * The initial `Glyph` to be transformed. + * @return + * A tuple containing the final `Glyph` after transformation and a sequence + * of `Glyph` states after each rotor's transformation, including the + * initial state. + */ + def in(g: Glyph): (Glyph, Seq[Glyph]) = + rotors.foldLeft((g, Seq(g))): (acc, rotor) => + val h = rotor.in(acc._1) + (h, acc._2 :+ h) + + /** Convenience overload for chars. */ + def in(c: Char): Char = in(c.glyph)._1.char + + /** Transforms the given `Glyph` object by applying a sequence of states in + * reverse order. + * + * @param g + * the initial `Glyph` to be transformed + * @return + * the transformed `Glyph` after applying the states + */ + def out(g: Glyph): (Glyph, Seq[Glyph]) = + rotors.foldRight((g, Seq(g))): (rotor, acc) => + val h = rotor.out(acc._1) + (h, acc._2 :+ h) + + /** Convenience overload for chars. */ + def out(c: Char): Char = out(c.glyph)._1.char + + /** + * Retrieves the current positions of all rotors in the rotor sequence. + * + * @return + * An immutable array of `Glyph` representing the positions of the rotors. + */ + def pos: Seq[Glyph] = rotors.map(_.pos) + + /** + * Updates the positions of the rotors in the sequence to the specified positions. + * + * @param pos + * An immutable array of `Glyph` values representing the new positions for the rotors. + * The length of this array must match the number of rotors (`size`). + * @return + * A new `Rotors` instance with the updated rotor positions. + * @throws IllegalArgumentException + * If the length of the `pos` array does not match the number of rotors. + */ + def pos(pos: IArray[Glyph]): Rotors = + require(pos.length == size, "Length of pos must match the number of rotors.") + val reposed = rotors + .zip(pos) + .map: + case (r, g) => r.copy(pos = g) + copy(rotors = IArray.from(reposed)) + + override def toString: String = s"""Rotors(${rotors + .map(_.wheel.name) + .reverse + .mkString("-")} ${pos.string.reverse})""" + + /** + * Rotates the sequence of rotors to their next positions according to Enigma's stepping mechanism, + * including handling the "double-stepping" anomaly. + * + * The method progresses through the rotors, applying the rotation based on their individual states + * and carry state, updating their positions appropriately in sequence. + * + * @return + * A new instance of `Rotors` representing the updated state after rotation. + */ + def rotate: Rotors = + val (rotated, _) = rotors.zipWithIndex.foldLeft((Seq.empty[Rotor], true)): + case ((seq, _), (rotor, 0)) => + val next = rotor.rotate + (seq :+ next, next.carry) + case ((seq, carry), (rotor, 1)) => + // We need to handle double step anomaly + val rotated = rotor.rotate + val (c, n) = (carry, rotor.carry, rotated.carry) match + case (true, false, true) => (true, rotated) + case (true, _, true) => (false, rotated) + case (true, _, false) => (false, rotated) + case (false, _, true) => (true, rotated) + case (false, _, false) => (false, rotor) + (seq :+ n, c) + case ((seq, true), (rotor, _)) => + val rotated = rotor.rotate + (seq :+ rotated, rotated.carry) + case ((seq, false), (rotor, _)) => + (seq :+ rotor, rotor.carry) + val next = Rotors(IArray.from(rotated)) + scribe.trace(s"Rotated ${pos.string.reverse} -> ${next.pos.string.reverse}") + next + +object Rotors: + /** Convenience constructor allowing varargs of RotorState with a minimum of + * one. Right-most rotor should be provided first (index 0), matching the + * IArray ordering used by the primary case class. + */ + def apply(head: Rotor, tail: Rotor*): Rotors = + val arr: IArray[Rotor] = IArray.from(head +: tail.toSeq) + Rotors(arr) + + /** Convenience constructor from a single settings string with the format: + * "names rings positions" + * + * Names are hyphen-separated rotor identifiers from left-most to right-most + * (e.g. "VI-II-I"). Ring and position are sequences of letters whose lengths + * must match the number of names, and are given left-most to right-most + * (e.g. "ABC DEF"). Lowercase letters are accepted for ring/pos and + * normalized to uppercase. + * + * Example: + * - "VI-II-I ABC DEF" => Rotors(Rotor("I C F"), Rotor("II B E"), Rotor("VI + * A D")) + * + * The internal ordering of `Rotors` is right-most first (index 0), so the + * resulting sequence is reversed relative to the names/rings/positions + * provided (which are left-most to right-most). + * + * @throws IllegalArgumentException + * if the format is invalid, lengths are mismatched, names are empty, or + * ring/pos contain non-alphabetic characters, or if any wheel name is + * unknown. + */ + def apply(settings: String): Rotors = + // Tolerate leading/trailing whitespace; require exactly three whitespace-separated parts + val Settings = """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Za-z]+)\s*$""".r + + settings match + case Settings(namesPart, ringsPart, posPart) => + val names = namesPart.split("-").map(_.trim).toVector + if names.isEmpty || !names.forall(_.nonEmpty) then + throw IllegalArgumentException( + "Rotors.apply requires non-empty hyphen-separated rotor names (e.g. 'VI-II-I')" + ) + + val n = names.length + if !(ringsPart.length == n && posPart.length == n) then + throw IllegalArgumentException( + s"Rotors.apply rings and positions must have length $n to match names count" + ) + + // Validate letters (regex already restricts A-Za-z but double-check defensively) + if !(ringsPart.forall(_.isLetter) && posPart.forall(_.isLetter)) then + throw IllegalArgumentException( + "Rotors.apply requires ring/pos to be letters A-Z or a-z only" + ) + + // Build rotors in right-most-first order (reverse of input order) + val rotors: Seq[Rotor] = (0 until n).reverse.map: i => + val name = names(i) + val ringCh = ringsPart.charAt(i).toUpper + val posCh = posPart.charAt(i).toUpper + Rotor(name, ringCh, posCh) + + Rotors(IArray.from(rotors)) + case _ => + throw IllegalArgumentException( + "Rotors.apply requires format \"names rings positions\" (e.g. \"VI-II-I ABC DEF\") with ring/pos as letters A-Z or a-z" + ) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala new file mode 100644 index 0000000..2c56523 --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala @@ -0,0 +1,66 @@ +package cryptic +package cipher +package enigma + +import scala.util.{Failure, Success, Try} + +case class Settings( + rotors: Rotors, + reflector: Reflector, + plugboard: PlugBoard, + preamble: Boolean +): + /** Adjusts the rotor positions using the given array of glyphs and returns an + * updated Settings object. + * + * @param pos + * An immutable array of Glyph objects representing the rotor positions to + * be set. + * @return + * A new Settings instance with the updated rotor positions. + */ + def pos(pos: IArray[Glyph]): Settings = copy(rotors = rotors.pos(pos)) + +object Settings: + /** + * Constructs a new `Settings` instance by parsing the provided configuration string. + * + * @param settings + * A string containing the configuration settings in the format + * `"names rings [positions] reflector [plugboard]"`. + * @return + * The `Settings` instance constructed from the parsed configuration. + * Throws an exception if the string format is invalid. + */ + def apply(settings: String): Settings = parse(settings).get + /** + * Parses a configuration string and attempts to construct a `Settings` instance. + * + * @param settings + * A string containing the configuration settings in the format + * `"names rings [positions] reflector [plugboard]"`. For example, + * `"III-II-I AAA AAZ B ABCD"`. + * @return + * A `Try[Settings]` containing the successfully parsed `Settings` instance, + * or a `Failure` with an `IllegalArgumentException` if the string format is invalid. + */ + def parse(settings: String): Try[Settings] = + // Match parts: names, rings, optional positions, reflector, optional plugboard + val SettingsFormat = + """^\s*(\S+)\s+([A-Za-z]+)\s+(?:([A-Za-z]+)\s+)?([A-Ca-c])(?:\s+([A-Za-z]*))?\s*$""".r + + settings match + case SettingsFormat(names, rings, posOrNull, refl, pbOrNull) => + val posOption = Option(posOrNull) + val preamble = posOption.isEmpty + val pos = posOption.getOrElse("A" * rings.length) + val rotors = Rotors(s"$names $rings $pos") + val reflector = Reflector.valueOf(refl.toUpperCase) + val plugboard = PlugBoard(Option(pbOrNull).getOrElse("")) + Success(Settings(rotors, reflector, plugboard, preamble)) + case _ => + Failure( + IllegalArgumentException( + s"""Invalid settings: $settings. Required format "names rings [positions] reflector [plugboard]" (e.g. "III-II-I AAA AAZ B ABCD")""" + ) + ) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala new file mode 100644 index 0000000..c0696a4 --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala @@ -0,0 +1,77 @@ +package cryptic +package cipher +package enigma + +import scala.util.Try + +/** + * Represents a Wheel (or Rotor) used in an Enigma-like device. + * + * A Wheel defines a specific wiring configuration and one or more notches + * that determine when it triggers a carry operation to the next rotor. + * + * @constructor Creates a Wheel with a specific name, wiring configuration, and + * associated notches. + * @param name The name of the wheel, typically indicating its type or version. + * @param wiring An indexed sequence of Glyphs representing the wiring configuration + * used for letter substitution. + * @param notches An indexed sequence of Glyphs indicating the positions at + * which this wheel causes a carry operation. + */ +case class Wheel(name: String, wiring: IArray[Glyph], notches: IArray[Glyph]): + def carry(pos: Glyph): Boolean = notches.contains(pos) + def in(g: Glyph): Glyph = wiring(g.int) + def out(g: Glyph): Glyph = Glyph(wiring.indexOf(g)) + override def toString: String = name + +/** + * Companion object for the `Wheel` class, providing factory methods and predefined configurations. + * + * The `Wheel` object allows for the creation of `Wheel` instances, either from scratch with + * specified wiring and notch configurations, or by referencing predefined configurations + * by their conventional names (e.g., "I", "II", etc.). + */ +object Wheel: + // Predefined rotor singletons (kept private) to preserve reference and structural equality + // while "inlining" access through the name-based apply. + private lazy val predefined: Map[String, Wheel] = + Array[ + (String, String, String) + ]( + ("I", "EKMFLGDQVZNTOWYHXUSPAIBRCJ", "R"), + ("II", "AJDKSIRUXBLHWTMCQGZNPYFVOE", "F"), + ("III", "BDFHJLCPRTXVZNYEIWGAKMUSQO", "W"), + ("IV", "ESOVPZJAYQUIRHXLNFTGKDCMWB", "K"), + ("V", "VZBRGITYUPSDNHLXAWMJQOFECK", "A"), + ("VI", "JPGVOUMFYQBENHZRDKASXLICTW", "AN") + ).map: (name, wiring, notch) => + name -> Wheel(name, wiring, notch) + .toMap + + /** Construct a Rotor from notch and wiring strings. Non‑alphabetic characters + * are ignored (per `String.glyph`). + */ + def apply(name: String, wiring: String, notch: String): Wheel = + val n = notch.glyph + val w = wiring.glyph + require( + n.nonEmpty, + "Rotor notch must be non-empty (at least one valid A–Z char)" + ) + require( + n.distinct.length == n.length, + "Rotor notch characters must be non-repeating" + ) + require( + w.length == Glyph.mod, + s"Rotor wiring must have length ${Glyph.mod}" + ) + Wheel(name, w, n) + + /** Lookup a predefined rotor by its conventional name ("I" .. "VI"). */ + def apply(name: String): Wheel = predefined.getOrElse( + name, + throw IllegalArgumentException(s"Unknown rotor name: '$name'") + ) + + def unsafe(name: String): Try[Wheel] = Try(apply(name)) diff --git a/cipher-enigma/src/test/resources/lorem-cipher-AAA-AAA.txt b/cipher-enigma/src/test/resources/lorem-cipher-AAA-AAA.txt new file mode 100644 index 0000000..10a951d --- /dev/null +++ b/cipher-enigma/src/test/resources/lorem-cipher-AAA-AAA.txt @@ -0,0 +1 @@ +ILFDFARUBDONVISRUKOZQMNDIYCOUHRLAWBRMPYLAZNYNGRMRMVAAJLNSZFHSYBBKFODPCHQPHSWOQZCJFKXNBAZJNPZHZBGOMNXOPPXXEVJWISBPSDPJQBKZXCTHIVUOJJCSOVBSWFZLGSLFDXMWZWSMHAYRJASZSGQDMQRLKAULWOZNGHSRKVXFQLEVXDWHAWZBXVFBISAAQCNNFYJXBAWIYKWCEUOCSKMKHEQGAJDONNHVNJTADKKYRYCYITTAGPDUZMKESMIJDCHOKQOEJIOXFMKSLNBKCZGAHMMJRSFVSIYQZJGMCNEMLSOUHZAJXDRBOXVJLMNZUQAMQONCFCOKGESAVPJZESHNZBLEEJOAPXRV diff --git a/cipher-enigma/src/test/resources/lorem-cipher-MAR_ADQ.txt b/cipher-enigma/src/test/resources/lorem-cipher-MAR_ADQ.txt new file mode 100644 index 0000000..732415d --- /dev/null +++ b/cipher-enigma/src/test/resources/lorem-cipher-MAR_ADQ.txt @@ -0,0 +1 @@ +NSUVWPHJSEZVTUOFNRMZMXFZVIGODXZZTTWTOMOEFZVPTKNAGAVWTTSKVSYDVGLQLUCGETGXEVIJRYCLZNXTHHSKBTXSKPAYTFMVRUKJNXMNCLWSTUILJVAOGXODUVEEBLQWUVXCUTACJSFYKVBDIERPBVXNSGSOZDYOSASMMQMZDYEWDPCEYVIETWTEIZHKEPAMYCUCARTSOIEANLZZHCQJWGHODHOAPTZFHJHRWLJZCOTGWWZCDFRTDQGYGWJXWHCNWMQLIRYPVLUBLVWOIKWQMDLUBSZRFOVZGCDATSXIONJLRXDYVSVIEBNFGMJQJCWQQDHSKETZNJKNWPNUKOVURCPQWDUOCGYKJVZTHQDGQZJZQ \ No newline at end of file diff --git a/cipher-enigma/src/test/resources/lorem-cipher-ZBQ-AAA.txt b/cipher-enigma/src/test/resources/lorem-cipher-ZBQ-AAA.txt new file mode 100644 index 0000000..12e3fbf --- /dev/null +++ b/cipher-enigma/src/test/resources/lorem-cipher-ZBQ-AAA.txt @@ -0,0 +1 @@ +DYXNTZEWFWLZTGKWHNBTBZQACIFJZQCBVQPGIKWILCQMOJZJLJVHRVXBQZSUREIBOWTEGFNTGDQFUZZNDLXUAKNVEMCPONSKZOLFBYDGWVHYQYAGKKACVGYQKSXRNOBLDZVOMJLNYLLKZRDJRQWOQPPDZLFUFEEMEBVAFUMVMUCJYZTQIROLQTFDDCCXCYLHKHPUOZCELGKDQLHKCUJMLFWPYRGLAPLUTDTSAXNYJSHLMTMPFZHVDCQJWQKBCCIJJOQNKSOHZTAOIAKIUZHTMXYYYASXAQTLIQPUZKQOUNEQQXHLXVZQWZFIRJCMTOGNRLYMMXJDVFIVWPDMDVEHYRZTJDUXHUYXFXTRXBFIQKKWPZWRR diff --git a/cipher-enigma/src/test/resources/lorem.txt b/cipher-enigma/src/test/resources/lorem.txt new file mode 100644 index 0000000..f97cb4f --- /dev/null +++ b/cipher-enigma/src/test/resources/lorem.txt @@ -0,0 +1 @@ +LoremipsumdolorsitametconsecteturadipiscingelitseddoeiusmodtemporincididuntutlaboreetdoloremagnaaliquaUtenimadminimveniamquisnostrudexercitationullamcolaborisnisiutaliquipexeacommodoconsequatDuisauteiruredolorinreprehenderitinvoluptatevelitessecillumdoloreeufugiatnullapariaturExcepteursintoccaecatcupidatatnonproidentsuntinculpaquiofficiadeseruntmollitanimidestlaborum \ No newline at end of file diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala new file mode 100644 index 0000000..c974893 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala @@ -0,0 +1,89 @@ +package cryptic +package cipher +package enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.TryValues +import org.scalatest.prop.TableDrivenPropertyChecks + +import scala.util.Try + +class EnigmaSpec + extends AnyFlatSpec + with Matchers + with TryValues + with TableDrivenPropertyChecks: + behavior of "Enigma" + + import Enigma.default.{*, given} + + val lorem: String = "lorem.txt".fromResource.glyph.string + val loremCipherAAA_AAA: String = + "lorem-cipher-AAA-AAA.txt".fromResource.glyph.string + val loremCipherMAR_ADQ: String = + "lorem-cipher-MAR_ADQ.txt".fromResource.glyph.string + val loremCipherZBQ_AAA: String = + "lorem-cipher-ZBQ-AAA.txt".fromResource.glyph.string + + "Enigma engine" should "encrypt and decrypt with explicit ring settings" in: + val data = Table( + ("plain", "cipher", "settings"), + ("MARTIN", "WFSEXB", "III-II-I AAA AAZ B"), + ("MARTIN", "SNJAJP", "III-II-I AAB AAZ B"), + ("MARTIN", "HTFUSP", "III-II-I AAA AAA B"), + ("MARTIN", "ITDVLW", "III-II-I AAE AAA B"), + ("MARTIN", "FDEQBM", "III-II-I AAA AAA B MARTIN"), + ("ABCD", "ZUIN", "III-II-I AAA ADQ B"), + ("ABCD", "NODJ", "III-II-I AAA LEQ B"), + (lorem, loremCipherAAA_AAA, "III-II-I AAA AAA B"), + (lorem, loremCipherMAR_ADQ, "III-II-I MAR ADQ B"), + (lorem, loremCipherZBQ_AAA, "III-II-I ZBQ AAA B") + ) + forAll(data)((plain: String, cipher: String, settings: String) => + given s: Settings = Settings(settings) + Enigma.run(plain.bytes).string shouldBe cipher + ) + + "Settings" should "parse with pos settings" in: + val settings = Settings("III-II-I AAA AAZ B") + settings.rotors.rotors shouldBe Rotors("III-II-I AAA AAZ ").rotors + settings.reflector shouldBe Reflector.B + + "Settings" should "parse without pos settings default to AAA" in: + val settings = Settings("III-II-I AAZ B") + settings.rotors.rotors shouldBe Rotors("III-II-I AAZ AAA ").rotors + settings.reflector shouldBe Reflector.B + + "Settings" should "parse plugboard as last token when provided" in: + val settings = Settings("III-II-I AAA AAZ B ABCD") + settings.rotors.rotors shouldBe Rotors("III-II-I AAA AAZ ").rotors + settings.reflector shouldBe Reflector.B + // Plugboard ABCD => A<->B, C<->D + settings.plugboard.swap('A'.glyph).char shouldBe 'B' + settings.plugboard.swap('B'.glyph).char shouldBe 'A' + settings.plugboard.swap('C'.glyph).char shouldBe 'D' + settings.plugboard.swap('E'.glyph).char shouldBe 'E' + + "Enigma" should "add random start position and encrypted message key to preamble" in: + val plain = + "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do".glyph.string + given settings: Settings = Settings("III-II-I AAA B") + val encrypted: Encrypted[Try, String] = plain.encrypted + encrypted.decrypted.success.value shouldBe plain + + "Enigma" should "apply plugboard swap before and after rotors" in: + val base = Settings("III-II-I AAA AAZ B") + val withPB = Settings("III-II-I AAA AAZ B QW") // Q<->W pair + val plug = withPB.plugboard + val msg = "HELLOWORLD" + + // Encrypt with plugboard + val outWithPB = Enigma.run(msg.glyph)(using withPB) + + // Encrypt without plugboard but manually swap before and after + val preSwapped = msg.glyph.map(plug.swap) + val core = Enigma.run(preSwapped)(using base) + val postSwapped = core.map(plug.swap) + + outWithPB shouldBe postSwapped diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala new file mode 100644 index 0000000..0f3f9fd --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala @@ -0,0 +1,113 @@ +package cryptic +package cipher +package enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class GlyphSpec extends AnyFlatSpec with Matchers: + behavior of "Glyph" + + import Glyph.* + + "Glyph.apply(Int)" should "wrap values mod 26" in: + Glyph(0).char shouldBe 'A' + Glyph(25).char shouldBe 'Z' + Glyph(26).char shouldBe 'A' + Glyph(27).char shouldBe 'B' + Glyph(-1).char shouldBe 'Z' + + it should "construct from Char" in: + 'A'.glyph.char shouldBe 'A' + 'Z'.glyph.char shouldBe 'Z' + 'B'.glyph.char shouldBe 'B' + + it should "add and subtract with wrap-around" in: + ('A'.glyph + Glyph(1)).char shouldBe 'B' + ('Z'.glyph + Glyph(1)).char shouldBe 'A' + ('B'.glyph - Glyph(1)).char shouldBe 'A' + ('A'.glyph - Glyph(1)).char shouldBe 'Z' + + it should "increment with ++ and wrap-around" in: + 'A'.glyph.++.char shouldBe 'B' + 'Z'.glyph.++.char shouldBe 'A' + // Chaining ++ should advance twice + 'Y'.glyph.++.++.char shouldBe 'A' + // ++ should be equivalent to adding one + 'C'.glyph.++.char shouldBe ('C'.glyph + one).char + + "String.glyph" should "convert a String to Glyphs and back" in: + val s = "ABZ" + val g = s.glyph + g.string shouldBe s + + it should "upper-case lower-case input before mapping to Glyphs" in: + "abz".glyph.string shouldBe "ABZ" + "aBz".glyph.string shouldBe "ABZ" + "martin".glyph.string shouldBe "MARTIN" + + it should "ignore non alphabetic characters using unsafe and keep only letters" in: + "A-B Z1".glyph.string shouldBe "ABZ" + "--__ 123".glyph.string shouldBe "" + "a-b_z".glyph.string shouldBe "ABZ" + + "IArray[Glyph].string" should "handle empty and multi-letter strings" in: + IArray.empty[Glyph].string shouldBe "" + "MARTIN".glyph.string shouldBe "MARTIN" + + "Glyph.unsafe(Char)" should "accept uppercase letters" in: + Glyph.unsafe('A').get.char shouldBe 'A' + Glyph.unsafe('Z').get.char shouldBe 'Z' + + it should "convert lowercase letters to uppercase and succeed" in: + Glyph.unsafe('a').get.char shouldBe 'A' + Glyph.unsafe('m').get.char shouldBe 'M' + Glyph.unsafe('z').get.char shouldBe 'Z' + + it should "fail for non alphabetic characters with IllegalArgumentException" in: + val invalids = List('1', ' ', '-', '[', '`', 'å') + invalids.foreach { c => + val t = Glyph.unsafe(c) + t.isFailure shouldBe true + t.failed.get shouldBe a [IllegalArgumentException] + } + + "Glyph.isValid(Char)" should "return true for letters A–Z and a–z" in: + Glyph.isValid('A') shouldBe true + Glyph.isValid('Z') shouldBe true + Glyph.isValid('a') shouldBe true + Glyph.isValid('z') shouldBe true + + it should "return false for non alphabetic characters" in: + val invalids = List('1', ' ', '-', '[', '`', 'å') + invalids.foreach { c => + Glyph.isValid(c) shouldBe false + } + + "Glyph extension methods" should "convert between Char and String" in : + val g = 'A'.glyph + g.char shouldBe 'A' + g.string shouldBe "A" + Glyph(25).string shouldBe "Z" + "ABC".glyph.string shouldBe "ABC" + Seq('A'.glyph, 'B'.glyph, 'C'.glyph).string shouldBe "ABC" + + "Rotor.previousCarry" should "be true when current pos is one after the notch (single notch)" in: + // Wheel I has notch at 'R' -> previousCarry true at pos 'S' + val r1 = Rotor("I", 'A', 'S') + r1.previousCarry shouldBe true + // At the notch itself ('R') it's false for previousCarry + Rotor("I", 'A', 'R').previousCarry shouldBe false + // One past 'S' ('T') is also false + Rotor("I", 'A', 'T').previousCarry shouldBe false + + it should "handle multiple notches correctly (VI: A and N)" in: + // Wheel VI has notches at 'A' and 'N' -> previousCarry true at 'B' and 'O' + Rotor("VI", 'A', 'B').previousCarry shouldBe true + Rotor("VI", 'A', 'O').previousCarry shouldBe true + // At the notch positions themselves, previousCarry should be false + Rotor("VI", 'A', 'A').previousCarry shouldBe false + Rotor("VI", 'A', 'N').previousCarry shouldBe false + // Other positions should be false + Rotor("VI", 'A', 'C').previousCarry shouldBe false + diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/PlugBoardSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/PlugBoardSpec.scala new file mode 100644 index 0000000..fabcb1a --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/PlugBoardSpec.scala @@ -0,0 +1,64 @@ +package cryptic +package cipher +package enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks + +class PlugBoardSpec + extends AnyFlatSpec + with Matchers + with TableDrivenPropertyChecks: + behavior of "PlugBoard" + + it should "swap mapped glyphs and leave others unchanged" in: + val pb = PlugBoard("ABCD") // A<->B, C<->D + val data = Table( + ("in", "out"), + ('A', 'B'), + ('B', 'A'), + ('C', 'D'), + ('D', 'C'), + ('Z', 'Z') // unaffected + ) + forAll(data): (in: Char, out: Char) => + pb.swap(in.glyph) shouldBe out.glyph + + it should "support empty wiring (no-op)" in: + val pb = PlugBoard("") + pb.swap('A'.glyph) shouldBe 'A'.glyph + pb.swap('Z'.glyph) shouldBe 'Z'.glyph + + it should "normalize lowercase input when constructing from String" in: + val pb = PlugBoard("abCd") + // toString uses uppercase glyphs + pb.toString shouldBe "ABCD" + // swap behavior operates on Glyphs; use uppercase inputs + pb.swap('A'.glyph) shouldBe 'B'.glyph + pb.swap('C'.glyph) shouldBe 'D'.glyph + + behavior of "PlugBoard.apply(pairs: String)" + + it should "reject non-letter characters" in: + intercept[IllegalArgumentException] { PlugBoard("AB12") } + intercept[IllegalArgumentException] { PlugBoard("--__ ") } + + it should "reject an odd number of letters" in: + intercept[IllegalArgumentException] { PlugBoard("ABC") } + + it should "reject more than 10 pairs (20 letters)" in: + intercept[IllegalArgumentException] { PlugBoard("ABCDEFGHIJKLMNOPQRSTUVWXYZ") } + + it should "reject self-pairs and duplicate letters across pairs" in: + // self-pair AA + intercept[IllegalArgumentException] { PlugBoard("AA") } + // duplicate A used in two pairs + intercept[IllegalArgumentException] { PlugBoard("ABAC") } + + it should "preserve pair order and render as concatenated pairs in toString" in: + PlugBoard("ABCD").toString shouldBe "ABCD" + PlugBoard("EFGH").toString shouldBe "EFGH" + + it should "construct an empty PlugBoard from an empty string" in: + PlugBoard("").wiring.isEmpty shouldBe true diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/ReflectorSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/ReflectorSpec.scala new file mode 100644 index 0000000..2d32adf --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/ReflectorSpec.scala @@ -0,0 +1,22 @@ +package cryptic +package cipher +package enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class ReflectorSpec extends AnyFlatSpec with Matchers: + behavior of "Reflector" + + private val a: Reflector = Reflector.A + private val b: Reflector = Reflector.B + private val c: Reflector = Reflector.C + "Reflectors" should "be symmetric" in: + ('A' to 'Z').foreach: i => + a.reflect(a.reflect(i)) shouldBe i + b.reflect(b.reflect(i)) shouldBe i + c.reflect(c.reflect(i)) shouldBe i + + "Reflector B" should "map" in: + ('A' to 'Z').foreach: c => + b.reflect(c) should not be c diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorApplySpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorApplySpec.scala new file mode 100644 index 0000000..e60cbac --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorApplySpec.scala @@ -0,0 +1,48 @@ +package cryptic +package cipher +package enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class RotorApplySpec extends AnyFlatSpec with Matchers: + behavior of "Rotor.apply(settings: String) — dedicated parsing tests" + + private val formatError = "Rotor.apply requires format \"wheel ring pos\" with ring/pos as letters A-Z or a-z" + + it should "accept leading and trailing whitespace" in: + Rotor(" I A A ") shouldBe Rotor("I", 'A', 'A') + + it should "accept tab separators as whitespace" in: + // Tabs (\t) are matched by \s in the regex + Rotor("II\tB\tC") shouldBe Rotor("II", 'B', 'C') + + it should "normalize lowercase ring/pos to uppercase" in: + Rotor("III a z") shouldBe Rotor("III", 'A', 'Z') + Rotor("IV m n") shouldBe Rotor("IV", 'M', 'N') + + it should "handle mixed spacing between tokens" in: + Rotor("V A\t\tZ") shouldBe Rotor("V", 'A', 'Z') + + it should "reject empty or malformed formats with a clear message" in: + val ex1 = intercept[IllegalArgumentException] { Rotor("") } + ex1.getMessage shouldBe formatError + + val ex2 = intercept[IllegalArgumentException] { Rotor("I A") } + ex2.getMessage shouldBe formatError + + val ex3 = intercept[IllegalArgumentException] { Rotor("I A A X") } + ex3.getMessage shouldBe formatError + + it should "reject non-letter ring/pos with the format error message" in: + val ex1 = intercept[IllegalArgumentException] { Rotor("I 1 A") } + ex1.getMessage shouldBe formatError + + val ex2 = intercept[IllegalArgumentException] { Rotor("I A 1") } + ex2.getMessage shouldBe formatError + + val ex3 = intercept[IllegalArgumentException] { Rotor("I å A") } + ex3.getMessage shouldBe formatError + + it should "reject unknown wheel names via Wheel" in: + intercept[IllegalArgumentException] { Rotor("UNKNOWN A A") } diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala new file mode 100644 index 0000000..44c23b8 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala @@ -0,0 +1,107 @@ +package cryptic +package cipher +package enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks + +class RotorSpec + extends AnyFlatSpec + with Matchers + with TableDrivenPropertyChecks: + behavior of "Rotor" + + "Rotor" should "map and carry after rotate" in: + val data = Table( + ("Settings", "plain", "cipher", "carry"), + ("I A Z", 'A', 'E', false), + ("I A Z", 'B', 'K', false), + ("I A Z", 'E', 'L', false), + ("I A Z", 'J', 'Z', false), + ("I A Z", 'S', 'S', false), + ("I A A", 'A', 'J', false), + ("I A A", 'B', 'L', false), + ("I A B", 'A', 'K', false), + ("I A B", 'B', 'D', false), + ("I B Z", 'A', 'K', false), + ("I B B", 'A', 'J', false), + ("I B Q", 'A', 'H', true), + ("I Z Q", 'A', 'A', true), + ("I A Q", 'A', 'D', true), + ("I B R", 'A', 'D', false), + ("I C S", 'A', 'D', false), + ("I Z P", 'A', 'D', false), + ("I Y O", 'A', 'D', false), + ("I B Q", 'M', 'W', true), + ("I B Q", 'A', 'H', true), + ("II A Z", 'D', 'K', false), + ("III A Z", 'K', 'X', false) + ) + forAll(data): + (settings: String, plain: Char, cipher: Char, carry: Boolean) => + val rotor = Rotor(settings).rotate // We rotate before mapping + rotor.in(plain) shouldBe cipher + rotor.out(cipher) shouldBe plain + rotor.carry shouldBe carry + + private val rotorA = Rotor("I A A") + private val rotorB = Rotor("I A B") + private val rotorC = Rotor("I A C") + private val rotorQ = Rotor("I A Q") + private val rotorBQ = Rotor("I B Q") + + it should "rotate" in: + val data = Table( + ("before", "after"), + ("I A A", "I A B"), + ("I A B", "I A C") + ) + forAll(data): (before, after) => + Rotor(before).rotate shouldBe Rotor(after) + + it should "have carry on notch" in: + val data = Table( + ("rotor", "carry"), + ("I A A", false), + ("I A Q", false), + ("I A R", true), + ("I B Q", false), + ("I B R", true), + ("I B S", false), + ("VI A N", true), + ("VI A Z", false), + ("VI A B", false), + ("VI A M", false), + ("VI A O", false), + ("II A A", false) + ) + forAll(data): (rotor: String, carry: Boolean) => + Rotor(rotor).carry shouldBe carry + + behavior of "Rotor.apply(settings: String)" + + it should "parse \"wheel ring pos\" with spaces" in: + Rotor("I A A") shouldBe Rotor("I", 'A', 'A') + Rotor("II B C") shouldBe Rotor("II", 'B', 'C') + + it should "accept lowercase ring/pos and normalize" in: + Rotor("III a z") shouldBe Rotor("III", 'A', 'Z') + + it should "tolerate extra internal whitespace" in: + Rotor("IV A Z") shouldBe Rotor("IV", 'A', 'Z') + + it should "reject wrong token counts" in: + intercept[IllegalArgumentException] { Rotor("") } + intercept[IllegalArgumentException] { Rotor("I A") } + intercept[IllegalArgumentException] { Rotor("I A A X") } + + it should "reject non-letter ring/pos" in: + intercept[IllegalArgumentException] { Rotor("I 1 A") } + intercept[IllegalArgumentException] { Rotor("I A 1") } + + it should "reject unknown wheel names via Wheel" in: + intercept[IllegalArgumentException] { Rotor("VII A A") } + + it should "have a toString matching apply" in: + Rotor("IV Z Q").toString shouldBe "IV Z Q" diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsApplySpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsApplySpec.scala new file mode 100644 index 0000000..5930e13 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsApplySpec.scala @@ -0,0 +1,58 @@ +package cryptic +package cipher +package enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class RotorsApplySpec extends AnyFlatSpec with Matchers: + behavior of "Rotors.apply(settings: String)" + + private val formatError = + "Rotors.apply requires format \"names rings positions\" (e.g. \"VI-II-I ABC DEF\") with ring/pos as letters A-Z or a-z" + + it should "parse the example 'VI-II-I ABC DEF' into right-most-first order" in: + val r = Rotors("VI-II-I ABC DEF") + val expected = Rotors(Rotor("I C F"), Rotor("II B E"), Rotor("VI A D")) + r.rotors.toSeq shouldBe expected.rotors.toSeq + + it should "accept arbitrary rotor counts and normalize lowercase ring/pos" in: + val r = Rotors("IV-III-II-I abcd efgh") + r.rotors.length shouldBe 4 + // Expected right-most-first + val expected = Rotors( + Rotor("I D H"), + Rotor("II C G"), + Rotor("III B F"), + Rotor("IV A E") + ) + r.rotors.toSeq shouldBe expected.rotors.toSeq + + it should "tolerate leading/trailing whitespace and mixed internal spacing" in: + val r1 = Rotors(" VI-II-I ABC DEF ") + val r2 = Rotors("VI-II-I\tABC\tDEF") + val expected = Rotors(Rotor("I C F"), Rotor("II B E"), Rotor("VI A D")) + r1.rotors.toSeq shouldBe expected.rotors.toSeq + r2.rotors.toSeq shouldBe expected.rotors.toSeq + + it should "reject mismatched lengths with a clear message" in: + val ex = intercept[IllegalArgumentException] { Rotors("I-II ABC DE") } + ex.getMessage shouldBe "Rotors.apply rings and positions must have length 2 to match names count" + + it should "reject malformed formats with the format error message" in: + val ex1 = intercept[IllegalArgumentException] { Rotors("") } + ex1.getMessage shouldBe formatError + + val ex2 = intercept[IllegalArgumentException] { Rotors("I-II ABC") } + ex2.getMessage shouldBe formatError + + it should "reject non-letter ring/pos with the format error message" in: + val ex1 = intercept[IllegalArgumentException] { Rotors("I 1 A") } + ex1.getMessage shouldBe formatError + val ex2 = intercept[IllegalArgumentException] { Rotors("I A 1") } + ex2.getMessage shouldBe formatError + + it should "reject unknown wheel names via Wheel" in: + intercept[IllegalArgumentException] { + Rotors("UNKNOWN-I AB CD") + } diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala new file mode 100644 index 0000000..a0e6570 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala @@ -0,0 +1,159 @@ +package cryptic +package cipher +package enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks + +class RotorsSpec + extends AnyFlatSpec + with Matchers + with TableDrivenPropertyChecks: + behavior of "Rotors.rotate" + + it should "rotate 3 rotors" in: + val data = Table( + // Order is right to left, where left is at index 0 + // We do to rotations to check for the double step anomaly + // of the middle rotor + ("Before", "First", "Second"), + ("JAA", "JAB", "JAC"), // Only left rotor + ("KAQ", "KBR", "KBS"), // left R carry => middle rotate + ("LEQ", "MFR", "MFS"), // All three + ("MDQ", "MER", "NFS"), // Double step anomaly + ("NER", "OFS", "OFT") // Double step anomaly + ) + forAll(data): (before: String, first: String, second: String) => + val rotate1 = Rotors(s"III-II-I AAA $before").rotate + withClue(s"Failed after 1st rotation starting from $before: ") { + rotate1.pos.string.reverse shouldBe first + } + val rotate2 = rotate1.rotate + withClue(s"Failed after 2nd rotation starting from ${rotate1.pos.string.reverse}: ") { + rotate2.pos.string.reverse shouldBe second + } + + it should "rotate the right rotor only when no carry occurs (3 rotors)" in: + val rotors = Rotors("III-II-I AAA AAA").rotate + rotors.pos.string.reverse shouldBe "AAB" + + it should "rotate middle when right carries after its rotation (3 rotors)" in: + val rotors = Rotors("III-II-I AAA AAQ").rotate + rotors.pos.string.reverse shouldBe "ABR" + + it should "rotate left when middle carries after its rotation (double step trigger, 3 rotors)" in: + val rotors = Rotors("III-II-I AAA AEQ").rotate + rotors.pos.string.reverse shouldBe "BFR" + + it should "work with a single rotor (always rotates)" in: + val single0 = Rotors(IArray(Rotor("I A A"))) + val single1 = single0.rotate + single1.rotors.length shouldBe 1 + single1.rotors.head.pos shouldBe 'B'.glyph + + it should "ripple carry across four rotors" in: + // Set up a chain where first rotation causes cascading carries: + // Right (Rotor I) at Q -> rotates to R and carries (Rotor I notch R) + // Next (Rotor II) at E -> when rotated to F, carries (Rotor II notch F) + // Next (Rotor III) at V -> when rotated to W, carries (Rotor III notch W) + // Leftmost (Rotor IV) at A -> should rotate to B due to carry chain + val r0 = Rotor("I A Q") + val r1 = Rotor("II A E") + val r2 = Rotor("III A V") + val r3 = Rotor("IV A A") + val rotors0 = Rotors(IArray(r0, r1, r2, r3)) + val rotors1 = rotors0.rotate + rotors1.rotors + .map(_.pos) shouldBe IArray('R'.glyph, 'F'.glyph, 'W'.glyph, 'B'.glyph) + + behavior of "Rotors constructor" + + it should "require at least one rotor state and accept arbitrary sizes" in: + // empty should fail + intercept[IllegalArgumentException] { + Rotors(IArray.empty[Rotor]) + } + // 1, 2, 3, 4 should all be allowed + noException should be thrownBy Rotors(IArray(Rotor("I A A"))) + noException should be thrownBy Rotors(IArray(Rotor("I A A"), Rotor("II A A"))) + noException should be thrownBy Rotors(IArray(Rotor("I A A"), Rotor("II A A"), Rotor("III A A"))) + noException should be thrownBy Rotors(IArray(Rotor("I A A"), Rotor("II A A"), Rotor("III A A"), Rotor("IV A A"))) + + it should "reject duplicate rotors (by identity/name)" in: + // Duplicate rotor I used twice should fail + intercept[IllegalArgumentException] { + Rotors(IArray(Rotor("I A A"), Rotor("I A B"))) + } + // Duplicates among more than two should also fail + intercept[IllegalArgumentException] { + Rotors(IArray(Rotor("I A A"), Rotor("II A A"), Rotor("I A C"))) + } + + behavior of "Rotors.in" + + it should "chain RotorState.in from right-most to left-most (3 rotors)" in: + val r0 = Rotor("I A A") + val r1 = Rotor("II A A") + val r2 = Rotor("III A A") + val rotors = Rotors(IArray(r0, r1, r2)) + + val input = 'A' + val step1 = r0.in(input) + val step2 = r1.in(step1) + val step3 = r2.in(step2) + + rotors.in(input) shouldBe step3 + + it should "match single rotor behavior and support char overload" in: + val single = Rotors(IArray(Rotor("I A A"))) + val g = 'C'.glyph + + single.in(g) shouldBe (single.rotors.head.in(g), List(2, 12)) + single.in('C') shouldBe single.in(g.char) + + behavior of "Rotors companion object apply" + + it should "construct from varargs with at least one state (single)" in: + val single = Rotors(Rotor("I A A")) + single.rotors.length shouldBe 1 + single.rotors.head.pos shouldBe 'A'.glyph + + it should "construct from varargs with multiple states and preserve order" in: + val r0 = Rotor("I A A") + val r1 = Rotor("II A B") + val r2 = Rotor("III A C") + val rotors = Rotors(r0, r1, r2) + rotors.rotors.length shouldBe 3 + rotors.rotors.head shouldBe r0 + rotors.rotors(1) shouldBe r1 + rotors.rotors(2) shouldBe r2 + + + it should "reject duplicates when constructed via varargs" in: + intercept[IllegalArgumentException] { + Rotors(Rotor("I A A"), Rotor("I A B")) + } + + behavior of "Rotors.out" + + it should "chain RotorState.out from left-most to right-most (3 rotors)" in: + val r0 = Rotor("I A A") + val r1 = Rotor("II A A") + val r2 = Rotor("III A A") + val rotors = Rotors(IArray(r0, r1, r2)) + + val input = 'Z' + // Out path is left-most to right-most: r2, then r1, then r0 + val step1 = r2.out(input) + val step2 = r1.out(step1) + val step3 = r0.out(step2) + + rotors.out(input) shouldBe step3 + + it should "match single rotor behavior and support char overload" in: + val single = Rotors(IArray(Rotor("I A A"))) + val g = 'M'.glyph + + single.out(g) shouldBe (single.rotors.head.out(g), List(12, 2)) + single.out('M') shouldBe single.out(g.char) diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/SettingsSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/SettingsSpec.scala new file mode 100644 index 0000000..08a8f38 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/SettingsSpec.scala @@ -0,0 +1,38 @@ +package cryptic +package cipher +package enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.TryValues + +class SettingsSpec extends AnyFlatSpec with Matchers with TryValues: + behavior of "Settings" + "Settings" should "parse with start positions" in: + Settings.parse("III-II-I ABC DEF C ABTD").success.value shouldBe Settings( + Rotors("III-II-I ABC DEF"), + Reflector.C, + PlugBoard("ABTD"), + false + ) + "Settings" should "parse with without start positions" in: + Settings.parse("III-II-I ABC C ABTD").success.value shouldBe Settings( + Rotors("III-II-I ABC AAA"), + Reflector.C, + PlugBoard("ABTD"), + true + ) + "Settings" should "parse with start positions without plugboard" in: + Settings.parse("III-II-I ABC DEF C").success.value shouldBe Settings( + Rotors("III-II-I ABC DEF"), + Reflector.C, + PlugBoard.empty, + false + ) + "Settings" should "parse with with neither start positions nor plugboard" in: + Settings.parse("III-II-I ABC C").success.value shouldBe Settings( + Rotors("III-II-I ABC AAA"), + Reflector.C, + PlugBoard.empty, + true + ) diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/WheelSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/WheelSpec.scala new file mode 100644 index 0000000..c651799 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/WheelSpec.scala @@ -0,0 +1,54 @@ +package cryptic +package cipher +package enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class WheelSpec extends AnyFlatSpec with Matchers: + behavior of "Rotor.apply" + + it should "construct a rotor from valid notch and wiring strings" in: + val r = Wheel("Custom", "EKMFLGDQVZNTOWYHXUSPAIBRCJ", "R") + r.carry('R'.glyph) shouldBe true + + it should "reject an empty (or entirely invalid) notch" in: + intercept[IllegalArgumentException] { + Wheel("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "") + } + intercept[IllegalArgumentException] { + Wheel("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "--- __") + } + + it should "reject a notch with repeating characters" in: + intercept[IllegalArgumentException] { + Wheel("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "AA") + } + + it should "require wiring length to equal Glyph.mod (26)" in: + // Too short (25) + intercept[IllegalArgumentException] { + Wheel("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXY", "A") + } + // Too long (27) + intercept[IllegalArgumentException] { + Wheel("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZA", "A") + } + + behavior of "Rotor.apply(name:String)" + + it should "return predefined rotors by name I..VI" in: + Wheel("I").toString shouldBe "I" + Wheel("II").toString shouldBe "II" + Wheel("III").toString shouldBe "III" + Wheel("IV").toString shouldBe "IV" + Wheel("V").toString shouldBe "V" + Wheel("VI").toString shouldBe "VI" + + it should "throw for unknown rotor names" in: + intercept[IllegalArgumentException] { + Wheel("VII") + } + intercept[IllegalArgumentException] { + Wheel("") + } diff --git a/cipher-javax/src/main/scala/cryptic/cipher/javax.scala b/cipher-javax/src/main/scala/cryptic/cipher/javax.scala index 15f4f85..a272579 100644 --- a/cipher-javax/src/main/scala/cryptic/cipher/javax.scala +++ b/cipher-javax/src/main/scala/cryptic/cipher/javax.scala @@ -1,8 +1,6 @@ package cryptic package cipher -import org.bouncycastle.math.ec.rfc8032.Ed25519.Algorithm - import java.security.SecureRandom import javax.crypto.{KeyGenerator, SecretKey, SecretKeyFactory} import javax.crypto.spec.{PBEKeySpec, SecretKeySpec} diff --git a/core/src/main/scala/cryptic/Cryptic.scala b/core/src/main/scala/cryptic/Cryptic.scala index fff6237..231d87b 100644 --- a/core/src/main/scala/cryptic/Cryptic.scala +++ b/core/src/main/scala/cryptic/Cryptic.scala @@ -504,6 +504,9 @@ object Encrypted: yield encrypted new Encrypted(cipherText) + def apply[F[_]: Functor, V: Codec](bytes: Array[Byte]): Encrypted[F, V] = + Encrypted[F, V](CipherText(bytes.immutable).pure) + /** Constructs an `Encrypted` value from a precomputed cipher text effect. * * Use this when you already have `F[CipherText]` and want to wrap it as diff --git a/core/src/main/scala/cryptic/core.scala b/core/src/main/scala/cryptic/core.scala index 67895e2..6988ca1 100644 --- a/core/src/main/scala/cryptic/core.scala +++ b/core/src/main/scala/cryptic/core.scala @@ -2,9 +2,9 @@ package cryptic import java.nio.ByteBuffer import scala.annotation.targetName -import scala.concurrent.Future +import scala.io.Source import scala.reflect.ClassTag -import scala.util.{Failure, Success, Try} +import scala.util.{Failure, Try} /** Identity type alias used to represent pure (non-effectful) values. */ type Id[A] = A @@ -59,7 +59,7 @@ object PlainText: /** UTF-8 encodes a string into PlainText with an empty aad. */ def apply(x: String): PlainText = apply(x, AAD.empty) - /** UTF-8 encodes a string into PlainText with a aad. */ + /** UTF-8 encodes a string into PlainText with an aad. */ def apply(x: String, aad: AAD): PlainText = apply(x.getBytes().immutable, aad) @@ -288,6 +288,7 @@ extension (str: String) def aad: AAD = str.getBytes.aad @targetName("stringToBytes") def bytes: IArray[Byte] = str.getBytes.immutable + def fromResource:String = Source.fromResource(str).slurp extension (array: IArray[Byte]) /** Mutable Array view over an IArray[Byte]. */ @@ -369,3 +370,11 @@ extension (d: Double) buffer.putDouble(d) buffer.array().immutable def aad: AAD = AAD(bytes) + +extension (source: Source) + def using[A](f: Source => A): A = + try + f(source) + finally + source.close() + def slurp: String = source.using(_.mkString) diff --git a/core/src/test/scala/cryptic/AADParameterizedSpec.scala b/core/src/test/scala/cryptic/AADParameterizedSpec.scala new file mode 100644 index 0000000..1a582e1 --- /dev/null +++ b/core/src/test/scala/cryptic/AADParameterizedSpec.scala @@ -0,0 +1,30 @@ +package cryptic + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks + +class AADParameterizedSpec + extends AnyFlatSpec + with Matchers + with TableDrivenPropertyChecks: + + "AAD extensions" should "correctly round-trip different data types" in: + val stringData = Table( + ("input", "expectedLength"), // Headers + ("hello", 5), + ("secret payload", 14), + ("", 0) + ) + + forAll(stringData)((input: String, expectedLength: Int) => + val aad = input.aad + + aad.string shouldBe input + aad.bytes.length shouldBe expectedLength + ) + + it should "correctly round-trip numeric types" in: + val intData = List(1, 0, -999, Int.MaxValue) + + for number <- intData do number.aad.int shouldBe number diff --git a/project/plugins.sbt b/project/plugins.sbt index b2cf64b..f3f8906 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -4,3 +4,5 @@ addSbtPlugin("com.lightbend.paradox" % "sbt-paradox" % "0.10.7") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.5") addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.2") addSbtPlugin("com.github.sbt" % "sbt-github-actions" % "0.28.0") +// Fat-jar support +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.2.0")