diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 2c5afa6..2acf9a4 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,9 +1,8 @@ -import { CryptoProvider, LoginDetails } from '@internxt/sdk'; -import { Keys, Password } from '@internxt/sdk/dist/auth'; +import { LoginDetails } from '@internxt/sdk'; import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings.js'; import { SdkManager } from './SDKManager.service'; import { KeysService } from './keys.service'; -import { decryptText, decryptTextWithKey, encryptText, passToHash } from '../utils/crypto.utils'; +import { CryptoService } from './crypto.service'; export class AuthService { public static readonly instance: AuthService = new AuthService(); @@ -18,33 +17,16 @@ export class AuthService { newToken: string; mnemonic: string; }> => { - const authClient = SdkManager.getInstance().auth; + const authClient = SdkManager.instance.getAuth(); const loginDetails: LoginDetails = { email: email.toLowerCase(), password: password, tfaCode: twoFactorCode, }; - const cryptoProvider: CryptoProvider = { - encryptPasswordHash(password: Password, encryptedSalt: string): string { - const salt = decryptText(encryptedSalt); - const hashObj = passToHash({ password, salt }); - return encryptText(hashObj.hash); - }, - async generateKeys(password: Password): Promise { - const { privateKeyArmoredEncrypted, publicKeyArmored, revocationCertificate } = - await KeysService.instance.generateNewKeysWithEncrypted(password); - const keys: Keys = { - privateKeyEncrypted: privateKeyArmoredEncrypted, - publicKey: publicKeyArmored, - revocationCertificate: revocationCertificate, - }; - return keys; - }, - }; // eslint-disable-next-line no-useless-catch try { - const data = await authClient.login(loginDetails, cryptoProvider); + const data = await authClient.login(loginDetails, CryptoService.cryptoProvider); const { user, token, newToken } = data; const { privateKey, publicKey } = user; @@ -60,7 +42,7 @@ export class AuthService { ); } - const clearMnemonic = decryptTextWithKey(user.mnemonic, password); + const clearMnemonic = CryptoService.instance.decryptTextWithKey(user.mnemonic, password); const clearUser = { ...user, mnemonic: clearMnemonic, @@ -79,7 +61,7 @@ export class AuthService { }; public is2FANeeded = async (email: string): Promise => { - const authClient = SdkManager.getInstance().auth; + const authClient = SdkManager.instance.getAuth(); const securityDetails = await authClient.securityDetails(email).catch((error) => { throw new Error(error.message ?? 'Login error'); }); diff --git a/src/services/crypto.service.ts b/src/services/crypto.service.ts new file mode 100644 index 0000000..f61c764 --- /dev/null +++ b/src/services/crypto.service.ts @@ -0,0 +1,71 @@ +import { CryptoProvider } from '@internxt/sdk'; +import { Keys, Password } from '@internxt/sdk/dist/auth'; +import { KeysService } from './keys.service'; +import { ConfigService } from '../services/config.service'; + +interface PassObjectInterface { + salt?: string | null; + password: string; +} + +export class CryptoService { + public static readonly instance: CryptoService = new CryptoService(); + + public static readonly cryptoProvider: CryptoProvider = { + encryptPasswordHash(password: Password, encryptedSalt: string): string { + const salt = CryptoService.instance.decryptText(encryptedSalt); + const hashObj = CryptoService.instance.passToHash({ password, salt }); + return CryptoService.instance.encryptText(hashObj.hash); + }, + async generateKeys(password: Password): Promise { + const { privateKeyArmoredEncrypted, publicKeyArmored, revocationCertificate } = + await KeysService.instance.generateNewKeysWithEncrypted(password); + const keys: Keys = { + privateKeyEncrypted: privateKeyArmoredEncrypted, + publicKey: publicKeyArmored, + revocationCertificate: revocationCertificate, + }; + return keys; + }, + }; + + // Method to hash password. If salt is passed, use it, in other case use crypto lib for generate salt + public passToHash = (passObject: PassObjectInterface): { salt: string; hash: string } => { + const salt = passObject.salt ? CryptoJS.enc.Hex.parse(passObject.salt) : CryptoJS.lib.WordArray.random(128 / 8); + const hash = CryptoJS.PBKDF2(passObject.password, salt, { keySize: 256 / 32, iterations: 10000 }); + const hashedObjetc = { + salt: salt.toString(), + hash: hash.toString(), + }; + + return hashedObjetc; + }; + + // AES Plain text encryption method + public encryptText = (textToEncrypt: string): string => { + const APP_CRYPTO_SECRET = ConfigService.instance.get('APP_CRYPTO_SECRET'); + return this.encryptTextWithKey(textToEncrypt, APP_CRYPTO_SECRET); + }; + + // AES Plain text decryption method + public decryptText = (encryptedText: string): string => { + const APP_CRYPTO_SECRET = ConfigService.instance.get('APP_CRYPTO_SECRET'); + return this.decryptTextWithKey(encryptedText, APP_CRYPTO_SECRET); + }; + + // AES Plain text encryption method with enc. key + public encryptTextWithKey = (textToEncrypt: string, keyToEncrypt: string): string => { + const bytes = CryptoJS.AES.encrypt(textToEncrypt, keyToEncrypt).toString(); + const text64 = CryptoJS.enc.Base64.parse(bytes); + + return text64.toString(CryptoJS.enc.Hex); + }; + + // AES Plain text decryption method with enc. key + public decryptTextWithKey = (encryptedText: string, keyToDecrypt: string): string => { + const reb = CryptoJS.enc.Hex.parse(encryptedText); + const bytes = CryptoJS.AES.decrypt(reb.toString(CryptoJS.enc.Base64), keyToDecrypt); + + return bytes.toString(CryptoJS.enc.Utf8); + }; +} diff --git a/src/utils/crypto.utils.ts b/src/utils/crypto.utils.ts deleted file mode 100644 index 54311c1..0000000 --- a/src/utils/crypto.utils.ts +++ /dev/null @@ -1,47 +0,0 @@ -import CryptoJS from 'crypto-js'; -import { ConfigService } from '../services/config.service'; - -interface PassObjectInterface { - salt?: string | null; - password: string; -} - -// Method to hash password. If salt is passed, use it, in other case use crypto lib for generate salt -export const passToHash = (passObject: PassObjectInterface): { salt: string; hash: string } => { - const salt = passObject.salt ? CryptoJS.enc.Hex.parse(passObject.salt) : CryptoJS.lib.WordArray.random(128 / 8); - const hash = CryptoJS.PBKDF2(passObject.password, salt, { keySize: 256 / 32, iterations: 10000 }); - const hashedObjetc = { - salt: salt.toString(), - hash: hash.toString(), - }; - - return hashedObjetc; -}; - -// AES Plain text encryption method -export const encryptText = (textToEncrypt: string): string => { - const APP_CRYPTO_SECRET = ConfigService.instance.get('APP_CRYPTO_SECRET'); - return encryptTextWithKey(textToEncrypt, APP_CRYPTO_SECRET); -}; - -// AES Plain text decryption method -export const decryptText = (encryptedText: string): string => { - const APP_CRYPTO_SECRET = ConfigService.instance.get('APP_CRYPTO_SECRET'); - return decryptTextWithKey(encryptedText, APP_CRYPTO_SECRET); -}; - -// AES Plain text encryption method with enc. key -export const encryptTextWithKey = (textToEncrypt: string, keyToEncrypt: string): string => { - const bytes = CryptoJS.AES.encrypt(textToEncrypt, keyToEncrypt).toString(); - const text64 = CryptoJS.enc.Base64.parse(bytes); - - return text64.toString(CryptoJS.enc.Hex); -}; - -// AES Plain text decryption method with enc. key -export const decryptTextWithKey = (encryptedText: string, keyToDecrypt: string): string => { - const reb = CryptoJS.enc.Hex.parse(encryptedText); - const bytes = CryptoJS.AES.decrypt(reb.toString(CryptoJS.enc.Base64), keyToDecrypt); - - return bytes.toString(CryptoJS.enc.Utf8); -}; diff --git a/test/fixtures/auth.fixture.ts b/test/fixtures/auth.fixture.ts new file mode 100644 index 0000000..e5b3c82 --- /dev/null +++ b/test/fixtures/auth.fixture.ts @@ -0,0 +1,27 @@ +import crypto from 'crypto'; + +export const UserFixture = { + userId: crypto.randomBytes(16).toString('hex'), + uuid: crypto.randomBytes(16).toString('hex'), + email: crypto.randomBytes(16).toString('hex'), + name: crypto.randomBytes(16).toString('hex'), + lastname: crypto.randomBytes(16).toString('hex'), + username: crypto.randomBytes(16).toString('hex'), + bridgeUser: crypto.randomBytes(16).toString('hex'), + bucket: crypto.randomBytes(16).toString('hex'), + backupsBucket: crypto.randomBytes(16).toString('hex'), + root_folder_id: crypto.randomInt(1, 9999), + sharedWorkspace: false, + credit: crypto.randomInt(1, 9999), + mnemonic: crypto.randomBytes(16).toString('hex'), + privateKey: crypto.randomBytes(16).toString('hex'), + publicKey: crypto.randomBytes(16).toString('hex'), + revocationKey: crypto.randomBytes(16).toString('hex'), + teams: false, + appSumoDetails: null, + registerCompleted: true, + hasReferralsProgram: false, + createdAt: new Date(), + avatar: crypto.randomBytes(16).toString('hex'), + emailVerified: true, +}; diff --git a/test/services/auth.service.test.ts b/test/services/auth.service.test.ts new file mode 100644 index 0000000..d5d80cd --- /dev/null +++ b/test/services/auth.service.test.ts @@ -0,0 +1,100 @@ +import { expect } from 'chai'; +import sinon, { SinonSandbox } from 'sinon'; +import crypto from 'crypto'; +import { AuthService } from '../../src/services/auth.service'; +import { KeysService } from '../../src/services/keys.service'; +import { CryptoService } from '../../src/services/crypto.service'; +import { SdkManager } from '../../src/services/SDKManager.service'; +import { UserFixture } from '../fixtures/auth.fixture'; +import { Auth, LoginDetails, SecurityDetails } from '@internxt/sdk'; + +describe('Auth service', () => { + let authServiceSandbox: SinonSandbox; + + beforeEach(() => { + authServiceSandbox = sinon.createSandbox(); + }); + + afterEach(function () { + authServiceSandbox.restore(); + }); + + it('When user logs in, then login user credentials are generated', async () => { + const loginResponse = { + token: crypto.randomBytes(16).toString('hex'), + newToken: crypto.randomBytes(16).toString('hex'), + user: UserFixture, + userTeam: null, + }; + + authServiceSandbox.stub(Auth.prototype, 'login').returns(Promise.resolve(loginResponse)); + authServiceSandbox.stub(SdkManager.instance, 'getAuth').returns(Auth.prototype); + authServiceSandbox.stub(KeysService.instance, 'decryptPrivateKey').returns(loginResponse.user.privateKey); + authServiceSandbox.stub(KeysService.instance, 'assertPrivateKeyIsValid').resolves(); + authServiceSandbox.stub(KeysService.instance, 'assertValidateKeys').resolves(); + authServiceSandbox.stub(CryptoService.instance, 'decryptTextWithKey').returns(loginResponse.user.mnemonic); + + const responseLogin = await AuthService.instance.doLogin( + loginResponse.user.email, + crypto.randomBytes(16).toString('hex'), + '', + ); + const expectedResponseLogin = { + user: { ...loginResponse.user, privateKey: Buffer.from(loginResponse.user.privateKey).toString('base64') }, + token: loginResponse.token, + newToken: loginResponse.newToken, + mnemonic: loginResponse.user.mnemonic, + }; + expect(responseLogin).to.eql(expectedResponseLogin); + }); + + it('When user logs in and credentials are not correct, then an error is thrown', async () => { + const loginDetails: LoginDetails = { + email: crypto.randomBytes(16).toString('hex'), + password: crypto.randomBytes(8).toString('hex'), + tfaCode: crypto.randomInt(1, 999999).toString().padStart(6, '0'), + }; + + authServiceSandbox.stub(Auth.prototype, 'login').withArgs(loginDetails, CryptoService.cryptoProvider).rejects(); + authServiceSandbox.stub(SdkManager.instance, 'getAuth').returns(Auth.prototype); + + try { + await AuthService.instance.doLogin(loginDetails.email, loginDetails.password, loginDetails.tfaCode || ''); + expect(false).to.be.true; //should throw error + } catch { + /* no op */ + } + }); + + it('When two factor authentication property is enabled at securityDetails endpoint, then it is returned from is2FANeeded functionality', async () => { + const email = crypto.randomBytes(16).toString('hex'); + const securityDetails: SecurityDetails = { + encryptedSalt: crypto.randomBytes(16).toString('hex'), + tfaEnabled: true, + }; + + authServiceSandbox + .stub(Auth.prototype, 'securityDetails') + .withArgs(email) + .returns(Promise.resolve(securityDetails)); + authServiceSandbox.stub(SdkManager.instance, 'getAuth').returns(Auth.prototype); + + const responseLogin = await AuthService.instance.is2FANeeded(email); + + expect(responseLogin).to.equal(securityDetails.tfaEnabled); + }); + + it('When email is not correct when checking two factor authentication property, then an error is thrown', async () => { + const email = crypto.randomBytes(16).toString('hex'); + + authServiceSandbox.stub(Auth.prototype, 'securityDetails').withArgs(email).rejects(); + authServiceSandbox.stub(SdkManager.instance, 'getAuth').returns(Auth.prototype); + + try { + await AuthService.instance.is2FANeeded(email); + expect(false).to.be.true; //should throw error + } catch { + /* no op */ + } + }); +}); diff --git a/test/tsconfig.json b/test/tsconfig.json index 95898fc..a4ac0c6 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,8 +1,10 @@ { "extends": "../tsconfig", "compilerOptions": { - "noEmit": true + "noEmit": true, + "rootDir": "." }, + "include": ["**/*"], "references": [ {"path": ".."} ]