From 1c79be37a69cee9239396b2ac72245ba6831fc89 Mon Sep 17 00:00:00 2001 From: Pinga Date: Mon, 12 May 2025 11:20:23 +0300 Subject: [PATCH] RST updates and fixes --- cp/app/Controllers/ApplicationsController.php | 84 +++++++++++++---- cp/app/Controllers/DomainsController.php | 90 ++++++++++++++----- cp/bootstrap/helper.php | 30 +++++++ cp/composer.json | 3 +- epp/composer.json | 3 +- epp/src/epp-create.php | 34 +++++-- epp/src/epp-update.php | 6 +- epp/src/helpers.php | 30 +++++++ 8 files changed, 231 insertions(+), 49 deletions(-) diff --git a/cp/app/Controllers/ApplicationsController.php b/cp/app/Controllers/ApplicationsController.php index be18b9b..51f93e8 100644 --- a/cp/app/Controllers/ApplicationsController.php +++ b/cp/app/Controllers/ApplicationsController.php @@ -59,10 +59,10 @@ class ApplicationsController extends Controller $contactAdmin = $data['contactAdmin'] ?? null; $contactTech = $data['contactTech'] ?? null; $contactBilling = $data['contactBilling'] ?? null; - + $phaseType = $data['phaseType'] ?? null; - $phaseName = $data['phaseName'] ?? null; - + $phaseName = isset($data['phaseName']) && trim($data['phaseName']) !== '' ? $data['phaseName'] : null; + $nameservers = !empty($data['nameserver']) ? $data['nameserver'] : null; $nameserver_ipv4 = !empty($data['nameserver_ipv4']) ? $data['nameserver_ipv4'] : null; $nameserver_ipv6 = !empty($data['nameserver_ipv6']) ? $data['nameserver_ipv6'] : null; @@ -74,17 +74,19 @@ class ApplicationsController extends Controller $smdFile = $uploadedFiles['smd'] ?? null; $smd = null; - if ($smdFile && $smdFile->getError() === UPLOAD_ERR_OK) { - $smd = $smdFile->getStream()->getContents(); - $smd_filename = $smdFile->getClientFilename(); - - if (pathinfo($smd_filename, PATHINFO_EXTENSION) !== 'smd') { - $this->container->get('flash')->addMessage('error', 'Only .smd files are allowed'); + if ($phaseType === 'sunrise' || $phaseType === 'landrush') { + if ($smdFile && $smdFile->getError() === UPLOAD_ERR_OK) { + $smd = $smdFile->getStream()->getContents(); + $smd_filename = $smdFile->getClientFilename(); + + if (pathinfo($smd_filename, PATHINFO_EXTENSION) !== 'smd') { + $this->container->get('flash')->addMessage('error', 'Only .smd files are allowed'); + return $response->withHeader('Location', '/application/create')->withStatus(302); + } + } else { + $this->container->get('flash')->addMessage('error', 'SMD file upload failed'); return $response->withHeader('Location', '/application/create')->withStatus(302); } - } else { - $this->container->get('flash')->addMessage('error', 'SMD file upload failed'); - return $response->withHeader('Location', '/application/create')->withStatus(302); } if ($phaseType === 'custom' && empty($phaseName)) { @@ -141,11 +143,15 @@ class ApplicationsController extends Controller [$tld_id, $phaseType, $currentDate, $currentDate] ); + $noticeid = null; + $notafter = null; + $accepted = null; + if ($phase_details !== 'Application') { $this->container->get('flash')->addMessage('error', 'Error creating application: The launch phase ' . $phaseType . ' is improperly configured. Please check the settings or contact support.'); return $response->withHeader('Location', '/application/create')->withStatus(302); } - + if ($phaseType === 'claims') { if (!isset($data['noticeid']) || $data['noticeid'] === '' || !isset($data['notafter']) || $data['notafter'] === '' || @@ -157,12 +163,32 @@ class ApplicationsController extends Controller $noticeid = $data['noticeid']; $notafter = $data['notafter']; $accepted = $data['accepted']; - } else { - $noticeid = null; - $notafter = null; - $accepted = null; + + // Validate that acceptedDate is before notAfter + try { + $acceptedDate = DateTime::createFromFormat('Y-m-d\TH:i:s.u\Z', $data['accepted']); + $notAfterDate = DateTime::createFromFormat('Y-m-d\TH:i:s.u\Z', $data['notafter']); + + if (!$acceptedDate || !$notAfterDate) { + $this->container->get('flash')->addMessage('error', "Invalid date format"); + return $response->withHeader('Location', '/application/create')->withStatus(302); + } + + if ($acceptedDate >= $notAfterDate) { + $this->container->get('flash')->addMessage('error', "Invalid dates: acceptedDate must be before notAfter"); + return $response->withHeader('Location', '/application/create')->withStatus(302); + } + } catch (Exception $e) { + $this->container->get('flash')->addMessage('error', "Invalid date format"); + return $response->withHeader('Location', '/application/create')->withStatus(302); + } + + if (!validateTcnId($domainName, $noticeid, $data['notafter'])) { + $this->container->get('flash')->addMessage('error', "Invalid TMCH claims noticeID format"); + return $response->withHeader('Location', '/application/create')->withStatus(302); + } } - + if ($phaseType === 'sunrise') { if ($smd !== null && $smd !== '') { // Extract the BASE64 encoded part @@ -191,6 +217,28 @@ class ApplicationsController extends Controller $certPem = "-----BEGIN CERTIFICATE-----\n" . chunk_split($certBase64, 64, "\n") . "-----END CERTIFICATE-----\n"; + + // Load the SMD certificate + $x509 = new \phpseclib3\File\X509(); + $cert = $x509->loadX509($certPem); + $serial = strtoupper($cert['tbsCertificate']['serialNumber']->toHex()); // serial as hex + + // Get latest CRL from DB + $crlDer = $db->selectValue('SELECT content FROM tmch_crl ORDER BY update_timestamp DESC LIMIT 1'); + + // Load and parse the CRL + $crl = new \phpseclib3\File\X509(); + $crlData = $crl->loadCRL($crlDer); + + // Check revoked serials + $revoked = $crlData['tbsCertList']['revokedCertificates'] ?? []; + foreach ($revoked as $entry) { + $revokedSerial = strtoupper($entry['userCertificate']->toHex()); + if ($revokedSerial === $serial) { + $this->container->get('flash')->addMessage('error', 'Error creating application: SMD certificate has been revoked.'); + return $response->withHeader('Location', '/application/create')->withStatus(302); + } + } $notBefore = new \DateTime($xpath->evaluate('string(//smd:notBefore)')); $notafter = new \DateTime($xpath->evaluate('string(//smd:notAfter)')); diff --git a/cp/app/Controllers/DomainsController.php b/cp/app/Controllers/DomainsController.php index c9fe001..0dbded0 100644 --- a/cp/app/Controllers/DomainsController.php +++ b/cp/app/Controllers/DomainsController.php @@ -142,9 +142,9 @@ class DomainsController extends Controller $contactAdmin = $data['contactAdmin'] ?? null; $contactTech = $data['contactTech'] ?? null; $contactBilling = $data['contactBilling'] ?? null; - + $phaseType = $data['phaseType'] ?? 'none'; - $phaseName = $data['phaseName'] ?? null; + $phaseName = isset($data['phaseName']) && trim($data['phaseName']) !== '' ? $data['phaseName'] : null; $token = $data['token'] ?? null; @@ -169,17 +169,19 @@ class DomainsController extends Controller $smdFile = $uploadedFiles['smd'] ?? null; $smd = null; - if ($smdFile && $smdFile->getError() === UPLOAD_ERR_OK) { - $smd = $smdFile->getStream()->getContents(); - $smd_filename = $smdFile->getClientFilename(); - - if (pathinfo($smd_filename, PATHINFO_EXTENSION) !== 'smd') { - $this->container->get('flash')->addMessage('error', 'Only .smd files are allowed'); + if ($phaseType === 'sunrise' || $phaseType === 'landrush') { + if ($smdFile && $smdFile->getError() === UPLOAD_ERR_OK) { + $smd = $smdFile->getStream()->getContents(); + $smd_filename = $smdFile->getClientFilename(); + + if (pathinfo($smd_filename, PATHINFO_EXTENSION) !== 'smd') { + $this->container->get('flash')->addMessage('error', 'Only .smd files are allowed'); + return $response->withHeader('Location', '/domain/create')->withStatus(302); + } + } else { + $this->container->get('flash')->addMessage('error', 'SMD file upload failed'); return $response->withHeader('Location', '/domain/create')->withStatus(302); } - } else { - $this->container->get('flash')->addMessage('error', 'SMD file upload failed'); - return $response->withHeader('Location', '/domain/create')->withStatus(302); } if ($invalid_domain) { @@ -230,6 +232,10 @@ class DomainsController extends Controller [$tld_id, $currentDate, $currentDate] ); + $noticeid = null; + $notafter = null; + $accepted = null; + // Check if the phase requires application submission if ($phase_details && $phase_details === 'Application') { $this->container->get('flash')->addMessage('error', 'Domain registration is not allowed for this TLD. You must submit a new application instead.'); @@ -255,7 +261,7 @@ class DomainsController extends Controller return $response->withHeader('Location', '/domain/create')->withStatus(302); } } - + if ($phaseType === 'claims') { if (!isset($data['noticeid']) || $data['noticeid'] === '' || !isset($data['notafter']) || $data['notafter'] === '' || @@ -267,12 +273,32 @@ class DomainsController extends Controller $noticeid = $data['noticeid']; $notafter = $data['notafter']; $accepted = $data['accepted']; - } else { - $noticeid = null; - $notafter = null; - $accepted = null; + + // Validate that acceptedDate is before notAfter + try { + $acceptedDate = DateTime::createFromFormat('Y-m-d\TH:i:s.u\Z', $data['accepted']); + $notAfterDate = DateTime::createFromFormat('Y-m-d\TH:i:s.u\Z', $data['notafter']); + + if (!$acceptedDate || !$notAfterDate) { + $this->container->get('flash')->addMessage('error', "Invalid date format"); + return $response->withHeader('Location', '/domain/create')->withStatus(302); + } + + if ($acceptedDate >= $notAfterDate) { + $this->container->get('flash')->addMessage('error', "Invalid dates: acceptedDate must be before notAfter"); + return $response->withHeader('Location', '/domain/create')->withStatus(302); + } + } catch (Exception $e) { + $this->container->get('flash')->addMessage('error', "Invalid date format"); + return $response->withHeader('Location', '/domain/create')->withStatus(302); + } + + if (!validateTcnId($domainName, $noticeid, $data['notafter'])) { + $this->container->get('flash')->addMessage('error', "Invalid TMCH claims noticeID format"); + return $response->withHeader('Location', '/domain/create')->withStatus(302); + } } - + if ($phaseType === 'sunrise') { if ($smd !== null && $smd !== '') { // Extract the BASE64 encoded part @@ -301,6 +327,28 @@ class DomainsController extends Controller $certPem = "-----BEGIN CERTIFICATE-----\n" . chunk_split($certBase64, 64, "\n") . "-----END CERTIFICATE-----\n"; + + // Load the SMD certificate + $x509 = new \phpseclib3\File\X509(); + $cert = $x509->loadX509($certPem); + $serial = strtoupper($cert['tbsCertificate']['serialNumber']->toHex()); // serial as hex + + // Get latest CRL from DB + $crlDer = $db->selectValue('SELECT content FROM tmch_crl ORDER BY update_timestamp DESC LIMIT 1'); + + // Load and parse the CRL + $crl = new \phpseclib3\File\X509(); + $crlData = $crl->loadCRL($crlDer); + + // Check revoked serials + $revoked = $crlData['tbsCertList']['revokedCertificates'] ?? []; + foreach ($revoked as $entry) { + $revokedSerial = strtoupper($entry['userCertificate']->toHex()); + if ($revokedSerial === $serial) { + $this->container->get('flash')->addMessage('error', 'Error creating domain: SMD certificate has been revoked'); + return $response->withHeader('Location', '/domain/create')->withStatus(302); + } + } $notBefore = new \DateTime($xpath->evaluate('string(//smd:notBefore)')); $notafter = new \DateTime($xpath->evaluate('string(//smd:notAfter)')); @@ -312,14 +360,14 @@ class DomainsController extends Controller } if (!in_array($label, $labels)) { - $this->container->get('flash')->addMessage('error', 'Error creating domain: SMD file is not valid for the domain name being registered.'); + $this->container->get('flash')->addMessage('error', 'Error creating domain: SMD file is not valid for the domain name being registered'); return $response->withHeader('Location', '/domain/create')->withStatus(302); } // Check if current date and time is between notBefore and notAfter $now = new \DateTime(); if (!($now >= $notBefore && $now <= $notafter)) { - $this->container->get('flash')->addMessage('error', 'Error creating domain: Current time is outside the valid range in the SMD.'); + $this->container->get('flash')->addMessage('error', 'Error creating domain: Current time is outside the valid range in the SMD'); return $response->withHeader('Location', '/domain/create')->withStatus(302); } @@ -339,11 +387,11 @@ class DomainsController extends Controller $accepted = (new \DateTime())->format('Y-m-d H:i:s.v'); if (!$dsig->verify($key, $signatureNode)) { - $this->container->get('flash')->addMessage('error', 'Error creating domain: The XML signature of the SMD file is not valid.'); + $this->container->get('flash')->addMessage('error', 'Error creating domain: The XML signature of the SMD file is not valid'); return $response->withHeader('Location', '/domain/create')->withStatus(302); } } else { - $this->container->get('flash')->addMessage('error', "Error creating domain: SMD upload is required in the 'sunrise' phase."); + $this->container->get('flash')->addMessage('error', "Error creating domain: SMD upload is required in the 'sunrise' phase"); return $response->withHeader('Location', '/domain/create')->withStatus(302); } } diff --git a/cp/bootstrap/helper.php b/cp/bootstrap/helper.php index 474030c..c3a6412 100644 --- a/cp/bootstrap/helper.php +++ b/cp/bootstrap/helper.php @@ -1161,4 +1161,34 @@ function sign($ts, $method, $path, $body, $secret_key) { function getClid($db, string $clid): ?int { $result = $db->selectValue('SELECT id FROM registrar WHERE clid = ? LIMIT 1', [$clid]); return $result !== false ? (int)$result : null; +} + +function validateTcnId(string $domain, string $noticeId, string $notAfterUtc): bool +{ + // Ensure ID is at least 9 chars (8 for checksum + 1 for notice number) + if (strlen($noticeId) < 9) return false; + + $tcnChecksum = substr($noticeId, 0, 8); // First 8 hex chars + $noticeNumber = substr($noticeId, 8); // Rest is TMDB Notice Identifier + + // Validate numeric part + if (!ctype_digit($noticeNumber)) return false; + + // Convert domain to ASCII and get leftmost label + $asciiDomain = idn_to_ascii($domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46); + $leftmostLabel = explode('.', $asciiDomain)[0]; + + // Convert notAfter to Unix time + $notAfterTimestamp = strtotime($notAfterUtc); + if ($notAfterTimestamp === false) return false; + + // Build the checksum input string + $input = $leftmostLabel . $notAfterTimestamp . $noticeNumber; + + // Compute CRC32 as unsigned int, then format as 8-digit lowercase hex + $crc32 = hash('crc32b', $input); + $crc32Hex = str_pad(strtolower($crc32), 8, '0', STR_PAD_LEFT); + + // Compare + return hash_equals($tcnChecksum, $crc32Hex); } \ No newline at end of file diff --git a/cp/composer.json b/cp/composer.json index a532076..b5bf5a9 100644 --- a/cp/composer.json +++ b/cp/composer.json @@ -50,7 +50,8 @@ "moneyphp/money": "^4.6", "phpmailer/phpmailer": "^6.9", "filips123/monolog-phpmailer": "^2.0", - "robrichards/xmlseclibs": "^3.1" + "robrichards/xmlseclibs": "^3.1", + "phpseclib/phpseclib": "^3.0" }, "autoload": { "psr-4": { diff --git a/epp/composer.json b/epp/composer.json index 0ebaa75..17707a1 100644 --- a/epp/composer.json +++ b/epp/composer.json @@ -10,6 +10,7 @@ "phpmailer/phpmailer": "^6.9", "filips123/monolog-phpmailer": "^2.0", "ramsey/uuid": "^4.7", - "robrichards/xmlseclibs": "^3.1" + "robrichards/xmlseclibs": "^3.1", + "phpseclib/phpseclib": "^3.0" } } diff --git a/epp/src/epp-create.php b/epp/src/epp-create.php index e666105..59f6eaf 100644 --- a/epp/src/epp-create.php +++ b/epp/src/epp-create.php @@ -88,7 +88,7 @@ function processContactCreate($conn, $db, $xml, $clid, $database_type, $trans) { if ($postalInfoIntStreet1) { if ( preg_match('/(^\-)|(^\,)|(^\.)|(\-\-)|(\,\,)|(\.\.)|(\-$)/', $postalInfoIntStreet1) || - !preg_match('/^[a-zA-Z0-9\-\&\,\.\/\s]{5,}$/', $postalInfoIntStreet1) || + !preg_match('/^[a-zA-Z0-9\'\-\&\,\.\/\s]{5,}$/', $postalInfoIntStreet1) || strlen($postalInfoIntStreet1) > 255 ) { sendEppError($conn, $db, 2005, 'Invalid contact:street', $clTRID, $trans); @@ -99,7 +99,7 @@ function processContactCreate($conn, $db, $xml, $clid, $database_type, $trans) { if ($postalInfoIntStreet2) { if ( preg_match('/(^\-)|(^\,)|(^\.)|(\-\-)|(\,\,)|(\.\.)|(\-$)/', $postalInfoIntStreet2) || - !preg_match('/^[a-zA-Z0-9\-\&\,\.\/\s]{5,}$/', $postalInfoIntStreet2) || + !preg_match('/^[a-zA-Z0-9\'\-\&\,\.\/\s]{5,}$/', $postalInfoIntStreet2) || strlen($postalInfoIntStreet2) > 255 ) { sendEppError($conn, $db, 2005, 'Invalid contact:street', $clTRID, $trans); @@ -110,7 +110,7 @@ function processContactCreate($conn, $db, $xml, $clid, $database_type, $trans) { if ($postalInfoIntStreet3) { if ( preg_match('/(^\-)|(^\,)|(^\.)|(\-\-)|(\,\,)|(\.\.)|(\-$)/', $postalInfoIntStreet3) || - !preg_match('/^[a-zA-Z0-9\-\&\,\.\/\s]{5,}$/', $postalInfoIntStreet3) || + !preg_match('/^[a-zA-Z0-9\'\-\&\,\.\/\s]{5,}$/', $postalInfoIntStreet3) || strlen($postalInfoIntStreet3) > 255 ) { sendEppError($conn, $db, 2005, 'Invalid contact:street', $clTRID, $trans); @@ -690,8 +690,8 @@ function processDomainCreate($conn, $db, $xml, $clid, $database_type, $trans, $m return; } - if (strlen($noticeid) !== 27 || !ctype_alnum($noticeid)) { - sendEppError($conn, $db, 2306, 'Invalid noticeID format', $clTRID, $trans); + if (!validateTcnId($domainName, $noticeid, $launch_notAfter)) { + sendEppError($conn, $db, 2306, 'Invalid TMCH claims noticeID format', $clTRID, $trans); } } elseif ($launch_phase === 'landrush') { // Continue @@ -824,6 +824,30 @@ function processDomainCreate($conn, $db, $xml, $clid, $database_type, $trans, $m $certPem = "-----BEGIN CERTIFICATE-----\n" . chunk_split($certBase64, 64, "\n") . "-----END CERTIFICATE-----\n"; + + // Load the SMD certificate + $x509 = new \phpseclib3\File\X509(); + $cert = $x509->loadX509($certPem); + $serial = strtoupper($cert['tbsCertificate']['serialNumber']->toHex()); // serial as hex + + // Get latest CRL from DB + $stmt = $db->query("SELECT content FROM tmch_crl ORDER BY update_timestamp DESC LIMIT 1"); + $crlDer = $stmt->fetchColumn(); + $stmt->closeCursor(); + + // Load and parse the CRL + $crl = new \phpseclib3\File\X509(); + $crlData = $crl->loadCRL($crlDer); + + // Check revoked serials + $revoked = $crlData['tbsCertList']['revokedCertificates'] ?? []; + foreach ($revoked as $entry) { + $revokedSerial = strtoupper($entry['userCertificate']->toHex()); + if ($revokedSerial === $serial) { + sendEppError($conn, $db, 2306, 'Error creating domain: SMD certificate has been revoked.', $clTRID, $trans); + return; + } + } $notBefore = new \DateTime($xpath->evaluate('string(//smd:notBefore)')); $notafter = new \DateTime($xpath->evaluate('string(//smd:notAfter)')); diff --git a/epp/src/epp-update.php b/epp/src/epp-update.php index 2eb6240..393791d 100644 --- a/epp/src/epp-update.php +++ b/epp/src/epp-update.php @@ -188,7 +188,7 @@ function processContactUpdate($conn, $db, $xml, $clid, $database_type, $trans) { if ($postalInfoIntStreet1) { if ( preg_match('/(^\-)|(^\,)|(^\.)|(\-\-)|(\,\,)|(\.\.)|(\-$)/', $postalInfoIntStreet1) || - !preg_match('/^[a-zA-Z0-9\-\&\,\.\/\s]{5,}$/', $postalInfoIntStreet1) || + !preg_match('/^[a-zA-Z0-9\'\-\&\,\.\/\s]{5,}$/', $postalInfoIntStreet1) || strlen($postalInfoIntStreet1) > 255 ) { sendEppError($conn, $db, 2005, 'Invalid contact:street', $clTRID, $trans); @@ -199,7 +199,7 @@ function processContactUpdate($conn, $db, $xml, $clid, $database_type, $trans) { if ($postalInfoIntStreet2) { if ( preg_match('/(^\-)|(^\,)|(^\.)|(\-\-)|(\,\,)|(\.\.)|(\-$)/', $postalInfoIntStreet2) || - !preg_match('/^[a-zA-Z0-9\-\&\,\.\/\s]{5,}$/', $postalInfoIntStreet2) || + !preg_match('/^[a-zA-Z0-9\'\-\&\,\.\/\s]{5,}$/', $postalInfoIntStreet2) || strlen($postalInfoIntStreet2) > 255 ) { sendEppError($conn, $db, 2005, 'Invalid contact:street', $clTRID, $trans); @@ -210,7 +210,7 @@ function processContactUpdate($conn, $db, $xml, $clid, $database_type, $trans) { if ($postalInfoIntStreet3) { if ( preg_match('/(^\-)|(^\,)|(^\.)|(\-\-)|(\,\,)|(\.\.)|(\-$)/', $postalInfoIntStreet3) || - !preg_match('/^[a-zA-Z0-9\-\&\,\.\/\s]{5,}$/', $postalInfoIntStreet3) || + !preg_match('/^[a-zA-Z0-9\'\-\&\,\.\/\s]{5,}$/', $postalInfoIntStreet3) || strlen($postalInfoIntStreet3) > 255 ) { sendEppError($conn, $db, 2005, 'Invalid contact:street', $clTRID, $trans); diff --git a/epp/src/helpers.php b/epp/src/helpers.php index ca89f6d..c32c431 100644 --- a/epp/src/helpers.php +++ b/epp/src/helpers.php @@ -1069,4 +1069,34 @@ function normalizeDatetime($input) { $dt = DateTime::createFromFormat('Y-m-d\TH:i:s.v\Z', $input); return $dt ? $dt->format('Y-m-d H:i:s.v') : null; +} + +function validateTcnId(string $domain, string $noticeId, string $notAfterUtc): bool +{ + // Ensure ID is at least 9 chars (8 for checksum + 1 for notice number) + if (strlen($noticeId) < 9) return false; + + $tcnChecksum = substr($noticeId, 0, 8); // First 8 hex chars + $noticeNumber = substr($noticeId, 8); // Rest is TMDB Notice Identifier + + // Validate numeric part + if (!ctype_digit($noticeNumber)) return false; + + // Convert domain to ASCII and get leftmost label + $asciiDomain = idn_to_ascii($domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46); + $leftmostLabel = explode('.', $asciiDomain)[0]; + + // Convert notAfter to Unix time + $notAfterTimestamp = strtotime($notAfterUtc); + if ($notAfterTimestamp === false) return false; + + // Build the checksum input string + $input = $leftmostLabel . $notAfterTimestamp . $noticeNumber; + + // Compute CRC32 as unsigned int, then format as 8-digit lowercase hex + $crc32 = hash('crc32b', $input); + $crc32Hex = str_pad(strtolower($crc32), 8, '0', STR_PAD_LEFT); + + // Compare + return hash_equals($tcnChecksum, $crc32Hex); } \ No newline at end of file