Privacy Protection系列
0.N/A
Q:咦,你电脑里有个文件,打开一看全是一堆毫无意义的字符,这是啥东西?
A:哦,那是我的密码数据库的“密钥文件”,不是普通文本,所以看上去像乱码。Q:密钥文件?能给我简单演示一下吗?
A:好,你先看这个命令,我把数据库打开给你看。(A 在终端输入命令,数据库界面出现,Q 浏览了一会儿)
Q:我好像没看到什么特别的内容,就一些登录记录和加密字段。
A:对,这些都是数据库自动解密后展示给你的信息。Q:那要是没有那个密钥文件,就没法打开数据库了?
A:没错,没有它就算有数据库,也无法解密查看。Q:明白了,那我就不乱动它了。
A:嗯,有需要再找我。
1.写在前面
在网络日益发展的今天,数据的记录方式也从纸质转移到了数字。
在这些数据里,也不乏缺个人的隐私数据以及重要的机密数据。
每个人都有隐私,比如日记、账号密码、自己写的但是不想让其他人看到的文本文档等。
又或者,你需要给别人传输一个token或者是哪个服务器的密码。但是这个人就是不愿意使用我们自搭建的SimpleX聊天服务,为了便利性而执意使用QQ或者微信等聊天工具。
加密是安全的基石、是隐私最出色的卫士。
对于隐私,如果是视频或图片,你可能知道veracrypt之类的优秀加密软件。
但是,对于普通的文本形式,使用veracrypt似乎有些繁重了。并且有时,我们不得不解释电脑上出现的奇怪大文件。在这种情况下,难以做出合理的回答。
所以今天,我将介绍一款我让Cladue、Gemini、GPT-O4、Grok这些大模型共同完善的一个文本加密程序:TextCrypt
2.功能介绍
程序使用了C#语言,以控制台程序的方式启动。
首先看看加密文本。
对于原文,一共有两种输入方法。一个是程序内置的简单编辑器,一个是使用系统默认编辑器。
选择系统默认编辑器后,程序会创建一个临时文件,然后让系统编辑器打开这个临时文件修改。用户输入内容保存后,程序读取文件,加密后删除。
现在假设这是我们的秘密文本,不想让其他人知道,我们把文本复制到程序打开的编辑器,然后保存。
加密模式选择,这个值得说一下。
V0,核心加密模式,使用AES-256-ECB模式对文本加密。在同一明文、同一密码的情况下,密文也同一,仅适用于低安全性要求。
V0.5,在核心模式的基础上,加入salt值。但是salt值不参与加密,仅用于增加密文随机性,依旧使用AES-256-ECB模式。
V1,使用argon2和CBC+GCM双层加密模式,单独生成随机nonce增加密文随机性。
V2,argon2加AES-256-GCM进行加密,密文随机性靠每次加密随机生成的salt和IV。
V3,使用椭圆曲线NIST P-521的密钥对加密,公钥加密,私钥解密。通过 ECDH 交换生成共享秘密,再用 HKDF-SHA512 从该秘密派生出 AES 密钥,最后用该密钥和随机 IV 对明文以 AES-GCM 加密。
默认情况下,程序使用V2模式。
选择模式后,程序要求输入密码(V3选择公钥),程序对用户密码不作任何安全检测。换句话说,密码可以只是一个数字。
和Linux一样,输入的密码不会显示任何一个字符,哪怕是*号。
一共有三种方法保存加密后的密文,如果明文内容简单,那么可以控制台直接输出。反之,推荐保存到一个文件或者保存到数据库(后面会讲到)。
让我们看看加密后的密文。
在V2模式下的示例文本,加密前5KB,加密后9KB。
等一下,为什么这个密文看上去,像是普通的随机字符串?没有任何特征。
是的,这算是这个程序的一个特色了,让我们看看其他常见的文本加密。
OpenSSL:
U2FsdGVkX1+LHKa8eFXu26ocjb+48gABoZP3x9XQEa7pacTqVuUItBB1lJJYdJgZ
--------------
PqSx7k8gBlA1te+mwdWXtUTujRALzj/IMKhfLQy+wTQ=
OpenPGP:
-----BEGIN PGP MESSAGE-----
jA0ECQMIuWrUOA8Ik2v+0kABlfRrpdRqjnGaQ+q9Nc4kM0oRHyULLNwveofuEaSF
PmKPE5KpB399x5K14GvcpR6rbtsw3pVt0CT/6gFkOmjm
=zO/Q
-----END PGP MESSAGE-----
可以看到,OpenSSL加密后会有明显的U2FsdGVkX1(使用salt),即使不使用salt也会有明显的base64特征。虽然可以另外导出为bin和hex形式,但是也及其不方便。
OpenPGP的密文有明显的标头。我个人认为,如果我们不想让其他人知道这是一个加密文本,再对这两种密文进行处理就有些繁琐了。
TextCrypt的处理方法是,把包含密文的json结构再进行一次自定义编码处理,而非标准的base编码。自定义编码派生于用户密码的SHA-512,也就是说,每次用户输入的不同密码都会派生不同的自定义编码,只有正确的密码才能还原进行解密过程。
这个编码的作用只是让密文看上去不像是密文而非提升安全性,安全性的保障完全基于你的密码强度。
接下来讲一下解密过程。
对称加密的密文其实是一个json格式,只是最后经过自定义编码处理。所以解密可以自动识别模式。
对于V3模式,首先我们需要生成一个密钥对。程序主菜单里已经有了V3的密钥对管理。
生成密钥对时只需要输入一个前缀,程序会自动生成私钥和公钥。即使公钥丢失,也允许通过私钥重新生成一个公钥。公钥和私钥只要形成数学关系即可,所以生成的公钥和之前的公钥hash校验不通过是正常的。
密码都不用输入了,是不是很方便:)
解密时也会自动读取同一目录下的pem文件,和OpenSSL是否兼容还没试过,有兴趣的朋友可以自己研究一下。
有时,我们希望重新给当前密文换个模式和密码,甚至修改明文。反复解密加密有些麻烦了,怎么办呢?
我们选择主菜单的挂载模式。
此时,程序会解密文本并输出到临时文件。如果要更改,直接在临时文件更改即可。修改之后,选y就可以重新加密。
我们来说说另一种情况,你和别人正在使用不安全的软件即时通讯,你们双方打算使用这个程序传输内容。反反复复加密解密确实够心烦,这时,我们可以选择批量处理模式。
这个模式下,首先你们双方需要协商是对称加密还是非对称加密。对称加密的话确保同一个密码即可,模式无所谓。因为解密时程序会自动判断模式解密。
这里的解密速度受到argon2参数影响,如果你设置的内存比较大(256MB),那么程序就会卡一会才能解密。
如果是V3,需要提供你的私钥和对方的公钥,假设你们已经完成密钥交换。理论上,没有什么方法是比面对面交换公钥更安全的了。
加密数据库,好吧,这个功能其实有点多余,它只是把密文存储在SQLite数据库而已。特别是那个完整性校验,其实密文的校验已经很强了,只要有任何改动都无法解密。这个东西一般用不上。
3.似是而非的否认
虽然加密后的密文没有明显特征,但是对于凭空出现在电脑里的随机字符串文件本身还是值得怀疑的。你可能会问,为什么没有像veracrypt那种隐藏卷,把真的文本藏在密文里。一个密码是假文本,另外一个密码是真文本。
额,先不说实现难度,一大串密文结果只解密出几句无关紧要的话,说这就是全部的明文了你会信吗?不如利用程序的特性,搭配其他的安全软件使用。
2025年6月19日,星期四,晴
今天是一个普通的学校日,但我觉得它特别充实和有趣。早晨,我像往常一样早早起床,吃完早餐后背上书包,和爸爸妈妈一起走到了学校。天很蓝,阳光洒在大地上,空气清新,感觉一天会很美好。
第一节课是语文课,今天我们学习了古诗《静夜思》。老师带着我们一字一句地读,我一边听,一边想着夜晚的景象。那种寂静、清冷的感觉,让我好像真的站在了窗前,望着那轮明亮的月亮。老师还让我们背诵这首诗,我背得很流利,老师夸我记得很快,我心里非常高兴。
接下来是数学课,今天我们学习了除法的应用题。虽然我觉得应用题有点难,但我用心去做了,慢慢地,我发现自己其实能做对了。老师讲解得很细致,我也认真听,终于把那道最难的题做出来了,心里充满了成就感。
到了午休时间,大家都去操场上玩。我们班的男生在打篮球,女生则在跳绳。我和几个同学一起玩了“丢手绢”的游戏,大家都笑得很开心。有一会儿,我不小心掉了手绢,结果被同学抓住了,我和他们一起笑了,大家没有任何生气,反而玩得更加愉快。
下午的第一节课是英语,今天我们学习了新的单词和句型,老师还让我们分组进行角色扮演。我和小李一组,扮演了在商店里买东西的情景。虽然我有点紧张,但是我努力用英语表达,最后得到老师的表扬,感觉自己越来越有信心了。
最后一节课是音乐课,我们学了一首新歌,大家一起合唱。我发现自己唱得越来越好,尤其是和同学们一起唱的时候,心里觉得特别温暖。音乐让我们班的气氛更加愉快,也让大家更加团结。
放学后,我和几个同学一起走出学校,聊着今天学到的新知识和玩得开心的事。虽然今天的课很紧张,但是我觉得非常充实,每一节课我都学到了很多东西。今天的一天,虽然是普通的一天,但在我心里,它特别美好。
回到家后,我做了作业,吃了晚餐,和爸爸妈妈聊了学校的事情。今天真是快乐的一天,我很期待明天能继续在学校里度过一个有意义的日子。
假如你是一个六年级的小学生,你写了这篇日记。你家里面有一台电脑,平时是你家长在用。你把日记存储在电脑里,但是你不想让家长看到它。
于是,你决定用这个软件给日记加密一下(话说小学六年级会用这个软件吗),加密之后,如果家长不怎么懂电脑还好。但是,万一你家长正好懂点技术呢?一个随机字符串文件出现在一个文件夹里显然不合理。
这时,你可以用veracrypt创建一个加密卷,放点无关紧要的东西。但是不设置密码,或者设置密码后添加密文文件作为密钥文件。
你的家长让你解释这个看上去随机字符串的是什么文件,就可以说这是用来解锁VC卷的密钥文件。然后当着家长的面用密文文件解锁VC卷。
此时,绝大部分的家长都会被VC卷的内容吸引,可能他们还会觉得你懂技术了。谁会想到“密钥文件”本身?毕竟密钥文件的特征就是随机字符串。
正如上面以及开头的N/A情景所说,一个好的办法是创建一个veracrypt加密卷或者KeePassXC密码数据库,把密文作为密钥文件。
好吧,我承认这个情景很烂。不过肯定适用于日常情况了:)
4.debug模式
debug模式目前只能在Visual Studio启用。
在解决方案配置设置为Debug,生成后程序会进入debug模式。
调试模式启用后,每次加密和解密都会输出敏感加密参数。包括但不限于KEK、salt、GCM IV、GCM Tag等。
5.程序工作原理解释
程序首先根据选择的模式构建json,再把json经过自定义编码处理。
加密部分:
V3非对称加密:
{
"V": "3",
"EK": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7Z1nXFp1a2F3u5ZHv+J\nb9G3H2J8q6yXQ9u+JxKzL5YBqfWvGqH3K4j1P0vR7aG8dJ3PfT2sYw==",
"I": "4f8X2dV9kZ0tR3pQ",
"C": "u7Jk3PpV4m1Qx9bR",
"T": "h2Kj9Zq4LmN3V6dQ"
}
字段说明:
- V:版本号
"3"
。 - EK:Base64 编码的临时公钥(PEM DER 转 Base64 后,可另加 PEM 头尾),用于 ECIES 密钥协商。
- I:12 字节随机 GCM IV(Base64)。
- C:加密后的密文(Base64)。
- T:16 字节 GCM 认证标签(Base64)。
在解包时,先反序列化 JSON 拿到这些字段,
然后用接收者的私钥和 EK
做 ECDH ⇒ 共享密钥 ⇒ HKDF 派生出 AES-GCM 密钥,
最后用 I
、T
、密钥 解密 C
即可还原明文。
GPT说V3是有前向保密的,不过前向保密不适用这个程序。而且密文长期有效,拿到私钥的人随时都可以解密公钥加密的密文,无论这个密文何时生成。所以,记得保护好你的私钥:)
V1的json
{
"V": "1",
"S": "u1f3K2lQx7a5H2LmYRtW8A==", // 16 字节盐
"K": "qk3GHB1vR+5YhZ7Xf2r7Nw==:8v5K2LmYTJw6B9QeLx0z1g==",
// 加密后的 DEK + CBC IV
"I": "JXk9sN3yVQ1fT2pR", // 12 字节 GCM IV
"C": "5tJw2Y3qV0dZ1pRfX9aGQ==", // 密文
"T": "h3Jf2Kd9nL0sX6bQ4pV1w==", // GCM Tag
"AM": 65536, // Argon2 内存 (KB)
"AI": 3, // Argon2 迭代
"AP": 4 // Argon2 并行度
}
因为V1的随机nonce在json之外,所以V1模式的密文结构应为:
encryptedText = encode( nonce || JSON )
V2的json:
{
"V": "2",
"S": "t5Rk8YnPd3XvQ1aLmJf2Eg==", // 16 字节盐
"I": "G7k1Nc3pQf8Zt5Ry", // 12 字节 GCM IV
"C": "8rNw4Pz0aS1kX9mV", // 密文
"T": "Mf3Nq8ZrY1sL2dPw", // GCM Tag
"AM": 65536, // Argon2 内存 (KB)
"AI": 3, // Argon2 迭代
"AP": 4 // Argon2 并行度
}
V0.5:
{
"V": "0.5",
"S": "X9p3Tq6ZmJ1d2GfR", // 16 字节盐
"I": "h4Jk2Lq9Pz8Vn5Xr", // 随机填充(ECB 不用)
"C": "k3Jm8Yp1Vt4Bq7Nd" // 密文
}
V0:
{
"V": "0",
"C": "n9Lm2Kj5Xq8Vp3Rz" // 密文
}
其实可以把版本号看成安全等级,目前推荐V1、V2以及V3。V3估计要等到Shor算法成熟后才不再安全,不过那日子应该远着呢。
自定义编码部分:
private static (string shuffledCharset, Dictionary<char, int> charToValueMap, int customBase) GeneratePasswordDerivedCharset(string password)
{
// 1. 计算 SHA-512 哈希(64 字节)
byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
byte[] hashBytes;
using (var sha512 = SHA512.Create())
{
hashBytes = sha512.ComputeHash(passwordBytes); // 64 字节的哈希结果
}
// 2. 准备原始字符集(假设 BaseAlphanumericCharset 已定义,如 "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
char[] charsetArray = BaseAlphanumericCharset.ToCharArray();
int n = charsetArray.Length;
// 3. 使用 Fisher–Yates 洗牌算法,结合 hashBytes 作为“随机源”,保证对同一密码始终生成相同排列
// 采用循环读取 hashBytes,当字节用完后继续从头循环。
int hashIndex = 0;
for (int i = n - 1; i > 0; i--)
{
// 取当前 hashBytes 中的一字节,转换为 0 到 i 之间的索引
byte b = hashBytes[hashIndex];
int j = b % (i + 1);
// 交换位置 i 与 j 的字符
char tmp = charsetArray[i];
charsetArray[i] = charsetArray[j];
charsetArray[j] = tmp;
// 移动到下一个哈希字节,若到末尾则回到开头
hashIndex = (hashIndex + 1) % hashBytes.Length;
}
// 4. 生成打乱后的字符集字符串,以及字符到数值的映射
string shuffledCharset = new string(charsetArray);
var charToValueMap = new Dictionary<char, int>(n);
for (int i = 0; i < n; i++)
{
charToValueMap[shuffledCharset[i]] = i;
}
// customBase 即字符集长度
return (shuffledCharset, charToValueMap, n);
}
// Encode bytes to custom base string
public static string BytesToPasswordDerivedBaseString(byte[] data, string shuffledCharset, int customBase)
{
if (data == null) return null;
if (data.Length == 0) return string.Empty;
int leadingZeros = 0;
for (int i = 0; i < data.Length && data[i] == 0; i++)
{
leadingZeros++;
}
byte[] dataToConvert = new byte[data.Length - leadingZeros + 1];
dataToConvert[0] = 0;
Buffer.BlockCopy(data, leadingZeros, dataToConvert, 1, data.Length - leadingZeros);
Array.Reverse(dataToConvert);
BigInteger number = new BigInteger(dataToConvert);
if (number == BigInteger.Zero && data.Length > 0)
{
return new string(shuffledCharset[0], data.Length);
}
var sb = new StringBuilder();
while (number > 0)
{
number = BigInteger.DivRem(number, customBase, out BigInteger remainder);
sb.Insert(0, shuffledCharset[(int)remainder]);
}
if (leadingZeros > 0)
{
sb.Insert(0, new string(shuffledCharset[0], leadingZeros));
}
return sb.ToString();
}
// Decode custom base string to bytes
public static byte[] PasswordDerivedBaseStringToBytes(string customBaseString, string shuffledCharset, Dictionary<char, int> charToValueMap, int customBase)
{
if (customBaseString == null) return null;
if (string.IsNullOrEmpty(customBaseString)) return Array.Empty<byte>();
int leadingZeroChars = 0;
char firstCharOfCharset = shuffledCharset[0];
for (int i = 0; i < customBaseString.Length && customBaseString[i] == firstCharOfCharset; i++)
{
leadingZeroChars++;
}
BigInteger number = BigInteger.Zero;
for (int i = leadingZeroChars; i < customBaseString.Length; i++)
{
char c = customBaseString[i];
if (!charToValueMap.TryGetValue(c, out int digit))
{
throw new FormatException($"Invalid character '{c}' in password-derived base string.");
}
number = number * customBase + digit;
}
if (number == BigInteger.Zero)
{
return new byte[leadingZeroChars > 0 ? leadingZeroChars : (customBaseString.Length > 0 ? 1 : 0)];
}
byte[] tempBytes = number.ToByteArray();
Array.Reverse(tempBytes);
int startIndex = tempBytes.Length > 1 && tempBytes[0] == 0x00 ? 1 : 0;
byte[] numericBytes = new byte[tempBytes.Length - startIndex];
if (numericBytes.Length > 0)
{
Buffer.BlockCopy(tempBytes, startIndex, numericBytes, 0, numericBytes.Length);
}
byte[] finalResult = new byte[leadingZeroChars + numericBytes.Length];
if (numericBytes.Length > 0)
{
Buffer.BlockCopy(numericBytes, 0, finalResult, leadingZeroChars, numericBytes.Length);
}
return finalResult;
}
流程:
+---------------------------+
| 1. GeneratePasswordDerivedCharset |
| (生成基于密码的打乱字符集) |
+---------------------------+
输入:密码字符串 (password)
|
v
[步骤1:计算哈希]
- 将密码转换为 UTF-8 字节
- 使用 SHA-512 计算 64 字节哈希
|
v
[步骤2:准备字符集]
- 获取原始字母数字字符集(如 "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
- 转换为字符数组,长度为 n
|
v
[步骤3:Fisher-Yates 洗牌]
- 使用哈希字节作为“随机源”
- 循环读取哈希字节,生成 0 到 i 的索引
- 交换字符数组中位置 i 和 j 的字符
- 哈希字节用尽时从头循环
|
v
[步骤4:生成输出]
- 将打乱后的字符数组转为字符串 (shuffledCharset)
- 创建字符到数值映射 (charToValueMap),字符对应其索引
- customBase = 字符集长度 n
|
v
输出:(shuffledCharset, charToValueMap, customBase)
+---------------------------+
| 2. BytesToPasswordDerivedBaseString |
| (字节转换为基于密码的自定义进制字符串) |
+---------------------------+
输入:字节数组 (data)、打乱字符集 (shuffledCharset)、自定义进制 (customBase)
|
v
[步骤1:处理空输入]
- 若 data 为 null,返回 null
- 若 data 为空,返回空字符串
|
v
[步骤2:处理前导零]
- 计算 data 中的前导零字节数 (leadingZeros)
- 创建新数组,跳过前导零并添加一个前置零字节
|
v
[步骤3:转换为大整数]
- 反转数组并构造 BigInteger (number)
- 若 number 为零且 data 非空,返回 shuffledCharset[0] 重复 data 长度次
|
v
[步骤4:转换为自定义进制]
- 使用 BigInteger 除法和取余,逐位生成字符
- 每次取余对应 shuffledCharset 中的字符,插入字符串开头
|
v
[步骤5:添加前导零]
- 若有 leadingZeros,插入 shuffledCharset[0] 重复 leadingZeros 次
|
v
输出:自定义进制字符串
+---------------------------+
| 3. PasswordDerivedBaseStringToBytes |
| (自定义进制字符串转换回字节) |
+---------------------------+
输入:自定义进制字符串 (customBaseString)、打乱字符集 (shuffledCharset)、
字符到数值映射 (charToValueMap)、自定义进制 (customBase)
|
v
[步骤1:处理空输入]
- 若 customBaseString 为 null,返回 null
- 若为空,返回空字节数组
|
v
[步骤2:处理前导零字符]
- 计算字符串中前导 shuffledCharset[0] 的数量 (leadingZeroChars)
|
v
[步骤3:转换为大整数]
- 从非零字符开始,遍历字符串
- 使用 charToValueMap 获取每个字符的数值
- 累积计算:number = number * customBase + digit
- 若 number 为零,返回 leadingZeroChars 长度的零字节数组
|
v
[步骤4:转换为字节]
- 将 number 转为字节数组并反转
- 移除可能的额外零字节
- 创建结果数组,长度为 leadingZeroChars + 数值字节长度
- 复制数值字节到结果数组,前面填充零字节
|
v
输出:字节数组
6.最后
为什么我会突发奇想地让AI写这个软件?
呃,其实原本的用途是很简单的,密码加salt得到不同的密文,方便我在U盘安全存储密码什么的。
后面想到的功能越来越多,就成了现在这样。
还有就是为了隐私,这里特别点名我在现实的几个朋友,不要再用微信问我怎么翻墙了!我不知道!
你问白洲梓为什么不用微信?可能她比较有隐私意识吧:)
最后我要说的是,隐私是要靠自己保卫的。不要听其他人说隐私早就被曝光了什么的,这不是侵犯他人隐私的理由。
这篇文章就到这里,希望大家可以守护好心中的秘密。
🙂
项目Github仓库: