Compare commits

...

11 Commits

25 changed files with 672 additions and 302 deletions

3
.gitignore vendored
View File

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

4
Cargo.lock generated
View File

@ -210,7 +210,7 @@ dependencies = [
[[package]]
name = "doby"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"aes",
"argon2",
@ -220,10 +220,10 @@ dependencies = [
"clap",
"cpufeatures 0.2.1",
"hkdf",
"hmac",
"num_enum",
"rand",
"rpassword",
"subtle",
"tempfile",
"zeroize",
]

View File

@ -1,11 +1,12 @@
[package]
name = "doby"
version = "0.2.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
@ -21,7 +22,7 @@ num_enum = "0.5"
cpufeatures = "0.2"
aes = { version = "0.7", features = ["ctr"] }
chacha20 = "0.8"
hmac = "0.11"
subtle = "2.4"
blake2 = "0.9"
hkdf = "0.11"
argon2 = "0.3"

View File

@ -1,19 +1,19 @@
# 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 142 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
@ -28,7 +28,7 @@ Decryption:
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
@ -40,19 +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:
```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:
```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
@ -63,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]
@ -82,7 +83,7 @@ ARGS:
# Installation
You can download doby from the "Releases" section in this repo.
All binaries MUST be signed with my PGP key available on keyservers. To import it:
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
```
@ -95,15 +96,25 @@ gpg --verify <the file>
```
__Don't continue if the verification fails!__
If everything goes fine, you can compute the SHA-256 hash of the binary file you want to verify:
If everything goes fine, you can download the package corresponding to your distribution. To verify it, compute its SHA-256 hash:
```bash
sha256sum <doby binary file>
sha256sum <file>
```
Compare this output and the hash in the PGP-signed message. __Don't execute the file if the hashes don't match!__
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.
You can make available doby in your `$PATH` by running:
On debian:
```bash
sudo cp <doby binary file> /usr/local/bin/
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
@ -119,6 +130,8 @@ 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.
@ -146,12 +159,14 @@ 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
@ -214,7 +229,7 @@ So here is what an encrypted file layout looks like:
</tr>
<tr>
<th align="left">HMAC</th>
<td>64 bytes</td>
<td>32 bytes</td>
</tr>
</table>
@ -251,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,19 +97,24 @@ 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")
@ -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,34 +1,20 @@
use std::{convert::{TryFrom, TryInto}, 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, Clone, Copy)]
pub struct ArgonParams {
pub t_cost: u32,
pub m_cost: u32,
pub parallelism: u8,
}
impl TryFrom<ArgonParams> for argon2::Params {
type Error = argon2::Error;
fn try_from(params: ArgonParams) -> Result<Self, Self::Error> {
argon2::Params::new(params.m_cost, params.t_cost, params.parallelism.into(), None)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, TryFromPrimitive)]
#[repr(u8)]
pub enum CipherAlgorithm {
@ -57,14 +43,14 @@ impl Display for CipherAlgorithm {
#[derive(Debug, PartialEq, Eq)]
pub struct EncryptionParams {
salt: [u8; SALT_LEN],
pub argon2: ArgonParams,
pub argon2: argon2::Params,
pub cipher: CipherAlgorithm,
}
impl EncryptionParams {
pub const LEN: usize = SALT_LEN + 4*2 + 2;
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);
EncryptionParams {
@ -76,9 +62,9 @@ impl EncryptionParams {
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())?;
Ok(())
}
@ -90,97 +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 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,
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 params.argon2.try_into() {
Ok(argon2_params) => {
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut master_key = [0; KEY_LEN];
let password = password.unwrap_or_ask();
argon2.hash_password_into(password.as_bytes(), &params.salt, &mut master_key).zeroize(password)?;
let hkdf = Hkdf::<blake2::Blake2b>::new(Some(&params.salt), &master_key);
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();
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(EncryptionParams::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, &nonce).unwrap()),
CipherAlgorithm::XChaCha20 => Box::new(XChaCha20::new_from_slices(&encryption_key, &nonce).unwrap()),
};
encryption_key.zeroize();
let cipher: Box<dyn StreamCipher> = match params.cipher {
CipherAlgorithm::AesCtr => Box::new(Aes256Ctr::new_from_slices(&encryption_key, &nonce).unwrap()),
CipherAlgorithm::XChaCha20 => Box::new(XChaCha20::new_from_slices(&encryption_key, &nonce).unwrap()),
};
encryption_key.zeroize();
Ok(Self {
cipher,
hmac,
buffer: Vec::new(),
})
}
Err(e) => {
password.zeroize();
Err(e)
}
Self {
cipher,
hasher,
buffer: Vec::new(),
}
}
pub fn encrypt_chunk<W: Write>(&mut self, buff: &mut [u8], writer: &mut W) -> io::Result<()> {
self.cipher.apply_keystream(buff);
self.hmac.update(buff);
self.hasher.update(&buff);
writer.write_all(buff)
}
pub fn write_hmac<W: Write>(self, writer: &mut W) -> io::Result<usize> {
let tag = self.hmac.finalize().into_bytes();
writer.write(&tag)
pub fn write_hmac<W: Write>(self, writer: &mut W) -> io::Result<()> {
writer.write_all(&self.hasher.finalize_boxed())
}
//buff size must be > to HASH_LEN
@ -189,44 +153,43 @@ impl DobyCipher {
buff[..buffer_len].clone_from_slice(&self.buffer);
let read = reader.read(&mut buff[buffer_len..])?;
let n = if buffer_len + read >= HASH_LEN {
let n = if buffer_len + read >= HMAC_LEN {
self.buffer.clear();
buffer_len + read - HASH_LEN
buffer_len + read - HMAC_LEN
} else {
0
};
self.buffer.extend_from_slice(&buff[n..buffer_len+read]);
self.hmac.update(&buff[..n]);
self.hasher.update(&buff[..n]);
self.cipher.apply_keystream(&mut buff[..n]);
Ok(n)
}
pub fn verify_hmac(self) -> bool {
self.hmac.verify(&self.buffer).is_ok()
self.hasher.finalize_boxed().ct_eq(&self.buffer).into()
}
}
#[cfg(test)]
mod tests {
use super::{ArgonParams, CipherAlgorithm, EncryptionParams, DobyCipher, HASH_LEN};
use super::{CipherAlgorithm, EncryptionParams, DobyCipher, HMAC_LEN};
#[test]
fn encryption_params() {
let params = EncryptionParams::new(ArgonParams {
t_cost: 1,
m_cost: 8,
parallelism: 1,
}, CipherAlgorithm::XChaCha20);
let params = EncryptionParams::new(
argon2::Params::new(8, 1, 1, None).unwrap(),
CipherAlgorithm::XChaCha20
);
assert_eq!(EncryptionParams::LEN, 74);
assert_eq!(EncryptionParams::LEN, 77);
let mut buff = Vec::with_capacity(74);
params.write(&mut buff).unwrap();
assert_eq!(buff[..64], params.salt);
assert_eq!(buff[64..68], vec![0, 0, 0, 0x01]); //t_cost
assert_eq!(buff[68..72], vec![0, 0, 0, 0x08]); //m_cost
assert_eq!(buff[72], 0x01); //parallelism
assert_eq!(buff[73], CipherAlgorithm::XChaCha20 as u8);
assert_eq!(buff[72..76], vec![0, 0, 0, 0x01]); //p_cost
assert_eq!(buff[76], CipherAlgorithm::XChaCha20 as u8);
let new_params = EncryptionParams::read(&mut buff.as_slice()).unwrap().unwrap();
assert_eq!(new_params, params);
@ -234,25 +197,24 @@ mod tests {
#[test]
fn doby_cipher() {
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 password = "I like spaghetti";
let plaintext = b"but I love so much to listen to HARDCORE music on big subwoofer";
let mut buff: [u8; 63] = *plaintext;
let mut vec = Vec::with_capacity(buff.len()+HASH_LEN);
let mut vec = Vec::with_capacity(buff.len()+HMAC_LEN);
let mut enc_cipher = DobyCipher::new(password.into(), &params).unwrap();
let mut enc_cipher = DobyCipher::new(password.as_bytes(), &params);
enc_cipher.encrypt_chunk(&mut buff, &mut vec).unwrap();
assert_ne!(buff, *plaintext);
assert_eq!(buff, vec.as_slice());
assert_eq!(enc_cipher.write_hmac(&mut vec).unwrap(), HASH_LEN);
assert_eq!(vec.len(), buff.len()+HASH_LEN);
assert!(enc_cipher.write_hmac(&mut vec).is_ok());
assert_eq!(vec.len(), buff.len()+HMAC_LEN);
let mut dec_cipher = DobyCipher::new(password.into(), &params).unwrap();
let mut decrypted = vec![0; buff.len()+HASH_LEN];
let mut dec_cipher = DobyCipher::new(password.as_bytes(), &params);
let mut decrypted = vec![0; buff.len()+HMAC_LEN];
let mut n = dec_cipher.decrypt_chunk(&mut vec.as_slice(), &mut decrypted[..]).unwrap();
assert_eq!(n, buff.len());
n = dec_cipher.decrypt_chunk(&mut &vec[n..], &mut decrypted[n..]).unwrap();

View File

@ -1,68 +1,68 @@
pub mod cli;
pub mod crypto;
use std::{fs::File, path::Path, io::{self, Read, Write}};
use std::{fmt::Display, fs::OpenOptions, io::{self, BufWriter, Read, Write}, path::Path};
use crypto::{DobyCipher, EncryptionParams};
use zeroize::Zeroize;
pub const MAGIC_BYTES: &[u8; 4] = b"DOBY";
pub struct Password(Option<String>);
pub struct WrappedPassword(Option<String>);
impl Password {
fn unwrap_or_ask(self) -> String {
self.0.unwrap_or_else(|| rpassword::read_password_from_tty(Some("Password: ")).unwrap())
impl WrappedPassword {
pub fn get(self, ask_confirm: bool) -> Option<String> {
self.0.or_else(|| {
let mut password = rpassword::read_password_from_tty(Some("Password: ")).ok()?;
if ask_confirm {
let mut password_confirm = rpassword::read_password_from_tty(Some("Password (confirm): ")).ok()?;
if password == password_confirm {
password_confirm.zeroize();
Some(password)
} else {
password.zeroize();
password_confirm.zeroize();
eprintln!("Error: passwords don't match");
None
}
} else {
Some(password)
}
})
}
}
impl From<Option<&str>> for Password {
impl From<Option<&str>> for WrappedPassword {
fn from(s: Option<&str>) -> Self {
Self(s.map(String::from))
}
}
impl From<&str> for Password {
fn from(s: &str) -> Self {
Some(s).into()
pub enum WrappedWriter<P: AsRef<Path>> {
PATH {
path: P
},
WRITER {
writer: Box<dyn Write>
}
}
impl Zeroize for Password {
fn zeroize(&mut self) {
self.0.zeroize()
}
}
pub struct LazyWriter<P: AsRef<Path>> {
path: Option<P>,
writer: Option<Box<dyn Write>>,
}
impl<P: AsRef<Path>> LazyWriter<P> {
impl<P: AsRef<Path> + Display> WrappedWriter<P> {
fn from_path(path: P) -> Self {
Self {
path: Some(path),
writer: None,
}
Self::PATH { path }
}
fn from_writer<T: 'static + Write>(writer: T) -> Self {
Self {
path: None,
writer: Some(Box::new(writer)),
}
}
}
impl<P: AsRef<Path>> Write for LazyWriter<P> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.writer.is_none() {
self.writer = Some(Box::new(File::create(self.path.as_ref().unwrap()).unwrap()));
}
self.writer.as_mut().unwrap().write(buf)
Self::WRITER { writer: Box::new(writer) }
}
fn flush(&mut self) -> io::Result<()> {
self.writer.as_mut().unwrap().flush()
pub fn into_buf_writer(self) -> Option<BufWriter<Box<dyn Write>>> {
Some(BufWriter::new(match self {
Self::PATH { path } => Box::new(
OpenOptions::new().write(true).create(true).truncate(true).open(path.as_ref())
.map_err(|e| eprintln!("{}: {}", path, e))
.ok()?
) as Box<dyn Write>,
Self::WRITER { writer } => writer,
}))
}
}

View File

@ -1,4 +1,4 @@
use std::{process, io::{BufWriter, BufReader, Read}};
use std::{process, io::{BufReader, Read}};
use doby::{
cli,
crypto::{EncryptionParams, DobyCipher},