diff --git a/spec/common/misc/utils.spec.ts b/spec/common/misc/utils.spec.ts new file mode 100644 index 0000000000..28fcbd2398 --- /dev/null +++ b/spec/common/misc/utils.spec.ts @@ -0,0 +1,34 @@ +import { Utils } from '../../../src/misc/utils'; + +describe('Utils Service', () => { + describe('getHostname', () => { + it('should fail for invalid urls', () => { + expect(Utils.getHostname(null)).toBeNull(); + expect(Utils.getHostname(undefined)).toBeNull(); + expect(Utils.getHostname(' ')).toBeNull(); + expect(Utils.getHostname('https://bit!:"_&ward.com')).toBeNull(); + expect(Utils.getHostname('bitwarden')).toBeNull(); + }); + + it('should handle valid urls', () => { + expect(Utils.getHostname('bitwarden.com')).toBe('bitwarden.com'); + expect(Utils.getHostname('https://bitwarden.com')).toBe('bitwarden.com'); + expect(Utils.getHostname('http://bitwarden.com')).toBe('bitwarden.com'); + expect(Utils.getHostname('http://vault.bitwarden.com')).toBe('vault.bitwarden.com'); + expect(Utils.getHostname('https://user:password@bitwarden.com:8080/password/sites?and&query#hash')) + .toBe('bitwarden.com'); + }); + + it('should support localhost and IP', () => { + expect(Utils.getHostname('https://localhost')).toBe('localhost'); + expect(Utils.getHostname('https://192.168.1.1')).toBe('192.168.1.1'); + }); + }); + + describe('newGuid', () => { + it('should create a valid guid', () => { + const validGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(Utils.newGuid()).toMatch(validGuid); + }); + }); +}); diff --git a/spec/web/services/utils.service.spec.ts b/spec/web/services/utils.service.spec.ts deleted file mode 100644 index 1241674bc1..0000000000 --- a/spec/web/services/utils.service.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { UtilsService } from '../../../src/services/utils.service'; - -describe('Utils Service', () => { - describe('getHostname', () => { - it('should fail for invalid urls', () => { - expect(UtilsService.getHostname(null)).toBeNull(); - expect(UtilsService.getHostname(undefined)).toBeNull(); - expect(UtilsService.getHostname(' ')).toBeNull(); - expect(UtilsService.getHostname('https://bit!:"_&ward.com')).toBeNull(); - expect(UtilsService.getHostname('bitwarden')).toBeNull(); - }); - - it('should handle valid urls', () => { - expect(UtilsService.getHostname('bitwarden.com')).toBe('bitwarden.com'); - expect(UtilsService.getHostname('https://bitwarden.com')).toBe('bitwarden.com'); - expect(UtilsService.getHostname('http://bitwarden.com')).toBe('bitwarden.com'); - expect(UtilsService.getHostname('http://vault.bitwarden.com')).toBe('vault.bitwarden.com'); - expect(UtilsService.getHostname('https://user:password@bitwarden.com:8080/password/sites?and&query#hash')) - .toBe('bitwarden.com'); - }); - - it('should support localhost and IP', () => { - expect(UtilsService.getHostname('https://localhost')).toBe('localhost'); - expect(UtilsService.getHostname('https://192.168.1.1')).toBe('192.168.1.1'); - }); - }); - - describe('newGuid', () => { - it('should create a valid guid', () => { - const validGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - expect(UtilsService.newGuid()).toMatch(validGuid); - }); - }); -}); diff --git a/src/abstractions/crypto.service.ts b/src/abstractions/crypto.service.ts index 2b2adbda6e..d2bb86d1eb 100644 --- a/src/abstractions/crypto.service.ts +++ b/src/abstractions/crypto.service.ts @@ -30,4 +30,5 @@ export abstract class CryptoService { decrypt: (cipherString: CipherString, key?: SymmetricCryptoKey) => Promise; decryptToUtf8: (cipherString: CipherString, key?: SymmetricCryptoKey) => Promise; decryptFromBytes: (encBuf: ArrayBuffer, key: SymmetricCryptoKey) => Promise; + randomNumber: (min: number, max: number) => Promise; } diff --git a/src/abstractions/index.ts b/src/abstractions/index.ts index a727ee11a8..c9d10b5e58 100644 --- a/src/abstractions/index.ts +++ b/src/abstractions/index.ts @@ -21,4 +21,3 @@ export { SyncService } from './sync.service'; export { TokenService } from './token.service'; export { TotpService } from './totp.service'; export { UserService } from './user.service'; -export { UtilsService } from './utils.service'; diff --git a/src/abstractions/passwordGeneration.service.ts b/src/abstractions/passwordGeneration.service.ts index a8967db281..44f7cee32d 100644 --- a/src/abstractions/passwordGeneration.service.ts +++ b/src/abstractions/passwordGeneration.service.ts @@ -1,7 +1,7 @@ import { PasswordHistory } from '../models/domain/passwordHistory'; export abstract class PasswordGenerationService { - generatePassword: (options: any) => string; + generatePassword: (options: any) => Promise; getOptions: () => any; saveOptions: (options: any) => Promise; getHistory: () => Promise; diff --git a/src/abstractions/utils.service.ts b/src/abstractions/utils.service.ts deleted file mode 100644 index 9dd87774b9..0000000000 --- a/src/abstractions/utils.service.ts +++ /dev/null @@ -1,5 +0,0 @@ -export abstract class UtilsService { - copyToClipboard: (text: string, doc?: Document) => void; - getHostname: (uriString: string) => string; - getHost: (uriString: string) => string; -} diff --git a/src/angular/components/password-generator.component.ts b/src/angular/components/password-generator.component.ts index e4a35c8445..12f6267023 100644 --- a/src/angular/components/password-generator.component.ts +++ b/src/angular/components/password-generator.component.ts @@ -28,7 +28,7 @@ export class PasswordGeneratorComponent implements OnInit { async ngOnInit() { this.options = await this.passwordGenerationService.getOptions(); this.avoidAmbiguous = !this.options.ambiguous; - this.password = this.passwordGenerationService.generatePassword(this.options); + this.password = await this.passwordGenerationService.generatePassword(this.options); this.analytics.eventTrack.next({ action: 'Generated Password' }); await this.passwordGenerationService.addHistory(this.password); } @@ -41,7 +41,7 @@ export class PasswordGeneratorComponent implements OnInit { async sliderInput() { this.normalizeOptions(); - this.password = this.passwordGenerationService.generatePassword(this.options); + this.password = await this.passwordGenerationService.generatePassword(this.options); } async saveOptions(regenerate: boolean = true) { @@ -54,7 +54,7 @@ export class PasswordGeneratorComponent implements OnInit { } async regenerate() { - this.password = this.passwordGenerationService.generatePassword(this.options); + this.password = await this.passwordGenerationService.generatePassword(this.options); await this.passwordGenerationService.addHistory(this.password); this.analytics.eventTrack.next({ action: 'Regenerated Password' }); } diff --git a/src/misc/utils.ts b/src/misc/utils.ts index bcb9f76c80..bf68d929c9 100644 --- a/src/misc/utils.ts +++ b/src/misc/utils.ts @@ -95,6 +95,68 @@ export class Utils { return Array.prototype.map.call(bytes, (x: number) => ('00' + x.toString(16)).slice(-2)).join(''); } } + + static urlBase64Decode(str: string): string { + let output = str.replace(/-/g, '+').replace(/_/g, '/'); + switch (output.length % 4) { + case 0: + break; + case 2: + output += '=='; + break; + case 3: + output += '='; + break; + default: + throw new Error('Illegal base64url string!'); + } + + return decodeURIComponent(escape(window.atob(output))); + } + + // ref: http://stackoverflow.com/a/2117523/1090359 + static newGuid(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + // tslint:disable-next-line + const r = Math.random() * 16 | 0; + // tslint:disable-next-line + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + static getHostname(uriString: string): string { + const url = Utils.getUrl(uriString); + return url != null ? url.hostname : null; + } + + static getHost(uriString: string): string { + const url = Utils.getUrl(uriString); + return url != null ? url.host : null; + } + + private static getUrl(uriString: string): URL { + if (uriString == null) { + return null; + } + + uriString = uriString.trim(); + if (uriString === '') { + return null; + } + + if (uriString.indexOf('://') === -1 && uriString.indexOf('.') > -1) { + uriString = 'http://' + uriString; + } + + if (uriString.startsWith('http://') || uriString.startsWith('https://')) { + try { + return new URL(uriString); + } catch (e) { } + } + + return null; + } } Utils.init(); diff --git a/src/models/view/loginUriView.ts b/src/models/view/loginUriView.ts index 6e221e02d3..0c78bb50c8 100644 --- a/src/models/view/loginUriView.ts +++ b/src/models/view/loginUriView.ts @@ -6,7 +6,7 @@ import { LoginUri } from '../domain/loginUri'; import { PlatformUtilsService } from '../../abstractions/platformUtils.service'; -import { UtilsService } from '../../services/utils.service'; +import { Utils } from '../../misc/utils'; export class LoginUriView implements View { match: UriMatchType = null; @@ -52,7 +52,7 @@ export class LoginUriView implements View { get hostname(): string { if (this._hostname == null && this.uri != null) { - this._hostname = UtilsService.getHostname(this.uri); + this._hostname = Utils.getHostname(this.uri); if (this._hostname === '') { this._hostname = null; } diff --git a/src/services/appId.service.ts b/src/services/appId.service.ts index ba60a92551..c37668d192 100644 --- a/src/services/appId.service.ts +++ b/src/services/appId.service.ts @@ -1,4 +1,4 @@ -import { UtilsService } from './utils.service'; +import { Utils } from '../misc/utils'; import { AppIdService as AppIdServiceAbstraction } from '../abstractions/appId.service'; import { StorageService } from '../abstractions/storage.service'; @@ -21,7 +21,7 @@ export class AppIdService implements AppIdServiceAbstraction { return existingId; } - const guid = UtilsService.newGuid(); + const guid = Utils.newGuid(); await this.storageService.save(key, guid); return guid; } diff --git a/src/services/cipher.service.ts b/src/services/cipher.service.ts index 5f7dc33a27..e2be81f25f 100644 --- a/src/services/cipher.service.ts +++ b/src/services/cipher.service.ts @@ -36,7 +36,8 @@ import { PlatformUtilsService } from '../abstractions/platformUtils.service'; import { SettingsService } from '../abstractions/settings.service'; import { StorageService } from '../abstractions/storage.service'; import { UserService } from '../abstractions/user.service'; -import { UtilsService } from '../abstractions/utils.service'; + +import { Utils } from '../misc/utils'; const Keys = { ciphersPrefix: 'ciphers_', @@ -50,7 +51,7 @@ export class CipherService implements CipherServiceAbstraction { constructor(private cryptoService: CryptoService, private userService: UserService, private settingsService: SettingsService, private apiService: ApiService, private storageService: StorageService, private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, private utilsService: UtilsService) { + private platformUtilsService: PlatformUtilsService) { } clearCache(): void { @@ -221,8 +222,8 @@ export class CipherService implements CipherServiceAbstraction { } break; case UriMatchType.Host: - const urlHost = this.utilsService.getHost(url); - if (urlHost != null && urlHost === this.utilsService.getHost(u.uri)) { + const urlHost = Utils.getHost(url); + if (urlHost != null && urlHost === Utils.getHost(u.uri)) { return true; } break; diff --git a/src/services/crypto.service.ts b/src/services/crypto.service.ts index ab68394959..3055953df3 100644 --- a/src/services/crypto.service.ts +++ b/src/services/crypto.service.ts @@ -355,6 +355,42 @@ export class CryptoService implements CryptoServiceAbstraction { macBytes != null ? macBytes.buffer : null, key); } + // EFForg/OpenWireless + // ref https://github.com/EFForg/OpenWireless/blob/master/app/js/diceware.js + async randomNumber(min: number, max: number): Promise { + let rval = 0; + const range = max - min + 1; + const bitsNeeded = Math.ceil(Math.log2(range)); + if (bitsNeeded > 53) { + throw new Error('We cannot generate numbers larger than 53 bits.'); + } + + const bytesNeeded = Math.ceil(bitsNeeded / 8); + const mask = Math.pow(2, bitsNeeded) - 1; + // 7776 -> (2^13 = 8192) -1 == 8191 or 0x00001111 11111111 + + // Fill a byte array with N random numbers + const byteArray = new Uint8Array(await this.cryptoFunctionService.randomBytes(bytesNeeded)); + + let p = (bytesNeeded - 1) * 8; + for (let i = 0; i < bytesNeeded; i++) { + rval += byteArray[i] * Math.pow(2, p); + p -= 8; + } + + // Use & to apply the mask and reduce the number of recursive lookups + // tslint:disable-next-line + rval = rval & mask; + + if (rval >= range) { + // Integer out of acceptable range + return this.randomNumber(min, max); + } + + // Return an integer that falls within the range + return min + rval; + } + // Helpers private async aesEncrypt(plainValue: ArrayBuffer, key: SymmetricCryptoKey): Promise { diff --git a/src/services/index.ts b/src/services/index.ts index bddd1809b8..bd096fdc10 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -19,4 +19,3 @@ export { SyncService } from './sync.service'; export { TokenService } from './token.service'; export { TotpService } from './totp.service'; export { UserService } from './user.service'; -export { UtilsService } from './utils.service'; diff --git a/src/services/passwordGeneration.service.ts b/src/services/passwordGeneration.service.ts index 8674a277ca..b923e5eb76 100644 --- a/src/services/passwordGeneration.service.ts +++ b/src/services/passwordGeneration.service.ts @@ -1,8 +1,6 @@ import { CipherString } from '../models/domain/cipherString'; import { PasswordHistory } from '../models/domain/passwordHistory'; -import { UtilsService } from './utils.service'; - import { CryptoService } from '../abstractions/crypto.service'; import { PasswordGenerationService as PasswordGenerationServiceAbstraction, @@ -30,7 +28,12 @@ const Keys = { const MaxPasswordsInHistory = 100; export class PasswordGenerationService implements PasswordGenerationServiceAbstraction { - static generatePassword(options: any): string { + private optionsCache: any; + private history: PasswordHistory[]; + + constructor(private cryptoService: CryptoService, private storageService: StorageService) { } + + async generatePassword(options: any): Promise { // overload defaults with given options const o = Object.assign({}, DefaultOptions, options); @@ -83,9 +86,7 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr } // shuffle - positions.sort(() => { - return UtilsService.secureRandomNumber(0, 1) * 2 - 1; - }); + await this.shuffleArray(positions); // build out the char sets let allCharSet = ''; @@ -138,25 +139,18 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr case 'a': positionChars = allCharSet; break; + default: + console.log('unknown position at ' + i + ': ' + positions[i]); + break; } - const randomCharIndex = UtilsService.secureRandomNumber(0, positionChars.length - 1); + const randomCharIndex = await this.cryptoService.randomNumber(0, positionChars.length - 1); password += positionChars.charAt(randomCharIndex); } return password; } - private optionsCache: any; - private history: PasswordHistory[]; - - constructor(private cryptoService: CryptoService, private storageService: StorageService) { - } - - generatePassword(options: any) { - return PasswordGenerationService.generatePassword(options); - } - async getOptions() { if (this.optionsCache == null) { const options = await this.storageService.get(Keys.options); @@ -252,4 +246,12 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr return history[history.length - 1].password === password; } + + // ref: https://stackoverflow.com/a/12646864/1090359 + private async shuffleArray(array: string[]) { + for (let i = array.length - 1; i > 0; i--) { + const j = await this.cryptoService.randomNumber(0, i); + [array[i], array[j]] = [array[j], array[i]]; + } + } } diff --git a/src/services/token.service.ts b/src/services/token.service.ts index 94dbfa10fd..7b9807e00d 100644 --- a/src/services/token.service.ts +++ b/src/services/token.service.ts @@ -1,9 +1,10 @@ import { ConstantsService } from './constants.service'; -import { UtilsService } from './utils.service'; import { StorageService } from '../abstractions/storage.service'; import { TokenService as TokenServiceAbstraction } from '../abstractions/token.service'; +import { Utils } from '../misc/utils'; + const Keys = { accessToken: 'accessToken', refreshToken: 'refreshToken', @@ -94,7 +95,7 @@ export class TokenService implements TokenServiceAbstraction { throw new Error('JWT must have 3 parts'); } - const decoded = UtilsService.urlBase64Decode(parts[1]); + const decoded = Utils.urlBase64Decode(parts[1]); if (decoded == null) { throw new Error('Cannot decode the token'); } diff --git a/src/services/utils.service.ts b/src/services/utils.service.ts deleted file mode 100644 index 390d710924..0000000000 --- a/src/services/utils.service.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { UtilsService as UtilsServiceAbstraction } from '../abstractions/utils.service'; - -export class UtilsService implements UtilsServiceAbstraction { - static copyToClipboard(text: string, doc?: Document): void { - doc = doc || document; - if ((window as any).clipboardData && (window as any).clipboardData.setData) { - // IE specific code path to prevent textarea being shown while dialog is visible. - (window as any).clipboardData.setData('Text', text); - } else if (doc.queryCommandSupported && doc.queryCommandSupported('copy')) { - const textarea = doc.createElement('textarea'); - textarea.textContent = text; - // Prevent scrolling to bottom of page in MS Edge. - textarea.style.position = 'fixed'; - doc.body.appendChild(textarea); - textarea.select(); - - try { - // Security exception may be thrown by some browsers. - doc.execCommand('copy'); - } catch (e) { - // tslint:disable-next-line - console.warn('Copy to clipboard failed.', e); - } finally { - doc.body.removeChild(textarea); - } - } - } - - static urlBase64Decode(str: string): string { - let output = str.replace(/-/g, '+').replace(/_/g, '/'); - switch (output.length % 4) { - case 0: - break; - case 2: - output += '=='; - break; - case 3: - output += '='; - break; - default: - throw new Error('Illegal base64url string!'); - } - - return decodeURIComponent(escape(window.atob(output))); - } - - // ref: http://stackoverflow.com/a/2117523/1090359 - static newGuid(): string { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - // tslint:disable-next-line - const r = Math.random() * 16 | 0; - // tslint:disable-next-line - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - } - - // EFForg/OpenWireless - // ref https://github.com/EFForg/OpenWireless/blob/master/app/js/diceware.js - static secureRandomNumber(min: number, max: number): number { - let rval = 0; - const range = max - min + 1; - const bitsNeeded = Math.ceil(Math.log2(range)); - if (bitsNeeded > 53) { - throw new Error('We cannot generate numbers larger than 53 bits.'); - } - - const bytesNeeded = Math.ceil(bitsNeeded / 8); - const mask = Math.pow(2, bitsNeeded) - 1; - // 7776 -> (2^13 = 8192) -1 == 8191 or 0x00001111 11111111 - - // Create byte array and fill with N random numbers - const byteArray = new Uint8Array(bytesNeeded); - window.crypto.getRandomValues(byteArray); - - let p = (bytesNeeded - 1) * 8; - for (let i = 0; i < bytesNeeded; i++) { - rval += byteArray[i] * Math.pow(2, p); - p -= 8; - } - - // Use & to apply the mask and reduce the number of recursive lookups - // tslint:disable-next-line - rval = rval & mask; - - if (rval >= range) { - // Integer out of acceptable range - return UtilsService.secureRandomNumber(min, max); - } - - // Return an integer that falls within the range - return min + rval; - } - - static fromB64ToArray(str: string): Uint8Array { - const binaryString = window.atob(str); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; - } - - static fromUtf8ToArray(str: string): Uint8Array { - const strUtf8 = unescape(encodeURIComponent(str)); - const arr = new Uint8Array(strUtf8.length); - for (let i = 0; i < strUtf8.length; i++) { - arr[i] = strUtf8.charCodeAt(i); - } - return arr; - } - - static fromBufferToB64(buffer: ArrayBuffer): string { - let binary = ''; - const bytes = new Uint8Array(buffer); - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - return window.btoa(binary); - } - - static fromBufferToUtf8(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - const encodedString = String.fromCharCode.apply(null, bytes); - return decodeURIComponent(escape(encodedString)); - } - - // ref: https://stackoverflow.com/a/40031979/1090359 - static fromBufferToHex(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - return Array.prototype.map.call(bytes, (x: number) => ('00' + x.toString(16)).slice(-2)).join(''); - } - - static getHostname(uriString: string): string { - const url = UtilsService.getUrl(uriString); - return url != null ? url.hostname : null; - } - - static getHost(uriString: string): string { - const url = UtilsService.getUrl(uriString); - return url != null ? url.host : null; - } - - private static getUrl(uriString: string): URL { - if (uriString == null) { - return null; - } - - uriString = uriString.trim(); - if (uriString === '') { - return null; - } - - if (uriString.indexOf('://') === -1 && uriString.indexOf('.') > -1) { - uriString = 'http://' + uriString; - } - - if (uriString.startsWith('http://') || uriString.startsWith('https://')) { - try { - return new URL(uriString); - } catch (e) { } - } - - return null; - } - - getHostname(uriString: string): string { - return UtilsService.getHostname(uriString); - } - - getHost(uriString: string): string { - return UtilsService.getHost(uriString); - } - - copyToClipboard(text: string, doc?: Document) { - UtilsService.copyToClipboard(text, doc); - } -}