---
id: crypto-flag-verification
severity: HIGH
applies_to: [all-agents, applied-cryptographer]
tags: [cryptography, cli-tools, openssl, defaults]
---

# Cryptographic CLI Flag Verification

**Enforcement Level**: HIGH
**Scope**: Any code or script invoking low-level crypto CLI tools
**Framework**: security-engineering

## Rule

When invoking low-level cryptographic CLI tools (`openssl`, `gpg`, `age`, `7z`, `dd` with crypto pipes, etc.), the invocation MUST specify all KDF, mode, and iteration parameters explicitly. Tool defaults are frequently insecure or deprecated, and silent fallbacks are common. Specifically:

- `openssl enc` MUST include `-pbkdf2 -iter <N>` (N ≥ 600,000 for SHA-256) AND an AEAD mode (`-aes-256-gcm`, `-chacha20-poly1305` if available). Without `-pbkdf2`, `openssl enc` defaults to `EVP_BytesToKey` — a single MD5 iteration. Without an AEAD mode, the output is unauthenticated (also see `no-unauthenticated-encryption`).
- `gpg --symmetric` MUST include `--s2k-mode 3 --s2k-count <high>` AND `--s2k-cipher-algo AES256 --s2k-digest-algo SHA512`. Default S2K count varies by version and is often insufficient.
- `7z` archive encryption is acceptable for casual use; for security-sensitive archives, use `age` or a libsodium-based program instead.
- `zip --encrypt` (legacy ZipCrypto) is BROKEN. Use 7z or age.

## Why

CLI crypto tools are designed for backwards compatibility and breadth, not for safe defaults. `openssl enc` in particular has multiple footguns:

- Default key derivation (`-pass`) without `-pbkdf2` is `EVP_BytesToKey`, which uses a single MD5 iteration over `password || salt`. Brute-force speed: ~10^9 attempts/sec on a single GPU. A 10-character password is recovered in seconds.
- Default mode is CBC (unauthenticated). `-aes-256-cbc` is unauthenticated, allowing tampering and padding-oracle attacks.
- Salt is auto-generated by default (good) but the file format binds salt and ciphertext loosely (`Salted__` magic + 8 bytes), which has been deprecated in OpenSSL 3.0+.

The combination of weak default KDF + unauthenticated mode is what review finding H6 caught: `openssl enc -aes-256-cbc -pass fd:0` was nominally claimed to use "PBKDF2 100k iterations", but the actual command line lacked `-pbkdf2 -iter`, making the protection ~five orders of magnitude weaker than claimed.

This rule exists because the cost of confirming a flag is grep, and the cost of missing a flag is total compromise.

Source: review finding H6 (2026-05-03 gap analysis).

## How to apply

### Detection

Grep patterns to flag for review:

```bash
# openssl enc without -pbkdf2 — BLOCK
grep -rn "openssl enc" --include="*.sh" --include="*.py" --include="Makefile" \
  | grep -v "\-pbkdf2"

# openssl enc with CBC — BLOCK (also no-unauthenticated-encryption)
grep -rn "openssl enc.*-aes.*-cbc"

# openssl enc reading from stdin without explicit flags
grep -rn "openssl enc" | grep -v "\-iter"

# gpg --symmetric without explicit S2K
grep -rn "gpg --symmetric" --include="*.sh" \
  | grep -v "\-\-s2k-mode 3"

# zip --encrypt — broken construction
grep -rn "zip.*\-\-encrypt"
grep -rn "zip.*-e " | grep -v "/\*"
```

### Remediation

#### `openssl enc` — required form

```bash
# Correct invocation: AEAD mode + explicit KDF + iteration count
openssl enc -aes-256-gcm \
  -pbkdf2 -iter 600000 \
  -in plaintext \
  -out ciphertext \
  -pass fd:0
```

But for new code, **don't use `openssl enc`**. Use a small Python program around libsodium instead:

```python
#!/usr/bin/env python3
"""Encrypt stdin to stdout using XChaCha20-Poly1305.
Reads passphrase from fd 3 (caller responsibility to pipe it cleanly).
"""
import os, sys
import nacl.secret, nacl.pwhash, nacl.utils

passphrase = os.read(3, 4096).rstrip(b"\n")
salt = nacl.utils.random(nacl.pwhash.argon2id.SALTBYTES)
key = nacl.pwhash.argon2id.kdf(
    nacl.secret.SecretBox.KEY_SIZE,
    passphrase, salt,
    opslimit=nacl.pwhash.argon2id.OPSLIMIT_INTERACTIVE,
    memlimit=nacl.pwhash.argon2id.MEMLIMIT_INTERACTIVE,
)
box = nacl.secret.SecretBox(key)
plaintext = sys.stdin.buffer.read()
ct = box.encrypt(plaintext)
# wire format: salt (16) || ct (24-byte nonce + plaintext + 16-byte tag)
sys.stdout.buffer.write(salt + ct)
```

The whole program is ~20 lines, has one dependency (`pynacl`, audited C lib underneath), uses Argon2id for the password, and produces an AEAD ciphertext with all parameters explicit.

#### `gpg --symmetric` — required form

```bash
gpg --symmetric \
  --s2k-mode 3 \
  --s2k-count 65011712 \
  --s2k-cipher-algo AES256 \
  --s2k-digest-algo SHA512 \
  --compress-algo none \
  --batch --passphrase-fd 0 \
  -o ciphertext.gpg \
  plaintext
```

`s2k-count` must be a power of two between 1024 and 65011712 (per RFC 4880). Use the maximum unless latency is unacceptable.

#### `age` — generally safe defaults

```bash
# Encrypt to a recipient (X25519 or SSH key)
age -r age1xyz... -o ct.age plaintext

# Encrypt with passphrase (prompts interactively; uses scrypt internally)
age -p -o ct.age plaintext
```

`age` has good defaults; verify the binary signature before use, and pin a specific version in production scripts.

## Verification checklist for any crypto CLI invocation

Before approving any code that calls a crypto CLI:

- [ ] Mode is AEAD (or paired with a separate MAC per `no-unauthenticated-encryption`)
- [ ] KDF is explicit (`-pbkdf2 -iter N`, `--s2k-mode 3 --s2k-count N`)
- [ ] Iteration / cost parameter is current per OWASP guidance
- [ ] Password/key is read from fd or env, never `-k password` or `-pass pass:...` (visible in `ps`)
- [ ] Output format embeds enough metadata for clean decryption (KDF params, salt, mode)
- [ ] No silent fallbacks (e.g., `openssl enc` without `-aes-*` flag has version-dependent default)
- [ ] Decryption verifies authentication BEFORE any structural processing (padding, parsing)

## Linked rules

- `no-unauthenticated-encryption` — most common pairing: missing flags AND missing authentication
- `no-adhoc-kdf` — `openssl enc` without `-pbkdf2` falls back to an ad-hoc-equivalent KDF (single MD5)
- `no-key-reuse-across-purposes` — when a CLI tool internally derives multiple keys, verify they're domain-separated

## References

- OpenSSL `enc(1)` man page — read the FILE FORMATS and OPTIONS sections
- RFC 4880 §3.7 — OpenPGP S2K (string-to-key) specification
- OWASP Cryptographic Storage Cheat Sheet — symmetric encryption guidance
- "PSA: Don't use `openssl enc`" — recurring blog topic (search "openssl enc considered harmful")
- Filippo Valsorda's `age` specification — `age-encryption.org/v1`
