From faa7b6e3a3e6d36069dc6d01fb3d0ee62a4447ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Wed, 6 May 2026 15:28:42 -0400 Subject: [PATCH 1/7] Add SecretKey type for typed symmetric encryption Add a wrapped SecretKey type analogous to PrivateKey/PublicKey. `Ciphertext::encrypt_with_secret_key` and `Ciphertext::decrypt_with_secret_key` take the key as a SecretKey instead of raw bytes. Methods encrypt_with_raw_key and decrypt_with_raw_key were defined, encrypt/decrypt call these methods. --- _plans/secret-key.md | 76 ++++++++++++++ src/ciphertext/mod.rs | 213 ++++++++++++++++++++++++++++++++++++++- src/enums.rs | 1 + src/key/mod.rs | 139 ++++++++++++++++++++++++- src/key/secret_key_v1.rs | 66 ++++++++++++ src/lib.rs | 6 +- tests/conformity.rs | 27 ++++- 7 files changed, 521 insertions(+), 7 deletions(-) create mode 100644 _plans/secret-key.md create mode 100644 src/key/secret_key_v1.rs diff --git a/_plans/secret-key.md b/_plans/secret-key.md new file mode 100644 index 000000000..5161a7415 --- /dev/null +++ b/_plans/secret-key.md @@ -0,0 +1,76 @@ +# Plan: Add SecretKey type for symmetric encryption + +## TL;DR +Add a wrapped `SecretKey` type (DataType::Key + KeySubtype::Secret) analogous to `PrivateKey`/`PublicKey`, backed by 32 raw random bytes. Provide typed encrypt/decrypt methods on the ciphertext module so callers pass `&SecretKey` instead of `&[u8]`. Include unit tests and a conformity test. + +## Phase 1 — Enum & Core Type + +1. **Add `Secret = 4` to `KeySubtype`** in `src/enums.rs`. + +2. **Create `src/key/secret_key_v1.rs`** with: + - `pub struct SecretKeyV1 { key: Zeroizing<[u8; 32]> }` + - `generate() -> SecretKeyV1` using `rand::rngs::OsRng` + - `as_bytes(&self) -> &[u8]` + - `impl TryFrom<&[u8]> for SecretKeyV1` — validate len == 32 + - `impl From for Vec` + +3. **Add `SecretKey` to `src/key/mod.rs`** (parallel to `PrivateKey`): + - `pub struct SecretKey { pub(crate) header: Header, payload: SecretKeyPayload }` + - `enum SecretKeyPayload { V1(SecretKeyV1) }` + - `impl HeaderType for SecretKey` → `data_type() = DataType::Key`, `subtype() = KeySubtype::Secret`, `Version = KeyVersion` + - `impl From for Vec` — header + payload (follows exact PrivateKey pattern) + - `impl TryFrom<&[u8]> for SecretKey` — validates `header.data_subtype == KeySubtype::Secret`, then dispatches on version + - `pub fn generate_secret_key(version: KeyVersion) -> SecretKey` + - `impl SecretKey { pub fn as_bytes(&self) -> &[u8] }` — exposes key material for internal use by ciphertext module + - Add `mod secret_key_v1;` declaration + +## Phase 2 — Ciphertext Module Overloads + +4. **Add typed encrypt/decrypt to `src/ciphertext/mod.rs`**: + - Free function: `pub fn encrypt_with_secret_key(data: &[u8], key: &SecretKey, version: CiphertextVersion) -> Result` — delegates to `encrypt(data, key.as_bytes(), version)` + - Free function: `pub fn encrypt_with_secret_key_and_aad(data: &[u8], key: &SecretKey, aad: &[u8], version: CiphertextVersion) -> Result` — delegates to `encrypt_with_aad` + - Method: `impl Ciphertext { pub fn decrypt_with_secret_key(&self, key: &SecretKey) -> Result> }` — delegates to `self.decrypt(key.as_bytes())` + - Method: `impl Ciphertext { pub fn decrypt_with_secret_key_and_aad(&self, key: &SecretKey, aad: &[u8]) -> Result> }` — delegates to `self.decrypt_with_aad` + - Add `use super::key::SecretKey;` import + +## Phase 3 — Exports + +5. **Update `src/lib.rs`**: + - Add `KeySubtype` to the `pub use enums::{ ... }` re-export list + - Add `SecretKey` and `generate_secret_key` to `pub use key::{ ... }` or ensure they're accessible via `pub mod key` + +## Phase 4 — Tests + +6. **Unit tests in `src/key/mod.rs`** (inside `#[cfg(test)]` block): + - `secret_key_generate_roundtrip` — generate → serialize to bytes → deserialize → verify as_bytes round-trips + - `secret_key_wrong_subtype_rejected` — try_from a PrivateKey bytes slice as SecretKey should return `Err(InvalidDataType)` + - `secret_key_wrong_length_rejected` — too-short byte slice returns `Err(InvalidLength)` + +7. **Unit tests in `src/ciphertext/mod.rs`**: + - `encrypt_decrypt_with_secret_key` — generate key, encrypt, decrypt, assert plaintext equality + - `encrypt_decrypt_with_secret_key_aad` — same with AAD; also verify wrong AAD returns error + - `encrypt_decrypt_with_secret_key_v1` and `_v2` — explicit version coverage + +8. **Conformity test in `tests/conformity.rs`**: + - `test_symmetric_decrypt_with_secret_key_v2` — parse a known-good SecretKey from base64, decrypt a known ciphertext, assert result == expected plaintext. (Test vector generated during implementation from the existing known symmetric key `ozJVEme4+5e/4NG3C+Rl26GQbGWAqGc0QPX8/1xvaFM=` wrapped in a SecretKey header.) + +## Relevant files +- `src/enums.rs` — add `Secret = 4` to `KeySubtype` +- `src/key/mod.rs` — add `SecretKey`, `SecretKeyPayload`, `generate_secret_key`, conversions; reference the `PrivateKey` impl at lines 82–241 as the template +- `src/key/secret_key_v1.rs` — new file; use `key_v1.rs` TryFrom/From pattern as reference +- `src/ciphertext/mod.rs` — add 4 typed encrypt/decrypt wrappers; reference `decrypt_asymmetric` (lines ~230) for method placement +- `src/lib.rs` — update re-exports +- `tests/conformity.rs` — add conformity test + +## Verification +1. `cargo test` — all tests pass +2. `cargo check` — no type errors +3. Confirm `SecretKey::try_from(private_key_bytes)` returns `Err(InvalidDataType)` +4. Confirm `PrivateKey::try_from(secret_key_bytes)` returns `Err(InvalidDataType)` +5. Conformity test decrypts known test vector correctly + +## Decisions +- **Reuse `KeyVersion`** for SecretKey (same as PrivateKey/PublicKey) — since the version here is about the raw format (32-byte block), not the cryptographic algorithm. `V1 = 32 random bytes`. +- **`as_bytes()` method** on SecretKey exposes raw bytes for delegation to existing encrypt/decrypt internals — avoids duplicating encryption logic. +- **Scope: Rust core library only.** FFI (`ffi/src/lib.rs`), WASM (`src/wasm.rs`), UniFFI (`uniffi/`), C# wrapper, Kotlin/Swift/Python wrappers are out of scope for this plan. They follow the same patterns and can be addressed in a follow-up. +- `SecretKeyV1` uses `Zeroizing<[u8; 32]>` to ensure key material is cleared from memory on drop. diff --git a/src/ciphertext/mod.rs b/src/ciphertext/mod.rs index 62bcbeb53..a86aa3b31 100644 --- a/src/ciphertext/mod.rs +++ b/src/ciphertext/mod.rs @@ -51,7 +51,7 @@ use super::Header; use super::HeaderType; use super::Result; -use super::key::{PrivateKey, PublicKey}; +use super::key::{PrivateKey, PublicKey, SecretKey}; use ciphertext_v1::CiphertextV1; use ciphertext_v2::{CiphertextV2Asymmetric, CiphertextV2Symmetric}; @@ -104,6 +104,27 @@ enum CiphertextPayload { /// let encrypted_data = encrypt(data, key, CiphertextVersion::Latest).unwrap(); /// ``` pub fn encrypt(data: &[u8], key: &[u8], version: CiphertextVersion) -> Result { + encrypt_with_raw_key(data, key, version) +} + + +/// Returns an `Ciphertext` from cleartext data and a key. +/// # Arguments +/// * `data` - The data to encrypt. +/// * `key` - The key to use. The recommended size is 32 bytes. +/// * `version` - Version of the library to encrypt with. Use `CiphertTextVersion::Latest` if you're not dealing with shared data. +/// # Returns +/// Returns a `Ciphertext` containing the encrypted data. +/// # Example +/// ```rust +/// use devolutions_crypto::ciphertext::{ encrypt_with_raw_key, CiphertextVersion }; +/// +/// let data = b"somesecretdata"; +/// let key = b"somesecretkey"; +/// +/// let encrypted_data = encrypt_with_raw_key(data, key, CiphertextVersion::Latest).unwrap(); +/// ``` +pub fn encrypt_with_raw_key(data: &[u8], key: &[u8], version: CiphertextVersion) -> Result { encrypt_with_aad(data, key, [].as_slice(), version) } @@ -218,6 +239,59 @@ pub fn encrypt_asymmetric_with_aad( Ok(Ciphertext { header, payload }) } +/// Returns a `Ciphertext` from cleartext data and a `SecretKey`. +/// # Arguments +/// * `data` - The data to encrypt. +/// * `key` - The `SecretKey` to use. Generate one with `generate_secret_key`. +/// * `version` - Version of the library to encrypt with. Use `CiphertextVersion::Latest` if you're not dealing with shared data. +/// # Returns +/// Returns a `Ciphertext` containing the encrypted data. +/// # Example +/// ```rust +/// use devolutions_crypto::ciphertext::{ encrypt_with_secret_key, CiphertextVersion }; +/// use devolutions_crypto::key::{ generate_secret_key, KeyVersion }; +/// +/// let data = b"somesecretdata"; +/// let key = generate_secret_key(KeyVersion::Latest); +/// +/// let encrypted_data = encrypt_with_secret_key(data, &key, CiphertextVersion::Latest).unwrap(); +/// ``` +pub fn encrypt_with_secret_key( + data: &[u8], + key: &SecretKey, + version: CiphertextVersion, +) -> Result { + encrypt(data, key.as_bytes(), version) +} + +/// Returns a `Ciphertext` from cleartext data and a `SecretKey` with additional authenticated data. +/// # Arguments +/// * `data` - The data to encrypt. +/// * `key` - The `SecretKey` to use. Generate one with `generate_secret_key`. +/// * `aad` - Additional data to authenticate alongside the ciphertext. +/// * `version` - Version of the library to encrypt with. Use `CiphertextVersion::Latest` if you're not dealing with shared data. +/// # Returns +/// Returns a `Ciphertext` containing the encrypted data, which also authenticates the `aad` argument. +/// # Example +/// ```rust +/// use devolutions_crypto::ciphertext::{ encrypt_with_secret_key_and_aad, CiphertextVersion }; +/// use devolutions_crypto::key::{ generate_secret_key, KeyVersion }; +/// +/// let data = b"somesecretdata"; +/// let aad = b"somepublicdata"; +/// let key = generate_secret_key(KeyVersion::Latest); +/// +/// let encrypted_data = encrypt_with_secret_key_and_aad(data, &key, aad, CiphertextVersion::Latest).unwrap(); +/// ``` +pub fn encrypt_with_secret_key_and_aad( + data: &[u8], + key: &SecretKey, + aad: &[u8], + version: CiphertextVersion, +) -> Result { + encrypt_with_aad(data, key.as_bytes(), aad, version) +} + impl Ciphertext { /// Decrypt the data blob using a key. /// # Arguments @@ -237,6 +311,27 @@ impl Ciphertext { /// assert_eq!(data.to_vec(), decrypted_data); ///``` pub fn decrypt(&self, key: &[u8]) -> Result> { + self.decrypt_with_raw_key(key) + } + + /// Decrypt the data blob using a key. + /// # Arguments + /// * `key` - Key to use. The recommended size is 32 bytes. + /// # Returns + /// Returns the decrypted data. + /// # Example + /// ```rust + /// use devolutions_crypto::ciphertext::{ encrypt_with_raw_key, CiphertextVersion}; + /// + /// let data = b"somesecretdata"; + /// let key = b"somesecretkey"; + /// + /// let encrypted_data = encrypt_with_raw_key(data, key, CiphertextVersion::Latest).unwrap(); + /// let decrypted_data = encrypted_data.decrypt_with_raw_key(key).unwrap(); + /// + /// assert_eq!(data.to_vec(), decrypted_data); + ///``` + pub fn decrypt_with_raw_key(&self, key: &[u8]) -> Result> { self.decrypt_with_aad(key, [].as_slice()) } @@ -320,6 +415,52 @@ impl Ciphertext { _ => Err(Error::InvalidDataType), } } + + /// Decrypt the data blob using a `SecretKey`. + /// # Arguments + /// * `key` - The `SecretKey` used for encryption. + /// # Returns + /// Returns the decrypted data. + /// # Example + /// ```rust + /// use devolutions_crypto::ciphertext::{ encrypt_with_secret_key, CiphertextVersion }; + /// use devolutions_crypto::key::{ generate_secret_key, KeyVersion }; + /// + /// let data = b"somesecretdata"; + /// let key = generate_secret_key(KeyVersion::Latest); + /// + /// let encrypted_data = encrypt_with_secret_key(data, &key, CiphertextVersion::Latest).unwrap(); + /// let decrypted_data = encrypted_data.decrypt_with_secret_key(&key).unwrap(); + /// + /// assert_eq!(decrypted_data, data); + ///``` + pub fn decrypt_with_secret_key(&self, key: &SecretKey) -> Result> { + self.decrypt(key.as_bytes()) + } + + /// Decrypt the data blob using a `SecretKey` and additional authenticated data. + /// # Arguments + /// * `key` - The `SecretKey` used for encryption. + /// * `aad` - Additional data to authenticate alongside the ciphertext. + /// # Returns + /// Returns the decrypted data. + /// # Example + /// ```rust + /// use devolutions_crypto::ciphertext::{ encrypt_with_secret_key_and_aad, CiphertextVersion }; + /// use devolutions_crypto::key::{ generate_secret_key, KeyVersion }; + /// + /// let data = b"somesecretdata"; + /// let aad = b"somepublicdata"; + /// let key = generate_secret_key(KeyVersion::Latest); + /// + /// let encrypted_data = encrypt_with_secret_key_and_aad(data, &key, aad, CiphertextVersion::Latest).unwrap(); + /// let decrypted_data = encrypted_data.decrypt_with_secret_key_and_aad(&key, aad).unwrap(); + /// + /// assert_eq!(decrypted_data, data); + ///``` + pub fn decrypt_with_secret_key_and_aad(&self, key: &SecretKey, aad: &[u8]) -> Result> { + self.decrypt_with_aad(key.as_bytes(), aad) + } } impl From for Vec { @@ -626,3 +767,73 @@ fn asymmetric_aad_test_v2() { assert!(decrypted.is_err()); } + +#[test] +fn encrypt_decrypt_with_secret_key() { + use super::key::{generate_secret_key, KeyVersion}; + + let data = b"somesecretdata"; + let key = generate_secret_key(KeyVersion::Latest); + + let encrypted = encrypt_with_secret_key(data, &key, CiphertextVersion::Latest).unwrap(); + let decrypted = encrypted.decrypt_with_secret_key(&key).unwrap(); + + assert_eq!(decrypted, data); +} + +#[test] +fn encrypt_decrypt_with_secret_key_aad() { + use super::key::{generate_secret_key, KeyVersion}; + + let data = b"somesecretdata"; + let aad = b"somepublicdata"; + let wrong_aad = b"somewrongdata"; + let key = generate_secret_key(KeyVersion::Latest); + + let encrypted = + encrypt_with_secret_key_and_aad(data, &key, aad, CiphertextVersion::Latest).unwrap(); + let decrypted = encrypted.decrypt_with_secret_key_and_aad(&key, aad).unwrap(); + + assert_eq!(decrypted, data); + + assert!(encrypted + .decrypt_with_secret_key_and_aad(&key, wrong_aad) + .is_err()); + assert!(encrypted.decrypt_with_secret_key(&key).is_err()); +} + +#[test] +fn encrypt_decrypt_with_secret_key_v1() { + use super::key::{generate_secret_key, KeyVersion}; + + let data = b"somesecretdata"; + let key = generate_secret_key(KeyVersion::Latest); + + let encrypted = encrypt_with_secret_key(data, &key, CiphertextVersion::V1).unwrap(); + + assert_eq!(encrypted.header.version, CiphertextVersion::V1); + + let encrypted_bytes: Vec = encrypted.into(); + let encrypted = Ciphertext::try_from(encrypted_bytes.as_slice()).unwrap(); + let decrypted = encrypted.decrypt_with_secret_key(&key).unwrap(); + + assert_eq!(decrypted, data); +} + +#[test] +fn encrypt_decrypt_with_secret_key_v2() { + use super::key::{generate_secret_key, KeyVersion}; + + let data = b"somesecretdata"; + let key = generate_secret_key(KeyVersion::Latest); + + let encrypted = encrypt_with_secret_key(data, &key, CiphertextVersion::V2).unwrap(); + + assert_eq!(encrypted.header.version, CiphertextVersion::V2); + + let encrypted_bytes: Vec = encrypted.into(); + let encrypted = Ciphertext::try_from(encrypted_bytes.as_slice()).unwrap(); + let decrypted = encrypted.decrypt_with_secret_key(&key).unwrap(); + + assert_eq!(decrypted, data); +} diff --git a/src/enums.rs b/src/enums.rs index fd4ceb08d..2639fc497 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -153,6 +153,7 @@ pub enum KeySubtype { Private = 1, Public = 2, Pair = 3, + Secret = 4, } #[derive(Clone, Copy, PartialEq, Eq, Zeroize, IntoPrimitive, TryFromPrimitive, Debug)] diff --git a/src/key/mod.rs b/src/key/mod.rs index da5a7c0a3..3f228489f 100644 --- a/src/key/mod.rs +++ b/src/key/mod.rs @@ -1,7 +1,5 @@ //! Module for dealing with wrapped keys and key exchange. //! -//! For now, this module only deal with keypairs, as the symmetric keys are not wrapped yet. -//! //! ### Generation/Derivation //! //! Using `generate_keypair` will generate a random keypair. @@ -41,6 +39,7 @@ //! ``` mod key_v1; +mod secret_key_v1; use super::DataType; use super::Error; @@ -51,6 +50,7 @@ pub use super::KeyVersion; use super::Result; use key_v1::{KeyV1Private, KeyV1Public}; +use secret_key_v1::SecretKeyV1; use std::borrow::Borrow; use std::convert::TryFrom; @@ -333,6 +333,113 @@ impl From<&PrivateKey> for x25519_dalek::StaticSecret { } } +/// A secret key for symmetric encryption. Should never be sent over an insecure channel or stored unsecurely. +#[cfg_attr(feature = "wbindgen", wasm_bindgen(inspectable))] +#[cfg_attr(feature = "fuzz", derive(Arbitrary))] +#[derive(Clone, Debug)] +pub struct SecretKey { + pub(crate) header: Header, + payload: SecretKeyPayload, +} + +impl HeaderType for SecretKey { + type Version = KeyVersion; + type Subtype = KeySubtype; + + fn data_type() -> DataType { + DataType::Key + } + + fn subtype() -> Self::Subtype { + KeySubtype::Secret + } +} + +#[derive(Clone, Debug)] +#[cfg_attr(feature = "fuzz", derive(Arbitrary))] +enum SecretKeyPayload { + V1(SecretKeyV1), +} + +impl SecretKey { + /// Returns the raw key bytes for use with symmetric encryption primitives. + pub fn as_bytes(&self) -> &[u8] { + match &self.payload { + SecretKeyPayload::V1(k) => k.as_bytes(), + } + } +} + +/// Generates a `SecretKey` for use with symmetric encryption. +/// # Arguments +/// * `version` - Version of the key scheme to use. Use `KeyVersion::Latest` if you're not dealing with shared data. +/// # Returns +/// Returns a `SecretKey` containing the raw key material. +/// # Example +/// ```rust +/// use devolutions_crypto::key::{generate_secret_key, KeyVersion}; +/// +/// let key = generate_secret_key(KeyVersion::Latest); +/// ``` +pub fn generate_secret_key(version: KeyVersion) -> SecretKey { + let mut header: Header = Header::default(); + + match version { + KeyVersion::V1 | KeyVersion::Latest => { + header.version = KeyVersion::V1; + } + } + + SecretKey { + header, + payload: SecretKeyPayload::V1(SecretKeyV1::generate()), + } +} + +impl From for Vec { + /// Serialize the structure into a `Vec`, for storage, transmission or use in another language. + fn from(data: SecretKey) -> Self { + let mut header: Self = data.header.borrow().into(); + let mut payload: Self = data.payload.into(); + header.append(&mut payload); + header + } +} + +impl TryFrom<&[u8]> for SecretKey { + type Error = Error; + + /// Parses the data. Can return an Error if the data is invalid or unrecognized. + fn try_from(data: &[u8]) -> Result { + if data.len() < Header::len() { + return Err(Error::InvalidLength); + }; + + let header = Header::try_from(&data[0..Header::len()])?; + + if header.data_subtype != KeySubtype::Secret { + return Err(Error::InvalidDataType); + } + + let payload = match header.version { + KeyVersion::V1 => { + SecretKeyPayload::V1(SecretKeyV1::try_from(&data[Header::len()..])?) + } + _ => return Err(Error::UnknownVersion), + }; + + Ok(Self { header, payload }) + } +} + +impl From for Vec { + fn from(data: SecretKeyPayload) -> Self { + match data { + SecretKeyPayload::V1(x) => x.into(), + } + } +} + #[test] fn ecdh_test() { let bob_keypair = generate_keypair(KeyVersion::Latest); @@ -344,3 +451,31 @@ fn ecdh_test() { assert_eq!(bob_shared, alice_shared); } + +#[test] +fn secret_key_generate_roundtrip() { + let key = generate_secret_key(KeyVersion::Latest); + let original_bytes = key.as_bytes().to_vec(); + + let serialized: Vec = key.into(); + let deserialized = SecretKey::try_from(serialized.as_slice()).unwrap(); + + assert_eq!(deserialized.as_bytes(), original_bytes.as_slice()); +} + +#[test] +fn secret_key_wrong_subtype_rejected() { + use std::convert::TryFrom as _; + let keypair = generate_keypair(KeyVersion::Latest); + let private_bytes: Vec = keypair.private_key.into(); + + let result = SecretKey::try_from(private_bytes.as_slice()); + assert!(matches!(result, Err(Error::InvalidDataType))); +} + +#[test] +fn secret_key_wrong_length_rejected() { + let short = [0u8; 4]; + let result = SecretKey::try_from(short.as_slice()); + assert!(matches!(result, Err(Error::InvalidLength))); +} diff --git a/src/key/secret_key_v1.rs b/src/key/secret_key_v1.rs new file mode 100644 index 000000000..508cc5a18 --- /dev/null +++ b/src/key/secret_key_v1.rs @@ -0,0 +1,66 @@ +//! SecretKey V1: 32 raw random bytes +use super::Error; +use super::Result; + +use rand_08::RngCore; +use zeroize::Zeroizing; + +use std::convert::TryFrom; + +#[cfg(feature = "fuzz")] +use arbitrary::Arbitrary; + +#[derive(Clone)] +pub struct SecretKeyV1 { + key: Zeroizing<[u8; 32]>, +} + +impl core::fmt::Debug for SecretKeyV1 { + fn fmt(&self, f: &mut core::fmt::Formatter) -> std::result::Result<(), core::fmt::Error> { + write!(f, "Secret Key") + } +} + +#[cfg(feature = "fuzz")] +impl Arbitrary for SecretKeyV1 { + fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result { + let key: [u8; 32] = Arbitrary::arbitrary(u)?; + Ok(Self { + key: Zeroizing::new(key), + }) + } +} + +impl SecretKeyV1 { + pub fn generate() -> Self { + let mut key = Zeroizing::new([0u8; 32]); + rand_08::rngs::OsRng.fill_bytes(key.as_mut()); + Self { key } + } + + pub fn as_bytes(&self) -> &[u8] { + self.key.as_ref() + } +} + +impl From for Vec { + fn from(key: SecretKeyV1) -> Self { + key.key.as_ref().to_vec() + } +} + +impl TryFrom<&[u8]> for SecretKeyV1 { + type Error = Error; + + fn try_from(data: &[u8]) -> Result { + if data.len() != 32 { + return Err(Error::InvalidLength); + } + + let mut key = [0u8; 32]; + key.copy_from_slice(data); + Ok(Self { + key: Zeroizing::new(key), + }) + } +} diff --git a/src/lib.rs b/src/lib.rs index b383807ea..d1562c40f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -222,12 +222,12 @@ pub mod signature; pub mod signing_key; pub mod utils; -use enums::{CiphertextSubtype, KeySubtype, PasswordHashSubtype, ShareSubtype, SignatureSubtype}; +use enums::{CiphertextSubtype, PasswordHashSubtype, ShareSubtype, SignatureSubtype}; pub use header::{Header, HeaderType}; pub use enums::{ - CiphertextVersion, DataType, KeyVersion, OnlineCiphertextVersion, PasswordHashVersion, - SecretSharingVersion, SignatureVersion, SigningKeyVersion, + CiphertextVersion, DataType, KeySubtype, KeyVersion, OnlineCiphertextVersion, + PasswordHashVersion, SecretSharingVersion, SignatureVersion, SigningKeyVersion, }; pub use argon2::Variant as Argon2Variant; diff --git a/tests/conformity.rs b/tests/conformity.rs index 5aba9326e..3c6e4af68 100644 --- a/tests/conformity.rs +++ b/tests/conformity.rs @@ -1,7 +1,7 @@ use base64::{engine::general_purpose, Engine as _}; use devolutions_crypto::{ ciphertext::Ciphertext, - key::PrivateKey, + key::{PrivateKey, SecretKey}, password_hash::PasswordHash, utils::{derive_key_argon2, derive_key_pbkdf2}, Argon2Parameters, @@ -227,3 +227,28 @@ fn test_utils_base64() { assert_eq!(base64_encode(data), "QmFzZTY0VGVzdA=="); } + +#[test] +fn test_symmetric_decrypt_with_secret_key_v2() { + // SecretKey wrapping the known key ozJVEme4+5e/4NG3C+Rl26GQbGWAqGc0QPX8/1xvaFM= + // Header: sig=0x0D0C, DataType::Key=1, KeySubtype::Secret=4, KeyVersion::V1=1 + let secret_key = SecretKey::try_from( + general_purpose::STANDARD + .decode("DQwBAAQAAQCjMlUSZ7j7l7/g0bcL5GXboZBsZYCoZzRA9fz/XG9oUw==") + .unwrap() + .as_slice(), + ) + .unwrap(); + + let ciphertext = Ciphertext::try_from( + general_purpose::STANDARD + .decode("DQwCAAAAAgAA0iPpI4IEzcrWAQiy6tqDqLbRYduGvlMC32mVH7tpIN2CXDUu5QHF91I7pMrmjt/61pm5CeR/IcU=") + .unwrap() + .as_slice(), + ) + .unwrap(); + + let result = ciphertext.decrypt_with_secret_key(&secret_key).unwrap(); + + assert_eq!(result, b"test Ciph3rtext~2"); +} From 727353b264dd1b664b6dec1d78696a687f5283d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Wed, 6 May 2026 15:44:03 -0400 Subject: [PATCH 2/7] fix typo --- src/lib.rs | 6 +++--- src/secret_sharing/mod.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index d1562c40f..c65c50cc5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,7 +17,7 @@ //! //! ## Overview //! -//! The library is splitted into multiple modules, which are explained below. When +//! The library is split into multiple modules, which are explained below. When //! dealing with "managed" data, that includes an header and versionning, you deal //! with structures like `Ciphertext`, `PublicKey`, etc. //! @@ -146,7 +146,7 @@ //! ``` //! //! ## SecretSharing -//! This module is used to generate a key that is splitted in multiple `Share` +//! This module is used to generate a key that is split in multiple `Share` //! and that requires a specific amount of them to regenerate the key. //! You can think of it as a "Break The Glass" scenario. You can //! generate a key using this, lock your entire data by encrypting it @@ -156,7 +156,7 @@ //! ```rust //! use devolutions_crypto::secret_sharing::{generate_shared_key, join_shares, SecretSharingVersion, Share}; //! -//! // You want a key of 32 bytes, splitted between 5 people, and I want a +//! // You want a key of 32 bytes, split between 5 people, and I want a //! // minimum of 3 of these shares to regenerate the key. //! let shares: Vec = generate_shared_key(5, 3, 32, SecretSharingVersion::Latest).expect("generation shouldn't fail with the right parameters"); //! diff --git a/src/secret_sharing/mod.rs b/src/secret_sharing/mod.rs index dc1b44861..ab29d0ad9 100644 --- a/src/secret_sharing/mod.rs +++ b/src/secret_sharing/mod.rs @@ -1,8 +1,8 @@ -//! Module for creating keys splitted between multiple parties. +//! Module for creating keys split between multiple parties. //! Use this for "Break The Glass" scenarios or when you want to cryptographically enforce //! approval of multiple users. //! -//! This module is used to generate a key that is splitted in multiple `Share` +//! This module is used to generate a key that is split in multiple `Share` //! and that requires a specific amount of them to regenerate the key. //! You can think of it as a "Break The Glass" scenario. You can //! generate a key using this, lock your entire data by encrypting it @@ -12,7 +12,7 @@ //! ```rust //! use devolutions_crypto::secret_sharing::{generate_shared_key, join_shares, SecretSharingVersion, Share}; //! -//! // You want a key of 32 bytes, splitted between 5 people, and I want a +//! // You want a key of 32 bytes, split between 5 people, and I want a //! // minimum of 3 of these shares to regenerate the key. //! let shares: Vec = generate_shared_key(5, 3, 32, SecretSharingVersion::Latest).expect("generation shouldn't fail with the right parameters"); //! From 55e546cd931862d1824dd416a3eb003c28add405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Wed, 6 May 2026 16:17:25 -0400 Subject: [PATCH 3/7] Rewording and typos in docstrings --- README_RUST.md | 1 - src/ciphertext/mod.rs | 10 +++++----- src/lib.rs | 33 +++++++++------------------------ 3 files changed, 14 insertions(+), 30 deletions(-) diff --git a/README_RUST.md b/README_RUST.md index 1d0d4d548..3337eabd9 100644 --- a/README_RUST.md +++ b/README_RUST.md @@ -1,5 +1,4 @@ # devolutions-crypto -[![Build Status](https://dev.azure.com/devolutions-net/Open%20Source/_apis/build/status/devolutions-crypto?branchName=master)](https://dev.azure.com/devolutions-net/Open%20Source/_build/latest?definitionId=170&branchName=master) [![](https://meritbadge.herokuapp.com/devolutions-crypto)](https://crates.io/crates/devolutions-crypto) Cryptographic library used in Devolutions products. It is made to be fast, easy to use and misuse-resistant. [![crates.io](https://img.shields.io/crates/v/devolutions-crypto.svg)](https://crates.io/crates/devolutions-crypto) [Documentation](https://docs.rs/devolutions-crypto/) diff --git a/src/ciphertext/mod.rs b/src/ciphertext/mod.rs index a86aa3b31..bf5cbb763 100644 --- a/src/ciphertext/mod.rs +++ b/src/ciphertext/mod.rs @@ -87,7 +87,7 @@ enum CiphertextPayload { V2Asymmetric(CiphertextV2Asymmetric), } -/// Returns an `Ciphertext` from cleartext data and a key. +/// Returns a `Ciphertext` from cleartext data and a key. /// # Arguments /// * `data` - The data to encrypt. /// * `key` - The key to use. The recommended size is 32 bytes. @@ -108,7 +108,7 @@ pub fn encrypt(data: &[u8], key: &[u8], version: CiphertextVersion) -> Result` and `Into>` which are the implemented way to serialize and deserialize data. +//! These structures all implement `TryFrom<&[u8]>` and `Into>` to serialize and deserialize data. //! //! ```rust //! use std::convert::TryFrom as _; @@ -29,22 +28,15 @@ //! use devolutions_crypto::ciphertext::{ encrypt, CiphertextVersion, Ciphertext }; //! //! let key: Vec = generate_key(32).expect("generate key shouldn't fail");; -//! //! let data = b"somesecretdata"; -//! //! let encrypted_data: Ciphertext = encrypt(data, &key, CiphertextVersion::Latest).expect("encryption shouldn't fail"); //! -//! // The ciphertext can be serialized. +//! // The ciphertext can be serialized to be saved somewhere, passed to another language or over the network. //! let encrypted_data_vec: Vec = encrypted_data.into(); //! -//! // This data can be saved somewhere, passed to another language or over the network -//! // ... //! // When you receive the data as a byte array, you can deserialize it into a struct using TryFrom -//! //! let ciphertext = Ciphertext::try_from(encrypted_data_vec.as_slice()).expect("deserialization shouldn't fail"); -//! //! let decrypted_data = ciphertext.decrypt(&key).expect("The decryption shouldn't fail"); -//! //! assert_eq!(decrypted_data, data); //! ``` //! @@ -57,16 +49,13 @@ //! //! ```rust //! use devolutions_crypto::utils::generate_key; -//! use devolutions_crypto::ciphertext::{ encrypt, CiphertextVersion, Ciphertext }; -//! -//! let key: Vec = generate_key(32).expect("generate key shouldn't fail");; +//! use devolutions_crypto::ciphertext::{encrypt, CiphertextVersion, Ciphertext}; //! +//! let key: Vec = generate_key(32).expect("generate key shouldn't fail"); //! let data = b"somesecretdata"; //! //! let encrypted_data: Ciphertext = encrypt(data, &key, CiphertextVersion::Latest).expect("encryption shouldn't fail"); -//! //! let decrypted_data = encrypted_data.decrypt(&key).expect("The decryption shouldn't fail"); -//! //! assert_eq!(decrypted_data, data); //! ``` //! @@ -80,27 +69,23 @@ //! use devolutions_crypto::ciphertext::{ encrypt_asymmetric, CiphertextVersion, Ciphertext }; //! //! let keypair: KeyPair = generate_keypair(KeyVersion::Latest); -//! //! let data = b"somesecretdata"; //! //! let encrypted_data: Ciphertext = encrypt_asymmetric(data, &keypair.public_key, CiphertextVersion::Latest).expect("encryption shouldn't fail"); -//! //! let decrypted_data = encrypted_data.decrypt_asymmetric(&keypair.private_key).expect("The decryption shouldn't fail"); -//! //! assert_eq!(decrypted_data, data); //! ``` //! //! ## Key //! -//! For now, this module only deal with keypairs, as the symmetric keys are not wrapped yet. +//! This module provides secret keys and keypairs. //! //! ### Generation/Derivation //! -//! Using `generate_keypair` will generate a random keypair. +//! Use `generate_secret_key` to a generate a random symmetric key and `generate_keypair` to generate a random keypair. //! //! Asymmetric keys have two uses. They can be used to [encrypt and decrypt data](##asymmetric) and to perform a [key exchange](#key-exchange). //! -//! #### `generate_keypair` //! ```rust //! use devolutions_crypto::key::{generate_keypair, KeyVersion, KeyPair}; //! From bed44cfa3436654c0ac6ac9b7bf9da6175554a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Fri, 8 May 2026 20:41:00 -0400 Subject: [PATCH 4/7] Update the bindings to support SecretKey --- _plans/secret-key-wrappers.md | 394 ++++++++++++++++++ ffi/devolutions-crypto.h | 21 + ffi/src/lib.rs | 51 ++- python/devolutions_crypto.pyi | 73 ++++ python/src/lib.rs | 63 ++- src/wasm.rs | 46 +- .../src/ciphertext.rs | 38 ++ uniffi/devolutions-crypto-uniffi/src/key.rs | 6 + wrappers/csharp/src/Managed.cs | 49 +++ wrappers/csharp/src/Native.Core.cs | 6 + wrappers/csharp/src/SecretKey.cs | 66 +++ .../csharp/tests/unit-tests/Conformity.cs | 15 + .../csharp/tests/unit-tests/TestManaged.cs | 73 +++- wrappers/python/tests/symmetric.py | 23 + wrappers/wasm/demo/package-lock.json | 13 +- wrappers/wasm/demo/package.json | 2 +- wrappers/wasm/demo/src/app/app.component.html | 1 + wrappers/wasm/demo/src/app/app.routes.ts | 4 +- .../secret-key-encryption.component.html | 75 ++++ .../secret-key-encryption.component.ts | 109 +++++ .../app/service/encryption.inner.service.ts | 16 +- wrappers/wasm/tests/tests/symmetric.ts | 39 +- wrappers/wasm/wasm_build.ps1 | 31 ++ 23 files changed, 1193 insertions(+), 21 deletions(-) create mode 100644 _plans/secret-key-wrappers.md create mode 100644 wrappers/csharp/src/SecretKey.cs create mode 100644 wrappers/wasm/demo/src/app/secret-key-encryption/secret-key-encryption.component.html create mode 100644 wrappers/wasm/demo/src/app/secret-key-encryption/secret-key-encryption.component.ts create mode 100644 wrappers/wasm/wasm_build.ps1 diff --git a/_plans/secret-key-wrappers.md b/_plans/secret-key-wrappers.md new file mode 100644 index 000000000..6faab2b42 --- /dev/null +++ b/_plans/secret-key-wrappers.md @@ -0,0 +1,394 @@ +# Plan: Expose SecretKey in all language wrappers + +## TL;DR +Follow-up to `secret-key.md`. The Rust core now has `SecretKey`, `generate_secret_key`, `encrypt_with_secret_key`, and `decrypt_with_secret_key`. This plan wires them into every language wrapper: FFI/C, C#, WASM/JS, UniFFI (Kotlin + Swift), and Python. + +Each wrapper follows its own established pattern for how keys are represented (see per-wrapper sections below). + +--- + +## Phase 1 — FFI / C + +### Files +- `ffi/src/lib.rs` +- `ffi/devolutions-crypto.h` + +### Changes + +The existing `Encrypt`/`Decrypt` FFI functions already accept a raw key buffer, so they already work with the payload bytes of a `SecretKey`. What is missing is a way to generate and serialize a `SecretKey` from C. + +1. **Add `GenerateSecretKey`** (writes serialized `SecretKey` bytes into a caller-supplied output buffer): + ```rust + #[no_mangle] + pub unsafe extern "C" fn GenerateSecretKey( + result: *mut u8, + result_length: usize, + ) -> i64 + ``` + Implementation: call `generate_secret_key(KeyVersion::Latest)`, serialize via `Into::>::into()`, copy into `result`. + +2. **Add `GenerateSecretKeySize`** (returns the byte length of a serialized `SecretKey`): + ```rust + #[no_mangle] + pub extern "C" fn GenerateSecretKeySize() -> i64 + ``` + Implementation: `Header::::len() + 32` (header size + 32 raw key bytes). + Alternatively: generate one and measure — but a constant is cleaner. + +3. **Update `ffi/devolutions-crypto.h`** — add the two C declarations: + ```c + int64_t GenerateSecretKey(uint8_t *result, size_t result_length); + int64_t GenerateSecretKeySize(void); + ``` + +4. **Add import** `use devolutions_crypto::key::{generate_secret_key, SecretKey, KeyVersion};` to the top of `ffi/src/lib.rs` (alongside existing key imports). + +### Tests (in `ffi/src/lib.rs`) +- `generate_secret_key_ffi` — call `GenerateSecretKeySize`, allocate, call `GenerateSecretKey`, parse result with `SecretKey::try_from`, assert 32 bytes. + +--- + +## Phase 2 — C# + +### Files +- `wrappers/csharp/src/Native.Core.cs` +- `wrappers/csharp/src/Managed.cs` + +### Changes + +C# is a thin managed wrapper over the FFI layer. Follow the exact `GenerateKeyPair` pattern. + +1. **`Native.Core.cs`** — add two P/Invoke declarations: + ```csharp + [DllImport(LibName, EntryPoint = "GenerateSecretKey", CallingConvention = CallingConvention.Cdecl)] + internal static extern long GenerateSecretKeyNative(byte[] result, UIntPtr resultLength); + + [DllImport(LibName, EntryPoint = "GenerateSecretKeySize", CallingConvention = CallingConvention.Cdecl)] + internal static extern long GenerateSecretKeySizeNative(); + ``` + +2. **`Managed.cs`** — add `GenerateSecretKey()` returning a `byte[]`: + ```csharp + public static byte[] GenerateSecretKey() + { + long size = Native.GenerateSecretKeySizeNative(); + byte[] result = new byte[size]; + long res = Native.GenerateSecretKeyNative(result, (UIntPtr)result.Length); + if (res < 0) throw DevolutionsCryptoException.FromErrorCode(res); + return result; + } + ``` + Callers then pass the returned `byte[]` to the existing `Encrypt`/`Decrypt` methods directly (no new encrypt/decrypt wrappers needed — `Encrypt(data, secretKeyBytes)` already works). + +### Tests (`wrappers/csharp/tests/unit-tests/TestManaged.cs`) +- `GenerateSecretKey` — generate, assert non-null and non-empty, round-trip through `Encrypt`/`Decrypt`, assert equality. + +--- + +## Phase 3 — WASM / JavaScript + +### Files +- `src/wasm.rs` + +The TypeScript `.d.ts` and the JS glue in `wrappers/wasm/dist/` are generated artifacts — they do not need manual edits. + +### Changes + +WASM exposes key objects as first-class JS classes (see `PrivateKey`, `PublicKey` pattern). `SecretKey` should follow the same shape. + +1. **Import `SecretKey` and `generate_secret_key`** at the top of `src/wasm.rs`: + ```rust + use super::{ + key, + key::{KeyVersion, PrivateKey, PublicKey, SecretKey}, + }; + ``` + +2. **Implement wasm-bindgen methods on `SecretKey`** (in an `impl` block gated with `#[wasm_bindgen]`): + ```rust + #[wasm_bindgen] + impl SecretKey { + #[wasm_bindgen(getter)] + pub fn bytes(&self) -> Vec { + self.clone().into() + } + + #[wasm_bindgen(js_name = "fromBytes")] + pub fn from_bytes(buffer: &[u8]) -> Result { + Ok(SecretKey::try_from(buffer)?) + } + } + ``` + +3. **Add `generateSecretKey` free function**: + ```rust + #[wasm_bindgen(js_name = "generateSecretKey")] + pub fn generate_secret_key(version: Option) -> SecretKey { + key::generate_secret_key(version.unwrap_or(KeyVersion::Latest)) + } + ``` + +4. **Add `encryptWithSecretKey` free function** (typed, takes a `SecretKey` object): + ```rust + #[wasm_bindgen(js_name = "encryptWithSecretKey")] + pub fn encrypt_with_secret_key( + data: &[u8], + key: &SecretKey, + aad: Option>, + version: Option, + ) -> Result, JsValue> { + Ok(ciphertext::encrypt_with_aad( + data, + key.as_bytes(), + &aad.unwrap_or_default(), + version.unwrap_or(CiphertextVersion::Latest), + )? + .into()) + } + ``` + +5. **Add `decryptWithSecretKey` free function**: + ```rust + #[wasm_bindgen(js_name = "decryptWithSecretKey")] + pub fn decrypt_with_secret_key( + data: &[u8], + key: &SecretKey, + aad: Option>, + ) -> Result, JsValue> { + let data_blob = Ciphertext::try_from(data)?; + Ok(data_blob.decrypt_with_aad(key.as_bytes(), &aad.unwrap_or_default())?) + } + ``` + +### Resulting TypeScript surface (generated) +```ts +export class SecretKey { + readonly bytes: Uint8Array; + static fromBytes(buffer: Uint8Array): SecretKey; +} +export function generateSecretKey(version?: KeyVersion | null): SecretKey; +export function encryptWithSecretKey( + data: Uint8Array, + key: SecretKey, + aad?: Uint8Array | null, + version?: CiphertextVersion | null +): Uint8Array; +export function decryptWithSecretKey( + data: Uint8Array, + key: SecretKey, + aad?: Uint8Array | null +): Uint8Array; +``` + +--- + +## Phase 4 — UniFFI (Kotlin + Swift) + +### Files +- `uniffi/devolutions-crypto-uniffi/src/key.rs` +- `uniffi/devolutions-crypto-uniffi/src/ciphertext.rs` + +Kotlin and Swift bindings are auto-generated by UniFFI at build time. Editing these two Rust files is all that is needed. + +### Changes + +**`uniffi/devolutions-crypto-uniffi/src/key.rs`** + +Add `generate_secret_key` export (key is returned as serialized `Vec`, matching the byte-array convention used for `generate_keypair`): +```rust +#[uniffi::export(default(version = None))] +pub fn generate_secret_key(version: Option) -> Vec { + let version = version.unwrap_or(KeyVersion::Latest); + devolutions_crypto::key::generate_secret_key(version).into() +} +``` + +**`uniffi/devolutions-crypto-uniffi/src/ciphertext.rs`** + +Add four functions following the exact `encrypt_asymmetric`/`decrypt_asymmetric` pattern (deserialize key, delegate): + +```rust +#[uniffi::export(default(version = None))] +pub fn encrypt_with_secret_key( + data: &[u8], + key: &[u8], + version: Option, +) -> Result> { + let version = version.unwrap_or(CiphertextVersion::Latest); + let key = devolutions_crypto::key::SecretKey::try_from(key)?; + Ok(devolutions_crypto::ciphertext::encrypt_with_secret_key(data, &key, version)?.into()) +} + +#[uniffi::export(default(version = None))] +pub fn encrypt_with_secret_key_and_aad( + data: &[u8], + key: &[u8], + aad: &[u8], + version: Option, +) -> Result> { + let version = version.unwrap_or(CiphertextVersion::Latest); + let key = devolutions_crypto::key::SecretKey::try_from(key)?; + Ok(devolutions_crypto::ciphertext::encrypt_with_secret_key_and_aad(data, &key, aad, version)?.into()) +} + +#[uniffi::export] +pub fn decrypt_with_secret_key(data: &[u8], key: &[u8]) -> Result> { + let key = devolutions_crypto::key::SecretKey::try_from(key)?; + let data = devolutions_crypto::ciphertext::Ciphertext::try_from(data)?; + data.decrypt_with_secret_key(&key) +} + +#[uniffi::export] +pub fn decrypt_with_secret_key_and_aad(data: &[u8], key: &[u8], aad: &[u8]) -> Result> { + let key = devolutions_crypto::key::SecretKey::try_from(key)?; + let data = devolutions_crypto::ciphertext::Ciphertext::try_from(data)?; + data.decrypt_with_secret_key_and_aad(&key, aad) +} +``` + +Also add `use devolutions_crypto::key::SecretKey;` to the imports at the top of `ciphertext.rs`. + +### Resulting generated Kotlin surface +```kotlin +fun generateSecretKey(version: KeyVersion? = null): ByteArray +fun encryptWithSecretKey(data: ByteArray, key: ByteArray, version: CiphertextVersion? = null): ByteArray +fun encryptWithSecretKeyAndAad(data: ByteArray, key: ByteArray, aad: ByteArray, version: CiphertextVersion? = null): ByteArray +fun decryptWithSecretKey(data: ByteArray, key: ByteArray): ByteArray +fun decryptWithSecretKeyAndAad(data: ByteArray, key: ByteArray, aad: ByteArray): ByteArray +``` + +### Resulting generated Swift surface +```swift +public func generateSecretKey(version: KeyVersion? = nil) -> Data +public func encryptWithSecretKey(data: Data, key: Data, version: CiphertextVersion? = nil) throws -> Data +public func encryptWithSecretKeyAndAad(data: Data, key: Data, aad: Data, version: CiphertextVersion? = nil) throws -> Data +public func decryptWithSecretKey(data: Data, key: Data) throws -> Data +public func decryptWithSecretKeyAndAad(data: Data, key: Data, aad: Data) throws -> Data +``` + +### Tests + +**Kotlin** (`wrappers/kotlin/lib/src/test/kotlin/org/devolutions/crypto/SymmetricTest.kt`): +- `testGenerateSecretKeyAndEncryptDecrypt` — generate, encrypt, decrypt, assert equality. +- `testEncryptDecryptWithSecretKeyAndAad` — same with AAD; wrong AAD returns error. + +**Swift** (`wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/SymmetricTests.swift`): +- `testGenerateSecretKeyAndEncryptDecrypt` — generate, encrypt, decrypt, assert equality. +- `testEncryptDecryptWithSecretKeyAndAad` — same with AAD; wrong AAD throws. + +--- + +## Phase 5 — Python + +### Files +- `python/src/lib.rs` +- `python/devolutions_crypto.pyi` + +### Changes + +**`python/src/lib.rs`** + +1. Add `use devolutions_crypto::key::{SecretKey, generate_secret_key as dc_generate_secret_key, KeyVersion as DcKeyVersion};` (or adjust existing imports). + +2. Add `generate_secret_key`: + ```rust + #[pyfunction] + #[pyo3(signature = (version=0))] + fn generate_secret_key(py: Python, version: u16) -> Result> { + let version = DcKeyVersion::try_from(version)?; + let key = dc_generate_secret_key(version); + let bytes: Vec = key.into(); + Ok(PyBytes::new(py, &bytes).into()) + } + ``` + +3. Add `encrypt_with_secret_key`: + ```rust + #[pyfunction] + #[pyo3(signature = (data, key, aad=None, version=0))] + fn encrypt_with_secret_key( + py: Python, + data: &[u8], + key: &[u8], + aad: Option<&[u8]>, + version: u16, + ) -> Result> { + let version = CiphertextVersion::try_from(version)?; + let key = SecretKey::try_from(key)?; + let aad = aad.unwrap_or(&[]); + let ct = devolutions_crypto::ciphertext::encrypt_with_secret_key_and_aad(data, &key, aad, version)?; + Ok(PyBytes::new(py, &Into::>::into(ct)).into()) + } + ``` + +4. Add `decrypt_with_secret_key`: + ```rust + #[pyfunction] + #[pyo3(signature = (data, key, aad=None))] + fn decrypt_with_secret_key( + py: Python, + data: &[u8], + key: &[u8], + aad: Option<&[u8]>, + ) -> Result> { + let key = SecretKey::try_from(key)?; + let aad = aad.unwrap_or(&[]); + let ct = devolutions_crypto::ciphertext::Ciphertext::try_from(data)?; + let plaintext = ct.decrypt_with_secret_key_and_aad(&key, aad)?; + Ok(PyBytes::new(py, &plaintext).into()) + } + ``` + +5. **Register all three** in the `#[pymodule]` function: + ```rust + m.add_function(wrap_pyfunction!(generate_secret_key, m)?)?; + m.add_function(wrap_pyfunction!(encrypt_with_secret_key, m)?)?; + m.add_function(wrap_pyfunction!(decrypt_with_secret_key, m)?)?; + ``` + +**`python/devolutions_crypto.pyi`** — add stubs: +```python +def generate_secret_key(version: int = 0) -> bytes: ... +def encrypt_with_secret_key(data: bytes, key: bytes, aad: bytes | None = None, version: int = 0) -> bytes: ... +def decrypt_with_secret_key(data: bytes, key: bytes, aad: bytes | None = None) -> bytes: ... +``` + +### Tests (inline in `python/src/lib.rs` or in a Python test file) +- Generate a secret key, encrypt, decrypt, assert equality. +- Wrong key returns error. Wrong AAD returns error. + +--- + +## Relevant files summary + +| File | Change | +|------|--------| +| `ffi/src/lib.rs` | Add `GenerateSecretKey`, `GenerateSecretKeySize` | +| `ffi/devolutions-crypto.h` | Add C declarations for the two new FFI functions | +| `wrappers/csharp/src/Native.Core.cs` | Add `GenerateSecretKeyNative`, `GenerateSecretKeySizeNative` P/Invoke | +| `wrappers/csharp/src/Managed.cs` | Add `GenerateSecretKey() -> byte[]` | +| `src/wasm.rs` | Add `SecretKey` wasm impls, `generateSecretKey`, `encryptWithSecretKey`, `decryptWithSecretKey` | +| `uniffi/devolutions-crypto-uniffi/src/key.rs` | Add `generate_secret_key` export | +| `uniffi/devolutions-crypto-uniffi/src/ciphertext.rs` | Add four `*_with_secret_key` exports | +| `python/src/lib.rs` | Add `generate_secret_key`, `encrypt_with_secret_key`, `decrypt_with_secret_key` | +| `python/devolutions_crypto.pyi` | Add three stubs | + +--- + +## Verification + +1. `cargo check` — no errors across all crates (ffi, python, uniffi, main with wbindgen feature). +2. `cargo test` — all tests pass. +3. C#: `dotnet test` in `wrappers/csharp/`. +4. WASM: rebuild with `wasm-pack build` and run the JS/TS tests. +5. Kotlin: `./gradlew test` in `wrappers/kotlin/`. +6. Swift: `swift test` in `wrappers/swift/DevolutionsCryptoSwift/`. +7. Python: `maturin develop` + `pytest`. + +## Decisions + +- **FFI returns the full serialized `SecretKey` blob** (header + 32 bytes), not just the raw 32 bytes. This is consistent with how `GenerateKeyPair` returns serialized key blobs, and means callers can pass the result directly to the existing `Encrypt`/`Decrypt` byte-array functions. +- **C# and Python return `byte[]`/`bytes`**, not dedicated wrapper types — consistent with how asymmetric keys are handled in those wrappers. +- **WASM gets a first-class `SecretKey` class** with `bytes`/`fromBytes` — consistent with `PrivateKey`/`PublicKey` in that wrapper. +- **UniFFI (Kotlin/Swift) uses serialized bytes** — consistent with the `encrypt_asymmetric` pattern where keys are passed as `&[u8]` and deserialized inside. +- **No new `EncryptWithSecretKey`/`DecryptWithSecretKey` in the FFI layer** — the existing `Encrypt`/`Decrypt` already accept the key as a raw byte buffer, and callers can pass the payload of a `SecretKey`. A typed variant adds no functionality at the C ABI level. diff --git a/ffi/devolutions-crypto.h b/ffi/devolutions-crypto.h index af2544b6d..fec3c283a 100644 --- a/ffi/devolutions-crypto.h +++ b/ffi/devolutions-crypto.h @@ -317,6 +317,27 @@ int64_t GenerateKeyPair(uint8_t *private_, */ int64_t GenerateKeyPairSize(void); +/** + * Generate a secret key for symmetric encryption. + * # Arguments + * * `result` - Pointer to the buffer to write the secret key to. + * * `result_length` - Length of the buffer to write the secret key to. + * You can get the value by calling `GenerateSecretKeySize()` beforehand. + * # Returns + * Returns 0 if the generation worked. If there is an error, + * it will return the appropriate error code defined in DevoCryptoError. + * # Safety + * This method is made to be called by C, so it is therefore unsafe. The caller should make sure it passes the right pointers and sizes. + */ +int64_t GenerateSecretKey(uint8_t *result, size_t result_length); + +/** + * Get the size of a serialized secret key. + * # Returns + * Returns the length of the buffer to pass as `result_length` in `GenerateSecretKey()`. + */ +int64_t GenerateSecretKeySize(void); + /** * Generates a secret key shared amongst multiple actor. * # Arguments diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index 345d83596..004fe1268 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -22,7 +22,7 @@ use devolutions_crypto::ciphertext::{ encrypt_asymmetric_with_aad, encrypt_with_aad, Ciphertext, CiphertextVersion, }; use devolutions_crypto::key::{ - generate_keypair, mix_key_exchange, KeyVersion, PrivateKey, PublicKey, + generate_keypair, generate_secret_key, mix_key_exchange, KeyVersion, PrivateKey, PublicKey, }; use devolutions_crypto::password_hash::{hash_password, PasswordHash, PasswordHashVersion}; use devolutions_crypto::secret_sharing::{ @@ -672,6 +672,43 @@ pub extern "C" fn GenerateKeyPairSize() -> i64 { 8 + 32 // header + key length } +/// Generate a secret key for symmetric encryption. +/// # Arguments +/// * `result` - Pointer to the buffer to write the secret key to. +/// * `result_length` - Length of the buffer to write the secret key to. +/// You can get the value by calling `GenerateSecretKeySize()` beforehand. +/// # Returns +/// Returns 0 if the generation worked. If there is an error, +/// it will return the appropriate error code defined in DevoCryptoError. +/// # Safety +/// This method is made to be called by C, so it is therefore unsafe. The caller should make sure it passes the right pointers and sizes. +#[no_mangle] +pub unsafe extern "C" fn GenerateSecretKey(result: *mut u8, result_length: usize) -> i64 { + if result.is_null() { + return Error::NullPointer.error_code(); + } + + if result_length != GenerateSecretKeySize() as usize { + return Error::InvalidOutputLength.error_code(); + } + + let result = slice::from_raw_parts_mut(result, result_length); + + let key = generate_secret_key(KeyVersion::Latest); + let key_bytes: Zeroizing> = Zeroizing::new(key.into()); + + result[0..key_bytes.len()].copy_from_slice(&key_bytes); + 0 +} + +/// Get the size of a serialized secret key. +/// # Returns +/// Returns the length of the buffer to pass as `result_length` in `GenerateSecretKey()`. +#[no_mangle] +pub extern "C" fn GenerateSecretKeySize() -> i64 { + 8 + 32 // header + key length +} + /// Get the size of the keypair used for signing. /// # Returns /// Returns the length of the keypair to input as `keypair_length` @@ -1809,3 +1846,15 @@ fn test_decode() { assert!(res > 0i64) } } + +#[test] +fn test_generate_secret_key() { + let size = GenerateSecretKeySize() as usize; + let mut key_buf = vec![0u8; size]; + + let res = unsafe { GenerateSecretKey(key_buf.as_mut_ptr(), size) }; + assert_eq!(res, 0); + + let key = devolutions_crypto::key::SecretKey::try_from(key_buf.as_slice()).expect("should parse as SecretKey"); + assert_eq!(key.as_bytes().len(), 32); +} diff --git a/python/devolutions_crypto.pyi b/python/devolutions_crypto.pyi index ef851d935..601873730 100644 --- a/python/devolutions_crypto.pyi +++ b/python/devolutions_crypto.pyi @@ -258,6 +258,76 @@ def generate_signing_keypair(version: int = 0) -> bytes: """ ... +def generate_secret_key(version: int = 0) -> bytes: + """ + Generate a random secret key for symmetric encryption. + + Args: + version: Key version (default: 0) + + Returns: + The serialized secret key as bytes (header + 32 raw key bytes) + + Raises: + DevolutionsCryptoException: If key generation fails or invalid version + + Example: + >>> secret_key = generate_secret_key() + >>> ciphertext = encrypt_with_secret_key(b'data', secret_key) + """ + ... + +def encrypt_with_secret_key( + data: bytes, + key: bytes, + aad: Optional[bytes] = None, + version: int = 0 +) -> bytes: + """ + Encrypt data using a SecretKey (AES-256-GCM). + + Args: + data: The plaintext data to encrypt + key: The serialized SecretKey (generated by generate_secret_key) + aad: Optional Additional Authenticated Data for AEAD + version: Ciphertext version (default: 0) + + Returns: + The encrypted ciphertext as bytes + + Raises: + DevolutionsCryptoException: If encryption fails or invalid key provided + + Example: + >>> secret_key = generate_secret_key() + >>> ciphertext = encrypt_with_secret_key(b'Hello', secret_key) + """ + ... + +def decrypt_with_secret_key( + data: bytes, + key: bytes, + aad: Optional[bytes] = None +) -> bytes: + """ + Decrypt data that was encrypted with a SecretKey. + + Args: + data: The ciphertext to decrypt + key: The serialized SecretKey used for encryption + aad: Optional Additional Authenticated Data (must match encryption AAD) + + Returns: + The decrypted plaintext as bytes + + Raises: + DevolutionsCryptoException: If decryption fails, authentication fails, or invalid key + + Example: + >>> plaintext = decrypt_with_secret_key(ciphertext, secret_key) + """ + ... + def get_signing_public_key(keypair: bytes) -> bytes: """ Extract the public key from a signing keypair. @@ -342,4 +412,7 @@ __all__ = [ 'get_signing_public_key', 'sign', 'verify_signature', + 'generate_secret_key', + 'encrypt_with_secret_key', + 'decrypt_with_secret_key', ] diff --git a/python/src/lib.rs b/python/src/lib.rs index 1c99a62ce..60fcf0d10 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -10,7 +10,7 @@ use devolutions_crypto::Error; use devolutions_crypto::{ciphertext, ciphertext::Ciphertext}; use devolutions_crypto::{ key, - key::{PrivateKey, PublicKey}, + key::{PrivateKey, PublicKey, SecretKey}, }; use devolutions_crypto::{signature, signature::Signature}; use devolutions_crypto::{ @@ -242,6 +242,64 @@ fn generate_keypair(py: Python, version: u16) -> Result { Ok(keypair) } +#[pyfunction] +#[pyo3(name = "generate_secret_key")] +#[pyo3(signature = (version=0))] +fn generate_secret_key(py: Python, version: u16) -> Result> { + let version = match KeyVersion::try_from(version) { + Ok(v) => v, + Err(_) => { + let error: DevolutionsCryptoError = Error::UnknownVersion.into(); + return Err(error); + } + }; + + let key = key::generate_secret_key(version); + let bytes: Vec = key.into(); + Ok(PyBytes::new(py, &bytes).into()) +} + +#[pyfunction] +#[pyo3(name = "encrypt_with_secret_key")] +#[pyo3(signature = (data, key, aad=None, version=0))] +fn encrypt_with_secret_key( + py: Python, + data: &[u8], + key: &[u8], + aad: Option<&[u8]>, + version: u16, +) -> Result> { + let version = match CiphertextVersion::try_from(version) { + Ok(v) => v, + Err(_) => { + let error: DevolutionsCryptoError = Error::UnknownVersion.into(); + return Err(error); + } + }; + + let key = SecretKey::try_from(key)?; + let aad = aad.unwrap_or(&[]); + let ct: Vec = + ciphertext::encrypt_with_secret_key_and_aad(data, &key, aad, version)?.into(); + Ok(PyBytes::new(py, &ct).into()) +} + +#[pyfunction] +#[pyo3(name = "decrypt_with_secret_key")] +#[pyo3(signature = (data, key, aad=None))] +fn decrypt_with_secret_key( + py: Python, + data: &[u8], + key: &[u8], + aad: Option<&[u8]>, +) -> Result> { + let key = SecretKey::try_from(key)?; + let aad = aad.unwrap_or(&[]); + let ct = ciphertext::Ciphertext::try_from(data)?; + let plaintext = ct.decrypt_with_secret_key_and_aad(&key, aad)?; + Ok(PyBytes::new(py, &plaintext).into()) +} + #[pyfunction] #[pyo3(name = "generate_signing_keypair")] #[pyo3(signature = (version=0))] @@ -287,6 +345,9 @@ fn devolutions_crypto_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(generate_keypair, m)?)?; m.add_function(wrap_pyfunction!(generate_signing_keypair, m)?)?; m.add_function(wrap_pyfunction!(get_signing_public_key, m)?)?; + m.add_function(wrap_pyfunction!(generate_secret_key, m)?)?; + m.add_function(wrap_pyfunction!(encrypt_with_secret_key, m)?)?; + m.add_function(wrap_pyfunction!(decrypt_with_secret_key, m)?)?; m.add_class::()?; m.add( "DevolutionsCryptoException", diff --git a/src/wasm.rs b/src/wasm.rs index 49941d608..d2b1a86a5 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -13,7 +13,7 @@ use super::{ }; use super::{ key, - key::{KeyVersion, PrivateKey, PublicKey}, + key::{KeyVersion, PrivateKey, PublicKey, SecretKey}, }; use super::{ password_hash, @@ -219,6 +219,50 @@ pub fn generate_keypair(version: Option) -> KeyPair { key::generate_keypair(version.unwrap_or(KeyVersion::Latest)).into() } +#[wasm_bindgen] +impl SecretKey { + #[wasm_bindgen(getter)] + pub fn bytes(&self) -> Vec { + self.clone().into() + } + + #[wasm_bindgen(js_name = "fromBytes")] + pub fn from_bytes(buffer: &[u8]) -> Result { + Ok(SecretKey::try_from(buffer)?) + } +} + +#[wasm_bindgen(js_name = "generateSecretKey")] +pub fn generate_secret_key(version: Option) -> SecretKey { + key::generate_secret_key(version.unwrap_or(KeyVersion::Latest)) +} + +#[wasm_bindgen(js_name = "encryptWithSecretKey")] +pub fn encrypt_with_secret_key( + data: &[u8], + key: &SecretKey, + aad: Option>, + version: Option, +) -> Result, JsValue> { + Ok(ciphertext::encrypt_with_aad( + data, + key.as_bytes(), + &aad.unwrap_or_default(), + version.unwrap_or(CiphertextVersion::Latest), + )? + .into()) +} + +#[wasm_bindgen(js_name = "decryptWithSecretKey")] +pub fn decrypt_with_secret_key( + data: &[u8], + key: &SecretKey, + aad: Option>, +) -> Result, JsValue> { + let data_blob = Ciphertext::try_from(data)?; + Ok(data_blob.decrypt_with_aad(key.as_bytes(), &aad.unwrap_or_default())?) +} + #[wasm_bindgen(js_name = "generateSigningKeyPair")] pub fn generate_signing_keypair(version: Option) -> SigningKeyPair { signing_key::generate_signing_keypair(version.unwrap_or(SigningKeyVersion::Latest)).into() diff --git a/uniffi/devolutions-crypto-uniffi/src/ciphertext.rs b/uniffi/devolutions-crypto-uniffi/src/ciphertext.rs index eb96487ff..41d9418b3 100644 --- a/uniffi/devolutions-crypto-uniffi/src/ciphertext.rs +++ b/uniffi/devolutions-crypto-uniffi/src/ciphertext.rs @@ -1,5 +1,6 @@ use crate::CiphertextVersion; use crate::Result; +use devolutions_crypto::key::SecretKey; #[uniffi::export(default(version = None))] pub fn encrypt(data: &[u8], key: &[u8], version: Option) -> Result> { @@ -69,3 +70,40 @@ fn decrypt_asymmetric_with_aad(data: &[u8], key: &[u8], aad: &[u8]) -> Result, +) -> Result> { + let version = version.unwrap_or(CiphertextVersion::Latest); + let key = SecretKey::try_from(key)?; + Ok(devolutions_crypto::ciphertext::encrypt_with_secret_key(data, &key, version)?.into()) +} + +#[uniffi::export(default(version = None))] +pub fn encrypt_with_secret_key_and_aad( + data: &[u8], + key: &[u8], + aad: &[u8], + version: Option, +) -> Result> { + let version = version.unwrap_or(CiphertextVersion::Latest); + let key = SecretKey::try_from(key)?; + Ok(devolutions_crypto::ciphertext::encrypt_with_secret_key_and_aad(data, &key, aad, version)?.into()) +} + +#[uniffi::export] +pub fn decrypt_with_secret_key(data: &[u8], key: &[u8]) -> Result> { + let key = SecretKey::try_from(key)?; + let data = devolutions_crypto::ciphertext::Ciphertext::try_from(data)?; + data.decrypt_with_secret_key(&key) +} + +#[uniffi::export] +pub fn decrypt_with_secret_key_and_aad(data: &[u8], key: &[u8], aad: &[u8]) -> Result> { + let key = SecretKey::try_from(key)?; + let data = devolutions_crypto::ciphertext::Ciphertext::try_from(data)?; + data.decrypt_with_secret_key_and_aad(&key, aad) +} diff --git a/uniffi/devolutions-crypto-uniffi/src/key.rs b/uniffi/devolutions-crypto-uniffi/src/key.rs index cbfb43544..4ed395b89 100644 --- a/uniffi/devolutions-crypto-uniffi/src/key.rs +++ b/uniffi/devolutions-crypto-uniffi/src/key.rs @@ -22,6 +22,12 @@ pub fn generate_keypair(version: Option) -> KeyPair { devolutions_crypto::key::generate_keypair(version).into() } +#[uniffi::export(default(version = None))] +pub fn generate_secret_key(version: Option) -> Vec { + let version = version.unwrap_or(KeyVersion::Latest); + devolutions_crypto::key::generate_secret_key(version).into() +} + #[uniffi::export] pub fn mix_key_exchange(private_key: &[u8], public_key: &[u8]) -> Result> { let private_key = private_key.try_into()?; diff --git a/wrappers/csharp/src/Managed.cs b/wrappers/csharp/src/Managed.cs index adade3b2b..72bbd3040 100644 --- a/wrappers/csharp/src/Managed.cs +++ b/wrappers/csharp/src/Managed.cs @@ -541,6 +541,55 @@ public static KeyPair GenerateKeyPair() }; } + internal static byte[] GenerateSecretKeyBytes() + { + long size = Native.GenerateSecretKeySizeNative(); + byte[] result = new byte[size]; + + long res = Native.GenerateSecretKeyNative(result, (UIntPtr)result.Length); + + if (res < 0) + { + Utils.HandleError(res); + } + + return result; + } + + /// + /// Generates a random secret key for symmetric encryption. + /// + /// Returns the generated . + public static SecretKey GenerateSecretKey() + { + return new SecretKey(GenerateSecretKeyBytes()); + } + + /// + /// Encrypts data using a . + /// + /// The data to encrypt. + /// The to use for encryption. + /// Additional authenticated data. (Optional). + /// The cipher version to use. (Latest is recommended). + /// Returns the encrypted data as a byte array. + public static byte[]? Encrypt(byte[]? data, SecretKey key, byte[]? aad = null, CipherTextVersion version = CIPHERTEXT_VERSION) + { + return Encrypt(data, key.KeyMaterial, aad, version); + } + + /// + /// Decrypts data using a . + /// + /// The data to decrypt. + /// The used for encryption. + /// Additional authenticated data. (Optional). + /// Returns the decrypted data as a byte array. + public static byte[]? Decrypt(byte[]? data, SecretKey key, byte[]? aad = null) + { + return Decrypt(data, key.KeyMaterial, aad); + } + /// /// Encrypts the data. /// diff --git a/wrappers/csharp/src/Native.Core.cs b/wrappers/csharp/src/Native.Core.cs index 913ffd397..603f74117 100644 --- a/wrappers/csharp/src/Native.Core.cs +++ b/wrappers/csharp/src/Native.Core.cs @@ -73,6 +73,12 @@ public static partial class Native [DllImport(LibName, EntryPoint = "GenerateKeyPairSize", CallingConvention = CallingConvention.Cdecl)] internal static extern long GenerateKeyPairSizeNative(); + [DllImport(LibName, EntryPoint = "GenerateSecretKey", CallingConvention = CallingConvention.Cdecl)] + internal static extern long GenerateSecretKeyNative(byte[] result, UIntPtr resultLength); + + [DllImport(LibName, EntryPoint = "GenerateSecretKeySize", CallingConvention = CallingConvention.Cdecl)] + internal static extern long GenerateSecretKeySizeNative(); + [DllImport(LibName, EntryPoint = "GenerateKey", CallingConvention = CallingConvention.Cdecl)] internal static extern long GenerateKeyNative(byte[] key, UIntPtr keyLength); diff --git a/wrappers/csharp/src/SecretKey.cs b/wrappers/csharp/src/SecretKey.cs new file mode 100644 index 000000000..5d5413b85 --- /dev/null +++ b/wrappers/csharp/src/SecretKey.cs @@ -0,0 +1,66 @@ +namespace Devolutions.Cryptography +{ + using System; + + /// + /// A secret key for symmetric encryption. Should never be sent over an insecure channel or stored unsecurely. + /// + public class SecretKey + { + /// + /// Initializes a new instance of the class. + /// + public SecretKey(byte[] payload) + { + this.Payload = payload; + } + + /// + /// Gets the raw serialized key data. + /// + internal byte[] Payload { get; } + + /// + /// Gets the raw 32-byte key material, without the serialization header. + /// This is the value used as the actual encryption key. + /// + internal byte[] KeyMaterial + { + get + { + if (this.Payload.Length < 8) + { + throw new InvalidOperationException("Invalid secret key payload: too short to contain header and key material."); + } + + byte[] result = new byte[this.Payload.Length - 8]; + Array.Copy(this.Payload, 8, result, 0, result.Length); + return result; + } + } + + /// + /// Gets the raw serialized key data as a base64 string. + /// + public string PayloadString => Convert.ToBase64String(this.Payload); + + /// + /// Deserialize a from a byte array. + /// + /// The serialized secret key bytes. + /// Returns the deserialized . + public static SecretKey FromByteArray(byte[] data) + { + return new SecretKey(data); + } + + /// + /// Serialize the to a byte array. + /// + /// Returns the raw serialized key bytes. + public byte[] ToByteArray() + { + return this.Payload; + } + } +} diff --git a/wrappers/csharp/tests/unit-tests/Conformity.cs b/wrappers/csharp/tests/unit-tests/Conformity.cs index 1fc9aafbf..3af828018 100644 --- a/wrappers/csharp/tests/unit-tests/Conformity.cs +++ b/wrappers/csharp/tests/unit-tests/Conformity.cs @@ -157,6 +157,21 @@ public void SignatureV1() Assert.IsTrue(Managed.VerifySignature(data, publicKey, signature)); Assert.IsFalse(Managed.VerifySignature(wrongData, publicKey, signature)); } + + [TestMethod] + public void DecryptWithSecretKeyV2() + { + // SecretKey wrapping the known key ozJVEme4+5e/4NG3C+Rl26GQbGWAqGc0QPX8/1xvaFM= + // Header: sig=0x0D0C, DataType::Key=1, KeySubtype::Secret=4, KeyVersion::V1=1 + SecretKey secretKey = SecretKey.FromByteArray( + Utils.Base64StringToByteArray("DQwBAAQAAQCjMlUSZ7j7l7/g0bcL5GXboZBsZYCoZzRA9fz/XG9oUw==")!); + + byte[] ciphertext = Utils.Base64StringToByteArray( + "DQwCAAAAAgAA0iPpI4IEzcrWAQiy6tqDqLbRYduGvlMC32mVH7tpIN2CXDUu5QHF91I7pMrmjt/61pm5CeR/IcU=")!; + + byte[]? result = Managed.Decrypt(ciphertext, secretKey); + Assert.AreEqual("test Ciph3rtext~2", Utils.ByteArrayToUtf8String(result)); + } } } #pragma warning restore SA1600 // Elements should be documented \ No newline at end of file diff --git a/wrappers/csharp/tests/unit-tests/TestManaged.cs b/wrappers/csharp/tests/unit-tests/TestManaged.cs index 6fcbafdf0..64d7fdf9d 100644 --- a/wrappers/csharp/tests/unit-tests/TestManaged.cs +++ b/wrappers/csharp/tests/unit-tests/TestManaged.cs @@ -36,7 +36,7 @@ public void Decrypt() } [TestMethod] - public void DecryptAsymmetric_Test() + public void DecryptAsymmetric() { byte[] encryptedData = Convert.FromBase64String("DQwCAAIAAgD5rUXkPQO55rzI69WSxtVTA43lDXougn6BxJ7evqf+Yq+SEGXZxpE49874fz/aEk39LTnh1yWnY2VNoAAqKVB5CWZryd6SSld8Sx8v"); byte[]? decryptedData = Managed.DecryptAsymmetric(encryptedData, TestData.AlicePrivateKey); @@ -105,9 +105,9 @@ public void Encrypt() } [TestMethod] - public void EncryptAsymmetric_Test() + public void EncryptAsymmetric() { - byte[] dataToEncrypt = Encoding.UTF8.GetBytes("test"); + byte[] dataToEncrypt = "test"u8.ToArray(); byte[]? encryptedData = Managed.EncryptAsymmetric(dataToEncrypt, TestData.AlicePublicKey); Assert.IsNotNull(encryptedData); @@ -196,6 +196,63 @@ public void GenerateKeyPair() Assert.AreEqual(Convert.ToBase64String(bobMix), Convert.ToBase64String(aliceMix)); } + [TestMethod] + public void GenerateSecretKeyObject() + { + SecretKey key = Managed.GenerateSecretKey(); + Assert.IsNotNull(key.ToByteArray()); + Assert.IsTrue(key.ToByteArray().Length > 0); + } + + [TestMethod] + public void EncryptDecryptWithSecretKey() + { + byte[] plaintext = "test secret message"u8.ToArray(); + SecretKey key = Managed.GenerateSecretKey(); + + byte[]? ciphertext = Managed.Encrypt(plaintext, key); + Assert.IsNotNull(ciphertext); + Assert.IsTrue(Utils.ValidateHeader(ciphertext, DataType.Cipher)); + + byte[]? decrypted = Managed.Decrypt(ciphertext, key); + Assert.IsNotNull(decrypted); + Assert.AreEqual("test secret message", Encoding.UTF8.GetString(decrypted)); + } + + [TestMethod] + public void EncryptDecryptWithSecretKeyAndAad() + { + byte[] plaintext = "test secret message"u8.ToArray(); + byte[] aad = "public metadata"u8.ToArray(); + byte[] wrongAad = "tampered metadata"u8.ToArray(); + SecretKey key = Managed.GenerateSecretKey(); + + byte[]? ciphertext = Managed.Encrypt(plaintext, key, aad); + Assert.IsNotNull(ciphertext); + + byte[]? decrypted = Managed.Decrypt(ciphertext, key, aad); + Assert.IsNotNull(decrypted); + Assert.AreEqual("test secret message", Encoding.UTF8.GetString(decrypted)); + + Assert.ThrowsException(() => Managed.Decrypt(ciphertext, key, wrongAad)); + } + + [TestMethod] + public void SecretKeyRoundTrip() + { + SecretKey original = Managed.GenerateSecretKey(); + byte[] serialized = original.ToByteArray(); + SecretKey restored = SecretKey.FromByteArray(serialized); + + byte[] plaintext = "round-trip test"u8.ToArray(); + + byte[]? ciphertext = Managed.Encrypt(plaintext, original); + byte[]? decrypted = Managed.Decrypt(ciphertext, restored); + + Assert.IsNotNull(decrypted); + Assert.AreEqual(Encoding.UTF8.GetString(decrypted), "round-trip test"); + } + [TestMethod] public void GenerateSigningKeyPair() { @@ -213,7 +270,7 @@ public void Sign() byte[]? signature = Managed.Sign(data, keypair); Assert.IsNotNull(signature); - Assert.AreEqual(Convert.ToBase64String(signature), TestData.SignedTestingb64); + Assert.AreEqual(TestData.SignedTestingb64, Convert.ToBase64String(signature)); } [TestMethod] @@ -226,7 +283,7 @@ public void VerifySignature() bool res = Managed.VerifySignature(Encoding.UTF8.GetBytes(TestData.SignTesting), pubkey, signature); - Assert.AreEqual(res, true); + Assert.IsTrue(res); } [TestMethod] @@ -237,9 +294,9 @@ public void VerifySignature_FailBadData() SigningKeyPair keypair = SigningKeyPair.FromByteArray(Convert.FromBase64String(TestData.SigningKeyPairb64)); SigningPublicKey pubkey = SigningPublicKey.FromByteArray(Convert.FromBase64String(TestData.SigningPublicKeyb64)); - bool res = Managed.VerifySignature(Encoding.UTF8.GetBytes("bad data"), pubkey, signature); + bool res = Managed.VerifySignature("bad data"u8.ToArray(), pubkey, signature); - Assert.AreEqual(res, false); + Assert.IsFalse(res); } [TestMethod] @@ -266,7 +323,7 @@ public void VerifySignature_FailBadSignature() bool res = Managed.VerifySignature(Encoding.UTF8.GetBytes(TestData.SignTesting), pubkey, signature); - Assert.AreEqual(res, false); + Assert.IsFalse(res); } [TestMethod] diff --git a/wrappers/python/tests/symmetric.py b/wrappers/python/tests/symmetric.py index 11b8f04ad..d6595485a 100644 --- a/wrappers/python/tests/symmetric.py +++ b/wrappers/python/tests/symmetric.py @@ -27,6 +27,29 @@ def test_symmetric_with_aad(self): with self.assertRaises(devolutions_crypto.DevolutionsCryptoException): devolutions_crypto.decrypt(ciphertext, key, aad = b"Wrong AAD") + def test_symmetric_with_secret_key(self): + key = devolutions_crypto.generate_secret_key() + plaintext = b'Test plaintext' + + ciphertext = devolutions_crypto.encrypt_with_secret_key(plaintext, key) + + self.assertEqual(devolutions_crypto.decrypt_with_secret_key(ciphertext, key), plaintext) + + def test_symmetric_with_secret_key_and_aad(self): + key = devolutions_crypto.generate_secret_key() + plaintext = b'Test plaintext' + aad = b"Test AAD" + + ciphertext = devolutions_crypto.encrypt_with_secret_key(plaintext, key, aad) + + self.assertEqual(devolutions_crypto.decrypt_with_secret_key(ciphertext, key, aad), plaintext) + + with self.assertRaises(devolutions_crypto.DevolutionsCryptoException): + devolutions_crypto.decrypt_with_secret_key(ciphertext, key) + + with self.assertRaises(devolutions_crypto.DevolutionsCryptoException): + devolutions_crypto.decrypt_with_secret_key(ciphertext, key, aad = b"Wrong AAD") + if __name__ == "__main__": unittest.main() diff --git a/wrappers/wasm/demo/package-lock.json b/wrappers/wasm/demo/package-lock.json index 6204173e6..7c6bc1dab 100644 --- a/wrappers/wasm/demo/package-lock.json +++ b/wrappers/wasm/demo/package-lock.json @@ -16,7 +16,7 @@ "@angular/platform-browser": "^21.2.4", "@angular/platform-browser-dynamic": "^21.2.4", "@angular/router": "^21.2.4", - "@devolutions/devolutions-crypto-web": "0.9.2", + "@devolutions/devolutions-crypto-web": "file:../dist/web", "@fortawesome/angular-fontawesome": "^4.0.0", "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.0.0", @@ -35,6 +35,11 @@ "typescript": "~5.9.0" } }, + "../dist/web": { + "name": "@devolutions/devolutions-crypto-web", + "version": "0.9.3", + "license": "MIT OR Apache-2.0" + }, "node_modules/@algolia/abtesting": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", @@ -2671,10 +2676,8 @@ } }, "node_modules/@devolutions/devolutions-crypto-web": { - "version": "0.9.2", - "resolved": "https://devolutions.jfrog.io/devolutions/api/npm/npm/@devolutions/devolutions-crypto-web/-/devolutions-crypto-web-0.9.2.tgz", - "integrity": "sha512-9MSex6zmx1Eoq2KT6/DQUdjnBtkfqWZ+AzQBBwiTsE+hKHdo4NTdOyhw4Dur9b5gPrbMelp8znrSz6cyh6Mo9g==", - "license": "MIT OR Apache-2.0" + "resolved": "../dist/web", + "link": true }, "node_modules/@discoveryjs/json-ext": { "version": "0.6.3", diff --git a/wrappers/wasm/demo/package.json b/wrappers/wasm/demo/package.json index a0fdaa107..1a4c3c508 100644 --- a/wrappers/wasm/demo/package.json +++ b/wrappers/wasm/demo/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^21.2.4", "@angular/platform-browser-dynamic": "^21.2.4", "@angular/router": "^21.2.4", - "@devolutions/devolutions-crypto-web": "0.9.2", + "@devolutions/devolutions-crypto-web": "file:../dist/web", "@fortawesome/angular-fontawesome": "^4.0.0", "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.0.0", diff --git a/wrappers/wasm/demo/src/app/app.component.html b/wrappers/wasm/demo/src/app/app.component.html index 43228181e..989bfe04d 100644 --- a/wrappers/wasm/demo/src/app/app.component.html +++ b/wrappers/wasm/demo/src/app/app.component.html @@ -12,6 +12,7 @@

DevolutionsCrypto

Secret Sharing Password Hashing Asymmetric + Secret Key Encryption Utilities diff --git a/wrappers/wasm/demo/src/app/app.routes.ts b/wrappers/wasm/demo/src/app/app.routes.ts index 6df1d785a..3883d948f 100644 --- a/wrappers/wasm/demo/src/app/app.routes.ts +++ b/wrappers/wasm/demo/src/app/app.routes.ts @@ -4,6 +4,7 @@ import { SecretSharingComponent } from './secret-sharing/secret-sharing.componen import { PasswordComponent } from './password/password.component'; import { UtilitiesComponent } from './utilities/utilities.component'; import { AsymmetricComponent } from './asymmetric/asymmetric.component'; +import { SecretKeyEncryptionComponent } from './secret-key-encryption/secret-key-encryption.component'; export const routes: Routes = [ { path: '', redirectTo: '/encryption', pathMatch: 'full' }, @@ -11,5 +12,6 @@ export const routes: Routes = [ { path: 'secret-sharing', component: SecretSharingComponent }, { path: 'password', component: PasswordComponent }, { path: 'utilities', component: UtilitiesComponent }, - { path: 'asymmetric', component: AsymmetricComponent } + { path: 'asymmetric', component: AsymmetricComponent }, + { path: 'secret-key-encryption', component: SecretKeyEncryptionComponent } ]; diff --git a/wrappers/wasm/demo/src/app/secret-key-encryption/secret-key-encryption.component.html b/wrappers/wasm/demo/src/app/secret-key-encryption/secret-key-encryption.component.html new file mode 100644 index 000000000..c5e25acf5 --- /dev/null +++ b/wrappers/wasm/demo/src/app/secret-key-encryption/secret-key-encryption.component.html @@ -0,0 +1,75 @@ + +
+ + Devolutions Crypto +
+ +
+ +
+ +

Secret Key Encryption

+
+ +
+
Key Generation
+
+
+
+ +
+
+ Result + +
+
+
+
+ +
+
Encrypt
+
+
+
+ + +
+
+ + +
+
+ +
+
+ Result + +
+
+
+
+ +
+
Decrypt
+
+
+
+ + +
+
+ + +
+
+ +
+
+ Result + +
+
+
+
+ +
diff --git a/wrappers/wasm/demo/src/app/secret-key-encryption/secret-key-encryption.component.ts b/wrappers/wasm/demo/src/app/secret-key-encryption/secret-key-encryption.component.ts new file mode 100644 index 000000000..0bfa5cd8a --- /dev/null +++ b/wrappers/wasm/demo/src/app/secret-key-encryption/secret-key-encryption.component.ts @@ -0,0 +1,109 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormControl, ReactiveFormsModule } from '@angular/forms'; +import { EncryptionService } from '../service/encryption.service'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faKey } from '@fortawesome/free-solid-svg-icons'; +import * as functions from '../shared/shared.component'; + +import { CiphertextVersion, KeyVersion, SecretKey } from '@devolutions/devolutions-crypto-web'; + +type EncryptionServiceInner = typeof import('../service/encryption.inner.service'); + +@Component({ + selector: 'app-secret-key-encryption', + standalone: true, + imports: [ReactiveFormsModule, FaIconComponent], + templateUrl: './secret-key-encryption.component.html', +}) +export class SecretKeyEncryptionComponent implements OnInit { + faKey = faKey; + + keyGenerationForm: FormGroup; + encryptForm: FormGroup; + decryptForm: FormGroup; + + decoder: TextDecoder; + encoder: TextEncoder; + + constructor(private encryptionService: EncryptionService) { + this.decoder = new TextDecoder(); + this.encoder = new TextEncoder(); + + this.keyGenerationForm = new FormGroup({ + generationResult: new FormControl(''), + }); + + this.encryptForm = new FormGroup({ + textToEncrypt: new FormControl(''), + secretKey: new FormControl(''), + encryptResult: new FormControl(''), + }); + + this.decryptForm = new FormGroup({ + textToDecrypt: new FormControl(''), + secretKey: new FormControl(''), + decryptResult: new FormControl(''), + }); + } + + ngOnInit() {} + + w3Open() { + functions.w3_open(); + } + + w3Close() { + functions.w3_close(); + } + + async generateSecretKey() { + const service: EncryptionServiceInner = await this.encryptionService.innerModule; + + const key: SecretKey = service.generateSecretKey(service.KeyVersion.Latest); + const keyText: string = service.base64encode(key.bytes); + + this.keyGenerationForm.setValue({ generationResult: keyText }); + } + + async encrypt() { + const service: EncryptionServiceInner = await this.encryptionService.innerModule; + + const textToEncrypt: string = this.encryptForm.value.textToEncrypt; + const secretKeyString: string = this.encryptForm.value.secretKey; + if (!textToEncrypt || !secretKeyString) { return; } + + const keyBytes: Uint8Array = service.base64decode(secretKeyString.trim()); + const key: SecretKey = service.SecretKey.fromBytes(keyBytes); + const data: Uint8Array = this.encoder.encode(textToEncrypt); + + const version: CiphertextVersion = service.CiphertextVersion.Latest; + const encrypted: Uint8Array = service.encryptWithSecretKey(data, key, version); + + this.encryptForm.setValue({ + textToEncrypt, + secretKey: secretKeyString, + encryptResult: service.base64encode(encrypted), + }); + } + + async decrypt() { + const service: EncryptionServiceInner = await this.encryptionService.innerModule; + + const textToDecrypt: string = this.decryptForm.value.textToDecrypt; + const secretKeyString: string = this.decryptForm.value.secretKey; + if (!textToDecrypt || !secretKeyString) { return; } + + const keyBytes: Uint8Array = service.base64decode(secretKeyString.trim()); + const key: SecretKey = service.SecretKey.fromBytes(keyBytes); + const ciphertext: Uint8Array = service.base64decode(textToDecrypt.trim()); + + const plaintext: Uint8Array = service.decryptWithSecretKey(ciphertext, key); + const text: string = this.decoder.decode(plaintext); + + this.decryptForm.setValue({ + textToDecrypt, + secretKey: secretKeyString, + decryptResult: text, + }); + } +} diff --git a/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts b/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts index b055e1643..ca39e6a17 100644 --- a/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts +++ b/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts @@ -1,5 +1,5 @@ -import wasmInit, { CiphertextVersion, KeyVersion, KeyPair, PrivateKey, PublicKey, Argon2Parameters, PasswordHashVersion } from '@devolutions/devolutions-crypto-web'; -export { CiphertextVersion, KeyVersion, KeyPair, PrivateKey, PublicKey, Argon2Parameters, PasswordHashVersion } from '@devolutions/devolutions-crypto-web'; +import wasmInit, { CiphertextVersion, KeyVersion, KeyPair, PrivateKey, PublicKey, SecretKey, Argon2Parameters, PasswordHashVersion } from '@devolutions/devolutions-crypto-web'; +export { CiphertextVersion, KeyVersion, KeyPair, PrivateKey, PublicKey, SecretKey, Argon2Parameters, PasswordHashVersion } from '@devolutions/devolutions-crypto-web'; import * as devolutionsCrypto from '@devolutions/devolutions-crypto-web'; // Initialize WASM before any functions are used @@ -61,3 +61,15 @@ export function mixKeyExchange(privateKey: PrivateKey, publicKey: PublicKey): Ui const result = devolutionsCrypto.mixKeyExchange(privateKey, publicKey); return result; } + +export function generateSecretKey(version?: KeyVersion): SecretKey { + return devolutionsCrypto.generateSecretKey(version); +} + +export function encryptWithSecretKey(data: Uint8Array, key: SecretKey, version?: CiphertextVersion): Uint8Array { + return devolutionsCrypto.encryptWithSecretKey(data, key, undefined, version); +} + +export function decryptWithSecretKey(data: Uint8Array, key: SecretKey): Uint8Array { + return devolutionsCrypto.decryptWithSecretKey(data, key); +} diff --git a/wrappers/wasm/tests/tests/symmetric.ts b/wrappers/wasm/tests/tests/symmetric.ts index 47ad7dd43..4df41900d 100644 --- a/wrappers/wasm/tests/tests/symmetric.ts +++ b/wrappers/wasm/tests/tests/symmetric.ts @@ -1,4 +1,11 @@ -import { generateKey, encrypt, decrypt } from 'devolutions-crypto' +import { + generateKey, + generateSecretKey, + encrypt, + encryptWithSecretKey, + decrypt, + decryptWithSecretKey, +} from 'devolutions-crypto' import { describe, test } from 'node:test' import assert from 'node:assert/strict' @@ -34,4 +41,34 @@ describe('encrypt/decrypt', () => { assert.throws(() => decrypt(encrypted, key)) assert.throws(() => decrypt(encrypted, key, wrongAad)) }) + + test('should be able to encrypt and decrypt with a secret key', () => { + const input: Uint8Array = encoder.encode('This is some test data') + const key = generateSecretKey() + const encrypted: Uint8Array = encryptWithSecretKey(input, key) + const decrypted: Uint8Array = decryptWithSecretKey(encrypted, key) + assert.notDeepStrictEqual(encrypted, input) + assert.deepStrictEqual(decrypted, input) + }) + + test('should be able to encrypt and decrypt with a secret key and AAD', () => { + const input: Uint8Array = encoder.encode('This is some test data') + const aad: Uint8Array = encoder.encode('This is some public data') + const key = generateSecretKey() + const encrypted: Uint8Array = encryptWithSecretKey(input, key, aad) + const decrypted: Uint8Array = decryptWithSecretKey(encrypted, key, aad) + assert.notDeepStrictEqual(encrypted, input) + assert.deepStrictEqual(decrypted, input) + }) + + test('should fail if secret key AAD is invalid', () => { + const input: Uint8Array = encoder.encode('This is some test data') + const aad: Uint8Array = encoder.encode('This is some public data') + const wrongAad: Uint8Array = encoder.encode('this is some public data') + const key = generateSecretKey() + const encrypted: Uint8Array = encryptWithSecretKey(input, key, aad) + + assert.throws(() => decryptWithSecretKey(encrypted, key)) + assert.throws(() => decryptWithSecretKey(encrypted, key, wrongAad)) + }) }) diff --git a/wrappers/wasm/wasm_build.ps1 b/wrappers/wasm/wasm_build.ps1 new file mode 100644 index 000000000..464ab7f03 --- /dev/null +++ b/wrappers/wasm/wasm_build.ps1 @@ -0,0 +1,31 @@ +$ErrorActionPreference = 'Stop' + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$crateRoot = (Resolve-Path (Join-Path $scriptRoot '..\..')).Path +$distRoot = Join-Path $scriptRoot 'dist' + +function Invoke-WasmPackBuild { + param( + [Parameter(Mandatory = $true)] + [string]$Target, + + [Parameter(Mandatory = $true)] + [string]$OutDir + ) + + & wasm-pack build $crateRoot --out-dir $OutDir --target $Target --scope devolutions -- --features=wbindgen + + if ($LASTEXITCODE -ne 0) { + throw "wasm-pack build failed for target '$Target' with exit code $LASTEXITCODE." + } +} + +Invoke-WasmPackBuild -Target 'bundler' -OutDir (Join-Path $distRoot 'bundler') +Invoke-WasmPackBuild -Target 'nodejs' -OutDir (Join-Path $distRoot 'node') +Invoke-WasmPackBuild -Target 'web' -OutDir (Join-Path $distRoot 'web') +Invoke-WasmPackBuild -Target 'no-modules' -OutDir (Join-Path $distRoot 'no-modules') + +$webPackageJsonPath = Join-Path $distRoot 'web\package.json' +$webPackageJson = Get-Content $webPackageJsonPath -Raw | ConvertFrom-Json +$webPackageJson.name = '@devolutions/devolutions-crypto-web' +$webPackageJson | ConvertTo-Json -Depth 10 | Set-Content $webPackageJsonPath From 11bbde2cc378ce7ae35c3fa4889d615cbd45b462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Sun, 10 May 2026 18:45:05 -0400 Subject: [PATCH 5/7] cargo fmt --- ffi/src/lib.rs | 3 ++- python/src/lib.rs | 3 +-- src/ciphertext/mod.rs | 11 ++++++++--- src/key/mod.rs | 4 +--- uniffi/devolutions-crypto-uniffi/src/ciphertext.rs | 5 ++++- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index 004fe1268..5d312939e 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -1855,6 +1855,7 @@ fn test_generate_secret_key() { let res = unsafe { GenerateSecretKey(key_buf.as_mut_ptr(), size) }; assert_eq!(res, 0); - let key = devolutions_crypto::key::SecretKey::try_from(key_buf.as_slice()).expect("should parse as SecretKey"); + let key = devolutions_crypto::key::SecretKey::try_from(key_buf.as_slice()) + .expect("should parse as SecretKey"); assert_eq!(key.as_bytes().len(), 32); } diff --git a/python/src/lib.rs b/python/src/lib.rs index 60fcf0d10..e33ca90b9 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -279,8 +279,7 @@ fn encrypt_with_secret_key( let key = SecretKey::try_from(key)?; let aad = aad.unwrap_or(&[]); - let ct: Vec = - ciphertext::encrypt_with_secret_key_and_aad(data, &key, aad, version)?.into(); + let ct: Vec = ciphertext::encrypt_with_secret_key_and_aad(data, &key, aad, version)?.into(); Ok(PyBytes::new(py, &ct).into()) } diff --git a/src/ciphertext/mod.rs b/src/ciphertext/mod.rs index bf5cbb763..d7f6adfce 100644 --- a/src/ciphertext/mod.rs +++ b/src/ciphertext/mod.rs @@ -107,7 +107,6 @@ pub fn encrypt(data: &[u8], key: &[u8], version: CiphertextVersion) -> Result Result Result { +pub fn encrypt_with_raw_key( + data: &[u8], + key: &[u8], + version: CiphertextVersion, +) -> Result { encrypt_with_aad(data, key, [].as_slice(), version) } @@ -792,7 +795,9 @@ fn encrypt_decrypt_with_secret_key_aad() { let encrypted = encrypt_with_secret_key_and_aad(data, &key, aad, CiphertextVersion::Latest).unwrap(); - let decrypted = encrypted.decrypt_with_secret_key_and_aad(&key, aad).unwrap(); + let decrypted = encrypted + .decrypt_with_secret_key_and_aad(&key, aad) + .unwrap(); assert_eq!(decrypted, data); diff --git a/src/key/mod.rs b/src/key/mod.rs index 3f228489f..ef76e88d7 100644 --- a/src/key/mod.rs +++ b/src/key/mod.rs @@ -422,9 +422,7 @@ impl TryFrom<&[u8]> for SecretKey { } let payload = match header.version { - KeyVersion::V1 => { - SecretKeyPayload::V1(SecretKeyV1::try_from(&data[Header::len()..])?) - } + KeyVersion::V1 => SecretKeyPayload::V1(SecretKeyV1::try_from(&data[Header::len()..])?), _ => return Err(Error::UnknownVersion), }; diff --git a/uniffi/devolutions-crypto-uniffi/src/ciphertext.rs b/uniffi/devolutions-crypto-uniffi/src/ciphertext.rs index 41d9418b3..078aabb83 100644 --- a/uniffi/devolutions-crypto-uniffi/src/ciphertext.rs +++ b/uniffi/devolutions-crypto-uniffi/src/ciphertext.rs @@ -91,7 +91,10 @@ pub fn encrypt_with_secret_key_and_aad( ) -> Result> { let version = version.unwrap_or(CiphertextVersion::Latest); let key = SecretKey::try_from(key)?; - Ok(devolutions_crypto::ciphertext::encrypt_with_secret_key_and_aad(data, &key, aad, version)?.into()) + Ok( + devolutions_crypto::ciphertext::encrypt_with_secret_key_and_aad(data, &key, aad, version)? + .into(), + ) } #[uniffi::export] From 3d8760b328df95633b2c65aecea207d8fc17fb2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Mon, 11 May 2026 11:05:09 -0400 Subject: [PATCH 6/7] Address copilot review comments --- .github/dependabot.yml | 3 -- README.md | 32 +++++++++++++++++++ python/devolutions_crypto.pyi | 2 +- src/ciphertext/mod.rs | 10 +++--- wrappers/csharp/src/SecretKey.cs | 3 +- .../csharp/tests/unit-tests/TestManaged.cs | 2 +- wrappers/wasm/wasm_build.bat | 4 --- 7 files changed, 40 insertions(+), 16 deletions(-) delete mode 100644 wrappers/wasm/wasm_build.bat diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b9414fadd..b66eeb313 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,8 +4,5 @@ updates: directory: "/" schedule: interval: "daily" - assignees: - - "pdugre" - - "MathieuMorrissette" # Disable version updates, we only want security updates. open-pull-requests-limit: 0 \ No newline at end of file diff --git a/README.md b/README.md index f0786f8fe..eddaf6fb1 100644 --- a/README.md +++ b/README.md @@ -149,3 +149,35 @@ A Curve25519 private key from Devolutions Crypto | V1 | 0x10 | Uses version 1: XChaCha20-Poly1305 wrapped in a STREAM construction. | +# Local development setup + +## Rust + +Build the project with cargo. + +``` +cargo build +cargo test +``` + +## C# + +Build the rust library, then open the solution wrappers\csharp\tests\unit-tests\local\devolutions-crypto-tests\devolutions-crypto-tests.sln. + +## WebAssembly + +**Setup** + +- Install the wasm32-unknown-unknown target with `rustup target add wasm32-unknown-unknown` +- Install `wasm-pack` with `cargo install wasm-pack`. + +**Building** + +Run `wasm_build.ps1` or `wasm_build.sh` in the wrappers/wasm folder. + +Tests can then be run in the wrappers/wasm/tests folder. + +``` +npm install +npm run test +``` \ No newline at end of file diff --git a/python/devolutions_crypto.pyi b/python/devolutions_crypto.pyi index 601873730..c0e8a8920 100644 --- a/python/devolutions_crypto.pyi +++ b/python/devolutions_crypto.pyi @@ -284,7 +284,7 @@ def encrypt_with_secret_key( version: int = 0 ) -> bytes: """ - Encrypt data using a SecretKey (AES-256-GCM). + Encrypt data using a SecretKey. Args: data: The plaintext data to encrypt diff --git a/src/ciphertext/mod.rs b/src/ciphertext/mod.rs index d7f6adfce..4b8ad5a8a 100644 --- a/src/ciphertext/mod.rs +++ b/src/ciphertext/mod.rs @@ -91,7 +91,7 @@ enum CiphertextPayload { /// # Arguments /// * `data` - The data to encrypt. /// * `key` - The key to use. The recommended size is 32 bytes. -/// * `version` - Version of the library to encrypt with. Use `CiphertTextVersion::Latest` if you're not dealing with shared data. +/// * `version` - Version of the library to encrypt with. Use `CiphertextVersion::Latest` if you're not dealing with shared data. /// # Returns /// Returns a `Ciphertext` containing the encrypted data. /// # Example @@ -111,7 +111,7 @@ pub fn encrypt(data: &[u8], key: &[u8], version: CiphertextVersion) -> Result - /// Gets the raw 32-byte key material, without the serialization header. - /// This is the value used as the actual encryption key. + /// Gets the raw key material, without the serialization header. This is the value used as the actual encryption key. /// internal byte[] KeyMaterial { diff --git a/wrappers/csharp/tests/unit-tests/TestManaged.cs b/wrappers/csharp/tests/unit-tests/TestManaged.cs index 64d7fdf9d..2e875e2f8 100644 --- a/wrappers/csharp/tests/unit-tests/TestManaged.cs +++ b/wrappers/csharp/tests/unit-tests/TestManaged.cs @@ -250,7 +250,7 @@ public void SecretKeyRoundTrip() byte[]? decrypted = Managed.Decrypt(ciphertext, restored); Assert.IsNotNull(decrypted); - Assert.AreEqual(Encoding.UTF8.GetString(decrypted), "round-trip test"); + Assert.AreEqual("round-trip test", Encoding.UTF8.GetString(decrypted)); } [TestMethod] diff --git a/wrappers/wasm/wasm_build.bat b/wrappers/wasm/wasm_build.bat deleted file mode 100644 index e9f0d1789..000000000 --- a/wrappers/wasm/wasm_build.bat +++ /dev/null @@ -1,4 +0,0 @@ -wasm-pack build ../../ --out-dir ./wrappers/wasm/dist/bundler --target bundler --scope devolutions -- --features=wbindgen -wasm-pack build ../../ --out-dir ./wrappers/wasm/dist/node --target nodejs --scope devolutions -- --features=wbindgen -wasm-pack build ../../ --out-dir ./wrappers/wasm/dist/web --target web --scope devolutions -- --features=wbindgen -wasm-pack build ../../ --out-dir ./wrappers/wasm/dist/no-modules --target no-modules --scope devolutions -- --features=wbindgen From 85238b9acd14ac0b9d482a1fc978cdfe06722f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Tue, 12 May 2026 11:24:21 -0400 Subject: [PATCH 7/7] Add Secret to the readme, remove plans --- README.md | 4 +- _plans/secret-key-wrappers.md | 394 ---------------------------------- _plans/secret-key.md | 76 ------- 3 files changed, 2 insertions(+), 472 deletions(-) delete mode 100644 _plans/secret-key-wrappers.md delete mode 100644 _plans/secret-key.md diff --git a/README.md b/README.md index eddaf6fb1..da1e91dd1 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,6 @@ Unless you explicitly state otherwise, any contribution intentionally submitted This header represents : A Curve25519 private key from Devolutions Crypto - - Signature Bytes - The first two bytes specifies that the data is from Devolutions Crypto (DC) - Data type @@ -70,6 +69,7 @@ A Curve25519 private key from Devolutions Crypto - The fourth two bytes (pos: 7, 8) represents the version. ## Data Type + | Data Types | Value | Description | |---------------------|--------|------------------------------------------------------------------------------| | None | 0x00 | No data type. Only used as a default value. | @@ -82,7 +82,6 @@ A Curve25519 private key from Devolutions Crypto | OnlineCiphertext | 0x70 | A wrapped online ciphertext that can be encrypted/decrypted chunk by chunk | - ## Sub types | Key Sub Types | Value | @@ -91,6 +90,7 @@ A Curve25519 private key from Devolutions Crypto | Private | 0x10 | | Public | 0x20 | | Pair | 0x30 | +| Secret | 0x40 | | Ciphertext Sub Types | Value | |----------------------|--------| diff --git a/_plans/secret-key-wrappers.md b/_plans/secret-key-wrappers.md deleted file mode 100644 index 6faab2b42..000000000 --- a/_plans/secret-key-wrappers.md +++ /dev/null @@ -1,394 +0,0 @@ -# Plan: Expose SecretKey in all language wrappers - -## TL;DR -Follow-up to `secret-key.md`. The Rust core now has `SecretKey`, `generate_secret_key`, `encrypt_with_secret_key`, and `decrypt_with_secret_key`. This plan wires them into every language wrapper: FFI/C, C#, WASM/JS, UniFFI (Kotlin + Swift), and Python. - -Each wrapper follows its own established pattern for how keys are represented (see per-wrapper sections below). - ---- - -## Phase 1 — FFI / C - -### Files -- `ffi/src/lib.rs` -- `ffi/devolutions-crypto.h` - -### Changes - -The existing `Encrypt`/`Decrypt` FFI functions already accept a raw key buffer, so they already work with the payload bytes of a `SecretKey`. What is missing is a way to generate and serialize a `SecretKey` from C. - -1. **Add `GenerateSecretKey`** (writes serialized `SecretKey` bytes into a caller-supplied output buffer): - ```rust - #[no_mangle] - pub unsafe extern "C" fn GenerateSecretKey( - result: *mut u8, - result_length: usize, - ) -> i64 - ``` - Implementation: call `generate_secret_key(KeyVersion::Latest)`, serialize via `Into::>::into()`, copy into `result`. - -2. **Add `GenerateSecretKeySize`** (returns the byte length of a serialized `SecretKey`): - ```rust - #[no_mangle] - pub extern "C" fn GenerateSecretKeySize() -> i64 - ``` - Implementation: `Header::::len() + 32` (header size + 32 raw key bytes). - Alternatively: generate one and measure — but a constant is cleaner. - -3. **Update `ffi/devolutions-crypto.h`** — add the two C declarations: - ```c - int64_t GenerateSecretKey(uint8_t *result, size_t result_length); - int64_t GenerateSecretKeySize(void); - ``` - -4. **Add import** `use devolutions_crypto::key::{generate_secret_key, SecretKey, KeyVersion};` to the top of `ffi/src/lib.rs` (alongside existing key imports). - -### Tests (in `ffi/src/lib.rs`) -- `generate_secret_key_ffi` — call `GenerateSecretKeySize`, allocate, call `GenerateSecretKey`, parse result with `SecretKey::try_from`, assert 32 bytes. - ---- - -## Phase 2 — C# - -### Files -- `wrappers/csharp/src/Native.Core.cs` -- `wrappers/csharp/src/Managed.cs` - -### Changes - -C# is a thin managed wrapper over the FFI layer. Follow the exact `GenerateKeyPair` pattern. - -1. **`Native.Core.cs`** — add two P/Invoke declarations: - ```csharp - [DllImport(LibName, EntryPoint = "GenerateSecretKey", CallingConvention = CallingConvention.Cdecl)] - internal static extern long GenerateSecretKeyNative(byte[] result, UIntPtr resultLength); - - [DllImport(LibName, EntryPoint = "GenerateSecretKeySize", CallingConvention = CallingConvention.Cdecl)] - internal static extern long GenerateSecretKeySizeNative(); - ``` - -2. **`Managed.cs`** — add `GenerateSecretKey()` returning a `byte[]`: - ```csharp - public static byte[] GenerateSecretKey() - { - long size = Native.GenerateSecretKeySizeNative(); - byte[] result = new byte[size]; - long res = Native.GenerateSecretKeyNative(result, (UIntPtr)result.Length); - if (res < 0) throw DevolutionsCryptoException.FromErrorCode(res); - return result; - } - ``` - Callers then pass the returned `byte[]` to the existing `Encrypt`/`Decrypt` methods directly (no new encrypt/decrypt wrappers needed — `Encrypt(data, secretKeyBytes)` already works). - -### Tests (`wrappers/csharp/tests/unit-tests/TestManaged.cs`) -- `GenerateSecretKey` — generate, assert non-null and non-empty, round-trip through `Encrypt`/`Decrypt`, assert equality. - ---- - -## Phase 3 — WASM / JavaScript - -### Files -- `src/wasm.rs` - -The TypeScript `.d.ts` and the JS glue in `wrappers/wasm/dist/` are generated artifacts — they do not need manual edits. - -### Changes - -WASM exposes key objects as first-class JS classes (see `PrivateKey`, `PublicKey` pattern). `SecretKey` should follow the same shape. - -1. **Import `SecretKey` and `generate_secret_key`** at the top of `src/wasm.rs`: - ```rust - use super::{ - key, - key::{KeyVersion, PrivateKey, PublicKey, SecretKey}, - }; - ``` - -2. **Implement wasm-bindgen methods on `SecretKey`** (in an `impl` block gated with `#[wasm_bindgen]`): - ```rust - #[wasm_bindgen] - impl SecretKey { - #[wasm_bindgen(getter)] - pub fn bytes(&self) -> Vec { - self.clone().into() - } - - #[wasm_bindgen(js_name = "fromBytes")] - pub fn from_bytes(buffer: &[u8]) -> Result { - Ok(SecretKey::try_from(buffer)?) - } - } - ``` - -3. **Add `generateSecretKey` free function**: - ```rust - #[wasm_bindgen(js_name = "generateSecretKey")] - pub fn generate_secret_key(version: Option) -> SecretKey { - key::generate_secret_key(version.unwrap_or(KeyVersion::Latest)) - } - ``` - -4. **Add `encryptWithSecretKey` free function** (typed, takes a `SecretKey` object): - ```rust - #[wasm_bindgen(js_name = "encryptWithSecretKey")] - pub fn encrypt_with_secret_key( - data: &[u8], - key: &SecretKey, - aad: Option>, - version: Option, - ) -> Result, JsValue> { - Ok(ciphertext::encrypt_with_aad( - data, - key.as_bytes(), - &aad.unwrap_or_default(), - version.unwrap_or(CiphertextVersion::Latest), - )? - .into()) - } - ``` - -5. **Add `decryptWithSecretKey` free function**: - ```rust - #[wasm_bindgen(js_name = "decryptWithSecretKey")] - pub fn decrypt_with_secret_key( - data: &[u8], - key: &SecretKey, - aad: Option>, - ) -> Result, JsValue> { - let data_blob = Ciphertext::try_from(data)?; - Ok(data_blob.decrypt_with_aad(key.as_bytes(), &aad.unwrap_or_default())?) - } - ``` - -### Resulting TypeScript surface (generated) -```ts -export class SecretKey { - readonly bytes: Uint8Array; - static fromBytes(buffer: Uint8Array): SecretKey; -} -export function generateSecretKey(version?: KeyVersion | null): SecretKey; -export function encryptWithSecretKey( - data: Uint8Array, - key: SecretKey, - aad?: Uint8Array | null, - version?: CiphertextVersion | null -): Uint8Array; -export function decryptWithSecretKey( - data: Uint8Array, - key: SecretKey, - aad?: Uint8Array | null -): Uint8Array; -``` - ---- - -## Phase 4 — UniFFI (Kotlin + Swift) - -### Files -- `uniffi/devolutions-crypto-uniffi/src/key.rs` -- `uniffi/devolutions-crypto-uniffi/src/ciphertext.rs` - -Kotlin and Swift bindings are auto-generated by UniFFI at build time. Editing these two Rust files is all that is needed. - -### Changes - -**`uniffi/devolutions-crypto-uniffi/src/key.rs`** - -Add `generate_secret_key` export (key is returned as serialized `Vec`, matching the byte-array convention used for `generate_keypair`): -```rust -#[uniffi::export(default(version = None))] -pub fn generate_secret_key(version: Option) -> Vec { - let version = version.unwrap_or(KeyVersion::Latest); - devolutions_crypto::key::generate_secret_key(version).into() -} -``` - -**`uniffi/devolutions-crypto-uniffi/src/ciphertext.rs`** - -Add four functions following the exact `encrypt_asymmetric`/`decrypt_asymmetric` pattern (deserialize key, delegate): - -```rust -#[uniffi::export(default(version = None))] -pub fn encrypt_with_secret_key( - data: &[u8], - key: &[u8], - version: Option, -) -> Result> { - let version = version.unwrap_or(CiphertextVersion::Latest); - let key = devolutions_crypto::key::SecretKey::try_from(key)?; - Ok(devolutions_crypto::ciphertext::encrypt_with_secret_key(data, &key, version)?.into()) -} - -#[uniffi::export(default(version = None))] -pub fn encrypt_with_secret_key_and_aad( - data: &[u8], - key: &[u8], - aad: &[u8], - version: Option, -) -> Result> { - let version = version.unwrap_or(CiphertextVersion::Latest); - let key = devolutions_crypto::key::SecretKey::try_from(key)?; - Ok(devolutions_crypto::ciphertext::encrypt_with_secret_key_and_aad(data, &key, aad, version)?.into()) -} - -#[uniffi::export] -pub fn decrypt_with_secret_key(data: &[u8], key: &[u8]) -> Result> { - let key = devolutions_crypto::key::SecretKey::try_from(key)?; - let data = devolutions_crypto::ciphertext::Ciphertext::try_from(data)?; - data.decrypt_with_secret_key(&key) -} - -#[uniffi::export] -pub fn decrypt_with_secret_key_and_aad(data: &[u8], key: &[u8], aad: &[u8]) -> Result> { - let key = devolutions_crypto::key::SecretKey::try_from(key)?; - let data = devolutions_crypto::ciphertext::Ciphertext::try_from(data)?; - data.decrypt_with_secret_key_and_aad(&key, aad) -} -``` - -Also add `use devolutions_crypto::key::SecretKey;` to the imports at the top of `ciphertext.rs`. - -### Resulting generated Kotlin surface -```kotlin -fun generateSecretKey(version: KeyVersion? = null): ByteArray -fun encryptWithSecretKey(data: ByteArray, key: ByteArray, version: CiphertextVersion? = null): ByteArray -fun encryptWithSecretKeyAndAad(data: ByteArray, key: ByteArray, aad: ByteArray, version: CiphertextVersion? = null): ByteArray -fun decryptWithSecretKey(data: ByteArray, key: ByteArray): ByteArray -fun decryptWithSecretKeyAndAad(data: ByteArray, key: ByteArray, aad: ByteArray): ByteArray -``` - -### Resulting generated Swift surface -```swift -public func generateSecretKey(version: KeyVersion? = nil) -> Data -public func encryptWithSecretKey(data: Data, key: Data, version: CiphertextVersion? = nil) throws -> Data -public func encryptWithSecretKeyAndAad(data: Data, key: Data, aad: Data, version: CiphertextVersion? = nil) throws -> Data -public func decryptWithSecretKey(data: Data, key: Data) throws -> Data -public func decryptWithSecretKeyAndAad(data: Data, key: Data, aad: Data) throws -> Data -``` - -### Tests - -**Kotlin** (`wrappers/kotlin/lib/src/test/kotlin/org/devolutions/crypto/SymmetricTest.kt`): -- `testGenerateSecretKeyAndEncryptDecrypt` — generate, encrypt, decrypt, assert equality. -- `testEncryptDecryptWithSecretKeyAndAad` — same with AAD; wrong AAD returns error. - -**Swift** (`wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/SymmetricTests.swift`): -- `testGenerateSecretKeyAndEncryptDecrypt` — generate, encrypt, decrypt, assert equality. -- `testEncryptDecryptWithSecretKeyAndAad` — same with AAD; wrong AAD throws. - ---- - -## Phase 5 — Python - -### Files -- `python/src/lib.rs` -- `python/devolutions_crypto.pyi` - -### Changes - -**`python/src/lib.rs`** - -1. Add `use devolutions_crypto::key::{SecretKey, generate_secret_key as dc_generate_secret_key, KeyVersion as DcKeyVersion};` (or adjust existing imports). - -2. Add `generate_secret_key`: - ```rust - #[pyfunction] - #[pyo3(signature = (version=0))] - fn generate_secret_key(py: Python, version: u16) -> Result> { - let version = DcKeyVersion::try_from(version)?; - let key = dc_generate_secret_key(version); - let bytes: Vec = key.into(); - Ok(PyBytes::new(py, &bytes).into()) - } - ``` - -3. Add `encrypt_with_secret_key`: - ```rust - #[pyfunction] - #[pyo3(signature = (data, key, aad=None, version=0))] - fn encrypt_with_secret_key( - py: Python, - data: &[u8], - key: &[u8], - aad: Option<&[u8]>, - version: u16, - ) -> Result> { - let version = CiphertextVersion::try_from(version)?; - let key = SecretKey::try_from(key)?; - let aad = aad.unwrap_or(&[]); - let ct = devolutions_crypto::ciphertext::encrypt_with_secret_key_and_aad(data, &key, aad, version)?; - Ok(PyBytes::new(py, &Into::>::into(ct)).into()) - } - ``` - -4. Add `decrypt_with_secret_key`: - ```rust - #[pyfunction] - #[pyo3(signature = (data, key, aad=None))] - fn decrypt_with_secret_key( - py: Python, - data: &[u8], - key: &[u8], - aad: Option<&[u8]>, - ) -> Result> { - let key = SecretKey::try_from(key)?; - let aad = aad.unwrap_or(&[]); - let ct = devolutions_crypto::ciphertext::Ciphertext::try_from(data)?; - let plaintext = ct.decrypt_with_secret_key_and_aad(&key, aad)?; - Ok(PyBytes::new(py, &plaintext).into()) - } - ``` - -5. **Register all three** in the `#[pymodule]` function: - ```rust - m.add_function(wrap_pyfunction!(generate_secret_key, m)?)?; - m.add_function(wrap_pyfunction!(encrypt_with_secret_key, m)?)?; - m.add_function(wrap_pyfunction!(decrypt_with_secret_key, m)?)?; - ``` - -**`python/devolutions_crypto.pyi`** — add stubs: -```python -def generate_secret_key(version: int = 0) -> bytes: ... -def encrypt_with_secret_key(data: bytes, key: bytes, aad: bytes | None = None, version: int = 0) -> bytes: ... -def decrypt_with_secret_key(data: bytes, key: bytes, aad: bytes | None = None) -> bytes: ... -``` - -### Tests (inline in `python/src/lib.rs` or in a Python test file) -- Generate a secret key, encrypt, decrypt, assert equality. -- Wrong key returns error. Wrong AAD returns error. - ---- - -## Relevant files summary - -| File | Change | -|------|--------| -| `ffi/src/lib.rs` | Add `GenerateSecretKey`, `GenerateSecretKeySize` | -| `ffi/devolutions-crypto.h` | Add C declarations for the two new FFI functions | -| `wrappers/csharp/src/Native.Core.cs` | Add `GenerateSecretKeyNative`, `GenerateSecretKeySizeNative` P/Invoke | -| `wrappers/csharp/src/Managed.cs` | Add `GenerateSecretKey() -> byte[]` | -| `src/wasm.rs` | Add `SecretKey` wasm impls, `generateSecretKey`, `encryptWithSecretKey`, `decryptWithSecretKey` | -| `uniffi/devolutions-crypto-uniffi/src/key.rs` | Add `generate_secret_key` export | -| `uniffi/devolutions-crypto-uniffi/src/ciphertext.rs` | Add four `*_with_secret_key` exports | -| `python/src/lib.rs` | Add `generate_secret_key`, `encrypt_with_secret_key`, `decrypt_with_secret_key` | -| `python/devolutions_crypto.pyi` | Add three stubs | - ---- - -## Verification - -1. `cargo check` — no errors across all crates (ffi, python, uniffi, main with wbindgen feature). -2. `cargo test` — all tests pass. -3. C#: `dotnet test` in `wrappers/csharp/`. -4. WASM: rebuild with `wasm-pack build` and run the JS/TS tests. -5. Kotlin: `./gradlew test` in `wrappers/kotlin/`. -6. Swift: `swift test` in `wrappers/swift/DevolutionsCryptoSwift/`. -7. Python: `maturin develop` + `pytest`. - -## Decisions - -- **FFI returns the full serialized `SecretKey` blob** (header + 32 bytes), not just the raw 32 bytes. This is consistent with how `GenerateKeyPair` returns serialized key blobs, and means callers can pass the result directly to the existing `Encrypt`/`Decrypt` byte-array functions. -- **C# and Python return `byte[]`/`bytes`**, not dedicated wrapper types — consistent with how asymmetric keys are handled in those wrappers. -- **WASM gets a first-class `SecretKey` class** with `bytes`/`fromBytes` — consistent with `PrivateKey`/`PublicKey` in that wrapper. -- **UniFFI (Kotlin/Swift) uses serialized bytes** — consistent with the `encrypt_asymmetric` pattern where keys are passed as `&[u8]` and deserialized inside. -- **No new `EncryptWithSecretKey`/`DecryptWithSecretKey` in the FFI layer** — the existing `Encrypt`/`Decrypt` already accept the key as a raw byte buffer, and callers can pass the payload of a `SecretKey`. A typed variant adds no functionality at the C ABI level. diff --git a/_plans/secret-key.md b/_plans/secret-key.md deleted file mode 100644 index 5161a7415..000000000 --- a/_plans/secret-key.md +++ /dev/null @@ -1,76 +0,0 @@ -# Plan: Add SecretKey type for symmetric encryption - -## TL;DR -Add a wrapped `SecretKey` type (DataType::Key + KeySubtype::Secret) analogous to `PrivateKey`/`PublicKey`, backed by 32 raw random bytes. Provide typed encrypt/decrypt methods on the ciphertext module so callers pass `&SecretKey` instead of `&[u8]`. Include unit tests and a conformity test. - -## Phase 1 — Enum & Core Type - -1. **Add `Secret = 4` to `KeySubtype`** in `src/enums.rs`. - -2. **Create `src/key/secret_key_v1.rs`** with: - - `pub struct SecretKeyV1 { key: Zeroizing<[u8; 32]> }` - - `generate() -> SecretKeyV1` using `rand::rngs::OsRng` - - `as_bytes(&self) -> &[u8]` - - `impl TryFrom<&[u8]> for SecretKeyV1` — validate len == 32 - - `impl From for Vec` - -3. **Add `SecretKey` to `src/key/mod.rs`** (parallel to `PrivateKey`): - - `pub struct SecretKey { pub(crate) header: Header, payload: SecretKeyPayload }` - - `enum SecretKeyPayload { V1(SecretKeyV1) }` - - `impl HeaderType for SecretKey` → `data_type() = DataType::Key`, `subtype() = KeySubtype::Secret`, `Version = KeyVersion` - - `impl From for Vec` — header + payload (follows exact PrivateKey pattern) - - `impl TryFrom<&[u8]> for SecretKey` — validates `header.data_subtype == KeySubtype::Secret`, then dispatches on version - - `pub fn generate_secret_key(version: KeyVersion) -> SecretKey` - - `impl SecretKey { pub fn as_bytes(&self) -> &[u8] }` — exposes key material for internal use by ciphertext module - - Add `mod secret_key_v1;` declaration - -## Phase 2 — Ciphertext Module Overloads - -4. **Add typed encrypt/decrypt to `src/ciphertext/mod.rs`**: - - Free function: `pub fn encrypt_with_secret_key(data: &[u8], key: &SecretKey, version: CiphertextVersion) -> Result` — delegates to `encrypt(data, key.as_bytes(), version)` - - Free function: `pub fn encrypt_with_secret_key_and_aad(data: &[u8], key: &SecretKey, aad: &[u8], version: CiphertextVersion) -> Result` — delegates to `encrypt_with_aad` - - Method: `impl Ciphertext { pub fn decrypt_with_secret_key(&self, key: &SecretKey) -> Result> }` — delegates to `self.decrypt(key.as_bytes())` - - Method: `impl Ciphertext { pub fn decrypt_with_secret_key_and_aad(&self, key: &SecretKey, aad: &[u8]) -> Result> }` — delegates to `self.decrypt_with_aad` - - Add `use super::key::SecretKey;` import - -## Phase 3 — Exports - -5. **Update `src/lib.rs`**: - - Add `KeySubtype` to the `pub use enums::{ ... }` re-export list - - Add `SecretKey` and `generate_secret_key` to `pub use key::{ ... }` or ensure they're accessible via `pub mod key` - -## Phase 4 — Tests - -6. **Unit tests in `src/key/mod.rs`** (inside `#[cfg(test)]` block): - - `secret_key_generate_roundtrip` — generate → serialize to bytes → deserialize → verify as_bytes round-trips - - `secret_key_wrong_subtype_rejected` — try_from a PrivateKey bytes slice as SecretKey should return `Err(InvalidDataType)` - - `secret_key_wrong_length_rejected` — too-short byte slice returns `Err(InvalidLength)` - -7. **Unit tests in `src/ciphertext/mod.rs`**: - - `encrypt_decrypt_with_secret_key` — generate key, encrypt, decrypt, assert plaintext equality - - `encrypt_decrypt_with_secret_key_aad` — same with AAD; also verify wrong AAD returns error - - `encrypt_decrypt_with_secret_key_v1` and `_v2` — explicit version coverage - -8. **Conformity test in `tests/conformity.rs`**: - - `test_symmetric_decrypt_with_secret_key_v2` — parse a known-good SecretKey from base64, decrypt a known ciphertext, assert result == expected plaintext. (Test vector generated during implementation from the existing known symmetric key `ozJVEme4+5e/4NG3C+Rl26GQbGWAqGc0QPX8/1xvaFM=` wrapped in a SecretKey header.) - -## Relevant files -- `src/enums.rs` — add `Secret = 4` to `KeySubtype` -- `src/key/mod.rs` — add `SecretKey`, `SecretKeyPayload`, `generate_secret_key`, conversions; reference the `PrivateKey` impl at lines 82–241 as the template -- `src/key/secret_key_v1.rs` — new file; use `key_v1.rs` TryFrom/From pattern as reference -- `src/ciphertext/mod.rs` — add 4 typed encrypt/decrypt wrappers; reference `decrypt_asymmetric` (lines ~230) for method placement -- `src/lib.rs` — update re-exports -- `tests/conformity.rs` — add conformity test - -## Verification -1. `cargo test` — all tests pass -2. `cargo check` — no type errors -3. Confirm `SecretKey::try_from(private_key_bytes)` returns `Err(InvalidDataType)` -4. Confirm `PrivateKey::try_from(secret_key_bytes)` returns `Err(InvalidDataType)` -5. Conformity test decrypts known test vector correctly - -## Decisions -- **Reuse `KeyVersion`** for SecretKey (same as PrivateKey/PublicKey) — since the version here is about the raw format (32-byte block), not the cryptographic algorithm. `V1 = 32 random bytes`. -- **`as_bytes()` method** on SecretKey exposes raw bytes for delegation to existing encrypt/decrypt internals — avoids duplicating encryption logic. -- **Scope: Rust core library only.** FFI (`ffi/src/lib.rs`), WASM (`src/wasm.rs`), UniFFI (`uniffi/`), C# wrapper, Kotlin/Swift/Python wrappers are out of scope for this plan. They follow the same patterns and can be addressed in a follow-up. -- `SecretKeyV1` uses `Zeroizing<[u8; 32]>` to ensure key material is cleared from memory on drop.