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