Получи и распишись. Защищаем подписью запросы приложения для Android

Получи и распишись. Защищаем подписью запросы приложения для Android

Life-Hack [Жизнь-Взлом]/Хакинг

#Обучение

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

При раз­работ­ке кли­ент‑сер­верных при­ложе­ний под Android есть нес­коль­ко оче­вид­ных спо­собов сде­лать соеди­нение безопас­нее. Кажет­ся, что к 2020 году уже все выучи­ли аббре­виату­ру HTTPS как ман­тру, да и Google со сво­ей сто­роны помога­ет, зап­рещая по умол­чанию HTTP-тра­фик в новых вер­сиях ОС. Чуть более прод­винутые товари­щи зна­ют, что сам по себе HTTPS защища­ет не от всех век­торов атак (при­вет, Мэл­лори!), и нак­ручива­ют SSL Pinning (aka Certificate/Public Key Pinning). Чаще все­го защита канала на этом закан­чива­ется. Да и чес­тно говоря, в боль­шинс­тве слу­чаев этой защиты дос­таточ­но. Осо­бен­но если с помощью шиф­рования поль­зователь­ских дан­ных и про­вер­ки на недове­рен­ное окру­жение лик­видиру­ются дру­гие век­торы ата­ки.

Но быва­ет и по‑дру­гому. При­ложе­ние вынуж­дено работать в недове­рен­ной сре­де, а это зна­чит, что злов­ред на кли­ент­ском устрой­стве может перех­ватить токены дос­тупа к сер­веру пря­мо из памяти при­ложе­ния. Далее, в зависи­мос­ти от реали­зации механиз­ма инва­лида­ции этих токенов, зло­умыш­ленник какое‑то вре­мя может выпол­нять зап­росы от лица поль­зовате­ля. У этой проб­лемы есть решение — вешать циф­ровую под­пись на все зап­росы, выпол­няющиеся из авто­ризо­ван­ной зоны. Как пра­вило, это все зап­росы, которые не /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:

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 удоб­нее. Сос­тавим спи­сок фун­кци­ональ­ных тре­бова­ний к нашему механиз­му под­писи зап­росов:

  1. Под­писывать нуж­но толь­ко зап­росы из авто­ризо­ван­ной зоны.
  2. В под­пись дол­жны быть вклю­чены сле­дующие стан­дар­тные заголов­ки: AuthorizationUser-Agent.
  3. К зап­росу необ­ходимо добавить мет­ку вре­мени в виде заголов­ка Data, который так­же дол­жен быть под­писан. Зна­чение заголов­ка — дата и вре­мя в фор­мате ISO-8601.
  4. Дан­ные для под­писи фор­миру­ются по сле­дующе­му шаб­лону:
  5. (request-target): %method% %request_uri%n
  6. host: %host_header_value%n
  7. authorization: %authorization_header_value%n
  8. user-agent: %user-agent_header_value%
  9. До­бавить к зап­росу заголо­вок X-Signed-Headers, в котором нуж­но ука­зать заголов­ки, учас­тву­ющие в под­писи в том же поряд­ке, в каком они были добав­лены в стро­ку для фор­мирова­ния под­писи.
  10. Для зап­росов с телом необ­ходимо допол­нитель­но вычис­лять хеш SHA-512 от тела и добав­лять его как в виде заголов­ка Digest, так и в стро­ку для фор­мирова­ния под­писи.
  11. До­бавить к зап­росу заголо­вок 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.

ИТОГИ

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

Источник


Report Page