diff --git a/cms/db/__init__.py b/cms/db/__init__.py index 5533c72bb4..6f61c41dc8 100644 --- a/cms/db/__init__.py +++ b/cms/db/__init__.py @@ -81,7 +81,7 @@ # Instantiate or import these objects. -version = 46 +version = 47 engine = create_engine(config.database.url, echo=config.database.debug, pool_timeout=60, pool_recycle=120) diff --git a/cms/db/submission.py b/cms/db/submission.py index 10647e1d11..5ce81c3b43 100644 --- a/cms/db/submission.py +++ b/cms/db/submission.py @@ -27,7 +27,6 @@ """ from datetime import datetime -import random from sqlalchemy import Boolean from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.orm import relationship @@ -187,25 +186,6 @@ def tokened(self) -> bool: """ return self.token is not None - @classmethod - def generate_opaque_id(cls, session, participation_id): - randint_upper_bound = 2**63-1 - - opaque_id = random.randint(0, randint_upper_bound) - - # Note that in theory this may cause the transaction to fail by - # generating a non-actually-unique ID. This is however extremely - # unlikely (prob. ~num_parallel_submissions_per_contestant^2/2**63). - while (session - .query(Submission) - .filter(Submission.participation_id == participation_id) - .filter(Submission.opaque_id == opaque_id) - .first() - is not None): - opaque_id = random.randint(0, randint_upper_bound) - - return opaque_id - class File(Base): """Class to store information about one file submitted within a diff --git a/cms/db/usertest.py b/cms/db/usertest.py index 6c9e8a2a9a..8bcde40e09 100644 --- a/cms/db/usertest.py +++ b/cms/db/usertest.py @@ -40,12 +40,20 @@ class UserTest(Base): """ __tablename__ = 'user_tests' + __table_args__ = ( + UniqueConstraint("participation_id", "opaque_id"), + ) # Auto increment primary key. id: int = Column( Integer, primary_key=True) + # Opaque ID to be used to refer to this user test. + opaque_id: int = Column( + BigInteger, + nullable=False) + # User and Contest, thus Participation (id and object) that did the # submission. participation_id: int = Column( diff --git a/cms/db/util.py b/cms/db/util.py index 8284089919..07aca06870 100644 --- a/cms/db/util.py +++ b/cms/db/util.py @@ -23,6 +23,7 @@ """ +import random import sys import logging @@ -372,3 +373,24 @@ def enumerate_files( digests = set(r[0] for r in session.execute(union(*queries))) digests.discard(Digest.TOMBSTONE) return digests + +def generate_opaque_id( + cls: type[Submission | UserTest], session: Session, participation_id: int +): + randint_upper_bound = 2**63-1 + + opaque_id = random.randint(0, randint_upper_bound) + + # Note that in theory this may cause the transaction to fail by + # generating a non-actually-unique ID. This is however extremely + # unlikely (prob. ~num_parallel_submissions_per_contestant^2/2**63). + while ( + session.query(cls) + .filter(cls.participation_id == participation_id) + .filter(cls.opaque_id == opaque_id) + .first() + is not None + ): + opaque_id = random.randint(0, randint_upper_bound) + + return opaque_id diff --git a/cms/server/admin/authentication.py b/cms/server/admin/authentication.py index 23e2cb60f8..c81d5e0e7b 100644 --- a/cms/server/admin/authentication.py +++ b/cms/server/admin/authentication.py @@ -25,7 +25,6 @@ from werkzeug.wrappers import Request, Response from cms import config -from cmscommon.binary import hex_to_bin from cmscommon.datetime import make_timestamp @@ -130,7 +129,7 @@ def wsgi_app(self, environ: dict, start_response: Callable): self._local.request = Request(environ) self._local.cookie = JSONSecureCookie.load_cookie( self._request, AWSAuthMiddleware.COOKIE, - hex_to_bin(config.web_server.secret_key)) + bytes.fromhex(config.web_server.secret_key)) self._verify_cookie() def my_start_response(status, headers, exc_info=None): diff --git a/cms/server/admin/handlers/__init__.py b/cms/server/admin/handlers/__init__.py index 86c02f22c0..588ba7ae83 100644 --- a/cms/server/admin/handlers/__init__.py +++ b/cms/server/admin/handlers/__init__.py @@ -212,7 +212,7 @@ # Submissions - (r"/submission/([0-9]+)(?:/([0-9]+))?", SubmissionHandler), + (r"/submission/((?:opaque_)?[0-9]+)(?:/([0-9]+))?", SubmissionHandler), (r"/submission/([0-9]+)(?:/([0-9]+))?/comment", SubmissionCommentHandler), (r"/submission/([0-9]+)(?:/([0-9]+))?/official", SubmissionOfficialStatusHandler), (r"/submission_file/([0-9]+)", SubmissionFileHandler), @@ -220,7 +220,7 @@ # User tests - (r"/user_test/([0-9]+)(?:/([0-9]+))?", UserTestHandler), + (r"/user_test/((?:opaque_)?[0-9]+)(?:/([0-9]+))?", UserTestHandler), (r"/user_test_file/([0-9]+)", UserTestFileHandler), # The following prefixes are handled by WSGI middlewares: diff --git a/cms/server/admin/handlers/submission.py b/cms/server/admin/handlers/submission.py index cd528cff1b..bf2abb82ca 100644 --- a/cms/server/admin/handlers/submission.py +++ b/cms/server/admin/handlers/submission.py @@ -30,6 +30,15 @@ import logging import difflib +import collections +try: + collections.MutableMapping +except: + # Monkey-patch: Tornado 4.5.3 does not work on Python 3.11 by default + collections.MutableMapping = collections.abc.MutableMapping + +import tornado.web + from cms.db import Dataset, File, Submission from cms.grading.languagemanager import safe_get_lang_filename from cmscommon.datetime import make_datetime @@ -47,8 +56,18 @@ class SubmissionHandler(BaseHandler): """ @require_permission(BaseHandler.AUTHENTICATED) - def get(self, submission_id, dataset_id=None): - submission = self.safe_get_item(Submission, submission_id) + def get(self, submission_id: str, dataset_id=None): + if submission_id.startswith("opaque_"): + oid = int(submission_id.removeprefix("opaque_")) + submission = ( + self.sql_session.query(Submission) + .filter(Submission.opaque_id == oid) + .first() + ) + if submission is None: + raise tornado.web.HTTPError(404) + else: + submission = self.safe_get_item(Submission, submission_id) task = submission.task self.contest = task.contest diff --git a/cms/server/admin/handlers/usertest.py b/cms/server/admin/handlers/usertest.py index 25328ed049..636cdc7420 100644 --- a/cms/server/admin/handlers/usertest.py +++ b/cms/server/admin/handlers/usertest.py @@ -20,6 +20,15 @@ """ +import collections +try: + collections.MutableMapping +except: + # Monkey-patch: Tornado 4.5.3 does not work on Python 3.11 by default + collections.MutableMapping = collections.abc.MutableMapping + +import tornado.web + from cms.db import Dataset, UserTestFile, UserTest from cms.grading.languagemanager import safe_get_lang_filename @@ -29,8 +38,18 @@ class UserTestHandler(BaseHandler): """Shows the details of a user test.""" @require_permission(BaseHandler.AUTHENTICATED) - def get(self, user_test_id, dataset_id=None): - user_test = self.safe_get_item(UserTest, user_test_id) + def get(self, user_test_id: str, dataset_id=None): + if user_test_id.startswith("opaque_"): + oid = int(user_test_id.removeprefix("opaque_")) + user_test = ( + self.sql_session.query(UserTest) + .filter(UserTest.opaque_id == oid) + .first() + ) + if user_test is None: + raise tornado.web.HTTPError(404) + else: + user_test = self.safe_get_item(UserTest, user_test_id) task = user_test.task self.contest = task.contest diff --git a/cms/server/admin/server.py b/cms/server/admin/server.py index d2af937bee..2ef586b59b 100644 --- a/cms/server/admin/server.py +++ b/cms/server/admin/server.py @@ -34,7 +34,6 @@ from cms.db import SessionGen, Dataset, Submission, SubmissionResult, Task from cms.io import WebService, rpc_method from cms.service import EvaluationService -from cmscommon.binary import hex_to_bin from .authentication import AWSAuthMiddleware from .handlers import HANDLERS from .jinja2_toolbox import AWS_ENVIRONMENT @@ -52,7 +51,7 @@ def __init__(self, shard: int): parameters = { "static_files": [("cms.server", "static"), ("cms.server.admin", "static")], - "cookie_secret": hex_to_bin(config.web_server.secret_key), + "cookie_secret": bytes.fromhex(config.web_server.secret_key), "debug": config.web_server.tornado_debug, "num_proxies_used": config.admin_web_server.num_proxies_used, "auth_middleware": AWSAuthMiddleware, diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index be0b55b74e..55ddce8f58 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -263,15 +263,12 @@ def get_task(self, task_name: str) -> Task | None: .one_or_none() def get_submission(self, task: Task, opaque_id: str | int) -> Submission | None: - """Return the num-th contestant's submission on the given task. + """Return a contestant's specific submission on the given task. task: a task for the contest that is being served. - submission_num: a positive number, in decimal encoding. + opaque_id: submission's opaque_id - return: the submission_num-th submission - (1-based), in chronological order, that was sent by the - currently logged in contestant on the given task (None if - not found). + return: Submission with this opaque_id, or None if not found. """ return self.sql_session.query(Submission) \ @@ -280,22 +277,19 @@ def get_submission(self, task: Task, opaque_id: str | int) -> Submission | None: .filter(Submission.opaque_id == int(opaque_id)) \ .first() - def get_user_test(self, task: Task, user_test_num: int) -> UserTest | None: - """Return the num-th contestant's test on the given task. + def get_user_test(self, task: Task, opaque_id: str | int) -> UserTest | None: + """Return a contestant's specific user test on the given task. task: a task for the contest that is being served. - user_test_num: a positive number, in decimal encoding. + opaque_id: user test's opaque_id - return: the user_test_num-th user test, in - chronological order, that was sent by the currently logged - in contestant on the given task (None if not found). + return: User test with this opaque_id, or None if not found. """ return self.sql_session.query(UserTest) \ .filter(UserTest.participation == self.current_user) \ .filter(UserTest.task == task) \ - .order_by(UserTest.timestamp) \ - .offset(int(user_test_num) - 1) \ + .filter(UserTest.opaque_id == int(opaque_id)) \ .first() def add_notification( diff --git a/cms/server/contest/handlers/tasksubmission.py b/cms/server/contest/handlers/tasksubmission.py index a5cdedee4b..d8c09efef4 100644 --- a/cms/server/contest/handlers/tasksubmission.py +++ b/cms/server/contest/handlers/tasksubmission.py @@ -55,7 +55,6 @@ UnacceptableSubmission, accept_submission from cms.server.contest.tokening import \ UnacceptableToken, TokenAlreadyPlayed, accept_token, tokens_available -from cmscommon.crypto import encrypt_number from cmscommon.mimetypes import get_type_for_file_name from .contest import ContestHandler, FileHandler, api_login_required from ..phase_management import actual_phase_required @@ -109,11 +108,9 @@ def post(self, task_name): self.notify_success(N_("Submission received"), N_("Your submission has been received " "and is currently being evaluated.")) - # The argument (encrypted submission id) is not used by CWS - # (nor it discloses information to the user), but it is - # useful for automatic testing to obtain the submission id). - query_args["submission_id"] = \ - encrypt_number(submission.id, config.web_server.secret_key) + # The argument is not used by CWS, but it is useful for automatic + # testing to obtain the submission id. + query_args["submission_id"] = submission.opaque_id self.redirect(self.contest_url("tasks", task.name, "submissions", **query_args)) diff --git a/cms/server/contest/handlers/taskusertest.py b/cms/server/contest/handlers/taskusertest.py index fc9e9814db..da1151389f 100644 --- a/cms/server/contest/handlers/taskusertest.py +++ b/cms/server/contest/handlers/taskusertest.py @@ -46,7 +46,6 @@ from cms.server import multi_contest from cms.server.contest.submission import get_submission_count, \ TestingNotAllowed, UnacceptableUserTest, accept_user_test -from cmscommon.crypto import encrypt_number from cmscommon.mimetypes import get_type_for_file_name from .contest import ContestHandler, FileHandler, api_login_required from ..phase_management import actual_phase_required @@ -152,11 +151,9 @@ def post(self, task_name): self.notify_success(N_("Test received"), N_("Your test has been received " "and is currently being executed.")) - # The argument (encrypted user test id) is not used by CWS - # (nor it discloses information to the user), but it is - # useful for automatic testing to obtain the user test id). - query_args["user_test_id"] = \ - encrypt_number(user_test.id, config.web_server.secret_key) + # The argument is not used by CWS, but it is useful for automatic + # testing to obtain the user test id. + query_args["user_test_id"] = user_test.opaque_id self.redirect(self.contest_url("testing", task_name=task.name, **query_args)) @@ -224,7 +221,7 @@ class UserTestDetailsHandler(ContestHandler): @api_login_required @actual_phase_required(0) @multi_contest - def get(self, task_name, user_test_num): + def get(self, task_name, opaque_id): if not self.r_params["testing_enabled"]: raise tornado.web.HTTPError(404) @@ -232,7 +229,7 @@ def get(self, task_name, user_test_num): if task is None: raise tornado.web.HTTPError(404) - user_test = self.get_user_test(task, user_test_num) + user_test = self.get_user_test(task, opaque_id) if user_test is None: raise tornado.web.HTTPError(404) @@ -249,7 +246,7 @@ class UserTestIOHandler(FileHandler): @tornado.web.authenticated @actual_phase_required(0) @multi_contest - def get(self, task_name, user_test_num, io): + def get(self, task_name, opaque_id, io): if not self.r_params["testing_enabled"]: raise tornado.web.HTTPError(404) @@ -257,7 +254,7 @@ def get(self, task_name, user_test_num, io): if task is None: raise tornado.web.HTTPError(404) - user_test = self.get_user_test(task, user_test_num) + user_test = self.get_user_test(task, opaque_id) if user_test is None: raise tornado.web.HTTPError(404) @@ -283,7 +280,7 @@ class UserTestFileHandler(FileHandler): @tornado.web.authenticated @actual_phase_required(0) @multi_contest - def get(self, task_name, user_test_num, filename): + def get(self, task_name, opaque_id, filename): if not self.r_params["testing_enabled"]: raise tornado.web.HTTPError(404) @@ -291,7 +288,7 @@ def get(self, task_name, user_test_num, filename): if task is None: raise tornado.web.HTTPError(404) - user_test = self.get_user_test(task, user_test_num) + user_test = self.get_user_test(task, opaque_id) if user_test is None: raise tornado.web.HTTPError(404) diff --git a/cms/server/contest/server.py b/cms/server/contest/server.py index 172cad4267..3243487c1e 100644 --- a/cms/server/contest/server.py +++ b/cms/server/contest/server.py @@ -46,7 +46,6 @@ from cms.io import WebService from cms.locale import get_translations from cms.server.contest.jinja2_toolbox import CWS_ENVIRONMENT -from cmscommon.binary import hex_to_bin from .handlers import HANDLERS from .handlers.base import ContestListHandler from .handlers.main import MainHandler @@ -66,7 +65,7 @@ def __init__(self, shard: int, contest_id: int | None = None): parameters = { "static_files": [("cms.server", "static"), ("cms.server.contest", "static")], - "cookie_secret": hex_to_bin(config.web_server.secret_key), + "cookie_secret": bytes.fromhex(config.web_server.secret_key), "debug": config.web_server.tornado_debug, "is_proxy_used": None, "num_proxies_used": config.contest_web_server.num_proxies_used, diff --git a/cms/server/contest/submission/workflow.py b/cms/server/contest/submission/workflow.py index a421427eb8..2ef6ceb870 100644 --- a/cms/server/contest/submission/workflow.py +++ b/cms/server/contest/submission/workflow.py @@ -32,6 +32,8 @@ import logging import typing +from cms.db.util import generate_opaque_id + if typing.TYPE_CHECKING: from tornado.httputil import HTTPFile @@ -270,7 +272,7 @@ def accept_submission( submission = Submission( timestamp=timestamp, - opaque_id=Submission.generate_opaque_id(sql_session, participation.id), + opaque_id=generate_opaque_id(Submission, sql_session, participation.id), language=language.name if language is not None else None, task=task, participation=participation, @@ -486,6 +488,7 @@ def accept_user_test( user_test = UserTest( timestamp=timestamp, + opaque_id=generate_opaque_id(UserTest, sql_session, participation.id), language=language.name if language is not None else None, input=digests["input"], participation=participation, diff --git a/cms/server/contest/templates/macro/submission.html b/cms/server/contest/templates/macro/submission.html index e7832ebc4e..2c0d79400b 100644 --- a/cms/server/contest/templates/macro/submission.html +++ b/cms/server/contest/templates/macro/submission.html @@ -87,7 +87,6 @@ {% else %} {% for s in submissions|sort(attribute="timestamp")|reverse %} {% if s.official == official %} - {# loop.revindex is broken: https://github.com/pallets/jinja/issues/794 #} {{ row( url, contest_url, diff --git a/cms/server/contest/templates/test_interface.html b/cms/server/contest/templates/test_interface.html index e4c7ab621f..c9ee8ab773 100644 --- a/cms/server/contest/templates/test_interface.html +++ b/cms/server/contest/templates/test_interface.html @@ -214,8 +214,6 @@

{% trans %}Previous tests{% endtrans %}

{% for t in user_tests[task.id]|sort(attribute="timestamp")|reverse %} - {# loop.revindex is broken: https://github.com/pallets/jinja/issues/794 #} - {% set t_idx = user_tests[task.id]|length - loop.index0 %} {% set tr = t.get_result(t.task.active_dataset) or undefined %} {% include "user_test_row.html" %} {% else %} diff --git a/cms/server/contest/templates/user_test_row.html b/cms/server/contest/templates/user_test_row.html index 3bfd5f4144..8025fc07bf 100644 --- a/cms/server/contest/templates/user_test_row.html +++ b/cms/server/contest/templates/user_test_row.html @@ -1,5 +1,5 @@ {% set status = tr.get_status() if tr is defined else UserTestResult.COMPILING %} - + {% if show_date %} {{ t.timestamp|format_datetime }} {% else %} @@ -37,7 +37,7 @@ {% endif %} - + {% trans %}Download{% endtrans %} @@ -52,7 +52,7 @@ {% trans %}N/A{% endtrans %} {% else %} - + {% trans %}Download{% endtrans %} {% endif %} @@ -70,7 +70,7 @@ {% elif files|length == 1 %} {% set filename = next(iter(t.files.keys())) %} {% set real_filename = filename|replace(".%l", (t.language|to_language).source_extension) %} - + {% trans %}Download{% endtrans %} {% else %} @@ -87,7 +87,7 @@ {% set real_filename = filename %} {% endif %}
  • - + {{ real_filename }}
  • diff --git a/cmscommon/crypto.py b/cmscommon/crypto.py index f90cb00352..c6fce0db6f 100644 --- a/cmscommon/crypto.py +++ b/cmscommon/crypto.py @@ -22,24 +22,16 @@ """Utilities dealing with encryption and randomness.""" -import binascii import hmac -import random +import secrets from string import ascii_lowercase import bcrypt -from Cryptodome import Random -from Cryptodome.Cipher import AES - -from cmscommon.binary import bin_to_hex, hex_to_bin, bin_to_b64, b64_to_bin __all__ = [ "get_random_key", "get_hex_random_key", - "encrypt_binary", "decrypt_binary", - "encrypt_number", "decrypt_number", - "generate_random_password", "validate_password", "build_password", "hash_password", @@ -47,8 +39,6 @@ ] -_RANDOM = Random.new() - # bcrypt difficulty parameter. This is here so that it can be set to a lower # value when running unit tests. It seems that the lowest accepted value is 4. BCRYPT_ROUNDS = 12 @@ -57,7 +47,7 @@ def get_random_key() -> bytes: """Generate 16 random bytes, safe to be used as AES key. """ - return _RANDOM.read(16) + return secrets.token_bytes(16) def get_hex_random_key() -> str: @@ -65,88 +55,7 @@ def get_hex_random_key() -> str: Return it encoded in hexadecimal. """ - return bin_to_hex(get_random_key()) - - -def encrypt_binary(pt: bytes, key_hex: str) -> str: - """Encrypt the plaintext with the 16-bytes key. - - A random salt is added to avoid having the same input being - encrypted to the same output. - - pt: the "plaintext" to encode. - key_hex: a 16-bytes key in hex (a string of 32 hex chars). - - return: pt encrypted using the key, in a format URL-safe - (more precisely, base64-encoded with alphabet "a-zA-Z0-9.-_"). - - """ - key = hex_to_bin(key_hex) - # Pad the plaintext to make its length become a multiple of the block size - # (that is, for AES, 16 bytes), using a byte 0x01 followed by as many bytes - # 0x00 as needed. If the length of the message is already a multiple of 16 - # bytes, add a new block. - pt_pad = pt + b'\01' + b'\00' * (16 - (len(pt) + 1) % 16) - # The IV is a random block used to differentiate messages encrypted with - # the same key. An IV should never be used more than once in the lifetime - # of the key. In this way encrypting the same plaintext twice will produce - # different ciphertexts. - iv = get_random_key() - # Initialize the AES cipher with the given key and IV. - aes = AES.new(key, AES.MODE_CBC, iv) - ct = aes.encrypt(pt_pad) - # Convert the ciphertext in a URL-safe base64 encoding - ct_b64 = bin_to_b64(iv + ct)\ - .replace('+', '-').replace('/', '_').replace('=', '.') - return ct_b64 - - -def decrypt_binary(ct_b64: str, key_hex: str) -> bytes: - """Decrypt a ciphertext generated by encrypt_binary. - - ct_b64: the ciphertext as produced by encrypt_binary. - key_hex: the 16-bytes key in hex format used to encrypt. - - return: the plaintext. - - raise (ValueError): if the ciphertext is invalid. - - """ - key = hex_to_bin(key_hex) - try: - # Convert the ciphertext from a URL-safe base64 encoding to a - # bytestring, which contains both the IV (the first 16 bytes) as well - # as the encrypted padded plaintext. - iv_ct = b64_to_bin( - ct_b64.replace('-', '+').replace('_', '/').replace('.', '=')) - aes = AES.new(key, AES.MODE_CBC, iv_ct[:16]) - # Get the padded plaintext. - pt_pad = aes.decrypt(iv_ct[16:]) - # Remove the padding. - # TODO check that the padding is correct, i.e. that it contains at most - # 15 bytes 0x00 preceded by a byte 0x01. - pt = pt_pad.rstrip(b'\x00')[:-1] - return pt - except (TypeError, binascii.Error): - raise ValueError('Could not decode from base64.') - except ValueError: - raise ValueError('Wrong AES cryptogram length.') - - -def encrypt_number(num: int, key_hex: str) -> str: - """Encrypt an integer number, with the same properties as - encrypt_binary(). - - """ - hexnum = b"%x" % num - return encrypt_binary(hexnum, key_hex) - - -def decrypt_number(enc: str, key_hex: str) -> int: - """Decrypt an integer number encrypted with encrypt_number(). - - """ - return int(decrypt_binary(enc, key_hex), 16) + return get_random_key().hex() def generate_random_password() -> str: @@ -155,7 +64,7 @@ def generate_random_password() -> str: return: a random string. """ - return "".join((random.choice(ascii_lowercase) for _ in range(6))) + return "".join((secrets.choice(ascii_lowercase) for _ in range(6))) def parse_authentication(authentication: str) -> tuple[str, str]: diff --git a/cmscommon/digest.py b/cmscommon/digest.py index 49e58386a5..b3ff95ca14 100644 --- a/cmscommon/digest.py +++ b/cmscommon/digest.py @@ -19,8 +19,6 @@ import hashlib import io -from cmscommon.binary import bin_to_hex - __all__ = [ "Digester", "bytes_digest", "path_digest" @@ -39,7 +37,7 @@ def update(self, b: bytes): def digest(self) -> str: """Return the digest as an hex string.""" - return bin_to_hex(self._hasher.digest()) + return self._hasher.digest().hex() def bytes_digest(b: bytes) -> str: diff --git a/cmscontrib/AddSubmission.py b/cmscontrib/AddSubmission.py index 9c6a54244b..a85921caef 100755 --- a/cmscontrib/AddSubmission.py +++ b/cmscontrib/AddSubmission.py @@ -28,6 +28,7 @@ from cms.db import File, Participation, SessionGen, Submission, Task, User, \ ask_for_contest from cms.db.filecacher import FileCacher +from cms.db.util import generate_opaque_id from cms.grading.language import Language from cms.grading.languagemanager import filename_to_language, get_language from cms.io import RemoteServiceClient @@ -160,7 +161,7 @@ def add_submission( language=language_name, participation=participation, task=task, - opaque_id=Submission.generate_opaque_id(session, participation.id) + opaque_id=generate_opaque_id(Submission, session, participation.id) ) for filename, digest in file_digests.items(): session.add(File(filename, digest, submission=submission)) diff --git a/cmscommon/binary.py b/cmscontrib/updaters/update_47.py similarity index 52% rename from cmscommon/binary.py rename to cmscontrib/updaters/update_47.py index 7cf7c02b91..16eaf55c80 100644 --- a/cmscommon/binary.py +++ b/cmscontrib/updaters/update_47.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 # Contest Management System - http://cms-dev.github.io/ -# Copyright © 2018 Luca Wehrstedt -# Copyright © 2018 Stefano Maggiolo +# Copyright © 2025 Luca Versari # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -17,25 +16,33 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import binascii +"""A class to update a dump created by CMS. +Used by DumpImporter and DumpUpdater. -__all__ = [ - "bin_to_hex", "hex_to_bin", "bin_to_b64", "b64_to_bin", -] +Adds opaque_id to user tests. +""" -def bin_to_hex(bin: bytes) -> str: - return binascii.b2a_hex(bin).decode('ascii') +import random -def hex_to_bin(hex: str) -> bytes: - return binascii.a2b_hex(hex.encode('ascii')) +class Updater: -def bin_to_b64(bin: bytes) -> str: - return binascii.b2a_base64(bin, newline=False).decode('ascii') + def __init__(self, data): + assert data["_version"] == 46 + self.objs = data + def run(self): + used_ids = set() + for k, v in self.objs.items(): + if k.startswith("_"): + continue + if v["_class"] == "UserTest": + while "opaque_id" not in v or v["opaque_id"] in used_ids: + v["opaque_id"] = random.randint(0, 2**63-1) + used_ids.add(v["opaque_id"]) + + return self.objs -def b64_to_bin(b64: str) -> bytes: - return binascii.a2b_base64(b64.encode('ascii')) diff --git a/cmscontrib/updaters/update_from_1.5.sql b/cmscontrib/updaters/update_from_1.5.sql index a1ff578498..38f456d6a4 100644 --- a/cmscontrib/updaters/update_from_1.5.sql +++ b/cmscontrib/updaters/update_from_1.5.sql @@ -45,4 +45,10 @@ ALTER TABLE user_test_results DROP COLUMN evaluation_sandbox; -- https://github.com/cms-dev/cms/pull/1486 ALTER TABLE public.tasks ADD COLUMN allowed_languages varchar[]; +-- https://github.com/cms-dev/cms/pull/1526 +ALTER TABLE user_tests ADD COLUMN opaque_id BIGINT; +UPDATE user_tests SET opaque_id = id WHERE opaque_id IS NULL; +ALTER TABLE user_tests ADD CONSTRAINT user_tests_participation_id_opaque_id_key UNIQUE (participation_id, opaque_id); +ALTER TABLE user_tests ALTER COLUMN opaque_id SET NOT NULL; + COMMIT; diff --git a/cmstestsuite/unit_tests/cmscommon/binary_test.py b/cmstestsuite/unit_tests/cmscommon/binary_test.py deleted file mode 100755 index b51e0f41ae..0000000000 --- a/cmstestsuite/unit_tests/cmscommon/binary_test.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 - -# Contest Management System - http://cms-dev.github.io/ -# Copyright © 2018 Stefano Maggiolo -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Tests for the binary module""" - -import binascii -import unittest - -from cmscommon.binary import bin_to_hex, hex_to_bin, bin_to_b64, b64_to_bin - - -class TestBinToHex(unittest.TestCase): - - def test_success(self): - self.assertEqual(bin_to_hex(b"\x32\x00\xa0"), "3200a0") - self.assertEqual(bin_to_hex(b"\xFF\xFF\xFF\xFF"), "ffffffff") - self.assertEqual(bin_to_hex(b"\x00" * 1000), "0" * 2000) - - def test_string(self): - with self.assertRaises(TypeError): - bin_to_hex("cms") - - -class TestHexToBin(unittest.TestCase): - - def setUp(self): - super().setUp() - # The exception type depend on Python's version. - self.error = binascii.Error - - def test_success(self): - self.assertEqual(hex_to_bin("3200a0"), b"\x32\x00\xa0") - self.assertEqual(hex_to_bin("ffffffff"), b"\xFF\xFF\xFF\xFF") - self.assertEqual(hex_to_bin("0" * 2000), b"\x00" * 1000) - - def test_invalid_length(self): - with self.assertRaises(self.error): - hex_to_bin("000") - - def test_invalid_alphabet(self): - with self.assertRaises(self.error): - hex_to_bin("cmscms") - - -class TestBinToB64(unittest.TestCase): - - def test_success(self): - self.assertEqual(bin_to_b64(b"\x32\x00\xa0"), "MgCg") - self.assertEqual(bin_to_b64(b"\xFF\xFF\xFF\xFF"), "/////w==") - self.assertEqual(bin_to_b64(b"\x00" * 3000), "A" * (3000 * 4 // 3)) - - def test_string(self): - with self.assertRaises(TypeError): - bin_to_b64("cms") - - -class TestB64ToBin(unittest.TestCase): - - def test_success(self): - self.assertEqual(b64_to_bin("MgCg"), b"\x32\x00\xa0") - self.assertEqual(b64_to_bin("/////w=="), b"\xFF\xFF\xFF\xFF") - self.assertEqual(b64_to_bin("A" * (3000 * 4 // 3)), b"\x00" * 3000) - - def test_invalid_length(self): - with self.assertRaises(binascii.Error): - b64_to_bin("MgC") - - def test_invalid_alphabet(self): - # binascii ignores invalid characters - self.assertEqual(b64_to_bin("M\x00g.C,g\x0a"), b"\x32\x00\xa0") - - -if __name__ == "__main__": - unittest.main() diff --git a/cmstestsuite/unit_tests/cmscommon/crypto_test.py b/cmstestsuite/unit_tests/cmscommon/crypto_test.py index bd280e6a9d..9ea79da157 100755 --- a/cmstestsuite/unit_tests/cmscommon/crypto_test.py +++ b/cmstestsuite/unit_tests/cmscommon/crypto_test.py @@ -18,12 +18,9 @@ """Tests for the crypto module""" -import re import unittest -from cmscommon.binary import bin_to_b64 from cmscommon.crypto import build_password, \ - decrypt_binary, decrypt_number, encrypt_binary, encrypt_number, \ generate_random_password, get_hex_random_key, get_random_key, \ hash_password, parse_authentication, validate_password @@ -47,76 +44,6 @@ def test_length(self): self.assertEqual(len(get_hex_random_key()), 32) -class TestEncryptAndDecryptBinary(unittest.TestCase): - """Tests for the functions encrypt_binary and decrypt_binary.""" - - def setUp(self): - super().setUp() - self.key = get_hex_random_key() - - def test_encrypt_and_decrypt(self): - self.assertEqual( - decrypt_binary(encrypt_binary(b"stuff", self.key), self.key), - b"stuff") - - def test_encrypt_and_decrypt_empty(self): - self.assertEqual( - decrypt_binary(encrypt_binary(b"", self.key), self.key), - b"") - - def test_encrypt_and_decrypt_long(self): - value = b"0" * 1_000_000 - self.assertEqual( - decrypt_binary(encrypt_binary(value, self.key), self.key), - value) - - def test_encrypt_chaining(self): - # Even if the input is repeated, the output should not be. - encrypted = encrypt_binary(b"0" * 1_000_000, self.key) - # The output should appear random, so any sequence of 64 bytes is - # very unlikely to repeat. - blocks = re.findall(".{64}", encrypted) - self.assertEqual(len(blocks), len(set(blocks))) - - def test_encrypt_salting(self): - self.assertNotEqual(encrypt_binary(b"stuff", self.key), - encrypt_binary(b"stuff", self.key)) - - def test_decrypt_invalid_base64(self): - encrypted = encrypt_binary(b"stuff", self.key) - with self.assertRaises(ValueError): - decrypt_binary(encrypted[1:], self.key) - - def test_decrypt_invalid_encrypted(self): - # "stuff" is not decryptable. - encrypted = bin_to_b64(b"stuff") - with self.assertRaises(ValueError): - decrypt_binary(encrypted, self.key) - - -class TestEncryptAndDecryptNumber(unittest.TestCase): - """Tests for the functions encrypt_number and decrypt_number.""" - - def setUp(self): - super().setUp() - self.key = get_hex_random_key() - - def test_encrypt_and_decrypt(self): - self.assertEqual( - decrypt_number(encrypt_number(123, self.key), self.key), - 123) - - def test_encrypt_and_decrypt_negative(self): - self.assertEqual( - decrypt_number(encrypt_number(-123, self.key), self.key), - -123) - - def test_encrypt_and_decrypt_big(self): - self.assertEqual( - decrypt_number(encrypt_number(10 ** 42, self.key), self.key), - 10 ** 42) - - class TestGenerateRandomPassword(unittest.TestCase): """Tests for the function generate_random_password.""" diff --git a/cmstestsuite/unit_tests/cmscontrib/DumpImporterTest.py b/cmstestsuite/unit_tests/cmscontrib/DumpImporterTest.py index 1c1c237cf8..57e7c39bf7 100755 --- a/cmstestsuite/unit_tests/cmscontrib/DumpImporterTest.py +++ b/cmstestsuite/unit_tests/cmscontrib/DumpImporterTest.py @@ -359,6 +359,7 @@ def test_import_old(self): "preferred_languages": "[\"en\", \"it_IT\"]", "contest": "contest_key", "submissions": ["sub1_key", "sub2_key"], + "user_tests": ["ut_key"], }, "sub1_key": { "_class": "Submission", @@ -382,6 +383,17 @@ def test_import_old(self): "executables": {}, "evaluations": [], }, + "ut_key": { + "_class": "UserTest", + "timestamp": 1_234_567_900.123, + "language": "c", + "user": "user_key", + "task": "task_key", + "files": {}, + "managers": {}, + "input": TestDumpImporter.NON_GENERATED_FILE_DIGEST, + "results": [] + }, "exe_key": { "_class": "Executable", "submission": "sub1_key", diff --git a/cmstestsuite/unit_tests/databasemixin.py b/cmstestsuite/unit_tests/databasemixin.py index f24c7b6e14..255488dc2c 100644 --- a/cmstestsuite/unit_tests/databasemixin.py +++ b/cmstestsuite/unit_tests/databasemixin.py @@ -456,6 +456,7 @@ def add_user_test(self, task=None, participation=None, **kwargs): "participation": participation, "input": unique_digest(), "timestamp": (task.contest.start + timedelta(0, unique_long_id())), + "opaque_id": unique_long_id(), } args.update(kwargs) user_test = UserTest(**args) diff --git a/cmstestsuite/web/AWSRequests.py b/cmstestsuite/web/AWSRequests.py index 990cc94ba5..9bcaea70f8 100644 --- a/cmstestsuite/web/AWSRequests.py +++ b/cmstestsuite/web/AWSRequests.py @@ -48,7 +48,7 @@ class AWSSubmissionViewRequest(GenericRequest): def __init__(self, browser, submission_id, base_url=None): GenericRequest.__init__(self, browser, base_url) self.submission_id = submission_id - self.url = "%s/submission/%s" % (self.base_url, submission_id) + self.url = "%s/submission/opaque_%s" % (self.base_url, submission_id) def describe(self): return "check submission %s" % self.submission_id @@ -105,7 +105,7 @@ class AWSUserTestViewRequest(GenericRequest): def __init__(self, browser, user_test_id, base_url=None): GenericRequest.__init__(self, browser, base_url) self.user_test_id = user_test_id - self.url = "%s/user_test/%s" % (self.base_url, user_test_id) + self.url = "%s/user_test/opaque_%s" % (self.base_url, user_test_id) def describe(self): return "check user_test %s" % self.user_test_id diff --git a/cmstestsuite/web/CWSRequests.py b/cmstestsuite/web/CWSRequests.py index 6c68ee7a0e..8aab8ff1e2 100644 --- a/cmstestsuite/web/CWSRequests.py +++ b/cmstestsuite/web/CWSRequests.py @@ -28,9 +28,7 @@ import tempfile from urllib.parse import parse_qs, urlsplit -from cms import config from cms.grading.languagemanager import filename_to_language -from cmscommon.crypto import decrypt_number from cmstestsuite.web import GenericRequest, LoginRequest @@ -160,13 +158,7 @@ def get_submission_id(self): logger.warning("Redirected to an unexpected page: `%s'", self.redirected_to) return None - try: - submission_id = decrypt_number(query["submission_id"][0], - config.web_server.secret_key) - except Exception: - logger.warning("Unable to decrypt submission id from page: `%s'", - self.redirected_to) - return None + submission_id = int(query["submission_id"][0]) return submission_id @@ -227,13 +219,7 @@ def get_user_test_id(self): logger.warning("Redirected to an unexpected page: `%s'", self.redirected_to) return None - try: - user_test_id = decrypt_number(query["user_test_id"][0], - config.web_server.secret_key) - except Exception: - logger.warning("Unable to decrypt user test id from page: `%s'", - self.redirected_to) - return None + user_test_id = int(query["user_test_id"][0]) return user_test_id diff --git a/constraints.txt b/constraints.txt index 4f1f7ced2f..ecfc8a36c5 100644 --- a/constraints.txt +++ b/constraints.txt @@ -26,7 +26,6 @@ pluggy==1.6.0 prometheus_client==0.21.1 psutil==7.0.0 psycopg2==2.9.10 -pycryptodomex==3.23.0 pycups==2.0.4 Pygments==2.19.2 PyPDF2==3.0.1 diff --git a/pyproject.toml b/pyproject.toml index a4f4c8a5c8..8a940cef87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ dependencies = [ "psycopg2==2.9.10", # http://initd.org/psycopg/articles/tag/release/ "sqlalchemy>=1.3,<1.4", # http://docs.sqlalchemy.org/en/latest/changelog/index.html "netifaces>=0.10,<0.12", # https://bitbucket.org/al45tair/netifaces/src/ - "pycryptodomex==3.23.0", # https://github.com/Legrandin/pycryptodome/blob/master/Changelog.rst "psutil>=5.5,<7.1", # https://github.com/giampaolo/psutil/blob/master/HISTORY.rst "requests==2.32.3", # https://pypi.python.org/pypi/requests "gevent==25.8.1", # http://www.gevent.org/changelog.html