casebench
test cases straight onto the board.
git clone https://owenrusk.dev/casebench.git
commit 662cc35b720ed1bb4e6b9b3ee23d05e856419ba9 parent d7077748bc1326bdc2a12408215fd70d1fe8271d author Owen Rusk <owen@papermothgames.com> date 2025-05-12 16:18:47 -0500
lock: more than one line, checked together one hash over all the lines, so a wrong try can't tell anyone which line was right.
| casebench/cli.py | +7 | -4 |
| casebench/db.py | +7 | -5 |
| casebench/lock.py | +20 | -7 |
diff --git a/casebench/cli.py b/casebench/cli.py index 7c7a768..31eaa7a 100644 --- a/casebench/cli.py +++ b/casebench/cli.py @@ -30,10 +30,10 @@ def main(argv: list[str] | None = None) -> int: note.add_argument("--item", help="the item to write on; the case's title if left out") note.set_defaults(run=run_note)- locker = sub.add_parser("lock", help="lock a case behind an answer")+ locker = sub.add_parser("lock", help="lock a case behind one or more answers") locker.add_argument("case") locker.add_argument("--player", required=True)- locker.add_argument("--answer", required=True)+ locker.add_argument("--answer", action="append", required=True, help="one per line of the lock") locker.add_argument("--text", default="locked.", help="what the lock says (default: locked.)") locker.set_defaults(run=run_lock) @@ -82,6 +82,9 @@ def run_lock(conn: psycopg.Connection, args: argparse.Namespace) -> None: who = player(conn, args.player) if db.source(conn, who, args.case) is None: raise Fail(f"{args.player} doesn't have {args.case}")- salt, answer_hash = lock.make(args.answer)- db.lock(conn, who, args.case, args.text, salt, answer_hash)+ try: + new = lock.make(args.answer, args.text) + except ValueError as e: + raise Fail(str(e)) from None + db.lock(conn, who, args.case, new) print(f"{args.player}: locked {args.case}") diff --git a/casebench/db.py b/casebench/db.py index 6dc6b31..943ab8e 100644 --- a/casebench/db.py +++ b/casebench/db.py @@ -3,6 +3,7 @@ import hashlib import psycopg from .casefile import Case, Image +from .lock import Lock def connect(dsn: str) -> psycopg.Connection: @@ -66,11 +67,12 @@ def note(conn: psycopg.Connection, player: str, case_id: str, item_id: str | Non conn.commit()-def lock(conn: psycopg.Connection, player: str, case_id: str, text: str, salt: bytes, answer_hash: str) -> None:+def lock(conn: psycopg.Connection, player: str, case_id: str, new: Lock) -> None: conn.execute(- "insert into board_locks (player_id, case_id, text, salt, answer_hash) values (%s, %s, %s, %s, %s)"- " on conflict (player_id, case_id) do update"- " set text = excluded.text, salt = excluded.salt, answer_hash = excluded.answer_hash, opened_at = null",- (player, case_id, text, salt, answer_hash),+ "insert into board_locks (player_id, case_id, text, lines, salt, answer_hash)" + " values (%s, %s, %s, %s, %s, %s)" + " on conflict (player_id, case_id) do update set text = excluded.text, lines = excluded.lines," + " salt = excluded.salt, answer_hash = excluded.answer_hash, opened_at = null", + (player, case_id, new.text, new.lines, new.salt, new.hash), ) conn.commit() diff --git a/casebench/lock.py b/casebench/lock.py index c9e4ab9..68be9d4 100644 --- a/casebench/lock.py +++ b/casebench/lock.py @@ -1,21 +1,34 @@ import hashlib import hmac import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Lock: + text: str + lines: int + salt: bytes + hash: str def normalize(answer: str) -> str: return " ".join(answer.casefold().split())-def digest(answer: str, salt: bytes) -> str:- return hashlib.scrypt(normalize(answer).encode(), salt=salt, n=2**14, r=8, p=1, dklen=32).hex()+def digest(lines: list[str], salt: bytes) -> str: + joined = "\n".join(normalize(line) for line in lines) + return hashlib.scrypt(joined.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32).hex()-def make(answer: str) -> tuple[bytes, str]:+def make(answers: list[str], text: str = "locked.") -> Lock: + if not answers or not all(normalize(answer) for answer in answers): + raise ValueError("a lock needs an answer on every line") salt = os.urandom(16)- return salt, digest(answer, salt)+ return Lock(text, len(answers), salt, digest(answers, salt))-def opens(salt: bytes, stored: str, typed: str) -> bool:- # the board calls this when someone types into a lock.- return hmac.compare_digest(digest(typed, salt), stored)+def opens(lock: Lock, typed: list[str]) -> bool: + # the board calls this when someone types into a lock. the lines are + # checked as one, so a wrong try never says which line was right. + return len(typed) == lock.lines and hmac.compare_digest(digest(typed, lock.salt), lock.hash)