Получи и распишись. Защищаем подписью запросы приложения для Android
Life-Hack [Жизнь-Взлом]/ХакингЦифровая подпись запросов к серверу — это не какая‑то черная магия или удел избранных сумрачных безопасников. Реализация этой функциональности в мобильном приложении вполне по силам любому хорошему разработчику — при условии, что он знает правильные инструменты и подход к этой задаче. И если хорошим разработчиком тебе придется становиться самостоятельно, то о правильных инструментах и подходах я расскажу в этой статье.
При разработке клиент‑серверных приложений под Android есть несколько очевидных способов сделать соединение безопаснее. Кажется, что к 2020 году уже все выучили аббревиатуру HTTPS как мантру, да и Google со своей стороны помогает, запрещая по умолчанию HTTP-трафик в новых версиях ОС. Чуть более продвинутые товарищи знают, что сам по себе HTTPS защищает не от всех векторов атак (привет, Мэллори!), и накручивают SSL Pinning (aka Certificate/Public Key Pinning). Чаще всего защита канала на этом заканчивается. Да и честно говоря, в большинстве случаев этой защиты достаточно. Особенно если с помощью шифрования пользовательских данных и проверки на недоверенное окружение ликвидируются другие векторы атаки.
Но бывает и по‑другому. Приложение вынуждено работать в недоверенной среде, а это значит, что зловред на клиентском устройстве может перехватить токены доступа к серверу прямо из памяти приложения. Далее, в зависимости от реализации механизма инвалидации этих токенов, злоумышленник какое‑то время может выполнять запросы от лица пользователя. У этой проблемы есть решение — вешать цифровую подпись на все запросы, выполняющиеся из авторизованной зоны. Как правило, это все запросы, которые не /login или /register. О том, как реализовать подпись запросов на клиенте и на сервере, а также о подводных камнях и ограничениях этой техники поговорим в статье.
КРИПТОЛИКБЕЗ
Чтобы сделать повествование более системным, давай для начала синхронизируемся в понятиях и освежим знания криптографии, если они по какой‑то причине заплесневели.
Начнем с понятия цифровая подпись. Тема ЦП довольно обширная, поэтому ограничимся асимметричной схемой цифровой подписи, в которой участвуют открытый и закрытый ключи. В самом простом случае цифровая подпись работает по следующему алгоритму:
- Алиса шифрует документ своим закрытым ключом, тем самым подписывая его.
- Алиса отправляет подписанный документ Бобу.
- Боб расшифровывает документ с помощью открытого ключа Алисы, тем самым проверяя подпись.
Это работает, но есть проблема. Если документ, подписанный Алисой, — чек на некоторую сумму денег, то неблагонадежный Боб сможет обналичивать этот чек, пока у Алисы не закончатся деньги на счете или пока Боба не поймают. Для борьбы с этой проблемой применяются метки времени. Алиса добавляет к документу текущее время и шифрует его вместе с документом. Банк, в который Боб приносит этот чек и открытый ключ Алисы, расшифровывает документ и сохраняет метку времени. Теперь при попытке обналичить такой чек повторно банк заблокирует эту операцию, так как метки времени будут одинаковые.
Еще не заскучал? Потерпи, это все нам пригодится уже скоро, когда будем писать реализацию. Финальный аспект, который хочется обсудить, — производительность асимметричных криптосистем. Они оказываются довольно неэффективны на больших массивах данных, а значит, попытка применить этот подход для подписи объемных запросов будет нещадно жрать батарею смартфона и замедлять общение с сервером. Для ускорения всей этой машинерии принято использовать односторонние хеш‑функции. Итоговая версия алгоритма будет выглядеть так:
- Алиса вычисляет значение хеш‑функции для документа.
- Алиса шифрует это значение своим закрытым ключом, тем самым подписывая документ.
- Алиса посылает Бобу документ и подписанное хеш‑значение.
- Боб вычисляет значение хеш‑функции для документа, присланного Алисой.
- Боб расшифровывает значение хеш‑функции документа, присланного Алисой.
- Боб сравнивает это значение с вычисленным самостоятельно. Если они совпадают, то подпись подлинна.

Как видно из примеров — надежность механизма цифровой подписи базируется на двух предположениях:
- Закрытый ключ Алисы доступен только ей и больше никому.
- У Боба находится открытый ключ именно Алисы, а не кого‑то другого.
РЕАЛИЗАЦИЯ КЛИЕНТСКОЙ ЧАСТИ
Теперь ты должен примерно представлять, как можно реализовать подпись запросов. Способов реализации больше одного, но я покажу самый, по моему мнению, простой и удобный.
Для начала определимся с генерацией ключей и с самим алгоритмом цифровой подписи. Очень не рекомендую писать это все руками, используя криптопримитивы из Android SDK. Лучше взять готовое и зарекомендовавшее себя решение — библиотеку Tink, написанную сумрачными гениями из Google. Она решает сразу несколько наших проблем:
- сохраняет ключи в Android Keystore, что практически исключает их насильственное извлечение с устройства. А значит, обеспечивает нам истинность первого предположения о надежности механизма цифровой подписи;
- предоставляет надежный алгоритм подписи на эллиптических кривых — ECDSA P-256;
- предоставляет удобные криптопримитивы и API для создания цифровой подписи.
Подключаем библиотеку к проекту (implementation 'com.google.crypto.tink:tink-android:1.5.0') и генерируем пару ключей, которые сразу будут сохранены в Android Keystore:
companion object {
const val KEYSET_NAME = "master_signature_keyset"
const val PREFERENCE_FILE = "master_signature_key_preference"
const val MASTER_KEY_URI = "android-keystore://master_signature_key"
}
SignatureConfig.register()
val privateKeysetHandle = AndroidKeysetManager.Builder()
.withSharedPref(application, KEYSET_NAME, PREFERENCE_FILE)
.withKeyTemplate(EcdsaSignKeyManager.ecdsaP256Template())
.withMasterKeyUri(MASTER_KEY_URI)
.build()
.keysetHandle
Чтобы сервер мог проверить нашу цифровую подпись, ему нужно как‑то передать публичный ключ от той пары, которую мы сгенерировали выше. Делать это правильнее всего на этапе авторизации. Публичный ключ не секретный, поэтому мы вполне можем передать его прямо в запросе вместе с логином и паролем пользователя, предварительно закодировав в Base64:
val bos = ByteArrayOutputStream()
val w = BinaryKeysetWriter.withOutputStream(bos)
privateKeysetHandle.publicKeysetHandle.writeNoSecret(w)
val response = api.login(
LoginRequest(
username,
password,
Base64.encodeToString(bos.toByteArray(), Base64.DEFAULT)
)
)
bos.close()
Tink не позволяет работать с ключевым материалом напрямую. Вместо этого библиотека предлагает концепцию Reader/Writer’ов, которые позволяют читать и писать ключи в JSON-представлении или в бинарном. Подробности есть в документации.
Теперь получим примитив для создания цифровой подписи:
val signer = privateKeysetHandle.getPrimitive(PublicKeySign::class.java)
После написания кода можно и попроектировать
Если ты забыл постановку задачи, то нам нужно обеспечить подпись всех запросов из авторизованной зоны. Проще всего это сделать с помощью абстракции network interceptor из библиотеки OkHttp. Что‑то похожее можно сделать на чем угодно, но с OkHttp удобнее. Составим список функциональных требований к нашему механизму подписи запросов:
- Подписывать нужно только запросы из авторизованной зоны.
- В подпись должны быть включены следующие стандартные заголовки:
Authorization,User-Agent. - К запросу необходимо добавить метку времени в виде заголовка
Data, который также должен быть подписан. Значение заголовка — дата и время в формате ISO-8601. - Данные для подписи формируются по следующему шаблону:
(request-target): %method% %request_uri%nhost: %host_header_value%nauthorization: %authorization_header_value%nuser-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"))
}
Функция createJwtMiddleware() нам менее интересна, так как практически полностью повторяет пример из репозитория gin-jwt. Она создает преднастроенную мидлварь, которая умеет выдавать и обновлять токены. А вот signatureVerifierMiddleware() как раз реализует верификатор подписи запросов от клиента, основанный на пришедшем от него публичном ключе. Ее мы разберем самым подробным образом чуть ниже. Публичный ключ клиента можно хранить где угодно, но в данном примере я использую для этого базу данных SQLite.
Теперь разберемся, как реализовать мидлварь с верификатором подписи. Для начала необходимо самостоятельно сформировать подпись по тому же алгоритму, который мы использовали на клиенте:
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()})
}
Полную реализацию можно будет также найти по ссылке в конце статьи.
КОЕ-ЧТО ЕЩЕ...
Искушенный читатель, скорее всего, уже догадался, какая проблема у описанной схемы. Это момент отправки публичного ключа на сервер. Если злоумышленник полностью контролирует канал передачи и смог перехватить публичный ключ во время авторизации пользователя, то ему ничто не мешает подменить этот ключ своим. В этом случае он также получает возможность подписывать запросы самостоятельно, что сводит к нулю безопасность приведенной схемы. Работает это следующим образом.

Самый простой способ прикрыться от этой проблемы — реализовать Certificate Pinning на стороне мобильного приложения. До версии Android 7.0 это лучше всего делать через CertificatePinner в OkHttp, а начиная с этой версии — через Network Security Config.
ИТОГИ
Надеюсь, теперь ты гораздо лучше понимаешь, зачем нужна подпись запросов и как ее правильно реализовать относительно небольшими усилиями. Конечно, это все равно дополнительная работа и код, который нужно поддерживать, да и разбираться в этом всем тоже на каком‑то уровне нужно. Именно поэтому многие компании не реализуют у себя такую полезную практику, оставляя злоумышленникам лазейки в безопасности. Но ты теперь знаешь, что надо делать.