Merge pull request #139 from dirtbags/substring-answers

Substring answers. John said it was okay.
This commit is contained in:
Neale Pickett 2020-03-10 17:50:23 -06:00 committed by GitHub
commit be448eadc5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 87 additions and 27 deletions

View File

@ -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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Changed
- We are now using SHA256 instead of djb2hash
### Added ### Added
- URL parameter to points.json to allow returning only the JSON for a single - 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). 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 - add_script_stream convenience function allows easy script addition to puzzle
- Autobuild Docker images to test buildability - Autobuild Docker images to test buildability
- Extract and use X-Forwarded-For headers in mothd logging - 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 ### Fixed
- Handle cases where non-legacy puzzles don't have an `author` attribute - Handle cases where non-legacy puzzles don't have an `author` attribute
- Handle YAML-formatted file and script lists as expected - Handle YAML-formatted file and script lists as expected

View File

@ -77,12 +77,12 @@ class MothRequestHandler(http.server.SimpleHTTPRequestHandler):
"status": "success", "status": "success",
"data": { "data": {
"short": "", "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: 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_response(200)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.end_headers() self.end_headers()

View File

@ -22,11 +22,8 @@ messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def djb2hash(str): def sha256hash(str):
h = 5381 return hashlib.sha256(str.encode("utf-8")).hexdigest()
for c in str.encode("utf-8"):
h = ((h * 33) + c) & 0xffffffff
return h
@contextlib.contextmanager @contextlib.contextmanager
def pushd(newdir): def pushd(newdir):
@ -130,6 +127,7 @@ class Puzzle:
self.summary = None self.summary = None
self.authors = [] self.authors = []
self.answers = [] self.answers = []
self.xAnchors = {"begin", "end"}
self.scripts = [] self.scripts = []
self.pattern = None self.pattern = None
self.hint = None self.hint = None
@ -215,6 +213,16 @@ class Puzzle:
if not isinstance(val, str): if not isinstance(val, str):
raise ValueError("Answers must be strings, got %s, instead" % (type(val),)) raise ValueError("Answers must be strings, got %s, instead" % (type(val),))
self.answers.append(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": elif key == "answers":
for answer in val: for answer in val:
if not isinstance(answer, str): if not isinstance(answer, str):
@ -448,12 +456,13 @@ class Puzzle:
'success': self.success, 'success': self.success,
'solution': self.solution, 'solution': self.solution,
'ksas': self.ksas, 'ksas': self.ksas,
'xAnchors': list(self.xAnchors),
} }
def hashes(self): def hashes(self):
"Return a list of answer hashes" "Return a list of answer hashes"
return [djb2hash(a) for a in self.answers] return [sha256hash(a) for a in self.answers]
class Category: class Category:

View File

@ -1,6 +1,8 @@
Summary: Answer patterns Summary: Answer patterns
Answer: command.com Answer: command.com
Answer: COMMAND.COM Answer: COMMAND.COM
X-Answer-Pattern: PINBALL.*
X-Answer-Pattern: pinball.*
Author: neale Author: neale
Pattern: [0-9A-Za-z]{1,8}\.[A-Za-z]{1,3} Pattern: [0-9A-Za-z]{1,8}\.[A-Za-z]{1,3}

View File

@ -22,6 +22,7 @@
<form> <form>
<input type="hidden" name="cat"> <input type="hidden" name="cat">
<input type="hidden" name="points"> <input type="hidden" name="points">
<input type="hidden" name="xAnswer">
Team ID: <input type="text" name="id"> <br> Team ID: <input type="text" name="id"> <br>
Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br> Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br>
<input type="submit" value="Submit"> <input type="submit" value="Submit">

View File

@ -51,12 +51,10 @@ function devel_addin(obj, e) {
} }
} }
// Hash routine used in v3.4 and earlier
// The routine used to hash answers in compiled puzzle packages
function djb2hash(buf) { function djb2hash(buf) {
let h = 5381 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. // 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. // So we have to do "unsigned right shift" by zero to get it back to unsigned.
h = (((h * 33) + c) & 0xffffffff) >>> 0 h = (((h * 33) + c) & 0xffffffff) >>> 0
@ -64,6 +62,47 @@ function djb2hash(buf) {
return h 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 // Pop up a message
function toast(message, timeout=5000) { function toast(message, timeout=5000) {
@ -80,9 +119,17 @@ function toast(message, timeout=5000) {
// When the user submits an answer // When the user submits an answer
function submit(e) { function submit(e) {
e.preventDefault() 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", { fetch("answer", {
method: "POST", method: "POST",
body: new FormData(e.target), body: data,
}) })
.then(resp => { .then(resp => {
if (resp.ok) { if (resp.ok) {
@ -180,21 +227,17 @@ function answerCheck(e) {
return return
} }
let possiblyCorrect = false possiblyCorrect(answer)
let answerHash = djb2hash(answer) .then (correct => {
for (let correctHash of window.puzzle.hashes) { document.querySelector("[name=xAnswer").value = correct || answer
if (correctHash == answerHash) { if (correct) {
possiblyCorrect = true 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() { function init() {