Files
whitebox/impl/reference/license_whitebox_impl.cc
Aaron Vaage 5d90e8d89b Benchmarking and Unmasking
In this code drop we introduce the benchmarking tests that allow us to
compare the performance of different implementations. Like the other
tests, any implementation can link with them to create their own
binary.

There are two types of benchmarks:
  1 - Throughput, which measures the speed that a function can process
      information (bits per second). These are used for AEAD decrypt
      and license white-box decrypt functions.
  2 - Samples, which measures the min, 25% percentile, median, 75%
      percentile, and max observed values. These is used for all other
      functions as a way to measure the execute duration of a call.

The other change in this code drop is the update to the unmasking
function to only unmask a subset of the bytes in the masked buffer.
This was added to better align with the decoder behaviour in the CDM.
2020-06-24 15:30:50 -07:00

695 lines
25 KiB
C++

// Copyright 2020 Google LLC. All Rights Reserved.
#include "api/license_whitebox.h"
#include <stdint.h>
#include <map>
#include <memory>
#include <string>
#include <vector>
#include "base/logging.h"
#include "cdm/protos/license_protocol.pb.h"
#include "crypto_utils/aes_cbc_decryptor.h"
#include "crypto_utils/aes_ctr_encryptor.h"
#include "crypto_utils/crypto_util.h"
#include "crypto_utils/rsa_key.h"
#include "impl/reference/memory_util.h"
#include "third_party/boringssl/src/include/openssl/aes.h"
#include "third_party/boringssl/src/include/openssl/cmac.h"
#include "third_party/boringssl/src/include/openssl/err.h"
#include "third_party/boringssl/src/include/openssl/evp.h"
#include "third_party/boringssl/src/include/openssl/hmac.h"
#include "third_party/boringssl/src/include/openssl/rsa.h"
#include "third_party/boringssl/src/include/openssl/sha.h"
namespace {
using AesCbcDecryptor = widevine::AesCbcDecryptor;
using AesCtrDecryptor = widevine::AesCtrEncryptor;
using KeyContainer = video_widevine::License_KeyContainer;
using RsaPrivateKey = widevine::RsaPrivateKey;
struct ContentKey {
// When we store a key, we create our own little policy for the key saying
// what functions may use it. This allows us to "blacklist" a key by setting
// all "allow_*" to false.
bool allow_decrypt;
bool allow_masked_decrypt;
// Key used to decrypt content.
std::vector<uint8_t> key;
};
} // namespace
// The white-box type can't be in the namespace as it is defined in the header.
struct WB_License_Whitebox {
// CDM key, used for license requests.
std::unique_ptr<RsaPrivateKey> key;
// Keys used for license renewal.
std::string server_signing_key;
std::string client_signing_key;
std::map<std::string, ContentKey> content_keys;
};
namespace {
// For simplicity we use a basic pad but we use a different byte for each
// position as we need to support abirtary indexes and we want to make sure that
// a wrong index will actually trigger a failure.
const uint8_t kSecretStringPattern[] = {
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F,
};
const size_t kSecretStringPatternSize = sizeof(kSecretStringPattern);
const ContentKey* FindKey(const WB_License_Whitebox* whitebox,
const uint8_t* id,
size_t id_size) {
DCHECK(whitebox);
DCHECK(id);
DCHECK_GT(id_size, 0u);
const std::string key_id(id, id + id_size);
const auto found = whitebox->content_keys.find(key_id);
return found == whitebox->content_keys.end() ? nullptr : &found->second;
}
WB_Result DecryptBuffer(WB_CipherMode mode,
const std::vector<uint8_t>& key,
const uint8_t* input_data,
size_t input_data_size,
const uint8_t* iv,
size_t iv_size,
uint8_t* output_data,
size_t* output_data_size) {
if (!input_data || !iv || !output_data || !output_data_size) {
DVLOG(1) << "Invalid parameter: null pointer.";
return WB_RESULT_INVALID_PARAMETER;
}
if (input_data_size == 0) {
DVLOG(1) << "Invalid parameter: array size 0.";
return WB_RESULT_INVALID_PARAMETER;
}
// Data must be "block aligned" for CBC. CTR does not have this requirement.
if (mode == WB_CIPHER_MODE_CBC && input_data_size % AES_BLOCK_SIZE != 0) {
DVLOG(1) << "Invalid parameter: bad block size.";
return WB_RESULT_INVALID_PARAMETER;
}
// We only support 16 byte IVs. CENC allows 8 byte, but it is expected that
// the IV will be padded before entering the whitebox.
if (iv_size != 16) {
DVLOG(1) << "Invalid parameter: invalid iv size.";
return WB_RESULT_INVALID_PARAMETER;
}
// There is no padding, so the output will be the same length as the input.
if (*output_data_size < input_data_size) {
DVLOG(1) << "Buffer too small: needs " << input_data_size << " but got "
<< *output_data_size << ".";
*output_data_size = input_data_size;
return WB_RESULT_BUFFER_TOO_SMALL;
}
// By this point, we have verified everything that need to be verified.
// Decryption should just work.
if (mode == WB_CIPHER_MODE_CBC) {
AesCbcDecryptor decryptor;
CHECK(decryptor.SetKey(key.data(), key.size()));
*output_data_size = input_data_size;
CHECK(decryptor.Decrypt(iv, iv_size, input_data, input_data_size,
output_data));
} else if (mode == WB_CIPHER_MODE_CTR) {
AesCtrDecryptor decryptor;
CHECK(decryptor.SetKey(key.data(), key.size()));
// Encrypt and Decrypt for CBC use the same interface.
*output_data_size = input_data_size;
CHECK(decryptor.Encrypt(iv, iv_size, input_data, input_data_size,
output_data));
} else {
DVLOG(1) << "Invalid parameter: invalid cipher mode.";
return WB_RESULT_INVALID_PARAMETER;
}
return WB_RESULT_OK;
}
// We use "remote_attestation_verified" and "platform_verification_status" to
// determine whether the platform is hardware verified.
//
// Each variable can be in one of three states. Each variable has a missing
// value, a true value, and a false value.
//
// |----------------------------------------------------------|
// | | RA N/A | RA VERIFIED | RA NOT VERIFIED |
// |----------------------------------------------------------|
// | VMP N/A | 0 | 1 | 0 |
// | VMP HW_VERIFIED | 1 | 1 | 0 |
// | VMP OTHER | 0 | 0 | 0 |
// |----------------------------------------------------------|
bool IsPlatformHardwareVerified(const video_widevine::License& license) {
const bool has_ra = license.has_remote_attestation_verified();
const bool has_vmp = license.has_platform_verification_status();
if (!has_ra && !has_vmp) {
return false;
}
// We trust "missing" more than "false". Now that we know we have some values,
// default to "it's okay" and only override it if we have a new value from the
// license.
bool flags[2] = {true, true};
if (has_ra) {
flags[0] = license.remote_attestation_verified();
}
if (has_vmp) {
flags[1] = license.platform_verification_status() ==
video_widevine::PLATFORM_HARDWARE_VERIFIED;
}
// If we were missing a value, that flag will still be true. But if we see any
// false values, then we know something was found to be invalid.
return flags[0] && flags[1];
}
} // namespace
WB_Result WB_License_Create(const uint8_t* whitebox_init_data,
size_t whitebox_init_data_size,
WB_License_Whitebox** whitebox) {
if (!whitebox_init_data || !whitebox) {
DVLOG(1) << "Invalid parameter: null pointer.";
return WB_RESULT_INVALID_PARAMETER;
}
if (whitebox_init_data_size == 0) {
DVLOG(1) << "Invalid parameter: array size 0.";
return WB_RESULT_INVALID_PARAMETER;
}
// |whitebox_init_data| is simply the bytes of a PKCS #8 PrivateKeyInfo block
// of a RSA 2048 bit key.
std::unique_ptr<RsaPrivateKey> key(RsaPrivateKey::Create(std::string(
whitebox_init_data, whitebox_init_data + whitebox_init_data_size)));
if (!key) {
DVLOG(1) << "Invalid parameter: invalid init data.";
return WB_RESULT_INVALID_PARAMETER;
}
// Should always be non-null on modern compilers
// (https://isocpp.org/wiki/faq/freestore-mgmt).
*whitebox = new WB_License_Whitebox();
(*whitebox)->key.swap(key);
return WB_RESULT_OK;
}
void WB_License_Delete(WB_License_Whitebox* whitebox) {
// Safe to delete nullptr (https://isocpp.org/wiki/faq/freestore-mgmt).
delete whitebox;
}
WB_Result WB_License_SignLicenseRequest(const WB_License_Whitebox* whitebox,
const uint8_t* license_request,
size_t license_request_size,
uint8_t* signature,
size_t* signature_size) {
if (!whitebox || !license_request || !signature || !signature_size) {
DVLOG(1) << "Invalid parameter: null pointer.";
return WB_RESULT_INVALID_PARAMETER;
}
if (license_request_size == 0) {
DVLOG(1) << "Invalid parameter: array size 0.";
return WB_RESULT_INVALID_PARAMETER;
}
std::string result;
DCHECK(whitebox->key->GenerateSignature(
std::string(license_request, license_request + license_request_size),
&result));
if (!widevine::MemCopy(result.data(), result.size(), signature,
*signature_size)) {
DVLOG(1) << "Buffer too small: signature needs " << result.size() << ".";
*signature_size = result.size();
return WB_RESULT_BUFFER_TOO_SMALL;
}
*signature_size = result.size();
return WB_RESULT_OK;
}
WB_Result WB_License_ProcessLicenseResponse(WB_License_Whitebox* whitebox,
const uint8_t* message,
size_t message_size,
const uint8_t* signature,
size_t signature_size,
const uint8_t* session_key,
size_t session_key_size,
const uint8_t* license_request,
size_t license_request_size) {
const size_t kSigningKeySizeBytes =
widevine::crypto_util::kSigningKeySizeBytes;
if (!whitebox || !message || !signature || !session_key || !license_request) {
DVLOG(1) << "Invalid parameter: null pointer.";
return WB_RESULT_INVALID_PARAMETER;
}
// If we have already loaded a license, ProcessLicenseResponse() may not be
// called again. The white-box needs to be destroyed and a new one needs
// to be created.
if (whitebox->content_keys.size() > 0) {
DVLOG(1) << "Invalid state: already loaded a license.";
return WB_RESULT_INVALID_STATE;
}
if (message_size == 0 || session_key_size == 0 || license_request_size == 0) {
DVLOG(1) << "Invalid parameter: array size 0.";
return WB_RESULT_INVALID_PARAMETER;
}
// Because we use SHA256, the hash will be 32 bytes (256 bits).
if (signature_size != 32) {
DVLOG(1) << "Invalid parameter: invalid signature size.";
return WB_RESULT_INVALID_PARAMETER;
}
std::string decrypted_session_key;
if (!whitebox->key->Decrypt(
std::string(session_key, session_key + session_key_size),
&decrypted_session_key)) {
DVLOG(1) << "Invalid parameter: invalid session key.";
return WB_RESULT_INVALID_PARAMETER;
}
std::string signing_key_material = widevine::crypto_util::DeriveKey(
decrypted_session_key, widevine::crypto_util::kSigningKeyLabel,
std::string(license_request, license_request + license_request_size),
widevine::crypto_util::kSigningKeySizeBits * 2);
if (signing_key_material.size() < kSigningKeySizeBytes * 2) {
DVLOG(1) << "Invalid parameter: invalid session key size.";
return WB_RESULT_INVALID_PARAMETER;
}
const std::string server_signing_key =
signing_key_material.substr(0, kSigningKeySizeBytes);
if (!widevine::crypto_util::VerifySignatureHmacSha256(
server_signing_key,
std::string(signature, signature + signature_size),
std::string(message, message + message_size))) {
DVLOG(1) << "Failed to verify signed message.";
return WB_RESULT_INVALID_SIGNATURE;
}
std::string decryption_key = widevine::crypto_util::DeriveKey(
decrypted_session_key, widevine::crypto_util::kWrappingKeyLabel,
std::string(license_request, license_request + license_request_size),
widevine::crypto_util::kWrappingKeySizeBits);
if (decryption_key.empty()) {
DVLOG(1) << "Failed to decrypt decryption key";
return WB_RESULT_INVALID_SIGNATURE;
}
AesCbcDecryptor decryptor;
CHECK(
decryptor.SetKey(reinterpret_cast<const uint8_t*>(decryption_key.data()),
decryption_key.size()));
video_widevine::License license;
if (!license.ParseFromArray(message, message_size)) {
DVLOG(1) << "Invalid parameter: Invalid license.";
return WB_RESULT_INVALID_PARAMETER;
}
std::string server_renewal_key;
std::string client_renewal_key;
std::map<std::string, ContentKey> content_keys;
// When the platform is hardware verified, all keys are unlocked and are
// available to be used with either decrypt function. Use this flag to
// overwrite the default values internal our internal policies to enable
// this behaviour.
const bool is_verified = IsPlatformHardwareVerified(license);
for (const auto& key : license.key()) {
// If this is not a key we're interested in, skip it as soon as possible.
// Don't even bother unwrapping it.
if (key.type() != KeyContainer::SIGNING &&
key.type() != KeyContainer::CONTENT) {
continue;
}
const std::string wrapped_key = key.key();
std::vector<uint8_t> unwrapped_key(wrapped_key.size());
if (!decryptor.Decrypt(reinterpret_cast<const uint8_t*>(key.iv().data()),
key.iv().size(),
reinterpret_cast<const uint8_t*>(wrapped_key.data()),
wrapped_key.size(), unwrapped_key.data())) {
// The input has to be a specific length, so if it is not, it means that
// something is wrong with the license.
DVLOG(1) << "Invalid parameter: Invalid license.";
return WB_RESULT_INVALID_PARAMETER;
}
if (key.type() == KeyContainer::SIGNING) {
if (unwrapped_key.size() < kSigningKeySizeBytes * 2) {
DVLOG(1) << "Invalid parameter: Invalid signing key.";
return WB_RESULT_INVALID_PARAMETER;
}
const std::string signing_key(unwrapped_key.begin(), unwrapped_key.end());
server_renewal_key = signing_key.substr(0, kSigningKeySizeBytes);
client_renewal_key =
signing_key.substr(kSigningKeySizeBytes, kSigningKeySizeBytes);
} else if (key.type() == KeyContainer::CONTENT) {
constexpr size_t kContentKeySizeBytes = 16;
if (unwrapped_key.size() < kContentKeySizeBytes) {
DVLOG(1) << "Invalid parameter: Invalid content key.";
return WB_RESULT_INVALID_PARAMETER;
}
unwrapped_key.resize(kContentKeySizeBytes);
ContentKey content_key;
switch (key.level()) {
case video_widevine::
License_KeyContainer_SecurityLevel_SW_SECURE_CRYPTO:
content_key.allow_decrypt = true;
content_key.allow_masked_decrypt = true;
break;
case video_widevine::
License_KeyContainer_SecurityLevel_SW_SECURE_DECODE:
content_key.allow_decrypt = false;
content_key.allow_masked_decrypt = true;
break;
default:
content_key.allow_decrypt = false;
content_key.allow_masked_decrypt = false;
break;
}
content_key.allow_decrypt |= is_verified;
content_key.allow_masked_decrypt |= is_verified;
// Unless we are going to use the key, we don't want to save this key as
// it will only risk exposing it. We only have an entry for it so we can
// handle errors correctly.
if (content_key.allow_decrypt || content_key.allow_masked_decrypt) {
content_key.key = std::move(unwrapped_key);
}
content_keys[key.id()] = content_key;
} else {
// We should have already skipped over this key.
CHECK(false);
}
}
// Copy the loaded state over to the white-box instance now that we know we
// have a valid state.
whitebox->server_signing_key.swap(server_renewal_key);
whitebox->client_signing_key.swap(client_renewal_key);
whitebox->content_keys.swap(content_keys);
return WB_RESULT_OK;
}
WB_Result WB_License_SignRenewalRequest(const WB_License_Whitebox* whitebox,
const uint8_t* message,
size_t message_size,
uint8_t* signature,
size_t* signature_size) {
if (!whitebox || !message || !signature || !signature_size) {
DVLOG(1) << "Invalid parameter: null pointer.";
return WB_RESULT_INVALID_PARAMETER;
}
if (message_size == 0) {
DVLOG(1) << "Invalid parameter: array size 0.";
return WB_RESULT_INVALID_PARAMETER;
}
if (whitebox->content_keys.empty()) {
DVLOG(1) << "Invalid state: missing license.";
return WB_RESULT_INVALID_STATE;
}
if (whitebox->client_signing_key.empty()) {
DVLOG(1) << "Invalid state: license does not support renewals.";
return WB_RESULT_INVALID_STATE;
}
const std::string computed_signature =
widevine::crypto_util::CreateSignatureHmacSha256(
whitebox->client_signing_key,
std::string(message, message + message_size));
if (!widevine::MemCopy(computed_signature.data(), computed_signature.size(),
signature, *signature_size)) {
DVLOG(1) << "Buffer too small: signature needs "
<< computed_signature.size() << ".";
*signature_size = computed_signature.size();
return WB_RESULT_BUFFER_TOO_SMALL;
}
*signature_size = computed_signature.size();
return WB_RESULT_OK;
}
WB_Result WB_License_VerifyRenewalResponse(const WB_License_Whitebox* whitebox,
const uint8_t* message,
size_t message_size,
const uint8_t* signature,
size_t signature_size) {
if (!whitebox || !message || !signature) {
DVLOG(1) << "Invalid parameter: null pointer";
return WB_RESULT_INVALID_PARAMETER;
}
if (message_size == 0) {
DVLOG(1) << "Invalid parameter: array size 0";
return WB_RESULT_INVALID_PARAMETER;
}
if (whitebox->content_keys.empty()) {
DVLOG(1) << "Invalid state: missing license.";
return WB_RESULT_INVALID_STATE;
}
if (whitebox->server_signing_key.empty()) {
DVLOG(1) << "Invalid state: license does not support renewals.";
return WB_RESULT_INVALID_STATE;
}
const std::string computed_signature =
widevine::crypto_util::CreateSignatureHmacSha256(
whitebox->server_signing_key,
std::string(message, message + message_size));
if (signature_size != computed_signature.size()) {
DVLOG(1) << "Invalid parameters: invalid signature size.";
return WB_RESULT_INVALID_PARAMETER;
}
if (computed_signature !=
std::string(signature, signature + signature_size)) {
DVLOG(1) << "Data verification error: signatures do not match.";
return WB_RESULT_INVALID_SIGNATURE;
}
return WB_RESULT_OK;
}
WB_Result WB_License_GetSecretString(const WB_License_Whitebox* whitebox,
WB_CipherMode mode,
const uint8_t* key_id,
size_t key_id_size,
uint8_t* secret_string,
size_t* secret_string_size) {
if (!whitebox || !key_id || !secret_string || !secret_string_size) {
DVLOG(1) << "Invalid parameter: null pointer.";
return WB_RESULT_INVALID_PARAMETER;
}
if (whitebox->content_keys.empty()) {
DVLOG(1) << "Invalid state: missing license.";
return WB_RESULT_INVALID_STATE;
}
if (mode != WB_CIPHER_MODE_CTR && mode != WB_CIPHER_MODE_CBC) {
DVLOG(1) << "Invalid parameter: invalid cipher mode.";
return WB_RESULT_INVALID_PARAMETER;
}
if (key_id_size == 0) {
DVLOG(1) << "Invalid parameter: array size 0.";
return WB_RESULT_INVALID_PARAMETER;
}
// The secret string can differ between keys, so we need to make sure that
// the key id is actually a content key.
const ContentKey* content_key = FindKey(whitebox, key_id, key_id_size);
if (content_key == nullptr) {
DVLOG(1) << "Key unavailable: could not find key.";
return WB_RESULT_KEY_UNAVAILABLE;
}
if (!content_key->allow_masked_decrypt) {
DVLOG(1) << "Insufficient security level: key policy does not allow use "
"with MaskedDecrypt().";
return WB_RESULT_INSUFFICIENT_SECURITY_LEVEL;
}
if (!widevine::MemCopy(kSecretStringPattern, kSecretStringPatternSize,
secret_string, *secret_string_size)) {
DVLOG(1) << "Buffer too small: needs " << kSecretStringPatternSize << ".";
*secret_string_size = kSecretStringPatternSize;
return WB_RESULT_BUFFER_TOO_SMALL;
}
*secret_string_size = kSecretStringPatternSize;
return WB_RESULT_OK;
}
WB_Result WB_License_Decrypt(const WB_License_Whitebox* whitebox,
WB_CipherMode mode,
const uint8_t* key_id,
size_t key_id_size,
const uint8_t* input_data,
size_t input_data_size,
const uint8_t* iv,
size_t iv_size,
uint8_t* output_data,
size_t* output_data_size) {
if (!whitebox || !key_id) {
DVLOG(1) << "Invalid parameter: null pointer.";
return WB_RESULT_INVALID_PARAMETER;
}
if (whitebox->content_keys.empty()) {
DVLOG(1) << "Invalid state: missing license.";
return WB_RESULT_INVALID_STATE;
}
if (key_id_size == 0) {
DVLOG(1) << "Invalid parameter: array size 0.";
return WB_RESULT_INVALID_PARAMETER;
}
const ContentKey* content_key = FindKey(whitebox, key_id, key_id_size);
if (content_key == nullptr) {
DVLOG(1) << "Key unavailable: could not find key.";
return WB_RESULT_KEY_UNAVAILABLE;
}
if (!content_key->allow_decrypt) {
DVLOG(1) << "Insufficient security level: key policy does not allow use "
"with Decrypt().";
return WB_RESULT_INSUFFICIENT_SECURITY_LEVEL;
}
// DecryptBuffer() will validate the remaining decryption parameters.
return DecryptBuffer(mode, content_key->key, input_data, input_data_size, iv,
iv_size, output_data, output_data_size);
}
WB_Result WB_License_MaskedDecrypt(const WB_License_Whitebox* whitebox,
WB_CipherMode mode,
const uint8_t* key_id,
size_t key_id_size,
const uint8_t* input_data,
size_t input_data_size,
const uint8_t* iv,
size_t iv_size,
uint8_t* masked_output_data,
size_t* masked_output_data_size) {
if (!whitebox || !key_id || !masked_output_data || !masked_output_data_size) {
DVLOG(1) << "Invalid parameter: null pointer.";
return WB_RESULT_INVALID_PARAMETER;
}
if (whitebox->content_keys.empty()) {
DVLOG(1) << "Invalid state: missing license.";
return WB_RESULT_INVALID_STATE;
}
if (key_id_size == 0) {
DVLOG(1) << "Invalid parameter: array size 0.";
return WB_RESULT_INVALID_PARAMETER;
}
const ContentKey* content_key = FindKey(whitebox, key_id, key_id_size);
if (content_key == nullptr) {
DVLOG(1) << "Key unavailable: could not find key.";
return WB_RESULT_KEY_UNAVAILABLE;
}
if (!content_key->allow_masked_decrypt) {
DVLOG(1) << "Insufficient security level: key policy does not allow use "
"with MaskedDecrypt().";
return WB_RESULT_INSUFFICIENT_SECURITY_LEVEL;
}
// DecryptBuffer() will validate all the parameters, so just make sure it is
// safe to resize this and let the normal validation process handle anything
// wrong with the output size.
std::vector<uint8_t> output;
if (masked_output_data_size) {
output.resize(*masked_output_data_size);
}
// DecryptBuffer() will validate the remaining decryption parameters and set
// |masked_output_data_size|.
const WB_Result result =
DecryptBuffer(mode, content_key->key, input_data, input_data_size, iv,
iv_size, output.data(), masked_output_data_size);
if (result != WB_RESULT_OK) {
return result;
}
// Trivial implementation that simply takes the decrypted output and XORs it
// with a fixed pattern. |output|'s size is based on |masked_output_data| so
// we shouldn't need to worry about overflow. This logic must be mirrored in
// Unmask().
const uint8_t* mask = kSecretStringPattern;
const size_t mask_size = kSecretStringPatternSize;
for (size_t i = 0; i < output.size(); ++i) {
masked_output_data[i] = output[i] ^ mask[i % mask_size];
}
return WB_RESULT_OK;
}
void WB_License_Unmask(const uint8_t* masked_data,
size_t offset,
size_t size,
const uint8_t* secret_string,
size_t secret_string_size,
uint8_t* unmasked_data) {
// No return code, so only check if parameters are valid.
DCHECK(masked_data);
DCHECK(secret_string);
DCHECK(unmasked_data);
for (size_t local_i = 0; local_i < size; local_i++) {
const size_t global_i = offset + local_i;
const uint8_t mask = secret_string[global_i % secret_string_size];
unmasked_data[local_i] = masked_data[global_i] ^ mask;
}
}