From 79af30d0d2f5b1f957c12009f59c06a89e9d2c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Nie=C3=9Fen?= Date: Wed, 11 Mar 2020 22:09:08 -0300 Subject: [PATCH] Add JWK for RSA --- lib/algorithms/ecdsa.js | 89 ++++------------------ lib/algorithms/rsa.js | 144 +++++++++++++++++++++++++++++++++--- lib/util.js | 137 ++++++++++++++++++++++++++++++++-- test/unit/algorithms/rsa.js | 54 ++++++++++++++ test/unit/util.js | 96 ++++++++++++++++++++++++ 5 files changed, 427 insertions(+), 93 deletions(-) create mode 100644 test/unit/util.js diff --git a/lib/algorithms/ecdsa.js b/lib/algorithms/ecdsa.js index c5405b4..820a887 100644 --- a/lib/algorithms/ecdsa.js +++ b/lib/algorithms/ecdsa.js @@ -14,12 +14,15 @@ const { requireDOMString } = require('../idl'); const { kKeyMaterial, CryptoKey } = require('../key'); -const { limitUsages, opensslHashFunctionName } = require('../util'); +const { + limitUsages, + opensslHashFunctionName, + Asn1SequenceDecoder, + Asn1SequenceEncoder +} = require('../util'); const generateKeyPair = promisify(crypto.generateKeyPair); -const byte = (b) => Buffer.from([b]); - function convertSignatureToASN1(signature, n) { if (signature.length !== 2 * n) return undefined; @@ -27,81 +30,17 @@ function convertSignatureToASN1(signature, n) { 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]); + const enc = new Asn1SequenceEncoder(); + enc.unsignedInteger(r); + enc.unsignedInteger(s); + return enc.end(); } 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 dec = new Asn1SequenceDecoder(signature); + const r = dec.unsignedInteger(); + const s = dec.unsignedInteger(); + dec.end(); const result = Buffer.alloc(2 * n, 0); r.copy(result, n - r.length); diff --git a/lib/algorithms/rsa.js b/lib/algorithms/rsa.js index 4860e6b..929abf8 100644 --- a/lib/algorithms/rsa.js +++ b/lib/algorithms/rsa.js @@ -5,6 +5,7 @@ const { promisify } = require('util'); const algorithms = require('../algorithms'); const { + DataError, InvalidAccessError, NotSupportedError, OperationError @@ -14,7 +15,14 @@ const { toUnsignedLongEnforceRange, bufferFromBufferSource } = require('../idl'); -const { limitUsages, opensslHashFunctionName } = require('../util'); +const { + decodeBase64Url, + encodeBase64Url, + limitUsages, + opensslHashFunctionName, + Asn1SequenceDecoder, + Asn1SequenceEncoder +} = require('../util'); const generateKeyPair = promisify(crypto.generateKeyPair); @@ -38,6 +46,89 @@ function uint32ToUint8Array(integer) { return result.slice(i + 1); } +function jwkToDer(jwk, jwkHashMapping) { + if (jwk.kty !== 'RSA') + throw new DataError(); + + if (jwk.use !== undefined && jwk.use !== 'sig') + throw new DataError(); + + let hash; + if (jwk.alg !== undefined) { + hash = jwkHashMapping[jwk.alg]; + if (hash === undefined) + throw new DataError(); + } + + const enc = new Asn1SequenceEncoder(); + + if (jwk.d === undefined) { + enc.unsignedInteger(decodeBase64Url(jwk.n)); + enc.unsignedInteger(decodeBase64Url(jwk.e)); + } else { + enc.unsignedInteger(Buffer.from([0])); + enc.unsignedInteger(decodeBase64Url(jwk.n)); + enc.unsignedInteger(decodeBase64Url(jwk.e)); + enc.unsignedInteger(decodeBase64Url(jwk.d)); + enc.unsignedInteger(decodeBase64Url(jwk.p)); + enc.unsignedInteger(decodeBase64Url(jwk.q)); + enc.unsignedInteger(decodeBase64Url(jwk.dp)); + enc.unsignedInteger(decodeBase64Url(jwk.dq)); + enc.unsignedInteger(decodeBase64Url(jwk.qi)); + } + + return enc.end(); +} + +const kInverted = Symbol('kInverted'); +function swapKeysAndValues(obj) { + if (obj[kInverted]) + return obj[kInverted]; + const entries = Object.entries(obj).map(([k, v]) => [v, k]); + return obj[kInverted] = Object.fromEntries(entries); +} + +function derToJwk(cryptoKey, jwkHashMapping) { + const der = cryptoKey[kKeyMaterial].export({ + format: 'der', + type: 'pkcs1' + }); + + const dec = new Asn1SequenceDecoder(der); + + if (cryptoKey.type === 'private') { + dec.unsignedInteger(); // TODO: Don't ignore this + } + + const n = encodeBase64Url(dec.unsignedInteger()); + const e = encodeBase64Url(dec.unsignedInteger()); + let keyProps; + + if (cryptoKey.type === 'public') { + keyProps = { n, e }; + } else { + const d = encodeBase64Url(dec.unsignedInteger()); + const p = encodeBase64Url(dec.unsignedInteger()); + const q = encodeBase64Url(dec.unsignedInteger()); + const dp = encodeBase64Url(dec.unsignedInteger()); + const dq = encodeBase64Url(dec.unsignedInteger()); + const qi = encodeBase64Url(dec.unsignedInteger()); + keyProps = { n, e, d, p, q, dp, dq, qi }; + } + + dec.end(); + + const alg = swapKeysAndValues(jwkHashMapping)[cryptoKey.algorithm.hash.name]; + + return { + kty: 'RSA', + alg, + key_ops: [...cryptoKey.usages], + ext: cryptoKey.extractable, + ...keyProps + }; +} + const rsaBase = { async generateKey(algorithm, extractable, usages) { let privateUsages, publicUsages; @@ -91,28 +182,38 @@ const rsaBase = { name: algorithms.getAlgorithm(hashAlg, 'digest').name }; - const opts = { - key: bufferFromBufferSource(keyData), - format: 'der', - type: keyFormat - }; + const isPublic = keyFormat === 'spki' || + (keyFormat === 'jwk' && keyData.d === undefined); - let key; - if (keyFormat === 'spki') { + if (isPublic) limitUsages(keyUsages, this.sign ? ['verify'] : ['encrypt', 'wrapKey']); - key = crypto.createPublicKey(opts); - } else if (keyFormat === 'pkcs8') { + else limitUsages(keyUsages, this.sign ? ['sign'] : ['decrypt', 'unwrapKey']); - key = crypto.createPrivateKey(opts); + + if (keyFormat === 'spki' || keyFormat === 'pkcs8') { + keyData = bufferFromBufferSource(keyData); + } else if (keyFormat === 'jwk') { + keyData = jwkToDer(keyData, this.jwkHashMapping); + keyFormat = 'pkcs1'; } else { throw new NotSupportedError(); } + const key = (isPublic ? crypto.createPublicKey : crypto.createPrivateKey)({ + key: keyData, + format: 'der', + type: keyFormat + }); + return new CryptoKey(key.type, { name: this.name, hash }, extractable, keyUsages, key); }, exportKey(format, key) { + if (format === 'jwk') { + return derToJwk(key, this.jwkHashMapping); + } + if (format !== 'spki' && format !== 'pkcs8') throw new NotSupportedError(); @@ -132,6 +233,13 @@ module.exports.RSASSA_PKCS1 = { name: 'RSASSA-PKCS1-v1_5', ...rsaBase, + jwkHashMapping: { + RS1: 'SHA-1', + RS256: 'SHA-256', + RS384: 'SHA-384', + RS512: 'SHA-512' + }, + sign(algorithm, key, data) { const hashFn = opensslHashFunctionName(key.algorithm.hash); return crypto.sign(hashFn, bufferFromBufferSource(data), key[kKeyMaterial]); @@ -149,6 +257,13 @@ module.exports.RSA_PSS = { name: 'RSA-PSS', ...rsaBase, + jwkHashMapping: { + PS1: 'SHA-1', + PS256: 'SHA-256', + PS384: 'SHA-384', + PS512: 'SHA-512' + }, + sign(algorithm, key, data) { let { saltLength } = algorithm; saltLength = toUnsignedLongEnforceRange(saltLength); @@ -179,6 +294,13 @@ module.exports.RSA_OAEP = { name: 'RSA-OAEP', ...rsaBase, + jwkHashMapping: { + 'RSA-OAEP': 'SHA-1', + 'RSA-OAEP-256': 'SHA-256', + 'RSA-OAEP-384': 'SHA-384', + 'RSA-OAEP-512': 'SHA-512' + }, + encrypt(params, key, data) { try { return crypto.publicEncrypt({ diff --git a/lib/util.js b/lib/util.js index d71434c..c3186c7 100644 --- a/lib/util.js +++ b/lib/util.js @@ -7,19 +7,19 @@ function callOp(op) { return (algorithm) => algorithms.getAlgorithm(algorithm, op)[op](); } -module.exports.opensslHashFunctionName = callOp('get hash function'); -module.exports.getHashBlockSize = callOp('get hash block size'); +const opensslHashFunctionName = callOp('get hash function'); +const getHashBlockSize = callOp('get hash block size'); -module.exports.limitUsages = (usages, allowed, err = SyntaxError) => { +function limitUsages(usages, allowed, err = SyntaxError) { for (const usage of usages) { if (!allowed.includes(usage)) throw new err(); } -}; +} // Variant of base64 encoding as described in // https://tools.ietf.org/html/rfc4648#section-5 -module.exports.decodeBase64Url = (enc) => { +function decodeBase64Url(enc) { if (typeof enc !== 'string') throw new DataError(); @@ -35,9 +35,9 @@ module.exports.decodeBase64Url = (enc) => { }).join(''); return Buffer.from(enc, 'base64'); -}; +} -module.exports.encodeBase64Url = (enc) => { +function encodeBase64Url(enc) { return enc.toString('base64').split('').map((c) => { switch (c) { case '+': @@ -50,4 +50,127 @@ module.exports.encodeBase64Url = (enc) => { return c; } }).join(''); +} + +const tagInteger = 0x02; +const tagSequence = 0x30; + +const bZero = Buffer.from([0x00]); +const bTagInteger = Buffer.from([tagInteger]); +const bTagSequence = Buffer.from([tagSequence]); + +class Asn1SequenceDecoder { + constructor(buffer) { + if (buffer[0] !== tagSequence) + throw new DataError(); + + this.buffer = buffer; + this.offset = 1; + + const len = this.decodeLength(); + if (len !== buffer.length - this.offset) + throw new DataError(); + } + + decodeLength() { + let length = this.buffer[this.offset++]; + if (length & 0x80) { + // Long form. + const nBytes = length & ~0x80; + length = 0; + for (let i = 0; i < nBytes; i++) + length = (length << 8) | this.buffer[this.offset + i]; + this.offset += nBytes; + } + return length; + } + + unsignedInteger() { + if (this.buffer[this.offset++] !== tagInteger) + throw new DataError(); + + let length = this.decodeLength(); + + // There may be exactly one leading zero (if the next byte's MSB is set). + if (this.buffer[this.offset] === 0) { + this.offset++; + length--; + } + + const result = this.buffer.slice(this.offset, this.offset + length); + this.offset += length; + return result; + } + + end() { + if (this.offset !== this.buffer.length) + throw new DataError(); + } +} + +class Asn1SequenceEncoder { + constructor() { + this.length = 0; + this.elements = []; + } + + encodeLength(len) { + // Short form. + if (len < 128) + return Buffer.from([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); + } + + unsignedInteger(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) { + const len = this.encodeLength(integer.length + 1); + this.elements.push( + bTagInteger, + len, + bZero, + integer + ); + this.length += 2 + len.length + integer.length; + } 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++; + + const len = this.encodeLength(integer.length - i); + this.elements.push( + bTagInteger, + this.encodeLength(integer.length - i), + integer.slice(i) + ); + this.length += 1 + len.length + integer.length - i; + } + } + + end() { + const len = this.encodeLength(this.length); + return Buffer.concat([bTagSequence, len, ...this.elements], + 1 + len.length + this.length); + } +} + + +module.exports = { + opensslHashFunctionName, + getHashBlockSize, + limitUsages, + decodeBase64Url, + encodeBase64Url, + Asn1SequenceDecoder, + Asn1SequenceEncoder }; diff --git a/test/unit/algorithms/rsa.js b/test/unit/algorithms/rsa.js index 4549a61..bc7055f 100644 --- a/test/unit/algorithms/rsa.js +++ b/test/unit/algorithms/rsa.js @@ -269,4 +269,58 @@ describe('RSA-OAEP', () => { }, privateKey, Buffer.from(ciphertext, 'hex')); assert.deepStrictEqual(decrypted, plaintext); }); + + it('should support JWK keys', async () => { + const pubJwk = { + alg: 'RSA-OAEP-256', + e: 'AQAB', + ext: true, + key_ops: ['encrypt'], + kty: 'RSA', + n: '45Es9JLwnFY6hrSSgQ8Jg9R8keRW1vGYE6HUnuIxCuAwNBvPLNAZk6Xjo8LLlTNhGxu' + + 'prMcdmCTnDb1bBDg1xGCu3npaSeuXiRXUCtJ7rSzRRtLPMUDFamDgpMksUp6TzkUMjV' + + 'DJRzqjgtJMoJ6wV-pJBE-8XQSGRkVJJ9i0t9E' + }; + + const pubKey = await subtle.importKey('jwk', pubJwk, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + true, ['encrypt']); + const exportedPubJwk = await subtle.exportKey('jwk', pubKey); + assert.deepStrictEqual(exportedPubJwk, pubJwk); + + const privJwk = { + alg: 'RSA-OAEP-256', + d: 'D8q8YSjiRXhuvXgsni6mNSMK1aVQHmtBElmzCNHptSzAw5j2G_tr-Z-eiA0loLq3n3s' + + '1tN2o0NenaMAYymvr1SLuljLUbQnxYKxc6kmsR8DjPAlzTs_OgQ4cI3gdObuveptjPH' + + '9DpjuLyFqS_Vv-bzaGq0ywizEot1iKTHJxhk0', + dp: 'nAllsl_oYf3K8kV7DafF48VFndJ4wKi8AYLQFhrZK1ueDGX328odKIBCE42q1U5TZg' + + 'tvzyF_ryL3ar85RBJRvQ', + dq: 'pPYfF37ZjuK7KcZ7ST7Yg4-SwPwBmrpoF5ZoTiiwqqD9EiEfsfw0D9IKON_Jpfm3-H' + + 'd5HOC2LDLGE5QrKepB-Q', + e: 'AQAB', + ext: true, + key_ops: ['decrypt'], + kty: 'RSA', + n: '45Es9JLwnFY6hrSSgQ8Jg9R8keRW1vGYE6HUnuIxCuAwNBvPLNAZk6Xjo8LLlTNhGxu' + + 'prMcdmCTnDb1bBDg1xGCu3npaSeuXiRXUCtJ7rSzRRtLPMUDFamDgpMksUp6TzkUMjV' + + 'DJRzqjgtJMoJ6wV-pJBE-8XQSGRkVJJ9i0t9E', + p: '-1Z0sFXW4BHMq7jiYOA3cREt7oWEvEoMNTjrrTEA2cgMYfP1dz0Bm7pgh5opQYzmVmf' + + '3j06cO5a214PyweCKBQ', + q: '58nWsMm5LzbzyP27yTafEsK0cmA9mObjeyrSU6yFzwdXlR2jNy6Wbp48rRmaDZu5AuJ' + + 'LFhAyenH6ZH918JiEXQ', + qi: 'dtd9nD_Ss8o7iZBrwP_4A4fsae_E5En9w_jrw8arp8u_MVO6PbRrGHRcpCkKliBSwU' + + 'CoUtm1LevNhdtmEIJ5jQ' + }; + + const privKey = await subtle.importKey('jwk', + privJwk, + { + name: 'RSA-OAEP', + hash: 'SHA-256' + }, + true, + ['decrypt']); + const exportedPrivJwk = await subtle.exportKey('jwk', privKey); + assert.deepStrictEqual(exportedPrivJwk, privJwk); + }); }); diff --git a/test/unit/util.js b/test/unit/util.js new file mode 100644 index 0000000..ebba584 --- /dev/null +++ b/test/unit/util.js @@ -0,0 +1,96 @@ +'use strict'; + +const assert = require('assert'); +const { randomBytes } = require('crypto'); + +const { + Asn1SequenceDecoder, + Asn1SequenceEncoder +} = require('../../lib/util'); + +function throwsDataError(fn) { + assert.throws(fn, { + name: 'DataError' + }); +} + +describe('util', () => { + describe('Asn1SequenceDecoder', () => { + it('should decode empty sequences', () => { + const data = Buffer.from([0x30, 0x00]); + const enc = new Asn1SequenceDecoder(data); + enc.end(); + }); + + it('should decode small unsigned integers', () => { + const dec = new Asn1SequenceDecoder(Buffer.from('3005020300ffff', 'hex')); + assert.deepStrictEqual(dec.unsignedInteger(), Buffer.from([0xff, 0xff])); + dec.end(); + }); + + it('should decode large unsigned integers', () => { + function test(largeIntData, knownStart) { + const buf = Buffer.concat([ + Buffer.from(knownStart, 'hex'), + Buffer.from(largeIntData) + ]); + const dec = new Asn1SequenceDecoder(buf); + const largeInt = dec.unsignedInteger(); + dec.end(); + assert.deepStrictEqual([...largeInt], largeIntData); + } + + test([0x01, ...randomBytes(1999)], '308207d4028207d0'); + test([0xff, ...randomBytes(1998)], '308207d4028207d000'); + }); + + it('should throw if ASN.1 tag is incorrect', () => { + throwsDataError(() => { + new Asn1SequenceDecoder(Buffer.from([0x31, 0x00])); + }); + }); + + it('should throw if the buffer is longer than the sequence', () => { + throwsDataError(() => { + new Asn1SequenceDecoder(Buffer.from([0x30, 0x00, 0x00])); + }); + }); + + it('should throw if the buffer is shorter than the sequence', () => { + throwsDataError(() => { + new Asn1SequenceDecoder(Buffer.from([0x30, 0x10, 0x00])); + }); + }); + }); + + describe('Asn1SequenceEncoder', () => { + it('should encode empty sequences', () => { + const enc = new Asn1SequenceEncoder(); + const buf = enc.end(); + assert.deepStrictEqual(buf, Buffer.from([0x30, 0x00])); + }); + + it('should encode small unsigned integers', () => { + const enc = new Asn1SequenceEncoder(); + enc.unsignedInteger(Buffer.from([0xff, 0xff])); + const buf = enc.end(); + assert.strictEqual(buf.toString('hex'), '3005020300ffff'); + }); + + it('should encode large unsigned integers', () => { + function test(largeIntData, knownStart) { + const enc = new Asn1SequenceEncoder(); + const largeInt = Buffer.from(largeIntData); + enc.unsignedInteger(largeInt); + const buf = enc.end(); + assert.strictEqual(buf.length, 2008); + const ksLength = knownStart.length >> 1; + assert.strictEqual(buf.slice(0, ksLength).toString('hex'), knownStart); + assert.deepStrictEqual(buf.slice(ksLength), largeInt); + } + + test([0x01, ...randomBytes(1999)], '308207d4028207d0'); + test([0xff, ...randomBytes(1998)], '308207d4028207d000'); + }); + }); +});