Layered Encryption in Flutter: Building a Custom XOR Chaining Cipher Combined with AES

A deep technical guide for Flutter developers who want to understand cryptography from the ground up — not just copy-paste code.


Table of Contents

  1. Why Encryption Matters in Modern Apps
  2. Understanding XOR — The Foundation
  3. The Custom XOR Chaining Cipher Architecture
  4. Step-by-Step Encryption Example: Encrypting “HELLO”
  5. Serializing the Cipher Array
  6. AES Encryption — The Real Security Layer
  7. AES Modes Explained: ECB, CBC, and GCM
  8. Why AES-GCM is the Right Choice for Flutter Apps
  9. Initialization Vectors (IVs) — Everything You Need to Know
  10. Step-by-Step Decryption: Recovering “HELLO”
  11. Best Practices for Encryption in Flutter
  12. Warnings and Critical Security Caveats
  13. Conclusion

1. Why Encryption Matters in Modern Apps

Modern applications are data machines. They collect, transmit, store, and process sensitive information continuously — authentication tokens, personal profiles, financial records, private messages, healthcare data, and API secrets. If any of that data travels unprotected, it becomes trivially exploitable.

The threat model is broader than most developers realize:

Threat Vector Risk Without Encryption
Man-in-the-Middle (MITM) Attacker intercepts plain HTTP traffic and reads everything
Device theft / physical access Local storage is readable if device isn’t encrypted or is rooted
Reverse engineering APK/IPA unpacking exposes hardcoded secrets and logic
Database breach Leaked server DB exposes all user data in plaintext
Malicious SDKs / supply chain Third-party libraries may exfiltrate in-memory data

Real-World Use Cases That Demand Encryption

Secure Chat Applications — Apps like Signal, WhatsApp, and Telegram use end-to-end encryption so that even the server operator cannot read message content. Every message is encrypted before it leaves the device and decrypted only on the recipient’s device.

Authentication Tokens — JWT tokens and API keys stored on-device are high-value targets. An attacker with a valid token can impersonate a user indefinitely. Tokens must be encrypted at rest using hardware-backed storage.

Secure API Communication — HTTPS (TLS) encrypts data in transit, but data at the application layer — inside the request payload — should also be encrypted if it contains sensitive fields like passwords, card numbers, or PII.

User Data Protection (GDPR/CCPA Compliance) — Legal frameworks now require “data protection by design.” Encrypting personally identifiable information (PII) is often a regulatory requirement, not just good practice.

Transformation vs. Cryptographic Security — A Critical Distinction

This is where many developers go wrong. There is a fundamental difference between data transformation and cryptographic security:

  • Transformation (e.g., Base64 encoding, ROT13, simple XOR) is reversible without a key. Anyone who knows the algorithm — which is public — can reverse it instantly. It provides zero security. It just changes the shape of data, not its confidentiality.
  • Cryptographic Security (e.g., AES, ChaCha20, RSA) requires possession of a secret key that an attacker does not have. Even with full knowledge of the algorithm, the data is computationally infeasible to decrypt without the key.

The custom XOR chaining cipher in this article is a transformation layer — it adds structural obfuscation that makes patterns less visible, but it is not the security boundary. The security boundary is AES-GCM. We are building both layers together so you understand exactly what each one contributes.


2. Understanding XOR — The Foundation

What Is XOR?

XOR stands for Exclusive OR. It is a binary bitwise operation that returns 1 when the two input bits are different, and 0 when they are the same.

XOR Truth Table:

A | B | A XOR B
--|---|--------
0 | 0 |   0
0 | 1 |   1
1 | 0 |   1
1 | 1 |   0

In Dart/Flutter:

int result = 72 ^ 12; // XOR operator is ^

Why XOR Is Perfectly Reversible

XOR has a beautiful mathematical property that makes it ideal for symmetric cipher construction:

A XOR B = C
C XOR B = A   ← applying the same key reverses the operation

In other words:

A XOR B XOR B = A

This works because XOR-ing any value with itself produces zero, and XOR-ing any value with zero returns the original value:

B XOR B = 0
A XOR 0 = A

Therefore:

A XOR B XOR B
= A XOR (B XOR B)
= A XOR 0
= A

This is the entire mathematical basis of XOR-based ciphers: apply a key to encrypt, apply the same key again to decrypt.

Binary/XOR Walkthrough with ASCII

Let’s work through a real example. The ASCII value of the character H is 72.

72 in binary  =  0100 1000
12 in binary  =  0000 1100
                 ---------  XOR
Result        =  0100 0100  =  68 in decimal

Let’s verify the reversal:

68 in binary  =  0100 0100
12 in binary  =  0000 1100
                 ---------  XOR
Result        =  0100 1000  =  72 in decimal  ← original 'H' recovered

ASCII Reference Table for Our Example

Character ASCII (Decimal) Binary
H 72 0100 1000
E 69 0100 0101
L 76 0100 1100
L 76 0100 1100
O 79 0100 1111

3. The Custom XOR Chaining Cipher Architecture

Concept Overview

In a naive XOR cipher, every character is XOR’d with the same key (the IV). This is equivalent to a one-time pad with a reused key — a known cryptographic weakness. If an attacker can guess one plaintext character, they can derive the key and break all other characters.

The chaining approach solves this by making each cipher output dependent on the previous cipher output rather than directly on the IV alone. This creates a cascade: a single changed input anywhere in the chain alters all subsequent outputs.

Architecture: How the Pipeline Works

Plaintext
   │
   ▼
┌─────────────────────────────────────────┐
│         CUSTOM XOR CHAINING CIPHER      │
│                                         │
│   Char[0] XOR IV         = Cipher[0]   │
│   Char[1] XOR Cipher[0]  = Cipher[1]   │
│   Char[2] XOR Cipher[1]  = Cipher[2]   │
│   Char[3] XOR Cipher[2]  = Cipher[3]   │
│   Char[4] XOR Cipher[3]  = Cipher[4]   │
└─────────────────────────────────────────┘
   │
   ▼
Cipher Array: [C0, C1, C2, C3, C4]
   │
   ▼
Serialized as String: "C0,C1,C2,C3,C4"
   │
   ▼
┌─────────────────────────────────────────┐
│           AES-GCM ENCRYPTION            │
│   Key: 256-bit secure random key        │
│   IV:  96-bit (12-byte) nonce           │
└─────────────────────────────────────────┘
   │
   ▼
Final Payload: IV (Base64) + ":" + AES Ciphertext (Base64)

Why Chaining Reduces Pattern Visibility

Imagine encrypting the message "AAAA" — four identical characters. With a simple XOR cipher (each character XOR’d with the same IV), you get four identical cipher values. A frequency analyst can immediately see that the original characters were identical.

With chaining:

A XOR IV   = C1  (some value)
A XOR C1   = C2  (different value — C1 ≠ IV)
A XOR C2   = C3  (different again)
A XOR C3   = C4  (different again)

All four cipher values are different even though the plaintext was identical. The cipher array [C1, C2, C3, C4] reveals nothing about the original pattern.

This is conceptually similar to how CBC mode works in block ciphers — each block is XOR’d with the previous ciphertext block before encryption.


4. Step-by-Step Encryption Example: Encrypting “HELLO”

Let’s walk through the complete encryption of the string "HELLO" with full arithmetic at every step.

Setup: ASCII Values

H = 72
E = 69
L = 76
L = 76
O = 79

Step 0: Generate the IV

For this walkthrough we will use a fixed IV of 1 so the calculations are fully reproducible. In production, the IV must be cryptographically random (see Section 9).

IV = 1

Step 1: Encrypt H → Cipher1

Cipher1 = ASCII(H) XOR IV
Cipher1 = 72 XOR 1

72 in binary:  0100 1000
 1 in binary:  0000 0001
               ---------  XOR
Result:        0100 1001  = 73

Cipher1 = 73

Step 2: Encrypt E → Cipher2

The previous cipher output (Cipher1 = 73) is the XOR key for this step, not the IV.

Cipher2 = ASCII(E) XOR Cipher1
Cipher2 = 69 XOR 73

69 in binary:  0100 0101
73 in binary:  0100 1001
               ---------  XOR
Result:        0000 1100  = 12

Cipher2 = 12

Step 3: Encrypt L → Cipher3

Cipher3 = ASCII(L) XOR Cipher2
Cipher3 = 76 XOR 12

76 in binary:  0100 1100
12 in binary:  0000 1100
               ---------  XOR
Result:        0100 0000  = 64

Cipher3 = 64

Step 4: Encrypt L → Cipher4

Note: this is the second L in HELLO. Even though the character is identical to the previous L, the cipher value will be completely different because we XOR with Cipher3, not Cipher2.

Cipher4 = ASCII(L) XOR Cipher3
Cipher4 = 76 XOR 64

76 in binary:  0100 1100
64 in binary:  0100 0000
               ---------  XOR
Result:        0000 1100  = 12

Cipher4 = 12

Step 5: Encrypt O → Cipher5

Cipher5 = ASCII(O) XOR Cipher4
Cipher5 = 79 XOR 12

79 in binary:  0100 1111
12 in binary:  0000 1100
               ---------  XOR
Result:        0100 0011  = 67

Cipher5 = 67

Visual Flow Diagram

Plaintext:  H    E    L    L    O
ASCII:      72   69   76   76   79

IV = 1
│
├── 72 XOR 1  = 73   →  Cipher1
│
├── 69 XOR 73 = 12   →  Cipher2
│
├── 76 XOR 12 = 64   →  Cipher3
│
├── 76 XOR 64 = 12   →  Cipher4
│
└── 79 XOR 12 = 67   →  Cipher5

Cipher Array: [1, 73, 12, 64, 12, 67]
               ↑
               IV is prepended to the array

Summary Table

Step Char ASCII XOR Key (prev cipher) Cipher Value
IV 1
1 H 72 IV = 1 73
2 E 69 Cipher1 = 73 12
3 L 76 Cipher2 = 12 64
4 L 76 Cipher3 = 64 12
5 O 79 Cipher4 = 12 67

Final cipher array (with IV prepended): [1, 73, 12, 64, 12, 67]

Dart Implementation

List<int> xorChainEncrypt(String plaintext, int iv) {
  final List<int> cipherArray = [iv]; // IV is always the first element
  int previousCipher = iv;

  for (int i = 0; i < plaintext.length; i++) {
    final int asciiValue = plaintext.codeUnitAt(i);
    final int cipher = asciiValue ^ previousCipher;
    cipherArray.add(cipher);
    previousCipher = cipher;
  }

  return cipherArray; // e.g., [1, 73, 12, 64, 12, 67]
}

5. Serializing the Cipher Array

Once we have the cipher array [1, 73, 12, 64, 12, 67], we need to pass it to AES encryption. AES operates on bytes — specifically, it takes a byte array (or a string that we convert to bytes).

Why AES Encrypts the Entire Array, Not Each Cipher Separately

This is a critical design choice. We serialize the entire cipher array as a single string and pass that entire string to AES as one operation.

Cipher array: [1, 73, 12, 64, 12, 67]
               ↓
Serialized:   "1,73,12,64,12,67"
               ↓
Convert to bytes (UTF-8):  [49, 44, 55, 51, 44, 49, 50, 44, 54, 52, 44, 49, 50, 44, 54, 55]
               ↓
AES-GCM encrypts the entire byte sequence as one block
               ↓
AES Ciphertext (opaque, authenticated blob)

If we encrypted each cipher value individually, the AES output would be N separate blobs — and an attacker could analyze their lengths, patterns, and relative ordering. Encrypting the serialized array as a single unit makes the entire structure opaque.

String serializeCipherArray(List<int> cipherArray) {
  return cipherArray.join(',');
  // [1, 73, 12, 64, 12, 67] → "1,73,12,64,12,67"
}

List<int> deserializeCipherArray(String serialized) {
  return serialized.split(',').map(int.parse).toList();
  // "1,73,12,64,12,67" → [1, 73, 12, 64, 12, 67]
}

The Complete Encryption Pipeline in Dart

import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:pointycastle/export.dart';

/// Full encryption pipeline:
/// Plaintext → XOR chain → Serialize → AES-GCM → Base64 payload
String encryptPayload(String plaintext, Uint8List aesKey) {
  // Step 1: Generate a random IV for XOR chaining
  final int xorIV = Random.secure().nextInt(255) + 1; // 1–255, never 0

  // Step 2: XOR chain encryption
  final List<int> cipherArray = xorChainEncrypt(plaintext, xorIV);

  // Step 3: Serialize cipher array to string
  final String serialized = serializeCipherArray(cipherArray);

  // Step 4: Convert to bytes
  final Uint8List plaintextBytes = Uint8List.fromList(utf8.encode(serialized));

  // Step 5: Generate AES-GCM IV (nonce) — 12 bytes
  final Uint8List gcmIV = _generateSecureRandom(12);

  // Step 6: AES-GCM encrypt
  final Uint8List ciphertext = aesGcmEncrypt(plaintextBytes, aesKey, gcmIV);

  // Step 7: Encode both IV and ciphertext as Base64 and join
  final String ivBase64 = base64Encode(gcmIV);
  final String ciphertextBase64 = base64Encode(ciphertext);

  return '$ivBase64:$ciphertextBase64';
}

Uint8List _generateSecureRandom(int length) {
  final rng = Random.secure();
  return Uint8List.fromList(List.generate(length, (_) => rng.nextInt(256)));
}

6. AES Encryption — The Real Security Layer

What Is AES?

AES (Advanced Encryption Standard) is a symmetric-key block cipher standardized by NIST in 2001. It replaced the aging DES (Data Encryption Standard) and has since become the de facto standard for symmetric encryption globally. It is used in TLS, WPA2/WPA3 Wi-Fi, disk encryption (BitLocker, FileVault), and virtually every modern secure system.

AES is a block cipher — it operates on fixed-size blocks of data (128 bits = 16 bytes at a time). It processes each block through multiple rounds of substitution, permutation, and mixing operations using a secret key.

Key Size Rounds Security Level
AES-128 10 128-bit security
AES-192 12 192-bit security
AES-256 14 256-bit security

For mobile apps, AES-256 is the recommended key size. The 256-bit key makes brute-force attacks computationally infeasible with any current or foreseeable technology.

Why AES Is the Actual Security Layer

Our XOR chaining cipher provides structural obfuscation — it breaks plaintext patterns. But given the cipher array and knowledge of the algorithm, an attacker could reverse it without needing a key (because the IV is embedded in the array itself).

AES is fundamentally different:

  • No key = no decryption. Without the 256-bit key, recovering the plaintext from AES ciphertext requires breaking a problem that would take longer than the age of the universe with current computing power.
  • The algorithm is public; the security comes entirely from the key. This is Kerckhoffs’s Principle — a properly designed cipher should be secure even if everything about the system, except the key, is public knowledge.
  • AES-GCM additionally provides authentication — an attacker cannot tamper with the ciphertext without detection.

The Difference Between AES Algorithm and AES Modes

AES the algorithm defines how a single 128-bit block is encrypted. But real-world messages are almost always longer than 16 bytes. AES modes define how multiple blocks are chained together.

The mode you choose is not a minor implementation detail — it fundamentally determines your security guarantees. Choosing the wrong mode can make AES completely insecure even with a perfect key.


7. AES Modes Explained: ECB, CBC, and GCM

AES-ECB (Electronic Codebook Mode)

How It Works

In ECB mode, each 16-byte block of plaintext is encrypted independently with the same key.

Block 1: Plaintext1 + Key → Ciphertext1
Block 2: Plaintext2 + Key → Ciphertext2
Block 3: Plaintext3 + Key → Ciphertext3

The Fatal Weakness

Because identical plaintext blocks produce identical ciphertext blocks, patterns in the plaintext survive into the ciphertext. The famous “ECB penguin” demonstrates this: encrypting an image of a penguin in ECB mode produces a ciphertext that is still clearly recognizable as a penguin because large uniform areas (the background, the penguin’s body) produce uniform ciphertext blocks.

Plaintext:   [BLOCK A][BLOCK A][BLOCK B][BLOCK A]
             identical           different identical

ECB output:  [CRYPT_A][CRYPT_A][CRYPT_B][CRYPT_A]
             identical           different identical
                   ↑ attacker can see which blocks match

Verdict

Never use AES-ECB in production. It provides confidentiality in theory but fails catastrophically when encrypting structured or repetitive data. No IV is used (or needed), which is itself a red flag.


AES-CBC (Cipher Block Chaining Mode)

How It Works

CBC solves ECB’s pattern problem by XOR-ing each plaintext block with the previous ciphertext block before encrypting. The first block uses a random Initialization Vector (IV) in place of a “previous block.”

IV ─────────────────────────────────────────────────────────────┐
      │                                                          │
      ▼                                                          │
Plaintext1 ─→ [XOR] ─→ [AES Encrypt Key] ─→ Ciphertext1 ─────┐ │
                                                               │ │
                                              ┌────────────────┘ │
                                              ↓                  │
Plaintext2 ─→ [XOR] ─→ [AES Encrypt Key] ─→ Ciphertext2 ─────┐ │
                                                               │ │
                                              ...              │ │

Because each block depends on all previous ciphertext blocks, identical plaintext blocks produce different ciphertext.

Advantages

  • Eliminates ECB’s pattern vulnerability
  • Widely supported and well-understood
  • Requires and uses an IV, so encrypting the same message twice produces different ciphertext

Weaknesses

  • No authentication — CBC is unauthenticated. An attacker can flip bits in the ciphertext and the decryption will succeed (producing corrupted plaintext), but you will have no idea the data was tampered with. This leads to padding oracle attacks and bit-flipping attacks.
  • Padding required — Block ciphers require input to be a multiple of 16 bytes. CBC requires explicit padding (e.g., PKCS7), which introduces its own attack surface.
  • Decryption is sequential, not parallelizable.

Verdict

⚠️ CBC is acceptable when used carefully, but it is considered outdated for new implementations. Always pair it with a separate HMAC for message authentication if you must use it. In 2026, there is rarely a good reason to prefer CBC over GCM.


AES-GCM (Galois/Counter Mode)

How It Works

GCM is a combination of two components:

  1. CTR (Counter) Mode for encryption — a keystream is generated by encrypting a counter value, then XOR’d with plaintext. This converts AES into a stream cipher.
  2. GHASH — a Galois Field multiplication-based authentication function that produces a 128-bit authentication tag (MAC) over the ciphertext.
     Nonce (IV) + Counter
           │
           ▼
     [AES Encrypt Key]
           │
           ▼
       Keystream
           │
           XOR
           │
           ▼
       Ciphertext ──────────────────────────────┐
                                                 ▼
                                           [GHASH function]
                                                 │
                                                 ▼
                                          Auth Tag (16 bytes)

Final output: IV + Ciphertext + Auth Tag

Advantages

  • Authenticated Encryption with Associated Data (AEAD) — encryption and authentication in a single operation.
  • Tamper detection — any modification to the ciphertext, IV, or associated data causes authentication verification to fail. The attacker cannot modify the encrypted payload without detection.
  • No padding — CTR mode is a stream cipher, so arbitrary-length data is handled without padding.
  • Parallelizable — counter mode blocks are independent, enabling hardware-accelerated encryption on modern CPUs and mobile SoCs.
  • Modern standard — recommended by NIST, used in TLS 1.3, QUIC, and all modern secure protocols.

Weaknesses

  • IV reuse is catastrophic. If the same (key, IV) pair is ever used twice, an attacker can XOR the two ciphertexts together to cancel out the keystream and recover XOR of both plaintexts — completely breaking confidentiality. Always generate a fresh random IV for every encryption operation.
  • Tag truncation — some implementations allow truncating the auth tag below 128 bits. Never do this for security-sensitive applications.

Verdict

AES-GCM is the recommended mode for Flutter applications. It provides both confidentiality and integrity in a single, well-audited construction.


Mode Comparison Summary

Feature ECB CBC GCM
Pattern hiding ❌ Fails ✅ Yes ✅ Yes
Authentication / tamper detection ❌ No ❌ No ✅ Yes
Requires IV ❌ No ✅ Yes ✅ Yes (nonce)
Padding required ✅ Yes ✅ Yes ❌ No
Parallelizable ✅ Enc + Dec ❌ Enc sequential ✅ Both
Recommended for new apps Never ⚠️ Legacy only ✅ Recommended
NIST / TLS 1.3 standard

8. Why AES-GCM is the Right Choice for Flutter Apps

Flutter apps run on mobile devices — Android and iOS — which are exposed to a unique set of threats compared to server environments:

Integrity protection at the storage layer — When you store an AES-GCM encrypted blob in flutter_secure_storage, the auth tag guarantees that even if some other process on the device modifies the stored bytes, you will detect the tampering on read.

Authenticated encryption eliminates a class of attacks — CBC without HMAC is vulnerable to padding oracle attacks. An attacker who can observe whether your decryption succeeds or fails can recover the plaintext byte by byte. GCM’s integrated auth tag eliminates this entire attack class.

Tamper detection for tokens and session data — If a user is rooted or using a compromised device, an attacker might modify stored session tokens to escalate privileges. GCM authentication makes this detectable.

Hardware acceleration on modern devices — Both Apple Silicon (A-series chips) and ARM-based Android SoCs include AES hardware acceleration. GCM’s CTR mode is designed to leverage this. Encrypting and decrypting on mobile is fast enough to be imperceptible.

Modern standard with long-term support — GCM is part of TLS 1.3, QUIC, and NIST SP 800-38D. It will remain the standard for the foreseeable future.

// AES-GCM implementation using PointyCastle
import 'package:pointycastle/export.dart';
import 'dart:typed_data';

Uint8List aesGcmEncrypt(
  Uint8List plaintext,
  Uint8List key,      // Must be 32 bytes for AES-256
  Uint8List nonce,    // Must be 12 bytes for GCM
) {
  final cipher = GCMBlockCipher(AESEngine());
  final params = AEADParameters(
    KeyParameter(key),
    128, // auth tag length in bits (always 128)
    nonce,
    Uint8List(0), // additional authenticated data (empty here)
  );

  cipher.init(true, params); // true = encrypt
  return cipher.process(plaintext);
  // Output: ciphertext + 16-byte auth tag appended
}

Uint8List aesGcmDecrypt(
  Uint8List ciphertext, // includes the 16-byte auth tag at the end
  Uint8List key,
  Uint8List nonce,
) {
  final cipher = GCMBlockCipher(AESEngine());
  final params = AEADParameters(
    KeyParameter(key),
    128,
    nonce,
    Uint8List(0),
  );

  cipher.init(false, params); // false = decrypt
  // Throws InvalidCipherTextException if auth tag verification fails
  return cipher.process(ciphertext);
}

9. Initialization Vectors (IVs) — Everything You Need to Know

What Is an IV?

An Initialization Vector (IV) — also called a nonce in GCM context — is a random value used to ensure that encrypting the same message twice produces different ciphertext outputs.

Without an IV, a deterministic cipher would always map "Hello" → the same ciphertext for a given key. An attacker observing multiple messages could detect repetition and learn information about the plaintext without ever breaking the key.

Why the IV Is Not a Secret

The IV is not a key — it does not need to be kept secret. Its sole purpose is to be unique for each encryption operation. The IV can be sent in plaintext alongside the ciphertext.

This is standard practice: in the payload format IV:Ciphertext, the IV is exposed. This is correct and expected. The security comes from the key, not the IV.

Why the IV Must Be Random

The IV must be generated using a cryptographically secure random number generator (CSPRNG), not Random(). Dart’s Random() is a pseudo-random number generator seeded from the system clock — predictable under analysis. Random.secure() uses the OS-level CSPRNG (e.g., /dev/urandom on Linux/Android, SecRandomCopyBytes on iOS).

// WRONG — do not use this for cryptographic IVs
final iv = Uint8List.fromList(List.generate(12, (_) => Random().nextInt(256)));

// CORRECT — cryptographically secure
final iv = Uint8List.fromList(List.generate(12, (_) => Random.secure().nextInt(256)));

For AES-GCM, the nonce must be exactly 12 bytes (96 bits). Never reuse a (key, nonce) pair.

Why the IV Is Sent with the Payload

Decryption requires the same IV that was used during encryption. Since the IV is not secret, transmitting it alongside the ciphertext is safe and necessary.

Encrypted payload format:
┌────────────────┬───┬────────────────────────────────┐
│  IV (12 bytes) │ : │  AES-GCM Ciphertext + Auth Tag │
│  Base64 encoded│   │  Base64 encoded                │
└────────────────┴───┴────────────────────────────────┘

Example:
"dGhpcyBpcyBhIHRlc3Q=:ZW5jcnlwdGVkIGRhdGEgaGVyZQ=="

IV Size Requirements by Mode

Mode IV Size Notes
CBC 16 bytes Must match block size
GCM 12 bytes 96-bit nonce is strongly recommended by NIST
CTR 16 bytes Counter is part of the block

10. Step-by-Step Decryption: Recovering “HELLO”

Decryption is the complete reverse of encryption. We start with the AES-GCM ciphertext and work backwards through the pipeline.

Decryption Flow

Encrypted Payload: "IV_B64:CIPHERTEXT_B64"
         │
         ▼
    Split on ":"
    ├── Decode IV from Base64    → 12-byte GCM nonce
    └── Decode ciphertext from Base64 → AES-GCM ciphertext
         │
         ▼
    AES-GCM Decrypt (using key + nonce)
    → Verifies auth tag (throws if tampered)
    → Returns plaintext bytes
         │
         ▼
    UTF-8 decode → "1,73,12,64,12,67"
         │
         ▼
    Split on "," → [1, 73, 12, 64, 12, 67]
    [0] = IV = 1
    [1..N] = Cipher values
         │
         ▼
    Reverse XOR Chaining
    → Recover original ASCII values
    → Decode as UTF-8 string
         │
         ▼
    Plaintext: "HELLO"

Reverse XOR Chaining — Full Arithmetic

We have the cipher array: [1, 73, 12, 64, 12, 67]

  • cipherArray[0] = IV = 1
  • cipherArray[1] = Cipher1 = 73
  • cipherArray[2] = Cipher2 = 12
  • cipherArray[3] = Cipher3 = 64
  • cipherArray[4] = Cipher4 = 12
  • cipherArray[5] = Cipher5 = 67

Recall the encryption formula:

Cipher[i] = ASCII[i] XOR Cipher[i-1]

Therefore the decryption formula is:

ASCII[i] = Cipher[i] XOR Cipher[i-1]

Recover H (index 1)

ASCII[0] = Cipher[1] XOR IV
ASCII[0] = 73 XOR 1

73 in binary:  0100 1001
 1 in binary:  0000 0001
               ---------  XOR
Result:        0100 1000  = 72  →  ASCII 72 = 'H' ✓

Recover E (index 2)

ASCII[1] = Cipher[2] XOR Cipher[1]
ASCII[1] = 12 XOR 73

12 in binary:  0000 1100
73 in binary:  0100 1001
               ---------  XOR
Result:        0100 0101  = 69  →  ASCII 69 = 'E' ✓

Recover L (index 3)

ASCII[2] = Cipher[3] XOR Cipher[2]
ASCII[2] = 64 XOR 12

64 in binary:  0100 0000
12 in binary:  0000 1100
               ---------  XOR
Result:        0100 1100  = 76  →  ASCII 76 = 'L' ✓

Recover L (index 4)

ASCII[3] = Cipher[4] XOR Cipher[3]
ASCII[3] = 12 XOR 64

12 in binary:  0000 1100
64 in binary:  0100 0000
               ---------  XOR
Result:        0100 1100  = 76  →  ASCII 76 = 'L' ✓

Recover O (index 5)

ASCII[4] = Cipher[5] XOR Cipher[4]
ASCII[4] = 67 XOR 12

67 in binary:  0100 0011
12 in binary:  0000 1100
               ---------  XOR
Result:        0100 1111  = 79  →  ASCII 79 = 'O' ✓

Recovery Summary Table

Index Cipher XOR Key Result (ASCII) Character
1 73 IV=1 72 H
2 12 C1=73 69 E
3 64 C2=12 76 L
4 12 C3=64 76 L
5 67 C4=12 79 O

Original plaintext fully recovered: "HELLO"

Dart Implementation of Decryption

import 'dart:convert';
import 'dart:typed_data';

/// Full decryption pipeline:
/// Base64 payload → AES-GCM decrypt → deserialize → XOR reverse → plaintext
String decryptPayload(String payload, Uint8List aesKey) {
  // Step 1: Split IV and ciphertext
  final parts = payload.split(':');
  if (parts.length != 2) throw FormatException('Invalid payload format');

  final Uint8List gcmIV = base64Decode(parts[0]);
  final Uint8List ciphertext = base64Decode(parts[1]);

  // Step 2: AES-GCM decrypt (throws if auth tag fails)
  final Uint8List decryptedBytes = aesGcmDecrypt(ciphertext, aesKey, gcmIV);

  // Step 3: UTF-8 decode → serialized cipher array string
  final String serialized = utf8.decode(decryptedBytes);

  // Step 4: Deserialize → [1, 73, 12, 64, 12, 67]
  final List<int> cipherArray = deserializeCipherArray(serialized);

  // Step 5: Reverse XOR chaining
  return xorChainDecrypt(cipherArray);
}

String xorChainDecrypt(List<int> cipherArray) {
  // cipherArray[0] is the IV; cipherArray[1..N] are the cipher values
  final StringBuffer buffer = StringBuffer();
  final int iv = cipherArray[0];

  for (int i = 1; i < cipherArray.length; i++) {
    final int xorKey = (i == 1) ? iv : cipherArray[i - 1];
    final int asciiValue = cipherArray[i] ^ xorKey;
    buffer.writeCharCode(asciiValue);
  }

  return buffer.toString(); // "HELLO"
}

11. Best Practices for Encryption in Flutter

Implementing encryption correctly requires more than just the right algorithm. The security of your entire system is only as strong as its weakest link — and that link is almost always key management.

Use flutter_secure_storage for Key Storage

Never store encryption keys in SharedPreferences. On Android, SharedPreferences stores data in a plain XML file that is trivially readable on rooted devices and accessible to anyone who can read the app’s data partition.

flutter_secure_storage uses:

  • Android: Android Keystore System — keys are stored in hardware-backed security (TEE or StrongBox) and never leave the secure enclave
  • iOS: Keychain Services — hardware-backed, protected by device passcode and Secure Enclave
# pubspec.yaml
dependencies:
  flutter_secure_storage: ^9.0.0
  pointycastle: ^3.7.4
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

const storage = FlutterSecureStorage(
  aOptions: AndroidOptions(encryptedSharedPreferences: true),
  iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);

// Store the AES key (Base64-encoded)
Future<void> saveKey(Uint8List key) async {
  await storage.write(key: 'aes_master_key', value: base64Encode(key));
}

// Retrieve the AES key
Future<Uint8List> loadKey() async {
  final String? keyBase64 = await storage.read(key: 'aes_master_key');
  if (keyBase64 == null) throw StateError('Key not found in secure storage');
  return base64Decode(keyBase64);
}

Never Hardcode Keys

This is the single most common catastrophic mistake in mobile app security. Hardcoded keys in source code will be extracted by:

  • APK/IPA reverse engineering with apktool, jadx, or class-dump
  • Static analysis tools scanning for base64 strings
  • Memory dumps on rooted/jailbroken devices
//  NEVER DO THIS
const String aesKey = "MySecretKey12345"; // Exposed in APK
const String aesKey = "dGhpcyBpcyBhIGJhZCBrZXk="; // Base64 is still hardcoded

// ✅ CORRECT: Generate on first launch, store in secure storage
Future<Uint8List> getOrCreateKey() async {
  const storage = FlutterSecureStorage();
  final String? existing = await storage.read(key: 'aes_master_key');

  if (existing != null) {
    return base64Decode(existing);
  }

  // Generate a new 256-bit key
  final Uint8List newKey = Uint8List.fromList(
    List.generate(32, (_) => Random.secure().nextInt(256)),
  );
  await storage.write(key: 'aes_master_key', value: base64Encode(newKey));
  return newKey;
}

Generate Random IVs Every Time

// ✅ Always generate a fresh IV per encryption operation
Uint8List generateNonce() {
  return Uint8List.fromList(
    List.generate(12, (_) => Random.secure().nextInt(256)),
  );
}

Avoid SharedPreferences for Any Sensitive Data

//  Never store tokens, keys, or user PII here
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.setString('auth_token', token); // Readable on rooted devices

// ✅ Store sensitive data only in flutter_secure_storage
await storage.write(key: 'auth_token', value: token);

Rooted Device Risks

On rooted Android devices and jailbroken iOS devices, the OS security boundaries are significantly weakened:

  • The Android Keystore may fall back to software-backed storage
  • App sandboxing can be bypassed
  • flutter_secure_storage can be accessed by root-privileged processes
  • Memory can be dumped and searched for decrypted key material

Mitigation strategies:

import 'package:flutter_jailbreak_detection/flutter_jailbreak_detection.dart';

Future<void> checkDeviceSecurity() async {
  bool isJailbroken = await FlutterJailbreakDetection.jailbroken;
  bool developerMode = await FlutterJailbreakDetection.developerMode;

  if (isJailbroken) {
    // At minimum, warn the user
    // For high-security apps, consider refusing to run
    showSecurityWarningDialog();
  }
}

Reverse Engineering Risks and Code Obfuscation

Dart/Flutter apps can be partially reverse engineered using tools like blutter. While native Dart compilation makes this harder than JVM-based apps, string constants, symbol names, and logic flow remain visible.

Enable obfuscation in your release build:

flutter build apk --release --obfuscate --split-debug-info=build/debug-info
flutter build ipa --release --obfuscate --split-debug-info=build/debug-info

This strips symbol names from the compiled binary. The debug info files should be stored securely for crash symbolication — never shipped with the app.

Complete Security Checklist

Key Management:
  ✅ Keys generated using Random.secure()
  ✅ Keys stored in flutter_secure_storage (hardware-backed)
  ✅ No hardcoded keys in source code
  ✅ No keys in environment variables or config files shipped with app
  ✅ Keys rotated if compromise is suspected

IV/Nonce Management:
  ✅ 12-byte nonce for AES-GCM
  ✅ Fresh nonce generated per encryption operation
  ✅ Nonce transmitted with ciphertext (not secret)
  ✅ Never reuse (key, nonce) pair

Algorithm Choices:
  ✅ AES-256-GCM for symmetric encryption
  ✅ Never AES-ECB
  ✅ 128-bit authentication tag (never truncated)

Build / Deployment:
  ✅ Code obfuscation enabled for release builds
  ✅ Certificate pinning for network calls
  ✅ Root/jailbreak detection where appropriate

12. Warnings and Critical Security Caveats

⚠️ Custom Crypto Alone Is Unsafe

The XOR chaining cipher is a home-built construction. Home-built cryptography is almost universally weaker than it appears to its creator. Without formal cryptanalysis, peer review, and adversarial testing, custom ciphers should be treated as zero-security obfuscation, not as security primitives.

Security researchers have a saying: “Don’t roll your own crypto.” This applies directly to the XOR layer in our implementation. If you removed AES-GCM and shipped only the XOR chaining cipher, a competent attacker could recover plaintext from the cipher array in minutes.

The XOR layer in this article serves a pedagogical purpose — to demonstrate chaining, IV propagation, and pattern disruption. In production, its contribution to security is marginal. AES-GCM is your security.

⚠️ AES Is the Real Security Layer

Every security guarantee in this architecture comes from AES-GCM:

  • Confidentiality — only the key holder can decrypt
  • Integrity — the auth tag detects any tampering
  • Authenticity — tied to key possession

If you compromise the AES key, the entire system collapses. The XOR cipher offers no additional protection once AES is broken.

⚠️ Security Depends Heavily on Key Management

The most expertly implemented AES-GCM is completely useless if:

  • The key is hardcoded and extracted from the APK
  • The key is stored in SharedPreferences and read by a root-privileged attacker
  • The key is derived from a predictable source (user’s username, device ID)
  • The key is transmitted over an insecure channel at any point

Key management is harder than the cryptography itself. This is why systems like HSMs (Hardware Security Modules), Android Keystore, and iOS Secure Enclave exist — to enforce that keys never leave secure hardware in plaintext form.

⚠️ Nonce Reuse in GCM Is a Critical Vulnerability

In AES-GCM, reusing the same (key, nonce) pair for two different messages completely breaks confidentiality:

Ciphertext1 = Plaintext1 XOR Keystream(key, nonce)
Ciphertext2 = Plaintext2 XOR Keystream(key, nonce)

Ciphertext1 XOR Ciphertext2 = Plaintext1 XOR Plaintext2

An attacker who observes both ciphertexts can XOR them together to obtain the XOR of both plaintexts — from which both plaintexts can often be recovered using frequency analysis or known-plaintext techniques.

Always generate a fresh 12-byte nonce using Random.secure() for every single encryption operation.


13. Conclusion

We have built a complete, layered encryption pipeline for Flutter — from the binary foundations of XOR arithmetic through custom chaining cipher construction, all the way up to authenticated AES-GCM encryption. Let’s consolidate what each layer contributes:

Layer 1: XOR Chaining Cipher
─────────────────────────────
Purpose:     Structural obfuscation
Contribution: Disrupts plaintext patterns before AES
Security:    Zero — breakable without a key
Analogy:     Scrambling letters in an envelope before sealing it

Layer 2: AES-256-GCM
─────────────────────────────
Purpose:     Cryptographic security + authentication
Contribution: Provides actual confidentiality and tamper detection
Security:    Computationally infeasible to break without key
Analogy:     A bank vault that also detects if someone tried to pick the lock

Combined:
─────────────────────────────
What an attacker sees:    An authenticated, opaque ciphertext blob
What an attacker needs:   A 256-bit key stored in hardware-backed secure storage

Layered security is not about redundancy for its own sake — it is about defense in depth. Each layer addresses different aspects of the threat model. The XOR layer makes the data structurally complex before AES processes it. AES-GCM makes it cryptographically inaccessible and tamper-evident.

But the most important lesson in this entire article is one that does not appear in any code block: encryption is only as strong as its key management. You can implement AES-256-GCM perfectly, use cryptographically secure nonces, and build a flawless chaining cipher — and still have zero security if your key is hardcoded in the APK or stored in a plain text file.

Modern Flutter development gives you powerful tools — hardware-backed flutter_secure_storage, the pointycastle library for AES-GCM, and Random.secure() for nonce generation. The cryptographic primitives are mature and battle-tested. Your job as an engineer is to compose them correctly, store keys securely, and never underestimate the creativity of a motivated attacker.

Cryptography is not a feature you sprinkle on top of a finished app. It is a design discipline that starts at the architecture stage and permeates every decision about where data lives, how it moves, and who can access it.

Build with that mindset, and your Flutter applications will stand up to the scrutiny they deserve.


References and Further Reading

  • NIST SP 800-38D — Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode (GCM)
  • RFC 5288 — AES Galois Counter Mode (GCM) Cipher Suites for TLS
  • Flutter Secure Storage Package: https://pub.dev/packages/flutter_secure_storage
  • PointyCastle Dart Library: https://pub.dev/packages/pointycastle
  • Android Keystore System: https://developer.android.com/training/articles/keystore
  • iOS Secure Enclave: https://developer.apple.com/documentation/security/certificate_key_and_trust_services/keys/storing_keys_in_the_secure_enclave
  • Kerckhoffs’s Principle — Claude Shannon, “Communication Theory of Secrecy Systems” (1949)

You may also like

Leave a Reply