Compare commits

...

18 Commits

25 changed files with 801 additions and 362 deletions

3
.gitignore vendored
View File

@ -1,3 +1,4 @@
.DS_Store
/target
/local
/local
/.vscode

68
Cargo.lock generated
View File

@ -10,7 +10,7 @@ checksum = "495ee669413bfbe9e8cace80f4d3d78e6d8c8d99579f97fb93bde351b185f2d4"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.1.5",
"ctr",
"opaque-debug",
]
@ -26,19 +26,20 @@ dependencies = [
[[package]]
name = "argon2"
version = "0.2.1"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d60f5f3113c903294dc81dd8cf0012963ed4dda8bc931c864e12e175356ff98b"
checksum = "d805bb12b532be9ce066df7913311f43716b41d8d780e9322113e8a6ae7c41ab"
dependencies = [
"base64ct",
"blake2",
"password-hash",
]
[[package]]
name = "assert_cmd"
version = "1.0.6"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa2dfc8228c6260bf620fc5a341afa8e27edcde388b19ffc5732320bfe657eb2"
checksum = "54f002ce7d0c5e809ebb02be78fd503aeed4a511fd0fcaff6e6914cbdabbfa33"
dependencies = [
"bstr",
"doc-comment",
@ -101,13 +102,13 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chacha20"
version = "0.7.1"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fee7ad89dc1128635074c268ee661f90c3f7e83d9fd12910608c36b47d6c3412"
checksum = "01b72a433d0cf2aef113ba70f62634c56fddb0f244e6377185c56a7cadbd8f91"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.1",
]
[[package]]
@ -143,6 +144,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469"
dependencies = [
"libc",
]
[[package]]
name = "crypto-mac"
version = "0.8.0"
@ -184,10 +194,10 @@ dependencies = [
]
[[package]]
name = "difference"
version = "2.0.0"
name = "difflib"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]]
name = "digest"
@ -200,7 +210,7 @@ dependencies = [
[[package]]
name = "doby"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"aes",
"argon2",
@ -208,12 +218,12 @@ dependencies = [
"blake2",
"chacha20",
"clap",
"cpufeatures",
"cpufeatures 0.2.1",
"hkdf",
"hmac",
"num_enum",
"rand",
"rpassword",
"subtle",
"tempfile",
"zeroize",
]
@ -224,6 +234,12 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
[[package]]
name = "either"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
[[package]]
name = "generic-array"
version = "0.14.4"
@ -274,6 +290,15 @@ dependencies = [
"digest",
]
[[package]]
name = "itertools"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69ddb889f9d0d08a67338271fa9b62996bc788c7796a5c18cf057420aaed5eaf"
dependencies = [
"either",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
@ -282,9 +307,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.97"
version = "0.2.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12b8adadd720df158f4d70dfe7ccc6adb0472d7c55ca83445f6a5ab3e36f8fb6"
checksum = "3cb00336871be5ed2c8ed44b60ae9959dc5b9f08539422ed43f09e34ecaeba21"
[[package]]
name = "memchr"
@ -322,9 +347,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "password-hash"
version = "0.2.1"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1a5d4e9c205d2c1ae73b84aab6240e98218c0e72e63b50422cfb2d1ca952282"
checksum = "7ad7268ef9bc463fddde8361d358fbfae1aeeb1fb62eca111cd8c763bf1c5891"
dependencies = [
"base64ct",
"rand_core",
@ -339,11 +364,12 @@ checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857"
[[package]]
name = "predicates"
version = "1.0.8"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f49cfaf7fdaa3bfacc6fa3e7054e65148878354a5cfddcf661df4c851f8021df"
checksum = "c143348f141cc87aab5b950021bac6145d0e5ae754b0591de23244cee42c9308"
dependencies = [
"difference",
"difflib",
"itertools",
"predicates-core",
]

View File

@ -1,11 +1,12 @@
[package]
name = "doby"
version = "0.1.0"
edition = "2018"
version = "0.3.0"
edition = "2021"
authors = ["Hardcore Sushi <hardcore.sushi@disroot.org>"]
license = "GPL-3.0-or-later"
description = "Secure symmetric encryption from the command line"
description = "Simple, secure and lightweight symmetric encryption from the command line"
readme = "README.md"
repository = "https://forge.chapril.org/hardcoresushi/doby"
[profile.release]
lto = true
@ -18,16 +19,16 @@ opt-level = 3
clap = "2.33"
rand = "0.8"
num_enum = "0.5"
cpufeatures = "0.1"
cpufeatures = "0.2"
aes = { version = "0.7", features = ["ctr"] }
chacha20 = "0.7"
hmac = "0.11"
chacha20 = "0.8"
subtle = "2.4"
blake2 = "0.9"
hkdf = "0.11"
argon2 = "0.2"
argon2 = "0.3"
rpassword = "5.0"
zeroize = "1.3"
[dev-dependencies]
assert_cmd = "1.0"
assert_cmd = "2.0"
tempfile = "3.0"

156
README.md
View File

@ -1,37 +1,34 @@
# doby
Secure symmetric encryption from the command line.
Simple, secure and lightweight symmetric encryption from the command line.
doby started as a fork of [aef](https://github.com/wyhaya/aef) by [wyhaya](https://github.com/wyhaya). It aims to replace the [ccrypt](http://ccrypt.sourceforge.net) tool which is a bit old and not very secure.
doby started as a fork of [aef](https://github.com/wyhaya/aef) by [wyhaya](https://github.com/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](http://ccrypt.sourceforge.net) tool by using modern cryptography and authenticated encryption.
# Features
* Fast: written in [rust](https://www.rust-lang.org), encrypts with [AES-256-CTR](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Counter_(CTR)) or [XChaCha20](https://en.wikipedia.org/wiki/Salsa20#XChaCha)
* [HMAC](https://en.wikipedia.org/wiki/HMAC) ciphertext authentication
* Password brute-force resistance with [Argon2](https://en.wikipedia.org/wiki/Argon2)
* Increase the plaintext size of only 158 bytes
* Increase the plaintext size of only 113 bytes
* Encryption from STDIN/STDOUT or from files
* Adjustable performance & secuity parameters
* Adjustable performance & security parameters
# Disclamer
# Disclaimer
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__.
# Usage
Encryption
Encryption:
```bash
doby my-super-secret-source-code.rs encrypted.doby
```
Decryption
Decryption:
```bash
doby encrypted.doby decrypted.rs
```
If you ommit file path or use `-`, doby operates from `stdin/stdout`
If you omit file path or use `-`, doby operates from `stdin/stdout`:
```bash
# Read from stdin and write to stdout
cat my-super-secret-music.flac | doby > encrypted.doby
@ -43,22 +40,19 @@ doby encrypted.doby > decrypted.flac
cat my-super-secret-logs-file.log | doby - logs.doby
```
Speicfy password from the command line
Specify password from the command line:
```bash
doby -p "A super very ultra strong passphrase" my-super-secret-document.pdf document.doby
doby --password "A super very ultra strong passphrase" my-super-secret-document.pdf document.doby
```
Double encryption
Double encryption:
```bash
doby -p "first password" my-super-secret-database.db | doby -f - double-encrypted.doby
doby --password "first password" my-super-secret-database.db | doby -f - double-encrypted.doby
```
Increase password brute-force resistance
Increase password brute-force resistance:
```bash
echo "you-will-never-break-this" | doby --memory-cost 524288 --threads 16 --iterations 40 > my-super-secret-password.doby
echo "you-will-never-break-this" | doby --memory-cost 524288 --parallelism 16 --time-cost 40 > my-super-secret-data.doby
```
## Full Options
@ -69,14 +63,15 @@ USAGE:
FLAGS:
-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
OPTIONS:
-p, --password <password> Password used to derive encryption keys
-i, --iterations <iterations> Argon2 time cost [default: 10]
-m, --memory-cost <memory cost> Argon2 memory cost (in kilobytes) [default: 4096]
-t, --threads <threads> Argon2 parallelism (between 1 and 255) [default: 4]
--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]
@ -85,27 +80,58 @@ ARGS:
<OUTPUT> <PATH> | "-" or empty for stdout
```
# Build
# Installation
You can download doby from the "Releases" section in this repo.
You should verify commits before building the binary. You can download my PGP key from keyservers:
```
All releases MUST be signed with my PGP key available on keyservers. To import it:
```bash
gpg --keyserver hkp://pool.sks-keyservers.net --recv-keys 007F84120107191E
```
Fingerprint: `BD56 2147 9E7B 74D3 6A40 5BE8 007F 8412 0107 191E` \
Email: `Hardcore Sushi <hardcore.sushi@disroot.org>`
Once imported:
Then, save the PGP-signed message to a file and run:
```bash
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:
```bash
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:
```bash
sudo dpkg -i doby-*.deb
```
On Arch:
```bash
sudo pacman -U doby-*.pkg.tar.zst
```
On other distros:
```bash
tar -xzf doby-*.tar.gz && sudo doby/install.sh
```
# Build
You should verify commits before building the binary. Follow the steps in [Installation](#installation) to import my PGP key.
```bash
git clone --depth=1 https://forge.chapril.org/hardcoresushi/doby.git
cd doby
git verify-commit HEAD
cargo build --release #outputs to ./target/release/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.
### Encryption
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.
@ -120,7 +146,7 @@ let master_key: [u8; 32] = argon2id(
);
```
Then, doby uses [HKDF](https://en.wikipedia.org/wiki/HKDF) with the previous random salt to compute the `encryption_key` and the `authentication_key`.
Then, doby uses [HKDF](https://en.wikipedia.org/wiki/HKDF) with the previous random salt to compute the `nonce`, the `encryption_key` and the `authentication_key`.
```rust
let hkdf = Hkdf::new(
@ -128,31 +154,34 @@ 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](https://en.wikipedia.org/wiki/BLAKE_(hash_function)#BLAKE2) HMAC with `authentication_key` and add all public encryption parameters to it.
```rust
let hmac = Hmac::new(
let hmac = Blake2b::new_keyed(
authentication_key,
blake2b, //hash function
32, //digest size
);
hmac.update(random_salt);
//integers are encoded in big-endian
hmac.update(argon2_time_cost);
hmac.update(argon2_memory_cost);
hmac.update(argon2_parallelism);
hmac.update(cipher); //1-byte representation of the symmetric cipher used to encrypt (either AES-CTR or XChaCha20)
hmac.update(random_nonce); //random nonce used for encryption (16 bytes for AES-CTR, 24 for 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 `random_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.
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.
```rust
let cipher = Aes256Ctr::new(encryption_key, random_nonce); //example with AES-CTR
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 {
@ -169,6 +198,41 @@ Once the whole plaintext is encrypted, doby computes and appends the HMAC to the
output.write(hmac.digest());
```
So here is what an encrypted file layout looks like:
<table>
<tr>
<th align="left">Magic bytes</th>
<td>4 bytes</td>
</tr>
<tr>
<th align="left">Salt</th>
<td>64 bytes</td>
</tr>
<tr>
<th align="left" rowspan="3">Argon2 parameters</th>
<td>Time cost: 4 bytes</td>
</tr>
<tr>
<td>Memory cost: 4 bytes</td>
</tr>
<tr>
<td>Parallelism cost: 1 byte</td>
</tr>
<tr>
<th align="left">Encryption cipher</th>
<td>1 byte</td>
</tr>
<tr>
<th align="left">Ciphertext</th>
<td>Exact same size as the plaintext</td>
</tr>
<tr>
<th align="left">HMAC</th>
<td>32 bytes</td>
</tr>
</table>
### Decryption
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.
@ -183,12 +247,12 @@ let master_key: [u8; 32] = argon2id(
);
```
`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.
`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.
```rust
let cipher = XChaCha20::new(encryption_key, nonce_read_from_input); //example with XChaCha20
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 {
@ -202,9 +266,19 @@ while n != 0 {
Once the whole ciphertext is decrypted, doby computes and verifies the HMAC.
```rust
hmac.digest() == last_64_bytes_read // the default blake2b output size is 64 bytes
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._
_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 several 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.

63
completions/bash Normal file
View File

@ -0,0 +1,63 @@
#/usr/bin/env bash
_remove_opts() {
local opt new_opts
for opt in ${available_opts}; do
if [[ $opt != $1 && $opt != $2 ]]; then
new_opts+="$opt "
fi
done
available_opts=$new_opts
}
_doby_completion() {
local cur="${COMP_WORDS[COMP_CWORD]}"
local opts="-f --force-encrypt -i --interactive -h --help -V --version --password -t --time-cost -m --memory-cost -p --parallelism -b --block-size -c --cipher"
if [[ ${cur} == -* ]]; then
local i available_opts=$opts
for i in ${COMP_WORDS[@]}; do
if [[ ${opts[*]} =~ $i ]]; then
case $i in
"-f"|"--force-encrypt")
_remove_opts "-f" "--force-encrypt"
;;
"-i"|"--interactive")
_remove_opts "-i" "--interactive"
;;
"-h"|"--help")
_remove_opts "-h" "--help"
;;
"-V"|"--version")
_remove_opts "-V" "--version"
;;
"--password")
_remove_opts "--password"
;;
"-t"|"--time-cost")
_remove_opts "-t" "--time-cost"
;;
"-m"|"--memory-cost")
_remove_opts "-m" "--memory-cost"
;;
"-p"|"--parallelism")
_remove_opts "-p" "--parallelism"
;;
"-b"|"--block-size")
_remove_opts "-b" "--block-size"
;;
"-c"|"--cipher")
_remove_opts "-c" "--cipher"
;;
esac
fi
done
COMPREPLY=($(compgen -W "${available_opts}" -- "${cur}"))
else
local prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ ${prev} == "-c" || ${prev} == "--cipher" ]]; then
COMPREPLY=($(compgen -W "aes xchacha20" -- "${cur}"))
fi
fi
}
complete -F _doby_completion -o bashdefault -o default doby

19
completions/zsh Normal file
View File

@ -0,0 +1,19 @@
#compdef doby
function _doby {
_arguments \
'(-f --force-encrypt)'{-f,--force-encrypt}'[Encrypt even if doby format is recognized]' \
'(-i --interactive)'{-i,--interactive}'[Prompt before overwriting files]' \
'(: * -)'{-h,--help}'[Prints help information]' \
'(: * -)'{-V,--version}'[Prints version information]' \
'--password=[Password used to derive encryption keys]' \
'(-t --time-cost)'{-t,--time-cost}'[Argon2 time cost]' \
'(-m --memory-cost)'{-m,--memory-cost}'[Argon2 memory cost (in kilobytes)]' \
'(-p --parallelism)'{-p,--parallelism}'[Argon2 parallelism cost]' \
'(-b --block-size)'{-b,--block-size}'[Size of the I/O buffer (in bytes)]' \
'(-c --cipher)'{-c,--cipher}'[Encryption cipher to use]: :(aes xchacha20)' \
':::_files' \
':::_files' \
}
_doby "$@"

1
man/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/*.gz

18
man/compile.sh Executable file
View File

@ -0,0 +1,18 @@
#!/bin/bash
cargo_toml="../Cargo.toml"
source_md="source.md"
if [ ! -f $cargo_toml ]; then
echo "Error: $cargo_toml not found." >&2;
exit 1;
elif [ ! -f $source_md ]; then
echo "Error: $source_md not found." >&2;
exit 1;
fi
version=$(grep "^version = " $cargo_toml | cut -d "\"" -f 2)
date=$(date +"%B %Y")
pandoc $source_md -s -t man | sed \
"s/^\.TH.*$/\.TH \"DOBY\" \"1\" \"$date\" \"doby v$version\" \"doby v$version\"/; \
s/^\.hy/\.ad l/" | gzip - > doby.1.gz

88
man/source.md Normal file
View File

@ -0,0 +1,88 @@
% DOBY(1)
# NAME
doby - Simple, secure and lightweight symmetric encryption from the command line
# SYNOPSIS
doby [**-fi**] [**\--password** password] [**-t** time_cost] [**-m** memory_cost] [**-p** parallelism] [**-b** block_size] [**-c**] {aes | xchacha20} [INPUT] [OUTPUT]
doby [**-h** | **\--help**]
doby [**-V** | **\--version**]
# DESCRIPTION
doby aims to be a small, fast and user-friendly command line tool for symmetric encryption of single files. It uses modern cryptography and (obviously) it's built in rust.
doby can operate with files larger than memory but also from stdout/stdin. In addition to encrypt files, doby also use HMAC cryptography to authenticate the data. This means that encrypted files can't be tampered. Encryptions keys are derived from the user password using Argon2, an expensive KDF function that slows down a lot brute force attacks. You can find more details about cryptography on the doby's repository: https://forge.chapril.org/hardcoresushi/doby#cryptographic-details
doby will add a header at the beginning of the encrypted files so that it can know whether it is encrypted or not. That's why you don't need to specify which operation should be performed. doby will detect this automatically.
# OPTIONS
**-h**, **\--help**
: Print help.
**-V**, **\--version**
: Print doby version.
**-f**, **\--force-encrypt**
: Perform encryption even if doby format is recognized in the input file.
**-i**, **\--interactive**
: Prompt before overwriting the output file if it already exists.
**\--password** *password*
: Specify the password which will be used to derive encryption keys. If omitted, the password will be prompted in the terminal.
**-t**, **\--time-cost** *iterations*
: Argon2 time cost used to derive the master key. Default: 10
**-m**, **\--memory-cost** *memory size*
: Argon2 memory cost used to derive the master key (in kilobytes). Default: 4096 KB
**-p,** **\--parallelism** *threads*
: Argon2 parallelism cost used to derive the master key. Default: 4
**-b,** **\--block-size** *blocksize*
: Size of the buffer used when reading the file (in bytes). Default: 65536 B
**-c,** **\--cipher** *cipher*
: Encryption cipher to use. Either "aes" or "xchacha20". If not specified, AES will be used if your CPU supports AES native instructions, XChaCha20 otherwise. Ignored when performing decryption.
**INPUT**
: The file doby will read as input. If it's omitted or set to "-", doby will read from stdin.
**OUTPUT**
: The file doby will write to. If it's omitted or set to "-", doby will write to stdout.
# EXAMPLES
doby my-super-secret-source-code.rs encrypted.doby
doby \--block-size 4096 encrypted.doby decrypted.rs
cat my-super-secret-music.flac | doby \--cipher xchacha20 > encrypted.doby
doby \--password "rockyou" encrypted.doby > decrypted.flac
cat my-super-secret-logs-file.log | doby \--interactive - logs.doby
echo "you-will-never-break-this" | doby \--memory-cost 524288 \--parallelism 16 \--time-cost 40 > my-super-secret-data.doby
# EXIT STATUS
**0**
: Success
**1**
: Error
# REPORTING BUGS
You can open an issues on Gitea (https://forge.chapril.org/hardcoresushi/doby) or on GitHub (https://github.com/hardcore-sushi/doby) if you find an issue or if you have any questions/suggestions.
If you prefer, you can also email me at hardcore.sushi@disroot.org. My PGP key is available on keyservers (fingerprint: 0x007F84120107191E).
# AUTHOR
Hardcore Sushi <hardcore.sushi@disroot.org>
# COPYRIGHT
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>. This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law.
# SEE ALSO
**ccrypt**(1), **age**(1), **gocryptfs**(1), **cryfs**(1)

3
packaging/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.deb
*.gz
*.pkg.tar.zst

View File

@ -0,0 +1,7 @@
Package: doby
Version: VERSION
Depends: libc6
Homepage: https://forge.chapril.org/hardcoresushi/doby
Maintainer: Hardcore Sushi <hardcore.sushi@disroot.org>
Architecture: amd64
Description: Simple, secure and lightweight symmetric encryption from the command line

71
packaging/package.sh Executable file
View File

@ -0,0 +1,71 @@
#!/bin/bash
set_version() {
cp $1 /tmp/$(basename $1).original &&
sed -i "s/VERSION/$version/g" $1
}
restore() {
mv /tmp/$(basename $1).original $1
}
package_deb() {(
mkdir -p deb/doby/usr/bin deb/doby/usr/share/man/man1 \
deb/doby/usr/share/bash-completion/completions \
deb/doby/usr/share/zsh/vendor-completions &&
strip -s ../target/release/doby -o deb/doby/usr/bin/doby &&
cp ../man/doby.1.gz deb/doby/usr/share/man/man1 &&
cp ../completions/bash deb/doby/usr/share/bash-completion/completions/doby &&
cp ../completions/zsh deb/doby/usr/share/zsh/vendor-completions/_doby &&
cd deb && set_version doby/DEBIAN/control && dpkg -b doby &&
restore doby/DEBIAN/control && mv doby.deb ../doby-$version-x86_64.deb &&
rm -r doby/usr
)}
package_pkg() {(
mkdir pkg/src &&
strip -s ../target/release/doby -o pkg/src/doby &&
cp ../man/doby.1.gz pkg/src &&
cp -r ../completions pkg/src &&
cd pkg && set_version PKGBUILD &&
makepkg && restore PKGBUILD &&
mv doby-*.pkg.tar.zst ../doby-$version-x86_64.pkg.tar.zst && rm -r src pkg
)}
package_tarball() {(
strip -s ../target/x86_64-unknown-linux-musl/release/doby -o tarball/doby/doby &&
cp ../man/doby.1.gz tarball/doby &&
cd tarball && tar -chzf ../doby-$version-x86_64.tar.gz doby &&
rm doby/doby*
)}
if [ "$#" -eq 1 ]; then
cargo_toml="../Cargo.toml"
if [ ! -f $cargo_toml ]; then
echo "Error: $cargo_toml not found." >&2;
exit 1;
fi
version=$(grep "^version = " ../Cargo.toml | cut -d "\"" -f 2)
echo "Packaging doby v$version..."
case $1 in
"deb")
package_deb
;;
"pkg")
package_pkg
;;
"tarball")
package_tarball
;;
"all")
package_deb
package_pkg
package_tarball
;;
esac
else
echo "usage: $0 <deb|pkg|tarball|all>" >&2
exit 1;
fi

18
packaging/pkg/PKGBUILD Normal file
View File

@ -0,0 +1,18 @@
# Maintainer: Hardcore Sushi <hardcore.sushi@disroot.org>
pkgname=doby
pkgver=VERSION
pkgrel=0
depends=("glibc")
arch=("x86_64")
pkgdesc="Simple, secure and lightweight symmetric encryption from the command line"
url="https://forge.chapril.org/hardcoresushi/doby"
license=("GPL-3.0-or-later")
package() {
mkdir -p $pkgdir/usr/bin $pkgdir/usr/share/man/man1 \
$pkgdir/usr/share/bash-completion/completions $pkgdir/usr/share/zsh/vendor-completions
cp $srcdir/doby $pkgdir/usr/bin
cp $srcdir/doby.1.gz $pkgdir/usr/share/man/man1
cp $srcdir/completions/bash $pkgdir/usr/share/bash-completion/completions/doby
cp $srcdir/completions/zsh $pkgdir/usr/share/zsh/vendor-completions/_doby
}

View File

@ -0,0 +1 @@
../../../completions

View File

@ -0,0 +1,28 @@
#!/bin/sh
ROOT=$(dirname $0)
if [ $(id -u) -ne 0 ]; then
echo "Error: root access required" >&2
exit 1
elif [ ! -f $ROOT/doby ]; then
echo "Error: doby binary not found in $ROOT" >&2
exit 1
fi
install -v -g 0 -o 0 $ROOT/doby /usr/bin
MAN_FOLDER=/usr/share/man/man1
if [ -d $MAN_FOLDER ]; then
install -v -g 0 -o 0 -m 0644 $ROOT/doby.1.gz $MAN_FOLDER
fi
BASH_COMPLETION_FOLDER=/usr/share/bash-completion/completions
if [ -d $BASH_COMPLETION_FOLDER ]; then
install -v -g 0 -o 0 -m 0644 $ROOT/completions/bash $BASH_COMPLETION_FOLDER/doby
fi
ZSH_COMPLETION_FOLDER=/usr/share/zsh/vendor-completions
if [ -d $ZSH_COMPLETION_FOLDER ]; then
install -v -g 0 -o 0 -m 0644 $ROOT/completions/zsh $ZSH_COMPLETION_FOLDER/_doby
fi

View File

@ -0,0 +1,11 @@
#!/bin/sh
if [ $(id -u) -ne 0 ]; then
echo "Error: root access required" >&2
exit 1
fi
rm -v /usr/bin/doby
rm -v /usr/share/man/man1/doby.1.gz 2>/dev/null
rm -v /usr/share/bash-completion/completions/doby 2>/dev/null
rm -v /usr/share/zsh/vendor-completions/_doby 2>/dev/null

View File

@ -6,7 +6,7 @@ use std::{
};
use doby::{
encrypt, decrypt,
crypto::{ArgonParams, EncryptionParams, CipherAlgorithm, DobyCipher}
crypto::{EncryptionParams, CipherAlgorithm, DobyCipher}
};
const MAX_BLOCK_SIZE: usize = 1_073_741_824; //1GB
@ -33,11 +33,10 @@ fn main() -> io::Result<()> {
let input = File::open(&args[1])?;
let output = OpenOptions::new().create(true).truncate(true).write(true).open(&args[2])?;
let params = EncryptionParams::new(ArgonParams{
t_cost: 1,
m_cost: 8,
parallelism: 1,
}, CipherAlgorithm::AesCtr);
let params = EncryptionParams::new(
argon2::Params::new(8, 1, 1, None).unwrap(),
CipherAlgorithm::AesCtr
);
let mut best_encrypt_time = None;
let mut best_encrypt_block_size = None;
@ -49,7 +48,7 @@ fn main() -> io::Result<()> {
let mut reader = BufReader::with_capacity(block_size, &input);
let mut writer = BufWriter::with_capacity(block_size, &output);
let cipher = DobyCipher::new(PASSWORD.into(), &params).unwrap();
let cipher = DobyCipher::new(PASSWORD.as_bytes(), &params);
let t_encrypt = Instant::now();
encrypt(&mut reader, &mut writer, &params, cipher, block_size, None)?;
writer.flush()?;
@ -59,7 +58,7 @@ fn main() -> io::Result<()> {
reset(&mut reader)?;
reset(&mut writer)?;
let cipher = DobyCipher::new(PASSWORD.into(), &params).unwrap();
let cipher = DobyCipher::new(PASSWORD.as_bytes(), &params);
let t_decrypt = Instant::now();
decrypt(&mut reader, &mut writer, cipher, block_size)?;
writer.flush()?;

17
src/bin/compgen.rs Normal file
View File

@ -0,0 +1,17 @@
use std::{env, io};
use clap::Shell;
use doby::cli;
fn main() {
let mut args = env::args().skip(1);
if let Some(shell) = args.next() {
if let Ok(shell) = shell.parse() {
cli::app().gen_completions_to("doby", shell, &mut io::stdout());
} else {
eprintln!("error: invalid shell: {}", shell);
eprintln!("shell variants: {:?}", Shell::variants());
}
} else {
eprintln!("usage: compgen <shell>");
}
}

View File

@ -10,15 +10,15 @@ fn main() -> io::Result<()> {
if magic_bytes == MAGIC_BYTES {
match EncryptionParams::read(&mut file)? {
Some(params) => {
println!("Argon2 time cost: {}", params.argon2.t_cost);
println!("Argon2 memory cost: {}KB", params.argon2.m_cost);
println!("Argon2 parallelism: {}", params.argon2.parallelism);
println!("Argon2 time cost: {}", params.argon2.t_cost());
println!("Argon2 memory cost: {}KB", params.argon2.m_cost());
println!("Argon2 parallelism cost: {}", params.argon2.p_cost());
println!("Encryption cihpher: {}", params.cipher);
}
None => eprintln!("Invalid cipher")
None => eprintln!("Invalid parameters")
}
} else {
eprintln!("Doby format not recognized.");
eprintln!("doby format not recognized.");
}
Ok(())
}

View File

@ -1,48 +1,65 @@
use std::{
path::Path,
fs::File,
str::FromStr,
io::{stdin, stdout, Read},
};
use std::{fs::File, io::{self, Read, stdin, stdout}, path::Path, str::FromStr};
use clap::{crate_name, crate_version, App, Arg, AppSettings};
use crate::{LazyWriter, Password, crypto::{ArgonParams, CipherAlgorithm}};
use crate::{WrappedWriter, WrappedPassword, crypto::CipherAlgorithm};
cpufeatures::new!(aes_ni, "aes");
pub struct CliArgs {
pub password: Password,
pub password: WrappedPassword,
pub force_encrypt: bool,
pub argon2_params: ArgonParams,
pub argon2_params: argon2::Params,
pub cipher: CipherAlgorithm,
pub block_size: usize,
pub reader: Box<dyn Read>,
pub writer: LazyWriter<String>,
pub writer: WrappedWriter<String>,
}
pub fn parse() -> Option<CliArgs> {
let app = App::new(crate_name!())
pub struct ParseResult {
pub error: bool,
pub cli_args: Option<CliArgs>,
}
impl ParseResult {
fn exited() -> Self {
Self { error: false, cli_args: None }
}
}
impl From<CliArgs> for ParseResult {
fn from(args: CliArgs) -> Self {
ParseResult { error: false, cli_args: Some(args) }
}
}
pub fn app<'a>() -> App<'a, 'a> {
App::new(crate_name!())
.version(crate_version!())
.setting(AppSettings::ColoredHelp)
.about("Secure symmetric encryption from the command line.")
.arg(Arg::with_name("INPUT").help("<PATH> | \"-\" or empty for stdin"))
.arg(Arg::with_name("OUTPUT").help("<PATH> | \"-\" or empty for stdout"))
.arg(
Arg::with_name("force-encrypt")
Arg::with_name("1_force_encrypt")
.short("f")
.long("force-encrypt")
.help(&format!("Encrypt even if {} format is recognized", crate_name!()))
.help(concat!("Encrypt even if ", crate_name!(), " format is recognized"))
)
.arg(
Arg::with_name("2_interactive")
.short("i")
.long("interactive")
.help("Prompt before overwriting files")
)
.arg(
Arg::with_name("1_password")
.short("p")
.long("password")
.value_name("password")
.help("Password used to derive encryption keys")
)
.arg(
Arg::with_name("2_t_cost")
.short("i")
.long("iterations")
.short("t")
.long("time-cost")
.value_name("iterations")
.help("Argon2 time cost")
.default_value("10")
@ -51,16 +68,16 @@ pub fn parse() -> Option<CliArgs> {
Arg::with_name("3_m_cost")
.short("m")
.long("memory-cost")
.value_name("memory cost")
.value_name("memory size")
.help("Argon2 memory cost (in kilobytes)")
.default_value("4096")
)
.arg(
Arg::with_name("4_parallelism")
.short("t")
.long("threads")
Arg::with_name("4_p_cost")
.short("p")
.long("parallelism")
.value_name("threads")
.help("Argon2 parallelism (between 1 and 255)")
.help("Argon2 parallelism cost")
.default_value("4")
)
.arg(
@ -80,27 +97,32 @@ pub fn parse() -> Option<CliArgs> {
.possible_values(&["aes", "xchacha20"])
.case_insensitive(true)
)
.get_matches();
}
pub fn parse() -> Option<ParseResult> {
let app = app().get_matches();
let params = {
let t_cost = number(app.value_of("2_t_cost").unwrap())?;
let m_cost = number(app.value_of("3_m_cost").unwrap())?;
let parallelism = number(app.value_of("4_parallelism").unwrap())?;
let p_cost = number(app.value_of("4_p_cost").unwrap())?;
ArgonParams {
t_cost,
m_cost,
parallelism,
match argon2::Params::new(m_cost, t_cost, p_cost, None) {
Ok(params) => Some(params),
Err(e) => {
eprintln!("Invalid Argon2 parameters: {}", e);
None
}
}
};
}?;
let cipher = app
.value_of("cipher")
.and_then(|s| Some(if s.to_lowercase() == "aes" {
.map(|s| if s.to_lowercase() == "aes" {
CipherAlgorithm::AesCtr
} else {
CipherAlgorithm::XChaCha20
})
}
)
.unwrap_or_else(|| if aes_ni::get() {
CipherAlgorithm::AesCtr
@ -125,35 +147,44 @@ pub fn parse() -> Option<CliArgs> {
None => Box::new(stdin())
};
let output = app
let wrapped_writer = match app
.value_of("OUTPUT")
.and_then(|s| if s == "-" { None } else { Some(s) })
.map(|s| {
if Path::new(s).exists() {
eprintln!("WARNING: {} already exists", s);
None
} else {
Some(LazyWriter::from_path(s.to_owned()))
.and_then(|s| if s == "-" { None } else { Some(s) }) {
Some(path) => {
if {
if app.is_present("2_interactive") && Path::new(path).exists() {
eprint!("Warning: {} already exists. Overwrite [y/N]? ", path);
let mut c = String::with_capacity(2);
io::stdin().read_line(&mut c).unwrap();
!c.is_empty() && c.chars().nth(0).unwrap() == 'y'
} else {
true
}
} {
WrappedWriter::from_path(path.to_string())
} else {
return Some(ParseResult::exited())
}
}
})
.unwrap_or_else(|| Some(LazyWriter::from_writer(stdout())))?;
None => WrappedWriter::from_writer(stdout())
};
Some(CliArgs {
password: app.value_of("1_password").into(),
force_encrypt: app.is_present("force-encrypt"),
force_encrypt: app.is_present("1_force_encrypt"),
argon2_params: params,
cipher,
block_size,
reader: input,
writer: output,
})
writer: wrapped_writer,
}.into())
}
fn number<T: FromStr>(val: &str) -> Option<T> {
match val.parse::<T>() {
Ok(n) => Some(n),
Err(_) => {
eprintln!("Cannot parse '{}' to '{}'", val, std::any::type_name::<T>());
eprintln!("Error: '{}' is not a number", val);
None
}
}

View File

@ -1,31 +1,20 @@
use std::{
convert::TryFrom,
fmt::{self, Display, Formatter},
io::{self, Read, Write}
};
use std::{convert::TryFrom, fmt::{self, Display, Formatter}, io::{self, Read, Write}};
use blake2::{Blake2b, VarBlake2b, digest::{Update, VariableOutput}};
use num_enum::TryFromPrimitive;
use chacha20::XChaCha20;
use aes::{Aes256Ctr, cipher::{NewCipher, StreamCipher}};
use hmac::{Hmac, Mac, NewMac};
use subtle::ConstantTimeEq;
use rand::{Rng, rngs::OsRng};
use argon2::{Argon2, Version, Algorithm};
use hkdf::Hkdf;
use zeroize::Zeroize;
use crate::Password;
pub const SALT_LEN: usize = 64;
const AES_NONCE_LEN: usize = 16;
const XCHACHA20_NONCE_LEN: usize = 24;
pub const HASH_LEN: usize = 64;
pub const HMAC_LEN: usize = 32;
const KEY_LEN: usize = 32;
#[derive(Debug, PartialEq, Eq)]
pub struct ArgonParams {
pub t_cost: u32,
pub m_cost: u32,
pub parallelism: u8,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, TryFromPrimitive)]
#[repr(u8)]
pub enum CipherAlgorithm {
@ -54,37 +43,32 @@ impl Display for CipherAlgorithm {
#[derive(Debug, PartialEq, Eq)]
pub struct EncryptionParams {
salt: [u8; SALT_LEN],
pub argon2: ArgonParams,
nonce: Vec<u8>,
pub argon2: argon2::Params,
pub cipher: CipherAlgorithm,
}
impl EncryptionParams {
pub fn get_params_len(&self) -> usize {
SALT_LEN + 4*2 + 2 + self.cipher.get_nonce_size()
}
pub const LEN: usize = SALT_LEN + 4*3 + 1;
pub fn new(argon2_params: ArgonParams, cipher: CipherAlgorithm) -> EncryptionParams {
pub fn new(argon2_params: argon2::Params, cipher: CipherAlgorithm) -> EncryptionParams {
let mut salt = [0; SALT_LEN];
OsRng.fill(&mut salt);
let mut nonce = vec![0; cipher.get_nonce_size()];
OsRng.fill(&mut nonce[..]);
EncryptionParams {
salt,
argon2: argon2_params,
nonce,
cipher,
}
}
pub fn write<W: Write>(&self, writer: &mut W) -> io::Result<()> {
writer.write_all(&self.salt)?;
writer.write_all(&self.argon2.t_cost.to_be_bytes())?;
writer.write_all(&self.argon2.m_cost.to_be_bytes())?;
writer.write_all(&self.argon2.parallelism.to_be_bytes())?;
writer.write_all(&self.argon2.t_cost().to_be_bytes())?;
writer.write_all(&self.argon2.m_cost().to_be_bytes())?;
writer.write_all(&self.argon2.p_cost().to_be_bytes())?;
writer.write_all(&(self.cipher as u8).to_be_bytes())?;
writer.write_all(&self.nonce)?;
Ok(())
}
pub fn read<R: Read>(reader: &mut R) -> io::Result<Option<Self>> {
let mut salt = [0; SALT_LEN];
reader.read_exact(&mut salt)?;
@ -92,98 +76,75 @@ impl EncryptionParams {
reader.read_exact(&mut t_cost)?;
let mut m_cost = [0; 4];
reader.read_exact(&mut m_cost)?;
let mut parallelism = [0; 1];
reader.read_exact(&mut parallelism)?;
let mut p_cost = [0; 4];
reader.read_exact(&mut p_cost)?;
let mut cipher_buff = [0; 1];
reader.read_exact(&mut cipher_buff)?;
match CipherAlgorithm::try_from(cipher_buff[0]) {
Ok(cipher) => {
let mut nonce = vec![0; cipher.get_nonce_size()];
reader.read_exact(&mut nonce)?;
let argon2_params = ArgonParams {
t_cost: u32::from_be_bytes(t_cost),
m_cost: u32::from_be_bytes(m_cost),
parallelism: u8::from_be_bytes(parallelism),
};
Ok(Some(EncryptionParams {
if let Ok(cipher) = CipherAlgorithm::try_from(cipher_buff[0]) {
if let Ok(argon2_params) = argon2::Params::new(
u32::from_be_bytes(m_cost),
u32::from_be_bytes(t_cost),
u32::from_be_bytes(p_cost),
None
) {
return Ok(Some(EncryptionParams {
salt,
argon2: argon2_params,
nonce,
cipher,
}))
}));
}
Err(_) => Ok(None)
}
}
}
trait ThenZeroize {
fn zeroize<T: Zeroize>(self, v: T) -> Self;
}
impl<S, E> ThenZeroize for Result<S, E> {
fn zeroize<T: Zeroize>(self, mut v: T) -> Self {
v.zeroize();
self
Ok(None)
}
}
pub struct DobyCipher {
cipher: Box<dyn StreamCipher>,
hmac: Hmac<blake2::Blake2b>,
hasher: VarBlake2b,
buffer: Vec<u8>,
}
impl DobyCipher {
pub fn new(mut password: Password, params: &EncryptionParams) -> Result<Self, argon2::Error> {
match Argon2::new(None, params.argon2.t_cost, params.argon2.m_cost, params.argon2.parallelism.into(), Version::V0x13) {
Ok(argon2) => {
let mut master_key = [0; KEY_LEN];
let password = password.unwrap_or_ask();
argon2.hash_password_into(Algorithm::Argon2id, password.as_bytes(), &params.salt, &[], &mut master_key).zeroize(password)?;
let hkdf = Hkdf::<blake2::Blake2b>::new(Some(&params.salt), &master_key);
let mut encryption_key = [0; KEY_LEN];
hkdf.expand(b"doby_encryption_key", &mut encryption_key).unwrap();
let mut authentication_key = [0; KEY_LEN];
hkdf.expand(b"doby_authentication_key", &mut authentication_key).unwrap();
master_key.zeroize();
pub fn new(password: &[u8], params: &EncryptionParams) -> Self {
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params.argon2.clone());
let mut master_key = [0; KEY_LEN];
argon2.hash_password_into(password, &params.salt, &mut master_key).unwrap();
let hkdf = Hkdf::<Blake2b>::new(Some(&params.salt), &master_key);
master_key.zeroize();
let mut nonce = vec![0; params.cipher.get_nonce_size()];
hkdf.expand(b"doby_nonce", &mut nonce).unwrap();
let mut encryption_key = [0; KEY_LEN];
hkdf.expand(b"doby_encryption_key", &mut encryption_key).unwrap();
let mut authentication_key = [0; KEY_LEN];
hkdf.expand(b"doby_authentication_key", &mut authentication_key).unwrap();
let mut encoded_params = Vec::with_capacity(params.get_params_len());
params.write(&mut encoded_params).unwrap();
let mut hmac = Hmac::new_from_slice(&authentication_key).unwrap();
authentication_key.zeroize();
hmac.update(&encoded_params);
let mut encoded_params = Vec::with_capacity(EncryptionParams::LEN);
params.write(&mut encoded_params).unwrap();
let mut hasher = VarBlake2b::new_keyed(&authentication_key, HMAC_LEN);
authentication_key.zeroize();
hasher.update(&encoded_params);
let cipher: Box<dyn StreamCipher> = match params.cipher {
CipherAlgorithm::AesCtr => Box::new(Aes256Ctr::new_from_slices(&encryption_key, &params.nonce).unwrap()),
CipherAlgorithm::XChaCha20 => Box::new(XChaCha20::new_from_slices(&encryption_key, &params.nonce).unwrap()),
};
encryption_key.zeroize();
let cipher: Box