YAPM(又一个密码管理器)





5.00/5 (6投票s)
本文描述了创建安全的离线密码管理器所需的安全技术,以及 Libsodium 库如何用于实现这一目标。YAPM 使用 AES 加密存储密码,并使用 Argon2 哈希对用户进行身份验证。
引言
信息安全领域有句话说,我们不应该自己动手。这背后的普遍想法是,在你开发新协议或实现现有协议的过程中,你会在某个地方犯下一个严重的安全性错误,从而严重影响系统的安全性。
然而,本项目仅用于教育目的,旨在了解现有协议、实现问题和一般编程。我知道我在开发 YAPM 时几乎肯定犯了一些严重的错误,因此我不建议将其用作您的个人密码管理器(如果能通过评论指出任何安全问题,将不胜感激!)。
密码管理器非常有用,因为它们不仅解决了忘记密码的问题,而且还鼓励为每个帐户使用更强大、更独特的密码,因为它们不需要记住。然而,这也意味着主密码必须非常安全——如果它被发现,那么攻击者就掌握了“王国之钥”。
安全
一般安全
YAPM 密码使用 AES-GCM-256 位加密存储在名为 main_store 的文件中,并使用密码学随机的 32 字节加密密钥来加密和解密存储的密码。该加密密钥使用 AES-GCM-256 位加密,以主密码的哈希作为密钥,并存储在名为 enc_key 的文件中。用户通过主密码的 Argon2i 哈希(和盐)进行身份验证,该哈希存储在名为 auth 的文件中。Argon2 非常慢,导致登录时有几秒钟的延迟——但这正是我们想要的:用于哈希密码的算法需要很慢,这样暴力破解攻击才能花费很长时间才能找到用于创建哈希的密码。这里的错误是使用不是为密码存储设计的快速哈希算法,例如 MD5 或 SHA-256。
所有文件都存储在 %appdata%/YAPM 中。
身份验证和更多详细信息
YAPM 使用 Libsodium(C# 包装器)作为加密库。
要访问 YAPM 存储的密码,需要用户的主密码(至少 15 个字符)。此主密码根据验证哈希进行验证,验证哈希是用户主密码的 Argon2i 哈希,参数如下:time=6,memory=131072KiB,parallelism=1(这些参数来自 Libsodium 预定义值 PasswordHash.StrengthArgon.Moderate
)。我选择 Argon2 是因为它赢得了最近的密码哈希竞赛,并且目前被广泛推荐。
如果主密码已成功针对验证哈希进行验证,则主密码用于解密加密密钥。此 32 字节加密密钥由 Libsodium 中的密码学随机函数 SodiumCore.GetRandomBytes()
生成。然后,此密钥使用 AES-GCM-256 位加密进行加密,密钥是主密码的 32 字节哈希 (GenericHash.Hash()
)。
然后,此解密的 32 字节加密密钥用于解密密码,这些密码使用 AES-GCM-256 位加密。所有 AES 操作都使用唯一的 12 字节 nonce,对于主存储的加密,nonce 会递增以确保它永远不会被使用两次。
Using the Code
该程序是一个 WinForms 应用程序,可以在 Visual Studio 环境中执行,也可以直接从可执行文件执行。代码注释非常详细,但如果您有任何具体问题,请随时提问,我将提供更多详细信息。
使用了 9 个主要类
MainForm
- 包含主用户窗体并处理与窗体的交互。AddPasswordForm
- 包含添加密码的窗体,处理新密码的创建。EditPasswordEntryForm
- 包含编辑密码条目的窗体,处理现有密码条目的修改。SettingsForm
- 包含设置窗体,用于更改、查看和读/写设置到存储文件(%appdata%/YAPM/settings)。设置文件的内容遵循严格的 setting=value 格式。Crypto
- 借助 Libsodium 处理所有加密操作。此类的目的包括验证用户身份、加密/解密存储的密码、更改用户的主密码。Register
- 在存储密码之前创建所需的初始文件(加密密钥、nonce、哈希、默认设置)。PasswordManage
- 管理从文件加载的密码(显示密码、编辑密码、添加/删除密码)。Watchdog
- 创建一个看门狗计时器,如果用户闲置一段时间,该计时器会导致程序超时并锁定应用程序。当鼠标移动到窗体上时,看门狗计时器会重置。ByteOperation
- 递增/递减一个字节。这用于递增/递减用于加密的 12 字节 nonce。
主存储的加密和解密过程如下所示
// Function to get the encryption key for the main store.
private static byte[] GetKey(string masterPassword) {
// Get encrypted encryption key and nonce.
byte[] encryptedKey = Convert.FromBase64String(File.ReadAllText(YAPM_KEY));
byte[] keyNonce = Convert.FromBase64String(File.ReadAllText(YAPM_KEY_NONCE));
// Decrypt key with parameters stored in YAPM_PATH.
byte[] key = SecretAeadAes.Decrypt(encryptedKey, keyNonce,
GenericHash.Hash(masterPassword, (byte[])null, 32));
return key;
}
// Function to get the contents of the main store file.
private static string[] GetStoreFileContents() {
// Read each line from file. String array contains base64 encoded strings.
string[] storeFileContents = File.ReadAllLines(YAPM_STORE);
return storeFileContents;
}
// Function to get the nonce for the main store.
private static byte[] GetNonce() {
byte[] nonce = Convert.FromBase64String(File.ReadAllText(YAPM_STORE_NONCE));
return nonce;
}
// Function to encrypt the main store.
public static void EncryptStoreFile(string masterPassword, string[] dataToEncrypt) {
// Get required variables.
byte[] nonce = GetNonce();
byte[] key = GetKey(masterPassword);
// Clear main store.
File.WriteAllText(YAPM_STORE, "");
// Encrypt each password entry.
for (int i = 0; i < dataToEncrypt.Length; i++) {
// Increment nonce so every password entry uses a different nonce.
ByteOperation.Increment(ref nonce);
byte[] byteDataToEnc = Encoding.ASCII.GetBytes(dataToEncrypt[i]);
var encrypted = SecretAeadAes.Encrypt(byteDataToEnc, nonce, key);
File.AppendAllText(YAPM_STORE, Convert.ToBase64String(encrypted) + Environment.NewLine);
}
// Write nonce to file.
File.WriteAllText(YAPM_STORE_NONCE, Convert.ToBase64String(nonce));
}
// Function to decrypt the main store.
public static List<string> DecryptStoreFile(string masterPassword) {
// Get required variables.
string[] storeFileContents = GetStoreFileContents();
byte[] nonce = GetNonce();
byte[] key = GetKey(masterPassword);
List<string> decryptedList = new List<string>();
// Decrypt each password entry. Work backwards with last nonce used, as nonce decrements.
for (int i = storeFileContents.Length - 1; i > -1; i--) {
byte[] dataToDecrypt = Convert.FromBase64String(storeFileContents[i]);
var decrypted = SecretAeadAes.Decrypt(dataToDecrypt, nonce, key);
decryptedList.Add(Encoding.ASCII.GetString(decrypted));
// Decrement nonce to get each nonce used to encrypt password entry.
ByteOperation.Decrement(ref nonce);
}
// Return list containing all decrypted password entries.
return decryptedList;
}
正如您在 EncryptStoreFile
和 DecryptStoreFile
中看到的那样,数组中要加密/解密的每个项目都使用相同的密钥,但使用不同的 nonce 进行加密。
虽然所有加密/解密都由 Libsodium 处理,但 nonce 由 ByteOperation.cs 处理。此类有两个简单的方法
ByteOperation.Increment()
public static void Increment(ref byte[] byteArr) {
for (int i = 0; i < byteArr.Length; i++) {
byteArr[i]++;
if (byteArr[i] > 0) {
return;
}
}
return;
}
ByteOperation.Decrement()
public static void Decrement(ref byte[] byteArr) {
for (int i = 0; i < byteArr.Length; i++) {
byteArr[i]--;
if (byteArr[i] < 255) {
return;
}
}
return;
}
屏幕截图
我选择了非常基本的设计,因为我不希望用户界面看起来过于杂乱。
登录后的主窗体(可完全调整大小)。右键单击行可显示更多选项
设置菜单
添加新密码
编辑现有密码条目
关注点
在撰写本文时,我正在查看“使用代码”部分中显示的代码,并注意到有些地方不太对劲,具体来说是以下内容
// Increment nonce.
ByteOperation.Increment(ref nonce);
// Clear main store.
File.WriteAllText(YAPM_STORE, "");
// Encrypt each password entry.
for (int i = 0; i < dataToEncrypt.Length; i++) {
byte[] byteDataToEnc = Encoding.ASCII.GetBytes(dataToEncrypt[i]);
var encrypted = SecretAeadAes.Encrypt(byteDataToEnc, nonce, key);
File.AppendAllText(YAPM_STORE, Convert.ToBase64String(encrypted) + Environment.NewLine);
}
// Write nonce to file.
File.WriteAllText(YAPM_STORE_NONCE, Convert.ToBase64String(nonce));
请注意 nonce 是如何递增的,然后相同的 nonce 用于加密数组中的每个密码条目。这很糟糕,因为您现在有多个使用相同密钥流加密的密文,这是不安全的。我通过在加密时在 for
循环的每次迭代中递增 nonce,并在解密时在 for
循环的每次迭代中递减 nonce 来修复此问题
// Function to encrypt the main store.
public static void EncryptStoreFile(string masterPassword, string[] dataToEncrypt) {
// Get required variables.
byte[] nonce = GetNonce();
byte[] key = GetKey(masterPassword);
// Clear main store.
File.WriteAllText(YAPM_STORE, "");
// Encrypt each password entry.
for (int i = 0; i < dataToEncrypt.Length; i++) {
// Increment nonce so every password entry uses a different nonce.
ByteOperation.Increment(ref nonce);
byte[] byteDataToEnc = Encoding.ASCII.GetBytes(dataToEncrypt[i]);
var encrypted = SecretAeadAes.Encrypt(byteDataToEnc, nonce, key);
File.AppendAllText(YAPM_STORE, Convert.ToBase64String(encrypted) + Environment.NewLine);
}
// Write nonce to file.
File.WriteAllText(YAPM_STORE_NONCE, Convert.ToBase64String(nonce));
}
// Function to decrypt the main store.
public static List<string> DecryptStoreFile(string masterPassword) {
// Get required variables.
string[] storeFileContents = GetStoreFileContents();
byte[] nonce = GetNonce();
byte[] key = GetKey(masterPassword);
List<string> decryptedList = new List<string>();
// Decrypt each password entry. Work backwards with last nonce used, as nonce decrements.
for (int i = storeFileContents.Length - 1; i > -1; i--) {
byte[] dataToDecrypt = Convert.FromBase64String(storeFileContents[i]);
var decrypted = SecretAeadAes.Decrypt(dataToDecrypt, nonce, key);
decryptedList.Add(Encoding.ASCII.GetString(decrypted));
// Decrement nonce to get each nonce used to encrypt password entry.
ByteOperation.Decrement(ref nonce);
}
// Return list containing all decrypted password entries.
return decryptedList;
}
特点
YAPM 有一个超时功能,这非常重要——如果没有它,如果有人离开他们的电脑,他们几乎就把所有账户的所有权交给了任何有恶意意图的路人。超时功能的工作原理是启动一个看门狗计时器(包含在 Watchdog.cs 中),当鼠标移动到窗体上时,该计时器会重置(“踢看门狗”)。当看门狗计时器达到超时时间时,应用程序通过注销来锁定。超时时间可以在设置中更改,默认为 2 分钟(120000 毫秒)。如果不需要,也可以在设置中禁用超时。
在更改密码时确认当前主密码也很重要——如果没有它,有人可以将账户所有权转移给自己,这也会将用户锁定在他们的账户之外。
使用主密码登录后,密码默认会被混淆(这可以在设置中更改),可以通过右键单击密码所在的行 -> 密码可见性 -> 显示来显示。右键单击还用于编辑/删除密码、从密码条目复制内容和显示任何备注。
历史
- 2018/03/08 - 初次发布