1. 项目概述:为什么要在HarmonyNext上做文件加密?
最近在做一个HarmonyOS应用项目,里面涉及到一些用户隐私数据的本地存储,比如离线缓存的分析报告、用户填写的表单草稿。直接存成明文文件,万一用户手机丢了或者被他人借用,风险不小。虽然系统有沙箱机制,但应用内的文件对其他应用不可见,不代表在文件管理器里看不到。所以,我决定在应用内实现一个轻量级的文件加密解密模块,确保这些敏感数据即使被直接访问,也是一堆“乱码”。
选择HarmonyNext和ArkTS来搞这件事,不是跟风。HarmonyNext是鸿蒙生态面向全场景的新一代系统底座,ArkTS作为其主推的开发语言,在声明式UI和系统能力调用上优势明显。更重要的是,它提供了完善的文件管理(@ohos.file.fs)和加解密安全库(@ohos.security.cryptoFramework),这两者结合,让我们能在应用层相对轻松地实现可靠的文件加密。这不像在传统安卓上,你可能得自己集成第三方库或者处理复杂的JNI调用。ArkTS的异步并发模型(async/await)也让文件IO和加解密这种耗时操作的处理变得清晰、不易阻塞UI。
这个系统不追求像TrueCrypt那样的全盘加密强度,目标是实用、高效、易集成。核心流程就是:用户选择或应用生成一个密钥,用这个密钥对指定的文件进行加密,生成一个密文文件;需要时,再用同一密钥解密还原。我们会用到对称加密算法AES,因为它速度快,适合文件这种大数据块。整个开发过程,我会带你走一遍从环境搭建、核心加解密逻辑实现、到性能优化和异常处理的全流程,分享我踩过的坑和总结的技巧。
2. 核心思路与架构设计
2.1 技术选型与依赖分析
首先明确我们的武器库。HarmonyNext的ArkTS开发,主要依赖以下几个关键模块:
- 文件操作 (
@ohos.file.fs): 负责文件的读取、写入、创建、删除等。这是数据进出的通道。 - 加解密框架 (
@ohos.security.cryptoFramework): 提供密码学原语。我们将主要使用其对称密钥生成、加密解密功能。 - 安全密钥库 (
@ohos.security.keyManager) (可选但推荐): 用于安全地生成和存储加密密钥。对于生产环境,绝对不能把密钥硬编码在代码里或存在普通文件中。 - Buffer处理 (
@ohos.buffer): 加解密操作处理的是二进制数据,Uint8Array是核心的数据载体。
为什么选AES(Advanced Encryption Standard)?因为它是目前全球公认的安全、高效的对称加密标准。在cryptoFramework中,我们使用AES算法,GCM(Galois/Counter Mode)模式。GCM模式的优势在于它同时提供了加密和认证功能,也就是说,它不仅能防止内容被窥探,还能检测密文在传输或存储过程中是否被篡改。这比传统的CBC模式更安全、更现代。
整个系统的架构很简单,分为三层:
- 表示层 (UI): 一个简单的ArkTS UI界面,提供“加密文件”、“解密文件”、“选择密钥”等按钮和文件路径展示。
- 业务逻辑层: 核心的
FileCryptoService类,封装所有加解密和文件IO逻辑。 - 数据层: 本地文件系统(存放明文/密文文件)和系统安全密钥库(存放加密密钥)。
2.2 密钥管理:安全的核心
密钥管理是加密系统的命门。这里有几个方案:
- 方案A(最不安全): 代码里写死一个字符串当密钥。绝对禁止!相当于把家门钥匙挂在门上。
- 方案B(有所改善): 使用一个固定的密码,通过PBKDF2(Password-Based Key Derivation Function 2)算法派生出一个密钥。这适用于由用户口令保护的情况。
- 方案C(推荐): 使用系统
keyManager生成一个随机的、高强度的AES密钥,并将其存入系统的密钥库中。应用每次使用时,通过一个别名(Alias)来获取密钥。这样密钥本身不会出现在应用的代码或普通存储区。
在本实战中,为了演示完整性,我会先展示基于固定密码派生的方案(方案B),因为它涉及更多cryptoFramework的API使用。然后,我会重点介绍如何集成更安全的keyManager方案(方案C)。
注意:即使是方案C,密钥的“别名”或访问密钥库的凭证也需要妥善管理。对于更高安全要求,可以考虑与设备的硬件安全模块(如TEE)结合。
3. 开发环境准备与基础工程搭建
3.1 环境与项目创建
确保你已安装DevEco Studio(建议4.0或以上版本),并配置好HarmonyOS SDK。创建一个新的Empty Ability项目,选择ArkTS语言和API 9(对应HarmonyNext)或更高版本。
创建完成后,首先在entry/src/main/resources/base/profile/下的main_pages.json中,配置我们的主页面。然后,在entry/src/main/ets/pages/目录下,创建我们的主页面Index.ets。
3.2 声明权限与导入模块
文件操作和加解密需要声明权限。打开entry/src/main/module.json5文件,在module字段下添加:
"requestPermissions": [ { "name": "ohos.permission.READ_MEDIA", "reason": "用于读取待加密的文件", "usedScene": { "abilities": ["EntryAbility"], "when": "always" } }, { "name": "ohos.permission.WRITE_MEDIA", "reason": "用于写入加密或解密后的文件", "usedScene": { "abilities": ["EntryAbility"], "when": "always" } } ]对于更精细的文件访问(如应用私有目录),可能不需要这些媒体库权限,但这里我们为了通用性,选择访问公共目录。在实际产品中,应遵循最小权限原则。
在即将编写的业务逻辑代码中,我们需要导入核心模块:
import fs from '@ohos.file.fs'; import cryptoFramework from '@ohos.security.cryptoFramework'; import buffer from '@ohos.buffer'; // 后续会用到keyManager // import keyManager from '@ohos.security.keyManager';4. 核心加解密服务类实现
我们将创建一个FileCryptoService类,它是整个系统的引擎。这个类会提供以下关键方法:generateKeyFromPassword,encryptFile,decryptFile。
4.1 基于密码的密钥生成
首先实现从用户密码生成密钥的函数。这里使用PBKDF2算法,它是一种通过密码和盐值(salt)生成密钥的标准方法,能有效抵御彩虹表攻击。
import cryptoFramework from '@ohos.security.cryptoFramework'; class FileCryptoService { private algName = 'AES256|GCM|PKCS7'; // 算法名:256位AES,GCM模式,PKCS7填充 private pbkdf2Iterations = 10000; // PBKDF2迭代次数,增加破解难度 /** * 从密码生成AES密钥 * @param password 用户输入的密码字符串 * @param salt 盐值,必须是二进制数据。如果为空,会生成随机盐(解密时需要此盐) * @returns 返回生成的对称密钥对象和使用的盐 */ async generateKeyFromPassword(password: string, salt?: Uint8Array): Promise<{key: cryptoFramework.SymKey; salt: Uint8Array}> { // 1. 创建PBKDF2参数 let pbkdf2Params: cryptoFramework.Pbkdf2Params = { algName: 'PBKDF2', salt: salt, // 如果salt为空,后续生成器会创建随机盐 iterations: this.pbkdf2Iterations, keySize: 256 // 生成256位的密钥材料,用于AES-256 }; // 2. 创建PBKDF2密钥生成器 let pbkdf2Generator = cryptoFramework.createPbkdf2(pbkdf2Params); // 3. 将密码字符串转换为Uint8Array let passwordData = new Uint8Array(buffer.from(password, 'utf-8').buffer); // 4. 生成密钥材料 let keyMaterial = await pbkdf2Generator.generateSecretKey(passwordData.buffer); // 5. 创建AES密钥生成参数 let aesParams: cryptoFramework.AesKeyGeneratorParams = { algName: 'AES256', keySize: 256 }; // 6. 创建AES密钥生成器并生成密钥 let aesGenerator = cryptoFramework.createSymKeyGenerator('AES256'); // 关键:将PBKDF2生成的密钥材料,转换为AES密钥 let symKey = await aesGenerator.convertKey(keyMaterial); // 7. 获取实际使用的盐(如果是新生成的,需要返回给调用者保存) let usedSalt = pbkdf2Params.salt as Uint8Array; return { key: symKey, salt: usedSalt }; } }实操心得:
- 盐值(Salt)至关重要:盐值必须是随机的,并且每个文件加密最好使用不同的盐。盐不需要保密,但必须和密文一起保存,解密时使用相同的盐才能派生出相同的密钥。通常我们将盐直接附加在密文文件的开头。
- 迭代次数:
pbkdf2Iterations值越高,派生密钥耗时越长,暴力破解难度也越大。10000是一个平衡安全与性能的常用起点,可根据设备性能调整。
4.2 文件加密流程详解
加密一个文件,不仅仅是调用一个加密函数。我们需要考虑文件可能很大,需要分块处理;需要生成并保存初始向量(IV,对于GCM模式);还需要将盐(如果用了PBKDF2)、IV等元数据与密文一起保存,以便解密。
/** * 加密文件 * @param srcFilePath 源文件(明文)路径 * @param dstFilePath 目标文件(密文)路径 * @param symKey 对称密钥 * @param salt 盐值(如果密钥由密码生成) * @returns 是否成功 */ async encryptFile(srcFilePath: string, dstFilePath: string, symKey: cryptoFramework.SymKey, salt?: Uint8Array): Promise<boolean> { try { // 1. 打开源文件(只读)和目标文件(创建、只写) let srcFile = fs.openSync(srcFilePath, fs.OpenMode.READ_ONLY); let dstFile = fs.openSync(dstFilePath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE); // 2. 创建GCM模式的加密参数,并生成随机IV(初始向量) let iv = cryptoFramework.createRandomIv('AES256|GCM'); // 生成12字节的随机IV(GCM推荐长度) let gcmParams: cryptoFramework.GcmParams = { algName: 'AES-GCM', iv: iv, aad: new Uint8Array(), // 附加认证数据,这里为空 authTagLength: 16 // GCM认证标签长度,16字节(128位) }; // 3. 创建加密器并初始化 let cipher = cryptoFramework.createCipher(this.algName); await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, symKey, gcmParams); // 4. 准备文件头:我们将盐的长度、盐本身、IV的长度、IV本身依次写入文件头部 // 格式:[4字节盐长度][盐][4字节IV长度][IV] let headerBuffer = new ArrayBuffer(0); if (salt && salt.length > 0) { let saltLenBuf = new Uint8Array(new Uint32Array([salt.length]).buffer); let ivLenBuf = new Uint8Array(new Uint32Array([iv.length]).buffer); // 拼接所有头部数据 headerBuffer = this.concatBuffers([saltLenBuf.buffer, salt.buffer, ivLenBuf.buffer, iv.buffer]); } else { // 如果没有盐(例如使用keyManager的密钥),只保存IV let ivLenBuf = new Uint8Array(new Uint32Array([iv.length]).buffer); headerBuffer = this.concatBuffers([ivLenBuf.buffer, iv.buffer]); } // 写入头部到目标文件 fs.writeSync(dstFile.fd, headerBuffer); // 5. 分块读取、加密、写入 const CHUNK_SIZE = 1024 * 64; // 64KB块大小 let buffer = new ArrayBuffer(CHUNK_SIZE); let bytesRead: number; while ((bytesRead = fs.readSync(srcFile.fd, buffer)) > 0) { // 将实际读取的数据切片 let dataToEncrypt = new Uint8Array(buffer.slice(0, bytesRead)); // 更新加密器(分块处理) let updateResult = await cipher.update(dataToEncrypt.buffer); // 将加密后的数据块写入目标文件 fs.writeSync(dstFile.fd, updateResult.data.buffer); } // 6. 结束加密,获取最后的认证标签(GCM模式特有) let finalResult = await cipher.doFinal(null); // 传入null表示没有更多数据 // finalResult.data 可能为空,但finalResult.authTag包含认证标签 // 非常重要:GCM的认证标签必须保存,解密时需要它来验证完整性 if (finalResult.authTag) { // 将认证标签追加到文件末尾 fs.writeSync(dstFile.fd, finalResult.authTag.buffer); } // 7. 关闭文件 fs.closeSync(srcFile); fs.closeSync(dstFile); console.info(`文件加密成功: ${dstFilePath}`); return true; } catch (error) { console.error(`加密文件失败: ${error.code}, ${error.message}`); // 这里应该尝试清理可能已部分写入的目标文件 try { fs.unlink(dstFilePath); } catch (e) {} return false; } } // 一个辅助函数,用于拼接多个ArrayBuffer private concatBuffers(buffers: ArrayBuffer[]): ArrayBuffer { let totalLength = buffers.reduce((acc, buf) => acc + buf.byteLength, 0); let result = new Uint8Array(totalLength); let offset = 0; for (let buf of buffers) { result.set(new Uint8Array(buf), offset); offset += buf.byteLength; } return result.buffer; }关键点解析:
- 分块处理:文件可能很大,一次性读入内存不现实。我们采用流式处理,分块读取、加密、写入。
- 头部信息:为了能正确解密,我们必须将解密所需的元数据(盐、IV)和密文一起保存。这里设计了一个简单的二进制格式。解密时,需要先按同样规则解析头部。
- GCM认证标签:
cipher.doFinal()返回的authTag是GCM模式用于验证数据完整性的关键。必须将其保存到文件末尾。解密时,需要用同样的标签进行验证,如果标签不匹配,说明文件被篡改,解密会失败。 - 错误处理与清理:加密过程中发生错误时,应尝试删除可能已损坏的输出文件,避免留下无效的密文。
4.3 文件解密流程详解
解密是加密的逆过程,但需要先解析文件头部,获取盐和IV,然后初始化解密器,最后分块处理并验证认证标签。
/** * 解密文件 * @param srcFilePath 源文件(密文)路径 * @param dstFilePath 目标文件(明文)路径 * @param password 用户密码(如果使用密码派生密钥) * @param symKey 直接提供的对称密钥(如果使用keyManager) * @returns 是否成功 */ async decryptFile(srcFilePath: string, dstFilePath: string, password?: string, symKey?: cryptoFramework.SymKey): Promise<boolean> { try { let srcFile = fs.openSync(srcFilePath, fs.OpenMode.READ_ONLY); let dstFile = fs.openSync(dstFilePath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE); let finalSymKey: cryptoFramework.SymKey; let gcmParams: cryptoFramework.GcmParams; // --- 第一步:解析文件头部,获取盐和IV --- // 先读取8个字节,用于判断是否有盐(4字节盐长 + 至少4字节IV长) let initialBuffer = new ArrayBuffer(8); let bytesRead = fs.readSync(srcFile.fd, initialBuffer); if (bytesRead < 8) { throw new Error('文件已损坏或格式不正确'); } let dataView = new DataView(initialBuffer); let saltLength = dataView.getUint32(0, true); // 小端序 let offset = 4; let salt: Uint8Array | undefined; if (saltLength > 0) { // 读取盐值 let saltBuffer = new ArrayBuffer(saltLength); // 从initialBuffer之后继续读 // 这里需要移动文件指针,为了清晰,我们重新定位并读取整个头部 fs.closeSync(srcFile); srcFile = fs.openSync(srcFilePath, fs.OpenMode.READ_ONLY); // 重新打开,从头开始 // 读取盐长度、盐、IV长度、IV的总长度 let headerSize = 4 + saltLength + 4; let headerBuffer = new ArrayBuffer(headerSize); fs.readSync(srcFile.fd, headerBuffer); let headerView = new DataView(headerBuffer); saltLength = headerView.getUint32(0, true); salt = new Uint8Array(headerBuffer.slice(4, 4 + saltLength)); offset = 4 + saltLength; } else { // 没有盐,重新打开文件,只准备读IV长度 fs.closeSync(srcFile); srcFile = fs.openSync(srcFilePath, fs.OpenMode.READ_ONLY); let ivLenBuffer = new ArrayBuffer(4); fs.readSync(srcFile.fd, ivLenBuffer); let ivLenView = new DataView(ivLenBuffer); saltLength = 0; offset = 0; } // 读取IV长度和IV(此时文件指针在正确位置) // 如果之前没读,现在读IV长度 let ivLengthBuffer: ArrayBuffer; if (salt === undefined) { ivLengthBuffer = new ArrayBuffer(4); fs.readSync(srcFile.fd, ivLengthBuffer); } else { // 如果已经读了完整头部,IV长度信息已经在headerView里了 // 这里为了逻辑简化,我们假设一种实现:在解析完整头部时,一次性获取了IV长度和IV // 以下代码展示另一种更清晰的流式解析思路(实际项目可能需要调整): // 让我们采用更稳健的方法:已知saltLength,计算偏移量读取IV长度 } // 为了代码清晰,我们换一种更直接但稍低效的方法:先获取文件大小,然后读取整个头部区域进行解析。 // 以下是优化后的解密头部解析逻辑: let fileStat = fs.statSync(srcFilePath); let fileSize = fileStat.size; // 假设我们预留足够空间读头部(例如前1KB肯定够放盐、IV长度等信息) let potentialHeaderSize = Math.min(1024, fileSize); let headerReadBuffer = new ArrayBuffer(potentialHeaderSize); fs.readSync(srcFile.fd, headerReadBuffer, { offset: 0 }); let headerOffset = 0; let headerDataView = new DataView(headerReadBuffer); // 解析盐长度 let parsedSaltLength = headerDataView.getUint32(headerOffset, true); headerOffset += 4; if (parsedSaltLength > 0) { // 有盐,读取盐 salt = new Uint8Array(headerReadBuffer.slice(headerOffset, headerOffset + parsedSaltLength)); headerOffset += parsedSaltLength; } // 解析IV长度 let ivLength = headerDataView.getUint32(headerOffset, true); headerOffset += 4; // 读取IV let iv = new Uint8Array(headerReadBuffer.slice(headerOffset, headerOffset + ivLength)); headerOffset += ivLength; // 现在文件指针应该指向密文数据的开始位置。我们需要记录这个位置。 // 由于我们是用fs.readSync从文件开头读取的,需要重新定位fd到密文开始处。 fs.closeSync(srcFile); srcFile = fs.openSync(srcFilePath, fs.OpenMode.READ_ONLY); // 将文件指针移动到头部之后 fs.lseekSync(srcFile.fd, headerOffset, fs.SeekPos.SEEK_SET); // --- 第二步:准备密钥 --- if (password && salt) { // 使用密码和盐重新生成密钥 let keyResult = await this.generateKeyFromPassword(password, salt); finalSymKey = keyResult.key; } else if (symKey) { finalSymKey = symKey; } else { throw new Error('必须提供密码或直接提供密钥'); } // --- 第三步:初始化解密器 --- // 先读取文件末尾的16字节认证标签(GCM模式) let authTagBuffer = new ArrayBuffer(16); fs.readSync(srcFile.fd, authTagBuffer, { offset: fileSize - 16 }); // 重新定位文件指针到密文开始处(因为刚才读末尾移动了指针) fs.lseekSync(srcFile.fd, headerOffset, fs.SeekPos.SEEK_SET); gcmParams = { algName: 'AES-GCM', iv: iv, aad: new Uint8Array(), authTag: new Uint8Array(authTagBuffer), // 设置认证标签 authTagLength: 16 }; let cipher = cryptoFramework.createCipher(this.algName); // 解密也用Cipher对象,但模式不同 await cipher.init(cryptoFramework.CryptoMode.DECRYPT_MODE, finalSymKey, gcmParams); // --- 第四步:分块解密并写入 --- const CHUNK_SIZE = 1024 * 64; // 计算需要读取的密文总长度(文件总长 - 头部长度 - 认证标签长度) let cipherDataLength = fileSize - headerOffset - 16; let totalRead = 0; let readBuffer = new ArrayBuffer(CHUNK_SIZE); while (totalRead < cipherDataLength) { let bytesToRead = Math.min(CHUNK_SIZE, cipherDataLength - totalRead); let bytesRead = fs.readSync(srcFile.fd, readBuffer, { length: bytesToRead }); if (bytesRead <= 0) break; let cipherChunk = new Uint8Array(readBuffer.slice(0, bytesRead)); let updateResult = await cipher.update(cipherChunk.buffer); if (updateResult.data) { fs.writeSync(dstFile.fd, updateResult.data.buffer); } totalRead += bytesRead; } // --- 第五步:结束解密(验证认证标签) --- let finalResult = await cipher.doFinal(null); // 如果doFinal没有抛出异常,说明认证标签验证通过,解密成功 console.info(`文件解密成功: ${dstFilePath}`); fs.closeSync(srcFile); fs.closeSync(dstFile); return true; } catch (error) { console.error(`解密文件失败: ${error.code}, ${error.message}`); // 清理可能部分写入的目标文件 try { fs.unlink(dstFilePath); } catch (e) {} // 特别处理认证失败错误 if (error.code === 401) { // 假设401是cryptoFramework的认证失败错误码(需查证) console.error('解密失败:文件可能被篡改或密码错误!'); } return false; } }注意事项:
- 头部解析的复杂性:上面代码展示了头部解析的复杂性。在实际项目中,你可能需要设计更鲁棒、更简洁的头部格式和解析逻辑,例如使用固定的头部长度,或者将头部信息用JSON序列化后保存在文件开头。
- 认证标签验证:解密
init时传入的authTag必须与加密时生成的一致。doFinal()方法会内部验证这个标签。如果验证失败,doFinal()会抛出异常,这是我们判断文件是否被篡改或密钥是否错误的关键。 - 错误码:
cryptoFramework的错误码需要查阅官方文档。认证失败通常有特定的错误码。
4.4 集成系统密钥库 (keyManager)
对于更安全的密钥管理,我们使用@ohos.security.keyManager。这里简述关键步骤:
生成并存储密钥:
import keyManager from '@ohos.security.keyManager'; async function generateAndStoreAESKey(keyAlias: string): Promise<void> { let keyProperties: keyManager.KeyProperties = { alias: keyAlias, algName: 'AES256', keySize: 256, purpose: [keyManager.KeyPurpose.ENCRYPT, keyManager.KeyPurpose.DECRYPT], padding: 'PKCS7', blockMode: 'GCM', // 设置密钥在设备锁屏时仍需可用(根据场景选择) // securityLevel: keyManager.SecurityLevel.SOFTWARE, }; let options: keyManager.KeyGenOptions = { properties: keyProperties }; // 生成密钥并存入密钥库 await keyManager.generateSymKey(keyAlias, options); }获取密钥进行加解密:
async function getKeyForCrypto(keyAlias: string): Promise<cryptoFramework.SymKey> { // 从密钥库获取密钥属性 let key = await keyManager.getSymKey(keyAlias); // 将keyManager的Key对象转换为cryptoFramework可用的SymKey // 注意:这里可能需要通过key.getEncoded()获取密钥材料,再用cryptoFramework的SymKeyGenerator转换。 // 具体API需要参考最新文档,因为keyManager和cryptoFramework的集成方式可能更新。 // 一种常见模式是:keyManager负责安全存储,当需要使用时,导出密钥材料(在安全环境内)给cryptoFramework。 // 示例(概念性): let keyMaterial = await key.getEncoded(); // 获取密钥二进制数据 let generator = cryptoFramework.createSymKeyGenerator('AES256'); let symKey = await generator.convertKey(keyMaterial); return symKey; }使用
keyManager后,encryptFile和decryptFile方法就不再需要处理密码和盐,而是直接传入从keyManager获取的symKey。文件头部也可以不再存储盐,只存储IV。
重要提示:
keyManager的API和使用方式可能随HarmonyOS版本更新而变化,请务必查阅对应版本的官方开发文档。
5. UI界面与业务逻辑串联
有了核心服务类,我们需要一个简单的UI来触发这些操作。在Index.ets中,我们构建一个基础界面。
@Entry @Component struct Index { @State message: string = '文件加密/解密系统'; @State srcPath: string = ''; // 源文件路径 @State dstPath: string = ''; // 目标文件路径 @State password: string = 'MySecretPassword123!'; // 演示用密码 @State log: string = ''; // 操作日志 private fileCryptoService: FileCryptoService = new FileCryptoService(); // 使用HarmonyOS的文件选择器API选择文件(此处为示意,需用系统Picker) async selectFile() { // 实际开发中应调用 @ohos.file.picker // 例如:let photoSelectOptions = new picker.PhotoSelectOptions(); // let photoPicker = new picker.PhotoViewPicker(); // let photoSelectResult = await photoPicker.select(photoSelectOptions); // this.srcPath = photoSelectResult.photoUris[0]; console.info('打开文件选择器...'); // 为演示,我们模拟一个路径 this.srcPath = '/storage/media/100/local/files/test_document.txt'; this.appendLog(`已选择文件: ${this.srcPath}`); } async encrypt() { if (!this.srcPath) { this.appendLog('请先选择源文件'); return; } this.dstPath = this.srcPath + '.encrypted'; this.appendLog('开始加密...'); let success = await this.fileCryptoService.encryptFileWithPassword( this.srcPath, this.dstPath, this.password ); if (success) { this.appendLog(`加密完成!密文保存在: ${this.dstPath}`); } else { this.appendLog('加密失败!'); } } async decrypt() { if (!this.srcPath || !this.srcPath.endsWith('.encrypted')) { this.appendLog('请选择一个.encrypted后缀的加密文件'); return; } this.dstPath = this.srcPath.replace('.encrypted', '.decrypted'); this.appendLog('开始解密...'); let success = await this.fileCryptoService.decryptFileWithPassword( this.srcPath, this.dstPath, this.password ); if (success) { this.appendLog(`解密完成!明文保存在: ${this.dstPath}`); } else { this.appendLog('解密失败!请检查密码或文件是否完整。'); } } appendLog(text: string) { this.log = `[${new Date().toLocaleTimeString()}] ${text}\n${this.log}`; } build() { Column({ space: 20 }) { Text(this.message).fontSize(30).fontWeight(FontWeight.Bold) Text('当前文件: ' + (this.srcPath || '未选择')).fontSize(14).width('90%').wrapText(true) Row({ space: 10 }) { Button('选择文件').onClick(() => this.selectFile()) Button('加密').type(ButtonType.Capsule).onClick(() => this.encrypt()) Button('解密').type(ButtonType.Capsule).onClick(() => this.decrypt()) } TextInput({ placeholder: '输入加密/解密密码' }) .width('90%') .onChange((value: string) => { this.password = value; }) .value(this.password) Scroll() { Text(this.log) .width('90%') .fontSize(12) .textAlign(TextAlign.Start) .backgroundColor(Color.White) .padding(10) .border({ width: 1, color: Color.Grey }) } .height(200) .width('90%') } .width('100%') .height('100%') .padding(20) .backgroundColor(Color.F5F5F5) } }说明:上述UI代码是高度简化的。真实场景中,文件选择需要使用系统弹窗(@ohos.file.picker),路径处理需要使用@ohos.file.fs的API来获取有效的文件URI和绝对路径。密码输入框应该设置为type(InputType.Password)来隐藏明文。
6. 性能优化、调试与常见问题
6.1 性能优化要点
- 块大小选择:代码中的
CHUNK_SIZE(64KB)是一个经验值。太小会增加IO和加解密调用的次数,太大可能会占用过多内存或导致UI线程阻塞(尽管我们在异步函数中)。可以在不同设备上测试,找到平衡点。 - 异步与进度反馈:加解密大文件是耗时操作。务必在异步函数(
async)中执行,并使用Progress组件或后台任务通知用户进度。可以通过比较已处理的文件大小和总大小来计算进度。 - 密钥缓存:如果多次操作使用同一个密码,可以缓存生成的
symKey,避免每次都需要执行耗时的PBKDF2密钥派生过程。
6.2 真机调试与常见错误
在DevEco Studio中连接真机进行调试是必须的。
- 错误 0x80071771: 指定文件无法解密:这个Windows错误代码在HarmonyOS中不直接对应。但在我们的上下文中,类似的解密失败通常源于:
- 密码/密钥错误:这是最常见的原因。确保加密和解密使用的密码或密钥完全一致。
- 盐或IV不匹配:解密时读取的盐或IV与加密时写入的不一致。检查头部解析逻辑是否正确,文件是否被意外修改。
- 认证标签验证失败:GCM模式中,
authTag不匹配。可能是文件损坏,或者加密/解密时处理authTag的逻辑有误(例如忘记写入或读取)。 - 文件格式损坏:密文文件在传输或存储过程中出现错误。
- 权限问题:确保在
module.json5中声明了正确的权限,并且在真机上首次运行时授权。对于API 9+,部分敏感权限需要动态申请。 - 文件路径问题:HarmonyOS的应用沙箱路径和公共媒体库路径不同。使用
@ohos.file.fs的API(如fs.access)检查文件是否存在,使用@ohos.file.picker获取用户选择的文件URI,并用@ohos.file.uri的getUriToPath等方法转换为可操作的路径。 - 内存问题:处理超大文件时,注意分块,避免一次性加载整个文件到
Uint8Array中。
6.3 扩展思考与优化方向
- 多线程加密:对于多核设备,可以将大文件分片,使用
Worker或TaskPool进行并行加密,最后合并。但需要注意GCM等模式可能不适合简单并行,可能需要使用其他模式如CTR。 - 加密压缩:可以先使用
zlib等库压缩文件,然后再加密,可以节省存储空间和传输带宽。 - 密钥轮换:对于长期存储的数据,应考虑定期更换加密密钥,并重新加密数据。
- 与云端同步:将加密后的文件安全地上传至云端。密钥永远只留在本地设备上,实现“端到端加密”的云备份。
这个基于HarmonyNext和ArkTS的文件加密解密系统,从核心加解密逻辑到密钥管理、文件IO、错误处理,覆盖了主要开发环节。在实际集成到项目时,你需要根据具体的UI设计、权限管理和错误提示需求进行细化。最重要的是,始终将密钥的安全管理放在首位,这是整个系统安全的基石。