Я делаю сервис, который разворачивает проекты юзеров на их серверах. Первая версия просила пароль от 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 за одну команду. То есть сузить права до чего-то безопасного нельзя в принципе: доступ, которого хватает для деплоя, это доступ, которого хватает для всего.

Это надо признать, а не прятать за формулировками вроде «ограниченный доступ только для деплоя». Как только минимизация прав перестаёт работать, остаются ровно две вещи, и вся дальнейшая статья про них:

  1. видимость: человек точно знает, что именно он выдал и где оно лежит;

  2. отзыв в одностороннем порядке: он может забрать доступ сам, без моего участия, без моего согласия и не спрашивая меня.

Часть 2. Как это хранить

Половина статей про секреты заканчивается на «шифруйте». Этого мало, потому что дальше сразу идёт вопрос, которым мало кто хочет заниматься: а чем зашифровано и где лежит этот ключ.

Иерархия, в порядке убывания приятности.

Не хранить. См. ступень 3 выше. Если можешь выпускать короткоживущий токен по требованию, не храни долгоживущий.

Хранить производное с меньшими правами. Не пользовательский пароль, а токен. Не токен с записью, а токен на чтение. Не корневой ключ, а ключ, выданный конкретно под эту задачу.

Шифровать, понимая границу. Шифрование секретов в базе защищает от вполне конкретного сценария: утёк дамп базы. Бэкап забыли в S3, разработчик снял копию прода себе на ноутбук, sql-инъекция вытащила таблицу. Это частые случаи, так что шифровать надо обязательно.

Но если ключ шифрования лежит на той же машине, что и база, то от захвата машины оно не спасает никак: кто получил сервер, получил и ключ. И это нормальное состояние для маленького сервиса, потому что нормальная альтернатива (KMS, HSM, отдельный сервис секретов) стоит денег и времени, которых на старте нет.

Важно другое: знать про себя правду и не обещать большего. Между «храним в зашифрованном виде» и «банковский уровень шифрования» пропасть, и первое можно написать честно, а второе нельзя.

Считать, что однажды утечёт. Если исходить из того, что хранилище рано или поздно окажется у чужих, то важными становятся три вещи, и ни одна из них не про шифрование:

  • насколько узкий у секрета скоуп (что именно сможет сделать тот, кто его получил);

  • как быстро жертва узнает (есть ли у неё вообще способ заметить);

  • может ли она отозвать доступ без твоего участия.

Последний пункт я бы вынес в отдельное правило.

Секрет, который жертва не может отозвать сама, это не секрет, а заложник.

Если для отзыва нужно, чтобы я нажал кнопку в своей админке, ответил на письмо или просто существовал, то никакой гарантии у человека нет. Есть только моя добросовестность, а её нельзя проверить, и именно на этом месте разговор упирается в «ну, я вам верю или не верю».

Часть 3. Как это выглядит на практике

Пароль я из сервиса убрал, вместо него человек выдаёт доступ сам.

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

func installOnServer(pubLine, label string) string {

guard := label

if 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 это никто, на общей машине это настоящая утечка.

Что из этого забрать себе

Если у тебя сервис, который просит доступ к чужому:

  1. Поднимись по лестнице настолько, насколько можешь. Часто выясняется, что write-скоуп был не нужен.

  2. Там, где подняться нельзя, перестань бороться за минимизацию и начни бороться за видимость и отзыв.

  3. Покажи человеку ровно то, что он себе кладёт. Целиком, читаемой строкой, без «просто нажмите ОК».

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

  5. Проверяй, что отзыв сработал, а не сообщай об успехе по факту отправки команды.

  6. Напиши раздел про то, чего ты не защищаешь. Он сделает для доверия больше, чем весь остальной текст.

Код той части, которая ходит на сервер, у меня открыт целиком, включая все места из этой статьи: github.com/slime4ik/djaploy-core. Там же в README есть раздел «чего это не защищает».