owenrusk.dev

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)