2021-12-03 15:45:01 +01:00

9.8 KiB


Simple, secure and lightweight symmetric encryption from the command line

doby started as a fork of aef by wyhaya with the goal of becoming a simple, fast and lightweight CLI utility for symmetric encryption. It aims to be an alternative to the old ccrypt tool by using modern cryptography and authenticated encryption.


  • Fast: written in rust, encrypts with AES-256-CTR or XChaCha20
  • HMAC ciphertext authentication
  • Password brute-force resistance with Argon2
  • Increase the plaintext size of only 113 bytes
  • Encryption from STDIN/STDOUT or from files
  • Adjustable performance & secuity parameters


doby is provided "as is", without any warranty of any kind. I'm not a professional cryptographer. This program didn't receive any security audit and therefore shouldn't be considered fully secure.



doby encrypted.doby


doby encrypted.doby

If you ommit file path or use -, doby operates from stdin/stdout:

# Read from stdin and write to stdout
cat my-super-secret-music.flac | doby > encrypted.doby

# Read from a file and output to stdout
doby encrypted.doby > decrypted.flac

# Read from stdin and save to a file
cat my-super-secret-logs-file.log | doby - logs.doby

Speicfy password from the command line:

doby --password "A super very ultra strong passphrase" my-super-secret-document.pdf document.doby

Double encryption:

doby --password "first password" my-super-secret-database.db | doby -f - double-encrypted.doby

Increase password brute-force resistance:

echo "you-will-never-break-this" | doby --memory-cost 524288 --parallelism 16 --time-cost 40 > my-super-secret-data.doby

Full Options

    doby [FLAGS] [OPTIONS] [ARGS]

    -f, --force-encrypt    Encrypt even if doby format is recognized
    -i, --interactive      Prompt before overwriting files
    -h, --help             Prints help information
    -V, --version          Prints version information

        --password <password>          Password used to derive encryption keys
    -t, --time-cost <iterations>       Argon2 time cost [default: 10]
    -m, --memory-cost <memory size>    Argon2 memory cost (in kilobytes) [default: 4096]
    -p, --parallelism <threads>        Argon2 parallelism cost [default: 4]
    -b, --block-size <blocksize>       Size of the I/O buffer (in bytes) [default: 65536]
    -c, --cipher <cipher>              Encryption cipher to use [possible values: aes, xchacha20]

    <INPUT>     <PATH> | "-" or empty for stdin
    <OUTPUT>    <PATH> | "-" or empty for stdout


You can download doby from the "Releases" section in this repo.

All releases MUST be signed with my PGP key available on keyservers. To import it:

gpg --keyserver hkp:// --recv-keys 007F84120107191E

Fingerprint: BD56 2147 9E7B 74D3 6A40 5BE8 007F 8412 0107 191E
Email: Hardcore Sushi <>

Then, save the PGP-signed message to a file and run:

gpg --verify <the file>

Don't continue if the verification fails!

If everything goes fine, you can download the package corresponding to your distribution. To verify it, compute its SHA-256 hash:

sha256sum <file>

Compare the output and the hash in the PGP-signed message. If the hashes match, the file is authenticated and you can continue the installation.

On debian:

sudo dpkg -i doby-*.deb

On Arch:

sudo pacman -U doby-*.pkg.tar.zst

On other distros:

tar -xzf doby-*.tar.gz && sudo doby/


You should verify commits before building the binary. Follow the steps in Installation to import my PGP key.

git clone --depth=1
cd doby
git verify-commit HEAD #you need to import my PGP key to verify the commit signature
cargo build --release --bin doby #outputs to ./target/release/doby

Cryptographic details

The following explanations are illustrated with pseudo rust code to simplify understanding. If you want to see how it's exactly implemented in doby, you can always check the source code.


doby first derives your password with Argon2 (version 19) in Argon2id mode with a 64 bytes long random salt. A master_key of 32 bytes is thus generated.

let master_key: [u8; 32] = argon2id(

Then, doby uses HKDF with the previous random salt to compute the nonce, the encryption_key and the authentication_key.

let hkdf = Hkdf::new(
    master_key, //ikm
    blake2b, //hash function
let nonce: [u8; 16] = hkdf.expand(b"doby_nonce"); //(16 bytes for AES-CTR, 24 for XChaCha20)
let encryption_key: [u8; 32] = hkdf.expand(b"doby_encryption_key");
let authentication_key: [u8; 32] = hkdf.expand(b"doby_authentication_key");

NOTE: To reduce the size of the header, the nonce is derived from the master_key instead of being generated purely at random then stored in the encrypted file.

Next, doby initializes a BLAKE2b HMAC with authentication_key and add all public encryption parameters to it.

let hmac = Blake2b::new_keyed(
    32, //digest size
//integers are encoded in big-endian
hmac.update(cipher); //1-byte representation of the symmetric cipher used to encrypt (either AES-CTR or XChaCha20)

All this parameters are also written in plain text in the header of the doby output.

Now, doby initializes a symmetric cipher with encryption_key and nonce (either AES-CTR or XChaCha20, based on the --cipher option) and starts the actual encryption. It reads chunks from the plaintext (according to the --block-size parameter), encrypts them with the cipher and updates the HMAC with the ciphertext.

let cipher = Aes256Ctr::new(encryption_key, nonce); //example with AES-CTR
let mut n = 1;
let mut chunk: [u8; block_size] = [0; block_size];
while n != 0 {
    n = chunk); //read plaintext
    cipher.apply_keystream(&mut chunk[..n]); //encrypt
    output.write(chunk[..n]); //write ciphertext

Once the whole plaintext is encrypted, doby computes and appends the HMAC to the ciphertext.


So here is what an encrypted file layout looks like:

Magic bytes 4 bytes
Salt 64 bytes
Argon2 parameters Time cost: 4 bytes
Memory cost: 4 bytes
Parallelism cost: 1 byte
Encryption cipher 1 byte
Ciphertext Exact same size as the plaintext
HMAC 32 bytes


doby reads the public encryption values from the input header to get all parameters needed to re-derive the master_key from the password with Argon2.

let master_key: [u8; 32] = argon2id(

nonce, encryption_key and authentication_key are computed from master_key in the same way as during encryption. The HMAC is also initialized and updated with the values read from the header.

Then, doby starts decryption.

let cipher = XChaCha20::new(encryption_key, nonce); //example with XChaCha20
let mut n = 1;
let mut chunk: [u8; block_size] = [0; block_size];
while n != 0 {
    n = chunk); //read ciphertext
    cipher.apply_keystream(&mut chunk[..n]); //decrypt
    output.write(chunk[..n]); //write plaintext

Once the whole ciphertext is decrypted, doby computes and verifies the HMAC.

hmac.digest() == last_32_bytes_read

If the verification success, the file is successfully decrypted and authenticated.

If you find any weakness or security issue is this protocol, please open an issue.

Why not using authenticated encryption such as AES-GCM instead of AES-CTR + HMAC ?

In order to encrypt data larger than memory, we need to split the plaintext into severavl smaller chunks and encrypt each of these chunks one by one. With authenticated encryption such as AES-GCM, this involves adding an authentication tag to each chunk. As a result, the final ciphertext size would be:

ciphertext size = plaintext size + (number of chunks ྾ tag size)

For example, a 50MB file encrypted with AES-GCM by chunks of 64KiB would be 12.2KB larger than the original plaintext, just to authenticate the file.

doby solves this problem by performing authentication independently of encryption. By using AES-CTR, the ciphertext remains the same size as the plaintext. The HMAC can be computed incrementally, one chunk at a time. Only one hash needs to be included in the final file. Thus, doby encrypted files are only 142 bytes larger than the plaintext, no matter how big the original file is.