diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc955c..10bd406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- We are now using SHA256 instead of djb2hash ### Added - URL parameter to points.json to allow returning only the JSON for a single team by its team id (e.g., points.json?id=abc123). @@ -13,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - add_script_stream convenience function allows easy script addition to puzzle - Autobuild Docker images to test buildability - Extract and use X-Forwarded-For headers in mothd logging +- Mothballs can now specify `X-Answer-Pattern` header fields, which allow `*` + at the beginning, end, or both, of an answer. This is `X-` because we + are hoping to change how this works in the future. ### Fixed - Handle cases where non-legacy puzzles don't have an `author` attribute - Handle YAML-formatted file and script lists as expected diff --git a/devel/devel-server.py b/devel/devel-server.py index e1e6cae..1c91c00 100755 --- a/devel/devel-server.py +++ b/devel/devel-server.py @@ -77,12 +77,12 @@ class MothRequestHandler(http.server.SimpleHTTPRequestHandler): "status": "success", "data": { "short": "", - "description": "Provided answer was not in list of answers" + "description": "%r was not in list of answers" % self.req.get("answer") }, } if self.req.get("answer") in puzzle.answers: - ret["data"]["description"] = "Answer is correct" + ret["data"]["description"] = "Answer %r is correct" % self.req.get("answer") self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() diff --git a/devel/moth.py b/devel/moth.py index 420b272..0d9c411 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -22,11 +22,8 @@ messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' LOGGER = logging.getLogger(__name__) -def djb2hash(str): - h = 5381 - for c in str.encode("utf-8"): - h = ((h * 33) + c) & 0xffffffff - return h +def sha256hash(str): + return hashlib.sha256(str.encode("utf-8")).hexdigest() @contextlib.contextmanager def pushd(newdir): @@ -130,6 +127,7 @@ class Puzzle: self.summary = None self.authors = [] self.answers = [] + self.xAnchors = {"begin", "end"} self.scripts = [] self.pattern = None self.hint = None @@ -215,6 +213,16 @@ class Puzzle: if not isinstance(val, str): raise ValueError("Answers must be strings, got %s, instead" % (type(val),)) self.answers.append(val) + elif key == 'x-answer-pattern': + a = val.strip("*") + assert "*" not in a, "Patterns may only have * at the beginning and end" + assert "?" not in a, "Patterns do not currently support ? characters" + assert "[" not in a, "Patterns do not currently support character ranges" + self.answers.append(a) + if val.startswith("*"): + self.xAnchors.discard("begin") + if val.endswith("*"): + self.xAnchors.discard("end") elif key == "answers": for answer in val: if not isinstance(answer, str): @@ -448,12 +456,13 @@ class Puzzle: 'success': self.success, 'solution': self.solution, 'ksas': self.ksas, + 'xAnchors': list(self.xAnchors), } def hashes(self): "Return a list of answer hashes" - return [djb2hash(a) for a in self.answers] + return [sha256hash(a) for a in self.answers] class Category: diff --git a/example-puzzles/example/4/puzzle.moth b/example-puzzles/example/4/puzzle.moth index b021ecc..c06a653 100644 --- a/example-puzzles/example/4/puzzle.moth +++ b/example-puzzles/example/4/puzzle.moth @@ -1,6 +1,8 @@ Summary: Answer patterns Answer: command.com Answer: COMMAND.COM +X-Answer-Pattern: PINBALL.* +X-Answer-Pattern: pinball.* Author: neale Pattern: [0-9A-Za-z]{1,8}\.[A-Za-z]{1,3} diff --git a/theme/puzzle.html b/theme/puzzle.html index 88de8dc..a7be166 100644 --- a/theme/puzzle.html +++ b/theme/puzzle.html @@ -22,6 +22,7 @@
+ Team ID:
Answer:
diff --git a/theme/puzzle.js b/theme/puzzle.js index f0bbba3..868bc80 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -51,12 +51,10 @@ function devel_addin(obj, e) { } } - - -// The routine used to hash answers in compiled puzzle packages +// Hash routine used in v3.4 and earlier function djb2hash(buf) { let h = 5381 - for (let c of (new TextEncoder).encode(buf)) { // Encode as UTF-8 and read in each byte + for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte // JavaScript converts everything to a signed 32-bit integer when you do bitwise operations. // So we have to do "unsigned right shift" by zero to get it back to unsigned. h = (((h * 33) + c) & 0xffffffff) >>> 0 @@ -64,6 +62,47 @@ function djb2hash(buf) { return h } +// The routine used to hash answers in compiled puzzle packages +async function sha256Hash(message) { + const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array + const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); // hash the message + const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string + return hashHex; +} + +// Is the provided answer possibly correct? +async function possiblyCorrect(answer) { + for (let correctHash of window.puzzle.hashes) { + // CPU time is cheap. Especially if it's not our server's time. + // So we'll just try absolutely everything and see what happens. + // We're counting on hash collisions being extremely rare with the algorithm we use. + // And honestly, this pales in comparison to the amount of CPU being eaten by + // something like the github 404 page. + + if (djb2hash(answer) == correctHash) { + return answer + } + for (let len = 0; len <= answer.length; len += 1) { + if (window.puzzle.xAnchors.includes("end") && (len != answer.length)) { + continue + } + for (let pos = 0; pos < answer.length - len + 1; pos += 1) { + if (window.puzzle.xAnchors.includes("begin") && (pos > 0)) { + continue + } + let sub = answer.substring(pos, pos+len) + let digest = await sha256Hash(sub) + + if (digest == correctHash) { + return sub + } + } + } + } + return false +} + // Pop up a message function toast(message, timeout=5000) { @@ -80,9 +119,17 @@ function toast(message, timeout=5000) { // When the user submits an answer function submit(e) { e.preventDefault() + let data = new FormData(e.target) + + // Kludge for patterned answers + let xAnswer = data.get("xAnswer") + if (xAnswer) { + data.set("answer", xAnswer) + } + window.data = data fetch("answer", { method: "POST", - body: new FormData(e.target), + body: data, }) .then(resp => { if (resp.ok) { @@ -180,21 +227,17 @@ function answerCheck(e) { return } - let possiblyCorrect = false - let answerHash = djb2hash(answer) - for (let correctHash of window.puzzle.hashes) { - if (correctHash == answerHash) { - possiblyCorrect = true + possiblyCorrect(answer) + .then (correct => { + document.querySelector("[name=xAnswer").value = correct || answer + if (correct) { + ok.textContent = "⭕" + ok.title = "Possibly correct" + } else { + ok.textContent = "❌" + ok.title = "Definitely not correct" } - } - - if (possiblyCorrect) { - ok.textContent = "❓" - ok.title = "Possibly correct" - } else { - ok.textContent = "⛔" - ok.title = "Definitely not correct" - } + }) } function init() {