From 25ac42bdae2a81eda33d5503a16f91e3eb673fcd Mon Sep 17 00:00:00 2001 From: Mawoka Date: Mon, 20 Jun 2022 21:47:12 +0200 Subject: [PATCH] :construction: Added Proof of Work on image-upload and more --- Pipfile | 1 + Pipfile.lock | 24 ++-- classquiz/helpers/hashcash.py | 167 ++++++++++++++++++++++++ classquiz/routers/editor.py | 38 +++++- classquiz/routers/users.py | 6 +- classquiz/storage/deta_storage.py | 1 + frontend/package.json | 1 + frontend/pnpm-lock.yaml | 9 ++ frontend/src/lib/editor.svelte | 16 ++- frontend/src/lib/editor/card.svelte | 4 + frontend/src/lib/editor/uploader.svelte | 5 +- frontend/src/lib/hashcash.ts | 47 +++++++ frontend/src/routes/index.svelte | 1 + 13 files changed, 297 insertions(+), 23 deletions(-) create mode 100644 classquiz/helpers/hashcash.py create mode 100644 frontend/src/lib/hashcash.ts diff --git a/Pipfile b/Pipfile index 4aad229..0fe471e 100644 --- a/Pipfile +++ b/Pipfile @@ -30,6 +30,7 @@ pillow = ">=9.1.1" authlib = "*" httpx = "*" itsdangerous = "*" +puremagic = "*" [dev-packages] coverage = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 5712a07..80bee32 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "259996d9979f6434bbd2a43c1146e10b485bca6df5fdb9d37319883800c56e33" + "sha256": "fe59e46268415d1b9088e7e27b599d88913b762a99d940336ec2e01e8e122378" }, "pipfile-spec": 6, "requires": { @@ -250,11 +250,11 @@ }, "certifi": { "hashes": [ - "sha256:9c5705e395cd70084351dd8ad5c41e65655e08ce46f2ec9cf6c2c08390f71eb7", - "sha256:f1d53542ee8cbedbe2118b5686372fb33c297fcd6379b050cca0ef13a597382a" + "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d", + "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412" ], "markers": "python_version >= '3.6'", - "version": "==2022.5.18.1" + "version": "==2022.6.15" }, "cffi": { "hashes": [ @@ -883,6 +883,14 @@ ], "version": "==2.9.3" }, + "puremagic": { + "hashes": [ + "sha256:3d5df26cc7ec9aebbf842a09115a2fa85dc59ea6414fa568572c44775d796cbc", + "sha256:40e32752827f2d0cea7e6f11454fab2b4bd440582af83d8a47bf403ab42364fa" + ], + "index": "pypi", + "version": "==1.14" + }, "pyasn1": { "hashes": [ "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", @@ -1084,11 +1092,11 @@ }, "setuptools": { "hashes": [ - "sha256:5a844ad6e190dccc67d6d7411d119c5152ce01f7c76be4d8a1eaa314501bba77", - "sha256:bf8a748ac98b09d32c9a64a995a6b25921c96cc5743c1efa82763ba80ff54e91" + "sha256:990a4f7861b31532871ab72331e755b5f14efbe52d336ea7f6118144dd478741", + "sha256:c1848f654aea2e3526d17fc3ce6aeaa5e7e24e66e645b5be2171f3f6b4e5a178" ], "markers": "python_version >= '3.7'", - "version": "==62.4.0" + "version": "==62.6.0" }, "six": { "hashes": [ @@ -1750,7 +1758,7 @@ "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" ], - "markers": "python_version < '3.11'", + "markers": "python_version >= '3.7'", "version": "==2.0.1" }, "virtualenv": { diff --git a/classquiz/helpers/hashcash.py b/classquiz/helpers/hashcash.py new file mode 100644 index 0000000..612a4bb --- /dev/null +++ b/classquiz/helpers/hashcash.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python2.3 +"""Implement Hashcash version 1 protocol in Python ++-------------------------------------------------------+ +| Written by David Mertz; released to the Public Domain | ++-------------------------------------------------------+ + +Double spend database not implemented in this module, but stub +for callbacks is provided in the 'check()' function + +The function 'check()' will validate hashcash v1 and v0 tokens, as well as +'generalized hashcash' tokens generically. Future protocol version are +treated as generalized tokens (should a future version be published w/o +this module being correspondingly updated). + +A 'generalized hashcash' is implemented in the '_mint()' function, with the +public function 'mint()' providing a wrapper for actual hashcash protocol. +The generalized form simply finds a suffix that creates zero bits in the +hash of the string concatenating 'challenge' and 'suffix' without specifying +any particular fields or delimiters in 'challenge'. E.g., you might get: + + >>> from hashcash import mint, _mint + >>> mint('foo', bits=16) + '1:16:040922:foo::+ArSrtKd:164b3' + >>> _mint('foo', bits=16) + '9591' + >>> import hashlib + >>> hashlib.sha1(('foo9591').encode()).hexdigest() + '0000de4c9b27cec9b20e2094785c1c58eaf23948' + >>> hashlib.sha1(('1:16:040922:foo::+ArSrtKd:164b3').encode()).hexdigest() + '0000a9fe0c6db2efcbcab15157735e77c0877f34' + +Notice that '_mint()' behaves deterministically, finding the same suffix +every time it is passed the same arguments. 'mint()' incorporates a random +salt in stamps (as per the hashcash v.1 protocol). +""" +import sys +from string import ascii_letters +from math import ceil, floor +import hashlib +from random import choice +from time import strftime, localtime, time + + +class HCNotValid(BaseException): + message: str + + +DAYS = 60 * 60 * 24 # Seconds in a day +tries = [0] # Count hashes performed for benchmark + + +def mint(resource, bits=20, now=None, ext="", saltchars=8, stamp_seconds=False): + """Mint a new hashcash stamp for 'resource' with 'bits' of collision + + 20 bits of collision is the default. + + 'ext' lets you add your own extensions to a minted stamp. Specify an + extension as a string of form 'name1=2,3;name2;name3=var1=2,2,val' + FWIW, urllib.urlencode(dct).replace('&',';') comes close to the + hashcash extension format. + + 'saltchars' specifies the length of the salt used; this version defaults + 8 chars, rather than the C version's 16 chars. This still provides about + 17 million salts per resource, per timestamp, before birthday paradox + collisions occur. Really paranoid users can use a larger salt though. + + 'stamp_seconds' lets you add the option time elements to the datestamp. + If you want more than just day, you get all the way down to seconds, + even though the spec also allows hours/minutes without seconds. + """ + ver = "1" + now = now or time() + if stamp_seconds: + ts = strftime("%y%m%d%H%M%S", localtime(now)) + else: + ts = strftime("%y%m%d", localtime(now)) + challenge = "%s:" * 6 % (ver, bits, ts, resource, ext, _salt(saltchars)) + return challenge + _mint(challenge, bits) + + +def _salt(l): + "Return a random string of length 'l'" + alphabet = ascii_letters + "+/=" + return "".join([choice(alphabet) for _ in [None] * l]) + + +def _mint(challenge, bits): + """Answer a 'generalized hashcash' challenge' + + Hashcash requires stamps of form 'ver:bits:date:res:ext:rand:counter' + This internal function accepts a generalized prefix 'challenge', + and returns only a suffix that produces the requested SHA leading zeros. + + NOTE: Number of requested bits is rounded up to the nearest multiple of 4 + """ + counter = 0 + hex_digits = int(ceil(bits / 4.0)) + zeros = "0" * hex_digits + while 1: + digest = hashlib.sha1((challenge + hex(counter)[2:]).encode()).hexdigest() + if digest[:hex_digits] == zeros: + tries[0] = counter + return hex(counter)[2:] + counter += 1 + + +def check(stamp, resource=None, bits=None, check_expiration=None, ds_callback=None): + """Check whether a stamp is valid + + Optionally, the stamp may be checked for a specific resource, and/or + it may require a minimum bit value, and/or it may be checked for + expiration, and/or it may be checked for double spending. + + If 'check_expiration' is specified, it should contain the number of + seconds old a date field may be. Indicating days might be easier in + many cases, e.g. + + >>> from hashcash import DAYS + >>> check(stamp, check_expiration=28*DAYS) + + NOTE: Every valid (version 1) stamp must meet its claimed bit value + NOTE: Check floor of 4-bit multiples (overly permissive in acceptance) + """ + if stamp.startswith("0:"): # Version 0 + try: + date, res, suffix = stamp[2:].split(":") + except ValueError: + return False + if resource is not None and resource != res: + return False + elif check_expiration is not None: + good_until = strftime("%y%m%d%H%M%S", localtime(time() - check_expiration)) + if date < good_until: + return False + elif callable(ds_callback) and ds_callback(stamp): + return False + elif type(bits) is not int: + return True + else: + hex_digits = int(floor(bits / 4)) + return hashlib.sha1((stamp).encode()).hexdigest().startswith("0" * hex_digits) + elif stamp.startswith("1:"): # Version 1 + try: + claim, date, res, ext, rand, counter = stamp[2:].split(":") + except ValueError: + return False + if resource is not None and resource != res: + return False + elif type(bits) is int and bits > int(claim): + return False + elif check_expiration is not None: + good_until = strftime("%y%m%d%H%M%S", localtime(time() - check_expiration)) + if date < good_until: + return False + elif callable(ds_callback) and ds_callback(stamp): + return False + else: + hex_digits = int(floor(int(claim) / 4)) + return hashlib.sha1((stamp).encode()).hexdigest().startswith("0" * hex_digits) + else: # Unknown ver or generalized hashcash + if type(bits) is not int: + return True + elif resource is not None and stamp.find(resource) < 0: + return False + else: + hex_digits = int(floor(bits / 4)) + return hashlib.sha1((stamp).encode()).hexdigest().startswith("0" * hex_digits) diff --git a/classquiz/routers/editor.py b/classquiz/routers/editor.py index e6eaca5..ca2efab 100644 --- a/classquiz/routers/editor.py +++ b/classquiz/routers/editor.py @@ -11,18 +11,21 @@ from pydantic import BaseModel from classquiz.config import settings, redis, storage, meilisearch from classquiz.db.models import Quiz, QuizInput, User +import puremagic from classquiz.auth import get_current_user import os from datetime import datetime from uuid import UUID -from classquiz.helpers import get_meili_data +from classquiz.helpers import get_meili_data, check_hashcash from classquiz.storage.errors import DeletionFailedError settings = settings() router = APIRouter() +allowed_image_extensions = [".gif", ".jpg", ".jpeg", ".png", ".svg", ".webp"] + class InitEditorResponse(BaseModel): token: str @@ -60,21 +63,44 @@ async def init_editor(edit: bool, quiz_id: Optional[UUID] = None, user: User = D return InitEditorResponse(token=edit_id) +class GetPowData(BaseModel): + data: str + + +@router.get("/pow", response_model=GetPowData) +async def get_pow_data(edit_id: str): + session_data = await redis.get(f"edit_session:{edit_id}") + if session_data is None: + raise HTTPException(status_code=401, detail="Edit ID not found!") + random_str = os.urandom(8).hex() + await redis.set(f"edit_session:{edit_id}:pow", random_str, ex=3800) + return GetPowData(data=random_str) + + class UploadImageReturn(BaseModel): id: str @router.post("/image", response_model=UploadImageReturn) -async def upload_image(edit_id: str, file: UploadFile = File()): +async def upload_image(edit_id: str, pow_data: str, file: UploadFile = File()): + print(pow_data) session_data = await redis.get(f"edit_session:{edit_id}") + pow_data_server = await redis.get(f"edit_session:{edit_id}:pow") + if pow_data_server is None: + print("1") + raise HTTPException(status_code=401, detail="Edit ID not found!") + if not check_hashcash(pow_data, pow_data_server): + print("2") + raise HTTPException(status_code=401, detail="Edit ID not found!") if session_data is None: raise HTTPException(status_code=401, detail="Edit ID not found!") + file_bytes = await file.read() + lol = puremagic.magic_string(file_bytes) + print(lol) session_data = EditSessionData.parse_raw(session_data) file_name = f"{session_data.quiz_id}--{uuid.uuid4()}" - print("Uploading...") - await storage.upload(file_name=file_name, file_data=await file.read()) - print("Finished Upload") - await redis.lpush(f"edit_session:{edit_id}:images", file_name) + # await storage.upload(file_name=file_name, file_data=file_bytes) + # await redis.lpush(f"edit_session:{edit_id}:images", file_name) return UploadImageReturn(id=file_name) diff --git a/classquiz/routers/users.py b/classquiz/routers/users.py index 9551e18..76cfcef 100644 --- a/classquiz/routers/users.py +++ b/classquiz/routers/users.py @@ -53,7 +53,6 @@ async def create_user(user: route_user, background_task: BackgroundTasks) -> Use raise HTTPException(status_code=400, detail=str(e)) user.verify_key = str(os.urandom(16).hex()) res = await User.objects.filter((User.email == user.email) | (User.username == user.username)).all() - if len(res) != 0: raise HTTPException(status_code=409, detail="User already exists") @@ -162,9 +161,8 @@ class ForgotPassword(BaseModel): @router.post("/forgot-password") async def forgotten_password(forgot_password: ForgotPassword, background_task: BackgroundTasks): user = await User.objects.filter(email=forgot_password.email, verified=True).get_or_none() - if user is None: - raise HTTPException(status_code=404, detail="User not found") - background_task.add_task(send_forgotten_password_email, email=user.email) + if user is not None: + background_task.add_task(send_forgotten_password_email, email=user.email) return {"message": "Password reset email sent"} diff --git a/classquiz/storage/deta_storage.py b/classquiz/storage/deta_storage.py index f186117..d86c356 100644 --- a/classquiz/storage/deta_storage.py +++ b/classquiz/storage/deta_storage.py @@ -41,6 +41,7 @@ class DetaStorage: if response.status == 201: return None else: + print(response.status, await response.json()) raise SavingFailedError async def delete(self, file_names: [str]) -> None: diff --git a/frontend/package.json b/frontend/package.json index 39b12f3..01779b9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "@uppy/xhr-upload": "^2.1.2", "autoprefixer": "^10.4.7", "cookie": "^0.5.0", + "crypto-js": "^4.1.1", "cssnano": "^5.1.11", "eslint": "^8.17.0", "eslint-config-prettier": "^8.5.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index beda530..1cee9af 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -27,6 +27,7 @@ specifiers: '@uppy/xhr-upload': ^2.1.2 autoprefixer: ^10.4.7 cookie: ^0.5.0 + crypto-js: ^4.1.1 cssnano: ^5.1.11 eslint: ^8.17.0 eslint-config-prettier: ^8.5.0 @@ -89,6 +90,7 @@ devDependencies: '@uppy/xhr-upload': 2.1.2_@uppy+core@2.3.1 autoprefixer: 10.4.7_postcss@8.4.14 cookie: 0.5.0 + crypto-js: 4.1.1 cssnano: 5.1.11_postcss@8.4.14 eslint: 8.17.0 eslint-config-prettier: 8.5.0_eslint@8.17.0 @@ -1562,6 +1564,13 @@ packages: which: 2.0.2 dev: true + /crypto-js/4.1.1: + resolution: + { + integrity: sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw== + } + dev: true + /css-declaration-sorter/6.3.0_postcss@8.4.14: resolution: { diff --git a/frontend/src/lib/editor.svelte b/frontend/src/lib/editor.svelte index 37b30e2..cdee7ef 100644 --- a/frontend/src/lib/editor.svelte +++ b/frontend/src/lib/editor.svelte @@ -1,12 +1,13 @@ @@ -167,7 +175,7 @@ {#if selected_question === -1} {:else} - + {/if} diff --git a/frontend/src/lib/editor/card.svelte b/frontend/src/lib/editor/card.svelte index 3b8abda..e35a8f5 100644 --- a/frontend/src/lib/editor/card.svelte +++ b/frontend/src/lib/editor/card.svelte @@ -8,6 +8,7 @@ export let data: EditorData; export let selected_question: number; export let edit_id: string; + export let pow_data; const empty_answer: Answer = { right: false, answer: '' @@ -67,6 +68,8 @@ }} /> + {:else if pow_data === undefined} + {:else} {#await import('$lib/editor/uploader.svelte')} @@ -77,6 +80,7 @@ bind:edit_id bind:data bind:selected_question + bind:pow_data /> {/await} {/if} diff --git a/frontend/src/lib/editor/uploader.svelte b/frontend/src/lib/editor/uploader.svelte index e07f0fc..c663296 100644 --- a/frontend/src/lib/editor/uploader.svelte +++ b/frontend/src/lib/editor/uploader.svelte @@ -20,7 +20,9 @@ export let edit_id: string; export let data: EditorData; export let selected_question: number; + export let pow_data; + console.log(pow_data); const uppy = new Uppy() .use(DropTarget, { target: document.body @@ -34,7 +36,7 @@ quality: 0.6 }) .use(XHRUpload, { - endpoint: `/api/v1/editor/image?edit_id=${edit_id}` + endpoint: `/api/v1/editor/image?edit_id=${edit_id}&pow_data=${pow_data}` }); const props = { inline: true }; let image_id; @@ -42,6 +44,7 @@ image_id = response.body.id; }); uppy.on('complete', (res) => { + console.log(pow_data); data.questions[ selected_question ].image = `${window.location.origin}/api/v1/storage/download/${image_id}`; diff --git a/frontend/src/lib/hashcash.ts b/frontend/src/lib/hashcash.ts new file mode 100644 index 0000000..dd23db1 --- /dev/null +++ b/frontend/src/lib/hashcash.ts @@ -0,0 +1,47 @@ +import { DateTime } from 'luxon'; + +const gen_salt = (l: number): string => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const charLength = chars.length; + let result = ''; + for (let i = 0; i < l; i++) { + result += chars.charAt(Math.floor(Math.random() * charLength)); + } + return result; +}; + +export const mint = async ( + resource: string, + bits = 19, + now = null, + ext = '', + saltchars = 8, + stamp_seconds = false +): Promise => { + const ver = '1'; + let ts; + if (stamp_seconds) { + ts = DateTime.now().toFormat('yyMMddHHmmss'); + } else { + ts = DateTime.now().toFormat('yyMMdd'); + } + const hex_digits = Math.ceil(bits / 4); + const zeros = '0'.repeat(hex_digits); + const salt = gen_salt(saltchars); + const challenge = `${ver}:${bits}:${ts}:${resource}:${ext}:${salt}`; + let digest: string; + let counter = 0; + let result: string; + while (true) { + const data = new TextEncoder().encode(`${challenge}:${counter.toString(16)}`); + const hashBuffer = await crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const digest = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + if (digest.slice(0, hex_digits) == zeros) { + result = counter.toString(16); + break; + } + counter += 1; + } + return `${challenge}:${result}`; +}; diff --git a/frontend/src/routes/index.svelte b/frontend/src/routes/index.svelte index 03f695c..b91e542 100644 --- a/frontend/src/routes/index.svelte +++ b/frontend/src/routes/index.svelte @@ -20,6 +20,7 @@ import LandingPromo from '$lib/landing/landing-promo.svelte'; import { onMount } from 'svelte'; + import { mint } from '$lib/hashcash'; const { t } = getLocalization();