From ad0b1dd36679e05ec563aa623d6b643487203024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Nie=C3=9Fen?= Date: Sat, 17 Aug 2019 12:22:59 +0200 Subject: [PATCH] Add ECDSA prototype --- lib/algorithms.js | 3 + lib/algorithms/ecdsa.js | 208 +++++++++++++++++++++++++++++++++++++++ test/algorithms/ecdsa.js | 108 ++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 lib/algorithms/ecdsa.js create mode 100644 test/algorithms/ecdsa.js diff --git a/lib/algorithms.js b/lib/algorithms.js index 64bd7a1..02e4f04 100644 --- a/lib/algorithms.js +++ b/lib/algorithms.js @@ -1,6 +1,7 @@ 'use strict'; const { AES_CTR, AES_CBC, AES_GCM, AES_KW } = require('./algorithms/aes'); +const { ECDSA } = require('./algorithms/ecdsa'); const { HKDF } = require('./algorithms/hkdf'); const { HMAC } = require('./algorithms/hmac'); const { PBKDF2 } = require('./algorithms/pbkdf2'); @@ -14,6 +15,8 @@ const algorithms = [ AES_GCM, AES_KW, + ECDSA, + HKDF, HMAC, diff --git a/lib/algorithms/ecdsa.js b/lib/algorithms/ecdsa.js new file mode 100644 index 0000000..025243f --- /dev/null +++ b/lib/algorithms/ecdsa.js @@ -0,0 +1,208 @@ +'use strict'; + +const crypto = require('crypto'); +const { promisify } = require('util'); + +const { + DataError, + InvalidAccessError, + NotSupportedError, + OperationError +} = require('../errors'); +const { kKeyMaterial, CryptoKey } = require('../key'); +const { limitUsages, opensslHashFunctionName, toBuffer } = require('../util'); + +const generateKeyPair = promisify(crypto.generateKeyPair); + +const curveBasePointOrderSizes = { + 'P-256': 32, + 'P-384': 48, + 'P-521': 66 +}; + +const byte = (b) => Buffer.from([b]); + +function convertSignatureToASN1(signature, n) { + if (signature.length !== 2 * n) + throw new OperationError(); + + const r = signature.slice(0, n); + const s = signature.slice(n); + + function encodeLength(len) { + // Short form. + if (len < 128) + return byte(len); + + // Long form. + const buffer = Buffer.alloc(5); + buffer.writeUInt32BE(len, 1); + let offset = 1; + while (buffer[offset] === 0) + offset++; + buffer[offset - 1] = 0x80 | (5 - offset); + return buffer.slice(offset - 1); + } + + function encodeUnsignedInteger(integer) { + // ASN.1 integers are signed, so in order to encode unsigned integers, we + // need to make sure that the MSB is not set. + if (integer[0] & 0x80) { + return Buffer.concat([ + byte(0x02), + encodeLength(integer.length + 1), + byte(0x00), integer + ]); + } else { + // If the MSB is not set, enforce a minimal representation of the integer. + let i = 0; + while (integer[i] === 0 && (integer[i + 1] & 0x80) === 0) + i++; + return Buffer.concat([ + byte(0x02), + encodeLength(integer.length - i), + integer.slice(i) + ]); + } + } + + const seq = Buffer.concat([ + encodeUnsignedInteger(r), + encodeUnsignedInteger(s) + ]); + + return Buffer.concat([byte(0x30), encodeLength(seq.length), seq]); +} + +function convertSignatureFromASN1(signature, n) { + let offset = 2; + if (signature[1] & 0x80) + offset += signature[1] & ~0x80; + + function decodeUnsignedInteger() { + let length = signature[offset + 1]; + offset += 2; + if (length & 0x80) { + // Long form. + const nBytes = length & ~0x80; + length = 0; + for (let i = 0; i < nBytes; i++) + length = (length << 8) | signature[offset + 2 + i]; + offset += nBytes; + } + + // There may be exactly one leading zero (if the next byte's MSB is set). + if (signature[offset] === 0) { + offset++; + length--; + } + + const result = signature.slice(offset, offset + length); + offset += length; + return result; + } + + const r = decodeUnsignedInteger(); + const s = decodeUnsignedInteger(); + + const result = Buffer.alloc(2 * n, 0); + r.copy(result, n - r.length); + s.copy(result, 2 * n - s.length); + return result; +} + +// Spec: https://www.w3.org/TR/WebCryptoAPI/#ecdsa +module.exports.ECDSA = { + name: 'ECDSA', + + async generateKey(algorithm, extractable, usages) { + limitUsages(usages, ['sign', 'verify']); + const privateUsages = usages.includes('sign') ? ['sign'] : []; + const publicUsages = usages.includes('verify') ? ['verify'] : []; + + const { namedCurve } = algorithm; + if (!curveBasePointOrderSizes[namedCurve]) + throw new NotSupportedError(); + + const { privateKey, publicKey } = await generateKeyPair('ec', { + namedCurve + }); + + const alg = { + name: this.name, + namedCurve + }; + + return { + privateKey: new CryptoKey('private', alg, extractable, privateUsages, + privateKey), + publicKey: new CryptoKey('public', alg, extractable, publicUsages, + publicKey) + }; + }, + + importKey(keyFormat, keyData, params, extractable, keyUsages) { + const { namedCurve } = params; + + const opts = { + key: toBuffer(keyData), + format: 'der', + type: keyFormat + }; + + let key; + if (keyFormat === 'spki') { + limitUsages(keyUsages, ['verify']); + key = crypto.createPublicKey(opts); + } else if (keyFormat === 'pkcs8') { + limitUsages(keyUsages, ['sign']); + key = crypto.createPrivateKey(opts); + } else { + throw new NotSupportedError(); + } + + if (key.asymmetricKeyType !== 'ec') + throw new DataError(); + + return new CryptoKey(key.type, { name: this.name, namedCurve }, + extractable, keyUsages, key); + }, + + exportKey(format, key) { + if (format !== 'spki' && format !== 'pkcs8') + throw new NotSupportedError(); + + if (format === 'spki' && key.type !== 'public' || + format === 'pkcs8' && key.type !== 'private') + throw new InvalidAccessError(); + + return key[kKeyMaterial].export({ + format: 'der', + type: format + }); + }, + + sign(algorithm, key, data) { + if (key.type !== 'private') + throw new InvalidAccessError(); + + const { hash } = algorithm; + const hashFn = opensslHashFunctionName(hash); + + const asn1Sig = crypto.sign(hashFn, toBuffer(data), key[kKeyMaterial]); + const n = curveBasePointOrderSizes[key.algorithm.namedCurve]; + return convertSignatureFromASN1(asn1Sig, n); + }, + + verify(algorithm, key, signature, data) { + if (key.type !== 'public') + throw new InvalidAccessError(); + + const n = curveBasePointOrderSizes[key.algorithm.namedCurve]; + signature = convertSignatureToASN1(toBuffer(signature), n); + + const { hash } = algorithm; + const hashFn = opensslHashFunctionName(hash); + return crypto.verify(hashFn, data, key[kKeyMaterial], signature); + } +}; diff --git a/test/algorithms/ecdsa.js b/test/algorithms/ecdsa.js new file mode 100644 index 0000000..c6dad51 --- /dev/null +++ b/test/algorithms/ecdsa.js @@ -0,0 +1,108 @@ +'use strict'; + +const assert = require('assert'); +const { randomBytes } = require('crypto'); + +const { subtle } = require('../../'); + +// Disables timeouts for tests that involve key pair generation. +const NO_TIMEOUT = 0; + +describe('ECDSA', () => { + it('should generate, import and export keys', async () => { + const { publicKey, privateKey } = await subtle.generateKey({ + name: 'ECDSA', + namedCurve: 'P-256' + }, true, ['sign', 'verify']); + + assert.strictEqual(publicKey.type, 'public'); + assert.strictEqual(privateKey.type, 'private'); + for (const key of [publicKey, privateKey]) { + assert.strictEqual(key.algorithm.name, 'ECDSA'); + assert.strictEqual(key.algorithm.namedCurve, 'P-256'); + } + + const expPublicKey = await subtle.exportKey('spki', publicKey); + assert(Buffer.isBuffer(expPublicKey)); + const expPrivateKey = await subtle.exportKey('pkcs8', privateKey); + assert(Buffer.isBuffer(expPrivateKey)); + + const impPublicKey = await subtle.importKey('spki', expPublicKey, { + name: 'ECDSA', + hash: 'SHA-384' + }, true, ['verify']); + const impPrivateKey = await subtle.importKey('pkcs8', expPrivateKey, { + name: 'ECDSA', + hash: 'SHA-384' + }, true, ['sign']); + + assert.deepStrictEqual(await subtle.exportKey('spki', impPublicKey), + expPublicKey); + assert.deepStrictEqual(await subtle.exportKey('pkcs8', impPrivateKey), + expPrivateKey); + }) + .timeout(NO_TIMEOUT); + + it('should sign and verify data', async () => { + async function test(namedCurve, signatureLength) { + const { privateKey, publicKey } = await subtle.generateKey({ + name: 'ECDSA', + namedCurve + }, false, ['sign', 'verify']); + + const data = randomBytes(200); + for (const hash of ['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512']) { + const signature = await subtle.sign({ + name: 'ECDSA', + hash + }, privateKey, data); + assert.strictEqual(signature.length, signatureLength); + + let ok = await subtle.verify({ + name: 'ECDSA', + hash + }, publicKey, signature, data); + assert.strictEqual(ok, true); + + signature[Math.floor(signature.length * Math.random())] ^= 1; + + ok = await subtle.verify({ + name: 'ECDSA', + hash + }, publicKey, signature, data); + assert.strictEqual(ok, false); + } + } + + return Promise.all([ + test('P-256', 2 * 32), + test('P-384', 2 * 48), + test('P-521', 2 * 66) + ]); + }) + .timeout(NO_TIMEOUT); + + it('should verify externally signed data', async () => { + const publicKeyData = '3076301006072a8648ce3d020106052b810400220362000476' + + 'ece47b2ab001a109f741f9fcd7fbe9cbfd3b6abbac626bd1fb' + + 'eca18fc700adc612339a732ee4621a129dfdc22940011d17ff' + + '94a06e8aa55b6a62c3014032aeefc099d455921a0072d26a45' + + 'b787bd327beb2846f70657268d2485423720be4b'; + const publicKeyBuffer = Buffer.from(publicKeyData, 'hex'); + const publicKey = await subtle.importKey('spki', publicKeyBuffer, { + name: 'ECDSA', + namedCurve: 'P-384' + }, false, ['verify']); + + const data = Buffer.from('0a0b0c0d0e0f', 'hex'); + const signatureData = '5ec17d2611a28d72e448826ba3b3fb7ef041275c5727b05d38' + + '8fb435b2897a9047d9f02ade37908e6f81e1419fd671978881' + + '9887f0fd830dd02ecc66051e14512fdba0f51fb3e58629210d' + + '136a48944f411649874cfb29498161c6327a7d4c3d'; + const signature = Buffer.from(signatureData, 'hex'); + + const ok = await subtle.verify({ name: 'ECDSA', hash: 'SHA-512' }, + publicKey, signature, data); + assert.strictEqual(ok, true); + }); +});