diff --git a/Cargo.lock b/Cargo.lock index cae99c0..4ca0219 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index bee03fb..b7957f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/SPECIFICATION.md b/SPECIFICATION.md index 8759514..fffa6f5 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -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 diff --git a/src/sender.rs b/src/sender.rs index ae2ec92..6123c4e 100644 --- a/src/sender.rs +++ b/src/sender.rs @@ -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, } @@ -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, } @@ -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(&mut self, sealer: F) -> Result<(), SenderError> + where + F: FnOnce(String, &PublicKey) -> Option>, + { 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 Option>> { + 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); - } }