Encryption 🔒
The application supports end-to-end encryption by encrypting sensitive fields before sending them to the API and decrypting them on the device. This ensures that no one – including us as the service provider, the hosting provider, or any third parties – can access the content and recipients of the messages.
Encryption Scope
Only specific fields should be encrypted:
- For text messages:
textMessage.textfield - For data messages:
dataMessage.datafield - All values in the
phoneNumbersarray
Other fields like id, simNumber, ttl, etc. should remain unencrypted.
Please note that using encryption will increase device battery usage.
Requirements ✅
- For text messages: encrypt the
textMessage.textfield - For data messages: encrypt the
dataMessage.datafield - Encrypt every value in the
phoneNumbersarray - Set the
isEncryptedfield totrue - Use the same passphrase on both client and device
Algorithm ⚙️
- Select a passphrase that will be used for encryption and specify it on the device
- Generate a random salt (16 bytes recommended)
- Create a secret key using PBKDF2 with:
- SHA1 hash function
- 256-bit key size
- Iteration count (see Iteration Count Recommendations below)
- Encrypt the target fields separately using AES-256-CBC
- Encode encrypted values as Base64
- Format each encrypted value as:
$aes-256-cbc/pbkdf2-sha1$i=<iterations>$<base64 salt>$<base64 encrypted data>
Iteration Count Recommendations 🔢
OWASP Guidelines
According to OWASP, the recommended minimum iteration count for PBKDF2-SHA1 is 1,400,000 iterations as of 2026. This provides stronger security against brute-force attacks by making key derivation more computationally expensive.
Application Default
The current default iteration count is 75,000, which is significantly lower than the OWASP recommendation of 1,400,000 iterations. The iteration ranges suggested in this document are intentionally below OWASP guidelines to maintain device compatibility. This deliberate choice balances security with performance on older devices:
- The application supports devices running Android 5.0 (API level 21) and above
- On older devices, even 75,000 iterations can cause decryption to take several seconds
- Higher iteration counts would make the user experience unacceptable on these devices
Security Considerations
While we recommend using the highest iteration count your use case can tolerate, consider these factors:
- Device Performance: Higher iterations increase CPU usage and battery consumption
- User Experience: Decryption time increases linearly with iteration count
- Security Needs: Evaluate the sensitivity of your data and threat model
Recommended Approach
- Compatibility-driven (below OWASP): For new deployments on modern devices (Android 10+), consider using 300,000-600,000 iterations; for broad device compatibility including older devices, 75,000-150,000 iterations provides reasonable security. Note that these ranges accept increased residual risk of brute-force attacks in exchange for usability on older/lower-powered devices.
- The iteration count is stored in the encrypted format, allowing different values per message
Implementation Examples 💻
<?php
class Encryptor {
protected string $passphrase;
protected int $iterationCount;
/**
* Encryptor constructor.
* @param string $passphrase Passphrase to use for encryption
* @param int $iterationCount Iteration count
*/
public function __construct(
string $passphrase,
int $iterationCount = 75000
) {
$this->passphrase = $passphrase;
$this->iterationCount = $iterationCount;
}
public function Encrypt(string $data): string {
$salt = $this->generateSalt();
$secretKey = $this->generateSecretKeyFromPassphrase($this->passphrase, $salt, 32, $this->iterationCount);
return sprintf(
'$aes-256-cbc/pbkdf2-sha1$i=%d$%s$%s',
$this->iterationCount,
base64_encode($salt),
openssl_encrypt($data, 'aes-256-cbc', $secretKey, 0, $salt)
);
}
public function Decrypt(string $data): string {
list($_, $algo, $paramsStr, $saltBase64, $encryptedBase64) = explode('$', $data);
if ($algo !== 'aes-256-cbc/pbkdf2-sha1') {
throw new \RuntimeException('Unsupported algorithm');
}
$params = $this->parseParams($paramsStr);
if (empty($params['i'])) {
throw new \RuntimeException('Missing iteration count');
}
$salt = base64_decode($saltBase64);
$secretKey = $this->generateSecretKeyFromPassphrase($this->passphrase, $salt, 32, intval($params['i']));
return openssl_decrypt($encryptedBase64, 'aes-256-cbc', $secretKey, 0, $salt);
}
protected function generateSalt(int $size = 16): string {
return random_bytes($size);
}
protected function generateSecretKeyFromPassphrase(
string $passphrase,
string $salt,
int $keyLength = 32,
int $iterationCount = 75000
): string {
return hash_pbkdf2('sha1', $passphrase, $salt, $iterationCount, $keyLength, true);
}
/**
* @return array<string, string>
*/
protected function parseParams(string $params): array {
$keyValuePairs = explode(',', $params);
$result = [];
foreach ($keyValuePairs as $pair) {
list($key, $value) = explode('=', $pair, 2);
$result[$key] = $value;
}
return $result;
}
}
Please note, that Bun's implementation of the crypto package is not optimized as of 2024, so it is much slower than Node's implementation.
import crypto from "crypto";
class Encryptor {
constructor(protected readonly passphrase: string, protected readonly iterations: number = 75_000) {
}
public Decrypt(input: string): string {
const parts = input.split("$");
if (parts.length !== 5) {
throw new Error("Invalid encrypted text");
}
if (parts[1] !== "aes-256-cbc/pbkdf2-sha1") {
throw new Error("Unsupported algorithm");
}
const paramsStr = parts[2];
const params = this.parseParams(paramsStr);
if (!params.has("i")) {
throw new Error("Missing iteration count");
}
const iterations = parseInt(params.get("i")!);
const salt = Buffer.from(parts[3], "base64");
const encryptedText = Buffer.from(parts[4], "base64");
const secretKey = this.generateSecretKeyFromPassphrase(this.passphrase, salt, 32, iterations);
const decryptedText = this.decryptString(encryptedText, secretKey, salt);
return decryptedText.toString("utf8");
}
protected parseParams(params: string): Map<string, string> {
const keyValuePairs = params.split(",");
const result = new Map<string, string>();
keyValuePairs.forEach(pair => {
const [key, value] = pair.split("=");
result.set(key, value);
});
return result;
}
protected decryptString(input: Buffer, secretKey: Buffer, iv: Buffer): Buffer {
const decipher = crypto
.createDecipheriv("aes-256-cbc", secretKey, iv);
return Buffer.concat([decipher.update(input), decipher.final()]);
}
public Encrypt(input: string): string {
const salt = this.generateSalt();
const secretKey = this.generateSecretKeyFromPassphrase(this.passphrase, salt, 32, this.iterations);
const encryptedText = this.encryptString(Buffer.from(input, "utf8"), secretKey, salt);
return `$aes-256-cbc/pbkdf2-sha1$i=${this.iterations}$${salt.toString("base64")}$${encryptedText.toString("base64")}`;
}
protected encryptString(input: Buffer, secretKey: Buffer, iv: Buffer): Buffer {
const cypher = crypto
.createCipheriv("aes-256-cbc", secretKey, iv);
return Buffer.concat([cypher.update(input), cypher.final()]);
}
protected generateSalt(size: number = 16): Buffer {
return crypto.randomBytes(size);
}
protected generateSecretKeyFromPassphrase(passphrase: string, salt: Buffer, keyLength: number = 32, iterations: number = 75_000): Buffer {
return crypto.pbkdf2Sync(passphrase, salt, iterations, keyLength, "sha1");
}
}
Based on pycryptodome
import base64
from Crypto.Cipher import AES
from Crypto.Hash import SHA1
from Crypto.Protocol.KDF import PBKDF2
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad, unpad
class AESEncryptor:
def __init__(self, passphrase: str, iterations: int = 75_000):
self.passphrase = passphrase
self.iterations = iterations
def encrypt(self, cleartext: str) -> str:
saltBytes = self._generate_salt()
key = self._generate_key(saltBytes, self.iterations)
cipher = AES.new(key, AES.MODE_CBC, iv=saltBytes)
encrypted_bytes = cipher.encrypt(pad(cleartext.encode(), AES.block_size))
salt = base64.b64encode(saltBytes).decode("utf-8")
encrypted = base64.b64encode(encrypted_bytes).decode("utf-8")
return f"$aes-256-cbc/pbkdf2-sha1$i={self.iterations}${salt}${encrypted}"
def decrypt(self, encrypted: str) -> str:
chunks = encrypted.split("$")
if len(chunks) < 5:
raise ValueError("Invalid encryption format")
if chunks[1] != "aes-256-cbc/pbkdf2-sha1":
raise ValueError("Unsupported algorithm")
params = self._parse_params(chunks[2])
if "i" not in params:
raise ValueError("Missing iteration count")
iterations = int(params["i"])
salt = base64.b64decode(chunks[-2])
encrypted_bytes = base64.b64decode(chunks[-1])
key = self._generate_key(salt, iterations)
cipher = AES.new(key, AES.MODE_CBC, iv=salt)
decrypted_bytes = unpad(cipher.decrypt(encrypted_bytes), AES.block_size)
return decrypted_bytes.decode("utf-8")
def _generate_salt(self) -> bytes:
return get_random_bytes(16)
def _generate_key(self, salt: bytes, iterations: int) -> bytes:
return PBKDF2(
self.passphrase,
salt,
count=iterations,
dkLen=32,
hmac_hash_module=SHA1,
)
def _parse_params(self, params: str) -> dict[str, str]:
return {k: v for k, v in [p.split("=") for p in params.split(",")]}