Implement sender client state machine with comprehensive tests
- Add SenderClient state machine with states: Idle, Ready, Composing, Encrypting, Submitted, Error - Implement all state transitions according to specification - Use sodiumoxide for sealed box encryption - Add validation for sender name (max 200 chars) and message body (max 10,000 chars) - Implement complete test suite covering: - State transitions and invalid transitions - Input validation (empty, max length, over max) - Encryption (ciphertext randomness, different messages) - Unicode and special characters support - Multiple messages in sequence - Error handling and recovery
This commit is contained in:
parent
184702057b
commit
f0f33e0934
6 changed files with 1162 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
370
Cargo.lock
generated
Normal file
370
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "1.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7"
|
||||
dependencies = [
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "libsodium-sys"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b779387cd56adfbc02ea4a668e704f729be8d6a6abd2c27ca5ee537849a92fd"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "messagebox-protocol"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sodiumoxide",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "1.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c"
|
||||
|
||||
[[package]]
|
||||
name = "sodiumoxide"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e26be3acb6c2d9a7aac28482586a7856436af4cfe7100031d219de2d2ecb0028"
|
||||
dependencies = [
|
||||
"ed25519",
|
||||
"libc",
|
||||
"libsodium-sys",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"
|
||||
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "messagebox-protocol"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
sodiumoxide = "0.2"
|
||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
14
README.md
Normal file
14
README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
## About
|
||||
A simple little protocol for a "message box" where people can send me messages that are sealed "on site" in their local client (most likely
|
||||
their web browser). Read `SPECIFICATION.md` for more details.
|
||||
|
||||
## Purpose
|
||||
This is intended to test a workflow using LLM pair design and programming. I've found LLMs to be great at dumping code that is annoying
|
||||
to read and understand, but mostly works. I would like to design systems that are pleasant and easy to understand, with the essential
|
||||
components displayed to the human in a way that is abstacted away from noisy implementation details, which are handled by the LLM.
|
||||
|
||||
### Goals
|
||||
- [x] Complete specification in informal markdown
|
||||
- [ ] Implement concretely in code
|
||||
- [ ] Apply standard code tests
|
||||
- [ ] Complete specification in formal language (P, Quint, etc.)
|
||||
3
src/lib.rs
Normal file
3
src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod sender;
|
||||
|
||||
pub use sender::{SenderClient, SenderState, SenderError};
|
||||
761
src/sender.rs
Normal file
761
src/sender.rs
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sodiumoxide::crypto::box_::{self, PublicKey as SodiumPublicKey};
|
||||
use sodiumoxide::crypto::sealedbox;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Errors that can occur in the sender client state machine
|
||||
#[derive(Error, Debug, PartialEq)]
|
||||
pub enum SenderError {
|
||||
#[error("No public key loaded")]
|
||||
NoPublicKey,
|
||||
|
||||
#[error("No plaintext message available")]
|
||||
NoPlaintext,
|
||||
|
||||
#[error("No sealed message available")]
|
||||
NoSealedMessage,
|
||||
|
||||
#[error("Invalid sender name: {0}")]
|
||||
InvalidSenderName(String),
|
||||
|
||||
#[error("Invalid message body: {0}")]
|
||||
InvalidMessageBody(String),
|
||||
|
||||
#[error("Encryption failed")]
|
||||
EncryptionFailed,
|
||||
|
||||
#[error("Invalid state transition from {from} to {to}")]
|
||||
InvalidStateTransition { from: String, to: String },
|
||||
}
|
||||
|
||||
/// States of the sender client state machine
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SenderState {
|
||||
Idle,
|
||||
Ready,
|
||||
Composing,
|
||||
Encrypting,
|
||||
Submitted,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SenderState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SenderState::Idle => write!(f, "Idle"),
|
||||
SenderState::Ready => write!(f, "Ready"),
|
||||
SenderState::Composing => write!(f, "Composing"),
|
||||
SenderState::Encrypting => write!(f, "Encrypting"),
|
||||
SenderState::Submitted => write!(f, "Submitted"),
|
||||
SenderState::Error => write!(f, "Error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public key information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublicKey {
|
||||
pub key_id: String,
|
||||
pub public_key: [u8; 32],
|
||||
}
|
||||
|
||||
/// Plaintext message before encryption
|
||||
#[derive(Debug, Clone)]
|
||||
struct PlaintextMessage {
|
||||
sender_name: String,
|
||||
message_body: String,
|
||||
}
|
||||
|
||||
/// Sealed message ready for transmission
|
||||
#[derive(Debug, Clone)]
|
||||
struct SealedMessage {
|
||||
sender_name: String,
|
||||
key_id: String,
|
||||
sealed_box: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Message record to be sent to the message store
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageRecord {
|
||||
pub message_id: Uuid,
|
||||
pub sender_name: String,
|
||||
pub created_at: i64, // Unix timestamp
|
||||
pub key_id: String,
|
||||
pub sealed_box: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Sender client state machine
|
||||
pub struct SenderClient {
|
||||
state: SenderState,
|
||||
public_key: Option<PublicKey>,
|
||||
plaintext: Option<PlaintextMessage>,
|
||||
sealed_message: Option<SealedMessage>,
|
||||
}
|
||||
|
||||
impl SenderClient {
|
||||
/// Create a new sender client in IDLE state
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: SenderState::Idle,
|
||||
public_key: None,
|
||||
plaintext: None,
|
||||
sealed_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current state
|
||||
pub fn state(&self) -> SenderState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Transition: IDLE → READY
|
||||
/// Load the public key that will be used for encryption
|
||||
pub fn load_public_key(&mut self, public_key: PublicKey) -> Result<(), SenderError> {
|
||||
if self.state != SenderState::Idle {
|
||||
return Err(SenderError::InvalidStateTransition {
|
||||
from: self.state.to_string(),
|
||||
to: "Ready".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
self.public_key = Some(public_key);
|
||||
self.state = SenderState::Ready;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transition: READY → COMPOSING
|
||||
/// Compose a message with sender name and message body
|
||||
pub fn compose_message(
|
||||
&mut self,
|
||||
sender_name: String,
|
||||
message_body: String,
|
||||
) -> Result<(), SenderError> {
|
||||
if self.state != SenderState::Ready {
|
||||
return Err(SenderError::InvalidStateTransition {
|
||||
from: self.state.to_string(),
|
||||
to: "Composing".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate sender name
|
||||
if sender_name.is_empty() {
|
||||
return Err(SenderError::InvalidSenderName(
|
||||
"Sender name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if sender_name.len() > 200 {
|
||||
return Err(SenderError::InvalidSenderName(
|
||||
"Sender name exceeds 200 characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate message body
|
||||
if message_body.is_empty() {
|
||||
return Err(SenderError::InvalidMessageBody(
|
||||
"Message body cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if message_body.len() > 10_000 {
|
||||
return Err(SenderError::InvalidMessageBody(
|
||||
"Message body exceeds 10,000 characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.plaintext = Some(PlaintextMessage {
|
||||
sender_name,
|
||||
message_body,
|
||||
});
|
||||
self.state = SenderState::Composing;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transition: COMPOSING → ENCRYPTING
|
||||
/// Seal the message using the loaded public key
|
||||
pub fn seal_message(&mut self) -> Result<(), SenderError> {
|
||||
if self.state != SenderState::Composing {
|
||||
return Err(SenderError::InvalidStateTransition {
|
||||
from: self.state.to_string(),
|
||||
to: "Encrypting".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let public_key = self.public_key.as_ref().ok_or(SenderError::NoPublicKey)?;
|
||||
let plaintext = self.plaintext.take().ok_or(SenderError::NoPlaintext)?;
|
||||
|
||||
// Convert public key bytes to sodiumoxide type
|
||||
let pk = SodiumPublicKey::from_slice(&public_key.public_key)
|
||||
.ok_or(SenderError::EncryptionFailed)?;
|
||||
|
||||
// Encrypt the message body
|
||||
let sealed_box = sealedbox::seal(plaintext.message_body.as_bytes(), &pk);
|
||||
|
||||
self.sealed_message = Some(SealedMessage {
|
||||
sender_name: plaintext.sender_name,
|
||||
key_id: public_key.key_id.clone(),
|
||||
sealed_box,
|
||||
});
|
||||
|
||||
// Plaintext is already taken and will be dropped
|
||||
self.state = SenderState::Encrypting;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transition: ENCRYPTING → SUBMITTED
|
||||
/// Create a message record ready for transmission to the store
|
||||
pub fn submit(&mut self) -> Result<MessageRecord, SenderError> {
|
||||
if self.state != SenderState::Encrypting {
|
||||
return Err(SenderError::InvalidStateTransition {
|
||||
from: self.state.to_string(),
|
||||
to: "Submitted".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let sealed = self
|
||||
.sealed_message
|
||||
.take()
|
||||
.ok_or(SenderError::NoSealedMessage)?;
|
||||
|
||||
let record = MessageRecord {
|
||||
message_id: Uuid::new_v4(),
|
||||
sender_name: sealed.sender_name,
|
||||
created_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64,
|
||||
key_id: sealed.key_id,
|
||||
sealed_box: sealed.sealed_box,
|
||||
};
|
||||
|
||||
self.state = SenderState::Submitted;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Transition: SUBMITTED → IDLE
|
||||
/// Reset the state machine for the next message
|
||||
pub fn reset(&mut self) {
|
||||
if self.state != SenderState::Submitted {
|
||||
// Allow reset from any state for error recovery
|
||||
}
|
||||
|
||||
self.plaintext = None;
|
||||
self.sealed_message = None;
|
||||
self.state = SenderState::Idle;
|
||||
}
|
||||
|
||||
/// Transition: Any State → ERROR
|
||||
/// Handle errors by transitioning to error state
|
||||
pub fn error(&mut self) {
|
||||
self.state = SenderState::Error;
|
||||
}
|
||||
|
||||
/// Check if ready to compose (has public key loaded)
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.state == SenderState::Ready && self.public_key.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SenderClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_public_key() -> super::PublicKey {
|
||||
// Initialize sodiumoxide
|
||||
sodiumoxide::init().unwrap();
|
||||
|
||||
// Generate a real keypair for testing
|
||||
let (pk, _sk) = box_::gen_keypair();
|
||||
|
||||
super::PublicKey {
|
||||
key_id: "test-key-1".to_string(),
|
||||
public_key: pk.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_sender_starts_idle() {
|
||||
let sender = SenderClient::new();
|
||||
assert_eq!(sender.state(), SenderState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_public_key_transitions_to_ready() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
let result = sender.load_public_key(pk);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(sender.state(), SenderState::Ready);
|
||||
assert!(sender.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cannot_load_key_when_not_idle() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk.clone()).unwrap();
|
||||
|
||||
let result = sender.load_public_key(pk);
|
||||
assert!(matches!(result, Err(SenderError::InvalidStateTransition { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_message_transitions_to_composing() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
|
||||
let result = sender.compose_message(
|
||||
"Alice".to_string(),
|
||||
"Hello, Bob!".to_string(),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(sender.state(), SenderState::Composing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_rejects_empty_sender_name() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
|
||||
let result = sender.compose_message(
|
||||
"".to_string(),
|
||||
"Hello!".to_string(),
|
||||
);
|
||||
assert!(matches!(result, Err(SenderError::InvalidSenderName(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_rejects_long_sender_name() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
|
||||
let long_name = "a".repeat(201);
|
||||
let result = sender.compose_message(
|
||||
long_name,
|
||||
"Hello!".to_string(),
|
||||
);
|
||||
assert!(matches!(result, Err(SenderError::InvalidSenderName(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_rejects_empty_message_body() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
|
||||
let result = sender.compose_message(
|
||||
"Alice".to_string(),
|
||||
"".to_string(),
|
||||
);
|
||||
assert!(matches!(result, Err(SenderError::InvalidMessageBody(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_rejects_long_message_body() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
|
||||
let long_message = "a".repeat(10_001);
|
||||
let result = sender.compose_message(
|
||||
"Alice".to_string(),
|
||||
long_message,
|
||||
);
|
||||
assert!(matches!(result, Err(SenderError::InvalidMessageBody(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seal_message_transitions_to_encrypting() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap();
|
||||
|
||||
let result = sender.seal_message();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(sender.state(), SenderState::Encrypting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seal_destroys_plaintext() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap();
|
||||
|
||||
sender.seal_message().unwrap();
|
||||
|
||||
// Plaintext should be None after sealing
|
||||
assert!(sender.plaintext.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submit_creates_message_record() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk.clone()).unwrap();
|
||||
sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap();
|
||||
sender.seal_message().unwrap();
|
||||
|
||||
let result = sender.submit();
|
||||
assert!(result.is_ok());
|
||||
|
||||
let record = result.unwrap();
|
||||
assert_eq!(record.sender_name, "Alice");
|
||||
assert_eq!(record.key_id, pk.key_id);
|
||||
assert!(!record.sealed_box.is_empty());
|
||||
assert_eq!(sender.state(), SenderState::Submitted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_transitions_to_idle() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap();
|
||||
sender.seal_message().unwrap();
|
||||
sender.submit().unwrap();
|
||||
|
||||
sender.reset();
|
||||
assert_eq!(sender.state(), SenderState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_happy_path() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
// IDLE → READY
|
||||
sender.load_public_key(pk.clone()).unwrap();
|
||||
assert_eq!(sender.state(), SenderState::Ready);
|
||||
|
||||
// READY → COMPOSING
|
||||
sender.compose_message("Alice".to_string(), "Hello, Bob!".to_string()).unwrap();
|
||||
assert_eq!(sender.state(), SenderState::Composing);
|
||||
|
||||
// COMPOSING → ENCRYPTING
|
||||
sender.seal_message().unwrap();
|
||||
assert_eq!(sender.state(), SenderState::Encrypting);
|
||||
|
||||
// ENCRYPTING → SUBMITTED
|
||||
let record = sender.submit().unwrap();
|
||||
assert_eq!(sender.state(), SenderState::Submitted);
|
||||
assert_eq!(record.sender_name, "Alice");
|
||||
assert_eq!(record.key_id, pk.key_id);
|
||||
|
||||
// SUBMITTED → IDLE
|
||||
sender.reset();
|
||||
assert_eq!(sender.state(), SenderState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cannot_skip_states() {
|
||||
let mut sender = SenderClient::new();
|
||||
|
||||
// Cannot compose without loading key
|
||||
let result = sender.compose_message("Alice".to_string(), "Hello!".to_string());
|
||||
assert!(matches!(result, Err(SenderError::InvalidStateTransition { .. })));
|
||||
|
||||
// Cannot seal without composing
|
||||
let pk = create_test_public_key();
|
||||
sender.load_public_key(pk).unwrap();
|
||||
let result = sender.seal_message();
|
||||
assert!(matches!(result, Err(SenderError::InvalidStateTransition { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cannot_submit_without_sealing() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap();
|
||||
|
||||
// Try to submit without sealing
|
||||
let result = sender.submit();
|
||||
assert!(matches!(result, Err(SenderError::InvalidStateTransition { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_at_max_length() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
|
||||
// Exactly 10,000 characters should be allowed
|
||||
let max_message = "a".repeat(10_000);
|
||||
let result = sender.compose_message("Alice".to_string(), max_message);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sender_name_at_max_length() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
|
||||
// Exactly 200 characters should be allowed
|
||||
let max_name = "a".repeat(200);
|
||||
let result = sender.compose_message(max_name, "Hello!".to_string());
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_from_error_state() {
|
||||
let mut sender = SenderClient::new();
|
||||
|
||||
sender.error();
|
||||
assert_eq!(sender.state(), SenderState::Error);
|
||||
|
||||
sender.reset();
|
||||
assert_eq!(sender.state(), SenderState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_from_intermediate_states() {
|
||||
// Test reset from READY state
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
sender.load_public_key(pk.clone()).unwrap();
|
||||
sender.reset();
|
||||
assert_eq!(sender.state(), SenderState::Idle);
|
||||
|
||||
// Test reset from COMPOSING state
|
||||
let mut sender = SenderClient::new();
|
||||
sender.load_public_key(pk.clone()).unwrap();
|
||||
sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap();
|
||||
sender.reset();
|
||||
assert_eq!(sender.state(), SenderState::Idle);
|
||||
|
||||
// Test reset from ENCRYPTING state
|
||||
let mut sender = SenderClient::new();
|
||||
sender.load_public_key(pk).unwrap();
|
||||
sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap();
|
||||
sender.seal_message().unwrap();
|
||||
sender.reset();
|
||||
assert_eq!(sender.state(), SenderState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_messages_in_sequence() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
// First message
|
||||
sender.load_public_key(pk.clone()).unwrap();
|
||||
sender.compose_message("Alice".to_string(), "First message".to_string()).unwrap();
|
||||
sender.seal_message().unwrap();
|
||||
let record1 = sender.submit().unwrap();
|
||||
sender.reset();
|
||||
|
||||
// Second message
|
||||
sender.load_public_key(pk.clone()).unwrap();
|
||||
sender.compose_message("Bob".to_string(), "Second message".to_string()).unwrap();
|
||||
sender.seal_message().unwrap();
|
||||
let record2 = sender.submit().unwrap();
|
||||
sender.reset();
|
||||
|
||||
// Messages should have different IDs
|
||||
assert_ne!(record1.message_id, record2.message_id);
|
||||
assert_eq!(record1.sender_name, "Alice");
|
||||
assert_eq!(record2.sender_name, "Bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sealed_box_is_not_empty() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
sender.compose_message("Alice".to_string(), "Secret message".to_string()).unwrap();
|
||||
sender.seal_message().unwrap();
|
||||
let record = sender.submit().unwrap();
|
||||
|
||||
// Sealed box should contain encrypted data
|
||||
assert!(!record.sealed_box.is_empty());
|
||||
// Sealed box should be longer than the plaintext due to crypto overhead
|
||||
assert!(record.sealed_box.len() > "Secret message".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_record_has_valid_timestamp() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
let before = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap();
|
||||
sender.seal_message().unwrap();
|
||||
let record = sender.submit().unwrap();
|
||||
|
||||
let after = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
// Timestamp should be between before and after
|
||||
assert!(record.created_at >= before);
|
||||
assert!(record.created_at <= after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_record_preserves_key_id() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
let expected_key_id = pk.key_id.clone();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap();
|
||||
sender.seal_message().unwrap();
|
||||
let record = sender.submit().unwrap();
|
||||
|
||||
assert_eq!(record.key_id, expected_key_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_messages_produce_different_ciphertext() {
|
||||
let pk = create_test_public_key();
|
||||
|
||||
// First message
|
||||
let mut sender1 = SenderClient::new();
|
||||
sender1.load_public_key(pk.clone()).unwrap();
|
||||
sender1.compose_message("Alice".to_string(), "Message A".to_string()).unwrap();
|
||||
sender1.seal_message().unwrap();
|
||||
let record1 = sender1.submit().unwrap();
|
||||
|
||||
// Second message (different content)
|
||||
let mut sender2 = SenderClient::new();
|
||||
sender2.load_public_key(pk).unwrap();
|
||||
sender2.compose_message("Alice".to_string(), "Message B".to_string()).unwrap();
|
||||
sender2.seal_message().unwrap();
|
||||
let record2 = sender2.submit().unwrap();
|
||||
|
||||
// Ciphertexts should be different
|
||||
assert_ne!(record1.sealed_box, record2.sealed_box);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_message_produces_different_ciphertext() {
|
||||
// Due to sealed box randomness, same plaintext should produce different ciphertext
|
||||
let pk = create_test_public_key();
|
||||
|
||||
// First encryption
|
||||
let mut sender1 = SenderClient::new();
|
||||
sender1.load_public_key(pk.clone()).unwrap();
|
||||
sender1.compose_message("Alice".to_string(), "Same message".to_string()).unwrap();
|
||||
sender1.seal_message().unwrap();
|
||||
let record1 = sender1.submit().unwrap();
|
||||
|
||||
// Second encryption of same message
|
||||
let mut sender2 = SenderClient::new();
|
||||
sender2.load_public_key(pk).unwrap();
|
||||
sender2.compose_message("Alice".to_string(), "Same message".to_string()).unwrap();
|
||||
sender2.seal_message().unwrap();
|
||||
let record2 = sender2.submit().unwrap();
|
||||
|
||||
// Ciphertexts should be different (sealed box uses randomness)
|
||||
assert_ne!(record1.sealed_box, record2.sealed_box);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_in_sender_name() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
|
||||
let result = sender.compose_message(
|
||||
"Alice 🎉 Müller".to_string(),
|
||||
"Hello!".to_string(),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_in_message_body() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
|
||||
let result = sender.compose_message(
|
||||
"Alice".to_string(),
|
||||
"Hello 世界! 🌍".to_string(),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_newlines_in_message_body() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
|
||||
let multiline_message = "Line 1\nLine 2\nLine 3";
|
||||
let result = sender.compose_message(
|
||||
"Alice".to_string(),
|
||||
multiline_message.to_string(),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_special_characters_in_message() {
|
||||
let mut sender = SenderClient::new();
|
||||
let pk = create_test_public_key();
|
||||
|
||||
sender.load_public_key(pk).unwrap();
|
||||
|
||||
let special_message = "Special: !@#$%^&*()[]{}|\\;':\"<>?,./`~";
|
||||
let result = sender.compose_message(
|
||||
"Alice".to_string(),
|
||||
special_message.to_string(),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_ready_only_when_key_loaded() {
|
||||
let mut sender = SenderClient::new();
|
||||
assert!(!sender.is_ready());
|
||||
|
||||
let pk = create_test_public_key();
|
||||
sender.load_public_key(pk).unwrap();
|
||||
assert!(sender.is_ready());
|
||||
|
||||
sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap();
|
||||
assert!(!sender.is_ready()); // Not ready in COMPOSING state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_creates_idle_sender() {
|
||||
let sender = SenderClient::default();
|
||||
assert_eq!(sender.state(), SenderState::Idle);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue