From 13d53af57cbc9f37aa2f66b7875da73d6dd24603 Mon Sep 17 00:00:00 2001 From: Mike Mulchrone Date: Wed, 17 Jun 2026 19:17:54 -0400 Subject: [PATCH] remove deps, use temporary directory, version bump --- .gitignore | 4 +- CLAUDE.md | 11 ++--- Cargo.lock | 9 +---- Cargo.toml | 5 +-- src/error.rs | 98 +++++++++++++++++++++++++++++++++++++++++++++ tests/common/mod.rs | 13 ++++++ tests/sponges.rs | 7 +++- tests/symmetric.rs | 27 +++++++------ 8 files changed, 140 insertions(+), 34 deletions(-) create mode 100644 tests/common/mod.rs diff --git a/.gitignore b/.gitignore index 34b763c..423b1b3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,4 @@ target/ # Added by cargo -/target -decrypted.docx -encrypted.docx \ No newline at end of file +/target \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 2b50ec4..99a9477 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,14 +29,16 @@ cargo publish ### Trait-Based Module Pattern -Every cryptographic module follows the same two-file pattern: +Most cryptographic modules follow the same two-file pattern: - `cas_.rs` — defines the public trait(s) and the concrete unit struct(s) that implement them - `.rs` — contains the trait `impl` block with the actual cryptographic logic For example, `password_hashers/cas_password_hasher.rs` declares the `CASPasswordHasher` trait and the `CASArgon2` / `CASBcrypt` / etc. unit structs; `password_hashers/argon2.rs` provides the implementation. -All modules are declared in [src/lib.rs](src/lib.rs) and re-exported from there. +A few modules deviate: `asymmetric` puts its trait in `types.rs` with the `impl` in `cas_rsa.rs` (inverting the naming), and `pqc` (`ml_kem`, `slh_dsa`) exposes free functions rather than trait methods. + +All modules are declared and made public in [src/lib.rs](src/lib.rs). Note that `lib.rs` only declares the module tree — it does not add any top-level `pub use` re-exports, so consumers reference items by their full module path (e.g. `cas_lib::symmetric::cas_symmetric_encryption::CASAES256Encryption`). ### Modules @@ -45,7 +47,7 @@ All modules are declared in [src/lib.rs](src/lib.rs) and re-exported from there. | `symmetric` | AES-128-GCM, AES-256-GCM, ChaCha20-Poly1305 | | `hashers` | BLAKE2b, BLAKE2s, SHA-2, SHA-3 | | `password_hashers` | Argon2, bcrypt, scrypt, PBKDF2 | -| `asymmetric` | RSA (encrypt/decrypt, sign/verify) | +| `asymmetric` | RSA (key generation, sign/verify) | | `signatures` | Ed25519 | | `key_exchange` | X25519 | | `hybrid` | HPKE | @@ -59,7 +61,7 @@ All modules are declared in [src/lib.rs](src/lib.rs) and re-exported from there. - Binary inputs/outputs use `Vec`. - Asymmetric keys are PEM-encoded strings. - Nonces/IVs are generated internally via `OsRng` — callers do not supply them. -- Post-quantum operations (ML-KEM, SLH-DSA) return `Result` types; most other operations panic on internal crypto errors. +- Fallible operations return `CasResult` (`Result` from [src/error.rs](src/error.rs)) rather than panicking — a panic unwinding across the FFI boundary is undefined behavior. Infallible operations (e.g. hashing in `hashers`) return their value directly. The `CasError` variants map to stable numeric codes consumed by the FFI binding crates; that mapping is an append-only ABI contract (see the doc comment on `CasError`). ### Test Vectors @@ -69,6 +71,5 @@ NIST/FIPS known-answer test vectors live in [tests/data/](tests/data/) and are c - PRs run `cargo build --release` on both Linux and Windows (`.github/workflows/linux-pr.yml`, `.github/workflows/windows-pr.yml`). - Pushes to `main` trigger an automatic `cargo publish` (`.github/workflows/publish-main.yml`). -- OWASP Dependency Check runs on PRs and `main` (`.github/workflows/owasp-dc.yml`). The dev profile sets `opt-level = 3` for `num-bigint-dig` (used by the RSA crate) to keep RSA key-generation fast during local development. diff --git a/Cargo.lock b/Cargo.lock index 53a1bdf..142d628 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,7 +158,7 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cas-lib" -version = "0.2.81" +version = "0.2.82" dependencies = [ "aes-gcm", "argon2", @@ -171,7 +171,6 @@ dependencies = [ "hmac", "hpke", "ml-kem", - "napi-build", "pbkdf2", "rand", "rsa", @@ -681,12 +680,6 @@ dependencies = [ "sha3", ] -[[package]] -name = "napi-build" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebd4419172727423cf30351406c54f6cc1b354a2cfb4f1dba3e6cd07f6d5522b" - [[package]] name = "num-bigint-dig" version = "0.8.6" diff --git a/Cargo.toml b/Cargo.toml index 7064bc5..17efbde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cas-lib" -version = "0.2.81" +version = "0.2.82" edition = "2021" description = "A function wrapper layer for RustCrypto and Dalek-Cryptography. Intended to be used in FFI situations with a global heap deallactor at the top level project." license = "Apache-2.0" @@ -37,6 +37,3 @@ ml-kem = "0.2.1" [profile.dev.package.num-bigint-dig] opt-level = 3 -[build-dependencies] -napi-build = "1" - diff --git a/src/error.rs b/src/error.rs index 6574379..8ec5662 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,6 +7,21 @@ use std::fmt; /// FFI boundary is undefined behavior and typically aborts the host process. A /// malformed key, a tampered ciphertext, or a failed authentication tag are all /// recoverable conditions and are reported through this enum. +/// +/// # ABI stability contract +/// +/// The downstream FFI binding crates (`cas-core-lib`, `cas-typescript-sdk`) +/// surface each variant to their callers as the stable numeric code returned by +/// [`CasError::error_code`]. Those numbers are part of the ABI contract with +/// every consumer SDK, so this enum is **append-only**: +/// +/// - Never remove, rename, or renumber an existing variant. +/// - Add new variants only at the end, and give them the next free code in +/// [`CasError::error_code`]. +/// +/// The `error_code_contract_is_stable` test in this module pins the mapping so +/// an accidental reorder or removal fails CI here rather than silently breaking +/// a consumer's error handling. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CasError { /// A provided key had an invalid length or could not be parsed. @@ -58,5 +73,88 @@ impl fmt::Display for CasError { impl std::error::Error for CasError {} +impl CasError { + /// Maps this error to the stable numeric code surfaced through the FFI by + /// the downstream binding crates. `0` is reserved for success and is never + /// returned here. + /// + /// These values are part of the ABI contract described on [`CasError`]; see + /// the type-level documentation before changing them. + pub fn error_code(&self) -> i32 { + match self { + CasError::InvalidKey => 1, + CasError::InvalidNonce => 2, + CasError::InvalidSignature => 3, + CasError::InvalidInput => 4, + CasError::InvalidPemKey => 5, + CasError::InvalidParameters => 6, + CasError::EncryptionFailed => 7, + CasError::DecryptionFailed => 8, + CasError::SigningFailed => 9, + CasError::KeyGenerationFailed => 10, + CasError::PasswordHashingFailed => 11, + CasError::CompressionFailed => 12, + } + } +} + /// The result type returned by all fallible `cas-lib` operations. pub type CasResult = Result; + +#[cfg(test)] +mod tests { + use super::CasError; + + /// Golden test pinning the FFI error-code contract. If you are changing this + /// test you are changing the ABI surfaced by every downstream SDK — make + /// sure that is intentional and that `cas-core-lib` / `cas-typescript-sdk` + /// are updated in lockstep. + #[test] + fn error_code_contract_is_stable() { + let expected: &[(CasError, i32)] = &[ + (CasError::InvalidKey, 1), + (CasError::InvalidNonce, 2), + (CasError::InvalidSignature, 3), + (CasError::InvalidInput, 4), + (CasError::InvalidPemKey, 5), + (CasError::InvalidParameters, 6), + (CasError::EncryptionFailed, 7), + (CasError::DecryptionFailed, 8), + (CasError::SigningFailed, 9), + (CasError::KeyGenerationFailed, 10), + (CasError::PasswordHashingFailed, 11), + (CasError::CompressionFailed, 12), + ]; + + for (error, code) in expected { + assert_eq!( + error.error_code(), + *code, + "error code for {error:?} changed; this breaks the FFI ABI contract" + ); + } + + // Compile-time guard against a variant being added (or removed) without + // updating the contract above: this match is intentionally exhaustive + // with no wildcard arm, so a new `CasError` variant fails to compile + // here until it is added to `expected` and to the downstream SDK + // mappings. + fn assert_all_variants_covered(error: &CasError) { + match error { + CasError::InvalidKey + | CasError::InvalidNonce + | CasError::InvalidSignature + | CasError::InvalidInput + | CasError::InvalidPemKey + | CasError::InvalidParameters + | CasError::EncryptionFailed + | CasError::DecryptionFailed + | CasError::SigningFailed + | CasError::KeyGenerationFailed + | CasError::PasswordHashingFailed + | CasError::CompressionFailed => {} + } + } + let _ = assert_all_variants_covered; + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..5e0efd8 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,13 @@ +//! Shared helpers for the integration test suite. + +use std::path::PathBuf; + +/// Returns a path inside the OS temp directory for a test output file. +/// +/// Several tests write their encrypted/decrypted bytes to disk so the round +/// trip can be inspected by hand. Routing those writes through the temp +/// directory keeps them out of the repository working tree (they previously +/// landed in the crate root as `encrypted.docx` / `decrypted.docx`). +pub fn temp_output_path(file_name: &str) -> PathBuf { + std::env::temp_dir().join(file_name) +} diff --git a/tests/sponges.rs b/tests/sponges.rs index 579243e..b54a614 100644 --- a/tests/sponges.rs +++ b/tests/sponges.rs @@ -1,7 +1,10 @@ +mod common; + #[cfg(test)] mod sponges { use std::{fs::File, io::Write, path::Path}; use cas_lib::sponges::{ascon_aead::AsconAead, cas_ascon_aead::CASAsconAead}; + use crate::common::temp_output_path; #[test] fn test_ascon_128() { @@ -10,11 +13,11 @@ mod sponges { let ascon_nonce = ::generate_nonce(); let ascon_key = ::generate_key(); let encrypted_bytes = ::encrypt(ascon_key.clone(), ascon_nonce.clone(), file_bytes.clone()).unwrap(); - let mut file = File::create("encrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("encrypted.docx")).unwrap(); file.write_all(&encrypted_bytes).unwrap(); let decrypted_bytes = ::decrypt(ascon_key, ascon_nonce, encrypted_bytes).unwrap(); - let mut file = File::create("decrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("decrypted.docx")).unwrap(); file.write_all(&decrypted_bytes).unwrap(); assert_eq!(file_bytes, decrypted_bytes); } diff --git a/tests/symmetric.rs b/tests/symmetric.rs index 3095742..b1c6a62 100644 --- a/tests/symmetric.rs +++ b/tests/symmetric.rs @@ -2,8 +2,11 @@ use std::{fs::{File}, io::Write, path::Path}; use cas_lib::symmetric::{aes::CASAES256, cas_symmetric_encryption::CASAES256Encryption}; +mod common; + #[cfg(test)] mod symmetric { + use crate::common::temp_output_path; use cas_lib::{key_exchange::{cas_key_exchange::CASKeyExchange, x25519::X25519}, pqc::{cas_pqc::MlKemKeyPair, ml_kem::{ml_kem_1024_decapsulate, ml_kem_1024_encapsulate, ml_kem_1024_generate}}, symmetric::{aes::CASAES128, cas_symmetric_encryption::{CASAES128Encryption, Chacha20Poly1305Encryption}, chacha20poly1305::CASChacha20Poly1305}}; use hkdf::Hkdf; use sha2::Sha256; @@ -201,11 +204,11 @@ mod symmetric { let aes_nonce = ::generate_nonce(); let aes_key = ::generate_key(); let encrypted_bytes = ::encrypt_plaintext(aes_key.clone(), aes_nonce.clone(), file_bytes.clone()).unwrap(); - let mut file = File::create("encrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("encrypted.docx")).unwrap(); file.write_all(&encrypted_bytes).unwrap(); let decrypted_bytes = ::decrypt_ciphertext(aes_key, aes_nonce, encrypted_bytes).unwrap(); - let mut file = File::create("decrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("decrypted.docx")).unwrap(); file.write_all(&decrypted_bytes).unwrap(); assert_eq!(file_bytes, decrypted_bytes); } @@ -223,12 +226,12 @@ mod symmetric { let file_bytes: Vec = std::fs::read(path).unwrap(); let alice_key = ::key_from_x25519_shared_secret(bob_shared_secret).unwrap(); let encrypted_bytes = ::encrypt_plaintext(alice_key, aes_nonce.clone(), file_bytes.clone()).unwrap(); - let mut file = File::create("encrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("encrypted.docx")).unwrap(); file.write_all(&encrypted_bytes).unwrap(); let bob_key = ::key_from_x25519_shared_secret(alice_shared_secret).unwrap(); let decrypted_bytes = ::decrypt_ciphertext(bob_key, aes_nonce, encrypted_bytes).unwrap(); - let mut file = File::create("decrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("decrypted.docx")).unwrap(); file.write_all(&decrypted_bytes).unwrap(); assert_eq!(file_bytes, decrypted_bytes); } @@ -240,12 +243,12 @@ mod symmetric { let aes_nonce = ::generate_nonce(); let aes_key = ::generate_key(); let encrypted_bytes = ::encrypt_plaintext(aes_key.clone(), aes_nonce.clone(), file_bytes.clone()).unwrap(); - let mut file = File::create("encrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("encrypted.docx")).unwrap(); file.write_all(&encrypted_bytes).unwrap(); let decrypted_bytes = ::decrypt_ciphertext(aes_key, aes_nonce, encrypted_bytes).unwrap(); - let mut file = File::create("decrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("decrypted.docx")).unwrap(); file.write_all(&decrypted_bytes).unwrap(); assert_eq!(file_bytes, decrypted_bytes); } @@ -263,13 +266,13 @@ mod symmetric { let file_bytes: Vec = std::fs::read(path).unwrap(); let alice_key = ::key_from_x25519_shared_secret(bob_shared_secret).unwrap(); let encrypted_bytes = ::encrypt_plaintext(alice_key, aes_nonce.clone(), file_bytes.clone()).unwrap(); - let mut file = File::create("encrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("encrypted.docx")).unwrap(); file.write_all(&encrypted_bytes).unwrap(); let bob_key = ::key_from_x25519_shared_secret(alice_shared_secret).unwrap(); let decrypted_bytes = ::decrypt_ciphertext(bob_key, aes_nonce, encrypted_bytes).unwrap(); - let mut file = File::create("decrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("decrypted.docx")).unwrap(); file.write_all(&decrypted_bytes).unwrap(); assert_eq!(file_bytes, decrypted_bytes); } @@ -281,11 +284,11 @@ mod symmetric { let chacha20_nonce = ::generate_nonce(); let chacha20_key = ::generate_key(); let encrypted_bytes = ::encrypt_plaintext(chacha20_key.clone(), chacha20_nonce.clone(), file_bytes.clone()).unwrap(); - let mut file = File::create("encrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("encrypted.docx")).unwrap(); file.write_all(&encrypted_bytes).unwrap(); let decrypted_bytes = ::decrypt_ciphertext(chacha20_key, chacha20_nonce, encrypted_bytes).unwrap(); - let mut file = File::create("decrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("decrypted.docx")).unwrap(); file.write_all(&decrypted_bytes).unwrap(); assert_eq!(file_bytes, decrypted_bytes); } @@ -310,11 +313,11 @@ mod symmetric { let path = Path::new("tests/test.docx"); let file_bytes: Vec = std::fs::read(path).unwrap(); let encrypted_bytes = ::encrypt_plaintext(aes_key.to_vec(), aes_nonce.clone(), file_bytes.clone()).unwrap(); - let mut file = File::create("encrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("encrypted.docx")).unwrap(); file.write_all(&encrypted_bytes).unwrap(); let decrypted_bytes = ::decrypt_ciphertext(aes_key2.to_vec(), aes_nonce, encrypted_bytes).unwrap(); - let mut file = File::create("decrypted.docx").unwrap(); + let mut file = File::create(temp_output_path("decrypted.docx")).unwrap(); file.write_all(&decrypted_bytes).unwrap(); assert_eq!(file_bytes, decrypted_bytes); }