- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| README.md | ||
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
- Quick start
- Usage
- Keys
- Salted vs unsalted
- Wire format
- Using this with MuleSoft
- Errors and exit codes
- Library usage
- Security notes
- Development
- Compatibility guarantee
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/-dis required; supplying both is an error. INPUTis 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
-kshows up inpsoutput and in your shell history. PreferMULE_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.