Secure Variables

📘

Available upon request

This feature is part of a Beta program, contact your CSM or [email protected] to get enrolled.

This functionality provides a secure way of using Leanplum variables with your backend. The variables are signed from Leanplum, passed through the client and can be verified by your server to ensure the data has not been tampered with.

Data tampering and payload rejection case

In case the client has changed any part of the variables payload, it is going to result in failing verification. In this scenario, the backend can revert to its control dataset and disregard the incoming information.

Payload

Leanplum variables are transferred through the Start and GetVars API methods to the SDK as JSON payload. This JSON structure is extended to include the hashed and signed signature of the payload.

When this feature is enabled, the payload also contains the user id and timestamp, so the payload is unique for each request.
Do not define variables with names lp_user_id and lp_user_id as those are reserved.

Example payload:

"vars": {
                "helloVar": "Hello, Leanplum",
                "lp_user_id": "userId",
                "lp_iat": 1628173739708
}
"varsSignature": "bde120b85f7ebd6e1b934d36e5e486e..." 

If there are no variables defined in Leanplum, the response returns an empty vars object {} and the varsSignature field is omitted.

SDK

📘

Leanplum SDK Support

iOS 3.2.1
Android 5.7.0
Unity 3.2.0
ReactNative 1.2.0

Interface

The SDK exposes a SecuredVars object, containing the varsJson and varsSignature strings.

LPSecuredVars
  - (NSString *)varsJson;
  - (NSString *)varsSignature;
SecuredVars
  public String getJson()
  public String getSignature()

Usage

Accessing both the JSON and the signature must be done through the Start Response callback and/or the ForceContentUpdate callback. This ensures the request has finished and the data processed.

[Leanplum onStartResponse:^(BOOL success) {
    LPSecuredVars *securedVars = [Leanplum securedVars];
    NSString *varsJson = [securedVars varsJSON];
    NSString *signature = [securedVars varsSignature];
}]
        
[Leanplum forceContentUpdate:^{
    LPSecuredVars *securedVars = [Leanplum securedVars];
}]
Leanplum.onStartResponse { (success) in
    if let vars = Leanplum.securedVars() {
         let vjson = vars.varsJson()
         let vsign = vars.varsSignature()
    }
}
Leanplum.addStartResponseHandler(new StartCallback() {
            @Override
            public void onResponse(boolean success) {
                SecuredVars vars = Leanplum.securedVars();
                if (vars != null) {
                    String vJson = vars.getJson();
                    String vSign = vars.getSignature();
                }
            }
        });
        
Leanplum.forceContentUpdate(new VariablesChangedCallback() {
            @Override
            public void variablesChanged() {
                SecuredVars vars = Leanplum.securedVars();
            }
        });

The securedVars method returns null if either the vars or the signature is null.

Caching

The signature is also cached, the same way as variables, in-app messages etc.
This enables access to the values even if the Start fails. It will return the last fetched vars JSON and signature.

Keys and validation

Leanplum issues public/private key pairs which are used to sign (private) the variables payload and verify (public) the signature. Signing/verification is based on SHA1 with RSA, where the signature is 2048 bits.

Keys rotation

Leanplum is responsible for rotating the private/public key pairs every 12 months. During the rotation period two public keys are available at the same time and the newest key is used to sign any subsequent payloads. The former key is available for 24 hours to ensure smooth transition for in-flight signed payloads.

Access to the public key

The active public keys are always available at https://keys.prod.leanplum.com/public. A valid application/json response contains an array of base64-encoded public keys with the newest key at index 0.

[ 
 "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXjvUl5RkNQfHcyE2hGe6IkVRgHgwhdlRdrJhU3T7Y5moMvOqlPjNQu1epUufPHO56qkEKfiRVA58kSMAyV37HD2Djm1mFr9HrS6Ml6HsdcRHQlRqMABawSpHvmC1j9VI3/iOkAIVsP6ygMN5I0XG/K0tV0rEepBohjpiungicDHjpYBI20MjOUx96UUJ14a5d6AVteDZHhxPOtROdDFxxJ3331dLgriTdpsPW0DeTV8ujagxBdKJoobOBCNINf5kRZ+PhpxZfgK0IBwZ1W/YnaVSKH4WlEI2g2+936gRLT9y7+HnXcGt50fauyqAt6EnlZ4hsoC35rQX6uTeG8bTwIDAQAB", 
 "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArEslJiThwGY9NUR6lhxIt/cXXvaHYlvPrwIdk4e8DDwZ44zx+dMPslB8gzLtR9PdAIVqbklrJ0iJiq2Eyacbc7UYsTBBE3O76XhstBx7Q856Y9UCsi2l/cWwHo5u62jV5vCKcBQVP/eiTmzkAdPI2t+Bhb8wnBSo0L14O5A16O6muKnbOrj/y1bBuXSfhPNDC7hLSbjab8HgOkXHBrue5tVmw1j1MwNzLg+IdPdHl8YSXQrGMs2nYCdRGeWIWi50sPHbht6HIfXv7dBPq1hgUSmg187G1hfAvML0UTsbrS4NJ2Z06JlAA3POxQTrYCps5jePqubQRxlTUDBYyBNP2QIDAQAB"
]

Your backend should fetch the available public keys on a regular basis (e.g. 1 hour) and cache them. The caching will greatly improve the performance of the verification process at your backend and will increase the reliability of the keys service at Leanplum.

Signature verification

The public key is used by the backend to verify the authenticity of variables payload. The variables payload is first canonicalised and then signed. The backend must canonicalise the variables payload prior to verifying the signature.

Implementation Steps

Follow the below steps to implement the signature verification.

STEP 1

Define your variables through the Leanplum Dashboard. Go to the Variables page and click on the settings button next to the search, then click the "+ New Variable" button.

This is recommended since our API is optimized to return only modified values based on those defined through code when variables are defined through the SDK. This means that when variables defined through the SDK code are used, the payload signed and returned by the server can differ from the actual variables values, when those are merged by the SDK.

If you want to use Variables Signing and define your variables from the SDK code, please keep in mind the following:

  • the API returns only variables that have been modified (that have Override values) from the Dashboard. Variables that are not changed (the value is not modified and stays the same as the 'defaults in code') will not be returned by the API. Those variables will not be present in the secured vars JSON. Your backend needs to take care of using the default values.
  • for arrays and dictionaries, the API also returns only modified values. To be able to return only the modified values from an array, the collection returned is a dictionary with a key - the index of the modified value. The signature and the secured vars JSON are based on the values returned by the API.
    Example:
713

Response:

"vars": {
                "Difficulties": {
                    "[2]": "Legendary"
                },
                "UI": {
                    "DefaultTheme": "Dark"
                }
            }

Your backend needs to be able to map the values correctly if you use arrays.
Your backend needs to fill unmodified key-value pairs if you use dictionaries.
Another approach will be to use only primitive values and prefix/suffix those if they are in a group. This way you can easily get the overridden values only and use the control for the others.

The above merging and processing is done automatically by the SDK under the hood when using the variables inside the app.
The API is implemented in a way to optimize the payload size and reduce network usage and latency from the client SDK.

Note: There could be cases where the default values in code could differ in different versions of your app, handle this accordingly.

STEP 2

Ensure you are running an SDK version that supports this feature, or a higher one.
Get the variables JSON and signature using the SDK methods provided above.

Signature is base64 ‘url-safe’ encoded.

Send the data from the client through your server using your preferred communication.

STEP 3

Fetch the public key using the Leanplum provided endpoint.

Public key is base64 ‘default’ encoded

STEP 4

Canonicalize the JSON payload that is acquired from the SDK.

STEP 5

Decrypt the payload hash.
When decrypting you need to use “RSA/NONE/PKCS1Padding” transformation.

STEP 6

Verify the hashes match.

Sample code:

import android.util.Base64;
import java.io.IOException;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Arrays;
import javax.crypto.Cipher;
import org.erdtman.jcs.JsonCanonicalizer;

public class SecuredVarsHelper {
  
  private PublicKey getPublicKey(String key) {
    try {
      byte[] byteKey = Base64.decode(key, Base64.DEFAULT);
      X509EncodedKeySpec X509publicKey = new X509EncodedKeySpec(byteKey);
      KeyFactory kf = KeyFactory.getInstance("RSA");
      return kf.generatePublic(X509publicKey);
    } catch (Exception e) {
      throw new RuntimeException(e); // TODO handle properly
    }
  }
  
  private byte[] payloadHash(String payload) {
    try {
      MessageDigest md = MessageDigest.getInstance("SHA-1");
      return md.digest(payload.getBytes("UTF-8"));
    } catch (Exception e) {
      throw new RuntimeException(e); // TODO handle properly
    }
  }
  
  private boolean verifySignature(
      byte[] encryptedPayloadHash,
      String originalMessage,
      PublicKey publicKey) {
    try {
      Cipher cipher = Cipher.getInstance("RSA/NONE/PKCS1Padding");
      cipher.init(Cipher.DECRYPT_MODE, publicKey);
      byte[] decryptedMessageHash = cipher.doFinal(encryptedPayloadHash);
      byte[] origHash = payloadHash(originalMessage);
      return Arrays.equals(decryptedMessageHash, origHash);
    } catch (Exception e) {
      throw new RuntimeException(e); // TODO handle properly
    }
  }
  
  public boolean verify(String json, String signature) {
    // TODO Load public key dynamically as our docs says. This is a copy-pasted key from web browser.
    String publicKeyBase64 = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ptCuaA2R/BtHVo8xmBrbOcDIG9XRYW7TVmejEwKfV7nqK2p2qehE217Zzp7mR7GwmRXb3CPlYTNXiETqIjnAHjdS+2TbrRMIs+BLzXfMGPsxNL1UC3/NNf/bxI3qU/iJ42r+ynSbu4LtupXYA2KJ9Hc5YFCUK/ZHzqeX8+aMK+kXMiCbAF7hZ5+kXzHlIfdBxZSLNPBUs9Cic2TK3Gtahbyl/F35pqnFSJs+vqwIWEgZ90Q+KS23lK0k8ZvJ/t+bXcwhUfLjgKvKZppAikVWS3BL02I7DRZwlrphap5orTFoJjfBaaCzjsXX38mqrdjwMz54PmtHGowUAJHEdBoswIDAQAB";

    byte[] encryptedPayloadHash = Base64.decode(signature, Base64.URL_SAFE);

    // Canonicalize
    String payload = null;
    try {
      payload = new JsonCanonicalizer(json).getEncodedString();
    } catch (IOException e) {
      e.printStackTrace(); // TODO handle properly
    }

    PublicKey publicKey = getPublicKey(publicKeyBase64);
    return verifySignature(encryptedPayloadHash, payload, publicKey);
  }
}

Failing validation

If the validation fails, the backend should check whether there is a newer key available and Leanplum is using it for signing the variables payload (keys rotation in place). If that's not the case, the backend can revert to its control dataset and disregard the incoming information.