使用 WebCrypto API 导入 pem 格式 public RSA 密钥时出现 DOMException

DOMException when importing a pem formatted public RSA key using the WebCrypto API

我可能遗漏了一些阻止浏览器将 public 密钥文件导入 CryptoKey 对象的安全问题,但我看不到文档中提到的任何内容。浏览器产生一个没有附加消息的“DOMException”。

我正在使用帮助程序库“OpenCrypto”来简化密钥管理过程,但是当我直接使用 WebCrypto APIs 时收到同样的错误(提示我使用帮助程序库,以确保不只是我滥用 API).

async importKey() {
    try {
        let pem = `-----BEGIN PUBLIC KEY-----
        MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq3GvsQ5+vT+lkuGb7PP6
        slV5mNyOAQo5rhInuDMFkyJOnwIDLzOQ7tLe4ApDPt2CmuRG+qpMaul+IYPBk6Ts
        9YPdvvVh5lohiDRN7ny3Sd5uwUy4Ea/NkY62lui4zDFnUDMH8pAUcJWQW4zKloRI
        k2EsXR5A5dqOq4wv2+I76Ax9lK2qYkQBZ8ZqeePPMYU1N0lETzCgDW/FqQEk6m81
        2c8LnF2bhnrjFJ2k0lTDVx4TwvEUOEg6TbFah+PNe8CFN/cJsHMxlr4StV6nwpZu
        n62YSXo9KskLmSRNhGKUS+oNEzTeLRyNfpZb3WQFOjqlgqJFW1xp1KfEdqFk+37z
        HwIDAQAB
        -----END PUBLIC KEY-----`;

        let key = await this.#crypt.pemPublicToCrypto(pem, { name: 'RSA-PSS', usages: ['verify'], isExtractable: true });
    } catch (error) {
        console.log(`Error importing key`, error);
    }
}

public密钥是使用Node JS实现的WebCryptoAPI(crypto.webcrypto)生成的,然后我简单的把内容复制粘贴到这个函数里试试. 运行 节点中的这个函数导入正常。 运行 它在浏览器中产生 DOMException。

以下是 OpenCrypto 来源的相关代码:

const cryptoLib = window.crypto || window.msCrypto
const cryptoApi = cryptoLib.subtle || cryptoLib.webkitSubtle

//... other code removed for clarity


  /**
   * Method that converts asymmetric public key from PEM to CryptoKey format
   * @param {String} publicKey default: "undefined"
   * @param {Object} options default: depends on algorithm below
   * -- ECDH: { name: 'ECDH', usages: [], isExtractable: true }
   * -- ECDSA: { name: 'ECDSA', usages: ['verify'], isExtractable: true }
   * -- RSA-OAEP: { name: 'RSA-OAEP', hash: { name: 'SHA-512' }, usages: ['encrypt', 'wrapKey'], isExtractable: true }
   * -- RSA-PSS: { name: 'RSA-PSS', hash: { name: 'SHA-512' }, usages: ['verify'], isExtractable: true }
   */
  pemPublicToCrypto (pem, options) {
    const self = this

    if (typeof options === 'undefined') {
      options = {}
    }

    options.isExtractable = (typeof options.isExtractable !== 'undefined') ? options.isExtractable : true

    return new Promise((resolve, reject) => {
      if (typeof pem !== 'string') {
        throw new TypeError('Expected input of pem to be a String')
      }

      if (typeof options.isExtractable !== 'boolean') {
        throw new TypeError('Expected input of options.isExtractable to be a Boolean')
      }

      pem = pem.replace('-----BEGIN PUBLIC KEY-----', '')
      pem = pem.replace('-----END PUBLIC KEY-----', '')

      const b64 = self.removeLines(pem)
      const arrayBuffer = self.base64ToArrayBuffer(b64)
      const hex = self.arrayBufferToHexString(arrayBuffer)
      const keyOptions = {}

      if (hex.includes(EC_OID)) {
        options.name = (typeof options.name !== 'undefined') ? options.name : 'ECDH'

        if (typeof options.name !== 'string') {
          throw new TypeError('Expected input of options.name to be a String')
        }

        let curve = null
        if (hex.includes(P256_OID)) {
          curve = 'P-256'
        } else if (hex.includes(P384_OID)) {
          curve = 'P-384'
        } else if (hex.includes(P521_OID)) {
          curve = 'P-521'
        }

        if (options.name === 'ECDH') {
          options.usages = (typeof options.usages !== 'undefined') ? options.usages : []
        } else if (options.name === 'ECDSA') {
          options.usages = (typeof options.usages !== 'undefined') ? options.usages : ['verify']
        } else {
          throw new TypeError('Expected input of options.name is not a valid algorithm name')
        }

        if (typeof options.usages !== 'object') {
          throw new TypeError('Expected input of options.usages to be an Array')
        }

        keyOptions.name = options.name
        keyOptions.namedCurve = curve
      } else if (hex.includes(RSA_OID)) {
        options.name = (typeof options.name !== 'undefined') ? options.name : 'RSA-OAEP'
        options.hash = (typeof options.hash !== 'undefined') ? options.hash : 'SHA-512'

        if (typeof options.name !== 'string') {
          throw new TypeError('Expected input of options.name to be a String')
        }

        if (typeof options.hash !== 'string') {
          throw new TypeError('Expected input of options.hash to be a String')
        }

        if (options.name === 'RSA-OAEP') {
          options.usages = (typeof options.usages !== 'undefined') ? options.usages : ['encrypt', 'wrapKey']
        } else if (options.name === 'RSA-PSS') {
          options.usages = (typeof options.usages !== 'undefined') ? options.usages : ['verify']
        } else {
          throw new TypeError('Expected input of options.name is not a valid algorithm name')
        }

        if (typeof options.usages !== 'object') {
          throw new TypeError('Expected input of options.usages to be an Array')
        }

        keyOptions.name = options.name
        keyOptions.hash = {}
        keyOptions.hash.name = options.hash
      } else {
        throw new TypeError('Expected input of pem is not a valid public key')
      }

      cryptoApi.importKey(
        'spki',
        arrayBuffer,
        keyOptions,
        options.isExtractable,
        options.usages
      ).then(importedPublicKey => {
        resolve(importedPublicKey)
      }).catch(err => {
        reject(err)
      })
    })
  }

错误是由 SubtleCrypto.importKey() 函数引发的。

我已经尝试过 ECDSA 和 RSA-PSS 密钥以防两者不兼容,但两者都会产生相同的错误。

如有任何帮助,我将不胜感激。

谢谢

问题仅仅是因为键的缩进造成的。

这会导致从 PEM 到 DER 的转换过程中出现损坏:在 pemPublicToCrypto() 中,行 const b64 = self.removeLines(pem) 中的换行符被删除。但是,这不会删除可能的空格或制表符,因此在随后转换为带有 const arrayBuffer = self.base64ToArrayBuffer(b64)ArrayBuffer 期间,它们会包含在数据中,这会损坏数据。

所以解决方案是省略缩进或删除缩进,例如pem.replace(/(\s)[ \t]+/g, ''):

async function importKey() {
  var c = new OpenCrypto();
  try {  

    // 
    // Without indentation: Works
    //
    var pem = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq3GvsQ5+vT+lkuGb7PP6
slV5mNyOAQo5rhInuDMFkyJOnwIDLzOQ7tLe4ApDPt2CmuRG+qpMaul+IYPBk6Ts
9YPdvvVh5lohiDRN7ny3Sd5uwUy4Ea/NkY62lui4zDFnUDMH8pAUcJWQW4zKloRI
k2EsXR5A5dqOq4wv2+I76Ax9lK2qYkQBZ8ZqeePPMYU1N0lETzCgDW/FqQEk6m81
2c8LnF2bhnrjFJ2k0lTDVx4TwvEUOEg6TbFah+PNe8CFN/cJsHMxlr4StV6nwpZu
n62YSXo9KskLmSRNhGKUS+oNEzTeLRyNfpZb3WQFOjqlgqJFW1xp1KfEdqFk+37z
HwIDAQAB
-----END PUBLIC KEY-----`;
    console.log("Key without indentation:") 
    var key = await c.pemPublicToCrypto(pem, { name: 'RSA-PSS', usages: ['verify'], isExtractable: true });
    console.log(key);  
    
    // 
    // With indentation and space/tab removal: Works
    //
    var pem = `-----BEGIN PUBLIC KEY-----
    MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq3GvsQ5+vT+lkuGb7PP6
    slV5mNyOAQo5rhInuDMFkyJOnwIDLzOQ7tLe4ApDPt2CmuRG+qpMaul+IYPBk6Ts
    9YPdvvVh5lohiDRN7ny3Sd5uwUy4Ea/NkY62lui4zDFnUDMH8pAUcJWQW4zKloRI
    k2EsXR5A5dqOq4wv2+I76Ax9lK2qYkQBZ8ZqeePPMYU1N0lETzCgDW/FqQEk6m81
    2c8LnF2bhnrjFJ2k0lTDVx4TwvEUOEg6TbFah+PNe8CFN/cJsHMxlr4StV6nwpZu
    n62YSXo9KskLmSRNhGKUS+oNEzTeLRyNfpZb3WQFOjqlgqJFW1xp1KfEdqFk+37z
    HwIDAQAB
    -----END PUBLIC KEY-----`;
    console.log("Key with indentation and space/tab removal:") 
    var key = await c.pemPublicToCrypto(pem.replace(/(\s)[ \t]+/g, ''), { name: 'RSA-PSS', usages: ['verify'], isExtractable: true });
    console.log(key);  
    
    //
    // With indentation: Doesn't work
    //
    var pem = `-----BEGIN PUBLIC KEY-----
    MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq3GvsQ5+vT+lkuGb7PP6
    slV5mNyOAQo5rhInuDMFkyJOnwIDLzOQ7tLe4ApDPt2CmuRG+qpMaul+IYPBk6Ts
    9YPdvvVh5lohiDRN7ny3Sd5uwUy4Ea/NkY62lui4zDFnUDMH8pAUcJWQW4zKloRI
    k2EsXR5A5dqOq4wv2+I76Ax9lK2qYkQBZ8ZqeePPMYU1N0lETzCgDW/FqQEk6m81
    2c8LnF2bhnrjFJ2k0lTDVx4TwvEUOEg6TbFah+PNe8CFN/cJsHMxlr4StV6nwpZu
    n62YSXo9KskLmSRNhGKUS+oNEzTeLRyNfpZb3WQFOjqlgqJFW1xp1KfEdqFk+37z
    HwIDAQAB
    -----END PUBLIC KEY-----`;

    console.log("Key with indentation:");  
    var key = await c.pemPublicToCrypto(pem, { name: 'RSA-PSS', usages: ['verify'], isExtractable: true });
    console.log(key);  
    
  } catch (error) {
    console.log(`Error importing key`, error);
  }
}

// --------------------------------------------------------------------------------------------------

/**
 *
 * Copyright (c) 2016 SafeBash
 * Cryptography consultant: Andrew Kozlik, Ph.D.
 *
 */

/**
 * MIT License
 *
 * Copyright (c) 2016 SafeBash
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
 * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
 * to whom the Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
 * Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
 * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
 * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */
const cryptoLib = window.crypto || window.msCrypto
const cryptoApi = cryptoLib.subtle || cryptoLib.webkitSubtle
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
const lookup = new Uint8Array(256)

const RSA_OID = '06092a864886f70d010101'
const EC_OID = '06072a8648ce3d0201'
const P256_OID = '06082a8648ce3d030107'
const P384_OID = '06052b81040022'
const P521_OID = '06052b81040023'

class OpenCrypto {
  constructor () {
    for (let i = 0; i < chars.length; i++) {
      lookup[chars.charCodeAt(i)] = i
    }
  }

  /**
   * BEGIN
   * base64-arraybuffer
   * GitHub @niklasvh
   * Copyright (c) 2012 Niklas von Hertzen
   * MIT License
   */
  decodeAb (base64) {
    const len = base64.length
    let bufferLength = base64.length * 0.75
    let p = 0
    let encoded1
    let encoded2
    let encoded3
    let encoded4

    if (base64[base64.length - 1] === '=') {
      bufferLength--
      if (base64[base64.length - 2] === '=') {
        bufferLength--
      }
    }

    const arrayBuffer = new ArrayBuffer(bufferLength)
    let bytes = new Uint8Array(arrayBuffer)

    for (let i = 0; i < len; i += 4) {
      encoded1 = lookup[base64.charCodeAt(i)]
      encoded2 = lookup[base64.charCodeAt(i + 1)]
      encoded3 = lookup[base64.charCodeAt(i + 2)]
      encoded4 = lookup[base64.charCodeAt(i + 3)]

      bytes[p++] = (encoded1 << 2) | (encoded2 >> 4)
      bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2)
      bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63)
    }

    return arrayBuffer
  }
  /**
   * END
   * base64-arraybuffer
   */

  /**
   * Method for encoding ArrayBuffer to hexadecimal String
   */
  arrayBufferToHexString (arrayBuffer) {
    if (typeof arrayBuffer !== 'object') {
      throw new TypeError('Expected input of arrayBuffer to be an ArrayBuffer Object')
    }

    const byteArray = new Uint8Array(arrayBuffer)
    let hexString = ''
    let nextHexByte

    for (let i = 0; i < byteArray.byteLength; i++) {
      nextHexByte = byteArray[i].toString(16)

      if (nextHexByte.length < 2) {
        nextHexByte = '0' + nextHexByte
      }

      hexString += nextHexByte
    }

    return hexString
  }

  /**
   * Method for decoding base64 String to ArrayBuffer
   */
  base64ToArrayBuffer (b64) {
    if (typeof b64 !== 'string') {
      throw new TypeError('Expected input of b64 to be a Base64 String')
    }

    return this.decodeAb(b64)
  }

  /**
   * Method that removes lines from PEM encoded key
   */
  removeLines (str) {
    return str.replace(/\r?\n|\r/g, '')
  }

  /**
   * Method that converts asymmetric public key from PEM to CryptoKey format
   * @param {String} publicKey default: "undefined"
   * @param {Object} options default: depends on algorithm below
   * -- ECDH: { name: 'ECDH', usages: [], isExtractable: true }
   * -- ECDSA: { name: 'ECDSA', usages: ['verify'], isExtractable: true }
   * -- RSA-OAEP: { name: 'RSA-OAEP', hash: { name: 'SHA-512' }, usages: ['encrypt', 'wrapKey'], isExtractable: true }
   * -- RSA-PSS: { name: 'RSA-PSS', hash: { name: 'SHA-512' }, usages: ['verify'], isExtractable: true }
   */
  pemPublicToCrypto (pem, options) {
    const self = this

    if (typeof options === 'undefined') {
      options = {}
    }

    options.isExtractable = (typeof options.isExtractable !== 'undefined') ? options.isExtractable : true

    return new Promise((resolve, reject) => {
      if (typeof pem !== 'string') {
        throw new TypeError('Expected input of pem to be a String')
      }

      if (typeof options.isExtractable !== 'boolean') {
        throw new TypeError('Expected input of options.isExtractable to be a Boolean')
      }

      pem = pem.replace('-----BEGIN PUBLIC KEY-----', '')
      pem = pem.replace('-----END PUBLIC KEY-----', '')

      const b64 = self.removeLines(pem)
      const arrayBuffer = self.base64ToArrayBuffer(b64)
      const hex = self.arrayBufferToHexString(arrayBuffer)
      const keyOptions = {}

      if (hex.includes(EC_OID)) {
        options.name = (typeof options.name !== 'undefined') ? options.name : 'ECDH'

        if (typeof options.name !== 'string') {
          throw new TypeError('Expected input of options.name to be a String')
        }

        let curve = null
        if (hex.includes(P256_OID)) {
          curve = 'P-256'
        } else if (hex.includes(P384_OID)) {
          curve = 'P-384'
        } else if (hex.includes(P521_OID)) {
          curve = 'P-521'
        }

        if (options.name === 'ECDH') {
          options.usages = (typeof options.usages !== 'undefined') ? options.usages : []
        } else if (options.name === 'ECDSA') {
          options.usages = (typeof options.usages !== 'undefined') ? options.usages : ['verify']
        } else {
          throw new TypeError('Expected input of options.name is not a valid algorithm name')
        }

        if (typeof options.usages !== 'object') {
          throw new TypeError('Expected input of options.usages to be an Array')
        }

        keyOptions.name = options.name
        keyOptions.namedCurve = curve
      } else if (hex.includes(RSA_OID)) {
        options.name = (typeof options.name !== 'undefined') ? options.name : 'RSA-OAEP'
        options.hash = (typeof options.hash !== 'undefined') ? options.hash : 'SHA-512'

        if (typeof options.name !== 'string') {
          throw new TypeError('Expected input of options.name to be a String')
        }

        if (typeof options.hash !== 'string') {
          throw new TypeError('Expected input of options.hash to be a String')
        }

        if (options.name === 'RSA-OAEP') {
          options.usages = (typeof options.usages !== 'undefined') ? options.usages : ['encrypt', 'wrapKey']
        } else if (options.name === 'RSA-PSS') {
          options.usages = (typeof options.usages !== 'undefined') ? options.usages : ['verify']
        } else {
          throw new TypeError('Expected input of options.name is not a valid algorithm name')
        }

        if (typeof options.usages !== 'object') {
          throw new TypeError('Expected input of options.usages to be an Array')
        }

        keyOptions.name = options.name
        keyOptions.hash = {}
        keyOptions.hash.name = options.hash
      } else {
        throw new TypeError('Expected input of pem is not a valid public key')
      }

      cryptoApi.importKey(
        'spki',
        arrayBuffer,
        keyOptions,
        options.isExtractable,
        options.usages
      ).then(importedPublicKey => {
        resolve(importedPublicKey)
      }).catch(err => {
        reject(err)
      })
    })
  }
}

// --------------------------------------------------------------------------------------------------
  
(async () => {
    await importKey();
})();