加密

本指南介绍 AdonisJS 应用中的加密和解密。你将学习如何:

  • 加密和解密敏感数据
  • 选择并配置加密算法
  • 使用目的绑定(purpose-bound)加密以增强安全性
  • 为加密值设置过期时间
  • 使用消息验证器(message verifier)在不加密的情况下对数据进行签名
  • 实现密钥轮换以无缝更新密钥

概述

加密将可读数据转换为只有使用正确的密钥才能解密的密文。与哈希不同,加密是一个可逆的过程。你加密数据是为了在存储或传输过程中保护它,然后在需要读取原始值时再解密它。

AdonisJS 提供了一个加密服务,内置支持三种行业标准的算法:ChaCha20-Poly1305、AES-256-GCM 和 AES-256-CBC。这三种都是经过认证的加密算法,意味着它们不仅能保护机密性,还能检测篡改。如果有人修改了加密数据,解密会失败,而不是返回被损坏的数据。

加密服务以结构化格式输出,其中包含驱动标识符、密文、初始化向量和认证标签。这种自描述格式允许你在切换算法或轮换密钥的同时,仍能解密旧值。

基本用法

加密服务提供了两个主要方法:encryption.encrypt 用于加密值,encryption.decrypt 用于取回原始数据。

加密值

encryption.encrypt 方法接受任何可序列化的值,并返回一个加密字符串。

app/services/api_token_service.ts
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

app/services/api_token_service.ts
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

目的绑定加密

目的绑定加密确保加密值只有在提供相同目的时才能被解密。这可以防止令牌在你的应用中跨不同上下文被复用。

app/services/token_service.ts
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')
  }
}

如果没有目的绑定,攻击者一旦获取到一个密码重置令牌,就可能将其作为邮箱验证令牌复用(如果两者包含相同的数据结构)。目的绑定加密通过以加密方式将目的绑定到加密值上,从而防止这种攻击。

app/services/token_service.ts
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

app/services/invitation_service.ts
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 }
  }
}

支持的时长格式包括:

FormatExampleDescription
Minutes'30m'30 分钟后过期
Hours'1h'1 小时后过期
Days'7d'7 天后过期

你可以将目的绑定与过期时间结合,以获得最大的安全性。

app/services/token_service.ts
/**
 * 创建一个 1 小时后过期的密码重置令牌,
 * 且只能用于密码重置操作。
 */
const token = encryption.encrypt(
  { userId: 1 },
  { expiresIn: '1h', purpose: 'password-reset' }
)

/**
 * 必须提供正确的目的才能解密。
 * 如果已过期或目的不匹配,则返回 null。
 */
const payload = encryption.decrypt(token, 'password-reset')

加密数据库列

在将数据存储到数据库之前,先加密敏感数据。

app/models/user.ts
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。

Warning

CBC 模式有历史上出现过的实现陷阱,包括填充预言机攻击(padding oracle attack)。AdonisJS 在内部处理了这些问题,但如果你在其他地方实现 CBC,请确保使用 Encrypt-then-MAC 以及对认证标签进行常量时间比较。

何时选择 Legacy

legacy 驱动专为从 AdonisJS v6 迁移到 v7 而设计。它只能解密使用 v6 加密服务加密的数据。当你在数据库中有来自 v6 应用、需要迁移的已加密数据时,请使用此驱动。

推荐的迁移策略为:

  1. 在配置现代驱动的同时配置 legacy 驱动
  2. 使用 legacy 驱动读取已加密的数据
  3. 使用现代驱动(ChaCha20-Poly1305 或 AES-256-GCM)重新加密数据
  4. 一旦所有数据都已完成迁移,移除 legacy 驱动
app/services/migration_service.ts
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 指定默认使用哪个。

config/encryption.ts
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
string

此驱动配置的唯一标识符。该 ID 会被嵌入到加密输出中,使解密过程能够识别使用了哪个驱动。

keys
string[]

一个密钥数组。第一个密钥用于加密,而解密时会尝试所有密钥。这实现了无缝的密钥轮换。

密钥轮换

加密服务支持多个密钥以实现无缝的密钥轮换。数组中的第一个密钥用于加密新值,而解密时会尝试所有密钥。这使你能够在不使现有加密数据失效的情况下轮换密钥。

config/encryption.ts
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'), // 旧密钥:仅用于解密
      ],
    }),
  },
})

轮换密钥时,请遵循以下流程:

  1. 生成一个新的密钥
  2. 将新密钥添加为 keys 数组的第一个元素
  3. 将旧密钥移到第二个位置
  4. 部署你的应用
  5. 在足够长的时间之后(当所有旧的加密值都已被重新加密或过期),移除旧密钥
app/services/rotation_example.ts
import encryption from '@adonisjs/core/services/encryption'

/**
 * 新的加密会自动使用第一个密钥。
 */
const newToken = encryption.encrypt({ userId: 1 })

/**
 * 解密会尝试所有密钥,因此旧的令牌仍然有效。
 */
const oldPayload = encryption.decrypt(tokenEncryptedWithOldKey) // 有效
const newPayload = encryption.decrypt(newToken)                 // 有效
Warning

在你确定所有用该密钥加密的数据要么已经用新密钥重新加密,要么不再需要之前,绝不要移除旧密钥。移除密钥会使所有用它加密的数据永久无法读取。

消息验证器

当你需要在不隐藏内容的情况下确保数据完整性时,请使用消息验证器。与加密不同,消息验证器不会对数据进行加密。它对载荷进行 base64 编码并使用 HMAC 签名,使任何人都能读取数据,同时确保其未被篡改。

app/services/state_service.ts
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 签名。

带有目的和过期的验证器

消息验证器支持与加密相同的目的绑定和过期功能。

app/services/csrf_service.ts
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 方法让你可以显式选择一个驱动。

app/services/multi_driver_service.ts
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 会被拒绝,已加密的数据库值也无法恢复。请始终安全地备份你的生产环境密钥。