From 79e9dcd62b7e0f5ff0d5acca8acd87a6cfc8b81d Mon Sep 17 00:00:00 2001 From: Diego Fernandez Date: Wed, 8 Jun 2022 02:27:23 +0200 Subject: [PATCH 1/2] feat: added external modules --- package-lock.json | 191 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 12 ++- 2 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..079f160 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,191 @@ +{ + "name": "js-nextgen-psd2", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "js-nextgen-psd2", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "axios": "^0.27.2", + "crypto": "^1.0.1", + "form-urlencoded": "^6.0.6", + "https": "^1.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in." + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", + "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-urlencoded": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-6.0.6.tgz", + "integrity": "sha512-5n3L86l3uVJLFk8w+HTcuaV8WrEeH9pPqJcICxAbs3oW/gsKg9kJ8XVPZ3I1PJR50ld2fQjstT94p4G90JDMAg==" + }, + "node_modules/https": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", + "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + } + }, + "dependencies": { + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "requires": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==" + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "follow-redirects": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", + "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "form-urlencoded": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-6.0.6.tgz", + "integrity": "sha512-5n3L86l3uVJLFk8w+HTcuaV8WrEeH9pPqJcICxAbs3oW/gsKg9kJ8XVPZ3I1PJR50ld2fQjstT94p4G90JDMAg==" + }, + "https": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", + "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + } + } +} diff --git a/package.json b/package.json index b87e23f..ee9aebf 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "js-nextgen-psd2", - "version": "0.0.1", + "version": "1.0.0", "description": "Library to communicate with European Banks using NextGenPSD2 standard and PSD2 XS2A", "main": "index.js", - "scripts": { }, + "scripts": {}, "repository": { "type": "git", "url": "git+https://github.com/diegojfer/js-nextgen-psd2.git" @@ -31,5 +31,11 @@ "bugs": { "url": "https://github.com/diegojfer/js-nextgen-psd2/issues" }, - "homepage": "https://github.com/diegojfer/js-nextgen-psd2#readme" + "homepage": "https://github.com/diegojfer/js-nextgen-psd2#readme", + "dependencies": { + "axios": "^0.27.2", + "crypto": "^1.0.1", + "form-urlencoded": "^6.0.6", + "https": "^1.0.0" + } } From 7b825dbad6a281baffdc0dbdafe9eeea3ac2967a Mon Sep 17 00:00:00 2001 From: Diego Fernandez Date: Wed, 8 Jun 2022 02:30:49 +0200 Subject: [PATCH 2/2] feat: first implementation --- README.md | 46 +++++++++++ index.js | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) diff --git a/README.md b/README.md index 5fdb273..e9d804b 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,49 @@ NextGenPSD2 is a european standard for PSD2 XS2A described by [The Berlin Group] ## How can I use JS-NextGen-PSD2? Before using the module, you must request a valid eIDAS QWAC certificate from a [distinguish certificate authority](https://esignature.ec.europa.eu/efda/tl-browser/). + +### Simple Example + +```javascript +const PSD2Client = require('js-nextgen-psd2') + +let client = new PSD2Client( + // Certificate and private key used for + // signing requests. + fs.readFileSync('SigningCertificate.cer'), + fs.readFileSync('SigningKey.pem'), + { + // Certificate and private key used + // for SSL client authentication. + sslCertificate: fs.readFileSync('SSLCertificate.cer'), + sslKey: fs.readFileSync('SSLPrivKey.pem'), + // HTTP request timeout + timeout: 16000 + } +) + +let response = await client.send( + 'post', + 'https://api.testbank.com/v1/consents', + { + 'PSU-IP-Address': '192.168.8.78', + 'PSU-User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0' + }, + { + access: { + balances: [ + { iban: "DE40100100103307118608" }, + { iban: "DE02100100109307118603", currency: "USD" }, + { iban: "DE67100100101306118605" } + ], + transactions: [ + { iban: "DE40100100103307118608" }, + { maskedPan: "123456xxxxxx1234" } + ] + }, + recurringIndicator: true, + validUntil: "2017-11-01", + frequencyPerDay: "4" + } +) +``` diff --git a/index.js b/index.js index e69de29..9a7c6e1 100644 --- a/index.js +++ b/index.js @@ -0,0 +1,238 @@ +const https = require('https') +const crypto = require('crypto') +const axios = require('axios') +const urlencoded = require('form-urlencoded') + +class PSD2Client { + constructor(signingCertificate, signingKey, options) { + const opts = typeof(options) === 'object' && options != null ? options : { } + + let sslAgent = undefined + try { + let signingCertificateBuffer = null + if (Buffer.isBuffer(signingCertificate)) { + signingCertificateBuffer = signingCertificate + } + if (typeof(signingCertificate) === 'string') { + signingCertificateBuffer = Buffer.from(signingCertificate, 'hex') + } + if (signingCertificateBuffer === null) { + throw new Error(`Unable to read signing certificate buffer.`) + } + + let signingKeyBuffer = null + if (Buffer.isBuffer(signingKey)) { + signingKeyBuffer = signingKey + } + if (typeof(signingKey) === 'string') { + signingKeyBuffer = Buffer.from(signingKey, 'hex') + } + if (signingKeyBuffer === null) { + throw new Error(`Unable to read signing key buffer.`) + } + + const signingX509 = new crypto.X509Certificate(signingCertificateBuffer) + const signingPriv = crypto.createPrivateKey(signingKeyBuffer) + if (signingX509.checkPrivateKey(signingPriv) === true) { + this._identifier = `SN=${signingX509.serialNumber},CA=${signingX509.issuer.split('\n').join(',')}` + this._certificate = signingX509.raw.toString('base64') + this._key = signingPriv + } else { + throw new Error(`Signing key doesn't match signing certificate.`) + } + + if (opts.sslCertificate && opts.sslKey) { + let sslCertificateBuffer = null + if (Buffer.isBuffer(opts.sslCertificate)) { + sslCertificateBuffer = opts.sslCertificate + } + if (typeof(opts.sslCertificate) === 'string') { + sslCertificateBuffer = Buffer.from(opts.sslCertificate, 'hex') + } + if (sslCertificateBuffer === null) { + throw new Error(`Unable to read authentication certificate buffer.`) + } + + let sslKeyBuffer = null + if (Buffer.isBuffer(opts.sslKey)) { + sslKeyBuffer = opts.sslKey + } + if (typeof(opts.sslKey) === 'string') { + sslKeyBuffer = Buffer.from(opts.sslKey, 'hex') + } + if (sslKeyBuffer === null) { + throw new Error(`Unable to read authentication key buffer.`) + } + + const sslX509 = new crypto.X509Certificate(sslCertificateBuffer) + const sslPriv = crypto.createPrivateKey(sslKeyBuffer) + if (sslX509.checkPrivateKey(sslPriv) === true) { + sslAgent = new https.Agent({ + cert: sslCertificateBuffer, + key: sslKeyBuffer + }) + } else { + throw new Error(`SSL key doesn't match SSL certificate.`) + } + } else { + if (opts.sslCertificate) { + throw new Error('SSL key not provided!') + } + } + } catch (error) { + let message = `Message: 'unknown'` + try { message = typeof(error.message) === 'string' ? `Message: '${error.message}'` : `Message: 'unknown'` } + catch { } + + throw new Error(`Unable to verify certificates. ${ message }`) + } + + try { + let axiosOpts = { + timeout: typeof(opts.timeout) === 'number' ? opts.timeout : 16000, + validateStatus: () => true + } + + if (sslAgent) { + axiosOpts['httpsAgent'] = sslAgent + } + + this._axios = axios.create(axiosOpts) + } catch (error) { + let message = `Message: 'unknown'` + try { message = typeof(error.message) === 'string' ? `Message: '${error.message}'` : `Message: 'unknown'` } + catch { } + + throw new Error(`Unable to create axios remote client. ${ message }`) + } + } + + _signedRequest(headers, body) { + const FORBIDDEN_HEADERS = [ + 'digest', + 'x-request-id', + 'tpp-signature-certificate', + 'signature' + ] + const SIGNED_HEADERS = [ + 'digest', + 'x-request-id', + 'psu-id', + 'psu-corporate-id', + 'tpp-redirect-uri' + ] + + let httpHeaders = typeof(headers) === 'object' ? headers : { } + let httpBody = Buffer.isBuffer(body) ? body : null + + let headersResult = { } + let bodyResult = Buffer.isBuffer(httpBody) ? httpBody : null + + // We have to sanitize the headers parameter. We + // should only accept haders with value type string + // or number. And remove headers used for signature. + let headersKeys = Object.keys(httpHeaders) + .filter(key => typeof(key) === 'string') + .filter(key => typeof(httpHeaders[key]) === 'string' || typeof(httpHeaders[key]) === 'number' || typeof(httpHeaders[key]) === 'boolean') + .filter(key => FORBIDDEN_HEADERS.includes(key.toLowerCase()) === false) + + headersKeys.forEach(key => headersResult[key] = httpHeaders[key]) + + // We should add a random generated request + // identifier. + let reqid = crypto.randomUUID() + + headersKeys.push('X-Request-Id') + headersResult['X-Request-Id'] = reqid + + // Compute body hash. We are using SHA-256, but + // we should implement SHA-512 algorithm too. + // TODO: Implement SHA-512 hashing method. + let bodyHashCrypto = crypto.createHash('sha256') + bodyHashCrypto.update(httpBody ? httpBody : Buffer.from('', 'hex')) + let bodyHash = bodyHashCrypto.digest('base64') + + headersKeys.push('Digest') + headersResult['Digest'] = `SHA-256=${bodyHash}` + + // Compute request signature. We will sign the + // request with RSA-SHA256. Currently no more algorithms + // are supported by EU. + let headersToSign = headersKeys.filter(key => SIGNED_HEADERS.includes(key.toLowerCase())) + let headersSignatureString = headersToSign.map(key => `${key.toLowerCase()}: ${headersResult[key]}`) + .sort() + .join('\n') + let headersSignatureCrypto = crypto.createSign('RSA-SHA256') + headersSignatureCrypto.update(headersSignatureString) + let headersSignature = headersSignatureCrypto.sign(this._key, 'base64') + + headersResult['TPP-Signature-Certificate'] = this._certificate + headersResult['Signature'] = `keyId="${this._identifier}",algorithm="sha-256",headers="${headersToSign.sort().join(' ').toLowerCase()}",signature="${headersSignature}"` + + return { request: reqid, headers: headersResult, body: bodyResult } + } + + async send(method, path, headers, body, encoding) { + const AVAILABLE_METHODS = [ + 'get', 'post', 'put', 'delete' + ] + + if (typeof(method) !== 'string') throw new Error(`Unable to send request. Message: 'invalid method type'`) + if (AVAILABLE_METHODS.includes(method.toLowerCase()) === false) throw new Error(`Unable to send request. Message: 'invalid method'`) + if (typeof(path) !== 'string') throw new Error(`Unable to send request. Message: 'invalid path'`) + + let sanitizedHeaders = typeof(headers) === 'object' ? headers : { } + let sanitizedBody = null + if (body) { + try { + if (Buffer.isBuffer(body)) { + sanitizedHeaders['Content-Type'] = typeof(sanitizedHeaders['Content-Type']) === 'string' ? sanitizedHeaders['Content-Type'] : 'application/octet-stream' + sanitizedBody = body + } else { + if (encoding === 'json' || encoding === undefined) { + let serializedBody = JSON.stringify(body) + let bufferedBody = Buffer.from(serializedBody, 'utf-8') + + sanitizedHeaders['Content-Type'] = 'application/json' + sanitizedBody = bufferedBody + } else if (encoding === 'urlencoded') { + let serializedBody = urlencoded(body) + let bufferedBody = Buffer.from(serializedBody, 'utf-8') + + sanitizedHeaders['Content-Type'] = 'x-www-form-urlencoded' + sanitizedBody = bufferedBody + } else { + throw new Error(`Invalid provided encoding (${encoding}).`) + } + } + } catch (error) { + let message = `Message: 'unknown'` + try { message = typeof(error.message) === 'string' ? `Message: '${error.message}'` : `Message: 'unknown'` } + catch { } + + throw new Error(`Unable to serialize request. ${ message }`) + } + } + + try { + let signedRequest = this._signedRequest(sanitizedHeaders, sanitizedBody) + + let result = await this._axios.request({ + method: method, + url: path, + headers: signedRequest.headers, + data: signedRequest.body + }) + + return { request: signedRequest.request, status: result.status, headers: result.headers, body: result.data } + } catch (error) { + let message = `Message: 'unknown'` + try { message = typeof(error.message) === 'string' ? `Message: '${error.message}'` : `Message: 'unknown'` } + catch { } + + throw new Error(`Unable to send request. ${ message }`) + } + } +} + +module.exports = PSD2Client