前端加密·补充

之前的 前端加密 虽然提到了 Web Cryptography API,但没有演示如何使用 Web Cryptography API 实现 RSA 加密、解密,这里做个补充。

subtle.importKey()

webcrypto 的 API 相对于 Node 的 crypto 模块更加底层(low-level),Node 的 crypto.publicEncryptcrypto.privateDecrypt 会自动识别传入的密钥格式,而 webcrypto 需要我们主动处理。

const cryptoKey = await subtle.importKey(
  format,
  keyData,
  algorithm,
  extractable,
  keyUsages
);
  • format: 待导入密钥的格式,目前支持 "raw", "spki", "pkcs8", "jwk" 4 种
  • keyData: 密钥数据,支持 BufferSourceJsonWebKey 类型
  • algorithm: 字典对象,包含了待导入密钥的类型和算法指定的额外参数
  • extractable: 布尔值,用于指定密钥是否可以导出
  • keyUsages: 字符串数组,用于指定密钥的用法,和 SubtleCrypto 暴露的方法对应

具体使用参考 SubtleCrypto.importKey() 文档。

我们可以写出如下的 RSA 公钥、私钥导入代码:

const publicKey = await subtle.importKey(
  'spki',
  publicKeyData,
  {
    name: 'RSA-OAEP',
    hash: 'SHA-256'
  },
  false,
  ['encrypt']
);

const privateKey = await subtle.importKey(
  'pkcs8',
  privateKeyData,
  {
    name: 'RSA-OAEP',
    hash: 'SHA-256'
  },
  false,
  ['decrypt']
);

其中的 publicKeyDataprivateKeyData 还需要进一步实现。

祭出前文生成的 RSA 私钥和公钥。

rsa_1024_priv.pem

-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQDKKqgnPzneyl2kz7G3yyBgeIKkUPk17DHfvU0ElcZt1tJpqFgG
8L6X4Xb3QyDagxqa/HKMSDrlpRakbzgJTs6kfFCofIvNhzQwQNfEnpKdWK7Im7di
zPIzIi58ogFHToDAEWfOtpaXMiqY6pknvtVSJHMNlbDJ3WDkPKj8KOUBGQIDAQAB
AoGAYuj73DfS2G2p4zi6enGnJYvQXxQ+2WL2A8FaLSQaMSMpTwhOCRdAKI7m5ZKy
QDZkje91G607I5/htBG2GNe1wWUNMgJmuPMuI738wYFRVj/VhFgfoB2XiKgzvA3q
CFyrOPt1d4H8XXMdAFEYpqMCc2bgcFrKWB+kZMQ715pboAECQQDxqXdbMFaIhB4m
Tg+wWnrUVUtWq1RrKxkZaf46hMKLPpdVrWjzgMY9DvMob8s3wTLcDXFmtBUHD5Zb
SVLgCaqJAkEA1ilOpHsuvXtByIWffB7oo65HQKwN5KegbG/kljE6Gz7rnjtEneFy
DCV02PTL9Jxf2NhlCTeSxwQcgInZpne+EQJADvHxDLWnlFe/WZUYSUq/L+R6fUip
Ntt6eOTiMRJGyb+8MjNAO1bqa5pCFW0cfz02fP9j1PssFby0Cr81Hd/bKQJBALzu
RAqnAVz319jmyQPe4K1FmmZbYwZNOyFutOIrG2/d2k8FSkteEBbXFHYxv5xUN9o9
TSUMedhIsDxVYEWTbYECQFF4KoYq2iBud98xNZoaMUToUNaF9WJGNYqCXyOUMk3A
Hohxi0kf+Nx1/+AVGFEPR1+FJgqFHEAaimgEL53mfU8=
-----END RSA PRIVATE KEY-----

rsa_1024_pub.pem

-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDKKqgnPzneyl2kz7G3yyBgeIKk
UPk17DHfvU0ElcZt1tJpqFgG8L6X4Xb3QyDagxqa/HKMSDrlpRakbzgJTs6kfFCo
fIvNhzQwQNfEnpKdWK7Im7dizPIzIi58ogFHToDAEWfOtpaXMiqY6pknvtVSJHMN
lbDJ3WDkPKj8KOUBGQIDAQAB
-----END PUBLIC KEY-----

PEM 编码的密钥(PEM-encoded)可以通过特定的头部和尾部区分密钥实际格式(format),以下是常见的头部:

  • -----BEGIN RSA PRIVATE KEY----- PKCS#1 RSA 私钥
  • -----BEGIN RSA PUBLIC KEY----- PKCS#1 RSA 公钥
  • -----BEGIN PRIVATE KEY----- PKCS#8 私钥,未加密
  • -----BEGIN PUBLIC KEY----- PKCS#8 公钥
  • -----BEGIN ENCRYPTED PRIVATE KEY----- PKCS#8 私钥,已加密
  • -----BEGIN OPENSSH PRIVATE KEY----- OpenSSH 私钥

rsa_1024_pub.pem 对应 PKCS#8 公钥格式,只需要将文本内容掐头去尾,base64 解码,最后处理成 BufferSource 类型。需要注意在 subtle.importKey() 中 RSA 私钥对应 pkcs8,RSA 公钥对应 spkiimportPublicKey() 的完整实现如下:

/**
 * PEM-encoded RSA public key string to CryptoKey
 * @param {string} pem PEM-encoded RSA public key (SubjectPublicKeyInfo format)
 * @returns {Promise<CryptoKey>}
 */
async function importPublicKey(pem) {
  const pemHeader = `-----BEGIN PUBLIC KEY-----`;
  const pemFooter = `-----END PUBLIC KEY-----`;
  const pemContent = pem
    .replace(pemHeader, '')
    .replace(pemFooter, '')
    .replace(/\n/g, '');
  const publicKeyData = base64ToUint8Array(pemContent);
  const publicKey = await subtle.importKey(
    'spki',
    publicKeyData,
    {
      name: 'RSA-OAEP',
      hash: 'SHA-256'
    },
    false,
    ['encrypt']
  );
  return publicKey;
}

rsa_1024_priv.pem 对应 PKCS#1 RSA 私钥格式,和公钥处理流程类似。但需要注意 subtle.importKey() 只支持 PKCS#8 私钥格式,和 Node 原本的 crypto 模块 API 不一致。

我们需要使用 OpenSSL 的命令行工具,将 PKCS#1 转换成 PKCS#8 格式。

openssl pkcs8 -topk8 -inform PEM -outform PEM -in rsa_1024_priv.pem -out rsa_1024_priv2.pem -nocrypt

执行完后得到 rsa_1024_priv2.pem

-----BEGIN PRIVATE KEY-----
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMoqqCc/Od7KXaTP
sbfLIGB4gqRQ+TXsMd+9TQSVxm3W0mmoWAbwvpfhdvdDINqDGpr8coxIOuWlFqRv
OAlOzqR8UKh8i82HNDBA18Sekp1Yrsibt2LM8jMiLnyiAUdOgMARZ862lpcyKpjq
mSe+1VIkcw2VsMndYOQ8qPwo5QEZAgMBAAECgYBi6PvcN9LYbanjOLp6cacli9Bf
FD7ZYvYDwVotJBoxIylPCE4JF0AojublkrJANmSN73UbrTsjn+G0EbYY17XBZQ0y
Ama48y4jvfzBgVFWP9WEWB+gHZeIqDO8DeoIXKs4+3V3gfxdcx0AURimowJzZuBw
WspYH6RkxDvXmlugAQJBAPGpd1swVoiEHiZOD7BaetRVS1arVGsrGRlp/jqEwos+
l1WtaPOAxj0O8yhvyzfBMtwNcWa0FQcPlltJUuAJqokCQQDWKU6key69e0HIhZ98
HuijrkdArA3kp6Bsb+SWMTobPuueO0Sd4XIMJXTY9Mv0nF/Y2GUJN5LHBByAidmm
d74RAkAO8fEMtaeUV79ZlRhJSr8v5Hp9SKk223p45OIxEkbJv7wyM0A7VuprmkIV
bRx/PTZ8/2PU+ywVvLQKvzUd39spAkEAvO5ECqcBXPfX2ObJA97grUWaZltjBk07
IW604isbb93aTwVKS14QFtcUdjG/nFQ32j1NJQx52EiwPFVgRZNtgQJAUXgqhira
IG533zE1mhoxROhQ1oX1YkY1ioJfI5QyTcAeiHGLSR/43HX/4BUYUQ9HX4UmCoUc
QBqKaAQvneZ9Tw==
-----END PRIVATE KEY-----

可以看到 -----BEGIN PRIVATE KEY----- 正好对应 PKCS#8 私钥格式。subtle.importPublicKey() 的完整实现如下:

/**
 * PEM-encoded RSA private key string to CryptoKey
 * @param {string} pem PEM-encoded RSA private key (PKCS #8 format)
 * @returns {Promise<CryptoKey>}
 */
async function importPrivateKey(pem) {
  const pemHeader = `-----BEGIN PRIVATE KEY-----`;
  const pemFooter = `-----END PRIVATE KEY-----`;
  const pemContent = pem
    .replace(pemHeader, '')
    .replace(pemFooter, '')
    .replace(/\n/g, '');
  const privateKeyData = base64ToUint8Array(pemContent);
  const privateKey = await subtle.importKey(
    'pkcs8',
    privateKeyData,
    {
      name: 'RSA-OAEP',
      hash: 'SHA-256'
    },
    false,
    ['decrypt']
  );
  return privateKey;
}

到此可以顺利获取到 webcrypto API 可用的公钥和私钥。

subtle.encrypt()

const encrypted = await subtle.encrypt(algorithm, key, data);
  • algorithm: 字典对象,包含了加密算法和需要的额外参数
  • key: CryptoKey 对象,对应 subtle.importKey() 返回值
  • data: BufferSource 类型,待加密明文

具体使用参考 SubtleCrypto.encrypt() 文档。

RSA 加密完整实现如下:

/**
 * RSA encrypt
 * @param {string} plaintext
 * @param {string} pk PEM-encoded RSA public key
 * @returns {Promise<string>}
 */
async function encrypt(plaintext, pk) {
  const publicKey = await importPublicKey(pk);
  const encryptedBuffer = await subtle.encrypt(
    {
      name: 'RSA-OAEP'
    },
    publicKey,
    new TextEncoder().encode(plaintext)
  );
  const encrypted = arrayBufferToBase64(encryptedBuffer);
  return encrypted;
}

subtle.decrypt()

解密函数的参数和加密类似,具体使用参考 SubtleCrypto.decrypt() 文档。

const decrypted = await subtle.decrypt(algorithm, key, data);

RSA 解密完整实现如下:

/**
 * RSA decrypt
 * @param {string} ciphertext
 * @param {string} sk PEM-encoded RSA private key
 * @returns {Promise<string>}
 */
async function decrypt(ciphertext, sk) {
  const privateKey = await importPrivateKey(sk);
  const decryptedBuffer = await subtle.decrypt(
    {
      name: 'RSA-OAEP'
    },
    privateKey,
    base64ToUint8Array(ciphertext)
  );
  const decrypted = new TextDecoder().decode(decryptedBuffer);
  return decrypted;
}

验证

在验证前,补齐两个简化 base64、类型数组转换的工具函数 base64ToUint8Array()arrayBufferToBase64()

/**
 * base64 string to Uint8Array
 * @param {string} base64
 */
function base64ToUint8Array(base64) {
  const byteString = atob(base64);
  const bytes = new Uint8Array(byteString.length);
  for (let i = 0; i < byteString.length; i++) {
    bytes[i] = byteString.charCodeAt(i);
  }
  return bytes;
}

/**
 * ArrayBuffer to base64 string
 * @param {ArrayBuffer} buffer
 * @returns
 */
function arrayBufferToBase64(buffer) {
  let binary = '';
  const bytes = new Uint8Array(buffer);
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}

验证:

const plaintext = 'keqingrong@outlook.com';
/** @type {Crypto} */
const webcrypto =
  typeof module === 'object' && typeof require === 'function'
    ? require('crypto').webcrypto
    : window.crypto;
const { subtle } = webcrypto;

(async () => {
  const encrypted = await encrypt(plaintext, publicKey);
  console.log(encrypted);

  const decrypted = await decrypt(encrypted, privateKey);
  console.log(decrypted);
})();

相关链接