Protect your subscription content with client-side encryption
If you’re an online publication, you probably rely on subscribers for revenue. You might block premium content behind a paywall on the client using CSS obfuscation (display: none
).
Unfortunately, more tech savvy people can work around this.
Instead, you may be showing users a document that completely lacks premium content! Serving an entirely new page once your backend validates the user. While more secure, this method costs time, resources and user happiness.
Solve both these issues by implementing premium subscriber validation and content decryption on the client side. With this solution, users with premium access will be able to decrypt content without needing to load a new page or wait for a backend to respond!
Setup overview
To implement client-side decryption, you will combine both symmetric-key and public-key cryptography in the following way:
- Create a random symmetric-key for each document, granting each document a unique key.
- Encrypt the premium content with it's document's symmetric-key. The key is symmetric to allow the same key to encrypt and decrypt the content.
- Encrypt the document key with a public key, using a hybrid encryption protocol to encrypt the symmetric keys.
- Using the
<amp-subscriptions>
and/or<amp-subscriptions-google>
component(s), store the encrypted document key inside of the AMP document, alongside the encrypted premium content.
The AMP document stores the encrypted key in itself. This prevents decoupling of the encrypted document with the key that decodes it.
How does it work?
- AMP parses the key from encrypted content on the document the user lands on.
- While serving the premium content, AMP sends the encrypted symmetric key from the document to the authorizer as a part of the user’s entitlements fetch.
- The authorizer decides if the user has the correct permissions. If yes, the authorizer decrypts the document’s symmetric key with the authorizer’s private key from their public/private key pair. Then the authorizer returns the document key to the amp-subscriptions component logic.
- AMP decrypts the premium content with the document key and shows it to the user!
Implementation steps
Follow the steps below to integrate AMP encryption handling with your internal entitlements server.
Step 1: Create a public/private key pair
To encrypt the document’s symmetric key, you need to have your own public/private key pair. The public-key encryption is a hybrid encryption protocol, specifically a P-256 Elliptic Curve ECIES asymmetric encryption method with an AES-GCM (128-bit) symmetric encryption method.
We require public key handling to be done with Tink using this asymmetric key type. To create your private-public key pair, use either of the following:
- Tink’s KeysetManager class
- Tinkey (Tink’s key utility tool)
Both support key rotation. Implementing key rotation limits vulnerability to a compromised private key.
To help you get started in creating asymmetric keys, we created this script. It:
- Creates a new ECIES with AEAD key.
- Outputs the public key in plaintext to an output file.
- Outputs the private key to another output file.
- Encrypts the generated private key using a key hosted on Google Cloud (GCP) before writing to the output file, (commonly referred to as Envelope Encryption).
We require storing/publishing your public Tink Keyset in JSON format. This allows other AMP provided tools to work seamlessly. Our script already outputs the public key in this format.
Step 2: Encrypt articles
Decide if you will manually encrypt premium content, or automatically encrypt premium content.
Manually Encrypt
We require AES-GCM 128 symmetric method using Tink to encrypt premium content. The symmetric document key used to encrypt the premium content should be unique for each document. Add the document key to a JSON object that contains the key in base64-encoded plaintext, as well as the SKUs required to access the document’s encrypted content.
The JSON object below contains an example of the key in base64-encoded plaintext and the SKU.
{
AccessRequirements: ['thenewsynews.com:premium'],
Key: 'aBcDef781-2-4/sjfdi',
}
Encrypt the above JSON object using the public key generated in Create a Public/Private Key Pair.
Add the encrypted result as the value to the key "local"
. Place the key-value pair within a JSON object wrapped inside a <script type="application/json" cryptokeys="">
tag. Place the tag in the head of the document.
<head>
...
<script type="application/json" cryptokeys="">
{
"local": ['y0^r$t^ff'], // This is for your environment
"google.com": ['g00g|e$t^ff'], // This is for Google's environment
}
</script>
…
</head>
You are required to encrypt the document key with the local environment and Google’s public key. Including Google’s public key allows Google AMP cache to serve your document. You must instantiate a Tink Keyset to accept the Google public key from it’s URL:
https://news.google.com/swg/encryption/keys/prod/tink/public\_key
Google’s public key is a Tink Keyset in JSON format. See here for an example of working with this keyset.
Read-on: See an example of a working encrypted AMP document.
Auto Encrypt
Encrypt document using our script. The script accepts an HTML document and encrypts all contents inside of <section subscriptions-section="content" encrypted>
tags. Using the public keys located at the URLs passed to it, the script encrypts the document key that is created by the script. Using this script ensures that all content is encoded and formatted correctly for serving. See here for further instructions on using this script.
Step 3: Integrate authorizer
You need to update your authorizer to decrypt document keys when a user has the correct entitlements. The amp-subscriptions component automatically sends the encrypted document key to the "local"
authorizer through a “crypt=” URL parameter. It performs:
- Parsing the document key from the
"local"
JSON key field. - Document decryption.
You must use Tink to decrypt document keys in your authorizer. To decrypt with Tink, instantiate a HybridDecrypt client using the private keys generated in the Create a Public/Private Key Pair section. Do this upon server startup for optimal performance.
Your HybridDecrypt/Authorizer deployment should roughly match your key rotation schedule. This creates availability of all generated keys to the HybridDecrypt client.
Tink has extensive documentation and examples in C++, Java, Go, and Python to help you get started on your server-side implementation.
Request management
When a request comes to your authorizer:
- Parse the entitlements pingback URL for the “crypt=” parameter.
- Decode the "crypt=” parameter value with base64. The value stored in the URL parameter is the base64-encoded encrypted JSON object.
- Once the encrypted key is in it’s raw bytes form, use HybridDecrypt’s decrypt function to decrypt the key using your private key.
- If decryption is successful, parse the result into a JSON object.
- Verify the user’s access to one of the entitlements listed in the AccessRequirements JSON field.
- Return the document key from the “Key” field of the decrypted JSON object in the entitlements response. Add the decrypted document key in a new field entitled “decryptedDocumentKey” in the entitlements response. This grants access to the AMP framework.
The sample below is a pseudo-code snippet that outlines the description steps above:
string decryptDocumentKey(string encryptedKey, List < string > usersEntitlements,
HybridDecrypt hybridDecrypter) {
// 1. Base64 decode the input encrypted key.
bytes encryptedKeyBytes = base64.decode(encryptedKey);
// 2. Try to decrypt the encrypted key.
bytes decryptedKeyBytes;
try {
decryptedKeyBytes = hybridDecrypter.decrypt(
encryptedKeyBytes, null /* contextInfo */ );
} catch (error e) {
// Decryption error occurred. Handle it how you want.
LOG("Error occurred decrypting: ", e);
return "";
}
// 3. Parse the decrypted text into a JSON object.
string decryptedKey = new string(decryptedKeyBytes, UTF_8);
json::object decryptedParsedJson = JsonParser.parse(decryptedKey);
// 4. Check to see if the requesting user has the entitlements specified in
// the AccessRequirements section of the JSON object.
for (entitlement in usersEntitlements) {
if (decryptedParsedJson["AccessRequirements"]
.contains(entitlement)) {
// 5. Return the document key if the user has entitlements.
return decryptedParsedJson["Key"];
}
}
// User doesn't have correct requirements, return empty string.
return "";
}
JsonResponse getEntitlements(string requestUri) {
// Do normal handling of entitlements here…
List < string > usersEntitlements = getUsersEntitlementInfo();
// Check if request URI has "crypt" parameter.
String documentCrypt = requestUri.getQueryParameters().getFirst("crypt");
// If URI has "crypt" param, try to decrypt it.
string documentKey;
if (documentCrypt != null) {
documentKey = decryptDocumentKey(
documentCrypt,
usersEntitlements,
this.hybridDecrypter_);
}
// Construct JSON response.
JsonResponse response = JsonResponse {
signedEntitlements: getSignedEntitlements(),
isReadyToPay: getIsReadyToPay(),
};
if (!documentKey.empty()) {
response.decryptedDocumentKey = documentKey;
}
return response;
}
Related resources
Check out the documentation and examples found on the Tink Github page.
All helper scripts are in the subscriptions-project/encryption Github repo.
Further support
For any questions, comments, or concerns, please file a Github Issue.
-
Written by @CrystalOnScript