Initital Sender state machine logic implemented

Implemented and tested the state machine model for the `Sender`. Tested against the rust bindings for libsodium using
the `crypto_box` api.
This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-01-19 11:09:17 +07:00
parent f3ab842cc2
commit cc39f8a0a8
4 changed files with 530 additions and 535 deletions

477
Cargo.lock generated
View file

@ -2,12 +2,48 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "adler32"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "bytes"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cc"
version = "1.2.53"
@ -25,12 +61,91 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "ed25519"
version = "1.5.3"
name = "core2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7"
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
dependencies = [
"signature",
"memchr",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "ct-codecs"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8"
[[package]]
name = "ctor"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e"
dependencies = [
"ctor-proc-macro",
"dtor",
]
[[package]]
name = "ctor-proc-macro"
version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "dary_heap"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04"
[[package]]
name = "dtor"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301"
dependencies = [
"dtor-proc-macro",
]
[[package]]
name = "dtor-proc-macro"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
@ -39,6 +154,23 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
[[package]]
name = "flate2"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "getrandom"
version = "0.3.4"
@ -51,6 +183,43 @@ dependencies = [
"wasip2",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.17"
@ -74,17 +243,84 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libsodium-sys"
version = "0.2.7"
name = "libflate"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b779387cd56adfbc02ea4a668e704f729be8d6a6abd2c27ca5ee537849a92fd"
checksum = "e3248b8d211bd23a104a42d81b4fa8bb8ac4a3b75e7a43d85d2c9ccb6179cd74"
dependencies = [
"adler32",
"core2",
"crc32fast",
"dary_heap",
"libflate_lz77",
]
[[package]]
name = "libflate_lz77"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c"
dependencies = [
"core2",
"hashbrown",
"rle-decode-fast",
]
[[package]]
name = "libredox"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags",
"libc",
"redox_syscall",
]
[[package]]
name = "libsodium-rs"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7a6a6c03c914ffa9e53f76300ab4e81a86230f8b884c7f224eb6617ab685878"
dependencies = [
"ct-codecs",
"ctor",
"libc",
"libsodium-sys-stable",
"pkg-config",
"thiserror 1.0.69",
"zeroize",
]
[[package]]
name = "libsodium-sys-stable"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5d23f4a051a13cf1085b2c5a050d4d890d80c754534cc4247eff525fa5283d"
dependencies = [
"cc",
"libc",
"libflate",
"minisign-verify",
"pkg-config",
"walkdir",
"tar",
"ureq",
"vcpkg",
"zip",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.7.6"
@ -95,19 +331,41 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
name = "messagebox-protocol"
version = "0.1.0"
dependencies = [
"libsodium-rs",
"serde",
"serde_json",
"sodiumoxide",
"thiserror",
"thiserror 2.0.18",
"uuid",
]
[[package]]
name = "minisign-verify"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pkg-config"
version = "0.3.32"
@ -138,21 +396,40 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "redox_syscall"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27"
dependencies = [
"bitflags",
]
[[package]]
name = "rle-decode-fast"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
[[package]]
name = "rustix"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[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"
@ -203,22 +480,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signature"
version = "1.6.4"
name = "simd-adler32"
version = "0.3.8"
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",
]
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "syn"
@ -231,13 +496,33 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tar"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
]
[[package]]
@ -251,12 +536,54 @@ dependencies = [
"syn",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
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 = "ureq"
version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a"
dependencies = [
"base64",
"log",
"percent-encoding",
"ureq-proto",
"utf-8",
]
[[package]]
name = "ureq-proto"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "uuid"
version = "1.19.0"
@ -270,14 +597,10 @@ dependencies = [
]
[[package]]
name = "walkdir"
version = "2.5.0"
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "wasip2"
@ -333,15 +656,6 @@ 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"
@ -363,8 +677,69 @@ version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zip"
version = "7.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9013f1222db8a6d680f13a7ccdc60a781199cd09c2fa4eff58e728bb181757fc"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
[[package]]
name = "zmij"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]

View file

@ -4,10 +4,10 @@ version = "0.1.0"
edition = "2021"
[dependencies]
sodiumoxide = "0.2"
uuid = { version = "1.6", features = ["v4", "serde"] }
uuid = { version = "1.19", features = ["v4", "serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
thiserror = "2.0"
[dev-dependencies]
libsodium-rs = "0.2.2" # The library itself is agnostic to implementation, but we need something to test against

View file

@ -760,19 +760,6 @@ Result:
---
## Future Enhancements (Out of Scope)
- Public key fingerprint verification
- Sender reply channel (optional contact info)
- Message categories/tags
- Read receipts
- Storage quotas and auto-cleanup
- Multi-device reader support
- Message threading
- Rate limiting and spam prevention
---
## Cryptographic Primitives
**Sealed Box:** NaCl/libsodium compatible
@ -847,83 +834,6 @@ Result:
- Key ID is preserved from PublicKey through MessageRecord
- Enables key rotation without sender awareness
### Testing Considerations
**Essential Test Coverage:**
1. State transitions (valid and invalid)
2. Input validation (empty, max, over-max)
3. Cryptographic randomness (same input → different output)
4. Memory cleanup (plaintext destroyed after sealing)
5. Unicode and special character handling
6. Multiple messages in sequence
7. Timestamp correctness
8. UUID uniqueness
**Error Cases:**
- Invalid state transitions
- Missing public key
- Missing plaintext
- Missing sealed message
- Invalid sender name (empty, too long)
- Invalid message body (empty, too long)
---
## Design Decisions (from Implementation)
### Public Key Structure
**Decision:** PublicKey contains both key_id and public_key bytes
- **Rationale:** Sender needs key_id to include in MessageRecord
- **Alternative considered:** Fetch key_id separately from store
- **Chosen approach:** Bundle them together to reduce round trips
### State Machine Strictness
**Decision:** Enforce state transitions with compile-time types would be ideal, but runtime checks are practical
- **Rationale:** Makes invalid states unrepresentable
- **Trade-off:** More complex type system vs runtime flexibility
- **Implementation:** Runtime checks with explicit error types
### Plaintext Ownership
**Decision:** Move (consume) plaintext during seal operation
- **Rationale:** Prevents accidental reuse or logging
- **Alternative considered:** Clone and clear
- **Chosen approach:** Rust's move semantics provide memory safety guarantee
### Reset from Any State
**Decision:** Allow reset from any state, not just SUBMITTED or ERROR
- **Rationale:** Provides escape hatch for error recovery
- **Trade-off:** Less strict state machine vs operational flexibility
- **Chosen approach:** Flexible reset for better UX
### Timestamp Precision
**Decision:** Unix epoch seconds (not milliseconds)
- **Rationale:** Second precision sufficient for message ordering
- **Trade-off:** Less precision vs simpler arithmetic
- **Chosen approach:** Seconds for simplicity
### Character vs Byte Limits
**Decision:** Enforce character limits, not byte limits
- **Rationale:** User-facing (WYSIWYG behavior)
- **Trade-off:** Unicode overhead vs user expectations
- **Chosen approach:** Character counting for better UX
### Error Type Design
**Decision:** Use enum with contextual error messages
- **Rationale:** Structured errors enable better error handling
- **Alternative considered:** String errors
- **Chosen approach:** Typed errors with Display impl for messages
## Open Questions
1. **Authentication mechanism** for Reader → Store operations (key rotation, deletion)
2. **Public key verification** - how does sender know public_key is authentic?
3. **Rate limiting** - should store impose submission limits?
4. **Message expiry** - auto-delete after N days?
5. **Multi-device reader** - how to distribute private keys securely?
6. **Sealed box size** - current spec says max 50KB, but actual max message is 10KB chars + overhead (~48 bytes). Should store validate based on plaintext limit or total size?
7. **Key ID format** - should it be standardized (e.g., hash of public key) or implementation-defined?
8. **Concurrent key rotation** - if sender caches public key, how long is cache valid?
---
## Version History

View file

@ -1,6 +1,4 @@
use serde::{Deserialize, Serialize};
use sodiumoxide::crypto::box_::{self, PublicKey as SodiumPublicKey};
use sodiumoxide::crypto::sealedbox;
use thiserror::Error;
use uuid::Uuid;
@ -54,11 +52,8 @@ impl std::fmt::Display for SenderState {
}
/// Public key information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicKey {
pub key_id: String,
pub public_key: [u8; 32],
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublicKey(pub [u8; 32]);
/// Plaintext message before encryption
#[derive(Debug, Clone)]
@ -68,10 +63,11 @@ struct PlaintextMessage {
}
/// Sealed message ready for transmission
#[allow(dead_code)]
#[derive(Debug, Clone)]
struct SealedMessage {
sender_name: String,
key_id: String,
public_key: PublicKey,
sealed_box: Vec<u8>,
}
@ -81,7 +77,7 @@ pub struct MessageRecord {
pub message_id: Uuid,
pub sender_name: String,
pub created_at: i64, // Unix timestamp
pub key_id: String,
pub public_key: PublicKey,
pub sealed_box: Vec<u8>,
}
@ -171,8 +167,11 @@ impl SenderClient {
}
/// Transition: COMPOSING → ENCRYPTING
/// Seal the message using the loaded public key
pub fn seal_message(&mut self) -> Result<(), SenderError> {
/// Seal the message using the loaded public key and a provided cryptographic method
pub fn seal_message<F>(&mut self, sealer: F) -> Result<(), SenderError>
where
F: FnOnce(String, &PublicKey) -> Option<Vec<u8>>,
{
if self.state != SenderState::Composing {
return Err(SenderError::InvalidStateTransition {
from: self.state.to_string(),
@ -183,16 +182,13 @@ impl SenderClient {
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);
let sealed_box =
sealer(plaintext.message_body, public_key).ok_or(SenderError::EncryptionFailed)?;
self.sealed_message = Some(SealedMessage {
sender_name: plaintext.sender_name,
key_id: public_key.key_id.clone(),
public_key: public_key.clone(),
sealed_box,
});
@ -216,6 +212,8 @@ impl SenderClient {
.take()
.ok_or(SenderError::NoSealedMessage)?;
let public_key = self.public_key.as_ref().ok_or(SenderError::NoPublicKey)?;
let record = MessageRecord {
message_id: Uuid::new_v4(),
sender_name: sealed.sender_name,
@ -223,7 +221,7 @@ impl SenderClient {
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64,
key_id: sealed.key_id,
public_key: public_key.clone(),
sealed_box: sealed.sealed_box,
};
@ -264,203 +262,93 @@ impl Default for SenderClient {
#[cfg(test)]
mod tests {
use super::*;
use libsodium_rs as ls;
fn create_test_public_key() -> super::PublicKey {
// Initialize sodiumoxide
sodiumoxide::init().unwrap();
fn create_test_public_key() -> PublicKey {
ls::ensure_init().unwrap();
// Generate a real keypair for testing
let (pk, _sk) = box_::gen_keypair();
PublicKey(
ls::crypto_box::KeyPair::generate()
.public_key
.as_bytes()
.clone(),
)
}
super::PublicKey {
key_id: "test-key-1".to_string(),
public_key: pk.0,
}
// Generic message sealing function for testing
fn generic_sealer() -> Box<dyn FnOnce(String, &PublicKey) -> Option<Vec<u8>>> {
ls::ensure_init().unwrap();
Box::new(|message, recipient_pk| {
match ls::crypto_box::seal_box(message.as_bytes(), &recipient_pk.0.into()) {
Ok(sealed) => Some(sealed),
Err(_) => None,
}
})
}
#[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() {
fn test_bad_messages_dont_parse() {
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(),
);
// Empty sender name should fail
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();
// Sender name too long should fail
let long_name = "a".repeat(201);
let result = sender.compose_message(
long_name,
"Hello!".to_string(),
);
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(),
);
// Empty message body should fail
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,
);
// Message body too long should fail
let long_body = "a".repeat(10_001);
let result = sender.compose_message("Alice".to_string(), long_body);
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();
let kp = ls::crypto_box::KeyPair::generate();
let pk = PublicKey(kp.public_key.as_bytes().clone());
let message = "Hello, Bob!".to_string();
// 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();
sender
.compose_message("Alice".to_string(), message.clone())
.unwrap();
assert_eq!(sender.state(), SenderState::Composing);
let sealer = generic_sealer();
// COMPOSING → ENCRYPTING
sender.seal_message().unwrap();
sender.seal_message(sealer).unwrap();
assert_eq!(sender.state(), SenderState::Encrypting);
// Ensure the sealed message is decryptable with the corresponding secret
let unboxed = {
let sealed_box = sender.sealed_message.as_ref().unwrap().sealed_box.clone();
ls::crypto_box::open_sealed_box(&sealed_box, &pk.0.into(), &kp.secret_key).unwrap()
};
assert_eq!(unboxed.as_slice(), message.as_bytes());
// 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);
assert_eq!(record.public_key, pk);
// SUBMITTED → IDLE
sender.reset();
@ -473,13 +361,21 @@ mod tests {
// Cannot compose without loading key
let result = sender.compose_message("Alice".to_string(), "Hello!".to_string());
assert!(matches!(result, Err(SenderError::InvalidStateTransition { .. })));
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 { .. })));
let sealer = generic_sealer();
let result = sender.seal_message(sealer);
assert!(matches!(
result,
Err(SenderError::InvalidStateTransition { .. })
));
}
#[test]
@ -488,37 +384,16 @@ mod tests {
let pk = create_test_public_key();
sender.load_public_key(pk).unwrap();
sender.compose_message("Alice".to_string(), "Hello!".to_string()).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());
assert!(matches!(
result,
Err(SenderError::InvalidStateTransition { .. })
));
}
#[test]
@ -544,15 +419,22 @@ mod tests {
// 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
.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
.compose_message("Alice".to_string(), "Hello!".to_string())
.unwrap();
let sealer = generic_sealer();
sender.seal_message(sealer).unwrap();
sender.reset();
assert_eq!(sender.state(), SenderState::Idle);
}
@ -564,15 +446,20 @@ mod tests {
// First message
sender.load_public_key(pk.clone()).unwrap();
sender.compose_message("Alice".to_string(), "First message".to_string()).unwrap();
sender.seal_message().unwrap();
sender
.compose_message("Alice".to_string(), "First message".to_string())
.unwrap();
sender.seal_message(generic_sealer()).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();
sender
.compose_message("Bob".to_string(), "Second message".to_string())
.unwrap();
sender.seal_message(generic_sealer()).unwrap();
let record2 = sender.submit().unwrap();
sender.reset();
@ -581,181 +468,4 @@ mod tests {
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);
}
}