AES-GCM是目前Web Crypto API中对安全性要求较高的场景里最常被选用的对称加密算法,浏览器原生支持,性能也不错。但在TypeScript项目中直接调用crypto.subtle.encrypt时,IV向量参数的类型只是一个宽泛的BufferSource,这意味着你可以把任意长度的数组、甚至一段明文数据当作IV传进去,编译器不会报任何错误。这种类型上的宽松在加密场景下是危险的,因为IV的错误使用往往不会立即暴露问题,而是埋下安全隐患。本文就来解决这个问题:如何通过TypeScript的类型系统,为AES-GCM的IV向量构建一套严格的类型定义,让错误在编译期就被拦截。

一、为什么AES-GCM的IV向量必须随机且唯一
在动手写类型定义之前,必须先理解IV在AES-GCM中的角色。GCM模式的全称是Galois/Counter Mode,它把加密和认证结合在一起。IV(在GCM标准中也叫nonce)的作用是为计数器提供起始值,同一个密钥下,每次加密必须使用不同的IV。NIST的推荐是IV长度为96位(12字节),这个长度下IV与计数器拼接后刚好填满一个块,性能和安全性都最优。
关键的禁忌是:同一个密钥配合同一个IV加密两段不同的明文,攻击者可以通过异或运算直接推导出明文之间的关系,认证的安全性也会被破坏。这属于灾难级错误,而且后果不可挽回——一旦发现IV重复,这个密钥加密过的所有数据都应视为已泄露。
正因为错误的代价如此之高,我们才希望用类型系统把好第一道关:IV只能通过随机生成或显式解密存储数据的方式获得,长度固定为12字节,普通数组不允许直接传入加密函数。这就需要用到TypeScript中的branded type技术。
二、用branded type构建不可伪造的IV类型
branded type的核心思路是给基础类型打上一个独一无二的标记,这个标记存在于类型层面但不存在于运行时。下面是IV向量的完整类型定义:
// 品牌标记类型,永远不会被真正实现
declare const IVBrand: unique symbol;
// IV向量类型:基于Uint8Array打上品牌标记
export type IV = Uint8Array & { readonly [IVBrand]: true };
// 常量:推荐的IV字节长度
export const IV_LENGTH = 12;
/**
* 生成随机IV向量
* 只有这个函数(以及后续的解密逻辑)能创建合法的IV类型
*/
export function generateIV(): IV {
const iv = new Uint8Array(IV_LENGTH);
crypto.getRandomValues(iv);
return iv as IV;
}
/**
* 从已有数据中还原IV(例如解密存储的密文时使用)
* 会校验长度,长度不符直接抛错
*/
export function reviveIV(data: Uint8Array): IV {
if (data.length !== IV_LENGTH) {
throw new Error(`IV长度必须为 ${IV_LENGTH} 字节,实际为 ${data.length}`);
}
return data as IV;
}注意generateIV内部的as IV断言,这是整个方案中唯一允许出现这个断言的地方。通过把构造函数的返回类型收紧为IV,外部代码没有任何途径凭空构造一个IV——你传一个普通的Uint8Array给需要IV参数的函数时,编译器会直接报错,因为缺少那个品牌标记。
有人可能会问,既然as断言能绕过,这套防御有意义吗?有。类型系统防的是误用而不是恶意,绝大多数IV事故都源于疏忽:随手写了new Uint8Array(12)全零数组、误传了盐值、复用了上一次的IV。这些无心之失在branded type面前全部编译报错,已经覆盖了绝大多数风险场景。
三、封装类型安全的加密与解密函数
有了IV类型之后,下一步是把crypto.subtle的调用封装起来,让IV类型真正发挥作用。这里给出一个完整的加密封装:
export async function encryptWithAESGCM(
key: CryptoKey,
plaintext: Uint8Array,
iv: IV // 参数类型是品牌类型,普通Uint8Array无法传入
): Promise<{ ciphertext: ArrayBuffer; iv: IV }> {
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, // iv字段同时兼容BufferSource
key,
plaintext
);
return { ciphertext, iv };
}
export async function decryptWithAESGCM(
key: CryptoKey,
ciphertext: ArrayBuffer,
iv: IV
): Promise<Uint8Array> {
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
);
return new Uint8Array(plain);
}
// 密钥生成辅助函数
export async function createAESKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}这个封装的调用方式很自然。加密时IV由函数内部生成并随密文一起返回,调用方负责把两者一起持久化;解密时用reviveIV还原IV类型再传入。整个数据流中,IV的来源被严格限制在generateIV和reviveIV两个入口,重复使用IV的可能性被压缩到几乎为零。
实际使用时还有一个细节值得注意:存储密文时建议把IV和密文拼在一起(IV放前面),读取时按固定长度切分。这个切分操作也应该封装成函数,内部调用reviveIV做长度校验,避免手写slice(0, 12)这种魔法数字。持久化格式一旦确定就不要改动,否则旧数据会解不开。
四、进阶:防止密钥与IV的类型混淆
同样的branded手法可以推广到密钥、密文等其他敏感数据上,构建一套完整的类型体系。比如密文也可以打上品牌:
declare const CiphertextBrand: unique symbol;
export type Ciphertext = Uint8Array & { readonly [CiphertextBrand]: true };
declare const PlaintextBrand: unique symbol;
export type Plaintext = Uint8Array & { readonly [PlaintextBrand]: true };
// 函数签名直接表达数据流向,明文绝不可能被当成密文解密
export async function encrypt(
key: CryptoKey,
data: Plaintext,
iv: IV
): Promise<Ciphertext> {
const result = await encryptWithAESGCM(key, data, iv);
return new Uint8Array(result.ciphertext) as Ciphertext;
}这种做法的好处是函数签名本身就构成了文档:看到(key: CryptoKey, data: Plaintext, iv: IV) => Promise<Ciphertext>,调用者不需要读实现就知道数据如何流转,任何参数传反、传错的操作都过不了编译。代价是需要维护更多的品牌类型和构造函数,适合加密逻辑较复杂或团队协作的项目;小型项目只做IV的品牌化已经足够。
最后提醒一点:类型安全只是防线之一,运行时依然要遵守基本规则——每个密钥配合足够长的随机IV(96位随机IV在重复约50亿次后才有碰撞风险,常规场景下安全),密钥本身要通过Web Crypto的CryptoKey对象管理,不要以明文形式长期存放在JavaScript变量或localStorage中。类型定义解决的是误用问题,加密体系的整体安全还需要密钥管理、传输层安全等多方面配合。
TypeScriptWeb Crypto APIAES-GCM修改时间:2026-09-07 14:54:50