434 lines
20 KiB
Go
Vendored
434 lines
20 KiB
Go
Vendored
package protocol
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"math/big"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/go-webauthn/webauthn/metadata"
|
|
)
|
|
|
|
func TestSafetyNetFormat_AttStatementErrors(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
attStatement map[string]any
|
|
err string
|
|
}{
|
|
{
|
|
name: "ShouldFailMissingVersion",
|
|
attStatement: map[string]any{},
|
|
err: "Unable to find the version of SafetyNet",
|
|
},
|
|
{
|
|
name: "ShouldFailVersionWrongType",
|
|
attStatement: map[string]any{stmtVersion: 123},
|
|
err: "Unable to find the version of SafetyNet",
|
|
},
|
|
{
|
|
name: "ShouldFailEmptyVersion",
|
|
attStatement: map[string]any{stmtVersion: ""},
|
|
err: "Not a proper version for SafetyNet",
|
|
},
|
|
{
|
|
name: "ShouldFailMissingResponse",
|
|
attStatement: map[string]any{stmtVersion: "2.0"},
|
|
err: "Unable to find the SafetyNet response",
|
|
},
|
|
{
|
|
name: "ShouldFailResponseWrongType",
|
|
attStatement: map[string]any{stmtVersion: "2.0", "response": "not-bytes"},
|
|
err: "Unable to find the SafetyNet response",
|
|
},
|
|
{
|
|
name: "ShouldFailInvalidJWT",
|
|
attStatement: map[string]any{stmtVersion: "2.0", "response": []byte("!!!.a.jwt")},
|
|
err: "Error finding cert issued to correct hostname: token is malformed: could not base64 decode header: illegal base64 data at input byte 0",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
att := AttestationObject{
|
|
Format: "android-safetynet",
|
|
AttStatement: tc.attStatement,
|
|
}
|
|
|
|
_, _, err := attestationFormatValidationHandlerAndroidSafetyNet(att, []byte("hash"), nil)
|
|
require.EqualError(t, err, tc.err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSafetyNetFormat_JWTValidation(t *testing.T) {
|
|
key, cert := safetyNetTestGenerateKeyCert(t, "attest.android.com")
|
|
wrongHostKey, wrongHostCert := safetyNetTestGenerateKeyCert(t, "wrong.example.com")
|
|
|
|
testCases := []struct {
|
|
name string
|
|
buildJWT func(t *testing.T) []byte
|
|
rawAuthData []byte
|
|
clientDataHash []byte
|
|
mds metadata.Provider
|
|
err string
|
|
attestationType string
|
|
}{
|
|
{
|
|
name: "ShouldFailNonceMismatch",
|
|
buildJWT: func(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
return safetyNetTestBuildJWT(t, key, cert, SafetyNetResponse{
|
|
Nonce: base64.StdEncoding.EncodeToString([]byte("wrong-nonce")),
|
|
TimestampMs: time.Now().UnixMilli(),
|
|
CtsProfileMatch: true,
|
|
})
|
|
},
|
|
rawAuthData: []byte("authdata"),
|
|
clientDataHash: []byte("clienthash"),
|
|
err: "Invalid nonce for in SafetyNet response",
|
|
},
|
|
{
|
|
name: "ShouldFailCtsProfileMatchFalse",
|
|
buildJWT: func(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
nonceHash := sha256.Sum256(append([]byte("authdata"), []byte("clienthash")...))
|
|
|
|
return safetyNetTestBuildJWT(t, key, cert, SafetyNetResponse{
|
|
Nonce: base64.StdEncoding.EncodeToString(nonceHash[:]),
|
|
TimestampMs: time.Now().UnixMilli(),
|
|
CtsProfileMatch: false,
|
|
})
|
|
},
|
|
rawAuthData: []byte("authdata"),
|
|
clientDataHash: []byte("clienthash"),
|
|
err: "ctsProfileMatch attribute of the JWT payload is false",
|
|
},
|
|
{
|
|
name: "ShouldFailFutureTimestamp",
|
|
buildJWT: func(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
nonceHash := sha256.Sum256(append([]byte("authdata"), []byte("clienthash")...))
|
|
|
|
return safetyNetTestBuildJWT(t, key, cert, SafetyNetResponse{
|
|
Nonce: base64.StdEncoding.EncodeToString(nonceHash[:]),
|
|
TimestampMs: time.Now().Add(time.Hour).UnixMilli(),
|
|
CtsProfileMatch: true,
|
|
})
|
|
},
|
|
rawAuthData: []byte("authdata"),
|
|
clientDataHash: []byte("clienthash"),
|
|
err: "SafetyNet response with timestamp after current time",
|
|
},
|
|
{
|
|
name: "ShouldFailHostnameMismatch",
|
|
buildJWT: func(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
nonceHash := sha256.Sum256(append([]byte("authdata"), []byte("clienthash")...))
|
|
|
|
return safetyNetTestBuildJWT(t, wrongHostKey, wrongHostCert, SafetyNetResponse{
|
|
Nonce: base64.StdEncoding.EncodeToString(nonceHash[:]),
|
|
TimestampMs: time.Now().UnixMilli(),
|
|
CtsProfileMatch: true,
|
|
})
|
|
},
|
|
rawAuthData: []byte("authdata"),
|
|
clientDataHash: []byte("clienthash"),
|
|
err: `Error finding cert issued to correct hostname: x509: certificate is valid for wrong.example.com, not attest.android.com`,
|
|
},
|
|
{
|
|
name: "ShouldFailOldTimestampWithMDS",
|
|
buildJWT: func(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
nonceHash := sha256.Sum256(append([]byte("authdata"), []byte("clienthash")...))
|
|
|
|
return safetyNetTestBuildJWT(t, key, cert, SafetyNetResponse{
|
|
Nonce: base64.StdEncoding.EncodeToString(nonceHash[:]),
|
|
TimestampMs: time.Now().Add(-5 * time.Minute).UnixMilli(),
|
|
CtsProfileMatch: true,
|
|
})
|
|
},
|
|
rawAuthData: []byte("authdata"),
|
|
clientDataHash: []byte("clienthash"),
|
|
mds: &safetyNetTestMDS{validateEntry: true},
|
|
err: "SafetyNet response with timestamp before one minute ago",
|
|
},
|
|
{
|
|
name: "ShouldPassOldTimestampWithoutMDS",
|
|
buildJWT: func(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
nonceHash := sha256.Sum256(append([]byte("authdata"), []byte("clienthash")...))
|
|
|
|
return safetyNetTestBuildJWT(t, key, cert, SafetyNetResponse{
|
|
Nonce: base64.StdEncoding.EncodeToString(nonceHash[:]),
|
|
TimestampMs: time.Now().Add(-5 * time.Minute).UnixMilli(),
|
|
CtsProfileMatch: true,
|
|
})
|
|
},
|
|
rawAuthData: []byte("authdata"),
|
|
clientDataHash: []byte("clienthash"),
|
|
attestationType: string(metadata.BasicFull),
|
|
},
|
|
{
|
|
name: "ShouldPassOldTimestampWithMDSNotValidating",
|
|
buildJWT: func(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
nonceHash := sha256.Sum256(append([]byte("authdata"), []byte("clienthash")...))
|
|
|
|
return safetyNetTestBuildJWT(t, key, cert, SafetyNetResponse{
|
|
Nonce: base64.StdEncoding.EncodeToString(nonceHash[:]),
|
|
TimestampMs: time.Now().Add(-5 * time.Minute).UnixMilli(),
|
|
CtsProfileMatch: true,
|
|
})
|
|
},
|
|
rawAuthData: []byte("authdata"),
|
|
clientDataHash: []byte("clienthash"),
|
|
mds: &safetyNetTestMDS{validateEntry: false},
|
|
attestationType: string(metadata.BasicFull),
|
|
},
|
|
{
|
|
name: "ShouldSucceedWithValidResponse",
|
|
buildJWT: func(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
nonceHash := sha256.Sum256(append([]byte("authdata"), []byte("clienthash")...))
|
|
|
|
return safetyNetTestBuildJWT(t, key, cert, SafetyNetResponse{
|
|
Nonce: base64.StdEncoding.EncodeToString(nonceHash[:]),
|
|
TimestampMs: time.Now().UnixMilli(),
|
|
CtsProfileMatch: true,
|
|
})
|
|
},
|
|
rawAuthData: []byte("authdata"),
|
|
clientDataHash: []byte("clienthash"),
|
|
attestationType: string(metadata.BasicFull),
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
att := AttestationObject{
|
|
Format: "android-safetynet",
|
|
RawAuthData: tc.rawAuthData,
|
|
AttStatement: map[string]any{
|
|
stmtVersion: "15180037",
|
|
"response": tc.buildJWT(t),
|
|
},
|
|
}
|
|
|
|
attestationType, _, err := attestationFormatValidationHandlerAndroidSafetyNet(att, tc.clientDataHash, tc.mds)
|
|
|
|
if tc.err != "" {
|
|
require.EqualError(t, err, tc.err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.attestationType, attestationType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestKeyFuncSafetyNetJWT(t *testing.T) {
|
|
key, cert := safetyNetTestGenerateKeyCert(t, "attest.android.com")
|
|
|
|
certDER, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key)
|
|
require.NoError(t, err)
|
|
|
|
validB64 := base64.StdEncoding.EncodeToString(certDER)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
header map[string]any
|
|
err string
|
|
}{
|
|
{
|
|
name: "ShouldFailMissingX5C",
|
|
header: map[string]any{},
|
|
err: "jwt header missing x5c",
|
|
},
|
|
{
|
|
name: "ShouldFailX5CNotArray",
|
|
header: map[string]any{stmtX5C: "not-an-array"},
|
|
err: "jwt header x5c is not a non-empty array",
|
|
},
|
|
{
|
|
name: "ShouldFailX5CEmptyArray",
|
|
header: map[string]any{stmtX5C: []any{}},
|
|
err: "jwt header x5c is not a non-empty array",
|
|
},
|
|
{
|
|
name: "ShouldFailX5CFirstNotString",
|
|
header: map[string]any{stmtX5C: []any{123}},
|
|
err: "jwt header x5c[0] not a base64 string",
|
|
},
|
|
{
|
|
name: "ShouldFailX5CFirstEmptyString",
|
|
header: map[string]any{stmtX5C: []any{""}},
|
|
err: "jwt header x5c[0] not a base64 string",
|
|
},
|
|
{
|
|
name: "ShouldFailX5CInvalidBase64",
|
|
header: map[string]any{stmtX5C: []any{"not valid base64!!!"}},
|
|
err: "decode x5c leaf: illegal base64 data at input byte 3",
|
|
},
|
|
{
|
|
name: "ShouldFailX5CInvalidCert",
|
|
header: map[string]any{stmtX5C: []any{base64.StdEncoding.EncodeToString([]byte("not-a-cert"))}},
|
|
err: "parse x5c leaf: x509: malformed certificate",
|
|
},
|
|
{
|
|
name: "ShouldSucceedWithValidCert",
|
|
header: map[string]any{stmtX5C: []any{validB64}},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
token := &jwt.Token{Header: tc.header}
|
|
|
|
result, err := keyFuncSafetyNetJWT(token)
|
|
|
|
if tc.err != "" {
|
|
assert.Nil(t, result)
|
|
require.EqualError(t, err, tc.err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSafetyNetFormat_ParseExistingResponse(t *testing.T) {
|
|
successParsed := attestationTestUnpackResponse(t, safetyNetTestResponse["success"])
|
|
|
|
assert.Equal(t, "android-safetynet", successParsed.Response.AttestationObject.Format)
|
|
require.NotEmpty(t, successParsed.Response.AttestationObject.AttStatement)
|
|
|
|
version, ok := successParsed.Response.AttestationObject.AttStatement[stmtVersion].(string)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "15180037", version)
|
|
|
|
_, ok = successParsed.Response.AttestationObject.AttStatement["response"].([]byte)
|
|
assert.True(t, ok)
|
|
}
|
|
|
|
// Supporting functions and test data.
|
|
|
|
func safetyNetTestGenerateKeyCert(t *testing.T, hostname string) (*rsa.PrivateKey, *x509.Certificate) {
|
|
t.Helper()
|
|
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: hostname},
|
|
DNSNames: []string{hostname},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
}
|
|
|
|
return key, template
|
|
}
|
|
|
|
type safetyNetTestMDS struct {
|
|
metadata.Provider
|
|
validateEntry bool
|
|
}
|
|
|
|
func (m *safetyNetTestMDS) GetValidateEntry(_ context.Context) bool {
|
|
return m.validateEntry
|
|
}
|
|
|
|
func safetyNetTestBuildJWT(t *testing.T, key *rsa.PrivateKey, certTemplate *x509.Certificate, response SafetyNetResponse) []byte {
|
|
t.Helper()
|
|
|
|
certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &key.PublicKey, key)
|
|
require.NoError(t, err)
|
|
|
|
certB64 := base64.StdEncoding.EncodeToString(certDER)
|
|
|
|
token := jwt.New(jwt.SigningMethodRS256)
|
|
token.Header[stmtX5C] = []any{certB64}
|
|
|
|
claims := jwt.MapClaims{
|
|
"nonce": response.Nonce,
|
|
"timestampMs": float64(response.TimestampMs),
|
|
"ctsProfileMatch": response.CtsProfileMatch,
|
|
}
|
|
|
|
token.Claims = claims
|
|
|
|
signed, err := token.SignedString(key)
|
|
require.NoError(t, err)
|
|
|
|
return []byte(signed)
|
|
}
|
|
|
|
//nolint:unused
|
|
var safetyNetTestRequest = map[string]string{
|
|
`success`: `{
|
|
"publicKey": {
|
|
"challenge": "dfo+HlqJp3MLK+J5TLxxmvXJieS3zGwdk9G9H9bPezg=",
|
|
"rp": {
|
|
"name": "webauthn.io",
|
|
"id": "webauthn.io"
|
|
},
|
|
"user": {
|
|
"name": "safetynetter",
|
|
"displayName": "safetynetter",
|
|
"id": "wDkAAAAAAAAAAA=="
|
|
},
|
|
"pubKeyCredParams": [
|
|
{ "type": "public-key", "alg": -7 },
|
|
{ "type": "public-key", "alg": -35 },
|
|
{ "type": "public-key", "alg": -36 },
|
|
{ "type": "public-key", "alg": -65535 },
|
|
{ "type": "public-key", "alg": -257 },
|
|
{ "type": "public-key", "alg": -258 },
|
|
{ "type": "public-key", "alg": -259 },
|
|
{ "type": "public-key", "alg": -37 },
|
|
{ "type": "public-key", "alg": -38 },
|
|
{ "type": "public-key", "alg": -39 },
|
|
{ "type": "public-key", "alg": -8 }
|
|
],
|
|
"authenticatorSelection": {
|
|
"authenticatorAttachment": "platform",
|
|
"userVerification": "preferred"
|
|
},
|
|
"timeout": 60000,
|
|
"attestation": "direct"
|
|
}
|
|
}`,
|
|
}
|
|
|
|
var safetyNetTestResponse = map[string]string{
|
|
`success`: `{
|
|
"id":"AUiVU3Mk3uJomfHcJcu6ScwUHRysE2e6IgaTNAzQ34TP0OPifi2LgGD_5hzxRhOfQTB1fW6k63C8tk-MwywpNVI",
|
|
"rawId":"AUiVU3Mk3uJomfHcJcu6ScwUHRysE2e6IgaTNAzQ34TP0OPifi2LgGD_5hzxRhOfQTB1fW6k63C8tk-MwywpNVI",
|
|
"type":"public-key",
|
|
"response":{
|
|
"attestationObject":"o2NmbXRxYW5kcm9pZC1zYWZldHluZXRnYXR0U3RtdKJjdmVyaDE1MTgwMDM3aHJlc3BvbnNlWRS9ZXlKaGJHY2lPaUpTVXpJMU5pSXNJbmcxWXlJNld5Sk5TVWxHYTJwRFEwSkljV2RCZDBsQ1FXZEpVVkpZY205T01GcFBaRkpyUWtGQlFVRkJRVkIxYm5wQlRrSm5hM0ZvYTJsSE9YY3dRa0ZSYzBaQlJFSkRUVkZ6ZDBOUldVUldVVkZIUlhkS1ZsVjZSV1ZOUW5kSFFURlZSVU5vVFZaU01qbDJXako0YkVsR1VubGtXRTR3U1VaT2JHTnVXbkJaTWxaNlRWSk5kMFZSV1VSV1VWRkVSWGR3U0ZaR1RXZFJNRVZuVFZVNGVFMUNORmhFVkVVMFRWUkJlRTFFUVROTlZHc3dUbFp2V0VSVVJUVk5WRUYzVDFSQk0wMVVhekJPVm05M1lrUkZURTFCYTBkQk1WVkZRbWhOUTFaV1RYaEZla0ZTUW1kT1ZrSkJaMVJEYTA1b1lrZHNiV0l6U25WaFYwVjRSbXBCVlVKblRsWkNRV05VUkZVeGRtUlhOVEJaVjJ4MVNVWmFjRnBZWTNoRmVrRlNRbWRPVmtKQmIxUkRhMlIyWWpKa2MxcFRRazFVUlUxNFIzcEJXa0puVGxaQ1FVMVVSVzFHTUdSSFZucGtRelZvWW0xU2VXSXliR3RNYlU1MllsUkRRMEZUU1hkRVVWbEtTMjlhU1doMlkwNUJVVVZDUWxGQlJHZG5SVkJCUkVORFFWRnZRMmRuUlVKQlRtcFlhM293WlVzeFUwVTBiU3N2UnpWM1QyOHJXRWRUUlVOeWNXUnVPRGh6UTNCU04yWnpNVFJtU3pCU2FETmFRMWxhVEVaSWNVSnJOa0Z0V2xaM01rczVSa2N3VHpseVVsQmxVVVJKVmxKNVJUTXdVWFZ1VXpsMVowaEROR1ZuT1c5MmRrOXRLMUZrV2pKd09UTllhSHAxYmxGRmFGVlhXRU40UVVSSlJVZEtTek5UTW1GQlpucGxPVGxRVEZNeU9XaE1ZMUYxV1ZoSVJHRkROMDlhY1U1dWIzTnBUMGRwWm5NNGRqRnFhVFpJTDNob2JIUkRXbVV5YkVvck4wZDFkSHBsZUV0d2VIWndSUzkwV2xObVlsazVNRFZ4VTJ4Q2FEbG1jR293TVRWamFtNVJSbXRWYzBGVmQyMUxWa0ZWZFdWVmVqUjBTMk5HU3pSd1pYWk9UR0Y0UlVGc0swOXJhV3hOZEVsWlJHRmpSRFZ1Wld3MGVFcHBlWE0wTVROb1lXZHhWekJYYUdnMVJsQXpPV2hIYXpsRkwwSjNVVlJxWVhwVGVFZGtkbGd3YlRaNFJsbG9hQzh5VmsxNVdtcFVORXQ2VUVwRlEwRjNSVUZCWVU5RFFXeG5kMmRuU2xWTlFUUkhRVEZWWkVSM1JVSXZkMUZGUVhkSlJtOUVRVlJDWjA1V1NGTlZSVVJFUVV0Q1oyZHlRbWRGUmtKUlkwUkJWRUZOUW1kT1ZraFNUVUpCWmpoRlFXcEJRVTFDTUVkQk1WVmtSR2RSVjBKQ1VYRkNVWGRIVjI5S1FtRXhiMVJMY1hWd2J6UlhObmhVTm1veVJFRm1RbWRPVmtoVFRVVkhSRUZYWjBKVFdUQm1hSFZGVDNaUWJTdDRaMjU0YVZGSE5rUnlabEZ1T1V0NlFtdENaMmR5UW1kRlJrSlJZMEpCVVZKWlRVWlpkMHAzV1VsTGQxbENRbEZWU0UxQlIwZEhNbWd3WkVoQk5reDVPWFpaTTA1M1RHNUNjbUZUTlc1aU1qbHVUREprTUdONlJuWk5SRUZ5UW1kbmNrSm5SVVpDVVdOM1FXOVpabUZJVWpCalJHOTJURE5DY21GVE5XNWlNamx1VERKa2VtTnFTWFpTTVZKVVRWVTRlRXh0VG5sa1JFRmtRbWRPVmtoU1JVVkdha0ZWWjJoS2FHUklVbXhqTTFGMVdWYzFhMk50T1hCYVF6VnFZakl3ZDBsUldVUldVakJuUWtKdmQwZEVRVWxDWjFwdVoxRjNRa0ZuU1hkRVFWbExTM2RaUWtKQlNGZGxVVWxHUVhwQmRrSm5UbFpJVWpoRlMwUkJiVTFEVTJkSmNVRm5hR2cxYjJSSVVuZFBhVGgyV1ROS2MweHVRbkpoVXpWdVlqSTVia3d3WkZWVmVrWlFUVk0xYW1OdGQzZG5aMFZGUW1kdmNrSm5SVVZCWkZvMVFXZFJRMEpKU0RGQ1NVaDVRVkJCUVdSM1EydDFVVzFSZEVKb1dVWkpaVGRGTmt4TldqTkJTMUJFVjFsQ1VHdGlNemRxYW1RNE1FOTVRVE5qUlVGQlFVRlhXbVJFTTFCTVFVRkJSVUYzUWtsTlJWbERTVkZEVTFwRFYyVk1Tblp6YVZaWE5rTm5LMmRxTHpsM1dWUktVbnAxTkVocGNXVTBaVmswWXk5dGVYcHFaMGxvUVV4VFlta3ZWR2g2WTNweGRHbHFNMlJyTTNaaVRHTkpWek5NYkRKQ01HODNOVWRSWkdoTmFXZGlRbWRCU0ZWQlZtaFJSMjFwTDFoM2RYcFVPV1ZIT1ZKTVNTdDRNRm95ZFdKNVdrVldla0UzTlZOWlZtUmhTakJPTUVGQlFVWnRXRkU1ZWpWQlFVRkNRVTFCVW1wQ1JVRnBRbU5EZDBFNWFqZE9WRWRZVURJM09IbzBhSEl2ZFVOSWFVRkdUSGx2UTNFeVN6QXJlVXhTZDBwVlltZEpaMlk0WjBocWRuQjNNbTFDTVVWVGFuRXlUMll6UVRCQlJVRjNRMnR1UTJGRlMwWlZlVm8zWmk5UmRFbDNSRkZaU2t0dldrbG9kbU5PUVZGRlRFSlJRVVJuWjBWQ1FVazVibFJtVWt0SlYyZDBiRmRzTTNkQ1REVTFSVlJXTm10aGVuTndhRmN4ZVVGak5VUjFiVFpZVHpReGExcDZkMG8yTVhkS2JXUlNVbFF2VlhORFNYa3hTMFYwTW1Nd1JXcG5iRzVLUTBZeVpXRjNZMFZYYkV4UldWSllVRXg1Um1wclYxRk9ZbE5vUWpGcE5GY3lUbEpIZWxCb2RETnRNV0kwT1doaWMzUjFXRTAyZEZnMVEzbEZTRzVVYURoQ2IyMDBMMWRzUm1sb2VtaG5iamd4Ukd4a2IyZDZMMHN5VlhkTk5sTTJRMEl2VTBWNGEybFdabllyZW1KS01ISnFkbWM1TkVGc1pHcFZabFYzYTBrNVZrNU5ha1ZRTldVNGVXUkNNMjlNYkRabmJIQkRaVVkxWkdkbVUxZzBWVGw0TXpWdmFpOUpTV1F6VlVVdlpGQndZaTl4WjBkMmMydG1aR1Y2ZEcxVmRHVXZTMU50Y21sM1kyZFZWMWRsV0daVVlra3plbk5wYTNkYVltdHdiVkpaUzIxcVVHMW9kalJ5YkdsNlIwTkhkRGhRYmpod2NUaE5Na3RFWmk5UU0ydFdiM1F6WlRFNFVUMGlMQ0pOU1VsRlUycERRMEY2UzJkQmQwbENRV2RKVGtGbFR6QnRjVWRPYVhGdFFrcFhiRkYxUkVGT1FtZHJjV2hyYVVjNWR6QkNRVkZ6UmtGRVFrMU5VMEYzU0dkWlJGWlJVVXhGZUdSSVlrYzVhVmxYZUZSaFYyUjFTVVpLZG1JelVXZFJNRVZuVEZOQ1UwMXFSVlJOUWtWSFFURlZSVU5vVFV0U01uaDJXVzFHYzFVeWJHNWlha1ZVVFVKRlIwRXhWVVZCZUUxTFVqSjRkbGx0Um5OVk1teHVZbXBCWlVaM01IaE9la0V5VFZSVmQwMUVRWGRPUkVwaFJuY3dlVTFVUlhsTlZGVjNUVVJCZDA1RVNtRk5SVWw0UTNwQlNrSm5UbFpDUVZsVVFXeFdWRTFTTkhkSVFWbEVWbEZSUzBWNFZraGlNamx1WWtkVloxWklTakZqTTFGblZUSldlV1J0YkdwYVdFMTRSWHBCVWtKblRsWkNRVTFVUTJ0a1ZWVjVRa1JSVTBGNFZIcEZkMmRuUldsTlFUQkhRMU54UjFOSllqTkVVVVZDUVZGVlFVRTBTVUpFZDBGM1oyZEZTMEZ2U1VKQlVVUlJSMDA1UmpGSmRrNHdOWHByVVU4NUszUk9NWEJKVW5aS2VucDVUMVJJVnpWRWVrVmFhRVF5WlZCRGJuWlZRVEJSYXpJNFJtZEpRMlpMY1VNNVJXdHpRelJVTW1aWFFsbHJMMnBEWmtNelVqTldXazFrVXk5a1RqUmFTME5GVUZwU2NrRjZSSE5wUzFWRWVsSnliVUpDU2pWM2RXUm5lbTVrU1UxWlkweGxMMUpIUjBac05YbFBSRWxMWjJwRmRpOVRTa2d2VlV3clpFVmhiSFJPTVRGQ2JYTkxLMlZSYlUxR0t5dEJZM2hIVG1oeU5UbHhUUzg1YVd3M01Va3laRTQ0UmtkbVkyUmtkM1ZoWldvMFlsaG9jREJNWTFGQ1ltcDRUV05KTjBwUU1HRk5NMVEwU1N0RWMyRjRiVXRHYzJKcWVtRlVUa001ZFhwd1JteG5UMGxuTjNKU01qVjRiM2x1VlhoMk9IWk9iV3R4TjNwa1VFZElXR3Q0VjFrM2IwYzVhaXRLYTFKNVFrRkNhemRZY2twbWIzVmpRbHBGY1VaS1NsTlFhemRZUVRCTVMxY3dXVE42Tlc5Nk1rUXdZekYwU2t0M1NFRm5UVUpCUVVkcVoyZEZlazFKU1VKTWVrRlBRbWRPVmtoUk9FSkJaamhGUWtGTlEwRlpXWGRJVVZsRVZsSXdiRUpDV1hkR1FWbEpTM2RaUWtKUlZVaEJkMFZIUTBOelIwRlJWVVpDZDAxRFRVSkpSMEV4VldSRmQwVkNMM2RSU1UxQldVSkJaamhEUVZGQmQwaFJXVVJXVWpCUFFrSlpSVVpLYWxJclJ6UlJOamdyWWpkSFEyWkhTa0ZpYjA5ME9VTm1NSEpOUWpoSFFURlZaRWwzVVZsTlFtRkJSa3AyYVVJeFpHNUlRamRCWVdkaVpWZGlVMkZNWkM5alIxbFpkVTFFVlVkRFEzTkhRVkZWUmtKM1JVSkNRMnQzU25wQmJFSm5aM0pDWjBWR1FsRmpkMEZaV1ZwaFNGSXdZMFJ2ZGt3eU9XcGpNMEYxWTBkMGNFeHRaSFppTW1OMldqTk9lVTFxUVhsQ1owNVdTRkk0UlV0NlFYQk5RMlZuU21GQmFtaHBSbTlrU0ZKM1QyazRkbGt6U25OTWJrSnlZVk0xYm1JeU9XNU1NbVI2WTJwSmRsb3pUbmxOYVRWcVkyMTNkMUIzV1VSV1VqQm5Ra1JuZDA1cVFUQkNaMXB1WjFGM1FrRm5TWGRMYWtGdlFtZG5ja0puUlVaQ1VXTkRRVkpaWTJGSVVqQmpTRTAyVEhrNWQyRXlhM1ZhTWpsMlduazVlVnBZUW5aak1td3dZak5LTlV4NlFVNUNaMnR4YUd0cFJ6bDNNRUpCVVhOR1FVRlBRMEZSUlVGSGIwRXJUbTV1TnpoNU5uQlNhbVE1V0d4UlYwNWhOMGhVWjJsYUwzSXpVazVIYTIxVmJWbElVRkZ4TmxOamRHazVVRVZoYW5aM1VsUXlhVmRVU0ZGeU1ESm1aWE54VDNGQ1dUSkZWRlYzWjFwUksyeHNkRzlPUm5ab2MwODVkSFpDUTA5SllYcHdjM2RYUXpsaFNqbDRhblUwZEZkRVVVZzRUbFpWTmxsYVdpOVlkR1ZFVTBkVk9WbDZTbkZRYWxrNGNUTk5SSGh5ZW0xeFpYQkNRMlkxYnpodGR5OTNTalJoTWtjMmVIcFZjalpHWWpaVU9FMWpSRTh5TWxCTVVrdzJkVE5OTkZSNmN6TkJNazB4YWpaaWVXdEtXV2s0ZDFkSlVtUkJka3RNVjFwMUwyRjRRbFppZWxsdGNXMTNhMjAxZWt4VFJGYzFia2xCU21KRlRFTlJRMXAzVFVnMU5uUXlSSFp4YjJaNGN6WkNRbU5EUmtsYVZWTndlSFUyZURaMFpEQldOMU4yU2tORGIzTnBjbE50U1dGMGFpODVaRk5UVmtSUmFXSmxkRGh4THpkVlN6UjJORnBWVGpnd1lYUnVXbm94ZVdjOVBTSmRmUS5leUp1YjI1alpTSTZJazlGTDJkV09FYzRXazFKTW1ORUsyRk1lRzB2VGt4a1dVMHdjemxsVDB0V1NYUlhOblZTVDI5d1prRTlJaXdpZEdsdFpYTjBZVzF3VFhNaU9qRTFOVE13TWpnd05ETTFNamtzSW1Gd2ExQmhZMnRoWjJWT1lXMWxJam9pWTI5dExtZHZiMmRzWlM1aGJtUnliMmxrTG1kdGN5SXNJbUZ3YTBScFoyVnpkRk5vWVRJMU5pSTZJbGRVYkd4aVVuVXhZbFEyYlZoeWRXRmlXVWQ1WmtvMFJGUTVVR1I0YnpGUFMwb3ZWRTQzTVZWU1lXODlJaXdpWTNSelVISnZabWxzWlUxaGRHTm9JanAwY25WbExDSmhjR3REWlhKMGFXWnBZMkYwWlVScFoyVnpkRk5vWVRJMU5pSTZXeUk0VURGelZ6QkZVRXBqYzJ4M04xVjZVbk5wV0V3Mk5IY3JUelV3UldRclVrSkpRM1JoZVRGbk1qUk5QU0pkTENKaVlYTnBZMGx1ZEdWbmNtbDBlU0k2ZEhKMVpYMC56V3ViaWlraGt5alhETUJpV080ajZEdnVBZWdpSUh1WGhaNWQtTEh3Z1VBZFVSMWxNTU0tZ0Y4VklmSEdYcFZNZ1hhN3plR0l5NEROU19uNTdBZ2c0eE5lTVhQMHRpMVJ4QktVVlJKeUc1OXVoejJJbDBtZkl1UVZNckRpSHBiWjdYb2tKcG1jZlUyWU9QbmppcjlWUjlsVlRZUHVHV1phT01ua1kyRnlvbTRGZzhrNFA3dEtWWllzTXNERWR3ZVdOdTM5MS1mcXdKWUxQUWNjQ0ZiNURCRWc0SlMwa05pWG8zLWc3MTFWVGd2Z284WDMyMS03NWw5MnN6UWpDeDQ3aDFzY243ZmE1TkJhTkdfanVPZjV0QnhFbl9uY3N1TjR3RVRnT0JJVHFVN0xZWmxTVEtUX2lYODFncUJOOWtuWGMtQ0NVZUh1LThvLUdmekh1Y1BsSEFoYXV0aERhdGFYxXSm6pITyZwvdLIkkrMgz0AmKpTBqVCgOX8pJQtghB7wRQAAAAC5P9lh8uZGL7EiggAiR954AEEBSJVTcyTe4miZ8dwly7pJzBQdHKwTZ7oiBpM0DNDfhM_Q4-J-LYuAYP_mHPFGE59BMHV9bqTrcLy2T4zDLCk1UqUBAgMmIAEhWCC0eleNTLgwWxaVBqV139T6hONseRz7HgXRIVS9bPxIjSJYIJ1MfwUhvkSEjeiNJ6y5-w8PuuwMAvfgpN7F4Q2EW79v",
|
|
"clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiZGZvLUhscUpwM01MSy1KNVRMeHhtdlhKaWVTM3pHd2RrOUc5SDliUGV6ZyIsIm9yaWdpbiI6Imh0dHBzOlwvXC93ZWJhdXRobi5pbyIsImFuZHJvaWRQYWNrYWdlTmFtZSI6ImNvbS5hbmRyb2lkLmNocm9tZSJ9"
|
|
}
|
|
}`,
|
|
}
|