便携文本加密程序|TextCrypt

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 密钥,
最后用 IT、密钥 解密 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仓库:

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇