Mulesoft-compatible AES-128 CBC encryption/decryption tool.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-05 22:26:44 -04:00
src Initial commit. 2026-08-05 22:07:43 -04:00
tests Fix hardcoded version string in tests. Added README.md file. 2026-08-05 22:26:44 -04:00
.gitignore Ingore CLAUDE.md file. 2026-08-05 22:11:38 -04:00
Cargo.lock Initial commit. 2026-08-05 22:07:43 -04:00
Cargo.toml Initial commit. 2026-08-05 22:07:43 -04:00
README.md Fix hardcoded version string in tests. Added README.md file. 2026-08-05 22:26:44 -04:00

mule_aes

A small, dependency-light command-line tool (and Rust library) for encrypting and decrypting strings in the format used by MuleSoft's Secure Configuration Properties module: AES-128-CBC with PKCS#7 padding, base64-encoded.

The point of this tool is interoperability. Values it produces can be pasted into a Mule application's secure properties, and values already encrypted by MuleSoft can be read back here — byte for byte, in both directions.

Contents

Installation

Requires Rust 1.85 or newer (the crate uses edition 2024).

git clone <this-repo>
cd mule-aes-rust
cargo build --release

The binary lands at target/release/mule_aes. Copy it onto your PATH if you want it available everywhere:

install -m 0755 target/release/mule_aes ~/.local/bin/

Or install straight from a checkout with Cargo:

cargo install --path .

Quick start

# Encrypt (salted — output differs every run)
$ mule_aes -k 0123456789abcdef -e 'hello world'
ZGo592Ib1gGOujqTwNjvg6Dk/m43Zz/OlUdH8tzwYno=

# Decrypt it back
$ mule_aes -k 0123456789abcdef -d 'ZGo592Ib1gGOujqTwNjvg6Dk/m43Zz/OlUdH8tzwYno='
hello world

# Encrypt unsalted (deterministic — same input always gives the same output)
$ mule_aes -k 0123456789abcdef -n -e 'hello world'
wem0Upqsl5MBD0Z39jWO/g==

Keeping the key out of your shell history and out of ps output:

$ read -rs MULE_AES_KEY && export MULE_AES_KEY
$ mule_aes -e 'hello world'

Usage

MuleSoft compatible AES encryption/decryption tool

Usage: mule_aes [OPTIONS] --key <KEY> <--encrypt|--decrypt> <INPUT>

Arguments:
  <INPUT>  String to encrypt, or base64 blob to decrypt

Options:
  -e, --encrypt    Encrypt the input string
  -d, --decrypt    Decrypt the input base64 string
  -k, --key <KEY>  Encryption key; must be exactly 16 bytes [env: MULE_AES_KEY]
  -n, --no-salt    Use the key as the IV instead of prepending a random salt
  -h, --help       Print help
  -V, --version    Print version

Notes on behaviour that isn't obvious from the help text:

  • Exactly one of -e / -d is required; supplying both is an error.
  • INPUT is a positional argument, not stdin. Quote it so the shell doesn't mangle base64 padding (=) or shell metacharacters in your plaintext.
  • On decrypt, surrounding whitespace is trimmed, so a blob piped in with a trailing newline works via $(...) command substitution.
  • Output is written to stdout with a trailing newline; errors go to stderr.
  • Encrypting an empty string is valid and round-trips back to an empty string.

Keys

The key must be exactly 16 bytes (AES-128). It is supplied either with -k/--key or through the MULE_AES_KEY environment variable; the flag wins if both are present.

Length is measured in bytes, not characters, which matches how MuleSoft treats the key. This matters for non-ASCII keys:

# 15 characters but 16 bytes ('é' is two bytes in UTF-8) — accepted
$ mule_aes -k 'café0123456789a' -n -e hi

# 16 characters but 17 bytes — rejected
$ mule_aes -k 'café0123456789ab' -n -e hi
error: key must be exactly 16 bytes, got 17

The key is never printed. --help hides the value of MULE_AES_KEY, and the internal Key type renders as Key(<redacted>) in any debug output, so it cannot leak through a log line or a panic message.

Salted vs unsalted

The two modes are different wire formats. Both sides of a round trip must agree — a blob written in one mode will not read back in the other.

Salted (default) Unsalted (-n, --no-salt)
IV 16 random bytes, fresh per call The key itself
Output base64(iv ‖ ciphertext) base64(ciphertext)
Deterministic? No — differs every run Yes — identical input, identical out
Overhead 16 bytes none

Prefer salted. A fresh random IV per message is what stops an observer from telling that two encrypted properties hold the same value, or that two values share a prefix. Unsalted mode reuses a fixed IV (the key) for every message under that key and gives that away; it exists for compatibility with data already encrypted that way, and because its determinism is what makes byte-for-byte regression testing possible.

Wire format

Both modes are AES-128-CBC with PKCS#7 padding, then standard base64 (with = padding).

Salted:    base64( IV[16] ‖ CBC-AES128(key, IV, PKCS#7(plaintext)) )
Unsalted:  base64(          CBC-AES128(key, key, PKCS#7(plaintext)) )

Because PKCS#7 always adds padding, a block-aligned plaintext gains a full extra block: a 16-byte plaintext encrypts to 32 bytes of ciphertext (48 bytes salted, before base64).

Minimum decodable lengths are therefore 32 bytes salted (IV plus one block) and 16 bytes unsalted; anything shorter is rejected before decryption is attempted.

Using this with MuleSoft

Mule applications reference encrypted property values with the ![...] placeholder syntax, for example:

db.password=![wem0Upqsl5MBD0Z39jWO/g==]

Paste the tool's output between the brackets. For the value to decrypt inside Mule, three things must line up with the app's secure-properties configuration: the algorithm (AES), the mode (CBC), and the 16-byte key the app is given at runtime.

Which salt mode to use depends on how the existing properties were produced. Salted is the default and the right starting point. If a value you know is correct fails to decrypt, try the other mode before concluding the key is wrong — a mode mismatch and a wrong key produce the same two errors (see below). MuleSoft's own secure-properties-tool.jar is the reference implementation; consult MuleSoft's documentation for its exact invocation.

Errors and exit codes

Exit code Meaning
0 Success
1 Runtime failure — bad key length, bad base64, failed decryption
2 Argument-parsing failure — missing/conflicting flags (from clap)

Runtime failures print error: <message> on stderr:

$ mule_aes -k short -e hi
error: key must be exactly 16 bytes, got 5

$ mule_aes -k 0123456789abcdef -d 'not!base64'
error: input is not a valid base64 string: Invalid symbol 33, offset 3.

$ mule_aes -k 0123456789abcdef -d 'AAAAAAAAAAAAAAAAAAAAAA=='
error: decoded input is 16 bytes, need at least 32 to decrypt with salt enabled

$ mule_aes -k fedcba9876543210 -n -d 'wem0Upqsl5MBD0Z39jWO/g=='
error: decryption failed: invalid PKCS#7 padding (wrong key, or wrong --no-salt setting?)

$ mule_aes -k 0123456789abcdef -n -d 'ZGo592Ib1gGOujqTwNjvg6Dk/m43Zz/OlUdH8tzwYno='
error: decrypted data is not valid UTF-8 (wrong key, or wrong --no-salt setting?): ...

The last two are the ones you'll hit in practice, and they are deliberately ambiguous: this format carries no authentication tag, so the tool genuinely cannot distinguish a wrong key from a wrong --no-salt setting. Check both.

Library usage

All the crypto lives in the mule_aes library crate; the binary is a thin front end over it. Add it as a path or git dependency:

[dependencies]
mule_aes = { path = "../mule-aes-rust" }
use mule_aes::{Key, SaltMode, encrypt, decrypt};

let key: Key = "0123456789abcdef".parse()?;

let blob = encrypt(&key, "secret", SaltMode::Salted);
let plain = decrypt(&key, &blob, SaltMode::Salted)?;
assert_eq!(plain, "secret");

The public surface:

Item Purpose
Key 16-byte key newtype; parse via FromStr, redacted Debug
SaltMode Salted / Unsalted, plus from_no_salt(bool)
encrypt(&key, plaintext, mode) Returns base64; draws a random IV when salted
encrypt_salted_with_iv(&key, &iv, txt) Salted encryption with a caller-supplied IV (deterministic)
decrypt(&key, encoded, mode) Returns the plaintext or an Error
Error / Result<T> thiserror enum covering key, base64, length and padding failures
KEY_SIZE, BLOCK_SIZE, SALTED_MIN_LEN Format constants (16, 16, 32)

encrypt is the entry point for production use. encrypt_salted_with_iv exists so salted output can be pinned in tests or a known blob reproduced; reusing an IV across messages under the same key leaks whether two plaintexts share a prefix.

Security notes

Worth understanding before relying on this:

  • The ciphertext is unauthenticated. CBC with no MAC means integrity is not guaranteed, and a successful decrypt is not proof the data is untampered. This is inherent to the MuleSoft format being matched, not a choice made here. If you need authenticated encryption and don't need MuleSoft compatibility, use AES-GCM instead.
  • A wrong key is usually, but not always, detected. PKCS#7 padding is validated, which catches most wrong keys — but roughly 1 in 256 wrong keys produces structurally valid padding and so yields garbage rather than an error.
  • AES-128 with a raw 16-byte key. The key is used directly, with no KDF, so its strength is exactly the entropy of the 16 bytes you supply. A 16-character passphrase is considerably weaker than 16 random bytes.
  • Arguments are visible to other processes. A key passed with -k shows up in ps output and in your shell history. Prefer MULE_AES_KEY, and prefer reading it into the environment without echoing it.
  • Plaintext and key are ordinary heap allocations. They are not locked into memory or zeroed on drop, so they may reach swap or a core dump.

Development

cargo build --release           # binary at target/release/mule_aes
cargo test                      # unit tests + CLI tests + compat vectors + doctests
cargo test --test cli           # end-to-end tests over the built binary
cargo test --test compat        # wire-format regression vectors only
cargo test round_trip           # a single test, by substring match
cargo clippy                    # full lint gate
cargo fmt

Lint configuration lives in Cargo.toml rather than in CI flags — clippy::all is set to deny and unsafe_code to forbid — so a plain cargo clippy enforces exactly what CI does, and -D warnings can neither be forgotten nor bypassed.

Layout:

src/lib.rs      all crypto, the Key/SaltMode/Error types, and unit tests
src/main.rs     clap front end: parses args, calls the library, maps errors to exit codes
tests/cli.rs    end-to-end tests over the built binary
tests/compat.rs base64 vectors captured from v1.0.0, pinning the wire format

Compatibility guarantee

tests/compat.rs holds base64 vectors captured from the original v1.0.0 binary, before the dependency upgrade that rewrote the crypto layer. They cover both modes, empty and block-aligned and multi-block plaintexts, Unicode, and more than one key.

Those tests existing is the whole reason the format can be trusted across refactors. A failure there means the output format has drifted and previously encrypted MuleSoft properties can no longer be read — a hard regression, never something to re-baseline.

Author

Damien Stuart <dstuart@dstuart.org>

No license file is currently included in this repository.