在之前做我的笔记软件「言叶」的时候增加了笔记端到端加密的功能。我使用了自定义文件格式 + RSA + AES 算法。当我写了一篇文章提及这点之后,有读者不干了,说我业余。

本来,我也想写一篇文章介绍如何设计加密文件的,就趁这个机会写出来罢了。
1、文件格式设计
我之前学习过 java 的 class/smali 文件格式,所以,文件格式的设计上也参考了它们的理念。
在我设计文件格式的时候首先要考虑的是功能,其次是性能,当然实践中是要两者兼顾的。因此,首先,我给加密文件定义了特殊的拓展名,也就是 lnmd
和 lntxt
,也就分别对应着 md
和 txt
两种文件格式。当然,md 也 txt 一样也是文本,但是毕竟我们需要知道该以哪种形式渲染文本。这是判断的第一步。
其次,我在自定义文件格式的头部使用了魔数。比如,java 的 class 文件的魔数是 CAFEBABE
.

这不过是一种除了拓展名之外判断文件格式是否合法的方式。也就是只需要读取文件字节码的前几个字节就能判断文件是否合法。
不过,我在文件设计的时候没有使用他们这种巧妙的字节。我使用的是字符串 LEAF 的字节码。但是最终的效果殊途同归。
其次,参考 class 的文件设计,除去魔数之外的部分是一个个数据区块。每个区块包含三部分,分别是区块的 code、区块的长度以及区块的字节。用 kotlin 描述的结果如下,
kotlin
/** 加密文件的分组信息 */
private class FileSection(
val code: Byte,
val length: Int,
val data: ByteArray
)
一般来说,length 使用 short 类型基本可以满足需求,不过我为了简单将其设计成了 int 类型,多使用两个字节,也无妨。
而具体的,我的加密文件分成三个区块,对应三个 code,
kotlin
private const val CODE_CHECK_WORLD: Byte = 0x01
private const val CODE_AES_PASSWORD: Byte = 0x02
private const val CODE_CONTENT: Byte = 0x03
分别是 0x01、0x02 和 0x03,其作用分别是:
- 0x01:用于校验用户密码是否正确
- 0x02:使用 RSA 公钥将用户密码加密之后写入到文件里(可选项)
- 0x03:使用 AES 加密之后的笔记的内容
下面是三个区块的设计思想和实现方式。
2、密码校验位
考虑到使用 AES 算法直接对笔记进行解密,但笔记文件较长的时候会占用较多的时间,因此,我设计了密码校验位用来判断用户的密码是否与该加密文件的加密密钥相同。
这里的实现思路是,当写入加密文件的时候将字符串 LEAFNOTE IS AWESOME
使用用户密钥 AES 加密之后写入到文件里。当读取文件的时候再使用用户设置的密钥和 AES 算法对该字符串加密,然后对比加密之后的字节和加密文件的字节,以此判断用户设置的密钥是否正确。
3、用户密码位
定义这个区块是为了防止用户忘记密码,这是一个可选的选项。
这里的实现思路是,当写入加密文件的时候将用户设置的密钥通过 RSA 算法的公钥进行加密,并将加密的结果写入到文件。当用户忘记密钥的时候,可以读取加密文件的这部分区块,然后将这部分区块经过 Base64 编码之后上传到我们的服务器。然后,在我们的服务器上面,经过 Base64 解码成字节数组之后再使用私钥解密出用户的加密密钥。
这样,不必上传整个文件进行解密,保障了用户的数据隐私,也减轻了服务器带宽的压力。同时,这个是可选的,如果用户能够确保自己不忘记密码,则完全不必将该区块写入到文件里。
4、笔记加密位
这个区块就是用户真实的笔记内容。当然,我们在写入的时候为了方便用户的文本完全基于 UTF-8 格式进行编码,读取的时候也默认使用 UTF-8 编码读取。因为,加密格式只会在我们的应用里产生。
这部分加密就是使用用户自己设置的密钥和 AES 算法进行加密。
5、用户密钥生成
为了减少用户使用过程中的困惑,对 AES 加密部分的密钥我的设计方式是,因为 AES 加密需要一个 32 位的 key 和一个 16 位的向量,总计 48 位。但是考虑到用户一般不愿意输入 48 位长度的字符串,因此,我的设计是由用户输入长度大于 8 的任意字母和数字组合的字符串,然后将其拓展或者删减成 48 位。然后,前 32 位作为 key,后 16 位作为向量。
kotlin
private fun getEncryptAESKeyAndIV(code: String): Pair<String, String> {
var passcode = code
while (passcode.length < 48) {
passcode += code
}
val key = passcode.substring(0, 32)
val iv = passcode.substring(32, 48)
return Pair(key, iv)
}
解密的过程算法也很有意思,
java
public String decryptFileEncryptionKey(String base64) {
try {
String password = decrypt(base64, RSA_PRIVATE_KEY);
// 密码的长度必须是 48
if (password.length() != 48) {
return null;
}
// 检测重复,至少长度为 8,所以,从 8 开始遍历
for (int i=8; i<48; i++) {
String possible = password.substring(0, i);
int length = possible.length();
boolean check = true;
// 这里的算法是从 i 开始(包含)往后没间隔 possible 的长度截取一次字符串
// 如果截取的字符串和 possible 相等,或者截取的字符串是 possible 的前缀,就算匹配
// 遍历完成没有,整个字符串完全符合规则则判定为成功,否则存在一个失败就是失败
for (int j=i; j<48; j+=length) {
String subText = safeSubString(password, j, j+length);
if (!subText.equals(possible) && !possible.startsWith(subText)) {
check = false;
break;
}
}
if (check) {
return possible;
}
}
} catch (Exception ex) {
log.error("failed to decrypt encryption key:", ex);
}
return null;
}
其他
上述是我的加密文件设计的思路,有功能和性能的考量,也有从用户使用角度的考虑,这种加密方案我写了 Python、Kotlin 和 Swift 三个版本的实现。
Swift 版本写入,
swift
/// 加密并写入文本到指定的文件
private static func encryptAndWrite(text: String, url: URL, encoding: String.Encoding, code: String) throws {
let (key, iv) = SecurityManager.getFileEncryptAESKeyAndIV(code: code)
let checkWordBytes = Encrypter.encryptAES(CHECK_WORLD.data(using: .utf8)!.bytes, key: key, iv: iv)
let enContent = Encrypter.encryptAES(text.data(using: encoding)!.bytes, key: key, iv: iv)
var data = MAGIC
+ Data([CODE_CHECK_WORLD])
+ Data(Encrypter.int2Bytes(int: checkWordBytes.count))
+ Data(checkWordBytes)
// 写入加密密钥到文件结构中
if KV.fileEncryptWriteKeyToFile {
let enAesCode = Encrypter.encryptRSA((key + iv), key: RSA_PUBLICK_KEY)
data = data
+ Data([CODE_AES_PASSWORD])
+ Data(Encrypter.int2Bytes(int: enAesCode.count))
+ Data(enAesCode)
}
data = data
+ Data([CODE_CONTENT])
+ Data(Encrypter.int2Bytes(int: enContent.count))
+ Data(enContent)
// 考虑到拓展性,除了上述三者以外的其他部分均加入到文件中
// let left = try fillLeftSections(url: url, included: [CODE_CHECK_WORLD, CODE_AES_PASSWORD, CODE_CONTENT])
// data = data + left
try data.write(to: url)
}
Kotlin 版本读取:
kotlin
fun decrypt(data: ByteArray, code: String): Resources<String> {
// 检查魔数是否匹配
val magic = data.copyOfRange(0, MAGIC.size)
if (!magic.contentEquals(MAGIC)) {
return Resources.failed(ERROR_CODE_ENCRYPT_FILE_FORMAT, "magic not match")
}
// 获取分组
val sections = getEncryptSections(data)
// 检查校验字段
val checkSection = sections[CODE_CHECK_WORLD]
?: return Resources.failed(ERROR_CODE_ENCRYPT_FILE_FORMAT, "file format error")
val pair = getEncryptAESKeyAndIV(code)
val key = pair.first.toByteArray()
val iv = pair.second.toByteArray()
val checkWordBytes = EncryptUtils.encryptAES(CHECK_WORLD, key, CIPHER_ALGORITHM, iv)
if (checkWordBytes == null || !checkSection.data.contentEquals(checkWordBytes)) {
return Resources.failed(ERROR_CODE_ENCRYPT_FILE_PASSWORD, "encrypt key not match")
}
// 内容字段
val contentSection = sections[CODE_CONTENT]
?: return Resources.failed(ERROR_CODE_ENCRYPT_FILE_FORMAT, "note body not found")
val decrypted = EncryptUtils.decryptAES(contentSection.data, key, CIPHER_ALGORITHM, iv)
?: return Resources.failed(ERROR_CODE_ENCRYPT_FILE_IO, "encrypt file io")
val content = String(decrypted, Charsets.UTF_8)
return Resources.success(content)
}
关于我
前大厂高级工程师,独立开发者,独立开发过多款应用且实现盈利,负责过日活千万、MAU 1.5 亿用户应用,技术接近全栈(前后端、Android、iOS、服务器以及 Python 等)。联系我:掘金博客 和 Github。