Цифровая подпись запросов сервера — это не черная магия или удел нескольких избранных секретных охранников. Реализовать эту функциональность в мобильном приложении под силу любому хорошему программисту при условии, что он знает соответствующие инструменты и подход к этой задаче. А если вам нужно самому стать хорошим разработчиком, я расскажу вам о правильных инструментах и подходах Как защитить Android приложение при помощи подписи.
При разработке клиент-серверных приложений для Android существует несколько очевидных способов сделать соединение более безопасным. Похоже, что к 2020 году все выучили аббревиатуру HTTPS как мантру, и Google, со своей стороны, помогает, запрещая HTTP-трафик по умолчанию в новых версиях операционной системы. Чуть более продвинутые товарищи знают, что сам HTTPS не защищает от всех векторов атак (привет, Мэллори!), И в конечном итоге они блокируют SSL (также известный как закрепление сертификата / открытого ключа). В большинстве случаев защита канала на этом заканчивается. И, честно говоря, в большинстве случаев этой защиты достаточно. Особенно, если другие векторы атак устраняются путем шифрования пользовательских данных и проверки ненадежной среды
Но бывает и по-другому. Приложение вынуждено запускаться в ненадежной среде, что означает, что вредоносное ПО на клиентском устройстве может перехватывать токены доступа к серверу непосредственно из памяти приложения. Более того, в зависимости от реализации механизма аннулирования этих токенов злоумышленник может выполнять свои запросы от имени пользователя в течение определенного времени. Существует обходной путь для этой проблемы: подписывайте все запросы, сделанные из разрешенной зоны, цифровой подписью. Обычно это все запросы, не относящиеся к / login или / register. В статье мы обсудим, как реализовать запросы подписи на клиенте и на сервере, а также о подводных камнях и ограничениях этого метода.
Криптоликбез
Чтобы сделать повествование более системным, давай для начала синхронизируемся в понятиях и освежим знания криптографии, если они по какой‑то причине заплесневели.
Начнем с понятия цифровая подпись. Тема ЦП довольно обширная, поэтому ограничимся асимметричной схемой цифровой подписи, в которой участвуют открытый и закрытый ключи. В самом простом случае цифровая подпись работает по следующему алгоритму:
- Алиса шифрует документ своим закрытым ключом, тем самым подписывая его.
- Алиса отправляет подписанный документ Бобу.
- Боб расшифровывает документ с помощью открытого ключа Алисы, тем самым проверяя подпись.
Это работает, но есть проблема. Если документ, подписанный Алисой, представляет собой чек на определенную сумму денег, то ненадежный Боб может обналичить этот чек до тех пор, пока у Алисы не закончатся деньги на счете или пока Боб не будет пойман. Для решения этой проблемы используются временные метки. Алиса добавляет в документ текущее время и шифрует его вместе с документом. Банк, в который Боб приносит этот чек и открытый ключ Алисы, расшифровывает документ и сохраняет отметку времени. Теперь при повторной попытке обналичить такой чек банк заблокирует эту операцию, поскольку метки времени будут такими же.
Вам еще не скучно? Наберитесь терпения, все это нам скоро пригодится, когда мы будем писать реализацию. Последний аспект, который я хочу обсудить, — это производительность асимметричных криптосистем. В конечном итоге они оказываются совершенно неэффективными для больших объемов данных, а это означает, что попытка использовать этот подход для подписи больших запросов будет безжалостно расходовать батарею смартфона и уменьшать связь с сервером.Для ускорения всей этой машинерии принято использовать односторонние хеш‑функции. Итоговая версия алгоритма будет выглядеть так:
- Алиса вычисляет значение хеш‑функции для документа.
- Она шифрует это значение своим закрытым ключом, тем самым подписывая документ.
- Алиса посылает Бобу документ и подписанное хеш‑значение.
- Боб вычисляет значение хеш‑функции для документа, присланного Алисой.
- Он расшифровывает значение хеш‑функции документа, присланного Алисой.
- Боб сравнивает это значение с вычисленным самостоятельно. Если они совпадают, то подпись подлинна.
Как работает цифровая подпись
Как видно из примеров — надежность механизма цифровой подписи базируется на двух предположениях:
- Закрытый ключ Алисы доступен только ей и больше никому.
- У Боба находится открытый ключ именно Алисы, а не кого‑то другого.
Реализация клиентской части
Теперь ты должен примерно представлять, как можно реализовать подпись запросов. Способов реализации больше одного, но я покажу самый, по моему мнению, простой и удобный.
Для начала определимся с генерацией ключа и самим алгоритмом цифровой подписи. Я действительно не рекомендую писать все вручную с использованием криптографических примитивов из Android SDK. Лучше взять готовое и проверенное решение — библиотеку Tink, написанную темными гениями Google. Она решает сразу несколько наших проблем:
- сохраняет ключи в Android Keystore, что практически исключает их насильственное извлечение с устройства. А значит, обеспечивает нам истинность первого предположения о надежности механизма цифровой подписи;
- предоставляет надежный алгоритм подписи на эллиптических кривых — ECDSA P-256;
- предоставляет удобные криптопримитивы и API для создания цифровой подписи.
Подключаем библиотеку к проекту (implementation
) и генерируем пару ключей, которые сразу будут сохранены в Android Keystore:
Чтобы сервер мог проверить нашу цифровую подпись, он должен каким-то образом передать открытый ключ из пары, которую мы сгенерировали выше. Лучше всего это сделать на этапе авторизации. Открытый ключ не является секретным, поэтому мы можем легко передать его прямо в запрос вместе с логином и паролем пользователя, предварительно закодировав в Base64:
Tink не позволяет работать напрямую с ключевым материалом. Вместо этого библиотека предлагает концепцию Reader / Writer, которая позволяет вам читать и записывать ключи в JSON или двоичном представлении. Подробности смотрите в документации.
Теперь получим примитив для создания цифровой подписи:
val signer = privateKeysetHandle.getPrimitive(PublicKeySign::class.java)
После написания кода вы также можете попроектировать 😉 Если вы забыли формулировку проблемы, то нам нужно убедиться, что все запросы в авторизованной зоне подписаны. Самый простой способ сделать это — использовать абстракцию сетевого перехватчика из библиотеки OkHttp. Что-то подобное можно сделать с чем угодно, но с OkHttp удобнее. Составим список функциональных требований к нашему механизму подписи запросов:
- Подписывать нужно только запросы из авторизованной зоны.
- В подпись должны быть включены следующие стандартные заголовки:
Authorization
,User-Agent
. - К запросу необходимо добавить метку времени в виде заголовка
Data
, который также должен быть подписан. Значение заголовка — дата и время в формате ISO-8601. -
Данные для подписи формируются по следующему шаблону:
(request-target): %method% %request_uri%n
host: %host_header_value%n
authorization: %authorization_header_value%n
user-agent: %user-agent_header_value%
-
Добавить к запросу заголовок
X-Signed-Headers
, в котором нужно указать заголовки, участвующие в подписи в том же порядке, в каком они были добавлены в строку для формирования подписи. -
Для запросов с телом необходимо дополнительно вычислять хеш SHA-512 от тела и добавлять его как в виде заголовка
Digest
, так и в строку для формирования подписи. -
Добавить к запросу заголовок
X-Signature
, содержащий закодированную в Base64 цифровую подпись.
Если что-то все еще непонятно, не волнуйтесь. Сейчас мы реализуем все это в коде, и понимание придет сразу. По крайней мере, я в это верю …
Весь исходный код будет доступен по ссылке в конце статьи, поэтому дальше мы разберем только действительно важные части. Весь код находится внутри класса‑перехватчика, который мы потом подключим в конструктор OkHttp-клиента:
class SigningInterceptor constructor(val signer: PublicKeySign) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
// Почти все находится здесь
}
}
Для выполнения первого требования отстрелим все запросы из неавторизованной зоны. Благо у нас такой только один — запрос на авторизацию.
val originalRequest = chain.request()
if (originalRequest.url.encodedPath.contains("/login") && originalRequest.method == "POST") {
return chain.proceed(originalRequest)
}
Теперь составим список имен заголовков, которые будут участвовать в подписи. Не забудь, порядок следования важен!
val headerNames = mutableListOf("authorization", "user-agent", "datе")
Здесь указаны не только стандартные заголовки, но и дополнительные. Из дополнительных у нас пока только метка времени. Сами дополнительные заголовки объявим в другой переменной:
val additionalHeaders = mutableMapOf(
"Datе" to LocalDateTime.now().toString()
)
Синхронизация часов
Теперь, по требованию 4, составим строку, которую будем потом подписывать:
val originalHeaders = originalRequest.headers
.filter { headerNames.contains(it.first.toLowerCase()) }
.associateBy({ it.first.toLowerCase() }, { it.second })
val headersToSign = originalHeaders + additionalHeaders.mapKeys { it.key.toLowerCase() }
val requestTarget = "(request-target): ${originalRequest.method.toLowerCase()} ${originalRequest.url.encodedPath}\n"
val signatureData = requestTarget + headerNames.joinToString("\n") {
"$it: ${headersToSign[it]}"
}
,
Осталось вычислить сигнатуру, прикрепить к запросу все необходимые заголовки и запустить на выполнение:
val signature = Base64.encodeToString(
signer.sign(signatureData.toByteArray()),
Base64.NO_WRAP
)
val request = originalRequest.newBuilder()
.apply { additionalHeaders.forEach { addHeader(it.key, it.value) } }
.addHeader("X-Signed-Headers", headerNames.joinToString(separator = " "))
.addHeader("X-Signature", signature)
return chain.proceed(request.build())
Убедимся, что все работает, и дополним нашу реализацию вычислением хеша от тела запроса:
originalRequest.body?.let {
val body = okio.Buffer()
val digest = MessageDigest.getInstance("SHA-512").apply { reset() }
it.writeTo(body)
headerNames += "digest"
additionalHeaders["Digest"] = digest.digest(body.readByteArray())
.joinToString("") { "%02x".format(it) }
}
На этом реализацию клиентской части можно считать завершенной. Добавляем перехватчик в конструктор HTTP-клиента и переходим к реализации серверной части:
val httpClient = OkHttpClient.Builder()
.addNetworkInterceptor(signingInterceptor)
.build()
Реализация серверной части
Мы собираемся реализовать серверную часть в Go, возьмем Gin в качестве веб-фреймворка. Этот аккумулятор для нашей задачи не является строго необходимым, поэтому при желании вы можете подобрать подходящий. Единственное условие, которое является обязательным для целей этой статьи, но которое также не является какой-то догмой, — это то, что поддержка выбранного языка программирования должна быть в библиотеке Tink. Довольно удобно использовать одну и ту же реализацию криптографии на клиенте и сервере. Это устраняет многие проблемы с внедрением и поддержкой.
Сначала определимся с ручками, которые нам понадобятся:
POST /
— метод без какой‑либо авторизации, принимает логин и пароль;login POST /
— обновляет токен, выданный на этапе логина;refresh GET /
— возвращает информацию о пользователе.user
Вы также можете выполнить / logout, но поскольку мы будем использовать JWT в качестве токена, этот метод не имеет большого смысла для мобильного приложения.
Чтобы не усложнять себе жизнь, воспользуемся готовой middleware для Gin, которая возьмет на себя все хлопоты с токенами, — gin-jwt. Она неидеальна, но в качестве учебного примера вполне сойдет. Теперь у нас есть все, чтобы набросать каркас сервера:
func main() {
router := gin.Default()
mw := createJwtMiddleware()
router.POST("/login", mw.LoginHandler)
authorized := router.Group("/")
authorized.Use(mw.MiddlewareFunc())
authorized.Use(signatureVerifierMiddleware())
{
authorized.GET("/user", userInfo)
authorized.POST("/refresh", mw.RefreshHandler)
}
log.Fatal(router.Run(":8080"))
}
signatureVerifierMiddleware
Теперь разберемся, как реализовать мидлварь с верификатором подписи. Для начала необходимо самостоятельно сформировать подпись по тому же алгоритму, который мы использовали на клиенте:
requestTarget := fmt.Sprintf("(request-target): %s %s",
strings.ToLower(ctx.Request.Method),
ctx.Request.RequestURI
)
signedHeaderNames := strings.Split(ctx.GetHeader("X-Signed-Headers"), " ")
signatureData := []string{requestTarget}
for _, name := range signedHeaderNames {
value := fmt.Sprintf("%s: %s", name, ctx.GetHeader(name))
signatureData = append(signatureData, value)
}
signatureString := strings.Join(signatureData, "n")
Для создания верификатора нам потребуется публичный ключ пользователя. Его нужно загрузить из базы и преобразовать:
username := jwt.ExtractClaims(ctx)["id"]
row := db.QueryRow("SELECT public_key FROM users WHERE username == ?", username)
var public_key string
if err := row.Scan(&public_key); err != nil {
log.Fatal(err)
}
res, _ := base64.StdEncoding.DecodeString(public_key)
buf := bytes.NewBuffer(res)
r := keyset.NewBinaryReader(buf)
pub, _ := keyset.ReadWithNoSecrets(r)
verifier, err := signature.NewVerifier(pub)
if err != nil {
log.Fatal(err)
}
Теперь у нас есть все компоненты, чтобы проверить подпись пришедшего запроса. Вариант подписи, которую посчитал backend, уже лежит в signatureString
. Подпись, пришедшую от клиента, сохраняем в inputSignature
и верифицируем:
inputSignature, err := base64.StdEncoding.DecodeString(ctx.GetHeader("X-Signature"))
if err != nil {
log.Fatal(err)
}
if err := v.Verify(inputSignature, []byte(signatureString)); err != nil {
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": err.Error()})
}
Кое-что еще…
Искушенный читатель, скорее всего, уже догадался, в чем тут проблема описанной схемы. Это момент, когда открытый ключ отправляется на сервер. Если злоумышленник полностью контролирует канал передачи и смог перехватить открытый ключ во время авторизации пользователя, то ничто не мешает ему заменить этот ключ своим. В этом случае он также получает возможность подписывать запросы сам, что сводит на нет безопасность данной схемы. Это работает следующим образом.
MITM с подменой публичного ключа
Самый простой способ прикрыться от этой проблемы — реализовать Certificate Pinning на стороне мобильного приложения. До версии Android 7.0 это лучше всего делать через CertificatePinner в OkHttp, а начиная с этой версии — через Network Security Config.
Итоги
Мы надеемся, что теперь вы гораздо лучше поймете, зачем подписывать запрос и как его правильно реализовать с относительно небольшими усилиями. Конечно, все это та же дополнительная работа и код, которые необходимо поддерживать, и каждый также должен понимать это на каком-то уровне. Вот почему многие компании не внедряют такие полезные практики в своих странах, оставляя киберпреступников с дырами в безопасности. Но теперь вы знаете как и что делать.
www
- Репозиторий с кодом Android-приложения
- Репозиторий с кодом бэкенда на Go
- Signing HTTP Messages
.