非对称密钥签名时,生产环境无法导出为字符串??

是的,这种情况很常见,但通常不是“生产环境不能导出”,而是以下原因导致的。

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']
)
        
      

注意:生产环境不建议随意将私钥导出为字符串,尤其不能放入:

  • localStorage

  • sessionStorage

  • Cookie

  • 前端源码

  • 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 私钥

pkcs8

RSA 公钥

spki

EC 私钥

pkcs8

EC 公钥

spki

对称密钥

raw

JWK 格式

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
)
        
      

更安全的架构是:

  1. 私钥只保存在服务端;

  2. 前端提交待签名数据;

  3. 服务端使用私钥签名;

  4. 私钥通过 KMS、HSM 或 Secret Manager 管理;

  5. 前端只获取签名结果或公钥。

如果一定要导出,至少保证:

        
const key = await crypto.subtle.generateKey(
  algorithm,
  true,
  ['sign', 'verify']
)
        
      

并使用 pkcs8jwk 导出后再进行 Base64/PEM 编码。

核心结论:
生产环境无法导出,最常见原因是密钥创建时 extractablefalse。如果只是签名,不要导出私钥;直接使用 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 -> 应用服务器:返回签名结果
        
      

这样应用服务器甚至不需要拿到原始私钥。


如果私钥已经在浏览器生成

需要满足两个条件:

  1. 生成时设置了 extractable: true

  2. 当前密钥确实是可导出的

        
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 必须一致。

结论

最合适的方案是:

  1. 在服务器端生成密钥对

  2. 私钥保存到 KMS/HSM/Secret Manager

  3. 服务器只负责调用签名能力;

  4. 公钥可以公开分发;

  5. 不要从浏览器导出私钥上传到服务器。

如果只是普通应用签名,至少也应使用服务器生成的 PKCS#8 私钥,并设置严格的文件权限 0600