Встречайте новый двухфакторный аутентификатор: Commodore 64

Чего только не делают из Commodore 64. Вряд ли кто-то сможет переплюнуть коммодордеон, но огромный древний аутентификатор — это тоже круто. 

SMS 2FA изначально неисправен, телефон с аутентификатором могут украсть, а токены безопасности теряются. Но попробуйте потерять Commodore SX-64!  Любой вор, который попытается схватить его и убежать, гарантированно получит грыжу:

Кроме того, можно сделать свой цвет для каждого ключа!

И, главное, это действительно работает:

Окно терминала показывает сгенерированный одноразовый пароль для полного ключа, а эмулированный 64 показывает правильный ключ в правильное время, который был известен и проверен на достоверность. Да, вы действительно можете использовать свой Commodore 64 в многофакторной аутентификации для генерации кодов TOTP!

Ключи можно вводить вручную в шестнадцатеричном формате или загружать с диска в виде двоичных файлов (вы указываете файл, смещение и длину), и он может использовать часы реального времени в устройствах CMD FD и HD, либо с устройств, реализующих совместимую команду T-RA или вы можете просто ввести время вручную.

Нет, я не сумасшедший! У C64 очень маленькая поверхность атаки, и его можно сделать полностью закрытым. Ключи можно вводить вручную или хранить в виде двоичных файлов, а для правильного использования необходимо знать нужный файл, смещение и длину (если только вы не сделаете ключом весь файл). Черт возьми, ко всему прочему вам нужно знать, на каком диске (или кассете?) он записан! Ну и вообще, это просто забавно.

В этом проекте из-за некоторых ограничений оборудования приходится решать ряд технологических проблем с 8-разрядным ALU, в котором нет аппаратного умножения или деления. Вот что мы должны написать, чтобы это заработало, прямо из RFC 6238:

  • Хеши SHA-1

  • Генератор HMAC, использующий эти хеш 

  • Преобразование часов реального времени во время Unix с поправкой на местный часовой пояс

  • Извлечение целевого значения из полученного HMAC, особенно его последних шести цифр 

И мы собираемся сделать все это на MOS 6510, работающем на частоте чуть более 1 МГц! (Или меньше, если ориентироваться на системы PAL).

Для начала попробуйте проделать это всё в VICE или на настоящем Commodore 64 (или 128 в режиме 64), чтобы убедиться, что оно работает.

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

#!/usr/bin/perl

@digots = qw(A B C D E F G H I J K L M N O P Q R S T U V W X Y Z 2 3 4 5 6 7);
%digits = map { $_, $i++ } @digots;
while(<>) {
        chomp;
        s/\s+//g;
        exit if (!length || length($_) & 1);

        s/\=+$//;
        if (length($_) & 2) {
                # 2 base 32 characters equals 2.5 base 16 characters,
                # which we null-pad with A's. this works enough for totp.
                $_ .= "AA";
        }

        $_ = uc($_);
        # 4 base 32 characters equal 5 base 16 characters.
        foreach $group (unpack("(A4)*", $_)) {
                $sum = 0;
                foreach $char (split('', $group)) {
                        exit if (!defined($digits{$char}));
                        $sum <<= 5;
                        $sum |= $digits{$char};
                }
                printf("%05x", $sum & 0xfffff);
        }
        print "\n";
}

__DATA__
GEZD GNBV GY3T QOJQ GEZD GNBV GY3T QOJQ
3132333435363738393031323334353637383930
3D4C QYE6 B5AF N6CS 4TE5 OVQF BGPU QPRA
d8f828609e0f4056f852e4c9d75605099f483e20
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0000000000000000000000000000000000000000
7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
f800000000000000000000000000000000000000

Продолжаем с вашим ключом:

  • При желании создайте ключ на диске (ключ должен быть двоичным, а не шестнадцатеричным ASCII: ключ 0102 должен состоять из файла, содержащего байты 0x01 и 0x02). Вы можете заполнить его данными (указать смещение и длину фактического ключа).

  • Ели вы используете VICE, то можете эмулировать CMD FD или HD, чтобы у вас были часы реального времени. VICE не предоставляет эту информацию ПЗУ по умолчанию. Если у вас есть соответствующее ПЗУ (я эмулирую FD-2000), перед загрузкой TOTP-C64 создайте для него пустой образ диска и убедитесь, что образ загружен в эмулируемый диск, а затем перезагрузите диск. Если вы не проделаете этот танец с бубном, то при запуске VICE, RTC может не среагировать правильно. VICE синхронизирует часы с системным временем хоста.

  • Скачайте его со вкладки Releases проекта Github. Это одна PRG, которую вы загружаете и запускаете как программу на бейсике (на самом деле, меню и является программой на бейсике). Итак, сделайте это (например, загрузите «TOTP» и запустите, подставив имя файла и устройство). Придётся подождать, пока он распаковывает свои компоненты.

  • Если вы создали диск с ключом, нажмите L, чтобы загрузить его, вставьте диск или образ диска, если это ещё не сделано. Затем укажите имя файла, устройство, тип файла, смещение (может быть равно нулю) и длину (если смещение ноль, то будет загружаться вся оставшаяся часть файла или его 64 байта, в зависимости от того, что меньше). Если вы не сделали диска с ключом, нажмите E, чтобы ввести шестнадцатеричный ключ (строчные буквы, без пробелов, максимум 128 символов). Этот ключ не будет сохранен на диск. Вы можете просто ввести пару случайных байтов, если просто хотите поиграться с программой.

  • Если вы ошиблись и хотите повторить процедуру, нажмите F1 или C, чтобы получить данные о времени из CMD или совместимого устройства, которое реализует T-RA. Чтобы ввести время вручную, нажмите E.

    • Если вы нажмёте C, система предложит вам ввести устройство, с которого нужно загрузить время. Это не обязательно то устройство, с которого вы загрузили ключ. Время будет запрошено и отображено на экране. Если в ответ вы получите ерунду (при условии, что вы пытаетесь эмулировать диск CMD в VICE), убедитесь, что образ смонтирован, перезагрузите диск ещё раз и повторите попытку (но этот вариант не поможет, если вы увидите ошибку синтаксиса). После получения правильного ответа введите свой часовой пояс относительно UTC, сначала часы, а затем минуты. Например, стандартное тихоокеанское время (PST) равно -08:00, поэтому введите -8 часов и 0 минут; Австралийское восточное летнее время (AEDT) равно +11:00, поэтому введите 11 часов и 0 минут.

    • Если вы нажмёте E, сначала у вас запросят часовой пояс (введите его так же, как в пункте выше), затем текущий месяц, день и год (поддерживаются только формат от 2000 и выше; если вы введёте две цифры, они будут дополнены до формата 2000), затем текущее время в часах (в 24-часовом формате: полночь = 0, 8:00 = 8, полдень = 12, 20:00 = 20), минуты и секунды. Введите время примерно на 10 или 15 секунд больше реального. Затем программа сделает паузу для нажатия клавиши. Когда часы совпадут с введённым вами временем, нажмите любую клавишу, кроме F1. На F1 надо нажать, если вы сделали что-то неправильно, тогда вы сможете повторно ввести время. 

  • Появится дисплей TOTP с текущим 6-значным кодом в центре экрана и индикатором выполнения под ним. Эта полоса заполняется другим цветом каждые 30 секунд, после чего генерируется новый код. Отображение продолжается до тех пор, пока вы не нажмёте F1 для остановки. Нажатие клавиши выключит отображение и запустит программу с самого начала, начиная с получения ключа.

Отображение TOTP зависит от часов Time-of-Day в CIA #1. Это настоящие часы, которые показывают время в двоично-десятичном формате и используют частоту сети 60 Гц или 50 Гц. Мы используем эти часы, потому что на них не влияют прерывания. Если индикатор выполнения не продвигается вперед, это означает, что CIA #1 неисправен, и/или неисправна линия переменного тока 9 В от источника питания, и/или диод Зенера 2,7 В, и/или чип, подключенный к выводу TOD CIA, перегорел. Если всё остальное в вашей системе работает нормально, то, скорее всего, проблема в двух последних пунктах. Дело в том, что только относительно небольшое количество программ используют часы TOD, но при этом используют другие функции CIA. На Commodore 64 проверьте U27 и CR1. На 128 или 128D(CR) проверьте U16 и CR8; на 128D(CR) CR8 неудобно расположен под блоком питания на краю материнской платы, поэтому, чтобы его отодвинуть, придётся сначала поработать отвёрткой. 

Исходный код также есть на Github (выпущен под лицензией BSD 3-clause). В TOTP-C64 есть две основные части: раздел BASIC, который предоставляет меню для чтения и принятия пользовательских настроек (это те подсказки, с которыми вы взаимодействовали), а затем основная часть на ассемблере, которая выполняет тяжелые вычисления и отображает код. Она, в свою очередь, разделена на три модуля: хешер SHA-1, вычислитель времени и фактический основной цикл, который вызывает модули для определения времени и вычисляет HMAC, отображает цифры и поддерживает часы, пока вы его не остановите.

Отображение кода очень простое, это полоса перевернутых пробелов с шестью большими спрайтами для цифр и спрайт замка в качестве украшения. Спрайты размером 16x32 создаются путем растяжения глифов числовых символов из встроенного ПЗУ символов в два раза по вертикали (отсюда цифра 8x16), а затем VIC-II снова удваивает размер каждого цифрового спрайта. Цвет, заполняющий индикатор выполнения, вычисляется из младшего байта значения счетчика часов. Это всё довольно просто. Основные разделы я написал на ассемблере 6502.

Первой частью написанного кода была реализация хеширования SHA-1, так как без этого не было смысла писать что-то еще. Беглым поиском в Google я обнаружил несколько криптографических хеш-проектов на основе 6502, например, эта реализация Commodore 64 SHA-256 в 100% сборке, еще одна в сборке, настоящий Commodore 64 SHA-256 file checksum, предположительно на ассемблере, и Atari SHA-1 file checksum, написанная на C. Должен официально заявить, что я не просматривал исходный код ни одного из этих проектов и не использовал их работу при написании своего.

К счастью, SHA-1 требует только сложения, вычитания, побитовых логических операций, поворотов и сдвигов. Тут есть две хитрости: 1) всё 32-битное, и 2) единственная ошибка в реализации, даже один плохой бит, может полностью испортить хеш из-за лавинного эффекта. 

На помощь должна прийти моя модель Perl, эмулирующая восьмибитные операции, которые должен выполнять процессор 6502 для вычисления SHA-1, а затем HMAC. Модель сделана так, чтобы я мог видеть каждый шаг вычислений. Код не был 1 в 1 тем, что я ожидал, но этого было достаточно, чтобы перевод на язык ассемблера был по возможности прямым.

Вы можете увидеть трассировку пяти переменных SHA-1 h (от h0 до h4 ) как на экране 64, так и в сеансе терминала. Каждый шаг нагляден, поэтому я мог определить практически точное место, где версия ассемблера расходилась с моделью Perl. Для удобства отображение 160-битного хеша на экране Commodore состоит из 40 шестнадцатеричных цифр, что соответствует ширине строки экрана.

Нет никакой магии в том, чтобы выполнить В 32-битную (или 16, 24, 48, 64-битные и т. д.) операции на 8-битном процессоре. Вы просто делаете по одному байту за раз. Я написал для этого макросы препроцессора, которые генерируют шаблонный код в кросс-ассемблере xa65. Для операций сложения и вычитания побайтовая математика должна быть последовательной от LSB к MSB. В частности, вам нужно загружать и сохранять каждый байт по порядку, так что это, как правило, самые медленные операции в этом модуле. Это также верно для сдвигов и поворотов, но у 6502 есть некоторые формы этих инструкций, которые избегают явной загрузки/сохранения (еще одна причина, по которой 6502 не является настоящим RISC), что может ускорить несколько операций и не требует прохождения через аккумулятор.

Однако для побитовых логических операций, таких как AND, OR, XOR и т. д., каждый байт 32-битного результата может обрабатываться независимо, и переноса не происходит. Это означает, что, например, AND, за которым следует XOR, может быть частью одной и той же цепочки: мы загружаем из памяти, выполняем AND, выполняем XOR и записываем обратно, и делаем это всего четыре раза для каждого байта. Это даёт экономию на  загрузке и сохранении между каждой логической операцией, и мы часто используем это в основном цикле (пример).

Следующей необходимой частью было вычисление времени. Сообщение для HMAC-SHA-1 представляет собой значение счётчика с обратным порядком байтов (полученное из HOTP), вычисленное из текущего времени Unix в секундах минус эпоха Unix (ноль), делённая на временной интервал, который составляет обычно 30 секунд. Нам также понадобится остаток, потому что он говорит нам, как долго нужно ждать, прежде чем сгенерировать второй код. RFC требует, чтобы любой совместимый алгоритм обрабатывал как минимум проблему Y2038, с которой большинство современных систем справляются, используя полный 64-битный time_t. Это плохо, потому что сейчас мы используем 32 бит и, даже скорее 30 (в конце концов я принял 64-битное значение времени, чтобы соответствовать RFC). Деление на что-то, что не является степенью двойки, в системе без аппаратного целочисленного деления — это бо-о-оль!

Но подождите, дальше будет хуже. Commodore 64 не имеет встроенного источника времени и часов реального времени, поскольку он дает вам время Unix: показывается местное время в удобном для человека формате в секундах, минутах, часах, днях, месяцах и годах. Это удобно практически для всего, но не для нас! По сути, мы вынуждены реализовать что-то вроде timegm() с нуля. Посмотрите, что Perl's Time::Local должен сделать, чтобы просто вычислить дни после эпохи Unix по дате: повозиться с месяцем и годом, умножить количество лет на 365, чтобы получить количество дней с учётом високосных годов, а затем добавьте месяц — умножьте на 306, прибавьте 5 и разделите на 10 — и текущую дату. Чтобы перейти от этого количества дней к секундам, нужно умножить всё на 86 400. В этот момент «просто» умножение на 3600 для получения секунд из часов и на 60 для перевода минут в секунды (и сложение всего этого) кажется тривиальным по сравнению с этим ужасом. О, и не забывайте, что когда мы закончим, нам придется разделить итоговое число сигналов на 30, чтобы получить текущее значение счетчика. Ах да! Нам нужно сделать все это в течение секунды или около того, чтобы часы “тикали” в реальном времени, а не отставали.

Шаблон строки времени CMD, в зависимости от конкретной реализации, примерно такой: День недели. ММ/ДД/ГГ ЧЧ:ММ:СС XM в 12-часовом формате (например, СБ. 12.11.22 19:57:40 ). Существуют варианты, которые дают нам упакованные BCD и байты, но наиболее широко распространена именно строковая форма PETSCII. Диск CMD (по крайней мере, эмулированный в VICE) на самом деле генерирует трёхзначный год для 2000-х и более поздних лет ( 11/12/122), хотя это не является частью спецификации. 

Сразу же становятся очевидными некоторые потенциальные сокращения. Вычитая 48 из значений цифр PETSCII, мы фактически получаем дату с устройства CMD в десятичном виде (технически неупакованный BCD). На самом деле, мы можем использовать это, тривиально конвертируя введенные вручную время и дату в распакованные BCD и использовать тот же код для их обработки. Это означает, что деление на 10 и 100 — это просто вопрос извлечения правильных цифр. Поскольку мы расширяем год до полных четырех цифр, деление года на 100 просто означает взятие двух верхних цифр. Чтобы разделить на 400, мы просто делим результат деления на 100 ещё на 4, что составляет два битовых сдвига. На практике это означает, что мы не можем обрабатывать годы после 9999, но это соответствует требованиям RFC. Наконец, код для работы с месяцем должен учитывать только 12 месяцев, что заставляет нас использовать справочную таблицу (как и для деления полученного числа на 10, а это ещё одна таблица, и мы получаем  int( ( ( $month * 306 ) + 5 ) / 10 ) )).

Два умножения, которых мы не можем избежать, мы можем превратить в сдвиги и сложения. Чтобы превратить год в 16-битную величину для умножения, мы должны умножить две верхние цифры на 100, что раскладывается на сумму их умножения на 4, умножения на 32 и умножения на 64, что равно 100. Мы добавляем это к нижним цифрам, чтобы получить год как короткое 16-битное значение. Оно, в свою очередь, должно быть умножено на 365, что раскладывается на сумму умножения на 256, умножения на 64, умножения на 32, умножения на 8, умножения на 4 и «умножения на 1». У нас уже есть быстрый способ умножения на 256: это сдвиг влево на 8, поэтому мы просто сдвигаем байты вверх на один байт и добавляем к нему остальные сдвиги. Мы суммируем все это в 64-битное значение времени. 

После перебазирования общего количества дней путём вычитания эквивалентного числа для эпохи Unix (которое мы храним как константу), нам нужно преобразовать это число в секунды. А значит, предстоит вычислить сумму дней, умноженную на 86 400, часов, умноженных на 3 600, минут, умноженных на 60, а затем разделить эту сумму на 30, чтобы сгенерировать счётчик TOTP и отметить остаток. Но мы можем сразу разделить все константы на 30, потому что они сами являются целыми числами, кратными 30, и  таким образом, деление полностью исключается. Это означает, что мы умножаем дни на 2880 (а это гораздо более удобное число), часы умножаем на 120 и минуты на 2. Остаток-30 можно легко вычислить, убрав секунды: если число больше или равно 30, отнять 30 и прибавить к сумме единицу, а остаток — это остаток; в противном случае сами секунды являются остатком. Но подождите, это ещё не всё! x2880 раскладывается на сумму x2048, x512, x256 и x64. Эй, есть ещё x256, так что мы можем просто сделать байтовый сдвиг! Кроме того, мы знаем, что секунды, разделённые на 30 (1 или 0), и минуты, умноженные на два (0-118), в сумме составляют меньше 256, поэтому они поместятся в один байт. Таким образом, мы начинаем с times-2880 с x256 сдвигом 64-битного числа на один байт, но делая младший байт суммой секунд и минут вместо нуля, выполняя сложение и первую часть полного умножения. Затем мы добавляем суммы других сдвигов и добавляем часы из таблицы поиска times-120.

Осталось сделать  корректировку для UTC по местному времени (время Unix всегда UTC), а это теперь просто: перемножьте разницу в часах с UTC с той же таблицей times-120 и минутами на 2 и либо добавьте, либо вычтите их из общей суммы. Теперь у нас есть время Unix, разделённое на 30, готовое служить счётчиком, плюс остаток, чтобы мы могли правильно синхронизироваться с 30-секундными интервалами. Вау!

Теперь поговорим о HMAC и моде 1000000. Наш модуль SHA-1 работает «фрагментами» по 512 бит/64 байта; он ожидает, что все, что его вызывает, правильно настроит буфер. HMAC вычисляется путём первого вычисления хеша 64-байтового ключа, объединённого XOR с повторяющимся внутренним байтом заполнения «ipad» ( 0x36 ), объединённым с 64-битным значением счётчика с обратным порядком байтов, всего 576 бит или 72 байта. SHA-1 требует, чтобы полезная нагрузка заканчивалась одним единичным битом, а затем дополнялась нулевыми битами для битовой длины, соответствующей -64 ≡ 448 (mod 512), за которой следует 64-битная длина всего сообщения с обратным порядком байтов. Затем 160-битный результат этого первого хеша присоединяется к концу 64-битного ключа, на этот раз подвергаясь операции XOR с внешним байтом заполнения «opad» ( 0x5c) всего 672 бита или 84 байта, а результат хешируется в последний раз.

Таким образом, мы запускаем четыре блока через модуль SHA-1, начиная со сброса хеэш-переменных в исходное состояние. Первый — это полный 64-байтовый «ipad» (который мы предварительно вычисляем). Мы не сбрасываем хеш-переменные перед вторым блоком, который представляет собой 64-битный счетчик, соединенный с 0x80 (конечный бит), за которым следуют 53 нуля и 0x2 0x40 (576 бит). Мы просматриваем этот фрагмент, копируем хеш для использования в четвертом фрагменте и сбрасываем хеш-переменные для нового хеша. Третий блок — это полный 64-байтовый «opad» (который мы также предварительно вычисляем); после его обработки мы снова не сбрасываем хеш-переменные и переходим к четвертому блоку, который представляет собой 160-битный/20-байтовый хеш из второго блока, объединенного с 0x80, 41 нулём и 0x2 0xa0 (672 бита). Хеш-значение после четвертого блока является результатом HMAC.

Последний шаг — преобразовать HMAC в 6-значный код TOTP, который описан в RFC 4226, RFC для HOTP. 32-битный фрагмент берётся из 160-битного результата на основе третьего байта h4 и с 15; это индекс первого из четырех байтов, составляющих 32-битное значение. Затем у значения маскируется самый старший бит, оставляя 31-битное значение. Мы можем легко сделать это из смежных хеш-переменных в памяти. Смысл в том, что 31-битное значение по модулю 10 цифры ; большинство реализаций используют 6, поэтому мы должны вычислить это значение по модулю 10 6 , или 1000000.

Первый способ, которым я это сделал, заключался в том, чтобы вычислить все десятичные цифры путем кропотливого деления на степени 10 (по сути, грубое многократное вычитание), а затем взять из них младшие шесть. Максимальное значение беззнакового 31-битного значения может быть представлено 10 цифрами, поэтому мы начинаем с разряда миллиардов и двигаемся вниз. По мере того, как показатель степени уменьшается, нам уже не нужно приходится проверять все 32 бита. 

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

Остался пользовательский интерфейс, который является самой интересной частью. Сначала я написал программу меню на простом языке BASIC, используя собственные инструменты для токенизации источника и связывания его в памяти в один исполняемый файл с собранным машинным кодом. Я также добавил спрайт замка для украшения и написал небольшой инструмент, чтобы превратить его в псевдооперации .byt или операторы DATA (в конце концов, я остановился на первом), и написал код для глифов больших растянутых чисел. Чтобы часы были максимально точными и не мешали изменениям статуса прерывания, я использовал часы CIA Time-of-Day в качестве интервального таймера, который нуждается в настройке для мощности 50 Гц или 60 Гц. Сами часы TOD отслеживают время в двоично-десятичном коде, поэтому для загрузки начального значения мы конвертируем остаток-30, который мы вычислили в прошлом, также в двоично-десятичный код (используя 8-битную версию алгоритма Эндрю, поскольку остаток никогда не будет больше 29). В этом случае часы TOD отсчитывают время независимо. По мере изменения секунд мы перемещаем маленький красочный индикатор выполнения, вычисляемый на основе младшего бита счетчика. Когда счетчик секунд TOD достигает 30 (то есть 48, десятичная версия 30 в BCD), мы сбрасываем его на ноль, увеличиваем счетчик часов, вычисляем новый 6-значный код и появляется новый цвет в индикаторе выполнения. Это повторяется до тех пор, пока пользователь не остановит процесс. 

Вот и всё, не стесняйтесь развивать этот проект его и портировать на свою любимую систему.