diff --git a/packages/asymmetric/README.md b/packages/asymmetric/README.md new file mode 100644 index 000000000..ee0edb8a4 --- /dev/null +++ b/packages/asymmetric/README.md @@ -0,0 +1,27 @@ +# @accounts/asymmetric + +## Install + +``` +yarn add @accounts/asymmetric +``` + +## Usage + +```js +import { AccountsServer } from '@accounts/server'; +import { AccountsAsymmetric } from '@accounts/asymmetric'; + +export const accountsAsymmetric = new AccountsAsymmetric({ + // options +}); + +const accountsServer = new AccountsServer( + { + // options + }, + { + asymmetric: accountsAsymmetric, + } +); +``` diff --git a/packages/asymmetric/__tests__/__snapshots__/accounts-asymmetric.ts.snap b/packages/asymmetric/__tests__/__snapshots__/accounts-asymmetric.ts.snap new file mode 100644 index 000000000..34d8c14c9 --- /dev/null +++ b/packages/asymmetric/__tests__/__snapshots__/accounts-asymmetric.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountsAsymmetric throws error when the user is not found by public key 1`] = `[Error: User not found]`; diff --git a/packages/asymmetric/__tests__/accounts-asymmetric.ts b/packages/asymmetric/__tests__/accounts-asymmetric.ts new file mode 100644 index 000000000..4d1fd2ae4 --- /dev/null +++ b/packages/asymmetric/__tests__/accounts-asymmetric.ts @@ -0,0 +1,215 @@ +import * as crypto from 'crypto'; + +import { PublicKeyType } from '../src/types'; +import { AccountsAsymmetric } from '../src'; + +describe('AccountsAsymmetric', () => { + it('should update the public key', async () => { + const { publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 512, + publicKeyEncoding: { + type: 'pkcs1', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs1', + format: 'pem', + }, + }); + + const service = new AccountsAsymmetric(); + + const setService = jest.fn(() => Promise.resolve(null)); + service.setStore({ setService } as any); + + const publicKeyParams: PublicKeyType = { + key: publicKey, + encoding: 'base64', + format: 'pem', + type: 'spki', + }; + + const res = await service.updatePublicKey('123', publicKeyParams); + + expect(res).toBeTruthy(); + expect(setService).toHaveBeenCalledWith('123', service.serviceName, { + id: publicKey, + ...publicKeyParams, + }); + }); + + it('throws error when the user is not found by public key', async () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 512, + publicKeyEncoding: { + type: 'pkcs1', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs1', + format: 'pem', + }, + }); + const payload = 'some data to sign'; + + const sign = crypto.createSign('SHA256'); + sign.write(payload); + sign.end(); + const signature = sign.sign(privateKey, 'hex'); + + const service = new AccountsAsymmetric(); + + const findUserByServiceId = jest.fn(() => Promise.resolve(null)); + service.setStore({ findUserByServiceId } as any); + + await expect( + service.authenticate({ + signature, + payload, + publicKey, + signatureAlgorithm: 'sha512', + signatureFormat: 'hex', + }) + ).rejects.toMatchSnapshot(); + }); + + it('should return null when signature is invalid', async () => { + const { publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 512, + publicKeyEncoding: { + type: 'pkcs1', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs1', + format: 'pem', + }, + }); + const payload = 'some data to sign'; + + const service = new AccountsAsymmetric(); + + const publicKeyParams: PublicKeyType = { + key: publicKey, + encoding: 'base64', + format: 'pem', + type: 'pkcs1', + }; + + const user = { + id: '123', + services: { + [service.serviceName]: publicKeyParams, + }, + }; + const findUserByServiceId = jest.fn(() => Promise.resolve(user)); + service.setStore({ findUserByServiceId } as any); + + const userFromService = await service.authenticate({ + signature: 'some signature', + payload, + publicKey: publicKey, + signatureAlgorithm: 'sha256', + signatureFormat: 'hex', + }); + + expect(userFromService).toBeNull(); + }); + + it('should return user when verification is successful', async () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 512, + publicKeyEncoding: { + type: 'pkcs1', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs1', + format: 'pem', + }, + }); + const payload = 'some data to sign'; + + const sign = crypto.createSign('sha256'); + sign.write(payload); + sign.end(); + const signature = sign.sign(privateKey, 'hex'); + + const service = new AccountsAsymmetric(); + + const publicKeyParams: PublicKeyType = { + key: publicKey, + encoding: 'base64', + format: 'pem', + type: 'pkcs1', + }; + + const user = { + id: '123', + services: { + [service.serviceName]: publicKeyParams, + }, + }; + const findUserByServiceId = jest.fn(() => Promise.resolve(user)); + service.setStore({ findUserByServiceId } as any); + + const userFromService = await service.authenticate({ + signature, + payload, + publicKey: publicKey, + signatureAlgorithm: 'sha256', + signatureFormat: 'hex', + }); + + expect(userFromService).toEqual(user); + }); + + it('should return user when verification is successful using der format', async () => { + const { publicKey: publicKeyBuffer, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 512, + publicKeyEncoding: { + type: 'pkcs1', + format: 'der', + }, + privateKeyEncoding: { + type: 'pkcs1', + format: 'pem', + }, + }); + const publicKey = publicKeyBuffer.toString('base64'); + const payload = 'some data to sign'; + + const sign = crypto.createSign('sha256'); + sign.write(payload); + sign.end(); + const signature = sign.sign(privateKey, 'hex'); + + const service = new AccountsAsymmetric(); + + const publicKeyParams: PublicKeyType = { + key: publicKey, + encoding: 'base64', + format: 'der', + type: 'pkcs1', + }; + + const user = { + id: '123', + services: { + [service.serviceName]: publicKeyParams, + }, + }; + const findUserByServiceId = jest.fn(() => Promise.resolve(user)); + service.setStore({ findUserByServiceId } as any); + + const userFromService = await service.authenticate({ + signature, + payload, + publicKey, + signatureAlgorithm: 'sha256', + signatureFormat: 'hex', + }); + + expect(userFromService).toEqual(user); + }); +}); diff --git a/packages/asymmetric/package.json b/packages/asymmetric/package.json new file mode 100644 index 000000000..3de6885ff --- /dev/null +++ b/packages/asymmetric/package.json @@ -0,0 +1,36 @@ +{ + "name": "@accounts/asymmetric", + "version": "0.19.1", + "license": "MIT", + "main": "lib/index.js", + "typings": "lib/index.d.ts", + "scripts": { + "clean": "rimraf lib", + "start": "tsc --watch", + "precompile": "yarn clean", + "compile": "tsc", + "prepublishOnly": "yarn compile", + "testonly": "jest", + "coverage": "jest --coverage" + }, + "jest": { + "testEnvironment": "node", + "preset": "ts-jest" + }, + "dependencies": { + "@accounts/types": "^0.19.0", + "node-forge": "^0.9.1", + "tslib": "1.10.0" + }, + "devDependencies": { + "@accounts/server": "^0.19.0", + "@types/jest": "24.0.18", + "@types/node": "10.14.19", + "@types/node-forge": "^0.8.6", + "jest": "24.9.0", + "rimraf": "3.0.0" + }, + "peerDependencies": { + "@accounts/server": "^0.19.0" + } +} diff --git a/packages/asymmetric/src/accounts-asymmetric.ts b/packages/asymmetric/src/accounts-asymmetric.ts new file mode 100644 index 000000000..ce3ad7706 --- /dev/null +++ b/packages/asymmetric/src/accounts-asymmetric.ts @@ -0,0 +1,92 @@ +import * as crypto from 'crypto'; + +import { AuthenticationService, DatabaseInterface } from '@accounts/types'; +import { AccountsServer } from '@accounts/server'; +import forge from 'node-forge'; + +import { AsymmetricLoginType, PublicKeyType, ErrorMessages } from './types'; +import { errors } from './errors'; + +export interface AccountsAsymmetricOptions { + errors?: ErrorMessages; +} + +const defaultOptions = { + errors, +}; + +export default class AccountsAsymmetric implements AuthenticationService { + public serviceName = 'asymmetric'; + public server!: AccountsServer; + private db!: DatabaseInterface; + private options: AccountsAsymmetricOptions & typeof defaultOptions; + + constructor(options: AccountsAsymmetricOptions = {}) { + this.options = { ...defaultOptions, ...options }; + } + + public setStore(store: DatabaseInterface) { + this.db = store; + } + + public async updatePublicKey( + userId: string, + { key, ...params }: PublicKeyType + ): Promise { + try { + await this.db.setService(userId, this.serviceName, { id: key, key, ...params }); + return true; + } catch (e) { + return false; + } + } + + public async authenticate(params: AsymmetricLoginType): Promise { + const user = await this.db.findUserByServiceId(this.serviceName, params.publicKey); + + if (!user) { + throw new Error(this.options.errors.userNotFound); + } + + try { + const verify = crypto.createVerify(params.signatureAlgorithm); + verify.write(params.payload); + verify.end(); + + const publicKeyParams: PublicKeyType = (user.services as any)[this.serviceName]; + + const pemText = + publicKeyParams.format === 'pem' ? publicKeyParams.key : this.derToPem(publicKeyParams); + + const isVerified = verify.verify(pemText, params.signature, params.signatureFormat); + + return isVerified ? user : null; + } catch (e) { + throw new Error(this.options.errors.verificationFailed); + } + } + + private derToPem(publicKeyParams: PublicKeyType): string { + let derKey; + + switch (publicKeyParams.encoding) { + case 'utf8': { + derKey = forge.util.decodeUtf8(publicKeyParams.key); + break; + } + case 'hex': { + derKey = forge.util.hexToBytes(publicKeyParams.key); + break; + } + case 'base64': + default: { + derKey = forge.util.decode64(publicKeyParams.key); + break; + } + } + + const asnObj = forge.asn1.fromDer(derKey); + const publicKey = forge.pki.publicKeyFromAsn1(asnObj); + return forge.pki.publicKeyToPem(publicKey); + } +} diff --git a/packages/asymmetric/src/errors.ts b/packages/asymmetric/src/errors.ts new file mode 100644 index 000000000..508e015f6 --- /dev/null +++ b/packages/asymmetric/src/errors.ts @@ -0,0 +1,6 @@ +import { ErrorMessages } from './types'; + +export const errors: ErrorMessages = { + userNotFound: 'User not found', + verificationFailed: 'Failed to verify signature', +}; diff --git a/packages/asymmetric/src/index.ts b/packages/asymmetric/src/index.ts new file mode 100644 index 000000000..f3f789979 --- /dev/null +++ b/packages/asymmetric/src/index.ts @@ -0,0 +1,5 @@ +import AccountsAsymmetric from './accounts-asymmetric'; +export * from './types'; + +export default AccountsAsymmetric; +export { AccountsAsymmetric }; diff --git a/packages/asymmetric/src/types/asymmetric-login-type.ts b/packages/asymmetric/src/types/asymmetric-login-type.ts new file mode 100644 index 000000000..1f005ccc4 --- /dev/null +++ b/packages/asymmetric/src/types/asymmetric-login-type.ts @@ -0,0 +1,7 @@ +export interface AsymmetricLoginType { + publicKey: string; + signature: string; + signatureAlgorithm: 'sha256' | 'sha512'; + signatureFormat: 'hex' | 'base64'; + payload: string; +} diff --git a/packages/asymmetric/src/types/error-messages.ts b/packages/asymmetric/src/types/error-messages.ts new file mode 100644 index 000000000..0cb2242a6 --- /dev/null +++ b/packages/asymmetric/src/types/error-messages.ts @@ -0,0 +1,4 @@ +export interface ErrorMessages { + userNotFound: string; + verificationFailed: string; +} diff --git a/packages/asymmetric/src/types/index.ts b/packages/asymmetric/src/types/index.ts new file mode 100644 index 000000000..028574650 --- /dev/null +++ b/packages/asymmetric/src/types/index.ts @@ -0,0 +1,3 @@ +export * from './asymmetric-login-type'; +export * from './public-key-type'; +export * from './error-messages'; diff --git a/packages/asymmetric/src/types/public-key-type.ts b/packages/asymmetric/src/types/public-key-type.ts new file mode 100644 index 000000000..8a6ebd224 --- /dev/null +++ b/packages/asymmetric/src/types/public-key-type.ts @@ -0,0 +1,6 @@ +export interface PublicKeyType { + key: string; + encoding: 'utf8' | 'base64' | 'hex'; + format: 'pem' | 'der'; + type: 'pkcs1' | 'spki'; +} diff --git a/packages/asymmetric/tsconfig.json b/packages/asymmetric/tsconfig.json new file mode 100644 index 000000000..4ec56d0f8 --- /dev/null +++ b/packages/asymmetric/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "importHelpers": true + }, + "exclude": ["node_modules", "__tests__", "lib"] +} diff --git a/yarn.lock b/yarn.lock index fd94449ba..ae6ccaa67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2404,6 +2404,11 @@ "@types/koa-compose" "*" "@types/node" "*" +"@types/lodash@4.14.136": + version "4.14.136" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.136.tgz#413e85089046b865d960c9ff1d400e04c31ab60f" + integrity sha512-0GJhzBdvsW2RUccNHOBkabI8HZVdOXmXbXhuKlDEd5Vv12P7oAVGfomGp3Ne21o5D/qu1WmthlNKFaoZJJeErA== + "@types/lodash@4.14.138", "@types/lodash@^4.14.138": version "4.14.138" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.138.tgz#34f52640d7358230308344e579c15b378d91989e" @@ -2432,6 +2437,14 @@ "@types/bson" "*" "@types/node" "*" +"@types/mongoose@5.5.11": + version "5.5.11" + resolved "https://registry.yarnpkg.com/@types/mongoose/-/mongoose-5.5.11.tgz#8562bb84b4f3f41aebec27f263607bfe2183729e" + integrity sha512-Z1W2V3zrB+SeDGI6G1G5XR3JJkkMl4ni7a2Kmq10abdY0wapbaTtUT2/31N+UTPEzhB0KPXUgtQExeKxrc+hxQ== + dependencies: + "@types/mongodb" "*" + "@types/node" "*" + "@types/mongoose@5.5.17": version "5.5.17" resolved "https://registry.yarnpkg.com/@types/mongoose/-/mongoose-5.5.17.tgz#1f8eb3799368ae266758d2df1bd1a7cfca0f6875" @@ -2447,11 +2460,23 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@12.7.4", "@types/node@>=6", "@types/node@^10.1.0": +"@types/node-forge@^0.8.6": + version "0.8.6" + resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-0.8.6.tgz#5d13614e865ccf31f44856d0dda2cb0a288032aa" + integrity sha512-m2G+ipvx+1+BU4LrwrHcEGDk1MnioEVxTnCrkFsKT+BdDl/8OD3WbtspPFj3osnix9fzDIfy2O9p/AFyH3fTzg== + dependencies: + "@types/node" "*" + +"@types/node@*", "@types/node@12.7.4", "@types/node@>=6": version "12.7.4" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.7.4.tgz#64db61e0359eb5a8d99b55e05c729f130a678b04" integrity sha512-W0+n1Y+gK/8G2P/piTkBBN38Qc5Q1ZSO6B5H3QmPCUewaiXOo2GCAWZ4ElZCcNhjJuBSUSLGFUJnmlCn5+nxOQ== +"@types/node@10.14.19", "@types/node@^10.1.0": + version "10.14.19" + resolved "https://registry.yarnpkg.com/@types/node/-/node-10.14.19.tgz#f52742c7834a815dedf66edfc8a51547e2a67342" + integrity sha512-j6Sqt38ssdMKutXBUuAcmWF8QtHW1Fwz/mz4Y+Wd9mzpBiVFirjpNQf363hG5itkG+yGaD+oiLyb50HxJ36l9Q== + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -7514,7 +7539,18 @@ graphql-toolkit@0.5.12, graphql-toolkit@^0.5.12: tslib "^1.9.3" valid-url "1.0.9" -graphql-tools@4.0.4, graphql-tools@4.0.5, graphql-tools@^4.0.0, graphql-tools@^4.0.5: +graphql-tools@4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/graphql-tools/-/graphql-tools-4.0.4.tgz#ca08a63454221fdde825fe45fbd315eb2a6d566b" + integrity sha512-chF12etTIGVVGy3fCTJ1ivJX2KB7OSG4c6UOJQuqOHCmBQwTyNgCDuejZKvpYxNZiEx7bwIjrodDgDe9RIkjlw== + dependencies: + apollo-link "^1.2.3" + apollo-utilities "^1.0.1" + deprecated-decorator "^0.1.6" + iterall "^1.1.3" + uuid "^3.1.0" + +graphql-tools@4.0.5, graphql-tools@^4.0.0, graphql-tools@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/graphql-tools/-/graphql-tools-4.0.5.tgz#d2b41ee0a330bfef833e5cdae7e1f0b0d86b1754" integrity sha512-kQCh3IZsMqquDx7zfIGWBau42xe46gmqabwYkpPlCLIjcEY1XK+auP7iGRD9/205BPyoQdY8hT96MPpgERdC9Q== @@ -10593,6 +10629,11 @@ node-forge@0.7.5: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.5.tgz#6c152c345ce11c52f465c2abd957e8639cd674df" integrity sha512-MmbQJ2MTESTjt3Gi/3yG1wGpIMhUfcIypUCGtTizFR9IiccFwxSpfp0vtIZlkFclEqERemxfnSdZEMR9VqqEFQ== +node-forge@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.1.tgz#775368e6846558ab6676858a4d8c6e8d16c677b5" + integrity sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ== + node-gyp@^5.0.2: version "5.0.3" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-5.0.3.tgz#80d64c23790244991b6d44532f0a351bedd3dd45"