Я делаю сервис, который разворачивает проекты юзеров на их серверах. Первая версия просила пароль от root в веб-форме.
Это конечно создавало большой барьер доверия, поэтому все юзеры обращались ко мне с претензиями, типо: «зачем тебе root пароль, украсть сервер хочешь да?»
По факту люди правы, потому что давая root пароль они давали полный доступ к серверу, и пользователь никак его не может гарантировано отозвать кроме смены root пароля. Любое обещание про безопасность стоит ноль. Не потому что все врут, а потому что проверить обещание нельзя, а механизм можно.
Дальше про механизм. Сначала общая часть, применимая к любому сервису, который просит доступ к чужому: что просить, как это хранить и что делать, чтобы у человека остался выход. Потом конкретика с кодом, включая пару мест, где я налажал.
Часть 1. Что просить
Есть лестница. Чем выше, тем лучше и почти всегда можно подняться хотя бы на ступень.
Ступень 0: пароль. Худшее из возможного:
— он неограничен: паролем можно всё, что можно владельцу;
— его нельзя отозвать точечно. Отзыв это смена пароля, то есть отзыв у всех сразу, включая самого владельца;
— он невидим. Человек не может посмотреть, кому и что он выдал: нет списка, нет следа;
— он переиспользуется. Тот же пароль почти наверняка стоит ещё где-то.
Ключевой пункт второй. Всё остальное можно пережить, а вот доступ, который нельзя забрать у одного участника, ломает саму идею выдачи.
Ступень 1: долгоживущий ключ с полными правами. Уже лучше: ключ отзывается персонально, у него есть след (строка в файле, запись в списке токенов), он не пересекается с другими сервисами. Плохо то, что он живёт вечно и умеет всё.
Ступень 2: ключ с суженными правами.
Живой пример из моего же кода. Для GitLab я запрашиваю такие скоупы:
// Скоупы только на чтение: read_user (логин), read_api+read_repository (проекты и клон).// Write-скоуп (api) не берём: вебхук для CD юзер добавляет самconst Scopes = "read_user read_api read_repository"
Ценой одного неудобства (вебхук для авто-деплоя человек добавляет руками, а не мы за него) я не держу у себя токен, которым можно писать в чужой репозиторий. Если мою базу однажды выгребут, украденный токен позволит прочитать список проектов и склонировать код. Это плохо, но это не то же самое, что запушить в main чужого продакшена.
Ступень 3: короткоживущее делегирование. Для GitHub я не храню пользовательский токен вообще: есть GitHub App, у него установка в аккаунте, и на каждый деплой я выпускаю installation-токен, который живёт около часа. Утечка базы тут не даёт вообще ничего, потому что в базе лежит только installation_id, а он бесполезен без приватного ключа приложения.
Ступень 4: не хранить ничего. Идеал.
Неприятное
С сервером эта лестница не работает.
Чтобы развернуть докер-проект, нужен docker. Кто в группе docker, тот может смонтировать корень хоста в контейнер и стать root за одну команду. То есть сузить права до чего-то безопасного нельзя в принципе: доступ, которого хватает для деплоя, это доступ, которого хватает для всего.
Это надо признать, а не прятать за формулировками вроде «ограниченный доступ только для деплоя». Как только минимизация прав перестаёт работать, остаются ровно две вещи, и вся дальнейшая статья про них:
-
видимость: человек точно знает, что именно он выдал и где оно лежит;
-
отзыв в одностороннем порядке: он может забрать доступ сам, без моего участия, без моего согласия и не спрашивая меня.
Часть 2. Как это хранить
Половина статей про секреты заканчивается на «шифруйте». Этого мало, потому что дальше сразу идёт вопрос, которым мало кто хочет заниматься: а чем зашифровано и где лежит этот ключ.
Иерархия, в порядке убывания приятности.
Не хранить. См. ступень 3 выше. Если можешь выпускать короткоживущий токен по требованию, не храни долгоживущий.
Хранить производное с меньшими правами. Не пользовательский пароль, а токен. Не токен с записью, а токен на чтение. Не корневой ключ, а ключ, выданный конкретно под эту задачу.
Шифровать, понимая границу. Шифрование секретов в базе защищает от вполне конкретного сценария: утёк дамп базы. Бэкап забыли в S3, разработчик снял копию прода себе на ноутбук, sql-инъекция вытащила таблицу. Это частые случаи, так что шифровать надо обязательно.
Но если ключ шифрования лежит на той же машине, что и база, то от захвата машины оно не спасает никак: кто получил сервер, получил и ключ. И это нормальное состояние для маленького сервиса, потому что нормальная альтернатива (KMS, HSM, отдельный сервис секретов) стоит денег и времени, которых на старте нет.
Важно другое: знать про себя правду и не обещать большего. Между «храним в зашифрованном виде» и «банковский уровень шифрования» пропасть, и первое можно написать честно, а второе нельзя.
Считать, что однажды утечёт. Если исходить из того, что хранилище рано или поздно окажется у чужих, то важными становятся три вещи, и ни одна из них не про шифрование:
-
насколько узкий у секрета скоуп (что именно сможет сделать тот, кто его получил);
-
как быстро жертва узнает (есть ли у неё вообще способ заметить);
-
может ли она отозвать доступ без твоего участия.
Последний пункт я бы вынес в отдельное правило.
Секрет, который жертва не может отозвать сама, это не секрет, а заложник.
Если для отзыва нужно, чтобы я нажал кнопку в своей админке, ответил на письмо или просто существовал, то никакой гарантии у человека нет. Есть только моя добросовестность, а её нельзя проверить, и именно на этом месте разговор упирается в «ну, я вам верю или не верю».
Часть 3. Как это выглядит на практике
Пароль я из сервиса убрал, вместо него человек выдаёт доступ сам.
Пара ключей генерится у меня, публичная половина уезжает к нему, приватная остаётся зашифрованной у меня. Ему показывается одна строка и команда, которой он кладёт её себе. Вот эта команда, целиком:
func installOnServer(pubLine, label string) string {guard := labelif guard == "" {guard = pubLine // у старых ключей метки нет, сверяем по самой строке }return umask 077; mkdir -p ~/.ssh; grep -q " + guard + " ~/.ssh/authorized_keys 2>/dev/null || +echo " + pubLine + " >> ~/.ssh/authorized_keys}func installCommand(sshUser, ip, pubLine, label string) string {return "ssh " + sshUser + "@" + ip + " '" + installOnServer(pubLine, label) + "'"}
Здесь четыре решения, каждое из которых выглядит мелочью, но каждое чинит конкретную проблему.
Метка djaploy-a3f9c1, а не просто ключ
В authorized_keys уезжает не голая строка ключа, а строка с комментарием:
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... djaploy-a3f9c1
Метка случайная, восемь hex-символов, генерится на каждую выдачу. Она решает сразу три задачи.
Человек находит строку глазами. В authorized_keys может лежать пять ключей, все выглядят как одинаковый забор из base64. Найти среди них «тот, который от сервиса», без метки невозможно. С меткой это одна секунда.
По ней же работает отзыв. Команда снятия бьёт по метке, а не по ключу:
func revokeOnServer(pubLine, label string) string {if label != "" {return sed -i "/ + label + /d" ~/.ssh/authorized_keys }// ...}
sed -i "/djaploy-a3f9c1/d" ~/.ssh/authorized_keys можно прочитать вслух и понять, что она делает. Команда, которая матчит 80 символов base64, читается как заклинание, а заклинания в свой прод не вставляют.
Поэтому же символы в метке ограничены: она попадает внутрь sed-паттерна, и лишнее там недопустимо. На это есть тест:
for _, r := range l { if !(r >= 'a' && r <= 'z' r >= '0' && r <= '9' r == '-') { t.Fatalf("метка попадёт в sed-паттерн, лишние символы недопустимы: %s", l) }}
По ней ловится повтор. Второй проект на том же сервере переиспользует уже выданный доступ, и команда, выполненная дважды, не должна класть вторую копию строки. Отсюда grep -q ... || echo ....
Ключ в команде встречается ровно один раз
Наивная идемпотентность выглядела бы так: grep -q "<вся строка ключа>" ... || echo "<вся строка ключа>" >>. Работает, но ключ в команде оказывается дважды, команда раздувается до трёхсот символов, и в терминале её приходится мотать вбок.
Это не косметика. Команду, которую нельзя прочитать целиком, страшно вставлять в свой сервер, и человек либо не вставит, либо вставит не глядя. Оба исхода плохие: в первом я потерял пользователя, во втором я приучил его вставлять непонятное из интернета в прод.
Проверяется тестом:
// Ключ длинный: если он в команде дважды, строку невозможно прочитать глазами.// Повтор ловим по метке, поэтому ключ должен встречаться ровно один раз.if n := strings.Count(cmd, pub); n != 1 {t.Fatalf("ключ встречается %d раз, должен один: %s", n, cmd)}
Внутри только двойные кавычки
Команда целиком заворачивается в одинарные кавычки для ssh user@host '...'. Значит, внутри одинарных быть не должно, иначе квотинг порвётся при копипасте.
inner := cmd[strings.Index(cmd, "'")+1 : strings.LastIndex(cmd, "'")]if strings.Contains(inner, "'") {t.Fatalf("одинарная кавычка внутри ssh-команды рвёт квотинг: %s", cmd)}
Заодно имя ssh-пользователя, которое подставляется в показанную команду, проходит фильтр: латиница, цифры, дефис, подчёркивание, точка, не длиннее 32 символов. Иначе root; rm -rf / в поле «пользователь» превращается в команду, которую я сам предлагаю человеку выполнить у себя.
Два варианта одной команды
Человек может быть уже в консоли сервера (веб-консоль хостера, открытая ssh-сессия), а может сидеть у себя. Поэтому вариантов два: с обёрткой ssh user@ip '...' и без неё. Второй это в точности внутренность первого, и это проверяется тестом, чтобы они не разъехались при правках.
Публичная половина это замок, а не ключ
Момент, который стоит проговаривать пользователям прямо, потому что он неочевиден для тех, кто не занимался ssh: строка, которая лежит у него в authorized_keys, никому ничего не даёт. Её можно опубликовать в твитере/инсте. Войти по ней нельзя, потому что вход требует приватной половины, а она на сервер не попадает вообще никогда.
Это снимает целый класс страхов.
Часть 4. Где я налажал
Теперь интересное. Все три ошибки ниже прожили в проде и все три относятся к категории «выглядит правильно, а работает не так».
Отзыв, который молча не отзывал
Для ключей без метки (остались от старой схемы) снятие строки было написано очевидным образом:
grep -vF "<ключ>" ~/.ssh/authorized_keys > ~/.ssh/authorized_keys.new && mv ~/.ssh/authorized_keys.new ~/.ssh/authorized_keys
Читается нормально: выкинь строку, положи результат на место. Работает почти всегда.
Не работает ровно в одном случае: когда наша строка в файле единственная. Тогда grep -v не находит ни одной подходящей строки, а grep возвращает код 1, если ничего не совпало. && обрывается, mv не выполняется, файл остаётся как был, ключ остаётся на сервере.
А интерфейс в этот момент показывает «доступ отозван».
Это худший вид бага в фиче безопасности: она не падает, не пишет ошибку, а тихо не делает свою работу, причём в самом типичном сценарии (человек выдал доступ один раз, потом захотел забрать). Отдельная ирония в том, что ровно этот сценарий и есть главное обещание всей схемы.
Лечится двумя вещами. Первая: не строить цепочки на кодах возврата там, где команда может легитимно ничего не найти. Вторая, более общая: выполнять ровно ту команду, которую показываешь человеку, и проверять результат, а не верить в него. После снятия строки я перечитываю файл и убеждаюсь, что её там нет, и только тогда рапортую об успехе.
Порядок ключей, из-за которого падал деплой
Ситуация: у человека есть проект команды на общем сервере, туда он деплоит нормально. Он заводит второй проект на том же сервере, жмёт «выдать доступ», получает строку и… не вставляет её. Отвлёкся.
И вот с этого момента ломается и первый проект тоже. Деплой падает на подключении, хотя минуту назад работал.
Причина в порядке выбора ключа. Логика была «если есть выданный ключ, берём его», и мёртвый, ни разу не проверенный ключ перекрывал рабочий командный. Сейчас порядок явный, и он строится от знания, а не от свежести:
// 1. Свой выданный ключ, по которому мы уже успешно заходили.if granted != nil && granted.VerifiedAt != nil {return s.decryptKey(granted.PrivEnc)}// 2. Свой ключ с прошлых деплоев на этот сервер.if old := s.deployedKey(ctx, userID, ip, sshUser); old != nil {return s.decryptKey(old.PrivEnc)}// 3. Ключ проекта команды на этом сервере: участнику он и так доступен.if shared := s.sharedKeyPEM(ctx, userID, teamID, ip, sshUser); shared != "" {return shared, nil}// 4. Выданный, но ещё не проверенный: вдруг строку добавили, а «проверить» не нажали.if granted != nil {return s.decryptKey(granted.PrivEnc)}
Мораль шире, чем про ssh: когда у тебя несколько credentials на один ресурс, порядок их перебора это часть логики, а не деталь реализации. Сортировать надо по тому, про что ты знаешь, что оно работает, а не по тому, что появилось последним.
Кому именно я отдаю ключ
Отдельная половина задачи: убедиться, что на том конце тот же сервер, что и в прошлый раз. Классический trust on first use: первое подключение запоминает отпечаток, дальше сверяем.
Здесь легко ошибиться.
Ошибка первая: закреплять отпечаток в колбэке проверки ключа. Соблазн понятный, колбэк как раз про ключ хоста. Но он срабатывает до аутентификации, значит, если пользователь опечатался в IP и попал на чужую машину, чужой отпечаток закрепится как доверенный, и дальше расхождение будет ловиться с точностью до наоборот. Поэтому запись идёт только после успешного входа:
// Закрепляем ТОЛЬКО после успешной авторизации: колбэк срабатывает раньше неё, и запись// оттуда закрепила бы чужой ключ при опечатке в IP.stored, terr := repo.TrustHostPin(pctx, ownerID, ip, pin.SeenFP, pin.SeenType, pin.SeenPub)
Ошибка вторая: не подумать про гонку. Два первых подключения к одному IP одновременно (запустили деплой в двух вкладках) могут увидеть разные ключи хоста. Если оба спокойно закрепят своё, никакого TOFU уже нет. Поэтому запись атомарная, и если вернувшееся значение не совпало с увиденным, соединение рвём:
if terr == nil && stored != "" && stored != pin.SeenFP { // Параллельное первое подключение к тому же IP увидело ДРУГОЙ ключ. Это уже не TOFU. sshc.Close() de := errHostKeyMismatch(ip, stored, pin.SeenFP) return nil, de, de}
И третье, уже не ошибка, а следствие: фоновые задачи (монитор аптайма и прочее) ходят в режиме «только сверять». Они не закрепляют отпечатки. Закрепление это действие, которое меняет модель доверия, и оно должно происходить тогда, когда человек осознанно нажал кнопку.
Часть 5. Чего всё это не защищает
Раздел, который в таких статьях обычно отсутствует, и зря.
Ключ может на сервере всё. Он лежит под пользователем, у которого есть docker, а docker это фактически root. Сузить нельзя, см. часть 1. Единственное, что реально ограничивает ущерб, это возможность забрать ключ в одну команду, не спрашивая меня.
Первое подключение не защищено ничем. TOFU так и работает: тот, кто уже сидит посередине именно в этот момент, будет записан как настоящий сервер. Все последующие подключения защищены, первое нет. Способ проверить есть: сверить показанный отпечаток с тем, что показывает консоль хостера.
Компрометация моего хоста означает доступ к серверам пользователей. Приватные половины ключей лежат у меня зашифрованными, и это защищает от утечки дампа базы, но не от того, кто получил машину целиком. Из этого прямо следует практический совет пользователю, который я не стесняюсь давать: если проект перестал деплоиться и вообще если он больше не нужен, снимай строку. Доступ, который никому сейчас не нужен, не должен лежать выданным.
Git-токен виден в аргументах процесса на самом сервере. Он уходит в git -c http.extraheader=..., и тот, кто в этот момент может выполнить ps на той же машине, его прочитает. На личном VPS это никто, на общей машине это настоящая утечка.
Что из этого забрать себе
Если у тебя сервис, который просит доступ к чужому:
-
Поднимись по лестнице настолько, насколько можешь. Часто выясняется, что write-скоуп был не нужен.
-
Там, где подняться нельзя, перестань бороться за минимизацию и начни бороться за видимость и отзыв.
-
Покажи человеку ровно то, что он себе кладёт. Целиком, читаемой строкой, без «просто нажмите ОК».
-
Дай команду отзыва до того, как он выдаст доступ, а не после. Видеть, чем выключается, важнее, чем нажать кнопку.
-
Проверяй, что отзыв сработал, а не сообщай об успехе по факту отправки команды.
-
Напиши раздел про то, чего ты не защищаешь. Он сделает для доверия больше, чем весь остальной текст.
Код той части, которая ходит на сервер, у меня открыт целиком, включая все места из этой статьи: github.com/slime4ik/djaploy-core. Там же в README есть раздел «чего это не защищает».
ссылка на оригинал статьи https://habr.com/ru/articles/1071590/