diff --git a/index.js b/index.js index a44bf77b..55caa154 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,7 @@ const { EventEmitter } = require('events'); const { Buffer } = require('buffer'); const bip39 = require('@metamask/bip39'); const ObservableStore = require('obs-store'); -const encryptor = require('browser-passworder'); +const encryptor = require('@metamask/browser-passworder'); const { normalize: normalizeAddress } = require('eth-sig-util'); const SimpleKeyring = require('eth-simple-keyring'); @@ -14,6 +14,7 @@ const KEYRINGS_TYPE_MAP = { HD_KEYRING: 'HD Key Tree', SIMPLE_KEYRING: 'Simple Key Pair', }; + /** * Strip the hex prefix from an address, if present * @param {string} address - The address that might be hex prefixed. @@ -40,12 +41,17 @@ class KeyringController extends EventEmitter { this.store = new ObservableStore(initState); this.memStore = new ObservableStore({ isUnlocked: false, - keyringTypes: this.keyringTypes.map((krt) => krt.type), + keyringTypes: this.keyringTypes.map((keyringType) => keyringType.type), keyrings: [], + encryptionKey: null, }); this.encryptor = opts.encryptor || encryptor; this.keyrings = []; + + // This option allows the controller to cache an exported key + // for use in decrypting and encrypting data without password + this.cacheEncryptionKey = Boolean(opts.cacheEncryptionKey); } /** @@ -143,9 +149,15 @@ class KeyringController extends EventEmitter { * @returns {Promise} A Promise that resolves to the state. */ async setLocked() { + delete this.password; + // set locked - this.password = null; - this.memStore.updateState({ isUnlocked: false }); + this.memStore.updateState({ + isUnlocked: false, + encryptionKey: null, + encryptionSalt: null, + }); + // remove keyrings this.keyrings = []; await this._updateMemStoreKeyrings(); @@ -168,6 +180,28 @@ class KeyringController extends EventEmitter { */ async submitPassword(password) { this.keyrings = await this.unlockKeyrings(password); + + this.setUnlocked(); + this.fullUpdate(); + } + + /** + * Submit Encryption Key + * + * Attempts to decrypt the current vault and load its keyrings + * into memory based on the vault and CryptoKey information + * + * @emits KeyringController#unlock + * @param {string} encryptionKey - The encrypted key information used to decrypt the vault + * @param {string} encryptionSalt - The salt used to generate the last key + * @returns {Promise} A Promise that resolves to the state. + */ + async submitEncryptionKey(encryptionKey, encryptionSalt) { + this.keyrings = await this.unlockKeyrings( + undefined, + encryptionKey, + encryptionSalt, + ); this.setUnlocked(); this.fullUpdate(); } @@ -215,7 +249,6 @@ class KeyringController extends EventEmitter { this.keyrings.push(keyring); await this.persistAllKeyrings(); - await this._updateMemStoreKeyrings(); this.fullUpdate(); return keyring; @@ -297,7 +330,6 @@ class KeyringController extends EventEmitter { }); await this.persistAllKeyrings(); - await this._updateMemStoreKeyrings(); this.fullUpdate(); } @@ -347,7 +379,6 @@ class KeyringController extends EventEmitter { } await this.persistAllKeyrings(); - await this._updateMemStoreKeyrings(); this.fullUpdate(); } @@ -513,6 +544,14 @@ class KeyringController extends EventEmitter { * @returns {Promise} Resolves to true once keyrings are persisted. */ async persistAllKeyrings() { + const { encryptionKey, encryptionSalt } = this.memStore.getState(); + + if (!this.password && !encryptionKey) { + throw new Error( + 'Cannot persist vault without password and encryption key', + ); + } + const serializedKeyrings = await Promise.all( this.keyrings.map(async (keyring) => { const [type, data] = await Promise.all([ @@ -522,11 +561,48 @@ class KeyringController extends EventEmitter { return { type, data }; }), ); - const encryptedString = await this.encryptor.encrypt( - this.password, - serializedKeyrings, - ); - this.store.updateState({ vault: encryptedString }); + + let vault; + let newEncryptionKey; + + if (this.cacheEncryptionKey) { + if (this.password) { + const { vault: newVault, exportedKeyString } = + await this.encryptor.encryptWithDetail( + this.password, + serializedKeyrings, + ); + + vault = newVault; + newEncryptionKey = exportedKeyString; + } else if (encryptionKey) { + const key = await this.encryptor.importKey(encryptionKey); + const vaultJSON = await this.encryptor.encryptWithKey( + key, + serializedKeyrings, + ); + vaultJSON.salt = encryptionSalt; + vault = JSON.stringify(vaultJSON); + } + } else { + vault = await this.encryptor.encrypt(this.password, serializedKeyrings); + } + + if (!vault) { + throw new Error('Cannot persist vault without vault information'); + } + + this.store.updateState({ vault }); + + // The keyring updates need to be announced before updating the encryptionKey + // so that the updated keyring gets propagated to the extension first. + // Not calling _updateMemStoreKeyrings results in the wrong account being selected + // in the extension. + await this._updateMemStoreKeyrings(); + if (newEncryptionKey) { + this.memStore.updateState({ encryptionKey: newEncryptionKey }); + } + return true; } @@ -537,17 +613,55 @@ class KeyringController extends EventEmitter { * initializing the persisted keyrings to RAM. * * @param {string} password - The keyring controller password. + * @param {string} encryptionKey - An exported key string to unlock keyrings with + * @param {string} encryptionSalt - The salt used to encrypt the vault * @returns {Promise>} The keyrings. */ - async unlockKeyrings(password) { + async unlockKeyrings(password, encryptionKey, encryptionSalt) { const encryptedVault = this.store.getState().vault; if (!encryptedVault) { throw new Error('Cannot unlock without a previous vault.'); } await this.clearKeyrings(); - const vault = await this.encryptor.decrypt(password, encryptedVault); - this.password = password; + + let vault; + + if (this.cacheEncryptionKey) { + if (password) { + const result = await this.encryptor.decryptWithDetail( + password, + encryptedVault, + ); + vault = result.vault; + this.password = password; + + this.memStore.updateState({ + encryptionKey: result.exportedKeyString, + encryptionSalt: result.salt, + }); + } else { + const parsedEncryptedVault = JSON.parse(encryptedVault); + + if (encryptionSalt !== parsedEncryptedVault.salt) { + throw new Error('Encryption key and salt provided are expired'); + } + + const key = await this.encryptor.importKey(encryptionKey); + vault = await this.encryptor.decryptWithKey(key, parsedEncryptedVault); + + // This call is required on the first call because encryptionKey + // is not yet inside the memStore + this.memStore.updateState({ + encryptionKey, + encryptionSalt, + }); + } + } else { + vault = await this.encryptor.decrypt(password, encryptedVault); + this.password = password; + } + await Promise.all(vault.map(this._restoreKeyring.bind(this))); await this._updateMemStoreKeyrings(); return this.keyrings; @@ -602,7 +716,7 @@ class KeyringController extends EventEmitter { * @returns {Keyring|undefined} The class, if it exists. */ getKeyringClassForType(type) { - return this.keyringTypes.find((kr) => kr.type === type); + return this.keyringTypes.find((keyring) => keyring.type === type); } /** @@ -744,7 +858,6 @@ class KeyringController extends EventEmitter { if (keyring.forgetDevice) { keyring.forgetDevice(); this.persistAllKeyrings(); - this._updateMemStoreKeyrings.bind(this)(); } else { throw new Error( `KeyringController - keyring does not have method "forgetDevice", keyring type: ${keyring.type}`, diff --git a/package.json b/package.json index 103251d4..47d65a33 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ }, "dependencies": { "@metamask/bip39": "^4.0.0", + "@metamask/browser-passworder": "^4.0.1", "@metamask/eth-hd-keyring": "^4.0.2", - "browser-passworder": "^2.0.3", "eth-sig-util": "^3.0.1", "eth-simple-keyring": "^4.2.0", "obs-store": "^4.0.3" diff --git a/test/index.js b/test/index.js index 775fd9ba..bb0e2508 100644 --- a/test/index.js +++ b/test/index.js @@ -9,6 +9,12 @@ const KeyringController = require('..'); const mockEncryptor = require('./lib/mock-encryptor'); const password = 'password123'; + +const MOCK_ENCRYPTION_KEY = + '{"alg":"A256GCM","ext":true,"k":"wYmxkxOOFBDP6F6VuuYFcRt_Po-tSLFHCWVolsHs4VI","key_ops":["encrypt","decrypt"],"kty":"oct"}'; +const MOCK_ENCRYPTION_SALT = 'HQ5sfhsb8XAQRJtD+UqcImT7Ve4n3YMagrh05YTOsjk='; +const MOCK_ENCRYPTION_DATA = `{"data":"2fOOPRKClNrisB+tmqIcETyZvDuL2iIR1Hr1nO7XZHyMqVY1cDBetw2gY5C+cIo1qkpyv3bPp+4buUjp38VBsjbijM0F/FLOqWbcuKM9h9X0uwxsgsZ96uwcIf5I46NiMgoFlhppTTMZT0Nkocz+SnvHM0IgLsFan7JqBU++vSJvx2M1PDljZSunOsqyyL+DKmbYmM4umbouKV42dipUwrCvrQJmpiUZrSkpMJrPJk9ufDQO4CyIVo0qry3aNRdYFJ6rgSyq/k6rXMwGExCMHn8UlhNnAMuMKWPWR/ymK1bzNcNs4VU14iVjEXOZGPvD9cvqVe/VtcnIba6axNEEB4HWDOCdrDh5YNWwMlQVL7vSB2yOhPZByGhnEOloYsj2E5KEb9jFGskt7EKDEYNofr6t83G0c+B72VGYZeCvgtzXzgPwzIbhTtKkP+gdBmt2JNSYrTjLypT0q+v4C9BN1xWTxPmX6TTt0NzkI9pJxgN1VQAfSU9CyWTVpd4CBkgom2cSBsxZ2MNbdKF+qSWz3fQcmJ55hxM0EGJSt9+8eQOTuoJlBapRk4wdZKHR2jdKzPjSF2MAmyVD2kU51IKa/cVsckRFEes+m7dKyHRvlNwgT78W9tBDdZb5PSlfbZXnv8z5q1KtAj2lM2ogJ7brHBdevl4FISdTkObpwcUMcvACOOO0dj6CSYjSKr0ZJ2RLChVruZyPDxEhKGb/8Kv8trLOR3mck/et6d050/NugezycNk4nnzu5iP90gPbSzaqdZI=","iv":"qTGO1afGv3waHN9KoW34Eg==","salt":"${MOCK_ENCRYPTION_SALT}"}`; + const walletOneSeedWords = 'puzzle seed penalty soldier say clay field arctic metal hen cage runway'; const walletOneAddresses = ['0xef35ca8ebb9669a35c31b5f6f249a9941a812ac1']; @@ -20,6 +26,7 @@ const walletTwoAddresses = [ '0xbbafcf3d00fb625b65bb1497c94bf42c1f4b3e78', '0x49dd2653f38f75d40fdbd51e83b9c9724c87f7eb', ]; + describe('KeyringController', function () { let keyringController; @@ -29,6 +36,7 @@ describe('KeyringController', function () { }); await keyringController.createNewVaultAndKeychain(password); + await keyringController.submitPassword(password); }); afterEach(function () { @@ -45,7 +53,7 @@ describe('KeyringController', function () { await keyringController.setLocked(); - expect(keyringController.password).toBeNull(); + expect(keyringController.password).toBeUndefined(); expect(keyringController.memStore.getState().isUnlocked).toBe(false); expect(keyringController.keyrings).toHaveLength(0); }); @@ -61,16 +69,18 @@ describe('KeyringController', function () { }); describe('submitPassword', function () { - it('should not create new keyrings when called in series', async function () { + it('should not load keyrings when incorrect password', async function () { await keyringController.createNewVaultAndKeychain(password); await keyringController.persistAllKeyrings(); expect(keyringController.keyrings).toHaveLength(1); - await keyringController.submitPassword(`${password}a`); - expect(keyringController.keyrings).toHaveLength(1); + await keyringController.setLocked(); - await keyringController.submitPassword(''); - expect(keyringController.keyrings).toHaveLength(1); + await expect( + keyringController.submitPassword(`${password}a`), + ).rejects.toThrow('Incorrect password.'); + expect(keyringController.password).toBeUndefined(); + expect(keyringController.keyrings).toHaveLength(0); }); it('emits "unlock" event', async function () { @@ -122,8 +132,8 @@ describe('KeyringController', function () { it('clears old keyrings and creates a one', async function () { const initialAccounts = await keyringController.getAccounts(); expect(initialAccounts).toHaveLength(1); - await keyringController.addNewKeyring('HD Key Tree'); + await keyringController.addNewKeyring('HD Key Tree'); const allAccounts = await keyringController.getAccounts(); expect(allAccounts).toHaveLength(2); @@ -460,4 +470,124 @@ describe('KeyringController', function () { ); }); }); + + describe('cacheEncryptionKey', function () { + it('sets encryption key data upon submitPassword', async function () { + keyringController.cacheEncryptionKey = true; + await keyringController.submitPassword(password); + + expect(keyringController.password).toBe(password); + expect(keyringController.memStore.getState().encryptionSalt).toBe('SALT'); + expect(keyringController.memStore.getState().encryptionKey).toStrictEqual( + expect.stringMatching('.+'), + ); + }); + + it('unlocks the keyrings with valid information', async function () { + keyringController.cacheEncryptionKey = true; + const returnValue = await keyringController.encryptor.decryptWithKey(); + const stub = sinon.stub(keyringController.encryptor, 'decryptWithKey'); + stub.resolves(Promise.resolve(returnValue)); + + keyringController.store.updateState({ vault: MOCK_ENCRYPTION_DATA }); + + await keyringController.setLocked(); + + await keyringController.submitEncryptionKey( + MOCK_ENCRYPTION_KEY, + MOCK_ENCRYPTION_SALT, + ); + + expect(keyringController.encryptor.decryptWithKey.calledOnce).toBe(true); + expect(keyringController.keyrings).toHaveLength(1); + }); + + it('should not load keyrings when invalid encryptionKey format', async function () { + keyringController.cacheEncryptionKey = true; + await keyringController.setLocked(); + keyringController.store.updateState({ vault: MOCK_ENCRYPTION_DATA }); + + await expect( + keyringController.submitEncryptionKey(`{}`, MOCK_ENCRYPTION_SALT), + ).rejects.toThrow( + `Failed to execute 'importKey' on 'SubtleCrypto': The provided value is not of type '(ArrayBuffer or ArrayBufferView or JsonWebKey)'.`, + ); + expect(keyringController.password).toBeUndefined(); + expect(keyringController.keyrings).toHaveLength(0); + }); + + it('should not load keyrings when encryptionKey is expired', async function () { + keyringController.cacheEncryptionKey = true; + await keyringController.setLocked(); + keyringController.store.updateState({ vault: MOCK_ENCRYPTION_DATA }); + + await expect( + keyringController.submitEncryptionKey( + MOCK_ENCRYPTION_KEY, + 'OUTDATED_SALT', + ), + ).rejects.toThrow('Encryption key and salt provided are expired'); + expect(keyringController.password).toBeUndefined(); + expect(keyringController.keyrings).toHaveLength(0); + }); + + it('persists keyrings when actions are performed', async function () { + keyringController.cacheEncryptionKey = true; + await keyringController.setLocked(); + keyringController.store.updateState({ vault: MOCK_ENCRYPTION_DATA }); + await keyringController.submitEncryptionKey( + MOCK_ENCRYPTION_KEY, + MOCK_ENCRYPTION_SALT, + ); + + const [firstKeyring] = keyringController.keyrings; + + await keyringController.addNewAccount(firstKeyring); + expect(await keyringController.getAccounts()).toHaveLength(2); + + await keyringController.addNewAccount(firstKeyring); + expect(await keyringController.getAccounts()).toHaveLength(3); + + const account = { + privateKey: + 'c87509a1c067bbde78beb793e6fa76530b6382a4c0241e5e4a9ec0a0f44dc0d3', + publicKey: '0x627306090abab3a6e1400e9345bc60c78a8bef57', + }; + + // Add a new keyring with one account + await keyringController.addNewKeyring('Simple Key Pair', [ + account.privateKey, + ]); + expect(await keyringController.getAccounts()).toHaveLength(4); + + // remove that account that we just added + await keyringController.removeAccount(account.publicKey); + expect(await keyringController.getAccounts()).toHaveLength(3); + }); + + it('triggers an error when trying to persist without password or encryption key', async function () { + keyringController.password = undefined; + await expect(keyringController.persistAllKeyrings()).rejects.toThrow( + 'Cannot persist vault without password and encryption key', + ); + }); + + it('cleans up login artifacts upon lock', async function () { + keyringController.cacheEncryptionKey = true; + await keyringController.submitPassword(password); + expect(keyringController.password).toBe(password); + expect( + keyringController.memStore.getState().encryptionSalt, + ).toStrictEqual(expect.stringMatching('.+')); + expect(keyringController.memStore.getState().encryptionKey).toStrictEqual( + expect.stringMatching('.+'), + ); + + await keyringController.setLocked(); + + expect(keyringController.memStore.getState().encryptionSalt).toBeNull(); + expect(keyringController.password).toBeUndefined(); + expect(keyringController.memStore.getState().encryptionKey).toBeNull(); + }); + }); }); diff --git a/test/lib/mock-encryptor.js b/test/lib/mock-encryptor.js index 6089a370..a319b39d 100644 --- a/test/lib/mock-encryptor.js +++ b/test/lib/mock-encryptor.js @@ -1,21 +1,72 @@ const sinon = require('sinon'); -const mockHex = '0xabcdef0123456789'; -const mockKey = Buffer.alloc(32); +const PASSWORD = 'password123'; +const MOCK_ENCRYPTION_KEY = + '{"alg":"A256GCM","ext":true,"k":"wYmxkxOOFBDP6F6VuuYFcRt_Po-tSLFHCWVolsHs4VI","key_ops":["encrypt","decrypt"],"kty":"oct"}'; +const MOCK_ENCRYPTION_SALT = 'HQ5sfhsb8XAQRJtD+UqcImT7Ve4n3YMagrh05YTOsjk='; +const MOCK_ENCRYPTION_DATA = `{"data":"2fOOPRKClNrisB+tmqIcETyZvDuL2iIR1Hr1nO7XZHyMqVY1cDBetw2gY5C+cIo1qkpyv3bPp+4buUjp38VBsjbijM0F/FLOqWbcuKM9h9X0uwxsgsZ96uwcIf5I46NiMgoFlhppTTMZT0Nkocz+SnvHM0IgLsFan7JqBU++vSJvx2M1PDljZSunOsqyyL+DKmbYmM4umbouKV42dipUwrCvrQJmpiUZrSkpMJrPJk9ufDQO4CyIVo0qry3aNRdYFJ6rgSyq/k6rXMwGExCMHn8UlhNnAMuMKWPWR/ymK1bzNcNs4VU14iVjEXOZGPvD9cvqVe/VtcnIba6axNEEB4HWDOCdrDh5YNWwMlQVL7vSB2yOhPZByGhnEOloYsj2E5KEb9jFGskt7EKDEYNofr6t83G0c+B72VGYZeCvgtzXzgPwzIbhTtKkP+gdBmt2JNSYrTjLypT0q+v4C9BN1xWTxPmX6TTt0NzkI9pJxgN1VQAfSU9CyWTVpd4CBkgom2cSBsxZ2MNbdKF+qSWz3fQcmJ55hxM0EGJSt9+8eQOTuoJlBapRk4wdZKHR2jdKzPjSF2MAmyVD2kU51IKa/cVsckRFEes+m7dKyHRvlNwgT78W9tBDdZb5PSlfbZXnv8z5q1KtAj2lM2ogJ7brHBdevl4FISdTkObpwcUMcvACOOO0dj6CSYjSKr0ZJ2RLChVruZyPDxEhKGb/8Kv8trLOR3mck/et6d050/NugezycNk4nnzu5iP90gPbSzaqdZI=","iv":"qTGO1afGv3waHN9KoW34Eg==","salt":"${MOCK_ENCRYPTION_SALT}"}`; + +const INVALID_PASSWORD_ERROR = 'Incorrect password.'; + +const MOCK_HEX = '0xabcdef0123456789'; +const MOCK_KEY = Buffer.alloc(32); let cacheVal; module.exports = { encrypt: sinon.stub().callsFake(function (_password, dataObj) { cacheVal = dataObj; - return Promise.resolve(mockHex); + + return Promise.resolve(MOCK_HEX); + }), + + encryptWithDetail: sinon.stub().callsFake(function (_password, dataObj) { + cacheVal = dataObj; + + return Promise.resolve({ vault: MOCK_HEX, exportedKeyString: '' }); }), - decrypt(_password, _text) { + async decrypt(_password, _text) { + if (_password && _password !== PASSWORD) { + throw new Error(INVALID_PASSWORD_ERROR); + } + return Promise.resolve(cacheVal || {}); }, - encryptWithKey(key, dataObj) { - return this.encrypt(key, dataObj); + async decryptWithEncryptedKeyString(_keyStr) { + const { vault } = await this.decryptWithDetail(); + return vault; + }, + + decryptWithDetail(_password, _text) { + if (_password && _password !== PASSWORD) { + throw new Error(INVALID_PASSWORD_ERROR); + } + + const result = cacheVal + ? { + vault: cacheVal, + exportedKeyString: MOCK_ENCRYPTION_KEY, + salt: 'SALT', + } + : {}; + return Promise.resolve(result); + }, + + importKey(keyString) { + if (keyString === '{}') { + throw new TypeError( + `Failed to execute 'importKey' on 'SubtleCrypto': The provided value is not of type '(ArrayBuffer or ArrayBufferView or JsonWebKey)'.`, + ); + } + return null; + }, + + encryptWithKey() { + const data = JSON.parse(MOCK_ENCRYPTION_DATA); + // Salt is not provided from this method + delete data.salt; + return data; }, decryptWithKey(key, text) { @@ -23,7 +74,7 @@ module.exports = { }, keyFromPassword(_password) { - return Promise.resolve(mockKey); + return Promise.resolve(MOCK_KEY); }, generateSalt() { diff --git a/yarn.lock b/yarn.lock index f8ffc0ad..31cfff2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -547,6 +547,11 @@ pbkdf2 "^3.0.9" randombytes "^2.0.1" +"@metamask/browser-passworder@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@metamask/browser-passworder/-/browser-passworder-4.0.1.tgz#bb2613bb1886774e18da4107ab94a21042e5e3da" + integrity sha512-mtekzCKph/S/15hRfWFDIUrpvz9mNoIU0CmH0SOlTHpLhalonEsZ+56MbQQUDshXbytzHp1eKr29OHrIx0u9iQ== + "@metamask/eslint-config-jest@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@metamask/eslint-config-jest/-/eslint-config-jest-7.0.0.tgz#81612aaf5307c3d65bb43366000233cd0b6e9db4" @@ -1204,13 +1209,6 @@ brorand@^1.1.0: resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= -browser-passworder@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/browser-passworder/-/browser-passworder-2.0.3.tgz#6fdd2082e516a176edbcb3dcee0b7f9fce4f7917" - integrity sha1-b90gguUWoXbtvLPc7gt/n85PeRc= - dependencies: - browserify-unibabel "^3.0.0" - browser-process-hrtime@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" @@ -1228,11 +1226,6 @@ browserify-aes@^1.0.6, browserify-aes@^1.2.0: inherits "^2.0.1" safe-buffer "^5.0.1" -browserify-unibabel@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/browserify-unibabel/-/browserify-unibabel-3.0.0.tgz#5a6b8f0f704ce388d3927df47337e25830f71dda" - integrity sha1-WmuPD3BM44jTkn30czfiWDD3Hdo= - browserslist@^4.16.6: version "4.16.6" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2"