Как работает цифровая подпись

Как защитить Android приложение при помощи подписи.

Цифровая подпись запросов сервера — это не черная магия или удел нескольких избранных секретных охранников. Реализовать эту функциональность в мобильном приложении под силу любому хорошему программисту при условии, что он знает соответствующие инструменты и подход к этой задаче. А если вам нужно самому стать хорошим разработчиком, я расскажу вам о правильных инструментах и ​​подходах Как защитить Android приложение при помощи подписи.

Защита подписью приложений для Android

При разработке клиент-серверных приложений для Android существует несколько очевидных способов сделать соединение более безопасным. Похоже, что к 2020 году все выучили аббревиатуру HTTPS как мантру, и Google, со своей стороны, помогает, запрещая HTTP-трафик по умолчанию в новых версиях операционной системы. Чуть более продвинутые товарищи знают, что сам HTTPS не защищает от всех векторов атак (привет, Мэллори!), И в конечном итоге они блокируют SSL (также известный как закрепление сертификата / открытого ключа). В большинстве случаев защита канала на этом заканчивается. И, честно говоря, в большинстве случаев этой защиты достаточно. Особенно, если другие векторы атак устраняются путем шифрования пользовательских данных и проверки ненадежной среды

Но бывает и по-другому. Приложение вынуждено запускаться в ненадежной среде, что означает, что вредоносное ПО на клиентском устройстве может перехватывать токены доступа к серверу непосредственно из памяти приложения. Более того, в зависимости от реализации механизма аннулирования этих токенов злоумышленник может выполнять свои запросы от имени пользователя в течение определенного времени. Существует обходной путь для этой проблемы: подписывайте все запросы, сделанные из разрешенной зоны, цифровой подписью. Обычно это все запросы, не относящиеся к / login или / register. В статье мы обсудим, как реализовать запросы подписи на клиенте и на сервере, а также о подводных камнях и ограничениях этого метода.

Криптоликбез

Что­бы сде­лать повес­тво­вание более сис­темным, давай для начала син­хро­низи­руем­ся в поняти­ях и осве­жим зна­ния крип­тогра­фии, если они по какой‑то при­чине зап­лесне­вели.

Нач­нем с понятия циф­ровая под­пись. Тема ЦП доволь­но обширная, поэто­му огра­ничим­ся асим­метрич­ной схе­мой циф­ровой под­писи, в которой учас­тву­ют откры­тый и зак­рытый клю­чи. В самом прос­том слу­чае циф­ровая под­пись работа­ет по сле­дующе­му алго­рит­му:

  1. Али­са шиф­рует документ сво­им зак­рытым клю­чом, тем самым под­писывая его.
  2. Али­са отправ­ляет под­писан­ный документ Бобу.
  3. Боб рас­шифро­выва­ет документ с помощью откры­того клю­ча Али­сы, тем самым про­веряя под­пись.

Это работает, но есть проблема. Если документ, подписанный Алисой, представляет собой чек на определенную сумму денег, то ненадежный Боб может обналичить этот чек до тех пор, пока у Алисы не закончатся деньги на счете или пока Боб не будет пойман. Для решения этой проблемы используются временные метки. Алиса добавляет в документ текущее время и шифрует его вместе с документом. Банк, в который Боб приносит этот чек и открытый ключ Алисы, расшифровывает документ и сохраняет отметку времени. Теперь при повторной попытке обналичить такой чек банк заблокирует эту операцию, поскольку метки времени будут такими же.

Вам еще не скучно? Наберитесь терпения, все это нам скоро пригодится, когда мы будем писать реализацию. Последний аспект, который я хочу обсудить, — это производительность асимметричных криптосистем. В конечном итоге они оказываются совершенно неэффективными для больших объемов данных, а это означает, что попытка использовать этот подход для подписи больших запросов будет безжалостно расходовать батарею смартфона и уменьшать связь с сервером.Для уско­рения всей этой машине­рии при­нято исполь­зовать односто­рон­ние хеш‑фун­кции. Ито­говая вер­сия алго­рит­ма будет выг­лядеть так:

  1. Али­са вычис­ляет зна­чение хеш‑фун­кции для докумен­та.
  2. Она шиф­рует это зна­чение сво­им зак­рытым клю­чом, тем самым под­писывая документ.
  3. Али­са посыла­ет Бобу документ и под­писан­ное хеш‑зна­чение.
  4. Боб вычис­ляет зна­чение хеш‑фун­кции для докумен­та, прис­ланно­го Али­сой.
  5. Он рас­шифро­выва­ет зна­чение хеш‑фун­кции докумен­та, прис­ланно­го Али­сой.
  6. Боб срав­нива­ет это зна­чение с вычис­ленным самос­тоятель­но. Если они сов­пада­ют, то под­пись под­линна.

Как работает цифровая подпись

Как работа­ет циф­ровая под­пись

Как вид­но из при­меров — надеж­ность механиз­ма циф­ровой под­писи базиру­ется на двух пред­положе­ниях:

  1. Зак­рытый ключ Али­сы дос­тупен толь­ко ей и боль­ше никому.
  2. У Боба находит­ся откры­тый ключ имен­но Али­сы, а не кого‑то дру­гого.

Реализация клиентской части

Те­перь ты дол­жен при­мер­но пред­став­лять, как мож­но реали­зовать под­пись зап­росов. Спо­собов реали­зации боль­ше одно­го, но я покажу самый, по моему мне­нию, прос­той и удоб­ный.

Для начала определимся с генерацией ключа и самим алгоритмом цифровой подписи. Я действительно не рекомендую писать все вручную с использованием криптографических примитивов из Android SDK. Лучше взять готовое и проверенное решение — библиотеку Tink, написанную темными гениями Google. Она реша­ет сра­зу нес­коль­ко наших проб­лем:

  • сох­раня­ет клю­чи в Android Keystore, что прак­тичес­ки исклю­чает их насиль­ствен­ное извле­чение с устрой­ства. А зна­чит, обес­печива­ет нам истинность пер­вого пред­положе­ния о надеж­ности механиз­ма циф­ровой под­писи;
  • пре­дос­тавля­ет надеж­ный алго­ритм под­писи на эллипти­чес­ких кри­вых — ECDSA P-256;
  • пре­дос­тавля­ет удоб­ные крип­топри­мити­вы и API для соз­дания циф­ровой под­писи.

Под­клю­чаем биб­лиоте­ку к про­екту (implementation 'com.google.crypto.tink:tink-android:1.5.0') и генери­руем пару клю­чей, которые сра­зу будут сох­ранены в Android Keystore:

Чтобы сервер мог проверить нашу цифровую подпись, он должен каким-то образом передать открытый ключ из пары, которую мы сгенерировали выше. Лучше всего это сделать на этапе авторизации. Открытый ключ не является секретным, поэтому мы можем легко передать его прямо в запрос вместе с логином и паролем пользователя, предварительно закодировав в Base64:

Tink не позволяет работать напрямую с ключевым материалом. Вместо этого библиотека предлагает концепцию Reader / Writer, которая позволяет вам читать и записывать ключи в JSON или двоичном представлении. Подробности смотрите в документации.

Те­перь получим при­митив для соз­дания циф­ровой под­писи:

val signer = privateKeysetHandle.getPrimitive(PublicKeySign::class.java)

После написания кода вы также можете попроектировать 😉 Если вы забыли формулировку проблемы, то нам нужно убедиться, что все запросы в авторизованной зоне подписаны. Самый простой способ сделать это — использовать абстракцию сетевого перехватчика из библиотеки OkHttp. Что-то подобное можно сделать с чем угодно, но с OkHttp удобнее. Сос­тавим спи­сок фун­кци­ональ­ных тре­бова­ний к нашему механиз­му под­писи зап­росов:

  1. Под­писывать нуж­но толь­ко зап­росы из авто­ризо­ван­ной зоны.
  2. В под­пись дол­жны быть вклю­чены сле­дующие стан­дар­тные заголов­ки: Authorization, User-Agent.
  3. К зап­росу необ­ходимо добавить мет­ку вре­мени в виде заголов­ка Data, который так­же дол­жен быть под­писан. Зна­чение заголов­ка — дата и вре­мя в фор­мате ISO-8601.
  4. Дан­ные для под­писи фор­миру­ются по сле­дующе­му шаб­лону:

    (request-target): %method% %request_uri%n
    host: %host_header_value%n
    authorization: %authorization_header_value%n
    user-agent: %user-agent_header_value%
  5. До­бавить к зап­росу заголо­вок X-Signed-Headers, в котором нуж­но ука­зать заголов­ки, учас­тву­ющие в под­писи в том же поряд­ке, в каком они были добав­лены в стро­ку для фор­мирова­ния под­писи.

  6. Для зап­росов с телом необ­ходимо допол­нитель­но вычис­лять хеш SHA-512 от тела и добав­лять его как в виде заголов­ка Digest, так и в стро­ку для фор­мирова­ния под­писи.

  7. До­бавить к зап­росу заголо­вок 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

 
Но 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()})
}
 
Пол­ную реали­зацию мож­но будет так­же най­ти по ссыл­ке в кон­це статьи.

Кое-что еще…

Искушенный читатель, скорее всего, уже догадался, в чем тут проблема описанной схемы. Это момент, когда открытый ключ отправляется на сервер. Если злоумышленник полностью контролирует канал передачи и смог перехватить открытый ключ во время авторизации пользователя, то ничто не мешает ему заменить этот ключ своим. В этом случае он также получает возможность подписывать запросы сам, что сводит на нет безопасность данной схемы. Это работает следующим образом.

MITM с подменой публичного ключа

MITM с под­меной пуб­лично­го клю­ча

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

Итоги

Мы надеемся, что теперь вы гораздо лучше поймете, зачем подписывать запрос и как его правильно реализовать с относительно небольшими усилиями. Конечно, все это та же дополнительная работа и код, которые необходимо поддерживать, и каждый также должен понимать это на каком-то уровне. Вот почему многие компании не внедряют такие полезные практики в своих странах, оставляя киберпреступников с дырами в безопасности. Но теперь вы знаете как и что делать.

www

 

 

 

.

Click to rate this post!
[Total: 0 Average: 0]

Leave a reply:

Your email address will not be published.