Skip to content

Commit

Permalink
refactor: drop pyopenssl dependency, use cryptography to re-implement…
Browse files Browse the repository at this point in the history
… OTA image verification logic (#437)

This PR drops the pyopenssl dependency, refactors the OTA image verification logic with cryptography instead. 
Note that we don't use the cryptography's high-level x509 verification APIs as that APIs are dedicated for TLS. Instead we directly use low-level APIs to perform certificate signature & valid period check.

This PR pins the cryptography version to >=42.0.8, <45.

Major changes:
1. ota_metadata.utils.cert_store: re-implement with cryptography.
2. ota_metadata.legacy.parser: integrate the new cert_store.
  • Loading branch information
Bodong-Yang authored Nov 29, 2024
1 parent 76fb0f1 commit 87ce7a3
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 69 deletions.
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,11 @@ dynamic = [
dependencies = [
"aiofiles<25,>=24.1",
"aiohttp>=3.10.11,<3.12",
"cryptography>=42.0.8,<43",
"cryptography>=42.0.8,<45",
"grpcio>=1.53.2,<1.69",
"protobuf>=4.21.12,<5.29",
"pydantic<3,>=2.6",
"pydantic-settings<3,>=2.3",
"pyopenssl==24.1.0",
"pyyaml<7,>=6.0.1",
"requests<2.33,>=2.32",
"simple-sqlite3-orm<0.3,>=0.2",
Expand Down
3 changes: 1 addition & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@
# DO NOT EDIT! Only for reference use.
aiofiles<25,>=24.1
aiohttp>=3.10.11,<3.12
cryptography>=42.0.8,<43
cryptography>=42.0.8,<45
grpcio>=1.53.2,<1.69
protobuf>=4.21.12,<5.29
pydantic<3,>=2.6
pydantic-settings<3,>=2.3
pyopenssl==24.1.0
pyyaml<7,>=6.0.1
requests<2.33,>=2.32
simple-sqlite3-orm<0.3,>=0.2
Expand Down
47 changes: 20 additions & 27 deletions src/ota_metadata/legacy/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@
)
from urllib.parse import quote

from OpenSSL import crypto
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA, EllipticCurvePublicKey
from cryptography.x509 import load_pem_x509_certificate
from typing_extensions import Self

from ota_metadata.utils.cert_store import CACertChainStore, load_cert_in_pem
from ota_metadata.utils.cert_store import CAChainStore
from ota_proxy import OTAFileCacheControl
from otaclient_common.common import urljoin_ensure_base
from otaclient_common.downloader import Downloader
Expand All @@ -91,6 +93,7 @@
logger = logging.getLogger(__name__)

CACHE_CONTROL_HEADER = OTAFileCacheControl.HEADER_LOWERCASE
ES256 = ECDSA(algorithm=hashes.SHA256())

_shutdown = False

Expand Down Expand Up @@ -243,9 +246,7 @@ class _MetadataJWTParser:
will be skipped! This SHOULD ONLY happen at non-production environment!
"""

HASH_ALG = "sha256"

def __init__(self, metadata_jwt: str, *, ca_chains_store: CACertChainStore):
def __init__(self, metadata_jwt: str, *, ca_chains_store: CAChainStore):
self.ca_chains_store = ca_chains_store

# pre_parse metadata_jwt
Expand Down Expand Up @@ -279,24 +280,15 @@ def verify_metadata_cert(self, metadata_cert: bytes) -> None:
raise MetadataJWTVerificationFailed(_err_msg)

try:
cert_to_verify = load_cert_in_pem(metadata_cert)
except crypto.Error as e:
cert_to_verify = load_pem_x509_certificate(metadata_cert)
except Exception as e:
_err_msg = f"invalid certificate {metadata_cert}: {e!r}"
logger.exception(_err_msg)
raise MetadataJWTVerificationFailed(_err_msg) from e

# verify the OTA image cert against CA cert chain store
for ca_prefix, ca_chain in self.ca_chains_store.items():
try:
crypto.X509StoreContext(
store=ca_chain,
certificate=cert_to_verify,
).verify_certificate()

logger.info(f"verfication succeeded against: {ca_prefix}")
return
except crypto.X509StoreContextError as e:
logger.info(f"verify against {ca_prefix} failed: {e}")
hit_cachain = self.ca_chains_store.verify(cert_to_verify)
if hit_cachain:
return

_err_msg = f"metadata sign certificate {metadata_cert} could not be verified"
logger.error(_err_msg)
Expand All @@ -309,15 +301,16 @@ def verify_metadata_signature(self, metadata_cert: bytes):
Raise MetadataJWTVerificationFailed on validation failed.
"""
try:
cert = crypto.load_certificate(crypto.FILETYPE_PEM, metadata_cert)
cert = load_pem_x509_certificate(metadata_cert)
logger.debug(f"verify data: {self.metadata_bytes=}")
crypto.verify(
cert,
self.metadata_signature,
self.metadata_bytes,
self.HASH_ALG,
_pubkey: EllipticCurvePublicKey = cert.public_key() # type: ignore[assignment]

_pubkey.verify(
signature=self.metadata_signature,
data=self.metadata_bytes,
signature_algorithm=ES256,
)
except crypto.Error as e:
except Exception as e:
msg = f"failed to verify metadata against sign cert: {e!r}"
logger.error(msg)
raise MetadataJWTVerificationFailed(msg) from e
Expand Down Expand Up @@ -590,7 +583,7 @@ def __init__(
url_base: str,
downloader: Downloader,
run_dir: Path,
ca_chains_store: CACertChainStore,
ca_chains_store: CAChainStore,
retry_interval: int = 1,
) -> None:
if not ca_chains_store:
Expand Down
122 changes: 109 additions & 13 deletions src/ota_metadata/utils/cert_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@

import logging
import re
from functools import partial
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict
from typing import Dict, Iterable

from OpenSSL import crypto
from cryptography.x509 import Certificate, Name, load_pem_x509_certificate

from otaclient_common.typing import StrOrPath

Expand All @@ -40,14 +40,105 @@
class CACertStoreInvalid(Exception): ...


class CACertChainStore(Dict[str, crypto.X509Store]):
"""A dict-like type that stores CA chain name and CA store mapping."""


load_cert_in_pem = partial(crypto.load_certificate, crypto.FILETYPE_PEM)
class CAChain(Dict[Name, Certificate]):
"""A dict that stores all the CA certs of a cert chains.
The key is the cert's subject, value is the cert itself.
"""

def load_ca_cert_chains(cert_dir: StrOrPath) -> CACertChainStore:
chain_prefix: str = ""

def set_chain_prefix(self, prefix: str) -> None:
self.chain_prefix = prefix

def add_cert(self, cert: Certificate) -> None:
self[cert.subject] = cert

def add_certs(self, certs: Iterable[Certificate]) -> None:
for cert in certs:
self[cert.subject] = cert

def internal_check(self) -> None:
"""Do an internal check to see if this CACertChain is valid.
Currently one check will be performed:
1. at least one root cert should be presented in the store.
Raises:
ValueError on failed check.
"""
for _, cert in self.items():
if cert.issuer == cert.subject:
return
raise ValueError("invalid chain: no root cert is presented")

def verify(self, cert: Certificate) -> None:
"""Verify the input <cert> against this chain.
Raises:
ValueError on input cert is not signed by this chain.
Other exceptions that could be raised by verify_directly_issued_by API.
Returns:
Return None on successful verification, otherwise raises exception.
"""
_now = datetime.now(tz=timezone.utc)
if not (cert.not_valid_after_utc >= _now >= cert.not_valid_before_utc):
_err_msg = (
"cert is not within valid period: "
f"{cert.not_valid_after_utc=}, {_now=}, {cert.not_valid_before_utc=}"
)
raise ValueError(_err_msg)

_start = cert
for _ in range(len(self) + 1):
if _start.issuer == _start.subject:
return

_issuer = self[_start.issuer]
_start.verify_directly_issued_by(_issuer)

_start = _issuer
raise ValueError(f"failed to verify {cert} against chain {self.chain_prefix}")


class CAChainStore(Dict[str, CAChain]):
"""A dict that stores CA chain name and CACertChains mapping."""

def add_chain(self, chain: CAChain) -> None:
self[chain.chain_prefix] = chain

def verify(self, cert: Certificate) -> CAChain | None:
"""Verify the input <cert> against this CAChainStore.
This verification only performs the following check:
1. ensure the cert is in valid period.
2. check cert is signed by any one of the CA chain.
Returns:
Return the cachain that issues the <cert>, None if no cachain matches.
"""
_now = datetime.now(tz=timezone.utc)
if not (cert.not_valid_after_utc >= _now >= cert.not_valid_before_utc):
logger.error(
"cert is not within valid period: "
f"{cert.not_valid_after_utc=}, {_now=}, {cert.not_valid_before_utc=}"
)
return

for _, chain in self.items():
try:
chain.verify(cert)
logger.info(f"verfication succeeded against: {chain.chain_prefix}")
return chain
except Exception as e:
logger.info(
f"failed to verify against CA chain {chain.chain_prefix}: {e!r}"
)
logger.error(f"failed to verify {cert=} against all CA chains: {list(self)}")


def load_ca_cert_chains(cert_dir: StrOrPath) -> CAChainStore:
"""Load CA cert chains from <cert_dir>.
Raises:
Expand All @@ -73,13 +164,18 @@ def load_ca_cert_chains(cert_dir: StrOrPath) -> CACertChainStore:

logger.info(f"found installed CA chains: {ca_set_prefix}")

ca_chains = CACertChainStore()
ca_chains = CAChainStore()
for ca_prefix in sorted(ca_set_prefix):
try:
ca_chain = crypto.X509Store()
ca_chain = CAChain()
ca_chain.set_chain_prefix(ca_prefix)

for c in cert_dir.glob(f"{ca_prefix}.*.pem"):
ca_chain.add_cert(load_cert_in_pem(c.read_bytes()))
ca_chains[ca_prefix] = ca_chain
_loaded_ca_cert = load_pem_x509_certificate(c.read_bytes())
ca_chain.add_cert(_loaded_ca_cert)

ca_chain.internal_check()
ca_chains.add_chain(ca_chain)
except Exception as e:
_err_msg = f"failed to load CA chain {ca_prefix}: {e!r}"
logger.warning(_err_msg)
Expand Down
6 changes: 3 additions & 3 deletions src/otaclient/ota_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
from ota_metadata.legacy import parser as ota_metadata_parser
from ota_metadata.legacy import types as ota_metadata_types
from ota_metadata.utils.cert_store import (
CACertChainStore,
CACertStoreInvalid,
CAChainStore,
load_ca_cert_chains,
)
from otaclient import errors as ota_errors
Expand Down Expand Up @@ -161,7 +161,7 @@ def __init__(
version: str,
raw_url_base: str,
cookies_json: str,
ca_chains_store: CACertChainStore,
ca_chains_store: CAChainStore,
upper_otaproxy: str | None = None,
boot_controller: BootControllerProtocol,
create_standby_cls: Type[StandbySlotCreatorProtocol],
Expand Down Expand Up @@ -682,7 +682,7 @@ def __init__(
_err_msg = f"failed to import ca_chains_store: {e!r}, OTA will NOT occur on no CA chains installed!!!"
logger.error(_err_msg)

self.ca_chains_store = CACertChainStore()
self.ca_chains_store = CAChainStore()

self.started = True
logger.info("otaclient started")
Expand Down
25 changes: 5 additions & 20 deletions tests/test_ota_metadata/test_ca_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,9 @@
from pathlib import Path

import pytest
from OpenSSL import crypto
from cryptography.x509 import load_pem_x509_certificate

from ota_metadata.utils.cert_store import (
CACertStoreInvalid,
load_ca_cert_chains,
load_cert_in_pem,
)
from ota_metadata.utils.cert_store import CACertStoreInvalid, load_ca_cert_chains
from tests.conftest import TEST_DIR
from tests.conftest import TestConfiguration as cfg

Expand Down Expand Up @@ -60,22 +56,11 @@ def setup_ca_chain(tmp_path: Path) -> tuple[str, Path, Path]:


def test_ca_store(setup_ca_chain: tuple[str, Path, Path]):
ca_chain, sign_pem, certs_dir = setup_ca_chain
_, sign_pem, certs_dir = setup_ca_chain
sign_cert = load_pem_x509_certificate(sign_pem.read_bytes())

ca_store = load_ca_cert_chains(certs_dir)

# verification should fail with wrong cert signed by other chain.
with pytest.raises(crypto.X509StoreContextError):
crypto.X509StoreContext(
store=ca_store[ca_chain],
certificate=load_cert_in_pem(TEST_BASE_SIGN_PEM.read_bytes()),
).verify_certificate()

# verification should succeed with proper chain and corresponding sign cert.
crypto.X509StoreContext(
store=ca_store[ca_chain],
certificate=load_cert_in_pem(sign_pem.read_bytes()),
).verify_certificate()
assert ca_store.verify(sign_cert)


def test_ca_store_empty(tmp_path: Path):
Expand Down
4 changes: 2 additions & 2 deletions tools/offline_ota_image_builder/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from typing import Mapping, Optional, Sequence

from ota_metadata.legacy import parser as ota_metadata_parser
from ota_metadata.utils.cert_store import CACertChainStore
from ota_metadata.utils.cert_store import CAChainStore
from otaclient_common.common import subprocess_call

from .configs import cfg
Expand Down Expand Up @@ -75,7 +75,7 @@ def _process_ota_image(ota_image_dir: StrPath, *, data_dir: StrPath, meta_dir: S
# NOTE: we don't need to do certificate verification here, sso we use an empty cert store.
metadata_jwt = ota_metadata_parser._MetadataJWTParser(
metadata_jwt_fpath.read_text(),
ca_chains_store=CACertChainStore(),
ca_chains_store=CAChainStore(),
).get_otametadata()

rootfs_dir = ota_image_dir / metadata_jwt.rootfs_directory
Expand Down

1 comment on commit 87ce7a3

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
src/ota_metadata/legacy
   __init__.py10100% 
   parser.py3254386%103, 159, 164, 200–201, 211–212, 215, 227, 278–280, 284–287, 313–316, 385, 388, 396–398, 411, 420–421, 424–425, 590–592, 642–643, 646, 674–676, 730, 733–735
   types.py841384%37, 40–42, 112–116, 122–125
src/ota_metadata/utils
   cert_store.py86890%58–59, 73, 87, 91, 102, 123, 127
src/ota_proxy
   __init__.py15660%48, 50, 52, 61, 71–72
   __main__.py880%16, 18–20, 22, 24–25, 27
   _consts.py150100% 
   cache_control_header.py68494%71, 91, 113, 121
   cache_streaming.py1441291%211, 225, 229–230, 265–266, 268, 349, 367–370
   config.py200100% 
   db.py731875%109, 115, 153, 159–160, 163, 169, 171, 192–199, 201–202
   errors.py50100% 
   external_cache.py282028%31, 35, 40–42, 44–45, 48–49, 51–53, 60, 63–65, 69–72
   lru_cache_helper.py47295%84–85
   ota_cache.py2216271%71–72, 147, 150–151, 163–164, 196–197, 214, 235, 254–258, 262–264, 266, 268–275, 277–279, 282–283, 287–288, 292, 339, 347–349, 428–431, 445, 448–449, 462–463, 465–467, 471–472, 478–479, 510, 516, 543, 595–597
   server_app.py1393971%76, 79, 85, 101, 103, 162, 171, 213–214, 216–218, 221, 226–228, 231–232, 235, 238, 241, 244, 257–258, 261–262, 264, 267, 293–296, 299, 313–315, 321–323
   utils.py140100% 
src/otaclient
   __init__.py5260%17, 19
   __main__.py110%16
   _logging.py513335%43–44, 46–47, 49–54, 56–57, 59–60, 62–65, 67, 77, 80–82, 84–86, 89–90, 92–96
   _status_monitor.py1611193%46, 48–49, 157, 160, 177, 180, 196–197, 204, 207
   _types.py750100% 
   errors.py120199%97
   main.py492940%30, 32–39, 41–42, 44–45, 47–48, 52, 54, 59, 61, 67–69, 72–73, 77–80, 82
   ota_core.py3009070%97, 124, 126–127, 129, 133, 137–138, 143–144, 150, 152, 191–194, 200, 204, 210, 349, 381–382, 384, 393, 396, 401–402, 405, 411, 413–417, 453–456, 459–466, 502–505, 583–590, 595, 598–605, 638–639, 645, 649–650, 656, 681–683, 685, 760, 788–789, 791–792, 800, 802–808
   utils.py37294%73–74
src/otaclient/boot_control
   __init__.py40100% 
   _firmware_package.py932276%82, 86, 136, 180, 186, 209–210, 213–218, 220–221, 224–229, 231
   _grub.py41812769%214, 262–265, 271–275, 312–313, 320–325, 328–334, 337, 340–341, 346, 348–350, 359–365, 367–368, 370–372, 381–383, 385–387, 466–467, 471–472, 524, 530, 556, 578, 582–583, 598–600, 624–627, 639, 643–645, 647–649, 708–711, 736–739, 762–765, 777–778, 781–782, 817, 823, 843–844, 846, 871–873, 891–894, 919–922, 929–932, 937–945, 950–957
   _jetson_cboot.py2612610%20, 22–25, 27–29, 35–40, 42, 58–60, 62, 64–65, 71, 75, 134, 137, 139–140, 143, 150–151, 159–160, 163, 169–170, 178, 187–191, 193, 199, 202–203, 209, 212–213, 218–219, 221, 227–228, 231–232, 235–237, 239, 245, 250–252, 254–256, 261, 263–266, 268–269, 278–279, 282–283, 288–289, 292–296, 299–300, 305–306, 309, 312–316, 321–324, 327, 330–331, 334, 337–338, 341, 345–350, 354–355, 359, 362–363, 366, 369–372, 374, 377–378, 382, 385, 388–391, 393, 400, 404–405, 408–409, 415–416, 422, 424–425, 429, 431, 433–435, 438, 442, 445, 448–449, 451, 454, 462–463, 470, 480, 483, 491–492, 497–500, 502, 509, 511–513, 519–520, 524–525, 528, 532, 535, 537, 544–548, 550, 562–565, 568, 571, 573, 580, 590–592, 594, 596, 599, 602, 605, 607–608, 611–615, 619–621, 623, 631–635, 637, 640, 644, 647, 658–659, 664, 674, 677–683, 687–693, 697–706, 710–718, 722, 724, 726–728
   _jetson_common.py1724573%132, 140, 288–291, 294, 311, 319, 354, 359–364, 382, 408–409, 411–413, 417–420, 422–423, 425–429, 431, 438–439, 442–443, 453, 456–457, 460, 462, 506–507
   _jetson_uefi.py40427432%124–126, 131–132, 151–153, 158–161, 328, 446, 448–451, 455, 459–460, 462–470, 472, 484–485, 488–489, 492–493, 496–498, 502–503, 508–510, 514, 518–519, 522–523, 526–527, 531, 534–535, 537, 542–543, 547, 550–551, 556, 560–561, 564, 568–570, 572, 576–579, 581–582, 604–605, 609–610, 612, 616, 620–621, 624–625, 632, 635–637, 640, 642–643, 648–649, 652–655, 657–658, 663, 665–666, 674, 677–680, 682–683, 685, 689–690, 694, 702–706, 709–710, 712, 715–719, 722, 725–729, 733–734, 737–742, 745–746, 749–752, 754–755, 762–763, 773–776, 779, 782–785, 788–792, 795–796, 799, 802–805, 808, 810, 815–816, 819, 822–825, 827, 833, 838–839, 858–859, 862, 870–871, 878, 888, 891, 898–899, 904–907, 915–918, 926–927, 939–942, 944, 947, 950, 958, 969–971, 973–975, 977–981, 986–987, 989, 1002, 1006, 1009, 1019, 1024, 1032–1033, 1036, 1040, 1042–1044, 1050–1051, 1056, 1064–1069, 1074–1079, 1084–1092, 1097–1104, 1112–1114
   _ota_status_control.py1021189%117, 122, 127, 240, 244–245, 248, 255, 257–258, 273
   _rpi_boot.py28713353%53, 56, 120–121, 125, 133–136, 150–153, 158–159, 161–162, 167–168, 171–172, 181–182, 222, 228–232, 235, 253–255, 259–261, 266–268, 272–274, 284–285, 288, 291, 293–294, 296–297, 299–301, 307, 310–311, 321–324, 332–336, 338, 340–341, 346–347, 354, 357–362, 393, 395–398, 408–411, 415–416, 418–422, 450–453, 472–475, 501–504, 509–517, 522–529, 544–547, 554–557, 565–567
   _slot_mnt_helper.py100100% 
   configs.py510100% 
   protocol.py60100% 
   selecter.py412929%44–46, 49–50, 54–55, 58–60, 63, 65, 69, 77–79, 81–82, 84–85, 89, 91, 93–94, 96, 98–99, 101, 103
src/otaclient/configs
   __init__.py170100% 
   _cfg_configurable.py470100% 
   _cfg_consts.py47197%97
   _common.py80100% 
   _ecu_info.py56394%59, 64–65
   _proxy_info.py51590%85, 87–88, 90, 101
   cfg.py190100% 
src/otaclient/create_standby
   __init__.py13192%36
   common.py2264480%59, 62–63, 67–69, 71, 75–76, 78, 126, 174–176, 178–180, 182, 185–188, 192, 203, 279–280, 282–287, 299, 339, 367, 370–372, 388–389, 403, 407, 429–430
   interface.py70100% 
   rebuild_mode.py1151091%98–100, 119, 150–155
src/otaclient/grpc
   _otaproxy_ctx.py644135%38, 40–41, 43–45, 47, 52–56, 58, 77–78, 80, 83, 87, 100–102, 106–107, 109, 111–112, 118–120, 124, 133–134, 136–141, 143–145
src/otaclient/grpc/api_v2
   ecu_status.py1531093%75, 78, 81, 147, 171, 173, 300, 370–371, 410
   ecu_tracker.py341944%40–42, 48, 52–54, 61–63, 66, 70–74, 77–79
   servicer.py1273671%82, 171–173, 180, 191–192, 233–234, 237–241, 250–259, 266, 272, 275–278, 284–285, 292, 295, 301, 304
   types.py46295%78–79
src/otaclient_api/v2
   api_caller.py39684%45–47, 83–85
   types.py2562391%86, 89–92, 131, 209–210, 212, 259, 262–263, 506–508, 512–513, 515, 518–519, 522–523, 586
src/otaclient_common
   __init__.py341555%42–44, 61, 63, 68–77
   _io.py64198%41
   cmdhelper.py130100% 
   common.py1061189%44, 148, 151–153, 168, 175–177, 271, 275
   downloader.py1991094%107–108, 126, 153, 369, 424, 428, 516–517, 526
   linux.py611575%51–53, 59, 69, 74, 76, 108–109, 133–134, 190, 195–196, 198
   logging.py29196%55
   persist_file_handling.py1181884%113, 118, 150–152, 163, 192–193, 228–232, 242–244, 246–247
   proto_streamer.py42880%33, 48, 66–67, 72, 81–82, 100
   proto_wrapper.py3984688%87, 165, 172, 184–186, 205, 210, 221, 257, 263, 268, 299, 303, 307, 402, 462, 469, 472, 492, 499, 501, 526, 532, 535, 537, 562, 568, 571, 573, 605, 609, 611, 625, 642, 669, 672, 676, 707, 713, 762–763, 765, 803–805
   retry_task_map.py105595%158–159, 161, 181–182
   shm_status.py952177%79–80, 83–84, 105, 120–122, 134, 139, 156–160, 169–170, 172, 179, 192, 204
   typing.py31487%48, 97–98, 100
TOTAL6561166274% 

Tests Skipped Failures Errors Time
236 0 💤 0 ❌ 0 🔥 12m 30s ⏱️

Please sign in to comment.