Overview
In the encrypted integration, the browser calls SEARCH v4 withencrypt=1, using either a GET or a POST request. Research Defender wraps the normal JSON result in an AES-256-GCM encrypted blob before returning it, so the response that reaches the browser cannot be read without your private key and encryptor key.
Your page passes the encrypted blob to your server, and your server decrypts it. You can write the server side in Python, Node.js, C#, or any language with AES-GCM and PBKDF2 support.
This page builds on the advanced integration. See also the secured integration, where your server calls SEARCH directly.
How it works
1
Collect library data in the browser
Load
rd_sys_util.min.js, call init(), wait for the datasets, then call pass_params().2
Request an encrypted result
Call SEARCH v4 with your publishable key and
encrypt=1. The response contains a single data field.3
Send the blob to your server
Post the
data value to an endpoint on your server.4
Decrypt on your server
Derive the key from your private key and encryptor key, decrypt the blob, and parse the JSON.
Prerequisites
- A Research Defender publishable key, used in the browser
- A Research Defender private key (a string), used only on your server to decrypt
- Your encryptor key (a string), used together with the private key to derive the decryption key
- Your survey number (
sy_nr) and the respondent’s session ID (sn_ud) - A page served over HTTP(S). Opening the file directly from disk (
file://) will not work.
Never include your private key or encryptor key in client-side code. Decrypt only on your server, and load both keys from environment variables or a secrets manager rather than source control.
Step 1: Request an encrypted result
string
GET or POST https://{environment}.rtymgt.com/api/v4/respondents/search/{publishable_key}Request parameters
For aGET request, send these as query parameters. For a POST request, send them as fields in a JSON body.
string
required
The survey number.
string
required
The respondent’s session ID.
string
required
Set to
"1" to receive the result as an encrypted blob.string
Set to
"1" to enable.string
Set to
"1" to enable.string
Set to
"1" to enable.string
required
The string returned by
pass_params(). Send it as the name of a parameter with an empty value: a query parameter for GET, or a JSON field for POST.Send the request with credentials (
credentials: "include") so Research Defender cookies are included.Encrypted response
{
"data": "<base64-encoded-blob>"
}
Browser example
- POST
- GET
encrypted-post.js
// ---------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------
const ENVIRONMENT = "prod"; // "staging" or "prod"
const PUBLISHABLE_KEY = "YOUR_PUBLISHABLE_KEY";
const LIBRARY_VERSION = "2.0.1";
// Replace with the values for the respondent you are checking.
const SURVEY_NUMBER = "YOUR_SURVEY_NUMBER"; // sy_nr
const SESSION_ID = "YOUR_SESSION_ID"; // sn_ud
// The endpoint on YOUR server that decrypts the response (see the server samples).
const BACKEND_ENDPOINT = "/api/rd-decrypt";
// Time to let the library collect its datasets before reading them.
const DATASET_READY_DELAY_MS = 1000;
const BASE_URL = `https://${ENVIRONMENT}.rtymgt.com`;
const LIBRARY_URL = `${BASE_URL}/static/js/rd_sys_util.min.js?v=${LIBRARY_VERSION}`;
const SEARCH_ENDPOINT = `${BASE_URL}/api/v4/respondents/search/${PUBLISHABLE_KEY}`;
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Parses a response body. If the body has a stray trailing character
* that breaks JSON.parse, it is trimmed and parsing is retried.
*/
function parseResponse(text) {
try {
return JSON.parse(text);
} catch {
return JSON.parse(text.slice(0, -1));
}
}
/** Loads the library, starts data collection, and returns its pass-through key. */
async function collectLibraryData() {
const { init, pass_params } = await import(LIBRARY_URL);
if (typeof init !== "function" || typeof pass_params !== "function") {
throw new Error("Research Defender library did not load correctly.");
}
await init({ key: PUBLISHABLE_KEY, env: ENVIRONMENT });
// Give the library time to finish collecting its datasets.
await sleep(DATASET_READY_DELAY_MS);
const pass = pass_params();
console.log("[rd] pass_params computed:", pass ? `${pass.length} chars` : "empty");
return pass;
}
/** Calls SEARCH v4 (POST) with encrypt=1 and returns the encrypted blob (base64). */
async function searchEncrypted({ surveyNumber, sessionId, pass }) {
const requestBody = {
sy_nr: surveyNumber,
sn_ud: sessionId,
dupe_details: "1",
df_details: "1",
mfr: "1",
encrypt: "1",
};
// The value returned by pass_params() is sent as a key with an empty
// string value. JSON.stringify takes care of quoting and escaping.
if (pass) {
requestBody[pass] = "";
}
const response = await fetch(SEARCH_ENDPOINT, {
method: "POST",
credentials: "include", // send Research Defender cookies
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Search request failed with status ${response.status}`);
}
const body = parseResponse(await response.text());
if (typeof body.data !== "string") {
throw new Error("Search response did not include encrypted data.");
}
return body.data;
}
/** Sends the encrypted blob to your server, which decrypts it with your private key. */
async function decryptWithBackend(encryptedBlob) {
const response = await fetch(BACKEND_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: encryptedBlob }),
});
if (!response.ok) {
throw new Error(`Backend request failed with status ${response.status}`);
}
return response.json();
}
// ---------------------------------------------------------------
// Usage
// ---------------------------------------------------------------
const pass = await collectLibraryData();
const encryptedBlob = await searchEncrypted({
surveyNumber: SURVEY_NUMBER,
sessionId: SESSION_ID,
pass,
});
const { result } = await decryptWithBackend(encryptedBlob);
console.log("Threat Potential:", result.Respondent?.threat_potential);
console.log("Duplicate Flag:", result.Surveys?.[0]?.flag);
console.log("Survey Number:", result.Surveys?.[0]?.survey_number);
encrypted-get.js
// ---------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------
const ENVIRONMENT = "prod"; // "staging" or "prod"
const PUBLISHABLE_KEY = "YOUR_PUBLISHABLE_KEY";
const LIBRARY_VERSION = "2.0.1";
// Replace with the values for the respondent you are checking.
const SURVEY_NUMBER = "YOUR_SURVEY_NUMBER"; // sy_nr
const SESSION_ID = "YOUR_SESSION_ID"; // sn_ud
// The endpoint on YOUR server that decrypts the response (see the server samples).
const BACKEND_ENDPOINT = "/api/rd-decrypt";
// Time to let the library collect its datasets before reading them.
const DATASET_READY_DELAY_MS = 1000;
const BASE_URL = `https://${ENVIRONMENT}.rtymgt.com`;
const LIBRARY_URL = `${BASE_URL}/static/js/rd_sys_util.min.js?v=${LIBRARY_VERSION}`;
const SEARCH_ENDPOINT = `${BASE_URL}/api/v4/respondents/search/${PUBLISHABLE_KEY}`;
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Parses a response body. If the body has a stray trailing character
* that breaks JSON.parse, it is trimmed and parsing is retried.
*/
function parseResponse(text) {
try {
return JSON.parse(text);
} catch {
return JSON.parse(text.slice(0, -1));
}
}
/** Loads the library, starts data collection, and returns its pass-through key. */
async function collectLibraryData() {
const { init, pass_params } = await import(LIBRARY_URL);
if (typeof init !== "function" || typeof pass_params !== "function") {
throw new Error("Research Defender library did not load correctly.");
}
await init({ key: PUBLISHABLE_KEY, env: ENVIRONMENT });
// Give the library time to finish collecting its datasets.
await sleep(DATASET_READY_DELAY_MS);
const pass = pass_params();
console.log("[rd] pass_params computed:", pass ? `${pass.length} chars` : "empty");
return pass;
}
/** Calls SEARCH v4 (GET) with encrypt=1 and returns the encrypted blob (base64). */
async function searchEncrypted({ surveyNumber, sessionId, pass }) {
const params = new URLSearchParams({
sy_nr: surveyNumber,
sn_ud: sessionId,
dupe_details: "1",
df_details: "1",
mfr: "1",
encrypt: "1",
});
// The value returned by pass_params() is sent as a parameter name with
// an empty value.
if (pass) {
params.set(pass, "");
}
const response = await fetch(`${SEARCH_ENDPOINT}?${params}`, {
method: "GET",
credentials: "include", // send Research Defender cookies
});
if (!response.ok) {
throw new Error(`Search request failed with status ${response.status}`);
}
const body = parseResponse(await response.text());
if (typeof body.data !== "string") {
throw new Error("Search response did not include encrypted data.");
}
return body.data;
}
/** Sends the encrypted blob to your server, which decrypts it with your private key. */
async function decryptWithBackend(encryptedBlob) {
const response = await fetch(BACKEND_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: encryptedBlob }),
});
if (!response.ok) {
throw new Error(`Backend request failed with status ${response.status}`);
}
return response.json();
}
// ---------------------------------------------------------------
// Usage
// ---------------------------------------------------------------
const pass = await collectLibraryData();
const encryptedBlob = await searchEncrypted({
surveyNumber: SURVEY_NUMBER,
sessionId: SESSION_ID,
pass,
});
const { result } = await decryptWithBackend(encryptedBlob);
console.log("Threat Potential:", result.Respondent?.threat_potential);
console.log("Duplicate Flag:", result.Surveys?.[0]?.flag);
console.log("Survey Number:", result.Surveys?.[0]?.survey_number);
Step 2: Decrypt on your server
Blob format
After base64 decoding, the blob has this layout:| Bytes | Length | Contents |
|---|---|---|
| 0–15 | 16 bytes | PBKDF2 salt |
| 16–27 | 12 bytes | AES-GCM nonce |
| 28–end | Variable | Ciphertext followed by the 16-byte authentication tag |
Keys
| Key | Type | Description |
|---|---|---|
| Private key | String | Your Research Defender private key. Used as the password when deriving the decryption key. |
| Encryptor key | String | Appended to the salt from the blob when deriving the decryption key. |
Key derivation
| Setting | Value |
|---|---|
| Algorithm | PBKDF2-HMAC-SHA256 |
| Password | Your private key, as UTF-8 bytes |
| Salt | The 16-byte salt from the blob, followed by your encryptor key as UTF-8 bytes |
| Iterations | 100000 |
| Key length | 32 bytes (AES-256) |
Decryption
Decrypt with AES-256-GCM, using the nonce from the blob and no additional authenticated data. Decryption fails if either key is wrong or the data was modified.- Python
- Node.js
- C#
decrypt.py
# pip install cryptography
import base64
import json
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
# ---------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------
# Your PRIVATE key. Keep it on the server only: never send it to a browser,
# and never commit it to source control.
PRIVATE_KEY = "YOUR_PRIVATE_KEY"
# Your encryptor key (a string). Keep it on the server only, like the private key.
ENCRYPTOR_KEY = "YOUR_ENCRYPTOR_KEY"
PBKDF2_ITERATIONS = 100_000
SALT_LENGTH = 16
NONCE_LENGTH = 12
TAG_LENGTH = 16
# ---------------------------------------------------------------
# Decryption
# ---------------------------------------------------------------
def decrypt_response(b64_blob: str, private_key: str, encryptor_key: str) -> dict:
"""Decrypt the "data" value of an encrypted search response.
Blob layout (after base64 decoding):
bytes 0-15 PBKDF2 salt
bytes 16-27 AES-GCM nonce
bytes 28-end ciphertext followed by the 16-byte authentication tag
"""
blob = base64.b64decode(b64_blob)
if len(blob) < SALT_LENGTH + NONCE_LENGTH + TAG_LENGTH:
raise ValueError("Encrypted response is too short")
pbkdf2_salt = blob[:SALT_LENGTH]
nonce = blob[SALT_LENGTH:SALT_LENGTH + NONCE_LENGTH]
ciphertext_tag = blob[SALT_LENGTH + NONCE_LENGTH:]
# Derive the AES-256 key from your private key and encryptor key.
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=pbkdf2_salt + encryptor_key.encode("utf-8"),
iterations=PBKDF2_ITERATIONS,
)
key = kdf.derive(private_key.encode("utf-8"))
# Decrypt and verify the authentication tag (no additional data).
plaintext = AESGCM(key).decrypt(nonce, ciphertext_tag, None)
return json.loads(plaintext.decode("utf-8"))
# Usage: encrypted = {"data": "<base64-encoded-blob>"} (the search response)
# result = decrypt_response(encrypted["data"], PRIVATE_KEY, ENCRYPTOR_KEY)
decrypt.js
// Node.js built-in crypto module, no packages required
const crypto = require("crypto");
// ---------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------
// Your PRIVATE key. Keep it on the server only: never send it to a browser,
// and never commit it to source control.
const PRIVATE_KEY = "YOUR_PRIVATE_KEY";
// Your encryptor key (a string). Keep it on the server only, like the private key.
const ENCRYPTOR_KEY = "YOUR_ENCRYPTOR_KEY";
const PBKDF2_ITERATIONS = 100000;
const SALT_LENGTH = 16;
const NONCE_LENGTH = 12;
const TAG_LENGTH = 16;
// ---------------------------------------------------------------
// Decryption
// ---------------------------------------------------------------
/**
* Decrypts the "data" value of an encrypted search response.
*
* Blob layout (after base64 decoding):
* bytes 0-15 PBKDF2 salt
* bytes 16-27 AES-GCM nonce
* bytes 28-end ciphertext followed by the 16-byte authentication tag
*/
function decryptResponse(b64Blob, privateKey, encryptorKey) {
const blob = Buffer.from(b64Blob, "base64");
if (blob.length < SALT_LENGTH + NONCE_LENGTH + TAG_LENGTH) {
throw new Error("Encrypted response is too short");
}
const pbkdf2Salt = blob.subarray(0, SALT_LENGTH);
const nonce = blob.subarray(SALT_LENGTH, SALT_LENGTH + NONCE_LENGTH);
const ciphertextWithTag = blob.subarray(SALT_LENGTH + NONCE_LENGTH);
// Derive the AES-256 key from your private key and encryptor key.
const key = crypto.pbkdf2Sync(
Buffer.from(privateKey, "utf-8"),
Buffer.concat([pbkdf2Salt, Buffer.from(encryptorKey, "utf-8")]),
PBKDF2_ITERATIONS,
32,
"sha256"
);
// AES-GCM appends the 16-byte authentication tag to the ciphertext.
const authTag = ciphertextWithTag.subarray(-TAG_LENGTH);
const ciphertext = ciphertextWithTag.subarray(0, -TAG_LENGTH);
// Decrypt and verify the authentication tag (final() throws if it does not match).
const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return JSON.parse(decrypted.toString("utf-8"));
}
// Usage: const encrypted = { data: "<base64-encoded-blob>" }; // the search response
// const result = decryptResponse(encrypted.data, PRIVATE_KEY, ENCRYPTOR_KEY);
ResearchDefenderDecryptor.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public static class ResearchDefenderDecryptor
{
// Your PRIVATE key. Keep it on the server only: load it from configuration
// or a secrets manager, and never send it to a browser.
private static readonly string PrivateKey =
Environment.GetEnvironmentVariable("RD_PRIVATE_KEY") ?? "YOUR_PRIVATE_KEY";
// Your encryptor key (a string). Keep it on the server only, like the private key.
private static readonly string EncryptorKey =
Environment.GetEnvironmentVariable("RD_ENCRYPTOR_KEY") ?? "YOUR_ENCRYPTOR_KEY";
private const int Pbkdf2Iterations = 100_000;
private const int SaltLength = 16;
private const int NonceLength = 12;
private const int TagLength = 16;
/// <summary>
/// Decrypts the "data" value of an encrypted search response.
///
/// Blob layout (after base64 decoding):
/// bytes 0-15 PBKDF2 salt
/// bytes 16-27 AES-GCM nonce
/// bytes 28-end ciphertext followed by the 16-byte authentication tag
/// </summary>
public static JsonElement Decrypt(string base64Blob)
{
var blob = Convert.FromBase64String(base64Blob);
if (blob.Length < SaltLength + NonceLength + TagLength)
{
throw new CryptographicException("Encrypted response is too short");
}
var pbkdf2Salt = blob.AsSpan(0, SaltLength);
var nonce = blob.AsSpan(SaltLength, NonceLength);
var ciphertextWithTag = blob.AsSpan(SaltLength + NonceLength);
var ciphertext = ciphertextWithTag[..^TagLength];
var tag = ciphertextWithTag[^TagLength..];
// Derive the AES-256 key from your private key and encryptor key.
var fullSalt = pbkdf2Salt.ToArray().Concat(Encoding.UTF8.GetBytes(EncryptorKey)).ToArray();
var key = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(PrivateKey),
fullSalt,
Pbkdf2Iterations,
HashAlgorithmName.SHA256,
32);
// Decrypt and verify the authentication tag (no additional data).
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key, TagLength);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return JsonDocument.Parse(plaintext).RootElement.Clone();
}
}
// Usage: var result = ResearchDefenderDecryptor.Decrypt(encryptedBlobFromSearchResponse);
Full samples
The samples below work together. The browser page posts the encrypted blob to/api/rd-decrypt on your server, and your server decrypts it.
Browser page, POST (HTML)
Browser page, POST (HTML)
encrypted-client-post.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Research Defender – Encrypted Integration (POST)</title>
</head>
<body>
<h1>Research Defender – Encrypted Integration (POST)</h1>
<pre id="output">Loading…</pre>
<script type="module">
// ---------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------
const ENVIRONMENT = "prod"; // "staging" or "prod"
const PUBLISHABLE_KEY = "YOUR_PUBLISHABLE_KEY";
const LIBRARY_VERSION = "2.0.1";
// Replace with the values for the respondent you are checking.
const SURVEY_NUMBER = "YOUR_SURVEY_NUMBER"; // sy_nr
const SESSION_ID = "YOUR_SESSION_ID"; // sn_ud
// The endpoint on YOUR server that decrypts the response (see the server samples).
const BACKEND_ENDPOINT = "/api/rd-decrypt";
// Time to let the library collect its datasets before reading them.
const DATASET_READY_DELAY_MS = 1000;
const BASE_URL = `https://${ENVIRONMENT}.rtymgt.com`;
const LIBRARY_URL = `${BASE_URL}/static/js/rd_sys_util.min.js?v=${LIBRARY_VERSION}`;
const SEARCH_ENDPOINT = `${BASE_URL}/api/v4/respondents/search/${PUBLISHABLE_KEY}`;
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Parses a response body. If the body has a stray trailing character
* that breaks JSON.parse, it is trimmed and parsing is retried.
*/
function parseResponse(text) {
try {
return JSON.parse(text);
} catch {
return JSON.parse(text.slice(0, -1));
}
}
/** Loads the library, starts data collection, and returns its pass-through key. */
async function collectLibraryData() {
const { init, pass_params } = await import(LIBRARY_URL);
if (typeof init !== "function" || typeof pass_params !== "function") {
throw new Error("Research Defender library did not load correctly.");
}
await init({ key: PUBLISHABLE_KEY, env: ENVIRONMENT });
// Give the library time to finish collecting its datasets.
await sleep(DATASET_READY_DELAY_MS);
const pass = pass_params();
console.log("[rd] pass_params computed:", pass ? `${pass.length} chars` : "empty");
return pass;
}
/** Calls SEARCH v4 (POST) with encrypt=1 and returns the encrypted blob (base64). */
async function searchEncrypted({ surveyNumber, sessionId, pass }) {
const requestBody = {
sy_nr: surveyNumber,
sn_ud: sessionId,
dupe_details: "1",
df_details: "1",
mfr: "1",
encrypt: "1",
};
// The value returned by pass_params() is sent as a key with an empty
// string value. JSON.stringify takes care of quoting and escaping.
if (pass) {
requestBody[pass] = "";
}
const response = await fetch(SEARCH_ENDPOINT, {
method: "POST",
credentials: "include", // send Research Defender cookies
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Search request failed with status ${response.status}`);
}
const body = parseResponse(await response.text());
if (typeof body.data !== "string") {
throw new Error("Search response did not include encrypted data.");
}
return body.data;
}
/** Sends the encrypted blob to your server, which decrypts it with your private key. */
async function decryptWithBackend(encryptedBlob) {
const response = await fetch(BACKEND_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: encryptedBlob }),
});
if (!response.ok) {
throw new Error(`Backend request failed with status ${response.status}`);
}
return response.json();
}
// ---------------------------------------------------------------
// Run
// ---------------------------------------------------------------
const output = document.getElementById("output");
try {
const pass = await collectLibraryData();
const encryptedBlob = await searchEncrypted({
surveyNumber: SURVEY_NUMBER,
sessionId: SESSION_ID,
pass,
});
const { result } = await decryptWithBackend(encryptedBlob);
console.log("Full results:", result);
const survey = result.Surveys?.[0];
output.textContent = [
`Threat Potential: ${result.Respondent?.threat_potential}`,
`Duplicate Flag: ${survey?.flag}`,
`Survey Number: ${survey?.survey_number}`,
"",
"Full response:",
JSON.stringify(result, null, 2),
].join("\n");
} catch (error) {
console.error(error);
output.textContent = `Error: ${error.message}`;
}
</script>
</body>
</html>
Browser page, GET (HTML)
Browser page, GET (HTML)
encrypted-client-get.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Research Defender – Encrypted Integration (GET)</title>
</head>
<body>
<h1>Research Defender – Encrypted Integration (GET)</h1>
<pre id="output">Loading…</pre>
<script type="module">
// ---------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------
const ENVIRONMENT = "prod"; // "staging" or "prod"
const PUBLISHABLE_KEY = "YOUR_PUBLISHABLE_KEY";
const LIBRARY_VERSION = "2.0.1";
// Replace with the values for the respondent you are checking.
const SURVEY_NUMBER = "YOUR_SURVEY_NUMBER"; // sy_nr
const SESSION_ID = "YOUR_SESSION_ID"; // sn_ud
// The endpoint on YOUR server that decrypts the response (see the server samples).
const BACKEND_ENDPOINT = "/api/rd-decrypt";
// Time to let the library collect its datasets before reading them.
const DATASET_READY_DELAY_MS = 1000;
const BASE_URL = `https://${ENVIRONMENT}.rtymgt.com`;
const LIBRARY_URL = `${BASE_URL}/static/js/rd_sys_util.min.js?v=${LIBRARY_VERSION}`;
const SEARCH_ENDPOINT = `${BASE_URL}/api/v4/respondents/search/${PUBLISHABLE_KEY}`;
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Parses a response body. If the body has a stray trailing character
* that breaks JSON.parse, it is trimmed and parsing is retried.
*/
function parseResponse(text) {
try {
return JSON.parse(text);
} catch {
return JSON.parse(text.slice(0, -1));
}
}
/** Loads the library, starts data collection, and returns its pass-through key. */
async function collectLibraryData() {
const { init, pass_params } = await import(LIBRARY_URL);
if (typeof init !== "function" || typeof pass_params !== "function") {
throw new Error("Research Defender library did not load correctly.");
}
await init({ key: PUBLISHABLE_KEY, env: ENVIRONMENT });
// Give the library time to finish collecting its datasets.
await sleep(DATASET_READY_DELAY_MS);
const pass = pass_params();
console.log("[rd] pass_params computed:", pass ? `${pass.length} chars` : "empty");
return pass;
}
/** Calls SEARCH v4 (GET) with encrypt=1 and returns the encrypted blob (base64). */
async function searchEncrypted({ surveyNumber, sessionId, pass }) {
const params = new URLSearchParams({
sy_nr: surveyNumber,
sn_ud: sessionId,
dupe_details: "1",
df_details: "1",
mfr: "1",
encrypt: "1",
});
// The value returned by pass_params() is sent as a parameter name with
// an empty value.
if (pass) {
params.set(pass, "");
}
const response = await fetch(`${SEARCH_ENDPOINT}?${params}`, {
method: "GET",
credentials: "include", // send Research Defender cookies
});
if (!response.ok) {
throw new Error(`Search request failed with status ${response.status}`);
}
const body = parseResponse(await response.text());
if (typeof body.data !== "string") {
throw new Error("Search response did not include encrypted data.");
}
return body.data;
}
/** Sends the encrypted blob to your server, which decrypts it with your private key. */
async function decryptWithBackend(encryptedBlob) {
const response = await fetch(BACKEND_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: encryptedBlob }),
});
if (!response.ok) {
throw new Error(`Backend request failed with status ${response.status}`);
}
return response.json();
}
// ---------------------------------------------------------------
// Run
// ---------------------------------------------------------------
const output = document.getElementById("output");
try {
const pass = await collectLibraryData();
const encryptedBlob = await searchEncrypted({
surveyNumber: SURVEY_NUMBER,
sessionId: SESSION_ID,
pass,
});
const { result } = await decryptWithBackend(encryptedBlob);
console.log("Full results:", result);
const survey = result.Surveys?.[0];
output.textContent = [
`Threat Potential: ${result.Respondent?.threat_potential}`,
`Duplicate Flag: ${survey?.flag}`,
`Survey Number: ${survey?.survey_number}`,
"",
"Full response:",
JSON.stringify(result, null, 2),
].join("\n");
} catch (error) {
console.error(error);
output.textContent = `Error: ${error.message}`;
}
</script>
</body>
</html>
Python server (Flask)
Python server (Flask)
encrypted_server.py
"""Research Defender - encrypted response sample (Python / Flask).
Receives the encrypted blob from your page and decrypts it with your
PRIVATE key.
pip install flask cryptography
export RD_PRIVATE_KEY="your-private-key"
export RD_ENCRYPTOR_KEY="your-encryptor-key"
python encrypted_server.py
"""
import base64
import json
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from flask import Flask, jsonify, request, send_from_directory
# ---------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------
# Your PRIVATE key. Keep it on the server only: never send it to a browser,
# and never commit it to source control.
PRIVATE_KEY = os.getenv("RD_PRIVATE_KEY", "YOUR_PRIVATE_KEY")
# Your encryptor key (a string). Keep it on the server only, like the private key.
ENCRYPTOR_KEY = os.getenv("RD_ENCRYPTOR_KEY", "YOUR_ENCRYPTOR_KEY")
PBKDF2_ITERATIONS = 100_000
SALT_LENGTH = 16
NONCE_LENGTH = 12
TAG_LENGTH = 16
# ---------------------------------------------------------------
# Decryption
# ---------------------------------------------------------------
def decrypt_response(b64_blob: str, private_key: str, encryptor_key: str) -> dict:
"""Decrypt the "data" value of an encrypted search response.
Blob layout (after base64 decoding):
bytes 0-15 PBKDF2 salt
bytes 16-27 AES-GCM nonce
bytes 28-end ciphertext followed by the 16-byte authentication tag
"""
blob = base64.b64decode(b64_blob)
if len(blob) < SALT_LENGTH + NONCE_LENGTH + TAG_LENGTH:
raise ValueError("Encrypted response is too short")
pbkdf2_salt = blob[:SALT_LENGTH]
nonce = blob[SALT_LENGTH:SALT_LENGTH + NONCE_LENGTH]
ciphertext_tag = blob[SALT_LENGTH + NONCE_LENGTH:]
# Derive the AES-256 key from your private key and encryptor key.
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=pbkdf2_salt + encryptor_key.encode("utf-8"),
iterations=PBKDF2_ITERATIONS,
)
key = kdf.derive(private_key.encode("utf-8"))
# Decrypt and verify the authentication tag (no additional data).
plaintext = AESGCM(key).decrypt(nonce, ciphertext_tag, None)
return json.loads(plaintext.decode("utf-8"))
# ---------------------------------------------------------------
# Web app (serves the sample page and exposes /api/rd-decrypt)
# ---------------------------------------------------------------
app = Flask(__name__, static_folder="static", static_url_path="/static")
@app.get("/")
def index():
return send_from_directory("static", "index.html")
@app.post("/api/rd-decrypt")
def rd_decrypt():
data = request.get_json(silent=True) or {}
blob = data.get("data")
if not isinstance(blob, str) or not blob:
return jsonify(error="data is required"), 400
try:
result = decrypt_response(blob, PRIVATE_KEY, ENCRYPTOR_KEY)
except (InvalidTag, ValueError) as error:
# Wrong key, tampered data, or malformed blob.
app.logger.error("Decryption failed: %s", type(error).__name__)
return jsonify(error="Could not decrypt the response"), 422
# For production, return only what your page needs (for example a
# pass/fail decision) instead of the full result.
return jsonify(result=result)
if __name__ == "__main__":
app.run(port=8000, debug=True)
Node.js server (Express)
Node.js server (Express)
encrypted_server.js
/**
* Research Defender - encrypted response sample (Node.js / Express).
*
* Receives the encrypted blob from your page and decrypts it with your
* PRIVATE key.
*
* npm install express
* export RD_PRIVATE_KEY="your-private-key"
* export RD_ENCRYPTOR_KEY="your-encryptor-key"
* node encrypted_server.js
*/
const crypto = require("crypto");
const path = require("path");
const express = require("express");
// ---------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------
// Your PRIVATE key. Keep it on the server only: never send it to a browser,
// and never commit it to source control.
const PRIVATE_KEY = process.env.RD_PRIVATE_KEY || "YOUR_PRIVATE_KEY";
// Your encryptor key (a string). Keep it on the server only, like the private key.
const ENCRYPTOR_KEY = process.env.RD_ENCRYPTOR_KEY || "YOUR_ENCRYPTOR_KEY";
const PBKDF2_ITERATIONS = 100000;
const SALT_LENGTH = 16;
const NONCE_LENGTH = 12;
const TAG_LENGTH = 16;
// ---------------------------------------------------------------
// Decryption
// ---------------------------------------------------------------
/**
* Decrypts the "data" value of an encrypted search response.
*
* Blob layout (after base64 decoding):
* bytes 0-15 PBKDF2 salt
* bytes 16-27 AES-GCM nonce
* bytes 28-end ciphertext followed by the 16-byte authentication tag
*/
function decryptResponse(b64Blob, privateKey, encryptorKey) {
const blob = Buffer.from(b64Blob, "base64");
if (blob.length < SALT_LENGTH + NONCE_LENGTH + TAG_LENGTH) {
throw new Error("Encrypted response is too short");
}
const pbkdf2Salt = blob.subarray(0, SALT_LENGTH);
const nonce = blob.subarray(SALT_LENGTH, SALT_LENGTH + NONCE_LENGTH);
const ciphertextWithTag = blob.subarray(SALT_LENGTH + NONCE_LENGTH);
// Derive the AES-256 key from your private key and encryptor key.
const key = crypto.pbkdf2Sync(
Buffer.from(privateKey, "utf-8"),
Buffer.concat([pbkdf2Salt, Buffer.from(encryptorKey, "utf-8")]),
PBKDF2_ITERATIONS,
32,
"sha256"
);
// AES-GCM appends the 16-byte authentication tag to the ciphertext.
const authTag = ciphertextWithTag.subarray(-TAG_LENGTH);
const ciphertext = ciphertextWithTag.subarray(0, -TAG_LENGTH);
// Decrypt and verify the authentication tag (final() throws if it does not match).
const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return JSON.parse(decrypted.toString("utf-8"));
}
// ---------------------------------------------------------------
// Web app (serves the sample page and exposes /api/rd-decrypt)
// ---------------------------------------------------------------
const app = express();
app.use(express.json());
app.get("/", (req, res) => {
res.sendFile(path.join(__dirname, "static", "index.html"));
});
app.post("/api/rd-decrypt", (req, res) => {
const { data } = req.body ?? {};
if (typeof data !== "string" || !data) {
return res.status(400).json({ error: "data is required" });
}
try {
const result = decryptResponse(data, PRIVATE_KEY, ENCRYPTOR_KEY);
// For production, return only what your page needs (for example a
// pass/fail decision) instead of the full result.
return res.json({ result });
} catch (error) {
// Wrong key, tampered data, or malformed blob.
console.error(`Decryption failed: ${error.name}`);
return res.status(422).json({ error: "Could not decrypt the response" });
}
});
app.listen(8000, () => console.log("Listening on http://localhost:8000"));
C# server (ASP.NET Core)
C# server (ASP.NET Core)
EncryptedServer.cs
// Research Defender - encrypted response sample (C# / ASP.NET Core minimal API, .NET 8).
//
// Receives the encrypted blob from your page and decrypts it with your
// PRIVATE key.
//
// dotnet new web -n EncryptedServer
// (replace Program.cs with this file, put the client page in wwwroot/ as index.html)
// export RD_PRIVATE_KEY="your-private-key"
// export RD_ENCRYPTOR_KEY="your-encryptor-key"
// dotnet run
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapPost("/api/rd-decrypt", (DecryptRequest request, ILogger<Program> logger) =>
{
if (string.IsNullOrWhiteSpace(request.Data))
{
return Results.BadRequest(new { error = "data is required" });
}
try
{
var result = ResearchDefenderDecryptor.Decrypt(request.Data);
// For production, return only what your page needs (for example a
// pass/fail decision) instead of the full result.
return Results.Json(new { result });
}
catch (Exception ex) when (ex is CryptographicException or FormatException or JsonException)
{
// Wrong key, tampered data, or malformed blob.
logger.LogError("Decryption failed: {ErrorType}", ex.GetType().Name);
return Results.Json(new { error = "Could not decrypt the response" }, statusCode: StatusCodes.Status422UnprocessableEntity);
}
});
app.Run();
// ---------------------------------------------------------------
// Request model (JSON property names are matched case-insensitively)
// ---------------------------------------------------------------
public record DecryptRequest(string Data);
// ---------------------------------------------------------------
// Decryption
// ---------------------------------------------------------------
public static class ResearchDefenderDecryptor
{
// Your PRIVATE key. Keep it on the server only: load it from configuration
// or a secrets manager, and never send it to a browser.
private static readonly string PrivateKey =
Environment.GetEnvironmentVariable("RD_PRIVATE_KEY") ?? "YOUR_PRIVATE_KEY";
// Your encryptor key (a string). Keep it on the server only, like the private key.
private static readonly string EncryptorKey =
Environment.GetEnvironmentVariable("RD_ENCRYPTOR_KEY") ?? "YOUR_ENCRYPTOR_KEY";
private const int Pbkdf2Iterations = 100_000;
private const int SaltLength = 16;
private const int NonceLength = 12;
private const int TagLength = 16;
/// <summary>
/// Decrypts the "data" value of an encrypted search response.
///
/// Blob layout (after base64 decoding):
/// bytes 0-15 PBKDF2 salt
/// bytes 16-27 AES-GCM nonce
/// bytes 28-end ciphertext followed by the 16-byte authentication tag
/// </summary>
public static JsonElement Decrypt(string base64Blob)
{
var blob = Convert.FromBase64String(base64Blob);
if (blob.Length < SaltLength + NonceLength + TagLength)
{
throw new CryptographicException("Encrypted response is too short");
}
var pbkdf2Salt = blob.AsSpan(0, SaltLength);
var nonce = blob.AsSpan(SaltLength, NonceLength);
var ciphertextWithTag = blob.AsSpan(SaltLength + NonceLength);
var ciphertext = ciphertextWithTag[..^TagLength];
var tag = ciphertextWithTag[^TagLength..];
// Derive the AES-256 key from your private key and encryptor key.
var fullSalt = pbkdf2Salt.ToArray().Concat(Encoding.UTF8.GetBytes(EncryptorKey)).ToArray();
var key = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(PrivateKey),
fullSalt,
Pbkdf2Iterations,
HashAlgorithmName.SHA256,
32);
// Decrypt and verify the authentication tag (no additional data).
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key, TagLength);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return JsonDocument.Parse(plaintext).RootElement.Clone();
}
}
static folder next to the server file as index.html, set the RD_PRIVATE_KEY and RD_ENCRYPTOR_KEY environment variables, start the server (python encrypted_server.py or node encrypted_server.js), and open http://localhost:8000. The Python sample needs pip install flask cryptography, and the Node.js sample needs npm install express.
Security recommendations
- Keep the private key and encryptor key on your server. Load them from environment variables or a secrets manager, and never send them to the browser.
- Treat a decryption failure as an error. AES-GCM verifies the data as it decrypts, so a failure means a wrong key or modified data. Do not fall back to trusting the blob.
- Return only what the page needs. The samples return the full decrypted result for demonstration. In production, return a decision such as pass or fail.
- Keep decrypted results out of logs unless you need them, and never log your private key or encryptor key.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Authentication or “invalid tag” error | The private key or encryptor key is wrong, or the blob was modified. |
| ”Encrypted response is too short” | The value passed to your server is not the full data string. |
| Base64 or format error | The blob was truncated or altered in transit. Post the data value unchanged. |
| Browser shows “did not include encrypted data” | The search request did not include encrypt=1, or the API returned an error. |
