英特尔® Software Guard Extensions 教程系列: 第四部分,安全区设计

英特尔® Software Guard Extensions(英特尔® SGX)教程系列第四部分将介绍如何设计安全区及其界面。 我们将回顾第三部分中定义的安全区边界,并识别必要的桥接函数,检查桥接函数对对象模型的影响,并创建所需的项目基础设施以将安全区集成至应用。 本部分仅为安全区 ECALLS 提供桩文件;第五部分将介绍完全集成安全区。

文章英特尔® Software Guard Extensions 教程系列简介列举了所有已经发布的教程。

该系列的安装部分将提供源代码:安全区桩文件和界面功能可供下载。

应用架构

开始设计安全区界面之前,我们首先需要了解整体应用架构。 如第一部分所述,安全区以动态加载库(Windows* 下的 DLL 和 Linux* 下的共享库)的形式实施,而且只能链接 100% 原生 C 代码。

不过 Tutorial Password Manager 有一个用 C# 编写的 GUI。 它使用用 C++/CLI 编写的混合模式汇编程序帮助我们从托管代码移至非托管代码,不过该汇编程序包含的原生代码不是 100% 原生模块,而且无法直接与英特尔 SGX 安全区交互。 尝试将非信任安全区桥接函数融入 C++/CLI 汇编程序会造成致命错误:

	Command line error D8045: cannot compile C file 'Enclave_u.c' with the /clr option

这表示我们需要将非信任桥接函数放在全是原生代码的独立 DLL 中。 因此,应用最少需要三个 DLL:C++/CLI 内核,安全区桥接和安全区。 其结构如图 1 所示。


图 1. 带有安全区的混合模式应用组成部分。

进一步完善

由于安全区桥接函数必须驻留在单独的 DLL 中,因此我们执行下一步骤,将直接处理安全区的所有功能都放置在同一个 DLL 中。 划分应用层不仅能够简化程序管理(和调试),还能减少对其他模块的影响,从而简化集成过程。 当类或模块的特定任务包含清晰定义的边界,对其他模块的修改不太可能对其造成影响。

在这种情况下,PasswordManagerCoreNative 类不应该承担其他实例化安全区的任务。 它只需知道平台是否支持英特尔 SGX,以便执行相应的功能。

例如,以下代码块显示了 unlock() 方法:

int PasswordManagerCoreNative::vault_unlock(const LPWSTR wpassphrase)
{
	int rv;
	UINT16 size;

	char *mbpassphrase = tombs(wpassphrase, -1, &size);
	if (mbpassphrase == NULL) return NL_STATUS_ALLOC;

	rv= vault.unlock(mbpassphrase);

	SecureZeroMemory(mbpassphrase, size);
	delete[] mbpassphrase;

	return rv;
} 

这种方法能够轻松地将用户的口令当作 wchar_t,将其转化成可变长度编码 (UTF-8),然后在仓库对象中调用 unlock() 方法。 为了防止安全区处理函数和逻辑使该类和该方法变得散乱,最好通过添加一行代码为该方法添加安全区支持:

int PasswordManagerCoreNative::vault_unlock(const LPWSTR wpassphrase)
{
	int rv;
	UINT16 size;

	char *mbpassphrase = tombs(wpassphrase, -1, &size);
	if (mbpassphrase == NULL) return NL_STATUS_ALLOC;

	// Call the enclave bridge function if we support Intel SGX
	if (supports_sgx()) rv = ew_unlock(mbpassphrase);
	else rv= vault.unlock(mbpassphrase);

	SecureZeroMemory(mbpassphrase, size);
	delete[] mbpassphrase;

	return rv;
}

我们的目标是,如果可行,尽量减少该类对安全区的感知。 PasswordManagerCoreNative 类另外只需添加一个标记,以帮助设置并获取英特尔 SGX 支持和方法。

class PASSWORDMANAGERCORE_API PasswordManagerCoreNative
{
	int _supports_sgx;

	// Other class members ommitted for clarity

protected:
	void set_sgx_support(void) { _supports_sgx = 1; }
	int supports_sgx(void) { return _supports_sgx; }

设计安全区

整体应用计划就绪后,现在可以开始设计安全区及其界面。 为此,我们回顾一下第三部分图 2 中面向应用内核的类图。 将驻留在安全区内的对象涂成绿色,非信任组件涂成蓝色。


图 2. 面向基于英特尔® Software Guard Extensions 的 Tutorial Password Manager 的类图。

该安全区边界仅穿过了一个连接:PasswordManagerCoreNative 对象和 Vault 对象之间的链路。 这表示大部分 ECALL 将仅成为围绕 Vault 中的类方法的包装程序。 我们还需要添加其他 ECALL 来管理安全区基础设施。 开发安全区所产生的一个问题是,ECALL、OCALL 和桥接函数必须为原生 C 代码,而且我们要充分利用 C++ 特性。 安全区启动后,我们还需要能够跨越 C 与 C++ 之间的障碍的函数(对象、构造函数、过载等)。

包装程序和桥接函数将前往它们自己的 DLL,我们将其命名为 EnclaveBridge.dll。 为了清楚起见,我们分别为包装程序函数和进行 ECALL 的桥接函数加上前缀 ew_(针对 “enclave wrapper”)和 ve_(针对 “vault enclave”)。

PasswordManagerCoreNativeVault 中相应方法的调用将遵循图 3 所示的基本流程。


图 3. 桥接函数和 ECALL 执行流程。

PasswordManagerCoreNative 中的方法将调用至 PasswordManagerCoreNative 中的包装程序函数。 该包装程序将反过来调用一个或多个 ECALL,以进入安全区并调用 Vault 对象中相应的类方法。 完成所有 ECALL 后,包装程序函数返回至 PasswordManagerCoreNative 中的调用方法,并为其提供一个返回值。

安全区逻辑

设计安全区的第一步是制定安全区管理系统。 安全区必须启动,而且生成的安全区 ID 必须提供给 ECALL。 理想状态下,该过程对应用的所有上层都是透明的。

对 Tutorial Password Manager 来说,最简单的解决方法是使用 EnclaveBridge DLL 中的全局变量保留安全区信息。 这种设计决策有一个限制条件:安全区中每次只有一个线程处于活跃状态。 不过这种方法比较合理,因为仓库上运行多个线程对密码管理器应用没有任何帮助。 大部分操作由用户界面驱动完成,不会消耗太长的 CPU 时间。

为解决透明度问题,包装程序函数会首先调用函数以查看安全区是否启动,如果没有,将启动该安全区。这种逻辑非常简单:

#define ENCLAVE_FILE _T("Enclave.signed.dll")

static sgx_enclave_id_t enclaveId = 0;
static sgx_launch_token_t launch_token = { 0 };
static int updated= 0;
static int launched = 0;
static sgx_status_t sgx_status= SGX_SUCCESS;

// Ensure the enclave has been created/launched.

static int get_enclave(sgx_enclave_id_t *eid)
{
	if (launched) return 1;
	else return create_enclave(eid);
}

static int create_enclave(sgx_enclave_id_t *eid)
{
	sgx_status = sgx_create_enclave(ENCLAVE_FILE, SGX_DEBUG_FLAG, &launch_token, &updated, &enclaveId, NULL);
	if (sgx_status == SGX_SUCCESS) {
		if ( eid != NULL ) *eid = enclaveId;
		launched = 1;
		return 1;
	}

	return 0;
}

包装程序函数首先调用 get_enclave(),通过检查静态变量查看安全区是否已启动。 如果已经启动,(可选)将安全区 ID 填充至 eid 指示器。 这一步骤是可选的,因为安全区 ID 也能够以全局变量 enclaveID 的形式保存,可以直接使用。

如果出现断电或系统因为漏洞而出现崩溃,导致安全区丢失怎么办? 如果出现这种情况,我们可以检查 ECALL 的返回值:它表示 ECALL 操作本身(而不是安全区中调用的函数)成功与否。

sgx_status = ve_initialize(enclaveId, &vault_rv);

在安全区中调用的函数的返回值(如有)将通过以次要参数形式提供的指示器传输至 ECALL(Edger8r 工具自动为您生成这些函数原型)。 必须时刻检查 ECALL 的返回值。 如果结果不是 SGX_SUCCESS,表示程序没有成功进入安全区,所请求的函数也没有运行。 (请注意,我们将 gx_status 同样定义为全局变量。 这是另外一种简化方法,源于单线程设计。)

我们将添加以下函数以检查 ECALL 返回的错误,并查看丢失或崩溃的安全区:

static int lost_enclave()
{
	if (sgx_status == SGX_ERROR_ENCLAVE_LOST || sgx_status == SGX_ERROR_ENCLAVE_CRASHED) {
		launched = 0;
		return 1;
	}

	return 0;
}

这些错误是可恢复的。 上层目前没有处理这些特定情况的逻辑,不过我们在 EnclaveBridge DLL 中提供该逻辑,以支持未来的增强措施。

大家还需注意,这里不提供破坏安全区的函数。 只要用户打开密码管理器应用,安全区就将处于就绪状态,即使选择锁定仓库也是如此。 安全区的这一规范并不合理。 即使处于闲置状态,安全区也可提取有限资源池。 本教程系列的后续部分探讨数据密封时,我们再来尝试解决这一问题。

安全区定义语言

开始实际设计安全区之前,我们简要介绍一下安全区定义语言 (EDL) 语法。 安全区的桥接函数(ECALL 和 OCALL)均在其 EDL 文件中构建原型,其通用结构如下所示:

enclave {
	// Include files
	 
	// Import other edl files
	 
	// Data structure declarations to be used as parameters of the function prototypes in edl

	trusted {
	// Include file if any. It will be inserted in the trusted header file (enclave_t.h)
	 
	// Trusted function prototypes (ECALLs)
	 
	};
	 
	untrusted {
	// Include file if any. It will be inserted in the untrusted header file (enclave_u.h)
	 
	// Untrusted function prototypes (OCALLs)
	 
	};
};

ECALL 在可信区构建原型,OCALL 在非信任区构建原型。

EDL 语法与 C 类似,而且其函数原型也与 C 函数原型非常相似,但并不相同。 具体而言,桥接函数参数和返回值会受到部分基本数据类型的限制,而且 DL 包含部分定义某种安全区行为的附加密码和语法。 英特尔® Software Guard Extensions(英特尔® SGX) SDK 用户指南详细介绍了 EDL 语法,并包含有关如何创建示例安全区的教程。 此处我们不再重复,只介绍特定于本应用的一些语言元素。

参数传递至安全区函数后,将封送至安全区的受保护内存空间。 不需要针对以数值形式传递的参数执行特殊操作,因为这些值放在安全区的受保护堆栈上,与用于其他函数调用情况一样。 而指示器则完全不同。

对于以指示器形式传递的参数来说,指示器引用的数据必须封送至安全区内,或从安全区封送出来。 执行此数据封送的边缘例程需要了解两种情况:

  1. 朝哪个方向拷贝数据:拷贝至桥接函数,拷贝出桥接函数,或这两者?
  2. 指示器引用的数据缓冲区的大小?

指示器方向

向函数提供指示器参数时,必须在括号中用关键明方向: [in][out][in, out]。 相关含义如表 1 所示。

方向       

ECALL

OCALL

in

缓冲区从应用拷贝至安全区。 更改仅影响安全区内的缓冲区。

缓冲区从安全区拷贝至应用。 更改仅影响安全区外的缓冲区。

out

缓冲区在安全区内分配并初始化为零。 ECALL 退出时拷贝至原始缓冲区。

缓冲区在安全区外分配并初始化为零。 OCALL 退出时非信任缓冲区拷贝至安全区内的原始缓冲区。

in, out               

数据来回拷贝。

与 ECALL 相同。

表 1. 指示器方向参数及其在 ECALL 和 OCALL 中的含义。

通过该表格我们注意到,方向与所调用的桥接函数有关。 对 ECALL 而言,[in] 表示“将缓冲区拷贝至安全区”,而对 OCALL 而言。它表示“将缓冲区拷贝至非信任函数”。

(还有一个名为 user_check 的选项可用于此处,但与我们此处介绍的内容无关。 关于其用法和用途,请参阅 SDK 文档。)

缓冲区大小

边缘例程负责计算缓冲区大小(单位:字节),计算公式为:

bytes = element_size * element_count

默认情况下,边缘例程假定 element_count = 1,并通过指示器参数引用的元素计算 element_size,例如,对整数指示器来说,它假定 element_size 为:

sizeof(int)

对数据类型固定的单个元素(比如 intfloat)来说,该函数的 EDL 原型中不需要提供其他信息。 对 void 指示器来说,必须指定元素大小,否则在编译时会出现错误。 对数据缓冲区长度大于一个元素的阵列、charwchar_t 字符串或其他类型来说,必须指定缓冲区中的元素数量,否则仅拷贝一个元素。

根据需要在括号中的指示器关键字中添加 countsize 参数(或两者)。 它们可设为恒定值,或该函数的其中一个参数。 在多数情况下,countsize 的功能相同,但最好在准确的环境中使用。 严格来说,传递 void 指示器时,仅指定 size。 其他情况需使用 count

如果传递 C 字符串或 wstring(NULL 终止的 charwchar_t 阵列),可以在 countsize 位置使用 stringwstring 参数。 在这种情况下,边缘例程将直接获取字符串的长度,以确定缓冲区大小。

function([in, size=12] void *param);
function([in, count=len] char *buffer, uint32_t len);
function([in, string] char *cstr);

注意,如果方向设置为 [in][in, out],那么只能使用 stringwstring。 如果方向仅设置为 [out],而尚未创建字符串,那么边缘例程将不知道缓冲区的大小。 指定 [out, string] 会导致编译期间出现错误。

包装程序和桥接函数

现在我们已经准备好定义包装程序和桥接函数。 如前所述,大部分 ECALL 将成为与 Vault 中的类方法有关的包装程序函数。 公共成员函数的类定义如下所示:

class PASSWORDMANAGERCORE_API Vault
{
	// Non-public methods and members ommitted for brevity

public:
	Vault();
	~Vault();

	int initialize();
	int initialize(const char *header, UINT16 size);
	int load_vault(const char *edata);

	int get_header(unsigned char *header, UINT16 *size);
	int get_vault(unsigned char *edate, UINT32 *size);
	
	UINT32 get_db_size();

	void lock();
	int unlock(const char *password);

	int set_master_password(const char *password);
	int change_master_password(const char *oldpass, const char *newpass);

	int accounts_get_count(UINT32 *count);
	int accounts_get_info(UINT32 idx, char *mbname, UINT16 *mbname_len, char *mblogin, UINT16 *mblogin_len, char *mburl, UINT16 *mburl_len);

	int accounts_get_password(UINT32 idx, char **mbpass, UINT16 *mbpass_len);
	
	int accounts_set_info(UINT32 idx, const char *mbname, UINT16 mbname_len, const char *mblogin, UINT16 mblogin_len, const char *mburl, UINT16 mburl_len);
	int accounts_set_password(UINT32 idx, const char *mbpass, UINT16 mbpass_len);

	int accounts_generate_password(UINT16 length, UINT16 pwflags, char *cpass);

	int is_valid() { return _VST_IS_VALID(state); }
	int is_locked() { return ((state&_VST_LOCKED) == _VST_LOCKED) ? 1 : 0; }
};

此类中包含多个问题函数。 部分问题函数显而易见,比如构造函数、析构函数和 initialize() 过载。 使用 C 函数时必须调用这些 C++ 特性。 而还有一些问题函数并不明显,因为它们是函数的内在设计所造成的。 (一些问题方法故意设计不当,以便我们在本教程中解决这些具体问题,而另外一些就是设计不当!) 我们将逐一解决这些问题,从而展示面向包装程序函数的原型和面向代理/桥接例程的 EDL 原型。

构造函数和析构函数

在非英特尔 SGX 代码路径中,Vault 类是 PasswordManagerCoreNative 的一个成员。 我们在英特尔 SGX 代码路径中无法进行此操作;但安全区能够包含 C++ 代码,只要桥接函数本身是单纯的 C 函数。

由于我们已将安全区限制在单个线程内,因此在安全区内能够将 Vault 类变成静态的全局对象。 这样大大简化了代码,而且对其进行实例化时无需创建桥接函数和逻辑。

initialize() 过载

有两种原型可用于 initialize() 方法:

  1. 不包含参数的方法针对无任何内容的新密码仓库初始化 Vault 对象。 该密码仓库为用户首次创建。
  2. 包含两个参数的方法通过仓库文件的头文件初始化对象。 它表示用户正在打开现有密码仓库(并且稍后尝试解锁)。

该过程将分成两个包装程序函数:

ENCLAVEBRIDGE_API int ew_initialize();
ENCLAVEBRIDGE_API int ew_initialize_from_header(const char *header, uint16_t hsize);

而且相应的 ECALL 将定义为:

public int ve_initialize ();
public int ve_initialize_from_header ([in, count=len] unsigned char *header, uint16_t len);

get_header()

该方法存在一个基本设计问题。 原型如下:

int get_header(unsigned char *header, uint16_t *size);

该函数可完成下列两个任务中的一个:

  1. 获取面向仓库文件的头文件数据块,并将其放在按照头文件指向的缓冲区中。 调用者必须分配足够的内存来保存此数据。
  2. 如果在头文件参数中传递 NULL 指示器,按照大小指向的 uint16_t 将设置为头文件数据块的大小,以便调用者知道分配多少内存。

在部分编程周期中,它是一种常见的压缩技术,但会导致安全区遇到一个问题:将指示器传递至 ECALL 或 OCALL 时,边缘函数会将指示器引用的数据拷贝至安全区内,或从安全区拷贝出来(或两者)。 这些边缘函数需要知道数据缓冲区的大小,以便知道拷贝多少字节。 第一种用法涉及一个有效参数,其大小可变(这不是问题),而第二种用法包含一个 NULL 指示器,其大小为零。

我们可以为该 ECALL 构建一个 EDL 原型以使其运行,但我们应该确保其清晰度(而非简洁性)。 最好将其分成两个 ECALL:

public int ve_get_header_size ([out] uint16_t *sz);
public int ve_get_header ([out, count=len] unsigned char *header, uint16_t len);

安全区包装程序函数会负责所需的逻辑,因此我们无需更改其他的类:

ENCLAVEBRIDGE_API int ew_get_header(unsigned char *header, uint16_t *size)
{
	int vault_rv;

	if (!get_enclave(NULL)) return NL_STATUS_SGXERROR;

	if ( header == NULL ) sgx_status = ve_get_header_size(enclaveId, &vault_rv, size);
	else sgx_status = ve_get_header(enclaveId, &vault_rv, header, *size);

	RETURN_SGXERROR_OR(vault_rv);
}

accounts_get_info()

这种方法的运行方式与 get_header() 类似:传递 NULL 指示器,并返回相应参数中对象的大小。 不过,由于有多个参数,它看起来不太美观,而且比较凌乱。 最好将其分成两个包装程序函数:

ENCLAVEBRIDGE_API int ew_accounts_get_info_sizes(uint32_t idx, uint16_t *mbname_sz, uint16_t *mblogin_sz, uint16_t *mburl_sz);
ENCLAVEBRIDGE_API int ew_accounts_get_info(uint32_t idx, char *mbname, uint16_t mbname_sz, char *mblogin, uint16_t mblogin_sz, char *mburl, uint16_t mburl_sz);

以及两个相应的 ECALL:

public int ve_accounts_get_info_sizes (uint32_t idx, [out] uint16_t *mbname_sz, [out] uint16_t *mblogin_sz, [out] uint16_t *mburl_sz);
public int ve_accounts_get_info (uint32_t idx, 
	[out, count=mbname_sz] char *mbname, uint16_t mbname_sz, 
	[out, count=mblogin_sz] char *mblogin, uint16_t mblogin_sz,
	[out, count=mburl_sz] char *mburl, uint16_t mburl_sz
);

accounts_get_password()

这是最糟糕的情况。 原型如下:

int accounts_get_password(UINT32 idx, char **mbpass, UINT16 *mbpass_len);

大家首先会看到,它将指示器传递至 mbpass 中的指示器。 该方法正在分配内存。

一般来说,这种设计不太好。 Vault 类中没有其他方法分配内存,因此它内部是不一致的,而且 API 打破了常规,因为它不提供方法以代表调用者释放内存。 还会导致安全区遇到一个特殊的问题:安全区无法在非信任空间中分配内存。

我们可以在包装程序函数中解决该问题。 它能够分配内存,然后形成 ECALL,这样对调用者来说一切都是透明的。但不管怎样我们必须在 Vault 类中修改该方法,以便只将其改成正确的方法,并对 PasswordManagerCoreNative 进行相应修改。 应该为调用者提供两个函数:一个用于获取密码长度,另一个用于提取密码(与之前的两个示例相同)。 PasswordManagerCoreNative 应负责分配内存,而非这些函数(非英特尔 SGX 代码路径也应更改)。

ENCLAVEBRIDGE_API int ew_accounts_get_password_size(uint32_t idx, uint16_t *len);
ENCLAVEBRIDGE_API int ew_accounts_get_password(uint32_t idx, char *mbpass, uint16_t len);

现在,EDL 定义如下所示:

public int ve_accounts_get_password_size (uint32_t idx, [out] uint16_t *mbpass_sz);
public int ve_accounts_get_password (uint32_t idx, [out, count=mbpass_sz] char *mbpass, uint16_t mbpass_sz);

load_vault()

load_vault() 问题非常微妙。 原型非常简单,咋一看没有任何问题:

int load_vault(const char *edata);

该方法负责将已完成加密和序列化的密码数据库加载至 Vault 对象。 由于 Vault 对象已经读取了头文件,因此它知道入站缓冲区的大小。

此处的问题是,安全区的边缘函数没有这类信息。 必须为 ECALL 明确提供长度,以便边缘函数知道将多少字节的数据从入站缓冲区拷贝至安全区的内部缓冲区,但大小保存在安全区中, 边缘函数无法使用这类信息。

包装程序函数的原型能够镜像该类方法的原型,如下所示:

ENCLAVEBRIDGE_API int ew_load_vault(const unsigned char *edata);

但 ECALL 需要以参数形式传递头文件大小,以便在 EDL 文件中定义入站数据缓冲区的大小:

public int ve_load_vault ([in, count=len] unsigned char *edata, uint32_t len)

为确保此操作对调用者的透明度,我们为包装程序函数提供额外的逻辑。 它将负责从安全区提取仓库大小,然后以参数形式将其传递至 ECALL。

ENCLAVEBRIDGE_API int ew_load_vault(const unsigned char *edata)
{
	int vault_rv;
	uint32_t dbsize;

	if (!get_enclave(NULL)) return NL_STATUS_SGXERROR;

	// We need to get the size of the password database before entering the enclave
	// to send the encrypted blob.

	sgx_status = ve_get_db_size(enclaveId, &dbsize);
	if (sgx_status == SGX_SUCCESS) {
		// Now we can send the encrypted vault data across.

		sgx_status = ve_load_vault(enclaveId, &vault_rv, (unsigned char *) edata, dbsize);
	}

	RETURN_SGXERROR_OR(vault_rv);
}

浅谈 Unicode

第三部分中,我们提到过 PasswordManagerCoreNative 类同样承担在 wchar_tchar 字符串之间进行转换的任务。 倘若安全区支持 wchar_t 数据类型,那为何要进行此操作?

这种设计决策旨在最大限度地缩小我们的占地空间。 在 Windows 中,wchar_t 数据类型是面向 Win32 API 的原生编码,负责保存 UTF-16 编码字符。 在 UTF-16 中,每个字符都是 16 位,以便支持非 ASCII 字符,尤其是不以拉丁字母表为基础或字符数量较多的语言。 UTF-16 的问题是字符始终保持 16 位的长度,即使在编码纯 ASCII 文本时也是如此。

在用户帐号信息为纯 ASCII 的常见情况下,将两倍多的数据保存在磁盘上或安全区内,当需要拷贝并加密这些额外字节时,会使性能会受到影响,相比之下,Tutorial Password Manager 选择将所有字符串从 .NET 转换成 UTF-8 编码。 UTF-8 是一种可变长度字符编码,其中 1-4 个 8 位字节表示一个字符。 它后向兼容 ASCII,因此其编码比面向纯 ASCII 文本的 UTF-16 更紧凑。 也有 UTF-8 生成的字符串比 UTF-16 长的情况,但对本 tutorial password manager 来说,我们接受此类折衷情况。

商用应用会根据用户的本地语言选择最佳的编码方式,并将该编码方式记录在仓库中(以便使用其他本地语言在系统上打开仓库时,知道使用哪种编码进行创建)。

示例代码

如简介部分所述,本部分提供示例代码供您下载。 随附档案包含面向 Tutorial Password Manager 桥接 DLL 和安全区 DLL 的源代码。 此时安全区函数只是桩文件,我们将在第五部分对其进行填充。

即将推出

第五部分通过将 Crypto、DRNG 和 Vault 类移植到安全区内,并将其连接至 ECALL,从而完成安全区开发。 敬请关注!

AdjuntoTamaño
Icono de paquete Tutorial-Password-Manager-part-4.zip23.69 KB
Para obtener información más completa sobre las optimizaciones del compilador, consulte nuestro Aviso de optimización.