Neale Pickett
·
2024-01-03
moth.mjs
1/**
2 * Hash/digest functions
3 */
4class Hash {
5 /**
6 * Dan Bernstein hash
7 *
8 * Used until MOTH v3.5
9 *
10 * @param {string} buf Input
11 * @returns {number}
12 */
13 static djb2(buf) {
14 let h = 5381
15 for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte
16 // JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
17 // So we have to do "unsigned right shift" by zero to get it back to unsigned.
18 h = ((h * 33) + c) >>> 0
19 }
20 return h
21 }
22
23 /**
24 * Dan Bernstein hash with xor
25 *
26 * @param {string} buf Input
27 * @returns {number}
28 */
29 static djb2xor(buf) {
30 let h = 5381
31 for (let c of (new TextEncoder()).encode(buf)) {
32 h = ((h * 33) ^ c) >>> 0
33 }
34 return h
35 }
36
37 /**
38 * SHA 256
39 *
40 * Used until MOTH v4.5
41 *
42 * @param {string} buf Input
43 * @returns {Promise.<string>} hex-encoded digest
44 */
45 static async sha256(buf) {
46 const msgUint8 = new TextEncoder().encode(buf)
47 const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
48 const hashArray = Array.from(new Uint8Array(hashBuffer))
49 return this.hexlify(hashArray);
50 }
51
52 /**
53 * SHA 1, but only the first 4 hexits (2 octets).
54 *
55 * Git uses this technique with 7 hexits (default) as a "short identifier".
56 *
57 * @param {string} buf Input
58 */
59 static async sha1_slice(buf, end=4) {
60 const msgUint8 = new TextEncoder().encode(buf)
61 const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8)
62 const hashArray = Array.from(new Uint8Array(hashBuffer))
63 const hexits = this.hexlify(hashArray)
64 return hexits.slice(0, end)
65 }
66
67 /**
68 * Hex-encode a byte array
69 *
70 * @param {number[]} buf Byte array
71 * @returns {string}
72 */
73 static hexlify(buf) {
74 return buf.map(b => b.toString(16).padStart(2, "0")).join("")
75 }
76
77 /**
78 * Apply every hash to the input buffer.
79 *
80 * @param {string} buf Input
81 * @returns {Promise.<string[]>}
82 */
83 static async All(buf) {
84 return [
85 String(this.djb2(buf)),
86 await this.sha256(buf),
87 await this.sha1_slice(buf),
88 ]
89 }
90}
91
92/**
93 * A point award.
94 */
95class Award {
96 constructor(when, teamid, category, points) {
97 /** Unix epoch timestamp for this award
98 * @type {number}
99 */
100 this.When = when
101 /** Team ID this award belongs to
102 * @type {string}
103 */
104 this.TeamID = teamid
105 /** Puzzle category for this award
106 * @type {string}
107 */
108 this.Category = category
109 /** Points value of this award
110 * @type {number}
111 */
112 this.Points = points
113 }
114}
115
116/**
117 * A puzzle.
118 *
119 * A new Puzzle only knows its category and point value.
120 * If you want to populate it with meta-information, you must call Populate().
121 *
122 * Parameters created by Populate are described in the server source code:
123 * {@link https://pkg.go.dev/github.com/dirtbags/moth/v4/pkg/transpile#Puzzle}
124 *
125 */
126class Puzzle {
127 /**
128 * @param {Server} server
129 * @param {string} category
130 * @param {number} points
131 */
132 constructor (server, category, points) {
133 if (points < 1) {
134 throw(`Invalid points value: ${points}`)
135 }
136
137 /** Server where this puzzle lives
138 * @type {Server}
139 */
140 this.server = server
141
142 /** Category this puzzle belongs to */
143 this.Category = String(category)
144
145 /** Point value of this puzzle */
146 this.Points = Number(points)
147
148 /** Error returned trying to retrieve this puzzle */
149 this.Error = {
150 /** Status code provided by server */
151 Status: 0,
152 /** Status text provided by server */
153 StatusText: "",
154 /** Full text of server error */
155 Body: "",
156 }
157 }
158
159 /**
160 * Populate this Puzzle object with meta-information from the server.
161 */
162 async Populate() {
163 let resp = await this.Get("puzzle.json")
164 if (!resp.ok) {
165 let body = await resp.text()
166 this.Error = {
167 Status: resp.status,
168 StatusText: resp.statusText,
169 Body: body,
170 }
171 throw(this.Error)
172 }
173 let obj = await resp.json()
174 Object.assign(this, obj)
175
176 // Make sure lists are lists
177 this.AnswerHashes ||= []
178 this.Answers ||= []
179 this.Attachments ||= []
180 this.Authors ||= []
181 this.Scripts ||= []
182 this.Debug ||= {}
183 this.Debug.Errors ||= []
184 this.Debug.Hints ||= []
185 this.Debug.Log ||= []
186 this.Extra ||= {}
187
188 // Be ready to handle a future revision to the Puzzle structure
189 this.Objective ||= this.Extra.Objective
190 this.KSAs ||= this.Extra.KSAs || []
191 this.Success ||= this.Extra.Success || {}
192 }
193
194 /**
195 * Get a resource associated with this puzzle.
196 *
197 * @param {string} filename Attachment/Script to retrieve
198 * @returns {Promise.<Response>}
199 */
200 Get(filename) {
201 return this.server.GetContent(this.Category, this.Points, filename)
202 }
203
204 /**
205 * Check if a string is possibly correct.
206 *
207 * The server sends a list of answer hashes with each puzzle: this method
208 * checks to see if any of those hashes match a hash of the string.
209 *
210 * The MOTH development team likes obscure hash functions with a lot of
211 * collisions, which means that a given input may match another possible
212 * string's hash. We do this so that if you run a brute force attack against
213 * the list of hashes, you have to write your own brute force program, and
214 * you still have to pick through a lot of potentially correct answers when
215 * it's done.
216 *
217 * @param {string} str User-submitted possible answer
218 * @returns {Promise.<boolean>}
219 */
220 async IsPossiblyCorrect(str) {
221 let userAnswerHashes = await Hash.All(str)
222
223 for (let pah of this.AnswerHashes) {
224 for (let uah of userAnswerHashes) {
225 if (pah == uah) {
226 return true
227 }
228 }
229 }
230 return false
231 }
232
233 /**
234 * Submit a proposed answer for points.
235 *
236 * The returned promise will fail if anything goes wrong, including the
237 * proposed answer being rejected.
238 *
239 * @param {string} proposed Answer to submit
240 * @returns {Promise.<string>} Success message
241 */
242 SubmitAnswer(proposed) {
243 return this.server.SubmitAnswer(this.Category, this.Points, proposed)
244 }
245}
246
247/**
248 * A snapshot of scores.
249 */
250class Scores {
251 constructor() {
252 /**
253 * Timestamp of this score snapshot
254 * @type number
255 */
256 this.Timestamp = 0
257
258 /**
259 * All categories present in this snapshot.
260 *
261 * ECMAScript sets preserve order, so iterating over this will yield
262 * categories as they were added to the points log.
263 *
264 * @type {Set.<string>}
265 */
266 this.Categories = new Set()
267
268 /**
269 * All team IDs present in this snapshot
270 * @type {Set.<string>}
271 */
272 this.TeamIDs = new Set()
273
274 /**
275 * Highest score in each category
276 * @type {Object.<string,number>}
277 */
278 this.MaxPoints = {}
279
280 this.categoryTeamPoints = {}
281 }
282
283 /**
284 * Return a sorted list of category names
285 *
286 * @returns {string[]}
287 */
288 SortedCategories() {
289 let categories = [...this.Categories]
290 categories.sort((a,b) => a.localeCompare(b, "en", {sensitivity: "base"}))
291 return categories
292 }
293
294 /**
295 * Add an award to a team's score.
296 *
297 * Updates this.Timestamp to the award's timestamp.
298 *
299 * @param {Award} award
300 */
301 Add(award) {
302 this.Timestamp = award.Timestamp
303 this.Categories.add(award.Category)
304 this.TeamIDs.add(award.TeamID)
305
306 let teamPoints = (this.categoryTeamPoints[award.Category] ??= {})
307 let points = (teamPoints[award.TeamID] || 0) + award.Points
308 teamPoints[award.TeamID] = points
309
310 let max = this.MaxPoints[award.Category] || 0
311 this.MaxPoints[award.Category] = Math.max(max, points)
312 }
313
314 /**
315 * Get a team's score within a category.
316 *
317 * @param {string} category
318 * @param {string} teamID
319 * @returns {number}
320 */
321 GetPoints(category, teamID) {
322 let teamPoints = this.categoryTeamPoints[category] || {}
323 return teamPoints[teamID] || 0
324 }
325
326 /**
327 * Calculate a team's score in a category, using the Cyber Fire algorithm.
328 *
329 *@param {string} category
330 * @param {string} teamID
331 */
332 CyFiCategoryScore(category, teamID) {
333 return this.GetPoints(category, teamID) / this.MaxPoints[category]
334 }
335
336 /**
337 * Calculate a team's overall score, using the Cyber Fire algorithm.
338 *
339 *@param {string} category
340 * @param {string} teamID
341 * @returns {number}
342 */
343 CyFiScore(teamID) {
344 let score = 0
345 for (let category of this.Categories) {
346 score += this.CyFiCategoryScore(category, teamID)
347 }
348 return score
349 }
350}
351
352/**
353 * MOTH instance state.
354 */
355class State {
356 /**
357 * @param {Server} server Server where we got this
358 * @param {Object} obj Raw state data
359 */
360 constructor(server, obj) {
361 for (let key of ["Config", "TeamNames", "PointsLog"]) {
362 if (!obj[key]) {
363 throw(`Missing state property: ${key}`)
364 }
365 }
366 this.server = server
367
368 /** Configuration */
369 this.Config = {
370 /** Is the server in development mode?
371 * @type {boolean}
372 */
373 Devel: obj.Config.Devel,
374 }
375
376 /** True if the server is in enabled state, or if we don't know */
377 this.Enabled = obj.Enabled ?? true
378
379 /** Map from Team ID to Team Name
380 * @type {Object.<string,string>}
381 */
382 this.TeamNames = obj.TeamNames
383
384 /** Map from category name to puzzle point values
385 * @type {Object.<string,number>}
386 */
387 this.PointsByCategory = obj.Puzzles
388
389 /** Log of points awarded
390 * @type {Award[]}
391 */
392 this.PointsLog = obj.PointsLog.map(entry => new Award(entry[0], entry[1], entry[2], entry[3]))
393 }
394
395 /**
396 * Returns a sorted list of open category names
397 *
398 * @returns {string[]} List of categories
399 */
400 Categories() {
401 let ret = []
402 for (let category in this.PointsByCategory) {
403 ret.push(category)
404 }
405 ret.sort()
406 return ret
407 }
408
409 /**
410 * Check whether a category contains unsolved puzzles.
411 *
412 * The server adds a puzzle with 0 points in every "solved" category,
413 * so this just checks whether there is a 0-point puzzle in the category's point list.
414 *
415 * @param {string} category
416 * @returns {boolean}
417 */
418 ContainsUnsolved(category) {
419 return !this.PointsByCategory[category].includes(0)
420 }
421
422 /**
423 * Is the server in development mode?
424 *
425 * @returns {boolean}
426 */
427 DevelopmentMode() {
428 return this.Config && this.Config.Devel
429 }
430
431 /**
432 * Return all open puzzles.
433 *
434 * The returned list will be sorted by (category, points).
435 * If not categories are given, all puzzles will be returned.
436 *
437 * @param {string} categories Limit results to these categories
438 * @returns {Puzzle[]}
439 */
440 Puzzles(...categories) {
441 if (categories.length == 0) {
442 categories = this.Categories()
443 }
444 let ret = []
445 for (let category of categories) {
446 for (let points of this.PointsByCategory[category]) {
447 if (0 == points) {
448 // This means all potential puzzles in the category are open
449 continue
450 }
451 let p = new Puzzle(this.server, category, points)
452 ret.push(p)
453 }
454 }
455 return ret
456 }
457
458 /**
459 * Has this puzzle been solved by this team?
460 *
461 * @param {Puzzle} puzzle
462 * @param {string} teamID Team to check, default the logged-in team
463 * @returns {boolean}
464 */
465 IsSolved(puzzle, teamID="self") {
466 for (let award of this.PointsLog) {
467 if (
468 (award.Category == puzzle.Category)
469 && (award.Points == puzzle.Points)
470 && (award.TeamID == teamID)
471 ) {
472 return true
473 }
474 }
475 return false
476 }
477
478 /**
479 * Replay scores.
480 *
481 * MOTH has no notion of who is "winning", we consider this a user interface
482 * decision. There are lots of interesting options: see
483 * [scoring]{@link ../docs/scoring.md} for more.
484 *
485 * @yields {Scores} Snapshot at a point in time
486 */
487 * ScoresHistory() {
488 let scores = new Scores()
489 for (let award of this.PointsLog) {
490 scores.Add(award)
491 yield scores
492 }
493 }
494
495 /**
496 * Calculate the current scores.
497 *
498 * @returns {Scores}
499 */
500 CurrentScores() {
501 return [...this.ScoresHistory()].pop()
502 }
503}
504
505/**
506 * A MOTH Server interface.
507 *
508 * This uses localStorage to remember Team ID,
509 * and will send a Team ID with every request, if it can find one.
510 */
511class Server {
512 /**
513 * @param {string | URL} baseUrl Base URL to server, for constructing API URLs
514 */
515 constructor(baseUrl) {
516 if (!baseUrl) {
517 throw("Must provide baseURL")
518 }
519 this.baseUrl = new URL(baseUrl, location)
520 this.teamIDKey = this.baseUrl.toString() + " teamID"
521 this.TeamID = localStorage[this.teamIDKey]
522 }
523
524 /**
525 * Fetch a MOTH resource.
526 *
527 * If anything other than a 2xx code is returned,
528 * this function throws an error.
529 *
530 * This always sends teamID.
531 * If args is set, POST will be used instead of GET
532 *
533 * @param {string} path Path to API endpoint
534 * @param {Object.<string,string>} args Key/Values to send in POST data
535 * @returns {Promise.<Response>} Response
536 */
537 fetch(path, args={}) {
538 let body = new URLSearchParams(args)
539 if (this.TeamID && !body.has("id")) {
540 body.set("id", this.TeamID)
541 }
542
543 let url = new URL(path, this.baseUrl)
544 return fetch(url, {
545 method: "POST",
546 body,
547 cache: "no-cache",
548 })
549 }
550
551 /**
552 * Send a request to a JSend API endpoint.
553 *
554 * @param {string} path Path to API endpoint
555 * @param {Object.<string,string>} args Key/Values to send in POST
556 * @returns {Promise.<Object>} JSend Data
557 */
558 async call(path, args={}) {
559 let resp = await this.fetch(path, args)
560 let obj = await resp.json()
561 switch (obj.status) {
562 case "success":
563 return obj.data
564 case "fail":
565 throw new Error(obj.data.description || obj.data.short || obj.data)
566 case "error":
567 throw new Error(obj.message)
568 default:
569 throw new Error(`Unknown JSend status: ${obj.status}`)
570 }
571 }
572
573 /**
574 * Make a new URL for the given resource.
575 *
576 * The returned URL instance will be absolute, and immune to changes to the
577 * page that would affect relative URLs.
578 *
579 * @returns {URL}
580 */
581 URL(url) {
582 return new URL(url, this.baseUrl)
583 }
584
585 /**
586 * Are we logged in to the server?
587 *
588 * @returns {boolean}
589 */
590 LoggedIn() {
591 return this.TeamID ? true : false
592 }
593
594 /**
595 * Forget about any previous Team ID.
596 *
597 * This is equivalent to logging out.
598 */
599 Reset() {
600 localStorage.removeItem(this.teamIDKey)
601 this.TeamID = null
602 }
603
604 /**
605 * Fetch current contest state.
606 *
607 * @returns {Promise.<State>}
608 */
609 async GetState() {
610 let resp = await this.fetch("/state")
611 let obj = await resp.json()
612 return new State(this, obj)
613 }
614
615 /**
616 * Log in to a team.
617 *
618 * This calls the server's registration endpoint; if the call succeds, or
619 * fails with "team already exists", the login is returned as successful.
620 *
621 * @param {string} teamID
622 * @param {string} teamName
623 * @returns {Promise.<string>} Success message from server
624 */
625 async Login(teamID, teamName) {
626 let data = await this.call("/register", {id: teamID, name: teamName})
627 this.TeamID = teamID
628 this.TeamName = teamName
629 localStorage[this.teamIDKey] = teamID
630 return data.description || data.short
631 }
632
633 /**
634 * Submit a proposed answer for points.
635 *
636 * The returned promise will fail if anything goes wrong, including the
637 * proposed answer being rejected.
638 *
639 * @param {string} category Category of puzzle
640 * @param {number} points Point value of puzzle
641 * @param {string} proposed Answer to submit
642 * @returns {Promise.<string>} Success message
643 */
644 async SubmitAnswer(category, points, proposed) {
645 let data = await this.call("/answer", {
646 cat: category,
647 points,
648 answer: proposed,
649 })
650 return data.description || data.short
651 }
652
653 /**
654 * Fetch a file associated with a puzzle.
655 *
656 * @param {string} category Category of puzzle
657 * @param {number} points Point value of puzzle
658 * @param {string} filename
659 * @returns {Promise.<Response>}
660 */
661 GetContent(category, points, filename) {
662 return this.fetch(`/content/${category}/${points}/${filename}`)
663 }
664
665 /**
666 * Return a Puzzle object.
667 *
668 * New Puzzle objects only know their category and point value.
669 * See docstrings on the Puzzle object for more information.
670 *
671 * @param {string} category
672 * @param {number} points
673 * @returns {Puzzle}
674 */
675 GetPuzzle(category, points) {
676 return new Puzzle(this, category, points)
677 }
678}
679
680export {
681 Hash,
682 Server,
683 State,
684}