Skip to content
Permalink
Browse files

Issue #434: Fretboard: Verify signatures of experiments collection

Closes #434: Fretboard: Verify signatures of experiments collection
  • Loading branch information...
fercarcedo authored and pocmo committed Jul 18, 2018
1 parent 75a87f8 commit f65aad40e6f8ec84009a3f809f816c1c969c7082
@@ -37,7 +37,12 @@ data class Experiment(
/**
* Experiment associated metadata
*/
val payload: ExperimentPayload? = null
val payload: ExperimentPayload? = null,
/**
* Last time the experiment schema was modified
* (as a UNIX timestamp)
*/
val schema: Long? = null
) {
data class Matcher(
/**
@@ -5,6 +5,7 @@
package mozilla.components.service.fretboard

import mozilla.components.support.ktx.android.org.json.putIfNotNull
import mozilla.components.support.ktx.android.org.json.sortKeys
import mozilla.components.support.ktx.android.org.json.toList
import mozilla.components.support.ktx.android.org.json.tryGetInt
import mozilla.components.support.ktx.android.org.json.tryGetLong
@@ -25,7 +26,7 @@ class JSONExperimentParser {
fun fromJson(jsonObject: JSONObject): Experiment {
val bucketsObject: JSONObject? = jsonObject.optJSONObject(BUCKETS_KEY)
val matchObject: JSONObject? = jsonObject.optJSONObject(MATCH_KEY)
val regions: List<String>? = matchObject?.optJSONArray(REGIONS_KEY).toList()
val regions: List<String>? = matchObject?.optJSONArray(REGIONS_KEY)?.toList()
val matcher = if (matchObject != null) {
Experiment.Matcher(
matchObject.tryGetString(LANG_KEY),
@@ -48,7 +49,8 @@ class JSONExperimentParser {
matcher,
bucket,
jsonObject.tryGetLong(LAST_MODIFIED_KEY),
payload)
payload,
jsonObject.tryGetLong(SCHEMA_KEY))
}

/**
@@ -70,6 +72,7 @@ class JSONExperimentParser {
jsonObject.putIfNotNull(LAST_MODIFIED_KEY, experiment.lastModified)
jsonObject.put(MATCH_KEY, matchObject)
jsonObject.putIfNotNull(NAME_KEY, experiment.name)
jsonObject.putIfNotNull(SCHEMA_KEY, experiment.schema)
jsonObject.putIfNotNull(PAYLOAD_KEY, payloadToJson(experiment.payload))
return jsonObject
}
@@ -114,7 +117,7 @@ class JSONExperimentParser {
else -> jsonObject.put(key, value)
}
}
return jsonObject
return jsonObject.sortKeys()
}

companion object {
@@ -134,6 +137,7 @@ class JSONExperimentParser {
private const val NAME_KEY = "name"
private const val DESCRIPTION_KEY = "description"
private const val LAST_MODIFIED_KEY = "last_modified"
private const val PAYLOAD_KEY = "payload"
private const val SCHEMA_KEY = "schema"
private const val PAYLOAD_KEY = "values"
}
}
@@ -43,5 +43,15 @@ internal class KintoClient(
return httpClient.get(URL("${recordsUrl()}?_since=$lastModified"), headers)
}

private fun recordsUrl() = "$baseUrl/buckets/$bucketName/collections/$collectionName/records"
/**
* Gets the collection associated metadata
*
* @return collection metadata
*/
fun getMetadata(): String {
return httpClient.get(URL(collectionUrl()))
}

private fun recordsUrl() = "${collectionUrl()}/records"
private fun collectionUrl() = "$baseUrl/buckets/$bucketName/collections/$collectionName"
}
@@ -24,16 +24,24 @@ class KintoExperimentSource(
private val baseUrl: String,
private val bucketName: String,
private val collectionName: String,
private val client: HttpClient = HttpURLConnectionHttpClient()
private val validateSignature: Boolean = false,
client: HttpClient = HttpURLConnectionHttpClient()
) : ExperimentSource {
private val kintoClient = KintoClient(client, baseUrl, bucketName, collectionName)
private val signatureVerifier = SignatureVerifier(client, kintoClient)

override fun getExperiments(snapshot: ExperimentsSnapshot): ExperimentsSnapshot {
val experimentsDiff = getExperimentsDiff(client, snapshot)
return mergeExperimentsFromDiff(experimentsDiff, snapshot)
val experimentsDiff = getExperimentsDiff(snapshot)
val updatedSnapshot = mergeExperimentsFromDiff(experimentsDiff, snapshot)
if (validateSignature &&
!signatureVerifier.validSignature(updatedSnapshot.experiments, updatedSnapshot.lastModified)) {
throw ExperimentDownloadException("Signature verification failed")
}
return updatedSnapshot
}

private fun getExperimentsDiff(client: HttpClient, snapshot: ExperimentsSnapshot): String {
private fun getExperimentsDiff(snapshot: ExperimentsSnapshot): String {
val lastModified = snapshot.lastModified
val kintoClient = KintoClient(client, baseUrl, bucketName, collectionName)
return if (lastModified != null) {
kintoClient.diff(lastModified)
} else {
@@ -0,0 +1,265 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package mozilla.components.service.fretboard.source.kinto

import android.util.Base64
import mozilla.components.service.fretboard.Experiment
import mozilla.components.service.fretboard.ExperimentDownloadException
import mozilla.components.service.fretboard.JSONExperimentParser
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.StringReader
import java.math.BigInteger
import java.net.URL
import java.nio.charset.StandardCharsets
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
import java.security.NoSuchProviderException
import java.security.PublicKey
import java.security.Signature
import java.security.SignatureException
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.Date
import java.util.concurrent.TimeUnit

/**
* This class is used to validate the signature of the downloaded list of experiments
*/
internal class SignatureVerifier(
private val client: HttpClient,
private val kintoClient: KintoClient,
private val currentDate: Date = Date()
) {
/**
* Checks the signature of an experiment list against the signature found on the
* metadata JSON object, using the certificates found on the x5u field
*
* @param experiments experiment list
* @param lastModified last modified time of the experiment list
*
* @return true if the list of experiments validates against the signature, false otherwise
*/
internal fun validSignature(experiments: List<Experiment>, lastModified: Long?): Boolean {
val sortedExperiments = experiments.sortedBy { it.id }
val resultJson = JSONArray()
val parser = JSONExperimentParser()
for (experiment in sortedExperiments) {
resultJson.put(parser.toJson(experiment))
}
val metadata: String? = kintoClient.getMetadata()
metadata?.let {
val metadataJson = JSONObject(metadata).getJSONObject(DATA_KEY)
val signatureJson = metadataJson.getJSONObject(SIGNATURE_KEY)
val signature = signatureJson.getString(SIGNATURE_KEY)
val publicKey = getX5U(URL(signatureJson.getString(X5U_KEY)))
val resultJsonString = resultJson.toString().replace("\\/", "/")
val data = "$SIGNATURE_PREFIX{\"data\":$resultJsonString,\"last_modified\":\"$lastModified\"}"
return validSignature(data, signature, publicKey)
}
return true
}

/**
* Retrieves the public key from the end-entity certificate from the certificate chain
* located on the given URL from the X5U field
*
* @param url url of the certificate chain
*
* @throws ExperimentDownloadException if the certificate chain format is invalid
*
* @return public key of the end-entity certificate
*/
private fun getX5U(url: URL): PublicKey {
val certs = ArrayList<X509Certificate>()
val cf = CertificateFactory.getInstance("X.509")
val response = client.get(url)
val reader = BufferedReader(StringReader(response))
val firstLine = reader.readLine()
if (firstLine != "-----BEGIN CERTIFICATE-----")
throw ExperimentDownloadException("")
var certPem = firstLine
certPem += '\n'

reader.readLines().forEach {
certPem += it
certPem += '\n'
if (it == "-----END CERTIFICATE-----") {
val cert = cf.generateCertificate(ByteArrayInputStream(certPem.toByteArray()))
certs.add(cert as X509Certificate)
certPem = ""
}
}
if (certs.count() < MIN_CERTIFICATES) {
throw ExperimentDownloadException("The chain must contain at least 2 certificates")
}
verifyCertChain(certs)
return certs[0].publicKey
}

/**
* Verifies the validity of the certificates on the chain
*
* @param certificates certificates
*
* @throws ExperimentDownloadException if any certificate is not valid
*/
private fun verifyCertChain(certificates: List<X509Certificate>) {
for (i in 0 until certificates.count()) {
val cert = certificates[i]
if (!isCertValid(cert)) {
throw ExperimentDownloadException("Certificate expired or not yet valid")
}
if ((i + 1) == certificates.count()) {
verifyRoot(cert)
} else {
verifyCertSignedByParent(cert, i, certificates)
}
}
}

/**
* Verifies that the certificate is signed by its parent on the chain
*
* @param certificate certificate
* @param index index of the certificate on the chain
* @param certificates certificate chain
*
* @throws ExperimentDownloadException if the certificate chain is invalid
*
* @return true if the certificate is signed by its parent on the chain, false otherwise
*/
private fun verifyCertSignedByParent(
certificate: X509Certificate,
index: Int,
certificates: List<X509Certificate>
) {
try {
certificate.verify(certificates[index + 1].publicKey)
} catch (e: CertificateException) {
invalidCertChain(e)
} catch (e: NoSuchAlgorithmException) {
invalidCertChain(e)
} catch (e: InvalidKeyException) {
invalidCertChain(e)
} catch (e: NoSuchProviderException) {
invalidCertChain(e)
} catch (e: SignatureException) {
invalidCertChain(e)
}
}

private fun invalidCertChain(e: Exception) {
throw ExperimentDownloadException(e)
}

/**
* Checks the certificate validity. It checks against a window of 30 days
*
* @param certificate certificate to check
*
* @return true if the certificate is still valid (with a 30-day window), false otherwise
*/
private fun isCertValid(certificate: X509Certificate): Boolean {
val notBefore = certificate.notBefore
val notAfter = certificate.notAfter
return currentDate.time >= (notBefore.time - TimeUnit.DAYS.toMillis(CERT_VALIDITY_ROOM_DAYS)) &&
(notAfter.time + TimeUnit.DAYS.toMillis(CERT_VALIDITY_ROOM_DAYS)) >= currentDate.time
}

/**
* Verifies the root certificate of the chain, checking that the issuer matches the subject
*
* @param certificate root certificate
*
* @throws ExperimentDownloadException if the root certificate fails the verification
*/
private fun verifyRoot(certificate: X509Certificate) {
val subject = certificate.subjectDN.name
val issuer = certificate.issuerDN.name
if (subject != issuer) {
throw ExperimentDownloadException("subject does not match issuer")
}
}

/**
* Validates the signature of a signed data, given a signature and a public key using ECDSA
* with curve P-384 and SHA-384
* @param signedData signed data
* @param signature signature
* @param publicKey public key
*
* @return true if the signed data validates against the signature
*/
private fun validSignature(signedData: String, signature: String, publicKey: PublicKey): Boolean {
val dsa = Signature.getInstance("SHA384withECDSA")
dsa.initVerify(publicKey)
dsa.update(signedData.toByteArray(StandardCharsets.UTF_8))
val signatureBytes = Base64.decode(signature.replace("-", "+").replace("_", "/"), 0)
return dsa.verify(signatureToASN1(signatureBytes))
}

/**
* Converts signature bytes into the ASN1 DER format. The provided bytes contain r and s values
* concatenated, and the implementation converts these two values into the DER format
* @param signatureBytes signatures bytes, r and s values concatenated
*
* @throws ExperimentDownloadException if the signature is not valid
*
* @return DER-encoded signature bytes
*/
private fun signatureToASN1(signatureBytes: ByteArray): ByteArray {
if (signatureBytes.count() == 0 || signatureBytes.count() % 2 != 0) {
throw ExperimentDownloadException("Invalid signature")
}
var rBytes = ByteArray(signatureBytes.count() / 2)
for (i in 0 until signatureBytes.count() / 2) {
rBytes += signatureBytes[i]
}
var sBytes = ByteArray(signatureBytes.count() / 2)
for (i in signatureBytes.count() / 2 until signatureBytes.count()) {
sBytes += signatureBytes[i]
}
val r = BigInteger(rBytes)
val s = BigInteger(sBytes)
rBytes = r.toByteArray()
sBytes = s.toByteArray()
val res = ByteArray(NUMBER_OF_DER_ADDITIONAL_BYTES + rBytes.size + sBytes.size)
res[START_POS] = FIRST_DER_NUMBER
res[B1_POS] = (REMAINING_DER_ADDITIONAL_BYTES + rBytes.size + sBytes.size).toByte()
res[SECOND_NUMBER_POS] = SECOND_DER_NUMBER
res[B2_POS] = rBytes.size.toByte()
System.arraycopy(rBytes, START_POS, res, R_POS, rBytes.size)
res[R_POS + rBytes.size] = SECOND_DER_NUMBER
res[B3_POS + rBytes.size] = sBytes.size.toByte()
System.arraycopy(sBytes, START_POS, res, S_POS + rBytes.size, sBytes.size)
return res
}

companion object {
private const val DATA_KEY = "data"
private const val SIGNATURE_KEY = "signature"
private const val X5U_KEY = "x5u"
private const val CERT_VALIDITY_ROOM_DAYS = 30L
private const val MIN_CERTIFICATES = 2
private const val SIGNATURE_PREFIX = "Content-Signature:\u0000"
private const val NUMBER_OF_DER_ADDITIONAL_BYTES = 6
private const val FIRST_DER_NUMBER: Byte = 0x30
private const val REMAINING_DER_ADDITIONAL_BYTES = 4
private const val SECOND_DER_NUMBER: Byte = 0x02
private const val START_POS = 0
private const val B1_POS = 1
private const val SECOND_NUMBER_POS = 2
private const val B2_POS = 3
private const val R_POS = 4
private const val B3_POS = 5
private const val S_POS = 6
}
}
Oops, something went wrong.

0 comments on commit f65aad4

Please sign in to comment.
You can’t perform that action at this time.