SMSGate 001

End-to-end encryption

SMSGate 001 supports optional end-to-end encryption between your Android device and your client code. Our server only sees ciphertext, timestamps, and phone numbers — never the message body.

Threat model

  • Our servers are out of scope. We can’t read, log, or hand over message contents.
  • Phone numbers (sender/recipient) are still visible to us.
  • The carrier (Telcel / AT&T / Movistar / WhatsApp) sees plaintext on the wire — this feature protects against us, not the carrier.
  • If you lose the passphrase, we cannot recover past messages.

Envelope format

Base64 of: "smsgw1" | salt[16 bytes] | iv[12 bytes] | AES-256-GCM(ciphertext + 16-byte tag).

Key derivation: PBKDF2-HMAC-SHA256(passphrase, salt, iterations=100_000, keyLen=32 bytes).

Cipher: AES-256-GCM, 12-byte IV, 16-byte tag.

Step 1 — Set the passphrase on your Android device

In the SMS Gateway Android app, tap 🔒 Set E2E encryption passphrase and enter a strong string (we recommend 4+ random words or a 20+ character random string). The passphrase is stored only in your phone’s private SharedPreferences — it never leaves the device.

Step 2 — Use the same passphrase in your client code

Node.js

import crypto from "node:crypto";

const ITER = 100_000;
const MAGIC = Buffer.from("smsgw1");

export function encrypt(passphrase, plaintext) {
  const salt = crypto.randomBytes(16);
  const iv = crypto.randomBytes(12);
  const key = crypto.pbkdf2Sync(passphrase, salt, ITER, 32, "sha256");
  const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
  const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
  const tag = cipher.getAuthTag();
  return Buffer.concat([MAGIC, salt, iv, ct, tag]).toString("base64");
}

export function decrypt(passphrase, envelopeB64) {
  const buf = Buffer.from(envelopeB64, "base64");
  if (!buf.slice(0, 6).equals(MAGIC)) throw new Error("bad magic");
  const salt = buf.slice(6, 22);
  const iv = buf.slice(22, 34);
  const tag = buf.slice(buf.length - 16);
  const ct = buf.slice(34, buf.length - 16);
  const key = crypto.pbkdf2Sync(passphrase, salt, ITER, 32, "sha256");
  const d = crypto.createDecipheriv("aes-256-gcm", key, iv);
  d.setAuthTag(tag);
  return Buffer.concat([d.update(ct), d.final()]).toString("utf8");
}

Python

import os, base64, hashlib
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

ITER = 100_000
MAGIC = b"smsgw1"

def encrypt(passphrase: str, plaintext: str) -> str:
    salt = os.urandom(16)
    iv = os.urandom(12)
    key = hashlib.pbkdf2_hmac("sha256", passphrase.encode(), salt, ITER, 32)
    ct = AESGCM(key).encrypt(iv, plaintext.encode(), None)  # ct includes 16-byte tag
    return base64.b64encode(MAGIC + salt + iv + ct).decode()

def decrypt(passphrase: str, envelope_b64: str) -> str:
    buf = base64.b64decode(envelope_b64)
    assert buf[:6] == MAGIC
    salt, iv, ct = buf[6:22], buf[22:34], buf[34:]
    key = hashlib.pbkdf2_hmac("sha256", passphrase.encode(), salt, ITER, 32)
    return AESGCM(key).decrypt(iv, ct, None).decode()

Step 3 — Send an encrypted message

Encrypt on your side, send the envelope as the message, and set isEncrypted: true:

CIPHERTEXT=$(node -e "import('./e2e.js').then(m=>console.log(m.encrypt('my passphrase','hola mundo')))")

curl -X POST https://sms.001.com.mx/api/v1/send \
  -H "Authorization: Bearer sk_..." \
  -H "content-type: application/json" \
  -d "{
    \"channel\": \"sms\",
    \"phoneNumbers\": [\"+5215555555555\"],
    \"message\": \"$CIPHERTEXT\",
    \"isEncrypted\": true
  }"

The Android device will decrypt the envelope with its local passphrase and forward the plaintext to the carrier. Our servers only ever hold the envelope.

Step 4 — Decrypt inbound messages

When E2E is enabled on the phone, inbound SMS bodies are encrypted with the passphrase before being sent to us. Your webhook receiver must decrypt:

import { decrypt } from "./e2e.js";
app.post("/webhook", express.json(), (req, res) => {
  const { event, data } = req.body;
  if (event === "sms:received" && data.isEncrypted) {
    const plaintext = decrypt(process.env.E2E_PASSPHRASE, data.body);
    console.log("plaintext:", plaintext);
  }
  res.sendStatus(200);
});

Caveats

  • WhatsApp uses its own E2E already; isEncrypted is for SMS/MMS only.
  • Multipart SMS: Envelope format adds ~60 bytes of overhead. A 140-byte SMS becomes ~100 chars of plaintext budget.
  • Key rotation: changing the passphrase invalidates old inbound messages. Keep a key log.
  • Passphrase hygiene: store it in a secrets manager (1Password, AWS Secrets Manager, GCP Secret Manager). Never commit to source.