Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ version = "0.1.3"
# *** Internal Dependencies ***
bouncycastle = { path = "./" }
bouncycastle-aes = { path = "./crypto/aes" }
bouncycastle-ascon = { path = "./crypto/ascon" }
bouncycastle-base64 = { path = "./crypto/base64" }
bouncycastle-modes = { path = "./crypto/modes" }
bouncycastle-core = { path = "crypto/core" }
Expand Down Expand Up @@ -46,6 +47,7 @@ edition.workspace = true

[dependencies]
bouncycastle-aes.workspace = true
bouncycastle-ascon.workspace = true
bouncycastle-base64.workspace = true
bouncycastle-core.workspace = true
bouncycastle-factory.workspace = true
Expand Down
80 changes: 78 additions & 2 deletions alpha_0.1.3_release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,8 +447,10 @@ Testing:
five strengths, which a key shorter than 32 bytes cannot carry, so the framework panicked for
any 16- or 24-byte key. It now skips the strengths the key length cannot hold. The bug was
invisible until now because nothing in the workspace implemented the block cipher traits. The
identical loop in `TestFrameworkSimpleCipher` and `TestFrameworkAEADCipher` is still unfixed;
both still have no implementors, so it stays latent.
identical loop in `TestFrameworkSimpleCipher` and `TestFrameworkAEADCipher` got the same fix in
the same PR, and each also gained a `strengths_tested > 0` assertion so the sweep cannot silently
become vacuous again. `bouncycastle-ascon`'s `AsconAead128Encryptor`/`AsconAead128Decryptor`
(16-byte key) are now the first implementors to actually exercise the AEAD suite's guard.
* `TestFrameworkStreamCipher::test` was a `todo!()` and is now implemented for the
`StreamCipherEncryptor` / `StreamCipherDecryptor` pair, carrying the same key-length guard as the
block suite from the start. It pins the paired contract: one-shot round trips, streaming in nine
Expand Down Expand Up @@ -482,6 +484,80 @@ Testing:
the new block cipher traits, covering every data length, ten chunkings in both directions, tampering, malformed
lengths, and buffer sizing. Criterion bench included.

`core`: new `AEADCipherEncryptor<KEY_LEN, NONCE_LEN, TAG_LEN, FINAL_LEN>` and
`AEADCipherDecryptor<KEY_LEN, NONCE_LEN, TAG_LEN, FINAL_LEN>` traits (#119/#120), the streaming API
for an authenticated cipher, shaped like `SimpleCipherEncryptor` / `SimpleCipherDecryptor` (separate
input/output buffers, exact `update_out_len`, generated nonce) with the two things authentication
adds: an AAD phase (`do_update_aad`, repeatable before the first `do_update_out`, refused with
`StateError` once data has started) and a finalizer that also produces the tag
(`do_encrypt_final`/`do_decrypt_final`, flushing up to `FINAL_LEN` held-back bytes alongside it).
`FINAL_LEN` is `0` for a cipher like Ascon-AEAD128 that never buffers; a block-oriented AEAD or one
whose wire format inlines the tag would need it non-zero. The one-shots (`encrypt_out[_rng]`,
`decrypt_out`, and the `std` `Vec` forms) are provided over the streaming methods, so an implementor
writes seven. `bouncycastle-ascon`'s `AsconAead128Encryptor` / `AsconAead128Decryptor` are the first
implementors.

Mutation-tested with `cargo mutants -p bouncycastle-core -F 'AEADCipher(Encryptor|Decryptor)'
--test-package bouncycastle-ascon` (`core` has no implementor of its own to test against): 68
mutants, 49 caught, 10 unviable, 9 missed -- all nine equivalent given `FINAL_LEN = 0`, the only
value Ascon-AEAD128 exercises. Six are `written + final_len` vs `written - final_len` in
`encrypt_out`/`encrypt_out_rng`/`decrypt_out`'s final-buffer splice, indistinguishable because
`final_len` is always `0` there; the other three are the one-shots' own buffer-length guard
(`plaintext.len() < needed` / `ciphertext.len() < needed`) against `>`, indistinguishable because
`needed` at `FINAL_LEN = 0` is exactly the bound Ascon's own `do_update_out` already enforces one
call deeper, so the outer guard's direction is never the only thing standing between a short buffer
and an error. A future `FINAL_LEN > 0` implementor (a block-oriented AEAD) would give both classes
of mutant something to bite on.

Where the tag goes is deliberately not fixed by the pair (contrast `AEADCipher`, whose one-shots
pick a layout): `core::tagged_aead::TaggedEncryptor<E>` / `TaggedDecryptor<D, TAG_LEN>` adapt any
`FINAL_LEN = 0` implementor to `SimpleCipherEncryptor` / `SimpleCipherDecryptor`, producing and
consuming the inline `ciphertext || tag` layout most wire formats and files use, with the AAD phase
still reachable through an inherent `do_update_aad` the `SimpleCipher*` traits have no slot for.
`TaggedDecryptor` holds back exactly the last `TAG_LEN` bytes it has seen at any point, releasing
everything older through the wrapped decryptor as soon as it is known not to be the tag -- the same
technique `bc-rust`'s `ascon-aead128 --decrypt` used by hand before this adapter existed, now
provided once. (A fully general adapter over a implementor whose own `FINAL_LEN` is non-zero needs
this adapter's `FINAL_LEN` to be `INNER_FINAL_LEN + TAG_LEN`, a value derived from two other const
generics that stable const generics cannot express as a trait argument; left to a future adapter.)

New crate `bouncycastle-ascon` (`bouncycastle::ascon`): Ascon-AEAD128 / Ascon-Hash256 / Ascon-XOF128
/ Ascon-CXOF128 (NIST SP 800-232), the lightweight cryptography suite selected from the NIST
Lightweight Cryptography competition.

* `AsconAead128` is the streaming primitive (rate 128 bits, capacity 192 bits, `Ascon-p[12]` at
init/finalization and `Ascon-p[8]` on AAD/data blocks), with a caller-supplied nonce for KAT and
protocol use. Every plaintext/ciphertext byte is transformed and emitted the moment it is seen --
no held-back buffering across calls -- because within a rate block each byte is independent of
the others in it; this is what lets its finalizers have nothing left to flush.
`AsconAead128Encryptor` / `AsconAead128Decryptor` are thin newtypes over it implementing the new
`AEADCipherEncryptor` / `AEADCipherDecryptor` pair with an internally-generated nonce; `AsconAead128`
itself keeps implementing the one-shot-only `AEADCipher` (both directions on one type, chosen by a
runtime flag), which the newtype split cannot replace since that trait needs both directions
available on a single implementor.
* `AsconHash256` (`Hash`) and `AsconXof128` (`XOF`) are sponge constructions over the same
permutation; `AsconCXof128` (`XOF`) adds the customization string of SP 800-232 Algorithm 7 (up to
256 bytes). All four are byte-oriented: `do_final_partial_bits`/the equivalent XOF methods always
return an error rather than accept a partial final byte, unlike SHA-2/SHA-3. Registered in
`HashFactory` (`"Ascon-Hash256"`) and `XOFFactory` (`"Ascon-XOF128"`), with `ascon-hash256`,
`ascon-xof128`, `ascon-cxof128` and `ascon-aead128` CLI subcommands; the last streams both
directions in 1 KiB chunks, decrypting through `TaggedDecryptor` rather than a hand-rolled tail
buffer.
* **Decryption releases plaintext before the tag is checked**, streaming or through the CLI: bytes
are necessarily written to the caller's buffer (or stdout) before the last `TAG_LEN` bytes -- the
tag -- can be read and compared. A non-zero exit from the CLI, or an `Err` from the streaming
finalizer, means the input was tampered with and any output already produced must be discarded;
do not treat it as authentic before that point. The one-shot APIs (`AsconAead128::decrypt`, both
`AEADCipher` and `AEADCipherDecryptor` views) do not have this caveat: they own the whole message
and zeroize the output buffer before returning an error.
* Verified against 4228 NIST LWC KAT vectors from `bc-test-data` (1089 each for AEAD128 and
CXOF128, 1025 each for Hash256 and XOF128), plus embedded always-on vectors for when that
repository is not checked out. Mutation-tested with `cargo mutants -p bouncycastle-ascon`: 665
mutants, 558 caught, 103 unviable, 4 missed -- all four the same equivalent survivors as the
crate's introduction (PR #21): the `Sponge::absorb`/`squeeze` boundary pair and the disjoint-bit
`set_state_byte` OR-vs-XOR pair, neither touched by the `AEADCipherEncryptor`/`AEADCipherDecryptor`
work.

## Minor features / bug fixes

* bug fixes to the way SHA3/SHAKE handled absorbing and squeezing a partial final byte.
Expand Down
194 changes: 194 additions & 0 deletions cli/src/ascon_cmd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
use std::io::{self, Read};
use std::process::exit;

use bouncycastle::ascon::ascon_aead128::{AsconAead128, AsconAead128Decryptor};
use bouncycastle::ascon::ascon_cxof128::AsconCXof128;
use bouncycastle::ascon::ascon_hash256::AsconHash256;
use bouncycastle::ascon::ascon_xof128::AsconXof128;
use bouncycastle::core::errors::SymmetricCipherError;
use bouncycastle::core::key_material::{
KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations,
};
use bouncycastle::core::tagged_aead::TaggedDecryptor;
use bouncycastle::core::traits::{SecurityStrength, SimpleCipherDecryptor};
use bouncycastle::hex;

use crate::helpers;

/// Load a hex string or a binary/hex file into bytes; exits with an error if neither is supplied.
fn load_bytes(value: &Option<String>, value_file: &Option<String>, label: &str) -> Vec<u8> {
if let Some(file) = value_file {
helpers::read_from_file(file)
} else if let Some(v) = value {
hex::decode(v).unwrap_or_else(|_| {
eprintln!("Error: {label} is not valid hex.");
exit(-1)
})
} else {
eprintln!("Error: {label} must be supplied.");
exit(-1)
}
}

fn require_16(bytes: Vec<u8>, label: &str) -> [u8; 16] {
bytes.try_into().unwrap_or_else(|_: Vec<u8>| {
eprintln!("Error: {label} must be exactly 16 bytes.");
exit(-1)
})
}

/// Build a `KeyMaterial<16>` for the AEAD key, warning (and forcing usable metadata) only if the
/// key turns out to be low-entropy (e.g. all-zero), the same way `helpers::parse_seed` does.
fn load_key_material(key_bytes: &[u8; 16]) -> KeyMaterial<16> {
let mut key =
KeyMaterial::<16>::from_bytes_as_type(key_bytes, KeyType::SymmetricCipherKey).unwrap();
if key.key_type() == KeyType::Zeroized || key.security_strength() < SecurityStrength::_128bit {
eprintln!(
"Warning: low entropy key provided. We'll still process it, but it may be insecure."
);
do_hazardous_operations(&mut key, |k| {
k.set_key_type(KeyType::SymmetricCipherKey)?;
k.set_security_strength(SecurityStrength::_128bit)
})
.unwrap();
}
key
}

/// Ascon-Hash256 of stdin. Streaming update; 256-bit digest.
pub(crate) fn hash256_cmd(output_hex: bool) {
helpers::stream_hash(AsconHash256::new(), output_hex);
}

/// Ascon-XOF128 of stdin, producing `output_len` bytes. Streaming absorb.
pub(crate) fn xof128_cmd(output_len: usize, output_hex: bool) {
helpers::stream_xof(AsconXof128::new(), output_len, output_hex);
}

/// Ascon-CXOF128 of stdin with a hex customization string, producing `output_len` bytes.
pub(crate) fn cxof128_cmd(customization: &Option<String>, output_len: usize, output_hex: bool) {
let z = match customization {
Some(v) => hex::decode(v).unwrap_or_else(|_| {
eprintln!("Error: customization is not valid hex.");
exit(-1)
}),
None => Vec::new(),
};
let x = AsconCXof128::with_customization(&z).unwrap_or_else(|_| {
eprintln!("Error: customization string exceeds 256 bytes.");
exit(-1)
});
helpers::stream_xof(x, output_len, output_hex);
}

/// Ascon-AEAD128 of stdin. Encrypts (stdin = plaintext, output = ciphertext||tag) or, with
/// `decrypt`, decrypts (stdin = ciphertext||tag, output = plaintext). Decryption exits with a
/// non-zero status if the authentication tag does not verify.
///
/// Both directions stream stdin in fixed-size chunks (no full-buffer slurp). Encryption emits
/// ciphertext eagerly, before the tag is known; note that in the decryption direction, plaintext
/// is likewise emitted before the tag has been checked, so it should not be treated as
/// authentic until this command exits with status 0 (see the crate's "Security Considerations").
pub(crate) fn aead128_cmd(
key: &Option<String>,
key_file: &Option<String>,
nonce: &Option<String>,
nonce_file: &Option<String>,
ad: &Option<String>,
decrypt: bool,
output_hex: bool,
) {
let key = load_key_material(&require_16(load_bytes(key, key_file, "key"), "key"));
let nonce = require_16(load_bytes(nonce, nonce_file, "nonce"), "nonce");
let ad_bytes = match ad {
Some(v) => hex::decode(v).unwrap_or_else(|_| {
eprintln!("Error: associated data is not valid hex.");
exit(-1)
}),
None => Vec::new(),
};
let ad_opt = if ad_bytes.is_empty() { None } else { Some(ad_bytes.as_slice()) };

if decrypt {
aead128_decrypt_stream(&key, &nonce, ad_opt, output_hex);
} else {
aead128_encrypt_stream(&key, &nonce, ad_opt, output_hex);
}
}

fn aead128_encrypt_stream(
key: &KeyMaterial<16>,
nonce: &[u8; 16],
ad_opt: Option<&[u8]>,
output_hex: bool,
) {
let mut cipher = AsconAead128::new(key, nonce, ad_opt, true).unwrap();
let mut buf = [0u8; 1024];
loop {
let n = io::stdin().read(&mut buf).expect("Failed to read from stdin");
if n == 0 {
break;
}
cipher.do_encrypt_update(&mut buf[..n]);
helpers::write_bytes_or_hex(&buf[..n], output_hex);
}
let tag = cipher.do_encrypt_final();
helpers::write_bytes_or_hex(&tag, output_hex);
if output_hex {
println!();
}
}

/// Decrypts a stream whose final 16 bytes are the tag, which is only known once EOF is reached.
/// The tag-candidate hold-back this needs is [`TaggedDecryptor`]'s job, not this function's: it
/// adapts [`AsconAead128Decryptor`] to the `ciphertext || tag` layout, releasing everything but
/// the last 16 bytes it has seen as soon as it is known not to be the tag.
fn aead128_decrypt_stream(
key: &KeyMaterial<16>,
nonce: &[u8; 16],
ad_opt: Option<&[u8]>,
output_hex: bool,
) {
const CHUNK: usize = 1024;

let mut cipher = <TaggedDecryptor<AsconAead128Decryptor, 16> as SimpleCipherDecryptor<
16,
16,
16,
>>::do_decrypt_init(key, nonce)
.unwrap();
if let Some(ad) = ad_opt {
cipher.do_update_aad::<16, 16>(ad).unwrap();
}

let mut buf = [0u8; CHUNK];
loop {
let n = io::stdin().read(&mut buf).expect("Failed to read from stdin");
if n == 0 {
break;
}
let expect = cipher.update_out_len(n);
let mut out = vec![0u8; expect];
// infallible: `out` is sized exactly to `update_out_len`, the only length
// `IncorrectOutputBufferLength` could complain about.
let written = cipher.do_update_out(&buf[..n], &mut out).unwrap();
helpers::write_bytes_or_hex(&out[..written], output_hex);
}

match cipher.do_final() {
Ok((last, last_len)) => {
helpers::write_bytes_or_hex(&last[..last_len], output_hex);
if output_hex {
println!();
}
}
Err(SymmetricCipherError::DecryptionFailed) => {
eprintln!("Error: ciphertext is shorter than the 16-byte tag.");
exit(-1);
}
Err(_) => {
eprintln!("Error: Ascon-AEAD128 authentication failed.");
exit(-1);
}
}
}
34 changes: 33 additions & 1 deletion cli/src/helpers.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use bouncycastle::core::key_material::{
KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations,
};
use bouncycastle::core::traits::SecurityStrength;
use bouncycastle::core::traits::{Hash, SecurityStrength, XOF};
use bouncycastle::hex;
use std::fs::File;
use std::io;
Expand Down Expand Up @@ -116,3 +116,35 @@ pub(crate) fn parse_seed<const SEED_LEN: usize>(bytes: &[u8]) -> Result<KeyMater
}
Ok(seed)
}

/// Stream stdin through a [`Hash`] and write the digest to stdout (hex or binary), followed by a
/// newline. Used by both the SHA-3 and Ascon-Hash256 subcommands.
pub(crate) fn stream_hash(mut hasher: impl Hash, output_hex: bool) {
let mut buf: [u8; 1024] = [0u8; 1024];

let mut bytes_read = io::stdin().read(&mut buf).expect("Failed to read from stdin");
while bytes_read != 0 {
hasher.do_update(&buf[..bytes_read]);
bytes_read = io::stdin().read(&mut buf).expect("Failed to read from stdin");
}

let out = hasher.do_final();
write_bytes_or_hex(&out, output_hex);
println!();
}

/// Stream stdin through an [`XOF`] and squeeze `output_len` bytes to stdout (hex or binary),
/// followed by a newline. Used by both the SHAKE and Ascon-XOF128/CXOF128 subcommands.
pub(crate) fn stream_xof(mut xof: impl XOF, output_len: usize, output_hex: bool) {
let mut buf: [u8; 1024] = [0u8; 1024];

let mut bytes_read = io::stdin().read(&mut buf).expect("Failed to read from stdin");
while bytes_read != 0 {
xof.absorb(&buf[..bytes_read]).expect("absorb before squeeze is infallible");
bytes_read = io::stdin().read(&mut buf).expect("Failed to read from stdin");
}

let out = xof.squeeze(output_len);
write_bytes_or_hex(&out, output_hex);
println!();
}
Loading
Loading