README · Architecture · Security · Contributing
zefer-cli uses the same cryptographic primitives as the zefer web app. The binary output is identical.
- Key size: 256 bits
- Nonce (IV): 96 bits, randomly generated per block, per chunk
- Auth tag: 128 bits (appended to ciphertext — same layout as Web Crypto API)
- Implementation: Node.js
crypto.createCipheriv('aes-256-gcm', key, iv)
AES-GCM provides both confidentiality and authenticity. Any modification to the ciphertext or associated data causes decryption to throw — the auth tag is verified before any plaintext is returned.
- Iterations: 300,000 / 600,000 / 1,000,000 (auto-benchmarked or user-selected)
- Salt: 256 bits (random per encryption)
- Output key: 256 bits
- Implementation: Node.js
crypto.pbkdf2(async, runs in libuv thread pool)
The salt is stored in the file and is not secret. Its purpose is to prevent rainbow table attacks and ensure that two encryptions with the same passphrase produce different keys.
- Iterations: 100,000
- Salt: Deterministic —
SHA-256("ZEFER_ANSWER_SALT:" + normalized_answer).slice(0, 16) - Normalization:
answer.trim().toLowerCase() - Output: 256 bits, stored as base64 inside the encrypted payload
The deterministic salt means the hash is reproducible from the answer alone, without storing extra state. The hash is never visible without decrypting the file first.
crypto.randomBytes(n)— Node.js binding to OS-level CSPRNG (/dev/urandomon Linux/macOS,BCryptGenRandomon Windows)- Used for: salt (32 bytes), base IV (12 bytes), key generation
Each 16 MB chunk has a unique IV derived from the base IV:
chunkIv = baseIv with last 4 bytes XOR'd with chunk index
This ensures nonce uniqueness across all chunks without generating a new random IV per chunk (which would require storing them all in the file header).
| Threat | Protection |
|---|---|
| Passive file disclosure | AES-256-GCM — key required to read any content |
| Passphrase brute force | PBKDF2 with 300k–1M iterations slows enumeration |
| Ciphertext tampering | GCM auth tag — any modification causes decryption failure |
| Two-party secret sharing | Dual key mode — both passphrases required |
| Credential delegation | Reveal key — share access without exposing main passphrase |
| Social engineering | Secret question + PBKDF2-hashed answer |
| Exfiltration window | TTL-based expiration inside encrypted payload |
| Automated brute force | Max attempts counter at ~/.zefer/attempts.json |
| File enumeration | All security metadata inside encrypted payload — invisible without key |
| Timing side-channels | 100ms minimum response on wrong passphrase |
| Limitation | Reason |
|---|---|
Passphrase in ps aux / shell history |
Using -p flag exposes it; use interactive prompt or env var |
~/.zefer/attempts.json deletion |
Client-side deterrent only, not a hard guarantee |
| Clock manipulation to bypass TTL | Expiration is checked against Date.now() on the decrypting machine |
| IP restriction bypass | IP is compared from the decrypting machine; CLI has no external IP detection |
| Memory inspection during session | Decrypted content exists in Node.js heap briefly before being written |
| Keyloggers or compromised OS | Out of scope for any application-level tool |
| Quantum computing | AES-256-GCM provides approximately 128-bit quantum security |
# Avoid: passphrase visible in process list
zefer encrypt file.txt -p "my-secret"
# Better: interactive prompt (never leaves memory as a string in shell)
zefer encrypt file.txt
# Better: environment variable (not visible in ps aux on most systems)
zefer encrypt file.txt -p "$ZEFER_PASS"
# Best: use a strong passphrase generated by zefer itself
zefer keygen -m secure -l 64The public header (readable without passphrase) contains only:
{
"iterations": 600000,
"compression": "gzip",
"hint": "optional public hint",
"note": "optional public note",
"mode": "file"
}Everything sensitive is inside the AES-256-GCM encrypted payload:
- File name and type
- File size
- Expiration timestamp
- Secret question hash
- IP allowlist
- Max attempts
An attacker with only the .zefer file cannot determine whether any of these security features are enabled, or what values they contain.
When --dual-key and -2 are used, the two passphrases are combined as:
combinedKey = passphrase1 + "\x00ZEFER_DUAL\x00" + passphrase2
This combined string is then used as the single PBKDF2 input. The separator \x00ZEFER_DUAL\x00 prevents trivial collision attacks (e.g., "ab" + "c" vs "a" + "bc").
The ZEFR3 format contains two independently encrypted blocks:
- Main block: encrypted with the main passphrase (or dual key combination)
- Reveal block: encrypted with the reveal key
Both blocks contain the same plaintext payload. The reveal key holder can decrypt the file but does not learn the main passphrase. The two blocks use independent random salts and IVs.
zefer-cli supports decrypting legacy formats:
| Format | Support | Notes |
|---|---|---|
| ZEFB3 | Read + Write | Current binary format |
| ZEFR3 | Read + Write | Current binary format with reveal key |
| ZEFER3 | Read only | Legacy text format (no new files written) |
| ZEFER2 | Read only | Legacy JSON format (no new files written) |
New files are always written in ZEFB3 (single key) or ZEFR3 (with reveal key).