Merge pull request #26 from kuba2k2/feature/hebe-jvm-update

[hebe-jvm] Refactor signature and certificate/keypair generators
This commit is contained in:
Mikołaj Pich 2021-02-19 11:47:34 +01:00 committed by GitHub
commit a99ca50a31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 162 additions and 93 deletions

View file

@ -16,22 +16,38 @@ dependencies {
}
```
Additionally, to use the library on Android, [Core Library Desugaring](https://developer.android.com/studio/write/java8-support) has to be enabled.
## Usage
Generate an RSA2048 key pair (private key and certificate):
Generate an RSA2048 key pair:
```kotlin
import io.github.wulkanowy.signer.hebe.android.generateKeyPair
val (certificate, fingerprint, privateKey) = generateKeyPair()
val (publicPem, privatePem, publicHash) = generateKeyPair()
```
Sign request content:
Generate a certificate:
```kotlin
import io.github.wulkanowy.signer.hebe.android.getSignatureValues
import io.github.wulkanowy.signer.hebe.android.generateKeyPair
val (digest, canonicalUrl, signature) = getSignatureValues(fingerprint, privateKey, body, fullUrl, Date())
val (certificatePem, certificateHash) = generateCertificate(privatePem)
```
### Sign request content
```kotlin
import io.github.wulkanowy.signer.hebe.android.getSignatureHeaders
val headers = getSignatureHeaders(keyId, privatePem, body, fullUrl, ZonedDateTime.now())
```
The `keyId` depends on the `CertificateType` (sent in the registration request JSON):
- for `X509` - SHA-1 of the raw certificate bytes (`certificateHash`)
- for `RSA_PEM` - MD5 of the PEM-encoded public key (`publicHash`)
Hashes are represented as hexadecimal strings, without spaces.
PEM encoding is considered as Base64 here, without wrapping or RSA headers.
## Tests
```bash

View file

@ -0,0 +1,54 @@
package io.github.wulkanowy.signer.hebe
import com.migcomponents.migbase64.Base64
import eu.szkolny.x509.X509Generator
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.interfaces.RSAPrivateCrtKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.RSAPublicKeySpec
import java.time.ZonedDateTime
fun generateKeyPair(): Triple<String, String, String> {
val generator = KeyPairGenerator.getInstance("RSA")
generator.initialize(2048)
val keyPair = generator.generateKeyPair()
val publicKey = keyPair.public.encoded
val privateKey = keyPair.private.encoded
val publicPem = Base64.encodeToString(publicKey, false)
val privatePem = Base64.encodeToString(privateKey, false)
val publicHash = MessageDigest.getInstance("MD5")
.digest(publicPem.toByteArray())
.joinToString("") { "%02x".format(it) }
return Triple(publicPem, privatePem, publicHash)
}
fun generateCertificate(privatePem: String): Pair<String, String> {
val keyFactory = KeyFactory.getInstance("RSA")
val privateBytes = Base64.decode(privatePem)
val privateSpec = PKCS8EncodedKeySpec(privateBytes)
val privateKey = keyFactory.generatePrivate(privateSpec) as RSAPrivateCrtKey
val publicSpec = RSAPublicKeySpec(privateKey.modulus, privateKey.publicExponent, privateKey.params)
val publicKey = keyFactory.generatePublic(publicSpec)
val keyPair = KeyPair(publicKey, privateKey)
val notBefore = ZonedDateTime.now()
val notAfter = notBefore.plusYears(20)
val cert = X509Generator(X509Generator.Algorithm.RSA_SHA256)
.generate(subject = mapOf("CN" to "APP_CERTIFICATE CA Certificate"),
notBefore = notBefore,
notAfter = notAfter,
serialNumber = 1,
keyPair = keyPair
)
val certificatePem = Base64.encodeToString(cert, false)
val certificateHash = MessageDigest.getInstance("SHA-1")
.digest(cert)
.joinToString("") { "%02x".format(it) }
return Pair(certificatePem, certificateHash)
}

View file

@ -1,32 +1,31 @@
package io.github.wulkanowy.signer.hebe
import com.migcomponents.migbase64.Base64
import eu.szkolny.x509.X509Generator
import java.net.URLEncoder
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import java.text.SimpleDateFormat
import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.*
import java.time.format.DateTimeFormatter
import java.security.MessageDigest.getInstance as createSign
private fun getDigest(body: String?): String {
if (body == null) return ""
private fun getDigest(body: String?): String? {
if (body == null) return null
return Base64.encodeToString(createSign("SHA-256").digest(body.toByteArray()), false)
}
private fun getSignatureValue(values: String, privateKey: String): String {
val bl = Base64.decode(privateKey)
val spec = PKCS8EncodedKeySpec(bl)
val kf = KeyFactory.getInstance("RSA")
private fun getSignatureValue(values: String, privatePem: String): String {
val keyFactory = KeyFactory.getInstance("RSA")
val privateBytes = Base64.decode(privatePem)
val privateSpec = PKCS8EncodedKeySpec(privateBytes)
val privateKey = keyFactory.generatePrivate(privateSpec)
val privateSignature = Signature.getInstance("SHA256withRSA")
privateSignature.initSign(kf.generatePrivate(spec))
privateSignature.update(values.toByteArray())
val signature = Signature.getInstance("SHA256withRSA")
signature.initSign(privateKey)
signature.update(values.toByteArray())
return Base64.encodeToString(privateSignature.sign(), false)
return Base64.encodeToString(signature.sign(), false)
}
private fun getEncodedPath(path: String): String {
@ -36,60 +35,30 @@ private fun getEncodedPath(path: String): String {
return URLEncoder.encode(url.groupValues[0], "UTF-8").orEmpty().toLowerCase()
}
private fun getHeadersList(body: String?, digest: String, canonicalUrl: String, timestamp: Date): Pair<String, String> {
val signData = mutableMapOf<String, String>()
signData["vCanonicalUrl"] = canonicalUrl
if (body != null) signData["Digest"] = digest
signData["vDate"] = SimpleDateFormat("EEE, d MMM yyyy hh:mm:ss z", Locale.ENGLISH).apply {
timeZone = TimeZone.getTimeZone("GMT")
}.format(timestamp)
return Pair(
first = signData.keys.joinToString(" "),
second = signData.values.joinToString("")
)
private fun getHeaders(digest: String?, canonicalUrl: String, timestamp: ZonedDateTime): MutableMap<String, String> {
val headers = mutableMapOf<String, String>()
headers["vCanonicalUrl"] = canonicalUrl
if (digest != null) headers["Digest"] = digest
headers["vDate"] = timestamp.format(DateTimeFormatter.RFC_1123_DATE_TIME)
return headers
}
fun getSignatureValues(
fingerprint: String,
privateKey: String,
fun getSignatureHeaders(
keyId: String,
privatePem: String,
body: String?,
requestPath: String,
timestamp: Date
): Triple<String, String, String> {
timestamp: ZonedDateTime
): Map<String, String> {
val canonicalUrl = getEncodedPath(requestPath)
val digest = getDigest(body)
val (headers, values) = getHeadersList(body, digest, canonicalUrl, timestamp)
val signatureValue = getSignatureValue(values, privateKey)
val headers = getHeaders(digest, canonicalUrl, timestamp.withZoneSameInstant(ZoneId.of("GMT")))
val headerNames = headers.keys.joinToString(" ")
val headerValues = headers.values.joinToString("")
val signatureValue = getSignatureValue(headerValues, privatePem)
return Triple(
"SHA-256=${digest}",
canonicalUrl,
"""keyId="$fingerprint",headers="$headers",algorithm="sha256withrsa",signature=Base64(SHA256withRSA($signatureValue))"""
)
}
fun generateKeyPair(): Triple<String, String, String> {
val generator = KeyPairGenerator.getInstance("RSA")
generator.initialize(2048)
val keyPair = generator.generateKeyPair()
val privateKey = keyPair.private
val notBefore = ZonedDateTime.now()
val notAfter = notBefore.plusYears(20)
val cert = X509Generator(X509Generator.Algorithm.RSA_SHA256)
.generate(subject = mapOf("CN" to "APP_CERTIFICATE CA Certificate"),
notBefore = notBefore,
notAfter = notAfter,
serialNumber = 1,
keyPair = keyPair
)
val certificatePem = Base64.encodeToString(cert, false)
val fingerprint = createSign("SHA-1")
.digest(cert)
.joinToString("") { "%02x".format(it) }
val privateKeyPem = Base64.encodeToString(privateKey.encoded, false)
return Triple(certificatePem, fingerprint, privateKeyPem)
if (body != null) headers["Digest"] = "SHA-256=${digest}"
headers["Signature"] = """keyId="$keyId,headers="$headerNames",algorithm="sha256",signature=Base64(sha256withrsa($signatureValue))"""
return headers
}

View file

@ -9,30 +9,45 @@ import java.security.cert.CertificateFactory
import java.security.interfaces.RSAPrivateCrtKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.RSAPublicKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.*
class GeneratorTest {
@Test
fun generatorTest() {
val (certificate, fingerprint, privateKey) = generateKeyPair()
val (publicPem, privatePem, publicHash) = generateKeyPair()
val (certificatePem, certificateHash) = generateCertificate(privatePem)
val certificateFactory = CertificateFactory.getInstance("X.509")
val x509 = certificateFactory.generateCertificate(
ByteArrayInputStream(Base64.getDecoder().decode(certificate))
ByteArrayInputStream(Base64.getDecoder().decode(certificatePem))
)
val keyFactory = KeyFactory.getInstance("RSA")
val pkcs8KeySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey))
val private = keyFactory.generatePrivate(pkcs8KeySpec) as RSAPrivateCrtKey
val rsaKeySpec = RSAPublicKeySpec(private.modulus, private.publicExponent, private.params)
val publicKey = keyFactory.generatePublic(rsaKeySpec)
val privateBytes = Base64.getDecoder().decode(privatePem)
val privateSpec = PKCS8EncodedKeySpec(privateBytes)
val privateKey = keyFactory.generatePrivate(privateSpec) as RSAPrivateCrtKey
val publicSpec = RSAPublicKeySpec(privateKey.modulus, privateKey.publicExponent, privateKey.params)
val publicKey = keyFactory.generatePublic(publicSpec)
val digest = MessageDigest.getInstance("SHA-1")
digest.update(x509.encoded)
val publicSpec2 = X509EncodedKeySpec(Base64.getDecoder().decode(publicPem))
val publicKey2 = keyFactory.generatePublic(publicSpec2)
assertEquals(fingerprint.length, 40)
assertEquals(digest.digest().joinToString("") { "%02x".format(it) }, fingerprint)
val sha1 = MessageDigest.getInstance("SHA-1")
val md5 = MessageDigest.getInstance("MD5")
assertEquals(certificateHash.length, 40)
assertEquals(sha1.digest(x509.encoded).joinToString("") { "%02x".format(it) }, certificateHash)
assertEquals(x509.publicKey, publicKey)
assertEquals(publicKey, publicKey2)
x509.verify(publicKey2)
assertEquals(x509.type, "X.509")
assertEquals(x509.publicKey.algorithm, "RSA")
assertEquals(md5.digest(
Base64.getEncoder()
.encodeToString(publicKey.encoded)
.toByteArray()
).joinToString("") { "%02x".format(it) }, publicHash)
}
}

View file

@ -3,29 +3,44 @@ package io.github.wulkanowy.signer.hebe
import org.junit.Assert.assertEquals
import org.junit.Test
import java.text.SimpleDateFormat
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.*
class SignerTest {
private val fullUrl = "/powiatwulkanowy/123456/api/mobile/register/hebe";
private val fingerprint = "7EBA57E1DDBA1C249D097A9FF1C9CCDD45351A6A";
private val privateKey = "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDCbF5Tt176EpB4cX5U+PZE0XytjJ9ABDZFaBFDkaexbkuNeuLOaARjQEOlUoBmpZQXxAF8HlYqeTvPiTcnSfQIS6EdqpICuQNdwvy6CHFAe2imkbbB0aHPsGep6zH8ZxHbssazkTCnGy0j2ZtGT2/iy1GEvc/p2bOkCVcR1H1GqFp+/XpfaMwi2SRCwc67K8Fu8TjSDKNvcRT9nuenGoPA1CWoOiOCxhQA6gnB8LULPel6TVDxeBVdYor/z2GxFe/m0pa7XAKzveuUDhH8k8NlNG65MjvZhgy9iFs+eBUq7lCZ0nuIsDzjnUrLSl4ciYKj9d94qrUyF8L8D9Rl+0WlAgMBAAECggEAQ6jg3rNmyxIg0rl0ZG/LjEF26RKR7P5KQLcpouESga3HfzHvsjMCq+OWZvciFhazRd4BQkdwZxGPnfa7ieGzmhtvs1pDu8zU/hE4UClV+EG6NpVpC2Q/sn5KZRijaZoY3eMGQUFatBzCBcLZxYspfbyR3ucLbu9DE+foNB1Fh4u9RCDj3bClTsqPcNBIaLMpYr3f/bM1fFbS9LrJ7AXZQtGg/2MH58WsvV67SiYAQqGCzld/Jp74gmod4Ii0w2XWZ7OeixdF2xr1j7TK0dUUlrrOrb1cgOWSOEXyy3RX/iF7R8uuLXiRfo1URh6VNPoOtrC6fHCrCp1iRBo08qOk4QKBgQDxqLrWA7gKcTr2yQeGfETXOAYi0xqbNj5A9eVC0XngxnFuwWc5zyg3Ps3c0UK2qTSSFv4SoeEHQM+U0+9LjYzIRSUH7zy4zBrBlLtTQCysSuuZ9QfgO55b3/QEYkyx6Hz/z/gg53jKHjsUKIftGMwJ6C1M2svbBNYCsWrUuYcsbQKBgQDN9gkVDABIeWUtKDHyB5HGcVbsg7Ji7GhMjdFA0GB+9kR0doKNctrzxKn65BI2uTWg+mxaw5V+UeJOIaeFsv2uClYJYn1F55VT7NIx3CLFv6zFRSiMSKz2W+NkwGjQqR7D3DeEyalpjeQeMdpHZg27LMbdVkzy/cK8EM9ZQlRLGQKBgQCpB2wn5dIE+85Sb6pj1ugP4Y/pK9+gUQCaT2RcqEingCY3Ye/h75QhkDxOB9CyEwhCZvKv9aqAeES5xMPMBOZD7plIQ34lhB3y6SVdxbV5ja3dshYgMZNCkBMOPfOHPSaxh7X2zfEe7qZEI1Vv8bhF9bA54ZBVUbyfhZlD0cFKwQKBgQC9BnXHb0BDQ8br7twH+ZJ8wkC4yRXLXJVMzUujZJtrarHhAXNIRoVU/MXUkcV1m/3wRGV119M4IAbHFnQdbO0N8kaMTmwS4DxYzh0LzbHMM+JpGtPgDENRx3unWD/aYZzuvQnnQP3O9n7Kh46BwNQRWUMamL3+tY8n83WZwhqC4QKBgBTUzHy0sEEZ3hYgwU2ygbzC0vPladw2KqtKy+0LdHtx5pqE4/pvhVMpRRTNBDiAvb5lZmMB/B3CzoiMQOwczuus8Xsx7bEci28DzQ+g2zt0/bC2Xl+992Ass5PP5NtOrP/9QiTNgoFSCrVnZnNzQqpjCrFsjfOD2fiuFLCD6zi6";
private val body = "{}";
private val fullUrl = "/powiatwulkanowy/123456/api/mobile/register/hebe?lastSyncDate=null"
private val keyId = "7eba57e1ddba1c249d097a9ff1c9ccdd45351a6a"
private val privatePem = "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDCbF5Tt176EpB4cX5U+PZE0XytjJ9ABDZFaBFDkaexbkuNeuLOaARjQEOlUoBmpZQXxAF8HlYqeTvPiTcnSfQIS6EdqpICuQNdwvy6CHFAe2imkbbB0aHPsGep6zH8ZxHbssazkTCnGy0j2ZtGT2/iy1GEvc/p2bOkCVcR1H1GqFp+/XpfaMwi2SRCwc67K8Fu8TjSDKNvcRT9nuenGoPA1CWoOiOCxhQA6gnB8LULPel6TVDxeBVdYor/z2GxFe/m0pa7XAKzveuUDhH8k8NlNG65MjvZhgy9iFs+eBUq7lCZ0nuIsDzjnUrLSl4ciYKj9d94qrUyF8L8D9Rl+0WlAgMBAAECggEAQ6jg3rNmyxIg0rl0ZG/LjEF26RKR7P5KQLcpouESga3HfzHvsjMCq+OWZvciFhazRd4BQkdwZxGPnfa7ieGzmhtvs1pDu8zU/hE4UClV+EG6NpVpC2Q/sn5KZRijaZoY3eMGQUFatBzCBcLZxYspfbyR3ucLbu9DE+foNB1Fh4u9RCDj3bClTsqPcNBIaLMpYr3f/bM1fFbS9LrJ7AXZQtGg/2MH58WsvV67SiYAQqGCzld/Jp74gmod4Ii0w2XWZ7OeixdF2xr1j7TK0dUUlrrOrb1cgOWSOEXyy3RX/iF7R8uuLXiRfo1URh6VNPoOtrC6fHCrCp1iRBo08qOk4QKBgQDxqLrWA7gKcTr2yQeGfETXOAYi0xqbNj5A9eVC0XngxnFuwWc5zyg3Ps3c0UK2qTSSFv4SoeEHQM+U0+9LjYzIRSUH7zy4zBrBlLtTQCysSuuZ9QfgO55b3/QEYkyx6Hz/z/gg53jKHjsUKIftGMwJ6C1M2svbBNYCsWrUuYcsbQKBgQDN9gkVDABIeWUtKDHyB5HGcVbsg7Ji7GhMjdFA0GB+9kR0doKNctrzxKn65BI2uTWg+mxaw5V+UeJOIaeFsv2uClYJYn1F55VT7NIx3CLFv6zFRSiMSKz2W+NkwGjQqR7D3DeEyalpjeQeMdpHZg27LMbdVkzy/cK8EM9ZQlRLGQKBgQCpB2wn5dIE+85Sb6pj1ugP4Y/pK9+gUQCaT2RcqEingCY3Ye/h75QhkDxOB9CyEwhCZvKv9aqAeES5xMPMBOZD7plIQ34lhB3y6SVdxbV5ja3dshYgMZNCkBMOPfOHPSaxh7X2zfEe7qZEI1Vv8bhF9bA54ZBVUbyfhZlD0cFKwQKBgQC9BnXHb0BDQ8br7twH+ZJ8wkC4yRXLXJVMzUujZJtrarHhAXNIRoVU/MXUkcV1m/3wRGV119M4IAbHFnQdbO0N8kaMTmwS4DxYzh0LzbHMM+JpGtPgDENRx3unWD/aYZzuvQnnQP3O9n7Kh46BwNQRWUMamL3+tY8n83WZwhqC4QKBgBTUzHy0sEEZ3hYgwU2ygbzC0vPladw2KqtKy+0LdHtx5pqE4/pvhVMpRRTNBDiAvb5lZmMB/B3CzoiMQOwczuus8Xsx7bEci28DzQ+g2zt0/bC2Xl+992Ass5PP5NtOrP/9QiTNgoFSCrVnZnNzQqpjCrFsjfOD2fiuFLCD6zi6"
private val body = "{}"
@Test
fun `values should match`() {
val (digest, canonicalUrl, signature) = getSignatureValues(fingerprint, privateKey, body, fullUrl, getDate("2020-04-14 04:14:16"))
assertEquals("SHA-256=RBNvo1WzZ4oRRq0W9+hknpT7T8If536DEMBg9hyq/4o=", digest);
assertEquals("api%2fmobile%2fregister%2fhebe", canonicalUrl);
assertEquals("keyId=\"7EBA57E1DDBA1C249D097A9FF1C9CCDD45351A6A\"," +
val headers = getSignatureHeaders(keyId, privatePem, body, fullUrl, ZonedDateTime.parse("2021-02-18T16:36:53Z"))
val signature = "keyId=\"7eba57e1ddba1c249d097a9ff1c9ccdd45351a6a," +
"headers=\"vCanonicalUrl Digest vDate\"," +
"algorithm=\"sha256withrsa\"," +
"signature=Base64(SHA256withRSA(mIVNkthTzTHmmXG1qxv1Jpt3uRlyhbj7VHysbCNpl0zXCCzuwTXsuCrfjexDDXsyJVo/LznQKOyvOaW4tEfrBobxtbtTnp7zYi54bdvAZa3pvM02yvkH4i/DvTLDKRO0R9UDZ1LraGrOTsIe3m3mQ21NOynVqCKadeqod8Y7l4YUlVYEmrtq/7xbCwr0qdne6G67eY4Amj6ffbG3TkVLpUrEETBnAC7oFjGYKhcRyvltAi+lcv6omANz1gwELf+Vmsa8NwFo/YGwY3R23z15athU/1iC1JcrECBLC8nRM1+KlvyIqx2HX6RG5R1cMOwBWVg6pRKUdrhxYbQ+VQ8Cag==))",
signature);
"algorithm=\"sha256\"," +
"signature=Base64(sha256withrsa(JLdhYWv05+f7KTstWsCgt0rU2QQpA+jZM6VaVKFYe0Q4hrKKVq5/ZPB3ttBJ0RwD+MGX2mePkYm3BiLdCOqoZfAyylpHCnnQ4lbFOX45sMogQQoFbhmIQF1ZwVxRKrn/lbrd+VsLsInXWn74CMNIOz55p/WrwV3J+w5g1FpYSRM/LVzQMXvcRRXx6WSmfo/qd1H6TH33EVU+fPTw3lhGpAPDl+clZyUDyfWrmxaRvJ/ag2LNdtGVNXDQfyMO7diOJQtqUnWsJoMh5iNFApd9nHgB1Fkmb62aDwGDGtDz65IA7ArdjNW56IXeogQyp+Vv/icap3/ujdQl5zjXssL4Sg==))"
assertEquals("SHA-256=RBNvo1WzZ4oRRq0W9+hknpT7T8If536DEMBg9hyq/4o=", headers["Digest"])
assertEquals("api%2fmobile%2fregister%2fhebe%3flastsyncdate%3dnull", headers["vCanonicalUrl"])
assertEquals("Thu, 18 Feb 2021 16:36:53 GMT", headers["vDate"])
assertEquals(signature, headers["Signature"])
}
private fun getDate(date: String) = SimpleDateFormat("yyyy-MM-dd hh:mm:ss").apply {
timeZone = TimeZone.getTimeZone("UTC")
}.parse(date)
@Test
fun `values should match when no body`() {
val headers = getSignatureHeaders(keyId, privatePem, null, fullUrl, ZonedDateTime.parse("2021-02-18T16:36:53Z"))
val signature = "keyId=\"7eba57e1ddba1c249d097a9ff1c9ccdd45351a6a," +
"headers=\"vCanonicalUrl vDate\"," +
"algorithm=\"sha256\"," +
"signature=Base64(sha256withrsa(HF8AbXOjH8CBu+xfviwSXz46GwuRDQL47Bb6rIumdEZ0l6sUF7oMakhFVPBc3G43sNSSIZsxTgdBu0GB+66OHj1ce6/E5WTUi01/chs22mqTFfGbkqQ2eebmmSciW1qGiYy/0IfwidTVJXJa4EYWjsM3538WMvaeoOuqAKOwrOGgQG0qmuR8ZUKIit3R0BbQTTBlV5jX2MVTmTTo2Knx5q9Hz5PTPdyzuKYGD5mXRhx42WK6EzSzxzbdY8+s3esCCEEAeXQS+Bps0nF+h8xFXwc+QHgPjlGVX2HE/EEw+Kp6AW3ub8QjvbvO+mkNU/JaFQBOvRvCy0heMjOTt66QxA==))"
assertEquals(null, headers["Digest"])
assertEquals("api%2fmobile%2fregister%2fhebe%3flastsyncdate%3dnull", headers["vCanonicalUrl"])
assertEquals("Thu, 18 Feb 2021 16:36:53 GMT", headers["vDate"])
assertEquals(signature, headers["Signature"])
}
}