DEV Community

GYPengDev
GYPengDev

Posted on

Encrypted Mobile API Debugging? No More Copying Ciphertext Across Multiple Tools

I'm an independent developer who spends a lot of time debugging mobile H5 pages through a local HTTPS proxy.

The part that never gets easier: the request body looks fine in the proxy, but every important field is still encrypted. You copy a Base64 blob into a Node REPL, tweak a phone number, re-encode it, paste it into Postman, realize the client also signs headers, close Postman, open Charles again…

Existing tools are good at transport (TLS MITM) or API replay (Postman/Insomnia). Neither really owns the loop of capture → decrypt → edit in context → re-encrypt → mock or resend. That's the gap I kept hitting for months.

This post walks through that manual workflow first — then the layered config model I used to automate it.


Two different "decryptions"

Before any app-layer trickery, you need plaintext HTTP.

Layer What you get Typical setup
Transport (HTTPS MITM) Readable HTTP headers/body Proxy + trusted root CA on device + SSL decrypt allowlist
Application (param transform) Readable business fields inside JSON/query Rules that decode Base64/AES/etc. on specific keys

If your capture list is full of CONNECT tunnels and empty bodies, fix layer 1 first. Param transform only helps once you can already see something like:

{
  "username": "alice",
  "token": "YWxpY2U6c2VjcmV0"
}
Enter fullscreen mode Exit fullscreen mode

Pain point 1: Decrypting once is easy; doing it fifty times a day is not

Here's the manual loop I used to run for a single login field:

1. Capture in the proxy

POST /v1/login HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"username":"alice","token":"YWxpY2U6c2VjcmV0"}
Enter fullscreen mode Exit fullscreen mode

2. Decode in a one-off script

// decode.mjs — run every time you need to inspect a value
const token = "YWxpY2U6c2VjcmV0";
console.log(Buffer.from(token, "base64").toString("utf8"));
// alice:secret
Enter fullscreen mode Exit fullscreen mode

3. Change the value and encode back

const plain = "alice:new-secret";
console.log(Buffer.from(plain, "utf8").toString("base64"));
// YWxpY2U6bmV3LXNlY3JldA==
Enter fullscreen mode Exit fullscreen mode

4. Paste into Postman — which doesn't know your client's header signing, cookie jar, or device fingerprint.

5. Give up and edit in Charles — but Charles has no first-class "this JSON key is always Base64" rule that also feeds your mock matcher.

The userId, phone, device token… each field repeats the same five steps. Teams share Postman collections but rarely share the half-broken decrypt scripts that live in someone's ~/tmp.


Pain point 2: Mocking on ciphertext is fragile

Say you want to mock "phone = 13800138000" on an encrypted API.

Without transform rules you either:

  • Pre-compute ciphertext offline and hard-code it in the mock (breaks when keys rotate), or
  • Write a Charles rewrite script that decrypts before matching (works, but nobody on the team wants to maintain it)

What you actually want is: match on plaintext, send ciphertext. That implies decrypt-before-match in the proxy pipeline:

incoming request
  → apply param transform (derive plaintext)
  → mock matcher reads plaintext features
  → (optional) re-encrypt before forwarding
Enter fullscreen mode Exit fullscreen mode

Order matters. If mock runs on raw body only, your 13800138000 rule never fires.


Pain point 3: AES is where copy-paste really breaks

Base64 is annoying. AES-CBC with a fixed IV is where I lost afternoons.

Minimal Node version that must match the mobile client byte-for-byte:

const crypto = require("crypto");

function decryptField(b64Cipher) {
  const key = Buffer.from("1234567890123456", "utf8"); // 16 bytes
  const iv  = Buffer.from("abcdefghijklmnop", "utf8"); // 16 bytes
  const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
  decipher.setAutoPadding(true); // PKCS7
  let plain = decipher.update(b64Cipher, "base64", "utf8");
  plain += decipher.final("utf8");
  return plain;
}

function encryptField(plain) {
  const key = Buffer.from("1234567890123456", "utf8");
  const iv  = Buffer.from("abcdefghijklmnop", "utf8");
  const cipher = crypto.createCipheriv("aes-128-cbc", key, iv);
  let out = cipher.update(plain, "utf8", "base64");
  out += cipher.final("base64");
  return out;
}
Enter fullscreen mode Exit fullscreen mode

One wrong assumption — key as hex vs utf8, IV prepended to ciphertext, GCM auth tag placement — and you get garbage. Now multiply that by three environments (staging keys, prod keys, feature-flag keys).

Multi-step pipelines are worse:

// common pattern: base64 wrapper around AES output
function convertDecrypt(input) {
  const inner = Buffer.from(input, "base64").toString("utf8");
  return decryptField(inner);
}
Enter fullscreen mode Exit fullscreen mode

That's when I stopped trusting one-liner snippets and started wanting versioned, exportable rules tied to URL patterns.


Pain point 4: Postman isn't on the phone's code path

Postman is great for contract testing. It's a poor stand-in when:

  • The app adds a timestamp + HMAC over the sorted body
  • Tokens come from a previous capture-only handshake
  • You need to see what this WebView actually sent, then nudge one field

The useful workflow is: same captured request, same headers, one edited field, resend through the proxy. Not "reconstruct the request from docs."


A cleaner model: separate "how" from "where"

The model I ended up with splits config into two layers — conversion rules (how to decrypt/encrypt) and param transform rules (which request + which param key):

Conversion rule     → HOW to decrypt/encrypt (algorithm or script)
        ↑ referenced by
Transform rule      → WHICH request + WHICH param key
        ↓ produces
paramTransformDerived (plaintext sidecar — original capture untouched)
Enter fullscreen mode Exit fullscreen mode

Why keep the raw capture?

  • Audit: you still see what the client actually sent
  • Diff: compare ciphertext drift across builds
  • Safety: failed transforms don't corrupt stored payloads

Example transform target for the login JSON above:

// conceptual config — not a literal file format
{
  "match": {
    "url": [{ "key": "host", "value": "api.example.com" }],
    "bodyParams": [{ "key": "token", "value": "*", "bodyKind": "json" }]
  },
  "apply": {
    "ruleId": "cr_login_b64",
    "target": { "source": "body", "key": "token", "bodyKind": "json" }
  }
}
Enter fullscreen mode Exit fullscreen mode

With a builtin Base64 conversion rule on cr_login_b64, every matching capture automatically gets a derived plaintext row — no REPL, no clipboard.


Script escape hatch (when builtin isn't enough)

For custom signing, chained transforms, or calling an internal decrypt API:

function convertDecrypt(input, request) {
  // input: the param string; request: read-only snapshot (host, headers, body, query)
  return util.crypto.aesDecrypt({
    ciphertext: input,
    key: "0123456789abcdef",
    iv: "0123456789abcdef",
    keyEncoding: "utf8",
    ivEncoding: "utf8",
    inputEncoding: "base64",
    outputEncoding: "utf8",
  });
}

function convertEncrypt(input, request) {
  return util.crypto.aesEncrypt({
    plaintext: input,
    key: "0123456789abcdef",
    iv: "0123456789abcdef",
    keyEncoding: "utf8",
    ivEncoding: "utf8",
    plaintextEncoding: "utf8",
    outputEncoding: "base64",
  });
}
Enter fullscreen mode Exit fullscreen mode

convertEncrypt is optional — only needed if you want to edit plaintext and send it back (mock tamper, resend drawer). MD5/SHA/HMAC-style rules are view-only by design.

That's the full layered model. I built a desktop proxy tool around it so the decrypt → edit → re-encrypt loop stays inside the capture workflow instead of bouncing between a REPL and three other apps.


What I wired this into — DevPeek

The app is called DevPeek (Electron + Vue 2 main/renderer, Node proxy in the middle). I used the pain points above as a checklist. Three places param transform actually matters:

  1. Capture detail — a dedicated tab shows ciphertext, derived plaintext, and success/failure per rule. Day-to-day debugging reads plaintext first.

  2. Mock — matchers can use decrypted features; tamper UI edits plaintext and re-encrypts on continue. Fixes pain point 2 without maintaining Charles scripts.

  3. Resend / debug API drawer — start from the captured request, edit a JSON field as plain text, send through the proxy with auto re-encrypt. Fixes pain point 4.

Builtins cover Base64, AES-ECB/CBC/GCM, DES/3DES, RSA; scripts run sandboxed with helpers for crypto, compression, and HTTP if you need a remote decrypt service.

Core desktop features (proxy, SSL decrypt, param transform, mock, search) are free — no subscription wall on the workflow described here.

I'm curious — what's the most annoying encrypted param debugging workflow you've had to deal with? Feel free to share your pain points below; I may add native support for those cases in future updates.

I've released Windows builds for anyone who wants to test this param transform workflow out — macOS and Linux builds are actively in development based on community feedback.

Docs: Param transform guide

Try it: devpeek.ypgao.com


Troubleshooting cheatsheet

Symptom Likely cause
Body still opaque gibberish HTTPS MITM not set up (cert / decrypt allowlist)
Transform tab empty Rule disabled or URL/body pattern mismatch
builtin_error Key/IV encoding, padding, or algorithm mismatch vs client
Mock never matches plaintext Transform didn't run before mock; check rule order
Field read-only in resend UI Re-encrypt not enabled, or algorithm is one-way (hash/HMAC)

Top comments (0)