Skip to content

安全与加密

模块内置了一套基于 Web Crypto API 的安全工具,涵盖了从数据加密、接口签名到安全存储的常见场景。

API 请求签名 (API Signature)

新增功能 (v1.1+)

为了防止接口被恶意调用、篡改或重放,插件提供了自动 API 签名功能。开启后,每个请求都会自动携带身份凭证和签名信息。

1. 开启配置

nuxt.config.ts 中配置:

typescript
export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      webPlugin: {
        security: {
          apiSignature: {
            enabled: true,
            // 推荐模式:AES-GCM (Token 模式)
            algorithm: 'AES-GCM', 
            appKey: 'my-app-id',
            appSecret: 'your-secure-password-must-be-long',
            headerPrefix: 'X-Hmac-' // 默认头前缀
          }
        }
      }
    }
  }
})

2. 请求头结构

开启后,所有通过 useApiClient 发起的请求都会自动携带以下 Header:

Header 字段说明示例
X-Hmac-Algorithm签名使用的算法AES-GCM
X-Hmac-Key你的 AppKey (身份标识)my-app-id
X-Hmac-Timestamp请求生成时间戳1703581234567
X-Hmac-Nonce随机数 (防重放)a1b2c3d4...
X-Hmac-Signature核心签名串 (加密后的 Token)base64_string...

3. 后端验证逻辑

后端验证非常简单:只需要解密 X-Hmac-Signature,并验证其中的 JSON 内容即可。

解密后的明文 Token 数据结构:

json
{
  "timestamp": "1703581234567",
  "nonce": "a1b2c3d4e5f6...",
  "appKey": "my-app-id"
}

Node.js 解密示例

javascript
const crypto = require('crypto');

async function decryptSignature(encryptedBase64, password) {
    try {
        const combined = Buffer.from(encryptedBase64, 'base64');
        const iv = combined.subarray(combined.length - 12);
        const encryptedData = combined.subarray(0, combined.length - 12);
        
        // 派生密钥 (PBKDF2)
        const salt = Buffer.alloc(16);
        const passwordBuffer = Buffer.from(password);
        for (let i = 0; i < 16; i++) {
            salt[i] = passwordBuffer[i % passwordBuffer.length];
        }
        
        const key = await new Promise((resolve, reject) => {
            crypto.pbkdf2(password, salt, 100000, 32, 'sha256', (err, derivedKey) => {
                if (err) reject(err); else resolve(derivedKey);
            });
        });

        const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
        
        // 分离 Tag (WebCrypto 默认在密文末尾16字节)
        const tag = encryptedData.subarray(encryptedData.length - 16);
        const text = encryptedData.subarray(0, encryptedData.length - 16);
        
        decipher.setAuthTag(tag);
        let decrypted = decipher.update(text, 'binary', 'utf8');
        decrypted += decipher.final('utf8');

        return JSON.parse(decrypted);
    } catch (e) {
        return null;
    }
}

PHP (ThinkPHP 8) 解密示例

在 PHP 中,我们需要手动处理 PBKDF2 密钥派生和 AES-GCM 的 Tag 分离。

php
<?php
namespace app\common;

class ApiSecurity
{
    /**
     * 解密前端传来的 X-Hmac-Signature
     * @param string $signature Base64 字符串
     * @param string $secret    AppSecret
     * @return array|null       解密后的数组或 null
     */
    public static function decryptSignature(string $signature, string $secret): ?array
    {
        try {
            // 1. Base64 解码
            $encryptedRaw = base64_decode($signature);
            if ($encryptedRaw === false) {
                return null;
            }

            // 2. 提取 IV (最后 12 字节)
            $ivLength = 12;
            $iv = substr($encryptedRaw, -$ivLength);
            
            // 3. 提取 Ciphertext (去除 IV)
            // 注意:Web Crypto API 的结果是 Ciphertext + Tag + IV
            // 但 PHP openssl_decrypt 需要 Tag 单独传入,且 Ciphertext 不包含 Tag
            $dataWithTag = substr($encryptedRaw, 0, -$ivLength);
            
            // 4. 提取 Tag (AES-GCM Tag 通常为 16 字节,位于 Ciphertext 末尾)
            $tagLength = 16;
            $tag = substr($dataWithTag, -$tagLength);
            $ciphertext = substr($dataWithTag, 0, -$tagLength);

            // 5. 派生密钥 (PBKDF2) - 模拟前端逻辑
            // 前端逻辑:如果没有提供 salt,则使用 password 填充 16 字节作为 salt
            $salt = '';
            $passwordBytes = array_values(unpack('C*', $secret)); // 转为字节数组
            $len = count($passwordBytes);
            for ($i = 0; $i < 16; $i++) {
                $salt .= chr($passwordBytes[$i % $len]);
            }

            // 使用 PBKDF2 生成 32 字节 (256位) 密钥
            // algo: sha256, iterations: 100000
            $key = hash_pbkdf2("sha256", $secret, $salt, 100000, 32, true);

            // 6. 执行解密
            // openssl_decrypt 对于 gcm 模式,tag 是引用传出参数用于加密,传入参数用于解密
            $decrypted = openssl_decrypt(
                $ciphertext, 
                'aes-256-gcm', 
                $key, 
                OPENSSL_RAW_DATA, 
                $iv, 
                $tag
            );

            if ($decrypted === false) {
                return null;
            }

            return json_decode($decrypted, true);

        } catch (\Exception $e) {
            // Log::error('Decrypt failed: ' . $e->getMessage());
            return null;
        }
    }
}

控制器使用示例:

php
public function index()
{
    $signature = request()->header('X-Hmac-Signature');
    $secret = 'your-secure-password-must-be-long';

    $token = ApiSecurity::decryptSignature($signature, $secret);

    if (!$token) {
        return json(['code' => 403, 'msg' => 'Invalid Signature']);
    }

    // 1. 校验时间戳 (5分钟有效期)
    if (time() * 1000 - $token['timestamp'] > 5 * 60 * 1000) {
        return json(['code' => 403, 'msg' => 'Token Expired']);
    }

    // 2. 校验 AppKey
    if ($token['appKey'] !== 'my-app-id') {
        return json(['code' => 403, 'msg' => 'Invalid AppKey']);
    }

    return json(['code' => 200, 'msg' => 'Success']);
}

对称加密 (Symmetric Crypto)

使用 useSymmetricCrypto 进行 AES-GCM 加密。适用于对敏感文本数据进行加密。

typescript
const { encrypt, decrypt, generateKey } = useSymmetricCrypto()

// 1. 准备密钥(可以是用户密码或随机生成的 Key)
const password = 'my-secret-password'

// 2. 加密
const encryptedData = await encrypt('敏感信息', password)
// 输出: base64字符串 (包含密文+IV)

// 3. 解密
const originalData = await decrypt(encryptedData, password)

非对称加密 (Asymmetric Crypto)

使用 useAsymmetricCrypto 进行 RSA-OAEP 加解密及签名。

typescript
const { 
  generateKeyPair, 
  encrypt, 
  decrypt, 
  sign, 
  verify 
} = useAsymmetricCrypto()

// 生成密钥对
const { publicKey, privateKey } = await generateKeyPair()
// 导出为 PEM 格式字符串以便传输或存储
const pubPem = await exportPublicKey(publicKey)
const privPem = await exportPrivateKey(privateKey)

// 加密 (使用公钥)
const secret = await encrypt('Hello', pubPem)

// 解密 (使用私钥)
const message = await decrypt(secret, privPem)

哈希工具 (Hash)

typescript
const { sha256, sha512, md5, generateSalt, hashWithSalt } = useHash()

const hash = await sha256('password')
const salt = generateSalt() // 生成随机盐值
const saltedHash = await hashWithSalt('password', salt)

加密存储 (Encrypted Storage)

useEncryption 提供了对 localStorage and cookie 的封装,在写入时自动加密,读取时自动解密。

注意: 加密存储依赖于客户端的密钥。虽然这能防止直接查看 Storage 中的明文,但如果攻击者获取了前端代码中的密钥,依然可以解密。建议结合非对称加密或动态密钥使用。

typescript
const { setEncryptedItem, getEncryptedItem } = useEncryption()
const key = 'storage-key'

// LocalStorage
await setEncryptedItem('token', 'xyz-123', key)
const token = await getEncryptedItem('token', key)

场景实战:安全登录

结合非对称加密(传输密码)和加密存储(保存 Token)的完整流程。

vue
<script setup lang="ts">
import { ref } from 'vue'

const { post } = useApiClient()
const { encrypt } = useAsymmetricCrypto()
const { setEncryptedCookie } = useEncryption()

const username = ref('')
const password = ref('')

// 后端提供的公钥
const SERVER_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----`

const login = async () => {
  // 1. 前端使用公钥加密密码
  const encryptedPassword = await encrypt(password.value, SERVER_PUBLIC_KEY)

  // 2. 发送请求
  const res = await post('/login', {
    body: { username: username.value, password: encryptedPassword }
  })

  // 3. 加密存储 Token
  await setEncryptedCookie('token', res.token, 'local-secret', { secure: true })
}
</script>

Released under the MIT License.