Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
61b8bb5e49 | |||
52c79d28a7 | |||
956aea50ef | |||
2baece0122 | |||
dbd4563e77 | |||
89be84860d | |||
10153f6316 | |||
1f50973381 | |||
311677d195 | |||
2c8ab7e8ad | |||
382ce3c389 | |||
9521528980 | |||
1f2e860a6f | |||
f6fe3912c6 | |||
882a917971 | |||
9ac3bd7a53 | |||
9e176d898f | |||
b6ab4777ce |
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
.DS_Store
|
||||
/target
|
||||
/local
|
||||
/local
|
||||
/.vscode
|
||||
|
68
Cargo.lock
generated
68
Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
|
17
Cargo.toml
17
Cargo.toml
@ -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
156
README.md
@ -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
63
completions/bash
Normal 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
19
completions/zsh
Normal 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
1
man/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/*.gz
|
18
man/compile.sh
Executable file
18
man/compile.sh
Executable 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
88
man/source.md
Normal 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
3
packaging/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
*.deb
|
||||
*.gz
|
||||
*.pkg.tar.zst
|
7
packaging/deb/doby/DEBIAN/control
Normal file
7
packaging/deb/doby/DEBIAN/control
Normal 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
71
packaging/package.sh
Executable 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
18
packaging/pkg/PKGBUILD
Normal 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
|
||||
}
|
1
packaging/tarball/doby/completions
Symbolic link
1
packaging/tarball/doby/completions
Symbolic link
@ -0,0 +1 @@
|
||||
../../../completions
|
28
packaging/tarball/doby/install.sh
Executable file
28
packaging/tarball/doby/install.sh
Executable 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
|
11
packaging/tarball/doby/uninstall.sh
Executable file
11
packaging/tarball/doby/uninstall.sh
Executable 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
|
@ -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(), ¶ms).unwrap();
|
||||
let cipher = DobyCipher::new(PASSWORD.as_bytes(), ¶ms);
|
||||
let t_encrypt = Instant::now();
|
||||
encrypt(&mut reader, &mut writer, ¶ms, 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(), ¶ms).unwrap();
|
||||
let cipher = DobyCipher::new(PASSWORD.as_bytes(), ¶ms);
|
||||
let t_decrypt = Instant::now();
|
||||
decrypt(&mut reader, &mut writer, cipher, block_size)?;
|
||||
writer.flush()?;
|
||||
|
17
src/bin/compgen.rs
Normal file
17
src/bin/compgen.rs
Normal 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>");
|
||||
}
|
||||
}
|
@ -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(())
|
||||
}
|
121
src/cli.rs
121
src/cli.rs
@ -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
|
||||
}
|
||||
}
|
||||
|
196
src/crypto.rs
196
src/crypto.rs
@ -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(), ¶ms.salt, &[], &mut master_key).zeroize(password)?;
|
||||
let hkdf = Hkdf::<blake2::Blake2b>::new(Some(¶ms.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, ¶ms.salt, &mut master_key).unwrap();
|
||||
let hkdf = Hkdf::<Blake2b>::new(Some(¶ms.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, ¶ms.nonce).unwrap()),
|
||||
CipherAlgorithm::XChaCha20 => Box::new(XChaCha20::new_from_slices(&encryption_key, ¶ms.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
|
||||
@ -192,45 +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!(params.get_params_len(), 98);
|
||||
assert_eq!(EncryptionParams::LEN, 77);
|
||||
|
||||
let mut buff = Vec::with_capacity(98);
|
||||
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[74..], params.nonce);
|
||||
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);
|
||||
@ -238,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(), ¶ms).unwrap();
|
||||
let mut enc_cipher = DobyCipher::new(password.as_bytes(), ¶ms);
|
||||
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(), ¶ms).unwrap();
|
||||
let mut decrypted = vec![0; buff.len()+HASH_LEN];
|
||||
let mut dec_cipher = DobyCipher::new(password.as_bytes(), ¶ms);
|
||||
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();
|
||||
|
96
src/lib.rs
96
src/lib.rs
@ -1,72 +1,72 @@
|
||||
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(|s| String::from(s)))
|
||||
Self(s.map(String::from))
|
||||
}
|
||||
}
|
||||
pub enum WrappedWriter<P: AsRef<Path>> {
|
||||
PATH {
|
||||
path: P
|
||||
},
|
||||
WRITER {
|
||||
writer: Box<dyn Write>
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Password {
|
||||
fn from(s: &str) -> Self {
|
||||
Some(s).into()
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
}
|
||||
Self::WRITER { writer: Box::new(writer) }
|
||||
}
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.writer.as_mut().unwrap().flush()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encrypt<R: Read, W: Write>(reader: &mut R, writer: &mut W, params: &EncryptionParams, mut cipher: DobyCipher, block_size: usize, already_read: Option<Vec<u8>>) -> io::Result<()> {
|
||||
pub fn encrypt<R: Read, W: Write>(reader: &mut R, writer: &mut W, params: &EncryptionParams, mut cipher: DobyCipher, block_size: usize, already_read: Option<&[u8]>) -> io::Result<()> {
|
||||
writer.write_all(MAGIC_BYTES)?;
|
||||
params.write(writer)?;
|
||||
let mut buff = vec![0; block_size];
|
||||
@ -97,7 +97,7 @@ pub fn decrypt<R: Read, W: Write>(reader: &mut R, writer: &mut W, mut cipher: Do
|
||||
if n == 0 {
|
||||
break;
|
||||
} else {
|
||||
writer.write(&buff[..n])?;
|
||||
writer.write_all(&buff[..n])?;
|
||||
}
|
||||
}
|
||||
Ok(cipher.verify_hmac())
|
||||
|
86
src/main.rs
86
src/main.rs
@ -1,4 +1,4 @@
|
||||
use std::{process, io::{BufWriter, BufReader, Read}};
|
||||
use std::{process, io::{BufReader, Read}};
|
||||
use doby::{
|
||||
cli,
|
||||
crypto::{EncryptionParams, DobyCipher},
|
||||
@ -6,66 +6,72 @@ use doby::{
|
||||
decrypt,
|
||||
encrypt,
|
||||
};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
fn run() -> bool {
|
||||
let mut success = false;
|
||||
if let Some(cli_args) = cli::parse() {
|
||||
let mut reader = BufReader::with_capacity(cli_args.block_size, cli_args.reader);
|
||||
let mut writer = BufWriter::with_capacity(cli_args.block_size, cli_args.writer);
|
||||
if let Some(result) = cli::parse() {
|
||||
if let Some(cli_args) = result.cli_args {
|
||||
let mut reader = BufReader::new(cli_args.reader);
|
||||
|
||||
let mut magic_bytes = vec![0; MAGIC_BYTES.len()];
|
||||
match reader.read(&mut magic_bytes) {
|
||||
Ok(n) => {
|
||||
if n < magic_bytes.len() {
|
||||
magic_bytes.truncate(n);
|
||||
}
|
||||
if magic_bytes == MAGIC_BYTES && !cli_args.force_encrypt { //we probably want to decrypt
|
||||
match EncryptionParams::read(&mut reader) {
|
||||
Ok(params) => {
|
||||
match params {
|
||||
Some(params) => {
|
||||
match DobyCipher::new(cli_args.password, ¶ms) {
|
||||
Ok(cipher) => {
|
||||
let mut magic_bytes = vec![0; MAGIC_BYTES.len()];
|
||||
match reader.read(&mut magic_bytes) {
|
||||
Ok(n) => {
|
||||
if magic_bytes == MAGIC_BYTES && !cli_args.force_encrypt { //we probably want to decrypt
|
||||
match EncryptionParams::read(&mut reader) {
|
||||
Ok(params) => {
|
||||
if let Some(params) = params {
|
||||
if let Some(mut password) = cli_args.password.get(false) {
|
||||
if let Some(mut writer) = cli_args.writer.into_buf_writer() {
|
||||
let cipher = DobyCipher::new(password.as_bytes(), ¶ms);
|
||||
password.zeroize();
|
||||
match decrypt(&mut reader, &mut writer, cipher, cli_args.block_size) {
|
||||
Ok(verified) => {
|
||||
if verified {
|
||||
success = true
|
||||
} else {
|
||||
eprintln!("WARNING: HMAC verification failed !\nEither your password is incorrect or the ciphertext has been corrupted.\nBe careful, the data could have been altered by an attacker.");
|
||||
eprintln!("Warning: HMAC verification failed !\nEither your password is incorrect or the ciphertext has been corrupted.\nBe careful, the data could have been altered by an attacker.");
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("I/O error while decrypting: {}", e)
|
||||
}
|
||||
} else {
|
||||
password.zeroize();
|
||||
}
|
||||
Err(e) => eprintln!("Invalid argon2 params: {}", e)
|
||||
}
|
||||
} else {
|
||||
eprintln!("Error: invalid encryption parameters")
|
||||
}
|
||||
None => eprintln!("Invalid cipher")
|
||||
}
|
||||
Err(e) => eprintln!("I/O error while reading headers: {}", e)
|
||||
}
|
||||
} else { //otherwise, encrypt
|
||||
let params = EncryptionParams::new(cli_args.argon2_params, cli_args.cipher);
|
||||
if let Some(mut password) = cli_args.password.get(true) {
|
||||
if let Some(mut writer) = cli_args.writer.into_buf_writer() {
|
||||
let cipher = DobyCipher::new(password.as_bytes(), ¶ms);
|
||||
password.zeroize();
|
||||
match encrypt(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
¶ms,
|
||||
cipher,
|
||||
cli_args.block_size,
|
||||
Some(&magic_bytes[..n])
|
||||
) {
|
||||
Ok(_) => success = true,
|
||||
Err(e) => eprintln!("I/O error while encrypting: {}", e)
|
||||
}
|
||||
} else {
|
||||
password.zeroize();
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("I/O error while reading headers: {}", e)
|
||||
}
|
||||
} else { //otherwise, encrypt
|
||||
let params = EncryptionParams::new(cli_args.argon2_params, cli_args.cipher);
|
||||
match DobyCipher::new(cli_args.password, ¶ms) {
|
||||
Ok(cipher) => {
|
||||
match encrypt(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
¶ms,
|
||||
cipher,
|
||||
cli_args.block_size,
|
||||
Some(magic_bytes)
|
||||
) {
|
||||
Ok(_) => success = true,
|
||||
Err(e) => eprintln!("I/O error while encrypting: {}", e)
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Invalid argon2 params: {}", e)
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("I/O error while reading magic bytes: {}", e),
|
||||
}
|
||||
Err(e) => eprintln!("I/O error while reading magic bytes: {}", e),
|
||||
} else {
|
||||
success = !result.error;
|
||||
}
|
||||
}
|
||||
success
|
||||
|
@ -1,7 +1,6 @@
|
||||
use rand::Rng;
|
||||
use doby::{
|
||||
crypto::{
|
||||
ArgonParams,
|
||||
CipherAlgorithm,
|
||||
EncryptionParams,
|
||||
DobyCipher,
|
||||
@ -19,17 +18,17 @@ fn different_elements<T: Eq>(v1: &Vec<T>, v2: &Vec<T>) -> usize {
|
||||
fn authentication() {
|
||||
const BLOCK_SIZE: usize = 65536;
|
||||
const PLAINTEXT: &[u8; 13] = b"the plaintext";
|
||||
const CIPHERTEXT_SIZE: usize = PLAINTEXT.len()+113;
|
||||
const PASSWORD: &str = "the password";
|
||||
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 encrypter = DobyCipher::new(PASSWORD.into(), ¶ms).unwrap();
|
||||
let mut ciphertext = Vec::with_capacity(PLAINTEXT.len()+158);
|
||||
let encrypter = DobyCipher::new(PASSWORD.as_bytes(), ¶ms);
|
||||
let mut ciphertext = Vec::with_capacity(CIPHERTEXT_SIZE);
|
||||
encrypt(&mut &PLAINTEXT[..], &mut ciphertext, ¶ms, encrypter, BLOCK_SIZE, None).unwrap();
|
||||
assert_eq!(ciphertext.len(), PLAINTEXT.len()+158);
|
||||
assert_eq!(ciphertext.len(), CIPHERTEXT_SIZE);
|
||||
|
||||
for i in 0..ciphertext.len() {
|
||||
let mut compromised = ciphertext.clone();
|
||||
@ -37,15 +36,15 @@ fn authentication() {
|
||||
compromised[i] = rand::thread_rng().gen();
|
||||
}
|
||||
assert_eq!(different_elements(&compromised, &ciphertext), 1);
|
||||
let decrypter = DobyCipher::new(PASSWORD.into(), ¶ms).unwrap();
|
||||
let decrypter = DobyCipher::new(PASSWORD.as_bytes(), ¶ms);
|
||||
let mut decrypted = Vec::with_capacity(PLAINTEXT.len());
|
||||
let verified = decrypt(&mut &compromised[..], &mut decrypted, decrypter, BLOCK_SIZE).unwrap();
|
||||
assert_eq!(verified, false);
|
||||
}
|
||||
|
||||
let decrypter = DobyCipher::new(PASSWORD.into(), ¶ms).unwrap();
|
||||
let decrypter = DobyCipher::new(PASSWORD.as_bytes(), ¶ms);
|
||||
let mut decrypted = Vec::with_capacity(PLAINTEXT.len());
|
||||
let verified = decrypt(&mut &ciphertext[4+params.get_params_len()..], &mut decrypted, decrypter, BLOCK_SIZE).unwrap();
|
||||
let verified = decrypt(&mut &ciphertext[4+EncryptionParams::LEN..], &mut decrypted, decrypter, BLOCK_SIZE).unwrap();
|
||||
assert_eq!(decrypted, PLAINTEXT);
|
||||
assert_eq!(verified, true);
|
||||
}
|
27
tests/cli.rs
27
tests/cli.rs
@ -1,7 +1,7 @@
|
||||
use std::{convert::TryInto, fs::{self, File, create_dir}, io::{self, Read, Write}, path::PathBuf};
|
||||
use assert_cmd::{Command, cargo::{CargoError, cargo_bin}};
|
||||
use tempfile::TempDir;
|
||||
use doby::crypto::{CipherAlgorithm, SALT_LEN, HASH_LEN};
|
||||
use doby::crypto::{CipherAlgorithm, SALT_LEN, HMAC_LEN};
|
||||
|
||||
const PLAINTEXT: &[u8] = b"the plaintext";
|
||||
const PASSWORD: &str = "the password";
|
||||
@ -22,7 +22,7 @@ fn setup_files<'a>() -> io::Result<(PathBuf, PathBuf, PathBuf)> {
|
||||
|
||||
fn doby_cmd() -> Result<Command, CargoError> {
|
||||
let mut cmd = Command::cargo_bin("doby")?;
|
||||
cmd.arg("-p").arg(PASSWORD);
|
||||
cmd.arg("--password").arg(PASSWORD);
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ fn files() -> io::Result<()> {
|
||||
fn stdout() -> io::Result<()> {
|
||||
let (_, tmp_plaintext, tmp_ciphertext) = setup_files()?;
|
||||
|
||||
let shell_cmd = format!("{} -p \"{}\" {} > {}", cargo_bin("doby").to_str().unwrap(), PASSWORD, tmp_plaintext.to_str().unwrap(), tmp_ciphertext.to_str().unwrap());
|
||||
let shell_cmd = format!("{} --password \"{}\" {} > {}", cargo_bin("doby").to_str().unwrap(), PASSWORD, tmp_plaintext.to_str().unwrap(), tmp_ciphertext.to_str().unwrap());
|
||||
bash_cmd().arg(shell_cmd).assert().success().stdout("").stderr("");
|
||||
|
||||
doby_cmd().unwrap().arg(tmp_ciphertext).assert().success().stdout(PLAINTEXT);
|
||||
@ -64,10 +64,10 @@ fn stdout() -> io::Result<()> {
|
||||
fn stdin() -> io::Result<()> {
|
||||
let (_, tmp_plaintext, tmp_ciphertext) = setup_files()?;
|
||||
|
||||
let mut shell_cmd = format!("cat {} | {} -p \"{}\" - {}", tmp_plaintext.to_str().unwrap(), cargo_bin("doby").to_str().unwrap(), PASSWORD, tmp_ciphertext.to_str().unwrap());
|
||||
let mut shell_cmd = format!("cat {} | {} --password \"{}\" - {}", tmp_plaintext.to_str().unwrap(), cargo_bin("doby").to_str().unwrap(), PASSWORD, tmp_ciphertext.to_str().unwrap());
|
||||
bash_cmd().arg(shell_cmd).assert().success().stdout("").stderr("");
|
||||
|
||||
shell_cmd = format!("cat {} | {} -p \"{}\"", tmp_ciphertext.to_str().unwrap(), cargo_bin("doby").to_str().unwrap(), PASSWORD);
|
||||
shell_cmd = format!("cat {} | {} --password \"{}\"", tmp_ciphertext.to_str().unwrap(), cargo_bin("doby").to_str().unwrap(), PASSWORD);
|
||||
bash_cmd().arg(shell_cmd).assert().success().stdout(PLAINTEXT);
|
||||
|
||||
Ok(())
|
||||
@ -85,7 +85,7 @@ fn force_encrypt() -> io::Result<()> {
|
||||
let buff_ciphertext_2 = fs::read(&tmp_ciphertext_2)?;
|
||||
assert_ne!(buff_ciphertext_1, buff_ciphertext_2);
|
||||
assert_ne!(buff_ciphertext_2, PLAINTEXT);
|
||||
assert!(buff_ciphertext_2.len() >= buff_ciphertext_1.len()+158);
|
||||
assert!(buff_ciphertext_2.len() >= buff_ciphertext_1.len()+113);
|
||||
|
||||
let tmp_decrypted_1 = tmp_path.join("decrypted_1");
|
||||
doby_cmd().unwrap().arg(tmp_ciphertext_2).arg(&tmp_decrypted_1).assert().success().stdout("").stderr("");
|
||||
@ -107,8 +107,8 @@ fn test_cipher(cipher_str: &str, cipher_algorithm: CipherAlgorithm) -> io::Resul
|
||||
doby_cmd().unwrap().arg("-c").arg(cipher_str).arg(tmp_plaintext).arg(&tmp_ciphertext).assert().success().stdout("").stderr("");
|
||||
|
||||
let ciphertext = fs::read(&tmp_ciphertext)?;
|
||||
assert_eq!(ciphertext[4+SALT_LEN+4*2+1], cipher_algorithm as u8);
|
||||
assert_eq!(ciphertext.len(), PLAINTEXT.len()+14+SALT_LEN+HASH_LEN+cipher_algorithm.get_nonce_size());
|
||||
assert_eq!(ciphertext[4+SALT_LEN+4*3], cipher_algorithm as u8);
|
||||
assert_eq!(ciphertext.len(), PLAINTEXT.len()+17+SALT_LEN+HMAC_LEN);
|
||||
|
||||
doby_cmd().unwrap().arg(tmp_ciphertext).assert().success().stdout(PLAINTEXT).stderr("");
|
||||
|
||||
@ -127,17 +127,16 @@ fn aes_cipher() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn argon2_params() -> io::Result<()> {
|
||||
Command::cargo_bin("doby").unwrap().arg("-i").arg("0").assert().failure().stderr("Invalid argon2 params: time cost is too small\n");
|
||||
Command::cargo_bin("doby").unwrap().arg("-m").arg("0").assert().failure().stderr("Invalid argon2 params: memory cost is too small\n");
|
||||
Command::cargo_bin("doby").unwrap().arg("-t").arg("0").assert().failure().stderr("Invalid argon2 params: too few lanes\n");
|
||||
Command::cargo_bin("doby").unwrap().arg("-t").arg("0").assert().failure().stderr("Invalid Argon2 parameters: time cost is too small\n");
|
||||
Command::cargo_bin("doby").unwrap().arg("-m").arg("0").assert().failure().stderr("Invalid Argon2 parameters: memory cost is too small\n");
|
||||
Command::cargo_bin("doby").unwrap().arg("-p").arg("0").assert().failure().stderr("Invalid Argon2 parameters: not enough threads\n");
|
||||
|
||||
let ciphertext = doby_cmd().unwrap().arg("-i").arg("8").arg("-m").arg("2048").arg("-t").arg("8").assert().success().stderr("").get_output().stdout.clone();
|
||||
let ciphertext = doby_cmd().unwrap().arg("-t").arg("8").arg("-m").arg("2048").arg("-p").arg("8").assert().success().stderr("").get_output().stdout.clone();
|
||||
assert_eq!(u32::from_be_bytes(ciphertext[4+SALT_LEN..4+SALT_LEN+4].try_into().unwrap()), 8); //time cost
|
||||
assert_eq!(u32::from_be_bytes(ciphertext[4+SALT_LEN+4..4+SALT_LEN+8].try_into().unwrap()), 2048); //memory cost
|
||||
assert_eq!(u8::from_be_bytes([ciphertext[4+SALT_LEN+8]]), 8); //parallelism
|
||||
assert_eq!(u32::from_be_bytes(ciphertext[4+SALT_LEN+8..4+SALT_LEN+12].try_into().unwrap()), 8); //parallelism
|
||||
|
||||
Ok(())
|
||||
}
|
Loading…
Reference in New Issue
Block a user