加密
本指南介绍 AdonisJS 应用中的加密和解密。你将学习如何:
- 加密和解密敏感数据
- 选择并配置加密算法
- 使用目的绑定(purpose-bound)加密以增强安全性
- 为加密值设置过期时间
- 使用消息验证器(message verifier)在不加密的情况下对数据进行签名
- 实现密钥轮换以无缝更新密钥
概述
加密将可读数据转换为只有使用正确的密钥才能解密的密文。与哈希不同,加密是一个可逆的过程。你加密数据是为了在存储或传输过程中保护它,然后在需要读取原始值时再解密它。
AdonisJS 提供了一个加密服务,内置支持三种行业标准的算法:ChaCha20-Poly1305、AES-256-GCM 和 AES-256-CBC。这三种都是经过认证的加密算法,意味着它们不仅能保护机密性,还能检测篡改。如果有人修改了加密数据,解密会失败,而不是返回被损坏的数据。
加密服务以结构化格式输出,其中包含驱动标识符、密文、初始化向量和认证标签。这种自描述格式允许你在切换算法或轮换密钥的同时,仍能解密旧值。
基本用法
加密服务提供了两个主要方法:encryption.encrypt 用于加密值,encryption.decrypt 用于取回原始数据。
加密值
encryption.encrypt 方法接受任何可序列化的值,并返回一个加密字符串。
import encryption from '@adonisjs/core/services/encryption'
export default class ApiTokenService {
createToken(userId: number, permissions: string[]) {
/**
* 加密令牌载荷。该服务会处理序列化,
* 因此你可以直接传递对象。
*/
const token = encryption.encrypt({
userId,
permissions,
createdAt: new Date(),
})
// token 形如:
// cbc.base64Ciphertext.base64IV.base64Tag
return token
}
}
加密服务支持加密字符串、数字、布尔值、数组、对象和日期。复杂的嵌套结构会在加密前自动序列化。
解密值
encryption.decrypt 方法接受一个加密字符串并返回原始值,如果解密失败则返回 null。
import encryption from '@adonisjs/core/services/encryption'
export default class ApiTokenService {
verifyToken(token: string) {
/**
* 尝试解密令牌。如果令牌无效、被篡改,
* 或使用不同的密钥加密,则返回 null。
*/
const payload = encryption.decrypt(token)
if (!payload) {
return null
}
return payload as { userId: number; permissions: string[] }
}
}
解密方法在解密失败时会返回 null 而不是抛出异常。这种设计可以防止计时攻击并简化错误处理。在使用解密值之前,你应当始终检查是否为 null。
目的绑定加密
目的绑定加密确保加密值只有在提供相同目的时才能被解密。这可以防止令牌在你的应用中跨不同上下文被复用。
import encryption from '@adonisjs/core/services/encryption'
export default class TokenService {
createPasswordResetToken(userId: number) {
/**
* purpose 选项指定了加密目的。
* 该令牌只能使用相同目的解密。
*/
return encryption.encrypt(
{ userId },
{ purpose: 'password-reset' }
)
}
createEmailVerificationToken(userId: number) {
return encryption.encrypt(
{ userId },
{ purpose: 'email-verification' }
)
}
verifyPasswordResetToken(token: string) {
/**
* 必须提供相同目的才能解密。
* 为邮箱验证创建的令牌在这里无法使用。
*/
return encryption.decrypt(token, 'password-reset')
}
}
如果没有目的绑定,攻击者一旦获取到一个密码重置令牌,就可能将其作为邮箱验证令牌复用(如果两者包含相同的数据结构)。目的绑定加密通过以加密方式将目的绑定到加密值上,从而防止这种攻击。
const token = encryption.encrypt(
{ userId: 1 },
{ purpose: 'password-reset' }
)
encryption.decrypt(token, 'password-reset') // => { userId: 1 }
/**
* 使用错误的目的解密会返回 null。
*/
encryption.decrypt(token, 'email-verification') // => null
encryption.decrypt(token) // => null
让加密值过期
你可以在加密值上设置一个存活时间(TTL)。在指定时长之后,即使加密数据仍然有效,解密方法也会返回 null。
import encryption from '@adonisjs/core/services/encryption'
export default class InvitationService {
createInvitationLink(email: string, teamId: number) {
const token = encryption.encrypt({ email, teamId }, {
expiresIn: '24h'
})
return `https://app.example.com/invitations/${token}`
}
acceptInvitation(token: string) {
/**
* 如果令牌已过期,返回 null,
* 即使加密数据仍然有效。
*/
const payload = encryption.decrypt(token)
if (!payload) {
return { error: 'Invalid or expired invitation' }
}
return payload as { email: string; teamId: number }
}
}
支持的时长格式包括:
| Format | Example | Description |
|---|---|---|
| Minutes | '30m' | 30 分钟后过期 |
| Hours | '1h' | 1 小时后过期 |
| Days | '7d' | 7 天后过期 |
你可以将目的绑定与过期时间结合,以获得最大的安全性。
/**
* 创建一个 1 小时后过期的密码重置令牌,
* 且只能用于密码重置操作。
*/
const token = encryption.encrypt(
{ userId: 1 },
{ expiresIn: '1h', purpose: 'password-reset' }
)
/**
* 必须提供正确的目的才能解密。
* 如果已过期或目的不匹配,则返回 null。
*/
const payload = encryption.decrypt(token, 'password-reset')
加密数据库列
在将数据存储到数据库之前,先加密敏感数据。
import { UserSchema } from '#database/schema'
import { beforeSave } from '@adonisjs/lucid/orm'
import encryption from '@adonisjs/core/services/encryption'
export default class User extends UserSchema {
@beforeSave()
static encryptSensitiveData(user: User) {
if (user.$dirty.ssn && user.ssn) {
user.ssn = encryption.encrypt(user.ssn)
}
}
decryptSsn(): string | null {
if (!this.ssn) {
return null
}
return encryption.decrypt(this.ssn)
}
}
选择算法
每种加密算法在安全性、性能和兼容性之间都有不同的权衡。正确的选择取决于你应用的需求。
何时选择 ChaCha20-Poly1305
ChaCha20-Poly1305 是新应用推荐的选择。它在所有平台上都提供一致的高性能,包括那些没有硬件 AES 加速的平台。它被广泛应用于现代协议中,包括 TLS 1.3 和 WireGuard。
何时选择 AES-256-GCM
当你需要兼容专门要求 AES 的系统,或运行在带有 AES-NI 加速的硬件上时,AES-256-GCM 是一个极佳的选择。它是许多生态系统中的默认密码,即使没有明确 AES 要求,也能简化互操作性。
何时选择 AES-256-CBC
AES-256-CBC 主要提供的是遗留兼容性,主要用于解密来自旧系统的现有数据。与 AEAD 密码不同,CBC 需要使用 Encrypt-then-MAC 模式进行单独的 HMAC 认证。对于新的加密需求,优先选择 ChaCha20-Poly1305 或 AES-256-GCM。
CBC 模式有历史上出现过的实现陷阱,包括填充预言机攻击(padding oracle attack)。AdonisJS 在内部处理了这些问题,但如果你在其他地方实现 CBC,请确保使用 Encrypt-then-MAC 以及对认证标签进行常量时间比较。
何时选择 Legacy
legacy 驱动专为从 AdonisJS v6 迁移到 v7 而设计。它只能解密使用 v6 加密服务加密的数据。当你在数据库中有来自 v6 应用、需要迁移的已加密数据时,请使用此驱动。
推荐的迁移策略为:
- 在配置现代驱动的同时配置 legacy 驱动
- 使用 legacy 驱动读取已加密的数据
- 使用现代驱动(ChaCha20-Poly1305 或 AES-256-GCM)重新加密数据
- 一旦所有数据都已完成迁移,移除 legacy 驱动
import encryption from '@adonisjs/core/services/encryption'
export default class MigrationService {
async migrateEncryptedField(encryptedValue: string) {
/**
* 使用 legacy 驱动(v6 格式)解密
*/
const decrypted = encryption.use('legacy').decrypt(encryptedValue)
if (!decrypted) {
return null
}
/**
* 使用现代驱动重新加密
*/
return encryption.encrypt(decrypted)
}
}
配置
加密配置位于 config/encryption.ts 中。你可以在 list 对象中定义可用的驱动,并通过 default 指定默认使用哪个。
import env from '#start/env'
import { defineConfig, drivers } from '@adonisjs/core/encryption'
export default defineConfig({
/**
* 当未显式指定驱动时,encryption.encrypt() 和
* encryption.decrypt() 使用的默认驱动。
*/
default: 'chacha',
list: {
chacha: drivers.chacha20({
id: 'chacha',
keys: [env.get('APP_KEY')],
}),
/**
* AES-256-GCM:行业标准的认证加密。
*/
// gcm: drivers.aes256gcm({
// id: 'gcm',
// keys: [env.get('APP_KEY')],
// }),
/**
* AES-256-CBC:带有 HMAC 认证的遗留支持。
*/
// cbc: drivers.aes256cbc({
// id: 'cbc',
// keys: [env.get('APP_KEY')],
// }),
/**
* Legacy:解密使用 AdonisJS v6 加密的数据。
* 使用此驱动将数据从 v6 迁移到 v7。
*/
// legacy: drivers.legacy({
// keys: [env.get('APP_KEY')],
// }),
},
})
驱动配置选项
所有驱动都接受相同的配置选项。
id
此驱动配置的唯一标识符。该 ID 会被嵌入到加密输出中,使解密过程能够识别使用了哪个驱动。
keys
一个密钥数组。第一个密钥用于加密,而解密时会尝试所有密钥。这实现了无缝的密钥轮换。
密钥轮换
加密服务支持多个密钥以实现无缝的密钥轮换。数组中的第一个密钥用于加密新值,而解密时会尝试所有密钥。这使你能够在不使现有加密数据失效的情况下轮换密钥。
import env from '#start/env'
import { defineConfig, drivers } from '@adonisjs/core/encryption'
export default defineConfig({
default: 'chacha',
list: {
chacha: drivers.chacha20({
id: 'chacha',
keys: [
env.get('APP_KEY'), // 新密钥:用于加密
env.get('OLD_APP_KEY'), // 旧密钥:仅用于解密
],
}),
},
})
轮换密钥时,请遵循以下流程:
- 生成一个新的密钥
- 将新密钥添加为
keys数组的第一个元素 - 将旧密钥移到第二个位置
- 部署你的应用
- 在足够长的时间之后(当所有旧的加密值都已被重新加密或过期),移除旧密钥
import encryption from '@adonisjs/core/services/encryption'
/**
* 新的加密会自动使用第一个密钥。
*/
const newToken = encryption.encrypt({ userId: 1 })
/**
* 解密会尝试所有密钥,因此旧的令牌仍然有效。
*/
const oldPayload = encryption.decrypt(tokenEncryptedWithOldKey) // 有效
const newPayload = encryption.decrypt(newToken) // 有效
在你确定所有用该密钥加密的数据要么已经用新密钥重新加密,要么不再需要之前,绝不要移除旧密钥。移除密钥会使所有用它加密的数据永久无法读取。
消息验证器
当你需要在不隐藏内容的情况下确保数据完整性时,请使用消息验证器。与加密不同,消息验证器不会对数据进行加密。它对载荷进行 base64 编码并使用 HMAC 签名,使任何人都能读取数据,同时确保其未被篡改。
import encryption from '@adonisjs/core/services/encryption'
export default class StateService {
createOAuthState(returnUrl: string, provider: string) {
/**
* 对 state 进行签名而不加密它。
* 数据可读但防篡改。
*/
return encryption.verifier.sign({ returnUrl, provider })
}
verifyOAuthState(state: string) {
/**
* 验证签名并返回载荷。
* 如果签名无效,返回 null。
*/
return encryption.verifier.unsign(state)
}
}
消息验证器适用于那些你想要检测篡改但不需要隐藏数据的场景,例如 OAuth state 参数、CSRF 令牌或 webhook 签名。
带有目的和过期的验证器
消息验证器支持与加密相同的目的绑定和过期功能。
import encryption from '@adonisjs/core/services/encryption'
export default class CsrfService {
createToken(sessionId: string) {
/**
* 创建一个 1 小时后过期,
* 且绑定到 'csrf' 目的的 CSRF 令牌。
*/
return encryption.verifier.sign({ sessionId }, '1h', 'csrf')
}
verifyToken(token: string, expectedSessionId: string) {
const payload = encryption.verifier.unsign(token, 'csrf')
if (!payload || payload.sessionId !== expectedSessionId) {
return false
}
return true
}
}
使用多个驱动
某些应用需要针对不同的目的使用不同的算法来加密数据。encryption.use 方法让你可以显式选择一个驱动。
import encryption from '@adonisjs/core/services/encryption'
export default class MultiDriverService {
/**
* 使用 ChaCha20-Poly1305 进行高性能加密。
*/
encryptSessionData(data: object) {
return encryption.use('chacha').encrypt(data)
}
/**
* 使用 AES-256-GCM 以兼容外部系统。
*/
encryptForExternalApi(data: object) {
return encryption.use('gcm').encrypt(data)
}
}
encryption.use() 中指定的每个驱动都必须在你的 config/encryption.ts 文件的 list 对象中配置。
生成应用密钥
加密服务需要一个存储在 APP_KEY 环境变量中的、加密安全的密钥。你可以使用 Ace CLI 生成一个新的密钥。
node ace generate:key
此命令会生成一个随机的 32 字符密钥,并将其写入你的 .env 文件。该密钥使用了加密安全的随机数生成器,以确保它无法被预测或猜测。
APP_KEY 对你的应用安全至关重要。如果该密钥泄露,攻击者可以解密你所有已加密的数据并伪造已签名的 cookie。请安全地存储它,切勿将其提交到版本控制中,并为每个环境使用不同的密钥。
如果你更改或丢失了 APP_KEY,所有现有的加密数据将永久无法读取。用旧密钥签名的 cookie 会被拒绝,已加密的数据库值也无法恢复。请始终安全地备份你的生产环境密钥。