From 04136ef264fec469e90712313921936d2ba95e03 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 10 Mar 2020 13:31:38 -0600 Subject: [PATCH 1/5] Support patterned answers --- devel/moth.py | 21 +++++--- example-puzzles/example/4/puzzle.moth | 2 + theme/puzzle.html | 1 + theme/puzzle.js | 78 +++++++++++++++++++-------- 4 files changed, 74 insertions(+), 28 deletions(-) diff --git a/devel/moth.py b/devel/moth.py index 0c8af68..58d17f7 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): @@ -129,6 +126,7 @@ class Puzzle: self.summary = None self.authors = [] self.answers = [] + self.xAnchors = {"begin", "end"} self.scripts = [] self.pattern = None self.hint = None @@ -214,6 +212,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): @@ -447,12 +455,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..23a14df 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -52,16 +52,46 @@ function devel_addin(obj, e) { } - // The routine used to hash answers in compiled puzzle packages -function djb2hash(buf) { - let h = 5381 - 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 +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. + for (let len = 0; len <= answer.length; len += 1) { + if (window.puzzle.xAnchors.includes("end") && (len != answer.length)) { + console.log(` Skipping unanchored end (len=${len})`) + continue + } + console.log(" What about length", len) + for (let pos = 0; pos < answer.length - len + 1; pos += 1) { + if (window.puzzle.xAnchors.includes("begin") && (pos > 0)) { + console.log(` Skipping unanchored begin (pos=${pos})`) + continue + } + let sub = answer.substring(pos, pos+len) + let digest = await sha256Hash(sub) + + console.log(" Could it be", sub, digest) + if (digest == correctHash) { + console.log(" YESSS") + return sub + } + } + } } - return h + return false } @@ -80,6 +110,14 @@ 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), @@ -180,21 +218,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 => { + if (correct) { + document.querySelector("[name=xAnswer").value = 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() { From a963daa19aebbac8a074b0a954fef7086fa983cd Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 10 Mar 2020 14:28:50 -0600 Subject: [PATCH 2/5] submit right form, remove debugging --- devel/devel-server.py | 4 ++-- theme/puzzle.js | 7 +------ 2 files changed, 3 insertions(+), 8 deletions(-) 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/theme/puzzle.js b/theme/puzzle.js index 23a14df..6eb5896 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -71,21 +71,16 @@ async function possiblyCorrect(answer) { // something like the github 404 page. for (let len = 0; len <= answer.length; len += 1) { if (window.puzzle.xAnchors.includes("end") && (len != answer.length)) { - console.log(` Skipping unanchored end (len=${len})`) continue } - console.log(" What about length", len) for (let pos = 0; pos < answer.length - len + 1; pos += 1) { if (window.puzzle.xAnchors.includes("begin") && (pos > 0)) { - console.log(` Skipping unanchored begin (pos=${pos})`) continue } let sub = answer.substring(pos, pos+len) let digest = await sha256Hash(sub) - console.log(" Could it be", sub, digest) if (digest == correctHash) { - console.log(" YESSS") return sub } } @@ -120,7 +115,7 @@ function submit(e) { window.data = data fetch("answer", { method: "POST", - body: new FormData(e.target), + body: data, }) .then(resp => { if (resp.ok) { From dac6fd40e76c5240042fa0ec01f560eb217ed6bc Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 10 Mar 2020 14:58:43 -0600 Subject: [PATCH 3/5] Change changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) 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 From fe0805e9c1cb1e081ffb9513ba0c37bf709b04ea Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 10 Mar 2020 15:03:32 -0600 Subject: [PATCH 4/5] Also set xAnswer for wrong answers --- theme/puzzle.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/theme/puzzle.js b/theme/puzzle.js index 6eb5896..7ffbd61 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -215,8 +215,8 @@ function answerCheck(e) { possiblyCorrect(answer) .then (correct => { + document.querySelector("[name=xAnswer").value = correct || answer if (correct) { - document.querySelector("[name=xAnswer").value = correct ok.textContent = "⭕" ok.title = "Possibly correct" } else { From 47edd56937ec5202b5193e1bec63fbdd1d191810 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 10 Mar 2020 16:40:37 -0600 Subject: [PATCH 5/5] Attempt to make client work with v3.4 and earlier mothballs --- theme/puzzle.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/theme/puzzle.js b/theme/puzzle.js index 7ffbd61..868bc80 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -51,6 +51,16 @@ function devel_addin(obj, e) { } } +// 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 + // 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 + } + return h +} // The routine used to hash answers in compiled puzzle packages async function sha256Hash(message) { @@ -69,6 +79,10 @@ async function possiblyCorrect(answer) { // 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