非对称密钥签名时,生产环境无法导出为字符串??
是的,这种情况很常见,但通常不是“生产环境不能导出”,而是以下原因导致的。
1. CryptoKey 不是字符串
使用 Web Crypto API 生成或导入的密钥通常是 CryptoKey 对象:
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSA-PSS',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
false, // extractable
['sign', 'verify']
)
CryptoKey 不能直接:
String(privateKey)
JSON.stringify(privateKey)
如果要导出,需要调用:
const data = await crypto.subtle.exportKey('pkcs8', privateKey)
但返回值是 ArrayBuffer,还需要转成 Base64 或 PEM。
2. extractable: false 时无法导出
上面的第二个参数是:
false
表示密钥不可导出。此时执行:
await crypto.subtle.exportKey('pkcs8', privateKey)
会抛出类似错误:
InvalidAccessError: key is not extractable
如果确实需要导出,生成时必须设置:
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSA-PSS',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['sign', 'verify']
)
注意:生产环境不建议随意将私钥导出为字符串,尤其不能放入:
localStoragesessionStorageCookie
前端源码
URL 参数
日志
3. 正确导出为 PEM
ArrayBuffer 转 Base64
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
PKCS8 私钥转 PEM
function toPem(base64, label) {
const body = base64.match(/.{1,64}/g)?.join('\n') || ''
return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----`
}
const pkcs8 = await crypto.subtle.exportKey('pkcs8', privateKey)
const privateKeyPem = toPem(
arrayBufferToBase64(pkcs8),
'PRIVATE KEY'
)
console.log(privateKeyPem)
公钥一般使用 spki:
const spki = await crypto.subtle.exportKey('spki', publicKey)
const publicKeyPem = toPem(
arrayBufferToBase64(spki),
'PUBLIC KEY'
)
4. 不同密钥类型对应的导出格式
密钥 | 常用格式 |
|---|---|
RSA 私钥 |
|
RSA 公钥 |
|
EC 私钥 |
|
EC 公钥 |
|
对称密钥 |
|
JWK 格式 |
|
例如导出为 JWK:
const jwk = await crypto.subtle.exportKey('jwk', privateKey)
console.log(JSON.stringify(jwk))
但仍然要求该密钥是可导出的。
5. 生产环境常见问题
生产环境使用了不同的密钥配置
例如开发环境:
extractable: true
生产环境:
extractable: false
或者生产环境使用的是服务器下发的不可导出密钥。
可以检查:
console.log(privateKey.extractable)
console.log(privateKey.type)
console.log(privateKey.algorithm)
console.log(privateKey.usages)
生产环境不是安全上下文
浏览器中的 crypto.subtle 通常要求:
HTTPS
或
localhost
生产环境如果是 HTTP,可能出现:
textCopy
crypto.subtle is undefined
应使用:
https://example.com
而不是:
http://example.com
SSR 或 Node.js 环境差异
如果代码运行在 Next.js、Nuxt、Node.js 服务端,需要确认使用的是 Web Crypto:
globalThis.crypto.subtle
Node.js 中也可能使用:
import { webcrypto } from 'node:crypto'
const { subtle } = webcrypto
不要把浏览器的 window.crypto 代码直接放到服务端执行。
6. 推荐的生产方案
如果只是为了签名,通常不需要导出私钥:
const signature = await crypto.subtle.sign(
{
name: 'RSA-PSS',
saltLength: 32,
},
privateKey,
data
)
更安全的架构是:
私钥只保存在服务端;
前端提交待签名数据;
服务端使用私钥签名;
私钥通过 KMS、HSM 或 Secret Manager 管理;
前端只获取签名结果或公钥。
如果一定要导出,至少保证:
const key = await crypto.subtle.generateKey(
algorithm,
true,
['sign', 'verify']
)
并使用 pkcs8 或 jwk 导出后再进行 Base64/PEM 编码。
核心结论:
生产环境无法导出,最常见原因是密钥创建时 extractable 为 false。如果只是签名,不要导出私钥;直接使用 CryptoKey 签名更安全。
例如我要将私钥放服务器怎么办
如果目标是“将私钥放到服务器上用于生产签名”,推荐在服务器端生成或导入私钥,不要在浏览器生成后再导出上传。
推荐方案:服务器端生成密钥
以 Node.js 为例:
import { generateKeyPairSync } from 'node:crypto'
import fs from 'node:fs'
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 3072,
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
})
fs.writeFileSync('/secure/path/private-key.pem', privateKey, {
mode: 0o600,
})
fs.writeFileSync('/secure/path/public-key.pem', publicKey, {
mode: 0o644,
})
私钥内容类似:
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
然后服务器启动时读取:
import fs from 'node:fs'
import crypto from 'node:crypto'
const privateKey = crypto.createPrivateKey({
key: fs.readFileSync('/secure/path/private-key.pem', 'utf8'),
format: 'pem',
type: 'pkcs8',
})
const signature = crypto.sign(
'sha256',
Buffer.from('待签名内容'),
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PADDING,
}
)
console.log(signature.toString('base64'))
更推荐:使用密钥管理服务
生产环境不要优先把私钥直接写入项目目录或 .env,建议使用:
AWS KMS / Secrets Manager
阿里云 KMS / Secrets Manager
Azure Key Vault
Google Cloud KMS
HashiCorp Vault
HSM 硬件密钥模块
应用只调用签名接口:
应用服务器 -> KMS/HSM:请对数据签名
KMS/HSM -> 应用服务器:返回签名结果
这样应用服务器甚至不需要拿到原始私钥。
如果私钥已经在浏览器生成
需要满足两个条件:
生成时设置了
extractable: true当前密钥确实是可导出的
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSA-PSS',
modulusLength: 3072,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['sign', 'verify']
)
导出为 PKCS#8:
const pkcs8 = await crypto.subtle.exportKey(
'pkcs8',
keyPair.privateKey
)
然后转成 Base64 或 PEM,再通过 HTTPS 上传到服务器。
但是不建议这样做,因为私钥经过了浏览器和网络传输,可能暴露在:
XSS 攻击
浏览器插件
前端日志
调试工具
错误上报系统
代理或错误的请求记录
如果生成时是:
extractable: false
那么无法通过 Web Crypto API 导出:
textCopy
InvalidAccessError: key is not extractable
此时只能重新生成一对密钥,或者让服务器重新生成。
如果必须从本地导入服务器
可以在本地或安全机器生成密钥:
openssl genpkey \
-algorithm RSA \
-pkeyopt rsa_keygen_bits:3072 \
-out private-key.pem
openssl rsa \
-pubout \
-in private-key.pem \
-out public-key.pem
chmod 600 private-key.pem
然后通过受控方式上传:
scp private-key.pem user@server:/secure/path/private-key.pem
服务器上设置权限:
chmod 600 /secure/path/private-key.pem
chown appuser:appuser /secure/path/private-key.pem
不要通过以下方式传输私钥:
Git 仓库
前端代码
邮件
微信或普通聊天工具
URL 参数
普通 HTTP
日志文件
使用环境变量时要注意
可以临时使用:
export SIGNING_PRIVATE_KEY="$(cat private-key.pem)"
但不建议长期直接放在 .env 中,尤其不要提交到 Git。
如果必须使用环境变量,需要处理换行:
const privateKey = process.env.SIGNING_PRIVATE_KEY.replace(/\\n/g, '\n')
更好的方式是:
应用启动时 -> Secret Manager 获取私钥 -> 放入内存 -> 使用
并避免打印:
console.log(process.env.SIGNING_PRIVATE_KEY) // 禁止
服务器端导入 PEM 私钥
import crypto from 'node:crypto'
const privateKey = crypto.createPrivateKey({
key: process.env.SIGNING_PRIVATE_KEY.replace(/\\n/g, '\n'),
format: 'pem',
type: 'pkcs8',
})
使用 RSA-PSS 签名:
const signature = crypto.sign(
null,
Buffer.from(payload),
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32,
}
)
const result = signature.toString('base64')
验证端使用公钥:
const valid = crypto.verify(
null,
Buffer.from(payload),
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32,
},
Buffer.from(signature, 'base64')
)
签名和验证两端的算法、Padding、Hash、saltLength 必须一致。
结论
最合适的方案是:
在服务器端生成密钥对;
私钥保存到 KMS/HSM/Secret Manager;
服务器只负责调用签名能力;
公钥可以公开分发;
不要从浏览器导出私钥上传到服务器。
如果只是普通应用签名,至少也应使用服务器生成的 PKCS#8 私钥,并设置严格的文件权限 0600。