Берем под контроль криптографию в облачном хранилище MEGA

    После запуска в какой-то мере скандального сервиса MEGA разговоры о его защищенности немного побурлили и затихли. На сегодняшний день сервис живет своей жизнью и его никто даже не поломал. Из всех разговоров почему-то был упущен термин «User Controlled Encryption» (UCE, или Контролируемая пользователем криптография), которой кичится MEGA. Под словом «упущен» я подразумеваю тот факт, что мы не рассмотрели все возможности, которые дает нам криптографический движок, выполняющийся в JavaScript на стороне клиента.

    Конечно, сам сервис MEGA под этим подразумевает всего лишь то, что ключи шифрования не хранятся на сервере, а вся их криптография выполняется в контексте браузера. При этом после запуска сервиса было много разговоров о том, что в нем используются нестойкие криптографические алгоритмы и что вообще все плохо и мы все умрем, а наши файлы прочитает ФСБ. Это подтолкнуло меня на мысль расширить понятие «UCE» и действительно взять криптографию под свой контроль, а именно — заменить или дополнить некоторые механизмы обеспечения безопасности сервиса.

    В этой статье я частично разложу по полочкам магию, которая происходит в двух мегабайтах JavaScript-кода MEGA и покажу, как можно переопределить некоторые методы, чтобы перестать волноваться и полюбить криптографию. В результате мы получим сервис облачного хранения файлов с двухфакторной аутентификацией и аппаратным шифрованием критически важной информации.

    MEGA, UCE и все-все-все

    Итак, начнем с того, что рассмотрим технологии, на которых построена клиентская часть сервиса, как происходит регистрация новых пользователей, аутентификация зарегистрированных пользователей, смена пароля и загрузка/скачивание файлов.

    JavaScript

    Как вам уже может быть известно, вся клиентская часть сервиса основана на JavaScript, в коде главной страницы прописаны контрольные суммы SHA-256 для всех скриптов и страниц, которые загружаются браузером. Сама загрузка происходит следующим образом: у всех файлов проверяются контрольные суммы, после чего они объединяются в один BLOB, который отдается браузеру. По исходному коду js-файлов видно, что их писали разные люди и порой встречаются забавные перлы, как например последствия копипаста, бессмысленные условия и просто странные переменные.







    В процессе изучения исходного кода сайта я также заметил, что он довольно активно обновляется, разработчики исправляют мелкие ошибки и оптимизируют уже написанный код, что не может не радовать. Сам код написан весьма прямолинейно и без излишней накрутки в виде прототипов: сайт обходится тремя сотнями глобальных переменных и более чем 8000 функций. Разбираться в архитектуре сайта и менять его код было весьма просто.

    Из сторонних фреймворков MEGA использует jQuery (без него сейчас никуда), Ext JS и SJCL. Последний как раз реализует криптографическое ядро с AES-шифрованием. SJCL также обуславливает интересный формат хранения ключей и прочих байт-массивов: вместо того, чтобы просто гонять байты в обычном массиве, они «сжимаются» в формат, который именуется a32. Его суть в том, что содержимое любого массива байт пакуется в 32-битные числа и записывается в массив меньшей длины. То есть, каждые 4 байта массива преобразуются в один банальный int. В коде сайта есть функции, которые выполняют всевозможные преобразования над импровизированным множеством {a32 array, string, base64 string}.

    Ключевая информация

    Прежде, чем перейти к описанию процессов регистрации и аутентификации, стоит рассмотреть информацию, которая подлежит зашифрованию, а именно:
    • Мастер-ключ учетной записи, который создается случайным образом в момент регистрации пользователя и имеет длину в 128 бит. Да и в принципе, длина всех ключей, используемых для симметричного шифрования, равна 128 бит.
    • Закрытый ключ RSA: создается в момент регистрации на основе движений мыши и ввода с клавиатуры. В данной статье я не буду акцентировать внимание на ассиметричной криптографии, поскольку она используется для общего доступа к загруженным файлам, а у меня стояла задача изменить процесс аутентификации и шифрования данных пользователя.
    • Индивидуальные ключи файлов и сами файлы, загружаемые на сервис. Ключи создаются случайным образом при загрузке файла, для зашифрования данных файла используется сам этот ключ, а для зашифрования атрибутов — ключ, созданный на основе индивидуального ключа файла и его контрольной суммы.

    Ближе к коду

    Сейчас я предлагаю разобрать процессы регистрации и аутентификации, посмотреть, как создается мастер-ключ и как производится его зашифрование.
    Я тут попытался изобразить на бумажке эти процессы и дабы дать вам понять всю суть безумия, даже сделал вот такую фотку:



    Регистрация нового пользователя

    Сам по себе процесс регистрации довольно запутанный, после заполнения пользователем анкеты вызывается могучая кучка функций, но нас интересует функция api_createuser:

    // создание нового пользователя и его мастер-ключа
    function api_createuser(ctx, invitecode, invitename, uh) {
    	var i;
    	var ssc = Array(4); // session self challenge, will be used to verify password
    	var req, res;
    	if (!ctx.passwordkey) {
    		ctx.passwordkey = Array(4);
    		for (i = 4; i--;) ctx.passwordkey[i] = rand(0x100000000);
    	}
    	if (!u_k) api_create_u_k(); // генерирование случайного мастер-ключа u_k
    	for (i = 4; i--;) ssc[i] = rand(0x100000000); // генерирование случайной аутентификационной последовательности
    	if (d) console.log("api_createuser - masterkey: " + u_k + " passwordkey: " + ctx.passwordkey);
    
    	// зашифрование мастер-ключа на текущем пароле и отправка его на сервер (поле k)
    	// поле ts представляет собой конкатенацию ssc с ее зашифрованным значением
    	req = {
    		a: 'up',
    		k: a32_to_base64(encrypt_key(new sjcl.cipher.aes(ctx.passwordkey), u_k)),
    		ts: base64urlencode(a32_to_str(ssc) + a32_to_str(encrypt_key(new sjcl.cipher.aes(u_k), ssc)))
    	};
    	if (invitecode) {
    		req.uh = uh;
    		req.ic = invitecode;
    		req.name = invitename;
    	}
    	if (d) console.log("Storing key: " + req.k);
    	api_req([req], ctx);
    }
    

    В этой функции нас интересуют следующие вещи:
    • u_k — сам мастер-ключ, глобальная переменная. Массив из 4х 32-битных чисел, который создается функцией api_create_uk
    • ssc — просто случайный массив, который зашифровывается на мастер-ключе, конкатенируется со своим открытым значением и отправляется на сервер. Позже он будет использован для проверки корректности мастер-ключа при аутентификации
    • sjcl — криптографическая библиотека, реализующая AES
    • rand() — местная реализация генератора псевдослучайных чисел, основанная на RC4
    • encrypt_key() — функция-краеугольный камень симметричной криптографии сервиса. Принимает инициализированный ключом объект sjcl и массив, который надо зашифровать. Код функции приведен ниже и, надеюсь, не нуждается в пояснениях.

    // encrypt/decrypt 4- or 8-element 32-bit integer array
    function encrypt_key(cipher, a) {
    	if (a.length == 4) return cipher.encrypt(a);
    	var x = [];
    	for (var i = 0; i < a.length; i += 4) 
    		x = x.concat(cipher.encrypt([a[i], a[i + 1], a[i + 2], a[i + 3]]));
    	return x;
    }
    

    В итоге после регистрации на сервер отправляются:
    • Мастер-ключ, зашифрованный на ключе, выведенном из пароля учетной записи
    • Строка вида ssc||encrypt_AES-128(u_k, ssc)

    Вход пользователя в систему

    Теперь можно плавно перейти к процессу аутентификации. Вкратце оно производится так:
    1. Пользователь вводит логин/пароль
    2. Если первый этап аутентификации пройден, то с сервера приходит зашифрованный мастер-ключ и аутентификационная последовательность (ssc), созданная при регистрации
    3. Производится расшифрование мастер-ключа на введенном пользователем пароле
    4. На мастер-ключе расшифровывается аутентификационная последовательность и сравнивается со своим открытым значением — таким образом проверяется корректность мастер-ключа и пароля.
    За все вышеописанное отвечает callback-функция api_getsid2:

    // расшифрование мастер-ключа после входа пользователя в систему
    function api_getsid2(res, ctx) {
    	var t, k;
    	var r = false;
    	if (typeof res == 'object') {
    		// инициализируем sjcl-aes текущим паролем учетки
    		var aes = new sjcl.cipher.aes(ctx.passwordkey);
    		// если нам в ответе сервера пришел мастер-ключ...
    		if (typeof res[0].k == 'string') {
    			k = base64_to_a32(res[0].k);
    			if (k.length == 4) {
    				// ... то расшифровываем его
    				k = decrypt_key(aes, k);
    				// и пере-инициализируем sjcl-aes, используя мастер-ключ
    				aes = new sjcl.cipher.aes(k);
    				// если нам пришла ssc из процесса регистрации
    				if (typeof res[0].tsid == 'string') {
    					t = base64urldecode(res[0].tsid);
    					// зашифровываем первую половину строки и сравниваем со значением с сервера
    					// если они совпали - значит, все явки и пароли сошлись и можно впустить юзера
    					if (a32_to_str(encrypt_key(aes, str_to_a32(t.substr(0, 16)))) == t.substr(-16)) r = [k, res[0].tsid];
    				} 
    				// ниже разбирается закрытый ключ RSA-пары, нам это пока не интересно
    				else if (typeof res[0].csid == 'string') {
    					var t = mpi2b(base64urldecode(res[0].csid));
    					var privk = a32_to_str(decrypt_key(aes, base64_to_a32(res[0].privk)));
    					var rsa_privk = Array(4);
    
    					// decompose private key
    					for (var i = 0; i < 4; i++) {
    						var l = ((privk.charCodeAt(0) * 256 + privk.charCodeAt(1) + 7) >> 3) + 2;
    						rsa_privk[i] = mpi2b(privk.substr(0, l));
    						if (typeof rsa_privk[i] == 'number') break;
    						privk = privk.substr(l);
    					}
    
    					// check format
    					if (i == 4 && privk.length < 16) {
    						// TODO: check remaining padding for added early wrong password detection likelihood
    						r = [k, base64urlencode(crypto_rsadecrypt(t, rsa_privk).substr(0, 43)), rsa_privk];
    					}
    				}
    			}
    		}
    	}
    	ctx.result(ctx, r);
    }
    

    Как бонус к регистрации/аутентификации можно взглянуть на процесс смены пароля.

    // смена пароля пользователя
    function changepw(currentpw, newpw, ctx) {
    	var pw_aes = new sjcl.cipher.aes(prepare_key_pw(newpw));
    	api_req([{
    		a: 'up',
    		currk: a32_to_base64(encrypt_key(new sjcl.cipher.aes(prepare_key_pw(currentpw)), u_k)),
    		k: a32_to_base64(encrypt_key(pw_aes, u_k)),
    		uh: stringhash(u_attr['email'].toLowerCase(), pw_aes)
    	}], ctx);
    }
    

    Код этой функции говорит сам за себя: мы зашифровываем мастер-ключ на двух ключах, полученных из старого и нового паролей, а затем отправляем эти значения на сервер. Если текущий пароль подошел, то он заменяется на новый. Тут я больше хотел обратить внимание на функцию prepare_key_pw, которая неявно присутствовала во всех предыдущих операциях. Ее задача — преобразовать строковый пароль в a32-массив, а потом выполнить операцию деривации ключа следующим образом:

    // convert user-supplied password array
    function prepare_key(a) {
    	var i, j, r;
    	var aes = [];
    	var pkey = [0x93C467E3, 0x7DB0C7A4, 0xD1BE3F81, 0x0152CB56];
    
    	for (j = 0; j < a.length; j += 4) {
    		key = [0, 0, 0, 0];
    		for (i = 0; i < 4; i++) 
    			if (i + j < a.length) 
    				key[i] = a[i + j];
    			aes.push(new sjcl.cipher.aes(key));
    	}
    	for (r = 65536; r--;) 
    		for (j = 0; j < aes.length; j++) 
    			pkey = aes[j].encrypt(pkey);
    	return pkey;
    }
    

    Эта функция вызвала много нареканий, поскольку основана на доморощенном алгоритме. За время написания статьи создатели сервиса успели немного поменять ее код, но существенных изменений я тут не заметил. Ее суть состоит в том, что переданный пароль зашифровывается 65536 раз на константном ключе для того, чтобы получить неотличимый от случайного ключ. Почему создатели сервиса не воспользовались существующими алгоритмами (например, PBKDF2), остается загадкой.

    Загрузка и зашифрование файлов

    Вкратце весь этот процесс можно представить вот так:

    Предупреждаю, долгое вникание в эту картинку опасно для мозга, поэтому ниже я расскажу, как же все это происходит.

    Как я уже говорил, при загрузке для каждого файла создается свой случайный ключ-массив из 6ти 32-битных чисел. Первые четыре элемента этого массива используются для зашифрования содержимого файла, а два последних — как начальные значения счетчика, с помощью которого вычисляется контрольная сумма файла. Этот массив хранится в глобальной переменной ul_key. Его же содержимое заносится в JSON-сериализированную строку ul_KeyNonce.

    Само за(рас)шифрование происходит с помощью Web Worker (если браузер поддерживает эту технологию) или просто внутри основного кода страницы. Когда файл становится готов к отправке, для зашифрования его атрибутов (на данный момент под атрибутами подразумевается только имя файла) создается новый ключ filekey, основанный на ul_key и контрольной сумме файла. Этот ключ затем зашифровывается на мастер-ключе и отправляется на сервер вместе с атрибутами файла. За все эти действия отвечают функции initupload3 и api_completeupload2. Создание ключа filekey происходит в функции ul_chunkcomplete, ниже я приведу ее часть.

    // начало загрузки файла: создание его индивидуального ключа и инициализация механизма шифрования
    function initupload3() {
    	// ... вырезано =)
    
    	// создание случайного индивидуального ключа файла
    	// ul_key используется в коде страницы,
    	// ul_keyNonce передавается в Web Worker и используется там 
    	// для зашифрования файла и вычисления его контрольной суммы
    	ul_key = Array(6);
    	for (i = 6; i--;) ul_key[i] = rand(0x100000000);
    	ul_keyNonce = JSON.stringify(ul_key);
    	ul_macs = [];
    
    	// ... дальше идет обработка очереди загрузки, она не несет интереса ...
    
    	// инициализация sjcl-aes для файла на основе ul_key
    	ul_aes = new sjcl.cipher.aes([ul_key[0], ul_key[1], ul_key[2], ul_key[3]]);
    
    	// ...
    
    	// запуск процесса загрузки файла: 
    	// чтение его с диска, зашифрование и отправка
    	onUploadStart(ul_queue_num);
    	ul_dispatch_chain();
    }
    
    // создание ключа для зашифрования атрибутов файла
    function ul_chunkcomplete(slot,pos,response)
    {
    	// ...
    	var t = [];
    	// ul_macs - массив с контрольной суммой файла, полученной внутри worker'а
    	for (p in ul_macs) t.push(p);
    	// заполнение и сортировка временного массива, если кто знает зачем это - объясните пожалуйста
    	t.sort(function(a,b) { return parseInt(a)-parseInt(b) });
    	for (var i = 0; i < t.length; i++) 
    		t[i] = ul_macs[t[i]];
    	// внутри condenseMacs производится зашифрование 
    	// и "уплотнение" контрольной суммы файла в массив из 4х элементов
    	var mac = condenseMacs(t,ul_key);
    	ul_settimeout(-1);
    	// на основе контрольной суммы и ключа файла создается ключ для шифрования атрибутов
    	// он же в зашифрованном виде позже будет отправлен на сервер
    	var filekey = [ul_key[0]^ul_key[4],ul_key[1]^ul_key[5],ul_key[2]^mac[0]^mac[1],ul_key[3]^mac[2]^mac[3],ul_key[4],ul_key[5],mac[0]^mac[1],mac[2]^mac[3]];
    	// ...
    }
    
    // завершение загрузки файла: зашифрование атрибутов и ключа файла и отправка их на сервер
    function api_completeupload2(ctx, ut) {
    	var p;
    	if (ctx.path && ctx.path != ctx.n && (p = ctx.path.indexOf('/')) > 0) {
    		var pc = ctx.path.substr(0, p);
    		ctx.path = ctx.path.substr(p + 1);
    		fm_requestfolderid(ut, pc, ctx);
    	} else {
    		// зашифрование имени файла на ключе, выведенном из ul_key и контрольной суммы
    		// ctx.k == filekey
    		a = { n: ctx.n };
    		if (d) console.log(ctx.k);
    		var ea = enc_attr(a, ctx.k);
    		if (d) console.log(ea);
    		// передача атрибутов и зашифрованного на мастер-ключе ключа файла
    		var req = {
    			a: 'p',
    			t: ut,
    			n: [{
    				h: ctx.t,
    				t: 0,
    				a: ab_to_base64(ea[0]), // атрибуты
    				k: a32_to_base64(encrypt_key(u_k_aes, ctx.k)), // == AES_encrypt(u_k, filekey)
    				fa: ctx.fa
    			}]
    		};
    
    		if (ut) {
    			// a target has been supplied: encrypt to all relevant shares
    			var sn = fm_getsharenodes(ut);
    			if (sn.length) {
    				req.cr = crypto_makecr([ctx.k], sn, false);
    				req.cr[1][0] = ctx.t;
    			}
    		}
    		api_req([req], ctx.ctx);
    	}
    }
    

    Скачивание и расшифрование файлов

    Очевидно, что эти процессы должны быть просто обратными к зашифрованию файла. Единственное что может нести интерес — это получение значения ключа ul_key из пришедшего с сервера зашифрованного значения filekey.

    На момент скачивания файла в контексте браузера уже содержится объект, хранящий расшифрованные ключи файлов. Поэтому сначала имеет смысл рассмотреть процесс, который происходит сразу после аутентификации пользователя, а именно — загрузку файл-менеджера. После того как пользователя пустили на сервис, ему естественно хочется получить доступ к своим файлам (предположим, что они у него уже там были). Для этого нам нужно расшифровать сначала ключи файлов, а затем — их атрибуты. Этим делом занимается очередная пачка функций, из которых нас интересуют loadfm_callback и process_f_f.

    Вкратце процесс получения атрибутов файлов можно описать следующим алгоритмом:
    1. Дождаться загрузки файл-менеджера (loadfm_callback), где получить JSON с описанием всех загруженных файлов
    2. Создать массив farray, в который положить массив с информацией о файлах
    3. Запустить (рекурсивно) для каждого файла функцию process_f_f
    4. Для каждого файла, у которого есть ключ, расшифровать этот ключ и атрибуты (функция crypto_processkey) и сохранить их обратно в массив с информацией о файлах
    5. После этого сохранить расшифрованные значения в переменную FileStore (окончание рекурсии в process_f_f)

    Ниже я приведу выдержки из кода, иллюстрирующие этот алгоритм

    // callback загрузки файл-менеджера
    function loadfm_callback(json, res) {
    	// ...
    
    	// обработка JSON с информацией о файлах
    	json = json[0];
    	if (d) console.log(json);
    	if (d) console.log(json);
    	if (json.u) process_u(json.u, false);
    	if (json.ok) process_ok(json.ok);
    
    	if (json.s) {
    		for (i in json.s) {
    			if (u_sharekeys[json.s[i].h]) {
    				sharingData.push({
    					id: json.s[i].h + '_' + json.s[i].u,
    					userid: json.s[i].u,
    					folderid: json.s[i].h,
    					rights: json.s[i].r,
    					date: json.s[i].ts
    				});
    				sharednodes[json.s[i].h] = true;
    			}
    		}
    	}
    	// ... дальше ничего особого...
    
    	// занесение информации о файлах в еще один глобальный массив
    	farray[fi] = new Object;
    	farray[fi].f = json.f;
    	// запуск его обработки, callback был объявлен выше 
    	// в этой функции и просто модифицирует верстку
    	process_f(fi, false, callback);
    	fi++;
    }
    
    // рекурсивная функция, в которой происходит расшифрование ключей и атрибутов файлов
    // вызывается из process_f
    function process_f_f(fid) {
    	// условие окончания рекурсии - мы обработали все файлы в массиве farray
    	if (!farray[fid].f[farray[fid].i]) {
    		if (farray[fid].ap) FileStore.suspendEvents();
    		// запись данных в FileStore
    		FileStore.loadData(farray[fid].mdata, true);
    		if (farray[fid].ap) FileStore.resumeEvents();
    		if (d) console.log('call reqmissingkeys:');
    		crypto_reqmissingkeys();
    		if (farray[fid].callback) farray[fid].callback.fn(farray[fid].callback);
    		return false;
    	}
    	var f = farray[fid].f[farray[fid].i];
    	f.attrs = f.a;
    	if (f.sk) u_sharekeys[f.h] = crypto_process_sharekey(f.h, f.sk);
    	// если файл подходит по типу и имеет ключ, то обработаем его
    	if ((f.t !== 2) && (f.t !== 3) && (f.t !== 4) && (f.k)) {
    		crypto_processkey(u_handle, u_k_aes, f); // описание этой функции ниже
    		u_nodekeys[f.h] = f.key;
    
    		if ((typeof f.name !== 'undefined') && (f.p == InboxID)) InboxCount++;
    	} else {
    		if (f.a) {
    			if (!missingkeys[f.h]) {
    				missingkeys[f.h] = true;
    				newmissingkeys = true;
    			}
    		}
    		f.k = '';
    		f.name = '';
    	}
    
    	if (f.t == 2) RootID = f.h;
    	else if (f.t == 3) InboxID = f.h;
    	else if (f.t == 4) TrashbinID = f.h;
    	else if ((f.t < 2) || (f.t == 5)) {
    		// тут идет обработка расшаренных файлов
    	} else {
    		// подготовка массива для записи в FileStore
    		farray[fid].mdata.push({
    			id: f.h.replace(/[^a-z^A-Z^0-9^_^-]/g, ""),
    			name: f.name,
    			size: f.s,
    			type: filetype(f.name, f.t),
    			icon: fileicon(f.name, icontype),
    			parentid: f.p,
    			folder: f.t,
    			owner: f.u,
    			date: f.ts,
    			attrs: f.attrs,
    			key: f.key,
    			r: f.r,
    			su: f.su,
    			fa: f.fa,
    		});
    
    		if (f.p == TrashbinID) trashbinfull = true;
    		if (((f.t) && (farray[fid].ap)) || (f.p == InboxID)) refreshtree = true;
    	}
    
    	farray[fid].i++;
    	// проверка таймаута (видимо, чтобы загрузка файл-менеджера не выглядела слишком долгой)		
    	timeoutcount++;
    	if (!(timeoutcount & 63)) {
    		// если у нас больше 63 файлов - дальше грузим их асинхронно
    		setTimeout("process_f_f(" + fid + ")", 1);
    		timeoutcount2++;
    	}
    	// иначе - запускаем обработку следующего файла
    	else process_f_f(fid);
    }
    
    // обработка ключа файла и его атрибутов
    function crypto_processkey(me, master_aes, file) {
    	var id, key, k, n;
    	if (!file.k) {
    		if (!keycache[file.h]) return;
    		file.k = keycache[file.h];
    	}
    
    	id = me;
    	// do I own the file? (user key is guaranteed to be first in .k)
    	// ключ записан в виде "<file handle>:<key>/<share key>"
    	var p = file.k.indexOf(id + ':');
    	// сначала проверим, не является ли файл общим
    	if (p) {
    		// I don't - do I have a suitable sharekey?
    		for (id in u_sharekeys) {
    			p = file.k.indexOf(id + ':');
    			if (p >= 0 && (!p || file.k.charAt(p - 1) == '/')) break;
    			p = -1;
    		}
    	}
    	// а затем уже можем перейти к расшифрованию
    	if (p >= 0) {
    		delete keycache[file.h];
    		// слеш - видимо признак шары
    		var pp = file.k.indexOf('/', p);
    		if (pp < 0) pp = file.k.length;
    		p += id.length + 1;
    		key = file.k.substr(p, pp - p);
    		// we have found a suitable key: decrypt!
    		if (key.length < 46) {
    			// short keys: AES
    			k = base64_to_a32(key);
    			// check for permitted key lengths (4 == folder, 8 == file)
    			if (k.length == 4 || k.length == 8) {
    				// ключ расшифровывается либо на мастер-ключе, либо на общем ключе шары				
    				k = decrypt_key(id == me ? master_aes : new sjcl.cipher.aes(u_sharekeys[id]), k);
    			} else {
    				if (d) console.log("Received invalid key length (" + k.length + "): " + file.h);
    				return;
    			}
    		} else {
    			// long keys: RSA
    			if (u_privk) {
    				var t = mpi2b(base64urldecode(key));
    				if (t) k = str_to_a32(crypto_rsadecrypt(t, u_privk).substr(0, file.t ? 16 : 32));
    				else {
    					if (d) console.log("Corrupt key for node " + file.h);
    					return;
    				}
    			} else {
    				if (d) console.log("Received RSA key, but have no public key published: " + file.h);
    				return;
    			}
    		}
    		// декодируем атрибуты файла
    		var ab = base64_to_ab(file.a);
    		// и расшифровываем их с помощью только что полученного ключа
    		var o = dec_attr(ab, k);
    		if (typeof o == 'object') {
    			if (typeof o.n == 'string') {
    				if (file.h) {
    					u_nodekeys[file.h] = k;
    					if (key.length >= 46) rsa2aes[file.h] = a32_to_str(encrypt_key(u_k_aes, k));
    				}
    				// если мы корректно расшифровали ключ и атрибуты - сохраняем их в объект файла
    				file.key = k;
    				file.name = o.n;
    			}
    		}
    	} else {
    		if (d) console.log("Received no suitable key: " + file.h);
    		if (!missingkeys[file.h]) {
    			newmissingkeys = true;
    			missingkeys[file.h] = true;
    		}
    		keycache[file.h] = file.k;
    	}
    }
    

    После этого мы можем получить значение исходного ключа ul_key из контекста браузера следующим образом:
    dl_keyNonce = JSON.stringify([dl_key[0]^dl_key[4],dl_key[1]^dl_key[5],dl_key[2]^dl_key[6],dl_key[3]^dl_key[7],dl_key[4],dl_key[5]]);
    

    Это преобразование происходит в функции startdownload. Если учесть, что значение dl_key == filekey из функции ul_chunkcomplete и выполнить нехитрые операции сложения по модулю, то мы заметим, что в переменной dl_keyNonce будет хранится значение ul_key, сгенерированное при загрузке файла. Иллюстрацию этому можно наблюдать в нижнем левом углу доски на фотографии в начале раздела про загрузку файлов.

    «Перегрузка» криптографических операций

    Несмотря на то, что вышеописанные принципы защиты файлов и ключей являются весьма безопасными, кому-то может не понравиться, что мы все равно зависим от реализации алгоритмов, которые предоставляет сервис. В этом случае мы можем разработать свое расширение для браузера, которое будет переопределять некоторые функции сервиса, реализуя в них дополнительное шифрование. А именно, я решил реализовать защиту ключевой информации (мастер-ключа и ключей файлов) с помощью аппаратного шифрования на неизвлекаемом ключе по алгоритму ГОСТ 28147-89. Бонусом к этому также будет и включение на сервисе двухфакторной аутентификации.
    Итак, рассмотрим такой use-case:
    • Пользователь регистрируется на сервисе
    • Затем он устанавливает расширение
    • С его помощью производится аппаратное зашифрование мастер-ключа на неизвлекаемом с токена ключе
    • Зашифрованный таким образом мастер-ключ загружается на сервер

    После этого будет невозможно получить значение мастер-ключа не обладая токеном и его PIN-кодом. Это даст:
    1. Двухфакторную аутентификацию на сервисе (без корректно расшифрованного мастер-ключа «завалится» функция api_getsid2)
    2. Без токена также нельзя будет сменить текущий пароль учетной записи

    Следующим шагом будет зашифрование с помощью токена ключа шифрования файла (он же ul_key) и ключа атрибутов файла (filekey), который как раз хранится на сервере. Таким образом, мы получим, что каждый файл будут зашифрован на ключе, который никогда не попадет на сервер, куда уйдет зашифрованный нами filekey из функции api_completeupload2. Атрибуты файлов будут зашифрованы на открытом значении filekey. Для пущей наглядности я набросал следующую схему, иллюстрирующую процесс загрузки файла:



    Хочу отметить, что здесь я применил весьма хитрый метод. В данном случае нам важно, чтобы злоумышленник не мог расшифровать файл, даже если он перехватит пришедший с сервера ключ файла и будет знать мастер-ключ пользователя. Поэтому тут можно сыграть на особенностях архитектуры сервиса и использовать для за(рас)шифрования файлов значение ключа ul_keyNonce (оно же dl_keyNonce), полученное в результате зашифрования на токене значения ключа ul_key (или dl_key).

    Используемые технологии

    Для реализации аппаратного шифрования будет использоваться USB-токен Рутокен ЭЦП (также подойдет Рутокен Web) совместно с плагином для браузера «Рутокен Web PKI Edition». Подробное описание плагина мы уже приводили в статьях Рутокен WEB PKI Edition и Щит и меч в системах ДБО. Прикладное решение.

    С момента написания этих статей в наш продукт добавилась возможность аппаратного шифрования по алгоритму ГОСТ 28147-89. Beta-версию плагина с функциональностью аппаратного шифрования по алгоритму ГОСТ 28147-89 можно скачать здесь. Эта версия плагина еще не прошла полное тестирование, поэтому предупреждаю о том, что в ней могут быть ошибки, о нахождении которых прошу сообщать в личку.
    В интерфейсе плагина симметричное зашифрование реализуется функцией encrypt, которая имеет следующий синтаксис:
    encrypt(deviceId, keyLabel, data, resultCallback, errorCallback) → {string}
    

    В качестве входных данных функция принимает:
    • Идентификатор устройства, number
    • Метку ключа для шифрования, number (если такого ключа нет, он будет сгенерирован)
    • Зашифровываемые данные, string (строка, содержащая в себе байт-массив вида "aa:bb:cc:dd")
    • Функции обратного вызова для успешного и неуспешного завершения операции шифрования

    Расшифрование производится аналогично с помощью функции decrypt
    Отдельное внимание стоит уделить метке ключа, поскольку она определяет, на каком ключе будет производится за(рас)шифрование данных. Метка представляет собой произвольную строку и в основном служит для удобной идентификации ключа. В данном случае я использую две ключевых пары: одну для за(рас)шифрования мастер-ключа, вторую — для зашифрования индивидуальных ключей файлов. Ключ, на котором зашифровывается мастер-ключ имеет метку, равную паролю пользователя (сейчас мне пришла в голову идея использовать хеш от строки e-mail||пароль, в ближайшее время я это исправлю). Для зашифрования ключей загружаемых файлов используется ключ с меткой, равной строковому представлению мастер-ключа (здесь также стоит использовать хеш от мастер-ключа).

    Непосредственно разработка

    Сразу хочу сделать замечание по поводу моего исходного кода: он, по сути, находится в альфа-версии, хотя и реализует описанную выше функциональность. Я не проверял, насколько моя переделка получилась совместимой с остальными функциями сервиса, поэтому все исходники я выложил на github и буду рад любой помощи по доработке этой системы. Поэтому я не буду засорять статью дальше огромными листингами, а лишь опишу общую схему работы расширения.

    Готовое расширение можно скачать здесь. Разработано оно с помощью сервиса Crossrider, что дает расширения для трех браузеров (Chrome, Firefox и IE), но проверять его работу лучше в Chrome или Firefox, причем в первом оно работает гораздо стабильнее.

    Код расширения до банального прост: он проверяет, находимся ли мы на странице сервиса и если это так, то просто подгружает дополнительные скрипты. Эти скрипты модифицируют код страницы, добавляя пару диалогов, и переопределяют следующие функции сервиса:
    • changepw: отвечает за смену пароля
    • api_getsid2: один из callback'ов логина
    • api_completeupload2: callback завершения загрузки файла
    • loadfm_callback: callback загрузки файл-менеджера
    • processpacket: очередной callback, в котором расшифровываются атрибуты только что загруженного файла
    • parsepage: отвечает за рисование дополнительных диалогов
    • dologin: расширяет возможности аутентификации
    • initupload3: отвечает за создание ключа шифрования файла
    • startdownload: обратный разбор ключа файла и инициализация скачивания
    Еще раз хочу предупредить, что не стоит тащить расширение на рабочий аккаунт (если тут кто-нибудь вообще пользуется этим сервисом), а лучше завести тестовый. Для того, чтобы воспользоваться расширением после его установки вам будет нужно:
    1. Для начала неплохо раздобыть Рутокен ЭЦП (или Рутокен Web) и установить плагин для браузера
    2. Установить расширение
    3. Залогиниться на сервис с отключенным расширением
    4. Включить расширение в браузере
    5. Зайти на страницу учетной записи
    6. Нажать на кнопку «Привязать токен»
    7. Ввести текущий пароль и выполнить эту операцию
    Вместо расширения можно воспользоваться следующим букмарклетом (проверено в Chrome, Safari, Firefox):
    javascript:(function(){if(document.getElementById('cryptorutokenjs')){alert('Плагин уже установлен');return}function loadRemoteScript(url){var script=document.createElement('script');script.type="text/javascript";script.src=url;document.head.appendChild(script)}function loadRemoteStyle(url){var style=document.createElement('link');style.rel='stylesheet';style.type="text/css";style.href=url;document.head.appendChild(style)}loadRemoteStyle("https://mega-crypto.googlecode.com/git/mega.css");loadRemoteScript("https://mega-crypto.googlecode.com/git/util.js");loadRemoteScript("https://mega-crypto.googlecode.com/git/rutoken-extra.js");loadRemoteScript("https://mega-crypto.googlecode.com/git/rutoken-crypto.js");loadRemoteScript("https://mega-crypto.googlecode.com/git/mega.js")})();
    

    Демонстрация работы

    Для начала подключим наше творение к сайту. Для этого:
    1. Логинимся на сервис и открываем страницу файл-менеджера



    2. Подключаем расширение или букмарклет, после чего нам нужно будет ввести PIN-код токена



    3. Переходим на страницу учетной записи и выполняем привязку токена к аккаунту





    Затем можно выйти из сервиса и попробовать снова зайти, используя двухфакторную аутентификацию:
    1. Вводим логин-пароль

    2. Вводим PIN-код



    3. ...

    4. profit!


    Аутентификация при этом происходит по следующей схеме:
    1. Проверка пары логин-пароль на сервере
    2. Если логин-пароль правильные, то с сервера приходит зашифрованный мастер-ключ
    3. Посредством плагина производится запрос PIN-кода к токену
    4. Если PIN введен правильно, то производится расшифрование мастер-ключа на ключе с токена

    Вместо заключения

    Здесь мне так и хочется написать «продолжение следует...», поскольку я не осветил детали создания расширения и интересности прикручивания асинхронных функций шифрования в сервис, который в большинстве случаев использует синхронные вызовы. В заключении этой статьи я хотел бы еще раз обратиться к идее реализации client-side криптографии.
    Подход к реализации дополнительных криптографических функций на стороне клиента может быть применен к любому веб-сервису, которому не важно, что хранить у себя на сервере: будь то файловое хранилище, почта или простейший чат. Например, можно реализовать защищенную почту на базе любого почтового сервиса с использованием шифрования сообщений в формате CMS и механизмов обмена ключами по алгоритму VKO GOST R 34.10-2001.
    Спасибо за внимание, жду ваших вопросов и комментариев.

    PS: первым пяти желающим (из Москвы) потестировать плагин мы можем подарить по токену — пишите в личку.
    «Актив» 47,32
    Компания
    Поделиться публикацией
    Похожие публикации
    Комментарии 31
    • +5
      Очень круто. О_О
      • +11
        Не обращались к меге — они ведь вполне могут привести ссылку на ваше расширение у себя в блоге?
        • +3
          Я планирую написать Доткому, когда мы здесь получим минимальный набор отзывов. Судя по тому, какой самопиар он разводит в Твиттере, его должно это заинтересовать.
          • 0
            Если это его заинтересует, то только в качестве PR. Цель использования шифрования на МЕГЕ защитить хостинг от правообладателей. Как бы это не было реализовано на МЕГЕ, оно свою задачу выполнят. Безопасность информации их вряд ли волнует.
            Однако подобный подход может быть эффективен при организации защищенного обмена информацией на различных публичных чатах и сервисах электронной почты. Интересно было бы посмотреть такие решения.
        • +1
          Статья замечательная!
          Даже интересно, что вообще можно в продолжении написать?
          • 0
            Начинаю изучать криптографию. Спасибо автору, за хороший пост по этой теме, пригодится в будущем.
            • +1
              было бы клево сделать мини API, где можно было бы например воткнуть Keccak вместо SHA и что нить вместо AES, хоть бы и для тестовых целей
              • +1
                В меге можно легко подменить нижний уровень криптографии (ядро AES, реализуемое библиотекой SJCL). Для этого надо всего лишь, чтобы эта подмена реализовала простой интерфейс с конструктором, принимающим пароль в виде массива из 4х или 8 элементов, и функции за(рас)шифрования таких же массивов. Таким образом мы заменим AES.
                Хешей там используется несколько видов, помимо SHA есть еще HMAC-подобная функция хеширования stringhash, с помощью которой в сервисе хешируется e-mail пользователя в различных ситуациях. Его можно подменить также, как описано в статье.
              • 0
                Вот еще для любителей других языков.
                • 0
                  На Питоновский SDK я натыкался в процессе написания статьи, там весьма хорошо расписана архитектура сервиса и возможности API MEGA.
                • 0
                  А самое интересное — как они получают из JS доступ к КОНТЕНТУ загружаемого файла (и в меньшей степени — как отдают скачиваемый файл из JS-строки в диалог сохранения файла) — в статье и не написали… Разве в стандартных настройках безопасности браузера это можно сделать?
                    • +1
                      Как вам уже попытались подсказать выше, для современных браузеров используется HTML5 FileReader, если браузер его не поддерживает, то используется Flash-аплоадер.

                      По коду сайта процесс загрузки можно проглядеть по такой цепочке функций:
                      FileSelectHandler(event) -> startupload() -> initupload2() -> initupload3() -> ul_dispatch_chain() -> ul_dispatch_read()
                      Имя файла приходит из события, файлы затем забиваются в очередь, а непосредственно чтение происходит в ul_dispatch_read и читается с помощью метода readAsArrayBuffer. Чтение происходит блоками по 20 кб, по завершению вызывается метод onloadend, в котором содержимое файла записывается в очередь файлов на загрузку в виде Uint8Array.

                      Загрузка и запись файла происходит аналогично с помощью FileWriter. Имя файла живет внутри объекта FileWriter (dl_fw). Непосредственно запись на диск происходит в функции dl_dispatch_write при вызове dl_fw.write(dlblob);
                    • 0
                      Скажите, а ключ в Рутокен Web действительно неизвлекаемый? Или все операции драйвер выполняет?
                      • 0
                        В обоих видах токенов (Web и ЭЦП) ключ действительно неизвлекаемый. Все операции по шифрованию, подписи и т.п. реализованы аппаратно, а драйвер отвечает только за передачу команд на токен. Причем в современных операционных системах для работы с токенами не приходится устанавливать дополнительных драйверов, поскольку устройства работают по стандартизированным протоколам.
                    • 0
                      Еще замечу что после привязки ключа:
                      1. Вы потеряете все существующие файлы в меге
                      2. Для обмена файлами ваш аккаунт больше не подходит (до отвязки от рутокена, а следовательно очередной чистки)
                      3. Даже имея рутокен по публичной ссылке сейчас скачивание не работает.
                      • 0
                        Да, на счет второго и третьего я догадывался. Просто уже не было сил смотреть в сторону работы с общими файлами =)
                        По поводу первого — файлы не теряются, они просто перестают отображаться в файл-менеджере, ну и собственно нельзя будет их скачать. Если отвязать токен, то файлы отобразятся снова.
                        • 0
                          т.е. вы проверяли отвязку?
                          насколько я читал описание меги, там было прописано что смена шифра (считай смена пароля без знания текущего происходит с потерей файлов)
                          если то что вы написали верно, то файлы можно восстановить, просто сменив пароль на ранее использовавшийся, криптокод будет восстановлен и файлы появятся в файл менеджере.
                          • +1
                            Я возможно недостаточно полно рассказал про привязку токена.
                            Если снять галочку «Привязать токен к учетной записи» на странице аккаунта, то будет восстановлен старый мастер-ключ и отобразятся файлы, зашифрованные на нем. MEGA после логина всегда присылает полный список хранящихся файлов, но отображает только те, атрибуты которых удалось расшифровать.
                            • 0
                              Теперь разобрался, спасибо
                      • 0
                        Вопрос, в случае утери рутокена доступ к зашифровано информации возможно получить? Т.е. все рутокены имеют одинаковое криптографическое ядро, и таблицы необходимые для ГОСТ 28147-89?
                        • 0
                          Ядро и алгоритмы одинаковые, но ключевая пара, которая создается при первом вызове функции зашифрования, всегда будет уникальна. Так что токен надо всегда беречь как зеницу ока.
                          • 0
                            Ключевая пара хранится на самом токене и сбрасывается при форматировании?
                            и как же резервирование? Обычные ключи обычно есть рабочий, а есть копия в сейфе.
                            • 0
                              Это ограничение, накладываемое условием неизвлекаемости ключей шифрования. Они не могут покинуть токен и, следовательно, нельзя сделать копию токена. Если поступиться этим принципом, то можно импортировать ключевые пары на токен, но при этом они будут извлекаемыми в дальнейшем.

                              По поводу «копии в сейфе»: все серьезные системы обычно проектируются с поддержкой нескольких ключей таким образом, чтобы при утрате одного можно было бы восстановить доступ. Таковы уж сложности обеспечения должного уровня защищенности.
                        • НЛО прилетело и опубликовало эту надпись здесь
                          • 0
                            У меня везде стоит английская локаль, поэтому точно не могу сказать. У коллег видел русский интерфейс, но они возможно сами поменяли.
                            • НЛО прилетело и опубликовало эту надпись здесь
                          • 0
                            Я буду читать это себе на ночь каждый день.
                            Роскошно.
                            Просто — _Роскошно_.
                            И стиль, и смысл… и вапще :-)

                            Автор — «пещы истчо!»
                            • 0
                              С Доткомом то связывались??? Какие успехи? Статья супер!!! Респект за труды!!!
                              • 0
                                Спасибо за отзыв =)
                                Доткому пока не писал, потому что пока ведем общение с заинтересованными разработчиками здесь, это важнее.

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое